pi-studio 0.9.53 → 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 = {
@@ -282,6 +284,16 @@
282
284
  };
283
285
  const initialResourceDir = initialQueryParams.get("resourceDir")
284
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 };
285
297
 
286
298
  let ws = null;
287
299
  let wsState = "Connecting";
@@ -333,6 +345,7 @@
333
345
  let studioDecisionMessageEl = null;
334
346
  let studioDecisionInputEl = null;
335
347
  let studioDecisionCancelBtn = null;
348
+ let studioDecisionTertiaryBtn = null;
336
349
  let studioDecisionSecondaryBtn = null;
337
350
  let studioDecisionConfirmBtn = null;
338
351
  let studioDecisionState = null;
@@ -340,6 +353,7 @@
340
353
  let pendingRequestId = null;
341
354
  let pendingKind = null;
342
355
  let stickyStudioKind = null;
356
+ const pendingSaveOperations = new Map();
343
357
  const pendingCompanionLaunches = new Map();
344
358
  const activeStudioTabLaunches = new Set();
345
359
  let sourceOriginSummaryEl = null;
@@ -369,6 +383,7 @@
369
383
 
370
384
  function normalizeRightViewValue(nextView) {
371
385
  const normalized = canonicalRightViewValue(nextView);
386
+ if (isWatchedFilePreview && normalized !== "editor-preview") return "editor-preview";
372
387
  if (normalized === "editor-quarto-preview" && !isCurrentStudioQuartoDocument()) {
373
388
  return "editor-preview";
374
389
  }
@@ -380,6 +395,7 @@
380
395
 
381
396
  function isRightViewAvailableInCurrentMode(view) {
382
397
  const normalized = canonicalRightViewValue(view);
398
+ if (isWatchedFilePreview) return normalized === "editor-preview";
383
399
  if (normalized === "editor-quarto-preview" && !isCurrentStudioQuartoDocument()) return false;
384
400
  return !isEditorOnlyMode || EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(normalized);
385
401
  }
@@ -399,12 +415,17 @@
399
415
  Array.from(rightViewSelect.options).forEach((option) => {
400
416
  if (!option) return;
401
417
  const isQuartoOption = option.value === "editor-quarto-preview";
418
+ if (isWatchedFilePreview && option.value === "editor-preview") option.textContent = "Watched preview";
402
419
  if (isQuartoOption) option.hidden = !quartoRelevant;
403
- 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);
404
423
  });
405
- rightViewSelect.title = isEditorOnlyMode
424
+ rightViewSelect.title = isWatchedFilePreview
425
+ ? "Read-only watched preview follows this file on disk."
426
+ : (isEditorOnlyMode
406
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."
407
- : "Right pane view mode. F7 cycles, including contextual Quarto Preview for file-backed .qmd, .md, and .markdown documents; Cmd/Ctrl+Alt+1–8 switches directly between the numbered views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.";
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.");
408
429
  }
409
430
 
410
431
  function getInitialRightView(source) {
@@ -2164,6 +2185,7 @@
2164
2185
  actionRequestId: null,
2165
2186
  };
2166
2187
  let fileBackedBaselineText = null;
2188
+ let fileBackedDiskRevision = null;
2167
2189
  let activePane = initialPaneFocusTarget === "right" ? "right" : "left";
2168
2190
  let paneFocusTarget = initialPaneFocusTarget;
2169
2191
  let paneSplitPercent = 50;
@@ -2923,6 +2945,7 @@
2923
2945
  rightTitleGroupEl.appendChild(rightViewSelect);
2924
2946
  rightIdentityEl.appendChild(rightTitleGroupEl);
2925
2947
  const rightToolsEl = makeStudioUiRefreshElement("div", "studio-refresh-pane-tools");
2948
+ if (watchedOpenEditableBtn && isWatchedFilePreview) rightToolsEl.appendChild(watchedOpenEditableBtn);
2926
2949
  if (exportPreviewControlsEl) {
2927
2950
  rightToolsEl.appendChild(exportPreviewControlsEl);
2928
2951
  } else if (exportPdfBtn) {
@@ -3846,7 +3869,7 @@
3846
3869
  }
3847
3870
 
3848
3871
  function getStudioDecisionFocusableElements() {
3849
- return [studioDecisionInputEl, studioDecisionCancelBtn, studioDecisionSecondaryBtn, studioDecisionConfirmBtn]
3872
+ return [studioDecisionInputEl, studioDecisionCancelBtn, studioDecisionTertiaryBtn, studioDecisionSecondaryBtn, studioDecisionConfirmBtn]
3850
3873
  .filter((element) => element && !element.hidden && !element.disabled);
3851
3874
  }
3852
3875
 
@@ -3894,6 +3917,30 @@
3894
3917
  cancelBtn.addEventListener("click", () => finishStudioDecision(null));
3895
3918
  actions.appendChild(cancelBtn);
3896
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
+
3897
3944
  const secondaryBtn = document.createElement("button");
3898
3945
  secondaryBtn.type = "button";
3899
3946
  secondaryBtn.className = "studio-decision-secondary";
@@ -3964,6 +4011,7 @@
3964
4011
  studioDecisionMessageEl = message;
3965
4012
  studioDecisionInputEl = input;
3966
4013
  studioDecisionCancelBtn = cancelBtn;
4014
+ studioDecisionTertiaryBtn = tertiaryBtn;
3967
4015
  studioDecisionSecondaryBtn = secondaryBtn;
3968
4016
  studioDecisionConfirmBtn = confirmBtn;
3969
4017
  return overlay;
@@ -3976,6 +4024,7 @@
3976
4024
  if (studioDecisionState) finishStudioDecision(null, false);
3977
4025
  const returnFocusEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
3978
4026
 
4027
+ const tertiaryLabel = String(settings.tertiaryLabel || "").trim();
3979
4028
  const secondaryLabel = String(settings.secondaryLabel || "").trim();
