diff --git a/wasm/c/lumbda-c.loader.js b/wasm/c/lumbda-c.loader.js index 1e2e8e2..1fccb21 100644 --- a/wasm/c/lumbda-c.loader.js +++ b/wasm/c/lumbda-c.loader.js @@ -99,7 +99,14 @@ async function _bootstrap() { // free path right now (LUMBDA_NO_BOEHM), so used = total โ€” // every malloc accumulates until reload. Documented and // surfaced in the REPL so the user sees the pressure. - const total = module.HEAPU8.byteLength; + // + // HEAPU8 isn't always live: after a worker reboots, the + // heap poll can fire while the new Module is mid-init and + // HEAPU8 isn't yet wired. Return null so the REPL hides + // the pressure indicator for that tick instead of crashing. + const heap = module.HEAPU8; + if (!heap) return null; + const total = heap.byteLength; return { used: total, total }; }, }; diff --git a/wasm/dist-repl/c/lumbda-c.loader.js b/wasm/dist-repl/c/lumbda-c.loader.js index 1e2e8e2..1fccb21 100644 --- a/wasm/dist-repl/c/lumbda-c.loader.js +++ b/wasm/dist-repl/c/lumbda-c.loader.js @@ -99,7 +99,14 @@ async function _bootstrap() { // free path right now (LUMBDA_NO_BOEHM), so used = total โ€” // every malloc accumulates until reload. Documented and // surfaced in the REPL so the user sees the pressure. - const total = module.HEAPU8.byteLength; + // + // HEAPU8 isn't always live: after a worker reboots, the + // heap poll can fire while the new Module is mid-init and + // HEAPU8 isn't yet wired. Return null so the REPL hides + // the pressure indicator for that tick instead of crashing. + const heap = module.HEAPU8; + if (!heap) return null; + const total = heap.byteLength; return { used: total, total }; }, }; diff --git a/wasm/dist-repl/index.html b/wasm/dist-repl/index.html index 5845a02..37ff31c 100644 --- a/wasm/dist-repl/index.html +++ b/wasm/dist-repl/index.html @@ -90,6 +90,9 @@
portals: + + +
diff --git a/wasm/dist-repl/repl.css b/wasm/dist-repl/repl.css index 2a37d53..244bf2f 100644 --- a/wasm/dist-repl/repl.css +++ b/wasm/dist-repl/repl.css @@ -190,7 +190,8 @@ body.repl { * (the asm-wat tier stays grey). */ .portal-bar { display: grid; - grid-template-columns: auto auto 1fr; + /* label ยท save ยท import ยท scope ยท chips-row */ + grid-template-columns: auto auto auto auto 1fr; gap: 0.4rem; align-items: center; padding: 0.2rem 0.5rem; @@ -211,7 +212,8 @@ body.repl { } .portal-chips .chip { display: inline-grid; - grid-template-columns: auto auto; + /* label ยท โ‡ฃ download ยท ร— delete */ + grid-template-columns: auto auto auto; gap: 0.3rem; align-items: center; background: var(--pane-bg); @@ -223,12 +225,20 @@ body.repl { white-space: nowrap; } .portal-chips .chip:hover { border-color: var(--green); } +.portal-chips .chip-global { + /* Cross-tab snapshots get a muted accent so they read as "borrowed + * from another tab" rather than belonging here. */ + border-style: dashed; + opacity: 0.85; +} +.portal-chips .chip .chip-export, .portal-chips .chip .chip-close { color: var(--muted); font-size: 0.85em; cursor: pointer; padding: 0 0.15rem; } +.portal-chips .chip .chip-export:hover { color: var(--green); } .portal-chips .chip .chip-close:hover { color: var(--err); } /* โ”€โ”€โ”€ Transcript โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index e72fc91..0cf89d4 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -30,7 +30,14 @@ const tierSelectEl = document.getElementById("tier-select"); const sendBtn = document.getElementById("send"); const portalBarEl = document.getElementById("portal-bar"); const portalSaveBtn = document.getElementById("portal-save"); +const portalImportBtn = document.getElementById("portal-import"); +const portalImportFileEl = document.getElementById("portal-import-file"); +const portalScopeBtn = document.getElementById("portal-scope"); const portalChipsEl = document.getElementById("portal-chips"); +// Whether the chip strip shows every tab's checkpoints (true) or just +// the active tab's (false). UI-only โ€” never persisted, so a fresh +// session always starts in the focused per-tab view. +let showGlobalCheckpoints = false; // โ”€โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const state = { @@ -309,6 +316,14 @@ async function saveCheckpoint() { `${ts.getSeconds().toString().padStart(2, "0")}`; const name = safePortalName(prompt("checkpoint name:", defaultName) || ""); if (!name) return; + // Overwrite guard โ€” saving with an existing name silently nuked + // the old blob, which has cost more than one "where did my snapshot + // go?" moment. Ask first. + if (tab.checkpoints && tab.checkpoints[name]) { + const old = tab.checkpoints[name]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return; + } let entry; if (tier === "asm") { @@ -340,10 +355,15 @@ async function saveCheckpoint() { saveSoon(); } -async function restoreCheckpoint(name) { +// Restore a checkpoint into the active tab. The optional sourceCp +// override lets the global-checkpoints view pass in a cp from a +// different tab โ€” the restore still operates on activeTab() so the +// user lands on the tab they were working in, with that tab's tier +// state reset to the snapshot. +async function restoreCheckpoint(name, sourceCp) { const tab = activeTab(); if (!tab) return; - const cp = tab.checkpoints && tab.checkpoints[name]; + const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]); if (!cp) return; if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return; if (tab.tier !== cp.tier) { @@ -383,6 +403,91 @@ async function restoreCheckpoint(name) { saveSoon(); } +function renameCheckpoint(oldName) { + const tab = activeTab(); + if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return; + const raw = prompt(`rename '${oldName}' to:`, oldName); + if (raw === null) return; + const newName = safePortalName(raw); + if (!newName || newName === oldName) return; + if (tab.checkpoints[newName]) { + const old = tab.checkpoints[newName]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return; + } + tab.checkpoints[newName] = tab.checkpoints[oldName]; + delete tab.checkpoints[oldName]; + renderAll(); + saveSoon(); +} + +// Export a checkpoint to a downloadable .portal.json file. The blob +// itself is opaque JSON for c/python (portal_save's wire format) and a +// {tier, mode: "replay", inputs} object for asm. Either way the file +// holds everything importCheckpoint needs to reconstruct the entry; no +// vault-side metadata bleeds out. +function exportCheckpoint(name) { + const tab = activeTab(); + if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return; + const entry = tab.checkpoints[name]; + const payload = { __portal: 1, name, ...entry }; + const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(data); + const a = document.createElement("a"); + a.href = url; + a.download = `${name}.portal.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +// Import a checkpoint File (from ). Validates the +// shape, prompts on name collision, lands the entry in the active +// tab's checkpoints map. The imported entry lives on whatever tier it +// was saved under โ€” restoring it will switch the active tab to that +// tier automatically (restoreCheckpoint already does the swap). +async function importCheckpoint(file) { + const tab = activeTab(); + if (!tab) return; + let payload; + try { + const text = await file.text(); + payload = JSON.parse(text); + } catch (e) { + alert("import failed โ€” not valid JSON: " + e.message); + return; + } + if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) { + alert("import failed โ€” file isn't a portal snapshot (missing __portal/tier/savedAt)"); + return; + } + const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported"; + let name = baseName; + tab.checkpoints = tab.checkpoints || {}; + if (tab.checkpoints[name]) { + const old = tab.checkpoints[name]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) { + // Auto-suffix on decline so the user gets to keep both. + let i = 2; + while (tab.checkpoints[`${baseName}-${i}`]) i++; + name = `${baseName}-${i}`; + } + } + const entry = { tier: payload.tier, savedAt: payload.savedAt }; + if (payload.mode === "replay") { + entry.mode = "replay"; + entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : []; + } else { + entry.mode = "blob"; + entry.blob = payload.blob || ""; + } + tab.checkpoints[name] = entry; + renderAll(); + saveSoon(); +} + function deleteCheckpoint(name) { const tab = activeTab(); if (!tab || !tab.checkpoints) return; @@ -492,24 +597,63 @@ function renderAll() { transcriptEl.appendChild(block); } - // Portal chips โ€” one per saved checkpoint in the active tab. + // Portal chips โ€” when showGlobalCheckpoints is off, one per saved + // checkpoint in the active tab. When on, gather every checkpoint + // across every tab so the user can restore a snapshot saved on + // tab A into tab B. The label gains a "ยท tabName" suffix in + // global mode so it's clear where each chip came from. portalChipsEl.innerHTML = ""; - const checkpoints = tab.checkpoints || {}; - const names = Object.keys(checkpoints).sort(); - for (const name of names) { + portalScopeBtn.textContent = showGlobalCheckpoints ? "๐ŸŒ all tabs" : "๐Ÿ“‚ this tab"; + portalScopeBtn.title = showGlobalCheckpoints + ? "showing checkpoints from every tab โ€” click for this tab only" + : "showing only this tab's checkpoints โ€” click for every tab"; + const chipRows = []; + if (showGlobalCheckpoints) { + for (const t of state.tabs) { + const cps = t.checkpoints || {}; + for (const name of Object.keys(cps)) { + chipRows.push({ tab: t, name, cp: cps[name] }); + } + } + chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt); + } else { + const cps = tab.checkpoints || {}; + for (const name of Object.keys(cps).sort()) { + chipRows.push({ tab, name, cp: cps[name] }); + } + } + for (const { tab: cpTab, name, cp } of chipRows) { const chip = document.createElement("span"); chip.className = "chip"; - chip.title = `${name} ยท ${checkpoints[name].tier} ยท saved ${new Date(checkpoints[name].savedAt).toLocaleString()} โ€” click to restore`; + const when = new Date(cp.savedAt).toLocaleString(); + chip.title = showGlobalCheckpoints + ? `${name} ยท ${cp.tier} ยท saved ${when} on tab '${cpTab.name}' โ€” click to restore into THIS tab` + : `${name} ยท ${cp.tier} ยท saved ${when} โ€” click to restore, dbl-click to rename`; const label = document.createElement("span"); - label.textContent = name; - label.addEventListener("click", () => restoreCheckpoint(name)); - const close = document.createElement("span"); - close.className = "chip-close"; - close.textContent = "ร—"; - close.title = "delete checkpoint"; - close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); }); - chip.appendChild(label); - chip.appendChild(close); + label.textContent = showGlobalCheckpoints ? `${name} ยท ${cpTab.name}` : name; + // Restore always operates on the active tab; in global mode + // the snapshot's cp object is passed so we don't depend on + // the active tab having a checkpoints[name] match. + label.addEventListener("click", () => restoreCheckpoint(name, cp)); + if (!showGlobalCheckpoints) { + label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); }); + const exportBtn = document.createElement("span"); + exportBtn.className = "chip-export"; + exportBtn.textContent = "โ‡ฃ"; + exportBtn.title = "download checkpoint as .portal.json"; + exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); }); + const close = document.createElement("span"); + close.className = "chip-close"; + close.textContent = "ร—"; + close.title = "delete checkpoint"; + close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); }); + chip.appendChild(label); + chip.appendChild(exportBtn); + chip.appendChild(close); + } else { + chip.classList.add("chip-global"); + chip.appendChild(label); + } portalChipsEl.appendChild(chip); } @@ -766,6 +910,17 @@ clearLogBtn.addEventListener("click", () => { }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); portalSaveBtn.addEventListener("click", saveCheckpoint); +portalImportBtn.addEventListener("click", () => portalImportFileEl.click()); +portalImportFileEl.addEventListener("change", async (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + await importCheckpoint(file); + e.target.value = ""; +}); +portalScopeBtn.addEventListener("click", () => { + showGlobalCheckpoints = !showGlobalCheckpoints; + renderAll(); +}); lockBtn.addEventListener("click", relock); // Insert heap pressure indicator into the tabbar after the spacer. document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn); diff --git a/wasm/repl/index.html b/wasm/repl/index.html index 5845a02..37ff31c 100644 --- a/wasm/repl/index.html +++ b/wasm/repl/index.html @@ -90,6 +90,9 @@
portals: + + +
diff --git a/wasm/repl/repl.css b/wasm/repl/repl.css index 2a37d53..244bf2f 100644 --- a/wasm/repl/repl.css +++ b/wasm/repl/repl.css @@ -190,7 +190,8 @@ body.repl { * (the asm-wat tier stays grey). */ .portal-bar { display: grid; - grid-template-columns: auto auto 1fr; + /* label ยท save ยท import ยท scope ยท chips-row */ + grid-template-columns: auto auto auto auto 1fr; gap: 0.4rem; align-items: center; padding: 0.2rem 0.5rem; @@ -211,7 +212,8 @@ body.repl { } .portal-chips .chip { display: inline-grid; - grid-template-columns: auto auto; + /* label ยท โ‡ฃ download ยท ร— delete */ + grid-template-columns: auto auto auto; gap: 0.3rem; align-items: center; background: var(--pane-bg); @@ -223,12 +225,20 @@ body.repl { white-space: nowrap; } .portal-chips .chip:hover { border-color: var(--green); } +.portal-chips .chip-global { + /* Cross-tab snapshots get a muted accent so they read as "borrowed + * from another tab" rather than belonging here. */ + border-style: dashed; + opacity: 0.85; +} +.portal-chips .chip .chip-export, .portal-chips .chip .chip-close { color: var(--muted); font-size: 0.85em; cursor: pointer; padding: 0 0.15rem; } +.portal-chips .chip .chip-export:hover { color: var(--green); } .portal-chips .chip .chip-close:hover { color: var(--err); } /* โ”€โ”€โ”€ Transcript โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index e72fc91..0cf89d4 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -30,7 +30,14 @@ const tierSelectEl = document.getElementById("tier-select"); const sendBtn = document.getElementById("send"); const portalBarEl = document.getElementById("portal-bar"); const portalSaveBtn = document.getElementById("portal-save"); +const portalImportBtn = document.getElementById("portal-import"); +const portalImportFileEl = document.getElementById("portal-import-file"); +const portalScopeBtn = document.getElementById("portal-scope"); const portalChipsEl = document.getElementById("portal-chips"); +// Whether the chip strip shows every tab's checkpoints (true) or just +// the active tab's (false). UI-only โ€” never persisted, so a fresh +// session always starts in the focused per-tab view. +let showGlobalCheckpoints = false; // โ”€โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const state = { @@ -309,6 +316,14 @@ async function saveCheckpoint() { `${ts.getSeconds().toString().padStart(2, "0")}`; const name = safePortalName(prompt("checkpoint name:", defaultName) || ""); if (!name) return; + // Overwrite guard โ€” saving with an existing name silently nuked + // the old blob, which has cost more than one "where did my snapshot + // go?" moment. Ask first. + if (tab.checkpoints && tab.checkpoints[name]) { + const old = tab.checkpoints[name]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return; + } let entry; if (tier === "asm") { @@ -340,10 +355,15 @@ async function saveCheckpoint() { saveSoon(); } -async function restoreCheckpoint(name) { +// Restore a checkpoint into the active tab. The optional sourceCp +// override lets the global-checkpoints view pass in a cp from a +// different tab โ€” the restore still operates on activeTab() so the +// user lands on the tab they were working in, with that tab's tier +// state reset to the snapshot. +async function restoreCheckpoint(name, sourceCp) { const tab = activeTab(); if (!tab) return; - const cp = tab.checkpoints && tab.checkpoints[name]; + const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]); if (!cp) return; if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return; if (tab.tier !== cp.tier) { @@ -383,6 +403,91 @@ async function restoreCheckpoint(name) { saveSoon(); } +function renameCheckpoint(oldName) { + const tab = activeTab(); + if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return; + const raw = prompt(`rename '${oldName}' to:`, oldName); + if (raw === null) return; + const newName = safePortalName(raw); + if (!newName || newName === oldName) return; + if (tab.checkpoints[newName]) { + const old = tab.checkpoints[newName]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return; + } + tab.checkpoints[newName] = tab.checkpoints[oldName]; + delete tab.checkpoints[oldName]; + renderAll(); + saveSoon(); +} + +// Export a checkpoint to a downloadable .portal.json file. The blob +// itself is opaque JSON for c/python (portal_save's wire format) and a +// {tier, mode: "replay", inputs} object for asm. Either way the file +// holds everything importCheckpoint needs to reconstruct the entry; no +// vault-side metadata bleeds out. +function exportCheckpoint(name) { + const tab = activeTab(); + if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return; + const entry = tab.checkpoints[name]; + const payload = { __portal: 1, name, ...entry }; + const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(data); + const a = document.createElement("a"); + a.href = url; + a.download = `${name}.portal.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +// Import a checkpoint File (from ). Validates the +// shape, prompts on name collision, lands the entry in the active +// tab's checkpoints map. The imported entry lives on whatever tier it +// was saved under โ€” restoring it will switch the active tab to that +// tier automatically (restoreCheckpoint already does the swap). +async function importCheckpoint(file) { + const tab = activeTab(); + if (!tab) return; + let payload; + try { + const text = await file.text(); + payload = JSON.parse(text); + } catch (e) { + alert("import failed โ€” not valid JSON: " + e.message); + return; + } + if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) { + alert("import failed โ€” file isn't a portal snapshot (missing __portal/tier/savedAt)"); + return; + } + const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported"; + let name = baseName; + tab.checkpoints = tab.checkpoints || {}; + if (tab.checkpoints[name]) { + const old = tab.checkpoints[name]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) { + // Auto-suffix on decline so the user gets to keep both. + let i = 2; + while (tab.checkpoints[`${baseName}-${i}`]) i++; + name = `${baseName}-${i}`; + } + } + const entry = { tier: payload.tier, savedAt: payload.savedAt }; + if (payload.mode === "replay") { + entry.mode = "replay"; + entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : []; + } else { + entry.mode = "blob"; + entry.blob = payload.blob || ""; + } + tab.checkpoints[name] = entry; + renderAll(); + saveSoon(); +} + function deleteCheckpoint(name) { const tab = activeTab(); if (!tab || !tab.checkpoints) return; @@ -492,24 +597,63 @@ function renderAll() { transcriptEl.appendChild(block); } - // Portal chips โ€” one per saved checkpoint in the active tab. + // Portal chips โ€” when showGlobalCheckpoints is off, one per saved + // checkpoint in the active tab. When on, gather every checkpoint + // across every tab so the user can restore a snapshot saved on + // tab A into tab B. The label gains a "ยท tabName" suffix in + // global mode so it's clear where each chip came from. portalChipsEl.innerHTML = ""; - const checkpoints = tab.checkpoints || {}; - const names = Object.keys(checkpoints).sort(); - for (const name of names) { + portalScopeBtn.textContent = showGlobalCheckpoints ? "๐ŸŒ all tabs" : "๐Ÿ“‚ this tab"; + portalScopeBtn.title = showGlobalCheckpoints + ? "showing checkpoints from every tab โ€” click for this tab only" + : "showing only this tab's checkpoints โ€” click for every tab"; + const chipRows = []; + if (showGlobalCheckpoints) { + for (const t of state.tabs) { + const cps = t.checkpoints || {}; + for (const name of Object.keys(cps)) { + chipRows.push({ tab: t, name, cp: cps[name] }); + } + } + chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt); + } else { + const cps = tab.checkpoints || {}; + for (const name of Object.keys(cps).sort()) { + chipRows.push({ tab, name, cp: cps[name] }); + } + } + for (const { tab: cpTab, name, cp } of chipRows) { const chip = document.createElement("span"); chip.className = "chip"; - chip.title = `${name} ยท ${checkpoints[name].tier} ยท saved ${new Date(checkpoints[name].savedAt).toLocaleString()} โ€” click to restore`; + const when = new Date(cp.savedAt).toLocaleString(); + chip.title = showGlobalCheckpoints + ? `${name} ยท ${cp.tier} ยท saved ${when} on tab '${cpTab.name}' โ€” click to restore into THIS tab` + : `${name} ยท ${cp.tier} ยท saved ${when} โ€” click to restore, dbl-click to rename`; const label = document.createElement("span"); - label.textContent = name; - label.addEventListener("click", () => restoreCheckpoint(name)); - const close = document.createElement("span"); - close.className = "chip-close"; - close.textContent = "ร—"; - close.title = "delete checkpoint"; - close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); }); - chip.appendChild(label); - chip.appendChild(close); + label.textContent = showGlobalCheckpoints ? `${name} ยท ${cpTab.name}` : name; + // Restore always operates on the active tab; in global mode + // the snapshot's cp object is passed so we don't depend on + // the active tab having a checkpoints[name] match. + label.addEventListener("click", () => restoreCheckpoint(name, cp)); + if (!showGlobalCheckpoints) { + label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); }); + const exportBtn = document.createElement("span"); + exportBtn.className = "chip-export"; + exportBtn.textContent = "โ‡ฃ"; + exportBtn.title = "download checkpoint as .portal.json"; + exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); }); + const close = document.createElement("span"); + close.className = "chip-close"; + close.textContent = "ร—"; + close.title = "delete checkpoint"; + close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); }); + chip.appendChild(label); + chip.appendChild(exportBtn); + chip.appendChild(close); + } else { + chip.classList.add("chip-global"); + chip.appendChild(label); + } portalChipsEl.appendChild(chip); } @@ -766,6 +910,17 @@ clearLogBtn.addEventListener("click", () => { }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); portalSaveBtn.addEventListener("click", saveCheckpoint); +portalImportBtn.addEventListener("click", () => portalImportFileEl.click()); +portalImportFileEl.addEventListener("change", async (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + await importCheckpoint(file); + e.target.value = ""; +}); +portalScopeBtn.addEventListener("click", () => { + showGlobalCheckpoints = !showGlobalCheckpoints; + renderAll(); +}); lockBtn.addEventListener("click", relock); // Insert heap pressure indicator into the tabbar after the spacer. document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn); diff --git a/www/playground/c/lumbda-c.loader.js b/www/playground/c/lumbda-c.loader.js index 1e2e8e2..1fccb21 100644 --- a/www/playground/c/lumbda-c.loader.js +++ b/www/playground/c/lumbda-c.loader.js @@ -99,7 +99,14 @@ async function _bootstrap() { // free path right now (LUMBDA_NO_BOEHM), so used = total โ€” // every malloc accumulates until reload. Documented and // surfaced in the REPL so the user sees the pressure. - const total = module.HEAPU8.byteLength; + // + // HEAPU8 isn't always live: after a worker reboots, the + // heap poll can fire while the new Module is mid-init and + // HEAPU8 isn't yet wired. Return null so the REPL hides + // the pressure indicator for that tick instead of crashing. + const heap = module.HEAPU8; + if (!heap) return null; + const total = heap.byteLength; return { used: total, total }; }, }; diff --git a/www/repl/c/lumbda-c.loader.js b/www/repl/c/lumbda-c.loader.js index 1e2e8e2..1fccb21 100644 --- a/www/repl/c/lumbda-c.loader.js +++ b/www/repl/c/lumbda-c.loader.js @@ -99,7 +99,14 @@ async function _bootstrap() { // free path right now (LUMBDA_NO_BOEHM), so used = total โ€” // every malloc accumulates until reload. Documented and // surfaced in the REPL so the user sees the pressure. - const total = module.HEAPU8.byteLength; + // + // HEAPU8 isn't always live: after a worker reboots, the + // heap poll can fire while the new Module is mid-init and + // HEAPU8 isn't yet wired. Return null so the REPL hides + // the pressure indicator for that tick instead of crashing. + const heap = module.HEAPU8; + if (!heap) return null; + const total = heap.byteLength; return { used: total, total }; }, }; diff --git a/www/repl/index.html b/www/repl/index.html index 5845a02..37ff31c 100644 --- a/www/repl/index.html +++ b/www/repl/index.html @@ -90,6 +90,9 @@
portals: + + +
diff --git a/www/repl/repl.css b/www/repl/repl.css index 2a37d53..244bf2f 100644 --- a/www/repl/repl.css +++ b/www/repl/repl.css @@ -190,7 +190,8 @@ body.repl { * (the asm-wat tier stays grey). */ .portal-bar { display: grid; - grid-template-columns: auto auto 1fr; + /* label ยท save ยท import ยท scope ยท chips-row */ + grid-template-columns: auto auto auto auto 1fr; gap: 0.4rem; align-items: center; padding: 0.2rem 0.5rem; @@ -211,7 +212,8 @@ body.repl { } .portal-chips .chip { display: inline-grid; - grid-template-columns: auto auto; + /* label ยท โ‡ฃ download ยท ร— delete */ + grid-template-columns: auto auto auto; gap: 0.3rem; align-items: center; background: var(--pane-bg); @@ -223,12 +225,20 @@ body.repl { white-space: nowrap; } .portal-chips .chip:hover { border-color: var(--green); } +.portal-chips .chip-global { + /* Cross-tab snapshots get a muted accent so they read as "borrowed + * from another tab" rather than belonging here. */ + border-style: dashed; + opacity: 0.85; +} +.portal-chips .chip .chip-export, .portal-chips .chip .chip-close { color: var(--muted); font-size: 0.85em; cursor: pointer; padding: 0 0.15rem; } +.portal-chips .chip .chip-export:hover { color: var(--green); } .portal-chips .chip .chip-close:hover { color: var(--err); } /* โ”€โ”€โ”€ Transcript โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ diff --git a/www/repl/repl.js b/www/repl/repl.js index e72fc91..0cf89d4 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -30,7 +30,14 @@ const tierSelectEl = document.getElementById("tier-select"); const sendBtn = document.getElementById("send"); const portalBarEl = document.getElementById("portal-bar"); const portalSaveBtn = document.getElementById("portal-save"); +const portalImportBtn = document.getElementById("portal-import"); +const portalImportFileEl = document.getElementById("portal-import-file"); +const portalScopeBtn = document.getElementById("portal-scope"); const portalChipsEl = document.getElementById("portal-chips"); +// Whether the chip strip shows every tab's checkpoints (true) or just +// the active tab's (false). UI-only โ€” never persisted, so a fresh +// session always starts in the focused per-tab view. +let showGlobalCheckpoints = false; // โ”€โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const state = { @@ -309,6 +316,14 @@ async function saveCheckpoint() { `${ts.getSeconds().toString().padStart(2, "0")}`; const name = safePortalName(prompt("checkpoint name:", defaultName) || ""); if (!name) return; + // Overwrite guard โ€” saving with an existing name silently nuked + // the old blob, which has cost more than one "where did my snapshot + // go?" moment. Ask first. + if (tab.checkpoints && tab.checkpoints[name]) { + const old = tab.checkpoints[name]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return; + } let entry; if (tier === "asm") { @@ -340,10 +355,15 @@ async function saveCheckpoint() { saveSoon(); } -async function restoreCheckpoint(name) { +// Restore a checkpoint into the active tab. The optional sourceCp +// override lets the global-checkpoints view pass in a cp from a +// different tab โ€” the restore still operates on activeTab() so the +// user lands on the tab they were working in, with that tab's tier +// state reset to the snapshot. +async function restoreCheckpoint(name, sourceCp) { const tab = activeTab(); if (!tab) return; - const cp = tab.checkpoints && tab.checkpoints[name]; + const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]); if (!cp) return; if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return; if (tab.tier !== cp.tier) { @@ -383,6 +403,91 @@ async function restoreCheckpoint(name) { saveSoon(); } +function renameCheckpoint(oldName) { + const tab = activeTab(); + if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return; + const raw = prompt(`rename '${oldName}' to:`, oldName); + if (raw === null) return; + const newName = safePortalName(raw); + if (!newName || newName === oldName) return; + if (tab.checkpoints[newName]) { + const old = tab.checkpoints[newName]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return; + } + tab.checkpoints[newName] = tab.checkpoints[oldName]; + delete tab.checkpoints[oldName]; + renderAll(); + saveSoon(); +} + +// Export a checkpoint to a downloadable .portal.json file. The blob +// itself is opaque JSON for c/python (portal_save's wire format) and a +// {tier, mode: "replay", inputs} object for asm. Either way the file +// holds everything importCheckpoint needs to reconstruct the entry; no +// vault-side metadata bleeds out. +function exportCheckpoint(name) { + const tab = activeTab(); + if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return; + const entry = tab.checkpoints[name]; + const payload = { __portal: 1, name, ...entry }; + const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(data); + const a = document.createElement("a"); + a.href = url; + a.download = `${name}.portal.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +// Import a checkpoint File (from ). Validates the +// shape, prompts on name collision, lands the entry in the active +// tab's checkpoints map. The imported entry lives on whatever tier it +// was saved under โ€” restoring it will switch the active tab to that +// tier automatically (restoreCheckpoint already does the swap). +async function importCheckpoint(file) { + const tab = activeTab(); + if (!tab) return; + let payload; + try { + const text = await file.text(); + payload = JSON.parse(text); + } catch (e) { + alert("import failed โ€” not valid JSON: " + e.message); + return; + } + if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) { + alert("import failed โ€” file isn't a portal snapshot (missing __portal/tier/savedAt)"); + return; + } + const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported"; + let name = baseName; + tab.checkpoints = tab.checkpoints || {}; + if (tab.checkpoints[name]) { + const old = tab.checkpoints[name]; + const when = new Date(old.savedAt).toLocaleString(); + if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) { + // Auto-suffix on decline so the user gets to keep both. + let i = 2; + while (tab.checkpoints[`${baseName}-${i}`]) i++; + name = `${baseName}-${i}`; + } + } + const entry = { tier: payload.tier, savedAt: payload.savedAt }; + if (payload.mode === "replay") { + entry.mode = "replay"; + entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : []; + } else { + entry.mode = "blob"; + entry.blob = payload.blob || ""; + } + tab.checkpoints[name] = entry; + renderAll(); + saveSoon(); +} + function deleteCheckpoint(name) { const tab = activeTab(); if (!tab || !tab.checkpoints) return; @@ -492,24 +597,63 @@ function renderAll() { transcriptEl.appendChild(block); } - // Portal chips โ€” one per saved checkpoint in the active tab. + // Portal chips โ€” when showGlobalCheckpoints is off, one per saved + // checkpoint in the active tab. When on, gather every checkpoint + // across every tab so the user can restore a snapshot saved on + // tab A into tab B. The label gains a "ยท tabName" suffix in + // global mode so it's clear where each chip came from. portalChipsEl.innerHTML = ""; - const checkpoints = tab.checkpoints || {}; - const names = Object.keys(checkpoints).sort(); - for (const name of names) { + portalScopeBtn.textContent = showGlobalCheckpoints ? "๐ŸŒ all tabs" : "๐Ÿ“‚ this tab"; + portalScopeBtn.title = showGlobalCheckpoints + ? "showing checkpoints from every tab โ€” click for this tab only" + : "showing only this tab's checkpoints โ€” click for every tab"; + const chipRows = []; + if (showGlobalCheckpoints) { + for (const t of state.tabs) { + const cps = t.checkpoints || {}; + for (const name of Object.keys(cps)) { + chipRows.push({ tab: t, name, cp: cps[name] }); + } + } + chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt); + } else { + const cps = tab.checkpoints || {}; + for (const name of Object.keys(cps).sort()) { + chipRows.push({ tab, name, cp: cps[name] }); + } + } + for (const { tab: cpTab, name, cp } of chipRows) { const chip = document.createElement("span"); chip.className = "chip"; - chip.title = `${name} ยท ${checkpoints[name].tier} ยท saved ${new Date(checkpoints[name].savedAt).toLocaleString()} โ€” click to restore`; + const when = new Date(cp.savedAt).toLocaleString(); + chip.title = showGlobalCheckpoints + ? `${name} ยท ${cp.tier} ยท saved ${when} on tab '${cpTab.name}' โ€” click to restore into THIS tab` + : `${name} ยท ${cp.tier} ยท saved ${when} โ€” click to restore, dbl-click to rename`; const label = document.createElement("span"); - label.textContent = name; - label.addEventListener("click", () => restoreCheckpoint(name)); - const close = document.createElement("span"); - close.className = "chip-close"; - close.textContent = "ร—"; - close.title = "delete checkpoint"; - close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); }); - chip.appendChild(label); - chip.appendChild(close); + label.textContent = showGlobalCheckpoints ? `${name} ยท ${cpTab.name}` : name; + // Restore always operates on the active tab; in global mode + // the snapshot's cp object is passed so we don't depend on + // the active tab having a checkpoints[name] match. + label.addEventListener("click", () => restoreCheckpoint(name, cp)); + if (!showGlobalCheckpoints) { + label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); }); + const exportBtn = document.createElement("span"); + exportBtn.className = "chip-export"; + exportBtn.textContent = "โ‡ฃ"; + exportBtn.title = "download checkpoint as .portal.json"; + exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); }); + const close = document.createElement("span"); + close.className = "chip-close"; + close.textContent = "ร—"; + close.title = "delete checkpoint"; + close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); }); + chip.appendChild(label); + chip.appendChild(exportBtn); + chip.appendChild(close); + } else { + chip.classList.add("chip-global"); + chip.appendChild(label); + } portalChipsEl.appendChild(chip); } @@ -766,6 +910,17 @@ clearLogBtn.addEventListener("click", () => { }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); portalSaveBtn.addEventListener("click", saveCheckpoint); +portalImportBtn.addEventListener("click", () => portalImportFileEl.click()); +portalImportFileEl.addEventListener("change", async (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + await importCheckpoint(file); + e.target.value = ""; +}); +portalScopeBtn.addEventListener("click", () => { + showGlobalCheckpoints = !showGlobalCheckpoints; + renderAll(); +}); lockBtn.addEventListener("click", relock); // Insert heap pressure indicator into the tabbar after the spacer. document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);