pi-studio 0.9.12 → 0.9.13

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/client/studio.css CHANGED
@@ -396,7 +396,6 @@
396
396
  body[data-studio-mode="editor-only"] #sendRunBtn,
397
397
  body[data-studio-mode="editor-only"] #queueSteerBtn,
398
398
  body[data-studio-mode="editor-only"] #sendEditorBtn,
399
- body[data-studio-mode="editor-only"] #insertHeaderBtn,
400
399
  body[data-studio-mode="editor-only"] #lensSelect,
401
400
  body[data-studio-mode="editor-only"] #critiqueBtn,
402
401
  body[data-studio-mode="editor-only"] #followSelect,
@@ -4471,19 +4470,24 @@
4471
4470
  justify-self: stretch;
4472
4471
  min-width: 0;
4473
4472
  max-width: 100%;
4474
- overflow: hidden;
4473
+ overflow: visible;
4475
4474
  position: relative;
4476
4475
  z-index: 1;
4477
4476
  }
4478
4477
 
4479
4478
  body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-action-line {
4480
4479
  max-width: 100%;
4481
- overflow: hidden;
4480
+ overflow: visible;
4482
4481
  flex-wrap: nowrap;
4483
4482
  justify-content: flex-end;
4484
4483
  white-space: nowrap;
4485
4484
  }
4486
4485
 
4486
+ body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-menu-anchor {
4487
+ min-width: 0;
4488
+ max-width: 100%;
4489
+ }
4490
+
4487
4491
  body.studio-ui-refresh .studio-refresh-toolbar-state button,
4488
4492
  body.studio-ui-refresh .studio-refresh-toolbar-state select,
4489
4493
  body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-chip {
@@ -4491,6 +4495,11 @@
4491
4495
  max-width: 100%;
4492
4496
  }
4493
4497
 
4498
+ body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-chip {
4499
+ overflow: hidden;
4500
+ text-overflow: ellipsis;
4501
+ }
4502
+
4494
4503
  body.studio-ui-refresh .studio-refresh-toolbar button,
4495
4504
  body.studio-ui-refresh .studio-refresh-toolbar select {
4496
4505
  font-size: 12px;
@@ -4674,6 +4683,41 @@
4674
4683
  font-weight: 600;
4675
4684
  }
4676
4685
 