3980
4029
  studioDecisionTitleEl.textContent = String(settings.title || (mode === "prompt" ? "Enter a value" : "Confirm action"));
3981
4030
  studioDecisionMessageEl.textContent = String(settings.message || "");
@@ -3984,9 +4033,13 @@
3984
4033
  studioDecisionInputEl.placeholder = mode === "prompt" ? String(settings.placeholder || "") : "";
3985
4034
  studioDecisionInputEl.setAttribute("aria-label", String(settings.inputLabel || "Value"));
3986
4035
  studioDecisionCancelBtn.textContent = String(settings.cancelLabel || "Cancel");
4036
+ studioDecisionTertiaryBtn.hidden = !tertiaryLabel;
4037
+ studioDecisionTertiaryBtn.disabled = settings.tertiaryDisabled === true;
4038
+ studioDecisionTertiaryBtn.textContent = tertiaryLabel;
3987
4039
  studioDecisionSecondaryBtn.hidden = !secondaryLabel;
3988
4040
  studioDecisionSecondaryBtn.disabled = settings.secondaryDisabled === true;
3989
4041
  studioDecisionSecondaryBtn.textContent = secondaryLabel;
4042
+ studioDecisionConfirmBtn.disabled = settings.confirmDisabled === true;
3990
4043
  studioDecisionConfirmBtn.textContent = String(settings.confirmLabel || (mode === "prompt" ? "Continue" : "Confirm"));
3991
4044
  studioDecisionConfirmBtn.classList.toggle("is-destructive", settings.destructive === true);
3992
4045
  studioDecisionDialogEl.classList.toggle("is-destructive", settings.destructive === true);
@@ -3998,6 +4051,9 @@
3998
4051
  mode,
3999
4052
  resolve,
4000
4053
  returnFocusEl,
4054
+ onTertiary: typeof settings.onTertiary === "function" ? settings.onTertiary : null,
4055
+ hasTertiaryValue: Object.prototype.hasOwnProperty.call(settings, "tertiaryValue"),
4056
+ tertiaryValue: settings.tertiaryValue,
4001
4057
  onSecondary: typeof settings.onSecondary === "function" ? settings.onSecondary : null,
4002
4058
  hasSecondaryValue: Object.prototype.hasOwnProperty.call(settings, "secondaryValue"),
4003
4059
  secondaryValue: settings.secondaryValue,
@@ -4059,12 +4115,20 @@
4059
4115
  }
4060
4116
  });
4061
4117
 
4062
- 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) {
4063
4124
  fileBackedBaselineText = String(text || "");
4125
+ fileBackedDiskRevision = normalizeStudioDiskRevision(diskRevision);
4126
+ scheduleWorkspacePersistence();
4064
4127
  }
4065
4128
 
4066
4129
  function clearFileBackedBaseline() {
4067
4130
  fileBackedBaselineText = null;
4131
+ fileBackedDiskRevision = null;
4068
4132
  }
4069
4133
 
4070
4134
  function hasRefreshableFilePath() {
@@ -4079,13 +4143,17 @@
4079
4143
 
4080
4144
  function updateSourceBadge() {
4081
4145
  const label = sourceState && sourceState.label ? sourceState.label : "blank";
4082
- 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" : ""));
4083
4149
  const descriptor = getCurrentStudioDocumentDescriptor();
4084
4150
  if (sourceBadgeEl) {
4085
4151
  sourceBadgeEl.textContent = originText;
4086
- 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
4087
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.")
4088
- : ("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."));
4089
4157
  }
4090
4158
  if (sourceOriginSummaryEl) {
4091
4159
  sourceOriginSummaryEl.textContent = originText;
@@ -4544,10 +4612,14 @@
4544
4612
  }
4545
4613
 
4546
4614
  function triggerEditorSaveShortcut() {
4547
- if (saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
4615
+ if (hasRefreshableFilePath() && saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
4548
4616
  saveOverBtn.click();
4549
4617
  return true;
4550
4618
  }
4619
+ return triggerEditorSaveAsShortcut();
4620
+ }
4621
+
4622
+ function triggerEditorSaveAsShortcut() {
4551
4623
  if (saveAsBtn && !saveAsBtn.disabled && !saveAsBtn.hidden) {
4552
4624
  saveAsBtn.click();
4553
4625
  return true;
@@ -4890,6 +4962,22 @@
4890
4962
  return;
4891
4963
  }
4892
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
+
4893
4981
  const isSaveShortcut =
4894
4982
  key.toLowerCase() === "s"
4895
4983
  && (event.metaKey || event.ctrlKey)
@@ -4898,6 +4986,10 @@
4898
4986
 
4899
4987
  if (isSaveShortcut) {
4900
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
+ }
4901
4993
  triggerEditorSaveShortcut();
4902
4994
  return;
4903
4995
  }
@@ -5644,6 +5736,8 @@
5644
5736
  clearPreviewJumpHighlight(targetEl);
5645
5737
  finishPreviewRender(targetEl);
5646
5738
  targetEl.innerHTML = html;
5739
+ clearWatchedPreviewRenderError();
5740
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, text);
5647
5741
  if (pane === "response") {
5648
5742
  applyPendingResponseScrollReset();
5649
5743
  scheduleResponsePaneRepaintNudge();
@@ -7123,6 +7217,8 @@
7123
7217
  });
7124
7218
 
7125
7219
  targetEl.appendChild(shell);
7220
+ clearWatchedPreviewRenderError();
7221
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, html);
7126
7222
 
7127
7223
  if (pane === "response") {
7128
7224
  applyPendingResponseScrollReset();
@@ -9364,11 +9460,119 @@
9364
9460
 
9365
9461
  function hasMeaningfulPreviewContent(targetEl) {
9366
9462
  if (!targetEl || typeof targetEl.querySelector !== "function") return false;
9463
+ if (targetEl.dataset && targetEl.dataset.studioPreviewCommitted === "1") return true;
9367
9464
  if (targetEl.querySelector(".preview-loading")) return false;
9368
9465
  const text = typeof targetEl.textContent === "string" ? targetEl.textContent.trim() : "";
9369
9466
  return text.length > 0;
9370
9467
  }
9371
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
+
9372
9576
  function beginPreviewRender(targetEl) {
9373
9577
  if (!targetEl || !targetEl.classList) return;
9374
9578
 
@@ -10695,6 +10899,28 @@
10695
10899
  );
10696
10900
  }
