pi-studio 0.9.53 → 0.9.54
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 +14 -0
- package/README.md +7 -3
- package/ROADMAP.md +4 -4
- package/client/studio-client.js +794 -144
- package/client/studio.css +50 -0
- package/index.ts +445 -97
- package/package.json +1 -1
- package/shared/studio-disk-revisions.js +558 -0
- package/shared/studio-file-watcher.js +181 -0
- package/shared/studio-workspace-state.js +4 -0
package/index.ts
CHANGED
|
@@ -23,6 +23,12 @@ import {
|
|
|
23
23
|
transformStudioMarkdownOutsideFences,
|
|
24
24
|
} from "./shared/studio-annotation-scanner.js";
|
|
25
25
|
import { stripStudioMarkdownHtmlComments } from "./shared/studio-markdown-html-comments.js";
|
|
26
|
+
import { createStudioFileWatcher } from "./shared/studio-file-watcher.js";
|
|
27
|
+
import {
|
|
28
|
+
readStudioDiskFileSnapshot,
|
|
29
|
+
saveStudioDiskFileAs,
|
|
30
|
+
saveStudioDiskFileIfRevision,
|
|
31
|
+
} from "./shared/studio-disk-revisions.js";
|
|
26
32
|
import { normalizeStudioMarkdownSmartFences } from "./shared/studio-markdown-fences.js";
|
|
27
33
|
import {
|
|
28
34
|
extractStandaloneLatexDefinitionsFromMarkdown,
|
|
@@ -314,11 +320,24 @@ interface InitialStudioDocument {
|
|
|
314
320
|
path?: string;
|
|
315
321
|
draftId?: string;
|
|
316
322
|
resourceDir?: string;
|
|
323
|
+
diskRevision?: string;
|
|
324
|
+
watchFile?: boolean;
|
|
317
325
|
}
|
|
318
326
|
|
|
327
|
+
type StudioDiskWriteResult =
|
|
328
|
+
| { ok: true; path: string; revision: string; size: number }
|
|
329
|
+
| {
|
|
330
|
+
ok: false;
|
|
331
|
+
conflict?: boolean;
|
|
332
|
+
reason?: string;
|
|
333
|
+
path?: string;
|
|
334
|
+
currentRevision?: string | null;
|
|
335
|
+
message: string;
|
|
336
|
+
};
|
|
337
|
+
|
|
319
338
|
interface StudioLaunchSelection {
|
|
320
339
|
document: InitialStudioDocument;
|
|
321
|
-
kind: "document" | "pdf-preview";
|
|
340
|
+
kind: "document" | "pdf-preview" | "watched-preview";
|
|
322
341
|
mode?: StudioUiMode;
|
|
323
342
|
transient?: boolean;
|
|
324
343
|
skipWorkspaceRestore?: boolean;
|
|
@@ -719,12 +738,17 @@ interface SaveAsRequestMessage {
|
|
|
719
738
|
requestId: string;
|
|
720
739
|
path: string;
|
|
721
740
|
content: string;
|
|
741
|
+
overwrite?: boolean;
|
|
742
|
+
expectedRevision?: string;
|
|
722
743
|
}
|
|
723
744
|
|
|
724
745
|
interface SaveOverRequestMessage {
|
|
725
746
|
type: "save_over_request";
|
|
726
747
|
requestId: string;
|
|
748
|
+
path: string;
|
|
727
749
|
content: string;
|
|
750
|
+
expectedRevision?: string;
|
|
751
|
+
force?: boolean;
|
|
728
752
|
}
|
|
729
753
|
|
|
730
754
|
interface RefreshFromDiskRequestMessage {
|
|
@@ -733,6 +757,12 @@ interface RefreshFromDiskRequestMessage {
|
|
|
733
757
|
path?: string;
|
|
734
758
|
}
|
|
735
759
|
|
|
760
|
+
interface WatchFileSubscribeMessage {
|
|
761
|
+
type: "watch_file_subscribe";
|
|
762
|
+
path: string;
|
|
763
|
+
revision?: string;
|
|
764
|
+
}
|
|
765
|
+
|
|
736
766
|
interface SendToEditorRequestMessage {
|
|
737
767
|
type: "send_to_editor_request";
|
|
738
768
|
requestId: string;
|
|
@@ -821,6 +851,7 @@ type IncomingStudioMessage =
|
|
|
821
851
|
| SaveAsRequestMessage
|
|
822
852
|
| SaveOverRequestMessage
|
|
823
853
|
| RefreshFromDiskRequestMessage
|
|
854
|
+
| WatchFileSubscribeMessage
|
|
824
855
|
| SendToEditorRequestMessage
|
|
825
856
|
| GetFromEditorRequestMessage
|
|
826
857
|
| QuartoPreviewCheckRequestMessage
|
|
@@ -833,6 +864,7 @@ type IncomingStudioMessage =
|
|
|
833
864
|
|
|
834
865
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
835
866
|
const PREVIEW_RENDER_MAX_CHARS = 400_000;
|
|
867
|
+
const STUDIO_WATCHED_FILE_MAX_CLIENTS = 16;
|
|
836
868
|
const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
|
|
837
869
|
const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
|
|
838
870
|
const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
|
|
@@ -2463,8 +2495,8 @@ function resolveStudioPath(pathArg: string, cwd: string): { ok: true; resolved:
|
|
|
2463
2495
|
return { ok: true, resolved, label: normalized };
|
|
2464
2496
|
}
|
|
2465
2497
|
|
|
2466
|
-
function readStudioFile(pathArg: string, cwd: string):
|
|
2467
|
-
| { ok: true; text: string; label: string; resolvedPath: string }
|
|
2498
|
+
function readStudioFile(pathArg: string, cwd: string, options?: { requireCanonicalPath?: boolean }):
|
|
2499
|
+
| { ok: true; text: string; label: string; resolvedPath: string; diskRevision: string }
|
|
2468
2500
|
| { ok: false; message: string } {
|
|
2469
2501
|
const resolved = resolveStudioPath(pathArg, cwd);
|
|
2470
2502
|
if (resolved.ok === false) {
|
|
@@ -2472,39 +2504,41 @@ function readStudioFile(pathArg: string, cwd: string):
|
|
|
2472
2504
|
}
|
|
2473
2505
|
|
|
2474
2506
|
try {
|
|
2475
|
-
const
|
|
2476
|
-
if (
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
const buf = readFileSync(resolved.resolved);
|
|
2489
|
-
// Heuristic: check the first 8KB for binary indicators
|
|
2490
|
-
const sample = buf.subarray(0, 8192);
|
|
2507
|
+
const snapshot = readStudioDiskFileSnapshot(resolved.resolved);
|
|
2508
|
+
if (options?.requireCanonicalPath) {
|
|
2509
|
+
const requestedPath = resolve(resolved.resolved);
|
|
2510
|
+
const canonicalPath = resolve(snapshot.path);
|
|
2511
|
+
const pathsMatch = process.platform === "win32"
|
|
2512
|
+
? requestedPath.toLowerCase() === canonicalPath.toLowerCase()
|
|
2513
|
+
: requestedPath === canonicalPath;
|
|
2514
|
+
if (!pathsMatch) {
|
|
2515
|
+
return { ok: false, message: `Could not read file: ${resolved.label} (the file location now resolves somewhere else)` };
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
// Heuristic: check the first 8KB for binary indicators.
|
|
2519
|
+
const sample = snapshot.buffer.subarray(0, 8192);
|
|
2491
2520
|
let nulCount = 0;
|
|
2492
2521
|
let controlCount = 0;
|
|
2493
2522
|
for (let i = 0; i < sample.length; i++) {
|
|
2494
2523
|
const b = sample[i];
|
|
2495
2524
|
if (b === 0x00) nulCount++;
|
|
2496
|
-
// Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D)
|
|
2525
|
+
// Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D).
|
|
2497
2526
|
else if (b < 0x08 || (b > 0x0D && b < 0x20 && b !== 0x1B)) controlCount++;
|
|
2498
2527
|
}
|
|
2499
2528
|
if (nulCount > 0 || (sample.length > 0 && controlCount / sample.length > 0.1)) {
|
|
2500
2529
|
return { ok: false, message: `File appears to be binary: ${resolved.label}` };
|
|
2501
2530
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2531
|
+
return {
|
|
2532
|
+
ok: true,
|
|
2533
|
+
text: snapshot.buffer.toString("utf-8"),
|
|
2534
|
+
label: resolved.label,
|
|
2535
|
+
resolvedPath: snapshot.path,
|
|
2536
|
+
diskRevision: snapshot.revision,
|
|
2537
|
+
};
|
|
2504
2538
|
} catch (error) {
|
|
2505
2539
|
return {
|
|
2506
2540
|
ok: false,
|
|
2507
|
-
message: `
|
|
2541
|
+
message: `Could not read file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
|
|
2508
2542
|
};
|
|
2509
2543
|
}
|
|
2510
2544
|
}
|
|
@@ -2864,23 +2898,18 @@ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { file
|
|
|
2864
2898
|
}
|
|
2865
2899
|
}
|
|
2866
2900
|
|
|
2867
|
-
function writeStudioFile(pathArg: string, cwd: string, content: string):
|
|
2868
|
-
| { ok: true; label: string; resolvedPath: string }
|
|
2869
|
-
| { ok: false; message: string } {
|
|
2870
|
-
const
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
}
|
|
2879
|
-
return {
|
|
2880
|
-
ok: false,
|
|
2881
|
-
message: `Failed to write file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
|
|
2882
|
-
};
|
|
2883
|
-
}
|
|
2901
|
+
function writeStudioFile(pathArg: string, cwd: string, content: string, overwrite = false, expectedRevision?: string):
|
|
2902
|
+
| { ok: true; label: string; resolvedPath: string; diskRevision: string }
|
|
2903
|
+
| { ok: false; conflict?: boolean; reason?: string; path?: string; currentRevision?: string | null; message: string } {
|
|
2904
|
+
const normalizedLabel = normalizePathInput(pathArg);
|
|
2905
|
+
const result = saveStudioDiskFileAs({ path: pathArg, cwd, content, overwrite, expectedRevision }) as StudioDiskWriteResult;
|
|
2906
|
+
if (result.ok === false) return result;
|
|
2907
|
+
return {
|
|
2908
|
+
ok: true,
|
|
2909
|
+
label: normalizedLabel || basename(result.path),
|
|
2910
|
+
resolvedPath: result.path,
|
|
2911
|
+
diskRevision: result.revision,
|
|
2912
|
+
};
|
|
2884
2913
|
}
|
|
2885
2914
|
|
|
2886
2915
|
function writeStudioSideQuestionMarkdownFile(
|
|
@@ -7961,6 +7990,13 @@ async function respondLocalPreviewLinkJson(
|
|
|
7961
7990
|
}
|
|
7962
7991
|
|
|
7963
7992
|
const action = (requestUrl.searchParams.get("action") ?? "resolve").trim().toLowerCase();
|
|
7993
|
+
const originDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
|
|
7994
|
+
const originDocument = originDocId ? readTransientStudioDocument(originDocId) : null;
|
|
7995
|
+
const originatesFromWatchedPreview = requestUrl.searchParams.get("watchedFile") === "1" || originDocument?.watchFile === true;
|
|
7996
|
+
if (action === "document" && originatesFromWatchedPreview) {
|
|
7997
|
+
respondJson(res, 409, { ok: false, error: "Open here is unavailable in a watched preview. Open a separate editable file tab instead." });
|
|
7998
|
+
return;
|
|
7999
|
+
}
|
|
7964
8000
|
const basePayload = {
|
|
7965
8001
|
ok: true,
|
|
7966
8002
|
kind: resource.kind,
|
|
@@ -7990,10 +8026,14 @@ async function respondLocalPreviewLinkJson(
|
|
|
7990
8026
|
return;
|
|
7991
8027
|
}
|
|
7992
8028
|
|
|
7993
|
-
if (action !== "document" && action !== "editor-url") {
|
|
8029
|
+
if (action !== "document" && action !== "editor-url" && action !== "watch-url") {
|
|
7994
8030
|
respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
|
|
7995
8031
|
return;
|
|
7996
8032
|
}
|
|
8033
|
+
if (action === "watch-url" && resource.kind !== "text") {
|
|
8034
|
+
respondJson(res, 400, { ok: false, error: "Follow-changes preview is available only for readable text files." });
|
|
8035
|
+
return;
|
|
8036
|
+
}
|
|
7997
8037
|
if (resource.kind !== "text" && resource.kind !== "office") {
|
|
7998
8038
|
respondJson(res, 400, { ok: false, error: "This local resource is not a document Studio can load into the editor." });
|
|
7999
8039
|
return;
|
|
@@ -8038,6 +8078,7 @@ async function respondLocalPreviewLinkJson(
|
|
|
8038
8078
|
source: "file",
|
|
8039
8079
|
path: file.resolvedPath,
|
|
8040
8080
|
resourceDir: documentResourceDir,
|
|
8081
|
+
diskRevision: file.diskRevision,
|
|
8041
8082
|
};
|
|
8042
8083
|
}
|
|
8043
8084
|
if (action === "document") {
|
|
@@ -8047,10 +8088,18 @@ async function respondLocalPreviewLinkJson(
|
|
|
8047
8088
|
label: document.label,
|
|
8048
8089
|
converted,
|
|
8049
8090
|
resourceDir: documentResourceDir,
|
|
8091
|
+
diskRevision: document.diskRevision || null,
|
|
8050
8092
|
});
|
|
8051
8093
|
return;
|
|
8052
8094
|
}
|
|
8053
8095
|
|
|
8096
|
+
if (action === "watch-url") {
|
|
8097
|
+
if (responseText.length > PREVIEW_RENDER_MAX_CHARS) {
|
|
8098
|
+
respondJson(res, 413, { ok: false, error: `Watched preview source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.` });
|
|
8099
|
+
return;
|
|
8100
|
+
}
|
|
8101
|
+
document = { ...document, watchFile: true };
|
|
8102
|
+
}
|
|
8054
8103
|
const docId = storeTransientStudioDocument(document);
|
|
8055
8104
|
respondJson(res, 200, {
|
|
8056
8105
|
...basePayload,
|
|
@@ -9956,21 +10005,35 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
9956
10005
|
msg.type === "save_as_request" &&
|
|
9957
10006
|
typeof msg.requestId === "string" &&
|
|
9958
10007
|
typeof msg.path === "string" &&
|
|
9959
|
-
typeof msg.content === "string"
|
|
10008
|
+
typeof msg.content === "string" &&
|
|
10009
|
+
(msg.overwrite === undefined || typeof msg.overwrite === "boolean") &&
|
|
10010
|
+
(msg.expectedRevision === undefined || typeof msg.expectedRevision === "string")
|
|
9960
10011
|
) {
|
|
9961
10012
|
return {
|
|
9962
10013
|
type: "save_as_request",
|
|
9963
10014
|
requestId: msg.requestId,
|
|
9964
10015
|
path: msg.path,
|
|
9965
10016
|
content: msg.content,
|
|
10017
|
+
overwrite: msg.overwrite === true,
|
|
10018
|
+
expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
|
|
9966
10019
|
};
|
|
9967
10020
|
}
|
|
9968
10021
|
|
|
9969
|
-
if (
|
|
10022
|
+
if (
|
|
10023
|
+
msg.type === "save_over_request"
|
|
10024
|
+
&& typeof msg.requestId === "string"
|
|
10025
|
+
&& typeof msg.path === "string"
|
|
10026
|
+
&& typeof msg.content === "string"
|
|
10027
|
+
&& (msg.expectedRevision === undefined || typeof msg.expectedRevision === "string")
|
|
10028
|
+
&& (msg.force === undefined || typeof msg.force === "boolean")
|
|
10029
|
+
) {
|
|
9970
10030
|
return {
|
|
9971
10031
|
type: "save_over_request",
|
|
9972
10032
|
requestId: msg.requestId,
|
|
10033
|
+
path: msg.path,
|
|
9973
10034
|
content: msg.content,
|
|
10035
|
+
expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
|
|
10036
|
+
force: msg.force === true,
|
|
9974
10037
|
};
|
|
9975
10038
|
}
|
|
9976
10039
|
|
|
@@ -9986,6 +10049,19 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
9986
10049
|
};
|
|
9987
10050
|
}
|
|
9988
10051
|
|
|
10052
|
+
if (
|
|
10053
|
+
msg.type === "watch_file_subscribe"
|
|
10054
|
+
&& typeof msg.path === "string"
|
|
10055
|
+
&& msg.path.length <= 16_384
|
|
10056
|
+
&& (msg.revision === undefined || typeof msg.revision === "string")
|
|
10057
|
+
) {
|
|
10058
|
+
return {
|
|
10059
|
+
type: "watch_file_subscribe",
|
|
10060
|
+
path: msg.path,
|
|
10061
|
+
revision: typeof msg.revision === "string" ? msg.revision : undefined,
|
|
10062
|
+
};
|
|
10063
|
+
}
|
|
10064
|
+
|
|
9989
10065
|
if (msg.type === "send_to_editor_request" && typeof msg.requestId === "string" && typeof msg.content === "string") {
|
|
9990
10066
|
return {
|
|
9991
10067
|
type: "send_to_editor_request",
|
|
@@ -11322,6 +11398,7 @@ function buildStudioRelativeUrl(
|
|
|
11322
11398
|
if (doc?.path) params.set("docPath", doc.path);
|
|
11323
11399
|
if (doc?.draftId) params.set("draftId", doc.draftId);
|
|
11324
11400
|
if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
|
|
11401
|
+
if (doc?.watchFile) params.set("watchFile", "1");
|
|
11325
11402
|
if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
|
|
11326
11403
|
if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
|
|
11327
11404
|
return `/?${params.toString()}`;
|
|
@@ -11411,16 +11488,36 @@ function resolveRequestedStudioDocumentFromUrl(
|
|
|
11411
11488
|
const requestedLabel = (requestUrl.searchParams.get("docLabel") ?? "").trim();
|
|
11412
11489
|
const requestedDraftId = (requestUrl.searchParams.get("draftId") ?? "").trim();
|
|
11413
11490
|
const requestedResourceDir = (requestUrl.searchParams.get("resourceDir") ?? "").trim();
|
|
11491
|
+
const requestedWatchFile = requestUrl.searchParams.get("watchFile") === "1";
|
|
11414
11492
|
|
|
11415
11493
|
if (requestedPath) {
|
|
11416
11494
|
const file = readStudioFile(requestedPath, studioCwd);
|
|
11417
|
-
|
|
11495
|
+
const requestedCanonicalTextPath = resolve(requestedPath);
|
|
11496
|
+
const resolvedFilePath = file.ok !== false ? resolve(file.resolvedPath) : "";
|
|
11497
|
+
const watchedPathStayedCanonical = !requestedWatchFile || (
|
|
11498
|
+
process.platform === "win32"
|
|
11499
|
+
? requestedCanonicalTextPath.toLowerCase() === resolvedFilePath.toLowerCase()
|
|
11500
|
+
: requestedCanonicalTextPath === resolvedFilePath
|
|
11501
|
+
);
|
|
11502
|
+
if (file.ok !== false && watchedPathStayedCanonical) {
|
|
11418
11503
|
return {
|
|
11419
11504
|
text: file.text,
|
|
11420
11505
|
label: requestedLabel || file.label,
|
|
11421
11506
|
source: "file",
|
|
11422
11507
|
path: file.resolvedPath,
|
|
11423
11508
|
resourceDir: requestedResourceDir || fallback?.resourceDir || studioCwd,
|
|
11509
|
+
diskRevision: file.diskRevision,
|
|
11510
|
+
watchFile: requestedWatchFile,
|
|
11511
|
+
};
|
|
11512
|
+
}
|
|
11513
|
+
if (requestedWatchFile && isAbsolute(requestedPath)) {
|
|
11514
|
+
return {
|
|
11515
|
+
text: fallback?.watchFile && fallback.path === requestedPath ? fallback.text : "",
|
|
11516
|
+
label: requestedLabel || basename(requestedPath),
|
|
11517
|
+
source: "file",
|
|
11518
|
+
path: resolve(requestedPath),
|
|
11519
|
+
resourceDir: requestedResourceDir || dirname(resolve(requestedPath)),
|
|
11520
|
+
watchFile: true,
|
|
11424
11521
|
};
|
|
11425
11522
|
}
|
|
11426
11523
|
}
|
|
@@ -11705,6 +11802,8 @@ function buildStudioHtml(
|
|
|
11705
11802
|
const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
|
|
11706
11803
|
const initialDraftId = escapeHtmlForInline(initialDocument?.draftId ?? "");
|
|
11707
11804
|
const initialResourceDir = escapeHtmlForInline(initialDocument?.resourceDir ?? "");
|
|
11805
|
+
const initialDiskRevision = escapeHtmlForInline(initialDocument?.diskRevision ?? "");
|
|
11806
|
+
const initialWatchFile = initialDocument?.watchFile === true ? "1" : "0";
|
|
11708
11807
|
const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
|
|
11709
11808
|
const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
|
|
11710
11809
|
const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
|
|
@@ -11781,12 +11880,12 @@ ${cssVarsBlock}
|
|
|
11781
11880
|
</style>
|
|
11782
11881
|
<link rel="stylesheet" href="${stylesheetHref}" />
|
|
11783
11882
|
</head>
|
|
11784
|
-
<body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
|
|
11883
|
+
<body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-initial-disk-revision="${initialDiskRevision}" data-watched-file-preview="${initialWatchFile}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
|
|
11785
11884
|
<header id="studioHeader">
|
|
11786
11885
|
<h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
|
|
11787
11886
|
<div class="controls">
|
|
11788
|
-
<button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here
|
|
11789
|
-
<button id="saveOverBtn" type="button" title="
|
|
11887
|
+
<button id="saveAsBtn" type="button" title="Save editor content to a new file path. Shortcut: Cmd/Ctrl+Shift+S; Cmd/Ctrl+S falls back here for new drafts.">Save editor as…</button>
|
|
11888
|
+
<button id="saveOverBtn" type="button" title="Save only when the current file still matches Studio's disk revision. Shortcut: Cmd/Ctrl+S.">Save editor</button>
|
|
11790
11889
|
<button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
|
|
11791
11890
|
<button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
|
|
11792
11891
|
<button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
|
|
@@ -12020,6 +12119,7 @@ ${cssVarsBlock}
|
|
|
12020
12119
|
</div>
|
|
12021
12120
|
<div class="section-header-actions">
|
|
12022
12121
|
<button id="rightFocusBtn" class="pane-focus-btn" type="button" title="Show only the response pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
|
|
12122
|
+
<button id="watchedOpenEditableBtn" type="button" hidden title="Open this watched file in a separate editable Studio tab with conflict-safe saving.">Open editable tab</button>
|
|
12023
12123
|
<span id="exportPreviewControls" class="export-preview-controls">
|
|
12024
12124
|
<button id="exportPdfBtn" class="export-preview-trigger" type="button" aria-haspopup="menu" aria-expanded="false" title="Choose a format and export the current right-pane preview.">Export right preview</button>
|
|
12025
12125
|
<div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
|
|
@@ -12137,7 +12237,8 @@ ${cssVarsBlock}
|
|
|
12137
12237
|
<section class="shortcuts-group">
|
|
12138
12238
|
<h3>Editor</h3>
|
|
12139
12239
|
<dl>
|
|
12140
|
-
<div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
|
|
12240
|
+
<div><dt>Cmd/Ctrl+S</dt><dd>Save editor when the disk revision still matches</dd></div>
|
|
12241
|
+
<div><dt>Cmd/Ctrl+Shift+S</dt><dd>Save editor as a new file</dd></div>
|
|
12141
12242
|
<div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
|
|
12142
12243
|
<div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
|
|
12143
12244
|
<div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
|
|
@@ -12213,6 +12314,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
12213
12314
|
let serverState: StudioServerState | null = null;
|
|
12214
12315
|
const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
|
|
12215
12316
|
const studioResourceGrantRegistry = createStudioResourceGrantRegistry();
|
|
12317
|
+
const studioFileWatchers = new Map<WebSocket, ReturnType<typeof createStudioFileWatcher>>();
|
|
12318
|
+
const studioWatchedClientPaths = new Map<WebSocket, string>();
|
|
12319
|
+
const studioFileWatchSubscriptionGenerations = new Map<WebSocket, number>();
|
|
12216
12320
|
let activeRequest: ActiveStudioRequest | null = null;
|
|
12217
12321
|
let studioDirectRunChain: StudioDirectRunChain | null = null;
|
|
12218
12322
|
let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
|
|
@@ -12273,6 +12377,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
12273
12377
|
actionRequestId: null,
|
|
12274
12378
|
};
|
|
12275
12379
|
|
|
12380
|
+
const closeStudioFileWatcher = async (client: WebSocket): Promise<void> => {
|
|
12381
|
+
const watcher = studioFileWatchers.get(client);
|
|
12382
|
+
if (!watcher) return;
|
|
12383
|
+
studioFileWatchers.delete(client);
|
|
12384
|
+
await watcher.close().catch(() => undefined);
|
|
12385
|
+
};
|
|
12386
|
+
|
|
12387
|
+
const closeAllStudioFileWatchers = async (): Promise<void> => {
|
|
12388
|
+
const clients = Array.from(studioFileWatchers.keys());
|
|
12389
|
+
await Promise.all(clients.map((client) => closeStudioFileWatcher(client)));
|
|
12390
|
+
// Keep socket capability metadata until each socket is closed. Clearing it
|
|
12391
|
+
// while an authenticated socket is still open would briefly make a watched
|
|
12392
|
+
// preview look writable to the save handlers.
|
|
12393
|
+
};
|
|
12394
|
+
|
|
12276
12395
|
const recordStudioDocumentResourceGrants = (document: InitialStudioDocument, cwd = studioCwd) => {
|
|
12277
12396
|
if (document.path) {
|
|
12278
12397
|
try {
|
|
@@ -14393,6 +14512,92 @@ export default function (pi: ExtensionAPI) {
|
|
|
14393
14512
|
}
|
|
14394
14513
|
};
|
|
14395
14514
|
|
|
14515
|
+
const subscribeStudioWatchedFile = async (client: WebSocket, msg: WatchFileSubscribeMessage): Promise<void> => {
|
|
14516
|
+
const subscriptionGeneration = (studioFileWatchSubscriptionGenerations.get(client) ?? 0) + 1;
|
|
14517
|
+
studioFileWatchSubscriptionGenerations.set(client, subscriptionGeneration);
|
|
14518
|
+
await closeStudioFileWatcher(client);
|
|
14519
|
+
if (studioFileWatchSubscriptionGenerations.get(client) !== subscriptionGeneration || client.readyState !== WebSocket.OPEN) return;
|
|
14520
|
+
if (studioFileWatchers.size >= STUDIO_WATCHED_FILE_MAX_CLIENTS) {
|
|
14521
|
+
sendToClient(client, {
|
|
14522
|
+
type: "watched_file_error",
|
|
14523
|
+
path: msg.path,
|
|
14524
|
+
message: `Studio watched-file client limit reached (${STUDIO_WATCHED_FILE_MAX_CLIENTS}).`,
|
|
14525
|
+
});
|
|
14526
|
+
return;
|
|
14527
|
+
}
|
|
14528
|
+
const canonicalPath = resolve(msg.path);
|
|
14529
|
+
const clientWatchedPath = studioWatchedClientPaths.get(client) ?? "";
|
|
14530
|
+
if (clientWatchedPath !== canonicalPath) {
|
|
14531
|
+
sendToClient(client, {
|
|
14532
|
+
type: "watched_file_error",
|
|
14533
|
+
path: canonicalPath,
|
|
14534
|
+
message: "This socket is not authorized for that server-created watched preview.",
|
|
14535
|
+
});
|
|
14536
|
+
return;
|
|
14537
|
+
}
|
|
14538
|
+
|
|
14539
|
+
const watcher = createStudioFileWatcher({
|
|
14540
|
+
filePath: canonicalPath,
|
|
14541
|
+
initialRevision: msg.revision,
|
|
14542
|
+
readSnapshot: (filePath: string) => {
|
|
14543
|
+
const file = readStudioFile(filePath, dirname(filePath), { requireCanonicalPath: true });
|
|
14544
|
+
if (file.ok === false) throw new Error(file.message);
|
|
14545
|
+
if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
|
|
14546
|
+
throw new Error(`Watched file exceeds the ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()}-character preview limit.`);
|
|
14547
|
+
}
|
|
14548
|
+
return { path: file.resolvedPath, text: file.text, revision: file.diskRevision };
|
|
14549
|
+
},
|
|
14550
|
+
onUpdate: (snapshot: { path: string; text: string; revision: string }, details: { generation: number; recovered: boolean }) => {
|
|
14551
|
+
if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN) return;
|
|
14552
|
+
sendToClient(client, {
|
|
14553
|
+
type: "watched_file_update",
|
|
14554
|
+
path: snapshot.path,
|
|
14555
|
+
text: snapshot.text,
|
|
14556
|
+
diskRevision: snapshot.revision,
|
|
14557
|
+
generation: details.generation,
|
|
14558
|
+
recovered: details.recovered,
|
|
14559
|
+
message: `Updated ${basename(snapshot.path)} from disk.`,
|
|
14560
|
+
});
|
|
14561
|
+
},
|
|
14562
|
+
onError: (error: unknown, details: { generation: number; lastRevision: string | null }) => {
|
|
14563
|
+
if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN) return;
|
|
14564
|
+
sendToClient(client, {
|
|
14565
|
+
type: "watched_file_error",
|
|
14566
|
+
path: canonicalPath,
|
|
14567
|
+
diskRevision: details.lastRevision,
|
|
14568
|
+
generation: details.generation,
|
|
14569
|
+
message: `Could not refresh ${basename(canonicalPath)}; keeping the last good preview: ${error instanceof Error ? error.message : String(error)}`,
|
|
14570
|
+
});
|
|
14571
|
+
},
|
|
14572
|
+
onRecovered: (snapshot: { path: string; revision: string }, details: { generation: number; changed: boolean }) => {
|
|
14573
|
+
if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN || details.changed) return;
|
|
14574
|
+
sendToClient(client, {
|
|
14575
|
+
type: "watched_file_ready",
|
|
14576
|
+
path: snapshot.path,
|
|
14577
|
+
diskRevision: snapshot.revision,
|
|
14578
|
+
generation: details.generation,
|
|
14579
|
+
recovered: true,
|
|
14580
|
+
message: `Watching ${basename(snapshot.path)} for disk changes.`,
|
|
14581
|
+
});
|
|
14582
|
+
},
|
|
14583
|
+
});
|
|
14584
|
+
studioFileWatchers.set(client, watcher);
|
|
14585
|
+
await watcher.refresh();
|
|
14586
|
+
if (
|
|
14587
|
+
studioFileWatchers.get(client) === watcher
|
|
14588
|
+
&& client.readyState === WebSocket.OPEN
|
|
14589
|
+
&& !watcher.error
|
|
14590
|
+
) {
|
|
14591
|
+
sendToClient(client, {
|
|
14592
|
+
type: "watched_file_ready",
|
|
14593
|
+
path: canonicalPath,
|
|
14594
|
+
diskRevision: watcher.revision,
|
|
14595
|
+
generation: watcher.generation,
|
|
14596
|
+
message: `Watching ${basename(canonicalPath)} for disk changes.`,
|
|
14597
|
+
});
|
|
14598
|
+
}
|
|
14599
|
+
};
|
|
14600
|
+
|
|
14396
14601
|
const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage) => {
|
|
14397
14602
|
if (msg.type === "ping") {
|
|
14398
14603
|
sendToClient(client, { type: "pong", timestamp: Date.now() });
|
|
@@ -14402,6 +14607,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
14402
14607
|
studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
|
|
14403
14608
|
return;
|
|
14404
14609
|
}
|
|
14610
|
+
if (msg.type === "watch_file_subscribe") {
|
|
14611
|
+
void subscribeStudioWatchedFile(client, msg).catch((error) => {
|
|
14612
|
+
sendToClient(client, {
|
|
14613
|
+
type: "watched_file_error",
|
|
14614
|
+
path: msg.path,
|
|
14615
|
+
message: `Could not start watched preview: ${error instanceof Error ? error.message : String(error)}`,
|
|
14616
|
+
});
|
|
14617
|
+
});
|
|
14618
|
+
return;
|
|
14619
|
+
}
|
|
14405
14620
|
|
|
14406
14621
|
emitDebugEvent("studio_message", {
|
|
14407
14622
|
type: msg.type,
|
|
@@ -15372,6 +15587,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
15372
15587
|
}
|
|
15373
15588
|
|
|
15374
15589
|
if (msg.type === "save_as_request") {
|
|
15590
|
+
if (studioWatchedClientPaths.has(client)) {
|
|
15591
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Watched previews are read-only. Open an editable Studio tab before saving." });
|
|
15592
|
+
return;
|
|
15593
|
+
}
|
|
15375
15594
|
if (!isValidRequestId(msg.requestId)) {
|
|
15376
15595
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
15377
15596
|
return;
|
|
@@ -15380,14 +15599,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
15380
15599
|
sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
|
|
15381
15600
|
return;
|
|
15382
15601
|
}
|
|
15383
|
-
if (!msg.content.trim()) {
|
|
15384
|
-
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Nothing to save." });
|
|
15385
|
-
return;
|
|
15386
|
-
}
|
|
15387
15602
|
|
|
15388
|
-
const result = writeStudioFile(msg.path, studioCwd, msg.content);
|
|
15603
|
+
const result = writeStudioFile(msg.path, studioCwd, msg.content, msg.overwrite === true, msg.expectedRevision);
|
|
15389
15604
|
if (result.ok === false) {
|
|
15390
|
-
|
|
15605
|
+
if (result.conflict) {
|
|
15606
|
+
sendToClient(client, {
|
|
15607
|
+
type: "save_as_conflict",
|
|
15608
|
+
requestId: msg.requestId,
|
|
15609
|
+
path: result.path || msg.path,
|
|
15610
|
+
currentRevision: result.currentRevision || null,
|
|
15611
|
+
reason: result.reason || "target-exists",
|
|
15612
|
+
message: result.message,
|
|
15613
|
+
});
|
|
15614
|
+
} else {
|
|
15615
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
|
|
15616
|
+
}
|
|
15391
15617
|
return;
|
|
15392
15618
|
}
|
|
15393
15619
|
|
|
@@ -15397,6 +15623,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15397
15623
|
source: "file",
|
|
15398
15624
|
path: result.resolvedPath,
|
|
15399
15625
|
resourceDir: dirname(result.resolvedPath),
|
|
15626
|
+
diskRevision: result.diskRevision,
|
|
15400
15627
|
};
|
|
15401
15628
|
recordStudioDocumentResourceGrants(initialStudioDocument);
|
|
15402
15629
|
|
|
@@ -15406,12 +15633,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
15406
15633
|
path: result.resolvedPath,
|
|
15407
15634
|
label: result.label,
|
|
15408
15635
|
resourceDir: dirname(result.resolvedPath),
|
|
15636
|
+
diskRevision: result.diskRevision,
|
|
15409
15637
|
message: `Saved editor text to ${result.label}`,
|
|
15410
15638
|
});
|
|
15411
15639
|
return;
|
|
15412
15640
|
}
|
|
15413
15641
|
|
|
15414
15642
|
if (msg.type === "save_over_request") {
|
|
15643
|
+
if (studioWatchedClientPaths.has(client)) {
|
|
15644
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Watched previews are read-only. Open an editable Studio tab before saving." });
|
|
15645
|
+
return;
|
|
15646
|
+
}
|
|
15415
15647
|
if (!isValidRequestId(msg.requestId)) {
|
|
15416
15648
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
15417
15649
|
return;
|
|
@@ -15420,35 +15652,52 @@ export default function (pi: ExtensionAPI) {
|
|
|
15420
15652
|
sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
|
|
15421
15653
|
return;
|
|
15422
15654
|
}
|
|
15423
|
-
|
|
15424
|
-
|
|
15425
|
-
|
|
15426
|
-
|
|
15427
|
-
|
|
15428
|
-
|
|
15655
|
+
|
|
15656
|
+
const result = saveStudioDiskFileIfRevision({
|
|
15657
|
+
path: msg.path,
|
|
15658
|
+
content: msg.content,
|
|
15659
|
+
expectedRevision: msg.expectedRevision,
|
|
15660
|
+
force: msg.force === true,
|
|
15661
|
+
}) as StudioDiskWriteResult;
|
|
15662
|
+
if (result.ok === false) {
|
|
15663
|
+
if (result.conflict) {
|
|
15664
|
+
sendToClient(client, {
|
|
15665
|
+
type: "save_conflict",
|
|
15666
|
+
requestId: msg.requestId,
|
|
15667
|
+
path: result.path || msg.path,
|
|
15668
|
+
currentRevision: result.currentRevision || null,
|
|
15669
|
+
reason: result.reason || "disk-changed",
|
|
15670
|
+
canOverwrite: result.reason !== "location-changed" && result.reason !== "hard-linked-file",
|
|
15671
|
+
message: result.message,
|
|
15672
|
+
});
|
|
15673
|
+
} else {
|
|
15674
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
|
|
15675
|
+
}
|
|
15429
15676
|
return;
|
|
15430
15677
|
}
|
|
15431
15678
|
|
|
15432
|
-
|
|
15433
|
-
|
|
15434
|
-
|
|
15435
|
-
|
|
15436
|
-
|
|
15437
|
-
|
|
15438
|
-
|
|
15439
|
-
|
|
15440
|
-
|
|
15441
|
-
|
|
15442
|
-
|
|
15443
|
-
|
|
15444
|
-
|
|
15445
|
-
|
|
15446
|
-
|
|
15447
|
-
|
|
15448
|
-
|
|
15449
|
-
|
|
15450
|
-
|
|
15451
|
-
|
|
15679
|
+
const previousDocument = initialStudioDocument;
|
|
15680
|
+
const existingLabel = previousDocument?.path === result.path
|
|
15681
|
+
? previousDocument.label
|
|
15682
|
+
: basename(result.path);
|
|
15683
|
+
initialStudioDocument = {
|
|
15684
|
+
text: msg.content,
|
|
15685
|
+
label: existingLabel,
|
|
15686
|
+
source: "file",
|
|
15687
|
+
path: result.path,
|
|
15688
|
+
resourceDir: dirname(result.path),
|
|
15689
|
+
diskRevision: result.revision,
|
|
15690
|
+
};
|
|
15691
|
+
recordStudioDocumentResourceGrants(initialStudioDocument);
|
|
15692
|
+
sendToClient(client, {
|
|
15693
|
+
type: "saved",
|
|
15694
|
+
requestId: msg.requestId,
|
|
15695
|
+
path: result.path,
|
|
15696
|
+
label: existingLabel,
|
|
15697
|
+
resourceDir: dirname(result.path),
|
|
15698
|
+
diskRevision: result.revision,
|
|
15699
|
+
message: `Saved over ${existingLabel}`,
|
|
15700
|
+
});
|
|
15452
15701
|
return;
|
|
15453
15702
|
}
|
|
15454
15703
|
|
|
@@ -15472,7 +15721,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15472
15721
|
return;
|
|
15473
15722
|
}
|
|
15474
15723
|
|
|
15475
|
-
const refreshed = readStudioFile(refreshPath, studioCwd);
|
|
15724
|
+
const refreshed = readStudioFile(refreshPath, studioCwd, { requireCanonicalPath: true });
|
|
15476
15725
|
if (refreshed.ok === false) {
|
|
15477
15726
|
sendToClient(client, {
|
|
15478
15727
|
type: "error",
|
|
@@ -15488,6 +15737,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15488
15737
|
source: "file",
|
|
15489
15738
|
path: refreshed.resolvedPath,
|
|
15490
15739
|
resourceDir: dirname(refreshed.resolvedPath),
|
|
15740
|
+
diskRevision: refreshed.diskRevision,
|
|
15491
15741
|
};
|
|
15492
15742
|
if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
|
|
15493
15743
|
initialStudioDocument = refreshedDocument;
|
|
@@ -16855,7 +17105,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
16855
17105
|
studioCwd,
|
|
16856
17106
|
studioResourceGrantRegistry,
|
|
16857
17107
|
);
|
|
16858
|
-
await respondLocalPreviewLinkJson(
|
|
17108
|
+
await respondLocalPreviewLinkJson(
|
|
17109
|
+
req,
|
|
17110
|
+
res,
|
|
17111
|
+
requestUrl,
|
|
17112
|
+
resource,
|
|
17113
|
+
serverState,
|
|
17114
|
+
studioResourceGrantRegistry,
|
|
17115
|
+
);
|
|
16859
17116
|
} catch (error) {
|
|
16860
17117
|
if (error instanceof StudioResourceGrantRequiredError) {
|
|
16861
17118
|
respondStudioResourceGrantRequiredJson(res, error);
|
|
@@ -17019,6 +17276,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
17019
17276
|
const host = req.headers.host ?? `127.0.0.1:${state.port}`;
|
|
17020
17277
|
const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
|
|
17021
17278
|
const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
|
|
17279
|
+
const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
|
|
17280
|
+
const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
|
|
17281
|
+
const requestedDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
|
|
17282
|
+
const capabilityDocument = requestedDocId ? readTransientStudioDocument(requestedDocId) : null;
|
|
17283
|
+
const capabilityWatchedPath = capabilityDocument?.watchFile && capabilityDocument.path
|
|
17284
|
+
? resolve(capabilityDocument.path)
|
|
17285
|
+
: "";
|
|
17286
|
+
const comparableWatchedPath = process.platform === "win32" ? canonicalWatchedPath.toLowerCase() : canonicalWatchedPath;
|
|
17287
|
+
const comparableCapabilityPath = process.platform === "win32" ? capabilityWatchedPath.toLowerCase() : capabilityWatchedPath;
|
|
17288
|
+
const requestsWatchedCapability = Boolean(canonicalWatchedPath || capabilityWatchedPath);
|
|
17289
|
+
if (
|
|
17290
|
+
requestsWatchedCapability
|
|
17291
|
+
&& (
|
|
17292
|
+
clientMode !== "editor-only"
|
|
17293
|
+
|| !canonicalWatchedPath
|
|
17294
|
+
|| !capabilityWatchedPath
|
|
17295
|
+
|| comparableWatchedPath !== comparableCapabilityPath
|
|
17296
|
+
)
|
|
17297
|
+
) {
|
|
17298
|
+
try {
|
|
17299
|
+
ws.close(4003, "Watched preview authorization unavailable");
|
|
17300
|
+
} catch {
|
|
17301
|
+
// Ignore close errors.
|
|
17302
|
+
}
|
|
17303
|
+
return;
|
|
17304
|
+
}
|
|
17022
17305
|
if (clientMode === "full") {
|
|
17023
17306
|
for (const client of clients) {
|
|
17024
17307
|
if (client.readyState !== WebSocket.OPEN) continue;
|
|
@@ -17034,6 +17317,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17034
17317
|
}
|
|
17035
17318
|
clients.add(ws);
|
|
17036
17319
|
clientModes.set(ws, clientMode);
|
|
17320
|
+
if (canonicalWatchedPath) studioWatchedClientPaths.set(ws, canonicalWatchedPath);
|
|
17037
17321
|
emitDebugEvent("studio_ws_connected", { clients: clients.size, mode: clientMode });
|
|
17038
17322
|
broadcastState();
|
|
17039
17323
|
|
|
@@ -17046,15 +17330,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
17046
17330
|
handleStudioMessage(ws, parsed);
|
|
17047
17331
|
});
|
|
17048
17332
|
|
|
17049
|
-
|
|
17333
|
+
const cleanupClient = () => {
|
|
17050
17334
|
clients.delete(ws);
|
|
17051
17335
|
clientModes.delete(ws);
|
|
17336
|
+
studioWatchedClientPaths.delete(ws);
|
|
17337
|
+
const cleanupGeneration = (studioFileWatchSubscriptionGenerations.get(ws) ?? 0) + 1;
|
|
17338
|
+
studioFileWatchSubscriptionGenerations.set(ws, cleanupGeneration);
|
|
17339
|
+
void closeStudioFileWatcher(ws).finally(() => {
|
|
17340
|
+
if (studioFileWatchSubscriptionGenerations.get(ws) === cleanupGeneration) {
|
|
17341
|
+
studioFileWatchSubscriptionGenerations.delete(ws);
|
|
17342
|
+
}
|
|
17343
|
+
});
|
|
17344
|
+
};
|
|
17345
|
+
|
|
17346
|
+
ws.on("close", () => {
|
|
17347
|
+
cleanupClient();
|
|
17052
17348
|
emitDebugEvent("studio_ws_disconnected", { clients: clients.size });
|
|
17053
17349
|
});
|
|
17054
17350
|
|
|
17055
17351
|
ws.on("error", () => {
|
|
17056
|
-
|
|
17057
|
-
|
|
17352
|
+
// Make the socket unusable before removing its watched-path marker;
|
|
17353
|
+
// otherwise an error event could briefly weaken read-only enforcement.
|
|
17354
|
+
try {
|
|
17355
|
+
ws.terminate();
|
|
17356
|
+
} catch {
|
|
17357
|
+
try { ws.close(); } catch {}
|
|
17358
|
+
}
|
|
17359
|
+
cleanupClient();
|
|
17058
17360
|
});
|
|
17059
17361
|
});
|
|
17060
17362
|
|
|
@@ -17122,7 +17424,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
17122
17424
|
const stopServer = async () => {
|
|
17123
17425
|
studioSideQuestionStartRequestId = null;
|
|
17124
17426
|
await disposeStudioSideQuestionRuntime();
|
|
17125
|
-
|
|
17427
|
+
await closeAllStudioFileWatchers();
|
|
17428
|
+
if (!serverState) {
|
|
17429
|
+
studioWatchedClientPaths.clear();
|
|
17430
|
+
studioFileWatchSubscriptionGenerations.clear();
|
|
17431
|
+
return;
|
|
17432
|
+
}
|
|
17126
17433
|
clearStudioDirectRunState();
|
|
17127
17434
|
clearActiveRequest();
|
|
17128
17435
|
clearPendingStudioCompletion();
|
|
@@ -17148,6 +17455,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
17148
17455
|
await new Promise<void>((resolve) => {
|
|
17149
17456
|
state.server.close(() => resolve());
|
|
17150
17457
|
});
|
|
17458
|
+
studioWatchedClientPaths.clear();
|
|
17459
|
+
studioFileWatchSubscriptionGenerations.clear();
|
|
17151
17460
|
studioWorkspaceStateStore.clear();
|
|
17152
17461
|
studioResourceGrantRegistry.clear();
|
|
17153
17462
|
};
|
|
@@ -17166,6 +17475,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
17166
17475
|
await disposeStudioSideQuestionRuntime();
|
|
17167
17476
|
studioTraceHistory.clear();
|
|
17168
17477
|
lastCommandCtx = null;
|
|
17478
|
+
for (const client of studioWatchedClientPaths.keys()) {
|
|
17479
|
+
try { client.close(4001, "Watched preview session changed"); } catch {}
|
|
17480
|
+
}
|
|
17481
|
+
await closeAllStudioFileWatchers();
|
|
17482
|
+
transientStudioDocuments.clear();
|
|
17483
|
+
studioResourceGrantRegistry.clear();
|
|
17484
|
+
initialStudioDocument = null;
|
|
17169
17485
|
}
|
|
17170
17486
|
latestModelRequestCtx = ctx;
|
|
17171
17487
|
hydrateLatestAssistant(ctx.sessionManager.getBranch());
|
|
@@ -17517,7 +17833,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17517
17833
|
const resolveStudioLaunchDocument = (
|
|
17518
17834
|
trimmed: string,
|
|
17519
17835
|
ctx: ExtensionCommandContext,
|
|
17520
|
-
options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean },
|
|
17836
|
+
options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean; watchText?: boolean },
|
|
17521
17837
|
): StudioLaunchSelection | null => {
|
|
17522
17838
|
const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
|
|
17523
17839
|
const commandLabel = options?.commandLabel ?? "/studio";
|
|
@@ -17624,6 +17940,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
17624
17940
|
return null;
|
|
17625
17941
|
}
|
|
17626
17942
|
|
|
17943
|
+
if (options?.watchText) {
|
|
17944
|
+
if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
|
|
17945
|
+
ctx.ui.notify(`Watched preview source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.`, "error");
|
|
17946
|
+
return null;
|
|
17947
|
+
}
|
|
17948
|
+
return {
|
|
17949
|
+
document: {
|
|
17950
|
+
text: file.text,
|
|
17951
|
+
label: file.label,
|
|
17952
|
+
source: "file",
|
|
17953
|
+
path: file.resolvedPath,
|
|
17954
|
+
resourceDir: dirname(file.resolvedPath),
|
|
17955
|
+
diskRevision: file.diskRevision,
|
|
17956
|
+
watchFile: true,
|
|
17957
|
+
},
|
|
17958
|
+
kind: "watched-preview",
|
|
17959
|
+
mode: "editor-only",
|
|
17960
|
+
transient: true,
|
|
17961
|
+
skipWorkspaceRestore: true,
|
|
17962
|
+
paneFocus: "right",
|
|
17963
|
+
resourcePath: file.resolvedPath,
|
|
17964
|
+
};
|
|
17965
|
+
}
|
|
17966
|
+
|
|
17627
17967
|
if (file.text.length > 200_000) {
|
|
17628
17968
|
ctx.ui.notify(
|
|
17629
17969
|
"Loaded a large file. Studio critique requests currently reject documents over 200k characters.",
|
|
@@ -17637,6 +17977,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17637
17977
|
source: "file",
|
|
17638
17978
|
path: file.resolvedPath,
|
|
17639
17979
|
resourceDir: ctx.cwd,
|
|
17980
|
+
diskRevision: file.diskRevision,
|
|
17640
17981
|
});
|
|
17641
17982
|
};
|
|
17642
17983
|
|
|
@@ -17894,7 +18235,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17894
18235
|
trimmed: string,
|
|
17895
18236
|
ctx: ExtensionCommandContext,
|
|
17896
18237
|
mode: StudioUiMode,
|
|
17897
|
-
options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean },
|
|
18238
|
+
options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean; watchText?: boolean },
|
|
17898
18239
|
) => {
|
|
17899
18240
|
const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
|
|
17900
18241
|
if (launchOpenFlags.error) {
|
|
@@ -17910,11 +18251,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
17910
18251
|
const launchesPdfPreview = parsedLaunchPath
|
|
17911
18252
|
? Boolean(parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath)))
|
|
17912
18253
|
: false;
|
|
17913
|
-
if (launchOpenFlags.watchPdf && !
|
|
17914
|
-
ctx.ui.notify("--watch requires a local PDF path, for example: /studio --watch
|
|
18254
|
+
if (launchOpenFlags.watchPdf && !parsedLaunchPath) {
|
|
18255
|
+
ctx.ui.notify("--watch requires a local text or PDF path, for example: /studio --watch notes.md", "error");
|
|
17915
18256
|
return;
|
|
17916
18257
|
}
|
|
17917
|
-
const
|
|
18258
|
+
const launchesWatchedTextPreview = Boolean(launchOpenFlags.watchPdf && parsedLaunchPath && !launchesPdfPreview);
|
|
18259
|
+
const requestedLaunchMode: StudioUiMode = launchesPdfPreview || launchesWatchedTextPreview ? "editor-only" : mode;
|
|
17918
18260
|
if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
|
|
17919
18261
|
if (options?.replaceExistingFull) {
|
|
17920
18262
|
closeStudioClientsByMode("full", 4001, "Full Studio replaced");
|
|
@@ -17948,7 +18290,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
17948
18290
|
|
|
17949
18291
|
const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
|
|
17950
18292
|
...options,
|
|
17951
|
-
watchPdf: launchOpenFlags.watchPdf,
|
|
18293
|
+
watchPdf: launchOpenFlags.watchPdf && launchesPdfPreview,
|
|
18294
|
+
watchText: launchesWatchedTextPreview,
|
|
17952
18295
|
});
|
|
17953
18296
|
if (!selection) return;
|
|
17954
18297
|
const selected = selection.document;
|
|
@@ -17974,7 +18317,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17974
18317
|
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
17975
18318
|
const openedLabel = selection.kind === "pdf-preview"
|
|
17976
18319
|
? "pi Studio PDF preview"
|
|
17977
|
-
: (
|
|
18320
|
+
: (selection.kind === "watched-preview"
|
|
18321
|
+
? "pi Studio watched file preview"
|
|
18322
|
+
: (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio"));
|
|
17978
18323
|
|
|
17979
18324
|
const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
|
|
17980
18325
|
openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
|
|
@@ -17989,6 +18334,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
17989
18334
|
if (selection.kind === "pdf-preview") {
|
|
17990
18335
|
const watchLabel = launchOpenFlags.watchPdf ? " (auto-refresh on)" : "";
|
|
17991
18336
|
ctx.ui.notify(`Opened ${openedLabel}${watchLabel}: ${selection.resourcePath ?? selected.label}`, "info");
|
|
18337
|
+
} else if (selection.kind === "watched-preview") {
|
|
18338
|
+
ctx.ui.notify(`Opened ${openedLabel}: ${selection.resourcePath ?? selected.label}`, "info");
|
|
17992
18339
|
} else if (selected.source === "file") {
|
|
17993
18340
|
ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
|
|
17994
18341
|
} else if (selected.source === "last-response") {
|
|
@@ -18011,7 +18358,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
18011
18358
|
};
|
|
18012
18359
|
|
|
18013
18360
|
pi.registerCommand("studio", {
|
|
18014
|
-
description: "Open pi Studio browser UI or a
|
|
18361
|
+
description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser)",
|
|
18015
18362
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
18016
18363
|
const trimmed = args.trim();
|
|
18017
18364
|
|
|
@@ -18042,7 +18389,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
18042
18389
|
"Usage: /studio [path|--blank|--last]\n"
|
|
18043
18390
|
+ " /studio Open studio with last model response (fallback: blank)\n"
|
|
18044
18391
|
+ " /studio <path> Open a text file in Studio, or a PDF in a read-only companion preview\n"
|
|
18045
|
-
+ " /studio --watch <
|
|
18392
|
+
+ " /studio --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
|
|
18046
18393
|
+ " /studio --blank Open with blank editor\n"
|
|
18047
18394
|
+ " /studio --last Open with last model response\n"
|
|
18048
18395
|
+ " /studio --no-browser Print the Studio URL without opening a browser\n"
|
|
@@ -18093,7 +18440,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
18093
18440
|
});
|
|
18094
18441
|
|
|
18095
18442
|
pi.registerCommand("studio-editor-only", {
|
|
18096
|
-
description: "Open pi Studio in editor-only mode or
|
|
18443
|
+
description: "Open pi Studio in editor-only mode or a read-only watched preview (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --watch <file>)",
|
|
18097
18444
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
18098
18445
|
const trimmed = args.trim();
|
|
18099
18446
|
if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
|
|
@@ -18101,7 +18448,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
18101
18448
|
"Usage: /studio-editor-only [path|--blank|--last]\n"
|
|
18102
18449
|
+ " /studio-editor-only Open an editor-only Studio view (default: blank editor)\n"
|
|
18103
18450
|
+ " /studio-editor-only <path> Open a text file for editing, or a PDF in a read-only preview\n"
|
|
18104
|
-
+ " /studio-editor-only --watch <
|
|
18451
|
+
+ " /studio-editor-only --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
|
|
18105
18452
|
+ " /studio-editor-only --blank Open with blank editor\n"
|
|
18106
18453
|
+ " /studio-editor-only --last Open with last model response loaded into the editor\n"
|
|
18107
18454
|
+ " /studio-editor-only --no-browser Print URL without opening a browser\n"
|
|
@@ -18457,6 +18804,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
18457
18804
|
label: file.label,
|
|
18458
18805
|
source: "file",
|
|
18459
18806
|
path: file.resolvedPath,
|
|
18807
|
+
diskRevision: file.diskRevision,
|
|
18460
18808
|
};
|
|
18461
18809
|
initialStudioDocument = nextDoc;
|
|
18462
18810
|
recordStudioDocumentResourceGrants(nextDoc, ctx.cwd);
|