pi-studio 0.9.52 → 0.9.53

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.
@@ -242,6 +242,7 @@
242
242
  || typeof previewResourceHelpers.buildStudioPdfVersionSignature !== "function"
243
243
  || typeof previewResourceHelpers.createStudioPdfVersionObservationState !== "function"
244
244
  || typeof previewResourceHelpers.hydrateStudioPreviewLocalImages !== "function"
245
+ || typeof previewResourceHelpers.hydrateStudioPreviewLocalPdfEmbeds !== "function"
245
246
  || typeof previewResourceHelpers.observeStudioPdfVersion !== "function"
246
247
  ) {
247
248
  throw new Error("Studio preview resource helpers failed to load.");
@@ -402,8 +403,8 @@
402
403
  option.disabled = (isEditorOnlyMode && !EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(option.value)) || (isQuartoOption && !quartoRelevant);
403
404
  });
404
405
  rightViewSelect.title = isEditorOnlyMode
405
- ? "Editor-only views: Editor Preview, contextual Quarto Preview for .qmd/.md/.markdown files, Changes, Files, REPL, or Side questions. F7 cycles; Cmd/Ctrl+Alt+3/5/6/7/8 switch directly to numbered right-pane views, and Cmd/Ctrl+Alt+Q opens Side questions."
406
- : "Right pane view mode. F7 cycles, including contextual Quarto Preview for file-backed .qmd, .md, and .markdown documents; Cmd/Ctrl+Alt+1–8 switches directly between the numbered views. Cmd/Ctrl+Alt+P/E/W keep their mnemonic Preview/Editor Preview/Working shortcuts.";
406
+ ? "Editor-only views: Editor Preview, contextual Quarto Preview for .qmd/.md/.markdown files, Changes, Files, REPL, or Side questions. F7 cycles; Cmd/Ctrl+Alt+3/5/6/7/8 switch directly to numbered right-pane views, and Cmd/Ctrl+Alt+F/Q open Files/Side questions."
407
+ : "Right pane view mode. F7 cycles, including contextual Quarto Preview for file-backed .qmd, .md, and .markdown documents; Cmd/Ctrl+Alt+1–8 switches directly between the numbered views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.";
407
408
  }
408
409
 
409
410
  function getInitialRightView(source) {
@@ -478,6 +479,7 @@
478
479
  let sideQuestionAvailablePiTools = [];
479
480
  let sideQuestionPreviewRenderNonce = 0;
480
481
  let sideQuestionContextRefreshHandle = null;
482
+ let sideQuestionContextGrantPending = false;
481
483
  let sideQuestionMarkdownExportRequest = null;
482
484
  const sideQuestionMarkdownRenderCache = new Map();
483
485
  let sideQuestionUi = {
@@ -2293,6 +2295,9 @@
2293
2295
  relativeDir: "",
2294
2296
  parentDir: null,
2295
2297
  entries: [],
2298
+ exactFiles: [],
2299
+ locations: [],
2300
+ grantRequiredPath: "",
2296
2301
  omitted: 0,
2297
2302
  omittedIgnored: 0,
2298
2303
  sort: fileBrowserSortMode,
@@ -3894,8 +3899,12 @@
3894
3899
  secondaryBtn.className = "studio-decision-secondary";
3895
3900
  secondaryBtn.hidden = true;
3896
3901
  secondaryBtn.addEventListener("click", () => {
3897
- const handler = studioDecisionState && studioDecisionState.onSecondary;
3898
- if (typeof handler !== "function") return;
3902
+ const state = studioDecisionState;
3903
+ const handler = state && state.onSecondary;
3904
+ if (typeof handler !== "function") {
3905
+ if (state && state.hasSecondaryValue) finishStudioDecision(state.secondaryValue);
3906
+ return;
3907
+ }
3899
3908
  try {
3900
3909
  const result = handler();
3901
3910
  if (result && typeof result.catch === "function") {
@@ -3990,6 +3999,8 @@
3990
3999
  resolve,
3991
4000
  returnFocusEl,
3992
4001
  onSecondary: typeof settings.onSecondary === "function" ? settings.onSecondary : null,
4002
+ hasSecondaryValue: Object.prototype.hasOwnProperty.call(settings, "secondaryValue"),
4003
+ secondaryValue: settings.secondaryValue,
3993
4004
  };
3994
4005
  studioDecisionState = decisionState;
3995
4006
  const schedule = typeof window.requestAnimationFrame === "function"
@@ -4821,6 +4832,16 @@
4821
4832
  return;
4822
4833
  }
4823
4834
 
4835
+ const isFilesShortcut = (key.toLowerCase() === "f" || code === "KeyF")
4836
+ && (event.metaKey || event.ctrlKey)
4837
+ && event.altKey
4838
+ && !event.shiftKey;
4839
+ if (isFilesShortcut) {
4840
+ event.preventDefault();
4841
+ switchRightPaneToView("files");
4842
+ return;
4843
+ }
4844
+
4824
4845
  const isSideQuestionsShortcut = (key.toLowerCase() === "q" || code === "KeyQ")
4825
4846
  && (event.metaKey || event.ctrlKey)
4826
4847
  && event.altKey
@@ -6041,7 +6062,8 @@
6041
6062
  + " const images = Array.prototype.slice.call(document.querySelectorAll('img[src]'));\n"
6042
6063
  + " images.forEach((image) => {\n"
6043
6064
  + " if (!image || !image.getAttribute) return;\n"
6044
- + " if (image.getAttribute('data-pi-studio-html-resource-resolved') === 'true') return;\n"
6065
+ + " const resourceState = image.getAttribute('data-pi-studio-html-resource-resolved') || '';\n"
6066
+ + " if (resourceState === 'true' || resourceState === 'blocked' || resourceState === 'failed') return;\n"
6045
6067
  + " const raw = String(image.getAttribute('src') || '').trim();\n"
6046
6068
  + " if (!shouldResolveHtmlPreviewResourceUrl(raw)) return;\n"
6047
6069
  + " let resourceId = image.getAttribute('data-pi-studio-html-resource-id') || '';\n"
@@ -6073,6 +6095,7 @@
6073
6095
  + " image.setAttribute('data-pi-studio-html-resource-resolved', 'true');\n"
6074
6096
  + " } else if (typeof result.error === 'string' && result.error) {\n"
6075
6097
  + " image.setAttribute('title', result.error);\n"
6098
+ + " image.setAttribute('data-pi-studio-html-resource-resolved', result.blocked === true ? 'blocked' : 'failed');\n"
6076
6099
  + " }\n"
6077
6100
  + " htmlResourcePlaceholders.delete(resourceId);\n"
6078
6101
  + " });\n"
@@ -6523,18 +6546,28 @@
6523
6546
  const message = payload && typeof payload.error === "string"
6524
6547
  ? payload.error
6525
6548
  : (timeoutLabel || "Local preview resource load") + " failed with HTTP " + response.status + ".";
6526
- throw new Error(message);
6549
+ const requestError = new Error(message);
6550
+ requestError.studioPayload = payload && typeof payload === "object" ? payload : null;
6551
+ requestError.studioStatus = response.status;
6552
+ throw requestError;
6527
6553
  }
6528
6554
  return payload.dataUrl;
6529
6555
  }
6530
6556
 
6531
6557
  function postHtmlArtifactResourceResults(record, results) {
6532
6558
  if (!record || !record.iframe || !record.iframe.isConnected || !record.iframe.contentWindow) return;
6559
+ const publicResults = Array.isArray(results) ? results.map((result) => ({
6560
+ resourceId: result && result.resourceId ? result.resourceId : "",
6561
+ ok: Boolean(result && result.ok === true),
6562
+ dataUrl: result && typeof result.dataUrl === "string" ? result.dataUrl : undefined,
6563
+ error: result && typeof result.error === "string" ? result.error : undefined,
6564
+ blocked: Boolean(result && result.blocked === true),
6565
+ })) : [];
6533
6566
  try {
6534
6567
  record.iframe.contentWindow.postMessage({
6535
6568
  type: "pi-studio-html-artifact-resources-resolved",
6536
6569
  id: record.id || "",
6537
- results: Array.isArray(results) ? results : [],
6570
+ results: publicResults,
6538
6571
  }, "*");
6539
6572
  } catch {
6540
6573
  // Ignore iframe postMessage failures.
@@ -6552,14 +6585,58 @@
6552
6585
  );
6553
6586
  return { resourceId, ok: true, dataUrl };
6554
6587
  } catch (error) {
6555
- return { resourceId, ok: false, error: error && error.message ? error.message : String(error || "HTML preview resource load failed.") };
6588
+ const grantRequest = getStudioResourceGrantRequest(error);
6589
+ return {
6590
+ resourceId,
6591
+ ok: false,
6592
+ error: error && error.message ? error.message : String(error || "HTML preview resource load failed."),
6593
+ blocked: Boolean(grantRequest),
6594
+ grantRequest,
6595
+ sourceUrl: item && item.url ? String(item.url) : "",
6596
+ };
6556
6597
  }
6557
6598
  }
6558
6599
 
6600
+ function renderHtmlArtifactBlockedResources(record) {
6601
+ if (!record || !record.shell) return;
6602
+ const existing = record.shell.querySelector(".studio-html-artifact-blocked-media");
6603
+ if (existing) existing.remove();
6604
+ const blocked = record.blockedResources instanceof Map ? Array.from(record.blockedResources.values()) : [];
6605
+ if (!blocked.length) return;
6606
+ const container = document.createElement("div");
6607
+ container.className = "studio-html-artifact-blocked-media";
6608
+ blocked.forEach((entry) => {
6609
+ const notice = createStudioBlockedMediaNoticeForRequest(entry.grantRequest, "image", entry.sourceUrl);
6610
+ if (notice) container.appendChild(notice);
6611
+ });
6612
+ if (!container.childNodes.length) return;
6613
+ const frame = record.iframe && record.iframe.parentNode === record.shell ? record.iframe : null;
6614
+ record.shell.insertBefore(container, frame);
6615
+ }
6616
+
6617
+ function syncHtmlArtifactBlockedResources(record, results) {
6618
+ if (!record) return;
6619
+ if (!(record.blockedResources instanceof Map)) record.blockedResources = new Map();
6620
+ (Array.isArray(results) ? results : []).forEach((result) => {
6621
+ const resourceId = result && result.resourceId ? String(result.resourceId) : "";
6622
+ if (!resourceId) return;
6623
+ if (result.blocked === true && result.grantRequest) {
6624
+ record.blockedResources.set(resourceId, {
6625
+ grantRequest: result.grantRequest,
6626
+ sourceUrl: result.sourceUrl || "local image",
6627
+ });
6628
+ } else {
6629
+ record.blockedResources.delete(resourceId);
6630
+ }
6631
+ });
6632
+ renderHtmlArtifactBlockedResources(record);
6633
+ }
6634
+
6559
6635
  async function resolveHtmlArtifactResources(record, items) {
6560
6636
  if (!record || !Array.isArray(items) || items.length === 0) return;
6561
6637
  if (record.detail) record.detail.textContent = "HTML preview · loading local images";
6562
6638
  const results = await Promise.all(items.map((item) => fetchHtmlArtifactResource(record, item)));
6639
+ syncHtmlArtifactBlockedResources(record, results);
6563
6640
  postHtmlArtifactResourceResults(record, results);
6564
6641
  setHtmlArtifactDetailText(record, "HTML preview");
6565
6642
  }
@@ -6620,12 +6697,14 @@
6620
6697
  const action = typeof data.action === "string" ? data.action : "open";
6621
6698
  if (action === "contextmenu") {
6622
6699
  const point = getHtmlArtifactLocalLinkClientPoint(record, data);
6623
- showPreviewLinkMenu(null, point, context);
6700
+ void showPreviewLinkMenu(null, point, context);
6624
6701
  return;
6625
6702
  }
6626
6703
  const kind = getPreviewLocalLinkKind(context.href);
6627
6704
  if (kind === "pdf") {
6628
- openPreviewPdfLink(context.href, context.title, context);
6705
+ void openPreviewPdfLink(context.href, context.title, context).catch((error) => {
6706
+ setStatus((error && error.message) ? error.message : String(error || "Could not open linked PDF."), "warning");
6707
+ });
6629
6708
  return;
6630
6709
  }
6631
6710
  if (kind === "image") {
@@ -6635,9 +6714,8 @@
6635
6714
  return;
6636
6715
  }
6637
6716
  if (kind === "text" || kind === "office") {
6638
- void openPreviewDocumentInNewEditor(context.href, context).catch((error) => {
6639
- setStatus((error && error.message) ? error.message : String(error || "Could not open linked file."), "warning");
6640
- });
6717
+ const point = getHtmlArtifactLocalLinkClientPoint(record, data);
6718
+ void showPreviewLinkMenu(null, point, context);
6641
6719
  return;
6642
6720
  }
6643
6721
  setStatus("Right-click this local HTML preview link for file actions.", "warning");
@@ -7041,6 +7119,7 @@
7041
7119
  mathRenderItemCount: 0,
7042
7120
  resourceResolveBatchCount: 0,
7043
7121
  resourceResolveItemCount: 0,
7122
+ blockedResources: new Map(),
7044
7123
  });