4686
+ .studio-preview-link-menu {
4687
+ position: fixed;
4688
+ z-index: 12000;
4689
+ min-width: 180px;
4690
+ padding: 6px;
4691
+ border: 1px solid var(--border);
4692
+ border-radius: 10px;
4693
+ background: var(--panel);
4694
+ color: var(--text);
4695
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
4696
+ }
4697
+
4698
+ .studio-preview-link-menu[hidden] {
4699
+ display: none;
4700
+ }
4701
+
4702
+ .studio-preview-link-menu button {
4703
+ display: block;
4704
+ width: 100%;
4705
+ border: 0;
4706
+ border-radius: 7px;
4707
+ background: transparent;
4708
+ color: inherit;
4709
+ text-align: left;
4710
+ padding: 7px 9px;
4711
+ font: inherit;
4712
+ cursor: pointer;
4713
+ }
4714
+
4715
+ .studio-preview-link-menu button:hover,
4716
+ .studio-preview-link-menu button:focus-visible {
4717
+ background: var(--panel-2);
4718
+ outline: none;
4719
+ }
4720
+
4677
4721
  @media (max-width: 1280px) {
4678
4722
  body.studio-ui-refresh #rightSectionHeader,
4679
4723
  body.studio-ui-refresh .studio-refresh-header-top,
package/index.ts CHANGED
@@ -1862,6 +1862,36 @@ function expandHome(pathInput: string): string {
1862
1862
  return join(home, pathInput.slice(2));
1863
1863
  }
1864
1864
 
1865
+ function normalizeStudioResourceDirectoryInput(resourceDir: string): string {
1866
+ let value = stripMatchingPathQuotes(String(resourceDir || "").trim());
1867
+ if (!value) return "";
1868
+ if (/^file:\/\//i.test(value)) {
1869
+ try {
1870
+ value = decodeURIComponent(new URL(value).pathname || value).trim();
1871
+ } catch {
1872
+ // Keep the original value if URL parsing fails.
1873
+ }
1874
+ }
1875
+ const windowsMatch = value.match(/.*([A-Za-z]:[\\/].*)$/);
1876
+ if (windowsMatch?.[1]) return windowsMatch[1].trim();
1877
+ const markers = ["/Users/", "/home/", "/Volumes/", "/private/", "/tmp/", "/var/", "/opt/", "/Applications/"];
1878
+ let embeddedAbsoluteIndex = -1;
1879
+ for (const marker of markers) {
1880
+ const index = value.lastIndexOf(marker);
1881
+ if (index > 0) embeddedAbsoluteIndex = Math.max(embeddedAbsoluteIndex, index);
1882
+ }
1883
+ if (embeddedAbsoluteIndex > 0) value = value.slice(embeddedAbsoluteIndex).trim();
1884
+ return value;
1885
+ }
1886
+
1887
+ function recoverLikelyDroppedLeadingSlashPath(pathInput: string): string {
1888
+ const value = String(pathInput || "").trim();
1889
+ if (!value || isAbsolute(value)) return value;
1890
+ if (!/^(?:Users|home|Volumes|private|tmp|var|opt|Applications)\//.test(value)) return value;
1891
+ const candidate = `/${value}`;
1892
+ return existsSync(candidate) ? candidate : value;
1893
+ }
1894
+
1865
1895
  function resolveStudioPath(pathArg: string, cwd: string): { ok: true; resolved: string; label: string } | { ok: false; message: string } {
1866
1896
  const normalized = normalizePathInput(pathArg);
1867
1897
  if (!normalized) {
@@ -2332,23 +2362,53 @@ function resolveStudioBaseDir(sourcePath: string | undefined, resourceDir: strin
2332
2362
  return dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded));
2333
2363
  }
2334
2364
 
2335
- const resource = typeof resourceDir === "string" ? resourceDir.trim() : "";
2365
+ const resource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2336
2366
  if (resource) {
2337
- const expanded = expandHome(resource);
2367
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(resource));
2338
2368
  return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
2339
2369
  }
2340
2370
 
2341
2371
  return fallbackCwd;
2342
2372
  }
2343
2373
 
2374
+ function isPathInsideOrEqualDirectory(childPath: string, parentDir: string): boolean {
2375
+ const rel = relative(parentDir, childPath);
2376
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
2377
+ }
2378
+
2379
+ function resolveStudioPreviewResourceContext(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): { baseDir: string; boundaryDir: string } {
2380
+ const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
2381
+ const sourceBaseDir = source
2382
+ ? dirname(isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(source)))
2383
+ ? recoverLikelyDroppedLeadingSlashPath(expandHome(source))
2384
+ : resolve(fallbackCwd, recoverLikelyDroppedLeadingSlashPath(expandHome(source))))
2385
+ : "";
2386
+
2387
+ const resource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2388
+ const resourceBaseDir = resource
2389
+ ? (isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(resource)))
2390
+ ? recoverLikelyDroppedLeadingSlashPath(expandHome(resource))
2391
+ : resolve(fallbackCwd, recoverLikelyDroppedLeadingSlashPath(expandHome(resource))))
2392
+ : "";
2393
+
2394
+ const baseDir = sourceBaseDir || resourceBaseDir || fallbackCwd;
2395
+ let boundaryDir = baseDir;
2396
+ if (resourceBaseDir) {
2397
+ boundaryDir = !sourceBaseDir || isPathInsideOrEqualDirectory(resolve(sourceBaseDir), resolve(resourceBaseDir))
2398
+ ? resourceBaseDir
2399
+ : baseDir;
2400
+ }
2401
+ return { baseDir, boundaryDir };
2402
+ }
2403
+
2344
2404
  function resolveStudioGitDiffBaseDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2345
2405
  return resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2346
2406
  }
2347
2407
 
2348
2408
  function resolveStudioCompanionResourceDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string | undefined {
2349
- const explicitResource = typeof resourceDir === "string" ? resourceDir.trim() : "";
2409
+ const explicitResource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2350
2410
  if (explicitResource) {
2351
- const expanded = expandHome(explicitResource);
2411
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(explicitResource));
2352
2412
  return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
2353
2413
  }
