From 580c2352b59bb28a989157b0f74fdc93697505eb Mon Sep 17 00:00:00 2001 From: Kinflou <149606337+Kinflou@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:10:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(sim):=20the=20simulate=20view=20=E2=80=94?= =?UTF-8?q?=20canvas,=20inspector,=20call=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 1d (docs: design/playground-simulation.md). Everything 1c does, now through the UI. - header gains an edit / simulate toggle; simulate takes the full width (generation controls hide). Entering it runs describe_project on the current schemas and (re)builds the view's session. - sim/ui/view.ts — palette of protocol × role (drag onto the canvas), a two-lane canvas with an SVG wire between the connected pair, and an inspector: instance facts + ir_hash (click to copy), a "connect to …" select of valid partners, per-function behaviour pickers (kind + a JSON config box, applied live), and — for a connected client — the call form. - sim/ui/argsform.ts — a form from a function's args: typed input per primitive, a fieldset per struct, a select per enum, a JSON textarea for arrays / unions / unresolved, and a whole-form raw-JSON toggle. - sim/ui/framelog.ts — the frame list under the canvas, fed by the tap. - sim/view.test.ts (linkedom) — the 1d acceptance driven through the DOM: drop a server + client, connect via the inspector, send from the call form, see the reply and the frames; then flip the server to Raise error and see the mapped error. 14/14 sim tests pass. Main bundle +~8 KB gz (the sim is imported now). --- app/index.html | 8 +- app/package-lock.json | 302 +++++++++++++++++++++++ app/package.json | 1 + app/src/main.ts | 27 +++ app/src/sim/index.ts | 6 + app/src/sim/ui/argsform.ts | 181 ++++++++++++++ app/src/sim/ui/framelog.ts | 74 ++++++ app/src/sim/ui/view.ts | 482 +++++++++++++++++++++++++++++++++++++ app/src/sim/view.test.ts | 149 ++++++++++++ app/src/style.css | 343 ++++++++++++++++++++++++++ 10 files changed, 1572 insertions(+), 1 deletion(-) create mode 100644 app/src/sim/index.ts create mode 100644 app/src/sim/ui/argsform.ts create mode 100644 app/src/sim/ui/framelog.ts create mode 100644 app/src/sim/ui/view.ts create mode 100644 app/src/sim/view.test.ts diff --git a/app/index.html b/app/index.html index c392583..14a9123 100644 --- a/app/index.html +++ b/app/index.html @@ -9,7 +9,11 @@

Comline Playground

-
+
+ + +
+
@@ -60,6 +64,8 @@

Comline Playground

