lumbda/www/playground/app.js
russell@unturf.com ffada026ae
playground: split streamed chunks into per-line divs + per-program drafts
Two fixes in one push.

(1) Streaming horizontal-output bug, take three. textContent += chunk
then appendChild(createTextNode(chunk)) both still rendered chunks
horizontally in fox's Firefox tab even though the chunks clearly
contained \\n (node test confirmed; deployed loader hash matched
local; curl-fetched lumbda-c.loader.js carried the line + '\\n' fix).
Whatever the browser was doing with sibling text nodes inside a
<pre> wasn't honoring the embedded newlines.

Switch to one <div class=\"stream-line\"> per logical line. liveBlock.append
walks the incoming chunk byte by byte, every \\n closes the current
pending div and spawns a fresh empty one for the next line. CSS
adds .stream-line { display: block; white-space: pre; } so each
finished line stacks vertically no matter what the parent
white-space rule was doing. Empty lines get a single space so they
take a row instead of collapsing. Reconcile path inside finalize()
compares the joined per-line text to the full output and rebuilds
the div column if they diverge — for the asm-tier fallback we
already had and now also for any future browser/wasm combo where
a flush silently drops a chunk.

Also drops the temporary [c-tier print] console.log debug we added
in the last commit — diagnosis arrived from elsewhere, no point
keeping the spam.