10697
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
+
10698
10924
  async function applyRenderedMarkdown(targetEl, markdown, pane, nonce) {
10699
10925
  const previewPrepared = annotationsEnabled
10700
10926
  ? prepareMarkdownForPandocPreview(markdown)
@@ -10705,35 +10931,44 @@
10705
10931
  };
10706
10932
  const pdfPrepared = prepareStudioPdfBlocksForPreview(previewPrepared.markdown);
10707
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
+ };
10708
10943
 
10709
10944
  try {
10710
10945
  const renderedHtml = await renderMarkdownWithPandoc(pdfPrepared.markdown, {
10711
10946
  includeEditorLanguage: pane === "source" || rightView === "editor-preview",
10712
10947
  resourceContext: previewResourceContext,
10713
10948
  });
10714
-
10715
- if (pane === "source") {
10716
- if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
10717
- } else {
10718
- if (nonce !== responsePreviewRenderNonce || (rightView !== "preview" && rightView !== "editor-preview")) return;
10719
- }
10720
-
10721
- clearPreviewJumpHighlight(targetEl);
10722
- finishPreviewRender(targetEl);
10723
- targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown, previewFallbackOptions);
10724
- await hydrateStudioPreviewLocalMedia(targetEl, previewResourceContext);
10725
- await renderStudioPdfBlocksInElement(targetEl, pdfPrepared.blocks, previewingEditorText);
10726
- applyPreviewAnnotationPlaceholdersToElement(targetEl, previewPrepared.placeholders);
10727
- await renderAnnotationMathInElement(targetEl);
10728
- decoratePdfEmbeds(targetEl);
10729
- await renderPdfPreviewsInElement(targetEl);
10730
- 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);
10731
10964
  const annotationMode = (pane === "source" || pane === "response")
10732
10965
  ? (annotationsEnabled ? "highlight" : "hide")
10733
10966
  : "none";
10734
- applyAnnotationMarkersToElement(targetEl, annotationMode);
10735
- await renderMermaidInElement(targetEl);
10736
- await renderMathFallbackInElement(targetEl);
10967
+ applyAnnotationMarkersToElement(staging, annotationMode);
10968
+ await renderMermaidInElement(staging);
10969
+ if (abandonIfStale()) return;
10970
+ await renderMathFallbackInElement(staging);
10971
+ if (abandonIfStale()) return;
10737
10972
 
10738
10973
  const shouldDecoratePreviewComments = supportsPreviewCommentsForCurrentEditor()
10739
10974
  && (
@@ -10741,35 +10976,56 @@
10741
10976
  || (pane === "response" && rightView === "editor-preview")
10742
10977
  );
10743
10978
  if (shouldDecoratePreviewComments) {
10744
- decorateRenderedEditorPreviewComments(targetEl, sourceTextEl.value || "");
10979
+ decorateRenderedEditorPreviewComments(staging, sourceTextEl.value || "");
10745
10980
  }
10746
- decorateCopyablePreviewBlocks(targetEl);
10747
- decoratePreviewImages(targetEl);
10981
+ decorateCopyablePreviewBlocks(staging);
10982
+ decoratePreviewImages(staging);
10748
10983
 
10749
- // 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).
10750
10985
  if (!sourceState.path && !getCurrentResourceDirValue()) {
10751
10986
  var hasRelativeImages = /!\[.*?\]\((?!https?:\/\/|data:)[^)]+\)/.test(markdown || "");
10752
10987
  var hasLatexImages = /\\includegraphics/.test(markdown || "");
10753
10988
  if (hasRelativeImages || hasLatexImages) {
10754
- 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>.");
10755
10990
  }
10756
10991
  }
10992
+ if (abandonIfStale()) return;
10757
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);
10758
11003
  if (pane === "response") {
10759
11004
  applyPendingResponseScrollReset();
10760
11005
  scheduleResponsePaneRepaintNudge();
10761
11006
  }
10762
11007
  } catch (error) {
10763
- if (pane === "source") {
10764
- if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
10765
- } else {
10766
- 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;
10767
11013
  }
11014
+ if (!stillCurrent()) return;
10768
11015
 
10769
11016
  const detail = error && error.message ? error.message : String(error || "unknown error");
10770
11017
  clearPreviewJumpHighlight(targetEl);
10771
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
+ }
10772
11027
  targetEl.innerHTML = buildPreviewErrorHtml("Preview renderer unavailable (" + detail + "). Showing plain markdown.", markdown, previewFallbackOptions);
