pi-studio 0.9.52 → 0.9.54

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.
@@ -155,6 +155,7 @@
155
155
  const shortcutsCloseBtn = document.getElementById("shortcutsCloseBtn");
156
156
  const leftFocusBtn = document.getElementById("leftFocusBtn");
157
157
  const rightFocusBtn = document.getElementById("rightFocusBtn");
158
+ const watchedOpenEditableBtn = document.getElementById("watchedOpenEditableBtn");
158
159
  const reviewNotesBtn = document.getElementById("reviewNotesBtn");
159
160
  const outlineBtn = document.getElementById("outlineBtn");
160
161
  const scratchpadBtn = document.getElementById("scratchpadBtn");
@@ -192,6 +193,7 @@
192
193
  ? "editor-only"
193
194
  : "full";
194
195
  const isEditorOnlyMode = studioMode === "editor-only";
196
+ const isWatchedFilePreview = Boolean(document.body && document.body.dataset && document.body.dataset.watchedFilePreview === "1");
195
197
  const isSshStudioSession = Boolean(document.body && document.body.dataset && document.body.dataset.sshSession === "1");
196
198
  const EDITOR_ONLY_RIGHT_VIEW_ALLOWED = new Set(["editor-preview", "editor-quarto-preview", "files", "changes", "repl", "side-questions"]);
197
199
  const RIGHT_VIEW_LABELS = {
@@ -242,6 +244,7 @@
242
244
  || typeof previewResourceHelpers.buildStudioPdfVersionSignature !== "function"
243
245
  || typeof previewResourceHelpers.createStudioPdfVersionObservationState !== "function"
244
246
  || typeof previewResourceHelpers.hydrateStudioPreviewLocalImages !== "function"
247
+ || typeof previewResourceHelpers.hydrateStudioPreviewLocalPdfEmbeds !== "function"
245
248
  || typeof previewResourceHelpers.observeStudioPdfVersion !== "function"
246
249
  ) {
247
250
  throw new Error("Studio preview resource helpers failed to load.");
@@ -281,6 +284,16 @@
281
284
  };
282
285
  const initialResourceDir = initialQueryParams.get("resourceDir")
283
286
  || ((document.body && document.body.dataset && document.body.dataset.initialResourceDir) || "");
287
+ const initialDiskRevision = (document.body && document.body.dataset && document.body.dataset.initialDiskRevision) || "";
288
+ let watchedFilePreviewState = {
289
+ enabled: isWatchedFilePreview,
290
+ path: isWatchedFilePreview && initialSourceState.path ? initialSourceState.path : "",
291
+ diskRevision: isWatchedFilePreview ? initialDiskRevision : "",
292
+ generation: 0,
293
+ lastError: "",
294
+ renderError: "",
295
+ };
296
+ const watchedFilePreviewReadingPositions = { source: null, response: null };
284
297
 
285
298
  let ws = null;
286
299
  let wsState = "Connecting";
@@ -332,6 +345,7 @@
332
345
  let studioDecisionMessageEl = null;
333
346
  let studioDecisionInputEl = null;
334
347
  let studioDecisionCancelBtn = null;
348
+ let studioDecisionTertiaryBtn = null;
335
349
  let studioDecisionSecondaryBtn = null;
336
350
  let studioDecisionConfirmBtn = null;
337
351
  let studioDecisionState = null;
@@ -339,6 +353,7 @@
339
353
  let pendingRequestId = null;
340
354
  let pendingKind = null;
341
355
  let stickyStudioKind = null;
356
+ const pendingSaveOperations = new Map();
342
357
  const pendingCompanionLaunches = new Map();
343
358
  const activeStudioTabLaunches = new Set();
344
359
  let sourceOriginSummaryEl = null;
@@ -368,6 +383,7 @@
368
383
 
369
384
  function normalizeRightViewValue(nextView) {
370
385
  const normalized = canonicalRightViewValue(nextView);
386
+ if (isWatchedFilePreview && normalized !== "editor-preview") return "editor-preview";
371
387
  if (normalized === "editor-quarto-preview" && !isCurrentStudioQuartoDocument()) {
372
388
  return "editor-preview";
373
389
  }
@@ -379,6 +395,7 @@
379
395
 
380
396
  function isRightViewAvailableInCurrentMode(view) {
381
397
  const normalized = canonicalRightViewValue(view);
398
+ if (isWatchedFilePreview) return normalized === "editor-preview";
382
399
  if (normalized === "editor-quarto-preview" && !isCurrentStudioQuartoDocument()) return false;
383
400
  return !isEditorOnlyMode || EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(normalized);
384
401
  }
@@ -398,12 +415,17 @@
398
415
  Array.from(rightViewSelect.options).forEach((option) => {
399
416
  if (!option) return;
400
417
  const isQuartoOption = option.value === "editor-quarto-preview";
418
+ if (isWatchedFilePreview && option.value === "editor-preview") option.textContent = "Watched preview";
401
419
  if (isQuartoOption) option.hidden = !quartoRelevant;
402
- option.disabled = (isEditorOnlyMode && !EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(option.value)) || (isQuartoOption && !quartoRelevant);
420
+ option.disabled = (isWatchedFilePreview && option.value !== "editor-preview")
421
+ || (isEditorOnlyMode && !EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(option.value))
422
+ || (isQuartoOption && !quartoRelevant);
403
423
  });
404
- 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.";
424
+ rightViewSelect.title = isWatchedFilePreview
425
+ ? "Read-only watched preview follows this file on disk."
426
+ : (isEditorOnlyMode
427
+ ? "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."
428
+ : "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
429
  }
408
430
 
409
431
  function getInitialRightView(source) {
@@ -478,6 +500,7 @@
478
500
  let sideQuestionAvailablePiTools = [];
479
501
  let sideQuestionPreviewRenderNonce = 0;
480
502
  let sideQuestionContextRefreshHandle = null;
503
+ let sideQuestionContextGrantPending = false;
481
504
  let sideQuestionMarkdownExportRequest = null;
482
505
  const sideQuestionMarkdownRenderCache = new Map();
483
506
  let sideQuestionUi = {
@@ -2162,6 +2185,7 @@
2162
2185
  actionRequestId: null,
2163
2186
  };
2164
2187
  let fileBackedBaselineText = null;
2188
+ let fileBackedDiskRevision = null;
2165
2189
  let activePane = initialPaneFocusTarget === "right" ? "right" : "left";
2166
2190
  let paneFocusTarget = initialPaneFocusTarget;
2167
2191
  let paneSplitPercent = 50;
@@ -2293,6 +2317,9 @@
2293
2317
  relativeDir: "",
2294
2318
  parentDir: null,
2295
2319
  entries: [],
2320
+ exactFiles: [],
2321
+ locations: [],
2322
+ grantRequiredPath: "",
2296
2323
  omitted: 0,
2297
2324
  omittedIgnored: 0,
2298
2325
  sort: fileBrowserSortMode,
@@ -2918,6 +2945,7 @@
2918
2945
  rightTitleGroupEl.appendChild(rightViewSelect);
2919
2946
  rightIdentityEl.appendChild(rightTitleGroupEl);
2920
2947
  const rightToolsEl = makeStudioUiRefreshElement("div", "studio-refresh-pane-tools");
2948
+ if (watchedOpenEditableBtn && isWatchedFilePreview) rightToolsEl.appendChild(watchedOpenEditableBtn);
2921
2949
  if (exportPreviewControlsEl) {
2922
2950
  rightToolsEl.appendChild(exportPreviewControlsEl);
2923
2951
  } else if (exportPdfBtn) {
@@ -3841,7 +3869,7 @@
3841
3869
  }
3842
3870
 
3843
3871
  function getStudioDecisionFocusableElements() {
3844
- return [studioDecisionInputEl, studioDecisionCancelBtn, studioDecisionSecondaryBtn, studioDecisionConfirmBtn]
3872
+ return [studioDecisionInputEl, studioDecisionCancelBtn, studioDecisionTertiaryBtn, studioDecisionSecondaryBtn, studioDecisionConfirmBtn]
3845
3873
  .filter((element) => element && !element.hidden && !element.disabled);
3846
3874
  }
3847
3875
 
@@ -3889,13 +3917,41 @@
3889
3917
  cancelBtn.addEventListener("click", () => finishStudioDecision(null));
3890
3918
  actions.appendChild(cancelBtn);
3891
3919
 
3920
+ const tertiaryBtn = document.createElement("button");
3921
+ tertiaryBtn.type = "button";
3922
+ tertiaryBtn.className = "studio-decision-tertiary";
3923
+ tertiaryBtn.hidden = true;
3924
+ tertiaryBtn.addEventListener("click", () => {
3925
+ const state = studioDecisionState;
3926
+ const handler = state && state.onTertiary;
3927
+ if (typeof handler !== "function") {
3928
+ if (state && state.hasTertiaryValue) finishStudioDecision(state.tertiaryValue);
3929
+ return;
3930
+ }
3931
+ try {
3932
+ const result = handler();
3933
+ if (result && typeof result.catch === "function") {
3934
+ result.catch((error) => {
3935
+ setStatus("Action failed: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
3936
+ });
3937
+ }
3938
+ } catch (error) {
3939
+ setStatus("Action failed: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
3940
+ }
3941
+ });
3942
+ actions.appendChild(tertiaryBtn);
3943
+
3892
3944
  const secondaryBtn = document.createElement("button");
3893
3945
  secondaryBtn.type = "button";
3894
3946
  secondaryBtn.className = "studio-decision-secondary";
3895
3947
  secondaryBtn.hidden = true;
3896
3948
  secondaryBtn.addEventListener("click", () => {
3897
- const handler = studioDecisionState && studioDecisionState.onSecondary;
3898
- if (typeof handler !== "function") return;
3949
+ const state = studioDecisionState;
3950
+ const handler = state && state.onSecondary;
3951
+ if (typeof handler !== "function") {
3952
+ if (state && state.hasSecondaryValue) finishStudioDecision(state.secondaryValue);
3953
+ return;
3954
+ }
3899
3955
  try {
3900
3956
  const result = handler();
3901
3957
  if (result && typeof result.catch === "function") {
@@ -3955,6 +4011,7 @@
3955
4011
  studioDecisionMessageEl = message;
3956
4012
  studioDecisionInputEl = input;
3957
4013
  studioDecisionCancelBtn = cancelBtn;
4014
+ studioDecisionTertiaryBtn = tertiaryBtn;
3958
4015
  studioDecisionSecondaryBtn = secondaryBtn;
3959
4016
  studioDecisionConfirmBtn = confirmBtn;
3960
4017
  return overlay;
@@ -3967,6 +4024,7 @@
3967
4024
  if (studioDecisionState) finishStudioDecision(null, false);
3968
4025
  const returnFocusEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
3969
4026
 
4027
+ const tertiaryLabel = String(settings.tertiaryLabel || "").trim();
3970
4028
  const secondaryLabel = String(settings.secondaryLabel || "").trim();
3971
4029
  studioDecisionTitleEl.textContent = String(settings.title || (mode === "prompt" ? "Enter a value" : "Confirm action"));
3972
4030
  studioDecisionMessageEl.textContent = String(settings.message || "");
@@ -3975,9 +4033,13 @@
3975
4033
  studioDecisionInputEl.placeholder = mode === "prompt" ? String(settings.placeholder || "") : "";
3976
4034
  studioDecisionInputEl.setAttribute("aria-label", String(settings.inputLabel || "Value"));
3977
4035
  studioDecisionCancelBtn.textContent = String(settings.cancelLabel || "Cancel");
4036
+ studioDecisionTertiaryBtn.hidden = !tertiaryLabel;
4037
+ studioDecisionTertiaryBtn.disabled = settings.tertiaryDisabled === true;
4038
+ studioDecisionTertiaryBtn.textContent = tertiaryLabel;
3978
4039
  studioDecisionSecondaryBtn.hidden = !secondaryLabel;
3979
4040
  studioDecisionSecondaryBtn.disabled = settings.secondaryDisabled === true;
3980
4041
  studioDecisionSecondaryBtn.textContent = secondaryLabel;
4042
+ studioDecisionConfirmBtn.disabled = settings.confirmDisabled === true;
3981
4043
  studioDecisionConfirmBtn.textContent = String(settings.confirmLabel || (mode === "prompt" ? "Continue" : "Confirm"));
3982
4044
  studioDecisionConfirmBtn.classList.toggle("is-destructive", settings.destructive === true);
3983
4045
  studioDecisionDialogEl.classList.toggle("is-destructive", settings.destructive === true);
@@ -3989,7 +4051,12 @@
3989
4051
  mode,
3990
4052
  resolve,
3991
4053
  returnFocusEl,
4054
+ onTertiary: typeof settings.onTertiary === "function" ? settings.onTertiary : null,
4055
+ hasTertiaryValue: Object.prototype.hasOwnProperty.call(settings, "tertiaryValue"),
4056
+ tertiaryValue: settings.tertiaryValue,
3992
4057
  onSecondary: typeof settings.onSecondary === "function" ? settings.onSecondary : null,
4058
+ hasSecondaryValue: Object.prototype.hasOwnProperty.call(settings, "secondaryValue"),
4059
+ secondaryValue: settings.secondaryValue,
3993
4060
  };
3994
4061
  studioDecisionState = decisionState;
3995
4062
  const schedule = typeof window.requestAnimationFrame === "function"
@@ -4048,12 +4115,20 @@
4048
4115
  }
4049
4116
  });
4050
4117
 
4051
- function markFileBackedBaseline(text) {
4118
+ function normalizeStudioDiskRevision(value) {
4119
+ const revision = typeof value === "string" ? value.trim().toLowerCase() : "";
4120
+ return /^sha256:[a-f0-9]{64}$/.test(revision) ? revision : null;
4121
+ }
4122
+
4123
+ function markFileBackedBaseline(text, diskRevision) {
4052
4124
  fileBackedBaselineText = String(text || "");
4125
+ fileBackedDiskRevision = normalizeStudioDiskRevision(diskRevision);
4126
+ scheduleWorkspacePersistence();
4053
4127
  }
4054
4128
 
4055
4129
  function clearFileBackedBaseline() {
4056
4130
  fileBackedBaselineText = null;
4131
+ fileBackedDiskRevision = null;
4057
4132
  }
4058
4133
 
4059
4134
  function hasRefreshableFilePath() {
@@ -4068,13 +4143,17 @@
4068
4143
 
4069
4144
  function updateSourceBadge() {
4070
4145
  const label = sourceState && sourceState.label ? sourceState.label : "blank";
4071
- const originText = (studioUiRefreshEnabled ? "Origin: " : "Editor origin: ") + label + (hasRefreshableFilePath() ? " · file" : "");
4146
+ const originText = isWatchedFilePreview
4147
+ ? ("Watching: " + label + " · read-only" + (watchedFilePreviewState.lastError || watchedFilePreviewState.renderError ? " · last good preview" : ""))
4148
+ : ((studioUiRefreshEnabled ? "Origin: " : "Editor origin: ") + label + (hasRefreshableFilePath() ? " · file" : ""));
4072
4149
  const descriptor = getCurrentStudioDocumentDescriptor();
4073
4150
  if (sourceBadgeEl) {
4074
4151
  sourceBadgeEl.textContent = originText;
4075
- sourceBadgeEl.title = descriptor.fileBacked
4152
+ sourceBadgeEl.title = isWatchedFilePreview
4153
+ ? ("Read-only watched file: " + (descriptor.label || label) + "\nStudio follows settled disk changes and keeps the last good rendered preview through temporary failures.")
4154
+ : (descriptor.fileBacked
4076
4155
  ? ("Editor origin: " + label + "\nClick to reset origin and detach the current editor text into a new draft. The file on disk will not be changed.")
4077
- : ("Editor origin: " + label + "\nClick to reset origin and start a new independent draft while keeping the current text and local notes.");
4156
+ : ("Editor origin: " + label + "\nClick to reset origin and start a new independent draft while keeping the current text and local notes."));
4078
4157
  }
4079
4158
  if (sourceOriginSummaryEl) {
4080
4159
  sourceOriginSummaryEl.textContent = originText;
@@ -4533,10 +4612,14 @@
4533
4612
  }
4534
4613
 
4535
4614
  function triggerEditorSaveShortcut() {
4536
- if (saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
4615
+ if (hasRefreshableFilePath() && saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
4537
4616
  saveOverBtn.click();
4538
4617
  return true;
4539
4618
  }
4619
+ return triggerEditorSaveAsShortcut();
4620
+ }
4621
+
4622
+ function triggerEditorSaveAsShortcut() {
4540
4623
  if (saveAsBtn && !saveAsBtn.disabled && !saveAsBtn.hidden) {
4541
4624
  saveAsBtn.click();
4542
4625
  return true;
@@ -4821,6 +4904,16 @@
4821
4904
  return;
4822
4905
  }
4823
4906
 
4907
+ const isFilesShortcut = (key.toLowerCase() === "f" || code === "KeyF")
4908
+ && (event.metaKey || event.ctrlKey)
4909
+ && event.altKey
4910
+ && !event.shiftKey;
4911
+ if (isFilesShortcut) {
4912
+ event.preventDefault();
4913
+ switchRightPaneToView("files");
4914
+ return;
4915
+ }
4916
+
4824
4917
  const isSideQuestionsShortcut = (key.toLowerCase() === "q" || code === "KeyQ")
4825
4918
  && (event.metaKey || event.ctrlKey)
4826
4919
  && event.altKey
@@ -4869,6 +4962,22 @@
4869
4962
  return;
4870
4963
  }
4871
4964
 
4965
+ const isSaveAsShortcut =
4966
+ key.toLowerCase() === "s"
4967
+ && (event.metaKey || event.ctrlKey)
4968
+ && !event.altKey
4969
+ && event.shiftKey;
4970
+
4971
+ if (isSaveAsShortcut) {
4972
+ event.preventDefault();
4973
+ if (isWatchedFilePreview) {
4974
+ setStatus("This preview is read-only. Open a file tab to edit or save a copy.", "warning");
4975
+ return;
4976
+ }
4977
+ triggerEditorSaveAsShortcut();
4978
+ return;
4979
+ }
4980
+
4872
4981
  const isSaveShortcut =
4873
4982
  key.toLowerCase() === "s"
4874
4983
  && (event.metaKey || event.ctrlKey)
@@ -4877,6 +4986,10 @@
4877
4986
 
4878
4987
  if (isSaveShortcut) {
4879
4988
  event.preventDefault();
4989
+ if (isWatchedFilePreview) {
4990
+ setStatus("This preview follows disk and cannot save. Open a file tab to edit safely.", "warning");
4991
+ return;
4992
+ }
4880
4993
  triggerEditorSaveShortcut();
4881
4994
  return;
4882
4995
  }
@@ -5623,6 +5736,8 @@
5623
5736
  clearPreviewJumpHighlight(targetEl);
5624
5737
  finishPreviewRender(targetEl);
5625
5738
  targetEl.innerHTML = html;
5739
+ clearWatchedPreviewRenderError();
5740
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, text);
5626
5741
  if (pane === "response") {
5627
5742
  applyPendingResponseScrollReset();
5628
5743
  scheduleResponsePaneRepaintNudge();
@@ -6041,7 +6156,8 @@
6041
6156
  + " const images = Array.prototype.slice.call(document.querySelectorAll('img[src]'));\n"
6042
6157
  + " images.forEach((image) => {\n"
6043
6158
  + " if (!image || !image.getAttribute) return;\n"
6044
- + " if (image.getAttribute('data-pi-studio-html-resource-resolved') === 'true') return;\n"
6159
+ + " const resourceState = image.getAttribute('data-pi-studio-html-resource-resolved') || '';\n"
6160
+ + " if (resourceState === 'true' || resourceState === 'blocked' || resourceState === 'failed') return;\n"
6045
6161
  + " const raw = String(image.getAttribute('src') || '').trim();\n"
6046
6162
  + " if (!shouldResolveHtmlPreviewResourceUrl(raw)) return;\n"
6047
6163
  + " let resourceId = image.getAttribute('data-pi-studio-html-resource-id') || '';\n"
@@ -6073,6 +6189,7 @@
6073
6189
  + " image.setAttribute('data-pi-studio-html-resource-resolved', 'true');\n"
6074
6190
  + " } else if (typeof result.error === 'string' && result.error) {\n"
6075
6191
  + " image.setAttribute('title', result.error);\n"
6192
+ + " image.setAttribute('data-pi-studio-html-resource-resolved', result.blocked === true ? 'blocked' : 'failed');\n"
6076
6193
  + " }\n"
6077
6194
  + " htmlResourcePlaceholders.delete(resourceId);\n"
6078
6195
  + " });\n"
@@ -6523,18 +6640,28 @@
6523
6640
  const message = payload && typeof payload.error === "string"
6524
6641
  ? payload.error
6525
6642
  : (timeoutLabel || "Local preview resource load") + " failed with HTTP " + response.status + ".";
6526
- throw new Error(message);
6643
+ const requestError = new Error(message);
6644
+ requestError.studioPayload = payload && typeof payload === "object" ? payload : null;
6645
+ requestError.studioStatus = response.status;
6646
+ throw requestError;
6527
6647
  }
6528
6648
  return payload.dataUrl;
6529
6649
  }
6530
6650
 
6531
6651
  function postHtmlArtifactResourceResults(record, results) {
6532
6652
  if (!record || !record.iframe || !record.iframe.isConnected || !record.iframe.contentWindow) return;
6653
+ const publicResults = Array.isArray(results) ? results.map((result) => ({
6654
+ resourceId: result && result.resourceId ? result.resourceId : "",
6655
+ ok: Boolean(result && result.ok === true),
6656
+ dataUrl: result && typeof result.dataUrl === "string" ? result.dataUrl : undefined,
6657
+ error: result && typeof result.error === "string" ? result.error : undefined,
6658
+ blocked: Boolean(result && result.blocked === true),
6659
+ })) : [];
6533
6660
  try {
6534
6661
  record.iframe.contentWindow.postMessage({
6535
6662
  type: "pi-studio-html-artifact-resources-resolved",
6536
6663
  id: record.id || "",
6537
- results: Array.isArray(results) ? results : [],
6664
+ results: publicResults,
6538
6665
  }, "*");
6539
6666
  } catch {
6540
6667
  // Ignore iframe postMessage failures.
@@ -6552,14 +6679,58 @@
6552
6679
  );
6553
6680
  return { resourceId, ok: true, dataUrl };
6554
6681
  } catch (error) {
6555
- return { resourceId, ok: false, error: error && error.message ? error.message : String(error || "HTML preview resource load failed.") };
6682
+ const grantRequest = getStudioResourceGrantRequest(error);
6683
+ return {
6684
+ resourceId,
6685
+ ok: false,
6686
+ error: error && error.message ? error.message : String(error || "HTML preview resource load failed."),
6687
+ blocked: Boolean(grantRequest),
6688
+ grantRequest,
6689
+ sourceUrl: item && item.url ? String(item.url) : "",
6690
+ };
6556
6691
  }
6557
6692
  }
6558
6693
 
6694
+ function renderHtmlArtifactBlockedResources(record) {
6695
+ if (!record || !record.shell) return;
6696
+ const existing = record.shell.querySelector(".studio-html-artifact-blocked-media");
6697
+ if (existing) existing.remove();
6698
+ const blocked = record.blockedResources instanceof Map ? Array.from(record.blockedResources.values()) : [];
6699
+ if (!blocked.length) return;
6700
+ const container = document.createElement("div");
6701
+ container.className = "studio-html-artifact-blocked-media";
6702
+ blocked.forEach((entry) => {
6703
+ const notice = createStudioBlockedMediaNoticeForRequest(entry.grantRequest, "image", entry.sourceUrl);
6704
+ if (notice) container.appendChild(notice);
6705
+ });
6706
+ if (!container.childNodes.length) return;
6707
+ const frame = record.iframe && record.iframe.parentNode === record.shell ? record.iframe : null;
6708
+ record.shell.insertBefore(container, frame);
6709
+ }
6710
+
6711
+ function syncHtmlArtifactBlockedResources(record, results) {
6712
+ if (!record) return;
6713
+ if (!(record.blockedResources instanceof Map)) record.blockedResources = new Map();
6714
+ (Array.isArray(results) ? results : []).forEach((result) => {
6715
+ const resourceId = result && result.resourceId ? String(result.resourceId) : "";
6716
+ if (!resourceId) return;
6717
+ if (result.blocked === true && result.grantRequest) {
6718
+ record.blockedResources.set(resourceId, {
6719
+ grantRequest: result.grantRequest,
6720
+ sourceUrl: result.sourceUrl || "local image",
6721
+ });
6722
+ } else {
6723
+ record.blockedResources.delete(resourceId);
6724
+ }
6725
+ });
6726
+ renderHtmlArtifactBlockedResources(record);
6727
+ }
6728
+
6559
6729
  async function resolveHtmlArtifactResources(record, items) {
6560
6730
  if (!record || !Array.isArray(items) || items.length === 0) return;
6561
6731
  if (record.detail) record.detail.textContent = "HTML preview · loading local images";
6562
6732
  const results = await Promise.all(items.map((item) => fetchHtmlArtifactResource(record, item)));
6733
+ syncHtmlArtifactBlockedResources(record, results);
6563
6734
  postHtmlArtifactResourceResults(record, results);
6564
6735
  setHtmlArtifactDetailText(record, "HTML preview");
6565
6736
  }
@@ -6620,12 +6791,14 @@
6620
6791
  const action = typeof data.action === "string" ? data.action : "open";
6621
6792
  if (action === "contextmenu") {
6622
6793
  const point = getHtmlArtifactLocalLinkClientPoint(record, data);
6623
- showPreviewLinkMenu(null, point, context);
6794
+ void showPreviewLinkMenu(null, point, context);
6624
6795
  return;
6625
6796
  }
6626
6797
  const kind = getPreviewLocalLinkKind(context.href);
6627
6798
  if (kind === "pdf") {
6628
- openPreviewPdfLink(context.href, context.title, context);
6799
+ void openPreviewPdfLink(context.href, context.title, context).catch((error) => {
6800
+ setStatus((error && error.message) ? error.message : String(error || "Could not open linked PDF."), "warning");
6801
+ });
6629
6802
  return;
6630
6803
  }
6631
6804
  if (kind === "image") {
@@ -6635,9 +6808,8 @@
6635
6808
  return;
6636
6809
  }
6637
6810
  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
- });
6811
+ const point = getHtmlArtifactLocalLinkClientPoint(record, data);
6812
+ void showPreviewLinkMenu(null, point, context);
6641
6813
  return;
6642
6814
  }
6643
6815
  setStatus("Right-click this local HTML preview link for file actions.", "warning");
@@ -7041,9 +7213,12 @@
7041
7213
  mathRenderItemCount: 0,
7042
7214
  resourceResolveBatchCount: 0,
7043
7215
  resourceResolveItemCount: 0,
7216
+ blockedResources: new Map(),
7044
7217
  });
