pi-studio 0.9.53 → 0.9.55

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.
@@ -74,7 +74,9 @@
74
74
  : null;
75
75
  const referenceBadgeEl = document.getElementById("referenceBadge");
76
76
  const editorViewSelect = document.getElementById("editorViewSelect");
77
+ const editorViewSelectWrap = document.getElementById("editorViewSelectWrap");
77
78
  const rightViewSelect = document.getElementById("rightViewSelect");
79
+ const rightViewSelectWrap = document.getElementById("rightViewSelectWrap");
78
80
  const followSelect = document.getElementById("followSelect");
79
81
  const responseHighlightSelect = document.getElementById("responseHighlightSelect");
80
82
  const responseFontSizeSelect = document.getElementById("responseFontSizeSelect");
@@ -155,6 +157,7 @@
155
157
  const shortcutsCloseBtn = document.getElementById("shortcutsCloseBtn");
156
158
  const leftFocusBtn = document.getElementById("leftFocusBtn");
157
159
  const rightFocusBtn = document.getElementById("rightFocusBtn");
160
+ const watchedOpenEditableBtn = document.getElementById("watchedOpenEditableBtn");
158
161
  const reviewNotesBtn = document.getElementById("reviewNotesBtn");
159
162
  const outlineBtn = document.getElementById("outlineBtn");
160
163
  const scratchpadBtn = document.getElementById("scratchpadBtn");
@@ -192,6 +195,7 @@
192
195
  ? "editor-only"
193
196
  : "full";
194
197
  const isEditorOnlyMode = studioMode === "editor-only";
198
+ const isWatchedFilePreview = Boolean(document.body && document.body.dataset && document.body.dataset.watchedFilePreview === "1");
195
199
  const isSshStudioSession = Boolean(document.body && document.body.dataset && document.body.dataset.sshSession === "1");
196
200
  const EDITOR_ONLY_RIGHT_VIEW_ALLOWED = new Set(["editor-preview", "editor-quarto-preview", "files", "changes", "repl", "side-questions"]);
197
201
  const RIGHT_VIEW_LABELS = {
@@ -282,6 +286,16 @@
282
286
  };
283
287
  const initialResourceDir = initialQueryParams.get("resourceDir")
284
288
  || ((document.body && document.body.dataset && document.body.dataset.initialResourceDir) || "");
289
+ const initialDiskRevision = (document.body && document.body.dataset && document.body.dataset.initialDiskRevision) || "";
290
+ let watchedFilePreviewState = {
291
+ enabled: isWatchedFilePreview,
292
+ path: isWatchedFilePreview && initialSourceState.path ? initialSourceState.path : "",
293
+ diskRevision: isWatchedFilePreview ? initialDiskRevision : "",
294
+ generation: 0,
295
+ lastError: "",
296
+ renderError: "",
297
+ };
298
+ const watchedFilePreviewReadingPositions = { source: null, response: null };
285
299
 
286
300
  let ws = null;
287
301
  let wsState = "Connecting";
@@ -333,6 +347,7 @@
333
347
  let studioDecisionMessageEl = null;
334
348
  let studioDecisionInputEl = null;
335
349
  let studioDecisionCancelBtn = null;
350
+ let studioDecisionTertiaryBtn = null;
336
351
  let studioDecisionSecondaryBtn = null;
337
352
  let studioDecisionConfirmBtn = null;
338
353
  let studioDecisionState = null;
@@ -340,6 +355,7 @@
340
355
  let pendingRequestId = null;
341
356
  let pendingKind = null;
342
357
  let stickyStudioKind = null;
358
+ const pendingSaveOperations = new Map();
343
359
  const pendingCompanionLaunches = new Map();
344
360
  const activeStudioTabLaunches = new Set();
345
361
  let sourceOriginSummaryEl = null;
@@ -369,6 +385,7 @@
369
385
 
370
386
  function normalizeRightViewValue(nextView) {
371
387
  const normalized = canonicalRightViewValue(nextView);
388
+ if (isWatchedFilePreview && normalized !== "editor-preview") return "editor-preview";
372
389
  if (normalized === "editor-quarto-preview" && !isCurrentStudioQuartoDocument()) {
373
390
  return "editor-preview";
374
391
  }
@@ -380,6 +397,7 @@
380
397
 
381
398
  function isRightViewAvailableInCurrentMode(view) {
382
399
  const normalized = canonicalRightViewValue(view);
400
+ if (isWatchedFilePreview) return normalized === "editor-preview";
383
401
  if (normalized === "editor-quarto-preview" && !isCurrentStudioQuartoDocument()) return false;
384
402
  return !isEditorOnlyMode || EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(normalized);
385
403
  }
@@ -399,12 +417,17 @@
399
417
  Array.from(rightViewSelect.options).forEach((option) => {
400
418
  if (!option) return;
401
419
  const isQuartoOption = option.value === "editor-quarto-preview";
420
+ if (isWatchedFilePreview && option.value === "editor-preview") option.textContent = "Watched preview";
402
421
  if (isQuartoOption) option.hidden = !quartoRelevant;
403
- option.disabled = (isEditorOnlyMode && !EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(option.value)) || (isQuartoOption && !quartoRelevant);
422
+ option.disabled = (isWatchedFilePreview && option.value !== "editor-preview")
423
+ || (isEditorOnlyMode && !EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(option.value))
424
+ || (isQuartoOption && !quartoRelevant);
404
425
  });
405
- rightViewSelect.title = isEditorOnlyMode
426
+ rightViewSelect.title = isWatchedFilePreview
427
+ ? "Read-only watched preview follows this file on disk."
428
+ : (isEditorOnlyMode
406
429
  ? "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.";
430
+ : "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
431
  }
409
432
 
410
433
  function getInitialRightView(source) {
@@ -465,6 +488,7 @@
465
488
  const EDITOR_TAB_TEXT = " ";
466
489
  const QUIZ_DEFAULT_COUNT = 5;
467
490
  const SIDE_QUESTION_THINKING_STORAGE_KEY = "piStudio.sideQuestionThinking";
491
+ const SIDE_QUESTION_THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
468
492
  const SIDE_QUESTION_GATHER_STORAGE_KEY = "piStudio.sideQuestionGatherScope";
469
493
  const SIDE_QUESTION_TOOLS_STORAGE_KEY = "piStudio.sideQuestionTools";
470
494
  const SIDE_QUESTION_TRANSCRIPT_RENDER_MAX_CHARS = 400_000;
@@ -475,6 +499,7 @@
475
499
  const QUIZ_ANGLES = ["general", "scientist", "mathematician", "statistician", "developer", "reviewer"];
476
500
  const QUIZ_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
477
501
  let sideQuestionState = null;
502
+ let sideQuestionThinkingLevels = [...SIDE_QUESTION_THINKING_LEVELS];
478
503
  let sideQuestionWebSearchAvailable = false;
479
504
  let sideQuestionAvailablePiTools = [];
480
505
  let sideQuestionPreviewRenderNonce = 0;
@@ -503,11 +528,12 @@
503
528
  thinking: (() => {
504
529
  try {
505
530
  const value = window.localStorage && window.localStorage.getItem(SIDE_QUESTION_THINKING_STORAGE_KEY);
506
- return ["off", "minimal", "low", "medium", "high"].includes(value) ? value : "low";
531
+ return SIDE_QUESTION_THINKING_LEVELS.includes(value) ? value : "low";
507
532
  } catch { return "low"; }
508
533
  })(),
509
534
  draft: "",
510
535
  };
536
+ let sideQuestionPreferredThinking = sideQuestionUi.thinking;
511
537
  let quizOverlayEl = null;
512
538
  let quizDialogEl = null;
513
539
  let quizPreviewRenderNonce = 0;
@@ -2164,6 +2190,7 @@
2164
2190
  actionRequestId: null,
2165
2191
  };
2166
2192
  let fileBackedBaselineText = null;
2193
+ let fileBackedDiskRevision = null;
2167
2194
  let activePane = initialPaneFocusTarget === "right" ? "right" : "left";
2168
2195
  let paneFocusTarget = initialPaneFocusTarget;
2169
2196
  let paneSplitPercent = 50;
@@ -2892,9 +2919,28 @@
2892
2919
  }
2893
2920
  titleGroupEl.appendChild(makeStudioUiRefreshSeparator());
2894
2921
  if (isEditorOnlyMode) {
2895
- titleGroupEl.appendChild(makeStudioUiRefreshElement("span", "studio-refresh-static-title", "Editor (Raw)"));
2922
+ const staticTitleEl = makeStudioUiRefreshElement(
2923
+ "span",
2924
+ "studio-refresh-static-title",
2925
+ isWatchedFilePreview ? "Source" : "Editor (Raw)",
2926
+ );
2927
+ if (isWatchedFilePreview) {
2928
+ staticTitleEl.classList.add("studio-watched-source-title");
2929
+ staticTitleEl.setAttribute("aria-label", "Source, read-only. Follows the file on disk.");
2930
+ staticTitleEl.title = "This source is read-only and follows the file on disk.";
2931
+ }
2932
+ titleGroupEl.appendChild(staticTitleEl);
2933
+ if (isWatchedFilePreview) {
2934
+ const readOnlyBadgeEl = makeStudioUiRefreshElement(
2935
+ "span",
2936
+ "studio-watched-source-read-only-badge",
2937
+ "Read-only · follows disk",
2938
+ );
2939
+ readOnlyBadgeEl.setAttribute("aria-hidden", "true");
2940
+ titleGroupEl.appendChild(readOnlyBadgeEl);
2941
+ }
2896
2942
  } else if (editorViewSelect) {
2897
- titleGroupEl.appendChild(editorViewSelect);
2943
+ titleGroupEl.appendChild(editorViewSelectWrap || editorViewSelect);
2898
2944
  }
2899
2945
  if (contextMenu) {
2900
2946
  titleGroupEl.appendChild(makeStudioUiRefreshSeparator());
@@ -2920,9 +2966,10 @@
2920
2966
  rightTitleGroupEl.appendChild(rightFocusBtn);
2921
2967
  rightTitleGroupEl.appendChild(makeStudioUiRefreshSeparator());
2922
2968
  }
2923
- rightTitleGroupEl.appendChild(rightViewSelect);
2969
+ rightTitleGroupEl.appendChild(rightViewSelectWrap || rightViewSelect);
2924
2970
  rightIdentityEl.appendChild(rightTitleGroupEl);
2925
2971
  const rightToolsEl = makeStudioUiRefreshElement("div", "studio-refresh-pane-tools");
2972
+ if (watchedOpenEditableBtn && isWatchedFilePreview) rightToolsEl.appendChild(watchedOpenEditableBtn);
2926
2973
  if (exportPreviewControlsEl) {
2927
2974
  rightToolsEl.appendChild(exportPreviewControlsEl);
2928
2975
  } else if (exportPdfBtn) {
@@ -3592,6 +3639,9 @@
3592
3639
  if (typeof message.thinkingLevel === "string") {
3593
3640
  piThinkingLevel = message.thinkingLevel.trim();
3594
3641
  }
3642
+ if (Array.isArray(message.sideQuestionThinkingLevels)) {
3643
+ applySideQuestionThinkingLevels(message.sideQuestionThinkingLevels);
3644
+ }
3595
3645
  renderFooterModelMenu();
3596
3646
  }
3597
3647
 
@@ -3626,8 +3676,8 @@
3626
3676
  });
3627
3677
  footerModelMenuEl.innerHTML = ""
3628
3678
  + "<div class='footer-model-menu-heading'>Pi model & thinking</div>"
3629
- + "<label class='footer-model-menu-field'><span>Pi model</span><select id='footerPiModelSelect'>" + modelOptionsHtml.join("") + "</select></label>"
3630
- + "<label class='footer-model-menu-field'><span>Thinking</span><select id='footerPiThinkingSelect'>" + thinkingOptionsHtml.join("") + "</select></label>"
3679
+ + "<label class='footer-model-menu-field'><span>Pi model</span><span class='studio-menu-select-wrap'><select id='footerPiModelSelect'>" + modelOptionsHtml.join("") + "</select></span></label>"
3680
+ + "<label class='footer-model-menu-field'><span>Thinking</span><span class='studio-menu-select-wrap'><select id='footerPiThinkingSelect'>" + thinkingOptionsHtml.join("") + "</select></span></label>"
3631
3681
  + "<div class='footer-model-menu-note'>Affects future Pi turns. Studio Suggest has its own model setting.</div>";
3632
3682
  }
3633
3683
 
@@ -3707,7 +3757,7 @@
3707
3757
  footerThemeMenuEl.innerHTML = ""
3708
3758
  + "<div class='footer-model-menu-heading'>Pi theme</div>"