7045
7124
 
7046
7125
  targetEl.appendChild(shell);
@@ -7927,6 +8006,13 @@
7927
8006
  const target = event && event.target;
7928
8007
  if (!(target instanceof Element)) return;
7929
8008
 
8009
+ const blockedMediaButton = target.closest("button.studio-blocked-media-allow");
8010
+ if (blockedMediaButton) {
8011
+ consumeStudioPreviewMediaEvent(event);
8012
+ void handleStudioBlockedMediaAllowButton(blockedMediaButton);
8013
+ return;
8014
+ }
8015
+
7930
8016
  const imageEl = target.closest("img.studio-image-focus-target");
7931
8017
  if (imageEl) {
7932
8018
  consumeStudioPreviewMediaEvent(event);
@@ -8400,7 +8486,53 @@
8400
8486
  });
8401
8487
  }
8402
8488
 
8403
- function createStudioPdfCard(block, useEditorResourceContext) {
8489
+ function createStudioPdfStatusCard(block, content) {
8490
+ const options = block && block.options ? block.options : {};
8491
+ const path = String(options.path || "").trim();
8492
+ const title = String(options.title || path || "Embedded PDF").trim();
8493
+ const caption = String(options.caption || "").trim();
8494
+ const card = document.createElement("figure");
8495
+ card.className = "studio-pdf-card studio-pdf-card-status";
8496
+ const header = document.createElement("figcaption");
8497
+ header.className = "studio-pdf-card-header";
8498
+ const titleEl = document.createElement("div");
8499
+ titleEl.className = "studio-pdf-card-title";
8500
+ titleEl.textContent = title;
8501
+ header.appendChild(titleEl);
8502
+ card.appendChild(header);
8503
+ if (caption) {
8504
+ const captionEl = document.createElement("div");
8505
+ captionEl.className = "studio-pdf-card-caption";
8506
+ captionEl.textContent = caption;
8507
+ card.appendChild(captionEl);
8508
+ }
8509
+ if (content instanceof Element) {
8510
+ card.appendChild(content);
8511
+ } else {
8512
+ const errorEl = document.createElement("div");
8513
+ errorEl.className = "studio-pdf-card-error";
8514
+ errorEl.textContent = String(content || "PDF resource unavailable.");
8515
+ card.appendChild(errorEl);
8516
+ }
8517
+ return card;
8518
+ }
8519
+
8520
+ async function createStudioPdfCard(block, useEditorResourceContext) {
8521
+ const options = block && block.options ? block.options : {};
8522
+ const path = String(options.path || "").trim();
8523
+ if (!path) return createAuthorizedStudioPdfCard(block, useEditorResourceContext);
8524
+ const resourceQuery = buildStudioPdfResourceQuery(options, useEditorResourceContext);
8525
+ try {
8526
+ await fetchPreviewLocalLink("resolve", path, resourceQuery, { skipGrantPrompt: true });
8527
+ return createAuthorizedStudioPdfCard(block, useEditorResourceContext);
8528
+ } catch (error) {
8529
+ const notice = createStudioBlockedMediaNotice(error, "pdf", path);
8530
+ if (notice) return createStudioPdfStatusCard(block, notice);
8531
+ return createStudioPdfStatusCard(block, error && error.message ? error.message : "PDF resource unavailable.");
8532
+ }
8533
+ }
8534
+
8535
+ function createAuthorizedStudioPdfCard(block, useEditorResourceContext) {
8404
8536
  const options = block && block.options ? block.options : {};
8405
8537
  const path = String(options.path || "").trim();
8406
8538
  const title = String(options.title || path || "Embedded PDF").trim();
@@ -8563,17 +8695,17 @@
8563
8695
  return card;
8564
8696
  }
8565
8697
 
8566
- function renderStudioPdfBlocksInElement(targetEl, blocks, useEditorResourceContext) {
8698
+ async function renderStudioPdfBlocksInElement(targetEl, blocks, useEditorResourceContext) {
8567
8699
  if (!targetEl || !Array.isArray(blocks) || blocks.length === 0) return;
8568
8700
  const candidates = Array.from(targetEl.querySelectorAll("p, pre, div"));
8569
- blocks.forEach((block) => {
8701
+ for (const block of blocks) {
8570
8702
  const placeholder = block && block.placeholder ? block.placeholder : "";
8571
- if (!placeholder) return;
8703
+ if (!placeholder) continue;
8572
8704
  const match = candidates.find((el) => String(el.textContent || "").trim() === placeholder);
8573
- if (match && match.parentNode) {
8574
- match.replaceWith(createStudioPdfCard(block, useEditorResourceContext));
8575
- }
8576
- });
8705
+ if (!match || !match.parentNode) continue;
8706
+ const card = await createStudioPdfCard(block, useEditorResourceContext);
8707
+ if (match.isConnected && match.parentNode) match.replaceWith(card);
8708
+ }
8577
8709
  }
8578
8710
 
8579
8711
  function sanitizeRenderedHtml(html, markdown, options) {
@@ -9145,6 +9277,91 @@
9145
9277
  targetEl.appendChild(el);
9146
9278
  }
9147
9279
 
9280
+ function createStudioBlockedMediaNotice(error, mediaKind, sourceLabel) {
9281
+ return createStudioBlockedMediaNoticeForRequest(getStudioResourceGrantRequest(error), mediaKind, sourceLabel);
9282
+ }
9283
+
9284
+ function createStudioBlockedMediaNoticeForRequest(grantRequest, mediaKind, sourceLabel) {
9285
+ if (!grantRequest) return null;
9286
+ const kind = mediaKind === "pdf" ? "PDF" : "image";
9287
+ const notice = document.createElement("span");
9288
+ notice.className = "studio-blocked-media studio-blocked-media-" + (mediaKind === "pdf" ? "pdf" : "image");
9289
+ notice.setAttribute("role", "note");
9290
+
9291
+ const text = document.createElement("span");
9292
+ text.className = "studio-blocked-media-text";
9293
+ const title = document.createElement("strong");
9294
+ title.textContent = "Local " + kind + " blocked";
9295
+ const detail = document.createElement("span");
9296
+ detail.textContent = "This media is outside the locations allowed for the current Studio session.";
9297
+ const path = document.createElement("code");
9298
+ path.textContent = grantRequest.path || String(sourceLabel || "local media");
9299
+ path.title = "Path on the computer running Pi";
9300
+ text.appendChild(title);
9301
+ text.appendChild(detail);
9302
+ text.appendChild(path);
9303
+ notice.appendChild(text);
9304
+
9305
+ const allowButton = document.createElement("button");
9306
+ allowButton.type = "button";
9307
+ allowButton.className = "studio-blocked-media-allow";
9308
+ allowButton.textContent = "Allow local " + kind + "…";
9309
+ allowButton.title = "Choose whether to allow only this file or its containing folder for the current Studio session.";
9310
+ allowButton.dataset.studioBlockedMediaPath = grantRequest.path;
9311
+ allowButton.dataset.studioBlockedMediaDirectory = grantRequest.directoryPath;
9312
+ allowButton.dataset.studioBlockedMediaLabel = grantRequest.label;
9313
+ allowButton.dataset.studioBlockedMediaKind = mediaKind === "pdf" ? "pdf" : "image";
9314
+ notice.appendChild(allowButton);
9315
+ return notice;
9316
+ }
9317
+
9318
+ function replaceStudioBlockedMediaElement(element, source, error, mediaKind) {
9319
+ if (!element || !element.parentNode) return false;
9320
+ const notice = createStudioBlockedMediaNotice(error, mediaKind, source);
9321
+ if (!notice) return false;
9322
+ element.replaceWith(notice);
9323
+ return true;
9324
+ }
9325
+
9326
+ function refreshStudioPassiveMediaAfterGrant() {
9327
+ renderSourcePreview({ previewDelayMs: 0 });
9328
+ if (rightView === "preview" || rightView === "editor-preview" || rightView === "side-questions") {
9329
+ renderActiveResult();
9330
+ }
9331
+ if (isQuizOpen()) renderQuizOverlay({ preserveScroll: true });
9332
+ }
9333
+
9334
+ async function handleStudioBlockedMediaAllowButton(button) {
9335
+ if (!(button instanceof HTMLButtonElement) || button.dataset.studioBlockedMediaBusy === "1") return false;
9336
+ const request = {
9337
+ path: String(button.dataset.studioBlockedMediaPath || "").trim(),
9338
+ directoryPath: String(button.dataset.studioBlockedMediaDirectory || "").trim(),
9339
+ label: String(button.dataset.studioBlockedMediaLabel || "local media").trim() || "local media",
9340
+ };
9341
+ if (!request.path || !request.directoryPath) {
9342
+ setStatus("Could not resolve this blocked media path.", "warning");
9343
+ return false;
9344
+ }
9345
+ button.dataset.studioBlockedMediaBusy = "1";
9346
+ button.disabled = true;
9347
+ const baselineText = button.textContent;
9348
+ button.textContent = "Choosing…";
9349
+ try {
9350
+ if (!(await requestStudioResourceGrant(request))) return false;
9351
+ refreshStudioPassiveMediaAfterGrant();
9352
+ return true;
9353
+ } catch (error) {
9354
+ setStatus("Could not allow local media: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
9355
+ return false;
9356
+ } finally {
9357
+ if (button.isConnected) {
9358
+ delete button.dataset.studioBlockedMediaBusy;
9359
+ button.disabled = false;
9360
+ button.textContent = baselineText;
9361
+ }
9362
+ }
9363
+ }
9364
+
9148
9365
  function hasMeaningfulPreviewContent(targetEl) {
9149
9366
  if (!targetEl || typeof targetEl.querySelector !== "function") return false;
9150
9367
  if (targetEl.querySelector(".preview-loading")) return false;
@@ -10451,6 +10668,33 @@
10451
10668
  });
10452
10669
  }
10453
10670
 
10671
+ async function hydrateStudioPreviewLocalMedia(targetEl, resourceContext) {
10672
+ const fetchResource = (resourceUrl, timeoutLabel) => fetchLocalPreviewResourceDataUrl(
10673
+ resourceContext,
10674
+ resourceUrl,
10675
+ RENDERED_PREVIEW_IMAGE_FETCH_TIMEOUT_MS,
10676
+ timeoutLabel,
10677
+ );
10678
+ await previewResourceHelpers.hydrateStudioPreviewLocalImages(
10679
+ targetEl,
10680
+ (resourceUrl) => fetchResource(resourceUrl, "Preview image load"),
10681
+ {
10682
+ onError: (imageEl, resourceUrl, error) => {
10683
+ replaceStudioBlockedMediaElement(imageEl, resourceUrl, error, "image");
10684
+ },
10685
+ },
10686
+ );
10687
+ await previewResourceHelpers.hydrateStudioPreviewLocalPdfEmbeds(
10688
+ targetEl,
10689
+ (resourceUrl) => fetchResource(resourceUrl, "Preview PDF load"),
10690
+ {
10691
+ onError: (embedEl, resourceUrl, error) => {
10692
+ replaceStudioBlockedMediaElement(embedEl, resourceUrl, error, "pdf");
10693
+ },
10694
+ },
10695
+ );
10696
+ }
10697
+
10454
10698
  async function applyRenderedMarkdown(targetEl, markdown, pane, nonce) {
10455
10699
  const previewPrepared = annotationsEnabled
10456
10700
  ? prepareMarkdownForPandocPreview(markdown)
@@ -10477,15 +10721,8 @@
10477
10721
  clearPreviewJumpHighlight(targetEl);
10478
10722
  finishPreviewRender(targetEl);
10479
10723
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown, previewFallbackOptions);
10480
- await previewResourceHelpers.hydrateStudioPreviewLocalImages(targetEl, (resourceUrl) => (
10481
- fetchLocalPreviewResourceDataUrl(
10482
- previewResourceContext,
10483
- resourceUrl,
10484
- RENDERED_PREVIEW_IMAGE_FETCH_TIMEOUT_MS,
10485
- "Preview image load",
10486
- )
10487
- ));
10488
- renderStudioPdfBlocksInElement(targetEl, pdfPrepared.blocks, previewingEditorText);
10724
+ await hydrateStudioPreviewLocalMedia(targetEl, previewResourceContext);
10725
+ await renderStudioPdfBlocksInElement(targetEl, pdfPrepared.blocks, previewingEditorText);
10489
10726
  applyPreviewAnnotationPlaceholdersToElement(targetEl, previewPrepared.placeholders);
10490
10727
  await renderAnnotationMathInElement(targetEl);
10491
10728
  decoratePdfEmbeds(targetEl);
@@ -11355,71 +11592,92 @@
11355
11592
  return "<select class='files-sort-select' data-files-sort aria-label='Files sort order' title='Sort file browser entries. Folders remain grouped first.'>" + options + "</select>";
11356
11593
  }
