pi-studio 0.9.12 → 0.9.14

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.
@@ -103,6 +103,7 @@
103
103
  const saveAsBtn = document.getElementById("saveAsBtn");
104
104
  const saveOverBtn = document.getElementById("saveOverBtn");
105
105
  const refreshFromDiskBtn = document.getElementById("refreshFromDiskBtn");
106
+ const clearWorkspaceBtn = document.getElementById("clearWorkspaceBtn");
106
107
  const sendEditorBtn = document.getElementById("sendEditorBtn");
107
108
  const openCompanionBtn = document.getElementById("openCompanionBtn");
108
109
  const getEditorBtn = document.getElementById("getEditorBtn");
@@ -1880,6 +1881,8 @@
1880
1881
  const PANE_SPLIT_MIN_PERCENT = 20;
1881
1882
  const PANE_SPLIT_MAX_PERCENT = 80;
1882
1883
  const PANE_SPLIT_SNAP_TO_CENTER_PERCENT = 1;
1884
+ const STUDIO_WORKSPACE_STORAGE_KEY = "piStudio.workspaceState.v1";
1885
+ const STUDIO_WORKSPACE_MAX_TEXT_CHARS = 900_000;
1883
1886
  const EDITOR_HIGHLIGHT_MAX_CHARS = 100_000;
1884
1887
  const EDITOR_HIGHLIGHT_STORAGE_KEY = "piStudio.editorHighlightEnabled";
1885
1888
  const EDITOR_LANGUAGE_STORAGE_KEY = "piStudio.editorLanguage";
@@ -1974,6 +1977,9 @@
1974
1977
  let pendingReviewNoteInlineFocusId = null;
1975
1978
  let activePreviewCommentSelection = null;
1976
1979
  let suppressEditorSelectionComment = false;
1980
+ let workspacePersistenceReady = false;
1981
+ let workspacePersistTimer = null;
1982
+ let workspaceRestoredFromBrowser = false;
1977
1983
  let suppressedEditorSelectionStart = null;
1978
1984
  let suppressedEditorSelectionEnd = null;
1979
1985
  const previewJumpHighlightState = new WeakMap();
@@ -2168,6 +2174,49 @@
2168
2174
  scheduleResponsePaneRepaintNudge();
2169
2175
  }
2170
2176
 