3709
3759
  + (optionsHtml.length
3710
- ? "<label class='footer-model-menu-field'><span>Active theme</span><select id='footerPiThemeSelect'>" + optionsHtml.join("") + "</select></label>"
3760
+ ? "<label class='footer-model-menu-field'><span>Active theme</span><span class='studio-menu-select-wrap'><select id='footerPiThemeSelect'>" + optionsHtml.join("") + "</select></span></label>"
3711
3761
  : "<div class='footer-model-menu-note'>No Pi themes are available yet.</div>")
3712
3762
  + "<div class='footer-model-menu-note'>Switches the active Pi theme and persists it to Pi settings.</div>";
3713
3763
  }
@@ -3846,7 +3896,7 @@
3846
3896
  }
3847
3897
 
3848
3898
  function getStudioDecisionFocusableElements() {
3849
- return [studioDecisionInputEl, studioDecisionCancelBtn, studioDecisionSecondaryBtn, studioDecisionConfirmBtn]
3899
+ return [studioDecisionInputEl, studioDecisionCancelBtn, studioDecisionTertiaryBtn, studioDecisionSecondaryBtn, studioDecisionConfirmBtn]
3850
3900
  .filter((element) => element && !element.hidden && !element.disabled);
3851
3901
  }
3852
3902
 
@@ -3894,6 +3944,30 @@
3894
3944
  cancelBtn.addEventListener("click", () => finishStudioDecision(null));
3895
3945
  actions.appendChild(cancelBtn);
3896
3946
 
3947
+ const tertiaryBtn = document.createElement("button");
3948
+ tertiaryBtn.type = "button";
3949
+ tertiaryBtn.className = "studio-decision-tertiary";
3950
+ tertiaryBtn.hidden = true;
3951
+ tertiaryBtn.addEventListener("click", () => {
3952
+ const state = studioDecisionState;
3953
+ const handler = state && state.onTertiary;
3954
+ if (typeof handler !== "function") {
3955
+ if (state && state.hasTertiaryValue) finishStudioDecision(state.tertiaryValue);
3956
+ return;
3957
+ }
3958
+ try {
3959
+ const result = handler();
3960
+ if (result && typeof result.catch === "function") {
3961
+ result.catch((error) => {
3962
+ setStatus("Action failed: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
3963
+ });
3964
+ }
3965
+ } catch (error) {
3966
+ setStatus("Action failed: " + (error && error.message ? error.message : String(error || "unknown error")), "warning");
3967
+ }
3968
+ });
3969
+ actions.appendChild(tertiaryBtn);
3970
+
3897
3971
  const secondaryBtn = document.createElement("button");
3898
3972
  secondaryBtn.type = "button";
3899
3973
  secondaryBtn.className = "studio-decision-secondary";
@@ -3964,6 +4038,7 @@
3964
4038
  studioDecisionMessageEl = message;
3965
4039
  studioDecisionInputEl = input;
3966
4040
  studioDecisionCancelBtn = cancelBtn;
4041
+ studioDecisionTertiaryBtn = tertiaryBtn;
3967
4042
  studioDecisionSecondaryBtn = secondaryBtn;
3968
4043
  studioDecisionConfirmBtn = confirmBtn;
3969
4044
  return overlay;
@@ -3976,6 +4051,7 @@
3976
4051
  if (studioDecisionState) finishStudioDecision(null, false);
3977
4052
  const returnFocusEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
3978
4053
 
4054
+ const tertiaryLabel = String(settings.tertiaryLabel || "").trim();
3979
4055
  const secondaryLabel = String(settings.secondaryLabel || "").trim();
3980
4056
  studioDecisionTitleEl.textContent = String(settings.title || (mode === "prompt" ? "Enter a value" : "Confirm action"));
3981
4057
  studioDecisionMessageEl.textContent = String(settings.message || "");
@@ -3984,9 +4060,13 @@
3984
4060
  studioDecisionInputEl.placeholder = mode === "prompt" ? String(settings.placeholder || "") : "";
3985
4061
  studioDecisionInputEl.setAttribute("aria-label", String(settings.inputLabel || "Value"));
3986
4062
  studioDecisionCancelBtn.textContent = String(settings.cancelLabel || "Cancel");
4063
+ studioDecisionTertiaryBtn.hidden = !tertiaryLabel;
4064
+ studioDecisionTertiaryBtn.disabled = settings.tertiaryDisabled === true;
4065
+ studioDecisionTertiaryBtn.textContent = tertiaryLabel;
3987
4066
  studioDecisionSecondaryBtn.hidden = !secondaryLabel;
3988
4067
  studioDecisionSecondaryBtn.disabled = settings.secondaryDisabled === true;
3989
4068
  studioDecisionSecondaryBtn.textContent = secondaryLabel;
4069
+ studioDecisionConfirmBtn.disabled = settings.confirmDisabled === true;
3990
4070
  studioDecisionConfirmBtn.textContent = String(settings.confirmLabel || (mode === "prompt" ? "Continue" : "Confirm"));
3991
4071
  studioDecisionConfirmBtn.classList.toggle("is-destructive", settings.destructive === true);
3992
4072
  studioDecisionDialogEl.classList.toggle("is-destructive", settings.destructive === true);
@@ -3998,6 +4078,9 @@
3998
4078
  mode,
3999
4079
  resolve,
4000
4080
  returnFocusEl,
4081
+ onTertiary: typeof settings.onTertiary === "function" ? settings.onTertiary : null,
4082
+ hasTertiaryValue: Object.prototype.hasOwnProperty.call(settings, "tertiaryValue"),
4083
+ tertiaryValue: settings.tertiaryValue,
4001
4084
  onSecondary: typeof settings.onSecondary === "function" ? settings.onSecondary : null,
4002
4085
  hasSecondaryValue: Object.prototype.hasOwnProperty.call(settings, "secondaryValue"),
4003
4086
  secondaryValue: settings.secondaryValue,
@@ -4059,12 +4142,20 @@
4059
4142
  }
4060
4143
  });
4061
4144
 