2354
2414
 
@@ -2373,10 +2433,51 @@ const STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT = new Map<string, string>([
2373
2433
  [".gif", "image/gif"],
2374
2434
  [".webp", "image/webp"],
2375
2435
  ]);
2436
+ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
2437
+ ".md", ".markdown", ".mdx", ".qmd", ".txt", ".tex", ".latex", ".rst", ".adoc",
2438
+ ".html", ".htm", ".css", ".xml", ".yaml", ".yml", ".toml", ".json", ".jsonc", ".json5", ".csv", ".tsv", ".log",
2439
+ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
2440
+ ".py", ".pyw", ".sh", ".bash", ".zsh", ".rs", ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hxx",
2441
+ ".jl", ".f90", ".f95", ".f03", ".f", ".for", ".r", ".m", ".java", ".go", ".rb", ".swift", ".lua",
2442
+ ".diff", ".patch",
2443
+ ]);
2444
+ const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2445
+
2446
+ type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "other";
2447
+
2448
+ interface StudioLocalPreviewResource {
2449
+ filePath: string;
2450
+ label: string;
2451
+ extension: string;
2452
+ kind: StudioLocalPreviewResourceKind;
2453
+ page: number | null;
2454
+ resourceDir: string;
2455
+ }
2376
2456
 
2377
2457
  function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2378
- const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2379
- return resolveStudioPdfResourceFile(pdfPath, baseDir);
2458
+ const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
2459
+ if (!rawPath) throw new Error("Missing PDF path.");
2460
+ if (/\0/.test(rawPath)) throw new Error("Invalid PDF path.");
2461
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
2462
+ throw new Error("Only local PDF paths are supported.");
2463
+ }
2464
+
2465
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2466
+ const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
2467
+ const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
2468
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
2469
+ if (extname(candidate).toLowerCase() !== ".pdf") throw new Error("Only .pdf files can be embedded.");
2470
+
2471
+ const boundaryReal = realpathSync(context.boundaryDir);
2472
+ const candidateReal = realpathSync(candidate);
2473
+ const rel = relative(boundaryReal, candidateReal);
2474
+ if (rel.startsWith("..") || isAbsolute(rel)) {
2475
+ throw new Error("PDF path must stay within the current Studio resource directory.");
2476
+ }
2477
+
2478
+ const stat = statSync(candidateReal);
2479
+ if (!stat.isFile()) throw new Error("PDF path does not refer to a file.");
2480
+ return candidateReal;
2380
2481
  }
2381
2482
 
2382
2483
  function stripStudioHtmlPreviewResourceUrlSuffix(resourcePath: string): string {
@@ -2392,6 +2493,82 @@ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
2392
2493
  }
2393
2494
  }
2394
2495
 