11357
11594
 
11595
+ function buildFileBrowserEntryRowHtml(entry) {
11596
+ const type = entry && entry.type === "directory" ? "directory" : "file";
11597
+ const kind = (entry && entry.kind) || (type === "directory" ? "directory" : "other");
11598
+ const path = String((entry && entry.path) || "");
11599
+ const name = String((entry && entry.name) || basenameForStudioPath(path));
11600
+ const icon = type === "directory" ? "📁" : (kind === "pdf" ? "📄" : (kind === "image" ? "🖼️" : (kind === "text" || kind === "office" ? "📝" : "📦")));
11601
+ const metaParts = [getFileBrowserKindLabel(entry)];
11602
+ if (type === "file") metaParts.push(formatFileBrowserSize(entry && entry.size));
11603
+ const time = formatFileBrowserTime(entry && entry.mtimeMs);
11604
+ if (time) metaParts.push(time);
11605
+ const newTabAction = kind === "text" || kind === "office"
11606
+ ? "open-new"
11607
+ : ((kind === "pdf" || kind === "image") ? "open-preview-new" : "");
11608
+ const newTabLabel = kind === "text"
11609
+ ? "Open file-backed tab"
11610
+ : (kind === "office" ? "Convert tab" : ((kind === "pdf" || kind === "image") ? "Preview tab" : "New tab"));
11611
+ const newTabTitle = kind === "text"
11612
+ ? "Open this file-backed document in a new refreshable editor tab. Save editor and Refresh from disk will use this file."
11613
+ : (kind === "office" ? "Convert this document to Markdown in a new editor tab." : ((kind === "pdf" || kind === "image") ? "Open this preview in a new Studio tab." : "Open in a new Studio tab."));
11614
+ const newTabButton = newTabAction
11615
+ ? "<button type='button' data-files-action='" + escapeHtml(newTabAction) + "' data-files-path='" + escapeHtml(path) + "' data-files-kind='" + escapeHtml(kind) + "' title='" + escapeHtml(newTabTitle) + "'>" + escapeHtml(newTabLabel) + "</button>"
11616
+ : "";
11617
+ const openTitle = type === "directory"
11618
+ ? "Open folder"
11619
+ : (kind === "text" ? "Open file-backed document in the current editor. Save editor and Refresh from disk will use this file." : (kind === "office" ? "Convert to Markdown in the current editor" : (kind === "pdf" ? "Open PDF preview" : (kind === "image" ? "Open image preview" : "Copy or reveal this file"))));
11620
+ return "<div class='files-row files-row-" + escapeHtml(type) + " files-kind-" + escapeHtml(kind) + "'>"
11621
+ + "<button type='button' class='files-open-btn' data-files-action='" + (type === "directory" ? "open-dir" : "open") + "' data-files-path='" + escapeHtml(path) + "' data-files-kind='" + escapeHtml(kind) + "' title='" + escapeHtml(openTitle) + "'>"
11622
+ + "<span class='files-icon' aria-hidden='true'>" + icon + "</span>"
11623
+ + "<span class='files-name'>" + escapeHtml(name) + "</span>"
11624
+ + "<span class='files-meta'>" + escapeHtml(metaParts.filter(Boolean).join(" · ")) + "</span>"
11625
+ + "</button>"
11626
+ + "<span class='files-actions'>"
11627
+ + newTabButton
11628
+ + "<button type='button' data-files-action='copy-path' data-files-path='" + escapeHtml(path) + "'>Copy path</button>"
11629
+ + (type === "file" ? "<button type='button' data-files-action='reveal' data-files-path='" + escapeHtml(path) + "'>Reveal</button>" : "")
11630
+ + "</span>"
11631
+ + "</div>";
11632
+ }
11633
+
11634
+ function buildFileBrowserLocationSelectHtml(state) {
11635
+ const locations = Array.isArray(state && state.locations) ? state.locations : [];
11636
+ if (!locations.length) return "<span class='files-no-locations'>No allowed folders</span>";
11637
+ const rootDir = String((state && state.rootDir) || "");
11638
+ const options = locations.map((location) => {
11639
+ const path = String((location && location.path) || "");
11640
+ const label = String((location && location.label) || basenameForStudioPath(path) || path);
11641
+ return "<option value='" + escapeHtml(path) + "'" + (path === rootDir ? " selected" : "") + ">" + escapeHtml(label + " — " + path) + "</option>";
11642
+ }).join("");
11643
+ return "<select class='files-location-select' data-files-location aria-label='Allowed Files location' title='Choose a folder allowed for this Studio session on the computer running Pi.'>" + options + "</select>";
11644
+ }
11645
+
11358
11646
  function buildFileBrowserPanelHtml() {
11359
11647
  const state = fileBrowserState || {};
11360
11648
  const entries = Array.isArray(state.entries) ? state.entries : [];
11649
+ const exactFiles = Array.isArray(state.exactFiles) ? state.exactFiles : [];
11361
11650
  const currentDir = state.currentDir || "";
11362
11651
  const rootDir = state.rootDir || "";
11363
11652
  const relativeDir = state.relativeDir || ".";
11364
11653
  const parentDisabled = state.parentDir ? "" : " disabled";
11365
11654
  const rows = entries.length
11366
- ? entries.map((entry) => {
11367
- const type = entry.type === "directory" ? "directory" : "file";
11368
- const kind = entry.kind || (type === "directory" ? "directory" : "other");
11369
- const icon = type === "directory" ? "📁" : (kind === "pdf" ? "📄" : (kind === "image" ? "🖼️" : (kind === "text" || kind === "office" ? "📝" : "📦")));
11370
- const metaParts = [];
11371
- metaParts.push(getFileBrowserKindLabel(entry));
11372
- if (type === "file") metaParts.push(formatFileBrowserSize(entry.size));
11373
- const time = formatFileBrowserTime(entry.mtimeMs);
11374
- if (time) metaParts.push(time);
11375
- const newTabAction = kind === "text" || kind === "office"
11376
- ? "open-new"
11377
- : ((kind === "pdf" || kind === "image") ? "open-preview-new" : "");
11378
- const newTabLabel = kind === "text"
11379
- ? "Open file-backed tab"
11380
- : (kind === "office" ? "Convert tab" : ((kind === "pdf" || kind === "image") ? "Preview tab" : "New tab"));
11381
- const newTabTitle = kind === "text"
11382
- ? "Open this file-backed document in a new refreshable editor tab. Save editor and Refresh from disk will use this file."
11383
- : (kind === "office" ? "Convert this document to Markdown in a new editor tab." : ((kind === "pdf" || kind === "image") ? "Open this preview in a new Studio tab." : "Open in a new Studio tab."));
11384
- const textActions = newTabAction
11385
- ? "<button type='button' data-files-action='" + escapeHtml(newTabAction) + "' data-files-path='" + escapeHtml(entry.path) + "' title='" + escapeHtml(newTabTitle) + "'>" + escapeHtml(newTabLabel) + "</button>"
11386
- : "";
11387
- const openTitle = type === "directory"
11388
- ? "Open folder"
11389
- : (kind === "text" ? "Open file-backed document in the current editor. Save editor and Refresh from disk will use this file." : (kind === "office" ? "Convert to Markdown in the current editor" : (kind === "pdf" ? "Open PDF preview" : (kind === "image" ? "Open image preview" : "Copy or reveal this file"))));
11390
- return "<div class='files-row files-row-" + escapeHtml(type) + " files-kind-" + escapeHtml(kind) + "'>"
11391
- + "<button type='button' class='files-open-btn' data-files-action='" + (type === "directory" ? "open-dir" : "open") + "' data-files-path='" + escapeHtml(entry.path) + "' data-files-kind='" + escapeHtml(kind) + "' title='" + escapeHtml(openTitle) + "'>"
11392
- + "<span class='files-icon' aria-hidden='true'>" + icon + "</span>"
11393
- + "<span class='files-name'>" + escapeHtml(entry.name) + "</span>"
11394
- + "<span class='files-meta'>" + escapeHtml(metaParts.filter(Boolean).join(" · ")) + "</span>"
11395
- + "</button>"
11396
- + "<span class='files-actions'>"
11397
- + textActions
11398
- + "<button type='button' data-files-action='copy-path' data-files-path='" + escapeHtml(entry.path) + "'>Copy path</button>"
11399
- + (type === "file" ? "<button type='button' data-files-action='reveal' data-files-path='" + escapeHtml(entry.path) + "'>Reveal</button>" : "")
11400
- + "</span>"
11401
- + "</div>";
11402
- }).join("")
11655
+ ? entries.map(buildFileBrowserEntryRowHtml).join("")
11403
11656
  : "<div class='files-empty'>" + (state.loading ? "Loading files…" : "This folder is empty.") + "</div>";
11657
+ const exactRows = exactFiles.map(buildFileBrowserEntryRowHtml).join("");
11404
11658
  const notices = [];
11405
11659
  if (state.error) notices.push("<div class='files-notice files-notice-error'>" + escapeHtml(state.error) + "</div>");
11660
+ if (state.grantRequiredPath) notices.push("<div class='files-notice'>Allow that folder to browse it in Files. Exact-file grants do not expose their parent folders.</div>");
11406
11661
  if (state.omitted) notices.push("<div class='files-notice'>" + escapeHtml(String(state.omitted)) + " item" + (state.omitted === 1 ? "" : "s") + " omitted.</div>");
11407
11662
  if (state.omittedIgnored) notices.push("<div class='files-notice'>" + escapeHtml(String(state.omittedIgnored)) + " heavy/cache folder" + (state.omittedIgnored === 1 ? "" : "s") + " hidden.</div>");
11408
11663
  return "<div class='files-panel'>"
11409
11664
  + "<div class='files-toolbar'>"
11410
11665
  + "<div class='files-path-group'><span class='files-label'>Files</span><span class='files-path' title='" + escapeHtml(currentDir) + "'>" + escapeHtml(relativeDir || ".") + "</span></div>"
11411
11666
  + "<div class='files-toolbar-actions'>"
11667
+ + buildFileBrowserLocationSelectHtml(state)
11412
11668
  + buildFileBrowserSortSelectHtml()
11669
+ + "<button type='button' data-files-action='allow-folder'>Allow folder…</button>"
11413
11670
  + "<button type='button' data-files-action='parent'" + parentDisabled + ">Parent</button>"
11414
11671
  + "<button type='button' data-files-action='refresh'>Refresh</button>"
11415
11672
  + (currentDir ? "<button type='button' data-files-action='copy-current' data-files-path='" + escapeHtml(currentDir) + "'>Copy path</button>" : "")
11416
11673
  + (currentDir ? "<button type='button' data-files-action='use-working-dir' data-files-path='" + escapeHtml(currentDir) + "'>Use as working dir</button>" : "")
11417
- + (rootDir ? "<button type='button' data-files-action='open-root' data-files-path='" + escapeHtml(rootDir) + "' title='Open the Files root folder in Finder or the system file manager.'>Open root</button>" : "")
11674
+ + (rootDir ? "<button type='button' data-files-action='open-root' data-files-path='" + escapeHtml(rootDir) + "' title='Open the Files root folder in Finder or the system file manager on the computer running Pi.'>Open root</button>" : "")
11418
11675
  + (rootDir ? "<button type='button' data-files-action='copy-root' data-files-path='" + escapeHtml(rootDir) + "'>Copy root</button>" : "")
11419
11676
  + "</div>"
11420
11677
  + "</div>"
11421
- + "<div class='files-subtitle'>Root: <span title='" + escapeHtml(rootDir) + "'>" + escapeHtml(rootDir || "current Studio directory") + "</span></div>"
11678
+ + "<div class='files-subtitle'>Allowed root on the computer running Pi: <span title='" + escapeHtml(rootDir) + "'>" + escapeHtml(rootDir || "none selected") + "</span></div>"
11422
11679
  + notices.join("")
11680
+ + (exactRows ? "<section class='files-exact-section'><div class='files-section-title'>Allowed exact files</div><div class='files-list' role='list'>" + exactRows + "</div></section>" : "")
11423
11681
  + "<div class='files-list' role='list'>" + rows + "</div>"
11424
11682
  + "</div>";
11425
11683
  }