11028
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, markdown);
10773
11029
  if (pane === "response") {
10774
11030
  applyPendingResponseScrollReset();
10775
11031
  scheduleResponsePaneRepaintNudge();
@@ -11614,6 +11870,9 @@
11614
11870
  const newTabButton = newTabAction
11615
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>"
11616
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
+ : "";
11617
11876
  const openTitle = type === "directory"
11618
11877
  ? "Open folder"
11619
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"))));
@@ -11624,6 +11883,7 @@
11624
11883
  + "<span class='files-meta'>" + escapeHtml(metaParts.filter(Boolean).join(" · ")) + "</span>"
11625
11884
  + "</button>"
11626
11885
  + "<span class='files-actions'>"
11886
+ + watchButton
11627
11887
  + newTabButton
11628
11888
  + "<button type='button' data-files-action='copy-path' data-files-path='" + escapeHtml(path) + "'>Copy path</button>"
11629
11889
  + (type === "file" ? "<button type='button' data-files-action='reveal' data-files-path='" + escapeHtml(path) + "'>Reveal</button>" : "")
@@ -11797,7 +12057,7 @@
11797
12057
  function ensureCurrentEditorFileBackedFromFilesPath(path) {
11798
12058
  const cleanPath = stripPreviewLocalLinkUrlSuffix(path || "").trim();
11799
12059
  if (!isLikelyAbsoluteStudioPath(cleanPath)) return;
11800
- if (sourceState && sourceState.path === cleanPath) return;
12060
+ if (sourceState && sourceState.path) return;
11801
12061
  const resourceDir = normalizeStudioResourceDirValue(fileBrowserState.rootDir || getCurrentResourceDirValue() || dirnameForDisplayPath(cleanPath));
11802
12062
  if (resourceDirInput && resourceDir) resourceDirInput.value = resourceDir;
11803
12063
  setSourceState({
@@ -11805,7 +12065,7 @@
11805
12065
  label: sourceState && sourceState.label && sourceState.label !== "blank" ? sourceState.label : basenameForStudioPath(cleanPath),
11806
12066
  path: cleanPath,
11807
12067
  });
11808
- markFileBackedBaseline(sourceTextEl.value);
12068
+ markFileBackedBaseline(sourceTextEl.value, null);
11809
12069
  }
11810
12070
 
11811
12071
  async function openFileBrowserEntry(path, kind) {
@@ -11945,6 +12205,10 @@
11945
12205
  await openPreviewDocumentInNewEditor(path, getFileBrowserLocalLinkContext());
11946
12206
  return;
11947
12207
  }
12208
+ if (action === "watch-new") {
12209
+ await openPreviewDocumentInWatchedPreview(path, getFileBrowserLocalLinkContext());
12210
+ return;
12211
+ }
11948
12212
  if (action === "open-preview-new") {
11949
12213
  await openPreviewResourceInNewEditor(path, getFileBrowserLocalLinkContext());
11950
12214
  return;
@@ -13575,13 +13839,13 @@
13575
13839
  function updateSaveFileTooltip() {
13576
13840
  if (!saveOverBtn) return;
13577
13841
 
13578
- var effectivePath = getEffectiveSavePath();
13842
+ var effectivePath = sourceState && sourceState.path ? sourceState.path : "";
13579
13843
  if (effectivePath) {
13580
- 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.";
13581
13845
  return;
13582
13846
  }
13583
13847
 
13584
- 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.";
13585
13849
  }
13586
13850
 
13587
13851
  function updateRefreshFromDiskTooltip() {
@@ -13596,13 +13860,13 @@
13596
13860
  }
13597
13861
 
13598
13862
  function syncActionButtons() {
13599
- const canSaveOver = Boolean(getEffectiveSavePath());
13863
+ const canSaveOver = hasRefreshableFilePath();
13600
13864
  const canRefreshFromDisk = hasRefreshableFilePath();
13601
13865
 
13602
- fileInput.disabled = uiBusy;
13603
- if (importFileBtn) importFileBtn.disabled = uiBusy;
13604
- if (sourceBadgeEl) sourceBadgeEl.disabled = uiBusy;
13605
- 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;
13606
13870
  if (sourceOpenCurrentFileTabBtn) {
13607
13871
  sourceOpenCurrentFileTabBtn.disabled = uiBusy || !hasRefreshableFilePath();
13608
13872
  sourceOpenCurrentFileTabBtn.title = hasRefreshableFilePath()
@@ -13610,17 +13874,21 @@
13610
13874
  : "Available after opening a file-backed document.";
13611
13875
  }
13612
13876
  if (sourceOpenCurrentTextCopyTabBtn) sourceOpenCurrentTextCopyTabBtn.disabled = uiBusy || wsState !== "Ready" || !String(sourceTextEl.value || "").trim();
13613
- saveAsBtn.disabled = uiBusy;
13614
- saveOverBtn.disabled = uiBusy || !canSaveOver;
13615
- if (refreshFromDiskBtn) refreshFromDiskBtn.disabled = uiBusy || !canRefreshFromDisk;
13616
- 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;
13617
13881
  sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
13618
- 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
+ }
13619
13887
  syncRunAndCritiqueButtons();
13620
13888
  copyDraftBtn.disabled = uiBusy;
13621
13889
  if (suggestCompletionBtn) {
13622
13890
  const hasSuggestionForCurrentText = Boolean(completionSuggestionState && sourceTextEl && sourceTextEl.value === completionSuggestionState.baseText);
13623
- suggestCompletionBtn.disabled = wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
13891
+ suggestCompletionBtn.disabled = isWatchedFilePreview || wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
13624
13892
  suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Stop" : (hasSuggestionForCurrentText ? "Try another" : "Suggest");
13625
13893
  suggestCompletionBtn.title = completionSuggestionInFlight
13626
13894
  ? "Stop the current suggestion request."
@@ -13636,15 +13904,15 @@
13636
13904
  if (highlightSelect) highlightSelect.disabled = uiBusy;
13637
13905
  if (lineNumbersSelect) lineNumbersSelect.disabled = uiBusy;
13638
13906
  if (annotationModeSelect) annotationModeSelect.disabled = uiBusy;
13639
- if (saveAnnotatedBtn) saveAnnotatedBtn.disabled = uiBusy;
13640
- 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);
13641
13909
  if (compactBtn) compactBtn.disabled = isEditorOnlyMode || uiBusy || compactInProgress || wsState === "Disconnected";
13642
13910
  editorViewSelect.disabled = isEditorOnlyMode;
13643
13911
  syncRightViewModeOptions();
13644
- rightViewSelect.disabled = false;
13912
+ rightViewSelect.disabled = isWatchedFilePreview;
13645
13913
  followSelect.disabled = isEditorOnlyMode || uiBusy;
13646
13914
  if (responseHighlightSelect) responseHighlightSelect.disabled = isEditorOnlyMode || rightView !== "markdown";
13647
- insertHeaderBtn.disabled = uiBusy;
13915
+ insertHeaderBtn.disabled = uiBusy || isWatchedFilePreview;
13648
13916
  lensSelect.disabled = uiBusy || isEditorOnlyMode;
13649
13917
  updateSaveFileTooltip();
13650
13918
  updateRefreshFromDiskTooltip();
@@ -13661,9 +13929,14 @@
13661
13929
 
13662
13930
  function setSourceState(next, options) {
13663
13931
  const previousDescriptor = getCurrentStudioDocumentDescriptor();
13932
+ const previousPath = sourceState && sourceState.path ? sourceState.path : null;
13664
13933
  const previousQuartoPath = getCurrentStudioQuartoSourcePath();
13665
13934
  const previousPreviewResourceContext = getHtmlPreviewResourceContextOptions();
13666
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
+ }
13667
13940
  sourceState = {
13668
13941
  source: next && next.source ? next.source : "blank",
13669
13942
  label: next && next.label ? next.label : "blank",
@@ -13681,7 +13954,7 @@
13681
13954
  quartoPreviewActionRequestId = null;
13682
13955
  quartoPreviewLogVisible = false;
13683
13956
  }
13684
- if (!sourceState.path) {
13957
+ if (!sourceState.path || sourceState.path !== previousPath) {
13685
13958
  clearFileBackedBaseline();
13686
13959
  }
13687
13960
  syncRightViewModeOptions();
@@ -13788,6 +14061,7 @@
13788
14061
  version: 1,
13789
14062
  savedAt: lastWorkspacePersistenceSavedAt,
13790
14063
  sourceState: normalizeWorkspaceSourceState(sourceState),
14064
+ diskRevision: fileBackedDiskRevision,
13791
14065
  resourceDir: getCurrentResourceDirValue(),
13792
14066
  editorView,
13793
14067
  rightView: normalizeRightViewValue(rightView),
@@ -13823,7 +14097,7 @@
13823
14097
  }
13824
14098
 
13825
14099
  function persistWorkspaceStateNow(options) {
13826
- if (!workspacePersistenceReady) return;
14100
+ if (!workspacePersistenceReady || isWatchedFilePreview) return;
13827
14101
  try {
13828
14102
  const payload = buildWorkspacePersistencePayload();
13829
14103
  if (payload.text.length > STUDIO_WORKSPACE_MAX_TEXT_CHARS) {
@@ -13845,7 +14119,7 @@
13845
14119
  }
13846
14120
 
13847
14121
  function scheduleWorkspacePersistence() {
13848
- if (!workspacePersistenceReady || workspacePersistTimer !== null) return;
14122
+ if (!workspacePersistenceReady || isWatchedFilePreview || workspacePersistTimer !== null) return;
13849
14123
  workspacePersistTimer = window.setTimeout(() => {
13850
14124
  workspacePersistTimer = null;
13851
14125
  persistWorkspaceStateNow();
@@ -13853,6 +14127,7 @@
13853
14127
  }
13854
14128
 
13855
14129
  function flushWorkspacePersistence(options) {
14130
+ if (isWatchedFilePreview) return;
13856
14131
  if (workspacePersistTimer !== null) {
13857
14132
  window.clearTimeout(workspacePersistTimer);
13858
14133
  workspacePersistTimer = null;
@@ -13882,9 +14157,17 @@
13882
14157
  if (!shouldRestorePersistedWorkspaceState(state)) return false;
13883
14158
  const nextSourceState = normalizeWorkspaceSourceState(state.sourceState);
13884
14159
  const nextResourceDir = normalizeStudioResourceDirValue(typeof state.resourceDir === "string" ? state.resourceDir : "");
14160
+ const currentBaselineText = fileBackedBaselineText;
14161
+ const currentDiskRevision = fileBackedDiskRevision;
14162
+ const persistedDiskRevision = normalizeStudioDiskRevision(state.diskRevision);
13885
14163
  if (resourceDirInput) resourceDirInput.value = nextResourceDir;
13886
14164
  setEditorText(state.text, { preserveScroll: false, preserveSelection: false });
13887
14165
  setSourceState(nextSourceState);
14166
+ if (nextSourceState.path) {
14167
+ fileBackedBaselineText = currentBaselineText;
14168
+ fileBackedDiskRevision = persistedDiskRevision
14169
+ || (currentBaselineText !== null && state.text === currentBaselineText ? currentDiskRevision : null);
14170
+ }
13888
14171
  if (resourceDirInput && nextResourceDir) {
13889
14172
  resourceDirInput.value = nextResourceDir;
13890
14173
  updateSourceBadge();
@@ -13970,6 +14253,10 @@
13970
14253
  }
13971
14254
 
13972
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
+ }
13973
14260
  const value = String(nextText || "");
13974
14261
  const preserveScroll = Boolean(options && options.preserveScroll);
13975
14262
  const preserveSelection = Boolean(options && options.preserveSelection);
@@ -14017,9 +14304,14 @@
14017
14304
  updateEditorSelectionCommentUi();
14018
14305
  updateOutlineUi();
14019
14306
  scheduleWorkspacePersistence();
14307
+ return true;
14020
14308
  }
14021
14309
 
14022
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
+ }
14023
14315
  const value = String(nextText || "");
14024
14316
  sourceTextEl.value = value;
14025
14317
  const maxIndex = value.length;
@@ -14031,6 +14323,7 @@
14031
14323
  if (editorView === "markdown") {
14032
14324
  scheduleEditorLineNumberRender();
14033
14325
  }
14326
+ return true;
14034
14327
  }
14035
14328
 
14036
14329
  function readCompletionSuggestionContextMode() {
@@ -15191,11 +15484,12 @@
15191
15484
  appendPreviewLinkMenuButton(menu, "Open in new Studio tab", "open-preview-new");
15192
15485
  appendPreviewLinkMenuButton(menu, "Open in system viewer", "open-system");
15193
15486
  } else if (kind === "text") {
15487
+ appendPreviewLinkMenuButton(menu, "Preview file (follow changes)", "watch-new");
15194
15488
  appendPreviewLinkMenuButton(menu, "Open file tab", "open-new");
15195
- appendPreviewLinkMenuButton(menu, "Open here", "open-here");
15489
+ if (!isWatchedFilePreview) appendPreviewLinkMenuButton(menu, "Open here", "open-here");
15196
15490
  } else if (kind === "office") {
15197
15491
  appendPreviewLinkMenuButton(menu, "Convert tab", "open-new");
15198
- appendPreviewLinkMenuButton(menu, "Convert here", "open-here");
15492
+ if (!isWatchedFilePreview) appendPreviewLinkMenuButton(menu, "Convert here", "open-here");
15199
15493
  } else if (kind === "image") {
15200
15494
  appendPreviewLinkMenuButton(menu, "Open image preview", "open-image");
15201
15495
  appendPreviewLinkMenuButton(menu, "Open in new Studio tab", "open-preview-new");
@@ -15291,9 +15585,15 @@
15291
15585
  }
15292
15586
 
15293
15587
  async function fetchPreviewLocalLink(action, href, contextOverride, options) {
15294
- const request = () => fetchStudioJson("/local-preview-link", {
15295
- query: { ...getPreviewLinkResourceQuery(href, contextOverride), action },
15296
- });
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
+ };
15297
15597
  try {
15298
15598
  return await request();
15299
15599
  } catch (error) {
@@ -15385,6 +15685,9 @@
15385
15685
  }
15386
15686
 
15387
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
+ }
15388
15691
  if (!(await confirmPreviewOfficeConversion(href, "here"))) return;
15389
15692
  if (editorHasPotentialUnsavedContent()) {
15390
15693
  const kind = getPreviewLocalLinkKind(href);
@@ -15414,7 +15717,7 @@
15414
15717
  setSourceState({ source: "blank", label, path: null });
15415
15718
  } else {
15416
15719
  setSourceState({ source: "file", label, path });
15417
- markFileBackedBaseline(payload.text);
15720
+ markFileBackedBaseline(payload.text, payload.diskRevision);
15418
15721
  }
15419
15722
  const detected = converted ? "markdown" : detectLanguageFromName(path || label);
15420
15723
  if (detected) setEditorLanguage(detected);
@@ -15445,6 +15748,25 @@
15445
15748
  }
15446
15749
  }
15447
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
+ }
15766
+ throw error;
15767
+ }
15768
+ }
15769
+
15448
15770
  async function openPreviewResourceInNewEditor(href, contextOverride) {
15449
15771
  let launch = null;
15450
15772
  try {
@@ -15500,6 +15822,10 @@
15500
15822
  await openPreviewDocumentInNewEditor(href, context);
15501
15823
  return;
15502
15824
  }
15825
+ if (action === "watch-new") {
15826
+ await openPreviewDocumentInWatchedPreview(href, context);
15827
+ return;
15828
+ }
15503
15829
  if (action === "open-preview-new") {
15504
15830
  await openPreviewResourceInNewEditor(href, context);
15505
15831
  return;
@@ -16861,6 +17187,8 @@
16861
17187
  ensurePreviewSelectionActions(targetEl);
16862
17188
  updatePreviewCommentBlocksForElement(targetEl);
16863
17189
  decorateCopyablePreviewBlocks(targetEl);
17190
+ clearWatchedPreviewRenderError();
17191
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, text);
16864
17192
  if (pane === "response") {
16865
17193
  applyPendingResponseScrollReset();
16866
17194
  scheduleResponsePaneRepaintNudge();
@@ -21734,7 +22062,11 @@
21734
22062
  } catch {}
21735
22063
  }