2496
+ function parseStudioLocalPreviewResourcePage(resourcePath: string): number | null {
2497
+ const raw = String(resourcePath || "");
2498
+ const parts: string[] = [];
2499
+ const queryIndex = raw.indexOf("?");
2500
+ if (queryIndex >= 0) {
2501
+ const queryEnd = raw.indexOf("#", queryIndex);
2502
+ parts.push(raw.slice(queryIndex + 1, queryEnd >= 0 ? queryEnd : raw.length));
2503
+ }
2504
+ const hashIndex = raw.indexOf("#");
2505
+ if (hashIndex >= 0) parts.push(raw.slice(hashIndex + 1));
2506
+ for (const part of parts) {
2507
+ try {
2508
+ const params = new URLSearchParams(part);
2509
+ const rawPage = params.get("page") || params.get("p");
2510
+ if (rawPage) {
2511
+ const page = Number.parseInt(rawPage, 10);
2512
+ if (Number.isFinite(page) && page > 0) return page;
2513
+ }
2514
+ } catch {
2515
+ const match = part.match(/(?:^|[&;])page=(\d+)/i) || part.match(/^page=(\d+)$/i);
2516
+ if (match && match[1]) {
2517
+ const page = Number.parseInt(match[1], 10);
2518
+ if (Number.isFinite(page) && page > 0) return page;
2519
+ }
2520
+ }
2521
+ }
2522
+ return null;
2523
+ }
2524
+
2525
+ function getStudioLocalPreviewResourceKind(extension: string): StudioLocalPreviewResourceKind {
2526
+ const ext = extension.toLowerCase();
2527
+ if (ext === ".pdf") return "pdf";
2528
+ if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext)) return "text";
2529
+ if (STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS.has(ext)) return "image";
2530
+ return "other";
2531
+ }
2532
+
2533
+ function resolveStudioLocalPreviewResourcePath(
2534
+ resourcePath: string | undefined,
2535
+ sourcePath: string | undefined,
2536
+ resourceDir: string | undefined,
2537
+ fallbackCwd: string,
2538
+ ): StudioLocalPreviewResource {
2539
+ const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
2540
+ if (!rawPath) throw new Error("Missing local resource path.");
2541
+ if (/\0/.test(rawPath)) throw new Error("Invalid local resource path.");
2542
+ if (/^\/\//.test(rawPath)) throw new Error("Network resources are not local Studio resources.");
2543
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
2544
+ throw new Error("Only local relative resources are supported.");
2545
+ }
2546
+
2547
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2548
+ const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
2549
+ if (!cleanedPath || cleanedPath.startsWith("#")) throw new Error("Missing local resource path.");
2550
+ const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
2551
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
2552
+ const extension = extname(candidate).toLowerCase();
2553
+ const boundaryReal = realpathSync(context.boundaryDir);
2554
+ const candidateReal = realpathSync(candidate);
2555
+ const rel = relative(boundaryReal, candidateReal);
2556
+ if (rel.startsWith("..") || isAbsolute(rel)) {
2557
+ throw new Error("Local resource path must stay within the current Studio resource directory.");
2558
+ }
2559
+
2560
+ const stat = statSync(candidateReal);
2561
+ if (!stat.isFile()) throw new Error("Local resource path does not refer to a file.");
2562
+ return {
2563
+ filePath: candidateReal,
2564
+ label: rel && rel !== "" ? rel : basename(candidateReal),
2565
+ extension,
2566
+ kind: getStudioLocalPreviewResourceKind(extension),
2567
+ page: parseStudioLocalPreviewResourcePage(rawPath),
2568
+ resourceDir: boundaryReal,
2569
+ };
2570
+ }
2571
+
2395
2572
  function resolveStudioHtmlPreviewResourcePath(
2396
2573
  resourcePath: string | undefined,
2397
2574
  sourcePath: string | undefined,
@@ -2405,17 +2582,17 @@ function resolveStudioHtmlPreviewResourcePath(
2405
2582
  throw new Error("Only local HTML preview resources are supported.");
2406
2583
  }
2407
2584
 
2408
- const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2585
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2409
2586
  const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
2410
- const expandedPath = expandHome(cleanedPath);
2411
- const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(baseDir, expandedPath);
2587
+ const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
2588
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
2412
2589
  const ext = extname(candidate).toLowerCase();
2413
2590
  const mimeType = STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT.get(ext);
2414
2591
  if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and WebP images can be embedded in HTML previews.");
2415
2592
 
2416
- const baseReal = realpathSync(baseDir);
2593
+ const boundaryReal = realpathSync(context.boundaryDir);
2417
2594
  const candidateReal = realpathSync(candidate);
2418
- const rel = relative(baseReal, candidateReal);
2595
+ const rel = relative(boundaryReal, candidateReal);
2419
2596
  if (rel.startsWith("..") || isAbsolute(rel)) {
2420
2597
  throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
2421
2598
  }
@@ -6355,6 +6532,134 @@ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerRespons
6355
6532
  });
6356
6533
  }
6357
6534
 
