persist vm session

This commit is contained in:
Rachel Powers
2024-04-20 01:39:10 -07:00
parent 9a374a4f73
commit e4d42d69a5
17 changed files with 321 additions and 85 deletions

View File

@@ -19,6 +19,15 @@ import { VirtualMachine } from "../virtual_machine";
import { openFile, saveFile } from "../utils";
import "../virtual_machine/ui";
import "./save";
import { SaveDialog } from "./save";
declare global {
const __COMMIT_HASH__: string;
const __BUILD_DATE__: string;
}
import packageJson from "../../../package.json"
@customElement("ic10emu-app")
export class App extends BaseElement {
@@ -43,10 +52,15 @@ export class App extends BaseElement {
`,
];
version = packageJson.version;
gitVer = __COMMIT_HASH__;
buildDate = __BUILD_DATE__;
editorSettings: { fontSize: number; relativeLineNumbers: boolean };
@query("ace-ic10") accessor editor: IC10Editor;
@query("session-share-dialog") accessor shareDialog: ShareSessionDialog;
@query("ace-ic10") editor: IC10Editor;
@query("session-share-dialog") shareDialog: ShareSessionDialog;
@query("save-dialog") saveDialog: SaveDialog;
// get editor() {
// return this.renderRoot.querySelector("ace-ic10") as IC10Editor;
@@ -67,6 +81,7 @@ export class App extends BaseElement {
root.addEventListener("app-share-session", this._handleShare.bind(this));
root.addEventListener("app-open-file", this._handleOpenFile.bind(this));
root.addEventListener("app-save-as", this._handleSaveAs.bind(this));
root.addEventListener("app-save", this._handleSave.bind(this));
return root;
}
@@ -86,6 +101,7 @@ export class App extends BaseElement {
</sl-split-panel>
</div>
<session-share-dialog></session-share-dialog>
<save-dialog></save-dialog>
</div>
`;
}
@@ -102,6 +118,9 @@ export class App extends BaseElement {
saveFile(window.Editor.editorValue);
}
_handleSave(_e: Event) {
this.saveDialog.show("save");
}
_handleOpenFile(_e: Event) {
openFile(window.Editor.editor);
}

View File

@@ -1,5 +1,6 @@
import { App } from "./app";
import { Nav } from "./nav";
import { SaveDialog } from "./save";
import { ShareSessionDialog } from "./share";
import "./icons";
export { App, Nav, ShareSessionDialog }

View File

@@ -107,6 +107,7 @@ export class Nav extends BaseElement {
<sl-menu class="menu" @sl-select=${this._menuClickHandler} style="z-index: 10">
<sl-menu-item value="share">Share</sl-menu-item>
<sl-menu-item value="openFile">Open File</sl-menu-item>
<sl-menu-item value="save">Save</sl-menu-item>
<sl-menu-item value="saveAs">Save As</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item value="editorSettings"
@@ -179,6 +180,9 @@ export class Nav extends BaseElement {
case "openFile":
this.dispatchEvent(new CustomEvent("app-open-file", { bubbles: true }));
break;
case "save":
this.dispatchEvent(new CustomEvent("app-save", { bubbles: true }));
break;
case "saveAs":
this.dispatchEvent(new CustomEvent("app-save-as", { bubbles: true }));
break;

114
www/src/ts/app/save.ts Normal file
View File

@@ -0,0 +1,114 @@
import { HTMLTemplateResult, html, css, CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { BaseElement, defaultCss } from "../components";
import { VMState } from "../session";
import '@shoelace-style/shoelace/dist/components/dialog/dialog.js';
import '@shoelace-style/shoelace/dist/components/format-date/format-date.js';
import '@shoelace-style/shoelace/dist/components/relative-time/relative-time.js';
import '@shoelace-style/shoelace/dist/components/format-bytes/format-bytes.js';
import SlInput from '@shoelace-style/shoelace/dist/components/input/input.js';
import { repeat } from "lit/directives/repeat.js";
import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
import { when } from "lit/directives/when.js";
export type SaveDialogMode = "save" | "load";
@customElement("save-dialog")
export class SaveDialog extends BaseElement {
static styles = [
...defaultCss,
];
@state() saves: { name: string, date: Date, session: VMState }[];
@state() mode: SaveDialogMode;
constructor() {
super();
this.mode = "save";
}
connectedCallback(): void {
super.connectedCallback()
window.App.get().then(app => app.session.addEventListener("sessions-local-update", this._handleSessionsUpdate.bind(this)));
this.loadsaves();
}
_handleSessionsUpdate() {
this.loadsaves();
}
loadsaves() {
window.App.get().then(async (app) => {
const saves = await app.session.getLocalSaved();
this.saves = saves;
});
}
@query("sl-dialog") dialog: SlDialog;
show(mode: SaveDialogMode) {
this.mode = mode;
this.dialog.show();
}
hide() {
this.dialog.hide();
}
render() {
return html`
<sl-dialog label="Save Session">
${when(this.mode === "save",
() => html`
<div>
<sl-input class="save-name-input" autofocus></sl-input>
<sl-button variant="success" @click=${this._handleSaveClick}>Save</sl-button>
</div>
`
)}
<sl-input class="filter-input" ?autofocus=${this.mode === "load"} placeholder="Filter Saves" clearable
@sl-input=${this._handleSearchInput}>
<sl-icon slot="suffix" name="search"></sl-icon>
</sl-input>
<table>
<tr>
<th>Name</th>
<th>Date</th>
<th>Size</th>
</tr>
${when(
typeof this.saves !== "undefined",
() => repeat(this.saves, (save) => save.name, (save) => {
const size = JSON.stringify(save.session).length
return html`
<tr>
<td>${save.name}</td>
<td>
<sl-format-date .date=${save.date}></sl-format-date>
<sl-relative-time .date=${save.date}></sl-relative-time>
</td>
<td><sl-format-bytes .value=${size}></sl-format-bytes></td>
</tr>
`;
},
)
)}
</table>
</sl-dialog>
`;
}
@query(".save-name-input") saveInput: SlInput;
async _handleSaveClick(_e: CustomEvent) {
const name = this.saveInput.value;
const app = await window.App.get();
console.log(app);
await app.session.saveLocal(name);
}
_handleSearchInput() {
}
}

View File

@@ -274,11 +274,11 @@ export class IC10Editor extends BaseElement {
const app = await window.App.get();
app.session.onLoad(((e: CustomEvent) => {
const session = e.detail;
const session = app.session;
const updated_ids: number[] = [];
for (const [id, _] of session.programs) {
for (const [id, code] of session.programs) {
updated_ids.push(id);
that.createOrSetSession(id, session.programs.get(id));
that.createOrSetSession(id, code);
}
that.activateSession(that.active_session);
for (const [id, _] of that.sessions) {
@@ -484,18 +484,19 @@ export class IC10Editor extends BaseElement {
}
}
createOrSetSession(session_id: number, content: any) {
createOrSetSession(session_id: number, content: string) {
if (!this.sessions.has(session_id)) {
this.newSession(session_id);
this.newSession(session_id, content);
} else {
this.sessions.get(session_id).setValue(content);
}
this.sessions.get(session_id)?.setValue(content);
}
newSession(session_id: number) {
newSession(session_id: number, content?: string) {
if (this.sessions.has(session_id)) {
return false;
}
const session = ace.createEditSession("", this.mode as any);
const session = ace.createEditSession(content ?? "", this.mode as any);
session.setOptions({
firstLineNumber: 0,
});

View File

@@ -15,7 +15,7 @@ class DeferedApp {
get(): Promise<App> {
const that = this;
return new Promise(resolve => {
if (typeof that.app !== "undefined") {
if (typeof that.app === "undefined") {
that.resolvers.push(resolve);
} else {
resolve(that.app);
@@ -45,7 +45,7 @@ class DeferedVM {
get(): Promise<VirtualMachine> {
const that = this;
return new Promise(resolve => {
if (typeof that.vm !== "undefined") {
if (typeof that.vm === "undefined") {
that.resolvers.push(resolve);
} else {
resolve(that.vm);

View File

@@ -63,6 +63,11 @@ j ra
import type { ICError, FrozenVM } from "ic10emu_wasm";
import { App } from "./app";
import { openDB, DBSchema } from 'idb';
import { fromJson, toJson } from "./utils";
const LOCAL_DB_VERSION = 1;
export class Session extends EventTarget {
private _programs: Map<number, string>;
private _errors: Map<number, ICError[]>;
@@ -179,17 +184,17 @@ export class Session extends EventTarget {
save() {
if (this._save_timeout) clearTimeout(this._save_timeout);
this._save_timeout = setTimeout(() => {
this.saveToFragment();
if (this.app.vm) {
this.app.vm.updateCode();
}
this.saveToFragment();
this._save_timeout = undefined;
}, 1000);
}
async saveToFragment() {
const toSave = { vmState: this.app.vm.saveVMState(), activeIC: this.activeIC };
const bytes = new TextEncoder().encode(JSON.stringify(toSave));
const toSave = { vm: this.app.vm.saveVMState(), activeIC: this.activeIC };
const bytes = new TextEncoder().encode(toJson(toSave));
try {
const c_bytes = await compress(bytes, defaultCompression);
const fragment = base64url_encode(c_bytes);
@@ -200,11 +205,29 @@ export class Session extends EventTarget {
}
}
async load(data: VMState | OldPrograms | string) {
if (typeof data === "string") {
this._programs = new Map([[1, data]]);
} else if ( "programs" in data) {
this._programs = new Map(data.programs);
} else if ( "vm" in data ) {
this._programs = new Map();
const state = data.vm;
// assign first so it's present when the
// vm fires events
this._activeIC = data.activeIC;
this.app.vm.restoreVMState(state);
this.programs = this.app.vm.getPrograms();
// assign again to fire event
this.activeIC = data.activeIC;
}
this._fireOnLoad();
}
async loadFromFragment() {
const fragment = window.location.hash.slice(1);
if (fragment === "demo") {
this._programs = new Map([[1, demoCode]]);
this._fireOnLoad();
this.load(demoCode);
return;
}
if (fragment.length > 0) {
@@ -215,39 +238,78 @@ export class Session extends EventTarget {
const data = getJson(txt);
if (data === null) {
// backwards compatible
this._programs = new Map([[1, txt]]);
this, this._fireOnLoad();
this.load(txt);
return;
} else if ("programs" in data) {
try {
this._programs = new Map(data.programs);
this._fireOnLoad();
return;
} catch (e) {
console.log("Bad session data:", e);
}
} else if ("vmState" in data && "activeIC" in data) {
try {
this._programs = new Map();
const state = data.vmState as FrozenVM;
// assign first so it's present when the
// vm setting the programs list fires events
this._activeIC = data.activeIC;
this.app.vm.restoreVMState(state);
this.programs = this.app.vm.getPrograms();
// assign again to fire event
this.activeIC = data.activeIC;
this._fireOnLoad();
return;
} catch (e) {
console.log("Bad session data:", e);
}
this.load(data as OldPrograms);
return;
} else if ("vm" in data && "activeIC" in data) {
this.load(data as VMState);
} else {
console.log("Bad session data:", data);
console.log("Bad session data:", data);
}
}
}
}
async openIndexDB() {
return await openDB<AppDBSchemaV1>("ic10-vm-sessions", LOCAL_DB_VERSION, {
upgrade(db, oldVersion, newVersion, transaction, event) {
// only db verison currently known is v1
if (oldVersion < 1) {
const sessionStore = db.createObjectStore('sessions');
sessionStore.createIndex('by-date', 'date');
sessionStore.createIndex('by-name', 'name');
}
},
});
}
async saveLocal(name: string) {
const state: VMState = {
vm: (await window.VM.get()).ic10vm.saveVMState(),
activeIC: this.activeIC,
};
const db = await this.openIndexDB();
const transaction = db.transaction(['sessions'], "readwrite");
const sessionStore = transaction.objectStore("sessions");
sessionStore.put({
name,
date: new Date(),
session: state,
}, name);
this.dispatchEvent(new CustomEvent("sessions-local-update"))
}
async getLocalSaved() {
const db = await this.openIndexDB();
const sessions = await db.getAll('sessions');
return sessions;
}
}
export interface VMState {
activeIC: number;
vm: FrozenVM;
}
interface AppDBSchemaV1 extends DBSchema {
sessions: {
key: string;
value: {
name: string;
date: Date;
session: VMState;
}
indexes: {
'by-date': Date;
'by-name': string;
};
}
}
export interface OldPrograms {
programs: [number, string][]
}
const byteToHex: string[] = [];
@@ -298,7 +360,7 @@ async function decompressFragment(c_bytes: ArrayBuffer) {
function getJson(value: any) {
try {
return JSON.parse(value);
return fromJson(value);
} catch (_) {
return null;
}

View File

@@ -23,7 +23,11 @@ function replacer(key: any, value: any) {
return {
dataType: 'Number',
value: "NaN",
}
};
} else if (typeof value === "undefined" ) {
return {
dataType: 'undefined',
};
} else {
return value;
}
@@ -35,6 +39,8 @@ function reviver(_key: any, value: any) {
return new Map(value.value);
} else if (value.dataType === 'Number') {
return parseFloat(value.value)
} else if (value.dataType === 'undefined') {
return undefined;
}
}
return value;

View File

@@ -60,22 +60,22 @@ export const VMDeviceMixin = <T extends Constructor<LitElement>>(
device: DeviceRef;
@state() accessor name: string | null = null;
@state() accessor nameHash: number | null = null;
@state() accessor prefabName: string | null;
@state() accessor fields: LogicFields;
@state() accessor slots: Slot[];
@state() accessor reagents: Reagents;
@state() accessor connections: Connection[];
@state() accessor icIP: number;
@state() accessor icOpCount: number;
@state() accessor icState: string;
@state() accessor errors: ICError[];
@state() accessor registers: Registers | null;
@state() accessor stack: Stack | null;
@state() accessor aliases: Aliases | null;
@state() accessor defines: Defines | null;
@state() accessor pins: Pins | null;
@state() name: string | null = null;
@state() nameHash: number | null = null;
@state() prefabName: string | null;
@state() fields: LogicFields;
@state() slots: Slot[];
@state() reagents: Reagents;
@state() connections: Connection[];
@state() icIP: number;
@state() icOpCount: number;
@state() icState: string;
@state() errors: ICError[];
@state() registers: Registers | null;
@state() stack: Stack | null;
@state() aliases: Aliases | null;
@state() defines: Defines | null;
@state() pins: Pins | null;
connectedCallback(): void {
const root = super.connectedCallback();

View File

@@ -46,6 +46,7 @@ import { DeviceDB, DeviceDBEntry } from "./device_db";
import { connectionFromDeviceDBConnection } from "./utils";
import { SlDialog } from "@shoelace-style/shoelace";
import { repeat } from "lit/directives/repeat.js";
import { cache } from "lit/directives/cache.js";
@customElement("vm-device-card")
export class VMDeviceCard extends VMDeviceMixin(BaseElement) {
@@ -221,7 +222,8 @@ export class VMDeviceCard extends VMDeviceMixin(BaseElement) {
class="device-name"
size="small"
pill
placeholder="${this.prefabName}"
placeholder=${this.prefabName}
value=${this.name}
@sl-change=${this._handleChangeName}
>
<span slot="prefix">Name</span>
@@ -814,18 +816,20 @@ export class VMAddDeviceButton extends BaseElement {
this.deviceDB = e.detail;
}
renderSearchResults(): HTMLTemplateResult {
const renderedResults: HTMLTemplateResult[] = this._searchResults?.map(
(result) => html`
renderSearchResults() {
return repeat(
this._searchResults ?? [],
(result) => result.name,
(result) => cache(html`
<vm-device-template
prefab_name=${result.name}
class="card"
@add-device-template=${this._handleDeviceAdd}
>
</vm-device-template>
`,
`)
);
return html`${renderedResults}`;
}
_handleDeviceAdd() {
@@ -851,7 +855,7 @@ export class VMAddDeviceButton extends BaseElement {
@sl-input=${this._handleSearchInput}
>
<span slot="prefix">Search Structures</span>
<sl-icon slot="suffix" name="search"></sl-icon>"
<sl-icon slot="suffix" name="search"></sl-icon>
</sl-input>
<div class="search-results">${this.renderSearchResults()}</div>
<sl-button
@@ -926,8 +930,7 @@ export class VmDeviceTemplate extends BaseElement {
constructor() {
super();
const that = this;
window.VM.get().then((vm) => (that.deviceDB = vm.db));
this.deviceDB = window.VM.vm.db;
}
get deviceDB(): DeviceDB {

View File

@@ -127,6 +127,7 @@ class VirtualMachine extends EventTarget {
detail: ids,
}),
);
this.app.session.save();
}
}
@@ -152,7 +153,7 @@ class VirtualMachine extends EventTarget {
}
}
}
this.update();
this.update(false);
}
step() {
@@ -193,7 +194,7 @@ class VirtualMachine extends EventTarget {
}
}
update() {
update(save: boolean = true) {
this.updateDevices();
this.ic10vm.lastOperationModified.forEach((id, _index, _modifiedIds) => {
if (this.devices.has(id)) {
@@ -202,16 +203,18 @@ class VirtualMachine extends EventTarget {
);
}
}, this);
this.updateDevice(this.activeIC);
this.updateDevice(this.activeIC, save);
if (save) this.app.session.save();
}
updateDevice(device: DeviceRef) {
updateDevice(device: DeviceRef, save: boolean = true) {
this.dispatchEvent(
new CustomEvent("vm-device-modified", { detail: device.id }),
);
if (typeof device.ic !== "undefined") {
this.app.session.setActiveLine(device.id, device.ip!);
}
if (save) this.app.session.save();
}
handleVmError(err: Error) {
@@ -272,6 +275,7 @@ class VirtualMachine extends EventTarget {
this.dispatchEvent(
new CustomEvent("vm-device-modified", { detail: id }),
);
this.app.session.save();
return true;
} catch (e) {
this.handleVmError(e);
@@ -372,6 +376,7 @@ class VirtualMachine extends EventTarget {
detail: Array.from(device_ids),
}),
);
this.app.session.save();
return true;
} catch (err) {
this.handleVmError(err);
@@ -413,9 +418,4 @@ class VirtualMachine extends EventTarget {
}
}
export interface VMState {
activeIC: number;
vm: FrozenVM;
}
export { VirtualMachine };