21736
22064
 
21737
- 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
+ }
21738
22070
  editorLanguage = (lang && SUPPORTED_LANGUAGES.indexOf(lang) !== -1) ? lang : "markdown";
21739
22071
  persistEditorLanguage(editorLanguage);
21740
22072
  syncHighlightSelectUi();
@@ -21749,6 +22081,7 @@
21749
22081
  }
21750
22082
  updateOutlineUi();
21751
22083
  scheduleWorkspacePersistence();
22084
+ return true;
21752
22085
  }
21753
22086
 
21754
22087
  function setEditorHighlightMode(mode) {
@@ -22050,11 +22383,90 @@
22050
22383
  return true;
22051
22384
  }
22052
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
+
22053
22452
  function handleServerMessage(message) {
22054
22453
  if (!message || typeof message !== "object") return;
22055
22454
 
22056
22455
  debugTrace("server_message", summarizeServerMessage(message));
22057
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
+
22058
22470
  const contextChanged = applyContextUsageFromMessage(message);
22059
22471
  if (contextChanged) {
22060
22472
  updateFooterMeta();
@@ -22291,7 +22703,11 @@
22291
22703
  message.initialDocument &&
22292
22704
  typeof message.initialDocument.text === "string"
22293
22705
  ) {
22294
- setEditorText(message.initialDocument.text, { preserveScroll: false, preserveSelection: false });
22706
+ setEditorText(message.initialDocument.text, {
22707
+ preserveScroll: false,
22708
+ preserveSelection: false,
22709
+ allowWatchedFileUpdate: isWatchedFilePreview,
22710
+ });
22295
22711
  initialDocumentApplied = true;
22296
22712
  loadedInitialDocument = true;
22297
22713
  setSourceState({
@@ -22303,7 +22719,7 @@
22303
22719
  : (initialSourceState.draftId || null),
22304
22720
  });
22305
22721
  if (message.initialDocument.path) {
22306
- markFileBackedBaseline(message.initialDocument.text);
22722
+ markFileBackedBaseline(message.initialDocument.text, message.initialDocument.diskRevision);
22307
22723
  }
22308
22724
  refreshResponseUi();
22309
22725
  if (typeof message.initialDocument.label === "string" && message.initialDocument.label.length > 0) {
@@ -22664,7 +23080,19 @@
22664
23080
  return;
22665
23081
  }
22666
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
+
22667
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);
22668
23096
  if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
22669
23097
  pendingRequestId = null;
22670
23098
  pendingKind = null;
@@ -22683,7 +23111,7 @@
22683
23111
  }, {
22684
23112
  carryCurrentMetadataToNewDocument: true,
22685
23113
  });