4062
- function markFileBackedBaseline(text) {
4145
+ function normalizeStudioDiskRevision(value) {
4146
+ const revision = typeof value === "string" ? value.trim().toLowerCase() : "";
4147
+ return /^sha256:[a-f0-9]{64}$/.test(revision) ? revision : null;
4148
+ }
4149
+
4150
+ function markFileBackedBaseline(text, diskRevision) {
4063
4151
  fileBackedBaselineText = String(text || "");
4152
+ fileBackedDiskRevision = normalizeStudioDiskRevision(diskRevision);
4153
+ scheduleWorkspacePersistence();
4064
4154
  }
4065
4155
 
4066
4156
  function clearFileBackedBaseline() {
4067
4157
  fileBackedBaselineText = null;
4158
+ fileBackedDiskRevision = null;
4068
4159
  }
4069
4160
 
4070
4161
  function hasRefreshableFilePath() {
@@ -4079,13 +4170,17 @@
4079
4170
 
4080
4171
  function updateSourceBadge() {
4081
4172
  const label = sourceState && sourceState.label ? sourceState.label : "blank";
4082
- const originText = (studioUiRefreshEnabled ? "Origin: " : "Editor origin: ") + label + (hasRefreshableFilePath() ? " · file" : "");
4173
+ const originText = isWatchedFilePreview
4174
+ ? ("Watching: " + label + " · read-only" + (watchedFilePreviewState.lastError || watchedFilePreviewState.renderError ? " · last good preview" : ""))
4175
+ : ((studioUiRefreshEnabled ? "Origin: " : "Editor origin: ") + label + (hasRefreshableFilePath() ? " · file" : ""));
4083
4176
  const descriptor = getCurrentStudioDocumentDescriptor();
4084
4177
  if (sourceBadgeEl) {
4085
4178
  sourceBadgeEl.textContent = originText;
4086
- sourceBadgeEl.title = descriptor.fileBacked
4179
+ sourceBadgeEl.title = isWatchedFilePreview
4180
+ ? ("Read-only watched file: " + (descriptor.label || label) + "\nStudio follows settled disk changes and keeps the last good rendered preview through temporary failures.")
4181
+ : (descriptor.fileBacked
4087
4182
  ? ("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.");
4183
+ : ("Editor origin: " + label + "\nClick to reset origin and start a new independent draft while keeping the current text and local notes."));
4089
4184
  }
4090
4185
  if (sourceOriginSummaryEl) {
4091
4186
  sourceOriginSummaryEl.textContent = originText;
@@ -4291,7 +4386,9 @@
4291
4386
  ].forEach(([btn, pane]) => {
4292
4387
  if (!btn) return;
4293
4388
  const isFocusedPane = paneFocusTarget === pane;
4294
- const paneName = pane === "right" ? "response" : "editor";
4389
+ const paneName = pane === "right"
4390
+ ? (isWatchedFilePreview ? "watched preview" : "response")
4391
+ : (isWatchedFilePreview ? "read-only source" : "editor");
4295
4392
  btn.classList.toggle("is-active", isFocusedPane);
4296
4393
  btn.setAttribute("aria-pressed", isFocusedPane ? "true" : "false");
4297
4394
  btn.textContent = isFocusedPane ? "Exit focus" : "Focus pane";
@@ -4544,10 +4641,14 @@
4544
4641
  }
4545
4642
 
4546
4643
  function triggerEditorSaveShortcut() {
4547
- if (saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
4644
+ if (hasRefreshableFilePath() && saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
4548
4645
  saveOverBtn.click();
4549
4646
  return true;
4550
4647
  }
4648
+ return triggerEditorSaveAsShortcut();
4649
+ }
4650
+
4651
+ function triggerEditorSaveAsShortcut() {
4551
4652
  if (saveAsBtn && !saveAsBtn.disabled && !saveAsBtn.hidden) {
4552
4653
  saveAsBtn.click();
4553
4654
  return true;
@@ -4890,6 +4991,22 @@
4890
4991
  return;
4891
4992
  }
4892
4993
 
4994
+ const isSaveAsShortcut =
4995
+ key.toLowerCase() === "s"
4996
+ && (event.metaKey || event.ctrlKey)
4997
+ && !event.altKey
4998
+ && event.shiftKey;
4999
+
5000
+ if (isSaveAsShortcut) {
5001
+ event.preventDefault();
5002
+ if (isWatchedFilePreview) {
5003
+ setStatus("This preview is read-only. Open a file tab to edit or save a copy.", "warning");
5004
+ return;
5005
+ }
5006
+ triggerEditorSaveAsShortcut();
5007
+ return;
5008
+ }
5009
+
4893
5010
  const isSaveShortcut =
4894
5011
  key.toLowerCase() === "s"
4895
5012
  && (event.metaKey || event.ctrlKey)
@@ -4898,6 +5015,10 @@
4898
5015
 
4899
5016
  if (isSaveShortcut) {
4900
5017
  event.preventDefault();
5018
+ if (isWatchedFilePreview) {
5019
+ setStatus("This preview follows disk and cannot save. Open a file tab to edit safely.", "warning");
5020
+ return;
5021
+ }
4901
5022
  triggerEditorSaveShortcut();
4902
5023
  return;
4903
5024
  }
@@ -5644,6 +5765,8 @@
5644
5765
  clearPreviewJumpHighlight(targetEl);
5645
5766
  finishPreviewRender(targetEl);
5646
5767
  targetEl.innerHTML = html;
5768
+ clearWatchedPreviewRenderError();
5769
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, text);
5647
5770
  if (pane === "response") {
5648
5771
  applyPendingResponseScrollReset();
5649
5772
  scheduleResponsePaneRepaintNudge();
@@ -7123,6 +7246,8 @@
7123
7246
  });
7124
7247
 
7125
7248
  targetEl.appendChild(shell);
7249
+ clearWatchedPreviewRenderError();
7250
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, html);
7126
7251
 
7127
7252
  if (pane === "response") {
7128
7253
  applyPendingResponseScrollReset();
@@ -9364,11 +9489,119 @@
9364
9489
 
9365
9490
  function hasMeaningfulPreviewContent(targetEl) {
9366
9491
  if (!targetEl || typeof targetEl.querySelector !== "function") return false;
9492
+ if (targetEl.dataset && targetEl.dataset.studioPreviewCommitted === "1") return true;
9367
9493
  if (targetEl.querySelector(".preview-loading")) return false;
9368
9494
  const text = typeof targetEl.textContent === "string" ? targetEl.textContent.trim() : "";
9369
9495
  return text.length > 0;
9370
9496
  }
9371
9497
 
9498
+ function getWatchedPreviewAnchorSignature(element) {
9499
+ if (!element || !element.tagName) return "";
9500
+ const text = String(element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180);
9501
+ if (!text) return "";
9502
+ return String(element.tagName).toLowerCase() + ":" + text;
9503
+ }
9504
+
9505
+ function captureWatchedPreviewReadingPosition(targetEl) {
9506
+ if (!isWatchedFilePreview || !targetEl || typeof targetEl.querySelectorAll !== "function") return null;
9507
+ const maxScroll = Math.max(0, Number(targetEl.scrollHeight || 0) - Number(targetEl.clientHeight || 0));
9508
+ const ratio = maxScroll > 0 ? Math.max(0, Math.min(1, Number(targetEl.scrollTop || 0) / maxScroll)) : 0;
9509
+ if (typeof targetEl.getBoundingClientRect !== "function") return { ratio };
9510
+ const targetRect = targetEl.getBoundingClientRect();
9511
+ const anchorLine = Number(targetRect.top || 0) + Math.max(20, Math.min(Number(targetEl.clientHeight || 0) * 0.22, 140));
9512
+ const candidates = Array.from(targetEl.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,pre,table,blockquote,figure"));
9513
+ let anchor = null;
9514
+ for (const candidate of candidates) {
9515
+ if (!candidate || typeof candidate.getBoundingClientRect !== "function") continue;
9516
+ const rect = candidate.getBoundingClientRect();
9517
+ if (Number(rect.bottom || rect.top || 0) >= anchorLine) {
9518
+ anchor = candidate;
9519
+ break;
9520
+ }
9521
+ }
9522
+ if (!anchor && candidates.length) anchor = candidates[candidates.length - 1];
9523
+ const signature = getWatchedPreviewAnchorSignature(anchor);
9524
+ if (!anchor || !signature) return { ratio };
9525
+ let occurrence = 0;
9526
+ for (const candidate of candidates) {
9527
+ if (candidate === anchor) break;
9528
+ if (getWatchedPreviewAnchorSignature(candidate) === signature) occurrence += 1;
9529
+ }
9530
+ const anchorRect = anchor.getBoundingClientRect();
9531
+ return {
9532
+ ratio,
9533
+ signature,
9534
+ occurrence,
9535
+ offset: Number(anchorRect.top || 0) - Number(targetRect.top || 0),
9536
+ };
9537
+ }
9538
+
9539
+ function restoreWatchedPreviewReadingPosition(targetEl, snapshot) {
9540
+ if (!isWatchedFilePreview || !targetEl || !snapshot) return;
9541
+ const candidates = typeof targetEl.querySelectorAll === "function"
9542
+ ? Array.from(targetEl.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,pre,table,blockquote,figure"))
9543
+ : [];
9544
+ const matching = snapshot.signature
9545
+ ? candidates.filter((candidate) => getWatchedPreviewAnchorSignature(candidate) === snapshot.signature)
9546
+ : [];
9547
+ const anchor = matching[Math.max(0, Number(snapshot.occurrence) || 0)] || null;
9548
+ if (anchor && typeof anchor.getBoundingClientRect === "function" && typeof targetEl.getBoundingClientRect === "function") {
9549
+ const targetRect = targetEl.getBoundingClientRect();
9550
+ const anchorRect = anchor.getBoundingClientRect();
9551
+ const delta = (Number(anchorRect.top || 0) - Number(targetRect.top || 0)) - (Number(snapshot.offset) || 0);
9552
+ targetEl.scrollTop = Math.max(0, Number(targetEl.scrollTop || 0) + delta);
9553
+ return;
9554
+ }
9555
+ const maxScroll = Math.max(0, Number(targetEl.scrollHeight || 0) - Number(targetEl.clientHeight || 0));
9556
+ targetEl.scrollTop = Math.max(0, Math.min(maxScroll, maxScroll * Math.max(0, Math.min(1, Number(snapshot.ratio) || 0))));
9557
+ }
9558
+
9559
+ function scheduleWatchedPreviewReadingPositionRestore(targetEl, renderedText) {
9560
+ if (!isWatchedFilePreview || !targetEl) return;
9561
+ const pane = targetEl === sourcePreviewEl ? "source" : (targetEl === critiqueViewEl ? "response" : "");
9562
+ if (!pane || !watchedFilePreviewReadingPositions[pane]) return;
9563
+ const pending = watchedFilePreviewReadingPositions[pane];
9564
+ if (String(pending.text || "") !== String(renderedText || "")) return;
9565
+ const snapshot = pending.snapshot;
9566
+ watchedFilePreviewReadingPositions[pane] = null;
9567
+ let restored = false;
9568
+ const applyRestore = () => {
9569
+ if (restored) return;
9570
+ restored = true;
9571
+ restoreWatchedPreviewReadingPosition(targetEl, snapshot);
9572
+ };
9573
+ if (typeof window.requestAnimationFrame === "function") {
9574
+ window.requestAnimationFrame(applyRestore);
9575
+ }
9576
+ // Hidden embedded/headless surfaces can suspend animation frames entirely.
9577
+ window.setTimeout(applyRestore, 80);
9578
+ }
9579
+
9580
+ function showWatchedPreviewRenderError(targetEl, message) {
9581
+ if (!isWatchedFilePreview || !targetEl || typeof targetEl.appendChild !== "function") return false;
9582
+ const existing = typeof targetEl.querySelector === "function" ? targetEl.querySelector(".studio-watched-preview-error") : null;
9583
+ if (existing && existing.remove) existing.remove();
9584
+ const notice = document.createElement("div");
9585
+ notice.className = "preview-warning studio-watched-preview-error";
9586
+ notice.setAttribute("role", "status");
9587
+ notice.appendChild(document.createTextNode("Could not render the latest disk revision; keeping the last good preview. " + String(message || "Preview renderer unavailable.") + " "));
9588
+ const retry = document.createElement("button");
9589
+ retry.type = "button";
9590
+ retry.textContent = "Retry";
9591
+ retry.addEventListener("click", () => renderActiveResult());
9592
+ notice.appendChild(retry);
9593
+ targetEl.appendChild(notice);
9594
+ return true;
9595
+ }
9596
+
9597
+ function clearWatchedPreviewRenderError() {
9598
+ if (!isWatchedFilePreview) return;
9599
+ const recovered = Boolean(watchedFilePreviewState.renderError);
9600
+ watchedFilePreviewState.renderError = "";
9601
+ updateSourceBadge();
9602
+ if (recovered) setStatus("Rendered the latest watched file revision.", "success");
9603
+ }
9604
+
9372
9605
  function beginPreviewRender(targetEl) {
9373
9606
  if (!targetEl || !targetEl.classList) return;
9374
9607
 
@@ -10695,6 +10928,28 @@
10695
10928
  );
10696
10929
  }
10697
10930
 
10931
+ function isCurrentStudioPreviewRender(pane, nonce) {
10932
+ if (pane === "source") {
10933
+ return nonce === sourcePreviewRenderNonce && editorView === "preview";
10934
+ }
10935
+ return nonce === responsePreviewRenderNonce && (rightView === "preview" || rightView === "editor-preview");
10936
+ }
10937
+
10938
+ function createStudioPreviewStagingElement(targetEl) {
10939
+ const staging = document.createElement("div");
10940
+ staging.className = String(targetEl && targetEl.className ? targetEl.className : "rendered-markdown") + " studio-preview-staging";
10941
+ staging.setAttribute("aria-hidden", "true");
10942
+ staging.style.width = Math.max(320, Number(targetEl && targetEl.clientWidth) || 0) + "px";
10943
+ document.body.appendChild(staging);
10944
+ return staging;
10945
+ }
10946
+
10947
+ function commitStudioPreviewStagingElement(targetEl, staging) {
10948
+ const nodes = Array.from(staging.childNodes || []);
10949
+ targetEl.replaceChildren(...nodes);
10950
+ staging.remove();
10951
+ }
10952
+
10698
10953
  async function applyRenderedMarkdown(targetEl, markdown, pane, nonce) {
10699
10954
  const previewPrepared = annotationsEnabled
10700
10955
  ? prepareMarkdownForPandocPreview(markdown)
@@ -10705,35 +10960,44 @@
10705
10960
  };
10706
10961
  const pdfPrepared = prepareStudioPdfBlocksForPreview(previewPrepared.markdown);
10707
10962
  const previewResourceContext = getHtmlPreviewResourceContextOptions();
10963
+ let staging = null;
10964
+ let previewCommitted = false;
10965
+ const stillCurrent = () => isCurrentStudioPreviewRender(pane, nonce);
10966
+ const abandonIfStale = () => {
10967
+ if (stillCurrent()) return false;
10968
+ if (staging && staging.remove) staging.remove();
10969
+ staging = null;
10970
+ return true;
10971
+ };
10708
10972
 
10709
10973
  try {
10710
10974
  const renderedHtml = await renderMarkdownWithPandoc(pdfPrepared.markdown, {
10711
10975
  includeEditorLanguage: pane === "source" || rightView === "editor-preview",
10712
10976
  resourceContext: previewResourceContext,
10713
10977
  });
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);
10978
+ if (abandonIfStale()) return;
10979
+
10980
+ staging = createStudioPreviewStagingElement(targetEl);
10981
+ staging.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown, previewFallbackOptions);
10982
+ await hydrateStudioPreviewLocalMedia(staging, previewResourceContext);
10983
+ if (abandonIfStale()) return;
10984
+ await renderStudioPdfBlocksInElement(staging, pdfPrepared.blocks, previewingEditorText);
10985
+ if (abandonIfStale()) return;
10986
+ applyPreviewAnnotationPlaceholdersToElement(staging, previewPrepared.placeholders);
10987
+ await renderAnnotationMathInElement(staging);
10988
+ if (abandonIfStale()) return;
10989
+ decoratePdfEmbeds(staging);
10990
+ await renderPdfPreviewsInElement(staging);
10991
+ if (abandonIfStale()) return;
10992
+ decoratePreviewPdfFigures(staging);
10731
10993
  const annotationMode = (pane === "source" || pane === "response")
10732
10994
  ? (annotationsEnabled ? "highlight" : "hide")
10733
10995
  : "none";
10734
- applyAnnotationMarkersToElement(targetEl, annotationMode);
10735
- await renderMermaidInElement(targetEl);
10736
- await renderMathFallbackInElement(targetEl);
10996
+ applyAnnotationMarkersToElement(staging, annotationMode);
10997
+ await renderMermaidInElement(staging);
10998
+ if (abandonIfStale()) return;
10999
+ await renderMathFallbackInElement(staging);
11000
+ if (abandonIfStale()) return;
10737
11001
 
10738
11002
  const shouldDecoratePreviewComments = supportsPreviewCommentsForCurrentEditor()
10739
11003
  && (
@@ -10741,35 +11005,56 @@
10741
11005
  || (pane === "response" && rightView === "editor-preview")
10742
11006
  );
10743
11007
  if (shouldDecoratePreviewComments) {
10744
- decorateRenderedEditorPreviewComments(targetEl, sourceTextEl.value || "");
11008
+ decorateRenderedEditorPreviewComments(staging, sourceTextEl.value || "");
10745
11009
  }
10746
- decorateCopyablePreviewBlocks(targetEl);
10747
- decoratePreviewImages(targetEl);
11010
+ decorateCopyablePreviewBlocks(staging);
11011
+ decoratePreviewImages(staging);
10748
11012
 
10749
- // Warn if relative images are present but unlikely to resolve (non-file-backed content)
11013
+ // Warn if relative images are present but unlikely to resolve (non-file-backed content).
10750
11014
  if (!sourceState.path && !getCurrentResourceDirValue()) {
10751
11015
  var hasRelativeImages = /!\[.*?\]\((?!https?:\/\/|data:)[^)]+\)/.test(markdown || "");
10752
11016
  var hasLatexImages = /\\includegraphics/.test(markdown || "");
10753
11017
  if (hasRelativeImages || hasLatexImages) {
10754
- appendPreviewNotice(targetEl, "Images not displaying? Set working dir in the editor pane or open via /studio <path>.");
11018
+ appendPreviewNotice(staging, "Images not displaying? Set working dir in the editor pane or open via /studio <path>.");
10755
11019
  }
10756
11020
  }
11021
+ if (abandonIfStale()) return;
10757
11022
 
11023
+ clearPreviewJumpHighlight(targetEl);
11024
+ finishPreviewRender(targetEl);
11025
+ commitStudioPreviewStagingElement(targetEl, staging);
11026
+ staging = null;
11027
+ if (targetEl.dataset) targetEl.dataset.studioPreviewCommitted = "1";
11028
+ previewCommitted = true;
11029
+ if (shouldDecoratePreviewComments) updatePreviewCommentBlocksForElement(targetEl);
11030
+ clearWatchedPreviewRenderError();
11031
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, markdown);
10758
11032
  if (pane === "response") {
10759
11033
  applyPendingResponseScrollReset();
10760
11034
  scheduleResponsePaneRepaintNudge();
10761
11035
  }
10762
11036
  } 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;
11037
+ if (staging && staging.remove) staging.remove();
11038
+ staging = null;
11039
+ if (previewCommitted) {
11040
+ console.error("Preview post-render update failed after the staged document was committed:", error);
11041
+ return;
10767
11042
  }
11043
+ if (!stillCurrent()) return;
10768
11044
 
10769
11045
  const detail = error && error.message ? error.message : String(error || "unknown error");
10770
11046
  clearPreviewJumpHighlight(targetEl);
10771
11047
  finishPreviewRender(targetEl);
11048
+ if (isWatchedFilePreview && hasMeaningfulPreviewContent(targetEl)) {
11049
+ watchedFilePreviewState.renderError = detail;
11050
+ showWatchedPreviewRenderError(targetEl, detail);
11051
+ updateSourceBadge();
11052
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, markdown);
11053
+ setStatus("Could not render the latest disk revision; keeping the last good preview.", "warning");
11054
+ return;
11055
+ }
10772
11056
  targetEl.innerHTML = buildPreviewErrorHtml("Preview renderer unavailable (" + detail + "). Showing plain markdown.", markdown, previewFallbackOptions);
