pi-studio 0.9.42 → 0.9.43
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/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/client/studio-client.js +114 -42
- package/client/studio-navigation-helpers.js +106 -0
- package/index.ts +84 -1
- package/package.json +1 -1
- package/shared/studio-workspace-state.js +118 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,11 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.43] — 2026-08-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Preserve each Studio tab’s unsaved editor text and selected views when cmux reconstructs a hidden browser surface after switching workspaces, while keeping tab recovery state isolated and bounded in the running Studio server.
|
|
11
|
+
|
|
7
12
|
## [0.9.42] — 2026-08-11
|
|
8
13
|
|
|
9
14
|
### Changed
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
|
|
|
38
38
|
- Includes an optional tmux-backed **REPL** view for Shell, Python, IPython, Julia, R, GHCi, and Clojure sessions, with Raw/Literate send modes, `Cmd/Ctrl+Shift+Enter` **Send to REPL**, session start/stop/interrupt controls, a compact refresh-persistent **Studio REPL Record** of user and Pi-sent code, a secondary raw tmux mirror, agent-facing `studio_repl_status` / `studio_repl_send` tools, and Markdown/PDF/HTML export
|
|
39
39
|
- Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them, with a **Recent…** picker for recovering scratchpads saved under earlier file/draft identities
|
|
40
40
|
- Includes a docked **Outline** rail for navigating document structure in the current editor text, with clickable entries that jump in the raw editor and reveal matching preview locations when available
|
|
41
|
-
- Restores
|
|
41
|
+
- Restores each browser tab’s editor workspace after refresh or cmux hidden-surface reconstruction, and provides an explicit **Reset editor** action when you want to discard the restored draft and return the tab to a fresh blank draft without changing responses or saved files
|
|
42
42
|
- Turns local preview links, including links inside sandboxed HTML previews, into Studio actions: PDFs open in the embedded viewer, images open in a zoomable focus viewer, PDF/image links can open in a new Studio preview tab, text/code/CSV/TSV document links can open in a new editor tab, DOCX/ODT links can be converted to editable Markdown, and right-click menus provide **Open here**, **Reveal in file manager**, and **Copy path** for local resources
|
|
43
43
|
- Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with transient **Comment** / **Jump** actions from raw-editor selections plus editor-preview selections for Markdown, LaTeX, code/text/diff previews, and an opt-in comment mode for editor HTML previews; source-anchored comments can be toggled into inline `[an: ...]` annotations when you want comments reflected in the document text
|
|
44
44
|
- Browses response history (`Prev/Next/Last`) and loads either:
|
package/client/studio-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
(() => {
|
|
1
|
+
(async () => {
|
|
2
2
|
const statusLineEl = document.getElementById("statusLine");
|
|
3
3
|
const statusEl = document.getElementById("status");
|
|
4
4
|
const statusSpinnerEl = document.getElementById("statusSpinner");
|
|
@@ -209,9 +209,17 @@
|
|
|
209
209
|
};
|
|
210
210
|
|
|
211
211
|
const navigationHelpers = globalThis.PiStudioNavigationHelpers;
|
|
212
|
-
if (
|
|
212
|
+
if (
|
|
213
|
+
!navigationHelpers
|
|
214
|
+
|| typeof navigationHelpers.readPaneFocusTarget !== "function"
|
|
215
|
+
|| typeof navigationHelpers.ensureStudioTabStateId !== "function"
|
|
216
|
+
|| typeof navigationHelpers.readStudioWorkspaceState !== "function"
|
|
217
|
+
|| typeof navigationHelpers.persistStudioWorkspaceState !== "function"
|
|
218
|
+
|| typeof navigationHelpers.clearStudioWorkspaceState !== "function"
|
|
219
|
+
) {
|
|
213
220
|
throw new Error("Studio navigation helpers failed to load.");
|
|
214
221
|
}
|
|
222
|
+
const studioTabStateId = navigationHelpers.ensureStudioTabStateId(window);
|
|
215
223
|
const initialQueryParams = new URLSearchParams(window.location.search || "");
|
|
216
224
|
const initialPaneFocusTarget = navigationHelpers.readPaneFocusTarget(window.location);
|
|
217
225
|
const skipInitialWorkspaceRestore = initialQueryParams.get("skipWorkspaceRestore") === "1";
|
|
@@ -2059,8 +2067,9 @@
|
|
|
2059
2067
|
const PANE_SPLIT_MIN_PERCENT = 20;
|
|
2060
2068
|
const PANE_SPLIT_MAX_PERCENT = 80;
|
|
2061
2069
|
const PANE_SPLIT_SNAP_TO_CENTER_PERCENT = 1;
|
|
2062
|
-
const STUDIO_WORKSPACE_STORAGE_KEY = "piStudio.workspaceState.v1";
|
|
2063
2070
|
const STUDIO_WORKSPACE_MAX_TEXT_CHARS = 900_000;
|
|
2071
|
+
const STUDIO_WORKSPACE_PERSIST_INTERVAL_MS = 300;
|
|
2072
|
+
const STUDIO_WORKSPACE_RECOVERY_FETCH_TIMEOUT_MS = 1_500;
|
|
2064
2073
|
const EDITOR_HIGHLIGHT_MAX_CHARS = 100_000;
|
|
2065
2074
|
const EDITOR_HIGHLIGHT_STORAGE_KEY = "piStudio.editorHighlightEnabled";
|
|
2066
2075
|
const EDITOR_LANGUAGE_STORAGE_KEY = "piStudio.editorLanguage";
|
|
@@ -2217,6 +2226,8 @@
|
|
|
2217
2226
|
let workspacePersistenceReady = false;
|
|
2218
2227
|
let workspacePersistTimer = null;
|
|
2219
2228
|
let workspaceRestoredFromBrowser = false;
|
|
2229
|
+
let workspaceServerPersistenceBlocked = false;
|
|
2230
|
+
let lastWorkspacePersistenceSavedAt = 0;
|
|
2220
2231
|
let suppressedEditorSelectionStart = null;
|
|
2221
2232
|
let suppressedEditorSelectionEnd = null;
|
|
2222
2233
|
const previewJumpHighlightState = new WeakMap();
|
|
@@ -3605,11 +3616,13 @@
|
|
|
3605
3616
|
});
|
|
3606
3617
|
|
|
3607
3618
|
document.addEventListener("visibilitychange", () => {
|
|
3608
|
-
if (
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3619
|
+
if (document.hidden) {
|
|
3620
|
+
flushWorkspacePersistence({ beacon: false });
|
|
3621
|
+
return;
|
|
3622
|
+
}
|
|
3623
|
+
windowHasFocus = typeof document.hasFocus === "function" ? document.hasFocus() : windowHasFocus;
|
|
3624
|
+
if (windowHasFocus) {
|
|
3625
|
+
clearTitleAttention();
|
|
3613
3626
|
}
|
|
3614
3627
|
});
|
|
3615
3628
|
|
|
@@ -11377,34 +11390,46 @@
|
|
|
11377
11390
|
return "source:" + normalized.source + ":" + normalized.label;
|
|
11378
11391
|
}
|
|
11379
11392
|
|
|
11380
|
-
function
|
|
11393
|
+
function readPersistedWorkspaceState() {
|
|
11381
11394
|
try {
|
|
11382
|
-
return window
|
|
11395
|
+
return navigationHelpers.readStudioWorkspaceState(window, studioTabStateId);
|
|
11383
11396
|
} catch {
|
|
11384
11397
|
return null;
|
|
11385
11398
|
}
|
|
11386
11399
|
}
|
|
11387
11400
|
|
|
11388
|
-
function
|
|
11389
|
-
|
|
11390
|
-
|
|
11391
|
-
|
|
11392
|
-
|
|
11393
|
-
|
|
11394
|
-
function readPersistedWorkspaceState() {
|
|
11401
|
+
async function readServerWorkspaceRecoveryState() {
|
|
11402
|
+
if (skipInitialWorkspaceRestore) return { status: "skipped", state: null };
|
|
11403
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
11404
|
+
const timer = controller
|
|
11405
|
+
? window.setTimeout(() => controller.abort(), STUDIO_WORKSPACE_RECOVERY_FETCH_TIMEOUT_MS)
|
|
11406
|
+
: null;
|
|
11395
11407
|
try {
|
|
11396
|
-
const
|
|
11397
|
-
|
|
11398
|
-
|
|
11399
|
-
|
|
11400
|
-
|
|
11401
|
-
|
|
11402
|
-
return parsed;
|
|
11408
|
+
const payload = await fetchStudioJson("/tab-workspace-state", {
|
|
11409
|
+
query: { tabStateId: studioTabStateId },
|
|
11410
|
+
signal: controller ? controller.signal : undefined,
|
|
11411
|
+
});
|
|
11412
|
+
const state = payload && payload.state && typeof payload.state === "object" ? payload.state : null;
|
|
11413
|
+
return { status: state ? "found" : "empty", state };
|
|
11403
11414
|
} catch {
|
|
11404
|
-
return null;
|
|
11415
|
+
return { status: "unavailable", state: null };
|
|
11416
|
+
} finally {
|
|
11417
|
+
if (timer !== null) window.clearTimeout(timer);
|
|
11405
11418
|
}
|
|
11406
11419
|
}
|
|
11407
11420
|
|
|
11421
|
+
function getWorkspaceStateSavedAt(state) {
|
|
11422
|
+
return state && typeof state.savedAt === "number" && Number.isFinite(state.savedAt)
|
|
11423
|
+
? state.savedAt
|
|
11424
|
+
: 0;
|
|
11425
|
+
}
|
|
11426
|
+
|
|
11427
|
+
function chooseNewestRestorableWorkspaceState(states) {
|
|
11428
|
+
return states
|
|
11429
|
+
.filter((state) => shouldRestorePersistedWorkspaceState(state))
|
|
11430
|
+
.sort((a, b) => getWorkspaceStateSavedAt(b) - getWorkspaceStateSavedAt(a))[0] || null;
|
|
11431
|
+
}
|
|
11432
|
+
|
|
11408
11433
|
function shouldRestorePersistedWorkspaceState(state) {
|
|
11409
11434
|
if (skipInitialWorkspaceRestore) return false;
|
|
11410
11435
|
if (!state || typeof state.text !== "string") return false;
|
|
@@ -11417,9 +11442,10 @@
|
|
|
11417
11442
|
}
|
|
11418
11443
|
|
|
11419
11444
|
function buildWorkspacePersistencePayload() {
|
|
11445
|
+
lastWorkspacePersistenceSavedAt = Math.max(Date.now(), lastWorkspacePersistenceSavedAt + 1);
|
|
11420
11446
|
return {
|
|
11421
11447
|
version: 1,
|
|
11422
|
-
savedAt:
|
|
11448
|
+
savedAt: lastWorkspacePersistenceSavedAt,
|
|
11423
11449
|
sourceState: normalizeWorkspaceSourceState(sourceState),
|
|
11424
11450
|
resourceDir: getCurrentResourceDirValue(),
|
|
11425
11451
|
editorView,
|
|
@@ -11434,38 +11460,63 @@
|
|
|
11434
11460
|
};
|
|
11435
11461
|
}
|
|
11436
11462
|
|
|
11437
|
-
function
|
|
11463
|
+
function sendServerWorkspaceRecoveryState(payload, options) {
|
|
11464
|
+
if ((options && options.skipServer) || (workspaceServerPersistenceBlocked && !(options && options.forceServer))) return;
|
|
11465
|
+
const body = { tabStateId: studioTabStateId, state: payload };
|
|
11466
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
11467
|
+
try {
|
|
11468
|
+
ws.send(JSON.stringify({ type: "workspace_state_update", ...body }));
|
|
11469
|
+
return;
|
|
11470
|
+
} catch {
|
|
11471
|
+
// Fall through to the authenticated HTTP path.
|
|
11472
|
+
}
|
|
11473
|
+
}
|
|
11474
|
+
if (options && options.beacon && trySendStudioJsonBeacon("/tab-workspace-state", body)) return;
|
|
11475
|
+
void fetchStudioJson("/tab-workspace-state", {
|
|
11476
|
+
method: "POST",
|
|
11477
|
+
body: JSON.stringify(body),
|
|
11478
|
+
keepalive: Boolean(options && options.beacon),
|
|
11479
|
+
}).catch(() => {
|
|
11480
|
+
// Session storage remains the in-page fallback if server recovery fails.
|
|
11481
|
+
});
|
|
11482
|
+
}
|
|
11483
|
+
|
|
11484
|
+
function persistWorkspaceStateNow(options) {
|
|
11438
11485
|
if (!workspacePersistenceReady) return;
|
|
11439
11486
|
try {
|
|
11440
|
-
const storage = getWorkspacePersistenceStorage();
|
|
11441
|
-
if (!storage) return;
|
|
11442
|
-
clearLegacyWorkspacePersistenceStorage();
|
|
11443
11487
|
const payload = buildWorkspacePersistencePayload();
|
|
11444
11488
|
if (payload.text.length > STUDIO_WORKSPACE_MAX_TEXT_CHARS) {
|
|
11445
|
-
|
|
11489
|
+
navigationHelpers.clearStudioWorkspaceState(window, studioTabStateId);
|
|
11490
|
+
sendServerWorkspaceRecoveryState({
|
|
11491
|
+
...payload,
|
|
11492
|
+
sourceState: { source: "recovery-omitted", label: "oversized editor", path: null, draftId: null },
|
|
11493
|
+
resourceDir: "",
|
|
11494
|
+
rightView: "editor-preview",
|
|
11495
|
+
text: "",
|
|
11496
|
+
}, { ...(options || {}), forceServer: true });
|
|
11446
11497
|
return;
|
|
11447
11498
|
}
|
|
11448
|
-
|
|
11499
|
+
navigationHelpers.persistStudioWorkspaceState(window, studioTabStateId, payload);
|
|
11500
|
+
sendServerWorkspaceRecoveryState(payload, options);
|
|
11449
11501
|
} catch {
|
|
11450
|
-
// Ignore browser storage
|
|
11502
|
+
// Ignore browser storage, serialization, and recovery-request failures.
|
|
11451
11503
|
}
|
|
11452
11504
|
}
|
|
11453
11505
|
|
|
11454
11506
|
function scheduleWorkspacePersistence() {
|
|
11455
|
-
if (!workspacePersistenceReady) return;
|
|
11456
|
-
if (workspacePersistTimer !== null) window.clearTimeout(workspacePersistTimer);
|
|
11507
|
+
if (!workspacePersistenceReady || workspacePersistTimer !== null) return;
|
|
11457
11508
|
workspacePersistTimer = window.setTimeout(() => {
|
|
11458
11509
|
workspacePersistTimer = null;
|
|
11459
11510
|
persistWorkspaceStateNow();
|
|
11460
|
-
},
|
|
11511
|
+
}, STUDIO_WORKSPACE_PERSIST_INTERVAL_MS);
|
|
11461
11512
|
}
|
|
11462
11513
|
|
|
11463
|
-
function flushWorkspacePersistence() {
|
|
11514
|
+
function flushWorkspacePersistence(options) {
|
|
11464
11515
|
if (workspacePersistTimer !== null) {
|
|
11465
11516
|
window.clearTimeout(workspacePersistTimer);
|
|
11466
11517
|
workspacePersistTimer = null;
|
|
11467
11518
|
}
|
|
11468
|
-
persistWorkspaceStateNow();
|
|
11519
|
+
persistWorkspaceStateNow(options || { beacon: true });
|
|
11469
11520
|
}
|
|
11470
11521
|
|
|
11471
11522
|
function clearPersistedWorkspaceState() {
|
|
@@ -11474,10 +11525,16 @@
|
|
|
11474
11525
|
workspacePersistTimer = null;
|
|
11475
11526
|
}
|
|
11476
11527
|
try {
|
|
11477
|
-
|
|
11478
|
-
|
|
11528
|
+
navigationHelpers.clearStudioWorkspaceState(window, studioTabStateId);
|
|
11529
|
+
const marker = buildWorkspacePersistencePayload();
|
|
11530
|
+
sendServerWorkspaceRecoveryState({
|
|
11531
|
+
...marker,
|
|
11532
|
+
sourceState: { source: "recovery-cleared", label: "cleared editor", path: null, draftId: null },
|
|
11533
|
+
resourceDir: "",
|
|
11534
|
+
rightView: "editor-preview",
|
|
11535
|
+
text: "",
|
|
11536
|
+
}, { beacon: true, forceServer: true });
|
|
11479
11537
|
} catch {}
|
|
11480
|
-
clearLegacyWorkspacePersistenceStorage();
|
|
11481
11538
|
}
|
|
11482
11539
|
|
|
11483
11540
|
function applyPersistedWorkspaceState(state) {
|
|
@@ -12555,6 +12612,8 @@
|
|
|
12555
12612
|
headers,
|
|
12556
12613
|
body: init.body,
|
|
12557
12614
|
cache: "no-store",
|
|
12615
|
+
signal: init.signal,
|
|
12616
|
+
keepalive: init.keepalive === true,
|
|
12558
12617
|
});
|
|
12559
12618
|
let payload = null;
|
|
12560
12619
|
try {
|
|
@@ -21892,13 +21951,26 @@
|
|
|
21892
21951
|
setAnnotationsEnabled(initialAnnotationsEnabled, { silent: true });
|
|
21893
21952
|
setReplSendMode(replSendMode);
|
|
21894
21953
|
|
|
21895
|
-
const
|
|
21954
|
+
const sessionWorkspaceState = readPersistedWorkspaceState();
|
|
21955
|
+
const serverWorkspaceRecovery = await readServerWorkspaceRecoveryState();
|
|
21956
|
+
workspaceServerPersistenceBlocked = serverWorkspaceRecovery.status === "unavailable";
|
|
21957
|
+
const serverWorkspaceState = serverWorkspaceRecovery.state;
|
|
21958
|
+
lastWorkspacePersistenceSavedAt = Math.max(
|
|
21959
|
+
lastWorkspacePersistenceSavedAt,
|
|
21960
|
+
getWorkspaceStateSavedAt(sessionWorkspaceState),
|
|
21961
|
+
getWorkspaceStateSavedAt(serverWorkspaceState),
|
|
21962
|
+
);
|
|
21963
|
+
const persistedWorkspaceState = chooseNewestRestorableWorkspaceState([
|
|
21964
|
+
sessionWorkspaceState,
|
|
21965
|
+
serverWorkspaceState,
|
|
21966
|
+
]);
|
|
21896
21967
|
applyPersistedWorkspaceState(persistedWorkspaceState);
|
|
21897
21968
|
|
|
21898
21969
|
setEditorView(editorView);
|
|
21899
21970
|
setRightView(rightView);
|
|
21900
21971
|
renderSourcePreview();
|
|
21901
21972
|
workspacePersistenceReady = true;
|
|
21973
|
+
persistWorkspaceStateNow({ skipServer: serverWorkspaceRecovery.status === "unavailable" });
|
|
21902
21974
|
if (workspaceRestoredFromBrowser) {
|
|
21903
21975
|
setStatus("Restored editor workspace from this browser tab. Use Reset editor to discard it.", "success");
|
|
21904
21976
|
}
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
const PANE_FOCUS_PARAM = "paneFocus";
|
|
3
3
|
const PANE_FOCUS_OFF = "off";
|
|
4
4
|
const PANE_FOCUS_TARGETS = Object.freeze(["left", "right"]);
|
|
5
|
+
const STUDIO_TAB_STATE_PARAM = "studioTabState";
|
|
6
|
+
const STUDIO_TAB_STATE_ID_PATTERN = /^[a-zA-Z0-9_-]{20,128}$/;
|
|
7
|
+
const STUDIO_WORKSPACE_STORAGE_PREFIX = "piStudio.workspaceState.v2:";
|
|
8
|
+
const STUDIO_WORKSPACE_LEGACY_STORAGE_KEY = "piStudio.workspaceState.v1";
|
|
5
9
|
const STUDIO_LAUNCH_PROTOCOL_VERSION = 1;
|
|
6
10
|
const STUDIO_LAUNCH_CHANNEL_PREFIX = "pi-studio-launch-v1:";
|
|
7
11
|
const STUDIO_PENDING_KINDS = Object.freeze(["document", "preview", "export"]);
|
|
@@ -48,6 +52,98 @@
|
|
|
48
52
|
return true;
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
function isValidStudioTabStateId(value) {
|
|
56
|
+
return typeof value === "string" && STUDIO_TAB_STATE_ID_PATTERN.test(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeStudioTabStateId(cryptoLike) {
|
|
60
|
+
try {
|
|
61
|
+
const launchId = makeStudioLaunchId(cryptoLike);
|
|
62
|
+
const candidate = "tab_" + launchId;
|
|
63
|
+
if (isValidStudioTabStateId(candidate)) return candidate;
|
|
64
|
+
} catch {
|
|
65
|
+
// This ID isolates local browser state; it is not an authentication secret.
|
|
66
|
+
}
|
|
67
|
+
const candidate = "tab_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2).padEnd(16, "0");
|
|
68
|
+
if (isValidStudioTabStateId(candidate)) return candidate;
|
|
69
|
+
throw new Error("Could not create a Studio tab-state ID.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ensureStudioTabStateId(windowLike) {
|
|
73
|
+
if (!windowLike || !windowLike.location) throw new Error("Studio tab-state URL is unavailable.");
|
|
74
|
+
const currentHref = String(windowLike.location.href || "");
|
|
75
|
+
const url = new URL(currentHref);
|
|
76
|
+
const existing = url.searchParams.getAll(STUDIO_TAB_STATE_PARAM);
|
|
77
|
+
if (existing.length === 1 && isValidStudioTabStateId(existing[0])) return existing[0];
|
|
78
|
+
|
|
79
|
+
const tabStateId = makeStudioTabStateId(windowLike.crypto);
|
|
80
|
+
url.searchParams.delete(STUDIO_TAB_STATE_PARAM);
|
|
81
|
+
url.searchParams.set(STUDIO_TAB_STATE_PARAM, tabStateId);
|
|
82
|
+
if (windowLike.history && typeof windowLike.history.replaceState === "function") {
|
|
83
|
+
windowLike.history.replaceState(windowLike.history.state, "", url.toString());
|
|
84
|
+
}
|
|
85
|
+
return tabStateId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getStudioWorkspaceStorageKey(tabStateId) {
|
|
89
|
+
if (!isValidStudioTabStateId(tabStateId)) throw new Error("Invalid Studio tab-state ID.");
|
|
90
|
+
return STUDIO_WORKSPACE_STORAGE_PREFIX + tabStateId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getStudioSessionStorage(windowLike) {
|
|
94
|
+
try {
|
|
95
|
+
return windowLike && windowLike.sessionStorage ? windowLike.sessionStorage : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseStudioWorkspaceState(raw) {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(String(raw || ""));
|
|
104
|
+
if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || typeof parsed.text !== "string") return null;
|
|
105
|
+
return parsed;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readStudioWorkspaceState(windowLike, tabStateId) {
|
|
112
|
+
const key = getStudioWorkspaceStorageKey(tabStateId);
|
|
113
|
+
const sessionStorage = getStudioSessionStorage(windowLike);
|
|
114
|
+
if (!sessionStorage || typeof sessionStorage.getItem !== "function") return null;
|
|
115
|
+
try {
|
|
116
|
+
const current = parseStudioWorkspaceState(sessionStorage.getItem(key));
|
|
117
|
+
if (current) return current;
|
|
118
|
+
return parseStudioWorkspaceState(sessionStorage.getItem(STUDIO_WORKSPACE_LEGACY_STORAGE_KEY));
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function persistStudioWorkspaceState(windowLike, tabStateId, state) {
|
|
125
|
+
const key = getStudioWorkspaceStorageKey(tabStateId);
|
|
126
|
+
const sessionStorage = getStudioSessionStorage(windowLike);
|
|
127
|
+
if (!sessionStorage || typeof sessionStorage.setItem !== "function") return false;
|
|
128
|
+
try {
|
|
129
|
+
sessionStorage.setItem(key, JSON.stringify(state));
|
|
130
|
+
sessionStorage.removeItem(STUDIO_WORKSPACE_LEGACY_STORAGE_KEY);
|
|
131
|
+
return true;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function clearStudioWorkspaceState(windowLike, tabStateId) {
|
|
138
|
+
const key = getStudioWorkspaceStorageKey(tabStateId);
|
|
139
|
+
const sessionStorage = getStudioSessionStorage(windowLike);
|
|
140
|
+
if (!sessionStorage || typeof sessionStorage.removeItem !== "function") return;
|
|
141
|
+
try {
|
|
142
|
+
sessionStorage.removeItem(key);
|
|
143
|
+
sessionStorage.removeItem(STUDIO_WORKSPACE_LEGACY_STORAGE_KEY);
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
|
|
51
147
|
function normalizeStudioPendingKind(value) {
|
|
52
148
|
return STUDIO_PENDING_KINDS.includes(value) ? value : null;
|
|
53
149
|
}
|
|
@@ -490,6 +586,9 @@
|
|
|
490
586
|
PANE_FOCUS_OFF,
|
|
491
587
|
PANE_FOCUS_PARAM,
|
|
492
588
|
PANE_FOCUS_TARGETS,
|
|
589
|
+
STUDIO_TAB_STATE_PARAM,
|
|
590
|
+
STUDIO_WORKSPACE_LEGACY_STORAGE_KEY,
|
|
591
|
+
STUDIO_WORKSPACE_STORAGE_PREFIX,
|
|
493
592
|
STUDIO_LAUNCH_CHANNEL_PREFIX,
|
|
494
593
|
STUDIO_LAUNCH_DELIVERY_TIMEOUT_MS,
|
|
495
594
|
STUDIO_LAUNCH_MESSAGE_MAX_CHARS,
|
|
@@ -501,14 +600,21 @@
|
|
|
501
600
|
buildPendingStudioUrl,
|
|
502
601
|
canOpenPendingStudioLaunch,
|
|
503
602
|
createPendingStudioLaunch,
|
|
603
|
+
clearStudioWorkspaceState,
|
|
604
|
+
ensureStudioTabStateId,
|
|
605
|
+
getStudioWorkspaceStorageKey,
|
|
504
606
|
isValidStudioLaunchId,
|
|
607
|
+
isValidStudioTabStateId,
|
|
505
608
|
makeStudioLaunchId,
|
|
609
|
+
makeStudioTabStateId,
|
|
506
610
|
normalizePaneFocusTarget,
|
|
507
611
|
normalizeStudioLaunchMessage,
|
|
508
612
|
normalizeStudioPendingKind,
|
|
509
613
|
normalizeStudioRelativeTarget,
|
|
510
614
|
openStudioTabDirect,
|
|
615
|
+
persistStudioWorkspaceState,
|
|
511
616
|
readPaneFocusTarget,
|
|
617
|
+
readStudioWorkspaceState,
|
|
512
618
|
replacePaneFocusUrlState,
|
|
513
619
|
startStudioPendingPage,
|
|
514
620
|
studioLaunchChannelName,
|
package/index.ts
CHANGED
|
@@ -38,6 +38,11 @@ import {
|
|
|
38
38
|
isValidStudioLaunchId,
|
|
39
39
|
normalizeStudioPendingKind,
|
|
40
40
|
} from "./shared/studio-tab-launcher.js";
|
|
41
|
+
import {
|
|
42
|
+
createStudioWorkspaceStateStore,
|
|
43
|
+
isValidStudioTabStateId,
|
|
44
|
+
normalizeStudioWorkspaceRecoveryState,
|
|
45
|
+
} from "./shared/studio-workspace-state.js";
|
|
41
46
|
import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
|
|
42
47
|
import {
|
|
43
48
|
buildStudioMermaidCliIconArgs,
|
|
@@ -567,6 +572,12 @@ interface CancelRequestMessage {
|
|
|
567
572
|
requestId: string;
|
|
568
573
|
}
|
|
569
574
|
|
|
575
|
+
interface WorkspaceStateUpdateMessage {
|
|
576
|
+
type: "workspace_state_update";
|
|
577
|
+
tabStateId: string;
|
|
578
|
+
state: ReturnType<typeof normalizeStudioWorkspaceRecoveryState>;
|
|
579
|
+
}
|
|
580
|
+
|
|
570
581
|
type IncomingStudioMessage =
|
|
571
582
|
| HelloMessage
|
|
572
583
|
| PingMessage
|
|
@@ -600,7 +611,8 @@ type IncomingStudioMessage =
|
|
|
600
611
|
| QuartoPreviewStopRequestMessage
|
|
601
612
|
| GitChangesRequestMessage
|
|
602
613
|
| OpenEditorOnlyRequestMessage
|
|
603
|
-
| CancelRequestMessage
|
|
614
|
+
| CancelRequestMessage
|
|
615
|
+
| WorkspaceStateUpdateMessage;
|
|
604
616
|
|
|
605
617
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
606
618
|
const PREVIEW_RENDER_MAX_CHARS = 400_000;
|
|
@@ -619,6 +631,7 @@ const STUDIO_QUIZ_CONTEXT_MAX_FILES = 18;
|
|
|
619
631
|
const STUDIO_QUIZ_SNIPPET_MAX_CHARS = 8_000;
|
|
620
632
|
const STUDIO_QUIZ_DISCUSSION_MAX_CHARS = 6_000;
|
|
621
633
|
const REQUEST_BODY_MAX_BYTES = 1_000_000;
|
|
634
|
+
const STUDIO_WORKSPACE_STATE_REQUEST_MAX_BYTES = 4_000_000;
|
|
622
635
|
const RESPONSE_HISTORY_LIMIT = 30;
|
|
623
636
|
const CMUX_NOTIFY_TIMEOUT_MS = 1200;
|
|
624
637
|
const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
|
|
@@ -8498,6 +8511,10 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
8498
8511
|
|
|
8499
8512
|
if (msg.type === "hello") return { type: "hello" };
|
|
8500
8513
|
if (msg.type === "ping") return { type: "ping" };
|
|
8514
|
+
if (msg.type === "workspace_state_update" && typeof msg.tabStateId === "string" && isValidStudioTabStateId(msg.tabStateId)) {
|
|
8515
|
+
const state = normalizeStudioWorkspaceRecoveryState(msg.state);
|
|
8516
|
+
if (state) return { type: "workspace_state_update", tabStateId: msg.tabStateId, state };
|
|
8517
|
+
}
|
|
8501
8518
|
if (msg.type === "get_latest_response") return { type: "get_latest_response" };
|
|
8502
8519
|
if (msg.type === "get_trace_snapshot" && typeof msg.responseHistoryId === "string") {
|
|
8503
8520
|
return {
|
|
@@ -10938,6 +10955,7 @@ ${cssVarsBlock}
|
|
|
10938
10955
|
|
|
10939
10956
|
export default function (pi: ExtensionAPI) {
|
|
10940
10957
|
let serverState: StudioServerState | null = null;
|
|
10958
|
+
const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
|
|
10941
10959
|
let activeRequest: ActiveStudioRequest | null = null;
|
|
10942
10960
|
let studioDirectRunChain: StudioDirectRunChain | null = null;
|
|
10943
10961
|
let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
|
|
@@ -12591,6 +12609,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
12591
12609
|
sendToClient(client, { type: "pong", timestamp: Date.now() });
|
|
12592
12610
|
return;
|
|
12593
12611
|
}
|
|
12612
|
+
if (msg.type === "workspace_state_update") {
|
|
12613
|
+
studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
|
|
12614
|
+
return;
|
|
12615
|
+
}
|
|
12594
12616
|
|
|
12595
12617
|
emitDebugEvent("studio_message", {
|
|
12596
12618
|
type: msg.type,
|
|
@@ -14553,6 +14575,66 @@ export default function (pi: ExtensionAPI) {
|
|
|
14553
14575
|
return;
|
|
14554
14576
|
}
|
|
14555
14577
|
|
|
14578
|
+
if (requestUrl.pathname === "/tab-workspace-state") {
|
|
14579
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
14580
|
+
if (token !== serverState.token) {
|
|
14581
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
14582
|
+
return;
|
|
14583
|
+
}
|
|
14584
|
+
|
|
14585
|
+
void (async () => {
|
|
14586
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
14587
|
+
if (method === "GET") {
|
|
14588
|
+
const tabStateId = requestUrl.searchParams.get("tabStateId") ?? "";
|
|
14589
|
+
if (!isValidStudioTabStateId(tabStateId)) {
|
|
14590
|
+
respondJson(res, 400, { ok: false, error: "Invalid Studio tab-state ID." });
|
|
14591
|
+
return;
|
|
14592
|
+
}
|
|
14593
|
+
respondJson(res, 200, { ok: true, state: studioWorkspaceStateStore.get(tabStateId) });
|
|
14594
|
+
return;
|
|
14595
|
+
}
|
|
14596
|
+
|
|
14597
|
+
if (method !== "POST") {
|
|
14598
|
+
res.setHeader("Allow", "GET, POST");
|
|
14599
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET or POST." });
|
|
14600
|
+
return;
|
|
14601
|
+
}
|
|
14602
|
+
|
|
14603
|
+
let rawBody = "";
|
|
14604
|
+
try {
|
|
14605
|
+
rawBody = await readRequestBody(req, STUDIO_WORKSPACE_STATE_REQUEST_MAX_BYTES);
|
|
14606
|
+
} catch (error) {
|
|
14607
|
+
respondJson(res, 413, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
14608
|
+
return;
|
|
14609
|
+
}
|
|
14610
|
+
let payload: Record<string, unknown> = {};
|
|
14611
|
+
try {
|
|
14612
|
+
payload = rawBody ? JSON.parse(rawBody) as Record<string, unknown> : {};
|
|
14613
|
+
} catch {
|
|
14614
|
+
respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
|
|
14615
|
+
return;
|
|
14616
|
+
}
|
|
14617
|
+
const tabStateId = typeof payload.tabStateId === "string" ? payload.tabStateId : "";
|
|
14618
|
+
if (!isValidStudioTabStateId(tabStateId)) {
|
|
14619
|
+
respondJson(res, 400, { ok: false, error: "Invalid Studio tab-state ID." });
|
|
14620
|
+
return;
|
|
14621
|
+
}
|
|
14622
|
+
const state = normalizeStudioWorkspaceRecoveryState(payload.state);
|
|
14623
|
+
if (!state) {
|
|
14624
|
+
respondJson(res, 400, { ok: false, error: "Invalid or oversized Studio workspace state." });
|
|
14625
|
+
return;
|
|
14626
|
+
}
|
|
14627
|
+
const stored = studioWorkspaceStateStore.set(tabStateId, state);
|
|
14628
|
+
respondJson(res, 200, { ok: true, stored });
|
|
14629
|
+
})().catch((error) => {
|
|
14630
|
+
respondJson(res, 500, {
|
|
14631
|
+
ok: false,
|
|
14632
|
+
error: `Studio workspace recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
14633
|
+
});
|
|
14634
|
+
});
|
|
14635
|
+
return;
|
|
14636
|
+
}
|
|
14637
|
+
|
|
14556
14638
|
if (requestUrl.pathname === "/scratchpad-state") {
|
|
14557
14639
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
14558
14640
|
if (token !== serverState.token) {
|
|
@@ -15029,6 +15111,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15029
15111
|
await new Promise<void>((resolve) => {
|
|
15030
15112
|
state.server.close(() => resolve());
|
|
15031
15113
|
});
|
|
15114
|
+
studioWorkspaceStateStore.clear();
|
|
15032
15115
|
};
|
|
15033
15116
|
|
|
15034
15117
|
const hydrateLatestAssistant = (entries: SessionEntry[]) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.43",
|
|
4
4
|
"description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const STUDIO_TAB_STATE_ID_PATTERN = /^[a-zA-Z0-9_-]{20,128}$/;
|
|
2
|
+
export const STUDIO_WORKSPACE_STATE_MAX_TEXT_CHARS = 900_000;
|
|
3
|
+
export const STUDIO_WORKSPACE_STATE_MAX_ENTRIES = 16;
|
|
4
|
+
export const STUDIO_WORKSPACE_STATE_MAX_TOTAL_TEXT_CHARS = 3_000_000;
|
|
5
|
+
export const STUDIO_WORKSPACE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export function isValidStudioTabStateId(value) {
|
|
8
|
+
return typeof value === "string" && STUDIO_TAB_STATE_ID_PATTERN.test(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function boundedString(value, maxLength) {
|
|
12
|
+
return typeof value === "string" ? value.slice(0, maxLength) : "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function finiteNumber(value, fallback = 0) {
|
|
16
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeStudioWorkspaceRecoveryState(value) {
|
|
20
|
+
if (!value || typeof value !== "object" || value.version !== 1 || typeof value.text !== "string") return null;
|
|
21
|
+
if (value.text.length > STUDIO_WORKSPACE_STATE_MAX_TEXT_CHARS) return null;
|
|
22
|
+
const sourceState = value.sourceState && typeof value.sourceState === "object" ? value.sourceState : {};
|
|
23
|
+
return {
|
|
24
|
+
version: 1,
|
|
25
|
+
savedAt: Math.max(0, finiteNumber(value.savedAt)),
|
|
26
|
+
sourceState: {
|
|
27
|
+
source: boundedString(sourceState.source, 100),
|
|
28
|
+
label: boundedString(sourceState.label, 4_000),
|
|
29
|
+
path: boundedString(sourceState.path, 16_384) || null,
|
|
30
|
+
draftId: boundedString(sourceState.draftId, 256) || null,
|
|
31
|
+
},
|
|
32
|
+
resourceDir: boundedString(value.resourceDir, 16_384),
|
|
33
|
+
editorView: boundedString(value.editorView, 100),
|
|
34
|
+
rightView: boundedString(value.rightView, 100),
|
|
35
|
+
editorLanguage: boundedString(value.editorLanguage, 100),
|
|
36
|
+
followLatest: value.followLatest === true,
|
|
37
|
+
responseHistoryIndex: Math.floor(finiteNumber(value.responseHistoryIndex, -1)),
|
|
38
|
+
selectionStart: Math.max(0, Math.floor(finiteNumber(value.selectionStart))),
|
|
39
|
+
selectionEnd: Math.max(0, Math.floor(finiteNumber(value.selectionEnd))),
|
|
40
|
+
scrollTop: Math.max(0, finiteNumber(value.scrollTop)),
|
|
41
|
+
text: value.text,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createStudioWorkspaceStateStore(options = {}) {
|
|
46
|
+
const maxEntries = Math.max(1, Math.floor(Number(options.maxEntries) || STUDIO_WORKSPACE_STATE_MAX_ENTRIES));
|
|
47
|
+
const maxTotalTextChars = Math.max(1, Math.floor(Number(options.maxTotalTextChars) || STUDIO_WORKSPACE_STATE_MAX_TOTAL_TEXT_CHARS));
|
|
48
|
+
const ttlMs = Math.max(1, Math.floor(Number(options.ttlMs) || STUDIO_WORKSPACE_STATE_TTL_MS));
|
|
49
|
+
const now = typeof options.now === "function" ? options.now : Date.now;
|
|
50
|
+
const entries = new Map();
|
|
51
|
+
let totalTextChars = 0;
|
|
52
|
+
|
|
53
|
+
function remove(tabStateId) {
|
|
54
|
+
const existing = entries.get(tabStateId);
|
|
55
|
+
if (!existing) return false;
|
|
56
|
+
entries.delete(tabStateId);
|
|
57
|
+
totalTextChars = Math.max(0, totalTextChars - existing.state.text.length);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cleanup() {
|
|
62
|
+
const currentTime = now();
|
|
63
|
+
for (const [tabStateId, entry] of entries) {
|
|
64
|
+
if (currentTime - entry.storedAt > ttlMs) remove(tabStateId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function evictOldest(excludedTabStateId) {
|
|
69
|
+
let oldestId = null;
|
|
70
|
+
let oldestStoredAt = Number.POSITIVE_INFINITY;
|
|
71
|
+
for (const [tabStateId, entry] of entries) {
|
|
72
|
+
if (tabStateId === excludedTabStateId) continue;
|
|
73
|
+
if (entry.storedAt < oldestStoredAt) {
|
|
74
|
+
oldestId = tabStateId;
|
|
75
|
+
oldestStoredAt = entry.storedAt;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return oldestId ? remove(oldestId) : false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
get(tabStateId) {
|
|
83
|
+
if (!isValidStudioTabStateId(tabStateId)) return null;
|
|
84
|
+
cleanup();
|
|
85
|
+
return entries.get(tabStateId)?.state ?? null;
|
|
86
|
+
},
|
|
87
|
+
set(tabStateId, rawState) {
|
|
88
|
+
if (!isValidStudioTabStateId(tabStateId)) return false;
|
|
89
|
+
const state = normalizeStudioWorkspaceRecoveryState(rawState);
|
|
90
|
+
if (!state || state.text.length > maxTotalTextChars) return false;
|
|
91
|
+
cleanup();
|
|
92
|
+
const existing = entries.get(tabStateId);
|
|
93
|
+
if (existing && existing.state.savedAt > state.savedAt) return false;
|
|
94
|
+
if (existing) remove(tabStateId);
|
|
95
|
+
while (entries.size >= maxEntries || totalTextChars + state.text.length > maxTotalTextChars) {
|
|
96
|
+
if (!evictOldest(tabStateId)) return false;
|
|
97
|
+
}
|
|
98
|
+
entries.set(tabStateId, { state, storedAt: now() });
|
|
99
|
+
totalTextChars += state.text.length;
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
delete(tabStateId) {
|
|
103
|
+
return isValidStudioTabStateId(tabStateId) ? remove(tabStateId) : false;
|
|
104
|
+
},
|
|
105
|
+
clear() {
|
|
106
|
+
entries.clear();
|
|
107
|
+
totalTextChars = 0;
|
|
108
|
+
},
|
|
109
|
+
get size() {
|
|
110
|
+
cleanup();
|
|
111
|
+
return entries.size;
|
|
112
|
+
},
|
|
113
|
+
get totalTextChars() {
|
|
114
|
+
cleanup();
|
|
115
|
+
return totalTextChars;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|