22686
- markFileBackedBaseline(sourceTextEl.value);
23114
+ markFileBackedBaseline(savedOperation && typeof savedOperation.content === "string" ? savedOperation.content : sourceTextEl.value, message.diskRevision);
22687
23115
  }
22688
23116
  setBusy(false);
22689
23117
  setWsState("Ready");
@@ -22703,6 +23131,10 @@
22703
23131
  }
22704
23132
 
22705
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
+ }
22706
23138
  if (typeof message.requestId === "string" && pendingRequestId && message.requestId !== pendingRequestId) {
22707
23139
  return;
22708
23140
  }
@@ -22726,6 +23158,10 @@
22726
23158
  }
22727
23159
 
22728
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
+ }
22729
23165
  const nextDoc = message.document;
22730
23166
  if (!nextDoc || typeof nextDoc !== "object" || typeof nextDoc.text !== "string") {
22731
23167
  return;
@@ -22763,7 +23199,7 @@
22763
23199
  draftId: typeof nextDoc.draftId === "string" && nextDoc.draftId.trim() ? nextDoc.draftId.trim() : null,
22764
23200
  });
22765
23201
  if (nextPath) {
22766
- markFileBackedBaseline(nextDoc.text);
23202
+ markFileBackedBaseline(nextDoc.text, nextDoc.diskRevision);
22767
23203
  }