2177
+ function getActivePaneTextSizeConfig() {
2178
+ if (activePane === "right") {
2179
+ return {
2180
+ label: "Right pane text size",
2181
+ value: responseFontSize,
2182
+ defaultValue: DEFAULT_RESPONSE_FONT_SIZE,
2183
+ options: RESPONSE_FONT_SIZE_OPTIONS,
2184
+ setValue: setResponseFontSize,
2185
+ };
2186
+ }
2187
+ return {
2188
+ label: "Editor text size",
2189
+ value: editorFontSize,
2190
+ defaultValue: DEFAULT_EDITOR_FONT_SIZE,
2191
+ options: EDITOR_FONT_SIZE_OPTIONS,
2192
+ setValue: setEditorFontSize,
2193
+ };
2194
+ }
2195
+
2196
+ function getNextStudioFontSizeOption(currentValue, options, defaultValue, direction) {
2197
+ const normalized = normalizeStudioFontSize(currentValue, options, defaultValue);
2198
+ const currentIndex = Math.max(0, options.findIndex((option) => Math.abs(option - normalized) < 0.001));
2199
+ const nextIndex = Math.max(0, Math.min(options.length - 1, currentIndex + direction));
2200
+ return options[nextIndex];
2201
+ }
2202
+
2203
+ function adjustActivePaneTextSize(direction) {
2204
+ const config = getActivePaneTextSizeConfig();
2205
+ const nextSize = getNextStudioFontSizeOption(config.value, config.options, config.defaultValue, direction);
2206
+ if (Math.abs(nextSize - config.value) < 0.001) {
2207
+ setStatus(config.label + " already at " + formatStudioFontSizeLabel(nextSize) + ".", "warning");
2208
+ return;
2209
+ }
2210
+ config.setValue(nextSize);
2211
+ setStatus(config.label + ": " + formatStudioFontSizeLabel(nextSize) + ".");
2212
+ }
2213
+
2214
+ function resetActivePaneTextSize() {
2215
+ const config = getActivePaneTextSizeConfig();
2216
+ config.setValue(config.defaultValue);
2217
+ setStatus(config.label + " reset to " + formatStudioFontSizeLabel(config.defaultValue) + ".");
2218
+ }
2219
+
2171
2220
  function getStudioUiRefreshAnnotationHeaderEnabled() {
2172
2221
  try {
2173
2222
  return Boolean(stripAnnotationHeader(sourceTextEl.value).hadHeader);
@@ -2180,12 +2229,8 @@
2180
2229
  if (!studioUiRefreshUi) return;
2181
2230
  if (studioUiRefreshUi.annotationsButton) {
2182
2231
  const inlineLabel = annotationsEnabled ? "Inline on" : "Inline hidden";
2183
- if (isEditorOnlyMode) {
2184
- setStudioUiRefreshButtonText(studioUiRefreshUi.annotationsButton, "Annotations: " + inlineLabel);
2185
- } else {
2186
- const headerLabel = getStudioUiRefreshAnnotationHeaderEnabled() ? "Header on" : "Header off";
2187
- setStudioUiRefreshButtonText(studioUiRefreshUi.annotationsButton, "Annotations: " + inlineLabel + " · " + headerLabel);
2188
- }
2232
+ const headerLabel = getStudioUiRefreshAnnotationHeaderEnabled() ? "Header on" : "Header off";
2233
+ setStudioUiRefreshButtonText(studioUiRefreshUi.annotationsButton, "Annotations: " + inlineLabel + " · " + headerLabel);
2189
2234
  }
2190
2235
  if (studioUiRefreshUi.viewButton) {
2191
2236
  const syntaxLabel = editorHighlightEnabled
@@ -2349,7 +2394,7 @@
2349
2394
  const stateEl = makeStudioUiRefreshElement("div", "studio-refresh-toolbar-state");
2350
2395
  const annotationsButton = makeStudioUiRefreshElement("button", "", "Annotations");
2351
2396
  const annotationsMenu = makeStudioUiRefreshMenu(annotationsButton, "annotations", "studio-refresh-annotations-anchor");
2352
- appendStudioUiRefreshMenuSection(annotationsMenu.menu, "Display", isEditorOnlyMode ? [annotationModeSelect] : [annotationModeSelect, insertHeaderBtn]);
2397
+ appendStudioUiRefreshMenuSection(annotationsMenu.menu, "Display", [annotationModeSelect, insertHeaderBtn]);
2353
2398
  appendStudioUiRefreshMenuSection(annotationsMenu.menu, "Actions", [stripAnnotationsBtn, saveAnnotatedBtn]);
2354
2399
  const viewButton = makeStudioUiRefreshElement("button", "", "View");
2355
2400
  const viewMenu = makeStudioUiRefreshMenu(viewButton, "view", "studio-refresh-view-anchor");
@@ -3071,14 +3116,16 @@
3071
3116
  // Show "Set working dir" button when not file-backed
3072
3117
  var isFileBacked = hasRefreshableFilePath();
3073
3118
  if (isFileBacked) {
3074
- if (resourceDirInput) resourceDirInput.value = "";
3119
+ var fileBackedResourceDir = getCurrentResourceDirValue() || dirnameForDisplayPath(sourceState.path);
3120
+ if (resourceDirInput) resourceDirInput.value = fileBackedResourceDir;
3075
3121
  if (resourceDirLabel) resourceDirLabel.textContent = "";
3076
3122
  if (resourceDirBtn) resourceDirBtn.hidden = true;
3077
3123
  if (resourceDirLabel) resourceDirLabel.hidden = true;
3078
3124
  if (resourceDirInputWrap) resourceDirInputWrap.classList.remove("visible");
3079
3125
  } else {
3080
3126
  // Restore to label if dir is set, otherwise show button
3081
- var dir = resourceDirInput ? resourceDirInput.value.trim() : "";
3127
+ var dir = getCurrentResourceDirValue();
3128
+ if (resourceDirInput) resourceDirInput.value = dir;
3082
3129
  if (dir) {
3083
3130
  if (resourceDirBtn) resourceDirBtn.hidden = true;
3084
3131
  if (resourceDirLabel) { resourceDirLabel.textContent = "Working dir: " + dir; resourceDirLabel.hidden = false; }
@@ -3144,7 +3191,6 @@
3144
3191
  }
3145
3192
 
3146
3193
  function loadPaneSplitPercent() {
3147
- if (isEditorOnlyMode) return;
3148
3194
  let stored = "";
3149
3195
  try {
3150
3196
  stored = window.localStorage ? String(window.localStorage.getItem(PANE_SPLIT_STORAGE_KEY) || "") : "";
@@ -3166,7 +3212,7 @@
3166
3212
  }
3167
3213
 
3168
3214
  function setupPaneResizeHandle() {
3169
- if (!paneResizeHandleEl || isEditorOnlyMode) return;
3215
+ if (!paneResizeHandleEl) return;
3170
3216
  loadPaneSplitPercent();
3171
3217
  let dragging = false;
3172
3218
  let movedDuringDrag = false;
@@ -3347,10 +3393,6 @@
3347
3393
 
3348
3394
  function activatePaneFromShortcut(nextPane) {
3349
3395
  const pane = nextPane === "right" ? "right" : "left";
3350
- if (isEditorOnlyMode && pane === "right") {
3351
- setStatus("Only the editor pane is available in editor-only Studio.", "warning");
3352
- return;
3353
- }
3354
3396
  const snapshot = snapshotStudioScrollablePositions();
3355
3397
  setActivePane(pane);
3356
3398
  scheduleStudioScrollablePositionRestore(snapshot);
@@ -3393,10 +3435,6 @@
3393
3435
  }
3394
3436
 
3395
3437
  function focusRightContentFromShortcut() {
3396
- if (isEditorOnlyMode) {
3397
- setStatus("Only the editor pane is available in editor-only Studio.", "warning");
3398
- return;
3399
- }
3400
3438
  const snapshot = snapshotStudioScrollablePositions();
3401
3439
  setActivePane("right");
3402
3440
  scheduleStudioScrollablePositionRestore(snapshot);
@@ -3653,6 +3691,24 @@
3653
3691
  }
3654
3692
  }
3655
3693
 
3694
+ if (!isTextEntryShortcutTarget(event.target) && !event.metaKey && !event.ctrlKey && event.altKey) {
3695
+ if (code === "Equal" || code === "NumpadAdd" || key === "=" || key === "+") {
3696
+ event.preventDefault();
3697
+ adjustActivePaneTextSize(1);
3698
+ return;
3699
+ }
3700
+ if (code === "Minus" || code === "NumpadSubtract" || key === "-" || key === "_") {
3701
+ event.preventDefault();
3702
+ adjustActivePaneTextSize(-1);
3703
+ return;
3704
+ }
3705
+ if (code === "Digit0" || code === "Numpad0" || key === "0") {
3706
+ event.preventDefault();
3707
+ resetActivePaneTextSize();
3708
+ return;
3709
+ }
3710
+ }
3711
+
3656
3712
  const isPaneSwitchShortcut = key === "F6" && !event.metaKey && !event.ctrlKey && !event.altKey;
3657
3713
  if (isPaneSwitchShortcut) {
3658
3714
  event.preventDefault();
@@ -4316,6 +4372,36 @@
4316
4372
  + " if (node && node.nodeType === 3) node = node.parentElement;\n"
4317
4373
  + " return node && typeof node.closest === 'function' ? node.closest('a[href]') : null;\n"
4318
4374
  + " }\n"
4375
+ + " function isLocalHtmlPreviewLinkHref(value) {\n"
4376
+ + " const raw = String(value || '').trim();\n"
4377
+ + " if (!raw || raw.charAt(0) === '#') return false;\n"
4378
+ + " if (/^\\/\\//.test(raw)) return false;\n"
4379
+ + " if (/^(?:https?|mailto|tel|data|blob|javascript|about):/i.test(raw)) return false;\n"
4380
+ + " return true;\n"
4381
+ + " }\n"
4382
+ + " function postHtmlPreviewLocalLink(action, anchor, event) {\n"
4383
+ + " if (!anchor || typeof anchor.getAttribute !== 'function') return false;\n"
4384
+ + " if (anchor.hasAttribute('download')) return false;\n"
4385
+ + " const target = String(anchor.getAttribute('target') || '').trim().toLowerCase();\n"
4386
+ + " if (target && target !== '_self') return false;\n"
4387
+ + " const href = String(anchor.getAttribute('href') || '').trim();\n"
4388
+ + " if (!isLocalHtmlPreviewLinkHref(href)) return false;\n"
4389
+ + " try { parent.postMessage({ type: 'pi-studio-html-artifact-local-link', id: PREVIEW_ID, action, href, title: String(anchor.textContent || href).trim(), clientX: event && event.clientX || 0, clientY: event && event.clientY || 0 }, '*'); } catch {}\n"
4390
+ + " return true;\n"
4391
+ + " }\n"
4392
+ + " function handleHtmlPreviewLocalLinkClick(event) {\n"
4393
+ + " if (!event || event.defaultPrevented) return;\n"
4394
+ + " if (typeof event.button === 'number' && event.button !== 0) return;\n"
4395
+ + " const anchor = getAnchorFromClickTarget(event.target);\n"
4396
+ + " if (!postHtmlPreviewLocalLink('open', anchor, event)) return;\n"
4397
+ + " event.preventDefault();\n"
4398
+ + " }\n"
4399
+ + " function handleHtmlPreviewLocalLinkContextMenu(event) {\n"
4400
+ + " if (!event || event.defaultPrevented) return;\n"
4401
+ + " const anchor = getAnchorFromClickTarget(event.target);\n"
4402
+ + " if (!postHtmlPreviewLocalLink('contextmenu', anchor, event)) return;\n"
4403
+ + " event.preventDefault();\n"
4404
+ + " }\n"
4319
4405
  + " function getSameDocumentFragment(anchor) {\n"
4320
4406
  + " if (!anchor || typeof anchor.getAttribute !== 'function') return null;\n"
4321
4407
  + " if (anchor.hasAttribute('download')) return null;\n"
@@ -4573,6 +4659,8 @@
4573
4659
  + " }\n"
4574
4660
  + " });\n"
4575
4661
  + " document.addEventListener('click', handleFragmentAnchorClick);\n"
4662
+ + " document.addEventListener('click', handleHtmlPreviewLocalLinkClick);\n"
4663
+ + " document.addEventListener('contextmenu', handleHtmlPreviewLocalLinkContextMenu);\n"
4576
4664
  + " document.addEventListener('DOMContentLoaded', () => { scheduleHtmlMathRenderScan(); scheduleHtmlPreviewResourceScan(); });\n"
4577
4665
  + " window.addEventListener('hashchange', () => {\n"
4578
4666
  + " const hash = String(window.location && window.location.hash || '');\n"
@@ -4840,7 +4928,8 @@
4840
4928
  const params = new URLSearchParams({ token, path: String(resourceUrl || "") });
4841
4929
  if (record && record.sourcePath) {
4842
4930
  params.set("sourcePath", record.sourcePath);
4843
- } else if (record && record.resourceDir) {
4931
+ }
4932
+ if (record && record.resourceDir) {
4844
4933
  params.set("resourceDir", record.resourceDir);
4845
4934
  }
4846
4935
  return "/html-preview-resource?" + params.toString();
@@ -4905,10 +4994,71 @@
4905
4994
  void resolveHtmlArtifactResources(record, items);
4906
4995
  }
4907
4996
 
4997
+ function getHtmlArtifactLocalLinkContext(record, data) {
4998
+ return {
4999
+ href: typeof data.href === "string" ? data.href : "",
5000
+ title: typeof data.title === "string" && data.title.trim() ? data.title.trim() : (typeof data.href === "string" ? data.href : "local link"),
5001
+ sourcePath: record && record.sourcePath ? String(record.sourcePath) : "",
5002
+ resourceDir: record && record.resourceDir ? String(record.resourceDir) : "",
5003
+ };
5004
+ }
5005
+
5006
+ function getHtmlArtifactLocalLinkClientPoint(record, data) {
5007
+ const iframe = record && record.iframe;
5008
+ const rect = iframe && typeof iframe.getBoundingClientRect === "function"
5009
+ ? iframe.getBoundingClientRect()
5010
+ : { left: 0, top: 0 };
5011
+ return {
5012
+ clientX: rect.left + (Number(data.clientX) || 0),
5013
+ clientY: rect.top + (Number(data.clientY) || 0),
5014
+ };
5015
+ }
5016
+
5017
+ function handleHtmlArtifactFrameLocalLinkMessage(event) {
5018
+ const data = event && event.data;
5019
+ if (!data || typeof data !== "object" || data.type !== "pi-studio-html-artifact-local-link") return;
5020
+ const id = typeof data.id === "string" ? data.id : "";
5021
+ const record = id ? htmlArtifactFramesById.get(id) : null;
5022
+ if (!record || !record.iframe || !record.iframe.isConnected) {
5023
+ if (id) htmlArtifactFramesById.delete(id);
5024
+ return;
5025
+ }
5026
+ if (event.source && record.iframe.contentWindow && event.source !== record.iframe.contentWindow) return;
5027
+ const context = getHtmlArtifactLocalLinkContext(record, data);
5028
+ if (!isStudioLocalPreviewHref(context.href)) return;
5029
+ const action = typeof data.action === "string" ? data.action : "open";
5030
+ if (action === "contextmenu") {
5031
+ const point = getHtmlArtifactLocalLinkClientPoint(record, data);
5032
+ showPreviewLinkMenu(null, point, context);
5033
+ return;
5034
+ }
5035
+ const kind = getPreviewLocalLinkKind(context.href);
5036
+ if (kind === "pdf") {
5037
+ openPreviewPdfLink(context.href, context.title, context);
5038
+ return;
5039
+ }
5040
+ if (kind === "image") {
5041
+ const pendingWindow = window.open("", "_blank");
5042
+ void openPreviewImageLink(context.href, context.title, context, pendingWindow).catch((error) => {
5043
+ setStatus((error && error.message) ? error.message : String(error || "Could not open linked image."), "warning");
5044
+ });
5045
+ return;
5046
+ }
5047
+ if (kind === "text") {
5048
+ const pendingWindow = window.open("", "_blank");
5049
+ void openPreviewDocumentInNewEditor(context.href, pendingWindow, context).catch((error) => {
5050
+ setStatus((error && error.message) ? error.message : String(error || "Could not open linked file."), "warning");
5051
+ });
5052
+ return;
5053
+ }
5054
+ setStatus("Right-click this local HTML preview link for file actions.", "warning");
5055
+ }
5056
+
4908
5057
  window.addEventListener("message", handleHtmlArtifactFrameSizeMessage);
4909
5058
  window.addEventListener("message", handleHtmlArtifactFrameFragmentMessage);
4910
5059
  window.addEventListener("message", handleHtmlArtifactFrameMathRenderMessage);
4911
5060
  window.addEventListener("message", handleHtmlArtifactFrameResourceMessage);
5061
+ window.addEventListener("message", handleHtmlArtifactFrameLocalLinkMessage);
4912
5062
 
4913
5063
  function isStudioHtmlFocusOpen() {
4914
5064
  return Boolean(studioHtmlFocusOverlayEl && studioHtmlFocusOverlayEl.hidden === false && studioHtmlFocusShellEl);
@@ -5461,13 +5611,16 @@
5461
5611
  if (!token) return "";
5462
5612
  const pdfPath = String(options && options.path ? options.path : "").trim();
5463
5613
  if (!pdfPath) return "";
5614
+ const explicitSourcePath = options && typeof options.sourcePath === "string" ? options.sourcePath.trim() : "";
5615
+ const explicitResourceDir = options && typeof options.resourceDir === "string" ? normalizeStudioResourceDirValue(options.resourceDir) : "";
5464
5616
  const effectivePath = getEffectiveSavePath();
5465
- const sourcePath = useEditorResourceContext ? (effectivePath || sourceState.path || "") : "";
5466
- const resourceDir = resourceDirInput && resourceDirInput.value.trim() ? resourceDirInput.value.trim() : "";
5617
+ const sourcePath = explicitSourcePath || (useEditorResourceContext ? (effectivePath || sourceState.path || "") : "");
5618
+ const resourceDir = explicitResourceDir || getCurrentResourceDirValue();
5467
5619
  const params = new URLSearchParams({ token, path: pdfPath });
5468
5620
  if (sourcePath) {
5469
5621
  params.set("sourcePath", sourcePath);
5470
- } else if (resourceDir) {
5622
+ }
5623
+ if (resourceDir) {
5471
5624
  params.set("resourceDir", resourceDir);
5472
5625
  }
5473
5626
  return "/pdf-resource?" + params.toString();
@@ -6661,7 +6814,7 @@
6661
6814
  const payload = {
6662
6815
  markdown: String(markdown || ""),
6663
6816
  sourcePath: sourcePath,
6664
- resourceDir: (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "",
6817
+ resourceDir: (!sourcePath && resourceDirInput) ? getCurrentResourceDirValue() : "",
6665
6818
  };
6666
6819
  if (previewOptions.includeEditorLanguage) {
6667
6820
  payload.editorLanguage = String(editorLanguage || "");
@@ -6789,7 +6942,7 @@
6789
6942
 
6790
6943
  const effectivePath = getEffectiveSavePath();
6791
6944
  const sourcePath = exportingReplJournal ? "" : (effectivePath || sourceState.path || "");
6792
- const resourceDir = (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "";
6945
+ const resourceDir = (!sourcePath && resourceDirInput) ? getCurrentResourceDirValue() : "";
6793
6946
  const isEditorPreview = rightView === "editor-preview";
6794
6947
  const editorPdfLanguage = isEditorPreview ? normalizeFenceLanguage(editorLanguage || "") : "";
6795
6948
  const isLatex = isEditorPreview
@@ -6955,7 +7108,7 @@
6955
7108
 
6956
7109
  const effectivePath = getEffectiveSavePath();
6957
7110
  const sourcePath = exportingReplJournal ? "" : (effectivePath || sourceState.path || "");
6958
- const resourceDir = (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "";
7111
+ const resourceDir = (!sourcePath && resourceDirInput) ? getCurrentResourceDirValue() : "";
6959
7112
  const isEditorPreview = rightView === "editor-preview";
6960
7113
  const editorHtmlLanguage = htmlArtifactSource ? "html" : (isEditorPreview ? normalizeFenceLanguage(editorLanguage || "") : "");
6961
7114
  const isLatex = htmlArtifactSource ? false : (isEditorPreview
@@ -7344,7 +7497,7 @@
7344
7497
  decorateCopyablePreviewBlocks(targetEl);
7345
7498
 
7346
7499
  // Warn if relative images are present but unlikely to resolve (non-file-backed content)
7347
- if (!sourceState.path && !(resourceDirInput && resourceDirInput.value.trim())) {
7500
+ if (!sourceState.path && !getCurrentResourceDirValue()) {
7348
7501
  var hasRelativeImages = /!\[.*?\]\((?!https?:\/\/|data:)[^)]+\)/.test(markdown || "");
7349
7502
  var hasLatexImages = /\\includegraphics/.test(markdown || "");
7350
7503
  if (hasRelativeImages || hasLatexImages) {
@@ -8131,23 +8284,54 @@
8131
8284
  updateResultActionButtons();
8132
8285
  }
8133
8286
 
8287
+ function normalizeStudioResourceDirValue(value) {
8288
+ let text = String(value || "").trim();
8289
+ if (text.length >= 2) {
8290
+ const first = text.charAt(0);
8291
+ const last = text.charAt(text.length - 1);
8292
+ if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
8293
+ text = text.slice(1, -1).trim();
8294
+ }
8295
+ }
8296
+ if (/^file:\/\//i.test(text)) {
8297
+ try {
8298
+ text = decodeURIComponent(new URL(text).pathname || text).trim();
8299
+ } catch {}
8300
+ }
8301
+ const markers = ["/Users/", "/home/", "/Volumes/", "/private/", "/tmp/", "/var/", "/opt/", "/Applications/"];
8302
+ let embeddedAbsoluteIndex = -1;
8303
+ for (const marker of markers) {
8304
+ const index = text.lastIndexOf(marker);
8305
+ if (index > 0) embeddedAbsoluteIndex = Math.max(embeddedAbsoluteIndex, index);
8306
+ }
8307
+ const windowsMatch = text.match(/.*([A-Za-z]:[\\/].*)$/);
8308
+ if (windowsMatch && windowsMatch[1]) return windowsMatch[1].trim();
8309
+ if (embeddedAbsoluteIndex > 0) text = text.slice(embeddedAbsoluteIndex).trim();
8310
+ return text;
8311
+ }
8312
+
8313
+ function getCurrentResourceDirValue() {
8314
+ return resourceDirInput ? normalizeStudioResourceDirValue(resourceDirInput.value) : "";
8315
+ }
8316
+
8134
8317
  function getEffectiveSavePath() {
8135
8318
  // File-backed: use the original path
8136
8319
  if (sourceState.path) return sourceState.path;
8137
8320
  // Upload with working dir + filename: derive path
8138
- if (sourceState.source === "upload" && sourceState.label && resourceDirInput && resourceDirInput.value.trim()) {
8321
+ const resourceDir = getCurrentResourceDirValue();
8322
+ if (sourceState.source === "upload" && sourceState.label && resourceDir) {
8139
8323
  var name = sourceState.label.replace(/^upload:\s*/i, "");
8140
- if (name) return resourceDirInput.value.trim().replace(/\/$/, "") + "/" + name;
8324
+ if (name) return resourceDir.replace(/\/$/, "") + "/" + name;
8141
8325
  }
8142
8326
  return null;
8143
8327
  }
8144
8328
 
8145
8329
  function getHtmlPreviewResourceContextOptions() {
8146
8330
  const sourcePath = getEffectiveSavePath() || sourceState.path || "";
8147
- const resourceDir = resourceDirInput && resourceDirInput.value.trim() ? resourceDirInput.value.trim() : "";
8331
+ const resourceDir = getCurrentResourceDirValue();
8148
8332
  return {
8149
8333
  sourcePath,
8150
- resourceDir: sourcePath ? "" : resourceDir,
8334
+ resourceDir,
8151
8335
  };
8152
8336
  }
8153
8337
 
@@ -8163,8 +8347,8 @@
8163
8347
 
8164
8348
  const rawLabel = sourceState.label ? sourceState.label.replace(/^upload:\s*/i, "") : "draft.md";
8165
8349
  const stem = rawLabel.replace(/\.[^.]+$/, "") || "draft";
8166
- const suggestedDir = resourceDirInput && resourceDirInput.value.trim()
8167
- ? resourceDirInput.value.trim().replace(/\/$/, "") + "/"
8350
+ const suggestedDir = getCurrentResourceDirValue()
8351
+ ? getCurrentResourceDirValue().replace(/\/$/, "") + "/"
8168
8352
  : "./";
8169
8353
  return suggestedDir + stem + ".annotated.md";
8170
8354
  }
@@ -8201,6 +8385,7 @@
8201
8385
  saveAsBtn.disabled = uiBusy;
8202
8386
  saveOverBtn.disabled = uiBusy || !canSaveOver;
8203
8387
  if (refreshFromDiskBtn) refreshFromDiskBtn.disabled = uiBusy || !canRefreshFromDisk;
8388
+ if (clearWorkspaceBtn) clearWorkspaceBtn.disabled = uiBusy;
8204
8389
  sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
8205
8390
  if (getEditorBtn) getEditorBtn.disabled = uiBusy;
8206
8391
  if (loadGitDiffBtn) loadGitDiffBtn.disabled = uiBusy;
@@ -8217,7 +8402,7 @@
8217
8402
  rightViewSelect.disabled = isEditorOnlyMode;
8218
8403
  followSelect.disabled = isEditorOnlyMode || uiBusy;
8219
8404
  if (responseHighlightSelect) responseHighlightSelect.disabled = isEditorOnlyMode || rightView !== "markdown";
8220
- insertHeaderBtn.disabled = uiBusy || isEditorOnlyMode;
8405
+ insertHeaderBtn.disabled = uiBusy;
8221
8406
  lensSelect.disabled = uiBusy || isEditorOnlyMode;
8222
8407
  updateSaveFileTooltip();
8223
8408
  updateRefreshFromDiskTooltip();
@@ -8259,6 +8444,197 @@
8259
8444
  previousDescriptor: previousDescriptor,
8260
8445
  carryCurrentMetadataToNewDocument: Boolean(options && options.carryCurrentMetadataToNewDocument),
8261
8446
  });
8447
+ scheduleWorkspacePersistence();
8448
+ }
8449
+
8450
+ function normalizeWorkspaceSourceState(value) {
8451
+ const raw = value && typeof value === "object" ? value : {};
8452
+ const path = typeof raw.path === "string" && raw.path.trim() ? raw.path.trim() : null;
8453
+ return {
8454
+ source: typeof raw.source === "string" && raw.source.trim() ? raw.source.trim() : "blank",
8455
+ label: typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : "blank",
8456
+ path,
8457
+ draftId: path ? null : (typeof raw.draftId === "string" && raw.draftId.trim() ? raw.draftId.trim() : null),
8458
+ };
8459
+ }
8460
+
8461
+ function getWorkspaceStateIdentity(state) {
8462
+ const normalized = normalizeWorkspaceSourceState(state);
8463
+ if (normalized.path) return "file:" + normalized.path;
8464
+ if (normalized.draftId) return "draft:" + normalized.draftId;
8465
+ return "source:" + normalized.source + ":" + normalized.label;
8466
+ }
8467
+
8468
+ function readPersistedWorkspaceState() {
8469
+ try {
8470
+ const raw = window.localStorage ? window.localStorage.getItem(STUDIO_WORKSPACE_STORAGE_KEY) : null;
8471
+ if (!raw) return null;
8472
+ const parsed = JSON.parse(raw);
8473
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1) return null;
8474
+ if (typeof parsed.text !== "string") return null;
8475
+ return parsed;
8476
+ } catch {
8477
+ return null;
8478
+ }
8479
+ }
8480
+
8481
+ function shouldRestorePersistedWorkspaceState(state) {
8482
+ if (!state || typeof state.text !== "string") return false;
8483
+ const storedSourceState = normalizeWorkspaceSourceState(state.sourceState);
8484
+ const initialIdentity = getWorkspaceStateIdentity(initialSourceState);
8485
+ const storedIdentity = getWorkspaceStateIdentity(storedSourceState);
8486
+ if (storedIdentity === initialIdentity) return true;
8487
+ if (!explicitDocumentIdentityFromUrl && initialSourceState.source === "blank" && !initialSourceState.path) return true;
8488
+ return false;
8489
+ }
8490
+
8491
+ function buildWorkspacePersistencePayload() {
8492
+ return {
8493
+ version: 1,
8494
+ savedAt: Date.now(),
8495
+ sourceState: normalizeWorkspaceSourceState(sourceState),
8496
+ resourceDir: getCurrentResourceDirValue(),
8497
+ editorView,
8498
+ rightView,
8499
+ editorLanguage,
8500
+ followLatest,
8501
+ responseHistoryIndex,
8502
+ selectionStart: typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : 0,
8503
+ selectionEnd: typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : 0,
8504
+ scrollTop: typeof sourceTextEl.scrollTop === "number" ? sourceTextEl.scrollTop : 0,
8505
+ text: String(sourceTextEl.value || ""),
8506
+ };
8507
+ }
8508
+
8509
+ function persistWorkspaceStateNow() {
8510
+ if (!workspacePersistenceReady) return;
8511
+ try {
8512
+ if (!window.localStorage) return;
8513
+ const payload = buildWorkspacePersistencePayload();
8514
+ if (payload.text.length > STUDIO_WORKSPACE_MAX_TEXT_CHARS) {
8515
+ window.localStorage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
8516
+ return;
8517
+ }
8518
+ window.localStorage.setItem(STUDIO_WORKSPACE_STORAGE_KEY, JSON.stringify(payload));
8519
+ } catch {
8520
+ // Ignore browser storage failures and quota limits.
8521
+ }
8522
+ }
8523
+
8524
+ function scheduleWorkspacePersistence() {
8525
+ if (!workspacePersistenceReady) return;
8526
+ if (workspacePersistTimer !== null) window.clearTimeout(workspacePersistTimer);
8527
+ workspacePersistTimer = window.setTimeout(() => {
8528
+ workspacePersistTimer = null;
8529
+ persistWorkspaceStateNow();
8530
+ }, 160);
8531
+ }
8532
+
8533
+ function flushWorkspacePersistence() {
8534
+ if (workspacePersistTimer !== null) {
8535
+ window.clearTimeout(workspacePersistTimer);
8536
+ workspacePersistTimer = null;
8537
+ }
8538
+ persistWorkspaceStateNow();
8539
+ }
8540
+
8541
+ function clearPersistedWorkspaceState() {
8542
+ if (workspacePersistTimer !== null) {
8543
+ window.clearTimeout(workspacePersistTimer);
8544
+ workspacePersistTimer = null;
8545
+ }
8546
+ try {
8547
+ if (window.localStorage) window.localStorage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
8548
+ } catch {}
8549
+ }
8550
+
8551
+ function applyPersistedWorkspaceState(state) {
8552
+ if (!shouldRestorePersistedWorkspaceState(state)) return false;
8553
+ const nextSourceState = normalizeWorkspaceSourceState(state.sourceState);
8554
+ const nextResourceDir = normalizeStudioResourceDirValue(typeof state.resourceDir === "string" ? state.resourceDir : "");
8555
+ if (resourceDirInput) resourceDirInput.value = nextResourceDir;
8556
+ setEditorText(state.text, { preserveScroll: false, preserveSelection: false });
8557
+ setSourceState(nextSourceState);
8558
+ if (resourceDirInput && nextResourceDir) {
8559
+ resourceDirInput.value = nextResourceDir;
8560
+ updateSourceBadge();
8561
+ }
8562
+ if (typeof state.editorLanguage === "string" && state.editorLanguage.trim()) {
8563
+ setEditorLanguage(state.editorLanguage.trim());
8564
+ }
8565
+ editorView = state.editorView === "preview" ? "preview" : "markdown";
8566
+ rightView = state.rightView === "preview"
8567
+ ? "preview"
8568
+ : (state.rightView === "editor-preview"
8569
+ ? "editor-preview"
8570
+ : (state.rightView === "repl" ? "repl" : ((state.rightView === "trace" || state.rightView === "thinking") ? "trace" : "markdown")));
8571
+ if (typeof state.followLatest === "boolean") {
8572
+ followLatest = state.followLatest;
8573
+ }
8574
+ if (followSelect) followSelect.value = followLatest ? "on" : "off";
8575
+ if (typeof state.responseHistoryIndex === "number" && Number.isFinite(state.responseHistoryIndex)) {
8576
+ responseHistoryIndex = Math.max(-1, Math.floor(state.responseHistoryIndex));
8577
+ }
8578
+ const maxIndex = String(sourceTextEl.value || "").length;
8579
+ const start = Math.max(0, Math.min(Math.floor(Number(state.selectionStart) || 0), maxIndex));
8580
+ const end = Math.max(start, Math.min(Math.floor(Number(state.selectionEnd) || start), maxIndex));
8581
+ try { sourceTextEl.setSelectionRange(start, end); } catch {}
8582
+ if (typeof state.scrollTop === "number" && Number.isFinite(state.scrollTop)) {
8583
+ sourceTextEl.scrollTop = Math.max(0, state.scrollTop);
8584
+ }
8585
+ workspaceRestoredFromBrowser = true;
8586
+ initialDocumentApplied = true;
8587
+ return true;
8588
+ }
8589
+
8590
+ function clearStudioWorkspace() {
8591
+ if (uiBusy) {
8592
+ setStatus("Studio is busy.", "warning");
8593
+ return;
8594
+ }
8595
+ const confirmed = window.confirm("Clear the current editor draft in this browser tab? Saved files and responses are not changed.");
8596
+ if (!confirmed) return;
8597
+ const preservedResponseState = {
8598
+ responseHistory: Array.isArray(responseHistory) ? responseHistory.slice() : [],
8599
+ responseHistoryIndex,
8600
+ queuedLatestResponse,
8601
+ followLatest,
8602
+ latestResponseMarkdown,
8603
+ latestResponseThinking,
8604
+ latestResponseTimestamp,
8605
+ latestResponseKind,
8606
+ latestResponseIsStructuredCritique,
8607
+ latestResponseHasContent,
8608
+ latestResponseNormalized,
8609
+ latestResponseThinkingNormalized,
8610
+ latestCritiqueNotes,
8611
+ latestCritiqueNotesNormalized,
8612
+ };
8613
+ clearPersistedWorkspaceState();
8614
+ if (resourceDirInput) resourceDirInput.value = "";
8615
+ if (resourceDirLabel) resourceDirLabel.textContent = "";
8616
+ setEditorText("", { preserveScroll: false, preserveSelection: false });
8617
+ setSourceState({ source: "blank", label: "blank", path: null, draftId: makeStudioDraftId() });
8618
+ setEditorLanguage("markdown");
8619
+ setEditorView("markdown");
8620
+ responseHistory = preservedResponseState.responseHistory;
8621
+ responseHistoryIndex = preservedResponseState.responseHistoryIndex;
8622
+ queuedLatestResponse = preservedResponseState.queuedLatestResponse;
8623
+ followLatest = preservedResponseState.followLatest;
8624
+ latestResponseMarkdown = preservedResponseState.latestResponseMarkdown;
8625
+ latestResponseThinking = preservedResponseState.latestResponseThinking;
8626
+ latestResponseTimestamp = preservedResponseState.latestResponseTimestamp;
8627
+ latestResponseKind = preservedResponseState.latestResponseKind;
8628
+ latestResponseIsStructuredCritique = preservedResponseState.latestResponseIsStructuredCritique;
8629
+ latestResponseHasContent = preservedResponseState.latestResponseHasContent;
8630
+ latestResponseNormalized = preservedResponseState.latestResponseNormalized;
8631
+ latestResponseThinkingNormalized = preservedResponseState.latestResponseThinkingNormalized;
8632
+ latestCritiqueNotes = preservedResponseState.latestCritiqueNotes;
8633
+ latestCritiqueNotesNormalized = preservedResponseState.latestCritiqueNotesNormalized;
8634
+ if (followSelect) followSelect.value = followLatest ? "on" : "off";
8635
+ refreshResponseUi();
8636
+ persistWorkspaceStateNow();
8637
+ setStatus("Editor cleared. Saved files and responses were not changed.", "success");
8262
8638
  }
8263
8639
 
8264
8640
  function setEditorText(nextText, options) {
@@ -8308,6 +8684,7 @@
8308
8684
  }
8309
8685
  updateEditorSelectionCommentUi();
8310
8686
  updateOutlineUi();
8687
+ scheduleWorkspacePersistence();
8311
8688
  }
8312
8689
 
8313
8690
  function applySourceTextEdit(nextText, selectionStart, selectionEnd) {
@@ -8445,6 +8822,7 @@
8445
8822
  updateReviewNotesUi();
8446
8823
  updateEditorSelectionCommentUi();
8447
8824
  updateOutlineUi();
8825
+ scheduleWorkspacePersistence();
8448
8826
  }
8449
8827
 
8450
8828
  function setRightView(nextView) {
@@ -8477,6 +8855,7 @@
8477
8855
 
8478
8856
  refreshResponseUi();
8479
8857
  syncActionButtons();
8858
+ scheduleWorkspacePersistence();
8480
8859
  }
8481
8860
 
8482
8861
  function lineNumbersShouldBeVisible() {
@@ -8785,6 +9164,383 @@
8785
9164
  }
8786
9165
  }
8787
9166
 
9167
+ const PREVIEW_LOCAL_TEXT_LINK_EXTENSIONS = new Set([
9168
+ ".md", ".markdown", ".mdx", ".qmd", ".txt", ".tex", ".latex", ".rst", ".adoc",
9169
+ ".html", ".htm", ".css", ".xml", ".yaml", ".yml", ".toml", ".json", ".jsonc", ".json5", ".csv", ".tsv", ".log",
9170
+ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
9171
+ ".py", ".pyw", ".sh", ".bash", ".zsh", ".rs", ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hxx",
9172
+ ".jl", ".f90", ".f95", ".f03", ".f", ".for", ".r", ".m", ".java", ".go", ".rb", ".swift", ".lua",
9173
+ ".diff", ".patch",
9174
+ ]);
9175
+ const PREVIEW_LOCAL_IMAGE_LINK_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
9176
+ let previewLinkMenuEl = null;
9177
+ let activePreviewLinkContext = null;
9178
+
9179
+ function stripPreviewLocalLinkUrlSuffix(href) {
9180
+ const raw = String(href || "").trim();
9181
+ const hashIndex = raw.indexOf("#");
9182
+ const queryIndex = raw.indexOf("?");
9183
+ let end = raw.length;
9184
+ if (queryIndex >= 0) end = Math.min(end, queryIndex);
9185
+ if (hashIndex >= 0) end = Math.min(end, hashIndex);
9186
+ return raw.slice(0, end);
9187
+ }
9188
+
9189
+ function parsePreviewLocalLinkPage(href) {
9190
+ const raw = String(href || "");
9191
+ const parts = [];
9192
+ const queryIndex = raw.indexOf("?");
9193
+ if (queryIndex >= 0) {
9194
+ const queryEnd = raw.indexOf("#", queryIndex);
9195
+ parts.push(raw.slice(queryIndex + 1, queryEnd >= 0 ? queryEnd : raw.length));
9196
+ }
9197
+ const hashIndex = raw.indexOf("#");
9198
+ if (hashIndex >= 0) parts.push(raw.slice(hashIndex + 1));
9199
+ for (const part of parts) {
9200
+ try {
9201
+ const params = new URLSearchParams(part);
9202
+ const value = params.get("page") || params.get("p");
9203
+ if (value) {
9204
+ const page = Number.parseInt(value, 10);
9205
+ if (Number.isFinite(page) && page > 0) return page;
9206
+ }
9207
+ } catch {}
9208
+ const match = String(part || "").match(/(?:^|[&;])page=(\d+)/i) || String(part || "").match(/^page=(\d+)$/i);
9209
+ if (match && match[1]) {
9210
+ const page = Number.parseInt(match[1], 10);
9211
+ if (Number.isFinite(page) && page > 0) return page;
9212
+ }
9213
+ }
9214
+ return 0;
9215
+ }
9216
+
9217
+ function getPreviewLocalLinkExtension(href) {
9218
+ const path = stripPreviewLocalLinkUrlSuffix(href);
9219
+ const match = path.match(/\.([A-Za-z0-9_+-]+)$/);
9220
+ return match ? ("." + match[1].toLowerCase()) : "";
9221
+ }
9222
+
9223
+ function getPreviewLocalLinkKind(href) {
9224
+ const ext = getPreviewLocalLinkExtension(href);
9225
+ if (ext === ".pdf") return "pdf";
9226
+ if (PREVIEW_LOCAL_TEXT_LINK_EXTENSIONS.has(ext)) return "text";
9227
+ if (PREVIEW_LOCAL_IMAGE_LINK_EXTENSIONS.has(ext)) return "image";
9228
+ return "other";
9229
+ }
9230
+
9231
+ function isStudioLocalPreviewHref(href) {
9232
+ const raw = String(href || "").trim();
9233
+ if (!raw || raw.charAt(0) === "#") return false;
9234
+ if (/^\/\//.test(raw)) return false;
9235
+ if (/^(?:https?|mailto|tel|data|blob|javascript|about):/i.test(raw)) return false;
9236
+ if (/^\/(?:pdf-resource|html-preview-resource|export-pdf|export-html|render-preview|render-math|local-preview-link|reveal-local-resource)(?:[?#/]|$)/i.test(raw)) return false;
9237
+ return true;
9238
+ }
9239
+
9240
+ function getEffectivePreviewLinkContext(contextOverride) {
9241
+ const fallback = getHtmlPreviewResourceContextOptions();
9242
+ const context = contextOverride && typeof contextOverride === "object" ? contextOverride : null;
9243
+ return {
9244
+ sourcePath: context && context.sourcePath ? String(context.sourcePath) : (fallback.sourcePath || ""),
9245
+ resourceDir: context && context.resourceDir ? String(context.resourceDir) : (fallback.resourceDir || ""),
9246
+ };
9247
+ }
9248
+
9249
+ function getPreviewLinkResourceQuery(path, contextOverride) {
9250
+ const context = getEffectivePreviewLinkContext(contextOverride);
9251
+ const query = { path: String(path || "") };
9252
+ if (context.sourcePath) query.sourcePath = String(context.sourcePath);
9253
+ if (context.resourceDir) query.resourceDir = String(context.resourceDir);
9254
+ return query;
9255
+ }
9256
+
9257
+ function getPreviewLinkAnchorFromEvent(event) {
9258
+ const target = event && event.target;
9259
+ const anchor = target instanceof Element ? target.closest("#sourcePreview a[href], #critiqueView a[href]") : null;
9260
+ if (!anchor) return null;
9261
+ if (anchor.closest(".studio-pdf-card, .studio-html-artifact-toolbar, .studio-copy-block-btn")) return null;
9262
+ const href = String(anchor.getAttribute("href") || "").trim();
9263
+ if (!isStudioLocalPreviewHref(href)) return null;
9264
+ return anchor;
9265
+ }
9266
+
9267
+ function closePreviewLinkMenu() {
9268
+ activePreviewLinkContext = null;
9269
+ if (previewLinkMenuEl) previewLinkMenuEl.hidden = true;
9270
+ }
9271
+
9272
+ function ensurePreviewLinkMenu() {
9273
+ if (previewLinkMenuEl) return previewLinkMenuEl;
9274
+ const menu = document.createElement("div");
9275
+ menu.className = "studio-preview-link-menu";
9276
+ menu.hidden = true;
9277
+ menu.setAttribute("role", "menu");
9278
+ document.body.appendChild(menu);
9279
+ previewLinkMenuEl = menu;
9280
+ return menu;
9281
+ }
9282
+
9283
+ function appendPreviewLinkMenuButton(menu, label, action) {
9284
+ const button = document.createElement("button");
9285
+ button.type = "button";
9286
+ button.setAttribute("role", "menuitem");
9287
+ button.dataset.previewLinkAction = action;
9288
+ button.textContent = label;
9289
+ menu.appendChild(button);
9290
+ }
9291
+
9292
+ function positionPreviewLinkMenu(menu, clientX, clientY) {
9293
+ const margin = 8;
9294
+ menu.style.left = "0px";
9295
+ menu.style.top = "0px";
9296
+ menu.hidden = false;
9297
+ const rect = menu.getBoundingClientRect();
9298
+ const x = Math.max(margin, Math.min(window.innerWidth - rect.width - margin, Number(clientX) || margin));
9299
+ const y = Math.max(margin, Math.min(window.innerHeight - rect.height - margin, Number(clientY) || margin));
9300
+ menu.style.left = x + "px";
9301
+ menu.style.top = y + "px";
9302
+ }
9303
+
9304
+ function showPreviewLinkMenu(anchor, event, contextOverride) {
9305
+ const href = String(anchor && anchor.getAttribute ? anchor.getAttribute("href") || "" : (contextOverride && contextOverride.href ? contextOverride.href : "")).trim();
9306
+ if (!isStudioLocalPreviewHref(href)) return false;
9307
+ const kind = getPreviewLocalLinkKind(href);
9308
+ const menu = ensurePreviewLinkMenu();
9309
+ menu.innerHTML = "";
9310
+ const linkContext = getEffectivePreviewLinkContext(contextOverride);
9311
+ activePreviewLinkContext = {
9312
+ href,
9313
+ title: String((contextOverride && contextOverride.title) || (anchor && anchor.textContent) || href || "local link").trim() || href,
9314
+ sourcePath: linkContext.sourcePath,
9315
+ resourceDir: linkContext.resourceDir,
9316
+ };
9317
+ if (kind === "pdf") {
9318
+ appendPreviewLinkMenuButton(menu, "Open PDF preview", "open-pdf");
9319
+ } else if (kind === "text") {
9320
+ appendPreviewLinkMenuButton(menu, "Open in new editor", "open-new");
9321
+ appendPreviewLinkMenuButton(menu, "Open here", "open-here");
9322
+ } else if (kind === "image") {
9323
+ appendPreviewLinkMenuButton(menu, "Open image preview", "open-image");
9324
+ }
9325
+ appendPreviewLinkMenuButton(menu, "Reveal in file manager", "reveal");
9326
+ appendPreviewLinkMenuButton(menu, "Copy path", "copy-path");
9327
+ positionPreviewLinkMenu(menu, event && event.clientX, event && event.clientY);
9328
+ const firstButton = menu.querySelector("button");
9329
+ if (firstButton && typeof firstButton.focus === "function") {
9330
+ window.setTimeout(() => firstButton.focus({ preventScroll: true }), 0);
9331
+ }
9332
+ return true;
9333
+ }
9334
+
9335
+ async function fetchPreviewLocalLink(action, href, contextOverride) {
9336
+ return fetchStudioJson("/local-preview-link", {
9337
+ query: { ...getPreviewLinkResourceQuery(href, contextOverride), action },
9338
+ });
9339
+ }
9340
+
9341
+ function getPreviewPdfViewerUrl(href, contextOverride) {
9342
+ const cleanPath = stripPreviewLocalLinkUrlSuffix(href);
9343
+ const context = contextOverride && typeof contextOverride === "object" ? contextOverride : {};
9344
+ const resourceUrl = buildStudioPdfResourceUrl({ path: cleanPath, sourcePath: context.sourcePath || "", resourceDir: context.resourceDir || "" }, true);
9345
+ const page = parsePreviewLocalLinkPage(href);
9346
+ return resourceUrl && page ? resourceUrl + "#page=" + encodeURIComponent(String(page)) : resourceUrl;
9347
+ }
9348
+
9349
+ function openPreviewPdfLink(href, title, contextOverride) {
9350
+ const viewerUrl = getPreviewPdfViewerUrl(href, contextOverride);
9351
+ if (!viewerUrl) {
9352
+ setStatus("Could not resolve this PDF link. Open the source file or set a working directory first.", "warning");
9353
+ return false;
9354
+ }
9355
+ openStudioPdfFocusViewer(viewerUrl, title || href);
9356
+ return true;
9357
+ }
9358
+
9359
+ async function openPreviewImageLink(href, title, contextOverride, pendingWindow) {
9360
+ const popup = pendingWindow || window.open("", "_blank");
9361
+ try {
9362
+ if (popup && popup.document && popup.document.body) {
9363
+ popup.document.title = "Opening image…";
9364
+ popup.document.body.innerHTML = "<p style=\"font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 16px;\">Opening image…</p>";
9365
+ }
9366
+ } catch {}
9367
+ try {
9368
+ const payload = await fetchStudioJson("/html-preview-resource", {
9369
+ query: getPreviewLinkResourceQuery(href, contextOverride),
9370
+ });
9371
+ const dataUrl = payload && typeof payload.dataUrl === "string" ? payload.dataUrl : "";
9372
+ if (!dataUrl) throw new Error("Studio did not return image data.");
9373
+ const safeTitle = escapeHtml(String(title || href || "Local image"));
9374
+ const safeSrc = escapeHtml(dataUrl);
9375
+ const html = "<!doctype html><html><head><meta charset='utf-8'><title>" + safeTitle + "</title>"
9376
+ + "<style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee;font:13px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;}img{max-width:100vw;max-height:100vh;object-fit:contain;}header{position:fixed;left:0;right:0;top:0;padding:8px 10px;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);}</style>"
9377
+ + "</head><body><header>" + safeTitle + "</header><img src='" + safeSrc + "' alt='" + safeTitle + "'></body></html>";
9378
+ if (popup && !popup.closed && popup.document) {
9379
+ popup.document.open();
9380
+ popup.document.write(html);
9381
+ popup.document.close();
9382
+ setStatus("Opened local image preview.", "success");
9383
+ return;
9384
+ }
9385
+ const opened = window.open(dataUrl, "_blank");
9386
+ if (!opened) throw new Error("Popup blocked while opening image preview.");
9387
+ setStatus("Opened local image preview.", "success");
9388
+ } catch (error) {
9389
+ if (popup && !popup.closed) {
9390
+ try { popup.close(); } catch {}
9391
+ }
9392
+ throw error;
9393
+ }
9394
+ }
9395
+
9396
+ function editorHasPotentialUnsavedContent() {
9397
+ const text = String(sourceTextEl.value || "");
9398
+ if (!text.trim()) return false;
9399
+ if (hasRefreshableFilePath()) return editorDiffersFromFileBackedBaseline();
9400
+ return true;
9401
+ }
9402
+
9403
+ async function openPreviewDocumentHere(href, contextOverride) {
9404
+ if (editorHasPotentialUnsavedContent()) {
9405
+ const confirmed = window.confirm("Replace the current editor contents with this linked file? Unsaved editor changes may be lost.");
9406
+ if (!confirmed) return;
9407
+ }
9408
+ const payload = await fetchPreviewLocalLink("document", href, contextOverride);
9409
+ if (typeof payload.text !== "string") throw new Error("Studio did not return document text.");
9410
+ const path = typeof payload.path === "string" ? payload.path : "";
9411
+ const label = typeof payload.label === "string" && payload.label.trim() ? payload.label.trim() : (path || "linked file");
9412
+ const nextResourceDir = typeof payload.resourceDir === "string" ? normalizeStudioResourceDirValue(payload.resourceDir) : "";
9413
+ if (resourceDirInput && nextResourceDir) resourceDirInput.value = nextResourceDir;
9414
+ setEditorText(payload.text, { preserveScroll: false, preserveSelection: false });
9415
+ setSourceState({ source: "file", label, path });
9416
+ markFileBackedBaseline(payload.text);
9417
+ const detected = detectLanguageFromName(path || label);
9418
+ if (detected) setEditorLanguage(detected);
9419
+ setEditorView("markdown");
9420
+ setActivePane("left");
9421
+ setStatus("Opened linked file in editor: " + label, "success");
9422
+ }
9423
+
9424
+ async function openPreviewDocumentInNewEditor(href, pendingWindow, contextOverride) {
9425
+ const popup = pendingWindow || window.open("", "_blank");
9426
+ try {
9427
+ if (popup && popup.document && popup.document.body) {
9428
+ popup.document.title = "Opening linked file…";
9429
+ popup.document.body.innerHTML = "<p style=\"font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 16px;\">Opening linked file…</p>";
9430
+ }
9431
+ } catch {}
9432
+ try {
9433
+ const payload = await fetchPreviewLocalLink("editor-url", href, contextOverride);
9434
+ const targetUrl = payload && typeof payload.relativeUrl === "string"
9435
+ ? new URL(payload.relativeUrl, window.location.href).href
9436
+ : (payload && typeof payload.url === "string" ? payload.url : "");
9437
+ if (!targetUrl) throw new Error("Studio did not return an editor URL.");
9438
+ if (popup && !popup.closed) {
9439
+ try {
9440
+ popup.opener = null;
9441
+ popup.location.href = targetUrl;
9442
+ setStatus("Opening linked file in a new editor.", "success");
9443
+ return;
9444
+ } catch {}
9445
+ }
9446
+ window.open(targetUrl, "_blank", "noopener");
9447
+ setStatus("Opening linked file in a new editor.", "success");
9448
+ } catch (error) {
9449
+ if (popup && !popup.closed) {
9450
+ try { popup.close(); } catch {}
9451
+ }
9452
+ throw error;
9453
+ }
9454
+ }
9455
+
9456
+ async function copyPreviewLocalLinkPath(href, contextOverride) {
9457
+ const payload = await fetchPreviewLocalLink("resolve", href, contextOverride);
9458
+ const path = typeof payload.path === "string" ? payload.path : "";
9459
+ if (!path) throw new Error("Studio did not return a file path.");
9460
+ const ok = await writeTextToClipboard(path);
9461
+ if (!ok) throw new Error("Clipboard write failed.");
9462
+ setStatus("Copied local path.", "success");
9463
+ }
9464
+
9465
+ async function revealPreviewLocalLink(href, contextOverride) {
9466
+ const query = getPreviewLinkResourceQuery(href, contextOverride);
9467
+ const payload = await fetchStudioJson("/reveal-local-resource", {
9468
+ method: "POST",
9469
+ body: JSON.stringify(query),
9470
+ });
9471
+ setStatus(typeof payload.message === "string" ? payload.message : "Opened file manager.", "success");
9472
+ }
9473
+
9474
+ async function runPreviewLinkAction(action, context) {
9475
+ const href = context && context.href ? context.href : "";
9476
+ if (!href) return;
9477
+ try {
9478
+ if (action === "open-pdf") {
9479
+ openPreviewPdfLink(href, context.title || href, context);
9480
+ return;
9481
+ }
9482
+ if (action === "open-new") {
9483
+ await openPreviewDocumentInNewEditor(href, null, context);
9484
+ return;
9485
+ }
9486
+ if (action === "open-here") {
9487
+ await openPreviewDocumentHere(href, context);
9488
+ return;
9489
+ }
9490
+ if (action === "open-image") {
9491
+ await openPreviewImageLink(href, context.title || href, context);
9492
+ return;
9493
+ }
9494
+ if (action === "copy-path") {
9495
+ await copyPreviewLocalLinkPath(href, context);
9496
+ return;
9497
+ }
9498
+ if (action === "reveal") {
9499
+ await revealPreviewLocalLink(href, context);
9500
+ }
9501
+ } catch (error) {
9502
+ setStatus((error && error.message) ? error.message : String(error || "Local link action failed."), "warning");
9503
+ }
9504
+ }
9505
+
9506
+ function handlePreviewLocalLinkClick(event) {
9507
+ const anchor = getPreviewLinkAnchorFromEvent(event);
9508
+ if (!anchor) return;
9509
+ const href = String(anchor.getAttribute("href") || "").trim();
9510
+ const kind = getPreviewLocalLinkKind(href);
9511
+ event.preventDefault();
9512
+ event.stopPropagation();
9513
+ closePreviewLinkMenu();
9514
+ const title = String(anchor.textContent || href).trim() || href;
9515
+ if (kind === "pdf") {
9516
+ openPreviewPdfLink(href, title);
9517
+ return;
9518
+ }
9519
+ if (kind === "image") {
9520
+ const pendingWindow = window.open("", "_blank");
9521
+ void openPreviewImageLink(href, title, null, pendingWindow).catch((error) => {
9522
+ setStatus((error && error.message) ? error.message : String(error || "Could not open linked image."), "warning");
9523
+ });
9524
+ return;
9525
+ }
9526
+ if (kind === "text") {
9527
+ const pendingWindow = window.open("", "_blank");
9528
+ void openPreviewDocumentInNewEditor(href, pendingWindow).catch((error) => {
9529
+ setStatus((error && error.message) ? error.message : String(error || "Could not open linked file."), "warning");
9530
+ });
9531
+ return;
9532
+ }
9533
+ setStatus("Right-click this local link for file actions.", "warning");
9534
+ }
9535
+
9536
+ function handlePreviewLocalLinkContextMenu(event) {
9537
+ const anchor = getPreviewLinkAnchorFromEvent(event);
9538
+ if (!anchor) return;
9539
+ event.preventDefault();
9540
+ event.stopPropagation();
9541
+ showPreviewLinkMenu(anchor, event);
9542
+ }
9543
+
8788
9544
  function makeRequestId() {
8789
9545
  if (window.crypto && typeof window.crypto.randomUUID === "function") {
8790
9546
  return window.crypto.randomUUID().replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -9002,10 +9758,6 @@
9002
9758
  return index > 0 ? value.slice(0, index) : "";
9003
9759
  }
9004
9760
 
9005
- function getCurrentResourceDirValue() {
9006
- return resourceDirInput ? String(resourceDirInput.value || "").trim() : "";
9007
- }
9008
-
9009
9761
  function getDefaultQuizContextPath(scope) {
9010
9762
  const normalizedScope = normalizeQuizScope(scope);
9011
9763
  const sourcePath = sourceState && sourceState.path ? String(sourceState.path) : "";
@@ -14656,6 +15408,7 @@
14656
15408
  scheduleSourcePreviewRender(0);
14657
15409
  }
14658
15410
  updateOutlineUi();
15411
+ scheduleWorkspacePersistence();
14659
15412
  }
14660
15413
 
14661
15414
  function setEditorHighlightMode(mode) {
@@ -15384,6 +16137,10 @@
15384
16137
  stickyStudioKind = null;
15385
16138
  }
15386
16139
  if (message.path) {
16140
+ const savedResourceDir = typeof message.resourceDir === "string" && message.resourceDir.trim()
16141
+ ? normalizeStudioResourceDirValue(message.resourceDir)
16142
+ : dirnameForDisplayPath(message.path);
16143
+ if (resourceDirInput) resourceDirInput.value = savedResourceDir;
15387
16144
  setSourceState({
15388
16145
  source: "file",
15389
16146
  label: message.label || message.path,
@@ -15459,6 +16216,10 @@
15459
16216
  ? nextDoc.path
15460
16217
  : null;
15461
16218
 
16219
+ const nextResourceDir = typeof nextDoc.resourceDir === "string" && nextDoc.resourceDir.trim()
16220
+ ? normalizeStudioResourceDirValue(nextDoc.resourceDir)
16221
+ : (nextPath ? dirnameForDisplayPath(nextPath) : "");
16222
+ if (resourceDirInput) resourceDirInput.value = nextResourceDir;
15462
16223
  setEditorText(nextDoc.text, { preserveScroll: false, preserveSelection: false });
15463
16224
  setSourceState({
15464
16225
  source: nextSource,
@@ -16027,6 +16788,7 @@
16027
16788
  window.addEventListener("keydown", handlePaneShortcut);
16028
16789
  window.addEventListener("beforeunload", () => {
16029
16790
  stopFooterSpinner();
16791
+ flushWorkspacePersistence();
16030
16792
  flushScratchpadPersistence();
16031
16793
  flushReviewNotesPersistence();
16032
16794
  });
@@ -16043,6 +16805,7 @@
16043
16805
 
16044
16806
  followSelect.addEventListener("change", () => {
16045
16807
  followLatest = followSelect.value !== "off";
16808
+ scheduleWorkspacePersistence();
16046
16809
  if (followLatest && queuedLatestResponse) {
16047
16810
  if (responseHistory.length > 0) {
16048
16811
  selectHistoryIndex(responseHistory.length - 1, { silent: true });
@@ -16206,6 +16969,7 @@
16206
16969
  renderReviewNotesList();
16207
16970
  updateReviewNotesUi();
16208
16971
  }
16972
+ scheduleWorkspacePersistence();
16209
16973
  });
16210
16974
 
16211
16975
  sourceTextEl.addEventListener("select", () => {
@@ -16382,7 +17146,10 @@
16382
17146
  closeExportPreviewMenu();
16383
17147
  });
16384
17148
  document.addEventListener("keydown", (event) => {
16385
- if (event.key === "Escape") closeExportPreviewMenu();
17149
+ if (event.key === "Escape") {
17150
+ closeExportPreviewMenu();
17151
+ closePreviewLinkMenu();
17152
+ }
16386
17153
  });
16387
17154
 
16388
17155
  saveAsBtn.addEventListener("click", () => {
@@ -16393,7 +17160,7 @@
16393
17160
  }
16394
17161
 
16395
17162
  var suggestedName = sourceState.label ? sourceState.label.replace(/^upload:\s*/i, "") : "draft.md";
16396
- var suggestedDir = resourceDirInput && resourceDirInput.value.trim() ? resourceDirInput.value.trim().replace(/\/$/, "") + "/" : "./";
17163
+ var suggestedDir = getCurrentResourceDirValue() ? getCurrentResourceDirValue().replace(/\/$/, "") + "/" : "./";
16397
17164
  const suggested = sourceState.path || (suggestedDir + suggestedName);
16398
17165
  const path = window.prompt("Save editor content as:", suggested);
16399
17166
  if (!path) return;
@@ -16472,6 +17239,12 @@
16472
17239
  });
16473
17240
  }
16474
17241
 
17242
+ if (clearWorkspaceBtn) {
17243
+ clearWorkspaceBtn.addEventListener("click", () => {
17244
+ clearStudioWorkspace();
17245
+ });
17246
+ }
17247
+
16475
17248
  sendEditorBtn.addEventListener("click", () => {
16476
17249
  const content = sourceTextEl.value;
16477
17250
  if (!content.trim()) {
@@ -16509,9 +17282,7 @@
16509
17282
  content,
16510
17283
  label: sourceState && sourceState.label ? sourceState.label : "current editor",
16511
17284
  path: sourceState && sourceState.path ? sourceState.path : undefined,
16512
- resourceDir: resourceDirInput && resourceDirInput.value.trim()
16513
- ? resourceDirInput.value.trim()
16514
- : undefined,
17285
+ resourceDir: getCurrentResourceDirValue() || undefined,
16515
17286
  });
16516
17287
 
16517
17288
  if (!sent) {
@@ -16551,9 +17322,7 @@
16551
17322
  type: "load_git_diff_request",
16552
17323
  requestId,
16553
17324
  sourcePath: effectivePath || sourceState.path || undefined,
16554
- resourceDir: resourceDirInput && resourceDirInput.value.trim()
16555
- ? resourceDirInput.value.trim()
16556
- : undefined,
17325
+ resourceDir: getCurrentResourceDirValue() || undefined,
16557
17326
  });
16558
17327
 
16559
17328
  if (!sent) {
@@ -16772,6 +17541,27 @@
16772
17541
  void handleCopyPreviewBlockButtonClick(event);
16773
17542
  }, true);
16774
17543
 
17544
+ document.addEventListener("click", (event) => {
17545
+ const target = event.target;
17546
+ const menuButton = target instanceof Element ? target.closest(".studio-preview-link-menu [data-preview-link-action]") : null;
17547
+ if (menuButton) {
17548
+ event.preventDefault();
17549
+ event.stopPropagation();
17550
+ const action = String(menuButton.getAttribute("data-preview-link-action") || "");
17551
+ const context = activePreviewLinkContext;
17552
+ closePreviewLinkMenu();
17553
+ void runPreviewLinkAction(action, context);
17554
+ return;
17555
+ }
17556
+ if (target instanceof Element && target.closest(".studio-preview-link-menu")) return;
17557
+ closePreviewLinkMenu();
17558
+ handlePreviewLocalLinkClick(event);
17559
+ }, true);
17560
+
17561
+ document.addEventListener("contextmenu", (event) => {
17562
+ handlePreviewLocalLinkContextMenu(event);
17563
+ }, true);
17564
+
16775
17565
  document.addEventListener("pointerup", (event) => {
16776
17566
  const target = event.target;
16777
17567
  const copyBtn = target instanceof Element ? target.closest(".studio-copy-block-btn") : null;
@@ -16962,7 +17752,8 @@
16962
17752
  if (resourceDirLabel) resourceDirLabel.hidden = state !== "label";
16963
17753
  }
16964
17754
  function applyResourceDir() {
16965
- var dir = resourceDirInput ? resourceDirInput.value.trim() : "";
17755
+ var dir = getCurrentResourceDirValue();
17756
+ if (resourceDirInput) resourceDirInput.value = dir;
16966
17757
  if (dir) {
16967
17758
  if (resourceDirLabel) resourceDirLabel.textContent = "Working dir: " + dir;
16968
17759
  showResourceDirState("label");
@@ -16972,6 +17763,7 @@
16972
17763
  updateSaveFileTooltip();
16973
17764
  syncActionButtons();
16974
17765
  renderSourcePreview();
17766
+ scheduleWorkspacePersistence();
16975
17767
  }
16976
17768
  if (sourceBadgeEl) {
16977
17769
  sourceBadgeEl.addEventListener("click", () => {
@@ -16997,7 +17789,7 @@
16997
17789
  applyResourceDir();
16998
17790
  } else if (e.key === "Escape") {
16999
17791
  e.preventDefault();
17000
- var dir = resourceDirInput.value.trim();
17792
+ var dir = getCurrentResourceDirValue();
17001
17793
  if (dir) {
17002
17794
  showResourceDirState("label");
17003
17795
  } else {
@@ -17014,6 +17806,7 @@
17014
17806
  updateSaveFileTooltip();
17015
17807
  syncActionButtons();
17016
17808
  renderSourcePreview();
17809
+ scheduleWorkspacePersistence();
17017
17810
  });
17018
17811
  }
17019
17812
 
@@ -17062,7 +17855,7 @@
17062
17855
  setResponseFontSize(initialResponseFontSize, { persist: false });
17063
17856
 
17064
17857
  if (resourceDirInput && initialResourceDir) {
17065
- resourceDirInput.value = initialResourceDir;
17858
+ resourceDirInput.value = normalizeStudioResourceDirValue(initialResourceDir);
17066
17859
  }
17067
17860
  setSourceState(initialSourceState);
17068
17861
  refreshResponseUi();
@@ -17090,9 +17883,16 @@
17090
17883
  setAnnotationsEnabled(initialAnnotationsEnabled, { silent: true });
17091
17884
  setReplSendMode(replSendMode);
17092
17885
 
17886
+ const persistedWorkspaceState = readPersistedWorkspaceState();
17887
+ applyPersistedWorkspaceState(persistedWorkspaceState);
17888
+
17093
17889
  setEditorView(editorView);
17094
17890
  setRightView(rightView);
17095
17891
  renderSourcePreview();
17892
+ workspacePersistenceReady = true;
17893
+ if (workspaceRestoredFromBrowser) {
17894
+ setStatus("Restored editor workspace from this browser tab. Use Clear editor to discard it.", "success");
17895
+ }
17096
17896
  connect();
17097
17897
  } catch (error) {
17098
17898
  hardFail("Studio UI init failed", error);