pi-studio 0.9.17 → 0.9.18

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/index.ts CHANGED
@@ -162,6 +162,7 @@ interface PreparedStudioPdfExport {
162
162
  createdAt: number;
163
163
  filePath?: string;
164
164
  tempDirPath?: string;
165
+ persistent?: boolean;
165
166
  }
166
167
 
167
168
  interface PreparedStudioHtmlExport {
@@ -171,6 +172,7 @@ interface PreparedStudioHtmlExport {
171
172
  createdAt: number;
172
173
  filePath?: string;
173
174
  tempDirPath?: string;
175
+ persistent?: boolean;
174
176
  }
175
177
 
176
178
  interface StudioHtmlAnnotationPlaceholder {
@@ -2257,6 +2259,8 @@ function inferStudioPdfLanguageFromPath(pathInput: string): string | undefined {
2257
2259
  ".yml": "yaml",
2258
2260
  ".toml": "toml",
2259
2261
  ".lua": "lua",
2262
+ ".csv": "csv",
2263
+ ".tsv": "tsv",
2260
2264
  ".txt": "text",
2261
2265
  ".rst": "text",
2262
2266
  ".adoc": "text",
@@ -2299,6 +2303,33 @@ function buildStudioResponseExportOutputPath(cwd: string, extension: "pdf" | "ht
2299
2303
  return join(cwd || process.cwd(), `studio-response-${formatStudioExportTimestamp()}.studio.${extension}`);
2300
2304
  }
2301
2305
 
2306
+ function buildStudioPreviewExportPath(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string, filename: string): string | null {
2307
+ const cleanFilename = String(filename || "").trim();
2308
+ if (!cleanFilename) return null;
2309
+ const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
2310
+ if (source) {
2311
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(source));
2312
+ return join(dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded)), cleanFilename);
2313
+ }
2314
+ const resource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2315
+ if (resource) {
2316
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(resource));
2317
+ return join(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded), cleanFilename);
2318
+ }
2319
+ return join(fallbackCwd || process.cwd(), cleanFilename);
2320
+ }
2321
+
2322
+ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { filePath: string | null; error: string | null } {
2323
+ if (!path) return { filePath: null, error: "No export path was resolved." };
2324
+ try {
2325
+ mkdirSync(dirname(path), { recursive: true });
2326
+ writeFileSync(path, data);
2327
+ return { filePath: path, error: null };
2328
+ } catch (error) {
2329
+ return { filePath: null, error: error instanceof Error ? error.message : String(error) };
2330
+ }
2331
+ }
2332
+
2302
2333
  function writeStudioFile(pathArg: string, cwd: string, content: string):
2303
2334
  | { ok: true; label: string; resolvedPath: string }
2304
2335
  | { ok: false; message: string } {
@@ -2466,6 +2497,7 @@ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
2466
2497
  ".diff", ".patch",
2467
2498
  ]);
2468
2499
  const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2500
+ const STUDIO_LOCAL_LINK_OFFICE_EXTENSIONS = new Set([".docx", ".odt"]);
2469
2501
  const STUDIO_LOCAL_LINK_TEXT_FILENAMES = new Set([
2470
2502
  ".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
2471
2503
  ".gitignore", ".gitmodules", ".npmignore", ".prettierignore", "dockerfile", "gemfile",
@@ -2477,7 +2509,7 @@ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
2477
2509
  "__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache",
2478
2510
  ]);
2479
2511
 
2480
- type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "other";
2512
+ type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "office" | "other";
2481
2513
 
2482
2514
  interface StudioLocalPreviewResource {
2483
2515
  filePath: string;
@@ -2573,6 +2605,7 @@ function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: s
2573
2605
  if (ext === ".pdf") return "pdf";
2574
2606
  if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext) || STUDIO_LOCAL_LINK_TEXT_FILENAMES.has(name)) return "text";
2575
2607
  if (STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS.has(ext)) return "image";
2608
+ if (STUDIO_LOCAL_LINK_OFFICE_EXTENSIONS.has(ext)) return "office";
2576
2609
  return "other";
2577
2610
  }
2578
2611
 
@@ -4304,6 +4337,125 @@ function wrapStudioCodeAsMarkdown(code: string, language?: string): string {
4304
4337
  return `${marker}${lang}\n${source}\n${marker}`;
4305
4338
  }