22768
23204
  refreshResponseUi();
22769
23205
  setStatus(
@@ -22906,6 +23342,7 @@
22906
23342
 
22907
23343
  if (message.type === "busy") {
22908
23344
  if (typeof message.requestId === "string") {
23345
+ pendingSaveOperations.delete(message.requestId);
22909
23346
  failPendingCompanionLaunch(message.requestId, "Studio could not start the companion editor because another request was busy.");
22910
23347
  }
22911
23348
  if (message.requestId && pendingRequestId === message.requestId) {
@@ -22927,6 +23364,7 @@
22927
23364
 
22928
23365
  if (message.type === "error") {
22929
23366
  if (typeof message.requestId === "string") {
23367
+ pendingSaveOperations.delete(message.requestId);
22930
23368
  failPendingCompanionLaunch(message.requestId, "Studio could not prepare the companion editor. Return to the originating Studio page for details.");
22931
23369
  }
22932
23370
  if (message.requestId && pendingRequestId === message.requestId) {
@@ -23033,6 +23471,11 @@
23033
23471
  if (studioMode !== "full") {
23034
23472
  wsParams.set("mode", studioMode);
23035
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
+ }
23036
23479
  if (DEBUG_ENABLED) {
23037
23480
  wsParams.set("debug", "1");
23038
23481
  }
@@ -23083,6 +23526,14 @@
23083
23526
  return;
23084
23527
  }
23085
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
+
23086
23537
  if (kind === "shutdown") {
23087
23538
  clearScheduledReconnect();
23088
23539
  reconnectAttempt = 0;
@@ -23098,14 +23549,28 @@
23098
23549
  };
23099
23550
 
23100
23551
  socket.addEventListener("open", () => {
23552
+ if (ws !== socket) {
23553
+ try { socket.close(); } catch {}
23554
+ return;
23555
+ }
23101
23556
  window.clearTimeout(connectWatchdog);
23102
23557
  setWsState("Ready");
23103
23558
  setStatus(wasReconnect ? "Reconnected. Syncing…" : "Connected. Syncing…");
23104
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
+ }
23105
23569
  reconnectAttempt = 0;
23106
23570
  });
23107
23571
 
23108
23572
  socket.addEventListener("message", (event) => {
23573
+ if (ws !== socket) return;
23109
23574
  try {
23110
23575
  const message = JSON.parse(event.data);
23111
23576
  handleServerMessage(message);
@@ -23116,6 +23581,7 @@
23116
23581
  });
23117
23582
 
23118
23583
  socket.addEventListener("close", (event) => {
23584
+ if (ws !== socket && !disconnectHandled) return;
23119
23585
  if (event && event.code === 4001) {
23120
23586
  handleDisconnect("invalidated", 4001);
23121
23587
  return;
@@ -23124,6 +23590,10 @@
23124
23590
  handleDisconnect("full_conflict", 4004);
23125
23591
  return;
23126
23592
  }
23593
+ if (event && event.code === 4003) {
23594
+ handleDisconnect("watch_unauthorized", 4003);
23595
+ return;
23596
+ }
23127
23597
  if (event && event.code === 1001) {
23128
23598
  handleDisconnect("shutdown", 1001);
23129
23599
  return;
@@ -23133,6 +23603,7 @@
23133
23603
  });
23134
23604
 
23135
23605
  socket.addEventListener("error", () => {
23606
+ if (ws !== socket) return;
23136
23607
  handleDisconnect("error");
23137
23608
  });
23138
23609
  }
@@ -23361,6 +23832,25 @@
23361
23832
  });
23362
23833
  }
23363
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
+
23364
23854
  updatePaneFocusButtons();
23365
23855
  window.addEventListener("keydown", handlePaneShortcut);
23366
23856
  window.addEventListener("pagehide", () => {
@@ -23882,100 +24372,249 @@
23882
24372
  setFooterThemeMenuOpen(false);
23883
24373
  });
23884
24374
 
23885
- saveAsBtn.addEventListener("click", async () => {
23886
- const content = sourceTextEl.value;
23887
- if (!content.trim()) {
23888
- setStatus("Editor is empty. Nothing to save.", "warning");
23889
- return;
24375
+ function abandonPendingSaveRequest(requestId) {
24376
+ pendingSaveOperations.delete(requestId);
24377
+ if (requestId) clearArmedTitleAttention(requestId);
24378
+ if (pendingRequestId === requestId) {
24379
+ pendingRequestId = null;
24380
+ pendingKind = null;
24381
+ }
24382
+ stickyStudioKind = null;
24383
+ setBusy(false);
24384
+ setWsState("Ready");
24385
+ }
24386
+
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;
23890
24413
  }
24414
+ return true;
24415
+ }
23891
24416
 
23892
- var suggestedName = sourceState.label ? stripImportedFileLabel(sourceState.label) : "draft.md";
23893
- var suggestedDir = getCurrentResourceDirValue() ? getCurrentResourceDirValue().replace(/\/$/, "") + "/" : "./";
23894
- const suggested = sourceState.path || (suggestedDir + suggestedName);
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"));
23895
24426
  const path = await requestStudioTextInput("Save editor content as:", suggested, {
23896
24427
  title: "Save editor as",
23897
24428
  confirmLabel: "Save",
24429
+ inputLabel: "File path",
23898
24430
  });
23899
- if (!path) return;
23900
-
23901
- const requestId = beginUiAction("save_as");
23902
- 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
+ }
23903
24440
 
23904
- const sent = sendMessage({
23905
- type: "save_as_request",
23906
- 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",
23907
24454
  path,
23908
- content,
23909
- });
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
+ }
23910
24477
 