11057
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, markdown);
10773
11058
  if (pane === "response") {
10774
11059
  applyPendingResponseScrollReset();
10775
11060
  scheduleResponsePaneRepaintNudge();
@@ -11360,10 +11645,10 @@
11360
11645
  return "<div class='repl-panel'>"
11361
11646
  + "<div class='repl-toolbar'>"
11362
11647
  + "<div class='repl-controls'>"
11363
- + "<label class='repl-control-label'>Runtime <select data-repl-runtime aria-label='REPL runtime'>" + runtimeOptions + "</select></label>"
11648
+ + "<label class='repl-control-label'>Runtime <select class='studio-flat-select' data-repl-runtime aria-label='REPL runtime'>" + runtimeOptions + "</select></label>"
11364
11649
  + "<label class='repl-control-label repl-command-label'>Start cmd <input data-repl-command type='text' value='" + escapeHtml(replCommand) + "' placeholder='default' aria-label='REPL start command' title='Command used by Start for this runtime. Leave blank for the default; use this for envs, e.g. .venv/bin/python, uv run python, or conda run --no-capture-output -n env python.'></label>"
11365
11650
  + "<button class='repl-start-btn' type='button' data-repl-action='start'" + (replBusy || replTmuxAvailable === false ? " disabled" : "") + " title='Start or switch to a " + escapeHtml(runtimeLabel) + " session using the selected runtime and start command.'>Start</button>"
11366
- + "<label class='repl-control-label repl-session-label'>Session <select data-repl-session aria-label='REPL session'" + (visibleSessions.length ? "" : " disabled") + ">" + sessionOptions + "</select></label>"
11651
+ + "<label class='repl-control-label repl-session-label'>Session <select class='studio-flat-select' data-repl-session aria-label='REPL session'" + (visibleSessions.length ? "" : " disabled") + ">" + sessionOptions + "</select></label>"
11367
11652
  + "<details class='repl-more-controls'>"
11368
11653
  + "<summary title='More REPL actions'>More</summary>"
11369
11654
  + "<div class='repl-more-menu'>"
@@ -11589,7 +11874,7 @@
11589
11874
  const options = getFileBrowserSortOptions().map((option) => {
11590
11875
  return "<option value='" + escapeHtml(option.value) + "'" + (option.value === currentSort ? " selected" : "") + ">" + escapeHtml(option.label) + "</option>";
11591
11876
  }).join("");
11592
- return "<select class='files-sort-select' data-files-sort aria-label='Files sort order' title='Sort file browser entries. Folders remain grouped first.'>" + options + "</select>";
11877
+ return "<select class='files-sort-select studio-flat-select' data-files-sort aria-label='Files sort order' title='Sort file browser entries. Folders remain grouped first.'>" + options + "</select>";
11593
11878
  }
11594
11879
 
11595
11880
  function buildFileBrowserEntryRowHtml(entry) {
@@ -11614,6 +11899,9 @@
11614
11899
  const newTabButton = newTabAction
11615
11900
  ? "<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
11901
  : "";
11902
+ const watchButton = kind === "text"
11903
+ ? "<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>"
11904
+ : "";
11617
11905
  const openTitle = type === "directory"
11618
11906
  ? "Open folder"
11619
11907
  : (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 +11912,7 @@
11624
11912
  + "<span class='files-meta'>" + escapeHtml(metaParts.filter(Boolean).join(" · ")) + "</span>"
11625
11913
  + "</button>"
11626
11914
  + "<span class='files-actions'>"
11915
+ + watchButton
11627
11916
  + newTabButton
11628
11917
  + "<button type='button' data-files-action='copy-path' data-files-path='" + escapeHtml(path) + "'>Copy path</button>"
11629
11918
  + (type === "file" ? "<button type='button' data-files-action='reveal' data-files-path='" + escapeHtml(path) + "'>Reveal</button>" : "")
@@ -11640,7 +11929,7 @@
11640
11929
  const label = String((location && location.label) || basenameForStudioPath(path) || path);
11641
11930
  return "<option value='" + escapeHtml(path) + "'" + (path === rootDir ? " selected" : "") + ">" + escapeHtml(label + " — " + path) + "</option>";
11642
11931
  }).join("");
11643
- return "<select class='files-location-select' data-files-location aria-label='Allowed Files location' title='Choose a folder allowed for this Studio session on the computer running Pi.'>" + options + "</select>";
11932
+ return "<select class='files-location-select studio-flat-select' data-files-location aria-label='Allowed Files location' title='Choose a folder allowed for this Studio session on the computer running Pi.'>" + options + "</select>";
11644
11933
  }
11645
11934
 
