pi-studio 0.9.63 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/BUFFER-DESIGN.md +197 -0
  2. package/CHANGELOG.md +31 -0
  3. package/PRERELEASE-CHECKLIST.md +100 -0
  4. package/README.md +110 -193
  5. package/ROADMAP.md +63 -6
  6. package/WORKFLOW.md +32 -2
  7. package/assets/screenshots/dark-workspace.png +0 -0
  8. package/assets/screenshots/light-workspace.png +0 -0
  9. package/assets/screenshots/theme-zen-code.png +0 -0
  10. package/client/studio-client.js +6694 -942
  11. package/client/studio-editor-draft-helpers.js +162 -0
  12. package/client/studio-navigation-helpers.js +31 -5
  13. package/client/studio.css +460 -12
  14. package/index.ts +1178 -79
  15. package/package.json +5 -3
  16. package/shared/STUDIO_BUFFER_FOUNDATION.md +164 -0
  17. package/shared/studio-buffer-client.js +663 -0
  18. package/shared/studio-buffer-decisions.js +129 -0
  19. package/shared/studio-buffer-lineage.js +48 -0
  20. package/shared/studio-buffer-recovery-panel.js +132 -0
  21. package/shared/studio-buffer-recovery.js +181 -0
  22. package/shared/studio-buffer-server.js +700 -0
  23. package/shared/studio-buffer-store.js +227 -0
  24. package/shared/studio-buffer-switching.js +53 -0
  25. package/shared/studio-disk-revisions.js +15 -0
  26. package/shared/studio-document-entrypoints.js +38 -0
  27. package/shared/studio-document-escrow.js +147 -0
  28. package/shared/studio-document-file-claims.js +215 -0
  29. package/shared/studio-document-host-client.js +268 -0
  30. package/shared/studio-document-hosting.js +319 -0
  31. package/shared/studio-document-launch-client.js +153 -0
  32. package/shared/studio-document-open-client.js +96 -0
  33. package/shared/studio-document-open-storage.js +21 -0
  34. package/shared/studio-document-paths.js +29 -0
  35. package/shared/studio-editor-mode.js +48 -0
  36. package/shared/studio-linked-reader-ui.js +232 -0
  37. package/shared/studio-linked-reader.js +110 -0
  38. package/shared/studio-metadata-write-order.js +35 -0
  39. package/shared/studio-read-only-media.js +24 -0
  40. package/shared/studio-resource-grants.js +3 -0
  41. package/shared/studio-run-lifecycle.js +47 -0
  42. package/shared/studio-system-theme.js +9 -0
  43. package/assets/screenshots/dark-code-mode.png +0 -0
  44. package/assets/screenshots/dark-critique.png +0 -0
  45. package/assets/screenshots/dark-fenced-code.png +0 -0
  46. package/assets/screenshots/dark-focus-mode.png +0 -0
  47. package/assets/screenshots/dark-julia-fenced.png +0 -0
  48. package/assets/screenshots/dark-math.png +0 -0
  49. package/assets/screenshots/dark-mermaid.png +0 -0
  50. package/assets/screenshots/dark-zen.png +0 -0
  51. package/assets/screenshots/light-code-mode.png +0 -0
  52. package/assets/screenshots/light-fenced-code.png +0 -0
  53. package/assets/screenshots/light-math.png +0 -0