23911
- 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 })) {
23912
24498
  pendingRequestId = null;
23913
24499
  pendingKind = null;
24500
+ stickyStudioKind = null;
23914
24501
  setBusy(false);
24502
+ setWsState("Ready");
24503
+ return false;
23915
24504
  }
23916
- });
23917
-
23918
- saveOverBtn.addEventListener("click", async () => {
23919
- var effectivePath = getEffectiveSavePath();
23920
- if (!effectivePath) {
23921
- setStatus("Save editor requires a file path. Open via /studio <path>, set a working dir, or use Save editor as…", "warning");
23922
- return;
23923
- }
24505
+ return true;
24506
+ }
23924
24507
 
23925
- const confirmed = await requestStudioConfirmation("Overwrite " + effectivePath + "?", {
23926
- 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",
23927
24536
  confirmLabel: "Overwrite",
24537
+ confirmDisabled: !canOverwrite,
23928
24538
  destructive: true,
23929
24539
  });
23930
- if (!confirmed) return;
23931
-
23932
- const requestId = beginUiAction("save_over");
23933
- 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
+ }
23934
24559
 
23935
- // Use save_as with the effective path for both file-backed and derived paths
23936
- const sent = sendMessage({
23937
- type: "save_as_request",
23938
- requestId,
23939
- 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 : "",
23940
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,
23941
24594
  });
23942
-
23943
- if (!sent) {
23944
- pendingRequestId = null;
23945
- pendingKind = null;
23946
- setBusy(false);
24595
+ if (decision === "choose-another") {
24596
+ await openEditorSaveAsDialog({ content: operation.content, suggestedPath: conflictPath, reportCancellation: true });
24597
+ return;
23947
24598
  }
23948
- });
23949
-
23950
- if (refreshFromDiskBtn) {
23951
- refreshFromDiskBtn.addEventListener("click", async () => {
23952
- if (!hasRefreshableFilePath()) {
23953
- 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");
23954
- return;
23955
- }
23956
-
23957
- if (editorDiffersFromFileBackedBaseline()) {
23958
- const confirmed = await requestStudioConfirmation(
23959
- "Replace current editor contents with the latest version from disk?",
23960
- { title: "Refresh from disk?", confirmLabel: "Replace", destructive: true },
23961
- );
23962
- if (!confirmed) return;
23963
- }
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
+ }
23964
24605
 
23965
- const requestId = beginUiAction("refresh_from_disk");
23966
- if (!requestId) return;
24606
+ saveAsBtn.addEventListener("click", () => {
24607
+ void openEditorSaveAsDialog();
24608
+ });
23967
24609
 
23968
- const sent = sendMessage({
23969
- type: "refresh_from_disk_request",
23970
- requestId,
23971
- path: sourceState.path,
23972
- });
24610
+ saveOverBtn.addEventListener("click", () => {
24611
+ if (uiBusy) return;
24612
+ sendEditorSaveOverRequest();
24613
+ });
23973
24614
 
23974
- if (!sent) {
23975
- pendingRequestId = null;
23976
- pendingKind = null;
23977
- setBusy(false);
23978
- }
24615
+ if (refreshFromDiskBtn) {
24616
+ refreshFromDiskBtn.addEventListener("click", () => {
24617
+ void requestEditorRefreshFromDisk();
23979
24618
  });
23980
24619
  }
23981
24620
 
@@ -24709,7 +25348,16 @@
24709
25348
  resourceDirInput.value = normalizeStudioResourceDirValue(initialResourceDir);
24710
25349
  }
24711
25350
  setSourceState(initialSourceState);
24712
- 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
+ }
24713
25361
  refreshResponseUi();
24714
25362
  updateAnnotatedReplyHeaderButton();
24715
25363
  setActivePane(initialPaneFocusTarget === "off" ? "left" : initialPaneFocusTarget);
@@ -24722,7 +25370,9 @@
24722
25370
 
24723
25371
  const initialDetectedLang = detectLanguageFromName(initialSourceState.path || initialSourceState.label || "");
24724
25372
  const storedLang = readStoredEditorLanguage();
24725
- setEditorLanguage(initialDetectedLang || storedLang || "markdown");
25373
+ setEditorLanguage(initialDetectedLang || storedLang || "markdown", {
25374
+ allowWatchedFileUpdate: isWatchedFilePreview,
25375
+ });
24726
25376
 
24727
25377
  const storedLineNumbersEnabled = readStoredEditorLineNumbersEnabled();
24728
25378
  const initialLineNumbersEnabled = storedLineNumbersEnabled ?? Boolean(lineNumbersSelect && lineNumbersSelect.value === "on");