repl portal: overwrite-guard, rename, export/import, cross-tab restore
Three UX gaps on the portal-bar closed in one pass plus a defensive fix on the C tier's heap probe: * Overwrite guard — saving with an existing name asks 'overwrite?' with the existing entry's tier + savedAt. Rename via dbl-click on the chip label; same overwrite guard applies on rename. * Export / import — a ⇣ icon on each chip downloads it as <name>.portal.json (opaque blob for c/python, replay-inputs for asm). A 📁 import button on the portal-bar accepts a .portal.json file via hidden <input type="file">; collisions prompt overwrite, decline auto-suffixes (baseName-2, -3, …) so importing a 2nd copy always lands somewhere. * Cross-tab restore — a 📂 this tab / 🌐 all tabs toggle switches the chip strip between the active tab's checkpoints and every tab's. Global chips render as 'name · tabName' with a dashed border; click restores the snapshot into the active tab (the saved cp is passed through restoreCheckpoint's new sourceCp argument so the chip doesn't need a checkpoints[name] match on the active tab). Edit/delete are hidden in global mode — the user switches to the owning tab to manage chips. * heapStats defensive — wasm/c/lumbda-c.loader.js now returns null when module.HEAPU8 isn't live yet (caught by the heap poll firing during the tiny window between tier reboot and Module init), so a restore no longer surfaces 'Cannot read properties of undefined (reading byteLength)' as a TypeError. Smoke-tested headlessly: overwrite confirm fires with the expected message; rename via dblclick swaps the label; ⇣ produces a download named '<name>.portal.json'; toggle shows both tabs' chips with the '· tabName' annotation; import round-trips back into the receiving tab. Zero page errors across the full flow.
This commit is contained in:
parent
ac664325e1
commit
2b46c1b79e
13 changed files with 590 additions and 58 deletions
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@
|
|||
<section class="portal-bar" id="portal-bar">
|
||||
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
|
||||
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
|
||||
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
|
||||
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button>
|
||||
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
|
||||
<div id="portal-chips" class="portal-chips"></div>
|
||||
</section>
|
||||
<div id="transcript" class="transcript"></div>
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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 <input type="file">). 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);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@
|
|||
<section class="portal-bar" id="portal-bar">
|
||||
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
|
||||
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
|
||||
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
|
||||
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button>
|
||||
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
|
||||
<div id="portal-chips" class="portal-chips"></div>
|
||||
</section>
|
||||
<div id="transcript" class="transcript"></div>
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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 <input type="file">). 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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@
|
|||
<section class="portal-bar" id="portal-bar">
|
||||
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
|
||||
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
|
||||
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
|
||||
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button>
|
||||
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
|
||||
<div id="portal-chips" class="portal-chips"></div>
|
||||
</section>
|
||||
<div id="transcript" class="transcript"></div>
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────── */
|
||||
|
|
|
|||
187
www/repl/repl.js
187
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 <input type="file">). 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue