pi-studio 0.9.41 → 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 +10 -0
- package/README.md +3 -2
- package/client/studio-client.js +114 -42
- package/client/studio-navigation-helpers.js +106 -0
- package/index.ts +87 -31
- package/package.json +1 -1
- package/shared/studio-browser-launcher.js +139 -0
- package/shared/studio-workspace-state.js +118 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ 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
|
+
|
|
12
|
+
## [0.9.42] — 2026-08-11
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Open Studio as a focused cmux browser surface in the caller’s workspace when launched from cmux, with a bounded system-browser fallback when cmux is unavailable or declines the request.
|
|
16
|
+
|
|
7
17
|
## [0.9.41] — 2026-08-11
|
|
8
18
|
|
|
9
19
|
### Fixed
|
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:
|
|
@@ -63,7 +63,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
|
|
|
63
63
|
|
|
64
64
|
| Command | Description |
|
|
65
65
|
|---|---|
|
|
66
|
-
| `/studio` | Open with last assistant response (fallback: blank) |
|
|
66
|
+
| `/studio` | Open in cmux when available, otherwise the system browser, with the last assistant response (fallback: blank) |
|
|
67
67
|
| `/studio <path>` | Open with file preloaded |
|
|
68
68
|
| `/studio --last` | Force last response |
|
|
69
69
|
| `/studio --blank` | Force blank editor |
|
|
@@ -150,6 +150,7 @@ Studio only passes icon-pack arguments when a diagram actually references `lucid
|
|
|
150
150
|
## Notes
|
|
151
151
|
|
|
152
152
|
- Local-only server (`127.0.0.1`) with tokenized Studio URLs.
|
|
153
|
+
- When Pi runs inside cmux, Studio opens as a focused cmux browser surface in the caller’s workspace. If cmux is unavailable or declines the request, Studio falls back to the system browser.
|
|
153
154
|
- For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter. If SSH is not auto-detected, use `/studio --no-browser`; for stable forwarding, use `/studio --port <port>` or combine them, e.g. `/studio --no-browser --port 3417`.
|
|
154
155
|
- Full Studio is a singleton per Pi session: use `/studio` to open it, `/studio-replace` to explicitly replace it, and `/studio-editor-only` for extra editing/preview tabs that do not take over the full Studio session view.
|
|
155
156
|
- Studio is designed as a complement to terminal pi, not a replacement.
|
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
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./shared/studio-markdown-latex-literals.js";
|
|
30
30
|
import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
|
|
31
31
|
import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
|
|
32
|
+
import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
|
|
32
33
|
import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
|
|
33
34
|
import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
|
|
34
35
|
import {
|
|
@@ -37,6 +38,11 @@ import {
|
|
|
37
38
|
isValidStudioLaunchId,
|
|
38
39
|
normalizeStudioPendingKind,
|
|
39
40
|
} from "./shared/studio-tab-launcher.js";
|
|
41
|
+
import {
|
|
42
|
+
createStudioWorkspaceStateStore,
|
|
43
|
+
isValidStudioTabStateId,
|
|
44
|
+
normalizeStudioWorkspaceRecoveryState,
|
|
45
|
+
} from "./shared/studio-workspace-state.js";
|
|
40
46
|
import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
|
|
41
47
|
import {
|
|
42
48
|
buildStudioMermaidCliIconArgs,
|
|
@@ -566,6 +572,12 @@ interface CancelRequestMessage {
|
|
|
566
572
|
requestId: string;
|
|
567
573
|
}
|
|
568
574
|
|
|
575
|
+
interface WorkspaceStateUpdateMessage {
|
|
576
|
+
type: "workspace_state_update";
|
|
577
|
+
tabStateId: string;
|
|
578
|
+
state: ReturnType<typeof normalizeStudioWorkspaceRecoveryState>;
|
|
579
|
+
}
|
|
580
|
+
|
|
569
581
|
type IncomingStudioMessage =
|
|
570
582
|
| HelloMessage
|
|
571
583
|
| PingMessage
|
|
@@ -599,7 +611,8 @@ type IncomingStudioMessage =
|
|
|
599
611
|
| QuartoPreviewStopRequestMessage
|
|
600
612
|
| GitChangesRequestMessage
|
|
601
613
|
| OpenEditorOnlyRequestMessage
|
|
602
|
-
| CancelRequestMessage
|
|
614
|
+
| CancelRequestMessage
|
|
615
|
+
| WorkspaceStateUpdateMessage;
|
|
603
616
|
|
|
604
617
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
605
618
|
const PREVIEW_RENDER_MAX_CHARS = 400_000;
|
|
@@ -618,6 +631,7 @@ const STUDIO_QUIZ_CONTEXT_MAX_FILES = 18;
|
|
|
618
631
|
const STUDIO_QUIZ_SNIPPET_MAX_CHARS = 8_000;
|
|
619
632
|
const STUDIO_QUIZ_DISCUSSION_MAX_CHARS = 6_000;
|
|
620
633
|
const REQUEST_BODY_MAX_BYTES = 1_000_000;
|
|
634
|
+
const STUDIO_WORKSPACE_STATE_REQUEST_MAX_BYTES = 4_000_000;
|
|
621
635
|
const RESPONSE_HISTORY_LIMIT = 30;
|
|
622
636
|
const CMUX_NOTIFY_TIMEOUT_MS = 1200;
|
|
623
637
|
const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
|
|
@@ -7544,27 +7558,6 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
|
|
|
7544
7558
|
}
|
|
7545
7559
|
}
|
|
7546
7560
|
|
|
7547
|
-
function openUrlInDefaultBrowser(url: string): Promise<void> {
|
|
7548
|
-
const openCommand =
|
|
7549
|
-
process.platform === "darwin"
|
|
7550
|
-
? { command: "open", args: [url] }
|
|
7551
|
-
: process.platform === "win32"
|
|
7552
|
-
? { command: "cmd", args: ["/c", "start", "", url] }
|
|
7553
|
-
: { command: "xdg-open", args: [url] };
|
|
7554
|
-
|
|
7555
|
-
return new Promise<void>((resolve, reject) => {
|
|
7556
|
-
const child = spawn(openCommand.command, openCommand.args, {
|
|
7557
|
-
stdio: "ignore",
|
|
7558
|
-
detached: true,
|
|
7559
|
-
});
|
|
7560
|
-
child.once("error", reject);
|
|
7561
|
-
child.once("spawn", () => {
|
|
7562
|
-
child.unref();
|
|
7563
|
-
resolve();
|
|
7564
|
-
});
|
|
7565
|
-
});
|
|
7566
|
-
}
|
|
7567
|
-
|
|
7568
7561
|
function openPathInDefaultViewer(path: string): Promise<void> {
|
|
7569
7562
|
const openCommand =
|
|
7570
7563
|
process.platform === "darwin"
|
|
@@ -8518,6 +8511,10 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
8518
8511
|
|
|
8519
8512
|
if (msg.type === "hello") return { type: "hello" };
|
|
8520
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
|
+
}
|
|
8521
8518
|
if (msg.type === "get_latest_response") return { type: "get_latest_response" };
|
|
8522
8519
|
if (msg.type === "get_trace_snapshot" && typeof msg.responseHistoryId === "string") {
|
|
8523
8520
|
return {
|
|
@@ -10958,6 +10955,7 @@ ${cssVarsBlock}
|
|
|
10958
10955
|
|
|
10959
10956
|
export default function (pi: ExtensionAPI) {
|
|
10960
10957
|
let serverState: StudioServerState | null = null;
|
|
10958
|
+
const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
|
|
10961
10959
|
let activeRequest: ActiveStudioRequest | null = null;
|
|
10962
10960
|
let studioDirectRunChain: StudioDirectRunChain | null = null;
|
|
10963
10961
|
let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
|
|
@@ -11328,14 +11326,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11328
11326
|
return null;
|
|
11329
11327
|
};
|
|
11330
11328
|
|
|
11331
|
-
const isProbablyCmuxSession = (): boolean =>
|
|
11332
|
-
const workspaceId = String(process.env.CMUX_WORKSPACE_ID ?? "").trim();
|
|
11333
|
-
if (workspaceId) return true;
|
|
11334
|
-
const termProgram = String(process.env.TERM_PROGRAM ?? "").trim().toLowerCase();
|
|
11335
|
-
if (termProgram === "cmux") return true;
|
|
11336
|
-
const term = String(process.env.TERM ?? "").trim().toLowerCase();
|
|
11337
|
-
return term.includes("cmux");
|
|
11338
|
-
};
|
|
11329
|
+
const isProbablyCmuxSession = (): boolean => isStudioCmuxSession(process.env);
|
|
11339
11330
|
|
|
11340
11331
|
const sanitizeTerminalNotificationText = (value: string, maxLength = 240): string => {
|
|
11341
11332
|
const sanitized = String(value)
|
|
@@ -12618,6 +12609,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
12618
12609
|
sendToClient(client, { type: "pong", timestamp: Date.now() });
|
|
12619
12610
|
return;
|
|
12620
12611
|
}
|
|
12612
|
+
if (msg.type === "workspace_state_update") {
|
|
12613
|
+
studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
|
|
12614
|
+
return;
|
|
12615
|
+
}
|
|
12621
12616
|
|
|
12622
12617
|
emitDebugEvent("studio_message", {
|
|
12623
12618
|
type: msg.type,
|
|
@@ -14580,6 +14575,66 @@ export default function (pi: ExtensionAPI) {
|
|
|
14580
14575
|
return;
|
|
14581
14576
|
}
|
|
14582
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
|
+
|
|
14583
14638
|
if (requestUrl.pathname === "/scratchpad-state") {
|
|
14584
14639
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
14585
14640
|
if (token !== serverState.token) {
|
|
@@ -15056,6 +15111,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15056
15111
|
await new Promise<void>((resolve) => {
|
|
15057
15112
|
state.server.close(() => resolve());
|
|
15058
15113
|
});
|
|
15114
|
+
studioWorkspaceStateStore.clear();
|
|
15059
15115
|
};
|
|
15060
15116
|
|
|
15061
15117
|
const hydrateLatestAssistant = (entries: SessionEntry[]) => {
|
|
@@ -15836,7 +15892,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15836
15892
|
const skipReason = launchOpenFlags.noBrowser ? "--no-browser was used" : "SSH was detected";
|
|
15837
15893
|
ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
|
|
15838
15894
|
} else {
|
|
15839
|
-
await
|
|
15895
|
+
await openStudioUrlInBrowser(url);
|
|
15840
15896
|
if (selected.source === "file") {
|
|
15841
15897
|
ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
|
|
15842
15898
|
} else if (selected.source === "last-response") {
|
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,139 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export const STUDIO_CMUX_BROWSER_OPEN_TIMEOUT_MS = 5_000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect whether the current process is running inside cmux.
|
|
7
|
+
*
|
|
8
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function isStudioCmuxSession(env = process.env) {
|
|
12
|
+
const workspaceId = String(env.CMUX_WORKSPACE_ID ?? "").trim();
|
|
13
|
+
const termProgram = String(env.TERM_PROGRAM ?? "").trim().toLowerCase();
|
|
14
|
+
const term = String(env.TERM ?? "").trim().toLowerCase();
|
|
15
|
+
const bundleId = String(env.CMUX_BUNDLE_ID ?? "").trim().toLowerCase();
|
|
16
|
+
return Boolean(workspaceId || termProgram === "cmux" || term.includes("cmux") || bundleId.includes("cmux"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the cmux CLI invocation for opening Studio in the caller's workspace.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} target
|
|
23
|
+
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
|
24
|
+
* @returns {{ command: string, args: string[] } | undefined}
|
|
25
|
+
*/
|
|
26
|
+
export function getStudioCmuxBrowserOpenCommand(target, env = process.env) {
|
|
27
|
+
if (!isStudioCmuxSession(env)) return undefined;
|
|
28
|
+
|
|
29
|
+
const workspaceId = String(env.CMUX_WORKSPACE_ID ?? "").trim();
|
|
30
|
+
const command = String(env.CMUX_BUNDLED_CLI_PATH ?? "").trim() || "cmux";
|
|
31
|
+
const args = ["browser", "open", target];
|
|
32
|
+
if (workspaceId) args.push("--workspace", workspaceId);
|
|
33
|
+
args.push("--focus", "true");
|
|
34
|
+
return { command, args };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the platform-native system-browser invocation.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} target
|
|
41
|
+
* @param {NodeJS.Platform} [platform]
|
|
42
|
+
* @returns {{ command: string, args: string[] }}
|
|
43
|
+
*/
|
|
44
|
+
export function getStudioDefaultBrowserOpenCommand(target, platform = process.platform) {
|
|
45
|
+
if (platform === "darwin") return { command: "open", args: [target] };
|
|
46
|
+
if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", target] };
|
|
47
|
+
return { command: "xdg-open", args: [target] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ command: string, args: string[] }} openCommand
|
|
52
|
+
* @param {typeof spawn} spawnProcess
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
function spawnDetachedBrowser(openCommand, spawnProcess) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let child;
|
|
58
|
+
try {
|
|
59
|
+
child = spawnProcess(openCommand.command, openCommand.args, {
|
|
60
|
+
stdio: "ignore",
|
|
61
|
+
detached: true,
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
reject(error);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
child.once("error", reject);
|
|
68
|
+
child.once("spawn", () => {
|
|
69
|
+
child.unref();
|
|
70
|
+
resolve();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Try to open Studio in a focused cmux browser surface.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} target
|
|
79
|
+
* @param {{
|
|
80
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
81
|
+
* spawnProcess?: typeof spawn,
|
|
82
|
+
* timeoutMs?: number,
|
|
83
|
+
* }} [options]
|
|
84
|
+
* @returns {Promise<boolean>}
|
|
85
|
+
*/
|
|
86
|
+
export async function tryOpenStudioUrlInCmuxBrowser(target, options = {}) {
|
|
87
|
+
const openCommand = getStudioCmuxBrowserOpenCommand(target, options.env ?? process.env);
|
|
88
|
+
if (!openCommand) return false;
|
|
89
|
+
|
|
90
|
+
const spawnProcess = options.spawnProcess ?? spawn;
|
|
91
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs >= 0
|
|
92
|
+
? options.timeoutMs
|
|
93
|
+
: STUDIO_CMUX_BROWSER_OPEN_TIMEOUT_MS;
|
|
94
|
+
|
|
95
|
+
return await new Promise((resolve) => {
|
|
96
|
+
let settled = false;
|
|
97
|
+
let child;
|
|
98
|
+
const finish = (opened) => {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
resolve(opened);
|
|
103
|
+
};
|
|
104
|
+
const timeout = setTimeout(() => {
|
|
105
|
+
child?.kill();
|
|
106
|
+
finish(false);
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
timeout.unref?.();
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
child = spawnProcess(openCommand.command, openCommand.args, { stdio: "ignore" });
|
|
112
|
+
} catch {
|
|
113
|
+
finish(false);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
child.once("error", () => finish(false));
|
|
117
|
+
child.once("close", (code) => finish(code === 0));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Open Studio in cmux when available, falling back to the system browser.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} target
|
|
125
|
+
* @param {{
|
|
126
|
+
* env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
|
|
127
|
+
* platform?: NodeJS.Platform,
|
|
128
|
+
* spawnProcess?: typeof spawn,
|
|
129
|
+
* timeoutMs?: number,
|
|
130
|
+
* }} [options]
|
|
131
|
+
* @returns {Promise<"cmux" | "system">}
|
|
132
|
+
*/
|
|
133
|
+
export async function openStudioUrlInBrowser(target, options = {}) {
|
|
134
|
+
if (await tryOpenStudioUrlInCmuxBrowser(target, options)) return "cmux";
|
|
135
|
+
|
|
136
|
+
const openCommand = getStudioDefaultBrowserOpenCommand(target, options.platform ?? process.platform);
|
|
137
|
+
await spawnDetachedBrowser(openCommand, options.spawnProcess ?? spawn);
|
|
138
|
+
return "system";
|
|
139
|
+
}
|
|
@@ -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
|
+
}
|