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/CHANGELOG.md +12 -0
- package/README.md +2 -0
- package/client/studio-client.js +788 -49
- package/client/studio.css +47 -3
- package/index.ts +356 -12
- package/package.json +1 -1
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
2379
|
-
|
|
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
|
|
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
|
|
2593
|
+
const boundaryReal = realpathSync(context.boundaryDir);
|
|
2417
2594
|
const candidateReal = realpathSync(candidate);
|
|
2418
|
-
const rel = relative(
|
|
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 ||
|
|
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.
|
|
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",
|