6535
+ function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): void {
6536
+ const method = (req.method ?? "GET").toUpperCase();
6537
+ if (method !== "GET" && method !== "HEAD") {
6538
+ res.setHeader("Allow", "GET, HEAD");
6539
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET." });
6540
+ return;
6541
+ }
6542
+
6543
+ const action = (requestUrl.searchParams.get("action") ?? "resolve").trim().toLowerCase();
6544
+ const basePayload = {
6545
+ ok: true,
6546
+ kind: resource.kind,
6547
+ path: resource.filePath,
6548
+ label: resource.label,
6549
+ extension: resource.extension,
6550
+ page: resource.page,
6551
+ resourceDir: resource.resourceDir,
6552
+ };
6553
+
6554
+ if (method === "HEAD" || action === "resolve") {
6555
+ respondJson(res, 200, basePayload);
6556
+ return;
6557
+ }
6558
+
6559
+ if (action !== "document" && action !== "editor-url") {
6560
+ respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
6561
+ return;
6562
+ }
6563
+ if (resource.kind !== "text") {
6564
+ respondJson(res, 400, { ok: false, error: "This local resource is not a text document Studio can load into the editor." });
6565
+ return;
6566
+ }
6567
+
6568
+ const file = readStudioFile(resource.filePath, dirname(resource.filePath));
6569
+ if (file.ok === false) {
6570
+ respondJson(res, 400, { ok: false, error: file.message });
6571
+ return;
6572
+ }
6573
+
6574
+ const document: InitialStudioDocument = {
6575
+ text: file.text,
6576
+ label: resource.label || file.label,
6577
+ source: "file",
6578
+ path: file.resolvedPath,
6579
+ resourceDir: resource.resourceDir,
6580
+ };
6581
+ if (action === "document") {
6582
+ respondJson(res, 200, {
6583
+ ...basePayload,
6584
+ text: file.text,
6585
+ resourceDir: resource.resourceDir,
6586
+ });
6587
+ return;
6588
+ }
6589
+
6590
+ const docId = storeTransientStudioDocument(document);
6591
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
6592
+ const parsedUrl = new URL(url);
6593
+ respondJson(res, 200, {
6594
+ ...basePayload,
6595
+ url,
6596
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
6597
+ });
6598
+ }
6599
+
6600
+ function revealStudioLocalFile(filePath: string): { ok: true; message: string } | { ok: false; message: string } {
6601
+ if (isSshSession()) {
6602
+ return { ok: false, message: "Cannot reveal files from an SSH/headless Studio session. Copy the path instead." };
6603
+ }
6604
+
6605
+ let command = "";
6606
+ let args: string[] = [];
6607
+ if (process.platform === "darwin") {
6608
+ command = "open";
6609
+ args = ["-R", filePath];
6610
+ } else if (process.platform === "win32") {
6611
+ command = "explorer.exe";
6612
+ args = [`/select,${filePath}`];
6613
+ } else {
6614
+ command = "xdg-open";
6615
+ args = [dirname(filePath)];
6616
+ }
6617
+
6618
+ const result = spawnSync(command, args, { stdio: "ignore" });
6619
+ if (result.error) {
6620
+ return { ok: false, message: `Could not open file manager: ${result.error.message}` };
6621
+ }
6622
+ if (result.status !== 0) {
6623
+ return { ok: false, message: "Could not open file manager for this resource." };
6624
+ }
6625
+ return { ok: true, message: process.platform === "linux" ? "Opened containing folder." : "Revealed resource in file manager." };
6626
+ }
6627
+
6628
+ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
6629
+ const method = (req.method ?? "GET").toUpperCase();
6630
+ if (method !== "POST") {
6631
+ res.setHeader("Allow", "POST");
6632
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
6633
+ return;
6634
+ }
6635
+
6636
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
6637
+ let payload: Record<string, unknown> = {};
6638
+ try {
6639
+ payload = rawBody ? JSON.parse(rawBody) : {};
6640
+ } catch {
6641
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
6642
+ return;
6643
+ }
6644
+
6645
+ try {
6646
+ const resource = resolveStudioLocalPreviewResourcePath(
6647
+ typeof payload.path === "string" ? payload.path : "",
6648
+ typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
6649
+ typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
6650
+ studioCwd,
6651
+ );
6652
+ const result = revealStudioLocalFile(resource.filePath);
6653
+ if (!result.ok) {
6654
+ respondJson(res, 409, { ok: false, error: result.message, path: resource.filePath });
6655
+ return;
6656
+ }
6657
+ respondJson(res, 200, { ok: true, message: result.message, path: resource.filePath, label: resource.label });
6658
+ } catch (error) {
6659
+ respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
6660
+ }
6661
+ }
6662
+
6358
6663
  function openUrlInDefaultBrowser(url: string): Promise<void> {
6359
6664
  const openCommand =
6360
6665
  process.platform === "darwin"
@@ -8653,7 +8958,7 @@ function resolveRequestedStudioDocumentFromUrl(
8653
8958
  label: requestedLabel || file.label,
8654
8959
  source: "file",
8655
8960
  path: file.resolvedPath,
8656
- resourceDir: requestedResourceDir || undefined,
8961
+ resourceDir: requestedResourceDir || dirname(file.resolvedPath),
8657
8962
  };
8658
8963
  }