+
+ diff --git a/app/package-lock.json b/app/package-lock.json index 8b43e9e..5f50de5 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@types/node": "^20.19.43", + "linkedom": "^0.18.13", "tsx": "^4.23.13", "typescript": "^5.6.0", "vite": "^5.4.0" @@ -1072,12 +1073,152 @@ "undici-types": "~6.21.0" } }, + "node_modules/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/crelt": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "license": "MIT" }, + "node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1132,6 +1273,143 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom": { + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^7.0.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.1.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1151,6 +1429,23 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1715,6 +2010,13 @@ "node": ">=14.17" } }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/app/package.json b/app/package.json index d941bd2..0d992a5 100644 --- a/app/package.json +++ b/app/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@types/node": "^20.19.43", + "linkedom": "^0.18.13", "tsx": "^4.23.13", "typescript": "^5.6.0", "vite": "^5.4.0" diff --git a/app/src/main.ts b/app/src/main.ts index ad33494..fe33a0f 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -20,6 +20,7 @@ import type { LspPosition, SemanticTokens, } from "./worker.ts"; +import { createSim, type ProjectShape, type SimView } from "./sim/index.ts"; // ── sample: two files, one `use`ing the other ──────────────────────────── const SAMPLE_FILES: { name: string; doc: string }[] = [ @@ -720,6 +721,32 @@ targetSel.addEventListener("change", () => { treeAddEl.addEventListener("click", addFile); +// ── simulate view ────────────────────────────────────────────────────── +const appModeEl = $("#app-mode"); +const simEl = $("#sim"); +let sim: SimView | null = null; + +async function enterSimulate() { + const shape = await unwrap( + { cmd: "describeProject", files: project() }, + { schemas: [] }, + ); + if (!sim) { + sim = createSim(); + simEl.append(sim.el); + } + sim.setShape(shape); + document.body.classList.add("mode-simulate"); +} + +appModeEl.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest("button"); + if (!btn) return; + for (const b of appModeEl.querySelectorAll("button")) b.classList.toggle("active", b === btn); + if (btn.dataset.app === "simulate") void enterSimulate(); + else document.body.classList.remove("mode-simulate"); +}); + // Collapse / expand a static bottom panel (files, problems) by its toggle. function wireCollapse(toggleSel: string) { const btn = $(toggleSel); diff --git a/app/src/sim/index.ts b/app/src/sim/index.ts new file mode 100644 index 0000000..087c151 --- /dev/null +++ b/app/src/sim/index.ts @@ -0,0 +1,6 @@ +/// The simulation: place two protocol instances, wire them together, send a +/// call, watch the frames. Public entry — everything else under `sim/` is +/// internal. + +export { createSim, type SimView } from "./ui/view.ts"; +export type { ProjectShape } from "./shape.ts"; diff --git a/app/src/sim/ui/argsform.ts b/app/src/sim/ui/argsform.ts new file mode 100644 index 0000000..a977872 --- /dev/null +++ b/app/src/sim/ui/argsform.ts @@ -0,0 +1,181 @@ +/// A form built from a function's `ArgShape[]` (or any single `TypeRef`): a +/// typed input per primitive, a fieldset per struct, a select per enum, and a +/// raw-JSON textarea for arrays / unions / anything unresolved. A top-level +/// toggle swaps the whole thing for one JSON textarea — the always-available +/// escape hatch. + +import type { ArgShape, FieldShape, SchemaShape, TypeRef } from "../shape.ts"; +import { typeLabel } from "../shape.ts"; + +export interface ArgsForm { + el: HTMLElement; + /** The current params object. Throws `Error` with a readable message on a + * malformed raw-JSON field. */ + read(): unknown; +} + +/** A form for a whole parameter list (`{ [argName]: value }`). */ +export function argsForm(args: ArgShape[], schema: SchemaShape, initial?: unknown): ArgsForm { + return objectForm( + args.map((a) => ({ name: a.name, ty: a.ty, optional: false })), + schema, + initial, + ); +} + +/** A form for a struct-shaped value (`{ [fieldName]: value }`), with the + * raw-JSON toggle. */ +export function objectForm( + fields: FieldShape[], + schema: SchemaShape, + initial?: unknown, +): ArgsForm { + const el = document.createElement("div"); + el.className = "args"; + + const bar = document.createElement("label"); + bar.className = "args-raw-toggle"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + bar.append(cb, document.createTextNode(" raw JSON")); + + const body = document.createElement("div"); + body.className = "args-body"; + + const raw = document.createElement("textarea"); + raw.className = "args-json mono"; + raw.rows = Math.max(3, fields.length + 1); + raw.spellcheck = false; + raw.hidden = true; + + el.append(bar, body, raw); + + const rows = fields.map((f) => fieldRow(f, schema, (initial as Record)?.[f.name])); + for (const r of rows) body.append(r.el); + + const currentObject = (): Record => { + const out: Record = {}; + for (const r of rows) out[r.name] = r.read(); + return out; + }; + + cb.addEventListener("change", () => { + if (cb.checked) { + raw.value = JSON.stringify(currentObject(), null, 2); + raw.hidden = false; + body.hidden = true; + } else { + body.hidden = false; + raw.hidden = true; + } + }); + + return { + el, + read() { + if (cb.checked) return parseJson(raw.value, "params"); + return currentObject(); + }, + }; +} + +interface Row { + el: HTMLElement; + name: string; + read(): unknown; +} + +function fieldRow(field: FieldShape, schema: SchemaShape, initial: unknown): Row { + const el = document.createElement("div"); + el.className = "arg-row"; + const label = document.createElement("label"); + label.className = "arg-label"; + label.textContent = field.name; + const type = document.createElement("span"); + type.className = "arg-type mono"; + type.textContent = typeLabel(field.ty); + label.append(type); + el.append(label); + + const input = inputFor(field.ty, schema, initial); + el.append(input.el); + return { el, name: field.name, read: input.read }; +} + +function inputFor(ty: TypeRef, schema: SchemaShape, initial: unknown): { el: HTMLElement; read(): unknown } { + switch (ty.kind) { + case "prim": { + if (ty.name === "bool") { + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = initial === true; + return { el: cb, read: () => cb.checked }; + } + const numeric = /^[us](8|16|32|64|128)$/.test(ty.name) || /^f(32|64)$|^float$/.test(ty.name); + const inp = document.createElement("input"); + inp.className = "arg-input"; + if (numeric) { + inp.type = "number"; + if (typeof initial === "number") inp.value = String(initial); + return { el: inp, read: () => (inp.value === "" ? 0 : Number(inp.value)) }; + } + inp.type = "text"; + if (typeof initial === "string") inp.value = initial; + return { el: inp, read: () => inp.value }; + } + + case "ref": { + const def = schema.types.find((t) => t.name === ty.name); + if (def?.kind === "enum") { + const sel = document.createElement("select"); + sel.className = "arg-input"; + for (const v of def.variants) { + const o = document.createElement("option"); + o.value = v; + o.textContent = v; + sel.append(o); + } + if (typeof initial === "string") sel.value = initial; + return { el: sel, read: () => sel.value }; + } + if (def?.kind === "struct") { + const fs = document.createElement("fieldset"); + fs.className = "arg-struct"; + const nested = def.fields.map((f) => + fieldRow(f, schema, (initial as Record)?.[f.name]), + ); + for (const n of nested) fs.append(n.el); + return { + el: fs, + read: () => { + const out: Record = {}; + for (const n of nested) out[n.name] = n.read(); + return out; + }, + }; + } + return jsonInput(ty, initial); + } + + // arrays, unions and unresolved names — one JSON field + default: + return jsonInput(ty, initial); + } +} + +function jsonInput(ty: TypeRef, initial: unknown): { el: HTMLElement; read(): unknown } { + const ta = document.createElement("textarea"); + ta.className = "arg-input mono"; + ta.rows = 2; + ta.spellcheck = false; + ta.value = initial === undefined ? (ty.kind === "array" ? "[]" : "null") : JSON.stringify(initial); + return { el: ta, read: () => parseJson(ta.value, typeLabel(ty)) }; +} + +function parseJson(text: string, what: string): unknown { + try { + return JSON.parse(text || "null"); + } catch (e) { + throw new Error(`${what}: ${(e as Error).message}`); + } +} diff --git a/app/src/sim/ui/framelog.ts b/app/src/sim/ui/framelog.ts new file mode 100644 index 0000000..39fee82 --- /dev/null +++ b/app/src/sim/ui/framelog.ts @@ -0,0 +1,74 @@ +/// The frame list under the canvas. Subscribes to a `Tap` and appends a row +/// per frame. 1d keeps it plain — seq, direction, kind, byte length; 1e turns +/// rows into expandable envelope inspectors. + +import type { Frame, Tap } from "../transport.ts"; + +export interface FrameLog { + el: HTMLElement; + /** Point at a new tap (a fresh connection); clears the list. */ + attach(tap: Tap | null): void; +} + +export function frameLog(): FrameLog { + const el = document.createElement("div"); + el.className = "sim-frames"; + + const head = document.createElement("div"); + head.className = "sim-frames-head"; + head.textContent = "frames"; + + const list = document.createElement("div"); + list.className = "sim-frames-list mono"; + + const empty = document.createElement("p"); + empty.className = "muted pad"; + empty.textContent = "no connection"; + list.append(empty); + + el.append(head, list); + + let unsub: (() => void) | null = null; + + const row = (f: Frame) => { + const r = document.createElement("div"); + r.className = `frame-row frame-${f.kind}`; + r.append( + span("frame-seq", String(f.seq).padStart(3, "0")), + span("frame-dir", `${f.from} → ${f.to}`), + span("frame-kind", f.kind), + span("frame-len", `${f.bytes.length} B`), + ); + return r; + }; + + return { + el, + attach(tap) { + unsub?.(); + unsub = null; + list.replaceChildren(); + if (!tap) { + list.append(empty); + return; + } + for (const f of tap.frames) list.append(row(f)); + scroll(list); + unsub = tap.on((f) => { + list.append(row(f)); + scroll(list); + }); + }, + }; +} + +function span(cls: string, text: string): HTMLSpanElement { + const s = document.createElement("span"); + s.className = cls; + s.textContent = text; + return s; +} + +function scroll(el: HTMLElement): void { + el.scrollTop = el.scrollHeight; +} diff --git a/app/src/sim/ui/view.ts b/app/src/sim/ui/view.ts new file mode 100644 index 0000000..d86c3ce --- /dev/null +++ b/app/src/sim/ui/view.ts @@ -0,0 +1,482 @@ +/// The full-width simulate view: a palette of protocol × role, a canvas the +/// instances sit on, one connection between a client and a server, an inspector +/// (instance facts, per-function behaviours, and — for a connected client — the +/// call form), and the frame list. Everything 1c does, through the UI. + +import { BEHAVIORS, BEHAVIOR_KINDS, type BehaviorKind } from "../behavior.ts"; +import { connect, type LiveConnection } from "../engine.ts"; +import { SimRemoteError } from "../generic.ts"; +import { + addInstance, + emptySession, + instance, + rebuild, + removeInstance, + setBehavior, + setConnection, + type Instance, + type Role, + type Session, +} from "../model.ts"; +import { findProtocol, type ProjectShape } from "../shape.ts"; +import { argsForm, type ArgsForm } from "./argsform.ts"; +import { frameLog } from "./framelog.ts"; + +const SVGNS = "http://www.w3.org/2000/svg"; + +export interface SimView { + el: HTMLElement; + /** Point at a freshly compiled shape (schemas were edited). */ + setShape(shape: ProjectShape): void; + destroy(): void; +} + +export function createSim(): SimView { + let session: Session | null = null; + let live: LiveConnection | null = null; + let selectedId: string | null = null; + let callForm: ArgsForm | null = null; + + const el = div("sim"); + const paletteEl = div("sim-col sim-palette"); + const canvasEl = div("sim-col sim-canvas"); + const inspectorEl = div("sim-col sim-inspector"); + const wireSvg = document.createElementNS(SVGNS, "svg"); + wireSvg.setAttribute("class", "sim-wire"); + canvasEl.append(wireSvg); + const flog = frameLog(); + + el.append( + labelled("palette", paletteEl), + labelled("canvas", canvasEl), + inspectorEl, + flog.el, + ); + + const onResize = () => drawWire(); + window.addEventListener("resize", onResize); + + // ── palette ────────────────────────────────────────────────────────── + function renderPalette() { + paletteEl.replaceChildren(); + if (!session) return; + for (const schema of session.shape.schemas) { + for (const protocol of schema.protocols) { + const group = div("palette-group"); + const title = document.createElement("div"); + title.className = "palette-proto"; + title.textContent = protocol.name; + group.append(title); + for (const role of ["server", "client"] as Role[]) { + const chip = document.createElement("div"); + chip.className = `palette-chip role-${role}`; + chip.textContent = role; + chip.draggable = true; + chip.addEventListener("dragstart", (e) => { + e.dataTransfer?.setData( + "application/json", + JSON.stringify({ schemaNs: schema.namespace, protocol: protocol.name, role }), + ); + }); + group.append(chip); + } + paletteEl.append(group); + } + } + const hint = document.createElement("p"); + hint.className = "muted pad"; + hint.textContent = "drag onto the canvas"; + paletteEl.append(hint); + } + + // ── canvas ─────────────────────────────────────────────────────────── + canvasEl.addEventListener("dragover", (e) => e.preventDefault()); + canvasEl.addEventListener("drop", (e) => { + e.preventDefault(); + const raw = e.dataTransfer?.getData("application/json"); + if (!raw || !session) return; + const spec = JSON.parse(raw) as { schemaNs: string; protocol: string; role: Role }; + const inst = addInstance(session, spec); + selectedId = inst.id; + renderAll(); + }); + + function nodeEl(id: string): HTMLElement | null { + return canvasEl.querySelector(`.sim-node[data-id="${id}"]`); + } + + function renderCanvas() { + for (const n of [...canvasEl.querySelectorAll(".sim-node, .sim-lane")]) n.remove(); + if (!session) return; + + const lane = (role: Role) => { + const l = div(`sim-lane lane-${role}`); + const cap = document.createElement("div"); + cap.className = "lane-cap"; + cap.textContent = role; + l.append(cap); + for (const inst of session!.instances.filter((i) => i.role === role)) { + l.append(nodeCard(inst)); + } + return l; + }; + canvasEl.append(lane("client"), lane("server")); + drawWire(); + } + + function nodeCard(inst: Instance): HTMLElement { + const n = div(`sim-node role-${inst.role}`); + n.dataset.id = inst.id; + if (inst.id === selectedId) n.classList.add("selected"); + if ( + session?.connection && + (session.connection.clientId === inst.id || session.connection.serverId === inst.id) + ) { + n.classList.add("wired"); + } + const name = document.createElement("div"); + name.className = "node-name"; + name.textContent = inst.name; + const sub = document.createElement("div"); + sub.className = "node-sub mono"; + sub.textContent = `${inst.protocol} · ${inst.role}`; + n.append(name, sub); + n.addEventListener("click", () => { + selectedId = inst.id; + renderCanvas(); + renderInspector(); + }); + return n; + } + + function drawWire() { + wireSvg.replaceChildren(); + if (!session?.connection) return; + const c = nodeEl(session.connection.clientId); + const s = nodeEl(session.connection.serverId); + if (!c || !s) return; + const a = center(c); + const b = center(s); + const line = document.createElementNS(SVGNS, "line"); + line.setAttribute("x1", String(a.x)); + line.setAttribute("y1", String(a.y)); + line.setAttribute("x2", String(b.x)); + line.setAttribute("y2", String(b.y)); + line.setAttribute("class", live ? "wire-live" : "wire-pending"); + wireSvg.append(line); + } + + function center(node: HTMLElement) { + return { x: node.offsetLeft + node.offsetWidth / 2, y: node.offsetTop + node.offsetHeight / 2 }; + } + + // ── connect / disconnect ───────────────────────────────────────────── + async function reconnect() { + live?.close(); + live = null; + if (session?.connection) { + try { + live = await connect(session, session.connection); + } catch (e) { + flashInspectorError((e as Error).message); + } + } + flog.attach(live?.tap ?? null); + renderCanvas(); + renderInspector(); + } + + function partnersFor(inst: Instance): Instance[] { + if (!session) return []; + const want: Role = inst.role === "client" ? "server" : "client"; + return session.instances.filter( + (i) => i.role === want && i.protocol === inst.protocol && i.schemaNs === inst.schemaNs, + ); + } + + function connectedPartnerId(inst: Instance): string | null { + const conn = session?.connection; + if (!conn) return null; + if (conn.clientId === inst.id) return conn.serverId; + if (conn.serverId === inst.id) return conn.clientId; + return null; + } + + // ── inspector ──────────────────────────────────────────────────────── + function renderInspector() { + inspectorEl.replaceChildren(); + callForm = null; + if (!session) return; + const sel = selectedId ? instance(session, selectedId) : undefined; + if (!sel) { + inspectorEl.append(muted("select an instance")); + return; + } + const found = findProtocol(session.shape, sel.schemaNs, sel.protocol); + if (!found) { + inspectorEl.append(muted(`${sel.protocol} is no longer compiled`)); + return; + } + + inspectorEl.append( + facts([ + ["instance", sel.name], + ["protocol", sel.protocol], + ["role", sel.role], + ["framing", found.protocol.framing], + ]), + ); + const hash = document.createElement("button"); + hash.className = "hash-copy mono"; + hash.textContent = found.schema.ir_hash; + hash.title = "copy ir_hash"; + hash.addEventListener("click", () => void navigator.clipboard?.writeText(found.schema.ir_hash)); + inspectorEl.append(row("ir_hash", hash)); + + // remove + const rm = button("remove instance", "danger", () => { + removeInstance(session!, sel.id); + if (selectedId === sel.id) selectedId = null; + void reconnect(); + renderAll(); + }); + inspectorEl.append(rm); + + // connect control + const partnerId = connectedPartnerId(sel); + const connectSel = document.createElement("select"); + connectSel.className = "connect-sel"; + connectSel.append(opt("", "— not connected —", !partnerId)); + for (const p of partnersFor(sel)) connectSel.append(opt(p.id, p.name, p.id === partnerId)); + connectSel.addEventListener("change", () => { + const otherId = selValue(connectSel); + if (!otherId) { + session!.connection = null; + } else { + const [clientId, serverId] = + sel.role === "client" ? [sel.id, otherId] : [otherId, sel.id]; + try { + setConnection(session!, clientId, serverId); + } catch (e) { + flashInspectorError((e as Error).message); + return; + } + } + void reconnect(); + }); + inspectorEl.append(section("connection"), row("partner", connectSel)); + if (session.connection && live) inspectorEl.append(muted("● live", "ok")); + + // server: per-function behaviours + if (sel.role === "server") { + inspectorEl.append(section("behaviours")); + for (const fn of found.protocol.functions) { + inspectorEl.append(behaviorRow(sel, fn.name)); + } + } + + // client + live: the call form + if (sel.role === "client" && live && connectedPartnerId(sel)) { + inspectorEl.append(renderCallForm(sel)); + } + } + + function behaviorRow(inst: Instance, fnName: string): HTMLElement { + const found = findProtocol(session!.shape, inst.schemaNs, inst.protocol)!; + const fn = found.protocol.functions.find((f) => f.name === fnName)!; + const setting = inst.behaviors[fnName]; + + const wrap = div("behavior-row"); + const head = div("behavior-head"); + const name = document.createElement("span"); + name.className = "behavior-fn mono"; + name.textContent = fnName; + const sel = document.createElement("select"); + sel.className = "behavior-kind"; + for (const kind of BEHAVIOR_KINDS) { + if (!BEHAVIORS[kind].appliesTo(fn)) continue; + sel.append(opt(kind, BEHAVIORS[kind].label, kind === setting.kind)); + } + head.append(name, sel); + wrap.append(head); + + const cfg = document.createElement("textarea"); + cfg.className = "behavior-config mono"; + cfg.rows = 3; + cfg.spellcheck = false; + cfg.value = JSON.stringify(setting.config, null, 1); + wrap.append(cfg); + + const applyLive = () => { + if (live && connectedPartnerId(inst)) live.setBehavior(fnName, inst.behaviors[fnName]); + }; + sel.addEventListener("change", () => { + setBehavior(session!, inst.id, fnName, selValue(sel) as BehaviorKind); + cfg.value = JSON.stringify(inst.behaviors[fnName].config, null, 1); + applyLive(); + }); + cfg.addEventListener("change", () => { + try { + const parsed = JSON.parse(cfg.value || "{}") as Record; + inst.behaviors[fnName].config = parsed; + cfg.classList.remove("bad"); + applyLive(); + } catch { + cfg.classList.add("bad"); + } + }); + return wrap; + } + + function renderCallForm(inst: Instance): HTMLElement { + const found = findProtocol(session!.shape, inst.schemaNs, inst.protocol)!; + const wrap = div("call-form"); + wrap.append(section("call")); + + const fnSel = document.createElement("select"); + fnSel.className = "call-fn"; + found.protocol.functions.forEach((fn, i) => fnSel.append(opt(fn.name, fn.name, i === 0))); + wrap.append(row("fn", fnSel)); + + const formHost = div("call-args"); + wrap.append(formHost); + + const out = div("call-out mono"); + wrap.append(out); + + const send = button("send", "primary", async () => { + if (!callForm || !live) return; + out.className = "call-out mono"; + let params: unknown; + try { + params = callForm.read(); + } catch (e) { + out.textContent = (e as Error).message; + out.classList.add("err"); + return; + } + out.textContent = "…"; + try { + const res = await live.call(selValue(fnSel), params); + out.textContent = res === undefined ? "(no reply)" : JSON.stringify(res, null, 2); + out.classList.add("ok"); + } catch (e) { + if (e instanceof SimRemoteError) { + out.textContent = `${e.errorName ?? "error " + e.ordinal} ${JSON.stringify(e.data)}`; + } else { + out.textContent = (e as Error).message; + } + out.classList.add("err"); + } + }); + wrap.append(send); + + const rebuildForm = () => { + const fn = + found.protocol.functions.find((f) => f.name === selValue(fnSel)) ?? + found.protocol.functions[0]; + callForm = argsForm(fn.args, found.schema); + formHost.replaceChildren(callForm.el); + }; + fnSel.addEventListener("change", rebuildForm); + rebuildForm(); + return wrap; + } + + function flashInspectorError(msg: string) { + const p = muted(msg, "err"); + inspectorEl.prepend(p); + setTimeout(() => p.remove(), 4000); + } + + // ── lifecycle ──────────────────────────────────────────────────────── + function renderAll() { + renderPalette(); + renderCanvas(); + renderInspector(); + } + + return { + el, + setShape(shape) { + if (!session) session = emptySession(shape); + else rebuild(session, shape); + if (selectedId && !instance(session, selectedId)) selectedId = null; + void reconnect(); // pick up any framing / ir_hash change, or drop a dead connection + renderAll(); + }, + destroy() { + window.removeEventListener("resize", onResize); + live?.close(); + }, + }; +} + +// ── tiny DOM helpers ─────────────────────────────────────────────────── +function div(cls: string): HTMLDivElement { + const d = document.createElement("div"); + d.className = cls; + return d; +} +function labelled(area: string, content: HTMLElement): HTMLElement { + content.dataset.area = area; + return content; +} +function section(text: string): HTMLElement { + const h = document.createElement("div"); + h.className = "insp-section"; + h.textContent = text; + return h; +} +function row(label: string, control: HTMLElement): HTMLElement { + const r = div("insp-row"); + const l = document.createElement("span"); + l.className = "insp-label"; + l.textContent = label; + r.append(l, control); + return r; +} +function facts(pairs: [string, string][]): HTMLElement { + const f = div("insp-facts"); + for (const [k, v] of pairs) { + const kv = div("fact"); + const kk = document.createElement("span"); + kk.className = "insp-label"; + kk.textContent = k; + const vv = document.createElement("span"); + vv.className = "fact-v"; + vv.textContent = v; + kv.append(kk, vv); + f.append(kv); + } + return f; +} +function muted(text: string, extra = ""): HTMLElement { + const p = document.createElement("p"); + p.className = `muted pad ${extra}`.trim(); + p.textContent = text; + return p; +} +function button(text: string, variant: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement("button"); + b.className = `sim-btn ${variant}`; + b.textContent = text; + b.addEventListener("click", onClick); + return b; +} +/** The chosen value — resilient to a DOM where `