pi-studio 0.9.12 → 0.9.14
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 +24 -0
- package/README.md +2 -0
- package/client/studio-client.js +849 -49
- package/client/studio.css +68 -7
- package/index.ts +369 -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,
|
|
@@ -642,6 +641,7 @@
|
|
|
642
641
|
overflow: hidden;
|
|
643
642
|
text-overflow: ellipsis;
|
|
644
643
|
white-space: nowrap;
|
|
644
|
+
font-weight: 400;
|
|
645
645
|
}
|
|
646
646
|
.resource-dir-label:hover {
|
|
647
647
|
color: var(--fg);
|
|
@@ -3584,17 +3584,29 @@
|
|
|
3584
3584
|
|
|
3585
3585
|
.shortcuts-body {
|
|
3586
3586
|
display: grid;
|
|
3587
|
-
gap:
|
|
3588
|
-
padding:
|
|
3587
|
+
gap: 16px;
|
|
3588
|
+
padding: 16px 18px 18px;
|
|
3589
3589
|
overflow-y: auto;
|
|
3590
3590
|
overflow-x: hidden;
|
|
3591
3591
|
min-height: 0;
|
|
3592
3592
|
background: var(--panel);
|
|
3593
3593
|
}
|
|
3594
3594
|
|
|
3595
|
+
.shortcuts-group {
|
|
3596
|
+
display: block;
|
|
3597
|
+
min-height: auto;
|
|
3598
|
+
border: 0;
|
|
3599
|
+
border-radius: 0;
|
|
3600
|
+
background: transparent;
|
|
3601
|
+
box-shadow: none;
|
|
3602
|
+
overflow: visible;
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3595
3605
|
.shortcuts-group h3 {
|
|
3596
|
-
margin: 0 0
|
|
3606
|
+
margin: 0 0 8px;
|
|
3607
|
+
padding-top: 3px;
|
|
3597
3608
|
font-size: 12px;
|
|
3609
|
+
line-height: 1.5;
|
|
3598
3610
|
font-weight: 700;
|
|
3599
3611
|
color: var(--studio-info-text, var(--muted));
|
|
3600
3612
|
text-transform: uppercase;
|
|
@@ -4260,7 +4272,6 @@
|
|
|
4260
4272
|
}
|
|
4261
4273
|
|
|
4262
4274
|
body.studio-ui-refresh #resourceDirBtn,
|
|
4263
|
-
body.studio-ui-refresh #resourceDirLabel,
|
|
4264
4275
|
body.studio-ui-refresh #reviewNotesBtn,
|
|
4265
4276
|
body.studio-ui-refresh #outlineBtn,
|
|
4266
4277
|
body.studio-ui-refresh #scratchpadBtn,
|
|
@@ -4268,6 +4279,11 @@
|
|
|
4268
4279
|
color: var(--text);
|
|
4269
4280
|
}
|
|
4270
4281
|
|
|
4282
|
+
body.studio-ui-refresh #resourceDirLabel {
|
|
4283
|
+
color: var(--studio-info-text, var(--muted));
|
|
4284
|
+
font-weight: 400;
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4271
4287
|
body.studio-ui-refresh #resourceDirInputWrap.visible {
|
|
4272
4288
|
display: inline-flex;
|
|
4273
4289
|
}
|
|
@@ -4471,19 +4487,24 @@
|
|
|
4471
4487
|
justify-self: stretch;
|
|
4472
4488
|
min-width: 0;
|
|
4473
4489
|
max-width: 100%;
|
|
4474
|
-
overflow:
|
|
4490
|
+
overflow: visible;
|
|
4475
4491
|
position: relative;
|
|
4476
4492
|
z-index: 1;
|
|
4477
4493
|
}
|
|
4478
4494
|
|
|
4479
4495
|
body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-action-line {
|
|
4480
4496
|
max-width: 100%;
|
|
4481
|
-
overflow:
|
|
4497
|
+
overflow: visible;
|
|
4482
4498
|
flex-wrap: nowrap;
|
|
4483
4499
|
justify-content: flex-end;
|
|
4484
4500
|
white-space: nowrap;
|
|
4485
4501
|
}
|
|
4486
4502
|
|
|
4503
|
+
body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-menu-anchor {
|
|
4504
|
+
min-width: 0;
|
|
4505
|
+
max-width: 100%;
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4487
4508
|
body.studio-ui-refresh .studio-refresh-toolbar-state button,
|
|
4488
4509
|
body.studio-ui-refresh .studio-refresh-toolbar-state select,
|
|
4489
4510
|
body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-chip {
|
|
@@ -4491,6 +4512,11 @@
|
|
|
4491
4512
|
max-width: 100%;
|
|
4492
4513
|
}
|
|
4493
4514
|
|
|
4515
|
+
body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-chip {
|
|
4516
|
+
overflow: hidden;
|
|
4517
|
+
text-overflow: ellipsis;
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4494
4520
|
body.studio-ui-refresh .studio-refresh-toolbar button,
|
|
4495
4521
|
body.studio-ui-refresh .studio-refresh-toolbar select {
|
|
4496
4522
|
font-size: 12px;
|
|
@@ -4674,6 +4700,41 @@
|
|
|
4674
4700
|
font-weight: 600;
|
|
4675
4701
|
}
|
|
4676
4702
|
|
|
4703
|
+
.studio-preview-link-menu {
|
|
4704
|
+
position: fixed;
|
|
4705
|
+
z-index: 12000;
|
|
4706
|
+
min-width: 180px;
|
|
4707
|
+
padding: 6px;
|
|
4708
|
+
border: 1px solid var(--border);
|
|
4709
|
+
border-radius: 10px;
|
|
4710
|
+
background: var(--panel);
|
|
4711
|
+
color: var(--text);
|
|
4712
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
.studio-preview-link-menu[hidden] {
|
|
4716
|
+
display: none;
|
|
4717
|
+
}
|
|
4718
|
+
|
|
4719
|
+
.studio-preview-link-menu button {
|
|
4720
|
+
display: block;
|
|
4721
|
+
width: 100%;
|
|
4722
|
+
border: 0;
|
|
4723
|
+
border-radius: 7px;
|
|
4724
|
+
background: transparent;
|
|
4725
|
+
color: inherit;
|
|
4726
|
+
text-align: left;
|
|
4727
|
+
padding: 7px 9px;
|
|
4728
|
+
font: inherit;
|
|
4729
|
+
cursor: pointer;
|
|
4730
|
+
}
|
|
4731
|
+
|
|
4732
|
+
.studio-preview-link-menu button:hover,
|
|
4733
|
+
.studio-preview-link-menu button:focus-visible {
|
|
4734
|
+
background: var(--panel-2);
|
|
4735
|
+
outline: none;
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4677
4738
|
@media (max-width: 1280px) {
|
|
4678
4739
|
body.studio-ui-refresh #rightSectionHeader,
|
|
4679
4740
|
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>
|
|
@@ -9294,6 +9600,14 @@ ${cssVarsBlock}
|
|
|
9294
9600
|
<div><dt>?</dt><dd>Show keyboard shortcuts when not editing text</dd></div>
|
|
9295
9601
|
</dl>
|
|
9296
9602
|
</section>
|
|
9603
|
+
<section class="shortcuts-group">
|
|
9604
|
+
<h3>View</h3>
|
|
9605
|
+
<dl>
|
|
9606
|
+
<div><dt>Alt/Option+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
|
|
9607
|
+
<div><dt>Alt/Option+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
|
|
9608
|
+
<div><dt>Alt/Option+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
|
|
9609
|
+
</dl>
|
|
9610
|
+
</section>
|
|
9297
9611
|
<section class="shortcuts-group">
|
|
9298
9612
|
<h3>Editor</h3>
|
|
9299
9613
|
<dl>
|
|
@@ -11241,6 +11555,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11241
11555
|
label: result.label,
|
|
11242
11556
|
source: "file",
|
|
11243
11557
|
path: result.resolvedPath,
|
|
11558
|
+
resourceDir: dirname(result.resolvedPath),
|
|
11244
11559
|
};
|
|
11245
11560
|
|
|
11246
11561
|
sendToClient(client, {
|
|
@@ -11248,6 +11563,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11248
11563
|
requestId: msg.requestId,
|
|
11249
11564
|
path: result.resolvedPath,
|
|
11250
11565
|
label: result.label,
|
|
11566
|
+
resourceDir: dirname(result.resolvedPath),
|
|
11251
11567
|
message: `Saved editor text to ${result.label}`,
|
|
11252
11568
|
});
|
|
11253
11569
|
return;
|
|
@@ -11327,6 +11643,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11327
11643
|
label: refreshed.label,
|
|
11328
11644
|
source: "file",
|
|
11329
11645
|
path: refreshed.resolvedPath,
|
|
11646
|
+
resourceDir: dirname(refreshed.resolvedPath),
|
|
11330
11647
|
};
|
|
11331
11648
|
|
|
11332
11649
|
broadcast({
|
|
@@ -12360,6 +12677,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
12360
12677
|
return;
|
|
12361
12678
|
}
|
|
12362
12679
|
|
|
12680
|
+
if (requestUrl.pathname === "/local-preview-link") {
|
|
12681
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
12682
|
+
if (token !== serverState.token) {
|
|
12683
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
12684
|
+
return;
|
|
12685
|
+
}
|
|
12686
|
+
|
|
12687
|
+
try {
|
|
12688
|
+
const resource = resolveStudioLocalPreviewResourcePath(
|
|
12689
|
+
requestUrl.searchParams.get("path") ?? "",
|
|
12690
|
+
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
12691
|
+
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
12692
|
+
studioCwd,
|
|
12693
|
+
);
|
|
12694
|
+
respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
|
|
12695
|
+
} catch (error) {
|
|
12696
|
+
respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
12697
|
+
}
|
|
12698
|
+
return;
|
|
12699
|
+
}
|
|
12700
|
+
|
|
12701
|
+
if (requestUrl.pathname === "/reveal-local-resource") {
|
|
12702
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
12703
|
+
if (token !== serverState.token) {
|
|
12704
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
12705
|
+
return;
|
|
12706
|
+
}
|
|
12707
|
+
|
|
12708
|
+
void handleRevealLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
|
|
12709
|
+
respondJson(res, 500, { ok: false, error: `Reveal failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
12710
|
+
});
|
|
12711
|
+
return;
|
|
12712
|
+
}
|
|
12713
|
+
|
|
12363
12714
|
if (requestUrl.pathname === "/pdf-resource") {
|
|
12364
12715
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
12365
12716
|
if (token !== serverState.token) {
|
|
@@ -12955,6 +13306,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
12955
13306
|
label: "last model response",
|
|
12956
13307
|
source: "last-response",
|
|
12957
13308
|
draftId: createStudioDraftId(),
|
|
13309
|
+
resourceDir: ctx.cwd,
|
|
12958
13310
|
};
|
|
12959
13311
|
}
|
|
12960
13312
|
return {
|
|
@@ -12962,6 +13314,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
12962
13314
|
label: "blank",
|
|
12963
13315
|
source: "blank",
|
|
12964
13316
|
draftId: createStudioDraftId(),
|
|
13317
|
+
resourceDir: ctx.cwd,
|
|
12965
13318
|
};
|
|
12966
13319
|
}
|
|
12967
13320
|
|
|
@@ -12971,6 +13324,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
12971
13324
|
label: "blank",
|
|
12972
13325
|
source: "blank",
|
|
12973
13326
|
draftId: createStudioDraftId(),
|
|
13327
|
+
resourceDir: ctx.cwd,
|
|
12974
13328
|
};
|
|
12975
13329
|
}
|
|
12976
13330
|
|
|
@@ -12982,6 +13336,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
12982
13336
|
label: "blank",
|
|
12983
13337
|
source: "blank",
|
|
12984
13338
|
draftId: createStudioDraftId(),
|
|
13339
|
+
resourceDir: ctx.cwd,
|
|
12985
13340
|
};
|
|
12986
13341
|
}
|
|
12987
13342
|
return {
|
|
@@ -12989,6 +13344,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
12989
13344
|
label: "last model response",
|
|
12990
13345
|
source: "last-response",
|
|
12991
13346
|
draftId: createStudioDraftId(),
|
|
13347
|
+
resourceDir: ctx.cwd,
|
|
12992
13348
|
};
|
|
12993
13349
|
}
|
|
12994
13350
|
|
|
@@ -13021,6 +13377,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13021
13377
|
label: file.label,
|
|
13022
13378
|
source: "file",
|
|
13023
13379
|
path: file.resolvedPath,
|
|
13380
|
+
resourceDir: dirname(file.resolvedPath),
|
|
13024
13381
|
};
|
|
13025
13382
|
};
|
|
13026
13383
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14",
|
|
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",
|