(2) Per-program autosave drafts. scheduleFreeFormSave previously
returned early if the selected program wasn't \"free-form\", so a
user who unlocked the vault, edited the bend-gpu demo, and came
back later found their edits gone — only free-form persisted.
Now drafts live as { [programName]: text } in the vault payload;
every edit, regardless of which radio is selected, debounces a
save into drafts[currentProgram]. unlockVault loads any saved
draft for the current program (and lifts the legacy
top-level freeForm field into drafts['free-form'] so existing
users don't lose their work). loadCurrentDemo shows a saved
draft instead of the ship default whenever one exists; vault bar
stays visible across all programs once the vault is engaged so
the save status note is always reachable.
2026-06-15 06:02:37 -04:00

468 lines
19 KiB
JavaScript

// wasm/app/app.js
// Single-page app shell — CodeMirror 6 editor + Web-Worker-backed tier
// runner. The main thread stays responsive: the live ms counter ticks
// every animation frame, and Cancel terminates the worker mid-eval.
import { EditorState } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, drawSelection } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@codemirror/language";
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
import { oneDark } from "@codemirror/theme-one-dark";
import { openVault } from "./crypto.js";
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
const FREE_FORM_DEFAULT = `; free-form mode — unlock the vault below to persist this code
; encrypted in localStorage with your password
(print "67")
(/ 42 6)
`;
const demoSources = {};
// Vault state. When unlocked, free-form code auto-saves on every edit.
const vaultState = { vault: null, freeForm: null, saveTimer: null };
async function loadDemoSource(name) {
if (name === "free-form") {
return vaultState.freeForm != null ? vaultState.freeForm : FREE_FORM_DEFAULT;
}
if (!demoSources[name]) {
const resp = await fetch(`demos/${name}.lsp`);
demoSources[name] = await resp.text();
}
return demoSources[name];
}
const editorParent = document.getElementById("editor");
const outputEl = document.getElementById("output");
const statusEl = document.getElementById("status");
const runBtn = document.getElementById("run");
const cancelBtn = document.getElementById("cancel");
const editorView = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
lineNumbers(),
history(),
drawSelection(),
syntaxHighlighting(defaultHighlightStyle),
StreamLanguage.define(scheme),
keymap.of([...defaultKeymap, ...historyKeymap]),
oneDark,
EditorView.updateListener.of((u) => {
if (u.docChanged) scheduleFreeFormSave();
}),
],
}),
parent: editorParent,
});
function setEditorText(text) {
editorView.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: text },
});
}
function getEditorText() {
return editorView.state.doc.toString();
}
async function loadCurrentDemo() {
const sel = document.querySelector('input[name="program"]:checked').value;
const vaultBar = document.getElementById("vault-bar");
// Vault bar visible whenever the vault gate has been engaged
// (locked or unlocked) — drafts live per-program now, not just
// for free-form, so the lock/unlock affordance stays useful on
// every demo. Hide entirely only if the user explicitly hasn't
// set up a vault yet (vault.exists()) — kept as-is to avoid
// a "first-paint shows lock UI to a fresh customer" surprise.
vaultBar.hidden = sel !== "free-form" && !vaultState.vault;
// If the vault is unlocked and has a saved draft for this
// program, use it; otherwise fall back to the demo's source.
if (vaultState.vault && vaultState.drafts
&& typeof vaultState.drafts[sel] === "string") {
setEditorText(vaultState.drafts[sel]);
return;
}
const src = await loadDemoSource(sel);
setEditorText(src);
}
// ─── Free-form vault ───────────────────────────────────────────────
const vaultBarEl = document.getElementById("vault-bar");
const vaultPwEl = document.getElementById("vault-pw");
const vaultUnlockBtn = document.getElementById("vault-unlock");
const vaultLockBtn = document.getElementById("vault-lock");
const vaultStateEl = document.getElementById("vault-state");
const vaultNoteEl = document.getElementById("vault-note");
function setVaultNote(text, isErr) {
vaultNoteEl.textContent = text || "";
vaultNoteEl.className = "vault-note" + (isErr ? " err" : "");
}
async function unlockVault() {
const pw = vaultPwEl.value;
if (!pw) { setVaultNote("password required", true); return; }
setVaultNote("");
try {
vaultState.vault = await openVault(pw);
const data = await vaultState.vault.read();
if (data && data.__decryptionFailed) {
setVaultNote("vault exists but password is wrong", true);
vaultState.vault = null;
return;
}
// Per-program draft map. Back-compat: lift any legacy
// top-level freeForm field into drafts['free-form'] so users
// who already had a free-form draft don't lose it.
vaultState.drafts = (data && data.drafts && typeof data.drafts === "object")
? Object.assign({}, data.drafts)
: {};
if (data && typeof data.freeForm === "string" && !vaultState.drafts["free-form"]) {
vaultState.drafts["free-form"] = data.freeForm;
}
vaultState.freeForm = vaultState.drafts["free-form"] || FREE_FORM_DEFAULT;
vaultStateEl.textContent = "unlocked";
vaultStateEl.classList.add("unlocked");
vaultUnlockBtn.hidden = true;
vaultLockBtn.hidden = false;
vaultPwEl.value = "";
vaultPwEl.disabled = true;
// If the currently-selected program has a saved draft, load it.
// Otherwise leave the demo's default source in place.
const sel = document.querySelector('input[name="program"]:checked').value;
if (typeof vaultState.drafts[sel] === "string") {
setEditorText(vaultState.drafts[sel]);
} else if (sel === "free-form") {
setEditorText(vaultState.freeForm);
}
setVaultNote("ok — edits auto-save");
} catch (e) {
setVaultNote("unlock failed: " + e.message, true);
}
}
function lockVault() {
vaultState.vault = null;
vaultState.drafts = null;
vaultState.freeForm = null;
if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
vaultStateEl.textContent = "locked";
vaultStateEl.classList.remove("unlocked");
vaultUnlockBtn.hidden = false;
vaultLockBtn.hidden = true;
vaultPwEl.disabled = false;
vaultPwEl.value = "";
setVaultNote("");
// Reset the editor to whatever the current program ships with
// (drafts only live while the vault is unlocked).
loadCurrentDemo();
}
// Debounced autosave — fires on every editor doc change while the
// vault is unlocked. Saves under drafts[program-name] so each demo's
// edits persist independently. Previously this was free-form-only,
// which surprised users who edited a demo, switched tabs, and came
// back to their original demo source untouched.
function scheduleFreeFormSave() {
if (!vaultState.vault) return;
const sel = document.querySelector('input[name="program"]:checked').value;
if (vaultState.saveTimer) clearTimeout(vaultState.saveTimer);
vaultState.saveTimer = setTimeout(async () => {
const text = getEditorText();
vaultState.drafts = vaultState.drafts || {};
vaultState.drafts[sel] = text;
// Keep the legacy freeForm field in sync so older builds that
// only know about that field still see the user's free-form
// edits if they re-open the vault from a different tab.
if (sel === "free-form") vaultState.freeForm = text;
try {
await vaultState.vault.write({
drafts: vaultState.drafts,
freeForm: vaultState.drafts["free-form"] || "",
savedAt: Date.now(),
});
setVaultNote(`saved ${sel} @ ${new Date().toLocaleTimeString()}`);
} catch (e) {
setVaultNote("save failed: " + e.message, true);
}
}, 350);
}
function selectedTiers() {
const sel = document.querySelector('input[name="tier"]:checked').value;
return sel === "all" ? ["python", "c", "asm"] : [sel];
}
function setStatus(text, cls) {
statusEl.textContent = text || "";
statusEl.className = "status" + (cls ? " " + cls : "");
}
// ─── Worker plumbing ───────────────────────────────────────────────
// ONE worker per tier so the three tiers run on independent threads —
// in "All three" mode they race, and a slow tier never blocks a fast one.
// Cancel terminates every active worker; next eval recreates them.
const workerState = {
workers: { python: null, c: null, asm: null },
pending: { python: null, c: null, asm: null },
nextRunId: 0,
};
function spawnWorker() {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
if (bendState.url) w.postMessage({ kind: "config", bendUrl: bendState.url });
return w;
}
function ensureWorker(tier) {
if (!workerState.workers[tier]) workerState.workers[tier] = spawnWorker();
return workerState.workers[tier];
}
// ─── Bend URL ──────────────────────────────────────────────────────
const bendState = { url: localStorage.getItem("lumbda_bend_url") || "" };
const bendUrlEl = document.getElementById("bend-url");
const bendSaveBtn = document.getElementById("bend-save");
const bendStateEl = document.getElementById("bend-state");
if (bendState.url) {
bendUrlEl.value = bendState.url;
bendStateEl.textContent = "saved";
}
function saveBendUrl() {
bendState.url = bendUrlEl.value.trim();
if (bendState.url) {
localStorage.setItem("lumbda_bend_url", bendState.url);
bendStateEl.textContent = "saved";
} else {
localStorage.removeItem("lumbda_bend_url");
bendStateEl.textContent = "";
}
// Push config to any already-spawned workers.
for (const w of Object.values(workerState.workers)) {
if (w) w.postMessage({ kind: "config", bendUrl: bendState.url });
}
}
bendSaveBtn.addEventListener("click", saveBendUrl);
bendUrlEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); saveBendUrl(); }
});
function runOnTierInWorker(tier, src, onLoading) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tier);
const myRunId = ++workerState.nextRunId;
let liveBlock = null;
workerState.pending[tier] = { runId: myRunId, resolve, reject };
const handler = (e) => {
if (e.data.runId !== myRunId) return;
if (e.data.kind === "loading") {
onLoading && onLoading(e.data.tier);
} else if (e.data.kind === "chunk") {
// First chunk spawns the in-progress block; subsequent
// chunks append to it. User sees displays land
// immediately, not just at the end of the run.
if (!liveBlock) liveBlock = startLiveBlock(tier);
liveBlock.append(e.data.chunk);
} else if (e.data.kind === "done") {
w.removeEventListener("message", handler);
workerState.pending[tier] = null;
resolve({ output: e.data.output, liveBlock });
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
workerState.pending[tier] = null;
const err = new Error(e.data.message);
err.liveBlock = liveBlock;
reject(err);
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId: myRunId, tier, src });
});
}
// Spawn a tier-block in the output panel immediately on first streamed
// chunk. Returns handles to append further chunks and to finalize the
// timing header once the eval reports done. Mirrors appendBlock's
// structure so finalized live blocks look identical to non-streamed
// ones — same DOM, same CSS.
function startLiveBlock(tierName) {
const block = document.createElement("div");
block.className = "tier-block";
const h = document.createElement("h3");
h.textContent = TIERS[tierName] || tierName;
const t = document.createElement("span");
t.className = "time";
t.textContent = " (running…)";
h.appendChild(t);
block.appendChild(h);
const pre = document.createElement("pre");
pre.textContent = "";
block.appendChild(pre);
outputEl.appendChild(block);
return {
append(chunk) {
// Append a TextNode rather than `pre.textContent += chunk`
// — the latter re-reads + restringifies + re-sets all
// existing children on every chunk, which under a fast
// C-tier loop (one chunk per (newline) printf) was
// dropping line breaks and rendering "tick 0tick 10000…"
// horizontally instead of stacking vertically. TextNode
// append preserves every byte verbatim.
pre.appendChild(document.createTextNode(chunk));
outputEl.scrollTop = outputEl.scrollHeight;
},
finalize(elapsedMs, kind, fullOutput) {
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
if (kind === "error") pre.className = "err";
// If the streamed chunks miss anything (e.g. asm tier
// which doesn't stream yet), reconcile with the full
// output. No-op when streaming captured everything.
if (fullOutput && pre.textContent !== fullOutput) {
pre.textContent = fullOutput;
}
},
};
}
function cancelCurrentRun() {
for (const t of Object.keys(workerState.workers)) {
if (workerState.workers[t]) {
workerState.workers[t].terminate();
workerState.workers[t] = null;
}
if (workerState.pending[t]) {
workerState.pending[t].reject(new Error("cancelled"));
workerState.pending[t] = null;
}
}
}
// Append a finished tier-block to the output. Order = finish order, so
// the fastest tier appears first naturally.
function appendBlock(tierName, elapsedMs, text, kind) {
const block = document.createElement("div");
block.className = "tier-block";
const h = document.createElement("h3");
h.textContent = TIERS[tierName] || tierName;
const t = document.createElement("span");
t.className = "time";
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
h.appendChild(t);
block.appendChild(h);
const pre = document.createElement("pre");
if (kind === "error") pre.className = "err";
pre.textContent = text;
block.appendChild(pre);
outputEl.appendChild(block);
}
// ─── Run / cancel ──────────────────────────────────────────────────
let inFlight = false;
async function runAll() {
if (inFlight) return;
// bend demo guard: refuse to run locally without a configured bend URL.
// A CPU-impossible workload would lock the customer's tab.
const program = document.querySelector('input[name="program"]:checked').value;
if (program === "bend-gpu" && !bendState.url) {
setStatus("set a bend URL first — this demo is GPU-only by design", "err");
return;
}
inFlight = true;
runBtn.disabled = true;
cancelBtn.disabled = false;
setStatus("running…", "busy");
outputEl.innerHTML = "";
const tiers = selectedTiers();
const src = getEditorText();
// Each tier in its own worker. Append-only output: as each tier finishes,
// we append its block — so the fastest tier shows up first.
const startTimes = {};
const tickStatus = () => {
const parts = [];
for (const t of tiers) {
if (startTimes[t] !== undefined) {
parts.push(`${t} ${((performance.now() - startTimes[t]) | 0)}ms`);
}
}
setStatus(parts.join(" · "), "busy");
};
let raf = requestAnimationFrame(function loop() {
tickStatus();
raf = requestAnimationFrame(loop);
});
const tierPromises = tiers.map((t) => {
startTimes[t] = performance.now();
return runOnTierInWorker(t, src, (loadingTier) => {
setStatus(`loading ${loadingTier}`, "busy");
})
.then(({ output, liveBlock }) => {
const elapsed = performance.now() - startTimes[t];
delete startTimes[t];
// Streamed tiers already painted via liveBlock; just
// finalize the timing header. Non-streaming tiers
// (today: asm) get a fresh appendBlock at the end.
if (liveBlock) liveBlock.finalize(elapsed, "ok", output);
else appendBlock(t, elapsed, output, "ok");
return { tier: t, ok: true, elapsed };
})
.catch((e) => {
const elapsed = performance.now() - startTimes[t];
delete startTimes[t];
if (e.message === "cancelled") {
if (e.liveBlock) e.liveBlock.finalize(elapsed, "cancelled");
else appendBlock(t, elapsed, "(cancelled)", "cancelled");
return { tier: t, cancelled: true, elapsed };
}
if (e.liveBlock) e.liveBlock.finalize(elapsed, "error", e.message);
else appendBlock(t, elapsed, e.message || String(e), "error");
return { tier: t, error: e.message, elapsed };
});
});
try {
const results = await Promise.all(tierPromises);
cancelAnimationFrame(raf);
const cancelled = results.some((r) => r.cancelled);
const anyErr = results.some((r) => r.error);
if (cancelled) setStatus("cancelled", "warn");
else if (anyErr) setStatus("completed with errors", "err");
else {
const fastest = results.reduce((a, b) => (a.elapsed < b.elapsed ? a : b));
setStatus(`ok — ${fastest.tier} won in ${fastest.elapsed.toFixed(0)} ms`, "ok");
}
} catch (e) {
cancelAnimationFrame(raf);
setStatus(`fatal: ${e.message}`, "err");
} finally {
inFlight = false;
runBtn.disabled = false;
cancelBtn.disabled = true;
}
}
function onCancel() {
if (!inFlight) return;
setStatus("cancelling…", "warn");
cancelCurrentRun();
}
document.querySelectorAll('input[name="program"]').forEach((el) => {
el.addEventListener("change", loadCurrentDemo);
});
runBtn.addEventListener("click", runAll);
cancelBtn.addEventListener("click", onCancel);
cancelBtn.disabled = true;
vaultUnlockBtn.addEventListener("click", unlockVault);
vaultLockBtn.addEventListener("click", lockVault);
vaultPwEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); unlockVault(); }
});
loadCurrentDemo();