8659
8964
  }
@@ -9009,6 +9314,7 @@ ${cssVarsBlock}
9009
9314
  <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here when no direct save path is available.">Save editor as…</button>
9010
9315
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
9011
9316
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
9317
+ <button id="clearWorkspaceBtn" type="button" title="Clear the current editor draft in this browser tab. Saved files and responses are not changed.">Clear editor</button>
9012
9318
  <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
9013
9319
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
9014
9320
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
@@ -11241,6 +11547,7 @@ export default function (pi: ExtensionAPI) {
11241
11547
  label: result.label,
11242
11548
  source: "file",
11243
11549
  path: result.resolvedPath,
11550
+ resourceDir: dirname(result.resolvedPath),
11244
11551
  };
11245
11552
 
11246
11553
  sendToClient(client, {
@@ -11248,6 +11555,7 @@ export default function (pi: ExtensionAPI) {
11248
11555
  requestId: msg.requestId,
11249
11556
  path: result.resolvedPath,
11250
11557
  label: result.label,
11558
+ resourceDir: dirname(result.resolvedPath),
11251
11559
  message: `Saved editor text to ${result.label}`,
11252
11560
  });
11253
11561
  return;
@@ -11327,6 +11635,7 @@ export default function (pi: ExtensionAPI) {
11327
11635
  label: refreshed.label,
11328
11636
  source: "file",
11329
11637
  path: refreshed.resolvedPath,
11638
+ resourceDir: dirname(refreshed.resolvedPath),
11330
11639
  };
11331
11640
 
11332
11641
  broadcast({
@@ -12360,6 +12669,40 @@ export default function (pi: ExtensionAPI) {
12360
12669
  return;
12361
12670
  }
12362
12671
 
12672
+ if (requestUrl.pathname === "/local-preview-link") {
12673
+ const token = requestUrl.searchParams.get("token") ?? "";
12674
+ if (token !== serverState.token) {
12675
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12676
+ return;
12677
+ }
12678
+
12679
+ try {
12680
+ const resource = resolveStudioLocalPreviewResourcePath(
12681
+ requestUrl.searchParams.get("path") ?? "",
12682
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
12683
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
12684
+ studioCwd,
12685
+ );
12686
+ respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
12687
+ } catch (error) {
12688
+ respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
12689
+ }
12690
+ return;
12691
+ }
12692
+
12693
+ if (requestUrl.pathname === "/reveal-local-resource") {
12694
+ const token = requestUrl.searchParams.get("token") ?? "";
12695
+ if (token !== serverState.token) {
12696
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12697
+ return;
12698
+ }
12699
+
12700
+ void handleRevealLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
12701
+ respondJson(res, 500, { ok: false, error: `Reveal failed: ${error instanceof Error ? error.message : String(error)}` });
12702
+ });
12703
+ return;
12704
+ }
12705
+
12363
12706
  if (requestUrl.pathname === "/pdf-resource") {
12364
12707
  const token = requestUrl.searchParams.get("token") ?? "";
12365
12708
  if (token !== serverState.token) {
@@ -13021,6 +13364,7 @@ export default function (pi: ExtensionAPI) {
13021
13364
  label: file.label,
13022
13365
  source: "file",
13023
13366
  path: file.resolvedPath,
13367
+ resourceDir: dirname(file.resolvedPath),
13024
13368
  };
13025
13369
  };
13026
13370
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.12",
3
+ "version": "0.9.13",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",