11646
11935
  function buildFileBrowserPanelHtml() {
@@ -11797,7 +12086,7 @@
11797
12086
  function ensureCurrentEditorFileBackedFromFilesPath(path) {
11798
12087
  const cleanPath = stripPreviewLocalLinkUrlSuffix(path || "").trim();
11799
12088
  if (!isLikelyAbsoluteStudioPath(cleanPath)) return;
11800
- if (sourceState && sourceState.path === cleanPath) return;
12089
+ if (sourceState && sourceState.path) return;
11801
12090
  const resourceDir = normalizeStudioResourceDirValue(fileBrowserState.rootDir || getCurrentResourceDirValue() || dirnameForDisplayPath(cleanPath));
11802
12091
  if (resourceDirInput && resourceDir) resourceDirInput.value = resourceDir;
11803
12092
  setSourceState({
@@ -11805,7 +12094,7 @@
11805
12094
  label: sourceState && sourceState.label && sourceState.label !== "blank" ? sourceState.label : basenameForStudioPath(cleanPath),
11806
12095
  path: cleanPath,
11807
12096
  });
11808
- markFileBackedBaseline(sourceTextEl.value);
12097
+ markFileBackedBaseline(sourceTextEl.value, null);
11809
12098
  }
11810
12099
 
11811
12100
  async function openFileBrowserEntry(path, kind) {
@@ -11945,6 +12234,10 @@
11945
12234
  await openPreviewDocumentInNewEditor(path, getFileBrowserLocalLinkContext());
11946
12235
  return;
11947
12236
  }
12237
+ if (action === "watch-new") {
12238
+ await openPreviewDocumentInWatchedPreview(path, getFileBrowserLocalLinkContext());
12239
+ return;
12240
+ }
11948
12241
  if (action === "open-preview-new") {
11949
12242
  await openPreviewResourceInNewEditor(path, getFileBrowserLocalLinkContext());
11950
12243
  return;
@@ -12726,6 +13019,49 @@
12726
13019
  return values.map(([value, label]) => "<option value='" + escapeHtml(value) + "'" + (value === current ? " selected" : "") + ">" + escapeHtml(label) + "</option>").join("");
12727
13020
  }
12728
13021
 
13022
+ function clampSideQuestionThinkingLevel(value, levels) {
13023
+ const available = Array.isArray(levels) && levels.length ? levels : [...SIDE_QUESTION_THINKING_LEVELS];
13024
+ if (available.includes(value)) return value;
13025
+ const requestedIndex = SIDE_QUESTION_THINKING_LEVELS.indexOf(value);
13026
+ if (requestedIndex < 0) return available.includes("low") ? "low" : available[0];
13027
+ for (let index = requestedIndex; index < SIDE_QUESTION_THINKING_LEVELS.length; index += 1) {
13028
+ const candidate = SIDE_QUESTION_THINKING_LEVELS[index];
13029
+ if (available.includes(candidate)) return candidate;
13030
+ }
13031
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
13032
+ const candidate = SIDE_QUESTION_THINKING_LEVELS[index];
13033
+ if (available.includes(candidate)) return candidate;
13034
+ }
13035
+ return "off";
13036
+ }
13037
+
13038
+ function applySideQuestionThinkingLevels(value) {
13039
+ const seen = new Set();
13040
+ const normalized = (Array.isArray(value) ? value : []).filter((level) => {
13041
+ if (typeof level !== "string" || !SIDE_QUESTION_THINKING_LEVELS.includes(level) || seen.has(level)) return false;
13042
+ seen.add(level);
13043
+ return true;
13044
+ });
13045
+ const next = normalized.length ? normalized : [...SIDE_QUESTION_THINKING_LEVELS];
13046
+ const levelsChanged = next.length !== sideQuestionThinkingLevels.length
13047
+ || next.some((level, index) => level !== sideQuestionThinkingLevels[index]);
13048
+ sideQuestionThinkingLevels = next;
13049
+ const nextThinking = clampSideQuestionThinkingLevel(sideQuestionPreferredThinking, next);
13050
+ const thinkingChanged = nextThinking !== sideQuestionUi.thinking;
13051
+ if (thinkingChanged) sideQuestionUi.thinking = nextThinking;
13052
+ if ((levelsChanged || thinkingChanged) && rightView === "side-questions" && (!sideQuestionState || !sideQuestionState.threadId)) {
13053
+ renderSideQuestionView();
13054
+ }
13055
+ }
13056
+
13057
+ function getSideQuestionThinkingOptions() {
13058
+ return sideQuestionThinkingLevels.map((level) => {
13059
+ if (level === "xhigh") return [level, "X-high (slower)"];
13060
+ if (level === "max") return [level, "Max (slowest)"];
13061
+ return [level, level.charAt(0).toUpperCase() + level.slice(1)];
13062
+ });
13063
+ }
13064
+
12729
13065
  function renderSideQuestionPiToolPicker() {
12730
13066
  const selected = new Set(sideQuestionUi.toolIds);
12731
13067
  const selectedCount = sideQuestionUi.toolIds.length;
@@ -12762,15 +13098,15 @@
12762
13098
  return "<div class='side-question-empty'>"
12763
13099
  + "<div class='side-question-intro'><h2>Side question</h2><p>Ask something without adding it to the main Pi conversation. Choose the starting text and whether Studio may look through related files.</p></div>"
12764
13100
  + "<div class='side-question-context-grid'>"
12765
- + "<label>Starting text<select data-side-question-field='focusMode' aria-describedby='sideQuestionContextRule'>" + sideQuestionSelectOptions([
13101
+ + "<label>Starting text<select class='studio-flat-select' data-side-question-field='focusMode' aria-describedby='sideQuestionContextRule'>" + sideQuestionSelectOptions([
12766
13102
  ["auto", "Automatic"], ["selection", "Editor selection only"], ["section", "Heading block or nearby text at cursor"], ["editor", "Whole editor document"], ["response", "Displayed response"], ["none", "No starting text"],
12767
13103
  ], sideQuestionUi.focusMode) + "</select></label>"
12768
- + "<label>Also use files from<select data-side-question-field='gatherScope'>" + sideQuestionSelectOptions([
13104
+ + "<label>Also use files from<select class='studio-flat-select' data-side-question-field='gatherScope'>" + sideQuestionSelectOptions([
12769
13105
  ["none", "No other files"], ["folder", "Same folder as document"], ["repo", "Repository"], ["custom", "Choose a folder"],
12770
13106
  ], scope) + "</select></label>"
12771
- + "<label>Thinking<select data-side-question-field='thinking'>" + sideQuestionSelectOptions([
12772
- ["off", "Off"], ["minimal", "Minimal"], ["low", "Low"], ["medium", "Medium"], ["high", "High"],
12773
- ], sideQuestionUi.thinking) + "</select></label>"
13107
+ + "<label>Thinking<select class='studio-flat-select' data-side-question-field='thinking'>" + sideQuestionSelectOptions(
13108
+ getSideQuestionThinkingOptions(), sideQuestionUi.thinking
13109
+ ) + "</select></label>"
12774
13110
  + "</div>"
12775
13111
  + "<details class='side-question-context-rule'><summary id='sideQuestionContextRule'>Automatic: selection → heading block at cursor → nearby text</summary><p>A heading block starts at the nearest Markdown/LaTeX heading above the cursor and ends before the next heading of the same or higher level. With no heading, Studio uses the surrounding text block or a nearby excerpt.</p></details>"
12776
13112
  + (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>")
@@ -13128,9 +13464,10 @@
13128
13464
  if (sideQuestionUi.gatherScope !== "repo") sideQuestionUi.gitContext = false;
13129
13465
  try { if (window.localStorage) window.localStorage.setItem(SIDE_QUESTION_GATHER_STORAGE_KEY, sideQuestionUi.gatherScope); } catch {}
13130
13466
  }
13131
- if (field === "thinking") {
13467
+ if (field === "thinking" && sideQuestionThinkingLevels.includes(target.value)) {
13468
+ sideQuestionPreferredThinking = target.value;
13132
13469
  sideQuestionUi.thinking = target.value;
13133
- try { if (window.localStorage) window.localStorage.setItem(SIDE_QUESTION_THINKING_STORAGE_KEY, sideQuestionUi.thinking); } catch {}
13470
+ try { if (window.localStorage) window.localStorage.setItem(SIDE_QUESTION_THINKING_STORAGE_KEY, sideQuestionPreferredThinking); } catch {}
13134
13471
  }
13135
13472
  if (field === "includeConversation") sideQuestionUi.includeConversation = target.checked;
13136
13473
  if (field === "gitContext") sideQuestionUi.gitContext = target.checked && getSideQuestionGatherScope() === "repo";
@@ -13575,13 +13912,13 @@
13575
13912
  function updateSaveFileTooltip() {
13576
13913
  if (!saveOverBtn) return;
13577
13914
 
13578
- var effectivePath = getEffectiveSavePath();
13915
+ var effectivePath = sourceState && sourceState.path ? sourceState.path : "";
13579
13916
  if (effectivePath) {
13580
- saveOverBtn.title = "Overwrite file: " + effectivePath + " · Shortcut: Cmd/Ctrl+S.";
13917
+ saveOverBtn.title = "Save file when its disk revision still matches: " + effectivePath + " · Shortcut: Cmd/Ctrl+S.";
13581
13918
  return;
13582
13919
  }
13583
13920
 
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.";
13921
+ saveOverBtn.title = "Save editor is available after opening a file-backed document. Use Save editor as for a new file.";
13585
13922
  }
13586
13923
 
13587
13924
  function updateRefreshFromDiskTooltip() {
@@ -13596,13 +13933,13 @@
13596
13933
  }
13597
13934
 
13598
13935
  function syncActionButtons() {
13599
- const canSaveOver = Boolean(getEffectiveSavePath());
13936
+ const canSaveOver = hasRefreshableFilePath();
13600
13937
  const canRefreshFromDisk = hasRefreshableFilePath();
13601
13938
 
13602
- fileInput.disabled = uiBusy;
13603
- if (importFileBtn) importFileBtn.disabled = uiBusy;
13604
- if (sourceBadgeEl) sourceBadgeEl.disabled = uiBusy;
13605
- if (sourceResetOriginBtn) sourceResetOriginBtn.disabled = uiBusy;
13939
+ fileInput.disabled = uiBusy || isWatchedFilePreview;
13940
+ if (importFileBtn) importFileBtn.disabled = uiBusy || isWatchedFilePreview;
13941
+ if (sourceBadgeEl) sourceBadgeEl.disabled = uiBusy || isWatchedFilePreview;
13942
+ if (sourceResetOriginBtn) sourceResetOriginBtn.disabled = uiBusy || isWatchedFilePreview;
13606
13943
  if (sourceOpenCurrentFileTabBtn) {
13607
13944
  sourceOpenCurrentFileTabBtn.disabled = uiBusy || !hasRefreshableFilePath();
13608
13945
  sourceOpenCurrentFileTabBtn.title = hasRefreshableFilePath()
@@ -13610,17 +13947,21 @@
13610
13947
  : "Available after opening a file-backed document.";
13611
13948
  }
13612
13949
  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;
13950
+ saveAsBtn.disabled = uiBusy || isWatchedFilePreview;
13951
+ saveOverBtn.disabled = uiBusy || isWatchedFilePreview || !canSaveOver;
13952
+ if (refreshFromDiskBtn) refreshFromDiskBtn.disabled = uiBusy || isWatchedFilePreview || !canRefreshFromDisk;
13953
+ if (clearWorkspaceBtn) clearWorkspaceBtn.disabled = uiBusy || isWatchedFilePreview;
13617
13954
  sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
13618
- if (getEditorBtn) getEditorBtn.disabled = uiBusy;
13955
+ if (getEditorBtn) getEditorBtn.disabled = uiBusy || isWatchedFilePreview;
13956
+ if (watchedOpenEditableBtn) {
13957
+ watchedOpenEditableBtn.hidden = !isWatchedFilePreview;
13958
+ watchedOpenEditableBtn.disabled = uiBusy || !watchedFilePreviewState.path;
13959
+ }
13619
13960
  syncRunAndCritiqueButtons();
13620
13961
  copyDraftBtn.disabled = uiBusy;
13621
13962
  if (suggestCompletionBtn) {
13622
13963
  const hasSuggestionForCurrentText = Boolean(completionSuggestionState && sourceTextEl && sourceTextEl.value === completionSuggestionState.baseText);
13623
- suggestCompletionBtn.disabled = wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
13964
+ suggestCompletionBtn.disabled = isWatchedFilePreview || wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
13624
13965
  suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Stop" : (hasSuggestionForCurrentText ? "Try another" : "Suggest");
13625
13966
  suggestCompletionBtn.title = completionSuggestionInFlight
13626
13967
  ? "Stop the current suggestion request."
@@ -13636,15 +13977,15 @@
13636
13977
  if (highlightSelect) highlightSelect.disabled = uiBusy;
13637
13978
  if (lineNumbersSelect) lineNumbersSelect.disabled = uiBusy;
13638
13979
  if (annotationModeSelect) annotationModeSelect.disabled = uiBusy;
13639
- if (saveAnnotatedBtn) saveAnnotatedBtn.disabled = uiBusy;
13640
- if (stripAnnotationsBtn) stripAnnotationsBtn.disabled = uiBusy || !hasAnnotationMarkers(sourceTextEl.value);
13980
+ if (saveAnnotatedBtn) saveAnnotatedBtn.disabled = uiBusy || isWatchedFilePreview;
13981
+ if (stripAnnotationsBtn) stripAnnotationsBtn.disabled = uiBusy || isWatchedFilePreview || !hasAnnotationMarkers(sourceTextEl.value);
13641
13982
  if (compactBtn) compactBtn.disabled = isEditorOnlyMode || uiBusy || compactInProgress || wsState === "Disconnected";
13642
13983
  editorViewSelect.disabled = isEditorOnlyMode;
13643
13984
  syncRightViewModeOptions();
13644
- rightViewSelect.disabled = false;
13985
+ rightViewSelect.disabled = isWatchedFilePreview;
13645
13986
  followSelect.disabled = isEditorOnlyMode || uiBusy;
13646
13987
  if (responseHighlightSelect) responseHighlightSelect.disabled = isEditorOnlyMode || rightView !== "markdown";
13647
- insertHeaderBtn.disabled = uiBusy;
13988
+ insertHeaderBtn.disabled = uiBusy || isWatchedFilePreview;
13648
13989
  lensSelect.disabled = uiBusy || isEditorOnlyMode;
13649
13990
  updateSaveFileTooltip();
13650
13991
  updateRefreshFromDiskTooltip();
@@ -13661,9 +14002,14 @@
13661
14002
 
13662
14003
  function setSourceState(next, options) {
13663
14004
  const previousDescriptor = getCurrentStudioDocumentDescriptor();
14005
+ const previousPath = sourceState && sourceState.path ? sourceState.path : null;
13664
14006
  const previousQuartoPath = getCurrentStudioQuartoSourcePath();
13665
14007
  const previousPreviewResourceContext = getHtmlPreviewResourceContextOptions();
13666
14008
  const nextPath = next && next.path ? next.path : null;
14009
+ if (isWatchedFilePreview && watchedFilePreviewState.path && nextPath !== watchedFilePreviewState.path) {
14010
+ setStatus("This read-only preview remains bound to its watched file.", "warning");
14011
+ return false;
14012
+ }
13667
14013
  sourceState = {
13668
14014
  source: next && next.source ? next.source : "blank",
13669
14015
  label: next && next.label ? next.label : "blank",
@@ -13681,7 +14027,7 @@
13681
14027
  quartoPreviewActionRequestId = null;
13682
14028
  quartoPreviewLogVisible = false;
13683
14029
  }
13684
- if (!sourceState.path) {
14030
+ if (!sourceState.path || sourceState.path !== previousPath) {
13685
14031
  clearFileBackedBaseline();
13686
14032
  }
13687
14033
  syncRightViewModeOptions();
@@ -13788,6 +14134,7 @@
13788
14134
  version: 1,
13789
14135
  savedAt: lastWorkspacePersistenceSavedAt,
13790
14136
  sourceState: normalizeWorkspaceSourceState(sourceState),
14137
+ diskRevision: fileBackedDiskRevision,
13791
14138
  resourceDir: getCurrentResourceDirValue(),
13792
14139
  editorView,
13793
14140
  rightView: normalizeRightViewValue(rightView),
@@ -13823,7 +14170,7 @@
13823
14170
  }
13824
14171
 
13825
14172
  function persistWorkspaceStateNow(options) {
13826
- if (!workspacePersistenceReady) return;
14173
+ if (!workspacePersistenceReady || isWatchedFilePreview) return;
13827
14174
  try {
13828
14175
  const payload = buildWorkspacePersistencePayload();
13829
14176
  if (payload.text.length > STUDIO_WORKSPACE_MAX_TEXT_CHARS) {
@@ -13845,7 +14192,7 @@
13845
14192
  }
13846
14193
 
13847
14194
  function scheduleWorkspacePersistence() {
13848
- if (!workspacePersistenceReady || workspacePersistTimer !== null) return;
14195
+ if (!workspacePersistenceReady || isWatchedFilePreview || workspacePersistTimer !== null) return;
13849
14196
  workspacePersistTimer = window.setTimeout(() => {
13850
14197
  workspacePersistTimer = null;
13851
14198
  persistWorkspaceStateNow();
@@ -13853,6 +14200,7 @@
13853
14200
  }
13854
14201
 
13855
14202
  function flushWorkspacePersistence(options) {
14203
+ if (isWatchedFilePreview) return;
13856
14204
  if (workspacePersistTimer !== null) {
13857
14205
  window.clearTimeout(workspacePersistTimer);
13858
14206
  workspacePersistTimer = null;
@@ -13882,9 +14230,17 @@
13882
14230
  if (!shouldRestorePersistedWorkspaceState(state)) return false;
13883
14231
  const nextSourceState = normalizeWorkspaceSourceState(state.sourceState);
13884
14232
  const nextResourceDir = normalizeStudioResourceDirValue(typeof state.resourceDir === "string" ? state.resourceDir : "");
14233
+ const currentBaselineText = fileBackedBaselineText;
14234
+ const currentDiskRevision = fileBackedDiskRevision;
14235
+ const persistedDiskRevision = normalizeStudioDiskRevision(state.diskRevision);
13885
14236
  if (resourceDirInput) resourceDirInput.value = nextResourceDir;
13886
14237
  setEditorText(state.text, { preserveScroll: false, preserveSelection: false });
13887
14238
  setSourceState(nextSourceState);
14239
+ if (nextSourceState.path) {
14240
+ fileBackedBaselineText = currentBaselineText;
14241
+ fileBackedDiskRevision = persistedDiskRevision
14242
+ || (currentBaselineText !== null && state.text === currentBaselineText ? currentDiskRevision : null);
14243
+ }
13888
14244
  if (resourceDirInput && nextResourceDir) {
13889
14245
  resourceDirInput.value = nextResourceDir;
13890
14246
  updateSourceBadge();
@@ -13970,6 +14326,10 @@
13970
14326
  }
13971
14327
 
13972
14328
  function setEditorText(nextText, options) {
14329
+ if (isWatchedFilePreview && !(options && options.allowWatchedFileUpdate === true)) {
14330
+ setStatus("This preview is read-only and follows its watched file on disk.", "warning");
14331
+ return false;
14332
+ }
13973
14333
  const value = String(nextText || "");
13974
14334
  const preserveScroll = Boolean(options && options.preserveScroll);
13975
14335
  const preserveSelection = Boolean(options && options.preserveSelection);
@@ -14017,9 +14377,14 @@
14017
14377
  updateEditorSelectionCommentUi();
14018
14378
  updateOutlineUi();
14019
14379
  scheduleWorkspacePersistence();
14380
+ return true;
14020
14381
  }
14021
14382
 
14022
14383
  function applySourceTextEdit(nextText, selectionStart, selectionEnd) {
14384
+ if (isWatchedFilePreview) {
14385
+ setStatus("This preview is read-only and follows its watched file on disk.", "warning");
14386
+ return false;
14387
+ }
14023
14388
  const value = String(nextText || "");
14024
14389
  sourceTextEl.value = value;
14025
14390
  const maxIndex = value.length;
@@ -14031,6 +14396,7 @@
14031
14396
  if (editorView === "markdown") {
14032
14397
  scheduleEditorLineNumberRender();
14033
14398
  }
14399
+ return true;
14034
14400
  }
14035
14401
 
14036
14402
  function readCompletionSuggestionContextMode() {
@@ -15191,11 +15557,12 @@
15191
15557
  appendPreviewLinkMenuButton(menu, "Open in new Studio tab", "open-preview-new");
15192
15558
  appendPreviewLinkMenuButton(menu, "Open in system viewer", "open-system");
15193
15559
  } else if (kind === "text") {
15560
+ appendPreviewLinkMenuButton(menu, "Preview file (follow changes)", "watch-new");
15194
15561
  appendPreviewLinkMenuButton(menu, "Open file tab", "open-new");
15195
- appendPreviewLinkMenuButton(menu, "Open here", "open-here");
15562
+ if (!isWatchedFilePreview) appendPreviewLinkMenuButton(menu, "Open here", "open-here");
15196
15563
  } else if (kind === "office") {
15197
15564
  appendPreviewLinkMenuButton(menu, "Convert tab", "open-new");
15198
- appendPreviewLinkMenuButton(menu, "Convert here", "open-here");
15565
+ if (!isWatchedFilePreview) appendPreviewLinkMenuButton(menu, "Convert here", "open-here");
15199
15566
  } else if (kind === "image") {
15200
15567
  appendPreviewLinkMenuButton(menu, "Open image preview", "open-image");
15201
15568
  appendPreviewLinkMenuButton(menu, "Open in new Studio tab", "open-preview-new");
@@ -15291,9 +15658,15 @@
15291
15658
  }
15292
15659
 
15293
15660
  async function fetchPreviewLocalLink(action, href, contextOverride, options) {
15294
- const request = () => fetchStudioJson("/local-preview-link", {
15295
- query: { ...getPreviewLinkResourceQuery(href, contextOverride), action },
15296
- });
15661
+ const request = () => {
15662
+ const query = { ...getPreviewLinkResourceQuery(href, contextOverride), action };
15663
+ if (isWatchedFilePreview) {
15664
+ query.watchedFile = "1";
15665
+ const watchedDocId = initialQueryParams.get("docId") || "";
15666
+ if (watchedDocId) query.docId = watchedDocId;
15667
+ }
15668
+ return fetchStudioJson("/local-preview-link", { query });
15669
+ };
15297
15670
  try {
15298
15671
  return await request();
15299
15672
  } catch (error) {
@@ -15385,6 +15758,9 @@
15385
15758
  }
15386
15759
 
15387
15760
  async function openPreviewDocumentHere(href, contextOverride, options) {
15761
+ if (isWatchedFilePreview) {
15762
+ throw new Error("This preview follows one disk file and cannot open another document here. Open a new file tab instead.");
15763
+ }
15388
15764
  if (!(await confirmPreviewOfficeConversion(href, "here"))) return;
15389
15765
  if (editorHasPotentialUnsavedContent()) {
15390
15766
  const kind = getPreviewLocalLinkKind(href);
@@ -15414,7 +15790,7 @@
15414
15790
  setSourceState({ source: "blank", label, path: null });
15415
15791
  } else {
15416
15792
  setSourceState({ source: "file", label, path });
15417
- markFileBackedBaseline(payload.text);
15793
+ markFileBackedBaseline(payload.text, payload.diskRevision);
15418
15794
  }
15419
15795
  const detected = converted ? "markdown" : detectLanguageFromName(path || label);
15420
15796
  if (detected) setEditorLanguage(detected);
@@ -15445,6 +15821,25 @@
15445
15821
  }
15446
15822
  }
15447
15823
 
15824
+ async function openPreviewDocumentInWatchedPreview(href, contextOverride) {
15825
+ let launch = null;
15826
+ try {
15827
+ launch = openPendingStudioTab("preview");
15828
+ const payload = await fetchPreviewLocalLink("watch-url", href, contextOverride);
15829
+ const relativeUrl = payload && typeof payload.relativeUrl === "string" ? payload.relativeUrl : "";
15830
+ if (!relativeUrl) throw new Error("Studio did not return a watched-preview URL.");
15831
+ navigatePendingStudioTab(launch, relativeUrl);
15832
+ setStatus("Opening read-only preview that follows disk changes.");
15833
+ } catch (error) {
15834
+ if (error && error.studioCancelled) {
15835
+ cancelPendingStudioTab(launch, "Local resource access was cancelled.");
15836
+ } else {
15837
+ failPendingStudioTab(launch, "Studio could not prepare this watched preview. Return to the originating Studio page for details.");
15838
+ }
15839
+ throw error;
15840
+ }
15841
+ }
15842
+
15448
15843
  async function openPreviewResourceInNewEditor(href, contextOverride) {
15449
15844
  let launch = null;
15450
15845
  try {
@@ -15500,6 +15895,10 @@
15500
15895
  await openPreviewDocumentInNewEditor(href, context);
15501
15896
  return;
15502
15897
  }
15898
+ if (action === "watch-new") {
15899
+ await openPreviewDocumentInWatchedPreview(href, context);
15900
+ return;
15901
+ }
15503
15902
  if (action === "open-preview-new") {
15504
15903
  await openPreviewResourceInNewEditor(href, context);
15505
15904
  return;
@@ -15995,13 +16394,13 @@
15995
16394
  return "<div class='studio-quiz-setup'>"
15996
16395
  + "<p class='studio-quiz-copy'>A short active-recall loop: answer one question, check it, ask about the card if useful, then move on.</p>"
15997
16396
  + "<div class='studio-quiz-fields'>"
15998
- + "<label>Scope<select data-quiz-field='scope'>"
16397
+ + "<label>Scope<select class='studio-flat-select' data-quiz-field='scope'>"
15999
16398
  + QUIZ_SCOPES.map((candidate) => candidate === "selection" && !hasSelection ? "" : renderQuizOption(candidate, scope, getQuizScopeLabel(candidate))).join("")
16000
16399
  + "</select></label>"
16001
- + "<label>Angle<select data-quiz-field='angle'>"
16400
+ + "<label>Angle<select class='studio-flat-select' data-quiz-field='angle'>"
16002
16401
  + QUIZ_ANGLES.map((candidate) => renderQuizOption(candidate, angle, getQuizAngleLabel(candidate))).join("")
16003
16402
  + "</select></label>"
16004
- + "<label>Thinking<select data-quiz-field='thinking'>"
16403
+ + "<label>Thinking<select class='studio-flat-select' data-quiz-field='thinking'>"
16005
16404
  + QUIZ_THINKING_LEVELS.map((candidate) => renderQuizOption(candidate, thinking, getQuizThinkingLabel(candidate))).join("")
16006
16405
  + "</select></label>"
16007
16406
  + "<label>Questions<input data-quiz-field='count' type='number' min='1' max='8' value='" + String(count) + "'></label>"
@@ -16861,6 +17260,8 @@
16861
17260
  ensurePreviewSelectionActions(targetEl);
16862
17261
  updatePreviewCommentBlocksForElement(targetEl);
16863
17262
  decorateCopyablePreviewBlocks(targetEl);
17263
+ clearWatchedPreviewRenderError();
17264
+ scheduleWatchedPreviewReadingPositionRestore(targetEl, text);
16864
17265
  if (pane === "response") {
16865
17266
  applyPendingResponseScrollReset();
16866
17267
  scheduleResponsePaneRepaintNudge();
@@ -21734,7 +22135,11 @@
21734
22135
  } catch {}
21735
22136
  }
21736
22137
 
21737
- function setEditorLanguage(lang) {
22138
+ function setEditorLanguage(lang, options) {
22139
+ if (isWatchedFilePreview && !(options && options.allowWatchedFileUpdate === true)) {
22140
+ setStatus("The watched preview language follows its file path.", "warning");
22141
+ return false;
22142
+ }
21738
22143
  editorLanguage = (lang && SUPPORTED_LANGUAGES.indexOf(lang) !== -1) ? lang : "markdown";
21739
22144
  persistEditorLanguage(editorLanguage);
21740
22145
  syncHighlightSelectUi();
@@ -21749,6 +22154,7 @@
21749
22154
  }
21750
22155
  updateOutlineUi();
21751
22156
  scheduleWorkspacePersistence();
22157
+ return true;
21752
22158
  }
21753
22159
 
21754
22160
  function setEditorHighlightMode(mode) {
@@ -22050,11 +22456,90 @@
22050
22456
  return true;
22051
22457
  }
22052
22458
 
22459
+ function handleWatchedFileUpdate(message) {
22460
+ if (!isWatchedFilePreview || !message || typeof message.text !== "string") return;
22461
+ const messagePath = String(message.path || "");
22462
+ if (!watchedFilePreviewState.path || messagePath !== watchedFilePreviewState.path) return;
22463
+ const generation = Math.max(0, Number(message.generation) || 0);
22464
+ if (generation < watchedFilePreviewState.generation) return;
22465
+
22466
+ const expectedPreviewText = prepareEditorTextForPreview(message.text);
22467
+ watchedFilePreviewReadingPositions.source = {
22468
+ snapshot: captureWatchedPreviewReadingPosition(sourcePreviewEl),
22469
+ text: expectedPreviewText,
22470
+ };
22471
+ watchedFilePreviewReadingPositions.response = {
22472
+ snapshot: captureWatchedPreviewReadingPosition(critiqueViewEl),
22473
+ text: expectedPreviewText,
22474
+ };
22475
+ const textareaMaxScroll = Math.max(0, Number(sourceTextEl.scrollHeight || 0) - Number(sourceTextEl.clientHeight || 0));
22476
+ const textareaScrollRatio = textareaMaxScroll > 0 ? Number(sourceTextEl.scrollTop || 0) / textareaMaxScroll : 0;
22477
+ const selectionStart = Math.max(0, Number(sourceTextEl.selectionStart) || 0);
22478
+ const selectionEnd = Math.max(selectionStart, Number(sourceTextEl.selectionEnd) || selectionStart);
22479
+
22480
+ sourceTextEl.value = message.text;
22481
+ const nextMaxScroll = Math.max(0, Number(sourceTextEl.scrollHeight || 0) - Number(sourceTextEl.clientHeight || 0));
22482
+ sourceTextEl.scrollTop = Math.max(0, Math.min(nextMaxScroll, nextMaxScroll * textareaScrollRatio));
22483
+ try {
22484
+ sourceTextEl.setSelectionRange(
22485
+ Math.min(selectionStart, message.text.length),
22486
+ Math.min(selectionEnd, message.text.length),
22487
+ );
22488
+ } catch {
22489
+ // Selection APIs are not guaranteed in every embedded browser.
22490
+ }
22491
+
22492
+ watchedFilePreviewState.generation = generation;
22493
+ watchedFilePreviewState.diskRevision = normalizeStudioDiskRevision(message.diskRevision) || watchedFilePreviewState.diskRevision;
22494
+ watchedFilePreviewState.lastError = "";
22495
+ fileBackedBaselineText = message.text;
22496
+ fileBackedDiskRevision = watchedFilePreviewState.diskRevision || null;
22497
+ editorLanguage = detectLanguageFromName(watchedFilePreviewState.path) || editorLanguage;
22498
+ syncHighlightSelectUi();
22499
+ scheduleEditorHighlightRender();
22500
+ renderSourcePreview({ previewDelayMs: 0 });
22501
+ renderActiveResult();
22502
+ updateSourceBadge();
22503
+ setStatus(message.message || "Watched preview updated from disk.", "success");
22504
+ }
22505
+
22506
+ function handleWatchedFileError(message) {
22507
+ if (!isWatchedFilePreview || !message) return;
22508
+ const messagePath = String(message.path || "");
22509
+ if (watchedFilePreviewState.path && messagePath && messagePath !== watchedFilePreviewState.path) return;
22510
+ watchedFilePreviewState.lastError = String(message.message || "Could not refresh the watched file.");
22511
+ updateSourceBadge();
22512
+ setStatus(watchedFilePreviewState.lastError, "warning");
22513
+ }
22514
+
22515
+ function handleWatchedFileReady(message) {
22516
+ if (!isWatchedFilePreview || !message) return;
22517
+ const messagePath = String(message.path || "");
22518
+ if (watchedFilePreviewState.path && messagePath && messagePath !== watchedFilePreviewState.path) return;
22519
+ watchedFilePreviewState.diskRevision = normalizeStudioDiskRevision(message.diskRevision) || watchedFilePreviewState.diskRevision;
22520
+ watchedFilePreviewState.lastError = "";
22521
+ updateSourceBadge();
22522
+ setStatus(message.message || "Watching file for disk changes.", "success");
22523
+ }
22524
+
22053
22525
  function handleServerMessage(message) {
22054
22526
  if (!message || typeof message !== "object") return;
22055
22527
 
22056
22528
  debugTrace("server_message", summarizeServerMessage(message));
22057
22529
 
22530
+ if (message.type === "watched_file_update") {
22531
+ handleWatchedFileUpdate(message);
22532
+ return;
22533
+ }
22534
+ if (message.type === "watched_file_error") {
22535
+ handleWatchedFileError(message);
22536
+ return;
22537
+ }
22538
+ if (message.type === "watched_file_ready") {
22539
+ handleWatchedFileReady(message);
22540
+ return;
22541
+ }
22542
+
22058
22543
  const contextChanged = applyContextUsageFromMessage(message);
22059
22544
  if (contextChanged) {
22060
22545
  updateFooterMeta();
@@ -22078,6 +22563,7 @@
22078
22563
  }
22079
22564
 
22080
22565
  if (message.type === "side_question_state") {
22566
+ if (Array.isArray(message.sideQuestionThinkingLevels)) applySideQuestionThinkingLevels(message.sideQuestionThinkingLevels);
22081
22567
  sideQuestionWebSearchAvailable = message.webSearchAvailable === true;
22082
22568
  if (Array.isArray(message.availablePiTools)) applySideQuestionToolCatalog(message.availablePiTools);
22083
22569
  const previousStatus = sideQuestionState && sideQuestionState.status;
@@ -22291,7 +22777,11 @@
22291
22777
  message.initialDocument &&
22292
22778
  typeof message.initialDocument.text === "string"
22293
22779
  ) {
22294
- setEditorText(message.initialDocument.text, { preserveScroll: false, preserveSelection: false });
22780
+ setEditorText(message.initialDocument.text, {
22781
+ preserveScroll: false,
22782
+ preserveSelection: false,
22783
+ allowWatchedFileUpdate: isWatchedFilePreview,
22784
+ });
22295
22785
  initialDocumentApplied = true;
22296
22786
  loadedInitialDocument = true;
22297
22787
  setSourceState({
@@ -22303,7 +22793,7 @@
22303
22793
  : (initialSourceState.draftId || null),
22304
22794
  });
22305
22795
  if (message.initialDocument.path) {
22306
- markFileBackedBaseline(message.initialDocument.text);
22796
+ markFileBackedBaseline(message.initialDocument.text, message.initialDocument.diskRevision);
22307
22797
  }
22308
22798
  refreshResponseUi();
22309
22799
  if (typeof message.initialDocument.label === "string" && message.initialDocument.label.length > 0) {
@@ -22664,7 +23154,19 @@
22664
23154
  return;
22665
23155
  }
22666
23156
 
23157
+ if (message.type === "save_conflict") {
23158
+ void handleEditorSaveConflict(message);
23159
+ return;
23160
+ }
23161
+
23162
+ if (message.type === "save_as_conflict") {
23163
+ void handleEditorSaveAsConflict(message);
23164
+ return;
23165
+ }
23166
+
22667
23167
  if (message.type === "saved") {
23168
+ const savedOperation = typeof message.requestId === "string" ? pendingSaveOperations.get(message.requestId) : null;
23169
+ if (typeof message.requestId === "string") pendingSaveOperations.delete(message.requestId);
22668
23170
  if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
22669
23171
  pendingRequestId = null;
22670
23172
  pendingKind = null;
@@ -22683,7 +23185,7 @@
22683
23185
  }, {
22684
23186
  carryCurrentMetadataToNewDocument: true,
22685
23187
  });
22686
- markFileBackedBaseline(sourceTextEl.value);
23188
+ markFileBackedBaseline(savedOperation && typeof savedOperation.content === "string" ? savedOperation.content : sourceTextEl.value, message.diskRevision);
22687
23189
  }
22688
23190
  setBusy(false);
22689
23191
  setWsState("Ready");
@@ -22703,6 +23205,10 @@
22703
23205
  }
22704
23206
 
22705
23207
  if (message.type === "editor_snapshot") {
23208
+ if (isWatchedFilePreview) {
23209
+ setStatus("Ignored editor snapshot because this preview follows its watched file on disk.", "warning");
23210
+ return;
23211
+ }
22706
23212
  if (typeof message.requestId === "string" && pendingRequestId && message.requestId !== pendingRequestId) {
22707
23213
  return;
22708
23214
  }
@@ -22726,6 +23232,10 @@
22726
23232
  }
22727
23233
 
22728
23234
  if (message.type === "studio_document") {
23235
+ if (isWatchedFilePreview) {
23236
+ setStatus("Ignored document replacement because this preview follows its watched file on disk.", "warning");
23237
+ return;
23238
+ }
22729
23239
  const nextDoc = message.document;
22730
23240
  if (!nextDoc || typeof nextDoc !== "object" || typeof nextDoc.text !== "string") {
22731
23241
  return;
@@ -22763,7 +23273,7 @@
22763
23273
  draftId: typeof nextDoc.draftId === "string" && nextDoc.draftId.trim() ? nextDoc.draftId.trim() : null,
22764
23274
  });
22765
23275
  if (nextPath) {
22766
- markFileBackedBaseline(nextDoc.text);
23276
+ markFileBackedBaseline(nextDoc.text, nextDoc.diskRevision);
22767
23277
  }
22768
23278
  refreshResponseUi();
22769
23279
  setStatus(
@@ -22906,6 +23416,7 @@
22906
23416
 
22907
23417
  if (message.type === "busy") {
22908
23418
  if (typeof message.requestId === "string") {
23419
+ pendingSaveOperations.delete(message.requestId);
22909
23420
  failPendingCompanionLaunch(message.requestId, "Studio could not start the companion editor because another request was busy.");
22910
23421
  }
22911
23422
  if (message.requestId && pendingRequestId === message.requestId) {
@@ -22927,6 +23438,7 @@
22927
23438
 
22928
23439
  if (message.type === "error") {
22929
23440
  if (typeof message.requestId === "string") {
23441
+ pendingSaveOperations.delete(message.requestId);
22930
23442
  failPendingCompanionLaunch(message.requestId, "Studio could not prepare the companion editor. Return to the originating Studio page for details.");
22931
23443
  }
22932
23444
  if (message.requestId && pendingRequestId === message.requestId) {
@@ -23033,6 +23545,11 @@
23033
23545
  if (studioMode !== "full") {
23034
23546
  wsParams.set("mode", studioMode);
23035
23547
  }
23548
+ if (isWatchedFilePreview && watchedFilePreviewState.path) {
23549
+ wsParams.set("watchPath", watchedFilePreviewState.path);
23550
+ const watchedDocId = initialQueryParams.get("docId") || "";
23551
+ if (watchedDocId) wsParams.set("docId", watchedDocId);
23552
+ }
23036
23553
  if (DEBUG_ENABLED) {
23037
23554
  wsParams.set("debug", "1");
23038
23555
  }
@@ -23083,6 +23600,14 @@
23083
23600
  return;
23084
23601
  }
23085
23602
 
23603
+ if (kind === "watch_unauthorized") {
23604
+ clearScheduledReconnect();
23605
+ reconnectAttempt = 0;
23606
+ setWsState("Disconnected");
23607
+ setStatus("Watched preview authorization is unavailable. Reload this tab or open a new watched preview from Studio.", "warning");
23608
+ return;
23609
+ }
23610
+
23086
23611
  if (kind === "shutdown") {
23087
23612
  clearScheduledReconnect();
23088
23613
  reconnectAttempt = 0;
@@ -23098,14 +23623,28 @@
23098
23623
  };
23099
23624
 
23100
23625
  socket.addEventListener("open", () => {
23626
+ if (ws !== socket) {
23627
+ try { socket.close(); } catch {}
23628
+ return;
23629
+ }
23101
23630
  window.clearTimeout(connectWatchdog);
23102
23631
  setWsState("Ready");
23103
23632
  setStatus(wasReconnect ? "Reconnected. Syncing…" : "Connected. Syncing…");
23104
23633
  sendMessage({ type: "hello" });
23634
+ if (isWatchedFilePreview && watchedFilePreviewState.path) {
23635
+ watchedFilePreviewState.generation = 0;
23636
+ sendMessage({
23637
+ type: "watch_file_subscribe",
23638
+ path: watchedFilePreviewState.path,
23639
+ revision: watchedFilePreviewState.diskRevision || undefined,
23640
+ });
23641
+ setStatus(wasReconnect ? "Reconnected. Resuming watched preview…" : "Connected. Starting watched preview…");
23642
+ }
23105
23643
  reconnectAttempt = 0;
23106
23644
  });
23107
23645
 
23108
23646
  socket.addEventListener("message", (event) => {
23647
+ if (ws !== socket) return;
23109
23648
  try {
23110
23649
  const message = JSON.parse(event.data);
23111
23650
  handleServerMessage(message);
@@ -23116,6 +23655,7 @@
23116
23655
  });
23117
23656
 
23118
23657
  socket.addEventListener("close", (event) => {
23658
+ if (ws !== socket && !disconnectHandled) return;
23119
23659
  if (event && event.code === 4001) {
23120
23660
  handleDisconnect("invalidated", 4001);
23121
23661
  return;
@@ -23124,6 +23664,10 @@
23124
23664
  handleDisconnect("full_conflict", 4004);
23125
23665
  return;
23126
23666
  }
23667
+ if (event && event.code === 4003) {
23668
+ handleDisconnect("watch_unauthorized", 4003);
23669
+ return;
23670
+ }
23127
23671
  if (event && event.code === 1001) {
23128
23672
  handleDisconnect("shutdown", 1001);
23129
23673
  return;
@@ -23133,6 +23677,7 @@
23133
23677
  });
23134
23678
 
23135
23679
  socket.addEventListener("error", () => {
23680
+ if (ws !== socket) return;
23136
23681
  handleDisconnect("error");
23137
23682
  });
23138
23683
  }
@@ -23361,6 +23906,25 @@
23361
23906
  });
23362
23907
  }
23363
23908
 
23909
+ if (watchedOpenEditableBtn) {
23910
+ watchedOpenEditableBtn.addEventListener("click", () => {
23911
+ const path = watchedFilePreviewState.path;
23912
+ if (!path) {
23913
+ setStatus("This watched preview no longer has a file path.", "warning");
23914
+ return;
23915
+ }
23916
+ try {
23917
+ openFileBackedStudioEditorTab(path, {
23918
+ label: sourceState && sourceState.label ? sourceState.label : basenameForStudioPath(path),
23919
+ resourceDir: getCurrentResourceDirValue() || dirnameForDisplayPath(path),
23920
+ });
23921
+ setStatus("Opening watched file in a separate editable tab.");
23922
+ } catch (error) {
23923
+ setStatus(error && error.message ? error.message : String(error || "Could not open editable tab."), "warning");
23924
+ }
23925
+ });
23926
+ }
23927
+
23364
23928
  updatePaneFocusButtons();
23365
23929
  window.addEventListener("keydown", handlePaneShortcut);
23366
23930
  window.addEventListener("pagehide", () => {
@@ -23882,100 +24446,249 @@
23882
24446
  setFooterThemeMenuOpen(false);
23883
24447
  });
23884
24448
 
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;
24449
+ function abandonPendingSaveRequest(requestId) {
24450
+ pendingSaveOperations.delete(requestId);
24451
+ if (requestId) clearArmedTitleAttention(requestId);
24452
+ if (pendingRequestId === requestId) {
24453
+ pendingRequestId = null;
24454
+ pendingKind = null;
23890
24455
  }
24456
+ stickyStudioKind = null;
24457
+ setBusy(false);
24458
+ setWsState("Ready");
24459
+ }
24460
+
24461
+ function sendEditorSaveAsRequest(path, content, overwrite, expectedRevision) {
24462
+ const cleanPath = String(path || "").trim();
24463
+ if (!cleanPath) {
24464
+ setStatus("Save cancelled: path is required.", "warning");
24465
+ return false;
24466
+ }
24467
+ const requestId = beginUiAction("save_as");
24468
+ if (!requestId) return false;
24469
+ const operation = {
24470
+ kind: "save_as",
24471
+ path: cleanPath,
24472
+ content: String(content ?? ""),
24473
+ overwrite: overwrite === true,
24474
+ expectedRevision: normalizeStudioDiskRevision(expectedRevision),
24475
+ };
24476
+ pendingSaveOperations.set(requestId, operation);
24477
+ if (!sendMessage({
24478
+ type: "save_as_request",
24479
+ requestId,
24480
+ path: operation.path,
24481
+ content: operation.content,
24482
+ overwrite: operation.overwrite,
24483
+ expectedRevision: operation.expectedRevision || undefined,
24484
+ })) {
24485
+ abandonPendingSaveRequest(requestId);
24486
+ return false;
24487
+ }
24488
+ return true;
24489
+ }
23891
24490
 
23892
- var suggestedName = sourceState.label ? stripImportedFileLabel(sourceState.label) : "draft.md";
23893
- var suggestedDir = getCurrentResourceDirValue() ? getCurrentResourceDirValue().replace(/\/$/, "") + "/" : "./";
23894
- const suggested = sourceState.path || (suggestedDir + suggestedName);
24491
+ async function openEditorSaveAsDialog(options) {
24492
+ if (uiBusy) return false;
24493
+ const settings = options && typeof options === "object" ? options : {};
24494
+ const resourceDir = getCurrentResourceDirValue();
24495
+ const currentPath = getEffectiveSavePath();
24496
+ const label = sourceState.label ? stripImportedFileLabel(sourceState.label) : "draft.md";
24497
+ const suggested = typeof settings.suggestedPath === "string" && settings.suggestedPath.trim()
24498
+ ? settings.suggestedPath.trim()
24499
+ : (currentPath || (resourceDir ? resourceDir.replace(/\/$/, "") + "/" + label : "./draft.md"));
23895
24500
  const path = await requestStudioTextInput("Save editor content as:", suggested, {
23896
24501
  title: "Save editor as",
23897
24502
  confirmLabel: "Save",
24503
+ inputLabel: "File path",
23898
24504
  });
23899
- if (!path) return;
23900
-
23901
- const requestId = beginUiAction("save_as");
23902
- if (!requestId) return;
24505
+ if (path === null) {
24506
+ if (settings.reportCancellation === true) setStatus("Save As cancelled; editor changes were kept.", "warning");
24507
+ return false;
24508
+ }
24509
+ const content = Object.prototype.hasOwnProperty.call(settings, "content")
24510
+ ? String(settings.content ?? "")
24511
+ : sourceTextEl.value;
24512
+ return sendEditorSaveAsRequest(path, content, false);
24513
+ }
23903
24514
 
23904
- const sent = sendMessage({
23905
- type: "save_as_request",
23906
- requestId,
24515
+ function sendEditorSaveOverRequest(options) {
24516
+ const settings = options && typeof options === "object" ? options : {};
24517
+ const path = typeof settings.path === "string" && settings.path.trim()
24518
+ ? settings.path.trim()
24519
+ : (sourceState && sourceState.path ? sourceState.path : "");
24520
+ if (!path) {
24521
+ setStatus("Save editor requires a file-backed document. Use Save editor as… for a new file.", "warning");
24522
+ return false;
24523
+ }
24524
+ const requestId = beginUiAction("save_over");
24525
+ if (!requestId) return false;
24526
+ const operation = {
24527
+ kind: "save_over",
23907
24528
  path,
23908
- content,
23909
- });
24529
+ content: Object.prototype.hasOwnProperty.call(settings, "content")
24530
+ ? String(settings.content ?? "")
24531
+ : sourceTextEl.value,
24532
+ expectedRevision: Object.prototype.hasOwnProperty.call(settings, "expectedRevision")
24533
+ ? normalizeStudioDiskRevision(settings.expectedRevision)
24534
+ : fileBackedDiskRevision,
24535
+ force: settings.force === true,
24536
+ };
24537
+ pendingSaveOperations.set(requestId, operation);
24538
+ if (!sendMessage({
24539
+ type: "save_over_request",
24540
+ requestId,
24541
+ path: operation.path,
24542
+ content: operation.content,
24543
+ expectedRevision: operation.expectedRevision || undefined,
24544
+ force: operation.force,
24545
+ })) {
24546
+ abandonPendingSaveRequest(requestId);
24547
+ return false;
24548
+ }
24549
+ return true;
24550
+ }
23910
24551
 
23911
- if (!sent) {
24552
+ async function requestEditorRefreshFromDisk(options) {
24553
+ const settings = options && typeof options === "object" ? options : {};
24554
+ if (uiBusy) return false;
24555
+ const path = typeof settings.path === "string" && settings.path.trim()
24556
+ ? settings.path.trim()
24557
+ : (sourceState && sourceState.path ? sourceState.path : "");
24558
+ if (!path) {
24559
+ setStatus("Refresh from disk requires a file-backed editor. Open one from Files or use /studio-editor-only <path>.", "warning");
24560
+ return false;
24561
+ }
24562
+ if (settings.skipConfirm !== true && editorDiffersFromFileBackedBaseline()) {
24563
+ const confirmed = await requestStudioConfirmation(
24564
+ "Replace the current editor contents with the latest version from disk? Unsaved editor changes will be lost.\n\n" + path,
24565
+ { title: "Refresh from disk?", confirmLabel: "Replace", destructive: true },
24566
+ );
24567
+ if (!confirmed) return false;
24568
+ }
24569
+ const requestId = beginUiAction("refresh_from_disk");
24570
+ if (!requestId) return false;
24571
+ if (!sendMessage({ type: "refresh_from_disk_request", requestId, path })) {
23912
24572
  pendingRequestId = null;
23913
24573
  pendingKind = null;
24574
+ stickyStudioKind = null;
23914
24575
  setBusy(false);
24576
+ setWsState("Ready");
24577
+ return false;
23915
24578
  }
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
- }
24579
+ return true;
24580
+ }
23924
24581
 
23925
- const confirmed = await requestStudioConfirmation("Overwrite " + effectivePath + "?", {
23926
- title: "Overwrite file?",
24582
+ async function handleEditorSaveConflict(message) {
24583
+ const requestId = typeof message.requestId === "string" ? message.requestId : "";
24584
+ const operation = pendingSaveOperations.get(requestId) || {
24585
+ kind: "save_over",
24586
+ path: typeof message.path === "string" ? message.path : (sourceState.path || ""),
24587
+ content: sourceTextEl.value,
24588
+ expectedRevision: fileBackedDiskRevision,
24589
+ force: false,
24590
+ };
24591
+ abandonPendingSaveRequest(requestId);
24592
+ const conflictPath = typeof message.path === "string" && message.path.trim() ? message.path.trim() : operation.path;
24593
+ const canOverwrite = message.canOverwrite !== false;
24594
+ const detail = (typeof message.message === "string" && message.message.trim()
24595
+ ? message.message.trim()
24596
+ : "The file changed on disk after Studio loaded it.")
24597
+ + "\n\n" + conflictPath
24598
+ + "\n\nReload replaces the editor with disk content. Save As keeps both versions."
24599
+ + (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.");
24600
+ setStatus("Save paused because the file changed on disk.", "warning");
24601
+ const decision = await openStudioDecision({
24602
+ mode: "confirm",
24603
+ title: "File changed on disk",
24604
+ message: detail,
24605
+ cancelLabel: "Cancel",
24606
+ tertiaryLabel: "Reload",
24607
+ tertiaryValue: "reload",
24608
+ secondaryLabel: "Save As…",
24609
+ secondaryValue: "save-as",
23927
24610
  confirmLabel: "Overwrite",
24611
+ confirmDisabled: !canOverwrite,
23928
24612
  destructive: true,
23929
24613
  });
23930
- if (!confirmed) return;
23931
-
23932
- const requestId = beginUiAction("save_over");
23933
- if (!requestId) return;
24614
+ if (decision === "reload") {
24615
+ await requestEditorRefreshFromDisk({ path: conflictPath, skipConfirm: true });
24616
+ return;
24617
+ }
24618
+ if (decision === "save-as") {
24619
+ await openEditorSaveAsDialog({ content: operation.content, suggestedPath: conflictPath, reportCancellation: true });
24620
+ return;
24621
+ }
24622
+ if (decision === true && canOverwrite) {
24623
+ sendEditorSaveOverRequest({
24624
+ path: conflictPath,
24625
+ content: operation.content,
24626
+ expectedRevision: message.currentRevision,
24627
+ force: true,
24628
+ });
24629
+ return;
24630
+ }
24631
+ setStatus("Save cancelled; editor changes were kept.", "warning");
24632
+ }
23934
24633
 
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,
24634
+ async function handleEditorSaveAsConflict(message) {
24635
+ const requestId = typeof message.requestId === "string" ? message.requestId : "";
24636
+ const operation = pendingSaveOperations.get(requestId) || {
24637
+ kind: "save_as",
24638
+ path: typeof message.path === "string" ? message.path : "",
23940
24639
  content: sourceTextEl.value,
24640
+ overwrite: false,
24641
+ expectedRevision: null,
24642
+ };
24643
+ abandonPendingSaveRequest(requestId);
24644
+ const conflictPath = typeof message.path === "string" && message.path.trim() ? message.path.trim() : operation.path;
24645
+ const targetExists = Boolean(normalizeStudioDiskRevision(message.currentRevision));
24646
+ const unsafeReplacement = message.reason === "location-changed" || message.reason === "hard-linked-file";
24647
+ const canCommitHere = !unsafeReplacement;
24648
+ setStatus(unsafeReplacement
24649
+ ? "Save As cannot replace this target safely; choose another path."
24650
+ : (targetExists
24651
+ ? "Save As paused because the target already exists."
24652
+ : "Save As paused because the target changed while confirmation was open."), "warning");
24653
+ const decision = await openStudioDecision({
24654
+ mode: "confirm",
24655
+ title: unsafeReplacement ? "Cannot replace existing file" : (targetExists ? "Replace existing file?" : "Create file at this path?"),
24656
+ message: (typeof message.message === "string" && message.message.trim()
24657
+ ? message.message.trim()
24658
+ : (targetExists ? "A file already exists at this location." : "The previous replacement target is no longer present."))
24659
+ + (unsafeReplacement
24660
+ ? "\n\nStudio will not replace a symlink, moved path, or hard-linked file. Choose another location instead."
24661
+ : (targetExists ? "\n\nReplacing it cannot be undone." : "\n\nCreating it will keep the current editor text at this path.")),
24662
+ cancelLabel: "Cancel",
24663
+ secondaryLabel: "Choose another…",
24664
+ secondaryValue: "choose-another",
24665
+ confirmLabel: targetExists ? "Replace" : "Create",
24666
+ confirmDisabled: !canCommitHere,
24667
+ destructive: targetExists,
23941
24668
  });
23942
-
23943
- if (!sent) {
23944
- pendingRequestId = null;
23945
- pendingKind = null;
23946
- setBusy(false);
24669
+ if (decision === "choose-another") {
24670
+ await openEditorSaveAsDialog({ content: operation.content, suggestedPath: conflictPath, reportCancellation: true });
24671
+ return;
23947
24672
  }
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
- }
24673
+ if (decision === true && canCommitHere) {
24674
+ sendEditorSaveAsRequest(conflictPath, operation.content, true, message.currentRevision);
24675
+ return;
24676
+ }
24677
+ setStatus("Save As cancelled; editor changes were kept.", "warning");
24678
+ }
23964
24679
 
23965
- const requestId = beginUiAction("refresh_from_disk");
23966
- if (!requestId) return;
24680
+ saveAsBtn.addEventListener("click", () => {
24681
+ void openEditorSaveAsDialog();
24682
+ });
23967
24683
 
23968
- const sent = sendMessage({
23969
- type: "refresh_from_disk_request",
23970
- requestId,
23971
- path: sourceState.path,
23972
- });
24684
+ saveOverBtn.addEventListener("click", () => {
24685
+ if (uiBusy) return;
24686
+ sendEditorSaveOverRequest();
24687
+ });
23973
24688
 
23974
- if (!sent) {
23975
- pendingRequestId = null;
23976
- pendingKind = null;
23977
- setBusy(false);
23978
- }
24689
+ if (refreshFromDiskBtn) {
24690
+ refreshFromDiskBtn.addEventListener("click", () => {
24691
+ void requestEditorRefreshFromDisk();
23979
24692
  });
23980
24693
  }
23981
24694
 
@@ -24709,7 +25422,16 @@
24709
25422
  resourceDirInput.value = normalizeStudioResourceDirValue(initialResourceDir);
24710
25423
  }
24711
25424
  setSourceState(initialSourceState);
24712
- if (initialSourceState.path) markFileBackedBaseline(sourceTextEl.value);
25425
+ if (initialSourceState.path) markFileBackedBaseline(sourceTextEl.value, initialDiskRevision);
25426
+ if (isWatchedFilePreview) {
25427
+ sourceTextEl.readOnly = true;
25428
+ sourceTextEl.setAttribute("aria-readonly", "true");
25429
+ sourceTextEl.title = "Read-only disk-backed source. Open a file tab to edit and save safely.";
25430
+ editorView = "markdown";
25431
+ rightView = "editor-preview";
25432
+ followLatest = false;
25433
+ if (document.body && document.body.classList) document.body.classList.add("watched-file-preview");
25434
+ }
24713
25435
  refreshResponseUi();
24714
25436
  updateAnnotatedReplyHeaderButton();
24715
25437
  setActivePane(initialPaneFocusTarget === "off" ? "left" : initialPaneFocusTarget);
@@ -24722,7 +25444,9 @@
24722
25444
 
24723
25445
  const initialDetectedLang = detectLanguageFromName(initialSourceState.path || initialSourceState.label || "");
24724
25446
  const storedLang = readStoredEditorLanguage();
24725
- setEditorLanguage(initialDetectedLang || storedLang || "markdown");
25447
+ setEditorLanguage(initialDetectedLang || storedLang || "markdown", {
25448
+ allowWatchedFileUpdate: isWatchedFilePreview,
25449
+ });
24726
25450
 
24727
25451
  const storedLineNumbersEnabled = readStoredEditorLineNumbersEnabled();
24728
25452
  const initialLineNumbersEnabled = storedLineNumbersEnabled ?? Boolean(lineNumbersSelect && lineNumbersSelect.value === "on");