pi-studio 0.9.52 → 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 +32 -0
- package/README.md +15 -9
- package/ROADMAP.md +15 -2
- package/client/studio-client.js +1387 -245
- package/client/studio-preview-resource-helpers.js +61 -12
- package/client/studio.css +180 -3
- package/index.ts +991 -166
- package/package.json +1 -1
- package/shared/studio-disk-revisions.js +558 -0
- package/shared/studio-file-watcher.js +181 -0
- package/shared/studio-resource-grants.js +157 -0
- package/shared/studio-side-question-context.js +17 -4
- 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,
|
|
@@ -32,6 +38,7 @@ import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js"
|
|
|
32
38
|
import { parseStudioLocalPreviewPage, parseStudioPdfLaunchTarget } from "./shared/studio-local-preview-path.js";
|
|
33
39
|
import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
|
|
34
40
|
import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pandoc-resource-flag.js";
|
|
41
|
+
import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
|
|
35
42
|
import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
|
|
36
43
|
import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
|
|
37
44
|
import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
|
|
@@ -75,6 +82,7 @@ import {
|
|
|
75
82
|
} from "./shared/studio-side-question.js";
|
|
76
83
|
import {
|
|
77
84
|
STUDIO_SIDE_CONTEXT_MAX_OUTPUT_CHARS,
|
|
85
|
+
assertStudioSideQuestionRootStable,
|
|
78
86
|
formatStudioSideQuestionContextMap,
|
|
79
87
|
listStudioSideQuestionContext,
|
|
80
88
|
readStudioSideQuestionContextText,
|
|
@@ -312,11 +320,24 @@ interface InitialStudioDocument {
|
|
|
312
320
|
path?: string;
|
|
313
321
|
draftId?: string;
|
|
314
322
|
resourceDir?: string;
|
|
323
|
+
diskRevision?: string;
|
|
324
|
+
watchFile?: boolean;
|
|
315
325
|
}
|
|
316
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
|
+
|
|
317
338
|
interface StudioLaunchSelection {
|
|
318
339
|
document: InitialStudioDocument;
|
|
319
|
-
kind: "document" | "pdf-preview";
|
|
340
|
+
kind: "document" | "pdf-preview" | "watched-preview";
|
|
320
341
|
mode?: StudioUiMode;
|
|
321
342
|
transient?: boolean;
|
|
322
343
|
skipWorkspaceRestore?: boolean;
|
|
@@ -451,6 +472,8 @@ interface StudioSideQuestionToolDescriptor {
|
|
|
451
472
|
gateway: boolean;
|
|
452
473
|
}
|
|
453
474
|
|
|
475
|
+
type StudioSideQuestionContextRootInput = Pick<StudioSideQuestionContextInput, "sourcePath" | "resourceDir" | "gatherScope" | "contextPath">;
|
|
476
|
+
|
|
454
477
|
interface StudioSideQuestionContextInput {
|
|
455
478
|
focusKind: StudioSideQuestionFocusKind;
|
|
456
479
|
focusLabel: string;
|
|
@@ -715,12 +738,17 @@ interface SaveAsRequestMessage {
|
|
|
715
738
|
requestId: string;
|
|
716
739
|
path: string;
|
|
717
740
|
content: string;
|
|
741
|
+
overwrite?: boolean;
|
|
742
|
+
expectedRevision?: string;
|
|
718
743
|
}
|
|
719
744
|
|
|
720
745
|
interface SaveOverRequestMessage {
|
|
721
746
|
type: "save_over_request";
|
|
722
747
|
requestId: string;
|
|
748
|
+
path: string;
|
|
723
749
|
content: string;
|
|
750
|
+
expectedRevision?: string;
|
|
751
|
+
force?: boolean;
|
|
724
752
|
}
|
|
725
753
|
|
|
726
754
|
interface RefreshFromDiskRequestMessage {
|
|
@@ -729,6 +757,12 @@ interface RefreshFromDiskRequestMessage {
|
|
|
729
757
|
path?: string;
|
|
730
758
|
}
|
|
731
759
|
|
|
760
|
+
interface WatchFileSubscribeMessage {
|
|
761
|
+
type: "watch_file_subscribe";
|
|
762
|
+
path: string;
|
|
763
|
+
revision?: string;
|
|
764
|
+
}
|
|
765
|
+
|
|
732
766
|
interface SendToEditorRequestMessage {
|
|
733
767
|
type: "send_to_editor_request";
|
|
734
768
|
requestId: string;
|
|
@@ -817,6 +851,7 @@ type IncomingStudioMessage =
|
|
|
817
851
|
| SaveAsRequestMessage
|
|
818
852
|
| SaveOverRequestMessage
|
|
819
853
|
| RefreshFromDiskRequestMessage
|
|
854
|
+
| WatchFileSubscribeMessage
|
|
820
855
|
| SendToEditorRequestMessage
|
|
821
856
|
| GetFromEditorRequestMessage
|
|
822
857
|
| QuartoPreviewCheckRequestMessage
|
|
@@ -829,6 +864,7 @@ type IncomingStudioMessage =
|
|
|
829
864
|
|
|
830
865
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
831
866
|
const PREVIEW_RENDER_MAX_CHARS = 400_000;
|
|
867
|
+
const STUDIO_WATCHED_FILE_MAX_CLIENTS = 16;
|
|
832
868
|
const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
|
|
833
869
|
const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
|
|
834
870
|
const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
|
|
@@ -2459,8 +2495,8 @@ function resolveStudioPath(pathArg: string, cwd: string): { ok: true; resolved:
|
|
|
2459
2495
|
return { ok: true, resolved, label: normalized };
|
|
2460
2496
|
}
|
|
2461
2497
|
|
|
2462
|
-
function readStudioFile(pathArg: string, cwd: string):
|
|
2463
|
-
| { 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 }
|
|
2464
2500
|
| { ok: false; message: string } {
|
|
2465
2501
|
const resolved = resolveStudioPath(pathArg, cwd);
|
|
2466
2502
|
if (resolved.ok === false) {
|
|
@@ -2468,39 +2504,41 @@ function readStudioFile(pathArg: string, cwd: string):
|
|
|
2468
2504
|
}
|
|
2469
2505
|
|
|
2470
2506
|
try {
|
|
2471
|
-
const
|
|
2472
|
-
if (
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
const buf = readFileSync(resolved.resolved);
|
|
2485
|
-
// Heuristic: check the first 8KB for binary indicators
|
|
2486
|
-
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);
|
|
2487
2520
|
let nulCount = 0;
|
|
2488
2521
|
let controlCount = 0;
|
|
2489
2522
|
for (let i = 0; i < sample.length; i++) {
|
|
2490
2523
|
const b = sample[i];
|
|
2491
2524
|
if (b === 0x00) nulCount++;
|
|
2492
|
-
// Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D)
|
|
2525
|
+
// Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D).
|
|
2493
2526
|
else if (b < 0x08 || (b > 0x0D && b < 0x20 && b !== 0x1B)) controlCount++;
|
|
2494
2527
|
}
|
|
2495
2528
|
if (nulCount > 0 || (sample.length > 0 && controlCount / sample.length > 0.1)) {
|
|
2496
2529
|
return { ok: false, message: `File appears to be binary: ${resolved.label}` };
|
|
2497
2530
|
}
|
|
2498
|
-
|
|
2499
|
-
|
|
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
|
+
};
|
|
2500
2538
|
} catch (error) {
|
|
2501
2539
|
return {
|
|
2502
2540
|
ok: false,
|
|
2503
|
-
message: `
|
|
2541
|
+
message: `Could not read file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
|
|
2504
2542
|
};
|
|
2505
2543
|
}
|
|
2506
2544
|
}
|
|
@@ -2860,23 +2898,18 @@ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { file
|
|
|
2860
2898
|
}
|
|
2861
2899
|
}
|
|
2862
2900
|
|
|
2863
|
-
function writeStudioFile(pathArg: string, cwd: string, content: string):
|
|
2864
|
-
| { ok: true; label: string; resolvedPath: string }
|
|
2865
|
-
| { ok: false; message: string } {
|
|
2866
|
-
const
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
}
|
|
2875
|
-
return {
|
|
2876
|
-
ok: false,
|
|
2877
|
-
message: `Failed to write file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
|
|
2878
|
-
};
|
|
2879
|
-
}
|
|
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
|
+
};
|
|
2880
2913
|
}
|
|
2881
2914
|
|
|
2882
2915
|
function writeStudioSideQuestionMarkdownFile(
|
|
@@ -3103,12 +3136,13 @@ function buildStudioCompanionLabel(_label: string | undefined): string {
|
|
|
3103
3136
|
}
|
|
3104
3137
|
|
|
3105
3138
|
const STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES = 25 * 1024 * 1024;
|
|
3106
|
-
const
|
|
3139
|
+
const STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT = new Map<string, string>([
|
|
3107
3140
|
[".png", "image/png"],
|
|
3108
3141
|
[".jpg", "image/jpeg"],
|
|
3109
3142
|
[".jpeg", "image/jpeg"],
|
|
3110
3143
|
[".gif", "image/gif"],
|
|
3111
3144
|
[".webp", "image/webp"],
|
|
3145
|
+
[".pdf", "application/pdf"],
|
|
3112
3146
|
]);
|
|
3113
3147
|
const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
|
|
3114
3148
|
".md", ".markdown", ".mdx", ".qmd", ".txt", ".tex", ".latex", ".rst", ".adoc",
|
|
@@ -3133,9 +3167,37 @@ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
|
|
|
3133
3167
|
|
|
3134
3168
|
type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "office" | "other";
|
|
3135
3169
|
type StudioFileBrowserSortMode = "name" | "mtime-desc" | "mtime-asc" | "size-desc" | "size-asc";
|
|
3170
|
+
type StudioResourceGrantRegistry = ReturnType<typeof createStudioResourceGrantRegistry>;
|
|
3171
|
+
|
|
3172
|
+
class StudioResourceGrantRequiredError extends Error {
|
|
3173
|
+
readonly code = "studio-resource-grant-required";
|
|
3174
|
+
readonly filePath: string;
|
|
3175
|
+
readonly directoryPath: string;
|
|
3176
|
+
readonly resourceKind: StudioLocalPreviewResourceKind;
|
|
3177
|
+
|
|
3178
|
+
constructor(filePath: string, resourceKind: StudioLocalPreviewResourceKind) {
|
|
3179
|
+
super("This local resource is outside the locations currently available to Studio.");
|
|
3180
|
+
this.name = "StudioResourceGrantRequiredError";
|
|
3181
|
+
this.filePath = filePath;
|
|
3182
|
+
this.directoryPath = dirname(filePath);
|
|
3183
|
+
this.resourceKind = resourceKind;
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
class StudioDirectoryGrantRequiredError extends Error {
|
|
3188
|
+
readonly code = "studio-resource-directory-grant-required";
|
|
3189
|
+
readonly directoryPath: string;
|
|
3190
|
+
|
|
3191
|
+
constructor(directoryPath: string) {
|
|
3192
|
+
super("This folder is not available to the current Studio session.");
|
|
3193
|
+
this.name = "StudioDirectoryGrantRequiredError";
|
|
3194
|
+
this.directoryPath = directoryPath;
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3136
3197
|
|
|
3137
3198
|
interface StudioLocalPreviewResource {
|
|
3138
3199
|
filePath: string;
|
|
3200
|
+
referencePath: string;
|
|
3139
3201
|
label: string;
|
|
3140
3202
|
extension: string;
|
|
3141
3203
|
kind: StudioLocalPreviewResourceKind;
|
|
@@ -3154,6 +3216,11 @@ interface StudioFileBrowserEntry {
|
|
|
3154
3216
|
hidden: boolean;
|
|
3155
3217
|
}
|
|
3156
3218
|
|
|
3219
|
+
interface StudioFileBrowserLocation {
|
|
3220
|
+
path: string;
|
|
3221
|
+
label: string;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3157
3224
|
function normalizeStudioFileBrowserSortMode(sort: string | null | undefined): StudioFileBrowserSortMode {
|
|
3158
3225
|
const value = typeof sort === "string" ? sort.trim().toLowerCase() : "";
|
|
3159
3226
|
if (value === "mtime-desc" || value === "modified-desc" || value === "newest") return "mtime-desc";
|
|
@@ -3177,25 +3244,34 @@ function compareStudioFileBrowserEntries(a: StudioFileBrowserEntry, b: StudioFil
|
|
|
3177
3244
|
return compareStudioFileBrowserEntryNames(a, b);
|
|
3178
3245
|
}
|
|
3179
3246
|
|
|
3180
|
-
function resolveStudioPdfResourcePath(
|
|
3247
|
+
function resolveStudioPdfResourcePath(
|
|
3248
|
+
pdfPath: string | undefined,
|
|
3249
|
+
sourcePath: string | undefined,
|
|
3250
|
+
resourceDir: string | undefined,
|
|
3251
|
+
fallbackCwd: string,
|
|
3252
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
3253
|
+
): string {
|
|
3181
3254
|
const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
|
|
3182
3255
|
if (!rawPath) throw new Error("Missing PDF path.");
|
|
3183
3256
|
if (/\0/.test(rawPath)) throw new Error("Invalid PDF path.");
|
|
3184
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3257
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3185
3258
|
throw new Error("Only local PDF paths are supported.");
|
|
3186
3259
|
}
|
|
3187
3260
|
|
|
3188
3261
|
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3189
|
-
const cleanedPath =
|
|
3262
|
+
const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
|
|
3190
3263
|
const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
|
|
3191
3264
|
const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
|
|
3192
3265
|
if (extname(candidate).toLowerCase() !== ".pdf") throw new Error("Only .pdf files can be embedded.");
|
|
3193
3266
|
|
|
3194
|
-
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3195
3267
|
const candidateReal = realpathSync(candidate);
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3268
|
+
if (resourceGrants) {
|
|
3269
|
+
if (!resourceGrants.allows(candidateReal)) throw new StudioResourceGrantRequiredError(candidateReal, "pdf");
|
|
3270
|
+
} else {
|
|
3271
|
+
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3272
|
+
if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
|
|
3273
|
+
throw new Error("PDF path must stay within the current Studio resource directory.");
|
|
3274
|
+
}
|
|
3199
3275
|
}
|
|
3200
3276
|
|
|
3201
3277
|
const stat = statSync(candidateReal);
|
|
@@ -3216,6 +3292,22 @@ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
|
|
|
3216
3292
|
}
|
|
3217
3293
|
}
|
|
3218
3294
|
|
|
3295
|
+
function decodeStudioLocalPreviewResourceReference(resourcePath: string): string {
|
|
3296
|
+
const raw = String(resourcePath ?? "").trim();
|
|
3297
|
+
if (!/^file:/i.test(raw)) {
|
|
3298
|
+
return decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(raw));
|
|
3299
|
+
}
|
|
3300
|
+
try {
|
|
3301
|
+
const url = new URL(raw);
|
|
3302
|
+
if (url.protocol !== "file:") throw new Error("Not a file URL.");
|
|
3303
|
+
url.hash = "";
|
|
3304
|
+
url.search = "";
|
|
3305
|
+
return fileURLToPath(url);
|
|
3306
|
+
} catch {
|
|
3307
|
+
throw new Error("Invalid local file URL.");
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3219
3311
|
function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
|
|
3220
3312
|
const ext = extension.toLowerCase();
|
|
3221
3313
|
const name = basename(String(filePathOrName || "")).toLowerCase();
|
|
@@ -3231,60 +3323,128 @@ function resolveStudioLocalPreviewResourcePath(
|
|
|
3231
3323
|
sourcePath: string | undefined,
|
|
3232
3324
|
resourceDir: string | undefined,
|
|
3233
3325
|
fallbackCwd: string,
|
|
3326
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
3234
3327
|
): StudioLocalPreviewResource {
|
|
3235
3328
|
const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
|
|
3236
3329
|
if (!rawPath) throw new Error("Missing local resource path.");
|
|
3237
3330
|
if (/\0/.test(rawPath)) throw new Error("Invalid local resource path.");
|
|
3238
3331
|
if (/^\/\//.test(rawPath)) throw new Error("Network resources are not local Studio resources.");
|
|
3239
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3240
|
-
throw new Error("Only local
|
|
3332
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3333
|
+
throw new Error("Only local file paths and file URLs are supported.");
|
|
3241
3334
|
}
|
|
3242
3335
|
|
|
3243
3336
|
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3244
|
-
const cleanedPath =
|
|
3337
|
+
const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
|
|
3245
3338
|
if (!cleanedPath || cleanedPath.startsWith("#")) throw new Error("Missing local resource path.");
|
|
3246
3339
|
const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
|
|
3247
3340
|
const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
|
|
3248
3341
|
const extension = extname(candidate).toLowerCase();
|
|
3249
|
-
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3250
3342
|
const candidateReal = realpathSync(candidate);
|
|
3251
|
-
const rel = relative(boundaryReal, candidateReal);
|
|
3252
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
3253
|
-
throw new Error("Local resource path must stay within the current Studio resource directory.");
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
3343
|
const stat = statSync(candidateReal);
|
|
3257
3344
|
if (!stat.isFile()) throw new Error("Local resource path does not refer to a file.");
|
|
3345
|
+
|
|
3346
|
+
const kind = getStudioLocalPreviewResourceKind(extension, candidateReal);
|
|
3347
|
+
const matchingGrant = resourceGrants?.findGrant(candidateReal);
|
|
3348
|
+
let effectiveDirectory: string;
|
|
3349
|
+
if (resourceGrants) {
|
|
3350
|
+
if (!matchingGrant) throw new StudioResourceGrantRequiredError(candidateReal, kind);
|
|
3351
|
+
effectiveDirectory = matchingGrant.kind === "directory" ? matchingGrant.path : dirname(candidateReal);
|
|
3352
|
+
} else {
|
|
3353
|
+
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3354
|
+
if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
|
|
3355
|
+
throw new Error("Local resource path must stay within the current Studio resource directory.");
|
|
3356
|
+
}
|
|
3357
|
+
effectiveDirectory = boundaryReal;
|
|
3358
|
+
}
|
|
3359
|
+
const relativePath = relative(effectiveDirectory, candidateReal);
|
|
3360
|
+
const useRelativeReference = isPathInsideOrEqualDirectory(candidateReal, effectiveDirectory);
|
|
3258
3361
|
return {
|
|
3259
3362
|
filePath: candidateReal,
|
|
3260
|
-
|
|
3363
|
+
referencePath: useRelativeReference ? (relativePath || basename(candidateReal)) : candidateReal,
|
|
3364
|
+
label: useRelativeReference ? (relativePath || basename(candidateReal)) : basename(candidateReal),
|
|
3261
3365
|
extension,
|
|
3262
|
-
kind
|
|
3366
|
+
kind,
|
|
3263
3367
|
page: parseStudioLocalPreviewPage(rawPath),
|
|
3264
|
-
resourceDir:
|
|
3368
|
+
resourceDir: effectiveDirectory,
|
|
3265
3369
|
};
|
|
3266
3370
|
}
|
|
3267
3371
|
|
|
3372
|
+
function getStudioFileBrowserGrantLocations(resourceGrants: StudioResourceGrantRegistry): {
|
|
3373
|
+
locations: StudioFileBrowserLocation[];
|
|
3374
|
+
exactFiles: StudioFileBrowserEntry[];
|
|
3375
|
+
} {
|
|
3376
|
+
const grants = resourceGrants.snapshot();
|
|
3377
|
+
const directoryPaths: string[] = [];
|
|
3378
|
+
for (const grant of grants) {
|
|
3379
|
+
if (grant.kind !== "directory") continue;
|
|
3380
|
+
try {
|
|
3381
|
+
const currentReal = realpathSync(grant.path);
|
|
3382
|
+
if (currentReal === grant.path && statSync(currentReal).isDirectory()) directoryPaths.push(currentReal);
|
|
3383
|
+
} catch {
|
|
3384
|
+
// Missing or replaced directory grants are omitted rather than followed.
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
const locations = directoryPaths
|
|
3388
|
+
.map((path) => ({ path, label: basename(path) || path }))
|
|
3389
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
3390
|
+
const exactFiles: StudioFileBrowserEntry[] = [];
|
|
3391
|
+
for (const grant of grants) {
|
|
3392
|
+
if (grant.kind !== "file") continue;
|
|
3393
|
+
if (directoryPaths.some((directoryPath) => isPathInsideOrEqualDirectory(grant.path, directoryPath))) continue;
|
|
3394
|
+
try {
|
|
3395
|
+
const currentReal = realpathSync(grant.path);
|
|
3396
|
+
if (currentReal !== grant.path) continue;
|
|
3397
|
+
const stat = statSync(currentReal);
|
|
3398
|
+
if (!stat.isFile()) continue;
|
|
3399
|
+
const name = basename(currentReal);
|
|
3400
|
+
const extension = extname(currentReal).toLowerCase();
|
|
3401
|
+
exactFiles.push({
|
|
3402
|
+
name,
|
|
3403
|
+
path: currentReal,
|
|
3404
|
+
type: "file",
|
|
3405
|
+
extension,
|
|
3406
|
+
kind: getStudioLocalPreviewResourceKind(extension, currentReal),
|
|
3407
|
+
size: stat.size,
|
|
3408
|
+
mtimeMs: stat.mtimeMs,
|
|
3409
|
+
hidden: name.startsWith("."),
|
|
3410
|
+
});
|
|
3411
|
+
} catch {
|
|
3412
|
+
// Missing exact-file grants remain bounded but are omitted from the Files view.
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
exactFiles.sort((a, b) => compareStudioFileBrowserEntries(a, b, "name"));
|
|
3416
|
+
return { locations, exactFiles };
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3268
3419
|
function resolveStudioFileBrowserDirectory(
|
|
3269
3420
|
dirPath: string | undefined,
|
|
3421
|
+
rootPath: string | undefined,
|
|
3270
3422
|
sourcePath: string | undefined,
|
|
3271
3423
|
resourceDir: string | undefined,
|
|
3272
3424
|
fallbackCwd: string,
|
|
3425
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
3273
3426
|
): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null } {
|
|
3274
3427
|
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3275
|
-
const
|
|
3428
|
+
const rawRoot = typeof rootPath === "string" ? rootPath.trim() : "";
|
|
3429
|
+
const expandedRoot = recoverLikelyDroppedLeadingSlashPath(expandHome(rawRoot || context.boundaryDir));
|
|
3430
|
+
const requestedRoot = isAbsolute(expandedRoot) ? expandedRoot : resolve(fallbackCwd, expandedRoot);
|
|
3431
|
+
const requestedRootReal = realpathSync(requestedRoot);
|
|
3432
|
+
if (!statSync(requestedRootReal).isDirectory()) throw new Error("File browser root does not refer to a directory.");
|
|
3433
|
+
const matchingRootGrant = resourceGrants.findGrant(requestedRootReal, { cwd: fallbackCwd });
|
|
3434
|
+
if (!matchingRootGrant || matchingRootGrant.kind !== "directory") {
|
|
3435
|
+
throw new StudioDirectoryGrantRequiredError(requestedRootReal);
|
|
3436
|
+
}
|
|
3437
|
+
const rootReal = matchingRootGrant.path;
|
|
3276
3438
|
const rawDir = typeof dirPath === "string" ? dirPath.trim() : "";
|
|
3277
|
-
const
|
|
3439
|
+
const expandedDir = recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir));
|
|
3278
3440
|
const requested = rawDir
|
|
3279
|
-
? (isAbsolute(
|
|
3280
|
-
|
|
3281
|
-
: resolve(baseDir, recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))))
|
|
3282
|
-
: baseDir;
|
|
3441
|
+
? (isAbsolute(expandedDir) ? expandedDir : resolve(rootReal, expandedDir))
|
|
3442
|
+
: rootReal;
|
|
3283
3443
|
const currentReal = realpathSync(requested);
|
|
3284
3444
|
const currentStat = statSync(currentReal);
|
|
3285
3445
|
if (!currentStat.isDirectory()) throw new Error("File browser path does not refer to a directory.");
|
|
3286
3446
|
if (!isPathInsideOrEqualDirectory(currentReal, rootReal)) {
|
|
3287
|
-
throw new Error("File browser path must stay within
|
|
3447
|
+
throw new Error("File browser path must stay within an allowed Studio folder.");
|
|
3288
3448
|
}
|
|
3289
3449
|
const parent = dirname(currentReal);
|
|
3290
3450
|
const parentDir = parent !== currentReal && isPathInsideOrEqualDirectory(parent, rootReal) ? parent : null;
|
|
@@ -3294,12 +3454,14 @@ function resolveStudioFileBrowserDirectory(
|
|
|
3294
3454
|
|
|
3295
3455
|
function listStudioFileBrowserDirectory(
|
|
3296
3456
|
dirPath: string | undefined,
|
|
3457
|
+
rootPath: string | undefined,
|
|
3297
3458
|
sourcePath: string | undefined,
|
|
3298
3459
|
resourceDir: string | undefined,
|
|
3299
3460
|
fallbackCwd: string,
|
|
3461
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
3300
3462
|
sortMode?: string | null,
|
|
3301
|
-
): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
|
|
3302
|
-
const context = resolveStudioFileBrowserDirectory(dirPath, sourcePath, resourceDir, fallbackCwd);
|
|
3463
|
+
): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; exactFiles: StudioFileBrowserEntry[]; locations: StudioFileBrowserLocation[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
|
|
3464
|
+
const context = resolveStudioFileBrowserDirectory(dirPath, rootPath, sourcePath, resourceDir, fallbackCwd, resourceGrants);
|
|
3303
3465
|
const sort = normalizeStudioFileBrowserSortMode(sortMode);
|
|
3304
3466
|
const entries: StudioFileBrowserEntry[] = [];
|
|
3305
3467
|
let omitted = 0;
|
|
@@ -3342,7 +3504,7 @@ function listStudioFileBrowserDirectory(
|
|
|
3342
3504
|
entries.sort((a, b) => compareStudioFileBrowserEntries(a, b, sort));
|
|
3343
3505
|
const limitedEntries = entries.slice(0, STUDIO_FILE_BROWSER_MAX_ENTRIES);
|
|
3344
3506
|
omitted += Math.max(0, entries.length - limitedEntries.length);
|
|
3345
|
-
return { ...context, entries: limitedEntries, omitted, omittedIgnored, sort };
|
|
3507
|
+
return { ...context, ...getStudioFileBrowserGrantLocations(resourceGrants), entries: limitedEntries, omitted, omittedIgnored, sort };
|
|
3346
3508
|
}
|
|
3347
3509
|
|
|
3348
3510
|
function resolveStudioHtmlPreviewResourcePath(
|
|
@@ -3350,27 +3512,32 @@ function resolveStudioHtmlPreviewResourcePath(
|
|
|
3350
3512
|
sourcePath: string | undefined,
|
|
3351
3513
|
resourceDir: string | undefined,
|
|
3352
3514
|
fallbackCwd: string,
|
|
3515
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
3353
3516
|
): { filePath: string; mimeType: string } {
|
|
3354
3517
|
const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
|
|
3355
3518
|
if (!rawPath) throw new Error("Missing HTML preview resource path.");
|
|
3356
3519
|
if (/\0/.test(rawPath)) throw new Error("Invalid HTML preview resource path.");
|
|
3357
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3520
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
3358
3521
|
throw new Error("Only local HTML preview resources are supported.");
|
|
3359
3522
|
}
|
|
3360
3523
|
|
|
3361
3524
|
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3362
|
-
const cleanedPath =
|
|
3525
|
+
const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
|
|
3363
3526
|
const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
|
|
3364
3527
|
const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
|
|
3365
3528
|
const ext = extname(candidate).toLowerCase();
|
|
3366
|
-
const mimeType =
|
|
3367
|
-
if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and
|
|
3529
|
+
const mimeType = STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT.get(ext);
|
|
3530
|
+
if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, WebP, and PDF media can be embedded in Studio previews.");
|
|
3368
3531
|
|
|
3369
|
-
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3370
3532
|
const candidateReal = realpathSync(candidate);
|
|
3371
|
-
const
|
|
3372
|
-
if (
|
|
3373
|
-
throw new
|
|
3533
|
+
const kind: StudioLocalPreviewResourceKind = ext === ".pdf" ? "pdf" : "image";
|
|
3534
|
+
if (resourceGrants) {
|
|
3535
|
+
if (!resourceGrants.allows(candidateReal)) throw new StudioResourceGrantRequiredError(candidateReal, kind);
|
|
3536
|
+
} else {
|
|
3537
|
+
const boundaryReal = realpathSync(context.boundaryDir);
|
|
3538
|
+
if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
|
|
3539
|
+
throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
|
|
3540
|
+
}
|
|
3374
3541
|
}
|
|
3375
3542
|
|
|
3376
3543
|
const stat = statSync(candidateReal);
|
|
@@ -3381,6 +3548,39 @@ function resolveStudioHtmlPreviewResourcePath(
|
|
|
3381
3548
|
return { filePath: candidateReal, mimeType };
|
|
3382
3549
|
}
|
|
3383
3550
|
|
|
3551
|
+
function resolveStudioAuthorizedPreviewRenderContext(
|
|
3552
|
+
sourcePath: string | undefined,
|
|
3553
|
+
resourceDir: string | undefined,
|
|
3554
|
+
fallbackCwd: string,
|
|
3555
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
3556
|
+
): { resourcePath?: string; sourcePath?: string } {
|
|
3557
|
+
const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
|
|
3558
|
+
let resourcePath: string;
|
|
3559
|
+
try {
|
|
3560
|
+
resourcePath = realpathSync(context.baseDir);
|
|
3561
|
+
if (!statSync(resourcePath).isDirectory()) return {};
|
|
3562
|
+
} catch {
|
|
3563
|
+
return {};
|
|
3564
|
+
}
|
|
3565
|
+
const directoryGrant = resourceGrants.findGrant(resourcePath, { cwd: fallbackCwd });
|
|
3566
|
+
if (!directoryGrant || directoryGrant.kind !== "directory") return {};
|
|
3567
|
+
|
|
3568
|
+
const rawSource = typeof sourcePath === "string" ? sourcePath.trim() : "";
|
|
3569
|
+
if (!rawSource) return { resourcePath };
|
|
3570
|
+
try {
|
|
3571
|
+
const expandedSource = recoverLikelyDroppedLeadingSlashPath(expandHome(rawSource));
|
|
3572
|
+
const requestedSource = isAbsolute(expandedSource) ? expandedSource : resolve(fallbackCwd, expandedSource);
|
|
3573
|
+
const canonicalSource = realpathSync(requestedSource);
|
|
3574
|
+
const sourceGrant = resourceGrants.findGrant(dirname(canonicalSource), { cwd: fallbackCwd });
|
|
3575
|
+
if (statSync(canonicalSource).isFile() && sourceGrant?.kind === "directory") {
|
|
3576
|
+
return { resourcePath, sourcePath: canonicalSource };
|
|
3577
|
+
}
|
|
3578
|
+
} catch {
|
|
3579
|
+
// The submitted Markdown can still render without file-adjacent metadata.
|
|
3580
|
+
}
|
|
3581
|
+
return { resourcePath };
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3384
3584
|
function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
|
|
3385
3585
|
const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
|
|
3386
3586
|
if (!normalized) return undefined;
|
|
@@ -5191,11 +5391,34 @@ function hasStudioYamlHeaderIncludes(markdown: string): boolean {
|
|
|
5191
5391
|
return /^\s*header-includes\s*:/im.test(split.frontMatter);
|
|
5192
5392
|
}
|
|
5193
5393
|
|
|
5394
|
+
function encodeStudioLocalFileUrlForPandoc(resourceUrl: string): string {
|
|
5395
|
+
try {
|
|
5396
|
+
const url = new URL(resourceUrl);
|
|
5397
|
+
if (url.protocol !== "file:" || (url.hostname && url.hostname !== "localhost")) return resourceUrl;
|
|
5398
|
+
const suffix = `${url.search}${url.hash}`;
|
|
5399
|
+
url.search = "";
|
|
5400
|
+
url.hash = "";
|
|
5401
|
+
const localPath = fileURLToPath(url);
|
|
5402
|
+
return encodeURI(localPath).replace(/\?/g, "%3F").replace(/#/g, "%23") + suffix;
|
|
5403
|
+
} catch {
|
|
5404
|
+
return resourceUrl;
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5408
|
+
function normalizeStudioMarkdownFileUrlDestinationsForPandoc(markdown: string): string {
|
|
5409
|
+
return transformStudioMarkdownOutsideFences(markdown, (segment: string) => (
|
|
5410
|
+
segment.replace(/(\]\(\s*<?)(file:\/\/(?:localhost)?\/[^<>\s)]+)/gi, (_match, prefix: string, resourceUrl: string) => (
|
|
5411
|
+
`${prefix}${encodeStudioLocalFileUrlForPandoc(resourceUrl)}`
|
|
5412
|
+
))
|
|
5413
|
+
));
|
|
5414
|
+
}
|
|
5415
|
+
|
|
5194
5416
|
function prepareStudioMarkdownForPandoc(markdown: string, options?: { preserveLiteralLatexCommands?: boolean }): string {
|
|
5195
5417
|
const shouldPreserveLiteralLatexCommands = options?.preserveLiteralLatexCommands !== false;
|
|
5196
5418
|
return mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown, (body) => {
|
|
5197
5419
|
const normalizedFences = normalizeStudioMarkdownSmartFences(body);
|
|
5198
|
-
const
|
|
5420
|
+
const normalizedFileUrls = normalizeStudioMarkdownFileUrlDestinationsForPandoc(normalizedFences);
|
|
5421
|
+
const normalizedMath = normalizeMathDelimiters(normalizedFileUrls);
|
|
5199
5422
|
const latexReady = shouldPreserveLiteralLatexCommands
|
|
5200
5423
|
? preserveLiteralLatexCommandsInMarkdown(normalizedMath)
|
|
5201
5424
|
: normalizedMath;
|
|
@@ -6413,7 +6636,13 @@ function preprocessStudioLatexFootnotemarksForPreview(latex: string): string {
|
|
|
6413
6636
|
});
|
|
6414
6637
|
}
|
|
6415
6638
|
|
|
6416
|
-
async function renderStudioMarkdownWithPandoc(
|
|
6639
|
+
async function renderStudioMarkdownWithPandoc(
|
|
6640
|
+
markdown: string,
|
|
6641
|
+
isLatex?: boolean,
|
|
6642
|
+
resourcePath?: string,
|
|
6643
|
+
sourcePath?: string,
|
|
6644
|
+
options?: { embedResources?: boolean },
|
|
6645
|
+
): Promise<string> {
|
|
6417
6646
|
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
6418
6647
|
const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
|
|
6419
6648
|
?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
|
|
@@ -6451,7 +6680,9 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
6451
6680
|
await mkdir(htmlTemplateDir, { recursive: true });
|
|
6452
6681
|
const htmlTemplatePath = join(htmlTemplateDir, "template.html");
|
|
6453
6682
|
await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
|
|
6454
|
-
if (resourcePath
|
|
6683
|
+
if (resourcePath && options?.embedResources !== false) {
|
|
6684
|
+
args.push(await resolveStudioPandocHtmlResourceFlag(pandocCommand));
|
|
6685
|
+
}
|
|
6455
6686
|
args.push("--standalone", `--template=${htmlTemplatePath}`);
|
|
6456
6687
|
}
|
|
6457
6688
|
const normalizedMarkdown = isLatex
|
|
@@ -7599,6 +7830,29 @@ function respondStudioPendingError(res: ServerResponse, status: number, text: st
|
|
|
7599
7830
|
res.end(text);
|
|
7600
7831
|
}
|
|
7601
7832
|
|
|
7833
|
+
function respondStudioResourceGrantRequiredJson(res: ServerResponse, error: StudioResourceGrantRequiredError): void {
|
|
7834
|
+
respondJson(res, 403, {
|
|
7835
|
+
ok: false,
|
|
7836
|
+
code: error.code,
|
|
7837
|
+
error: error.message,
|
|
7838
|
+
path: error.filePath,
|
|
7839
|
+
directoryPath: error.directoryPath,
|
|
7840
|
+
label: basename(error.filePath),
|
|
7841
|
+
resourceKind: error.resourceKind,
|
|
7842
|
+
});
|
|
7843
|
+
}
|
|
7844
|
+
|
|
7845
|
+
function respondStudioDirectoryGrantRequiredJson(res: ServerResponse, error: StudioDirectoryGrantRequiredError): void {
|
|
7846
|
+
respondJson(res, 403, {
|
|
7847
|
+
ok: false,
|
|
7848
|
+
code: error.code,
|
|
7849
|
+
error: error.message,
|
|
7850
|
+
path: error.directoryPath,
|
|
7851
|
+
directoryPath: error.directoryPath,
|
|
7852
|
+
label: basename(error.directoryPath) || error.directoryPath,
|
|
7853
|
+
});
|
|
7854
|
+
}
|
|
7855
|
+
|
|
7602
7856
|
function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
|
|
7603
7857
|
const method = (req.method ?? "GET").toUpperCase();
|
|
7604
7858
|
if (method !== "GET" && method !== "HEAD") {
|
|
@@ -7661,7 +7915,7 @@ function sanitizeStudioPreviewBlockLine(value: string): string {
|
|
|
7661
7915
|
|
|
7662
7916
|
function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource, options?: { watchPdf?: boolean }): InitialStudioDocument {
|
|
7663
7917
|
const label = basename(resource.filePath) || resource.label || "local preview";
|
|
7664
|
-
const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
|
|
7918
|
+
const resourcePath = resource.referencePath || resource.label || basename(resource.filePath) || resource.filePath;
|
|
7665
7919
|
const title = sanitizeStudioPreviewBlockLine(label);
|
|
7666
7920
|
let text = "";
|
|
7667
7921
|
if (resource.kind === "pdf") {
|
|
@@ -7720,7 +7974,14 @@ async function convertStudioOfficeDocumentToMarkdown(resource: StudioLocalPrevie
|
|
|
7720
7974
|
return { text: `${note}\n\n${body}\n`, label };
|
|
7721
7975
|
}
|
|
7722
7976
|
|
|
7723
|
-
async function respondLocalPreviewLinkJson(
|
|
7977
|
+
async function respondLocalPreviewLinkJson(
|
|
7978
|
+
req: IncomingMessage,
|
|
7979
|
+
res: ServerResponse,
|
|
7980
|
+
requestUrl: URL,
|
|
7981
|
+
resource: StudioLocalPreviewResource,
|
|
7982
|
+
serverState: StudioServerState,
|
|
7983
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
7984
|
+
): Promise<void> {
|
|
7724
7985
|
const method = (req.method ?? "GET").toUpperCase();
|
|
7725
7986
|
if (method !== "GET" && method !== "HEAD") {
|
|
7726
7987
|
res.setHeader("Allow", "GET, HEAD");
|
|
@@ -7729,6 +7990,13 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7729
7990
|
}
|
|
7730
7991
|
|
|
7731
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
|
+
}
|
|
7732
8000
|
const basePayload = {
|
|
7733
8001
|
ok: true,
|
|
7734
8002
|
kind: resource.kind,
|
|
@@ -7758,10 +8026,14 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7758
8026
|
return;
|
|
7759
8027
|
}
|
|
7760
8028
|
|
|
7761
|
-
if (action !== "document" && action !== "editor-url") {
|
|
8029
|
+
if (action !== "document" && action !== "editor-url" && action !== "watch-url") {
|
|
7762
8030
|
respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
|
|
7763
8031
|
return;
|
|
7764
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
|
+
}
|
|
7765
8037
|
if (resource.kind !== "text" && resource.kind !== "office") {
|
|
7766
8038
|
respondJson(res, 400, { ok: false, error: "This local resource is not a document Studio can load into the editor." });
|
|
7767
8039
|
return;
|
|
@@ -7770,6 +8042,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7770
8042
|
let document: InitialStudioDocument;
|
|
7771
8043
|
let responseText = "";
|
|
7772
8044
|
let converted = false;
|
|
8045
|
+
let documentResourceDir = resource.resourceDir;
|
|
7773
8046
|
if (resource.kind === "office") {
|
|
7774
8047
|
let conversion: { text: string; label: string };
|
|
7775
8048
|
try {
|
|
@@ -7793,12 +8066,19 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7793
8066
|
return;
|
|
7794
8067
|
}
|
|
7795
8068
|
responseText = file.text;
|
|
8069
|
+
try {
|
|
8070
|
+
const documentGrant = resourceGrants?.grantDocument(file.resolvedPath, { source: "document" });
|
|
8071
|
+
if (documentGrant?.path) documentResourceDir = documentGrant.path;
|
|
8072
|
+
} catch {
|
|
8073
|
+
// The exact-file grant remains usable if the bounded registry cannot add the document directory.
|
|
8074
|
+
}
|
|
7796
8075
|
document = {
|
|
7797
8076
|
text: file.text,
|
|
7798
8077
|
label: resource.label || file.label,
|
|
7799
8078
|
source: "file",
|
|
7800
8079
|
path: file.resolvedPath,
|
|
7801
|
-
resourceDir:
|
|
8080
|
+
resourceDir: documentResourceDir,
|
|
8081
|
+
diskRevision: file.diskRevision,
|
|
7802
8082
|
};
|
|
7803
8083
|
}
|
|
7804
8084
|
if (action === "document") {
|
|
@@ -7807,15 +8087,24 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
|
|
|
7807
8087
|
text: responseText,
|
|
7808
8088
|
label: document.label,
|
|
7809
8089
|
converted,
|
|
7810
|
-
resourceDir:
|
|
8090
|
+
resourceDir: documentResourceDir,
|
|
8091
|
+
diskRevision: document.diskRevision || null,
|
|
7811
8092
|
});
|
|
7812
8093
|
return;
|
|
7813
8094
|
}
|
|
7814
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
|
+
}
|
|
7815
8103
|
const docId = storeTransientStudioDocument(document);
|
|
7816
8104
|
respondJson(res, 200, {
|
|
7817
8105
|
...basePayload,
|
|
7818
8106
|
converted,
|
|
8107
|
+
resourceDir: documentResourceDir,
|
|
7819
8108
|
relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
|
|
7820
8109
|
});
|
|
7821
8110
|
}
|
|
@@ -7906,7 +8195,61 @@ async function handleImportStudioFileCopyRequest(req: IncomingMessage, res: Serv
|
|
|
7906
8195
|
});
|
|
7907
8196
|
}
|
|
7908
8197
|
|
|
7909
|
-
async function
|
|
8198
|
+
async function handleStudioResourceGrantRequest(
|
|
8199
|
+
req: IncomingMessage,
|
|
8200
|
+
res: ServerResponse,
|
|
8201
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
8202
|
+
studioCwd: string,
|
|
8203
|
+
): Promise<void> {
|
|
8204
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
8205
|
+
if (method !== "POST") {
|
|
8206
|
+
res.setHeader("Allow", "POST");
|
|
8207
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
|
|
8208
|
+
return;
|
|
8209
|
+
}
|
|
8210
|
+
|
|
8211
|
+
const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
|
|
8212
|
+
let payload: Record<string, unknown> = {};
|
|
8213
|
+
try {
|
|
8214
|
+
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
8215
|
+
} catch {
|
|
8216
|
+
respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
|
|
8217
|
+
return;
|
|
8218
|
+
}
|
|
8219
|
+
|
|
8220
|
+
const grantKind = payload.grantKind === "directory" ? "directory" : payload.grantKind === "file" ? "file" : "";
|
|
8221
|
+
const path = typeof payload.path === "string" ? payload.path.trim() : "";
|
|
8222
|
+
if (!grantKind || !path) {
|
|
8223
|
+
respondJson(res, 400, { ok: false, error: "Choose an exact file or folder to allow." });
|
|
8224
|
+
return;
|
|
8225
|
+
}
|
|
8226
|
+
|
|
8227
|
+
try {
|
|
8228
|
+
const grant = grantKind === "directory"
|
|
8229
|
+
? resourceGrants.grantDirectory(path, { cwd: studioCwd, source: "explicit-directory" })
|
|
8230
|
+
: resourceGrants.grantFile(path, { cwd: studioCwd, source: "explicit-file" });
|
|
8231
|
+
respondJson(res, 200, {
|
|
8232
|
+
ok: true,
|
|
8233
|
+
grant,
|
|
8234
|
+
resourceGrants: resourceGrants.snapshot(),
|
|
8235
|
+
message: grantKind === "directory"
|
|
8236
|
+
? `Allowed this folder for the current Studio session: ${grant.path}`
|
|
8237
|
+
: `Allowed this file for the current Studio session: ${grant.path}`,
|
|
8238
|
+
});
|
|
8239
|
+
} catch (error) {
|
|
8240
|
+
respondJson(res, 400, {
|
|
8241
|
+
ok: false,
|
|
8242
|
+
error: `Could not allow this ${grantKind}: ${error instanceof Error ? error.message : String(error)}`,
|
|
8243
|
+
});
|
|
8244
|
+
}
|
|
8245
|
+
}
|
|
8246
|
+
|
|
8247
|
+
async function handleRevealLocalPreviewResourceRequest(
|
|
8248
|
+
req: IncomingMessage,
|
|
8249
|
+
res: ServerResponse,
|
|
8250
|
+
studioCwd: string,
|
|
8251
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
8252
|
+
): Promise<void> {
|
|
7910
8253
|
const method = (req.method ?? "GET").toUpperCase();
|
|
7911
8254
|
if (method !== "POST") {
|
|
7912
8255
|
res.setHeader("Allow", "POST");
|
|
@@ -7929,6 +8272,7 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
|
|
|
7929
8272
|
typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
|
|
7930
8273
|
typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
|
|
7931
8274
|
studioCwd,
|
|
8275
|
+
resourceGrants,
|
|
7932
8276
|
);
|
|
7933
8277
|
const result = revealStudioLocalFile(resource.filePath);
|
|
7934
8278
|
if (!result.ok) {
|
|
@@ -7937,11 +8281,20 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
|
|
|
7937
8281
|
}
|
|
7938
8282
|
respondJson(res, 200, { ok: true, message: result.message, path: resource.filePath, label: resource.label });
|
|
7939
8283
|
} catch (error) {
|
|
8284
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
8285
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
8286
|
+
return;
|
|
8287
|
+
}
|
|
7940
8288
|
respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
7941
8289
|
}
|
|
7942
8290
|
}
|
|
7943
8291
|
|
|
7944
|
-
async function handleOpenLocalPreviewResourceRequest(
|
|
8292
|
+
async function handleOpenLocalPreviewResourceRequest(
|
|
8293
|
+
req: IncomingMessage,
|
|
8294
|
+
res: ServerResponse,
|
|
8295
|
+
studioCwd: string,
|
|
8296
|
+
resourceGrants?: StudioResourceGrantRegistry,
|
|
8297
|
+
): Promise<void> {
|
|
7945
8298
|
const method = (req.method ?? "GET").toUpperCase();
|
|
7946
8299
|
if (method !== "POST") {
|
|
7947
8300
|
res.setHeader("Allow", "POST");
|
|
@@ -7968,6 +8321,7 @@ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res:
|
|
|
7968
8321
|
typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
|
|
7969
8322
|
typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
|
|
7970
8323
|
studioCwd,
|
|
8324
|
+
resourceGrants,
|
|
7971
8325
|
);
|
|
7972
8326
|
if (resource.kind !== "pdf") {
|
|
7973
8327
|
respondJson(res, 400, { ok: false, error: "Only local PDF previews can be opened in the system PDF viewer." });
|
|
@@ -7981,6 +8335,10 @@ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res:
|
|
|
7981
8335
|
label: resource.label,
|
|
7982
8336
|
});
|
|
7983
8337
|
} catch (error) {
|
|
8338
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
8339
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
8340
|
+
return;
|
|
8341
|
+
}
|
|
7984
8342
|
respondJson(res, 404, { ok: false, error: `Local PDF unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
7985
8343
|
}
|
|
7986
8344
|
}
|
|
@@ -8006,7 +8364,12 @@ function openPathInDefaultViewer(path: string): Promise<void> {
|
|
|
8006
8364
|
});
|
|
8007
8365
|
}
|
|
8008
8366
|
|
|
8009
|
-
async function handleOpenStudioFileBrowserDirectoryRequest(
|
|
8367
|
+
async function handleOpenStudioFileBrowserDirectoryRequest(
|
|
8368
|
+
req: IncomingMessage,
|
|
8369
|
+
res: ServerResponse,
|
|
8370
|
+
studioCwd: string,
|
|
8371
|
+
resourceGrants: StudioResourceGrantRegistry,
|
|
8372
|
+
): Promise<void> {
|
|
8010
8373
|
const method = (req.method ?? "GET").toUpperCase();
|
|
8011
8374
|
if (method !== "POST") {
|
|
8012
8375
|
res.setHeader("Allow", "POST");
|
|
@@ -8030,13 +8393,25 @@ async function handleOpenStudioFileBrowserDirectoryRequest(req: IncomingMessage,
|
|
|
8030
8393
|
try {
|
|
8031
8394
|
const directory = resolveStudioFileBrowserDirectory(
|
|
8032
8395
|
typeof payload.dir === "string" ? payload.dir : undefined,
|
|
8396
|
+
typeof payload.root === "string" ? payload.root : undefined,
|
|
8033
8397
|
typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
|
|
8034
8398
|
typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
|
|
8035
8399
|
studioCwd,
|
|
8400
|
+
resourceGrants,
|
|
8036
8401
|
);
|
|
8037
8402
|
await openPathInDefaultViewer(directory.currentDir);
|
|
8038
8403
|
respondJson(res, 200, { ok: true, message: "Opened folder in file manager.", path: directory.currentDir, rootDir: directory.rootDir });
|
|
8039
8404
|
} catch (error) {
|
|
8405
|
+
if (error instanceof StudioDirectoryGrantRequiredError) {
|
|
8406
|
+
respondJson(res, 403, {
|
|
8407
|
+
ok: false,
|
|
8408
|
+
code: error.code,
|
|
8409
|
+
error: error.message,
|
|
8410
|
+
path: error.directoryPath,
|
|
8411
|
+
directoryPath: error.directoryPath,
|
|
8412
|
+
});
|
|
8413
|
+
return;
|
|
8414
|
+
}
|
|
8040
8415
|
respondJson(res, 404, { ok: false, error: `Could not open file-browser folder: ${error instanceof Error ? error.message : String(error)}` });
|
|
8041
8416
|
}
|
|
8042
8417
|
}
|
|
@@ -8504,10 +8879,11 @@ async function searchStudioSideQuestionLocalContext(
|
|
|
8504
8879
|
const query = String(queryInput || "").trim();
|
|
8505
8880
|
if (!query) throw new Error("Local context search query is empty.");
|
|
8506
8881
|
if (query.length > 500) throw new Error("Local context search query is too long.");
|
|
8882
|
+
const stableRoot = assertStudioSideQuestionRootStable(contextRoot);
|
|
8507
8883
|
const target = options.path
|
|
8508
|
-
? resolveStudioSideQuestionPath(
|
|
8509
|
-
:
|
|
8510
|
-
const targetRelative = relative(
|
|
8884
|
+
? resolveStudioSideQuestionPath(stableRoot, options.path, { directory: true }).path
|
|
8885
|
+
: stableRoot;
|
|
8886
|
+
const targetRelative = relative(stableRoot, target) || ".";
|
|
8511
8887
|
const maxResults = Math.max(1, Math.min(200, Math.floor(Number(options.maxResults) || 60)));
|
|
8512
8888
|
const args = [
|
|
8513
8889
|
"--fixed-strings",
|
|
@@ -8527,7 +8903,7 @@ async function searchStudioSideQuestionLocalContext(
|
|
|
8527
8903
|
args.push("--", query, targetRelative);
|
|
8528
8904
|
try {
|
|
8529
8905
|
const result = await runStudioSubprocess("rg", args, {
|
|
8530
|
-
cwd:
|
|
8906
|
+
cwd: stableRoot,
|
|
8531
8907
|
signal: options.signal,
|
|
8532
8908
|
timeoutMs: 20_000,
|
|
8533
8909
|
stdoutMaxBytes: 250_000,
|
|
@@ -8541,11 +8917,11 @@ async function searchStudioSideQuestionLocalContext(
|
|
|
8541
8917
|
if (!match) return [];
|
|
8542
8918
|
return [{ path: match[1].split("\\").join("/"), line: Number(match[2]), text: match[3].trim().slice(0, 1_000) }];
|
|
8543
8919
|
}).slice(0, maxResults);
|
|
8544
|
-
return { root:
|
|
8920
|
+
return { root: stableRoot, query, results: matches, truncated: result.stdoutTruncated || matches.length >= maxResults };
|
|
8545
8921
|
} catch (error) {
|
|
8546
8922
|
if (options.signal?.aborted) throw new Error("Local context search was cancelled.");
|
|
8547
8923
|
if (!(error instanceof Error) || !error.message.includes("__PI_STUDIO_RG_NOT_FOUND__")) throw error;
|
|
8548
|
-
return searchStudioSideQuestionContext(
|
|
8924
|
+
return searchStudioSideQuestionContext(stableRoot, query, {
|
|
8549
8925
|
path: targetRelative,
|
|
8550
8926
|
caseSensitive: options.caseSensitive,
|
|
8551
8927
|
maxResults,
|
|
@@ -8647,7 +9023,12 @@ function formatStudioSideQuestionGitSnapshotResult(
|
|
|
8647
9023
|
};
|
|
8648
9024
|
}
|
|
8649
9025
|
|
|
8650
|
-
function createStudioSideQuestionTools(
|
|
9026
|
+
function createStudioSideQuestionTools(
|
|
9027
|
+
contextRoot: string,
|
|
9028
|
+
webEnabled: boolean,
|
|
9029
|
+
gitSnapshot: StudioSideQuestionGitSnapshot | null = null,
|
|
9030
|
+
assertContextRoot?: () => string,
|
|
9031
|
+
) {
|
|
8651
9032
|
const mapTool = defineTool({
|
|
8652
9033
|
name: "studio_context_map",
|
|
8653
9034
|
label: "Map local context",
|
|
@@ -8658,6 +9039,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
|
|
|
8658
9039
|
}),
|
|
8659
9040
|
async execute(_toolCallId, params, signal) {
|
|
8660
9041
|
if (signal?.aborted) throw new Error("Context map was cancelled.");
|
|
9042
|
+
if (assertContextRoot) assertContextRoot();
|
|
8661
9043
|
const target = params.path
|
|
8662
9044
|
? resolveStudioSideQuestionPath(contextRoot, params.path, { directory: true }).path
|
|
8663
9045
|
: contextRoot;
|
|
@@ -8680,6 +9062,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
|
|
|
8680
9062
|
}),
|
|
8681
9063
|
async execute(_toolCallId, params, signal) {
|
|
8682
9064
|
if (signal?.aborted) throw new Error("Context read was cancelled.");
|
|
9065
|
+
if (assertContextRoot) assertContextRoot();
|
|
8683
9066
|
const result = readStudioSideQuestionContextText(contextRoot, params.path, { offset: params.offset, limit: params.limit, maxChars: STUDIO_SIDE_CONTEXT_MAX_OUTPUT_CHARS });
|
|
8684
9067
|
const extractableResult = result as { path: string; relativePath: string; extension?: string };
|
|
8685
9068
|
const content: { text: string; startLine: number; endLine: number; totalLines: number; truncated: boolean } = result.requiresExtraction
|
|
@@ -8703,6 +9086,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
|
|
|
8703
9086
|
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
|
|
8704
9087
|
}),
|
|
8705
9088
|
async execute(_toolCallId, params, signal) {
|
|
9089
|
+
if (assertContextRoot) assertContextRoot();
|
|
8706
9090
|
const result = await searchStudioSideQuestionLocalContext(contextRoot, params.query, {
|
|
8707
9091
|
path: params.path,
|
|
8708
9092
|
caseSensitive: params.caseSensitive,
|
|
@@ -9621,21 +10005,35 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
9621
10005
|
msg.type === "save_as_request" &&
|
|
9622
10006
|
typeof msg.requestId === "string" &&
|
|
9623
10007
|
typeof msg.path === "string" &&
|
|
9624
|
-
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")
|
|
9625
10011
|
) {
|
|
9626
10012
|
return {
|
|
9627
10013
|
type: "save_as_request",
|
|
9628
10014
|
requestId: msg.requestId,
|
|
9629
10015
|
path: msg.path,
|
|
9630
10016
|
content: msg.content,
|
|
10017
|
+
overwrite: msg.overwrite === true,
|
|
10018
|
+
expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
|
|
9631
10019
|
};
|
|
9632
10020
|
}
|
|
9633
10021
|
|
|
9634
|
-
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
|
+
) {
|
|
9635
10030
|
return {
|
|
9636
10031
|
type: "save_over_request",
|
|
9637
10032
|
requestId: msg.requestId,
|
|
10033
|
+
path: msg.path,
|
|
9638
10034
|
content: msg.content,
|
|
10035
|
+
expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
|
|
10036
|
+
force: msg.force === true,
|
|
9639
10037
|
};
|
|
9640
10038
|
}
|
|
9641
10039
|
|
|
@@ -9651,6 +10049,19 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
9651
10049
|
};
|
|
9652
10050
|
}
|
|
9653
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
|
+
|
|
9654
10065
|
if (msg.type === "send_to_editor_request" && typeof msg.requestId === "string" && typeof msg.content === "string") {
|
|
9655
10066
|
return {
|
|
9656
10067
|
type: "send_to_editor_request",
|
|
@@ -10987,6 +11398,7 @@ function buildStudioRelativeUrl(
|
|
|
10987
11398
|
if (doc?.path) params.set("docPath", doc.path);
|
|
10988
11399
|
if (doc?.draftId) params.set("draftId", doc.draftId);
|
|
10989
11400
|
if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
|
|
11401
|
+
if (doc?.watchFile) params.set("watchFile", "1");
|
|
10990
11402
|
if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
|
|
10991
11403
|
if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
|
|
10992
11404
|
return `/?${params.toString()}`;
|
|
@@ -11076,16 +11488,36 @@ function resolveRequestedStudioDocumentFromUrl(
|
|
|
11076
11488
|
const requestedLabel = (requestUrl.searchParams.get("docLabel") ?? "").trim();
|
|
11077
11489
|
const requestedDraftId = (requestUrl.searchParams.get("draftId") ?? "").trim();
|
|
11078
11490
|
const requestedResourceDir = (requestUrl.searchParams.get("resourceDir") ?? "").trim();
|
|
11491
|
+
const requestedWatchFile = requestUrl.searchParams.get("watchFile") === "1";
|
|
11079
11492
|
|
|
11080
11493
|
if (requestedPath) {
|
|
11081
11494
|
const file = readStudioFile(requestedPath, studioCwd);
|
|
11082
|
-
|
|
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) {
|
|
11083
11503
|
return {
|
|
11084
11504
|
text: file.text,
|
|
11085
11505
|
label: requestedLabel || file.label,
|
|
11086
11506
|
source: "file",
|
|
11087
11507
|
path: file.resolvedPath,
|
|
11088
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,
|
|
11089
11521
|
};
|
|
11090
11522
|
}
|
|
11091
11523
|
}
|
|
@@ -11370,6 +11802,8 @@ function buildStudioHtml(
|
|
|
11370
11802
|
const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
|
|
11371
11803
|
const initialDraftId = escapeHtmlForInline(initialDocument?.draftId ?? "");
|
|
11372
11804
|
const initialResourceDir = escapeHtmlForInline(initialDocument?.resourceDir ?? "");
|
|
11805
|
+
const initialDiskRevision = escapeHtmlForInline(initialDocument?.diskRevision ?? "");
|
|
11806
|
+
const initialWatchFile = initialDocument?.watchFile === true ? "1" : "0";
|
|
11373
11807
|
const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
|
|
11374
11808
|
const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
|
|
11375
11809
|
const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
|
|
@@ -11446,12 +11880,12 @@ ${cssVarsBlock}
|
|
|
11446
11880
|
</style>
|
|
11447
11881
|
<link rel="stylesheet" href="${stylesheetHref}" />
|
|
11448
11882
|
</head>
|
|
11449
|
-
<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}">
|
|
11450
11884
|
<header id="studioHeader">
|
|
11451
11885
|
<h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
|
|
11452
11886
|
<div class="controls">
|
|
11453
|
-
<button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here
|
|
11454
|
-
<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>
|
|
11455
11889
|
<button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
|
|
11456
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>
|
|
11457
11891
|
<button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
|
|
@@ -11671,7 +12105,7 @@ ${cssVarsBlock}
|
|
|
11671
12105
|
<section id="rightPane">
|
|
11672
12106
|
<div id="rightSectionHeader" class="section-header">
|
|
11673
12107
|
<div class="section-header-main">
|
|
11674
|
-
<select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/Q keep
|
|
12108
|
+
<select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
|
|
11675
12109
|
<option value="markdown">Response (Raw)</option>
|
|
11676
12110
|
<option value="preview" selected>Response (Preview)</option>
|
|
11677
12111
|
<option value="editor-preview">Editor (Preview)</option>
|
|
@@ -11685,6 +12119,7 @@ ${cssVarsBlock}
|
|
|
11685
12119
|
</div>
|
|
11686
12120
|
<div class="section-header-actions">
|
|
11687
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>
|
|
11688
12123
|
<span id="exportPreviewControls" class="export-preview-controls">
|
|
11689
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>
|
|
11690
12125
|
<div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
|
|
@@ -11780,6 +12215,7 @@ ${cssVarsBlock}
|
|
|
11780
12215
|
<div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
|
|
11781
12216
|
<div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
|
|
11782
12217
|
<div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
|
|
12218
|
+
<div><dt>Cmd/Ctrl+Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
|
|
11783
12219
|
<div><dt>Cmd/Ctrl+Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
|
|
11784
12220
|
<div><dt>F8</dt><dd>Focus editor text</dd></div>
|
|
11785
12221
|
<div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
|
|
@@ -11801,7 +12237,8 @@ ${cssVarsBlock}
|
|
|
11801
12237
|
<section class="shortcuts-group">
|
|
11802
12238
|
<h3>Editor</h3>
|
|
11803
12239
|
<dl>
|
|
11804
|
-
<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>
|
|
11805
12242
|
<div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
|
|
11806
12243
|
<div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
|
|
11807
12244
|
<div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
|
|
@@ -11876,6 +12313,10 @@ ${cssVarsBlock}
|
|
|
11876
12313
|
export default function (pi: ExtensionAPI) {
|
|
11877
12314
|
let serverState: StudioServerState | null = null;
|
|
11878
12315
|
const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
|
|
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>();
|
|
11879
12320
|
let activeRequest: ActiveStudioRequest | null = null;
|
|
11880
12321
|
let studioDirectRunChain: StudioDirectRunChain | null = null;
|
|
11881
12322
|
let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
|
|
@@ -11936,6 +12377,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
11936
12377
|
actionRequestId: null,
|
|
11937
12378
|
};
|
|
11938
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
|
+
|
|
12395
|
+
const recordStudioDocumentResourceGrants = (document: InitialStudioDocument, cwd = studioCwd) => {
|
|
12396
|
+
if (document.path) {
|
|
12397
|
+
try {
|
|
12398
|
+
studioResourceGrantRegistry.grantDocument(document.path, { cwd, source: "document" });
|
|
12399
|
+
} catch {
|
|
12400
|
+
// A failed grant must not broaden access or prevent an otherwise valid launch.
|
|
12401
|
+
}
|
|
12402
|
+
}
|
|
12403
|
+
if (document.resourceDir) {
|
|
12404
|
+
try {
|
|
12405
|
+
studioResourceGrantRegistry.grantDirectory(document.resourceDir, { cwd, source: "workspace" });
|
|
12406
|
+
} catch {
|
|
12407
|
+
// A failed workspace grant must not broaden access or prevent an otherwise valid launch.
|
|
12408
|
+
}
|
|
12409
|
+
}
|
|
12410
|
+
};
|
|
12411
|
+
|
|
11939
12412
|
const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
|
|
11940
12413
|
const state = listStudioReplSessions();
|
|
11941
12414
|
const sessions = state.sessions;
|
|
@@ -13604,7 +14077,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13604
14077
|
await closeStudioSideQuestionRuntime(runtime);
|
|
13605
14078
|
};
|
|
13606
14079
|
|
|
13607
|
-
const resolveStudioSideQuestionContextRoot = (context:
|
|
14080
|
+
const resolveStudioSideQuestionContextRoot = (context: StudioSideQuestionContextRootInput): string => {
|
|
13608
14081
|
if (context.gatherScope === "none") return "";
|
|
13609
14082
|
const sourcePath = resolveStudioQuizContextPath(context.sourcePath, studioCwd);
|
|
13610
14083
|
const resourceDir = resolveStudioQuizContextPath(context.resourceDir, studioCwd);
|
|
@@ -13627,6 +14100,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
13627
14100
|
return resolveStudioSideQuestionRoot(root, studioCwd);
|
|
13628
14101
|
};
|
|
13629
14102
|
|
|
14103
|
+
const assertStudioSideQuestionContextRootAuthorized = (contextRoot: string): string => {
|
|
14104
|
+
if (!contextRoot) return "";
|
|
14105
|
+
const stableRoot = assertStudioSideQuestionRootStable(contextRoot);
|
|
14106
|
+
const grant = studioResourceGrantRegistry.findGrant(stableRoot, { cwd: studioCwd });
|
|
14107
|
+
if (!grant || grant.kind !== "directory") throw new StudioDirectoryGrantRequiredError(stableRoot);
|
|
14108
|
+
return stableRoot;
|
|
14109
|
+
};
|
|
14110
|
+
|
|
14111
|
+
const resolveAuthorizedStudioSideQuestionContextRoot = (context: StudioSideQuestionContextRootInput): string => (
|
|
14112
|
+
assertStudioSideQuestionContextRootAuthorized(resolveStudioSideQuestionContextRoot(context))
|
|
14113
|
+
);
|
|
14114
|
+
|
|
14115
|
+
const handleStudioSideQuestionContextRootRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
14116
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
14117
|
+
if (method !== "POST") {
|
|
14118
|
+
res.setHeader("Allow", "POST");
|
|
14119
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
|
|
14120
|
+
return;
|
|
14121
|
+
}
|
|
14122
|
+
const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
|
|
14123
|
+
let payload: Record<string, unknown> = {};
|
|
14124
|
+
try {
|
|
14125
|
+
payload = rawBody ? JSON.parse(rawBody) as Record<string, unknown> : {};
|
|
14126
|
+
} catch {
|
|
14127
|
+
respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
|
|
14128
|
+
return;
|
|
14129
|
+
}
|
|
14130
|
+
const context: StudioSideQuestionContextRootInput = {
|
|
14131
|
+
gatherScope: normalizeStudioSideQuestionGatherScope(payload.gatherScope) as StudioSideQuestionGatherScope,
|
|
14132
|
+
sourcePath: typeof payload.sourcePath === "string" ? payload.sourcePath.slice(0, 16_384) : undefined,
|
|
14133
|
+
resourceDir: typeof payload.resourceDir === "string" ? payload.resourceDir.slice(0, 16_384) : undefined,
|
|
14134
|
+
contextPath: typeof payload.contextPath === "string" ? payload.contextPath.slice(0, 16_384) : undefined,
|
|
14135
|
+
};
|
|
14136
|
+
try {
|
|
14137
|
+
const contextRoot = resolveAuthorizedStudioSideQuestionContextRoot(context);
|
|
14138
|
+
respondJson(res, 200, { ok: true, contextRoot, authorized: Boolean(contextRoot) });
|
|
14139
|
+
} catch (error) {
|
|
14140
|
+
if (error instanceof StudioDirectoryGrantRequiredError) {
|
|
14141
|
+
respondStudioDirectoryGrantRequiredJson(res, error);
|
|
14142
|
+
return;
|
|
14143
|
+
}
|
|
14144
|
+
respondJson(res, 400, { ok: false, error: `Side-question context unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
14145
|
+
}
|
|
14146
|
+
};
|
|
14147
|
+
|
|
13630
14148
|
const describeStudioSideQuestionActivity = (toolName: string, args: unknown): string => {
|
|
13631
14149
|
const value = args && typeof args === "object" ? args as Record<string, unknown> : {};
|
|
13632
14150
|
if (toolName === "studio_context_read") return `Reading ${String(value.path || "local context")}`;
|
|
@@ -13729,19 +14247,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
13729
14247
|
const createStudioSideQuestionRuntime = async (context: StudioSideQuestionContextInput): Promise<StudioSideQuestionRuntime> => {
|
|
13730
14248
|
const ctx = lastCommandCtx;
|
|
13731
14249
|
if (!ctx) throw new Error("No active Studio command context is available. Re-open Studio and try again.");
|
|
13732
|
-
const
|
|
13733
|
-
if (!model) throw new Error("No active Pi model is available for side questions.");
|
|
13734
|
-
await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
|
|
13735
|
-
const contextRoot = resolveStudioSideQuestionContextRoot(context);
|
|
14250
|
+
const contextRoot = resolveAuthorizedStudioSideQuestionContextRoot(context);
|
|
13736
14251
|
if (context.gitContext && context.gatherScope !== "repo") {
|
|
13737
14252
|
throw new Error("Git context requires Related files to be set to Repository.");
|
|
13738
14253
|
}
|
|
14254
|
+
const model = latestModelRequestCtx?.model ?? ctx.model;
|
|
14255
|
+
if (!model) throw new Error("No active Pi model is available for side questions.");
|
|
14256
|
+
await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
|
|
13739
14257
|
const gitSnapshot = context.gitContext
|
|
13740
14258
|
? await captureStudioSideQuestionGitSnapshot(contextRoot, { runGit: runStudioSideQuestionGitCommand }) as StudioSideQuestionGitSnapshot
|
|
13741
14259
|
: null;
|
|
13742
14260
|
const webAvailable = Boolean(String(process.env.BRAVE_API_KEY || "").trim());
|
|
13743
14261
|
const webEnabled = context.webSearch && webAvailable;
|
|
13744
|
-
const
|
|
14262
|
+
const assertContextRoot = contextRoot ? () => assertStudioSideQuestionContextRootAuthorized(contextRoot) : undefined;
|
|
14263
|
+
const { tools, toolNames } = createStudioSideQuestionTools(contextRoot || studioCwd, webEnabled, gitSnapshot, assertContextRoot);
|
|
13745
14264
|
const localActiveToolNames = context.gatherScope === "none" ? (webEnabled ? ["studio_web_search"] : []) : toolNames;
|
|
13746
14265
|
const toolSelection = selectStudioSideQuestionTools(getStudioSideQuestionToolCatalog(), context.toolIds);
|
|
13747
14266
|
if (toolSelection.missing.length > 0) {
|
|
@@ -13924,6 +14443,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13924
14443
|
sendStudioSideQuestionState();
|
|
13925
14444
|
try {
|
|
13926
14445
|
const context = msg.context;
|
|
14446
|
+
if (runtime.contextRoot) assertStudioSideQuestionContextRootAuthorized(runtime.contextRoot);
|
|
13927
14447
|
let prompt: string;
|
|
13928
14448
|
if (isFollowUp) {
|
|
13929
14449
|
prompt = buildStudioSideQuestionFollowUpPrompt(msg.question);
|
|
@@ -13992,6 +14512,92 @@ export default function (pi: ExtensionAPI) {
|
|
|
13992
14512
|
}
|
|
13993
14513
|
};
|
|
13994
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
|
+
|
|
13995
14601
|
const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage) => {
|
|
13996
14602
|
if (msg.type === "ping") {
|
|
13997
14603
|
sendToClient(client, { type: "pong", timestamp: Date.now() });
|
|
@@ -14001,6 +14607,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
14001
14607
|
studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
|
|
14002
14608
|
return;
|
|
14003
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
|
+
}
|
|
14004
14620
|
|
|
14005
14621
|
emitDebugEvent("studio_message", {
|
|
14006
14622
|
type: msg.type,
|
|
@@ -14040,6 +14656,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
14040
14656
|
responseHistory: studioResponseHistory,
|
|
14041
14657
|
traceState: studioTraceState,
|
|
14042
14658
|
initialDocument: initialStudioDocument,
|
|
14659
|
+
resourceGrants: studioResourceGrantRegistry.snapshot(),
|
|
14043
14660
|
quartoPreview: getStudioQuartoPreviewSnapshot(),
|
|
14044
14661
|
sideQuestion: getStudioSideQuestionPublicState(),
|
|
14045
14662
|
webSearchAvailable: Boolean(String(process.env.BRAVE_API_KEY || "").trim()),
|
|
@@ -14970,6 +15587,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
14970
15587
|
}
|
|
14971
15588
|
|
|
14972
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
|
+
}
|
|
14973
15594
|
if (!isValidRequestId(msg.requestId)) {
|
|
14974
15595
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
14975
15596
|
return;
|
|
@@ -14978,14 +15599,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
14978
15599
|
sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
|
|
14979
15600
|
return;
|
|
14980
15601
|
}
|
|
14981
|
-
if (!msg.content.trim()) {
|
|
14982
|
-
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Nothing to save." });
|
|
14983
|
-
return;
|
|
14984
|
-
}
|
|
14985
15602
|
|
|
14986
|
-
const result = writeStudioFile(msg.path, studioCwd, msg.content);
|
|
15603
|
+
const result = writeStudioFile(msg.path, studioCwd, msg.content, msg.overwrite === true, msg.expectedRevision);
|
|
14987
15604
|
if (result.ok === false) {
|
|
14988
|
-
|
|
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
|
+
}
|
|
14989
15617
|
return;
|
|
14990
15618
|
}
|
|
14991
15619
|
|
|
@@ -14995,7 +15623,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
14995
15623
|
source: "file",
|
|
14996
15624
|
path: result.resolvedPath,
|
|
14997
15625
|
resourceDir: dirname(result.resolvedPath),
|
|
15626
|
+
diskRevision: result.diskRevision,
|
|
14998
15627
|
};
|
|
15628
|
+
recordStudioDocumentResourceGrants(initialStudioDocument);
|
|
14999
15629
|
|
|
15000
15630
|
sendToClient(client, {
|
|
15001
15631
|
type: "saved",
|
|
@@ -15003,12 +15633,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
15003
15633
|
path: result.resolvedPath,
|
|
15004
15634
|
label: result.label,
|
|
15005
15635
|
resourceDir: dirname(result.resolvedPath),
|
|
15636
|
+
diskRevision: result.diskRevision,
|
|
15006
15637
|
message: `Saved editor text to ${result.label}`,
|
|
15007
15638
|
});
|
|
15008
15639
|
return;
|
|
15009
15640
|
}
|
|
15010
15641
|
|
|
15011
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
|
+
}
|
|
15012
15647
|
if (!isValidRequestId(msg.requestId)) {
|
|
15013
15648
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
15014
15649
|
return;
|
|
@@ -15017,35 +15652,52 @@ export default function (pi: ExtensionAPI) {
|
|
|
15017
15652
|
sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
|
|
15018
15653
|
return;
|
|
15019
15654
|
}
|
|
15020
|
-
|
|
15021
|
-
|
|
15022
|
-
|
|
15023
|
-
|
|
15024
|
-
|
|
15025
|
-
|
|
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
|
+
}
|
|
15026
15676
|
return;
|
|
15027
15677
|
}
|
|
15028
15678
|
|
|
15029
|
-
|
|
15030
|
-
|
|
15031
|
-
|
|
15032
|
-
|
|
15033
|
-
|
|
15034
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
15037
|
-
|
|
15038
|
-
|
|
15039
|
-
|
|
15040
|
-
|
|
15041
|
-
|
|
15042
|
-
|
|
15043
|
-
|
|
15044
|
-
|
|
15045
|
-
|
|
15046
|
-
|
|
15047
|
-
|
|
15048
|
-
|
|
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
|
+
});
|
|
15049
15701
|
return;
|
|
15050
15702
|
}
|
|
15051
15703
|
|
|
@@ -15069,7 +15721,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15069
15721
|
return;
|
|
15070
15722
|
}
|
|
15071
15723
|
|
|
15072
|
-
const refreshed = readStudioFile(refreshPath, studioCwd);
|
|
15724
|
+
const refreshed = readStudioFile(refreshPath, studioCwd, { requireCanonicalPath: true });
|
|
15073
15725
|
if (refreshed.ok === false) {
|
|
15074
15726
|
sendToClient(client, {
|
|
15075
15727
|
type: "error",
|
|
@@ -15085,9 +15737,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
15085
15737
|
source: "file",
|
|
15086
15738
|
path: refreshed.resolvedPath,
|
|
15087
15739
|
resourceDir: dirname(refreshed.resolvedPath),
|
|
15740
|
+
diskRevision: refreshed.diskRevision,
|
|
15088
15741
|
};
|
|
15089
15742
|
if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
|
|
15090
15743
|
initialStudioDocument = refreshedDocument;
|
|
15744
|
+
recordStudioDocumentResourceGrants(refreshedDocument);
|
|
15091
15745
|
}
|
|
15092
15746
|
|
|
15093
15747
|
sendToClient(client, {
|
|
@@ -15592,14 +16246,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
15592
16246
|
parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorLanguage?: unknown }).editorLanguage === "string"
|
|
15593
16247
|
? (parsedBody as { editorLanguage: string }).editorLanguage
|
|
15594
16248
|
: "";
|
|
15595
|
-
const
|
|
16249
|
+
const authorizedContext = resolveStudioAuthorizedPreviewRenderContext(
|
|
16250
|
+
sourcePath || undefined,
|
|
16251
|
+
userResourceDir || undefined,
|
|
16252
|
+
studioCwd,
|
|
16253
|
+
studioResourceGrantRegistry,
|
|
16254
|
+
);
|
|
15596
16255
|
const editorPreviewLanguage = normalizeStudioEditorLanguage(requestedEditorLanguage);
|
|
15597
16256
|
const isLatex = editorPreviewLanguage === "latex"
|
|
15598
16257
|
|| (
|
|
15599
16258
|
(editorPreviewLanguage === undefined || editorPreviewLanguage === "markdown")
|
|
15600
16259
|
&& isLikelyStandaloneLatexPreview(markdown)
|
|
15601
16260
|
);
|
|
15602
|
-
const html = await renderStudioMarkdownWithPandoc(
|
|
16261
|
+
const html = await renderStudioMarkdownWithPandoc(
|
|
16262
|
+
markdown,
|
|
16263
|
+
isLatex,
|
|
16264
|
+
authorizedContext.resourcePath,
|
|
16265
|
+
authorizedContext.sourcePath,
|
|
16266
|
+
{ embedResources: false },
|
|
16267
|
+
);
|
|
15603
16268
|
respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
|
|
15604
16269
|
} catch (error) {
|
|
15605
16270
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -16342,13 +17007,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
16342
17007
|
try {
|
|
16343
17008
|
const listing = listStudioFileBrowserDirectory(
|
|
16344
17009
|
requestUrl.searchParams.get("dir") ?? undefined,
|
|
17010
|
+
requestUrl.searchParams.get("root") ?? undefined,
|
|
16345
17011
|
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
16346
17012
|
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
16347
17013
|
studioCwd,
|
|
17014
|
+
studioResourceGrantRegistry,
|
|
16348
17015
|
requestUrl.searchParams.get("sort") ?? undefined,
|
|
16349
17016
|
);
|
|
16350
|
-
respondJson(res, 200, {
|
|
17017
|
+
respondJson(res, 200, {
|
|
17018
|
+
ok: true,
|
|
17019
|
+
...listing,
|
|
17020
|
+
entries: method === "HEAD" ? [] : listing.entries,
|
|
17021
|
+
exactFiles: method === "HEAD" ? [] : listing.exactFiles,
|
|
17022
|
+
});
|
|
16351
17023
|
} catch (error) {
|
|
17024
|
+
if (error instanceof StudioDirectoryGrantRequiredError) {
|
|
17025
|
+
respondJson(res, 403, {
|
|
17026
|
+
ok: false,
|
|
17027
|
+
code: error.code,
|
|
17028
|
+
error: error.message,
|
|
17029
|
+
path: error.directoryPath,
|
|
17030
|
+
directoryPath: error.directoryPath,
|
|
17031
|
+
...getStudioFileBrowserGrantLocations(studioResourceGrantRegistry),
|
|
17032
|
+
});
|
|
17033
|
+
return;
|
|
17034
|
+
}
|
|
16352
17035
|
respondJson(res, 404, { ok: false, error: `File browser unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
16353
17036
|
}
|
|
16354
17037
|
return;
|
|
@@ -16361,7 +17044,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16361
17044
|
return;
|
|
16362
17045
|
}
|
|
16363
17046
|
|
|
16364
|
-
void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd).catch((error) => {
|
|
17047
|
+
void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
|
|
16365
17048
|
respondJson(res, 500, { ok: false, error: `Open folder failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
16366
17049
|
});
|
|
16367
17050
|
return;
|
|
@@ -16380,6 +17063,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
16380
17063
|
return;
|
|
16381
17064
|
}
|
|
16382
17065
|
|
|
17066
|
+
if (requestUrl.pathname === "/resource-grants") {
|
|
17067
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
17068
|
+
if (token !== serverState.token) {
|
|
17069
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
17070
|
+
return;
|
|
17071
|
+
}
|
|
17072
|
+
|
|
17073
|
+
void handleStudioResourceGrantRequest(req, res, studioResourceGrantRegistry, studioCwd).catch((error) => {
|
|
17074
|
+
respondJson(res, 500, { ok: false, error: `Resource grant failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
17075
|
+
});
|
|
17076
|
+
return;
|
|
17077
|
+
}
|
|
17078
|
+
|
|
17079
|
+
if (requestUrl.pathname === "/side-question-context-root") {
|
|
17080
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
17081
|
+
if (token !== serverState.token) {
|
|
17082
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
17083
|
+
return;
|
|
17084
|
+
}
|
|
17085
|
+
|
|
17086
|
+
void handleStudioSideQuestionContextRootRequest(req, res).catch((error) => {
|
|
17087
|
+
respondJson(res, 500, { ok: false, error: `Side-question context check failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
17088
|
+
});
|
|
17089
|
+
return;
|
|
17090
|
+
}
|
|
17091
|
+
|
|
16383
17092
|
if (requestUrl.pathname === "/local-preview-link") {
|
|
16384
17093
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
16385
17094
|
if (token !== serverState.token) {
|
|
@@ -16394,9 +17103,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
16394
17103
|
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
16395
17104
|
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
16396
17105
|
studioCwd,
|
|
17106
|
+
studioResourceGrantRegistry,
|
|
17107
|
+
);
|
|
17108
|
+
await respondLocalPreviewLinkJson(
|
|
17109
|
+
req,
|
|
17110
|
+
res,
|
|
17111
|
+
requestUrl,
|
|
17112
|
+
resource,
|
|
17113
|
+
serverState,
|
|
17114
|
+
studioResourceGrantRegistry,
|
|
16397
17115
|
);
|
|
16398
|
-
await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
|
|
16399
17116
|
} catch (error) {
|
|
17117
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
17118
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
17119
|
+
return;
|
|
17120
|
+
}
|
|
16400
17121
|
respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
16401
17122
|
}
|
|
16402
17123
|
})();
|
|
@@ -16410,7 +17131,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16410
17131
|
return;
|
|
16411
17132
|
}
|
|
16412
17133
|
|
|
16413
|
-
void handleRevealLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
|
|
17134
|
+
void handleRevealLocalPreviewResourceRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
|
|
16414
17135
|
respondJson(res, 500, { ok: false, error: `Reveal failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
16415
17136
|
});
|
|
16416
17137
|
return;
|
|
@@ -16423,7 +17144,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16423
17144
|
return;
|
|
16424
17145
|
}
|
|
16425
17146
|
|
|
16426
|
-
void handleOpenLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
|
|
17147
|
+
void handleOpenLocalPreviewResourceRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
|
|
16427
17148
|
respondJson(res, 500, { ok: false, error: `Open in system viewer failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
16428
17149
|
});
|
|
16429
17150
|
return;
|
|
@@ -16442,9 +17163,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
16442
17163
|
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
16443
17164
|
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
16444
17165
|
studioCwd,
|
|
17166
|
+
studioResourceGrantRegistry,
|
|
16445
17167
|
);
|
|
16446
17168
|
respondPdfFile(req, res, filePath);
|
|
16447
17169
|
} catch (error) {
|
|
17170
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
17171
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
17172
|
+
return;
|
|
17173
|
+
}
|
|
16448
17174
|
respondText(res, 404, `PDF resource unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
16449
17175
|
}
|
|
16450
17176
|
return;
|
|
@@ -16463,10 +17189,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
16463
17189
|
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
16464
17190
|
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
16465
17191
|
studioCwd,
|
|
17192
|
+
studioResourceGrantRegistry,
|
|
16466
17193
|
);
|
|
16467
17194
|
respondHtmlPreviewResourceJson(req, res, resource.filePath, resource.mimeType);
|
|
16468
17195
|
} catch (error) {
|
|
16469
|
-
|
|
17196
|
+
if (error instanceof StudioResourceGrantRequiredError) {
|
|
17197
|
+
respondStudioResourceGrantRequiredJson(res, error);
|
|
17198
|
+
return;
|
|
17199
|
+
}
|
|
17200
|
+
respondJson(res, 404, { ok: false, error: `Studio preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
16470
17201
|
}
|
|
16471
17202
|
return;
|
|
16472
17203
|
}
|
|
@@ -16545,6 +17276,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
16545
17276
|
const host = req.headers.host ?? `127.0.0.1:${state.port}`;
|
|
16546
17277
|
const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
|
|
16547
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
|
+
}
|
|
16548
17305
|
if (clientMode === "full") {
|
|
16549
17306
|
for (const client of clients) {
|
|
16550
17307
|
if (client.readyState !== WebSocket.OPEN) continue;
|
|
@@ -16560,6 +17317,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16560
17317
|
}
|
|
16561
17318
|
clients.add(ws);
|
|
16562
17319
|
clientModes.set(ws, clientMode);
|
|
17320
|
+
if (canonicalWatchedPath) studioWatchedClientPaths.set(ws, canonicalWatchedPath);
|
|
16563
17321
|
emitDebugEvent("studio_ws_connected", { clients: clients.size, mode: clientMode });
|
|
16564
17322
|
broadcastState();
|
|
16565
17323
|
|
|
@@ -16572,15 +17330,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
16572
17330
|
handleStudioMessage(ws, parsed);
|
|
16573
17331
|
});
|
|
16574
17332
|
|
|
16575
|
-
|
|
17333
|
+
const cleanupClient = () => {
|
|
16576
17334
|
clients.delete(ws);
|
|
16577
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();
|
|
16578
17348
|
emitDebugEvent("studio_ws_disconnected", { clients: clients.size });
|
|
16579
17349
|
});
|
|
16580
17350
|
|
|
16581
17351
|
ws.on("error", () => {
|
|
16582
|
-
|
|
16583
|
-
|
|
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();
|
|
16584
17360
|
});
|
|
16585
17361
|
});
|
|
16586
17362
|
|
|
@@ -16648,7 +17424,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
16648
17424
|
const stopServer = async () => {
|
|
16649
17425
|
studioSideQuestionStartRequestId = null;
|
|
16650
17426
|
await disposeStudioSideQuestionRuntime();
|
|
16651
|
-
|
|
17427
|
+
await closeAllStudioFileWatchers();
|
|
17428
|
+
if (!serverState) {
|
|
17429
|
+
studioWatchedClientPaths.clear();
|
|
17430
|
+
studioFileWatchSubscriptionGenerations.clear();
|
|
17431
|
+
return;
|
|
17432
|
+
}
|
|
16652
17433
|
clearStudioDirectRunState();
|
|
16653
17434
|
clearActiveRequest();
|
|
16654
17435
|
clearPendingStudioCompletion();
|
|
@@ -16674,7 +17455,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
16674
17455
|
await new Promise<void>((resolve) => {
|
|
16675
17456
|
state.server.close(() => resolve());
|
|
16676
17457
|
});
|
|
17458
|
+
studioWatchedClientPaths.clear();
|
|
17459
|
+
studioFileWatchSubscriptionGenerations.clear();
|
|
16677
17460
|
studioWorkspaceStateStore.clear();
|
|
17461
|
+
studioResourceGrantRegistry.clear();
|
|
16678
17462
|
};
|
|
16679
17463
|
|
|
16680
17464
|
const hydrateLatestAssistant = (entries: SessionEntry[]) => {
|
|
@@ -16691,6 +17475,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
16691
17475
|
await disposeStudioSideQuestionRuntime();
|
|
16692
17476
|
studioTraceHistory.clear();
|
|
16693
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;
|
|
16694
17485
|
}
|
|
16695
17486
|
latestModelRequestCtx = ctx;
|
|
16696
17487
|
hydrateLatestAssistant(ctx.sessionManager.getBranch());
|
|
@@ -17042,7 +17833,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17042
17833
|
const resolveStudioLaunchDocument = (
|
|
17043
17834
|
trimmed: string,
|
|
17044
17835
|
ctx: ExtensionCommandContext,
|
|
17045
|
-
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 },
|
|
17046
17837
|
): StudioLaunchSelection | null => {
|
|
17047
17838
|
const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
|
|
17048
17839
|
const commandLabel = options?.commandLabel ?? "/studio";
|
|
@@ -17149,6 +17940,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
17149
17940
|
return null;
|
|
17150
17941
|
}
|
|
17151
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
|
+
|
|
17152
17967
|
if (file.text.length > 200_000) {
|
|
17153
17968
|
ctx.ui.notify(
|
|
17154
17969
|
"Loaded a large file. Studio critique requests currently reject documents over 200k characters.",
|
|
@@ -17162,6 +17977,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17162
17977
|
source: "file",
|
|
17163
17978
|
path: file.resolvedPath,
|
|
17164
17979
|
resourceDir: ctx.cwd,
|
|
17980
|
+
diskRevision: file.diskRevision,
|
|
17165
17981
|
});
|
|
17166
17982
|
};
|
|
17167
17983
|
|
|
@@ -17419,7 +18235,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17419
18235
|
trimmed: string,
|
|
17420
18236
|
ctx: ExtensionCommandContext,
|
|
17421
18237
|
mode: StudioUiMode,
|
|
17422
|
-
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 },
|
|
17423
18239
|
) => {
|
|
17424
18240
|
const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
|
|
17425
18241
|
if (launchOpenFlags.error) {
|
|
@@ -17435,11 +18251,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
17435
18251
|
const launchesPdfPreview = parsedLaunchPath
|
|
17436
18252
|
? Boolean(parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath)))
|
|
17437
18253
|
: false;
|
|
17438
|
-
if (launchOpenFlags.watchPdf && !
|
|
17439
|
-
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");
|
|
17440
18256
|
return;
|
|
17441
18257
|
}
|
|
17442
|
-
const
|
|
18258
|
+
const launchesWatchedTextPreview = Boolean(launchOpenFlags.watchPdf && parsedLaunchPath && !launchesPdfPreview);
|
|
18259
|
+
const requestedLaunchMode: StudioUiMode = launchesPdfPreview || launchesWatchedTextPreview ? "editor-only" : mode;
|
|
17443
18260
|
if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
|
|
17444
18261
|
if (options?.replaceExistingFull) {
|
|
17445
18262
|
closeStudioClientsByMode("full", 4001, "Full Studio replaced");
|
|
@@ -17473,7 +18290,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
17473
18290
|
|
|
17474
18291
|
const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
|
|
17475
18292
|
...options,
|
|
17476
|
-
watchPdf: launchOpenFlags.watchPdf,
|
|
18293
|
+
watchPdf: launchOpenFlags.watchPdf && launchesPdfPreview,
|
|
18294
|
+
watchText: launchesWatchedTextPreview,
|
|
17477
18295
|
});
|
|
17478
18296
|
if (!selection) return;
|
|
17479
18297
|
const selected = selection.document;
|
|
@@ -17489,6 +18307,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17489
18307
|
ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
|
|
17490
18308
|
return;
|
|
17491
18309
|
}
|
|
18310
|
+
recordStudioDocumentResourceGrants(selected, ctx.cwd);
|
|
17492
18311
|
const docId = selection.transient ? storeTransientStudioDocument(selected) : undefined;
|
|
17493
18312
|
const url = buildStudioUrl(state.port, state.token, launchMode, selected, docId, {
|
|
17494
18313
|
skipWorkspaceRestore: selection.skipWorkspaceRestore,
|
|
@@ -17498,7 +18317,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17498
18317
|
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
17499
18318
|
const openedLabel = selection.kind === "pdf-preview"
|
|
17500
18319
|
? "pi Studio PDF preview"
|
|
17501
|
-
: (
|
|
18320
|
+
: (selection.kind === "watched-preview"
|
|
18321
|
+
? "pi Studio watched file preview"
|
|
18322
|
+
: (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio"));
|
|
17502
18323
|
|
|
17503
18324
|
const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
|
|
17504
18325
|
openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
|
|
@@ -17513,6 +18334,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
17513
18334
|
if (selection.kind === "pdf-preview") {
|
|
17514
18335
|
const watchLabel = launchOpenFlags.watchPdf ? " (auto-refresh on)" : "";
|
|
17515
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");
|
|
17516
18339
|
} else if (selected.source === "file") {
|
|
17517
18340
|
ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
|
|
17518
18341
|
} else if (selected.source === "last-response") {
|
|
@@ -17535,7 +18358,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17535
18358
|
};
|
|
17536
18359
|
|
|
17537
18360
|
pi.registerCommand("studio", {
|
|
17538
|
-
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)",
|
|
17539
18362
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
17540
18363
|
const trimmed = args.trim();
|
|
17541
18364
|
|
|
@@ -17566,7 +18389,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17566
18389
|
"Usage: /studio [path|--blank|--last]\n"
|
|
17567
18390
|
+ " /studio Open studio with last model response (fallback: blank)\n"
|
|
17568
18391
|
+ " /studio <path> Open a text file in Studio, or a PDF in a read-only companion preview\n"
|
|
17569
|
-
+ " /studio --watch <
|
|
18392
|
+
+ " /studio --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
|
|
17570
18393
|
+ " /studio --blank Open with blank editor\n"
|
|
17571
18394
|
+ " /studio --last Open with last model response\n"
|
|
17572
18395
|
+ " /studio --no-browser Print the Studio URL without opening a browser\n"
|
|
@@ -17617,7 +18440,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17617
18440
|
});
|
|
17618
18441
|
|
|
17619
18442
|
pi.registerCommand("studio-editor-only", {
|
|
17620
|
-
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>)",
|
|
17621
18444
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
17622
18445
|
const trimmed = args.trim();
|
|
17623
18446
|
if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
|
|
@@ -17625,7 +18448,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17625
18448
|
"Usage: /studio-editor-only [path|--blank|--last]\n"
|
|
17626
18449
|
+ " /studio-editor-only Open an editor-only Studio view (default: blank editor)\n"
|
|
17627
18450
|
+ " /studio-editor-only <path> Open a text file for editing, or a PDF in a read-only preview\n"
|
|
17628
|
-
+ " /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"
|
|
17629
18452
|
+ " /studio-editor-only --blank Open with blank editor\n"
|
|
17630
18453
|
+ " /studio-editor-only --last Open with last model response loaded into the editor\n"
|
|
17631
18454
|
+ " /studio-editor-only --no-browser Print URL without opening a browser\n"
|
|
@@ -17981,8 +18804,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
17981
18804
|
label: file.label,
|
|
17982
18805
|
source: "file",
|
|
17983
18806
|
path: file.resolvedPath,
|
|
18807
|
+
diskRevision: file.diskRevision,
|
|
17984
18808
|
};
|
|
17985
18809
|
initialStudioDocument = nextDoc;
|
|
18810
|
+
recordStudioDocumentResourceGrants(nextDoc, ctx.cwd);
|
|
17986
18811
|
|
|
17987
18812
|
broadcastState();
|
|
17988
18813
|
broadcastResponseHistory();
|