@@ -0,0 +1,129 @@
1
+ import { createStudioBuffer, validateStudioBufferWorkspace } from "./studio-buffer-store.js";
2
+ import { STUDIO_BUFFER_RECOVERY_PREFIX, STUDIO_BUFFER_LEGACY_TAB_PREFIX, STUDIO_BUFFER_RECOVERY_MAX_SERIALIZED_CHARS,
3
+ decodeStudioBufferRecovery, migrateStudioWorkspaceV1, createStudioBufferRecoveryStorage } from "./studio-buffer-recovery.js";
4
+ import { isStudioSingleEditorBufferState, projectStudioBufferEditor } from "./studio-buffer-client.js";
5
+ import { isStudioPromptDocumentBufferState } from "./studio-buffer-switching.js";
6
+
7
+ const fail = (reason, message) => ({ ok: false, reason, message });
8
+
9
+ // Explicit recovery is a FORK into a fresh namespace, never an overwrite/repair
10
+ // of the originals. Paths remain claims; compatibility and mode gates still apply.
11
+ export function createStudioBufferRecoveryDecisions(options) {
12
+ const { storage, workspaceId, mode, readRemote, canRestore, makeWorkspaceId } = options;
13
+ const expected = { workspaceId, mode };
14
+ const localKeys = [
15
+ ["browser-v2", "Browser buffers", STUDIO_BUFFER_RECOVERY_PREFIX + workspaceId, "v2"],
16
+ ["browser-legacy", "Browser single-editor copy", STUDIO_BUFFER_LEGACY_TAB_PREFIX + workspaceId, "legacy"],
17
+ ["browser-unscoped", "Older browser copy (no tab identity)", "piStudio.workspaceState.v1", "legacy"],
18
+ ];
19
+ let records = [], errors = [], handles = new Map(), epoch = 0, disposed = false;
20
+ function localRaw(key) {
21
+ try { const raw = storage.getItem(key); if (raw !== null && typeof raw !== "string") throw new Error(); return { ok: true, raw }; }
22
+ catch { return fail("unavailable", "Browser recovery could not be read. Nothing was treated as empty."); }
23
+ }
24
+ async function remoteRaw() {
25
+ try {
26
+ const result = await readRemote();
27
+ if (!result?.ok) return result || fail("unavailable", "Server copies could not be read.");
28
+ if (result.workspaceId !== workspaceId || result.mode !== mode || !Array.isArray(result.records) || result.records.length !== 2
29
+ || !["v2", "legacy"].every(kind => result.records.filter(r => r.kind === kind && (r.raw === null || typeof r.raw === "string")).length === 1)) {
30
+ return fail("invalid-state", "Server inspection did not match this workspace. It was not used.");
31
+ }
32
+ return result;
33
+ } catch { return fail("unavailable", "Server copies could not be read. Browser copies and live text were kept."); }
34
+ }
35
+ function validate(raw, kind) {
36
+ if (raw.length > STUDIO_BUFFER_RECOVERY_MAX_SERIALIZED_CHARS) return fail("limit-exceeded", "Too large for this build; download the original archive.");
37
+ if (kind === "v2") return decodeStudioBufferRecovery(raw, expected);
38
+ let value; try { value = JSON.parse(raw); } catch { return fail("invalid-json", "Malformed recovery; download the original archive."); }
39
+ let id = 0;
40
+ return migrateStudioWorkspaceV1(value, { ...expected, makeBufferId: () => "recovered-" + ++id });
41
+ }
42
+ function compatible(state) {
43
+ if (!(options.switching === true && mode === "full" ? isStudioPromptDocumentBufferState(state) : isStudioSingleEditorBufferState(state))) return fail("unsupported-collection", "This copy contains other buffers. Export them or use a build that can display the whole collection.");
44
+ if (!canRestore(projectStudioBufferEditor(state))) return fail("wrong-document", "This copy belongs to another source. Export its text; it cannot replace this document.");
45
+ return { ok: true };
46
+ }
47
+ function add(id, label, raw, kind, key = null) {
48
+ if (raw === null) return;
49
+ const result = validate(raw, kind);
50
+ const compatibility = result.ok ? compatible(result.state) : result;
51
+ const record = Object.freeze({ id, label, raw, state: result.ok ? result.state : null,
52
+ canUse: compatibility.ok, message: compatibility.ok ? "Compatible copy" : compatibility.message });
53
+ records.push(record); handles.set(record, { kind, key });
54
+ }
55
+ function seed(state, isStillCurrent) {
56
+ if (disposed || !isStillCurrent()) return fail("editor-changed", "Editor changed while recovery was open. Current text and original copies were kept.");
57
+ const compatibleResult = compatible(state); if (!compatibleResult.ok) return compatibleResult;
58
+ const nextId = makeWorkspaceId();
59
+ if (nextId === workspaceId) return fail("collision", "A fresh workspace identity is required.");
60
+ try {
61
+ const local = createStudioBufferRecoveryStorage({ storage, workspaceId: nextId, mode });
62
+ if (storage.getItem(local.key + ":serverAck") !== null) return fail("collision", "That workspace identity already has recovery metadata.");
63
+ const checked = validateStudioBufferWorkspace({ ...state, workspaceId: nextId }, { workspaceId: nextId, mode });
64
+ if (!checked.ok) return checked;
65
+ const written = local.write(checked.state, { expectedRaw: null });
66
+ if (!written.ok) return written;
67
+ // A re-entrant storage implementation cannot turn old consent into authority.
68
+ // Leave a successfully prepared copy intact if the final guard fails.
69
+ if (disposed || !isStillCurrent()) return fail("editor-changed", "Editor changed; the prepared copy was retained but navigation was cancelled.");
70
+ return { ok: true, workspaceId: nextId, state: written.state };
71
+ } catch { return fail("unavailable", "A fresh recovery copy could not be prepared. Original data and current text were kept."); }
72
+ }
73
+ return Object.freeze({
74
+ async inspect() {
75
+ if (disposed) return fail("cancelled", "This inspection has closed.");
76
+ const operation = ++epoch;
77
+ const remote = await remoteRaw();
78
+ if (disposed || operation !== epoch) return fail("cancelled", "Recovery inspection closed or was superseded.");
79
+ records = []; errors = []; handles = new Map();
80
+ for (const [id, label, key, kind] of localKeys) {
81
+ const result = localRaw(key);
82
+ if (result.ok) add(id, label, result.raw, kind, key); else errors.push(label + ": " + result.message);
83
+ }
84
+ if (remote.ok) for (const { kind, raw } of remote.records) add("server-" + kind, kind === "v2" ? "Server buffers" : "Server single-editor copy", raw, kind);
85
+ else errors.push(remote.message || "Server copies could not be read.");
86
+ return { ok: true, records: Object.freeze([...records]), errors: Object.freeze([...errors]) };
87
+ },
88
+ archive(currentText) {
89
+ // JSON-stringified raw strings preserve even malformed/future JSON and lone
90
+ // UTF-16 surrogates. No token, capability, server CAS revision or ack sidecar.
91
+ return JSON.stringify({ format: "pi-studio-recovery-export", version: 1, workspaceId, mode,
92
+ currentText, errors, copies: records.map(({ id, label, raw }) => ({ id, label, raw })) }, null, 2);
93
+ },
94
+ async use(record, isStillCurrent = () => true) {
95
+ const target = handles.get(record), operation = epoch;
96
+ if (disposed || !target || !record.state) return fail("invalid-choice", "Inspect and select a compatible recovery copy first.");
97
+ let current;
98
+ if (target.key) current = localRaw(target.key);
99
+ else {
100
+ const result = await remoteRaw();
101
+ current = result.ok ? { ok: true, raw: result.records.find(r => r.kind === target.kind).raw } : result;
102
+ }
103
+ if (disposed || operation !== epoch || !handles.has(record)) return fail("cancelled", "Recovery choice was superseded.");
104
+ if (!current.ok) return current;
105
+ if (current.raw !== record.raw) return fail("storage-changed", "That copy changed while recovery was open. Recheck the copies and choose again.");
106
+ return seed(record.state, isStillCurrent);
107
+ },
108
+ keepCurrent(editor, baselineText, extra = {}, isStillCurrent = () => true, liveState = null) {
109
+ let state = liveState;
110
+ if (!state) {
111
+ let id = 0;
112
+ const migrated = migrateStudioWorkspaceV1(editor, { ...expected, makeBufferId: () => "current-" + ++id });
113
+ if (!migrated.ok) return migrated;
114
+ state = migrated.state;
115
+ try {
116
+ state = { ...state, buffers: state.buffers.map(b => b.id !== state.selectedBufferId ? b : createStudioBuffer({ ...b,
117
+ baselineText: editor.sourceState.path ? baselineText : b.baselineText,
118
+ view: { ...b.view, ...extra.view }, metadata: { ...b.metadata, ...extra.metadata } })) };
119
+ } catch { return fail("invalid-state", "Current editor cannot fit in recovery. Export its full text first."); }
120
+ }
121
+ const checked = validateStudioBufferWorkspace(state, expected);
122
+ if (!checked.ok) return checked;
123
+ const projected = projectStudioBufferEditor(checked.state);
124
+ if (["text", "sourceState", "diskRevision", "resourceDir"].some(key => JSON.stringify(projected[key]) !== JSON.stringify(editor[key]))) return fail("editor-changed", "Current editor no longer matches the captured buffer. Try again.");
125
+ return seed(checked.state, isStillCurrent);
126
+ },
127
+ dispose() { disposed = true; epoch++; handles.clear(); },
128
+ });
129
+ }
@@ -0,0 +1,48 @@
1
+ // Browser-safe verification of server-issued, capability-scoped recovery lineage.
2
+ // A timestamp or matching text alone is never an ancestry/ownership proof.
3
+ import { validateStudioBufferWorkspace } from "./studio-buffer-store.js";
4
+
5
+ export async function studioBufferDigest(value) {
6
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
7
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
8
+ return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
9
+ }
10
+ export async function reconcileStudioHostingLineage({ local, remote, revision, checkpoint, lineage, now = Date.now }) {
11
+ if (!checkpoint || !Array.isArray(lineage) || lineage.length > 16) return null;
12
+ const start = lineage.findIndex(r => r.fromRevision === checkpoint.remoteRevision);
13
+ if (start < 0) return null;
14
+ const chain = lineage.slice(start);
15
+ let head = checkpoint.remoteRevision;
16
+ for (const receipt of chain) {
17
+ if (receipt.fromRevision !== head || typeof receipt.toRevision !== "string") return null;
18
+ head = receipt.toRevision;
19
+ }
20
+ if (head !== revision || chain[0].fromWorkspaceRevision !== checkpoint.workspaceRevision
21
+ || await studioBufferDigest(remote) !== chain.at(-1).toDigest) return null;
22
+ if (await studioBufferDigest(local) === chain[0].fromDigest) return { state: remote, rebased: false };
23
+ if (local.revision <= checkpoint.workspaceRevision) return null;
24
+ // A local branch may contain newer Prompt work. Apply only a proven slot
25
+ // replacement; never replace an independently changed moving document.
26
+ let state = local;
27
+ for (const receipt of chain) {
28
+ if (receipt.kind === "cancel") continue;
29
+ if (!receipt.slot) return null;
30
+ const { beforeId, beforeDigest, afterId } = receipt.slot;
31
+ const old = state.buffers.find(b => b.id === beforeId), next = remote.buffers.find(b => b.id === afterId);
32
+ if (!old || !next || await studioBufferDigest(old) !== beforeDigest
33
+ || state.activePromptId !== receipt.beforePromptId
34
+ || JSON.stringify(state.order) !== JSON.stringify(receipt.beforeOrder)
35
+ || state.selectedBufferId !== receipt.beforeSelected) return null;
36
+ for (const [id, digest] of Object.entries(receipt.unchanged || {})) {
37
+ const entry = remote.buffers.find(b => b.id === id);
38
+ if (!entry || await studioBufferDigest(entry) !== digest || !state.buffers.some(b => b.id === id)) return null;
39
+ }
40
+ if (state.buffers.length !== Object.keys(receipt.unchanged || {}).length + 1) return null;
41
+ state = { ...state, buffers: state.buffers.map(b => b.id === beforeId ? next : b),
42
+ order: state.order.map(id => id === beforeId ? afterId : id),
43
+ selectedBufferId: state.selectedBufferId === beforeId ? afterId : state.selectedBufferId };
44
+ }
45
+ const checked = validateStudioBufferWorkspace({ ...state, revision: Math.max(local.revision, remote.revision) + 1,
46
+ savedAt: Math.max(local.savedAt, remote.savedAt, now()) + 1 }, remote);
47
+ return checked.ok ? { state: checked.state, rebased: true } : null;
48
+ }
@@ -0,0 +1,132 @@
1
+ // Browser-only, plain-text recovery UI. Stored text is never rendered as HTML.
2
+ export function createStudioBufferRecoveryPanel(options) {
3
+ const { document } = options;
4
+ const element = (tag, text, parent) => { const el = document.createElement(tag); if (text) el.textContent = text; parent?.appendChild(el); return el; };
5
+ const dialog = element("dialog"); dialog.className = "studio-buffer-recovery-dialog";
6
+ dialog.dataset.bufferRecovery = "true"; dialog.setAttribute("aria-labelledby", "bufferRecoveryTitle");
7
+ element("h2", "Recover unsaved text", dialog).id = "bufferRecoveryTitle";
8
+ element("p", "Find an earlier copy if unsaved edits are missing. Looking here won’t change your editor.", dialog);
9
+ element("p", "These copies are temporary. Save files normally; recovery is not a backup.", dialog);
10
+ const message = element("p", "", dialog); message.setAttribute("role", "status"); message.dataset.recoveryMessage = "true";
11
+ const choicesLabel = element("label", "Stored copy ", dialog);
12
+ const choices = element("select", "", choicesLabel); choices.dataset.recoveryCopies = "true";
13
+ const detail = element("p", "", dialog);
14
+ const bufferLabel = element("label", "Text to preview/export ", dialog);
15
+ const buffers = element("select", "", bufferLabel); buffers.dataset.recoveryBuffers = "true";
16
+ const preview = element("textarea", "", dialog); preview.readOnly = true; preview.setAttribute("aria-label", "Recovery text preview"); preview.spellcheck = false;
17
+ const previewNote = element("p", "Preview limited to 20,000 characters; downloads include the full text.", dialog);
18
+ previewNote.className = "studio-buffer-recovery-note";
19
+ const actions = element("div", "", dialog); actions.className = "studio-buffer-recovery-actions";
20
+ let decisions = null, records = [], generation = 0, busy = false, removed = false, resolveConfirmation = null;
21
+ const confirmation = element("section", "", dialog); confirmation.hidden = true; confirmation.dataset.recoveryConfirmation = "true";
22
+ const confirmationTitle = element("h3", "", confirmation);
23
+ const confirmationText = element("p", "", confirmation);
24
+ const cancelConfirm = element("button", "Cancel — keep current text", confirmation); cancelConfirm.type = "button"; cancelConfirm.dataset.recoveryConfirm = "cancel";
25
+ const acceptConfirm = element("button", "Continue", confirmation); acceptConfirm.type = "button"; acceptConfirm.dataset.recoveryConfirm = "accept";
26
+ function finishConfirmation(value) { confirmation.hidden = true; const resolve = resolveConfirmation; resolveConfirmation = null; resolve?.(value); }
27
+ cancelConfirm.addEventListener("click", () => finishConfirmation(false)); acceptConfirm.addEventListener("click", () => finishConfirmation(true));
28
+ function confirm(text) { confirmationText.textContent = text; confirmation.hidden = false; cancelConfirm.focus(); return new Promise(resolve => { resolveConfirmation = resolve; }); }
29
+ const buttons = [];
30
+ function button(label, action, id) {
31
+ const btn = element("button", label, actions); btn.type = "button"; btn.dataset.recoveryAction = id; buttons.push(btn);
32
+ btn.addEventListener("click", async () => {
33
+ if (busy) return;
34
+ const operation = generation;
35
+ try { await action(); } catch (error) { if (dialog.open && operation === generation) message.textContent = error.message || "Recovery action failed. Current text was kept."; }
36
+ }); return btn;
37
+ }
38
+ const selected = () => records[Number(choices.value)] || null;
39
+ const selectedText = () => selected()?.state?.buffers[Number(buffers.value)]?.text ?? null;
40
+ function controls() {
41
+ for (const btn of buttons) btn.disabled = busy;
42
+ use.disabled = busy || !selected()?.canUse;
43
+ copy.disabled = downloadText.disabled = busy || selectedText() === null;
44
+ retry.disabled = busy || !options.canRetry();
45
+ choices.disabled = buffers.disabled = busy;
46
+ }
47
+ function showText() { preview.value = (selectedText() ?? selected()?.raw ?? "").slice(0, 20000); controls(); }
48
+ function showChoice() {
49
+ const record = selected(); buffers.replaceChildren();
50
+ for (const [i, b] of (record?.state?.buffers || []).entries()) {
51
+ const option = element("option", (b.sourceState.path || b.sourceState.label || b.role) + " — " + b.text.length + " characters", buffers);
52
+ option.value = String(i); option.selected = b.id === record.state.selectedBufferId;
53
+ }
54
+ bufferLabel.hidden = !record?.state;
55
+ const date = record?.state ? new Date(record.state.savedAt) : null;
56
+ const recorded = record?.state?.savedAt > 0 && Number.isFinite(date?.getTime()) ? "Recorded: " + date.toLocaleString() : "Time not recorded";
57
+ detail.textContent = record ? record.message + (record.state ? " · " + recorded : " · Raw data; download the archive to keep it.") : "No stored copies were found.";
58
+ showText();
59
+ }
60
+ async function recheck() {
61
+ busy = true; controls(); message.textContent = "Reading recovery copies…";
62
+ const operation = generation, owner = decisions;
63
+ const result = await owner.inspect();
64
+ if (!dialog.open || operation !== generation) return;
65
+ busy = false;
66
+ if (!result.ok) { message.textContent = result.message; controls(); return; }
67
+ records = result.records; choices.replaceChildren();
68
+ for (const [i, record] of records.entries()) { const option = element("option", record.label, choices); option.value = String(i); }
69
+ message.textContent = result.errors.join(" ") || "Your editor is unchanged.";
70
+ showChoice();
71
+ }
72
+ async function choose(current) {
73
+ if (!options.canChange()) { message.textContent = "Studio is busy. You can still copy or download text."; return; }
74
+ const consent = options.captureConsent(), operation = generation, record = selected();
75
+ busy = true; controls();
76
+ confirmationTitle.textContent = current ? "Keep current text?" : "Use this copy?";
77
+ const previewOnly = record?.state?.buffers[Number(buffers.value)]?.id !== record?.state?.selectedBufferId;
78
+ const selectionNote = previewOnly ? "This restores the stored editor, not the text chosen for preview/export. " : "";
79
+ const confirmed = await confirm(current
80
+ ? "Restart recovery with your current editor text? Older copies are kept. No file is saved and nothing is submitted."
81
+ : "Replace your editor with this stored copy? " + selectionNote + "Copy or download current edits first. Older copies are kept; no file is saved and nothing is submitted.");
82
+ const isCurrent = () => dialog.open && operation === generation && options.consentIsCurrent(consent) && options.canChange();
83
+ if (!confirmed || !isCurrent()) {
84
+ if (dialog.open && operation === generation) { busy = false; message.textContent = confirmed ? "Editor or connection changed. Kept current text; try again." : "Kept the current editor and recovery copies."; controls(); }
85
+ return;
86
+ }
87
+ const result = current ? options.keepCurrent(decisions, isCurrent) : await decisions.use(record, isCurrent);
88
+ if (!dialog.open || operation !== generation) return;
89
+ busy = false;
90
+ if (!result.ok || !isCurrent()) { message.textContent = result.message || "Editor changed. Navigation cancelled; copies were retained."; controls(); return; }
91
+ options.navigate(result, consent);
92
+ }
93
+ button("Recheck copies", recheck, "inspect");
94
+ const retry = button("Retry recovery", async () => {
95
+ if (!options.canChange()) { message.textContent = "Studio is busy. Wait before retrying recovery."; return; }
96
+ const operation = generation, consent = options.captureConsent(); busy = true; controls();
97
+ const result = await options.retry(() => dialog.open && operation === generation && options.consentIsCurrent(consent));
98
+ if (!dialog.open || operation !== generation) return;
99
+ busy = false; message.textContent = result.ok ? "Current text has a temporary recovery copy on the server. Your file has not been saved." : result.message; controls();
100
+ }, "retry");
101
+ button("Download recovery archive", () => options.download(decisions.archive(options.currentText()), "studio-recovery-archive.json", "application/json"), "archive");
102
+ button("Download current text", () => options.download(options.currentText(), "studio-current-editor.txt", "text/plain"), "current-text");
103
+ const copy = button("Copy selected text", async () => { const operation = generation; const ok = await options.copy(selectedText()); if (dialog.open && operation === generation) message.textContent = ok ? "Selected text copied." : "Clipboard unavailable. Download the text instead."; }, "copy-text");
104
+ const downloadText = button("Download selected text", () => options.download(selectedText(), "studio-recovered-text.txt", "text/plain"), "selected-text");
105
+ const use = button("Use selected copy…", () => choose(false), "use");
106
+ button("Keep current text…", () => choose(true), "keep-current");
107
+ const close = element("button", "Close — keep editing", dialog); close.type = "button"; close.dataset.recoveryAction = "close";
108
+ close.addEventListener("click", () => dialog.close());
109
+ const about = element("details", "", dialog); about.className = "studio-buffer-recovery-note";
110
+ element("summary", "About these copies", about);
111
+ element("p", "Copies live in browser storage and the running Studio process; they may disappear when either closes. Recorded times come from the stored data, not an independent check of which copy is newest.", about);
112
+ element("p", "Downloads may contain private text and file paths. Rechecking replaces this list; download an archive first if you want to keep it.", about);
113
+ element("p", "Copies from another source or an unsupported format can be exported, but not restored here. Using a copy restarts recovery separately and leaves the originals untouched.", about);
114
+ choices.addEventListener("change", showChoice); buffers.addEventListener("change", showText);
115
+ dialog.addEventListener("keydown", event => event.stopPropagation());
116
+ dialog.addEventListener("close", () => {
117
+ // Native close events are queued. A close/reopen in one task must not dispose
118
+ // the new inspection or resolve its confirmation using the old event.
119
+ if (dialog.open) return;
120
+ generation++; finishConfirmation(false); decisions?.dispose(); decisions = null; records = []; busy = false; options.onClose?.();
121
+ });
122
+ document.body.appendChild(dialog);
123
+ return Object.freeze({
124
+ async open() {
125
+ if (removed || dialog.open) return;
126
+ generation++; finishConfirmation(false); decisions?.dispose(); decisions = options.createDecisions(); records = []; choices.replaceChildren(); buffers.replaceChildren(); preview.value = "";
127
+ dialog.showModal(); await recheck();
128
+ },
129
+ isOpen: () => dialog.open,
130
+ dispose() { removed = true; generation++; decisions?.dispose(); if (dialog.open) dialog.close(); dialog.remove(); },
131
+ });
132
+ }
@@ -0,0 +1,181 @@
1
+ import {
2
+ StudioBufferStateError,
3
+ createStudioBuffer,
4
+ validateStudioBufferWorkspace,
5
+ } from "./studio-buffer-store.js";
6
+
7
+ // The old key contains "v2" but its payload schema is v1. Never reuse that namespace.
8
+ export const STUDIO_BUFFER_RECOVERY_PREFIX = "piStudio.bufferWorkspace.v2:";
9
+ export const STUDIO_BUFFER_LEGACY_TAB_PREFIX = "piStudio.workspaceState.v2:";
10
+ export const STUDIO_BUFFER_RECOVERY_MAX_SERIALIZED_CHARS = 20_000_000;
11
+ const LEGACY_KEYS = ["version", "savedAt", "sourceState", "diskRevision", "resourceDir", "editorView", "rightView", "editorLanguage", "followLatest", "responseHistoryIndex", "selectionStart", "selectionEnd", "scrollTop", "text"];
12
+ const fail = (reason, message) => ({ ok: false, reason, message });
13
+
14
+ function parse(raw) {
15
+ if (typeof raw !== "string") return fail("invalid-state", "Recovery must be serialized text.");
16
+ if (raw.length > STUDIO_BUFFER_RECOVERY_MAX_SERIALIZED_CHARS) return fail("limit-exceeded", "Recovery exceeds the serialized size limit; preserve the original data.");
17
+ try { return { ok: true, value: JSON.parse(raw) }; }
18
+ catch { return fail("invalid-json", "Recovery could not be parsed; preserve the original data."); }
19
+ }
20
+ export function decodeStudioBufferRecovery(raw, expected) {
21
+ const parsed = parse(raw);
22
+ return parsed.ok ? validateStudioBufferWorkspace(parsed.value, expected) : parsed;
23
+ }
24
+ export function encodeStudioBufferRecovery(state, expected) {
25
+ const checked = validateStudioBufferWorkspace(state, expected);
26
+ if (!checked.ok) return checked;
27
+ const raw = JSON.stringify(checked.state);
28
+ if (raw.length > STUDIO_BUFFER_RECOVERY_MAX_SERIALIZED_CHARS) return fail("limit-exceeded", "Recovery exceeds the serialized size limit; nothing was replaced.");
29
+ return { ...checked, raw };
30
+ }
31
+
32
+ // No disk reads: v1 did not persist a baseline, so a recovered file is conservatively dirty.
33
+ // mode/workspaceId come from the launch owner, not a field in the legacy payload.
34
+ export function migrateStudioWorkspaceV1(value, { workspaceId, mode, makeBufferId } = {}) {
35
+ try {
36
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1) return fail("unsupported-version", "Only schema v1 can be migrated here.");
37
+ if (Object.keys(value).some((key) => !LEGACY_KEYS.includes(key))) return fail("unknown-field", "Legacy recovery has unsupported fields; retain the original payload.");
38
+ if (typeof value.text !== "string") return fail("invalid-state", "Legacy editor text is missing.");
39
+ if (!["full", "editor-only"].includes(mode)) return fail("wrong-workspace", "An editable launch mode is required for migration.");
40
+ if (typeof makeBufferId !== "function") return fail("invalid-state", "Migration requires a buffer ID allocator.");
41
+ const sourceState = value.sourceState === undefined ? {} : value.sourceState;
42
+ if (!sourceState || typeof sourceState !== "object" || Array.isArray(sourceState)) return fail("invalid-state", "Invalid legacy source identity.");
43
+ if (["recovery-omitted", "recovery-cleared"].includes(sourceState.source)) return fail("legacy-marker", "This is a recovery omission/reset marker, not a draft to migrate.");
44
+ const fileBacked = typeof sourceState.path === "string" && sourceState.path.length > 0;
45
+ const role = mode === "full" && !fileBacked ? "prompt" : "document";
46
+ const id = makeBufferId();
47
+ const diskRevision = typeof value.diskRevision === "string" ? value.diskRevision.trim().toLowerCase() : value.diskRevision ?? null;
48
+ const selection = (input, fallback) => {
49
+ if (input === undefined) return fallback;
50
+ if (!Number.isSafeInteger(input) || input < 0) throw new StudioBufferStateError("invalid-state", "Invalid legacy selection.");
51
+ return Math.min(input, value.text.length);
52
+ };
53
+ const start = selection(value.selectionStart, 0);
54
+ const end = Math.max(start, selection(value.selectionEnd, start));
55
+ const entry = createStudioBuffer({
56
+ id, role, text: value.text, baselineText: fileBacked ? null : "", diskRevision,
57
+ sourceState: { source: "blank", label: "", path: null, draftId: null, ...sourceState },
58
+ resourceDir: value.resourceDir === undefined ? "" : value.resourceDir,
59
+ view: {
60
+ editorView: value.editorView === undefined ? "markdown" : value.editorView,
61
+ rightView: value.rightView === undefined ? "editor-preview" : value.rightView,
62
+ editorLanguage: value.editorLanguage === undefined ? "markdown" : value.editorLanguage,
63
+ followLatest: value.followLatest === undefined ? false : value.followLatest,
64
+ responseHistoryIndex: value.responseHistoryIndex === undefined ? -1 : value.responseHistoryIndex,
65
+ selectionStart: start, selectionEnd: end, scrollTop: value.scrollTop === undefined ? 0 : value.scrollTop,
66
+ },
67
+ });
68
+ const buffers = [entry];
69
+ let activePromptId = role === "prompt" ? id : null;
70
+ if (mode === "full" && role === "document") {
71
+ const prompt = createStudioBuffer({ id: makeBufferId(), role: "prompt" });
72
+ buffers.unshift(prompt);
73
+ activePromptId = prompt.id;
74
+ }
75
+ return validateStudioBufferWorkspace({ version: 2, workspaceId, mode, revision: 0, savedAt: value.savedAt === undefined ? 0 : value.savedAt,
76
+ selectedBufferId: id, activePromptId, order: buffers.map((entry) => entry.id), buffers }, { workspaceId, mode });
77
+ } catch (error) {
78
+ return fail(error.reason || "invalid-state", error instanceof StudioBufferStateError ? error.message : "Migration failed; preserve the original legacy payload.");
79
+ }
80
+ }
81
+
82
+ // Synchronous, same-page sessionStorage adapter, NOT a cross-client lease or server CAS.
83
+ // No methods erase recovery. The caller must display errors and offer export/retry/discard
84
+ // before activating a recovered editor. The opt-in client/decision layer does this;
85
+ // the default production path still uses the separate v1 implementation.
86
+ export function createStudioBufferRecoveryStorage({ storage, workspaceId, mode }) {
87
+ if (typeof workspaceId !== "string" || !/^[a-zA-Z0-9_-]{20,128}$/.test(workspaceId) || !["full", "editor-only"].includes(mode)) {
88
+ throw new StudioBufferStateError("wrong-workspace", "An explicit editable workspace identity and mode are required.");
89
+ }
90
+ const expected = { workspaceId, mode };
91
+ const key = STUDIO_BUFFER_RECOVERY_PREFIX + workspaceId;
92
+ const legacyKey = STUDIO_BUFFER_LEGACY_TAB_PREFIX + workspaceId;
93
+ function readRaw(which) {
94
+ try {
95
+ if (!storage || typeof storage.getItem !== "function") throw new Error("unavailable");
96
+ const raw = storage.getItem(which);
97
+ if (raw !== null && typeof raw !== "string") throw new Error("invalid storage result");
98
+ return { ok: true, raw };
99
+ } catch { return fail("storage-unavailable", "Recovery storage could not be read; no replacement is safe."); }
100
+ }
101
+ function legacyConflict(state) {
102
+ const legacy = readRaw(legacyKey);
103
+ if (!legacy.ok || legacy.raw === null) return legacy.ok ? { ok: true } : legacy;
104
+ const parsed = parse(legacy.raw);
105
+ let id = 0;
106
+ const checked = parsed.ok ? migrateStudioWorkspaceV1(parsed.value, { ...expected, makeBufferId: () => "legacy-check-" + ++id }) : parsed;
107
+ if (!checked.ok) return fail("legacy-conflict", "Legacy recovery is malformed, unsupported or a reset/omission marker; preserve both copies for a recovery decision.");
108
+ if (checked.state.savedAt > state.savedAt) return fail("legacy-newer", "A newer single-editor recovery exists; resolve it before writing or restoring v2.");
109
+ return { ok: true };
110
+ }
111
+ function write(state, options = {}, migratingLegacyRaw) {
112
+ if (!("expectedRaw" in options) || (options.expectedRaw !== null && typeof options.expectedRaw !== "string")) return fail("expected-state-required", "Read recovery before writing it.");
113
+ const encoded = encodeStudioBufferRecovery(state, expected);
114
+ if (!encoded.ok) return encoded;
115
+ const current = readRaw(key);
116
+ if (!current.ok) return current;
117
+ if (current.raw !== options.expectedRaw) return fail("storage-changed", "Recovery changed since it was read; retain the current editor and retry explicitly.");
118
+ let conflictBaseline = encoded.state;
119
+ if (current.raw !== null) {
120
+ const previous = decodeStudioBufferRecovery(current.raw, expected);
121
+ if (!previous.ok) return previous; // Exact-match consent cannot authorize erasing unknown/future data.
122
+ if (encoded.raw !== current.raw && (state.revision <= previous.state.revision || state.savedAt < previous.state.savedAt)) return fail("stale-state", "Recovery update is not newer than the stored workspace.");
123
+ conflictBaseline = previous.state; // A new candidate timestamp must not hide newer v1 work.
124
+ } else {
125
+ const legacy = readRaw(legacyKey);
126
+ if (!legacy.ok) return legacy;
127
+ if (legacy.raw !== null && legacy.raw !== migratingLegacyRaw) return fail("migration-required", "Legacy recovery must be migrated explicitly before creating a v2 workspace.");
128
+ }
129
+ const conflict = legacyConflict(conflictBaseline);
130
+ if (!conflict.ok) return conflict;
131
+ try {
132
+ if (!storage || typeof storage.setItem !== "function") throw new Error("unavailable");
133
+ storage.setItem(key, encoded.raw);
134
+ } catch { return fail("storage-write-failed", "Recovery could not be stored. Existing recovery and live buffers must be kept."); }
135
+ const verified = readRaw(key);
136
+ if (!verified.ok || verified.raw !== encoded.raw) return fail("storage-unverified", "Recovery write could not be verified; retain the original draft and legacy data.");
137
+ const decoded = decodeStudioBufferRecovery(verified.raw, expected);
138
+ return decoded.ok ? { ...decoded, raw: verified.raw } : decoded;
139
+ }
140
+ return Object.freeze({
141
+ key, legacyKey, write: (state, options) => write(state, options),
142
+ // Adopt a validated server/v1 candidate only against the exact local legacy read.
143
+ // Legacy bytes remain untouched; write() still validates their schema/timestamp.
144
+ adopt(state, { expectedLegacyRaw } = {}) {
145
+ const legacy = readRaw(legacyKey);
146
+ if (!legacy.ok) return legacy;
147
+ if (legacy.raw !== expectedLegacyRaw) return fail("storage-changed", "Legacy recovery changed before adoption.");
148
+ return write(state, { expectedRaw: null }, legacy.raw);
149
+ },
150
+ read() {
151
+ const current = readRaw(key);
152
+ if (!current.ok) return current;
153
+ if (current.raw !== null) {
154
+ const decoded = decodeStudioBufferRecovery(current.raw, expected);
155
+ if (!decoded.ok) return { ...decoded, key };
156
+ const conflict = legacyConflict(decoded.state);
157
+ return conflict.ok ? { ...decoded, raw: current.raw, status: "found" } : conflict;
158
+ }
159
+ const legacy = readRaw(legacyKey);
160
+ if (!legacy.ok) return legacy;
161
+ return { ok: true, status: legacy.raw === null ? "empty" : "legacy", raw: null, legacyRaw: legacy.raw };
162
+ },
163
+ migrateLegacy({ expectedLegacyRaw, makeBufferId }) {
164
+ const current = readRaw(key);
165
+ if (!current.ok) return current;
166
+ if (current.raw !== null) return fail("already-present", "A v2 recovery already exists; migration will not replace it.");
167
+ const legacy = readRaw(legacyKey);
168
+ if (!legacy.ok) return legacy;
169
+ if (typeof expectedLegacyRaw !== "string" || legacy.raw !== expectedLegacyRaw) return fail("storage-changed", "Legacy recovery changed or is missing; read it again before migrating.");
170
+ const parsed = parse(legacy.raw);
171
+ if (!parsed.ok) return parsed;
172
+ const migrated = migrateStudioWorkspaceV1(parsed.value, { ...expected, makeBufferId });
173
+ if (!migrated.ok) return migrated;
174
+ // ID allocators are caller code. Recheck both source and destination after invoking one.
175
+ const latest = readRaw(legacyKey);
176
+ if (!latest.ok) return latest;
177
+ if (latest.raw !== legacy.raw) return fail("storage-changed", "Legacy recovery changed during migration; nothing was replaced.");
178
+ return write(migrated.state, { expectedRaw: null }, legacy.raw); // Retain the exact legacy bytes, even after success.
179
+ },
180
+ });
181
+ }