4306
4339
 
4340
+ const STUDIO_DELIMITED_PREVIEW_MAX_DATA_ROWS = 200;
4341
+ const STUDIO_DELIMITED_PREVIEW_MAX_COLUMNS = 50;
4342
+ const STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS = 500;
4343
+
4344
+ function getStudioDelimitedTextConfig(language?: string): { label: string; delimiter: string } | null {
4345
+ const normalized = normalizeStudioEditorLanguage(language);
4346
+ if (normalized === "csv") return { label: "CSV", delimiter: "," };
4347
+ if (normalized === "tsv") return { label: "TSV", delimiter: "\t" };
4348
+ return null;
4349
+ }
4350
+
4351
+ function parseStudioDelimitedTextRows(text: string, delimiter: string, maxRows: number): { rows: string[][]; truncatedRows: boolean } {
4352
+ const source = String(text ?? "").replace(/^\uFEFF/, "");
4353
+ const limit = Math.max(1, Math.floor(maxRows));
4354
+ const rows: string[][] = [];
4355
+ let row: string[] = [];
4356
+ let cell = "";
4357
+ let inQuotes = false;
4358
+ let truncatedRows = false;
4359
+
4360
+ const pushCell = () => {
4361
+ row.push(cell);
4362
+ cell = "";
4363
+ };
4364
+ const pushRow = (index: number): boolean => {
4365
+ pushCell();
4366
+ rows.push(row);
4367
+ row = [];
4368
+ if (rows.length >= limit) {
4369
+ truncatedRows = index < source.length - 1;
4370
+ return true;
4371
+ }
4372
+ return false;
4373
+ };
4374
+
4375
+ for (let i = 0; i < source.length; i += 1) {
4376
+ if (rows.length >= limit) {
4377
+ truncatedRows = true;
4378
+ break;
4379
+ }
4380
+ const ch = source[i];
4381
+ if (inQuotes) {
4382
+ if (ch === '"') {
4383
+ if (source[i + 1] === '"') {
4384
+ cell += '"';
4385
+ i += 1;
4386
+ } else {
4387
+ inQuotes = false;
4388
+ }
4389
+ } else {
4390
+ cell += ch;
4391
+ }
4392
+ continue;
4393
+ }
4394
+ if (ch === '"' && cell === "") {
4395
+ inQuotes = true;
4396
+ continue;
4397
+ }
4398
+ if (ch === delimiter) {
4399
+ pushCell();
4400
+ continue;
4401
+ }
4402
+ if (ch === "\n") {
4403
+ if (pushRow(i)) break;
4404
+ continue;
4405
+ }
4406
+ if (ch === "\r") {
4407
+ if (source[i + 1] === "\n") i += 1;
4408
+ if (pushRow(i)) break;
4409
+ continue;
4410
+ }
4411
+ cell += ch;
4412
+ }
4413
+
4414
+ if (!truncatedRows && rows.length < limit && (cell.length > 0 || row.length > 0)) {
4415
+ pushCell();
4416
+ rows.push(row);
4417
+ }
4418
+
4419
+ return { rows, truncatedRows };
4420
+ }
4421
+
4422
+ function formatStudioDelimitedMarkdownCell(value: string | undefined): string {
4423
+ const raw = String(value ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4424
+ const shortened = raw.length > STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS
4425
+ ? `${raw.slice(0, STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS)}…`
4426
+ : raw;
4427
+ return shortened.replace(/\n/g, "<br>").replace(/\|/g, "\\|").trim() || " ";
4428
+ }
4429
+
4430
+ function formatStudioDelimitedTextAsMarkdown(text: string, language?: string): string | null {
4431
+ const config = getStudioDelimitedTextConfig(language);
4432
+ if (!config) return null;
4433
+ const parsed = parseStudioDelimitedTextRows(text, config.delimiter, STUDIO_DELIMITED_PREVIEW_MAX_DATA_ROWS + 1);
4434
+ const rows = parsed.rows;
4435
+ if (!rows.length) return `_${config.label} file has no tabular data to preview._`;
4436
+ const rawColumnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
4437
+ const columnCount = Math.min(rawColumnCount, STUDIO_DELIMITED_PREVIEW_MAX_COLUMNS);
4438
+ if (columnCount <= 0) return `_${config.label} file has no tabular data to preview._`;
4439
+ const header = rows[0] ?? [];
4440
+ const dataRows = rows.slice(1);
4441
+ const columnIndexes = Array.from({ length: columnCount }, (_value, index) => index);
4442
+ const lines: string[] = [`**${config.label} preview**`, ""];
4443
+ const notices: string[] = [];
4444
+ if (parsed.truncatedRows) notices.push(`showing first ${Math.max(0, dataRows.length)} data rows`);
4445
+ if (rawColumnCount > columnCount) notices.push(`showing first ${columnCount} of ${rawColumnCount} columns`);
4446
+ if (notices.length) lines.push(`_${notices.join("; ")}._`, "");
4447
+ lines.push(`| ${columnIndexes.map((index) => formatStudioDelimitedMarkdownCell(header[index] || `Column ${index + 1}`)).join(" | ")} |`);
4448
+ lines.push(`| ${columnIndexes.map(() => "---").join(" | ")} |`);
4449
+ if (dataRows.length) {
4450
+ dataRows.forEach((row) => {
4451
+ lines.push(`| ${columnIndexes.map((index) => formatStudioDelimitedMarkdownCell(row[index])).join(" | ")} |`);
4452
+ });
4453
+ } else {
4454
+ lines.push(`| ${columnIndexes.map(() => " ").join(" | ")} |`);
4455
+ }
4456
+ return lines.join("\n");
4457
+ }
4458
+
4307
4459
  function extractStudioFenceInfoLanguage(info: string): string | undefined {
4308
4460
  const firstToken = String(info ?? "").trim().split(/\s+/)[0]?.replace(/^\./, "") ?? "";
4309
4461
  return normalizeStudioEditorLanguage(firstToken || undefined);
@@ -5254,11 +5406,13 @@ function buildStudioLiteralTextPdfTexConfig(options?: StudioPdfRenderOptions): {
5254
5406
 
5255
5407
  function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLanguage?: string): string {
5256
5408
  if (isLatex) return markdown;
5257
- const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
5409
+ const delimitedMarkdown = formatStudioDelimitedTextAsMarkdown(markdown, editorLanguage);
5410
+ const input = delimitedMarkdown ?? markdown;
5411
+ const effectiveEditorLanguage = delimitedMarkdown ? "markdown" : inferStudioPdfLanguage(input, editorLanguage);
5258
5412
  const source = effectiveEditorLanguage && effectiveEditorLanguage !== "markdown" && effectiveEditorLanguage !== "latex"
5259
- && !isStudioSingleFencedCodeBlock(markdown)
5260
- ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
5261
- : markdown;
5413
+ && !isStudioSingleFencedCodeBlock(input)
5414
+ ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
5415
+ : input;
5262
5416
  const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
5263
5417
  ? replaceStudioAnnotationMarkersForPdf(source)
5264
5418
  : source;
@@ -5979,17 +6133,19 @@ async function renderStudioStandaloneHtmlWithPandoc(
5979
6133
  sourcePath?: string,
5980
6134
  options?: StudioHtmlRenderOptions,
5981
6135
  ): Promise<{ html: Buffer; warning?: string }> {
5982
- const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
5983
- if (!isLatex && isLikelyStandaloneStudioHtml(markdown, effectiveEditorLanguage)) {
5984
- return { html: Buffer.from(String(markdown ?? ""), "utf-8") };
6136
+ const delimitedMarkdown = isLatex ? null : formatStudioDelimitedTextAsMarkdown(markdown, editorLanguage);
6137
+ const input = delimitedMarkdown ?? markdown;
6138
+ const effectiveEditorLanguage = delimitedMarkdown ? "markdown" : inferStudioPdfLanguage(input, editorLanguage);
6139
+ if (!isLatex && isLikelyStandaloneStudioHtml(input, effectiveEditorLanguage)) {
6140
+ return { html: Buffer.from(String(input ?? ""), "utf-8") };
5985
6141
  }
5986
6142
  const source = !isLatex
5987
6143
  && effectiveEditorLanguage
5988
6144
  && effectiveEditorLanguage !== "markdown"
5989
6145
  && effectiveEditorLanguage !== "latex"
5990
- && !isStudioSingleFencedCodeBlock(markdown)
5991
- ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
5992
- : markdown;
6146
+ && !isStudioSingleFencedCodeBlock(input)
6147
+ ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
6148
+ : input;
5993
6149
  const annotationPrepared = prepareStudioAnnotationMarkersForHtml(source);
5994
6150
  const pdfPrepared = prepareStudioPdfBlocksForHtml(annotationPrepared.markdown);
5995
6151
  let renderedHtml = await renderStudioMarkdownWithPandoc(pdfPrepared.markdown, isLatex, resourcePath, sourcePath);
@@ -6660,7 +6816,74 @@ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerRespons
6660
6816
  });
6661
6817
  }
6662
6818
 
6663
- function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): void {
6819
+ function formatStudioMarkdownAngleTarget(pathText: string): string {
6820
+ return `<${String(pathText || "").replace(/>/g, "%3E")}>`;
6821
+ }
6822
+
6823
+ function sanitizeStudioPreviewBlockLine(value: string): string {
6824
+ return String(value || "").replace(/[\r\n]+/g, " ").trim();
6825
+ }
6826
+
6827
+ function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource): InitialStudioDocument {
6828
+ const label = basename(resource.filePath) || resource.label || "local preview";
6829
+ const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
6830
+ const title = sanitizeStudioPreviewBlockLine(label);
6831
+ let text = "";
6832
+ if (resource.kind === "pdf") {
6833
+ text = "```studio-pdf\n"
6834
+ + `path: ${sanitizeStudioPreviewBlockLine(resourcePath)}\n`
6835
+ + `title: ${title || "PDF preview"}\n`
6836
+ + "height: 820\n"
6837
+ + "```\n";
6838
+ } else if (resource.kind === "image") {
6839
+ text = `![${title || "Image preview"}](${formatStudioMarkdownAngleTarget(resourcePath)})\n`;
6840
+ } else {
6841
+ throw new Error("This local resource cannot be opened as a preview document.");
6842
+ }
6843
+ return {
6844
+ text,
6845
+ label: `${label} preview`,
6846
+ source: "blank",
6847
+ resourceDir: resource.resourceDir,
6848
+ };
6849
+ }
6850
+
6851
+ function getStudioOfficePandocInputFormat(extension: string): string {
6852
+ const ext = String(extension || "").toLowerCase();
6853
+ if (ext === ".docx") return "docx";
6854
+ if (ext === ".odt") return "odt";
6855
+ return ext.replace(/^\./, "") || "docx";
6856
+ }
6857
+
6858
+ async function convertStudioOfficeDocumentToMarkdown(resource: StudioLocalPreviewResource): Promise<{ text: string; label: string }> {
6859
+ if (resource.kind !== "office") throw new Error("This local resource is not a supported convertible document.");
6860
+ const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
6861
+ const inputFormat = getStudioOfficePandocInputFormat(resource.extension);
6862
+ const result = await runStudioSubprocess(pandocCommand, [
6863
+ "-f", inputFormat,
6864
+ "-t", "markdown",
6865
+ "--wrap=none",
6866
+ resource.filePath,
6867
+ ], {
6868
+ cwd: dirname(resource.filePath),
6869
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
6870
+ stdoutMaxBytes: STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES,
6871
+ label: "pandoc document conversion",
6872
+ notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to convert DOCX/ODT documents in Studio.",
6873
+ });
6874
+ if (result.code !== 0) {
6875
+ throw new Error(`pandoc failed with exit code ${result.code}${result.stderr ? `: ${result.stderr}` : ""}`);
6876
+ }
6877
+ if (result.stdoutTruncated) {
6878
+ throw new Error("Converted document exceeded Studio's import size limit.");
6879
+ }
6880
+ const label = `converted: ${resource.label || basename(resource.filePath) || "document"}`;
6881
+ const note = `<!-- ${label} from ${resource.filePath}. This is a Markdown conversion; saving will not update the original ${resource.extension || "document"} file. -->`;
6882
+ const body = result.stdout.trim();
6883
+ return { text: `${note}\n\n${body}\n`, label };
6884
+ }
6885
+
6886
+ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): Promise<void> {
6664
6887
  const method = (req.method ?? "GET").toUpperCase();
6665
6888
  if (method !== "GET" && method !== "HEAD") {
6666
6889
  res.setHeader("Allow", "GET, HEAD");
@@ -6684,32 +6907,72 @@ function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse,
6684
6907
  return;
6685
6908
  }
6686
6909
 
6910
+ if (action === "preview-url") {
6911
+ if (resource.kind !== "pdf" && resource.kind !== "image") {
6912
+ respondJson(res, 400, { ok: false, error: "This local resource cannot be opened in a Studio preview tab." });
6913
+ return;
6914
+ }
6915
+ const document = buildStudioLocalResourcePreviewDocument(resource);
6916
+ const docId = storeTransientStudioDocument(document);
6917
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
6918
+ const parsedUrl = new URL(url);
6919
+ respondJson(res, 200, {
6920
+ ...basePayload,
6921
+ url,
6922
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
6923
+ });
6924
+ return;
6925
+ }
6926
+
6687
6927
  if (action !== "document" && action !== "editor-url") {
6688
6928
  respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
6689
6929
  return;
6690
6930
  }
6691
- if (resource.kind !== "text") {
6692
- respondJson(res, 400, { ok: false, error: "This local resource is not a text document Studio can load into the editor." });
6931
+ if (resource.kind !== "text" && resource.kind !== "office") {
6932
+ respondJson(res, 400, { ok: false, error: "This local resource is not a document Studio can load into the editor." });
6693
6933
  return;
6694
6934
  }
6695
6935
 
6696
- const file = readStudioFile(resource.filePath, dirname(resource.filePath));
6697
- if (file.ok === false) {
6698
- respondJson(res, 400, { ok: false, error: file.message });
6699
- return;
6936
+ let document: InitialStudioDocument;
6937
+ let responseText = "";
6938
+ let converted = false;
6939
+ if (resource.kind === "office") {
6940
+ let conversion: { text: string; label: string };
6941
+ try {
6942
+ conversion = await convertStudioOfficeDocumentToMarkdown(resource);
6943
+ } catch (error) {
6944
+ respondJson(res, 400, { ok: false, error: `Document conversion failed: ${error instanceof Error ? error.message : String(error)}` });
6945
+ return;
6946
+ }
6947
+ converted = true;
6948
+ responseText = conversion.text;
6949
+ document = {
6950
+ text: conversion.text,
6951
+ label: conversion.label,
6952
+ source: "blank",
6953
+ resourceDir: resource.resourceDir,
6954
+ };
6955
+ } else {
6956
+ const file = readStudioFile(resource.filePath, dirname(resource.filePath));
6957
+ if (file.ok === false) {
6958
+ respondJson(res, 400, { ok: false, error: file.message });
6959
+ return;
6960
+ }
6961
+ responseText = file.text;
6962
+ document = {
6963
+ text: file.text,
6964
+ label: resource.label || file.label,
6965
+ source: "file",
6966
+ path: file.resolvedPath,
6967
+ resourceDir: resource.resourceDir,
6968
+ };
6700
6969
  }
6701
-
6702
- const document: InitialStudioDocument = {
6703
- text: file.text,
6704
- label: resource.label || file.label,
6705
- source: "file",
6706
- path: file.resolvedPath,
6707
- resourceDir: resource.resourceDir,
6708
- };
6709
6970
  if (action === "document") {
6710
6971
  respondJson(res, 200, {
6711
6972
  ...basePayload,
6712
- text: file.text,
6973
+ text: responseText,
6974
+ label: document.label,
6975
+ converted,
6713
6976
  resourceDir: resource.resourceDir,
6714
6977
  });
6715
6978
  return;
@@ -6720,6 +6983,7 @@ function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse,
6720
6983
  const parsedUrl = new URL(url);
6721
6984
  respondJson(res, 200, {
6722
6985
  ...basePayload,
6986
+ converted,
6723
6987
  url,
6724
6988
  relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
6725
6989
  });
@@ -9644,6 +9908,7 @@ ${cssVarsBlock}
9644
9908
  <option value="c">Syntax highlight: C</option>
9645
9909
  <option value="cpp">Syntax highlight: C++</option>
9646
9910
  <option value="css">Syntax highlight: CSS</option>
9911
+ <option value="csv">Syntax highlight: CSV</option>
9647
9912
  <option value="diff">Syntax highlight: Diff</option>
9648
9913
  <option value="fortran">Syntax highlight: Fortran</option>
9649
9914
  <option value="go">Syntax highlight: Go</option>
@@ -9662,6 +9927,7 @@ ${cssVarsBlock}
9662
9927
  <option value="rust">Syntax highlight: Rust</option>
9663
9928
  <option value="swift">Syntax highlight: Swift</option>
9664
9929
  <option value="toml">Syntax highlight: TOML</option>
9930
+ <option value="tsv">Syntax highlight: TSV</option>
9665
9931
  <option value="typescript">Syntax highlight: TypeScript</option>
9666
9932
  <option value="xml">Syntax highlight: XML</option>
9667
9933
  <option value="yaml">Syntax highlight: YAML</option>
@@ -9847,11 +10113,11 @@ ${cssVarsBlock}
9847
10113
  <div class="shortcuts-header">
9848
10114
  <div>
9849
10115
  <h2 id="shortcutsTitle">Keyboard shortcuts</h2>
9850
- <p class="shortcuts-description">Studio navigation and high-frequency actions.</p>
10116
+ <p class="shortcuts-description">Studio navigation and high-frequency actions. Use arrow keys, Page Up/Down, Home/End, or mouse/trackpad to scroll.</p>
9851
10117
  </div>
9852
10118
  <button id="shortcutsCloseBtn" class="shortcuts-close-btn" type="button" aria-label="Close keyboard shortcuts">Close</button>
9853
10119
  </div>
9854
- <div class="shortcuts-body">
10120
+ <div id="shortcutsBody" class="shortcuts-body" tabindex="0" aria-label="Keyboard shortcuts list">
9855
10121
  <section class="shortcuts-group">
9856
10122
  <h3>Navigation</h3>
9857
10123
  <dl>
@@ -12073,6 +12339,7 @@ export default function (pi: ExtensionAPI) {
12073
12339
  };
12074
12340
 
12075
12341
  const disposePreparedPdfExport = (entry: PreparedStudioPdfExport | null | undefined) => {
12342
+ if (entry?.persistent) return;
12076
12343
  if (!entry?.tempDirPath) return;
12077
12344
  void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
12078
12345
  };
@@ -12101,7 +12368,7 @@ export default function (pi: ExtensionAPI) {
12101
12368
  }
12102
12369
  };
12103
12370
 
12104
- const storePreparedPdfExport = (pdf: Buffer, filename: string, warning?: string): string => {
12371
+ const storePreparedPdfExport = (pdf: Buffer, filename: string, warning?: string, filePath?: string): string => {
12105
12372
  prunePreparedPdfExports();
12106
12373
  const exportId = randomUUID();
12107
12374
  preparedPdfExports.set(exportId, {
@@ -12109,6 +12376,8 @@ export default function (pi: ExtensionAPI) {
12109
12376
  filename,
12110
12377
  warning,
12111
12378
  createdAt: Date.now(),
12379
+ filePath,
12380
+ persistent: Boolean(filePath),
12112
12381
  });
12113
12382
  return exportId;
12114
12383
  };
@@ -12117,7 +12386,7 @@ export default function (pi: ExtensionAPI) {
12117
12386
  prunePreparedPdfExports();
12118
12387
  const entry = preparedPdfExports.get(exportId);
12119
12388
  if (!entry) return null;
12120
- if (entry.filePath && entry.tempDirPath) return entry;
12389
+ if (entry.filePath && (entry.tempDirPath || entry.persistent)) return entry;
12121
12390
 
12122
12391
  const tempDirPath = join(tmpdir(), `pi-studio-prepared-pdf-${Date.now()}-${randomUUID()}`);
12123
12392
  const filePath = join(tempDirPath, sanitizePdfFilename(entry.filename));
@@ -12167,6 +12436,7 @@ export default function (pi: ExtensionAPI) {
12167
12436
  };
12168
12437
 
12169
12438
  const disposePreparedHtmlExport = (entry: PreparedStudioHtmlExport | null | undefined) => {
12439
+ if (entry?.persistent) return;
12170
12440
  if (!entry?.tempDirPath) return;
12171
12441
  void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
12172
12442
  };
@@ -12195,7 +12465,7 @@ export default function (pi: ExtensionAPI) {
12195
12465
  }
12196
12466
  };
12197
12467
 
12198
- const storePreparedHtmlExport = (html: Buffer, filename: string, warning?: string): string => {
12468
+ const storePreparedHtmlExport = (html: Buffer, filename: string, warning?: string, filePath?: string): string => {
12199
12469
  prunePreparedHtmlExports();
12200
12470
  const exportId = randomUUID();
12201
12471
  preparedHtmlExports.set(exportId, {
@@ -12203,6 +12473,8 @@ export default function (pi: ExtensionAPI) {
12203
12473
  filename,
12204
12474
  warning,
12205
12475
  createdAt: Date.now(),
12476
+ filePath,
12477
+ persistent: Boolean(filePath),
12206
12478
  });
12207
12479
  return exportId;
12208
12480
  };
@@ -12211,7 +12483,7 @@ export default function (pi: ExtensionAPI) {
12211
12483
  prunePreparedHtmlExports();
12212
12484
  const entry = preparedHtmlExports.get(exportId);
12213
12485
  if (!entry) return null;
12214
- if (entry.filePath && entry.tempDirPath) return entry;
12486
+ if (entry.filePath && (entry.tempDirPath || entry.persistent)) return entry;
12215
12487
 
12216
12488
  const tempDirPath = join(tmpdir(), `pi-studio-prepared-html-${Date.now()}-${randomUUID()}`);
12217
12489
  const filePath = join(tempDirPath, sanitizeHtmlFilename(entry.filename));
@@ -12632,7 +12904,8 @@ export default function (pi: ExtensionAPI) {
12632
12904
 
12633
12905
  try {
12634
12906
  const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage, sourcePath || undefined);
12635
- const exportId = storePreparedPdfExport(pdf, filename, warning);
12907
+ const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
12908
+ const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
12636
12909
  const token = serverState?.token ?? "";
12637
12910
  let openedExternal = false;
12638
12911
  let openError: string | null = null;
@@ -12649,6 +12922,8 @@ export default function (pi: ExtensionAPI) {
12649
12922
  respondJson(res, 200, {
12650
12923
  ok: true,
12651
12924
  filename,
12925
+ path: writeResult.filePath,
12926
+ writeError: writeResult.error,
12652
12927
  warning: warning ?? null,
12653
12928
  openedExternal,
12654
12929
  openError,
@@ -12743,7 +13018,8 @@ export default function (pi: ExtensionAPI) {
12743
13018
  themeVars,
12744
13019
  },
12745
13020
  );
12746
- const exportId = storePreparedHtmlExport(html, filename, warning);
13021
+ const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), html);
13022
+ const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
12747
13023
  const token = serverState?.token ?? "";
12748
13024
  let openedExternal = false;
12749
13025
  let openError: string | null = null;
@@ -12760,6 +13036,8 @@ export default function (pi: ExtensionAPI) {
12760
13036
  respondJson(res, 200, {
12761
13037
  ok: true,
12762
13038
  filename,
13039
+ path: writeResult.filePath,
13040
+ writeError: writeResult.error,
12763
13041
  warning: warning ?? null,
12764
13042
  openedExternal,
12765
13043
  openError,
@@ -13052,17 +13330,19 @@ export default function (pi: ExtensionAPI) {
13052
13330
  return;
13053
13331
  }
13054
13332
 
13055
- try {
13056
- const resource = resolveStudioLocalPreviewResourcePath(
13057
- requestUrl.searchParams.get("path") ?? "",
13058
- requestUrl.searchParams.get("sourcePath") ?? undefined,
13059
- requestUrl.searchParams.get("resourceDir") ?? undefined,
13060
- studioCwd,
13061
- );
13062
- respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
13063
- } catch (error) {
13064
- respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
13065
- }
13333
+ void (async () => {
13334
+ try {
13335
+ const resource = resolveStudioLocalPreviewResourcePath(
13336
+ requestUrl.searchParams.get("path") ?? "",
13337
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
13338
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
13339
+ studioCwd,
13340
+ );
13341
+ await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
13342
+ } catch (error) {
13343
+ respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
13344
+ }
13345
+ })();
13066
13346
  return;
13067
13347
  }
13068
13348
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.17",
3
+ "version": "0.9.18",
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",