7045
7218
 
7046
7219
  targetEl.appendChild(shell);
7220
+ clearWatchedPreviewRenderError();
7221
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, html);
7047
7222
 
7048
7223
  if (pane === "response") {
7049
7224
  applyPendingResponseScrollReset();
@@ -7927,6 +8102,13 @@
7927
8102
  const target = event && event.target;
7928
8103
  if (!(target instanceof Element)) return;
7929
8104
 
8105
+ const blockedMediaButton = target.closest("button.studio-blocked-media-allow");
8106
+ if (blockedMediaButton) {
8107
+ consumeStudioPreviewMediaEvent(event);
8108
+ void handleStudioBlockedMediaAllowButton(blockedMediaButton);
8109
+ return;
8110
+ }
8111
+
7930
8112
  const imageEl = target.closest("img.studio-image-focus-target");
7931
8113
  if (imageEl) {
7932
8114
  consumeStudioPreviewMediaEvent(event);
@@ -8400,7 +8582,53 @@
8400
8582
  });
8401
8583
  }
8402
8584
 
8403
- function createStudioPdfCard(block, useEditorResourceContext) {
8585
+ function createStudioPdfStatusCard(block, content) {
8586
+ const options = block && block.options ? block.options : {};
8587
+ const path = String(options.path || "").trim();
8588
+ const title = String(options.title || path || "Embedded PDF").trim();
8589
+ const caption = String(options.caption || "").trim();
8590
+ const card = document.createElement("figure");
8591
+ card.className = "studio-pdf-card studio-pdf-card-status";
8592
+ const header = document.createElement("figcaption");
8593
+ header.className = "studio-pdf-card-header";
8594
+ const titleEl = document.createElement("div");
8595
+ titleEl.className = "studio-pdf-card-title";
8596
+ titleEl.textContent = title;
8597
+ header.appendChild(titleEl);
8598
+ card.appendChild(header);
8599
+ if (caption) {
8600
+ const captionEl = document.createElement("div");
8601
+ captionEl.className = "studio-pdf-card-caption";
8602
+ captionEl.textContent = caption;
8603
+ card.appendChild(captionEl);
8604
+ }
8605
+ if (content instanceof Element) {
8606
+ card.appendChild(content);
8607
+ } else {
8608
+ const errorEl = document.createElement("div");
8609
+ errorEl.className = "studio-pdf-card-error";
8610
+ errorEl.textContent = String(content || "PDF resource unavailable.");
8611
+ card.appendChild(errorEl);
8612
+ }
8613
+ return card;
8614
+ }
8615
+
8616
+ async function createStudioPdfCard(block, useEditorResourceContext) {
8617
+ const options = block && block.options ? block.options : {};
8618
+ const path = String(options.path || "").trim();
8619
+ if (!path) return createAuthorizedStudioPdfCard(block, useEditorResourceContext);
8620
+ const resourceQuery = buildStudioPdfResourceQuery(options, useEditorResourceContext);
8621
+ try {
8622
+ await fetchPreviewLocalLink("resolve", path, resourceQuery, { skipGrantPrompt: true });
8623
+ return createAuthorizedStudioPdfCard(block, useEditorResourceContext);
8624
+ } catch (error) {
8625
+ const notice = createStudioBlockedMediaNotice(error, "pdf", path);
8626
+ if (notice) return createStudioPdfStatusCard(block, notice);
8627
+ return createStudioPdfStatusCard(block, error && error.message ? error.message : "PDF resource unavailable.");
8628
+ }
8629
+ }
8630
+
8631
+ function createAuthorizedStudioPdfCard(block, useEditorResourceContext) {
8404
8632
  const options = block && block.options ? block.options : {};
8405
8633
  const path = String(options.path || "").trim();
8406
8634
  const title = String(options.title || path || "Embedded PDF").trim();
@@ -8563,17 +8791,17 @@
8563
8791
  return card;
8564
8792
  }
8565
8793
 
8566
- function renderStudioPdfBlocksInElement(targetEl, blocks, useEditorResourceContext) {
8794
+ async function renderStudioPdfBlocksInElement(targetEl, blocks, useEditorResourceContext) {
8567
8795
  if (!targetEl || !Array.isArray(blocks) || blocks.length === 0) return;
8568
8796
  const candidates = Array.from(targetEl.querySelectorAll("p, pre, div"));
8569
- blocks.forEach((block) => {
8797
+ for (const block of blocks) {
8570
8798
  const placeholder = block && block.placeholder ? block.placeholder : "";
8571
- if (!placeholder) return;
8799
+ if (!placeholder) continue;
8572
8800
  const match = candidates.find((el) => String(el.textContent || "").trim() === placeholder);
8573
- if (match && match.parentNode) {
8574
- match.replaceWith(createStudioPdfCard(block, useEditorResourceContext));
8575
- }
8576
- });
8801
+ if (!match || !match.parentNode) continue;
8802
+ const card = await createStudioPdfCard(block, useEditorResourceContext);
8803
+ if (match.isConnected && match.parentNode) match.replaceWith(card);
8804
+ }
8577
8805
  }
8578
8806
 
8579
8807
  function sanitizeRenderedHtml(html, markdown, options) {
@@ -9145,13 +9373,206 @@
9145
9373
  targetEl.appendChild(el);
9146
9374
  }
9147
9375
 
9376
+ function createStudioBlockedMediaNotice(error, mediaKind, sourceLabel) {
9377
+ return createStudioBlockedMediaNoticeForRequest(getStudioResourceGrantRequest(error), mediaKind, sourceLabel);
9378
+ }
9379
+
9380
+ function createStudioBlockedMediaNoticeForRequest(grantRequest, mediaKind, sourceLabel) {
9381
+ if (!grantRequest) return null;
9382
+ const kind = mediaKind === "pdf" ? "PDF" : "image";
9383
+ const notice = document.createElement("span");
9384
+ notice.className = "studio-blocked-media studio-blocked-media-" + (mediaKind === "pdf" ? "pdf" : "image");
9385
+ notice.setAttribute("role", "note");
9386
+
9387
+ const text = document.createElement("span");
9388
+ text.className = "studio-blocked-media-text";
9389
+ const title = document.createElement("strong");
9390
+ title.textContent = "Local " + kind + " blocked";
9391
+ const detail = document.createElement("span");
9392
+ detail.textContent = "This media is outside the locations allowed for the current Studio session.";
9393
+ const path = document.createElement("code");
9394
+ path.textContent = grantRequest.path || String(sourceLabel || "local media");
9395
+ path.title = "Path on the computer running Pi";
9396
+ text.appendChild(title);
9397
+ text.appendChild(detail);
9398
+ text.appendChild(path);
9399
+ notice.appendChild(text);
9400
+
9401
+ const allowButton = document.createElement("button");
9402
+ allowButton.type = "button";
9403
+ allowButton.className = "studio-blocked-media-allow";
9404
+ allowButton.textContent = "Allow local " + kind + "…";
9405
+ allowButton.title = "Choose whether to allow only this file or its containing folder for the current Studio session.";
9406
+ allowButton.dataset.studioBlockedMediaPath = grantRequest.path;
9407
+ allowButton.dataset.studioBlockedMediaDirectory = grantRequest.directoryPath;
9408
+ allowButton.dataset.studioBlockedMediaLabel = grantRequest.label;
9409
+ allowButton.dataset.studioBlockedMediaKind = mediaKind === "pdf" ? "pdf" : "image";
9410
+ notice.appendChild(allowButton);
9411
+ return notice;
9412
+ }
9413
+
9414
+ function replaceStudioBlockedMediaElement(element, source, error, mediaKind) {
9415
+ if (!element || !element.parentNode) return false;
9416
+ const notice = createStudioBlockedMediaNotice(error, mediaKind, source);
9417
+ if (!notice) return false;
9418
+ element.replaceWith(notice);
9419
+ return true;
9420
+ }
9421
+
9422
+ function refreshStudioPassiveMediaAfterGrant() {
9423
+ renderSourcePreview({ previewDelayMs: 0 });
9424
+ if (rightView === "preview" || rightView === "editor-preview" || rightView === "side-questions") {
9425
+ renderActiveResult();
9426
+ }
9427
+ if (isQuizOpen()) renderQuizOverlay({ preserveScroll: true });
9428
+ }
9429
+
9430
+ async function handleStudioBlockedMediaAllowButton(button) {
9431
+ if (!(button instanceof HTMLButtonElement) || button.dataset.studioBlockedMediaBusy === "1") return false;
9432
+ const request = {
9433
+ path: String(button.dataset.studioBlockedMediaPath || "").trim(),
9434
+ directoryPath: String(button.dataset.studioBlockedMediaDirectory || "").trim(),
9435
+ label: String(button.dataset.studioBlockedMediaLabel || "local media").trim() || "local media",
9436
+ };
9437
+ if (!request.path || !request.directoryPath) {
9438
+ setStatus("Could not resolve this blocked media path.", "warning");
9439
+ return false;
9440
+ }
9441
+ button.dataset.studioBlockedMediaBusy = "1";
9442
+ button.disabled = true;
9443
+ const baselineText = button.textContent;
9444
+ button.textContent = "Choosing…";
9445
+ try {
9446
+ if (!(await requestStudioResourceGrant(request))) return false;
9447
+ refreshStudioPassiveMediaAfterGrant();
9448
+ return true;
9449
+ } catch (error) {
9450
+ setStatus("Could not allow local media: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
9451
+ return false;
9452
+ } finally {
9453
+ if (button.isConnected) {
9454
+ delete button.dataset.studioBlockedMediaBusy;
9455
+ button.disabled = false;
9456
+ button.textContent = baselineText;
9457
+ }
9458
+ }
9459
+ }
9460
+
9148
9461
  function hasMeaningfulPreviewContent(targetEl) {
9149
9462
  if (!targetEl || typeof targetEl.querySelector !== "function") return false;
9463
+ if (targetEl.dataset && targetEl.dataset.studioPreviewCommitted === "1") return true;
9150
9464
  if (targetEl.querySelector(".preview-loading")) return false;
9151
9465
  const text = typeof targetEl.textContent === "string" ? targetEl.textContent.trim() : "";
9152
9466
  return text.length > 0;
9153
9467
  }
9154
9468
 
9469
+ function getWatchedPreviewAnchorSignature(element) {
9470
+ if (!element || !element.tagName) return "";
9471
+ const text = String(element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
9472
+ if (!text) return "";
9473
+ return String(element.tagName).toLowerCase() + ":" + text;
9474
+ }
9475
+
9476
+ function captureWatchedPreviewReadingPosition(targetEl) {
9477
+ if (!isWatchedFilePreview || !targetEl || typeof targetEl.querySelectorAll !== "function") return null;
9478
+ const maxScroll = Math.max(0, Number(targetEl.scrollHeight || 0) - Number(targetEl.clientHeight || 0));
9479
+ const ratio = maxScroll > 0 ? Math.max(0, Math.min(1, Number(targetEl.scrollTop || 0) / maxScroll)) : 0;
9480
+ if (typeof targetEl.getBoundingClientRect !== "function") return { ratio };
9481
+ const targetRect = targetEl.getBoundingClientRect();
9482
+ const anchorLine = Number(targetRect.top || 0) + Math.max(20, Math.min(Number(targetEl.clientHeight || 0) * 0.22, 140));
9483
+ const candidates = Array.from(targetEl.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,pre,table,blockquote,figure"));
9484
+ let anchor = null;
9485
+ for (const candidate of candidates) {
9486
+ if (!candidate || typeof candidate.getBoundingClientRect !== "function") continue;
9487
+ const rect = candidate.getBoundingClientRect();
9488
+ if (Number(rect.bottom || rect.top || 0) >= anchorLine) {
9489
+ anchor = candidate;
9490
+ break;
9491
+ }
9492
+ }
9493
+ if (!anchor && candidates.length) anchor = candidates[candidates.length - 1];
9494
+ const signature = getWatchedPreviewAnchorSignature(anchor);
9495
+ if (!anchor || !signature) return { ratio };
9496
+ let occurrence = 0;
9497
+ for (const candidate of candidates) {
9498
+ if (candidate === anchor) break;
9499
+ if (getWatchedPreviewAnchorSignature(candidate) === signature) occurrence += 1;
9500
+ }
9501
+ const anchorRect = anchor.getBoundingClientRect();
9502
+ return {
9503
+ ratio,
9504
+ signature,
9505
+ occurrence,
9506
+ offset: Number(anchorRect.top || 0) - Number(targetRect.top || 0),
9507
+ };
9508
+ }
9509
+
9510
+ function restoreWatchedPreviewReadingPosition(targetEl, snapshot) {
9511
+ if (!isWatchedFilePreview || !targetEl || !snapshot) return;
9512
+ const candidates = typeof targetEl.querySelectorAll === "function"
9513
+ ? Array.from(targetEl.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,pre,table,blockquote,figure"))
9514
+ : [];
9515
+ const matching = snapshot.signature
9516
+ ? candidates.filter((candidate) => getWatchedPreviewAnchorSignature(candidate) === snapshot.signature)
9517
+ : [];
9518
+ const anchor = matching[Math.max(0, Number(snapshot.occurrence) || 0)] || null;
9519
+ if (anchor && typeof anchor.getBoundingClientRect === "function" && typeof targetEl.getBoundingClientRect === "function") {
9520
+ const targetRect = targetEl.getBoundingClientRect();
9521
+ const anchorRect = anchor.getBoundingClientRect();
9522
+ const delta = (Number(anchorRect.top || 0) - Number(targetRect.top || 0)) - (Number(snapshot.offset) || 0);
9523
+ targetEl.scrollTop = Math.max(0, Number(targetEl.scrollTop || 0) + delta);
9524
+ return;
9525
+ }
9526
+ const maxScroll = Math.max(0, Number(targetEl.scrollHeight || 0) - Number(targetEl.clientHeight || 0));
9527
+ targetEl.scrollTop = Math.max(0, Math.min(maxScroll, maxScroll * Math.max(0, Math.min(1, Number(snapshot.ratio) || 0))));
9528
+ }
9529
+
9530
+ function scheduleWatchedPreviewReadingPositionRestore(targetEl, renderedText) {
9531
+ if (!isWatchedFilePreview || !targetEl) return;
9532
+ const pane = targetEl === sourcePreviewEl ? "source" : (targetEl === critiqueViewEl ? "response" : "");
9533
+ if (!pane || !watchedFilePreviewReadingPositions[pane]) return;
9534
+ const pending = watchedFilePreviewReadingPositions[pane];
9535
+ if (String(pending.text || "") !== String(renderedText || "")) return;
9536
+ const snapshot = pending.snapshot;
9537
+ watchedFilePreviewReadingPositions[pane] = null;
9538
+ let restored = false;
9539
+ const applyRestore = () => {
9540
+ if (restored) return;
9541
+ restored = true;
9542
+ restoreWatchedPreviewReadingPosition(targetEl, snapshot);
9543
+ };
9544
+ if (typeof window.requestAnimationFrame === "function") {
9545
+ window.requestAnimationFrame(applyRestore);
9546
+ }
9547
+ // Hidden embedded/headless surfaces can suspend animation frames entirely.
9548
+ window.setTimeout(applyRestore, 80);
9549
+ }
9550
+
9551
+ function showWatchedPreviewRenderError(targetEl, message) {
9552
+ if (!isWatchedFilePreview || !targetEl || typeof targetEl.appendChild !== "function") return false;
9553
+ const existing = typeof targetEl.querySelector === "function" ? targetEl.querySelector(".studio-watched-preview-error") : null;
9554
+ if (existing && existing.remove) existing.remove();
9555
+ const notice = document.createElement("div");
9556
+ notice.className = "preview-warning studio-watched-preview-error";
9557
+ notice.setAttribute("role", "status");
9558
+ notice.appendChild(document.createTextNode("Could not render the latest disk revision; keeping the last good preview. " + String(message || "Preview renderer unavailable.") + " "));
9559
+ const retry = document.createElement("button");
9560
+ retry.type = "button";
9561
+ retry.textContent = "Retry";
9562
+ retry.addEventListener("click", () => renderActiveResult());
9563
+ notice.appendChild(retry);
9564
+ targetEl.appendChild(notice);
9565
+ return true;
9566
+ }
9567
+
9568
+ function clearWatchedPreviewRenderError() {
9569
+ if (!isWatchedFilePreview) return;
9570
+ const recovered = Boolean(watchedFilePreviewState.renderError);
9571
+ watchedFilePreviewState.renderError = "";
9572
+ updateSourceBadge();
9573
+ if (recovered) setStatus("Rendered the latest watched file revision.", "success");
9574
+ }
9575
+
9155
9576
  function beginPreviewRender(targetEl) {
9156
9577
  if (!targetEl || !targetEl.classList) return;
9157
9578
 
@@ -10451,6 +10872,55 @@
10451
10872
  });
10452
10873
  }
10453
10874
 
10875
+ async function hydrateStudioPreviewLocalMedia(targetEl, resourceContext) {
10876
+ const fetchResource = (resourceUrl, timeoutLabel) => fetchLocalPreviewResourceDataUrl(
10877
+ resourceContext,
10878
+ resourceUrl,
10879
+ RENDERED_PREVIEW_IMAGE_FETCH_TIMEOUT_MS,
10880
+ timeoutLabel,
10881
+ );
10882
+ await previewResourceHelpers.hydrateStudioPreviewLocalImages(
10883
+ targetEl,
10884
+ (resourceUrl) => fetchResource(resourceUrl, "Preview image load"),
10885
+ {
10886
+ onError: (imageEl, resourceUrl, error) => {
10887
+ replaceStudioBlockedMediaElement(imageEl, resourceUrl, error, "image");
10888
+ },
10889
+ },
10890
+ );
10891
+ await previewResourceHelpers.hydrateStudioPreviewLocalPdfEmbeds(
10892
+ targetEl,
10893
+ (resourceUrl) => fetchResource(resourceUrl, "Preview PDF load"),
10894
+ {
10895
+ onError: (embedEl, resourceUrl, error) => {
10896
+ replaceStudioBlockedMediaElement(embedEl, resourceUrl, error, "pdf");
10897
+ },
10898
+ },
10899
+ );
10900
+ }
10901
+
10902
+ function isCurrentStudioPreviewRender(pane, nonce) {
10903
+ if (pane === "source") {
10904
+ return nonce === sourcePreviewRenderNonce && editorView === "preview";
10905
+ }
10906
+ return nonce === responsePreviewRenderNonce && (rightView === "preview" || rightView === "editor-preview");
10907
+ }
10908
+
10909
+ function createStudioPreviewStagingElement(targetEl) {
10910
+ const staging = document.createElement("div");
10911
+ staging.className = String(targetEl && targetEl.className ? targetEl.className : "rendered-markdown") + " studio-preview-staging";
10912
+ staging.setAttribute("aria-hidden", "true");
10913
+ staging.style.width = Math.max(320, Number(targetEl && targetEl.clientWidth) || 0) + "px";
10914
+ document.body.appendChild(staging);
10915
+ return staging;
10916
+ }
10917
+
10918
+ function commitStudioPreviewStagingElement(targetEl, staging) {
10919
+ const nodes = Array.from(staging.childNodes || []);
10920
+ targetEl.replaceChildren(...nodes);
10921
+ staging.remove();
10922
+ }
10923
+
10454
10924
  async function applyRenderedMarkdown(targetEl, markdown, pane, nonce) {
10455
10925
  const previewPrepared = annotationsEnabled
10456
10926
  ? prepareMarkdownForPandocPreview(markdown)
@@ -10461,42 +10931,44 @@
10461
10931
  };
10462
10932
  const pdfPrepared = prepareStudioPdfBlocksForPreview(previewPrepared.markdown);
10463
10933
  const previewResourceContext = getHtmlPreviewResourceContextOptions();
10934
+ let staging = null;
10935
+ let previewCommitted = false;
10936
+ const stillCurrent = () => isCurrentStudioPreviewRender(pane, nonce);
10937
+ const abandonIfStale = () => {
10938
+ if (stillCurrent()) return false;
10939
+ if (staging && staging.remove) staging.remove();
10940
+ staging = null;
10941
+ return true;
10942
+ };
10464
10943
 
10465
10944
  try {
10466
10945
  const renderedHtml = await renderMarkdownWithPandoc(pdfPrepared.markdown, {
10467
10946
  includeEditorLanguage: pane === "source" || rightView === "editor-preview",
10468
10947
  resourceContext: previewResourceContext,
10469
10948
  });
10470
-
10471
- if (pane === "source") {
10472
- if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
10473
- } else {
10474
- if (nonce !== responsePreviewRenderNonce || (rightView !== "preview" && rightView !== "editor-preview")) return;
10475
- }
10476
-
10477
- clearPreviewJumpHighlight(targetEl);
10478
- finishPreviewRender(targetEl);
10479
- 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);
10489
- applyPreviewAnnotationPlaceholdersToElement(targetEl, previewPrepared.placeholders);
10490
- await renderAnnotationMathInElement(targetEl);
10491
- decoratePdfEmbeds(targetEl);
10492
- await renderPdfPreviewsInElement(targetEl);
10493
- decoratePreviewPdfFigures(targetEl);
10949
+ if (abandonIfStale()) return;
10950
+
10951
+ staging = createStudioPreviewStagingElement(targetEl);
10952
+ staging.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown, previewFallbackOptions);
10953
+ await hydrateStudioPreviewLocalMedia(staging, previewResourceContext);
10954
+ if (abandonIfStale()) return;
10955
+ await renderStudioPdfBlocksInElement(staging, pdfPrepared.blocks, previewingEditorText);
10956
+ if (abandonIfStale()) return;
10957
+ applyPreviewAnnotationPlaceholdersToElement(staging, previewPrepared.placeholders);
10958
+ await renderAnnotationMathInElement(staging);
10959
+ if (abandonIfStale()) return;
10960
+ decoratePdfEmbeds(staging);
10961
+ await renderPdfPreviewsInElement(staging);
10962
+ if (abandonIfStale()) return;
10963
+ decoratePreviewPdfFigures(staging);
10494
10964
  const annotationMode = (pane === "source" || pane === "response")
10495
10965
  ? (annotationsEnabled ? "highlight" : "hide")
10496
10966
  : "none";
10497
- applyAnnotationMarkersToElement(targetEl, annotationMode);
10498
- await renderMermaidInElement(targetEl);
10499
- await renderMathFallbackInElement(targetEl);
10967
+ applyAnnotationMarkersToElement(staging, annotationMode);
10968
+ await renderMermaidInElement(staging);
10969
+ if (abandonIfStale()) return;
10970
+ await renderMathFallbackInElement(staging);
10971
+ if (abandonIfStale()) return;
10500
10972
 
10501
10973
  const shouldDecoratePreviewComments = supportsPreviewCommentsForCurrentEditor()
10502
10974
  && (
@@ -10504,35 +10976,56 @@
10504
10976
  || (pane === "response" && rightView === "editor-preview")
10505
10977
  );
10506
10978
  if (shouldDecoratePreviewComments) {
10507
- decorateRenderedEditorPreviewComments(targetEl, sourceTextEl.value || "");
10979
+ decorateRenderedEditorPreviewComments(staging, sourceTextEl.value || "");
10508
10980
  }
10509
- decorateCopyablePreviewBlocks(targetEl);
10510
- decoratePreviewImages(targetEl);
10981
+ decorateCopyablePreviewBlocks(staging);
10982
+ decoratePreviewImages(staging);
10511
10983
 
10512
- // Warn if relative images are present but unlikely to resolve (non-file-backed content)
10984
+ // Warn if relative images are present but unlikely to resolve (non-file-backed content).
10513
10985
  if (!sourceState.path && !getCurrentResourceDirValue()) {
10514
10986
  var hasRelativeImages = /!\[.*?\]\((?!https?:\/\/|data:)[^)]+\)/.test(markdown || "");
10515
10987
  var hasLatexImages = /\\includegraphics/.test(markdown || "");
10516
10988
  if (hasRelativeImages || hasLatexImages) {
10517
- appendPreviewNotice(targetEl, "Images not displaying? Set working dir in the editor pane or open via /studio <path>.");
10989
+ appendPreviewNotice(staging, "Images not displaying? Set working dir in the editor pane or open via /studio <path>.");
10518
10990
  }
10519
10991
  }
10992
+ if (abandonIfStale()) return;
10520
10993
 
10994
+ clearPreviewJumpHighlight(targetEl);
10995
+ finishPreviewRender(targetEl);
10996
+ commitStudioPreviewStagingElement(targetEl, staging);
10997
+ staging = null;
10998
+ if (targetEl.dataset) targetEl.dataset.studioPreviewCommitted = "1";
10999
+ previewCommitted = true;
11000
+ if (shouldDecoratePreviewComments) updatePreviewCommentBlocksForElement(targetEl);
11001
+ clearWatchedPreviewRenderError();
11002
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, markdown);
10521
11003
  if (pane === "response") {
10522
11004
  applyPendingResponseScrollReset();
10523
11005
  scheduleResponsePaneRepaintNudge();
10524
11006
  }
10525
11007
  } catch (error) {
10526
- if (pane === "source") {
10527
- if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
10528
- } else {
10529
- if (nonce !== responsePreviewRenderNonce || (rightView !== "preview" && rightView !== "editor-preview")) return;
11008
+ if (staging && staging.remove) staging.remove();
11009
+ staging = null;
11010
+ if (previewCommitted) {
11011
+ console.error("Preview post-render update failed after the staged document was committed:", error);
11012
+ return;
10530
11013
  }
11014
+ if (!stillCurrent()) return;
10531
11015
 
10532
11016
  const detail = error && error.message ? error.message : String(error || "unknown error");
10533
11017
  clearPreviewJumpHighlight(targetEl);
10534
11018
  finishPreviewRender(targetEl);
11019
+ if (isWatchedFilePreview && hasMeaningfulPreviewContent(targetEl)) {
11020
+ watchedFilePreviewState.renderError = detail;
11021
+ showWatchedPreviewRenderError(targetEl, detail);
11022
+ updateSourceBadge();
11023
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, markdown);
11024
+ setStatus("Could not render the latest disk revision; keeping the last good preview.", "warning");
11025
+ return;
11026
+ }
10535
11027
  targetEl.innerHTML = buildPreviewErrorHtml("Preview renderer unavailable (" + detail + "). Showing plain markdown.", markdown, previewFallbackOptions);
11028
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, markdown);
10536
11029
  if (pane === "response") {
10537
11030
  applyPendingResponseScrollReset();
10538
11031
  scheduleResponsePaneRepaintNudge();
@@ -11355,71 +11848,96 @@
11355
11848
  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
11849
  }