@@ -11434,6 +11692,9 @@
11434
11692
  relativeDir: "",
11435
11693
  parentDir: null,
11436
11694
  entries: [],
11695
+ exactFiles: [],
11696
+ locations: [],
11697
+ grantRequiredPath: "",
11437
11698
  omitted: 0,
11438
11699
  omittedIgnored: 0,
11439
11700
  sort: fileBrowserSortMode,
@@ -11455,12 +11716,17 @@
11455
11716
  async function loadFileBrowserDirectory(dir, options) {
11456
11717
  const context = getHtmlPreviewResourceContextOptions();
11457
11718
  const contextKey = getFileBrowserContextKey();
11719
+ const config = options && typeof options === "object" ? options : {};
11720
+ const requestedRoot = Object.prototype.hasOwnProperty.call(config, "root")
11721
+ ? normalizeStudioResourceDirValue(config.root || "")
11722
+ : normalizeStudioResourceDirValue(fileBrowserState.rootDir || "");
11458
11723
  const nonce = ++fileBrowserLoadNonce;
11459
11724
  fileBrowserState = {
11460
11725
  ...fileBrowserState,
11461
11726
  contextKey,
11462
11727
  loading: true,
11463
11728
  error: "",
11729
+ grantRequiredPath: "",
11464
11730
  };
11465
11731
  if (rightView === "files") {
11466
11732
  finishPreviewRender(critiqueViewEl);
@@ -11469,6 +11735,7 @@
11469
11735
  try {
11470
11736
  const query = {};
11471
11737
  if (dir) query.dir = String(dir);
11738
+ if (requestedRoot) query.root = requestedRoot;
11472
11739
  if (context.sourcePath) query.sourcePath = context.sourcePath;
11473
11740
  if (context.resourceDir) query.resourceDir = context.resourceDir;
11474
11741
  query.sort = normalizeFileBrowserSortMode(fileBrowserSortMode);
@@ -11481,6 +11748,9 @@
11481
11748
  relativeDir: typeof payload.relativeDir === "string" ? payload.relativeDir : ".",
11482
11749
  parentDir: typeof payload.parentDir === "string" ? payload.parentDir : null,
11483
11750
  entries: Array.isArray(payload.entries) ? payload.entries : [],
11751
+ exactFiles: Array.isArray(payload.exactFiles) ? payload.exactFiles : [],
11752
+ locations: Array.isArray(payload.locations) ? payload.locations : [],
11753
+ grantRequiredPath: "",
11484
11754
  omitted: Number(payload.omitted) || 0,
11485
11755
  omittedIgnored: Number(payload.omittedIgnored) || 0,
11486
11756
  sort: normalizeFileBrowserSortMode(payload.sort || fileBrowserSortMode),
@@ -11494,11 +11764,18 @@
11494
11764
  critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
11495
11765
  scheduleResponsePaneRepaintNudge();
11496
11766
  }
11497
- if (options && options.user) setStatus("Loaded file list.", "success");
11767
+ if (config.user) setStatus("Loaded file list.", "success");
11498
11768
  } catch (error) {
11499
11769
  if (nonce !== fileBrowserLoadNonce) return;
11770
+ const errorPayload = error && error.studioPayload && typeof error.studioPayload === "object" ? error.studioPayload : null;
11771
+ const grantRequiredPath = errorPayload && errorPayload.code === "studio-resource-directory-grant-required" && typeof errorPayload.directoryPath === "string"
11772
+ ? normalizeStudioResourceDirValue(errorPayload.directoryPath)
11773
+ : "";
11500
11774
  fileBrowserState = {
11501
11775
  ...fileBrowserState,
11776
+ exactFiles: errorPayload && Array.isArray(errorPayload.exactFiles) ? errorPayload.exactFiles : fileBrowserState.exactFiles,
11777
+ locations: errorPayload && Array.isArray(errorPayload.locations) ? errorPayload.locations : fileBrowserState.locations,
11778
+ grantRequiredPath,
11502
11779
  loading: false,
11503
11780
  error: (error && error.message) ? error.message : String(error || "Could not load files."),
11504
11781
  loaded: true,
@@ -11546,7 +11823,7 @@
11546
11823
  return;
11547
11824
  }
11548
11825
  if (kind === "pdf") {
11549
- openPreviewPdfLink(path, path, context);
11826
+ await openPreviewPdfLink(path, path, context);
11550
11827
  return;
11551
11828
  }
11552
11829
  if (kind === "image") {
@@ -11556,6 +11833,34 @@
11556
11833
  setStatus("No Studio preview for this file type. Use Copy path or Reveal.", "warning");
11557
11834
  }
11558
11835
 
11836
+ async function allowFileBrowserFolder() {
11837
+ const context = getHtmlPreviewResourceContextOptions();
11838
+ const suggested = normalizeStudioResourceDirValue(
11839
+ fileBrowserState.grantRequiredPath
11840
+ || fileBrowserState.currentDir
11841
+ || fileBrowserState.rootDir
11842
+ || context.resourceDir
11843
+ || (context.sourcePath ? dirnameForDisplayPath(context.sourcePath) : "")
11844
+ || "./",
11845
+ );
11846
+ const path = await requestStudioTextInput(
11847
+ "Allow a folder on the computer running Pi for this Studio session:",
11848
+ suggested,
11849
+ { title: "Allow folder", confirmLabel: "Allow folder", inputLabel: "Folder path" },
11850
+ );
11851
+ if (!path) return;
11852
+ const payload = await fetchStudioJson("/resource-grants", {
11853
+ method: "POST",
11854
+ body: JSON.stringify({ grantKind: "directory", path }),
11855
+ });
11856
+ const grantedPath = payload && payload.grant && payload.grant.kind === "directory" && typeof payload.grant.path === "string"
11857
+ ? payload.grant.path
11858
+ : "";
11859
+ if (!grantedPath) throw new Error("Studio did not return the allowed folder.");
11860
+ await loadFileBrowserDirectory("", { root: grantedPath });
11861
+ setStatus(typeof payload.message === "string" ? payload.message : "Allowed folder for this Studio session.", "success");
11862
+ }
11863
+
11559
11864
  function setFileBrowserCurrentDirectoryAsWorkingDir(path) {
11560
11865
  const nextDir = normalizeStudioResourceDirValue(path || fileBrowserState.currentDir || "");
11561
11866
  if (!nextDir) {
@@ -11577,6 +11882,7 @@
11577
11882
  }
11578
11883
  const context = getHtmlPreviewResourceContextOptions();
11579
11884
  const body = { dir: targetDir };
11885
+ if (fileBrowserState.rootDir) body.root = fileBrowserState.rootDir;
11580
11886
  if (context.sourcePath) body.sourcePath = context.sourcePath;
11581
11887
  if (context.resourceDir) body.resourceDir = context.resourceDir;
11582
11888
  const payload = await fetchStudioJson("/file-browser-open", {
@@ -11589,6 +11895,14 @@
11589
11895
  async function handleFilesPaneChange(event) {
11590
11896
  if (rightView !== "files") return;
11591
11897
  const target = event.target;
11898
+ const locationSelect = target instanceof Element ? target.closest("[data-files-location]") : null;
11899
+ if (locationSelect && "value" in locationSelect) {
11900
+ const nextRoot = normalizeStudioResourceDirValue(locationSelect.value || "");
11901
+ if (nextRoot && nextRoot !== fileBrowserState.rootDir) {
11902
+ await loadFileBrowserDirectory("", { root: nextRoot, user: true });
11903
+ }
11904
+ return;
11905
+ }
11592
11906
  const sortSelect = target instanceof Element ? target.closest("[data-files-sort]") : null;
11593
11907
  if (!sortSelect || !("value" in sortSelect)) return;
11594
11908
  const nextSort = writeFileBrowserSortMode(sortSelect.value);
@@ -11607,6 +11921,10 @@
11607
11921
  const path = actionEl.getAttribute("data-files-path") || "";
11608
11922
  const kind = actionEl.getAttribute("data-files-kind") || getPreviewLocalLinkKind(path);
11609
11923
  try {
11924
+ if (action === "allow-folder") {
11925
+ await allowFileBrowserFolder();
11926
+ return;
11927
+ }
11610
11928
  if (action === "parent") {
11611
11929
  if (fileBrowserState.parentDir) await loadFileBrowserDirectory(fileBrowserState.parentDir, { user: true });
11612
11930
  return;
@@ -11624,14 +11942,6 @@
11624
11942
  return;
11625
11943
  }
11626
11944
  if (action === "open-new") {
11627
- if (kind === "text" && isLikelyAbsoluteStudioPath(path)) {
11628
- openFileBackedStudioEditorTab(path, {
11629
- label: basenameForStudioPath(path),
11630
- resourceDir: fileBrowserState.rootDir || getCurrentResourceDirValue() || dirnameForDisplayPath(path),
11631
- });
11632
- setStatus("Opening file-backed document in a new editor.");
11633
- return;
11634
- }
11635
11945
  await openPreviewDocumentInNewEditor(path, getFileBrowserLocalLinkContext());
11636
11946
  return;
11637
11947
  }
@@ -12345,6 +12655,33 @@
12345
12655
  };
12346
12656
  }
12347
12657
 
12658
+ async function ensureSideQuestionContextRootAuthorized(context) {
12659
+ if (!context || context.gatherScope === "none") return { contextRoot: "" };
12660
+ const body = JSON.stringify({
12661
+ gatherScope: context.gatherScope,
12662
+ sourcePath: context.sourcePath,
12663
+ resourceDir: context.resourceDir,
12664
+ contextPath: context.contextPath,
12665
+ });
12666
+ const check = () => fetchStudioJson("/side-question-context-root", { method: "POST", body });
12667
+ try {
12668
+ return await check();
12669
+ } catch (error) {
12670
+ const request = getStudioDirectoryGrantRequest(error);
12671
+ if (!request) throw error;
12672
+ const allowed = await requestStudioDirectoryGrant(request, {
12673
+ message: "Side questions can map, search, and read supported files beneath this folder through read-only context tools. Allow that access for this Studio session?",
12674
+ cancelStatus: "Side-question folder access cancelled.",
12675
+ });
12676
+ if (!allowed) {
12677
+ const cancelled = new Error("Side-question folder access cancelled.");
12678
+ cancelled.studioCancelled = true;
12679
+ throw cancelled;
12680
+ }
12681
+ return check();
12682
+ }
12683
+ }
12684
+
12348
12685
  async function renderSideQuestionMarkdownToHtml(markdown) {
12349
12686
  const source = String(markdown || "");
12350
12687
  if (sideQuestionMarkdownRenderCache.has(source)) return sideQuestionMarkdownRenderCache.get(source);
@@ -12369,7 +12706,11 @@
12369
12706
  const html = await renderSideQuestionMarkdownToHtml(markdown);
12370
12707
  if (nonce !== sideQuestionPreviewRenderNonce || rightView !== "side-questions" || !critiqueViewEl.contains(target)) return;
12371
12708
  target.innerHTML = html;
12709
+ await hydrateStudioPreviewLocalMedia(target, getHtmlPreviewResourceContextOptions());
12372
12710
  await renderAnnotationMathInElement(target);
12711
+ decoratePdfEmbeds(target);
12712
+ await renderPdfPreviewsInElement(target);
12713
+ decoratePreviewPdfFigures(target);
12373
12714
  await renderMermaidInElement(target);
12374
12715
  await renderMathFallbackInElement(target);
12375
12716
  decorateCopyablePreviewBlocks(target);
@@ -12432,6 +12773,7 @@
12432
12773
  ], sideQuestionUi.thinking) + "</select></label>"
12433
12774
  + "</div>"
12434
12775
  + "<details class='side-question-context-rule'><summary id='sideQuestionContextRule'>Automatic: selection → heading block at cursor → nearby text</summary><p>A heading block starts at the nearest Markdown/LaTeX heading above the cursor and ends before the next heading of the same or higher level. With no heading, Studio uses the surrounding text block or a nearby excerpt.</p></details>"
12776
+ + (scope === "none" ? "" : "<p class='side-question-context-access-note'>Related-file access is limited to folders allowed for this Studio session. Studio asks before starting if this folder is not already allowed.</p>")
12435
12777
  + (scope === "custom" ? "<label class='side-question-path-label'>Folder path<input data-side-question-field='customPath' type='text' value='" + escapeHtml(sideQuestionUi.customPath) + "' placeholder='Folder on the computer running Pi'></label>" : "")
12436
12778
  + "<div class='side-question-checks'>"
12437
12779
  + "<label><input data-side-question-field='includeConversation' type='checkbox'" + (sideQuestionUi.includeConversation ? " checked" : "") + "> Include the current main conversation snapshot</label>"
@@ -12442,7 +12784,7 @@
12442
12784
  + "<dl class='side-question-context-summary'><div><dt>Starting text</dt><dd>" + escapeHtml(summary.attachmentText) + "</dd></div><div><dt>Related files</dt><dd>" + escapeHtml(summary.relatedFilesText) + "</dd></div>" + (summary.gitContextText ? "<div><dt>Git context</dt><dd>" + escapeHtml(summary.gitContextText) + "</dd></div>" : "") + "</dl>"
12443
12785
  + "<label class='side-question-composer-label'>Question<textarea data-side-question-field='draft' rows='4' title='Enter adds a new line. Cmd/Ctrl+Enter asks the side question.' placeholder='Ask about the starting text or anything you want checked…'>" + escapeHtml(sideQuestionUi.draft) + "</textarea></label>"
12444
12786
  + (sideQuestionState && sideQuestionState.error ? "<div class='side-question-error'>" + escapeHtml(sideQuestionState.error) + "</div>" : "")
12445
- + "<div class='side-question-actions'><button type='button' class='side-question-primary' data-side-question-action='ask' aria-keyshortcuts='Meta+Enter Control+Enter' title='Ask side question (Cmd/Ctrl+Enter)'" + (!isSideQuestionConnectionReady() || (sideQuestionState && sideQuestionState.status === "running") || !sideQuestionUi.draft.trim() || (scope === "custom" && !sideQuestionUi.customPath.trim()) ? " disabled" : "") + ">" + (sideQuestionState && sideQuestionState.status === "running" ? "Preparing side thread…" : "Ask side question") + "</button></div>"
12787
+ + "<div class='side-question-actions'><button type='button' class='side-question-primary' data-side-question-action='ask' aria-keyshortcuts='Meta+Enter Control+Enter' title='Ask side question (Cmd/Ctrl+Enter)'" + (sideQuestionContextGrantPending || !isSideQuestionConnectionReady() || (sideQuestionState && sideQuestionState.status === "running") || !sideQuestionUi.draft.trim() || (scope === "custom" && !sideQuestionUi.customPath.trim()) ? " disabled" : "") + ">" + (sideQuestionContextGrantPending ? "Checking related-file access…" : (sideQuestionState && sideQuestionState.status === "running" ? "Preparing side thread…" : "Ask side question")) + "</button></div>"
12446
12788
  + "</div>";
12447
12789
  }
12448
12790
 
@@ -12513,7 +12855,8 @@
12513
12855
  }
12514
12856
  }
12515
12857
 
12516
- function submitSideQuestion() {
12858
+ async function submitSideQuestion() {
12859
+ if (sideQuestionContextGrantPending) return;
12517
12860
  const question = String(sideQuestionUi.draft || "").trim();
12518
12861
  if (!question) {
12519
12862
  setStatus("Enter a side question first.", "warning");
@@ -12532,11 +12875,31 @@
12532
12875
  if (sideQuestionState && sideQuestionState.threadId) {
12533
12876
  message.threadId = sideQuestionState.threadId;
12534
12877
  } else {
12535
- message.context = buildSideQuestionContextPayload();
12536
- if (message.context.gatherScope === "custom" && !String(message.context.contextPath || "").trim()) {
12878
+ const context = buildSideQuestionContextPayload();
12879
+ if (context.gatherScope === "custom" && !String(context.contextPath || "").trim()) {
12537
12880
  setStatus("Choose a custom context path first.", "warning");
12538
12881
  return;
12539
12882
  }
12883
+ if (context.gatherScope !== "none") {
12884
+ sideQuestionContextGrantPending = true;
12885
+ if (rightView === "side-questions") renderSideQuestionView();
12886
+ setStatus("Checking side-question folder access…", "warning");
12887
+ try {
12888
+ await ensureSideQuestionContextRootAuthorized(context);
12889
+ } catch (error) {
12890
+ if (!(error && error.studioCancelled)) {
12891
+ const messageText = error && error.message ? error.message : String(error || "Could not authorize side-question context.");
12892
+ if (!sideQuestionState) sideQuestionState = normalizeSideQuestionState(null);
12893
+ sideQuestionState.error = messageText;
12894
+ setStatus("Could not use related files: " + messageText, "warning");
12895
+ }
12896
+ return;
12897
+ } finally {
12898
+ sideQuestionContextGrantPending = false;
12899
+ if (rightView === "side-questions" && (!sideQuestionState || !sideQuestionState.threadId)) renderSideQuestionView();
12900
+ }
12901
+ }
12902
+ message.context = context;
12540
12903
  }
12541
12904
  if (!sendMessage(message)) return;
12542
12905
  sideQuestionUi.draft = "";
@@ -12652,7 +13015,7 @@
12652
13015
  event.preventDefault();
12653
13016
  const action = target.getAttribute("data-side-question-action");
12654
13017
  if (action === "ask") {
12655
- submitSideQuestion();
13018
+ await submitSideQuestion();
12656
13019
  } else if (action === "stop") {
12657
13020
  if (sideQuestionState && sideQuestionState.threadId && sideQuestionState.requestId) {
12658
13021
  sendMessage({ type: "side_question_cancel_request", threadId: sideQuestionState.threadId, requestId: sideQuestionState.requestId });
@@ -12705,7 +13068,7 @@
12705
13068
  if (field === "draft") sideQuestionUi.draft = target.value;
12706
13069
  if (field === "customPath") sideQuestionUi.customPath = target.value;
12707
13070
  const askButton = critiqueViewEl.querySelector("[data-side-question-action='ask']");
12708
- if (askButton) askButton.disabled = !isSideQuestionConnectionReady() || !sideQuestionUi.draft.trim() || (getSideQuestionGatherScope() === "custom" && !sideQuestionUi.customPath.trim());
13071
+ if (askButton) askButton.disabled = sideQuestionContextGrantPending || !isSideQuestionConnectionReady() || !sideQuestionUi.draft.trim() || (getSideQuestionGatherScope() === "custom" && !sideQuestionUi.customPath.trim());
12709
13072
  }
12710
13073
 
12711
13074
  function handleSideQuestionKeydown(event) {
@@ -12719,7 +13082,7 @@
12719
13082
  if (!submitShortcut) return;
12720
13083
  event.preventDefault();
12721
13084
  event.stopPropagation();
12722
- if (!sideQuestionState || sideQuestionState.status !== "running") submitSideQuestion();
13085
+ if (!sideQuestionState || sideQuestionState.status !== "running") void submitSideQuestion();
12723
13086
  }
12724
13087
 
12725
13088
  async function handleSideQuestionChange(event) {
@@ -14538,6 +14901,11 @@
14538
14901
  return launch.fail(message || "Studio could not prepare this tab. Return to the originating Studio page for details.");
14539
14902
  }
14540
14903
 
14904
+ function cancelPendingStudioTab(launch, message) {
14905
+ if (!launch || typeof launch.cancel !== "function") return false;
14906
+ return launch.cancel(message || "Studio tab launch was cancelled.");
14907
+ }
14908
+
14541
14909
  function abandonAllStudioTabLaunches(message) {
14542
14910
  const launches = Array.from(activeStudioTabLaunches);
14543
14911
  activeStudioTabLaunches.clear();
@@ -14616,7 +14984,10 @@
14616
14984
  const message = payload && typeof payload.error === "string"
14617
14985
  ? payload.error
14618
14986
  : (response.status + " " + response.statusText).trim();
14619
- throw new Error(message || (method + " " + pathname + " failed."));
14987
+ const requestError = new Error(message || (method + " " + pathname + " failed."));
14988
+ requestError.studioPayload = payload && typeof payload === "object" ? payload : null;
14989
+ requestError.studioStatus = response.status;
14990
+ throw requestError;
14620
14991
  }
14621
14992
  return payload;
14622
14993
  }
@@ -14649,6 +15020,7 @@
14649
15020
  ]);
14650
15021
  let previewLinkMenuEl = null;
14651
15022
  let activePreviewLinkContext = null;
15023
+ let previewLinkMenuRequestId = 0;
14652
15024
 
14653
15025
  function stripPreviewLocalLinkUrlSuffix(href) {
14654
15026
  const raw = String(href || "").trim();
@@ -14715,7 +15087,7 @@
14715
15087
  if (!raw || raw.charAt(0) === "#") return false;
14716
15088
  if (/^\/\//.test(raw)) return false;
14717
15089
  if (/^(?:https?|mailto|tel|data|blob|javascript|about):/i.test(raw)) return false;
14718
- if (/^\/(?:pdf-resource|html-preview-resource|export-pdf|export-html|render-preview|render-math|import-file-copy|local-preview-link|reveal-local-resource|open-local-resource)(?:[?#/]|$)/i.test(raw)) return false;
15090
+ if (/^\/(?:pdf-resource|html-preview-resource|export-pdf|export-html|render-preview|render-math|import-file-copy|resource-grants|local-preview-link|reveal-local-resource|open-local-resource)(?:[?#/]|$)/i.test(raw)) return false;
14719
15091
  return true;
14720
15092
  }
14721
15093
 
@@ -14785,19 +15157,35 @@
14785
15157
  menu.style.top = y + "px";
14786
15158
  }
14787
15159
 
14788
- function showPreviewLinkMenu(anchor, event, contextOverride) {
15160
+ async function showPreviewLinkMenu(anchor, event, contextOverride) {
14789
15161
  const href = String(anchor && anchor.getAttribute ? anchor.getAttribute("href") || "" : (contextOverride && contextOverride.href ? contextOverride.href : "")).trim();
14790
15162
  if (!isStudioLocalPreviewHref(href)) return false;
14791
15163
  const kind = getPreviewLocalLinkKind(href);
14792
- const menu = ensurePreviewLinkMenu();
14793
- menu.innerHTML = "";
14794
15164
  const linkContext = getEffectivePreviewLinkContext(contextOverride);
14795
- activePreviewLinkContext = {
15165
+ const nextContext = {
14796
15166
  href,
14797
15167
  title: String((contextOverride && contextOverride.title) || (anchor && anchor.textContent) || href || "local link").trim() || href,
14798
15168
  sourcePath: linkContext.sourcePath,
14799
15169
  resourceDir: linkContext.resourceDir,
14800
15170
  };
15171
+ const menuPoint = {
15172
+ clientX: event && event.clientX,
15173
+ clientY: event && event.clientY,
15174
+ };
15175
+ closePreviewLinkMenu();
15176
+ const menuRequestId = ++previewLinkMenuRequestId;
15177
+ try {
15178
+ await fetchPreviewLocalLink("resolve", href, nextContext);
15179
+ } catch (error) {
15180
+ if (!(error && error.studioCancelled)) {
15181
+ setStatus((error && error.message) ? error.message : String(error || "Could not inspect this local resource."), "warning");
15182
+ }
15183
+ return false;
15184
+ }
15185
+ if (menuRequestId !== previewLinkMenuRequestId) return false;
15186
+ const menu = ensurePreviewLinkMenu();
15187
+ menu.innerHTML = "";
15188
+ activePreviewLinkContext = nextContext;
14801
15189
  if (kind === "pdf") {
14802
15190
  appendPreviewLinkMenuButton(menu, "Open PDF preview", "open-pdf");
14803
15191
  appendPreviewLinkMenuButton(menu, "Open in new Studio tab", "open-preview-new");
@@ -14814,7 +15202,7 @@
14814
15202
  }
14815
15203
  appendPreviewLinkMenuButton(menu, "Reveal in file manager", "reveal");
14816
15204
  appendPreviewLinkMenuButton(menu, "Copy path", "copy-path");
14817
- positionPreviewLinkMenu(menu, event && event.clientX, event && event.clientY);
15205
+ positionPreviewLinkMenu(menu, menuPoint.clientX, menuPoint.clientY);
14818
15206
  const firstButton = menu.querySelector("button");
14819
15207
  if (firstButton && typeof firstButton.focus === "function") {
14820
15208
  window.setTimeout(() => firstButton.focus({ preventScroll: true }), 0);
@@ -14822,10 +15210,102 @@
14822
15210
  return true;
14823
15211
  }
14824
15212
 
14825
- async function fetchPreviewLocalLink(action, href, contextOverride) {
14826
- return fetchStudioJson("/local-preview-link", {
15213
+ function getStudioResourceGrantRequest(error) {
15214
+ const payload = error && error.studioPayload && typeof error.studioPayload === "object"
15215
+ ? error.studioPayload
15216
+ : null;
15217
+ if (!payload || payload.code !== "studio-resource-grant-required") return null;
15218
+ const path = typeof payload.path === "string" ? payload.path.trim() : "";
15219
+ const directoryPath = typeof payload.directoryPath === "string" ? payload.directoryPath.trim() : "";
15220
+ if (!path || !directoryPath) return null;
15221
+ return {
15222
+ path,
15223
+ directoryPath,
15224
+ label: typeof payload.label === "string" && payload.label.trim() ? payload.label.trim() : basenameForStudioPath(path),
15225
+ };
15226
+ }
15227
+
15228
+ function getStudioDirectoryGrantRequest(error) {
15229
+ const payload = error && error.studioPayload && typeof error.studioPayload === "object"
15230
+ ? error.studioPayload
15231
+ : null;
15232
+ if (!payload || payload.code !== "studio-resource-directory-grant-required") return null;
15233
+ const directoryPath = typeof payload.directoryPath === "string" ? payload.directoryPath.trim() : "";
15234
+ if (!directoryPath) return null;
15235
+ return {
15236
+ directoryPath,
15237
+ label: typeof payload.label === "string" && payload.label.trim() ? payload.label.trim() : basenameForStudioPath(directoryPath),
15238
+ };
15239
+ }
15240
+
15241
+ async function requestStudioDirectoryGrant(request, options) {
15242
+ if (!request || !request.directoryPath) return false;
15243
+ const config = options && typeof options === "object" ? options : {};
15244
+ const choice = await openStudioDecision({
15245
+ mode: "confirm",
15246
+ title: "Allow " + (request.label || "this folder") + "?",
15247
+ message: (config.message || "Allow read-only access beneath this folder for the current Studio session?")
15248
+ + "\n\nFolder on the computer running Pi:\n" + request.directoryPath,
15249
+ cancelLabel: "Cancel",
15250
+ confirmLabel: "Allow this folder for this Studio session",
15251
+ });
15252
+ if (choice !== true) {
15253
+ setStatus(config.cancelStatus || "Folder access cancelled.", "warning");
15254
+ return false;
15255
+ }
15256
+ const payload = await fetchStudioJson("/resource-grants", {
15257
+ method: "POST",
15258
+ body: JSON.stringify({ grantKind: "directory", path: request.directoryPath }),
15259
+ });
15260
+ fileBrowserState = { ...fileBrowserState, contextKey: "", loaded: false };
15261
+ setStatus(typeof payload.message === "string" ? payload.message : "Allowed this folder for the current Studio session.", "success");
15262
+ return true;
15263
+ }
15264
+
15265
+ async function requestStudioResourceGrant(request) {
15266
+ if (!request) return false;
15267
+ const choice = await openStudioDecision({
15268
+ mode: "confirm",
15269
+ title: "Allow " + request.label + "?",
15270
+ message: "This local resource is outside the locations currently available to Studio.\n\n"
15271
+ + "File on the computer running Pi:\n" + request.path + "\n\n"
15272
+ + "Allow only this file, or allow its containing folder for this Studio session?",
15273
+ cancelLabel: "Cancel",
15274
+ secondaryLabel: "Allow this folder for this Studio session",
15275
+ secondaryValue: "directory",
15276
+ confirmLabel: "Allow this file",
15277
+ });
15278
+ const grantKind = choice === true ? "file" : choice === "directory" ? "directory" : "";
15279
+ if (!grantKind) {
15280
+ setStatus("Local resource access cancelled.", "warning");
15281
+ return false;
15282
+ }
15283
+ const grantPath = grantKind === "directory" ? request.directoryPath : request.path;
15284
+ const payload = await fetchStudioJson("/resource-grants", {
15285
+ method: "POST",
15286
+ body: JSON.stringify({ grantKind, path: grantPath }),
15287
+ });
15288
+ fileBrowserState = { ...fileBrowserState, contextKey: "", loaded: false };
15289
+ setStatus(typeof payload.message === "string" ? payload.message : "Allowed local resource for this Studio session.", "success");
15290
+ return true;
15291
+ }
15292
+
15293
+ async function fetchPreviewLocalLink(action, href, contextOverride, options) {
15294
+ const request = () => fetchStudioJson("/local-preview-link", {
14827
15295
  query: { ...getPreviewLinkResourceQuery(href, contextOverride), action },
14828
15296
  });
15297
+ try {
15298
+ return await request();
15299
+ } catch (error) {
15300
+ const grantRequest = getStudioResourceGrantRequest(error);
15301
+ if (!grantRequest || (options && options.skipGrantPrompt === true)) throw error;
15302
+ if (!(await requestStudioResourceGrant(grantRequest))) {
15303
+ const cancelled = new Error("Local resource access cancelled.");
15304
+ cancelled.studioCancelled = true;
15305
+ throw cancelled;
15306
+ }
15307
+ return request();
15308
+ }
14829
15309
  }
14830
15310
 
14831
15311
  function getPreviewPdfViewerUrl(href, contextOverride) {
@@ -14836,16 +15316,16 @@
14836
15316
  return resourceUrl && page ? resourceUrl + "#page=" + encodeURIComponent(String(page)) : resourceUrl;
14837
15317
  }
14838
15318
 
14839
- function openPreviewPdfLink(href, title, contextOverride) {
15319
+ async function openPreviewPdfLink(href, title, contextOverride) {
15320
+ await fetchPreviewLocalLink("resolve", href, contextOverride);
14840
15321
  const viewerUrl = getPreviewPdfViewerUrl(href, contextOverride);
14841
15322
  if (!viewerUrl) {
14842
15323
  setStatus("Could not resolve this PDF link. Open the source file or set a working directory first.", "warning");
14843
15324
  return false;
14844
15325
  }
14845
- const cleanPath = stripPreviewLocalLinkUrlSuffix(href);
14846
15326
  const context = contextOverride && typeof contextOverride === "object" ? contextOverride : {};
14847
15327
  const resourceQuery = buildStudioPdfResourceQuery({
14848
- path: cleanPath,
15328
+ path: stripPreviewLocalLinkUrlSuffix(href),
14849
15329
  sourcePath: context.sourcePath || "",
14850
15330
  resourceDir: context.resourceDir || "",
14851
15331
  }, true);
@@ -14854,6 +15334,7 @@
14854
15334
  }
14855
15335
 
14856
15336
  async function openPreviewImageLink(href, title, contextOverride) {
15337
+ await fetchPreviewLocalLink("resolve", href, contextOverride);
14857
15338
  const payload = await fetchStudioJson("/html-preview-resource", {
14858
15339
  query: getPreviewLinkResourceQuery(href, contextOverride),
14859
15340
  });
@@ -14955,7 +15436,11 @@
14955
15436
  navigatePendingStudioTab(launch, relativeUrl);
14956
15437
  setStatus(payload && payload.converted ? "Opening converted document in a new editor." : "Opening file-backed document in a new editor.");
14957
15438
  } catch (error) {
14958
- failPendingStudioTab(launch, "Studio could not prepare this document tab. Return to the originating Studio page for details.");
15439
+ if (error && error.studioCancelled) {
15440
+ cancelPendingStudioTab(launch, "Local resource access was cancelled.");
15441
+ } else {
15442
+ failPendingStudioTab(launch, "Studio could not prepare this document tab. Return to the originating Studio page for details.");
15443
+ }
14959
15444
  throw error;
14960
15445
  }
14961
15446
  }
@@ -14970,7 +15455,11 @@
14970
15455
  navigatePendingStudioTab(launch, relativeUrl);
14971
15456
  setStatus("Opening preview in a new Studio tab.");
14972
15457
  } catch (error) {
14973
- failPendingStudioTab(launch, "Studio could not prepare this preview tab. Return to the originating Studio page for details.");
15458
+ if (error && error.studioCancelled) {
15459
+ cancelPendingStudioTab(launch, "Local resource access was cancelled.");
15460
+ } else {
15461
+ failPendingStudioTab(launch, "Studio could not prepare this preview tab. Return to the originating Studio page for details.");
15462
+ }
14974
15463
  throw error;
14975
15464
  }
14976
15465
  }
@@ -14985,6 +15474,7 @@
14985
15474
  }
14986
15475
 
14987
15476
  async function revealPreviewLocalLink(href, contextOverride) {
15477
+ await fetchPreviewLocalLink("resolve", href, contextOverride);
14988
15478
  const query = getPreviewLinkResourceQuery(href, contextOverride);
14989
15479
  const payload = await fetchStudioJson("/reveal-local-resource", {
14990
15480
  method: "POST",
@@ -14998,10 +15488,11 @@
14998
15488
  if (!href) return;
14999
15489
  try {
15000
15490
  if (action === "open-pdf") {
15001
- openPreviewPdfLink(href, context.title || href, context);
15491
+ await openPreviewPdfLink(href, context.title || href, context);
15002
15492
  return;
15003
15493
  }
15004
15494
  if (action === "open-system") {
15495
+ await fetchPreviewLocalLink("resolve", href, context);
15005
15496
  await runStudioPdfLocalAction("system-viewer", getPreviewLinkResourceQuery(href, context));
15006
15497
  return;
15007
15498
  }
@@ -15043,7 +15534,9 @@
15043
15534
  closePreviewLinkMenu();
15044
15535
  const title = String(anchor.textContent || href).trim() || href;
15045
15536
  if (kind === "pdf") {
15046
- openPreviewPdfLink(href, title);
15537
+ void openPreviewPdfLink(href, title).catch((error) => {
15538
+ setStatus((error && error.message) ? error.message : String(error || "Could not open linked PDF."), "warning");
15539
+ });
15047
15540
  return;
15048
15541
  }
15049
15542
  if (kind === "image") {
@@ -15053,9 +15546,7 @@
15053
15546
  return;
15054
15547
  }
15055
15548
  if (kind === "text" || kind === "office") {
15056
- void openPreviewDocumentInNewEditor(href).catch((error) => {
15057
- setStatus((error && error.message) ? error.message : String(error || "Could not open linked file."), "warning");
15058
- });
15549
+ void showPreviewLinkMenu(anchor, event);
15059
15550
  return;
15060
15551
  }
15061
15552
  setStatus("Right-click this local link for file actions.", "warning");
@@ -15066,7 +15557,7 @@
15066
15557
  if (!anchor) return;
15067
15558
  event.preventDefault();
15068
15559
  event.stopPropagation();
15069
- showPreviewLinkMenu(anchor, event);
15560
+ void showPreviewLinkMenu(anchor, event);
15070
15561
  }
15071
15562
 
15072
15563
  function makeRequestId() {
@@ -15245,6 +15736,7 @@
15245
15736
  const html = await renderQuizMarkdownToHtml(markdown);
15246
15737
  if (nonce !== quizPreviewRenderNonce || !quizDialogEl || !quizDialogEl.contains(target)) return;
15247
15738
  target.innerHTML = html;
15739
+ await hydrateStudioPreviewLocalMedia(target, getHtmlPreviewResourceContextOptions());
15248
15740
  await renderAnnotationMathInElement(target);
15249
15741
  decoratePdfEmbeds(target);
15250
15742
  await renderPdfPreviewsInElement(target);