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.
- package/BUFFER-DESIGN.md +197 -0
- package/CHANGELOG.md +31 -0
- package/PRERELEASE-CHECKLIST.md +100 -0
- package/README.md +110 -193
- package/ROADMAP.md +63 -6
- package/WORKFLOW.md +32 -2
- package/assets/screenshots/dark-workspace.png +0 -0
- package/assets/screenshots/light-workspace.png +0 -0
- package/assets/screenshots/theme-zen-code.png +0 -0
- package/client/studio-client.js +6694 -942
- package/client/studio-editor-draft-helpers.js +162 -0
- package/client/studio-navigation-helpers.js +31 -5
- package/client/studio.css +460 -12
- package/index.ts +1178 -79
- package/package.json +5 -3
- package/shared/STUDIO_BUFFER_FOUNDATION.md +164 -0
- package/shared/studio-buffer-client.js +663 -0
- package/shared/studio-buffer-decisions.js +129 -0
- package/shared/studio-buffer-lineage.js +48 -0
- package/shared/studio-buffer-recovery-panel.js +132 -0
- package/shared/studio-buffer-recovery.js +181 -0
- package/shared/studio-buffer-server.js +700 -0
- package/shared/studio-buffer-store.js +227 -0
- package/shared/studio-buffer-switching.js +53 -0
- package/shared/studio-disk-revisions.js +15 -0
- package/shared/studio-document-entrypoints.js +38 -0
- package/shared/studio-document-escrow.js +147 -0
- package/shared/studio-document-file-claims.js +215 -0
- package/shared/studio-document-host-client.js +268 -0
- package/shared/studio-document-hosting.js +319 -0
- package/shared/studio-document-launch-client.js +153 -0
- package/shared/studio-document-open-client.js +96 -0
- package/shared/studio-document-open-storage.js +21 -0
- package/shared/studio-document-paths.js +29 -0
- package/shared/studio-editor-mode.js +48 -0
- package/shared/studio-linked-reader-ui.js +232 -0
- package/shared/studio-linked-reader.js +110 -0
- package/shared/studio-metadata-write-order.js +35 -0
- package/shared/studio-read-only-media.js +24 -0
- package/shared/studio-resource-grants.js +3 -0
- package/shared/studio-run-lifecycle.js +47 -0
- package/shared/studio-system-theme.js +9 -0
- package/assets/screenshots/dark-code-mode.png +0 -0
- package/assets/screenshots/dark-critique.png +0 -0
- package/assets/screenshots/dark-fenced-code.png +0 -0
- package/assets/screenshots/dark-focus-mode.png +0 -0
- package/assets/screenshots/dark-julia-fenced.png +0 -0
- package/assets/screenshots/dark-math.png +0 -0
- package/assets/screenshots/dark-mermaid.png +0 -0
- package/assets/screenshots/dark-zen.png +0 -0
- package/assets/screenshots/light-code-mode.png +0 -0
- package/assets/screenshots/light-fenced-code.png +0 -0
- package/assets/screenshots/light-math.png +0 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { studioDocumentMetadataKey } from "./studio-document-hosting.js";
|
|
4
|
+
const metadataKeys = buffer => ({ scratchpadKey: studioDocumentMetadataKey(buffer, "scratchpadKey"), reviewNotesKey: studioDocumentMetadataKey(buffer, "reviewNotesKey") });
|
|
5
|
+
|
|
6
|
+
const fail = (reason, message) => ({ ok: false, reason, message });
|
|
7
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
8
|
+
const hash = text => "sha256:" + createHash("sha256").update(text).digest("hex");
|
|
9
|
+
|
|
10
|
+
// Successful filesystem writes cannot be rolled back with a recovery transaction.
|
|
11
|
+
// Keep a bounded claim until the owning document acknowledges the saved backing,
|
|
12
|
+
// or its user explicitly keeps a different backing. This grants no filesystem access.
|
|
13
|
+
export function createStudioDocumentFileClaims(options) {
|
|
14
|
+
const records = new Map();
|
|
15
|
+
const now = options.now ?? Date.now;
|
|
16
|
+
const maxRecords = options.maxRecords ?? 128;
|
|
17
|
+
const maxPendingChars = options.maxPendingChars ?? 3_000_000;
|
|
18
|
+
const ttlMs = options.ttlMs ?? 600_000;
|
|
19
|
+
const key = (workspaceId, operationId) => workspaceId + ":" + operationId;
|
|
20
|
+
const pending = () => [...records.values()].filter(r => r.status === "pending");
|
|
21
|
+
const summary = r => ({ operationId: r.operationId, bufferId: r.bufferId, status: r.status,
|
|
22
|
+
path: r.path, label: basename(r.path), resourceDir: dirname(r.path), diskRevision: r.diskRevision, savedText: r.content,
|
|
23
|
+
beforeSourceState: r.beforeSourceState, beforeResourceDir: r.beforeResourceDir,
|
|
24
|
+
beforeDocumentEpoch: r.beforeDocumentEpoch, beforeRevision: r.beforeRevision,
|
|
25
|
+
metadataTransfer: r.metadataTransfer ?? null });
|
|
26
|
+
function finish(record, status) {
|
|
27
|
+
record.status = status; record.finishedAt = now();
|
|
28
|
+
// Terminal retry receipts retain hashes/results, not old document bodies.
|
|
29
|
+
delete record.content; delete record.beforeBaselineText; delete record.beforeSourceState; delete record.beforeResourceDir;
|
|
30
|
+
delete record.beforeMetadataKeys; delete record.metadataTransfer;
|
|
31
|
+
record.chars = 0;
|
|
32
|
+
}
|
|
33
|
+
function sweep() {
|
|
34
|
+
// Every completed save rotates document authority, even for identical bytes.
|
|
35
|
+
// Original requests therefore stay fenced after their terminal receipts expire.
|
|
36
|
+
for (const [id, record] of records) if (record.status !== "pending" && now() - record.finishedAt > ttlMs) records.delete(id);
|
|
37
|
+
}
|
|
38
|
+
function heldFile(path) {
|
|
39
|
+
const canonical = options.canonicalPath(path);
|
|
40
|
+
return pending().find(r => r.fileKey === canonical || options.sameFile?.(r.fileKey, canonical));
|
|
41
|
+
}
|
|
42
|
+
function targetCheck(workspaceId, bufferId, path, inspectOwner = true) {
|
|
43
|
+
const owner = inspectOwner ? options.findFile(path) : null, held = heldFile(path);
|
|
44
|
+
if ((owner && (owner.workspaceId !== workspaceId || owner.bufferId !== bufferId))
|
|
45
|
+
|| (held && (held.workspaceId !== workspaceId || held.bufferId !== bufferId))) {
|
|
46
|
+
return { ...fail("already-open", "This file belongs to another Document or an unacknowledged save. Use that editor or choose another path."),
|
|
47
|
+
owner: owner || { workspaceId: held.workspaceId, bufferId: held.bufferId } };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}
|
|
51
|
+
function metadataPreview(state, record) {
|
|
52
|
+
const destinationKey = "file:" + record.path, keys = record.beforeMetadataKeys;
|
|
53
|
+
const source = { scratchpadText: state.scratchpadsByDocument[keys.scratchpadKey] || "", reviewNotes: state.reviewNotesByDocument[keys.reviewNotesKey] || [] };
|
|
54
|
+
const destination = { scratchpadText: state.scratchpadsByDocument[destinationKey] || "", reviewNotes: state.reviewNotesByDocument[destinationKey] || [] };
|
|
55
|
+
const conflict = Boolean((destination.scratchpadText && destination.scratchpadText !== source.scratchpadText)
|
|
56
|
+
|| (destination.reviewNotes.length && !same(destination.reviewNotes, source.reviewNotes)));
|
|
57
|
+
return { ok: true, destinationKey, source, conflict,
|
|
58
|
+
metadataRevision: hash(JSON.stringify({ keys, source, destination, labels: [state.scratchpadMetadataByDocument[keys.scratchpadKey], state.scratchpadMetadataByDocument[destinationKey]] })) };
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
ownsMetadataKey(kind, documentKey) {
|
|
62
|
+
const field = kind === "scratchpad" ? "scratchpadKey" : "reviewNotesKey";
|
|
63
|
+
return pending().some(r => r.beforeMetadataKeys[field] === documentKey || "file:" + r.path === documentKey);
|
|
64
|
+
},
|
|
65
|
+
async metadata(capability, request, transact) {
|
|
66
|
+
const check = () => {
|
|
67
|
+
const binding = options.bind(capability, request?.generation); if (!binding.ok) return binding;
|
|
68
|
+
const authority = options.authority(binding.workspaceId, request.bufferId, request.documentEpoch); if (!authority.ok) return authority;
|
|
69
|
+
const record = records.get(key(binding.workspaceId, request.operationId));
|
|
70
|
+
if (!record || record.status !== "pending" || record.bufferId !== request.bufferId) return fail("save-not-found", "Recheck the retained successful save before transferring its metadata.");
|
|
71
|
+
return { ...binding, record };
|
|
72
|
+
};
|
|
73
|
+
const checked = check(); if (!checked.ok) return checked;
|
|
74
|
+
const record = checked.record;
|
|
75
|
+
if (record.metadataTransfer) return !request.choice || (request.choice === record.metadataTransfer.choice && request.metadataRevision === record.metadataTransfer.metadataRevision)
|
|
76
|
+
? { ok: true, prepared: true, ...record.metadataTransfer } : fail("invalid-request", "This retry changed the completed metadata decision. Recheck the retained outcome.");
|
|
77
|
+
if (record.metadataBusy) return fail("metadata-pending", "The saved file's metadata is still being checked. Recheck this same outcome.");
|
|
78
|
+
record.metadataBusy = true;
|
|
79
|
+
try {
|
|
80
|
+
return await transact(state => {
|
|
81
|
+
const current = check();
|
|
82
|
+
if (!current.ok) return { result: current, persist: false };
|
|
83
|
+
if (current.entry.revision !== request.expectedRevision) return { result: fail("conflict", "The Document changed while choosing its saved metadata."), persist: false };
|
|
84
|
+
const preview = metadataPreview(state, record);
|
|
85
|
+
if (!request.choice) return { result: preview, persist: false };
|
|
86
|
+
if (!["carry", "destination"].includes(request.choice)) return { result: fail("invalid-request", "Choose how to attach the saved file's metadata."), persist: false };
|
|
87
|
+
if (request.metadataRevision !== preview.metadataRevision) return { result: fail("metadata-changed", "Source or destination notes changed. Recheck them before choosing; neither copy was replaced."), persist: false };
|
|
88
|
+
if (request.choice === "carry" && preview.conflict) return { result: fail("metadata-conflict", "The destination has different notes. Explicitly use its notes or keep the current backing; neither copy was overwritten."), persist: false };
|
|
89
|
+
let persist = false;
|
|
90
|
+
if (request.choice === "carry") {
|
|
91
|
+
const destination = preview.destinationKey, source = preview.source;
|
|
92
|
+
if (source.scratchpadText && state.scratchpadsByDocument[destination] !== source.scratchpadText) {
|
|
93
|
+
state.scratchpadsByDocument[destination] = source.scratchpadText;
|
|
94
|
+
state.scratchpadMetadataByDocument[destination] = { label: basename(record.path), updatedAt: now() }; persist = true;
|
|
95
|
+
}
|
|
96
|
+
if (source.reviewNotes.length && !same(state.reviewNotesByDocument[destination], source.reviewNotes)) {
|
|
97
|
+
state.reviewNotesByDocument[destination] = source.reviewNotes.map(note => ({ ...note })); persist = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const receipt = { destinationKey: preview.destinationKey, choice: request.choice, metadataRevision: preview.metadataRevision };
|
|
101
|
+
return { result: { ok: true, prepared: true, ...receipt }, persist, committed: () => { record.metadataTransfer = receipt; } };
|
|
102
|
+
});
|
|
103
|
+
} catch { return fail("metadata-failed", "Saved metadata could not be prepared. Current notes and the save claim were kept; recheck before attaching."); }
|
|
104
|
+
finally { record.metadataBusy = false; }
|
|
105
|
+
},
|
|
106
|
+
owner(path) { const held = heldFile(path); return held ? { workspaceId: held.workspaceId, bufferId: held.bufferId } : null; },
|
|
107
|
+
checkUnclaimedFile(path) {
|
|
108
|
+
try {
|
|
109
|
+
const canonical = options.canonicalPath(path), requested = resolve(path);
|
|
110
|
+
if (typeof canonical !== "string" || !isAbsolute(canonical) || canonical.includes("\0")) throw new Error("Invalid canonical identity");
|
|
111
|
+
if (pending().some(r => r.fileKey === requested || r.fileKey === canonical || options.sameFile?.(r.fileKey, canonical))) {
|
|
112
|
+
return fail("document-claimed", "The HTML export target is owned or claimed by a Document; its file and work were kept.");
|
|
113
|
+
}
|
|
114
|
+
return { ok: true };
|
|
115
|
+
} catch { return fail("identity-unknown", "The HTML export target's pending save identity could not be verified; nothing was written."); }
|
|
116
|
+
},
|
|
117
|
+
execute(capability, request, write) {
|
|
118
|
+
sweep();
|
|
119
|
+
const binding = options.bind(capability, request?.generation); if (!binding.ok) return binding;
|
|
120
|
+
const { workspaceId, entry } = binding;
|
|
121
|
+
if (!request || !/^[a-f0-9]{48}$/.test(request.operationId || "") || !["save-as", "save-over"].includes(request.kind)
|
|
122
|
+
|| typeof request.path !== "string" || !request.path || typeof request.content !== "string"
|
|
123
|
+
|| typeof write !== "function") return fail("invalid-request", "A fresh save operation and document identity are required.");
|
|
124
|
+
// The reconnect generation is transport authority, not part of a retry's
|
|
125
|
+
// immutable intent. A new owning connection may inspect an old outcome.
|
|
126
|
+
const intent = { kind: request.kind, path: request.path, content: request.content, bufferId: request.bufferId,
|
|
127
|
+
documentEpoch: request.documentEpoch, expectedRevision: request.expectedRevision,
|
|
128
|
+
expectedDiskRevision: request.expectedDiskRevision ?? null, overwrite: request.overwrite === true, force: request.force === true };
|
|
129
|
+
const intentDigest = hash(JSON.stringify(intent));
|
|
130
|
+
const id = key(workspaceId, request.operationId), previous = records.get(id);
|
|
131
|
+
if (previous) return previous.intentDigest === intentDigest
|
|
132
|
+
? { ...previous.result, claimStatus: previous.status, operationId: previous.operationId, documents: options.documents(workspaceId) }
|
|
133
|
+
: fail("invalid-request", "This retry changed the original save intent.");
|
|
134
|
+
const authority = options.authority(workspaceId, request.bufferId, request.documentEpoch); if (!authority.ok) return authority;
|
|
135
|
+
if (!entry || entry.revision !== request.expectedRevision) return fail("conflict", "The initiating recovery checkpoint changed; nothing was saved.");
|
|
136
|
+
if (pending().some(r => r.workspaceId === workspaceId && r.bufferId === request.bufferId)) return fail("save-ack-pending", "Resolve this Document's previous save before writing again.");
|
|
137
|
+
const before = entry.state.buffers.find(b => b.id === request.bufferId);
|
|
138
|
+
if (!before) return fail("not-owner", "This workspace no longer owns the Document.");
|
|
139
|
+
const chars = request.content.length + (before.baselineText?.length || 0);
|
|
140
|
+
if (records.size >= maxRecords) for (const [id, record] of records) {
|
|
141
|
+
if (record.status !== "pending") records.delete(id);
|
|
142
|
+
if (records.size < maxRecords) break;
|
|
143
|
+
}
|
|
144
|
+
// Reserve bounded capacity BEFORE performing irreversible I/O. No awaits
|
|
145
|
+
// or other workspace mutations may occur inside the trusted writer.
|
|
146
|
+
if (records.size >= maxRecords || pending().reduce((sum, r) => sum + r.chars, chars) > maxPendingChars) return fail("capacity", "Save acknowledgement capacity is full. Resolve existing saves first; no file was written.");
|
|
147
|
+
let authorizedPath = null, authorizedKey = null;
|
|
148
|
+
const authorizeCommit = path => {
|
|
149
|
+
const current = options.authority(workspaceId, request.bufferId, request.documentEpoch); if (!current.ok) return current;
|
|
150
|
+
if (request.kind === "save-over") { const check = options.checkWrite(workspaceId, request.bufferId, path); if (!check.ok) return check; }
|
|
151
|
+
try {
|
|
152
|
+
const available = targetCheck(workspaceId, request.bufferId, path); if (!available.ok) return available;
|
|
153
|
+
authorizedKey = options.canonicalPath(path); authorizedPath = path; return { ok: true };
|
|
154
|
+
} catch { return fail("invalid-path", "The save target's identity could not be checked."); }
|
|
155
|
+
};
|
|
156
|
+
const result = write(authorizeCommit);
|
|
157
|
+
if (!result || result.ok !== true) return result || fail("write-failed", "The writer did not report a save outcome.");
|
|
158
|
+
if (!authorizedPath) throw new Error("Hosted writer omitted synchronous canonical-target authorization");
|
|
159
|
+
const path = authorizedPath, fileKey = authorizedKey, diskRevision = hash(request.content);
|
|
160
|
+
// No second request can run between the synchronous disk write, authority
|
|
161
|
+
// rotation and claim insertion. Rotation also fences same-byte save replays.
|
|
162
|
+
options.advanceAuthority(workspaceId, request.bufferId);
|
|
163
|
+
records.set(id, { workspaceId, operationId: request.operationId, bufferId: request.bufferId, intentDigest,
|
|
164
|
+
beforeDocumentEpoch: request.documentEpoch, beforeRevision: request.expectedRevision,
|
|
165
|
+
status: "pending", path, fileKey, diskRevision, content: request.content, chars,
|
|
166
|
+
beforeSourceState: before.sourceState, beforeResourceDir: before.resourceDir, beforeBaselineText: before.baselineText, beforeDiskRevision: before.diskRevision,
|
|
167
|
+
beforeMetadataKeys: metadataKeys(before),
|
|
168
|
+
result });
|
|
169
|
+
return { ...result, operationId: request.operationId, claimStatus: "pending", documents: options.documents(workspaceId) };
|
|
170
|
+
},
|
|
171
|
+
checkCheckpoint(workspaceId, state) {
|
|
172
|
+
const acknowledgements = [];
|
|
173
|
+
if (!pending().length) return { ok: true, acknowledgements };
|
|
174
|
+
try {
|
|
175
|
+
for (const buffer of state.buffers) if (buffer.sourceState.path) {
|
|
176
|
+
const available = targetCheck(workspaceId, buffer.id, buffer.sourceState.path, false); if (!available.ok) return available;
|
|
177
|
+
}
|
|
178
|
+
for (const record of pending().filter(r => r.workspaceId === workspaceId)) {
|
|
179
|
+
const buffer = state.buffers.find(b => b.id === record.bufferId);
|
|
180
|
+
if (!buffer) return fail("save-ack-pending", "A saved Document cannot be removed before its save is acknowledged or explicitly discarded.");
|
|
181
|
+
if (buffer.sourceState.source === "file" && buffer.sourceState.path === record.path
|
|
182
|
+
&& buffer.diskRevision === record.diskRevision && buffer.baselineText === record.content
|
|
183
|
+
&& buffer.resourceDir === dirname(record.path) && options.canonicalPath(record.path) === record.fileKey) {
|
|
184
|
+
if (record.metadataBusy) return fail("metadata-pending", "Metadata preparation must finish before attaching the saved backing.");
|
|
185
|
+
if (record.beforeSourceState.path !== record.path) {
|
|
186
|
+
const destination = record.metadataTransfer?.destinationKey;
|
|
187
|
+
if (!destination || !Object.values(metadataKeys(buffer)).every(key => key === destination)) return fail("metadata-decision-required", "Prepare both metadata associations before attaching this saved backing.");
|
|
188
|
+
} else if (!same(metadataKeys(buffer), record.beforeMetadataKeys)) return fail("metadata-decision-required", "Keep this saved Document's metadata associations unchanged.");
|
|
189
|
+
acknowledgements.push(key(workspaceId, record.operationId));
|
|
190
|
+
} else if (!same(metadataKeys(buffer), record.beforeMetadataKeys) || !same(buffer.sourceState, record.beforeSourceState) || buffer.resourceDir !== record.beforeResourceDir
|
|
191
|
+
|| buffer.diskRevision !== record.beforeDiskRevision || buffer.baselineText !== record.beforeBaselineText) return fail("save-ack-pending", "Resolve the successful save before changing this Document's backing. Current text was retained.");
|
|
192
|
+
}
|
|
193
|
+
} catch { return fail("invalid-path", "The saved file identity could not be checked; its claim was retained."); }
|
|
194
|
+
return { ok: true, acknowledgements };
|
|
195
|
+
},
|
|
196
|
+
commitCheckpoint(plan) {
|
|
197
|
+
for (const id of plan.acknowledgements || []) {
|
|
198
|
+
const record = records.get(id); if (record?.status === "pending") finish(record, "acknowledged");
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
guardMove(workspaceId, bufferId) {
|
|
202
|
+
return pending().some(r => r.workspaceId === workspaceId && r.bufferId === bufferId)
|
|
203
|
+
? fail("save-ack-pending", "Resolve this Document's successful save before moving or replacing it.") : { ok: true };
|
|
204
|
+
},
|
|
205
|
+
list(workspaceId) { return pending().filter(r => r.workspaceId === workspaceId).map(summary); },
|
|
206
|
+
hasPending(workspaceId) { return pending().some(r => r.workspaceId === workspaceId); },
|
|
207
|
+
discard(workspaceId, operationId, bufferId, confirmed) {
|
|
208
|
+
const record = records.get(key(workspaceId, operationId));
|
|
209
|
+
if (!confirmed || !record || record.bufferId !== bufferId) return fail("confirmation-required", "Explicitly confirm keeping this Document's current backing. The saved disk file will not be deleted.");
|
|
210
|
+
if (record.metadataBusy) return fail("metadata-pending", "Finish checking the saved metadata before keeping another backing.");
|
|
211
|
+
finish(record, "discarded"); return { ok: true };
|
|
212
|
+
},
|
|
213
|
+
clear() { records.clear(); },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { isStudioPristineBuffer } from "./studio-document-hosting.js";
|
|
2
|
+
import { createStudioDocumentCopyIntent } from "./studio-document-escrow.js";
|
|
3
|
+
import { createStudioDocumentLaunchController, openStudioRetainedDocument } from "./studio-document-launch-client.js";
|
|
4
|
+
|
|
5
|
+
// Adoption permits discarding the empty replacement, never late local work or
|
|
6
|
+
// an unresolved/corrupt handoff backup. A full Studio host is never retired.
|
|
7
|
+
export function studioDocumentRetiredViewCanClose(facts) {
|
|
8
|
+
try {
|
|
9
|
+
const { snapshot, backup } = facts, entry = snapshot?.buffers?.[0];
|
|
10
|
+
return Boolean(facts.retired && facts.adoptionConfirmed && facts.editorText === ""
|
|
11
|
+
&& !facts.metadataPending && !facts.operationPending && snapshot?.mode === "editor-only"
|
|
12
|
+
&& snapshot.buffers.length === 1 && snapshot.selectedBufferId === entry.id && entry.role === "document"
|
|
13
|
+
&& facts.boundBufferId === entry.id && ["source", "label", "path", "draftId"].every(key => facts.editorSourceState[key] === entry.sourceState[key])
|
|
14
|
+
&& facts.editorResourceDir === entry.resourceDir && facts.editorBaselineText === entry.baselineText && facts.editorDiskRevision === entry.diskRevision
|
|
15
|
+
&& entry.diskRevision === null && isStudioPristineBuffer(entry) && backup?.record === null && backup.error === null);
|
|
16
|
+
} catch { return false; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createStudioDocumentHostController(options) {
|
|
20
|
+
let active = null, closed = false, polling = false, latest = null, preparing = false;
|
|
21
|
+
let copyFlight = null, openFlight = null, automaticOpen = null, copyLive = false, copyReceipt = null, forgettingCopy = false;
|
|
22
|
+
const destinationFlights = new Map(), destinationObservations = new Map();
|
|
23
|
+
const copies = createStudioDocumentCopyIntent(options.copyStorage, "piStudio.bufferWorkspace.v2:" + options.workspaceId + ":copy");
|
|
24
|
+
const launch = options.launch ? createStudioDocumentLaunchController({ ...options.launch,
|
|
25
|
+
workspaceId: options.workspaceId, storage: options.copyStorage, generation: options.generation,
|
|
26
|
+
prepare: () => options.prepare("launch"), authority: () => options.client()?.hostingAuthority(),
|
|
27
|
+
busy: () => closed || preparing || active || copyFlight || options.busy(), status: options.status, changed: options.changed,
|
|
28
|
+
selectOwner: options.selectOwner,
|
|
29
|
+
}) : null;
|
|
30
|
+
const receiving = new Set();
|
|
31
|
+
let adoptionPending = null;
|
|
32
|
+
const adoptionFlights = new Set();
|
|
33
|
+
const client = () => options.client();
|
|
34
|
+
const nonce = () => Array.from(crypto.getRandomValues(new Uint8Array(24)), b => b.toString(16).padStart(2, "0")).join("");
|
|
35
|
+
const request = body => options.request({ ...body, generation: options.generation() });
|
|
36
|
+
const notify = (message, kind = "warning") => { if (!closed) { options.status(message, kind); options.changed(); } };
|
|
37
|
+
function rememberMoved(result, sourceBufferId = result.bufferId) {
|
|
38
|
+
if (result.status !== "committed") return false;
|
|
39
|
+
const saved = copies.remember({ operationId: result.moveId, sourceBufferId, workspaceId: result.targetWorkspaceId, bufferId: result.bufferId, kind: "move", created: false });
|
|
40
|
+
if (!saved.ok) notify("Document moved, but destination tracking could not be verified. Keep the source backup and recheck.");
|
|
41
|
+
return saved.ok;
|
|
42
|
+
}
|
|
43
|
+
function destinations() {
|
|
44
|
+
return copies.peek().destinations.map(d => ({ ...d, live: destinationObservations.get(d.bufferId)?.generation === options.generation() && destinationObservations.get(d.bufferId)?.live === true }));
|
|
45
|
+
}
|
|
46
|
+
function openDestination(destination, focus = true) {
|
|
47
|
+
if (closed || !destination || !copies.peek().destinations.some(d => d.operationId === destination.operationId && d.bufferId === destination.bufferId)) return Promise.resolve(false);
|
|
48
|
+
const key = destination.bufferId + ":" + focus;
|
|
49
|
+
if (destinationFlights.has(key)) return destinationFlights.get(key);
|
|
50
|
+
// Silent and explicit flights share per-buffer observation order. Each
|
|
51
|
+
// owner RPC (including the final focus check) starts a fresh ticket.
|
|
52
|
+
let observation;
|
|
53
|
+
const beginObservation = () => { observation = { generation: options.generation(), live: false }; destinationObservations.set(destination.bufferId, observation); };
|
|
54
|
+
beginObservation();
|
|
55
|
+
const work = openStudioRetainedDocument({ workspaceId: options.workspaceId, generation: options.generation, current: () => !closed,
|
|
56
|
+
request: body => { beginObservation(); return request(body); }, confirm: options.confirmResume, open: options.open, selectOwner: options.selectOwner, status: options.status, changed: options.changed,
|
|
57
|
+
observed: (bufferId, result) => { if (destinationObservations.get(bufferId) !== observation) return; copyLive = result.live === true; observation.live = copyLive; },
|
|
58
|
+
}, destination.bufferId, focus).finally(() => { destinationFlights.delete(key); if (!closed) options.changed(); });
|
|
59
|
+
destinationFlights.set(key, work); return work;
|
|
60
|
+
}
|
|
61
|
+
function finish(proof, result) {
|
|
62
|
+
if (closed || !proof || active !== proof || !client()) return false;
|
|
63
|
+
options.capture(); // Include Prompt typing still waiting on the ordinary debounce.
|
|
64
|
+
// Synchronous: bind new DOM before any await or readiness callback can
|
|
65
|
+
// treat the old source textarea as the fresh replacement slot.
|
|
66
|
+
const applied = client().finishHosting(proof, result, () => options.domCurrent(proof));
|
|
67
|
+
if (!applied.ok) { notify(applied.message); return false; }
|
|
68
|
+
active = null;
|
|
69
|
+
if (result.status === "committed" && proof.direction === "outgoing") options.depart(proof.bufferId);
|
|
70
|
+
if (result.status === "committed" && proof.direction === "outgoing" && !options.full) options.retire();
|
|
71
|
+
else if (applied.selectedChanged) options.bind();
|
|
72
|
+
latest = result.status === "committed" ? result : latest;
|
|
73
|
+
if (result.status === "committed" && proof.direction === "outgoing") rememberMoved(result, proof.bufferId);
|
|
74
|
+
options.changed();
|
|
75
|
+
if (result.status === "cancelled") notify("Move cancelled. The original Document was kept.", "success");
|
|
76
|
+
else notify(proof.direction === "incoming" ? "Document returned to Studio. Prompt was kept." : "Document moved; use its destination editor.", "success");
|
|
77
|
+
if (result.status === "committed" && proof.direction === "incoming") void adopted(result);
|
|
78
|
+
if (result.status === "committed" && proof.direction === "outgoing" && options.full && automaticOpen !== result.moveId) {
|
|
79
|
+
automaticOpen = result.moveId;
|
|
80
|
+
void openMoved().catch(() => notify("The Document moved, but opening its owner could not be checked. Use Find moved Document."));
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
async function adopted(result) {
|
|
85
|
+
if (closed || adoptionFlights.has(result.moveId)) return;
|
|
86
|
+
adoptionFlights.add(result.moveId);
|
|
87
|
+
adoptionPending = { moveId: result.moveId, bufferId: result.bufferId }; options.changed();
|
|
88
|
+
try {
|
|
89
|
+
if (!client()?.hasVerifiedLocalCheckpoint()) { notify("Document arrived, but browser recovery is unverified. Keep the source backup and this tab open."); return; }
|
|
90
|
+
const response = await request({ operation: "adopted", moveId: result.moveId, bufferId: result.bufferId });
|
|
91
|
+
if (closed) return;
|
|
92
|
+
if (!response.ok) notify(response.message);
|
|
93
|
+
else if (adoptionPending?.moveId === result.moveId) { adoptionPending = null; options.changed(); }
|
|
94
|
+
} catch { notify("Adoption could not be acknowledged. Keep the source backup and recheck."); }
|
|
95
|
+
finally { adoptionFlights.delete(result.moveId); }
|
|
96
|
+
}
|
|
97
|
+
async function recheck(moveId) {
|
|
98
|
+
if (closed) return;
|
|
99
|
+
const result = await request({ operation: "status", moveId, incoming: true });
|
|
100
|
+
if (!result.ok || closed) return result;
|
|
101
|
+
if (latest?.moveId === moveId && result.status === "committed") latest = result;
|
|
102
|
+
if (active?.moveId === moveId && ["committed", "cancelled"].includes(result.status)) finish(active, result);
|
|
103
|
+
else if (result.status === "offered" && result.targetWorkspaceId === options.workspaceId && options.full && !active && !receiving.has(moveId)) {
|
|
104
|
+
receiving.add(moveId); try { await incoming(result); } finally { receiving.delete(moveId); }
|
|
105
|
+
}
|
|
106
|
+
if (adoptionPending?.moveId === moveId && result.status === "committed" && result.targetWorkspaceId === options.workspaceId
|
|
107
|
+
&& client()?.snapshot()?.buffers.some(buffer => buffer.id === result.bufferId)) await adopted(result);
|
|
108
|
+
const backup = client()?.handoffBackup()?.record;
|
|
109
|
+
if (!active && backup?.moveId === moveId && (result.adopted || result.status === "cancelled")) {
|
|
110
|
+
if (result.adopted) {
|
|
111
|
+
if (!rememberMoved(result)) return result;
|
|
112
|
+
const destination = copies.peek().destinations.find(d => d.operationId === moveId);
|
|
113
|
+
if (!await openDestination(destination, false)) return result;
|
|
114
|
+
}
|
|
115
|
+
if (closed) return result;
|
|
116
|
+
const resolved = client().resolveHandoff(result); options.changed();
|
|
117
|
+
if (resolved.ok && result.adopted) options.closeRetired();
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
async function incoming(result) {
|
|
122
|
+
// A concrete decline, not a GET/timeout, releases the source. Consent is
|
|
123
|
+
// acquired before freezing the destination, including non-text metadata.
|
|
124
|
+
if (preparing || copyFlight || launch?.busy() || options.busy()) { await request({ operation: "decline", moveId: result.moveId }); return; }
|
|
125
|
+
const state = client()?.snapshot(), doc = state?.buffers.find(b => b.role === "document");
|
|
126
|
+
if (!doc) { await request({ operation: "decline", moveId: result.moveId }); return; }
|
|
127
|
+
const discardTarget = !isStudioPristineBuffer(doc);
|
|
128
|
+
if (discardTarget && !await options.confirm("Replace Studio's Document?", "The browser Document will replace the current Document, including its unsaved text and metadata association. Prompt will be kept.")) {
|
|
129
|
+
await request({ operation: "decline", moveId: result.moveId }); return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await options.prepare("incoming");
|
|
133
|
+
if (closed) return;
|
|
134
|
+
if (JSON.stringify(client().snapshot().buffers.find(b => b.role === "document")) !== JSON.stringify(doc)) throw new Error("The destination Document changed. It was not replaced.");
|
|
135
|
+
const frozen = client().freezeHosting({ moveId: result.moveId, direction: "incoming", bufferId: doc.id });
|
|
136
|
+
if (!frozen.ok) throw new Error(frozen.message);
|
|
137
|
+
active = frozen.proof; options.changed();
|
|
138
|
+
const accepted = await request({ operation: "accept", moveId: result.moveId, expectedRevision: active.expectedRevision, targetBufferId: doc.id, discardTarget });
|
|
139
|
+
if (accepted.ok) finish(active, accepted);
|
|
140
|
+
else {
|
|
141
|
+
const declined = await request({ operation: "decline", moveId: result.moveId });
|
|
142
|
+
if (declined.ok) finish(active, declined); else notify("Move outcome is uncertain. Both editors remain protected; use Recheck move.");
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!active) await request({ operation: "decline", moveId: result.moveId }).catch(() => {});
|
|
146
|
+
notify(error.message || "Move outcome is uncertain. Recheck before resuming.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function move() {
|
|
150
|
+
if (closed || active || preparing || copyFlight || launch?.busy() || options.busy()) return;
|
|
151
|
+
preparing = true;
|
|
152
|
+
let proof;
|
|
153
|
+
try {
|
|
154
|
+
let targetWorkspaceId;
|
|
155
|
+
if (!options.full) {
|
|
156
|
+
const hosts = await request({ operation: "hosts" });
|
|
157
|
+
if (!hosts.ok || !hosts.workspaces?.length) throw new Error("Open the full Studio view first, then move this Document back.");
|
|
158
|
+
targetWorkspaceId = hosts.workspaces[0];
|
|
159
|
+
}
|
|
160
|
+
await options.prepare("outgoing");
|
|
161
|
+
if (closed) return;
|
|
162
|
+
const state = client().snapshot(), doc = state.buffers.find(b => b.id === state.selectedBufferId);
|
|
163
|
+
const frozen = client().freezeHosting({ moveId: nonce(), direction: "outgoing", bufferId: doc.id });
|
|
164
|
+
if (!frozen.ok) throw new Error(frozen.message);
|
|
165
|
+
proof = active = frozen.proof; options.changed();
|
|
166
|
+
const result = await request({ operation: options.full ? "move-out" : "begin", moveId: proof.moveId,
|
|
167
|
+
expectedRevision: proof.expectedRevision, bufferId: proof.bufferId, targetMode: options.full ? "editor-only" : "full", targetWorkspaceId });
|
|
168
|
+
if (closed || active !== proof) { if (openFlight) await openFlight; return; }
|
|
169
|
+
if (result.ok && result.status === "committed") { finish(proof, result); if (openFlight) await openFlight; }
|
|
170
|
+
else if (!result.ok) {
|
|
171
|
+
const cancelled = await request({ operation: "cancel", moveId: proof.moveId, expectedRevision: proof.expectedRevision });
|
|
172
|
+
if (cancelled.ok) finish(proof, cancelled);
|
|
173
|
+
notify(result.message);
|
|
174
|
+
} else notify("Waiting for Studio to accept the Document. Recheck or Cancel if needed.");
|
|
175
|
+
} catch (error) { notify(proof ? "Move outcome is uncertain; recheck its status before another move." : error.message); }
|
|
176
|
+
finally { preparing = false; }
|
|
177
|
+
}
|
|
178
|
+
function openMoved() {
|
|
179
|
+
if (closed) return Promise.resolve();
|
|
180
|
+
if (openFlight) return openFlight;
|
|
181
|
+
const moveId = client()?.handoffBackup()?.record?.moveId || latest?.moveId || copies.peek().destinations.filter(d => d.kind === "move").at(-1)?.operationId;
|
|
182
|
+
if (!moveId) return Promise.resolve();
|
|
183
|
+
openFlight = (async () => {
|
|
184
|
+
const result = await request({ operation: "status", moveId, focus: false });
|
|
185
|
+
if (closed) return;
|
|
186
|
+
if (!result.ok || !rememberMoved(result)) { notify(result.message || "The moved Document could not be checked. Keep or download its backup."); return; }
|
|
187
|
+
await openDestination(copies.peek().destinations.find(d => d.operationId === moveId));
|
|
188
|
+
})().finally(() => { openFlight = null; });
|
|
189
|
+
return openFlight;
|
|
190
|
+
}
|
|
191
|
+
function copy(open = true) {
|
|
192
|
+
if (closed || forgettingCopy) return Promise.resolve();
|
|
193
|
+
if (copyFlight) return copyFlight;
|
|
194
|
+
copyFlight = (async () => {
|
|
195
|
+
try {
|
|
196
|
+
const saved = copies.peek(); if (saved.error) throw new Error(saved.error.message);
|
|
197
|
+
let intent = saved.record?.request;
|
|
198
|
+
if (!intent) {
|
|
199
|
+
if (!open || active || preparing || launch?.busy() || options.busy()) return;
|
|
200
|
+
preparing = true;
|
|
201
|
+
try { await options.prepare("copy"); } finally { preparing = false; }
|
|
202
|
+
if (closed) return;
|
|
203
|
+
const authority = client().hostingAuthority(); if (!authority.ok) throw new Error(authority.message);
|
|
204
|
+
intent = { operationId: nonce(), bufferId: authority.bufferId, documentEpoch: authority.documentEpoch, expectedRevision: authority.expectedRevision };
|
|
205
|
+
}
|
|
206
|
+
const retained = copies.capture(intent); if (!retained.ok) throw new Error(retained.message);
|
|
207
|
+
options.changed();
|
|
208
|
+
const result = await request({ ...intent, operation: "copy", focus: open });
|
|
209
|
+
if (closed) return;
|
|
210
|
+
if (!result.ok) throw new Error(result.message || "Copy outcome is unknown. Its original identity was retained.");
|
|
211
|
+
const completed = copies.complete(result);
|
|
212
|
+
if (completed.destination) copyReceipt = Object.freeze({ ...completed.destination });
|
|
213
|
+
if (!completed.ok) throw new Error(completed.message);
|
|
214
|
+
options.changed();
|
|
215
|
+
await openDestination(copies.peek().destinations.find(d => d.operationId === intent.operationId), open);
|
|
216
|
+
} catch (error) { copyLive = false; if (open) notify(error.message || "Copy outcome is uncertain; recheck this same copy, not a new one."); }
|
|
217
|
+
})().finally(() => { copyFlight = null; if (!closed) options.changed(); });
|
|
218
|
+
return copyFlight;
|
|
219
|
+
}
|
|
220
|
+
const timer = setInterval(() => {
|
|
221
|
+
if (closed || polling || !options.generation() || !client()) return;
|
|
222
|
+
const moveId = active?.moveId || client().handoffBackup()?.record?.moveId || adoptionPending?.moveId;
|
|
223
|
+
const pendingCopy = !copyLive && copies.peek().record;
|
|
224
|
+
if (!moveId && !pendingCopy) return;
|
|
225
|
+
polling = true; void (async () => { if (moveId) await recheck(moveId); if (pendingCopy) await copy(false); })().catch(() => {}).finally(() => { polling = false; });
|
|
226
|
+
}, 1500);
|
|
227
|
+
return Object.freeze({
|
|
228
|
+
move, openMoved,
|
|
229
|
+
launch: (target, presentation) => launch?.start(target, presentation), launchState: () => launch?.state(), recheckLaunch: () => launch?.recheck(), forgetLaunch: () => launch?.forget(),
|
|
230
|
+
copy: () => copy(true),
|
|
231
|
+
copyState: () => ({ ...copies.peek(), destinations: destinations(), live: copies.peek().record ? false : copyLive, busy: Boolean(copyFlight), receipt: copyReceipt ? { ...copyReceipt } : null }),
|
|
232
|
+
openDestination: destination => destination.kind === "blank" || destination.kind === "file" ? launch?.openDestination(destination) : openDestination(destination),
|
|
233
|
+
moveState: () => ({ adoptionPending: adoptionPending ? { ...adoptionPending } : null, pending: active ? { moveId: active.moveId, bufferId: active.bufferId, direction: active.direction } : null,
|
|
234
|
+
receipt: latest ? { moveId: latest.moveId, bufferId: latest.bufferId, status: latest.status, adopted: latest.adopted === true } : null }),
|
|
235
|
+
needsUnloadConfirmation: () => Boolean(copyFlight || forgettingCopy || destinationFlights.size || launch?.needsUnloadConfirmation() || copies.peek().error || copies.peek().record || destinations().some(d => !d.live || !options.generation())),
|
|
236
|
+
async forgetCopy() {
|
|
237
|
+
if (closed || preparing) return;
|
|
238
|
+
// Oliver, 13 Oct: a click during an automatic recheck waits for it, then asks.
|
|
239
|
+
while (copyFlight) { await copyFlight.catch(() => {}); if (closed || preparing) return; }
|
|
240
|
+
const original = copies.peek().record; if (!original) return;
|
|
241
|
+
forgettingCopy = true; let agreed;
|
|
242
|
+
try { agreed = await options.confirmCopy("Forget this copy attempt?", "An existing copy will NOT be deleted. If its outcome is uncertain, forgetting this identity may leave a retained copy you cannot find here. Recheck first unless you deliberately want another independent copy."); }
|
|
243
|
+
finally { forgettingCopy = false; }
|
|
244
|
+
if (!agreed || closed || copyFlight || copies.peek().record !== original) return;
|
|
245
|
+
const result = copies.clear(true); if (!result.ok) notify(result.message);
|
|
246
|
+
else { copyLive = false; copyReceipt = null; notify("Copy tracking cleared. Existing documents were not deleted.", "success"); }
|
|
247
|
+
},
|
|
248
|
+
async cancel() {
|
|
249
|
+
if (closed || !active) return; const proof = active;
|
|
250
|
+
const result = await request({ operation: proof.direction === "incoming" ? "decline" : "cancel", moveId: proof.moveId, expectedRevision: proof.expectedRevision });
|
|
251
|
+
if (result.ok) finish(proof, result); else notify(result.message);
|
|
252
|
+
},
|
|
253
|
+
recheck: () => { const id = active?.moveId || client()?.handoffBackup()?.record?.moveId || adoptionPending?.moveId || latest?.moveId; return id ? recheck(id) : Promise.resolve(); },
|
|
254
|
+
onChanged: moveId => recheck(moveId).catch(() => {}),
|
|
255
|
+
async ready(remote) {
|
|
256
|
+
if (closed) return;
|
|
257
|
+
await launch?.ready();
|
|
258
|
+
if (copies.peek().record) await copy(false);
|
|
259
|
+
await Promise.all(copies.peek().destinations.map(d => openDestination(d, false)));
|
|
260
|
+
for (const receipt of remote?.lineage || []) if (receipt.move?.status === "committed" && receipt.move.targetWorkspaceId === options.workspaceId
|
|
261
|
+
&& client()?.snapshot().buffers.some(b => b.id === receipt.move.bufferId)) await adopted(receipt.move);
|
|
262
|
+
for (const move of remote?.pendingMoves || []) if (move.targetWorkspaceId === options.workspaceId) await recheck(move.moveId);
|
|
263
|
+
},
|
|
264
|
+
operationPending: () => Boolean(active || preparing || copyFlight || openFlight || destinationFlights.size || launch?.state().busy),
|
|
265
|
+
active: () => active,
|
|
266
|
+
dispose() { closed = true; clearInterval(timer); launch?.dispose(); },
|
|
267
|
+
});
|
|
268
|
+
}
|