11357
11850
 
11851
+ function buildFileBrowserEntryRowHtml(entry) {
11852
+ const type = entry && entry.type === "directory" ? "directory" : "file";
11853
+ const kind = (entry && entry.kind) || (type === "directory" ? "directory" : "other");
11854
+ const path = String((entry && entry.path) || "");
11855
+ const name = String((entry && entry.name) || basenameForStudioPath(path));
11856
+ const icon = type === "directory" ? "📁" : (kind === "pdf" ? "📄" : (kind === "image" ? "🖼️" : (kind === "text" || kind === "office" ? "📝" : "📦")));
11857
+ const metaParts = [getFileBrowserKindLabel(entry)];
11858
+ if (type === "file") metaParts.push(formatFileBrowserSize(entry && entry.size));
11859
+ const time = formatFileBrowserTime(entry && entry.mtimeMs);
11860
+ if (time) metaParts.push(time);
11861
+ const newTabAction = kind === "text" || kind === "office"
11862
+ ? "open-new"
11863
+ : ((kind === "pdf" || kind === "image") ? "open-preview-new" : "");
11864
+ const newTabLabel = kind === "text"
11865
+ ? "Open file-backed tab"
11866
+ : (kind === "office" ? "Convert tab" : ((kind === "pdf" || kind === "image") ? "Preview tab" : "New tab"));
11867
+ const newTabTitle = kind === "text"
11868
+ ? "Open this file-backed document in a new refreshable editor tab. Save editor and Refresh from disk will use this file."
11869
+ : (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."));
11870
+ const newTabButton = newTabAction
11871
+ ? "<button type='button' data-files-action='" + escapeHtml(newTabAction) + "' data-files-path='" + escapeHtml(path) + "' data-files-kind='" + escapeHtml(kind) + "' title='" + escapeHtml(newTabTitle) + "'>" + escapeHtml(newTabLabel) + "</button>"
11872
+ : "";
11873
+ const watchButton = kind === "text"
11874
+ ? "<button type='button' data-files-action='watch-new' data-files-path='" + escapeHtml(path) + "' data-files-kind='text' title='Open a read-only rendered preview that follows this file on disk.'>Preview (follow)</button>"
11875
+ : "";
11876
+ const openTitle = type === "directory"
11877
+ ? "Open folder"
11878
+ : (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"))));
11879
+ return "<div class='files-row files-row-" + escapeHtml(type) + " files-kind-" + escapeHtml(kind) + "'>"
11880
+ + "<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) + "'>"
11881
+ + "<span class='files-icon' aria-hidden='true'>" + icon + "</span>"
11882
+ + "<span class='files-name'>" + escapeHtml(name) + "</span>"
11883
+ + "<span class='files-meta'>" + escapeHtml(metaParts.filter(Boolean).join(" · ")) + "</span>"
11884
+ + "</button>"
11885
+ + "<span class='files-actions'>"
11886
+ + watchButton
11887
+ + newTabButton
11888
+ + "<button type='button' data-files-action='copy-path' data-files-path='" + escapeHtml(path) + "'>Copy path</button>"
11889
+ + (type === "file" ? "<button type='button' data-files-action='reveal' data-files-path='" + escapeHtml(path) + "'>Reveal</button>" : "")
11890
+ + "</span>"
11891
+ + "</div>";
11892
+ }
11893
+
11894
+ function buildFileBrowserLocationSelectHtml(state) {
11895
+ const locations = Array.isArray(state && state.locations) ? state.locations : [];
11896
+ if (!locations.length) return "<span class='files-no-locations'>No allowed folders</span>";
11897
+ const rootDir = String((state && state.rootDir) || "");
11898
+ const options = locations.map((location) => {
11899
+ const path = String((location && location.path) || "");
11900
+ const label = String((location && location.label) || basenameForStudioPath(path) || path);
11901
+ return "<option value='" + escapeHtml(path) + "'" + (path === rootDir ? " selected" : "") + ">" + escapeHtml(label + " — " + path) + "</option>";
11902
+ }).join("");
11903
+ 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>";
11904
+ }
11905
+
11358
11906
  function buildFileBrowserPanelHtml() {
11359
11907
  const state = fileBrowserState || {};
11360
11908
  const entries = Array.isArray(state.entries) ? state.entries : [];
11909
+ const exactFiles = Array.isArray(state.exactFiles) ? state.exactFiles : [];
11361
11910
  const currentDir = state.currentDir || "";
11362
11911
  const rootDir = state.rootDir || "";
11363
11912
  const relativeDir = state.relativeDir || ".";
11364
11913
  const parentDisabled = state.parentDir ? "" : " disabled";
11365
11914
  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("")
11915
+ ? entries.map(buildFileBrowserEntryRowHtml).join("")
11403
11916
  : "<div class='files-empty'>" + (state.loading ? "Loading files…" : "This folder is empty.") + "</div>";
11917
+ const exactRows = exactFiles.map(buildFileBrowserEntryRowHtml).join("");
11404
11918
  const notices = [];
11405
11919
  if (state.error) notices.push("<div class='files-notice files-notice-error'>" + escapeHtml(state.error) + "</div>");
11920
+ 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
11921
  if (state.omitted) notices.push("<div class='files-notice'>" + escapeHtml(String(state.omitted)) + " item" + (state.omitted === 1 ? "" : "s") + " omitted.</div>");
11407
11922
  if (state.omittedIgnored) notices.push("<div class='files-notice'>" + escapeHtml(String(state.omittedIgnored)) + " heavy/cache folder" + (state.omittedIgnored === 1 ? "" : "s") + " hidden.</div>");
11408
11923
  return "<div class='files-panel'>"
11409
11924
  + "<div class='files-toolbar'>"
11410
11925
  + "<div class='files-path-group'><span class='files-label'>Files</span><span class='files-path' title='" + escapeHtml(currentDir) + "'>" + escapeHtml(relativeDir || ".") + "</span></div>"
11411
11926
  + "<div class='files-toolbar-actions'>"
11927
+ + buildFileBrowserLocationSelectHtml(state)
11412
11928
  + buildFileBrowserSortSelectHtml()
11929
+ + "<button type='button' data-files-action='allow-folder'>Allow folder…</button>"
11413
11930
  + "<button type='button' data-files-action='parent'" + parentDisabled + ">Parent</button>"
11414
11931
  + "<button type='button' data-files-action='refresh'>Refresh</button>"
11415
11932
  + (currentDir ? "<button type='button' data-files-action='copy-current' data-files-path='" + escapeHtml(currentDir) + "'>Copy path</button>" : "")
11416
11933
  + (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>" : "")
11934
+ + (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
11935
  + (rootDir ? "<button type='button' data-files-action='copy-root' data-files-path='" + escapeHtml(rootDir) + "'>Copy root</button>" : "")
11419
11936
  + "</div>"
11420
11937
  + "</div>"
11421
- + "<div class='files-subtitle'>Root: <span title='" + escapeHtml(rootDir) + "'>" + escapeHtml(rootDir || "current Studio directory") + "</span></div>"
11938
+ + "<div class='files-subtitle'>Allowed root on the computer running Pi: <span title='" + escapeHtml(rootDir) + "'>" + escapeHtml(rootDir || "none selected") + "</span></div>"
11422
11939
  + notices.join("")
11940
+ + (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
11941
  + "<div class='files-list' role='list'>" + rows + "</div>"
11424
11942
  + "</div>";
11425
11943
  }
@@ -11434,6 +11952,9 @@
11434
11952
  relativeDir: "",
11435
11953
  parentDir: null,
11436
11954
  entries: [],
11955
+ exactFiles: [],
11956
+ locations: [],
11957
+ grantRequiredPath: "",
11437
11958
  omitted: 0,
11438
11959
  omittedIgnored: 0,
11439
11960
  sort: fileBrowserSortMode,
@@ -11455,12 +11976,17 @@
11455
11976
  async function loadFileBrowserDirectory(dir, options) {
11456
11977
  const context = getHtmlPreviewResourceContextOptions();
11457
11978
  const contextKey = getFileBrowserContextKey();
11979
+ const config = options && typeof options === "object" ? options : {};
11980
+ const requestedRoot = Object.prototype.hasOwnProperty.call(config, "root")
11981
+ ? normalizeStudioResourceDirValue(config.root || "")
11982
+ : normalizeStudioResourceDirValue(fileBrowserState.rootDir || "");
11458
11983
  const nonce = ++fileBrowserLoadNonce;
11459
11984
  fileBrowserState = {
11460
11985
  ...fileBrowserState,
11461
11986
  contextKey,
11462
11987
  loading: true,
11463
11988
  error: "",
11989
+ grantRequiredPath: "",
11464
11990
  };
11465
11991
  if (rightView === "files") {
11466
11992
  finishPreviewRender(critiqueViewEl);
@@ -11469,6 +11995,7 @@
11469
11995
  try {
11470
11996
  const query = {};
11471
11997
  if (dir) query.dir = String(dir);
11998
+ if (requestedRoot) query.root = requestedRoot;
11472
11999
  if (context.sourcePath) query.sourcePath = context.sourcePath;
11473
12000
  if (context.resourceDir) query.resourceDir = context.resourceDir;
11474
12001
  query.sort = normalizeFileBrowserSortMode(fileBrowserSortMode);
@@ -11481,6 +12008,9 @@
11481
12008
  relativeDir: typeof payload.relativeDir === "string" ? payload.relativeDir : ".",
11482
12009
  parentDir: typeof payload.parentDir === "string" ? payload.parentDir : null,
11483
12010
  entries: Array.isArray(payload.entries) ? payload.entries : [],
12011
+ exactFiles: Array.isArray(payload.exactFiles) ? payload.exactFiles : [],
12012
+ locations: Array.isArray(payload.locations) ? payload.locations : [],
12013
+ grantRequiredPath: "",
11484
12014
  omitted: Number(payload.omitted) || 0,
11485
12015
  omittedIgnored: Number(payload.omittedIgnored) || 0,
11486
12016
  sort: normalizeFileBrowserSortMode(payload.sort || fileBrowserSortMode),
@@ -11494,11 +12024,18 @@
11494
12024
  critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
11495
12025
  scheduleResponsePaneRepaintNudge();
11496
12026
  }
11497
- if (options && options.user) setStatus("Loaded file list.", "success");
12027
+ if (config.user) setStatus("Loaded file list.", "success");
11498
12028
  } catch (error) {
11499
12029
  if (nonce !== fileBrowserLoadNonce) return;
12030
+ const errorPayload = error && error.studioPayload && typeof error.studioPayload === "object" ? error.studioPayload : null;
12031
+ const grantRequiredPath = errorPayload && errorPayload.code === "studio-resource-directory-grant-required" && typeof errorPayload.directoryPath === "string"
12032
+ ? normalizeStudioResourceDirValue(errorPayload.directoryPath)
12033
+ : "";
11500
12034
  fileBrowserState = {
11501
12035
  ...fileBrowserState,
12036
+ exactFiles: errorPayload && Array.isArray(errorPayload.exactFiles) ? errorPayload.exactFiles : fileBrowserState.exactFiles,
12037
+ locations: errorPayload && Array.isArray(errorPayload.locations) ? errorPayload.locations : fileBrowserState.locations,
12038
+ grantRequiredPath,
11502
12039
  loading: false,
11503
12040
  error: (error && error.message) ? error.message : String(error || "Could not load files."),
11504
12041
  loaded: true,
@@ -11520,7 +12057,7 @@
11520
12057
  function ensureCurrentEditorFileBackedFromFilesPath(path) {
11521
12058
  const cleanPath = stripPreviewLocalLinkUrlSuffix(path || "").trim();
11522
12059
  if (!isLikelyAbsoluteStudioPath(cleanPath)) return;
11523
- if (sourceState && sourceState.path === cleanPath) return;
12060
+ if (sourceState && sourceState.path) return;
11524
12061
  const resourceDir = normalizeStudioResourceDirValue(fileBrowserState.rootDir || getCurrentResourceDirValue() || dirnameForDisplayPath(cleanPath));
11525
12062
  if (resourceDirInput && resourceDir) resourceDirInput.value = resourceDir;
11526
12063
  setSourceState({
@@ -11528,7 +12065,7 @@
11528
12065
  label: sourceState && sourceState.label && sourceState.label !== "blank" ? sourceState.label : basenameForStudioPath(cleanPath),
11529
12066
  path: cleanPath,
11530
12067
  });
11531
- markFileBackedBaseline(sourceTextEl.value);
12068
+ markFileBackedBaseline(sourceTextEl.value, null);
11532
12069
  }
11533
12070
 
11534
12071
  async function openFileBrowserEntry(path, kind) {
@@ -11546,7 +12083,7 @@
11546
12083
  return;
11547
12084
  }
11548
12085
  if (kind === "pdf") {
11549
- openPreviewPdfLink(path, path, context);
12086
+ await openPreviewPdfLink(path, path, context);
11550
12087
  return;
11551
12088
  }
11552
12089
  if (kind === "image") {
@@ -11556,6 +12093,34 @@
11556
12093
  setStatus("No Studio preview for this file type. Use Copy path or Reveal.", "warning");
11557
12094
  }
11558
12095
 
12096
+ async function allowFileBrowserFolder() {
12097
+ const context = getHtmlPreviewResourceContextOptions();
12098
+ const suggested = normalizeStudioResourceDirValue(
12099
+ fileBrowserState.grantRequiredPath
12100
+ || fileBrowserState.currentDir
12101
+ || fileBrowserState.rootDir
12102
+ || context.resourceDir
12103
+ || (context.sourcePath ? dirnameForDisplayPath(context.sourcePath) : "")
12104
+ || "./",
12105
+ );
12106
+ const path = await requestStudioTextInput(
12107
+ "Allow a folder on the computer running Pi for this Studio session:",
12108
+ suggested,
12109
+ { title: "Allow folder", confirmLabel: "Allow folder", inputLabel: "Folder path" },
12110
+ );
12111
+ if (!path) return;
12112
+ const payload = await fetchStudioJson("/resource-grants", {
12113
+ method: "POST",
12114
+ body: JSON.stringify({ grantKind: "directory", path }),
12115
+ });
12116
+ const grantedPath = payload && payload.grant && payload.grant.kind === "directory" && typeof payload.grant.path === "string"
12117
+ ? payload.grant.path
12118
+ : "";
12119
+ if (!grantedPath) throw new Error("Studio did not return the allowed folder.");
12120
+ await loadFileBrowserDirectory("", { root: grantedPath });
12121
+ setStatus(typeof payload.message === "string" ? payload.message : "Allowed folder for this Studio session.", "success");
12122
+ }
12123
+
11559
12124
  function setFileBrowserCurrentDirectoryAsWorkingDir(path) {
11560
12125
  const nextDir = normalizeStudioResourceDirValue(path || fileBrowserState.currentDir || "");
11561
12126
  if (!nextDir) {
@@ -11577,6 +12142,7 @@
11577
12142
  }
11578
12143
  const context = getHtmlPreviewResourceContextOptions();
11579
12144
  const body = { dir: targetDir };
12145
+ if (fileBrowserState.rootDir) body.root = fileBrowserState.rootDir;
11580
12146
  if (context.sourcePath) body.sourcePath = context.sourcePath;
11581
12147
  if (context.resourceDir) body.resourceDir = context.resourceDir;
11582
12148
  const payload = await fetchStudioJson("/file-browser-open", {
@@ -11589,6 +12155,14 @@
11589
12155
  async function handleFilesPaneChange(event) {
11590
12156
  if (rightView !== "files") return;
11591
12157
  const target = event.target;
12158
+ const locationSelect = target instanceof Element ? target.closest("[data-files-location]") : null;
12159
+ if (locationSelect && "value" in locationSelect) {
12160
+ const nextRoot = normalizeStudioResourceDirValue(locationSelect.value || "");
12161
+ if (nextRoot && nextRoot !== fileBrowserState.rootDir) {
12162
+ await loadFileBrowserDirectory("", { root: nextRoot, user: true });
12163
+ }
12164
+ return;
12165
+ }
11592
12166
  const sortSelect = target instanceof Element ? target.closest("[data-files-sort]") : null;
11593
12167
  if (!sortSelect || !("value" in sortSelect)) return;
11594
12168
  const nextSort = writeFileBrowserSortMode(sortSelect.value);
@@ -11607,6 +12181,10 @@
11607
12181
  const path = actionEl.getAttribute("data-files-path") || "";
11608
12182
  const kind = actionEl.getAttribute("data-files-kind") || getPreviewLocalLinkKind(path);
11609
12183
  try {
12184
+ if (action === "allow-folder") {
12185
+ await allowFileBrowserFolder();
12186
+ return;
12187
+ }
11610
12188
  if (action === "parent") {
11611
12189
  if (fileBrowserState.parentDir) await loadFileBrowserDirectory(fileBrowserState.parentDir, { user: true });
11612
12190
  return;
@@ -11624,17 +12202,13 @@
11624
12202
  return;
11625
12203
  }
11626
12204
  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
12205
  await openPreviewDocumentInNewEditor(path, getFileBrowserLocalLinkContext());
11636
12206
  return;
11637
12207
  }
12208
+ if (action === "watch-new") {
12209
+ await openPreviewDocumentInWatchedPreview(path, getFileBrowserLocalLinkContext());
12210
+ return;
12211
+ }
11638
12212
  if (action === "open-preview-new") {
11639
12213
  await openPreviewResourceInNewEditor(path, getFileBrowserLocalLinkContext());
11640
12214
  return;
@@ -12345,6 +12919,33 @@
12345
12919
  };
12346
12920
  }
12347
12921
 
12922
+ async function ensureSideQuestionContextRootAuthorized(context) {
12923
+ if (!context || context.gatherScope === "none") return { contextRoot: "" };
12924
+ const body = JSON.stringify({
12925
+ gatherScope: context.gatherScope,
12926
+ sourcePath: context.sourcePath,
12927
+ resourceDir: context.resourceDir,
12928
+ contextPath: context.contextPath,
12929
+ });
12930
+ const check = () => fetchStudioJson("/side-question-context-root", { method: "POST", body });
12931
+ try {
12932
+ return await check();
12933
+ } catch (error) {
12934
+ const request = getStudioDirectoryGrantRequest(error);
12935
+ if (!request) throw error;
12936
+ const allowed = await requestStudioDirectoryGrant(request, {
12937
+ 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?",
12938
+ cancelStatus: "Side-question folder access cancelled.",
12939
+ });
12940
+ if (!allowed) {
12941
+ const cancelled = new Error("Side-question folder access cancelled.");
12942
+ cancelled.studioCancelled = true;
12943
+ throw cancelled;
12944
+ }
12945
+ return check();
12946
+ }
12947
+ }
12948
+
12348
12949
  async function renderSideQuestionMarkdownToHtml(markdown) {
12349
12950
  const source = String(markdown || "");
12350
12951
  if (sideQuestionMarkdownRenderCache.has(source)) return sideQuestionMarkdownRenderCache.get(source);
@@ -12369,7 +12970,11 @@
12369
12970
  const html = await renderSideQuestionMarkdownToHtml(markdown);
12370
12971
  if (nonce !== sideQuestionPreviewRenderNonce || rightView !== "side-questions" || !critiqueViewEl.contains(target)) return;
12371
12972
  target.innerHTML = html;
12973
+ await hydrateStudioPreviewLocalMedia(target, getHtmlPreviewResourceContextOptions());
12372
12974
  await renderAnnotationMathInElement(target);
12975
+ decoratePdfEmbeds(target);
12976
+ await renderPdfPreviewsInElement(target);
12977
+ decoratePreviewPdfFigures(target);
12373
12978
  await renderMermaidInElement(target);
12374
12979
  await renderMathFallbackInElement(target);
12375
12980
  decorateCopyablePreviewBlocks(target);
@@ -12432,6 +13037,7 @@
12432
13037
  ], sideQuestionUi.thinking) + "</select></label>"
12433
13038
  + "</div>"
12434
13039
  + "<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>"
13040
+ + (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
13041
  + (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
13042
  + "<div class='side-question-checks'>"
12437
13043
  + "<label><input data-side-question-field='includeConversation' type='checkbox'" + (sideQuestionUi.includeConversation ? " checked" : "") + "> Include the current main conversation snapshot</label>"
@@ -12442,7 +13048,7 @@
12442
13048
  + "<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
13049
  + "<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
13050
  + (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>"
13051
+ + "<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
13052
  + "</div>";
12447
13053
  }
12448
13054
 
@@ -12513,7 +13119,8 @@
12513
13119
  }
12514
13120
  }
12515
13121
 
12516
- function submitSideQuestion() {
13122
+ async function submitSideQuestion() {
13123
+ if (sideQuestionContextGrantPending) return;
12517
13124
  const question = String(sideQuestionUi.draft || "").trim();
12518
13125
  if (!question) {
12519
13126
  setStatus("Enter a side question first.", "warning");
@@ -12532,11 +13139,31 @@
12532
13139
  if (sideQuestionState && sideQuestionState.threadId) {
12533
13140
  message.threadId = sideQuestionState.threadId;
12534
13141
  } else {
12535
- message.context = buildSideQuestionContextPayload();
12536
- if (message.context.gatherScope === "custom" && !String(message.context.contextPath || "").trim()) {
13142
+ const context = buildSideQuestionContextPayload();
13143
+ if (context.gatherScope === "custom" && !String(context.contextPath || "").trim()) {
12537
13144
  setStatus("Choose a custom context path first.", "warning");
12538
13145
  return;
12539
13146
  }
13147
+ if (context.gatherScope !== "none") {
13148
+ sideQuestionContextGrantPending = true;
13149
+ if (rightView === "side-questions") renderSideQuestionView();
13150
+ setStatus("Checking side-question folder access…", "warning");
13151
+ try {
13152
+ await ensureSideQuestionContextRootAuthorized(context);
13153
+ } catch (error) {
13154
+ if (!(error && error.studioCancelled)) {
13155
+ const messageText = error && error.message ? error.message : String(error || "Could not authorize side-question context.");
13156
+ if (!sideQuestionState) sideQuestionState = normalizeSideQuestionState(null);
13157
+ sideQuestionState.error = messageText;
13158
+ setStatus("Could not use related files: " + messageText, "warning");
13159
+ }
13160
+ return;
13161
+ } finally {
13162
+ sideQuestionContextGrantPending = false;
13163
+ if (rightView === "side-questions" && (!sideQuestionState || !sideQuestionState.threadId)) renderSideQuestionView();
13164
+ }
13165
+ }
13166
+ message.context = context;
12540
13167
  }
12541
13168
  if (!sendMessage(message)) return;
12542
13169
  sideQuestionUi.draft = "";
@@ -12652,7 +13279,7 @@
12652
13279
  event.preventDefault();
12653
13280
  const action = target.getAttribute("data-side-question-action");
12654
13281
  if (action === "ask") {
12655
- submitSideQuestion();
13282
+ await submitSideQuestion();
12656
13283
  } else if (action === "stop") {
12657
13284
  if (sideQuestionState && sideQuestionState.threadId && sideQuestionState.requestId) {
12658
13285
  sendMessage({ type: "side_question_cancel_request", threadId: sideQuestionState.threadId, requestId: sideQuestionState.requestId });
@@ -12705,7 +13332,7 @@
12705
13332
  if (field === "draft") sideQuestionUi.draft = target.value;
12706
13333
  if (field === "customPath") sideQuestionUi.customPath = target.value;
12707
13334
  const askButton = critiqueViewEl.querySelector("[data-side-question-action='ask']");
12708
- if (askButton) askButton.disabled = !isSideQuestionConnectionReady() || !sideQuestionUi.draft.trim() || (getSideQuestionGatherScope() === "custom" && !sideQuestionUi.customPath.trim());
13335
+ if (askButton) askButton.disabled = sideQuestionContextGrantPending || !isSideQuestionConnectionReady() || !sideQuestionUi.draft.trim() || (getSideQuestionGatherScope() === "custom" && !sideQuestionUi.customPath.trim());
12709
13336
  }
12710
13337
 
12711
13338
  function handleSideQuestionKeydown(event) {
@@ -12719,7 +13346,7 @@
12719
13346
  if (!submitShortcut) return;
12720
13347
  event.preventDefault();
12721
13348
  event.stopPropagation();
12722
- if (!sideQuestionState || sideQuestionState.status !== "running") submitSideQuestion();
13349
+ if (!sideQuestionState || sideQuestionState.status !== "running") void submitSideQuestion();
12723
13350
  }
12724
13351
 
12725
13352
  async function handleSideQuestionChange(event) {
@@ -13212,13 +13839,13 @@
13212
13839
  function updateSaveFileTooltip() {
13213
13840
  if (!saveOverBtn) return;
13214
13841
 
13215
- var effectivePath = getEffectiveSavePath();
13842
+ var effectivePath = sourceState && sourceState.path ? sourceState.path : "";
13216
13843
  if (effectivePath) {
13217
- saveOverBtn.title = "Overwrite file: " + effectivePath + " · Shortcut: Cmd/Ctrl+S.";
13844
+ saveOverBtn.title = "Save file when its disk revision still matches: " + effectivePath + " · Shortcut: Cmd/Ctrl+S.";
13218
13845
  return;
13219
13846
  }
13220
13847
 
13221
- saveOverBtn.title = "Save editor is available after opening a file, setting a working dir, or using Save editor as…. Shortcut: Cmd/Ctrl+S falls back to Save editor as… when needed.";
13848
+ saveOverBtn.title = "Save editor is available after opening a file-backed document. Use Save editor as for a new file.";
13222
13849
  }
13223
13850
 
13224
13851
  function updateRefreshFromDiskTooltip() {
@@ -13233,13 +13860,13 @@
13233
13860
  }
13234
13861
 
13235
13862
  function syncActionButtons() {
13236
- const canSaveOver = Boolean(getEffectiveSavePath());
13863
+ const canSaveOver = hasRefreshableFilePath();
13237
13864
  const canRefreshFromDisk = hasRefreshableFilePath();
13238
13865
 
13239
- fileInput.disabled = uiBusy;
13240
- if (importFileBtn) importFileBtn.disabled = uiBusy;
13241
- if (sourceBadgeEl) sourceBadgeEl.disabled = uiBusy;
13242
- if (sourceResetOriginBtn) sourceResetOriginBtn.disabled = uiBusy;
13866
+ fileInput.disabled = uiBusy || isWatchedFilePreview;
13867
+ if (importFileBtn) importFileBtn.disabled = uiBusy || isWatchedFilePreview;
13868
+ if (sourceBadgeEl) sourceBadgeEl.disabled = uiBusy || isWatchedFilePreview;
13869
+ if (sourceResetOriginBtn) sourceResetOriginBtn.disabled = uiBusy || isWatchedFilePreview;
13243
13870
  if (sourceOpenCurrentFileTabBtn) {
13244
13871
  sourceOpenCurrentFileTabBtn.disabled = uiBusy || !hasRefreshableFilePath();
13245
13872
  sourceOpenCurrentFileTabBtn.title = hasRefreshableFilePath()
@@ -13247,17 +13874,21 @@
13247
13874
  : "Available after opening a file-backed document.";
13248
13875
  }
13249
13876
  if (sourceOpenCurrentTextCopyTabBtn) sourceOpenCurrentTextCopyTabBtn.disabled = uiBusy || wsState !== "Ready" || !String(sourceTextEl.value || "").trim();
13250
- saveAsBtn.disabled = uiBusy;
13251
- saveOverBtn.disabled = uiBusy || !canSaveOver;
13252
- if (refreshFromDiskBtn) refreshFromDiskBtn.disabled = uiBusy || !canRefreshFromDisk;
13253
- if (clearWorkspaceBtn) clearWorkspaceBtn.disabled = uiBusy;
13877
+ saveAsBtn.disabled = uiBusy || isWatchedFilePreview;
13878
+ saveOverBtn.disabled = uiBusy || isWatchedFilePreview || !canSaveOver;
13879
+ if (refreshFromDiskBtn) refreshFromDiskBtn.disabled = uiBusy || isWatchedFilePreview || !canRefreshFromDisk;
13880
+ if (clearWorkspaceBtn) clearWorkspaceBtn.disabled = uiBusy || isWatchedFilePreview;
13254
13881
  sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
13255
- if (getEditorBtn) getEditorBtn.disabled = uiBusy;
13882
+ if (getEditorBtn) getEditorBtn.disabled = uiBusy || isWatchedFilePreview;
13883
+ if (watchedOpenEditableBtn) {
13884
+ watchedOpenEditableBtn.hidden = !isWatchedFilePreview;
13885
+ watchedOpenEditableBtn.disabled = uiBusy || !watchedFilePreviewState.path;
13886
+ }
13256
13887
  syncRunAndCritiqueButtons();
13257
13888
  copyDraftBtn.disabled = uiBusy;
13258
13889
  if (suggestCompletionBtn) {
13259
13890
  const hasSuggestionForCurrentText = Boolean(completionSuggestionState && sourceTextEl && sourceTextEl.value === completionSuggestionState.baseText);
13260
- suggestCompletionBtn.disabled = wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
13891
+ suggestCompletionBtn.disabled = isWatchedFilePreview || wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
13261
13892
  suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Stop" : (hasSuggestionForCurrentText ? "Try another" : "Suggest");
13262
13893
  suggestCompletionBtn.title = completionSuggestionInFlight
13263
13894
  ? "Stop the current suggestion request."
@@ -13273,15 +13904,15 @@
13273
13904
  if (highlightSelect) highlightSelect.disabled = uiBusy;
13274
13905
  if (lineNumbersSelect) lineNumbersSelect.disabled = uiBusy;
13275
13906
  if (annotationModeSelect) annotationModeSelect.disabled = uiBusy;
13276
- if (saveAnnotatedBtn) saveAnnotatedBtn.disabled = uiBusy;
13277
- if (stripAnnotationsBtn) stripAnnotationsBtn.disabled = uiBusy || !hasAnnotationMarkers(sourceTextEl.value);
13907
+ if (saveAnnotatedBtn) saveAnnotatedBtn.disabled = uiBusy || isWatchedFilePreview;
13908
+ if (stripAnnotationsBtn) stripAnnotationsBtn.disabled = uiBusy || isWatchedFilePreview || !hasAnnotationMarkers(sourceTextEl.value);
13278
13909
  if (compactBtn) compactBtn.disabled = isEditorOnlyMode || uiBusy || compactInProgress || wsState === "Disconnected";
13279
13910
  editorViewSelect.disabled = isEditorOnlyMode;
13280
13911
  syncRightViewModeOptions();
13281
- rightViewSelect.disabled = false;
13912
+ rightViewSelect.disabled = isWatchedFilePreview;
13282
13913
  followSelect.disabled = isEditorOnlyMode || uiBusy;
13283
13914
  if (responseHighlightSelect) responseHighlightSelect.disabled = isEditorOnlyMode || rightView !== "markdown";
13284
- insertHeaderBtn.disabled = uiBusy;
13915
+ insertHeaderBtn.disabled = uiBusy || isWatchedFilePreview;
13285
13916
  lensSelect.disabled = uiBusy || isEditorOnlyMode;
13286
13917
  updateSaveFileTooltip();
13287
13918
  updateRefreshFromDiskTooltip();
@@ -13298,9 +13929,14 @@
13298
13929
 
13299
13930
  function setSourceState(next, options) {
13300
13931
  const previousDescriptor = getCurrentStudioDocumentDescriptor();
13932
+ const previousPath = sourceState && sourceState.path ? sourceState.path : null;
13301
13933
  const previousQuartoPath = getCurrentStudioQuartoSourcePath();
13302
13934
  const previousPreviewResourceContext = getHtmlPreviewResourceContextOptions();
13303
13935
  const nextPath = next && next.path ? next.path : null;
13936
+ if (isWatchedFilePreview && watchedFilePreviewState.path && nextPath !== watchedFilePreviewState.path) {
13937
+ setStatus("This read-only preview remains bound to its watched file.", "warning");
13938
+ return false;
13939
+ }
13304
13940
  sourceState = {
13305
13941
  source: next && next.source ? next.source : "blank",
13306
13942
  label: next && next.label ? next.label : "blank",
@@ -13318,7 +13954,7 @@
13318
13954
  quartoPreviewActionRequestId = null;
13319
13955
  quartoPreviewLogVisible = false;
13320
13956
  }
13321
- if (!sourceState.path) {
13957
+ if (!sourceState.path || sourceState.path !== previousPath) {
13322
13958
  clearFileBackedBaseline();
13323
13959
  }
13324
13960
  syncRightViewModeOptions();
@@ -13425,6 +14061,7 @@
13425
14061
  version: 1,
13426
14062
  savedAt: lastWorkspacePersistenceSavedAt,
13427
14063
  sourceState: normalizeWorkspaceSourceState(sourceState),
14064
+ diskRevision: fileBackedDiskRevision,
13428
14065
  resourceDir: getCurrentResourceDirValue(),
13429
14066
  editorView,
13430
14067
  rightView: normalizeRightViewValue(rightView),
@@ -13460,7 +14097,7 @@
13460
14097
  }
13461
14098
 
13462
14099
  function persistWorkspaceStateNow(options) {
13463
- if (!workspacePersistenceReady) return;
14100
+ if (!workspacePersistenceReady || isWatchedFilePreview) return;
13464
14101
  try {
13465
14102
  const payload = buildWorkspacePersistencePayload();
13466
14103
  if (payload.text.length > STUDIO_WORKSPACE_MAX_TEXT_CHARS) {
@@ -13482,7 +14119,7 @@
13482
14119
  }
13483
14120
 
13484
14121
  function scheduleWorkspacePersistence() {
13485
- if (!workspacePersistenceReady || workspacePersistTimer !== null) return;
14122
+ if (!workspacePersistenceReady || isWatchedFilePreview || workspacePersistTimer !== null) return;
13486
14123
  workspacePersistTimer = window.setTimeout(() => {
13487
14124
  workspacePersistTimer = null;
13488
14125
  persistWorkspaceStateNow();
@@ -13490,6 +14127,7 @@
13490
14127
  }
13491
14128
 
13492
14129
  function flushWorkspacePersistence(options) {
14130
+ if (isWatchedFilePreview) return;
13493
14131
  if (workspacePersistTimer !== null) {
13494
14132
  window.clearTimeout(workspacePersistTimer);
13495
14133
  workspacePersistTimer = null;
@@ -13519,9 +14157,17 @@
13519
14157
  if (!shouldRestorePersistedWorkspaceState(state)) return false;
13520
14158
  const nextSourceState = normalizeWorkspaceSourceState(state.sourceState);
13521
14159
  const nextResourceDir = normalizeStudioResourceDirValue(typeof state.resourceDir === "string" ? state.resourceDir : "");
14160
+ const currentBaselineText = fileBackedBaselineText;
14161
+ const currentDiskRevision = fileBackedDiskRevision;
14162
+ const persistedDiskRevision = normalizeStudioDiskRevision(state.diskRevision);
13522
14163
  if (resourceDirInput) resourceDirInput.value = nextResourceDir;
13523
14164
  setEditorText(state.text, { preserveScroll: false, preserveSelection: false });
13524
14165
  setSourceState(nextSourceState);
14166
+ if (nextSourceState.path) {
14167
+ fileBackedBaselineText = currentBaselineText;
14168
+ fileBackedDiskRevision = persistedDiskRevision
14169
+ || (currentBaselineText !== null && state.text === currentBaselineText ? currentDiskRevision : null);
14170
+ }
13525
14171
  if (resourceDirInput && nextResourceDir) {
13526
14172
  resourceDirInput.value = nextResourceDir;
13527
14173
  updateSourceBadge();
@@ -13607,6 +14253,10 @@
13607
14253
  }
13608
14254
 
13609
14255
  function setEditorText(nextText, options) {
14256
+ if (isWatchedFilePreview && !(options && options.allowWatchedFileUpdate === true)) {
14257
+ setStatus("This preview is read-only and follows its watched file on disk.", "warning");
14258
+ return false;
14259
+ }
13610
14260
  const value = String(nextText || "");
13611
14261
  const preserveScroll = Boolean(options && options.preserveScroll);
13612
14262
  const preserveSelection = Boolean(options && options.preserveSelection);
@@ -13654,9 +14304,14 @@
13654
14304
  updateEditorSelectionCommentUi();
13655
14305
  updateOutlineUi();
13656
14306
  scheduleWorkspacePersistence();
14307
+ return true;
13657
14308
  }
13658
14309
 
13659
14310
  function applySourceTextEdit(nextText, selectionStart, selectionEnd) {
14311
+ if (isWatchedFilePreview) {
14312
+ setStatus("This preview is read-only and follows its watched file on disk.", "warning");
14313
+ return false;
14314
+ }
13660
14315
  const value = String(nextText || "");
13661
14316
  sourceTextEl.value = value;
13662
14317
  const maxIndex = value.length;
@@ -13668,6 +14323,7 @@
13668
14323
  if (editorView === "markdown") {
13669
14324
  scheduleEditorLineNumberRender();
13670
14325
  }
14326
+ return true;
13671
14327
  }
13672
14328
 
13673
14329
  function readCompletionSuggestionContextMode() {
@@ -14538,6 +15194,11 @@
14538
15194
  return launch.fail(message || "Studio could not prepare this tab. Return to the originating Studio page for details.");
14539
15195
  }
14540
15196
 
15197
+ function cancelPendingStudioTab(launch, message) {
15198
+ if (!launch || typeof launch.cancel !== "function") return false;
15199
+ return launch.cancel(message || "Studio tab launch was cancelled.");
15200
+ }
15201
+
14541
15202
  function abandonAllStudioTabLaunches(message) {
14542
15203
  const launches = Array.from(activeStudioTabLaunches);
14543
15204
  activeStudioTabLaunches.clear();
@@ -14616,7 +15277,10 @@
14616
15277
  const message = payload && typeof payload.error === "string"
14617
15278
  ? payload.error
14618
15279
  : (response.status + " " + response.statusText).trim();
14619
- throw new Error(message || (method + " " + pathname + " failed."));
15280
+ const requestError = new Error(message || (method + " " + pathname + " failed."));
15281
+ requestError.studioPayload = payload && typeof payload === "object" ? payload : null;
15282
+ requestError.studioStatus = response.status;
15283
+ throw requestError;
14620
15284
  }
14621
15285
  return payload;
14622
15286
  }
@@ -14649,6 +15313,7 @@
14649
15313
  ]);
14650
15314
  let previewLinkMenuEl = null;
14651
15315
  let activePreviewLinkContext = null;
15316
+ let previewLinkMenuRequestId = 0;
14652
15317
 
14653
15318
  function stripPreviewLocalLinkUrlSuffix(href) {
14654
15319
  const raw = String(href || "").trim();
@@ -14715,7 +15380,7 @@
14715
15380
  if (!raw || raw.charAt(0) === "#") return false;
14716
15381
  if (/^\/\//.test(raw)) return false;
14717
15382
  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;
15383
+ 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
15384
  return true;
14720
15385
  }
14721
15386
 
@@ -14785,36 +15450,53 @@
14785
15450
  menu.style.top = y + "px";
14786
15451
  }
14787
15452
 
14788
- function showPreviewLinkMenu(anchor, event, contextOverride) {
15453
+ async function showPreviewLinkMenu(anchor, event, contextOverride) {
14789
15454
  const href = String(anchor && anchor.getAttribute ? anchor.getAttribute("href") || "" : (contextOverride && contextOverride.href ? contextOverride.href : "")).trim();
14790
15455
  if (!isStudioLocalPreviewHref(href)) return false;
14791
15456
  const kind = getPreviewLocalLinkKind(href);
14792
- const menu = ensurePreviewLinkMenu();
14793
- menu.innerHTML = "";
14794
15457
  const linkContext = getEffectivePreviewLinkContext(contextOverride);
14795
- activePreviewLinkContext = {
15458
+ const nextContext = {
14796
15459
  href,
14797
15460
  title: String((contextOverride && contextOverride.title) || (anchor && anchor.textContent) || href || "local link").trim() || href,
14798
15461
  sourcePath: linkContext.sourcePath,
14799
15462
  resourceDir: linkContext.resourceDir,
14800
15463
  };
15464
+ const menuPoint = {
15465
+ clientX: event && event.clientX,
15466
+ clientY: event && event.clientY,
15467
+ };
15468
+ closePreviewLinkMenu();
15469
+ const menuRequestId = ++previewLinkMenuRequestId;
15470
+ try {
15471
+ await fetchPreviewLocalLink("resolve", href, nextContext);
15472
+ } catch (error) {
15473
+ if (!(error && error.studioCancelled)) {
15474
+ setStatus((error && error.message) ? error.message : String(error || "Could not inspect this local resource."), "warning");
15475
+ }
15476
+ return false;
15477
+ }
15478
+ if (menuRequestId !== previewLinkMenuRequestId) return false;
15479
+ const menu = ensurePreviewLinkMenu();
15480
+ menu.innerHTML = "";
15481
+ activePreviewLinkContext = nextContext;
14801
15482
  if (kind === "pdf") {
14802
15483
  appendPreviewLinkMenuButton(menu, "Open PDF preview", "open-pdf");
14803
15484
  appendPreviewLinkMenuButton(menu, "Open in new Studio tab", "open-preview-new");
14804
15485
  appendPreviewLinkMenuButton(menu, "Open in system viewer", "open-system");
14805
15486
  } else if (kind === "text") {
15487
+ appendPreviewLinkMenuButton(menu, "Preview file (follow changes)", "watch-new");
14806
15488
  appendPreviewLinkMenuButton(menu, "Open file tab", "open-new");
14807
- appendPreviewLinkMenuButton(menu, "Open here", "open-here");
15489
+ if (!isWatchedFilePreview) appendPreviewLinkMenuButton(menu, "Open here", "open-here");
14808
15490
  } else if (kind === "office") {
14809
15491
  appendPreviewLinkMenuButton(menu, "Convert tab", "open-new");
14810
- appendPreviewLinkMenuButton(menu, "Convert here", "open-here");
15492
+ if (!isWatchedFilePreview) appendPreviewLinkMenuButton(menu, "Convert here", "open-here");
14811
15493
  } else if (kind === "image") {
14812
15494
  appendPreviewLinkMenuButton(menu, "Open image preview", "open-image");
14813
15495
  appendPreviewLinkMenuButton(menu, "Open in new Studio tab", "open-preview-new");
14814
15496
  }
14815
15497
  appendPreviewLinkMenuButton(menu, "Reveal in file manager", "reveal");
14816
15498
  appendPreviewLinkMenuButton(menu, "Copy path", "copy-path");
14817
- positionPreviewLinkMenu(menu, event && event.clientX, event && event.clientY);
15499
+ positionPreviewLinkMenu(menu, menuPoint.clientX, menuPoint.clientY);
14818
15500
  const firstButton = menu.querySelector("button");
14819
15501
  if (firstButton && typeof firstButton.focus === "function") {
14820
15502
  window.setTimeout(() => firstButton.focus({ preventScroll: true }), 0);
@@ -14822,10 +15504,108 @@
14822
15504
  return true;
14823
15505
  }
14824
15506
 
14825
- async function fetchPreviewLocalLink(action, href, contextOverride) {
14826
- return fetchStudioJson("/local-preview-link", {
14827
- query: { ...getPreviewLinkResourceQuery(href, contextOverride), action },
15507
+ function getStudioResourceGrantRequest(error) {
15508
+ const payload = error && error.studioPayload && typeof error.studioPayload === "object"
15509
+ ? error.studioPayload
15510
+ : null;
15511
+ if (!payload || payload.code !== "studio-resource-grant-required") return null;
15512
+ const path = typeof payload.path === "string" ? payload.path.trim() : "";
15513
+ const directoryPath = typeof payload.directoryPath === "string" ? payload.directoryPath.trim() : "";
15514
+ if (!path || !directoryPath) return null;
15515
+ return {
15516
+ path,
15517
+ directoryPath,
15518
+ label: typeof payload.label === "string" && payload.label.trim() ? payload.label.trim() : basenameForStudioPath(path),
15519
+ };
15520
+ }
15521
+
15522
+ function getStudioDirectoryGrantRequest(error) {
15523
+ const payload = error && error.studioPayload && typeof error.studioPayload === "object"
15524
+ ? error.studioPayload
15525
+ : null;
15526
+ if (!payload || payload.code !== "studio-resource-directory-grant-required") return null;
15527
+ const directoryPath = typeof payload.directoryPath === "string" ? payload.directoryPath.trim() : "";
15528
+ if (!directoryPath) return null;
15529
+ return {
15530
+ directoryPath,
15531
+ label: typeof payload.label === "string" && payload.label.trim() ? payload.label.trim() : basenameForStudioPath(directoryPath),
15532
+ };
15533
+ }
15534
+
15535
+ async function requestStudioDirectoryGrant(request, options) {
15536
+ if (!request || !request.directoryPath) return false;
15537
+ const config = options && typeof options === "object" ? options : {};
15538
+ const choice = await openStudioDecision({
15539
+ mode: "confirm",
15540
+ title: "Allow " + (request.label || "this folder") + "?",
15541
+ message: (config.message || "Allow read-only access beneath this folder for the current Studio session?")
15542
+ + "\n\nFolder on the computer running Pi:\n" + request.directoryPath,
15543
+ cancelLabel: "Cancel",
15544
+ confirmLabel: "Allow this folder for this Studio session",
14828
15545
  });
15546
+ if (choice !== true) {
15547
+ setStatus(config.cancelStatus || "Folder access cancelled.", "warning");
15548
+ return false;
15549
+ }
15550
+ const payload = await fetchStudioJson("/resource-grants", {
15551
+ method: "POST",
15552
+ body: JSON.stringify({ grantKind: "directory", path: request.directoryPath }),
15553
+ });
15554
+ fileBrowserState = { ...fileBrowserState, contextKey: "", loaded: false };
15555
+ setStatus(typeof payload.message === "string" ? payload.message : "Allowed this folder for the current Studio session.", "success");
15556
+ return true;
15557
+ }
15558
+
15559
+ async function requestStudioResourceGrant(request) {
15560
+ if (!request) return false;
15561
+ const choice = await openStudioDecision({
15562
+ mode: "confirm",
15563
+ title: "Allow " + request.label + "?",
15564
+ message: "This local resource is outside the locations currently available to Studio.\n\n"
15565
+ + "File on the computer running Pi:\n" + request.path + "\n\n"
15566
+ + "Allow only this file, or allow its containing folder for this Studio session?",
15567
+ cancelLabel: "Cancel",
15568
+ secondaryLabel: "Allow this folder for this Studio session",
15569
+ secondaryValue: "directory",
15570
+ confirmLabel: "Allow this file",
15571
+ });
15572
+ const grantKind = choice === true ? "file" : choice === "directory" ? "directory" : "";
15573
+ if (!grantKind) {
15574
+ setStatus("Local resource access cancelled.", "warning");
15575
+ return false;
15576
+ }
15577
+ const grantPath = grantKind === "directory" ? request.directoryPath : request.path;
15578
+ const payload = await fetchStudioJson("/resource-grants", {
15579
+ method: "POST",
15580
+ body: JSON.stringify({ grantKind, path: grantPath }),
15581
+ });
15582
+ fileBrowserState = { ...fileBrowserState, contextKey: "", loaded: false };
15583
+ setStatus(typeof payload.message === "string" ? payload.message : "Allowed local resource for this Studio session.", "success");
15584
+ return true;
15585
+ }
15586
+
15587
+ async function fetchPreviewLocalLink(action, href, contextOverride, options) {
15588
+ const request = () => {
15589
+ const query = { ...getPreviewLinkResourceQuery(href, contextOverride), action };
15590
+ if (isWatchedFilePreview) {
15591
+ query.watchedFile = "1";
15592
+ const watchedDocId = initialQueryParams.get("docId") || "";
15593
+ if (watchedDocId) query.docId = watchedDocId;
15594
+ }
15595
+ return fetchStudioJson("/local-preview-link", { query });
15596
+ };
15597
+ try {
15598
+ return await request();
15599
+ } catch (error) {
15600
+ const grantRequest = getStudioResourceGrantRequest(error);
15601
+ if (!grantRequest || (options && options.skipGrantPrompt === true)) throw error;
15602
+ if (!(await requestStudioResourceGrant(grantRequest))) {
15603
+ const cancelled = new Error("Local resource access cancelled.");
15604
+ cancelled.studioCancelled = true;
15605
+ throw cancelled;
15606
+ }
15607
+ return request();
15608
+ }
14829
15609
  }
14830
15610
 
14831
15611
  function getPreviewPdfViewerUrl(href, contextOverride) {
@@ -14836,16 +15616,16 @@
14836
15616
  return resourceUrl && page ? resourceUrl + "#page=" + encodeURIComponent(String(page)) : resourceUrl;
14837
15617
  }
14838
15618
 
14839
- function openPreviewPdfLink(href, title, contextOverride) {
15619
+ async function openPreviewPdfLink(href, title, contextOverride) {
15620
+ await fetchPreviewLocalLink("resolve", href, contextOverride);
14840
15621
  const viewerUrl = getPreviewPdfViewerUrl(href, contextOverride);
14841
15622
  if (!viewerUrl) {
14842
15623
  setStatus("Could not resolve this PDF link. Open the source file or set a working directory first.", "warning");
14843
15624
  return false;
14844
15625
  }
14845
- const cleanPath = stripPreviewLocalLinkUrlSuffix(href);
14846
15626
  const context = contextOverride && typeof contextOverride === "object" ? contextOverride : {};
14847
15627
  const resourceQuery = buildStudioPdfResourceQuery({
14848
- path: cleanPath,
15628
+ path: stripPreviewLocalLinkUrlSuffix(href),
14849
15629
  sourcePath: context.sourcePath || "",
14850
15630
  resourceDir: context.resourceDir || "",
14851
15631
  }, true);
@@ -14854,6 +15634,7 @@
14854
15634
  }
14855
15635
 
14856
15636
  async function openPreviewImageLink(href, title, contextOverride) {
15637
+ await fetchPreviewLocalLink("resolve", href, contextOverride);
14857
15638
  const payload = await fetchStudioJson("/html-preview-resource", {
14858
15639
  query: getPreviewLinkResourceQuery(href, contextOverride),
14859
15640
  });
@@ -14904,6 +15685,9 @@
14904
15685
  }
14905
15686
 
14906
15687
  async function openPreviewDocumentHere(href, contextOverride, options) {
15688
+ if (isWatchedFilePreview) {
15689
+ throw new Error("This preview follows one disk file and cannot open another document here. Open a new file tab instead.");
15690
+ }
14907
15691
  if (!(await confirmPreviewOfficeConversion(href, "here"))) return;
14908
15692
  if (editorHasPotentialUnsavedContent()) {
14909
15693
  const kind = getPreviewLocalLinkKind(href);
@@ -14933,7 +15717,7 @@
14933
15717
  setSourceState({ source: "blank", label, path: null });
14934
15718
  } else {
14935
15719
  setSourceState({ source: "file", label, path });
14936
- markFileBackedBaseline(payload.text);
15720
+ markFileBackedBaseline(payload.text, payload.diskRevision);
14937
15721
  }
14938
15722
  const detected = converted ? "markdown" : detectLanguageFromName(path || label);
14939
15723
  if (detected) setEditorLanguage(detected);
@@ -14955,7 +15739,30 @@
14955
15739
  navigatePendingStudioTab(launch, relativeUrl);
14956
15740
  setStatus(payload && payload.converted ? "Opening converted document in a new editor." : "Opening file-backed document in a new editor.");
14957
15741
  } catch (error) {
14958
- failPendingStudioTab(launch, "Studio could not prepare this document tab. Return to the originating Studio page for details.");
15742
+ if (error && error.studioCancelled) {
15743
+ cancelPendingStudioTab(launch, "Local resource access was cancelled.");
15744
+ } else {
15745
+ failPendingStudioTab(launch, "Studio could not prepare this document tab. Return to the originating Studio page for details.");
15746
+ }
15747
+ throw error;
15748
+ }
15749
+ }
15750
+
15751
+ async function openPreviewDocumentInWatchedPreview(href, contextOverride) {
15752
+ let launch = null;
15753
+ try {
15754
+ launch = openPendingStudioTab("preview");
15755
+ const payload = await fetchPreviewLocalLink("watch-url", href, contextOverride);
15756
+ const relativeUrl = payload && typeof payload.relativeUrl === "string" ? payload.relativeUrl : "";
15757
+ if (!relativeUrl) throw new Error("Studio did not return a watched-preview URL.");
15758
+ navigatePendingStudioTab(launch, relativeUrl);
15759
+ setStatus("Opening read-only preview that follows disk changes.");
15760
+ } catch (error) {
15761
+ if (error && error.studioCancelled) {
15762
+ cancelPendingStudioTab(launch, "Local resource access was cancelled.");
15763
+ } else {
15764
+ failPendingStudioTab(launch, "Studio could not prepare this watched preview. Return to the originating Studio page for details.");
15765
+ }
14959
15766
  throw error;
14960
15767
  }
14961
15768
  }
@@ -14970,7 +15777,11 @@
14970
15777
  navigatePendingStudioTab(launch, relativeUrl);
14971
15778
  setStatus("Opening preview in a new Studio tab.");
14972
15779
  } catch (error) {
14973
- failPendingStudioTab(launch, "Studio could not prepare this preview tab. Return to the originating Studio page for details.");
15780
+ if (error && error.studioCancelled) {
15781
+ cancelPendingStudioTab(launch, "Local resource access was cancelled.");
15782
+ } else {
15783
+ failPendingStudioTab(launch, "Studio could not prepare this preview tab. Return to the originating Studio page for details.");
15784
+ }
14974
15785
  throw error;
14975
15786
  }
14976
15787
  }
@@ -14985,6 +15796,7 @@
14985
15796
  }
14986
15797
 
14987
15798
  async function revealPreviewLocalLink(href, contextOverride) {
15799
+ await fetchPreviewLocalLink("resolve", href, contextOverride);
14988
15800
  const query = getPreviewLinkResourceQuery(href, contextOverride);
14989
15801
  const payload = await fetchStudioJson("/reveal-local-resource", {
14990
15802
  method: "POST",
@@ -14998,10 +15810,11 @@
14998
15810
  if (!href) return;
14999
15811
  try {
15000
15812
  if (action === "open-pdf") {
15001
- openPreviewPdfLink(href, context.title || href, context);
15813
+ await openPreviewPdfLink(href, context.title || href, context);
15002
15814
  return;
15003
15815
  }
15004
15816
  if (action === "open-system") {
15817
+ await fetchPreviewLocalLink("resolve", href, context);
15005
15818
  await runStudioPdfLocalAction("system-viewer", getPreviewLinkResourceQuery(href, context));
15006
15819
  return;
15007
15820
  }
@@ -15009,6 +15822,10 @@
15009
15822
  await openPreviewDocumentInNewEditor(href, context);
15010
15823
  return;
15011
15824
  }
15825
+ if (action === "watch-new") {
15826
+ await openPreviewDocumentInWatchedPreview(href, context);
15827
+ return;
15828
+ }
15012
15829
  if (action === "open-preview-new") {
15013
15830
  await openPreviewResourceInNewEditor(href, context);
15014
15831
  return;
@@ -15043,7 +15860,9 @@
15043
15860
  closePreviewLinkMenu();
15044
15861
  const title = String(anchor.textContent || href).trim() || href;
15045
15862
  if (kind === "pdf") {
15046
- openPreviewPdfLink(href, title);
15863
+ void openPreviewPdfLink(href, title).catch((error) => {
15864
+ setStatus((error && error.message) ? error.message : String(error || "Could not open linked PDF."), "warning");
15865
+ });
15047
15866
  return;
15048
15867
  }
15049
15868
  if (kind === "image") {
@@ -15053,9 +15872,7 @@
15053
15872
  return;
15054
15873
  }
15055
15874
  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
- });
15875
+ void showPreviewLinkMenu(anchor, event);
15059
15876
  return;
15060
15877
  }
15061
15878
  setStatus("Right-click this local link for file actions.", "warning");
@@ -15066,7 +15883,7 @@
15066
15883
  if (!anchor) return;
15067
15884
  event.preventDefault();
15068
15885
  event.stopPropagation();
15069
- showPreviewLinkMenu(anchor, event);
15886
+ void showPreviewLinkMenu(anchor, event);
15070
15887
  }
15071
15888
 
15072
15889
  function makeRequestId() {
@@ -15245,6 +16062,7 @@
15245
16062
  const html = await renderQuizMarkdownToHtml(markdown);
15246
16063
  if (nonce !== quizPreviewRenderNonce || !quizDialogEl || !quizDialogEl.contains(target)) return;
15247
16064
  target.innerHTML = html;
16065
+ await hydrateStudioPreviewLocalMedia(target, getHtmlPreviewResourceContextOptions());
15248
16066
  await renderAnnotationMathInElement(target);
15249
16067
  decoratePdfEmbeds(target);
15250
16068
  await renderPdfPreviewsInElement(target);
@@ -16369,6 +17187,8 @@
16369
17187
  ensurePreviewSelectionActions(targetEl);
16370
17188
  updatePreviewCommentBlocksForElement(targetEl);
16371
17189
  decorateCopyablePreviewBlocks(targetEl);
17190
+ clearWatchedPreviewRenderError();
17191
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, text);
16372
17192
  if (pane === "response") {
16373
17193
  applyPendingResponseScrollReset();
16374
17194
  scheduleResponsePaneRepaintNudge();
@@ -21242,7 +22062,11 @@
21242
22062
  } catch {}
21243
22063
  }
21244
22064
 
21245
- function setEditorLanguage(lang) {
22065
+ function setEditorLanguage(lang, options) {
22066
+ if (isWatchedFilePreview && !(options && options.allowWatchedFileUpdate === true)) {
22067
+ setStatus("The watched preview language follows its file path.", "warning");
22068
+ return false;
22069
+ }
21246
22070
  editorLanguage = (lang && SUPPORTED_LANGUAGES.indexOf(lang) !== -1) ? lang : "markdown";
21247
22071
  persistEditorLanguage(editorLanguage);
21248
22072
  syncHighlightSelectUi();
@@ -21257,6 +22081,7 @@
21257
22081
  }
21258
22082
  updateOutlineUi();
21259
22083
  scheduleWorkspacePersistence();
22084
+ return true;
21260
22085
  }
21261
22086
 
21262
22087
  function setEditorHighlightMode(mode) {
@@ -21558,11 +22383,90 @@
21558
22383
  return true;
21559
22384
  }
21560
22385
 
22386
+ function handleWatchedFileUpdate(message) {
22387
+ if (!isWatchedFilePreview || !message || typeof message.text !== "string") return;
22388
+ const messagePath = String(message.path || "");
22389
+ if (!watchedFilePreviewState.path || messagePath !== watchedFilePreviewState.path) return;
22390
+ const generation = Math.max(0, Number(message.generation) || 0);
22391
+ if (generation < watchedFilePreviewState.generation) return;
22392
+
22393
+ const expectedPreviewText = prepareEditorTextForPreview(message.text);
22394
+ watchedFilePreviewReadingPositions.source = {
22395
+ snapshot: captureWatchedPreviewReadingPosition(sourcePreviewEl),
22396
+ text: expectedPreviewText,
22397
+ };
22398
+ watchedFilePreviewReadingPositions.response = {
22399
+ snapshot: captureWatchedPreviewReadingPosition(critiqueViewEl),
22400
+ text: expectedPreviewText,
22401
+ };
22402
+ const textareaMaxScroll = Math.max(0, Number(sourceTextEl.scrollHeight || 0) - Number(sourceTextEl.clientHeight || 0));
22403
+ const textareaScrollRatio = textareaMaxScroll > 0 ? Number(sourceTextEl.scrollTop || 0) / textareaMaxScroll : 0;
22404
+ const selectionStart = Math.max(0, Number(sourceTextEl.selectionStart) || 0);
22405
+ const selectionEnd = Math.max(selectionStart, Number(sourceTextEl.selectionEnd) || selectionStart);
22406
+
22407
+ sourceTextEl.value = message.text;
22408
+ const nextMaxScroll = Math.max(0, Number(sourceTextEl.scrollHeight || 0) - Number(sourceTextEl.clientHeight || 0));
22409
+ sourceTextEl.scrollTop = Math.max(0, Math.min(nextMaxScroll, nextMaxScroll * textareaScrollRatio));
22410
+ try {
22411
+ sourceTextEl.setSelectionRange(
22412
+ Math.min(selectionStart, message.text.length),
22413
+ Math.min(selectionEnd, message.text.length),
22414
+ );
22415
+ } catch {
22416
+ // Selection APIs are not guaranteed in every embedded browser.
22417
+ }
22418
+
22419
+ watchedFilePreviewState.generation = generation;
22420
+ watchedFilePreviewState.diskRevision = normalizeStudioDiskRevision(message.diskRevision) || watchedFilePreviewState.diskRevision;
22421
+ watchedFilePreviewState.lastError = "";
22422
+ fileBackedBaselineText = message.text;
22423
+ fileBackedDiskRevision = watchedFilePreviewState.diskRevision || null;
22424
+ editorLanguage = detectLanguageFromName(watchedFilePreviewState.path) || editorLanguage;
22425
+ syncHighlightSelectUi();
22426
+ scheduleEditorHighlightRender();
22427
+ renderSourcePreview({ previewDelayMs: 0 });
22428
+ renderActiveResult();
22429
+ updateSourceBadge();
22430
+ setStatus(message.message || "Watched preview updated from disk.", "success");
22431
+ }
22432
+
22433
+ function handleWatchedFileError(message) {
22434
+ if (!isWatchedFilePreview || !message) return;
22435
+ const messagePath = String(message.path || "");
22436
+ if (watchedFilePreviewState.path && messagePath && messagePath !== watchedFilePreviewState.path) return;
22437
+ watchedFilePreviewState.lastError = String(message.message || "Could not refresh the watched file.");
22438
+ updateSourceBadge();
22439
+ setStatus(watchedFilePreviewState.lastError, "warning");
22440
+ }
22441
+
22442
+ function handleWatchedFileReady(message) {
22443
+ if (!isWatchedFilePreview || !message) return;
22444
+ const messagePath = String(message.path || "");
22445
+ if (watchedFilePreviewState.path && messagePath && messagePath !== watchedFilePreviewState.path) return;
22446
+ watchedFilePreviewState.diskRevision = normalizeStudioDiskRevision(message.diskRevision) || watchedFilePreviewState.diskRevision;
22447
+ watchedFilePreviewState.lastError = "";
22448
+ updateSourceBadge();
22449
+ setStatus(message.message || "Watching file for disk changes.", "success");
22450
+ }
22451
+
21561
22452
  function handleServerMessage(message) {
21562
22453
  if (!message || typeof message !== "object") return;
21563
22454
 
21564
22455
  debugTrace("server_message", summarizeServerMessage(message));
21565
22456
 
22457
+ if (message.type === "watched_file_update") {
22458
+ handleWatchedFileUpdate(message);
22459
+ return;
22460
+ }
22461
+ if (message.type === "watched_file_error") {
22462
+ handleWatchedFileError(message);
22463
+ return;
22464
+ }
22465
+ if (message.type === "watched_file_ready") {
22466
+ handleWatchedFileReady(message);
22467
+ return;
22468
+ }
22469
+
21566
22470
  const contextChanged = applyContextUsageFromMessage(message);
21567
22471
  if (contextChanged) {
21568
22472
  updateFooterMeta();
@@ -21799,7 +22703,11 @@
21799
22703
  message.initialDocument &&
21800
22704
  typeof message.initialDocument.text === "string"
21801
22705
  ) {
21802
- setEditorText(message.initialDocument.text, { preserveScroll: false, preserveSelection: false });
22706
+ setEditorText(message.initialDocument.text, {
22707
+ preserveScroll: false,
22708
+ preserveSelection: false,
22709
+ allowWatchedFileUpdate: isWatchedFilePreview,
22710
+ });
21803
22711
  initialDocumentApplied = true;
21804
22712
  loadedInitialDocument = true;
21805
22713
  setSourceState({
@@ -21811,7 +22719,7 @@
21811
22719
  : (initialSourceState.draftId || null),
21812
22720
  });
21813
22721
  if (message.initialDocument.path) {
21814
- markFileBackedBaseline(message.initialDocument.text);
22722
+ markFileBackedBaseline(message.initialDocument.text, message.initialDocument.diskRevision);
21815
22723
  }
21816
22724
  refreshResponseUi();
21817
22725
  if (typeof message.initialDocument.label === "string" && message.initialDocument.label.length > 0) {
@@ -22172,7 +23080,19 @@
22172
23080
  return;
22173
23081
  }
22174
23082
 
23083
+ if (message.type === "save_conflict") {
23084
+ void handleEditorSaveConflict(message);
23085
+ return;
23086
+ }
23087
+
23088
+ if (message.type === "save_as_conflict") {
23089
+ void handleEditorSaveAsConflict(message);
23090
+ return;
23091
+ }
23092
+
22175
23093
  if (message.type === "saved") {
23094
+ const savedOperation = typeof message.requestId === "string" ? pendingSaveOperations.get(message.requestId) : null;
23095
+ if (typeof message.requestId === "string") pendingSaveOperations.delete(message.requestId);
22176
23096
  if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
22177
23097
  pendingRequestId = null;
22178
23098
  pendingKind = null;
@@ -22191,7 +23111,7 @@
22191
23111
  }, {
22192
23112
  carryCurrentMetadataToNewDocument: true,
22193
23113
  });
22194
- markFileBackedBaseline(sourceTextEl.value);
23114
+ markFileBackedBaseline(savedOperation && typeof savedOperation.content === "string" ? savedOperation.content : sourceTextEl.value, message.diskRevision);
22195
23115
  }
22196
23116
  setBusy(false);
22197
23117
  setWsState("Ready");
@@ -22211,6 +23131,10 @@
22211
23131
  }
22212
23132
 
22213
23133
  if (message.type === "editor_snapshot") {
23134
+ if (isWatchedFilePreview) {
23135
+ setStatus("Ignored editor snapshot because this preview follows its watched file on disk.", "warning");
23136
+ return;
23137
+ }
22214
23138
  if (typeof message.requestId === "string" && pendingRequestId && message.requestId !== pendingRequestId) {
22215
23139
  return;
22216
23140
  }
@@ -22234,6 +23158,10 @@
22234
23158
  }
22235
23159
 
22236
23160
  if (message.type === "studio_document") {
23161
+ if (isWatchedFilePreview) {
23162
+ setStatus("Ignored document replacement because this preview follows its watched file on disk.", "warning");
23163
+ return;
23164
+ }
22237
23165
  const nextDoc = message.document;
22238
23166
  if (!nextDoc || typeof nextDoc !== "object" || typeof nextDoc.text !== "string") {
22239
23167
  return;
@@ -22271,7 +23199,7 @@
22271
23199
  draftId: typeof nextDoc.draftId === "string" && nextDoc.draftId.trim() ? nextDoc.draftId.trim() : null,
22272
23200
  });
22273
23201
  if (nextPath) {
22274
- markFileBackedBaseline(nextDoc.text);
23202
+ markFileBackedBaseline(nextDoc.text, nextDoc.diskRevision);
22275
23203
  }
22276
23204
  refreshResponseUi();
22277
23205
  setStatus(
@@ -22414,6 +23342,7 @@
22414
23342
 
22415
23343
  if (message.type === "busy") {
22416
23344
  if (typeof message.requestId === "string") {
23345
+ pendingSaveOperations.delete(message.requestId);
22417
23346
  failPendingCompanionLaunch(message.requestId, "Studio could not start the companion editor because another request was busy.");
22418
23347
  }
22419
23348
  if (message.requestId && pendingRequestId === message.requestId) {
@@ -22435,6 +23364,7 @@
22435
23364
 
22436
23365
  if (message.type === "error") {
22437
23366
  if (typeof message.requestId === "string") {
23367
+ pendingSaveOperations.delete(message.requestId);
22438
23368
  failPendingCompanionLaunch(message.requestId, "Studio could not prepare the companion editor. Return to the originating Studio page for details.");
22439
23369
  }
22440
23370
  if (message.requestId && pendingRequestId === message.requestId) {
@@ -22541,6 +23471,11 @@
22541
23471
  if (studioMode !== "full") {
22542
23472
  wsParams.set("mode", studioMode);
22543
23473
  }
23474
+ if (isWatchedFilePreview && watchedFilePreviewState.path) {
23475
+ wsParams.set("watchPath", watchedFilePreviewState.path);
23476
+ const watchedDocId = initialQueryParams.get("docId") || "";
23477
+ if (watchedDocId) wsParams.set("docId", watchedDocId);
23478
+ }
22544
23479
  if (DEBUG_ENABLED) {
22545
23480
  wsParams.set("debug", "1");
22546
23481
  }
@@ -22591,6 +23526,14 @@
22591
23526
  return;
22592
23527
  }
22593
23528
 
23529
+ if (kind === "watch_unauthorized") {
23530
+ clearScheduledReconnect();
23531
+ reconnectAttempt = 0;
23532
+ setWsState("Disconnected");
23533
+ setStatus("Watched preview authorization is unavailable. Reload this tab or open a new watched preview from Studio.", "warning");
23534
+ return;
23535
+ }
23536
+
22594
23537
  if (kind === "shutdown") {
22595
23538
  clearScheduledReconnect();
22596
23539
  reconnectAttempt = 0;
@@ -22606,14 +23549,28 @@
22606
23549
  };
22607
23550
 
22608
23551
  socket.addEventListener("open", () => {
23552
+ if (ws !== socket) {
23553
+ try { socket.close(); } catch {}
23554
+ return;
23555
+ }
22609
23556
  window.clearTimeout(connectWatchdog);
22610
23557
  setWsState("Ready");
22611
23558
  setStatus(wasReconnect ? "Reconnected. Syncing…" : "Connected. Syncing…");
22612
23559
  sendMessage({ type: "hello" });
23560
+ if (isWatchedFilePreview && watchedFilePreviewState.path) {
23561
+ watchedFilePreviewState.generation = 0;
23562
+ sendMessage({
23563
+ type: "watch_file_subscribe",
23564
+ path: watchedFilePreviewState.path,
23565
+ revision: watchedFilePreviewState.diskRevision || undefined,
23566
+ });
23567
+ setStatus(wasReconnect ? "Reconnected. Resuming watched preview…" : "Connected. Starting watched preview…");
23568
+ }
22613
23569
  reconnectAttempt = 0;
22614
23570
  });
22615
23571
 
22616
23572
  socket.addEventListener("message", (event) => {
23573
+ if (ws !== socket) return;
22617
23574
  try {
22618
23575
  const message = JSON.parse(event.data);
22619
23576
  handleServerMessage(message);
@@ -22624,6 +23581,7 @@
22624
23581
  });
22625
23582
 
22626
23583
  socket.addEventListener("close", (event) => {
23584
+ if (ws !== socket && !disconnectHandled) return;
22627
23585
  if (event && event.code === 4001) {
22628
23586
  handleDisconnect("invalidated", 4001);
22629
23587
  return;
@@ -22632,6 +23590,10 @@
22632
23590
  handleDisconnect("full_conflict", 4004);
22633
23591
  return;
22634
23592
  }
23593
+ if (event && event.code === 4003) {
23594
+ handleDisconnect("watch_unauthorized", 4003);
23595
+ return;
23596
+ }
22635
23597
  if (event && event.code === 1001) {
22636
23598
  handleDisconnect("shutdown", 1001);
22637
23599
  return;
@@ -22641,6 +23603,7 @@
22641
23603
  });
22642
23604
 
22643
23605
  socket.addEventListener("error", () => {
23606
+ if (ws !== socket) return;
22644
23607
  handleDisconnect("error");
22645
23608
  });
22646
23609
  }
@@ -22869,6 +23832,25 @@
22869
23832
  });
22870
23833
  }
22871
23834
 
23835
+ if (watchedOpenEditableBtn) {
23836
+ watchedOpenEditableBtn.addEventListener("click", () => {
23837
+ const path = watchedFilePreviewState.path;
23838
+ if (!path) {
23839
+ setStatus("This watched preview no longer has a file path.", "warning");
23840
+ return;
23841
+ }
23842
+ try {
23843
+ openFileBackedStudioEditorTab(path, {
23844
+ label: sourceState && sourceState.label ? sourceState.label : basenameForStudioPath(path),
23845
+ resourceDir: getCurrentResourceDirValue() || dirnameForDisplayPath(path),
23846
+ });
23847
+ setStatus("Opening watched file in a separate editable tab.");
23848
+ } catch (error) {
23849
+ setStatus(error && error.message ? error.message : String(error || "Could not open editable tab."), "warning");
23850
+ }
23851
+ });
23852
+ }
23853
+
22872
23854
  updatePaneFocusButtons();
22873
23855
  window.addEventListener("keydown", handlePaneShortcut);
22874
23856
  window.addEventListener("pagehide", () => {
@@ -23390,100 +24372,249 @@
23390
24372
  setFooterThemeMenuOpen(false);
23391
24373
  });
23392
24374
 
23393
- saveAsBtn.addEventListener("click", async () => {
23394
- const content = sourceTextEl.value;
23395
- if (!content.trim()) {
23396
- setStatus("Editor is empty. Nothing to save.", "warning");
23397
- return;
24375
+ function abandonPendingSaveRequest(requestId) {
24376
+ pendingSaveOperations.delete(requestId);
24377
+ if (requestId) clearArmedTitleAttention(requestId);
24378
+ if (pendingRequestId === requestId) {
24379
+ pendingRequestId = null;
24380
+ pendingKind = null;
23398
24381
  }
24382
+ stickyStudioKind = null;
24383
+ setBusy(false);
24384
+ setWsState("Ready");
24385
+ }
23399
24386
 
23400
- var suggestedName = sourceState.label ? stripImportedFileLabel(sourceState.label) : "draft.md";
23401
- var suggestedDir = getCurrentResourceDirValue() ? getCurrentResourceDirValue().replace(/\/$/, "") + "/" : "./";
23402
- const suggested = sourceState.path || (suggestedDir + suggestedName);
24387
+ function sendEditorSaveAsRequest(path, content, overwrite, expectedRevision) {
24388
+ const cleanPath = String(path || "").trim();
24389
+ if (!cleanPath) {
24390
+ setStatus("Save cancelled: path is required.", "warning");
24391
+ return false;
24392
+ }
24393
+ const requestId = beginUiAction("save_as");
24394
+ if (!requestId) return false;
24395
+ const operation = {
24396
+ kind: "save_as",
24397
+ path: cleanPath,
24398
+ content: String(content ?? ""),
24399
+ overwrite: overwrite === true,
24400
+ expectedRevision: normalizeStudioDiskRevision(expectedRevision),
24401
+ };
24402
+ pendingSaveOperations.set(requestId, operation);
24403
+ if (!sendMessage({
24404
+ type: "save_as_request",
24405
+ requestId,
24406
+ path: operation.path,
24407
+ content: operation.content,
24408
+ overwrite: operation.overwrite,
24409
+ expectedRevision: operation.expectedRevision || undefined,
24410
+ })) {
24411
+ abandonPendingSaveRequest(requestId);
24412
+ return false;
24413
+ }
24414
+ return true;
24415
+ }
24416
+
24417
+ async function openEditorSaveAsDialog(options) {
24418
+ if (uiBusy) return false;
24419
+ const settings = options && typeof options === "object" ? options : {};
24420
+ const resourceDir = getCurrentResourceDirValue();
24421
+ const currentPath = getEffectiveSavePath();
24422
+ const label = sourceState.label ? stripImportedFileLabel(sourceState.label) : "draft.md";
24423
+ const suggested = typeof settings.suggestedPath === "string" && settings.suggestedPath.trim()
24424
+ ? settings.suggestedPath.trim()
24425
+ : (currentPath || (resourceDir ? resourceDir.replace(/\/$/, "") + "/" + label : "./draft.md"));
23403
24426
  const path = await requestStudioTextInput("Save editor content as:", suggested, {
23404
24427
  title: "Save editor as",
23405
24428
  confirmLabel: "Save",
24429
+ inputLabel: "File path",
23406
24430
  });
23407
- if (!path) return;
23408
-
23409
- const requestId = beginUiAction("save_as");
23410
- if (!requestId) return;
24431
+ if (path === null) {
24432
+ if (settings.reportCancellation === true) setStatus("Save As cancelled; editor changes were kept.", "warning");
24433
+ return false;
24434
+ }
24435
+ const content = Object.prototype.hasOwnProperty.call(settings, "content")
24436
+ ? String(settings.content ?? "")
24437
+ : sourceTextEl.value;
24438
+ return sendEditorSaveAsRequest(path, content, false);
24439
+ }
23411
24440
 
23412
- const sent = sendMessage({
23413
- type: "save_as_request",
23414
- requestId,
24441
+ function sendEditorSaveOverRequest(options) {
24442
+ const settings = options && typeof options === "object" ? options : {};
24443
+ const path = typeof settings.path === "string" && settings.path.trim()
24444
+ ? settings.path.trim()
24445
+ : (sourceState && sourceState.path ? sourceState.path : "");
24446
+ if (!path) {
24447
+ setStatus("Save editor requires a file-backed document. Use Save editor as… for a new file.", "warning");
24448
+ return false;
24449
+ }
24450
+ const requestId = beginUiAction("save_over");
24451
+ if (!requestId) return false;
24452
+ const operation = {
24453
+ kind: "save_over",
23415
24454
  path,
23416
- content,
23417
- });
24455
+ content: Object.prototype.hasOwnProperty.call(settings, "content")
24456
+ ? String(settings.content ?? "")
24457
+ : sourceTextEl.value,
24458
+ expectedRevision: Object.prototype.hasOwnProperty.call(settings, "expectedRevision")
24459
+ ? normalizeStudioDiskRevision(settings.expectedRevision)
24460
+ : fileBackedDiskRevision,
24461
+ force: settings.force === true,
24462
+ };
24463
+ pendingSaveOperations.set(requestId, operation);
24464
+ if (!sendMessage({
24465
+ type: "save_over_request",
24466
+ requestId,
24467
+ path: operation.path,
24468
+ content: operation.content,
24469
+ expectedRevision: operation.expectedRevision || undefined,
24470
+ force: operation.force,
24471
+ })) {
24472
+ abandonPendingSaveRequest(requestId);
24473
+ return false;
24474
+ }
24475
+ return true;
24476
+ }
23418
24477
 
23419
- if (!sent) {
24478
+ async function requestEditorRefreshFromDisk(options) {
24479
+ const settings = options && typeof options === "object" ? options : {};
24480
+ if (uiBusy) return false;
24481
+ const path = typeof settings.path === "string" && settings.path.trim()
24482
+ ? settings.path.trim()
24483
+ : (sourceState && sourceState.path ? sourceState.path : "");
24484
+ if (!path) {
24485
+ setStatus("Refresh from disk requires a file-backed editor. Open one from Files or use /studio-editor-only <path>.", "warning");
24486
+ return false;
24487
+ }
24488
+ if (settings.skipConfirm !== true && editorDiffersFromFileBackedBaseline()) {
24489
+ const confirmed = await requestStudioConfirmation(
24490
+ "Replace the current editor contents with the latest version from disk? Unsaved editor changes will be lost.\n\n" + path,
24491
+ { title: "Refresh from disk?", confirmLabel: "Replace", destructive: true },
24492
+ );
24493
+ if (!confirmed) return false;
24494
+ }
24495
+ const requestId = beginUiAction("refresh_from_disk");
24496
+ if (!requestId) return false;
24497
+ if (!sendMessage({ type: "refresh_from_disk_request", requestId, path })) {
23420
24498
  pendingRequestId = null;
23421
24499
  pendingKind = null;
24500
+ stickyStudioKind = null;
23422
24501
  setBusy(false);
24502
+ setWsState("Ready");
24503
+ return false;
23423
24504
  }
23424
- });
23425
-
23426
- saveOverBtn.addEventListener("click", async () => {
23427
- var effectivePath = getEffectiveSavePath();
23428
- if (!effectivePath) {
23429
- setStatus("Save editor requires a file path. Open via /studio <path>, set a working dir, or use Save editor as…", "warning");
23430
- return;
23431
- }
24505
+ return true;
24506
+ }
23432
24507
 
23433
- const confirmed = await requestStudioConfirmation("Overwrite " + effectivePath + "?", {
23434
- title: "Overwrite file?",
24508
+ async function handleEditorSaveConflict(message) {
24509
+ const requestId = typeof message.requestId === "string" ? message.requestId : "";
24510
+ const operation = pendingSaveOperations.get(requestId) || {
24511
+ kind: "save_over",
24512
+ path: typeof message.path === "string" ? message.path : (sourceState.path || ""),
24513
+ content: sourceTextEl.value,
24514
+ expectedRevision: fileBackedDiskRevision,
24515
+ force: false,
24516
+ };
24517
+ abandonPendingSaveRequest(requestId);
24518
+ const conflictPath = typeof message.path === "string" && message.path.trim() ? message.path.trim() : operation.path;
24519
+ const canOverwrite = message.canOverwrite !== false;
24520
+ const detail = (typeof message.message === "string" && message.message.trim()
24521
+ ? message.message.trim()
24522
+ : "The file changed on disk after Studio loaded it.")
24523
+ + "\n\n" + conflictPath
24524
+ + "\n\nReload replaces the editor with disk content. Save As keeps both versions."
24525
+ + (canOverwrite ? " Overwrite replaces the reported disk revision with the current editor text." : " Overwrite is unavailable for this file location; use Save As to preserve link and path safety.");
24526
+ setStatus("Save paused because the file changed on disk.", "warning");
24527
+ const decision = await openStudioDecision({
24528
+ mode: "confirm",
24529
+ title: "File changed on disk",
24530
+ message: detail,
24531
+ cancelLabel: "Cancel",
24532
+ tertiaryLabel: "Reload",
24533
+ tertiaryValue: "reload",
24534
+ secondaryLabel: "Save As…",
24535
+ secondaryValue: "save-as",
23435
24536
  confirmLabel: "Overwrite",
24537
+ confirmDisabled: !canOverwrite,
23436
24538
  destructive: true,
23437
24539
  });
23438
- if (!confirmed) return;
23439
-
23440
- const requestId = beginUiAction("save_over");
23441
- if (!requestId) return;
24540
+ if (decision === "reload") {
24541
+ await requestEditorRefreshFromDisk({ path: conflictPath, skipConfirm: true });
24542
+ return;
24543
+ }
24544
+ if (decision === "save-as") {
24545
+ await openEditorSaveAsDialog({ content: operation.content, suggestedPath: conflictPath, reportCancellation: true });
24546
+ return;
24547
+ }
24548
+ if (decision === true && canOverwrite) {
24549
+ sendEditorSaveOverRequest({
24550
+ path: conflictPath,
24551
+ content: operation.content,
24552
+ expectedRevision: message.currentRevision,
24553
+ force: true,
24554
+ });
24555
+ return;
24556
+ }
24557
+ setStatus("Save cancelled; editor changes were kept.", "warning");
24558
+ }
23442
24559
 
23443
- // Use save_as with the effective path for both file-backed and derived paths
23444
- const sent = sendMessage({
23445
- type: "save_as_request",
23446
- requestId,
23447
- path: effectivePath,
24560
+ async function handleEditorSaveAsConflict(message) {
24561
+ const requestId = typeof message.requestId === "string" ? message.requestId : "";
24562
+ const operation = pendingSaveOperations.get(requestId) || {
24563
+ kind: "save_as",
24564
+ path: typeof message.path === "string" ? message.path : "",
23448
24565
  content: sourceTextEl.value,
24566
+ overwrite: false,
24567
+ expectedRevision: null,
24568
+ };
24569
+ abandonPendingSaveRequest(requestId);
24570
+ const conflictPath = typeof message.path === "string" && message.path.trim() ? message.path.trim() : operation.path;
24571
+ const targetExists = Boolean(normalizeStudioDiskRevision(message.currentRevision));
24572
+ const unsafeReplacement = message.reason === "location-changed" || message.reason === "hard-linked-file";
24573
+ const canCommitHere = !unsafeReplacement;
24574
+ setStatus(unsafeReplacement
24575
+ ? "Save As cannot replace this target safely; choose another path."
24576
+ : (targetExists
24577
+ ? "Save As paused because the target already exists."
24578
+ : "Save As paused because the target changed while confirmation was open."), "warning");
24579
+ const decision = await openStudioDecision({
24580
+ mode: "confirm",
24581
+ title: unsafeReplacement ? "Cannot replace existing file" : (targetExists ? "Replace existing file?" : "Create file at this path?"),
24582
+ message: (typeof message.message === "string" && message.message.trim()
24583
+ ? message.message.trim()
24584
+ : (targetExists ? "A file already exists at this location." : "The previous replacement target is no longer present."))
24585
+ + (unsafeReplacement
24586
+ ? "\n\nStudio will not replace a symlink, moved path, or hard-linked file. Choose another location instead."
24587
+ : (targetExists ? "\n\nReplacing it cannot be undone." : "\n\nCreating it will keep the current editor text at this path.")),
24588
+ cancelLabel: "Cancel",
24589
+ secondaryLabel: "Choose another…",
24590
+ secondaryValue: "choose-another",
24591
+ confirmLabel: targetExists ? "Replace" : "Create",
24592
+ confirmDisabled: !canCommitHere,
24593
+ destructive: targetExists,
23449
24594
  });
23450
-
23451
- if (!sent) {
23452
- pendingRequestId = null;
23453
- pendingKind = null;
23454
- setBusy(false);
24595
+ if (decision === "choose-another") {
24596
+ await openEditorSaveAsDialog({ content: operation.content, suggestedPath: conflictPath, reportCancellation: true });
24597
+ return;
23455
24598
  }
23456
- });
23457
-
23458
- if (refreshFromDiskBtn) {
23459
- refreshFromDiskBtn.addEventListener("click", async () => {
23460
- if (!hasRefreshableFilePath()) {
23461
- setStatus("Refresh from disk needs a file path. Use Files → Open here, Files → Open file tab, or /studio-editor-only <path> for a refreshable editor tab.", "warning");
23462
- return;
23463
- }
23464
-
23465
- if (editorDiffersFromFileBackedBaseline()) {
23466
- const confirmed = await requestStudioConfirmation(
23467
- "Replace current editor contents with the latest version from disk?",
23468
- { title: "Refresh from disk?", confirmLabel: "Replace", destructive: true },
23469
- );
23470
- if (!confirmed) return;
23471
- }
24599
+ if (decision === true && canCommitHere) {
24600
+ sendEditorSaveAsRequest(conflictPath, operation.content, true, message.currentRevision);
24601
+ return;
24602
+ }
24603
+ setStatus("Save As cancelled; editor changes were kept.", "warning");
24604
+ }
23472
24605
 
23473
- const requestId = beginUiAction("refresh_from_disk");
23474
- if (!requestId) return;
24606
+ saveAsBtn.addEventListener("click", () => {
24607
+ void openEditorSaveAsDialog();
24608
+ });
23475
24609
 
23476
- const sent = sendMessage({
23477
- type: "refresh_from_disk_request",
23478
- requestId,
23479
- path: sourceState.path,
23480
- });
24610
+ saveOverBtn.addEventListener("click", () => {
24611
+ if (uiBusy) return;
24612
+ sendEditorSaveOverRequest();
24613
+ });
23481
24614
 
23482
- if (!sent) {
23483
- pendingRequestId = null;
23484
- pendingKind = null;
23485
- setBusy(false);
23486
- }
24615
+ if (refreshFromDiskBtn) {
24616
+ refreshFromDiskBtn.addEventListener("click", () => {
24617
+ void requestEditorRefreshFromDisk();
23487
24618
  });
23488
24619
  }
23489
24620
 
@@ -24217,7 +25348,16 @@
24217
25348
  resourceDirInput.value = normalizeStudioResourceDirValue(initialResourceDir);
24218
25349
  }
24219
25350
  setSourceState(initialSourceState);
24220
- if (initialSourceState.path) markFileBackedBaseline(sourceTextEl.value);
25351
+ if (initialSourceState.path) markFileBackedBaseline(sourceTextEl.value, initialDiskRevision);
25352
+ if (isWatchedFilePreview) {
25353
+ sourceTextEl.readOnly = true;
25354
+ sourceTextEl.setAttribute("aria-readonly", "true");
25355
+ sourceTextEl.title = "Read-only disk-backed source. Open a file tab to edit and save safely.";
25356
+ editorView = "markdown";
25357
+ rightView = "editor-preview";
25358
+ followLatest = false;
25359
+ if (document.body && document.body.classList) document.body.classList.add("watched-file-preview");
25360
+ }
24221
25361
  refreshResponseUi();
24222
25362
  updateAnnotatedReplyHeaderButton();
24223
25363
  setActivePane(initialPaneFocusTarget === "off" ? "left" : initialPaneFocusTarget);
@@ -24230,7 +25370,9 @@
24230
25370
 
24231
25371
  const initialDetectedLang = detectLanguageFromName(initialSourceState.path || initialSourceState.label || "");
24232
25372
  const storedLang = readStoredEditorLanguage();
24233
- setEditorLanguage(initialDetectedLang || storedLang || "markdown");
25373
+ setEditorLanguage(initialDetectedLang || storedLang || "markdown", {
25374
+ allowWatchedFileUpdate: isWatchedFilePreview,
25375
+ });
24234
25376
 
24235
25377
  const storedLineNumbersEnabled = readStoredEditorLineNumbersEnabled();
24236
25378
  const initialLineNumbersEnabled = storedLineNumbersEnabled ?? Boolean(lineNumbersSelect && lineNumbersSelect.value === "on");