tabminal 3.0.12 → 3.0.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.
package/public/app.js CHANGED
@@ -104,13 +104,22 @@ const editorPane = document.getElementById('editor-pane');
104
104
  const HEARTBEAT_INTERVAL_MS = 1000;
105
105
  const RECONNECT_RETRY_MS = 5000;
106
106
  const FILE_TREE_REFRESH_INTERVAL_MS = 3000;
107
+ const FILE_VERSION_CHECK_INTERVAL_MS = 3000;
107
108
  const MAIN_SERVER_ID = 'main';
108
109
  const RUNTIME_BOOT_ID_STORAGE_KEY = 'tabminal_runtime_boot_id';
109
110
  const WORKSPACE_DEVICE_ID_STORAGE_KEY = 'tabminal_workspace_device_id';
110
111
  const RECENT_AGENT_USAGE_STORAGE_KEY = 'tabminal_recent_agent_usage';
111
112
  const FILE_WORKSPACE_TAB_PREFIX = 'file:';
113
+ const MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX = 'markdown-preview:';
112
114
  const AGENT_WORKSPACE_TAB_PREFIX = 'agent:';
113
115
  const TERMINAL_WORKSPACE_TAB_KEY = 'terminal:main';
116
+ const SUPPORTED_MARKDOWN_EXTENSIONS = new Set([
117
+ 'md',
118
+ 'markdown',
119
+ 'mkd',
120
+ 'mkdn',
121
+ 'mdown'
122
+ ]);
114
123
  const SUPPORTED_IMAGE_EXTENSIONS = new Set([
115
124
  'png',
116
125
  'jpg',
@@ -119,6 +128,20 @@ const SUPPORTED_IMAGE_EXTENSIONS = new Set([
119
128
  'svg',
120
129
  'webp'
121
130
  ]);
131
+ const SUPPORTED_PDF_EXTENSIONS = new Set([
132
+ 'pdf'
133
+ ]);
134
+ const PDFJS_VERSION = '5.6.205';
135
+ const PDFJS_MODULE_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.min.mjs`;
136
+ const PDFJS_WORKER_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.worker.min.mjs`;
137
+ const MARKDOWN_IT_MODULE_URL = 'https://cdn.jsdelivr.net/npm/markdown-it@14.1.1/+esm';
138
+ const MARKDOWN_TASK_LISTS_MODULE_URL = 'https://cdn.jsdelivr.net/npm/markdown-it-task-lists@2.1.1/+esm';
139
+ const MARKDOWN_KATEX_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@traptitech/markdown-it-katex@3.6.0/+esm';
140
+ const KATEX_MODULE_URL = 'https://cdn.jsdelivr.net/npm/katex@0.16.25/+esm';
141
+ const HIGHLIGHT_JS_MODULE_URL = 'https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm';
142
+ const MARKDOWN_PREVIEW_GITHUB_CSS_URL = 'https://cdn.jsdelivr.net/npm/github-markdown-css@5.8.1/github-markdown-dark.min.css';
143
+ const MARKDOWN_PREVIEW_HIGHLIGHT_CSS_URL = 'https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.css';
144
+ const MARKDOWN_PREVIEW_KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@0.16.25/dist/katex.min.css';
122
145
  const CLOSE_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
123
146
  const AGENT_ICON_SVG = '<svg viewBox="0 0 24 24" width="17" height="17" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="10" height="10" rx="2"></rect><path d="M9 7V5"></path><path d="M15 7V5"></path><path d="M12 17v2"></path><path d="M5 12H3"></path><path d="M21 12h-2"></path><path d="M9 11h.01"></path><path d="M15 11h.01"></path><path d="M9.5 14c.7.67 1.53 1 2.5 1s1.8-.33 2.5-1"></path></svg>';
124
147
  const TERMINAL_TAB_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m8 10 3 2-3 2"></path><path d="M13 15h4"></path></svg>';
@@ -137,6 +160,9 @@ const RENAME_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke=
137
160
  const DELETE_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M6 7l1 12h10l1-12"></path><path d="M9 7V4h6v3"></path></svg>';
138
161
  const NEW_FOLDER_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7.5A2.5 2.5 0 0 1 6 5h4l2 2h6a2.5 2.5 0 0 1 2.5 2.5V17A2.5 2.5 0 0 1 18 19.5H6A2.5 2.5 0 0 1 3.5 17Z"></path><path d="M12 10.5v5"></path><path d="M9.5 13h5"></path></svg>';
139
162
  const NEW_FILE_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5h7l4 4V20.5H7A2.5 2.5 0 0 1 4.5 18V6A2.5 2.5 0 0 1 7 3.5Z"></path><path d="M14 3.5V8h4"></path><path d="M12 11v6"></path><path d="M9 14h6"></path></svg>';
163
+ const MARKDOWN_PREVIEW_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5.5h18"></path><path d="M3 9.5h18"></path><path d="M5 5.5V18a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V5.5"></path><path d="M9 13h6"></path><path d="M9 16h4"></path></svg>';
164
+ const MARKDOWN_SPLIT_ENABLE_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"></rect><path d="M12 5v14"></path></svg>';
165
+ const MARKDOWN_SPLIT_DISABLE_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"></rect><path d="M12 5v14"></path><path d="m9.25 8.5 5.5 7"></path></svg>';
140
166
  const TERMINAL_FONT_FAMILY = '\'Monaspace Neon\', "SF Mono Terminal", '
141
167
  + '"SFMono-Regular", "SF Mono", "JetBrains Mono", Menlo, Consolas, '
142
168
  + 'monospace';
@@ -161,12 +187,18 @@ const agentSetupState = {
161
187
  };
162
188
  let primaryServerBootId = '';
163
189
  let runtimeReloadScheduled = false;
190
+ let pdfJsLibPromise = null;
191
+ let markdownPreviewBundlePromise = null;
164
192
  // #endregion
165
193
 
166
194
  function makeFileWorkspaceTabKey(filePath) {
167
195
  return `${FILE_WORKSPACE_TAB_PREFIX}${filePath}`;
168
196
  }
169
197
 
198
+ function makeMarkdownPreviewWorkspaceTabKey(filePath) {
199
+ return `${MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX}${filePath}`;
200
+ }
201
+
170
202
  function makeAgentTabKey(serverId, tabId) {
171
203
  return `${AGENT_WORKSPACE_TAB_PREFIX}${serverId}:${tabId}`;
172
204
  }
@@ -182,7 +214,15 @@ function isTerminalWorkspaceTabKey(key) {
182
214
 
183
215
  function isFileWorkspaceTabKey(key) {
184
216
  return typeof key === 'string'
185
- && key.startsWith(FILE_WORKSPACE_TAB_PREFIX);
217
+ && (
218
+ key.startsWith(FILE_WORKSPACE_TAB_PREFIX)
219
+ || key.startsWith(MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX)
220
+ );
221
+ }
222
+
223
+ function isMarkdownPreviewWorkspaceTabKey(key) {
224
+ return typeof key === 'string'
225
+ && key.startsWith(MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX);
186
226
  }
187
227
 
188
228
  function isCompactWorkspaceMode() {
@@ -201,10 +241,127 @@ function isSupportedImagePath(filePath) {
201
241
  return SUPPORTED_IMAGE_EXTENSIONS.has(ext);
202
242
  }
203
243
 
244
+ function isSupportedPdfPath(filePath) {
245
+ if (typeof filePath !== 'string') {
246
+ return false;
247
+ }
248
+ const dotIndex = filePath.lastIndexOf('.');
249
+ if (dotIndex === -1) {
250
+ return false;
251
+ }
252
+ const ext = filePath.slice(dotIndex + 1).toLowerCase();
253
+ return SUPPORTED_PDF_EXTENSIONS.has(ext);
254
+ }
255
+
256
+ function isSupportedMarkdownPath(filePath) {
257
+ if (typeof filePath !== 'string') {
258
+ return false;
259
+ }
260
+ const dotIndex = filePath.lastIndexOf('.');
261
+ if (dotIndex === -1) {
262
+ return false;
263
+ }
264
+ const ext = filePath.slice(dotIndex + 1).toLowerCase();
265
+ return SUPPORTED_MARKDOWN_EXTENSIONS.has(ext);
266
+ }
267
+
268
+ function ensureExternalStylesheet(id, href) {
269
+ if (!href || document.getElementById(id)) {
270
+ return;
271
+ }
272
+ const link = document.createElement('link');
273
+ link.id = id;
274
+ link.rel = 'stylesheet';
275
+ link.href = href;
276
+ document.head.appendChild(link);
277
+ }
278
+
279
+ async function loadMarkdownPreviewBundle() {
280
+ if (!markdownPreviewBundlePromise) {
281
+ markdownPreviewBundlePromise = (async () => {
282
+ ensureExternalStylesheet(
283
+ 'markdown-preview-github-css',
284
+ MARKDOWN_PREVIEW_GITHUB_CSS_URL
285
+ );
286
+ ensureExternalStylesheet(
287
+ 'markdown-preview-highlight-css',
288
+ MARKDOWN_PREVIEW_HIGHLIGHT_CSS_URL
289
+ );
290
+ ensureExternalStylesheet(
291
+ 'markdown-preview-katex-css',
292
+ MARKDOWN_PREVIEW_KATEX_CSS_URL
293
+ );
294
+ const [
295
+ { default: MarkdownIt },
296
+ { default: markdownItTaskLists },
297
+ { default: markdownItKatex },
298
+ { default: katex },
299
+ { default: hljs }
300
+ ] = await Promise.all([
301
+ import(MARKDOWN_IT_MODULE_URL),
302
+ import(MARKDOWN_TASK_LISTS_MODULE_URL),
303
+ import(MARKDOWN_KATEX_MODULE_URL),
304
+ import(KATEX_MODULE_URL),
305
+ import(HIGHLIGHT_JS_MODULE_URL)
306
+ ]);
307
+ const renderer = new MarkdownIt({
308
+ html: true,
309
+ linkify: true,
310
+ breaks: false,
311
+ highlight(source, language) {
312
+ const code = String(source || '');
313
+ const nextLanguage = String(language || '').trim();
314
+ let html = '';
315
+ if (nextLanguage && hljs.getLanguage(nextLanguage)) {
316
+ html = hljs.highlight(code, {
317
+ language: nextLanguage,
318
+ ignoreIllegals: true
319
+ }).value;
320
+ } else {
321
+ html = hljs.highlightAuto(code).value;
322
+ }
323
+ const languageClass = nextLanguage
324
+ ? ` language-${escapeHtml(nextLanguage)}`
325
+ : '';
326
+ return `<pre class="hljs"><code class="hljs${languageClass}">${html}</code></pre>`;
327
+ }
328
+ });
329
+ renderer.use(markdownItTaskLists, {
330
+ enabled: false,
331
+ label: true,
332
+ labelAfter: true
333
+ });
334
+ renderer.use(markdownItKatex, { katex });
335
+ return {
336
+ renderer
337
+ };
338
+ })().catch((error) => {
339
+ markdownPreviewBundlePromise = null;
340
+ throw error;
341
+ });
342
+ }
343
+ return await markdownPreviewBundlePromise;
344
+ }
345
+
346
+ async function loadPdfJs() {
347
+ if (!pdfJsLibPromise) {
348
+ pdfJsLibPromise = import(PDFJS_MODULE_URL)
349
+ .then((pdfjsLib) => {
350
+ pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
351
+ return pdfjsLib;
352
+ });
353
+ }
354
+ return await pdfJsLibPromise;
355
+ }
356
+
204
357
  function isCompactTerminalTabsMode() {
205
358
  return !!window.__tabminalCompactTerminalTabsMode;
206
359
  }
207
360
 
361
+ function canUseMarkdownSplitTabsMode() {
362
+ return !isForcedTerminalWorkspaceMode();
363
+ }
364
+
208
365
  function isForcedTerminalWorkspaceMode() {
209
366
  return isCompactWorkspaceMode() || isCompactTerminalTabsMode();
210
367
  }
@@ -220,8 +377,52 @@ function buildMainTerminalTheme() {
220
377
  }
221
378
 
222
379
  function workspaceKeyToFilePath(key) {
223
- if (!isFileWorkspaceTabKey(key)) return '';
224
- return key.slice(FILE_WORKSPACE_TAB_PREFIX.length);
380
+ if (typeof key !== 'string' || key.length === 0) return '';
381
+ if (key.startsWith(MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX)) {
382
+ return key.slice(MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX.length);
383
+ }
384
+ if (key.startsWith(FILE_WORKSPACE_TAB_PREFIX)) {
385
+ return key.slice(FILE_WORKSPACE_TAB_PREFIX.length);
386
+ }
387
+ return '';
388
+ }
389
+
390
+ function isExternalHref(href) {
391
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(String(href || '').trim());
392
+ }
393
+
394
+ function resolveMarkdownLocalTarget(baseFilePath, href) {
395
+ const value = String(href || '').trim();
396
+ const basePath = String(baseFilePath || '').trim();
397
+ if (!value || !basePath || value.startsWith('#') || isExternalHref(value)) {
398
+ return null;
399
+ }
400
+ const baseDir = basePath.includes('/')
401
+ ? basePath.slice(0, basePath.lastIndexOf('/') + 1)
402
+ : '/';
403
+ try {
404
+ const resolved = new URL(
405
+ value,
406
+ `https://tabminal.local${encodeURI(baseDir)}`
407
+ );
408
+ if (resolved.origin !== 'https://tabminal.local') {
409
+ return null;
410
+ }
411
+ return {
412
+ path: decodeURIComponent(resolved.pathname),
413
+ hash: resolved.hash || ''
414
+ };
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+
420
+ function slugifyMarkdownHeading(text) {
421
+ return String(text || '')
422
+ .trim()
423
+ .toLowerCase()
424
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
425
+ .replace(/\s+/g, '-');
225
426
  }
226
427
 
227
428
  function getWorkspaceDeviceId() {
@@ -263,15 +464,44 @@ function normalizeWorkspaceSnapshot(input = {}, fallback = {}) {
263
464
  ? base.updatedBy
264
465
  : ''
265
466
  );
467
+ const openFiles = uniqueStringList(source.openFiles);
468
+ const fallbackOpenFiles = uniqueStringList(base.openFiles);
469
+ const markdownSplitPathSource =
470
+ typeof source.markdownSplitPath === 'string'
471
+ ? source.markdownSplitPath
472
+ : (
473
+ typeof base.markdownSplitPath === 'string'
474
+ ? base.markdownSplitPath
475
+ : ''
476
+ );
477
+ const markdownSplitPath = (
478
+ markdownSplitPathSource
479
+ && isSupportedMarkdownPath(markdownSplitPathSource)
480
+ && (
481
+ openFiles.includes(markdownSplitPathSource)
482
+ || fallbackOpenFiles.includes(markdownSplitPathSource)
483
+ )
484
+ )
485
+ ? markdownSplitPathSource
486
+ : '';
487
+ const activeWorkspaceTabKey = typeof source.activeWorkspaceTabKey === 'string'
488
+ ? source.activeWorkspaceTabKey
489
+ : (
490
+ typeof base.activeWorkspaceTabKey === 'string'
491
+ ? base.activeWorkspaceTabKey
492
+ : ''
493
+ );
266
494
  return {
267
495
  updatedAt,
268
496
  updatedBy,
269
497
  isVisible: !!source.isVisible,
270
- openFiles: uniqueStringList(source.openFiles),
498
+ openFiles,
271
499
  terminalDisplayMode: source.terminalDisplayMode === 'tab'
272
500
  ? 'tab'
273
501
  : 'auto',
274
- expandedPaths: uniqueStringList(source.expandedPaths)
502
+ expandedPaths: uniqueStringList(source.expandedPaths),
503
+ markdownSplitPath,
504
+ activeWorkspaceTabKey
275
505
  };
276
506
  }
277
507
 
@@ -299,6 +529,8 @@ function buildWorkspaceSnapshotForSession(session, overrides = {}) {
299
529
  openFiles: session.editorState.openFiles,
300
530
  terminalDisplayMode: session.sharedWorkspaceState.terminalDisplayMode,
301
531
  expandedPaths: session.sharedWorkspaceState.expandedPaths,
532
+ markdownSplitPath: session.workspaceState.markdownSplitPath,
533
+ activeWorkspaceTabKey: session.workspaceState.activeTabKey,
302
534
  ...overrides
303
535
  });
304
536
  }
@@ -709,6 +941,26 @@ class EditorManager {
709
941
  this.monacoContainer = document.getElementById('monaco-container');
710
942
  this.imagePreviewContainer = document.getElementById('image-preview-container');
711
943
  this.imagePreview = document.getElementById('image-preview');
944
+ this.pdfPreviewContainer = document.getElementById(
945
+ 'pdf-preview-container'
946
+ );
947
+ this.pdfPreviewStatus = document.getElementById('pdf-preview-status');
948
+ this.pdfPreviewStatusPrimary = document.getElementById(
949
+ 'pdf-preview-status-primary'
950
+ );
951
+ this.pdfPreviewStatusSecondary = document.getElementById(
952
+ 'pdf-preview-status-secondary'
953
+ );
954
+ this.pdfPreviewPages = document.getElementById('pdf-preview-pages');
955
+ this.markdownPreviewContainer = document.getElementById(
956
+ 'markdown-preview-container'
957
+ );
958
+ this.markdownPreviewScroll = document.getElementById(
959
+ 'markdown-preview-scroll'
960
+ );
961
+ this.markdownPreviewContent = document.getElementById(
962
+ 'markdown-preview-content'
963
+ );
712
964
  this.emptyState = document.getElementById('empty-editor-state');
713
965
  this.terminalWrapper = terminalWrapper;
714
966
  this.terminalOriginalParent = terminalWrapper?.parentElement || null;
@@ -758,17 +1010,47 @@ class EditorManager {
758
1010
  this.agentEmbeddedEditors = [];
759
1011
  this.agentEmbeddedTerminals = new Map();
760
1012
  this.agentTranscriptLayout = null;
1013
+ this.pdfPreviewState = {
1014
+ path: '',
1015
+ sessionKey: '',
1016
+ renderToken: 0,
1017
+ document: null,
1018
+ loadingTask: null,
1019
+ metadata: '',
1020
+ renderedWidth: 0,
1021
+ relayoutTimer: 0
1022
+ };
1023
+ this.markdownPreviewState = {
1024
+ path: '',
1025
+ sessionKey: '',
1026
+ renderToken: 0,
1027
+ renderTimer: 0,
1028
+ pendingHash: ''
1029
+ };
1030
+ this.fileVersionCheckTimer = null;
1031
+ this.fileVersionCheckPromise = null;
1032
+ this.fileConflictDialogKey = '';
1033
+ this.suppressFileWriteCapture = false;
761
1034
  this.agentTranscriptResizeObserver = null;
1035
+ this.treeDirectoryFetches = new Map();
1036
+ this.treeRefreshInFlight = false;
1037
+ this.treeRefreshRerunRequested = false;
1038
+ this.treeRefreshBatchQueued = false;
1039
+ this.pendingForcedTreeRefreshSessions = new Set();
762
1040
 
763
1041
  this.initTerminalControls();
764
1042
  this.initResizer();
765
1043
  this.initAgentPanel();
1044
+ this.initMarkdownPreview();
766
1045
  this.initMonaco();
767
1046
  this.loadIconMap();
768
1047
  this.agentTimestampTimer = window.setInterval(() => {
769
1048
  this.refreshAgentTimelineTimestamps();
770
1049
  this.refreshAgentUsageHud();
771
1050
  }, 1000);
1051
+ this.fileVersionCheckTimer = window.setInterval(() => {
1052
+ void this.checkActiveFileVersion();
1053
+ }, FILE_VERSION_CHECK_INTERVAL_MS);
772
1054
  }
773
1055
 
774
1056
  isTerminalTabPinned(session = this.currentSession) {
@@ -811,6 +1093,155 @@ class EditorManager {
811
1093
  this.terminalWrapper.appendChild(this.terminalLayoutButton);
812
1094
  }
813
1095
 
1096
+ initMarkdownPreview() {
1097
+ if (!this.markdownPreviewContainer || !this.markdownPreviewContent) {
1098
+ return;
1099
+ }
1100
+ this.markdownPreviewContainer.addEventListener('click', (event) => {
1101
+ const link = event.target.closest('a[data-markdown-local-path]');
1102
+ if (!link) {
1103
+ return;
1104
+ }
1105
+ const filePath = String(
1106
+ link.dataset.markdownLocalPath || ''
1107
+ ).trim();
1108
+ if (!filePath) {
1109
+ return;
1110
+ }
1111
+ event.preventDefault();
1112
+ event.stopPropagation();
1113
+ void this.openLocalMarkdownLink(
1114
+ filePath,
1115
+ String(link.dataset.markdownLocalHash || '')
1116
+ );
1117
+ });
1118
+ }
1119
+
1120
+ getMarkdownSplitPath(session = this.currentSession) {
1121
+ if (!session?.workspaceState) {
1122
+ return '';
1123
+ }
1124
+ return typeof session.workspaceState.markdownSplitPath === 'string'
1125
+ ? session.workspaceState.markdownSplitPath
1126
+ : '';
1127
+ }
1128
+
1129
+ isMarkdownSplitViewEnabled(
1130
+ session = this.currentSession,
1131
+ filePath = session?.editorState?.activeFilePath || ''
1132
+ ) {
1133
+ return !!(
1134
+ session
1135
+ && canUseMarkdownSplitTabsMode()
1136
+ && filePath
1137
+ && this.getMarkdownSplitPath(session) === filePath
1138
+ && isSupportedMarkdownPath(filePath)
1139
+ );
1140
+ }
1141
+
1142
+ setMarkdownSplitView(
1143
+ filePath,
1144
+ enabled,
1145
+ session = this.currentSession
1146
+ ) {
1147
+ if (!session?.workspaceState || !isSupportedMarkdownPath(filePath)) {
1148
+ return;
1149
+ }
1150
+ const nextMarkdownSplitPath = (
1151
+ enabled
1152
+ && canUseMarkdownSplitTabsMode()
1153
+ )
1154
+ ? filePath
1155
+ : '';
1156
+ if (session.workspaceState.markdownSplitPath === nextMarkdownSplitPath) {
1157
+ return;
1158
+ }
1159
+ session.workspaceState.markdownSplitPath = nextMarkdownSplitPath;
1160
+ session.saveState({ touchWorkspace: true });
1161
+ if (session.key !== this.currentSession?.key) {
1162
+ return;
1163
+ }
1164
+ this.renderEditorTabs();
1165
+ const activeKey = this.getActiveWorkspaceTabKey(session);
1166
+ if (
1167
+ activeKey === makeFileWorkspaceTabKey(filePath)
1168
+ || activeKey === makeMarkdownPreviewWorkspaceTabKey(filePath)
1169
+ ) {
1170
+ this.activateWorkspaceTab(activeKey, true);
1171
+ }
1172
+ this.layout();
1173
+ }
1174
+
1175
+ syncMarkdownSplitSupport(session = this.currentSession) {
1176
+ if (!session?.workspaceState) {
1177
+ return;
1178
+ }
1179
+ const markdownSplitPath = this.getMarkdownSplitPath(session);
1180
+ if (
1181
+ markdownSplitPath
1182
+ && (
1183
+ !isSupportedMarkdownPath(markdownSplitPath)
1184
+ || !session.editorState.openFiles.includes(markdownSplitPath)
1185
+ )
1186
+ ) {
1187
+ session.workspaceState.markdownSplitPath = '';
1188
+ }
1189
+ }
1190
+
1191
+ showMarkdownSplitView(filePath, options = {}) {
1192
+ const session = options.session || this.currentSession;
1193
+ const focusEditor = options.focusEditor !== false;
1194
+ if (!session || !filePath) {
1195
+ return false;
1196
+ }
1197
+ const file = this.getModel(filePath, session);
1198
+ if (!file || file.type !== 'text') {
1199
+ return false;
1200
+ }
1201
+ if (!this.editor || !this.monacoContainer || !this.contentContainer) {
1202
+ return false;
1203
+ }
1204
+
1205
+ this.contentContainer.classList.add('markdown-split-active');
1206
+ this.agentContainer.style.display = 'none';
1207
+ this.imagePreviewContainer.style.display = 'none';
1208
+ this.hidePdfPreview();
1209
+ this.monacoContainer.style.display = 'block';
1210
+ this.markdownPreviewContainer.style.display = 'flex';
1211
+ this.emptyState.style.display = 'none';
1212
+
1213
+ if (!file.model && file.content !== null && this.monacoInstance) {
1214
+ file.model = this.monacoInstance.editor.createModel(
1215
+ file.content,
1216
+ undefined,
1217
+ this.monacoInstance.Uri.file(filePath)
1218
+ );
1219
+ }
1220
+
1221
+ if (file.model) {
1222
+ this.editor.setModel(file.model);
1223
+ this.editor.updateOptions({ readOnly: !!file.readonly });
1224
+ const savedViewState = session.editorState.viewStates.get(filePath);
1225
+ if (savedViewState) {
1226
+ this.editor.restoreViewState(savedViewState);
1227
+ }
1228
+ }
1229
+
1230
+ void this.renderMarkdownPreview(filePath, {
1231
+ session,
1232
+ show: true
1233
+ });
1234
+
1235
+ requestAnimationFrame(() => {
1236
+ this.layout();
1237
+ if (focusEditor && this.editor) {
1238
+ this.editor.focus();
1239
+ }
1240
+ });
1241
+
1242
+ return true;
1243
+ }
1244
+
814
1245
  updateTerminalLayoutButton() {
815
1246
  if (!this.terminalLayoutButton) return;
816
1247
 
@@ -900,7 +1331,13 @@ class EditorManager {
900
1331
  }
901
1332
  } else if (isFileWorkspaceTabKey(lastNonTerminal)) {
902
1333
  const filePath = workspaceKeyToFilePath(lastNonTerminal);
903
- if (session.editorState.openFiles.includes(filePath)) {
1334
+ if (
1335
+ session.editorState.openFiles.includes(filePath)
1336
+ && (
1337
+ !isMarkdownPreviewWorkspaceTabKey(lastNonTerminal)
1338
+ || isSupportedMarkdownPath(filePath)
1339
+ )
1340
+ ) {
904
1341
  return lastNonTerminal;
905
1342
  }
906
1343
  }
@@ -1445,6 +1882,12 @@ class EditorManager {
1445
1882
  && session.editorState.openFiles.includes(
1446
1883
  workspaceKeyToFilePath(explicitKey)
1447
1884
  )
1885
+ && (
1886
+ !isMarkdownPreviewWorkspaceTabKey(explicitKey)
1887
+ || isSupportedMarkdownPath(
1888
+ workspaceKeyToFilePath(explicitKey)
1889
+ )
1890
+ )
1448
1891
  ) {
1449
1892
  return explicitKey;
1450
1893
  }
@@ -1485,82 +1928,555 @@ class EditorManager {
1485
1928
  store.set(filePath, value);
1486
1929
  }
1487
1930
 
1488
- remapTreePath(pathValue, oldPath, newPath, isDirectory) {
1489
- if (typeof pathValue !== 'string' || pathValue.length === 0) {
1490
- return pathValue;
1491
- }
1492
- if (pathValue === oldPath) {
1493
- return newPath;
1494
- }
1495
- if (
1496
- isDirectory
1497
- && pathValue.startsWith(`${oldPath}/`)
1498
- ) {
1499
- return `${newPath}${pathValue.slice(oldPath.length)}`;
1931
+ normalizePendingFileWrite(write, entry = null) {
1932
+ if (write && typeof write === 'object' && !Array.isArray(write)) {
1933
+ return {
1934
+ content: typeof write.content === 'string' ? write.content : '',
1935
+ expectedVersion: typeof write.expectedVersion === 'string'
1936
+ ? write.expectedVersion
1937
+ : (
1938
+ typeof entry?.version === 'string'
1939
+ ? entry.version
1940
+ : ''
1941
+ ),
1942
+ blocked: write.blocked === true,
1943
+ force: write.force === true
1944
+ };
1500
1945
  }
1501
- return pathValue;
1946
+ return {
1947
+ content: typeof write === 'string' ? write : '',
1948
+ expectedVersion: typeof entry?.version === 'string'
1949
+ ? entry.version
1950
+ : '',
1951
+ blocked: false,
1952
+ force: false
1953
+ };
1502
1954
  }
1503
1955
 
1504
- remapWorkspaceTabKey(key, oldPath, newPath, isDirectory) {
1505
- if (!isFileWorkspaceTabKey(key)) return key;
1506
- const filePath = workspaceKeyToFilePath(key);
1507
- const nextPath = this.remapTreePath(
1508
- filePath,
1509
- oldPath,
1510
- newPath,
1511
- isDirectory
1956
+ queuePendingFileWrite(session, filePath, content, overrides = {}) {
1957
+ if (!session || !filePath) return;
1958
+ const pending = getPendingSession(session.key);
1959
+ const entry = this.getModel(filePath, session);
1960
+ const previous = this.normalizePendingFileWrite(
1961
+ pending.fileWrites.get(filePath),
1962
+ entry
1512
1963
  );
1513
- return nextPath ? makeFileWorkspaceTabKey(nextPath) : key;
1964
+ pending.fileWrites.set(filePath, {
1965
+ ...previous,
1966
+ content,
1967
+ expectedVersion: typeof overrides.expectedVersion === 'string'
1968
+ ? overrides.expectedVersion
1969
+ : previous.expectedVersion,
1970
+ blocked: overrides.blocked ?? false,
1971
+ force: overrides.force ?? false
1972
+ });
1514
1973
  }
1515
1974
 
1516
- cloneRenamedModelEntry(entry, nextPath) {
1517
- if (!entry || typeof entry !== 'object') return entry;
1518
- const nextEntry = {
1519
- ...entry
1520
- };
1521
- if (nextEntry.model) {
1522
- let nextContent = nextEntry.content;
1523
- try {
1524
- if (typeof nextEntry.model.getValue === 'function') {
1525
- nextContent = nextEntry.model.getValue();
1526
- }
1527
- } catch {
1528
- // Ignore content extraction failure and keep cached content.
1529
- }
1530
- nextEntry.content = nextContent;
1975
+ getPendingFileWrite(session, filePath) {
1976
+ if (!session || !filePath) return null;
1977
+ const pending = getPendingSession(session.key);
1978
+ if (!pending?.fileWrites?.has(filePath)) {
1979
+ return null;
1980
+ }
1981
+ return this.normalizePendingFileWrite(
1982
+ pending.fileWrites.get(filePath),
1983
+ this.getModel(filePath, session)
1984
+ );
1985
+ }
1531
1986
 
1532
- if (
1533
- this.monacoInstance
1534
- && typeof nextEntry.model.getLanguageId === 'function'
1535
- ) {
1536
- const oldModel = nextEntry.model;
1537
- const languageId = oldModel.getLanguageId();
1538
- const uri = this.monacoInstance.Uri.file(nextPath);
1539
- const existingModel = this.monacoInstance.editor.getModel(uri);
1540
- if (existingModel && existingModel !== oldModel) {
1541
- existingModel.setValue(nextContent ?? '');
1542
- nextEntry.model = existingModel;
1543
- } else {
1544
- nextEntry.model = this.monacoInstance.editor.createModel(
1545
- nextContent ?? '',
1546
- languageId,
1547
- uri
1548
- );
1549
- }
1550
- if (nextEntry.model !== oldModel) {
1551
- try {
1552
- oldModel.dispose();
1553
- } catch {
1554
- // Ignore disposal failures for stale models.
1555
- }
1556
- }
1557
- return nextEntry;
1558
- }
1987
+ getTextFileEntry(filePath, session = this.currentSession) {
1988
+ const entry = this.getModel(filePath, session);
1989
+ if (!entry || entry.type !== 'text') {
1990
+ return null;
1559
1991
  }
1560
- return nextEntry;
1992
+ if (typeof entry.contentVersion !== 'string') {
1993
+ entry.contentVersion = typeof entry.version === 'string'
1994
+ ? entry.version
1995
+ : '';
1996
+ }
1997
+ return entry;
1561
1998
  }
1562
1999
 
1563
- remapModelStorePaths(server, oldPath, newPath, isDirectory) {
2000
+ getCurrentTextFileContent(filePath, session = this.currentSession) {
2001
+ const entry = this.getTextFileEntry(filePath, session);
2002
+ if (!entry) return '';
2003
+ try {
2004
+ if (typeof entry.model?.getValue === 'function') {
2005
+ return entry.model.getValue();
2006
+ }
2007
+ } catch {
2008
+ // Ignore model access failures and fall back to cached content.
2009
+ }
2010
+ return typeof entry.content === 'string' ? entry.content : '';
2011
+ }
2012
+
2013
+ isActiveTextFile(session, filePath) {
2014
+ if (!session || !filePath) return false;
2015
+ if (this.currentSession?.key !== session.key) return false;
2016
+ if (state.activeSessionKey !== session.key) return false;
2017
+ if (session.editorState.activeFilePath !== filePath) return false;
2018
+ return this.getActiveWorkspaceTabKey(session)
2019
+ === makeFileWorkspaceTabKey(filePath);
2020
+ }
2021
+
2022
+ updateTextFileEntry(filePath, updates, session = this.currentSession) {
2023
+ const entry = this.getTextFileEntry(filePath, session);
2024
+ if (!entry || !updates || typeof updates !== 'object') {
2025
+ return null;
2026
+ }
2027
+ Object.assign(entry, updates);
2028
+ return entry;
2029
+ }
2030
+
2031
+ updateActiveEditorReadOnlyState(session, filePath, readonly) {
2032
+ if (!this.isActiveTextFile(session, filePath) || !this.editor) {
2033
+ return;
2034
+ }
2035
+ this.editor.updateOptions({ readOnly: !!readonly });
2036
+ this.renderEditorTabs();
2037
+ }
2038
+
2039
+ applyProgrammaticTextContent(entry, nextContent) {
2040
+ if (
2041
+ !entry?.model
2042
+ || typeof entry.model.getValue !== 'function'
2043
+ || typeof entry.model.setValue !== 'function'
2044
+ ) {
2045
+ entry.content = nextContent;
2046
+ return;
2047
+ }
2048
+ const currentValue = entry.model.getValue();
2049
+ if (currentValue === nextContent) {
2050
+ entry.content = nextContent;
2051
+ return;
2052
+ }
2053
+ this.suppressFileWriteCapture = true;
2054
+ try {
2055
+ entry.model.setValue(nextContent);
2056
+ } finally {
2057
+ this.suppressFileWriteCapture = false;
2058
+ }
2059
+ entry.content = nextContent;
2060
+ }
2061
+
2062
+ async readTextFileSnapshot(session, filePath) {
2063
+ if (!session || !filePath) {
2064
+ throw new Error('File path required');
2065
+ }
2066
+ const response = await session.server.fetch(
2067
+ `/api/fs/read?path=${encodeURIComponent(filePath)}`
2068
+ );
2069
+ if (!response.ok) {
2070
+ await throwResponseError(response, 'Failed to read file');
2071
+ }
2072
+ return await response.json();
2073
+ }
2074
+
2075
+ async readTextFileInfo(session, filePath) {
2076
+ if (!session || !filePath) {
2077
+ throw new Error('File path required');
2078
+ }
2079
+ const response = await session.server.fetch(
2080
+ `/api/fs/info?path=${encodeURIComponent(filePath)}`
2081
+ );
2082
+ if (!response.ok) {
2083
+ await throwResponseError(response, 'Failed to inspect file');
2084
+ }
2085
+ return await response.json();
2086
+ }
2087
+
2088
+ applyTextFileSnapshot(session, filePath, snapshot, options = {}) {
2089
+ const entry = this.getTextFileEntry(filePath, session);
2090
+ if (!entry || !snapshot || typeof snapshot !== 'object') {
2091
+ return null;
2092
+ }
2093
+ const useLocalContent = options.useLocalContent === true;
2094
+ const nextReadonly = !!snapshot.readonly;
2095
+ const nextVersion = typeof snapshot.version === 'string'
2096
+ ? snapshot.version
2097
+ : entry.version || '';
2098
+ const nextContent = typeof snapshot.content === 'string'
2099
+ ? snapshot.content
2100
+ : entry.content || '';
2101
+
2102
+ if (!entry.model && this.monacoInstance) {
2103
+ const uri = this.monacoInstance.Uri.file(filePath);
2104
+ const existing = this.monacoInstance.editor.getModel(uri);
2105
+ entry.model = existing || this.monacoInstance.editor.createModel(
2106
+ typeof entry.content === 'string' ? entry.content : '',
2107
+ undefined,
2108
+ uri
2109
+ );
2110
+ }
2111
+
2112
+ if (!useLocalContent) {
2113
+ const restoreViewState = (
2114
+ this.isActiveTextFile(session, filePath)
2115
+ && this.editor
2116
+ && this.editor.getModel?.() === entry.model
2117
+ )
2118
+ ? this.editor.saveViewState()
2119
+ : null;
2120
+ this.applyProgrammaticTextContent(entry, nextContent);
2121
+ if (restoreViewState && this.editor) {
2122
+ this.editor.restoreViewState(restoreViewState);
2123
+ }
2124
+ entry.contentVersion = nextVersion;
2125
+ } else if (typeof snapshot.content === 'string') {
2126
+ entry.content = snapshot.content;
2127
+ entry.contentVersion = nextVersion;
2128
+ }
2129
+
2130
+ entry.version = nextVersion;
2131
+ entry.readonly = nextReadonly;
2132
+ entry.size = Number.isFinite(snapshot.size) ? snapshot.size : entry.size;
2133
+ entry.mtimeMs = Number.isFinite(snapshot.mtimeMs)
2134
+ ? snapshot.mtimeMs
2135
+ : entry.mtimeMs;
2136
+ entry.lastDismissedRemoteVersion = '';
2137
+ this.updateActiveEditorReadOnlyState(session, filePath, nextReadonly);
2138
+ if (
2139
+ this.currentSession?.key === session.key
2140
+ && isSupportedMarkdownPath(filePath)
2141
+ && this.currentSession.editorState.activeFilePath === filePath
2142
+ ) {
2143
+ this.scheduleMarkdownPreviewRender(filePath, session);
2144
+ }
2145
+ return entry;
2146
+ }
2147
+
2148
+ getFileConflictDialogKey(session, filePath, version, source) {
2149
+ return [
2150
+ session?.key || '',
2151
+ filePath || '',
2152
+ version || '',
2153
+ source || ''
2154
+ ].join(':');
2155
+ }
2156
+
2157
+ async promptTextFileConflict(session, filePath, snapshot, source) {
2158
+ if (!session || !filePath || !snapshot) {
2159
+ return 'dismiss';
2160
+ }
2161
+ const version = typeof snapshot.version === 'string'
2162
+ ? snapshot.version
2163
+ : '';
2164
+ const dialogKey = this.getFileConflictDialogKey(
2165
+ session,
2166
+ filePath,
2167
+ version,
2168
+ source
2169
+ );
2170
+ if (this.fileConflictDialogKey === dialogKey) {
2171
+ return 'dismiss';
2172
+ }
2173
+ this.fileConflictDialogKey = dialogKey;
2174
+ const fileName = filePath.split('/').pop() || filePath;
2175
+ const keepLocal = await showConfirmModal({
2176
+ title: source === 'save-conflict'
2177
+ ? 'Save Conflict'
2178
+ : 'File Changed on Disk',
2179
+ message: source === 'save-conflict'
2180
+ ? `“${fileName}” changed on disk before Tabminal could save it.`
2181
+ : `“${fileName}” was modified outside Tabminal.`,
2182
+ note: 'Use Remote reloads the disk version. Use Local keeps your '
2183
+ + 'current editor contents and overwrites the remote change '
2184
+ + 'on the next save.',
2185
+ confirmLabel: 'Use Local',
2186
+ cancelLabel: 'Use Remote',
2187
+ preferredFocus: 'cancel',
2188
+ allowDismiss: false,
2189
+ returnFocus: this.isActiveTextFile(session, filePath)
2190
+ ? this.monacoContainer
2191
+ : document.activeElement
2192
+ });
2193
+ this.fileConflictDialogKey = '';
2194
+ return keepLocal ? 'local' : 'remote';
2195
+ }
2196
+
2197
+ async resolveTextFileConflict(session, filePath, snapshot, source) {
2198
+ const entry = this.getTextFileEntry(filePath, session);
2199
+ if (!entry || !snapshot) {
2200
+ return;
2201
+ }
2202
+ const decision = await this.promptTextFileConflict(
2203
+ session,
2204
+ filePath,
2205
+ snapshot,
2206
+ source
2207
+ );
2208
+ if (decision === 'remote') {
2209
+ const remoteSnapshot = typeof snapshot.content === 'string'
2210
+ ? snapshot
2211
+ : await this.readTextFileSnapshot(session, filePath);
2212
+ this.applyTextFileSnapshot(session, filePath, remoteSnapshot);
2213
+ this.clearPendingFileWrite(session.key, filePath);
2214
+ if (this.isActiveTextFile(session, filePath)) {
2215
+ this.renderEditorTabs();
2216
+ }
2217
+ return;
2218
+ }
2219
+
2220
+ if (decision === 'local') {
2221
+ const currentContent = this.getCurrentTextFileContent(
2222
+ filePath,
2223
+ session
2224
+ );
2225
+ this.applyTextFileSnapshot(session, filePath, snapshot, {
2226
+ useLocalContent: true
2227
+ });
2228
+ this.queuePendingFileWrite(session, filePath, currentContent, {
2229
+ expectedVersion: typeof snapshot.version === 'string'
2230
+ ? snapshot.version
2231
+ : entry.version || '',
2232
+ blocked: false,
2233
+ force: false
2234
+ });
2235
+ requestImmediateServerSync(session.server, 0);
2236
+ }
2237
+ }
2238
+
2239
+ async applyFileWriteResults(server, sessionResults, sentFileWrites) {
2240
+ if (!server || !Array.isArray(sessionResults)) {
2241
+ return;
2242
+ }
2243
+ for (const update of sessionResults) {
2244
+ const session = state.sessions.get(
2245
+ makeSessionKey(server.id, update?.id)
2246
+ );
2247
+ if (!session || !Array.isArray(update?.fileWrites)) {
2248
+ continue;
2249
+ }
2250
+ for (const result of update.fileWrites) {
2251
+ const filePath = typeof result?.path === 'string'
2252
+ ? result.path
2253
+ : '';
2254
+ if (!filePath) continue;
2255
+ const entry = this.getTextFileEntry(filePath, session);
2256
+ const sentWrite = sentFileWrites?.get(update.id)?.get(filePath)
2257
+ || null;
2258
+ if (!entry) {
2259
+ this.clearPendingFileWrite(session.key, filePath);
2260
+ continue;
2261
+ }
2262
+ if (result.status === 'ok') {
2263
+ const currentWrite = this.getPendingFileWrite(
2264
+ session,
2265
+ filePath
2266
+ );
2267
+ const sentContent = sentWrite?.content
2268
+ ?? this.getCurrentTextFileContent(filePath, session);
2269
+ entry.content = sentContent;
2270
+ entry.version = typeof result.version === 'string'
2271
+ ? result.version
2272
+ : entry.version || '';
2273
+ entry.contentVersion = entry.version;
2274
+ entry.readonly = !!result.readonly;
2275
+ entry.lastDismissedRemoteVersion = '';
2276
+ const hasNewerPendingWrite = !!(
2277
+ currentWrite
2278
+ && sentWrite
2279
+ && (
2280
+ currentWrite.content !== sentWrite.content
2281
+ || currentWrite.expectedVersion
2282
+ !== sentWrite.expectedVersion
2283
+ || currentWrite.force !== sentWrite.force
2284
+ )
2285
+ );
2286
+ if (hasNewerPendingWrite) {
2287
+ this.queuePendingFileWrite(
2288
+ session,
2289
+ filePath,
2290
+ currentWrite.content,
2291
+ {
2292
+ expectedVersion: entry.version,
2293
+ blocked: false,
2294
+ force: currentWrite.force
2295
+ }
2296
+ );
2297
+ } else {
2298
+ this.clearPendingFileWrite(session.key, filePath);
2299
+ }
2300
+ this.updateActiveEditorReadOnlyState(
2301
+ session,
2302
+ filePath,
2303
+ entry.readonly
2304
+ );
2305
+ continue;
2306
+ }
2307
+ if (result.status === 'conflict') {
2308
+ this.queuePendingFileWrite(
2309
+ session,
2310
+ filePath,
2311
+ this.getCurrentTextFileContent(filePath, session),
2312
+ {
2313
+ expectedVersion: typeof result.version === 'string'
2314
+ ? result.version
2315
+ : entry.version || '',
2316
+ blocked: true,
2317
+ force: false
2318
+ }
2319
+ );
2320
+ await this.resolveTextFileConflict(
2321
+ session,
2322
+ filePath,
2323
+ result,
2324
+ 'save-conflict'
2325
+ );
2326
+ continue;
2327
+ }
2328
+ this.queuePendingFileWrite(
2329
+ session,
2330
+ filePath,
2331
+ this.getCurrentTextFileContent(filePath, session),
2332
+ {
2333
+ blocked: true
2334
+ }
2335
+ );
2336
+ alert(result?.error || 'Failed to save file.', {
2337
+ type: 'error',
2338
+ title: 'Save Error'
2339
+ });
2340
+ }
2341
+ }
2342
+ }
2343
+
2344
+ async checkActiveFileVersion() {
2345
+ if (
2346
+ this.fileVersionCheckPromise
2347
+ || document.visibilityState === 'hidden'
2348
+ || isConfirmModalOpen()
2349
+ ) {
2350
+ return;
2351
+ }
2352
+ const session = this.currentSession;
2353
+ const filePath = session?.editorState?.activeFilePath || '';
2354
+ if (!this.isActiveTextFile(session, filePath)) {
2355
+ return;
2356
+ }
2357
+ const entry = this.getTextFileEntry(filePath, session);
2358
+ if (!entry || entry.readonly) {
2359
+ return;
2360
+ }
2361
+ this.fileVersionCheckPromise = (async () => {
2362
+ try {
2363
+ const info = await this.readTextFileInfo(session, filePath);
2364
+ if (
2365
+ !info
2366
+ || typeof info.version !== 'string'
2367
+ || !info.version
2368
+ || info.version === entry.version
2369
+ || info.version === entry.lastDismissedRemoteVersion
2370
+ ) {
2371
+ return;
2372
+ }
2373
+ const pendingWrite = this.getPendingFileWrite(session, filePath);
2374
+ if (pendingWrite?.blocked) {
2375
+ return;
2376
+ }
2377
+ await this.resolveTextFileConflict(
2378
+ session,
2379
+ filePath,
2380
+ info,
2381
+ 'remote-change'
2382
+ );
2383
+ } catch (error) {
2384
+ console.warn('Failed to check file version:', error);
2385
+ }
2386
+ })();
2387
+ try {
2388
+ await this.fileVersionCheckPromise;
2389
+ } finally {
2390
+ this.fileVersionCheckPromise = null;
2391
+ }
2392
+ }
2393
+
2394
+ clearPendingFileWrite(sessionKey, filePath) {
2395
+ const pending = pendingChanges.sessions.get(sessionKey);
2396
+ pending?.fileWrites?.delete(filePath);
2397
+ }
2398
+
2399
+ remapTreePath(pathValue, oldPath, newPath, isDirectory) {
2400
+ if (typeof pathValue !== 'string' || pathValue.length === 0) {
2401
+ return pathValue;
2402
+ }
2403
+ if (pathValue === oldPath) {
2404
+ return newPath;
2405
+ }
2406
+ if (
2407
+ isDirectory
2408
+ && pathValue.startsWith(`${oldPath}/`)
2409
+ ) {
2410
+ return `${newPath}${pathValue.slice(oldPath.length)}`;
2411
+ }
2412
+ return pathValue;
2413
+ }
2414
+
2415
+ remapWorkspaceTabKey(key, oldPath, newPath, isDirectory) {
2416
+ if (!isFileWorkspaceTabKey(key)) return key;
2417
+ const filePath = workspaceKeyToFilePath(key);
2418
+ const nextPath = this.remapTreePath(
2419
+ filePath,
2420
+ oldPath,
2421
+ newPath,
2422
+ isDirectory
2423
+ );
2424
+ if (!nextPath) {
2425
+ return key;
2426
+ }
2427
+ return isMarkdownPreviewWorkspaceTabKey(key)
2428
+ ? makeMarkdownPreviewWorkspaceTabKey(nextPath)
2429
+ : makeFileWorkspaceTabKey(nextPath);
2430
+ }
2431
+
2432
+ cloneRenamedModelEntry(entry, nextPath) {
2433
+ if (!entry || typeof entry !== 'object') return entry;
2434
+ const nextEntry = {
2435
+ ...entry
2436
+ };
2437
+ if (nextEntry.model) {
2438
+ let nextContent = nextEntry.content;
2439
+ try {
2440
+ if (typeof nextEntry.model.getValue === 'function') {
2441
+ nextContent = nextEntry.model.getValue();
2442
+ }
2443
+ } catch {
2444
+ // Ignore content extraction failure and keep cached content.
2445
+ }
2446
+ nextEntry.content = nextContent;
2447
+
2448
+ if (
2449
+ this.monacoInstance
2450
+ && typeof nextEntry.model.getLanguageId === 'function'
2451
+ ) {
2452
+ const oldModel = nextEntry.model;
2453
+ const languageId = oldModel.getLanguageId();
2454
+ const uri = this.monacoInstance.Uri.file(nextPath);
2455
+ const existingModel = this.monacoInstance.editor.getModel(uri);
2456
+ if (existingModel && existingModel !== oldModel) {
2457
+ existingModel.setValue(nextContent ?? '');
2458
+ nextEntry.model = existingModel;
2459
+ } else {
2460
+ nextEntry.model = this.monacoInstance.editor.createModel(
2461
+ nextContent ?? '',
2462
+ languageId,
2463
+ uri
2464
+ );
2465
+ }
2466
+ if (nextEntry.model !== oldModel) {
2467
+ try {
2468
+ oldModel.dispose();
2469
+ } catch {
2470
+ // Ignore disposal failures for stale models.
2471
+ }
2472
+ }
2473
+ return nextEntry;
2474
+ }
2475
+ }
2476
+ return nextEntry;
2477
+ }
2478
+
2479
+ remapModelStorePaths(server, oldPath, newPath, isDirectory) {
1564
2480
  if (!server?.modelStore) return false;
1565
2481
  const nextEntries = [];
1566
2482
  let changed = false;
@@ -1712,6 +2628,17 @@ class EditorManager {
1712
2628
  visualChanged = true;
1713
2629
  }
1714
2630
 
2631
+ const nextMarkdownSplitPath = this.remapTreePath(
2632
+ this.getMarkdownSplitPath(session),
2633
+ oldPath,
2634
+ newPath,
2635
+ isDirectory
2636
+ );
2637
+ if (nextMarkdownSplitPath !== this.getMarkdownSplitPath(session)) {
2638
+ session.workspaceState.markdownSplitPath = nextMarkdownSplitPath;
2639
+ visualChanged = true;
2640
+ }
2641
+
1715
2642
  const nextActiveTabKey = this.remapWorkspaceTabKey(
1716
2643
  session.workspaceState.activeTabKey,
1717
2644
  oldPath,
@@ -1840,6 +2767,17 @@ class EditorManager {
1840
2767
  visualChanged = true;
1841
2768
  }
1842
2769
 
2770
+ if (
2771
+ this.pathMatchesTarget(
2772
+ this.getMarkdownSplitPath(session),
2773
+ targetPath,
2774
+ isDirectory
2775
+ )
2776
+ ) {
2777
+ session.workspaceState.markdownSplitPath = '';
2778
+ visualChanged = true;
2779
+ }
2780
+
1843
2781
  if (session.editorState.viewStates.size > 0) {
1844
2782
  const nextViewStates = new Map();
1845
2783
  let changed = false;
@@ -2138,24 +3076,7 @@ class EditorManager {
2138
3076
  }
2139
3077
 
2140
3078
  refreshSessionTree(session) {
2141
- if (!session || !session.fileTreeElement) return;
2142
- session.fileTreeRenderToken = (session.fileTreeRenderToken || 0) + 1;
2143
- const renderToken = session.fileTreeRenderToken;
2144
- const scrollTop = session.fileTreeElement.scrollTop;
2145
- void this.renderTree(
2146
- session.cwd,
2147
- session.fileTreeElement,
2148
- session,
2149
- renderToken
2150
- ).finally(() => {
2151
- if (
2152
- session.fileTreeElement
2153
- && session.fileTreeRenderToken === renderToken
2154
- ) {
2155
- session.fileTreeElement.scrollTop = scrollTop;
2156
- }
2157
- });
2158
- this.updateTreeAutoRefresh();
3079
+ this.requestSessionTreeRefresh(session);
2159
3080
  }
2160
3081
 
2161
3082
  isSessionTreeVisible(session) {
@@ -2166,29 +3087,186 @@ class EditorManager {
2166
3087
  return this.isSessionTreeVisible(session) && !session.treeEditingPath;
2167
3088
  }
2168
3089
 
2169
- refreshVisibleSessionTrees() {
2170
- for (const session of state.sessions.values()) {
2171
- if (this.canRefreshSessionTree(session)) {
2172
- this.requestSessionTreeRefresh(session);
3090
+ refreshVisibleSessionTrees() {
3091
+ this.requestVisibleTreeRefresh();
3092
+ }
3093
+
3094
+ requestSessionTreeRefresh(session, { force = false } = {}) {
3095
+ if (!session?.fileTreeElement) {
3096
+ this.updateTreeAutoRefresh();
3097
+ return;
3098
+ }
3099
+ if (!force && !this.canRefreshSessionTree(session)) {
3100
+ this.updateTreeAutoRefresh();
3101
+ return;
3102
+ }
3103
+ if (force) {
3104
+ this.pendingForcedTreeRefreshSessions.add(session.key);
3105
+ }
3106
+ this.scheduleTreeRefreshBatch();
3107
+ }
3108
+
3109
+ requestVisibleTreeRefresh() {
3110
+ this.scheduleTreeRefreshBatch();
3111
+ }
3112
+
3113
+ scheduleTreeRefreshBatch() {
3114
+ if (this.treeRefreshBatchQueued) {
3115
+ return;
3116
+ }
3117
+ this.treeRefreshBatchQueued = true;
3118
+ requestAnimationFrame(() => {
3119
+ this.treeRefreshBatchQueued = false;
3120
+ void this.flushTreeRefreshBatch();
3121
+ });
3122
+ }
3123
+
3124
+ getTreeRefreshRequestKey(server, dirPath) {
3125
+ return `${server?.id || 'main'}:${dirPath}`;
3126
+ }
3127
+
3128
+ getSessionTreeRefreshPaths(session) {
3129
+ if (!session?.cwd) {
3130
+ return [];
3131
+ }
3132
+ return uniqueStringList([
3133
+ session.cwd,
3134
+ ...(session.sharedWorkspaceState?.expandedPaths || [])
3135
+ ]);
3136
+ }
3137
+
3138
+ collectTreeRefreshSessions() {
3139
+ const sessions = [];
3140
+ for (const session of state.sessions.values()) {
3141
+ if (this.canRefreshSessionTree(session)) {
3142
+ sessions.push(session);
3143
+ continue;
3144
+ }
3145
+ if (
3146
+ this.pendingForcedTreeRefreshSessions.has(session.key)
3147
+ && this.isSessionTreeVisible(session)
3148
+ ) {
3149
+ sessions.push(session);
3150
+ }
3151
+ }
3152
+ return sessions;
3153
+ }
3154
+
3155
+ async fetchTreeDirectoryListing(server, dirPath) {
3156
+ const key = this.getTreeRefreshRequestKey(server, dirPath);
3157
+ const existing = this.treeDirectoryFetches.get(key);
3158
+ if (existing) {
3159
+ return existing;
3160
+ }
3161
+
3162
+ const request = (async () => {
3163
+ const response = await server.fetch(
3164
+ `/api/fs/list?path=${encodeURIComponent(dirPath)}`
3165
+ );
3166
+ if (!response.ok) {
3167
+ throw new Error(`Failed to list path: ${dirPath}`);
3168
+ }
3169
+ const payload = await response.json();
3170
+ return {
3171
+ files: Array.isArray(payload)
3172
+ ? payload
3173
+ : Array.isArray(payload?.items)
3174
+ ? payload.items
3175
+ : [],
3176
+ creatable: Array.isArray(payload)
3177
+ ? false
3178
+ : !!payload?.creatable
3179
+ };
3180
+ })().finally(() => {
3181
+ this.treeDirectoryFetches.delete(key);
3182
+ });
3183
+
3184
+ this.treeDirectoryFetches.set(key, request);
3185
+ return request;
3186
+ }
3187
+
3188
+ async flushTreeRefreshBatch() {
3189
+ if (this.treeRefreshInFlight) {
3190
+ this.treeRefreshRerunRequested = true;
3191
+ return;
3192
+ }
3193
+
3194
+ const sessions = this.collectTreeRefreshSessions();
3195
+ this.pendingForcedTreeRefreshSessions.clear();
3196
+ if (sessions.length === 0) {
3197
+ this.updateTreeAutoRefresh();
3198
+ return;
3199
+ }
3200
+
3201
+ this.treeRefreshInFlight = true;
3202
+ const renderPlans = sessions.map((session) => {
3203
+ session.fileTreeRenderToken = (session.fileTreeRenderToken || 0) + 1;
3204
+ return {
3205
+ session,
3206
+ renderToken: session.fileTreeRenderToken,
3207
+ scrollTop: session.fileTreeElement?.scrollTop || 0
3208
+ };
3209
+ });
3210
+
3211
+ const requestEntries = new Map();
3212
+ for (const { session } of renderPlans) {
3213
+ for (const dirPath of this.getSessionTreeRefreshPaths(session)) {
3214
+ requestEntries.set(
3215
+ this.getTreeRefreshRequestKey(session.server, dirPath),
3216
+ {
3217
+ server: session.server,
3218
+ dirPath
3219
+ }
3220
+ );
3221
+ }
3222
+ }
3223
+
3224
+ const directorySnapshots = new Map();
3225
+ await Promise.all(
3226
+ Array.from(requestEntries.entries()).map(
3227
+ async ([key, entry]) => {
3228
+ try {
3229
+ directorySnapshots.set(
3230
+ key,
3231
+ await this.fetchTreeDirectoryListing(
3232
+ entry.server,
3233
+ entry.dirPath
3234
+ )
3235
+ );
3236
+ } catch (error) {
3237
+ console.error(
3238
+ 'Failed to fetch tree directory:',
3239
+ entry.dirPath,
3240
+ error
3241
+ );
3242
+ }
3243
+ }
3244
+ )
3245
+ );
3246
+
3247
+ for (const plan of renderPlans) {
3248
+ const { session, renderToken, scrollTop } = plan;
3249
+ if (!session.fileTreeElement) {
3250
+ continue;
3251
+ }
3252
+ this.renderTreeFromSnapshots(
3253
+ session.cwd,
3254
+ session.fileTreeElement,
3255
+ session,
3256
+ directorySnapshots,
3257
+ renderToken
3258
+ );
3259
+ if (session.fileTreeRenderToken === renderToken) {
3260
+ session.fileTreeElement.scrollTop = scrollTop;
2173
3261
  }
2174
3262
  }
2175
- }
2176
3263
 
2177
- requestSessionTreeRefresh(session, { force = false } = {}) {
2178
- if (!force && !this.canRefreshSessionTree(session)) {
2179
- this.updateTreeAutoRefresh();
2180
- return;
3264
+ this.treeRefreshInFlight = false;
3265
+ this.updateTreeAutoRefresh();
3266
+ if (this.treeRefreshRerunRequested) {
3267
+ this.treeRefreshRerunRequested = false;
3268
+ this.scheduleTreeRefreshBatch();
2181
3269
  }
2182
- if (session.fileTreeRefreshQueued) return;
2183
- session.fileTreeRefreshQueued = true;
2184
- requestAnimationFrame(() => {
2185
- session.fileTreeRefreshQueued = false;
2186
- if (force || this.canRefreshSessionTree(session)) {
2187
- this.refreshSessionTree(session);
2188
- } else {
2189
- this.updateTreeAutoRefresh();
2190
- }
2191
- });
2192
3270
  }
2193
3271
 
2194
3272
  updateTreeAutoRefresh() {
@@ -2198,7 +3276,7 @@ class EditorManager {
2198
3276
  (session) => this.canRefreshSessionTree(session)
2199
3277
  )
2200
3278
  );
2201
- if (shouldRun && !this.treeRefreshTimer) {
3279
+ if (shouldRun && !this.treeRefreshTimer) {
2202
3280
  this.treeRefreshTimer = window.setInterval(() => {
2203
3281
  if (document.visibilityState !== 'visible') {
2204
3282
  this.updateTreeAutoRefresh();
@@ -2211,7 +3289,7 @@ class EditorManager {
2211
3289
  this.updateTreeAutoRefresh();
2212
3290
  return;
2213
3291
  }
2214
- this.refreshVisibleSessionTrees();
3292
+ this.requestVisibleTreeRefresh();
2215
3293
  }, FILE_TREE_REFRESH_INTERVAL_MS);
2216
3294
  return;
2217
3295
  }
@@ -2405,7 +3483,7 @@ class EditorManager {
2405
3483
  isDirectory: !!payload.isDirectory,
2406
3484
  renameable: true
2407
3485
  });
2408
- } catch (error) {
3486
+ } catch (_error) {
2409
3487
  alert(error.message || 'Failed to create path', {
2410
3488
  type: 'error',
2411
3489
  title: 'Files'
@@ -2477,7 +3555,7 @@ class EditorManager {
2477
3555
  );
2478
3556
  this.requestSessionTreeRefresh(session);
2479
3557
  session.fileTreeElement?.focus({ preventScroll: true });
2480
- } catch (error) {
3558
+ } catch (_error) {
2481
3559
  alert(error.message || 'Failed to delete path', {
2482
3560
  type: 'error',
2483
3561
  title: 'Files'
@@ -2652,7 +3730,7 @@ class EditorManager {
2652
3730
  list.appendChild(row);
2653
3731
  }
2654
3732
 
2655
- updateTreeItem(li, file, session, renderToken) {
3733
+ updateTreeItem(li, file, session) {
2656
3734
  li.dataset.path = file.path;
2657
3735
  li.dataset.isDirectory = file.isDirectory ? '1' : '0';
2658
3736
  li.dataset.renameable = file.renameable ? '1' : '0';
@@ -2870,7 +3948,7 @@ class EditorManager {
2870
3948
  });
2871
3949
 
2872
3950
  icon.innerHTML = this.getIcon(file.name, true, true);
2873
- await this.renderTree(file.path, li, session, renderToken);
3951
+ this.requestSessionTreeRefresh(session);
2874
3952
  this.updateTreeAutoRefresh();
2875
3953
  session.fileTreeElement?.focus({ preventScroll: true });
2876
3954
  return;
@@ -2914,7 +3992,7 @@ class EditorManager {
2914
3992
  }
2915
3993
  }
2916
3994
 
2917
- reconcileTreeList(list, dirPath, files, creatable, session, renderToken) {
3995
+ reconcileTreeList(list, dirPath, files, creatable, session) {
2918
3996
  const existingItems = new Map();
2919
3997
  Array.from(list.children).forEach((child) => {
2920
3998
  if (child.tagName === 'LI' && child.dataset.path) {
@@ -2930,7 +4008,7 @@ class EditorManager {
2930
4008
  } else {
2931
4009
  existingItems.delete(file.path);
2932
4010
  }
2933
- this.updateTreeItem(li, file, session, renderToken);
4011
+ this.updateTreeItem(li, file, session);
2934
4012
  orderedItems.push(li);
2935
4013
  }
2936
4014
 
@@ -2962,12 +4040,32 @@ class EditorManager {
2962
4040
  });
2963
4041
 
2964
4042
  this.editor.onDidChangeModelContent(() => {
4043
+ if (this.suppressFileWriteCapture) return;
2965
4044
  if (!this.currentSession) return;
2966
4045
  const filePath = this.currentSession.editorState.activeFilePath;
2967
4046
  if (!filePath) return;
2968
-
2969
- const pending = getPendingSession(this.currentSession.key);
2970
- pending.fileWrites.set(filePath, this.editor.getValue());
4047
+ const entry = this.getTextFileEntry(filePath, this.currentSession);
4048
+ if (!entry) return;
4049
+ const nextContent = this.editor.getValue();
4050
+ if (
4051
+ nextContent === (entry.content || '')
4052
+ && (entry.contentVersion || '') === (entry.version || '')
4053
+ ) {
4054
+ this.clearPendingFileWrite(this.currentSession.key, filePath);
4055
+ return;
4056
+ }
4057
+ entry.lastDismissedRemoteVersion = '';
4058
+ this.queuePendingFileWrite(
4059
+ this.currentSession,
4060
+ filePath,
4061
+ nextContent
4062
+ );
4063
+ if (isSupportedMarkdownPath(filePath)) {
4064
+ this.scheduleMarkdownPreviewRender(
4065
+ filePath,
4066
+ this.currentSession
4067
+ );
4068
+ }
2971
4069
  });
2972
4070
 
2973
4071
  monaco.editor.defineTheme('solarized-dark', {
@@ -2987,22 +4085,569 @@ class EditorManager {
2987
4085
  'editor.lineHighlightBackground': '#073642',
2988
4086
  'editorLineNumber.foreground': '#586e75',
2989
4087
  }
2990
- });
2991
- monaco.editor.setTheme('solarized-dark');
2992
-
2993
- // Process pending models
2994
- for (const server of state.servers.values()) {
2995
- for (const [path, file] of server.modelStore) {
2996
- if (file.type === 'text' && !file.model && file.content !== null) {
2997
- file.model = monaco.editor.createModel(file.content, undefined, monaco.Uri.file(path));
2998
- }
4088
+ });
4089
+ monaco.editor.setTheme('solarized-dark');
4090
+
4091
+ // Process pending models
4092
+ for (const server of state.servers.values()) {
4093
+ for (const [path, file] of server.modelStore) {
4094
+ if (file.type === 'text' && !file.model && file.content !== null) {
4095
+ file.model = monaco.editor.createModel(file.content, undefined, monaco.Uri.file(path));
4096
+ }
4097
+ }
4098
+ }
4099
+
4100
+ if (this.currentSession) {
4101
+ this.switchTo(this.currentSession);
4102
+ }
4103
+ });
4104
+ }
4105
+
4106
+ clearMarkdownPreview() {
4107
+ const state = this.markdownPreviewState;
4108
+ state.renderToken += 1;
4109
+ clearTimeout(state.renderTimer);
4110
+ state.renderTimer = 0;
4111
+ state.path = '';
4112
+ state.sessionKey = '';
4113
+ state.pendingHash = '';
4114
+ if (this.markdownPreviewContent) {
4115
+ this.markdownPreviewContent.innerHTML = '';
4116
+ }
4117
+ if (this.markdownPreviewScroll) {
4118
+ this.markdownPreviewScroll.scrollTop = 0;
4119
+ }
4120
+ }
4121
+
4122
+ hideMarkdownPreview() {
4123
+ if (this.contentContainer) {
4124
+ this.contentContainer.classList.remove('markdown-split-active');
4125
+ }
4126
+ if (this.markdownPreviewContainer) {
4127
+ this.markdownPreviewContainer.style.display = 'none';
4128
+ }
4129
+ }
4130
+
4131
+ getMarkdownSourceContent(filePath, session = this.currentSession) {
4132
+ const entry = this.getModel(filePath, session);
4133
+ if (!entry || entry.type !== 'text') {
4134
+ return '';
4135
+ }
4136
+ if (entry.model && typeof entry.model.getValue === 'function') {
4137
+ return entry.model.getValue();
4138
+ }
4139
+ return typeof entry.content === 'string' ? entry.content : '';
4140
+ }
4141
+
4142
+ resolveMarkdownPreviewImageUrl(filePath, src, session) {
4143
+ const resolved = resolveMarkdownLocalTarget(filePath, src);
4144
+ if (resolved && isSupportedImagePath(resolved.path)) {
4145
+ return session.server.resolveUrl(
4146
+ `/api/fs/raw?path=${encodeURIComponent(resolved.path)}`
4147
+ + `&token=${session.server.token}`
4148
+ );
4149
+ }
4150
+ return src;
4151
+ }
4152
+
4153
+ decorateMarkdownPreviewContent(root, filePath, session) {
4154
+ if (!(root instanceof DocumentFragment) && !(root instanceof Element)) {
4155
+ return;
4156
+ }
4157
+ const headingIds = new Map();
4158
+ for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
4159
+ const baseId = slugifyMarkdownHeading(heading.textContent || '')
4160
+ || 'section';
4161
+ const nextCount = (headingIds.get(baseId) || 0) + 1;
4162
+ headingIds.set(baseId, nextCount);
4163
+ heading.id = nextCount === 1
4164
+ ? baseId
4165
+ : `${baseId}-${nextCount}`;
4166
+ }
4167
+
4168
+ for (const image of root.querySelectorAll('img[src]')) {
4169
+ const src = String(image.getAttribute('src') || '').trim();
4170
+ if (!src) {
4171
+ continue;
4172
+ }
4173
+ image.loading = 'lazy';
4174
+ image.decoding = 'async';
4175
+ image.src = this.resolveMarkdownPreviewImageUrl(
4176
+ filePath,
4177
+ src,
4178
+ session
4179
+ );
4180
+ }
4181
+
4182
+ for (const link of root.querySelectorAll('a[href]')) {
4183
+ const href = String(link.getAttribute('href') || '').trim();
4184
+ if (!href) {
4185
+ continue;
4186
+ }
4187
+ const resolved = resolveMarkdownLocalTarget(filePath, href);
4188
+ if (resolved) {
4189
+ link.dataset.markdownLocalPath = resolved.path;
4190
+ link.dataset.markdownLocalHash = resolved.hash || '';
4191
+ continue;
4192
+ }
4193
+ if (!href.startsWith('#')) {
4194
+ link.target = '_blank';
4195
+ link.rel = 'noreferrer noopener';
4196
+ }
4197
+ }
4198
+ }
4199
+
4200
+ scrollMarkdownPreviewHash(hash) {
4201
+ const nextHash = String(hash || '').trim();
4202
+ if (!nextHash || !this.markdownPreviewScroll) {
4203
+ return;
4204
+ }
4205
+ const id = nextHash.startsWith('#') ? nextHash.slice(1) : nextHash;
4206
+ if (!id) {
4207
+ return;
4208
+ }
4209
+ const target = this.markdownPreviewContent?.querySelector(
4210
+ `#${CSS.escape(id)}`
4211
+ );
4212
+ if (!target) {
4213
+ return;
4214
+ }
4215
+ target.scrollIntoView({
4216
+ behavior: 'smooth',
4217
+ block: 'start'
4218
+ });
4219
+ }
4220
+
4221
+ scheduleMarkdownPreviewRender(filePath, session = this.currentSession) {
4222
+ if (
4223
+ !session
4224
+ || !filePath
4225
+ || !isSupportedMarkdownPath(filePath)
4226
+ ) {
4227
+ return;
4228
+ }
4229
+ const state = this.markdownPreviewState;
4230
+ clearTimeout(state.renderTimer);
4231
+ state.renderTimer = window.setTimeout(() => {
4232
+ if (
4233
+ this.currentSession?.key !== session.key
4234
+ || this.currentSession?.editorState.activeFilePath !== filePath
4235
+ ) {
4236
+ return;
4237
+ }
4238
+ const activeKey = this.getActiveWorkspaceTabKey(session);
4239
+ const shouldShow = (
4240
+ isMarkdownPreviewWorkspaceTabKey(activeKey)
4241
+ || this.isMarkdownSplitViewEnabled(session, filePath)
4242
+ );
4243
+ void this.renderMarkdownPreview(filePath, {
4244
+ session,
4245
+ show: shouldShow
4246
+ });
4247
+ }, 60);
4248
+ }
4249
+
4250
+ async renderMarkdownPreview(filePath, options = {}) {
4251
+ const session = options.session || this.currentSession;
4252
+ const show = options.show === true;
4253
+ if (
4254
+ !session
4255
+ || !filePath
4256
+ || !this.markdownPreviewContainer
4257
+ || !this.markdownPreviewContent
4258
+ || !isSupportedMarkdownPath(filePath)
4259
+ ) {
4260
+ return;
4261
+ }
4262
+ const state = this.markdownPreviewState;
4263
+ const renderToken = state.renderToken + 1;
4264
+ state.renderToken = renderToken;
4265
+ state.path = filePath;
4266
+ state.sessionKey = session.key;
4267
+ if (show) {
4268
+ this.markdownPreviewContainer.style.display = 'flex';
4269
+ }
4270
+
4271
+ try {
4272
+ const { renderer } = await loadMarkdownPreviewBundle();
4273
+ if (
4274
+ state.renderToken !== renderToken
4275
+ || state.path !== filePath
4276
+ || state.sessionKey !== session.key
4277
+ ) {
4278
+ return;
4279
+ }
4280
+ const source = this.getMarkdownSourceContent(filePath, session);
4281
+ const rendered = renderer.render(source || '');
4282
+ const sanitized = DOMPurify.sanitize(rendered, {
4283
+ USE_PROFILES: {
4284
+ html: true,
4285
+ mathMl: true,
4286
+ svg: true
4287
+ }
4288
+ });
4289
+ const template = document.createElement('template');
4290
+ template.innerHTML = sanitized;
4291
+ this.decorateMarkdownPreviewContent(
4292
+ template.content,
4293
+ filePath,
4294
+ session
4295
+ );
4296
+ this.markdownPreviewContent.replaceChildren(template.content);
4297
+ const pendingHash = state.pendingHash;
4298
+ state.pendingHash = '';
4299
+ if (pendingHash) {
4300
+ requestAnimationFrame(() => {
4301
+ this.scrollMarkdownPreviewHash(pendingHash);
4302
+ });
4303
+ }
4304
+ } catch (error) {
4305
+ console.error('Failed to render markdown preview:', error);
4306
+ this.markdownPreviewContent.innerHTML = '';
4307
+ const fallback = document.createElement('div');
4308
+ fallback.className = 'markdown-preview-error';
4309
+ fallback.textContent = 'Failed to render markdown preview.';
4310
+ this.markdownPreviewContent.appendChild(fallback);
4311
+ }
4312
+ }
4313
+
4314
+ async openLocalMarkdownLink(filePath, hash = '') {
4315
+ const session = this.currentSession;
4316
+ if (!session || !filePath) {
4317
+ return;
4318
+ }
4319
+ if (isSupportedMarkdownPath(filePath)) {
4320
+ this.markdownPreviewState.pendingHash = hash || '';
4321
+ await this.openFile(filePath, session, {
4322
+ activatePreview: true,
4323
+ focusEditor: false
4324
+ });
4325
+ return;
4326
+ }
4327
+ await this.openFile(filePath, session, {
4328
+ focusEditor: false
4329
+ });
4330
+ }
4331
+
4332
+ clearPdfPreview(preserveDocument = false) {
4333
+ const state = this.pdfPreviewState;
4334
+ state.renderToken += 1;
4335
+ clearTimeout(state.relayoutTimer);
4336
+ state.relayoutTimer = 0;
4337
+ if (!preserveDocument) {
4338
+ const documentRef = state.document;
4339
+ state.document = null;
4340
+ state.loadingTask = null;
4341
+ state.path = '';
4342
+ state.sessionKey = '';
4343
+ state.metadata = '';
4344
+ state.renderedWidth = 0;
4345
+ if (documentRef && typeof documentRef.destroy === 'function') {
4346
+ Promise.resolve(documentRef.destroy()).catch(() => {});
4347
+ }
4348
+ }
4349
+ if (this.pdfPreviewPages) {
4350
+ this.pdfPreviewPages.innerHTML = '';
4351
+ }
4352
+ this.setPdfPreviewStatus('', '');
4353
+ }
4354
+
4355
+ hidePdfPreview() {
4356
+ this.pdfPreviewContainer.style.display = 'none';
4357
+ }
4358
+
4359
+ getPdfPreviewUrl(filePath, session = this.currentSession) {
4360
+ if (!session) return '';
4361
+ return session.server.resolveUrl(
4362
+ `/api/fs/raw?path=${encodeURIComponent(filePath)}`
4363
+ + `&token=${session.server.token}`
4364
+ );
4365
+ }
4366
+
4367
+ getPdfPreviewTargetWidth() {
4368
+ if (!this.pdfPreviewPages) {
4369
+ return 0;
4370
+ }
4371
+ const width = this.pdfPreviewPages.clientWidth - 36;
4372
+ return Math.max(240, Math.floor(Math.min(width, 960)));
4373
+ }
4374
+
4375
+ setPdfPreviewStatus(primary = '', secondary = '') {
4376
+ const nextPrimary = String(primary || '').trim();
4377
+ const nextSecondary = String(secondary || '').trim();
4378
+ if (this.pdfPreviewStatusPrimary) {
4379
+ this.pdfPreviewStatusPrimary.textContent = nextPrimary;
4380
+ }
4381
+ if (this.pdfPreviewStatusSecondary) {
4382
+ this.pdfPreviewStatusSecondary.textContent = nextSecondary;
4383
+ this.pdfPreviewStatusSecondary.title = nextSecondary;
4384
+ }
4385
+ if (this.pdfPreviewStatus) {
4386
+ this.pdfPreviewStatus.classList.toggle(
4387
+ 'is-empty',
4388
+ !nextPrimary && !nextSecondary
4389
+ );
4390
+ }
4391
+ }
4392
+
4393
+ formatPdfByteSize(bytes) {
4394
+ if (!Number.isFinite(bytes) || bytes <= 0) {
4395
+ return '';
4396
+ }
4397
+ const units = ['B', 'KB', 'MB', 'GB'];
4398
+ let value = bytes;
4399
+ let unitIndex = 0;
4400
+ while (value >= 1024 && unitIndex < units.length - 1) {
4401
+ value /= 1024;
4402
+ unitIndex += 1;
4403
+ }
4404
+ const decimals = value >= 100 || unitIndex === 0 ? 0 : 1;
4405
+ return `${value.toFixed(decimals)} ${units[unitIndex]}`;
4406
+ }
4407
+
4408
+ describePdfPageSize(viewport) {
4409
+ if (!viewport) {
4410
+ return '';
4411
+ }
4412
+ const width = Math.min(viewport.width, viewport.height);
4413
+ const height = Math.max(viewport.width, viewport.height);
4414
+ const near = (targetWidth, targetHeight) => (
4415
+ Math.abs(width - targetWidth) < 2
4416
+ && Math.abs(height - targetHeight) < 2
4417
+ );
4418
+ if (near(595.276, 841.89)) return 'A4';
4419
+ if (near(612, 792)) return 'Letter';
4420
+ return '';
4421
+ }
4422
+
4423
+ async loadPdfMetadata(documentRef) {
4424
+ const parts = [];
4425
+ try {
4426
+ const meta = await documentRef.getMetadata();
4427
+ const version = String(meta?.info?.PDFFormatVersion || '').trim();
4428
+ parts.push(version ? `PDF ${version}` : 'PDF');
4429
+ } catch {
4430
+ parts.push('PDF');
4431
+ }
4432
+
4433
+ try {
4434
+ const firstPage = await documentRef.getPage(1);
4435
+ const pageSize = this.describePdfPageSize(
4436
+ firstPage.getViewport({ scale: 1 })
4437
+ );
4438
+ if (pageSize) {
4439
+ parts.push(pageSize);
4440
+ }
4441
+ } catch {
4442
+ // Ignore optional page-size metadata failures.
4443
+ }
4444
+
4445
+ try {
4446
+ const downloadInfo = await documentRef.getDownloadInfo();
4447
+ const byteSize = this.formatPdfByteSize(downloadInfo?.length);
4448
+ if (byteSize) {
4449
+ parts.push(byteSize);
4450
+ }
4451
+ } catch {
4452
+ // Ignore optional size metadata failures.
4453
+ }
4454
+
4455
+ return parts.join(' · ');
4456
+ }
4457
+
4458
+ schedulePdfPreviewRelayout() {
4459
+ const state = this.pdfPreviewState;
4460
+ if (
4461
+ !this.pdfPreviewContainer
4462
+ || this.pdfPreviewContainer.style.display === 'none'
4463
+ || !state.document
4464
+ || !state.path
4465
+ ) {
4466
+ return;
4467
+ }
4468
+ clearTimeout(state.relayoutTimer);
4469
+ state.relayoutTimer = window.setTimeout(() => {
4470
+ const nextWidth = this.getPdfPreviewTargetWidth();
4471
+ if (
4472
+ nextWidth > 0
4473
+ && Math.abs(nextWidth - state.renderedWidth) > 24
4474
+ ) {
4475
+ void this.renderPdfPreview(state.path);
4476
+ }
4477
+ }, 120);
4478
+ }
4479
+
4480
+ async loadPdfDocument(filePath, session, renderToken) {
4481
+ const state = this.pdfPreviewState;
4482
+ const url = this.getPdfPreviewUrl(filePath, session);
4483
+ const pdfjsLib = await loadPdfJs();
4484
+ if (state.renderToken !== renderToken) {
4485
+ return null;
4486
+ }
4487
+
4488
+ let loadingTask = pdfjsLib.getDocument({
4489
+ url
4490
+ });
4491
+ state.loadingTask = loadingTask;
4492
+ try {
4493
+ return await loadingTask.promise;
4494
+ } catch (_error) {
4495
+ if (state.renderToken !== renderToken) {
4496
+ return null;
4497
+ }
4498
+ loadingTask = pdfjsLib.getDocument({
4499
+ url,
4500
+ disableWorker: true
4501
+ });
4502
+ state.loadingTask = loadingTask;
4503
+ return await loadingTask.promise;
4504
+ }
4505
+ }
4506
+
4507
+ async renderPdfPreview(filePath) {
4508
+ const session = this.currentSession;
4509
+ if (!session || !filePath) {
4510
+ return;
4511
+ }
4512
+ const state = this.pdfPreviewState;
4513
+ const renderToken = state.renderToken + 1;
4514
+ const targetSessionKey = session.key;
4515
+ const nextWidth = this.getPdfPreviewTargetWidth();
4516
+ if (nextWidth <= 0) {
4517
+ requestAnimationFrame(() => {
4518
+ if (
4519
+ this.currentSession?.key === targetSessionKey
4520
+ && this.currentSession?.editorState.activeFilePath === filePath
4521
+ ) {
4522
+ void this.renderPdfPreview(filePath);
4523
+ }
4524
+ });
4525
+ return;
4526
+ }
4527
+
4528
+ if (
4529
+ state.path !== filePath
4530
+ || state.sessionKey !== targetSessionKey
4531
+ ) {
4532
+ this.clearPdfPreview();
4533
+ } else {
4534
+ this.clearPdfPreview(true);
4535
+ }
4536
+ state.renderToken = renderToken;
4537
+ state.path = filePath;
4538
+ state.sessionKey = targetSessionKey;
4539
+ this.setPdfPreviewStatus('Loading PDF…', '');
4540
+
4541
+ try {
4542
+ let documentRef = state.document;
4543
+ if (!documentRef) {
4544
+ documentRef = await this.loadPdfDocument(
4545
+ filePath,
4546
+ session,
4547
+ renderToken
4548
+ );
4549
+ if (!documentRef || state.renderToken !== renderToken) {
4550
+ return;
4551
+ }
4552
+ state.document = documentRef;
4553
+ state.metadata = await this.loadPdfMetadata(documentRef);
4554
+ if (state.renderToken !== renderToken) {
4555
+ return;
4556
+ }
4557
+ }
4558
+
4559
+ state.renderedWidth = nextWidth;
4560
+ if (this.pdfPreviewPages) {
4561
+ this.pdfPreviewPages.innerHTML = '';
4562
+ }
4563
+ const pageCount = documentRef.numPages;
4564
+ this.setPdfPreviewStatus(
4565
+ `${pageCount} page${pageCount === 1 ? '' : 's'}`,
4566
+ state.metadata || 'PDF'
4567
+ );
4568
+
4569
+ for (let pageNumber = 1; pageNumber <= documentRef.numPages; pageNumber += 1) {
4570
+ if (state.renderToken !== renderToken) {
4571
+ return;
4572
+ }
4573
+ const page = await documentRef.getPage(pageNumber);
4574
+ if (state.renderToken !== renderToken) {
4575
+ return;
4576
+ }
4577
+ const baseViewport = page.getViewport({ scale: 1 });
4578
+ const scale = nextWidth / baseViewport.width;
4579
+ const viewport = page.getViewport({ scale });
4580
+ const canvas = document.createElement('canvas');
4581
+ const context = canvas.getContext('2d', {
4582
+ alpha: false
4583
+ });
4584
+ if (!context) {
4585
+ throw new Error('Failed to create PDF canvas context');
4586
+ }
4587
+ const outputScale = Math.max(1, window.devicePixelRatio || 1);
4588
+ canvas.width = Math.ceil(viewport.width * outputScale);
4589
+ canvas.height = Math.ceil(viewport.height * outputScale);
4590
+ canvas.style.width = `${Math.ceil(viewport.width)}px`;
4591
+ canvas.style.height = `${Math.ceil(viewport.height)}px`;
4592
+ context.setTransform(outputScale, 0, 0, outputScale, 0, 0);
4593
+ const textLayer = document.createElement('div');
4594
+ textLayer.className = 'textLayer';
4595
+ const sheet = document.createElement('div');
4596
+ sheet.className = 'pdf-preview-sheet';
4597
+ sheet.style.width = `${Math.ceil(viewport.width)}px`;
4598
+ sheet.style.height = `${Math.ceil(viewport.height)}px`;
4599
+ sheet.style.setProperty('--user-unit', '1');
4600
+ sheet.style.setProperty('--scale-factor', String(scale));
4601
+ sheet.style.setProperty(
4602
+ '--total-scale-factor',
4603
+ String(scale)
4604
+ );
4605
+ sheet.style.setProperty('--scale-round-x', '1px');
4606
+ sheet.style.setProperty('--scale-round-y', '1px');
4607
+ await page.render({
4608
+ canvasContext: context,
4609
+ viewport
4610
+ }).promise;
4611
+ if (state.renderToken !== renderToken) {
4612
+ return;
4613
+ }
4614
+ const textContent = await page.getTextContent();
4615
+ if (state.renderToken !== renderToken) {
4616
+ return;
4617
+ }
4618
+ const textLayerBuilder = new pdfjsLib.TextLayer({
4619
+ textContentSource: textContent,
4620
+ container: textLayer,
4621
+ viewport
4622
+ });
4623
+ await textLayerBuilder.render();
4624
+ if (state.renderToken !== renderToken) {
4625
+ return;
2999
4626
  }
4627
+ const wrapper = document.createElement('div');
4628
+ wrapper.className = 'pdf-preview-page';
4629
+ wrapper.dataset.pageNumber = String(pageNumber);
4630
+ sheet.appendChild(canvas);
4631
+ sheet.appendChild(textLayer);
4632
+ wrapper.appendChild(sheet);
4633
+ this.pdfPreviewPages?.appendChild(wrapper);
3000
4634
  }
3001
-
3002
- if (this.currentSession) {
3003
- this.switchTo(this.currentSession);
4635
+ } catch (error) {
4636
+ console.error('Failed to render PDF preview:', error);
4637
+ if (state.renderToken !== renderToken) {
4638
+ return;
3004
4639
  }
3005
- });
4640
+ this.clearPdfPreview();
4641
+ this.hidePdfPreview();
4642
+ alert(
4643
+ `Failed to load PDF: ${filePath.split('/').pop()}`,
4644
+ {
4645
+ type: 'error',
4646
+ title: 'PDF Preview Error'
4647
+ }
4648
+ );
4649
+ this.closeFile(filePath);
4650
+ }
3006
4651
  }
3007
4652
 
3008
4653
  updateEditorPaneVisibility() {
@@ -3089,6 +4734,7 @@ class EditorManager {
3089
4734
  }
3090
4735
 
3091
4736
  this.currentSession = session;
4737
+ this.syncMarkdownSplitSupport(session);
3092
4738
  if (!session) {
3093
4739
  this.pane.style.display = 'none';
3094
4740
  this.resizer.style.display = 'none';
@@ -3126,10 +4772,13 @@ class EditorManager {
3126
4772
  layout() {
3127
4773
  // console.log('[Editor] layout called');
3128
4774
  if (!this.currentSession) return;
4775
+ this.syncMarkdownSplitSupport(this.currentSession);
3129
4776
  this.currentSession.fitMainTerminalIfVisible();
3130
4777
  if (this.editor && this.pane.style.display !== 'none') {
3131
- const width = this.pane.clientWidth;
3132
- const height = this.pane.clientHeight - 35; // Subtract fixed safety margin
4778
+ const width = this.monacoContainer?.clientWidth
4779
+ || this.pane.clientWidth;
4780
+ const height = this.monacoContainer?.clientHeight
4781
+ || (this.pane.clientHeight - 35);
3133
4782
 
3134
4783
  if (width > 0 && height > 0) {
3135
4784
  this.editor.layout({ width, height });
@@ -3137,56 +4786,56 @@ class EditorManager {
3137
4786
  this.editor.layout();
3138
4787
  }
3139
4788
  }
4789
+ this.schedulePdfPreviewRelayout();
3140
4790
  }
3141
4791
 
3142
- async renderTree(
4792
+ renderTreeFromSnapshots(
3143
4793
  dirPath,
3144
4794
  container,
3145
4795
  session,
4796
+ directorySnapshots,
3146
4797
  renderToken = session?.fileTreeRenderToken || 0
3147
4798
  ) {
3148
- try {
3149
- const res = await session.server.fetch(
3150
- `/api/fs/list?path=${encodeURIComponent(dirPath)}`
3151
- );
3152
- if (!res.ok) return;
3153
- const payload = await res.json();
3154
- const files = Array.isArray(payload)
3155
- ? payload
3156
- : Array.isArray(payload?.items)
3157
- ? payload.items
3158
- : [];
3159
- const creatable = Array.isArray(payload)
3160
- ? false
3161
- : !!payload?.creatable;
3162
- if ((session.fileTreeRenderToken || 0) !== renderToken) return;
3163
-
3164
- const list = this.ensureTreeList(container);
3165
- this.reconcileTreeList(
3166
- list,
3167
- dirPath,
3168
- files,
3169
- creatable,
3170
- session,
3171
- renderToken
3172
- );
3173
- if ((session.fileTreeRenderToken || 0) !== renderToken) return;
4799
+ const listing = directorySnapshots.get(
4800
+ this.getTreeRefreshRequestKey(session.server, dirPath)
4801
+ );
4802
+ if (!listing) {
4803
+ return;
4804
+ }
4805
+ if ((session.fileTreeRenderToken || 0) !== renderToken) {
4806
+ return;
4807
+ }
3174
4808
 
3175
- for (const file of files) {
3176
- if (
3177
- file.isDirectory
3178
- && this.getTreeItemExpanded(file.path, session)
3179
- ) {
3180
- const item = Array.from(list.children).find(
3181
- (child) => child.dataset.path === file.path
4809
+ const list = this.ensureTreeList(container);
4810
+ this.reconcileTreeList(
4811
+ list,
4812
+ dirPath,
4813
+ listing.files,
4814
+ listing.creatable,
4815
+ session
4816
+ );
4817
+ if ((session.fileTreeRenderToken || 0) !== renderToken) {
4818
+ return;
4819
+ }
4820
+
4821
+ for (const file of listing.files) {
4822
+ if (
4823
+ file.isDirectory
4824
+ && this.getTreeItemExpanded(file.path, session)
4825
+ ) {
4826
+ const item = Array.from(list.children).find(
4827
+ (child) => child.dataset.path === file.path
4828
+ );
4829
+ if (item) {
4830
+ this.renderTreeFromSnapshots(
4831
+ file.path,
4832
+ item,
4833
+ session,
4834
+ directorySnapshots,
4835
+ renderToken
3182
4836
  );
3183
- if (item) {
3184
- void this.renderTree(file.path, item, session, renderToken);
3185
- }
3186
4837
  }
3187
4838
  }
3188
- } catch (err) {
3189
- console.error('Failed to render tree:', err);
3190
4839
  }
3191
4840
  }
3192
4841
 
@@ -3209,34 +4858,31 @@ class EditorManager {
3209
4858
  const state = targetSession.editorState;
3210
4859
  const wasOpen = state.openFiles.includes(filePath);
3211
4860
  const isImage = isSupportedImagePath(filePath);
4861
+ const isPdf = isSupportedPdfPath(filePath);
3212
4862
 
3213
- if (!this.getModel(filePath)) {
4863
+ if (!this.getModel(filePath, targetSession)) {
3214
4864
  let model = null;
3215
4865
  let content = null;
3216
4866
  let readonly = false;
4867
+ let version = '';
4868
+ let size = 0;
4869
+ let mtimeMs = 0;
3217
4870
 
3218
- if (!isImage) {
4871
+ if (!isImage && !isPdf) {
3219
4872
  try {
3220
- const res = await targetSession.server.fetch(
3221
- `/api/fs/read?path=${encodeURIComponent(filePath)}`
4873
+ const data = await this.readTextFileSnapshot(
4874
+ targetSession,
4875
+ filePath
3222
4876
  );
3223
- if (res.status === 415) {
3224
- await showConfirmModal({
3225
- title: 'Unsupported File Type',
3226
- message: 'This file type is not supported yet.',
3227
- note: 'Only text files and supported images can be opened right now.',
3228
- confirmLabel: 'OK',
3229
- hideCancel: true,
3230
- returnFocus: document.activeElement
3231
- });
3232
- return;
3233
- }
3234
- if (!res.ok) {
3235
- throw new Error('Failed to read file');
3236
- }
3237
- const data = await res.json();
3238
4877
  content = data.content;
3239
4878
  readonly = data.readonly;
4879
+ version = typeof data.version === 'string'
4880
+ ? data.version
4881
+ : '';
4882
+ size = Number.isFinite(data.size) ? data.size : 0;
4883
+ mtimeMs = Number.isFinite(data.mtimeMs)
4884
+ ? data.mtimeMs
4885
+ : 0;
3240
4886
 
3241
4887
  if (this.monacoInstance) {
3242
4888
  const uri = this.monacoInstance.Uri.file(filePath);
@@ -3249,6 +4895,17 @@ class EditorManager {
3249
4895
  }
3250
4896
  }
3251
4897
  } catch (err) {
4898
+ if (err?.message === 'Unsupported file type') {
4899
+ await showConfirmModal({
4900
+ title: 'Unsupported File Type',
4901
+ message: 'This file type is not supported yet.',
4902
+ note: 'Only text files, supported images, and PDFs can be opened right now.',
4903
+ confirmLabel: 'OK',
4904
+ hideCancel: true,
4905
+ returnFocus: document.activeElement
4906
+ });
4907
+ return;
4908
+ }
3252
4909
  alert(`Failed to open file: ${err.message}`, { type: 'error', title: 'Error' });
3253
4910
  this.closeFile(filePath);
3254
4911
  return;
@@ -3256,11 +4913,16 @@ class EditorManager {
3256
4913
  }
3257
4914
 
3258
4915
  this.setModel(filePath, {
3259
- type: isImage ? 'image' : 'text',
4916
+ type: isImage ? 'image' : isPdf ? 'pdf' : 'text',
3260
4917
  model: model,
3261
4918
  content: content,
3262
- readonly: readonly
3263
- });
4919
+ readonly: readonly,
4920
+ version,
4921
+ contentVersion: version,
4922
+ size,
4923
+ mtimeMs,
4924
+ lastDismissedRemoteVersion: ''
4925
+ }, targetSession);
3264
4926
  }
3265
4927
 
3266
4928
  let touchedWorkspace = false;
@@ -3272,7 +4934,11 @@ class EditorManager {
3272
4934
 
3273
4935
  this.updateEditorPaneVisibility();
3274
4936
 
3275
- this.activateFileTab(filePath, false, options);
4937
+ if (options.activatePreview && isSupportedMarkdownPath(filePath)) {
4938
+ this.activateMarkdownPreviewTab(filePath, false);
4939
+ } else {
4940
+ this.activateFileTab(filePath, false, options);
4941
+ }
3276
4942
  if (touchedWorkspace) {
3277
4943
  targetSession.saveState({ touchWorkspace: true });
3278
4944
  }
@@ -3281,6 +4947,9 @@ class EditorManager {
3281
4947
  closeFile(filePath) {
3282
4948
  if (!this.currentSession) return;
3283
4949
  const state = this.currentSession.editorState;
4950
+ if (this.getMarkdownSplitPath(this.currentSession) === filePath) {
4951
+ this.currentSession.workspaceState.markdownSplitPath = '';
4952
+ }
3284
4953
 
3285
4954
  const index = state.openFiles.indexOf(filePath);
3286
4955
  let touchedWorkspace = false;
@@ -3319,8 +4988,10 @@ class EditorManager {
3319
4988
 
3320
4989
  renderEditorTabs() {
3321
4990
  if (!this.currentSession) return;
4991
+ this.syncMarkdownSplitSupport(this.currentSession);
3322
4992
  const state = this.currentSession.editorState;
3323
4993
  const activeWorkspaceTabKey = this.getActiveWorkspaceTabKey();
4994
+ const splitPath = this.getMarkdownSplitPath(this.currentSession);
3324
4995
 
3325
4996
  this.tabsContainer.innerHTML = '';
3326
4997
  if (this.hasCompactWorkspaceTabs(this.currentSession)) {
@@ -3349,11 +5020,28 @@ class EditorManager {
3349
5020
  }
3350
5021
 
3351
5022
  for (const path of state.openFiles) {
5023
+ const splitEnabled = this.isMarkdownSplitViewEnabled(
5024
+ this.currentSession,
5025
+ path
5026
+ );
3352
5027
  const tab = document.createElement('div');
3353
5028
  tab.className = 'editor-tab';
3354
- if (makeFileWorkspaceTabKey(path) === activeWorkspaceTabKey) {
5029
+ if (
5030
+ makeFileWorkspaceTabKey(path) === activeWorkspaceTabKey
5031
+ || (
5032
+ splitEnabled
5033
+ && makeMarkdownPreviewWorkspaceTabKey(path)
5034
+ === activeWorkspaceTabKey
5035
+ )
5036
+ ) {
3355
5037
  tab.classList.add('active');
3356
5038
  }
5039
+ if (isSupportedMarkdownPath(path)) {
5040
+ tab.classList.add('bound-tab', 'bound-tab-primary');
5041
+ }
5042
+ if (splitEnabled) {
5043
+ tab.classList.add('is-split');
5044
+ }
3357
5045
 
3358
5046
  const fileModel = this.getModel(path);
3359
5047
  if (fileModel && fileModel.readonly) {
@@ -3375,16 +5063,96 @@ class EditorManager {
3375
5063
  e.stopPropagation();
3376
5064
  this.closeFile(path);
3377
5065
  };
5066
+ let unsplitBtn = null;
5067
+
5068
+ if (splitEnabled) {
5069
+ unsplitBtn = document.createElement('span');
5070
+ unsplitBtn.className = 'tab-action-btn markdown-unsplit-btn';
5071
+ unsplitBtn.innerHTML = MARKDOWN_SPLIT_DISABLE_ICON_SVG;
5072
+ unsplitBtn.title = 'Restore tabbed markdown view';
5073
+ unsplitBtn.onclick = (e) => {
5074
+ e.stopPropagation();
5075
+ this.setMarkdownSplitView(
5076
+ path,
5077
+ false,
5078
+ this.currentSession
5079
+ );
5080
+ this.activateFileTab(path, false, {
5081
+ focusEditor: false
5082
+ });
5083
+ };
5084
+ tab.appendChild(unsplitBtn);
5085
+ }
3378
5086
 
3379
5087
  tab.onclick = () => this.activateFileTab(path);
3380
5088
  bindSingleTapActivation(tab, () => this.activateFileTab(path), {
3381
- ignoreSelector: '.close-btn'
5089
+ ignoreSelector: '.close-btn, .tab-action-btn'
3382
5090
  });
3383
5091
 
3384
5092
  tab.appendChild(icon);
3385
5093
  tab.appendChild(span);
5094
+ if (unsplitBtn) {
5095
+ tab.appendChild(unsplitBtn);
5096
+ }
3386
5097
  tab.appendChild(closeBtn);
3387
5098
  this.tabsContainer.appendChild(tab);
5099
+
5100
+ if (
5101
+ isSupportedMarkdownPath(path)
5102
+ && !splitEnabled
5103
+ ) {
5104
+ const previewTab = document.createElement('div');
5105
+ previewTab.className = 'editor-tab markdown-preview-tab bound-tab bound-tab-secondary';
5106
+ if (
5107
+ makeMarkdownPreviewWorkspaceTabKey(path)
5108
+ === activeWorkspaceTabKey
5109
+ ) {
5110
+ previewTab.classList.add('active');
5111
+ }
5112
+
5113
+ const previewIcon = document.createElement('span');
5114
+ previewIcon.className = 'file-editor-tab-icon';
5115
+ previewIcon.innerHTML = MARKDOWN_PREVIEW_ICON_SVG;
5116
+
5117
+ const previewLabel = document.createElement('span');
5118
+ previewLabel.textContent = 'Preview';
5119
+ let splitBtn = null;
5120
+
5121
+ if (
5122
+ path !== splitPath
5123
+ && canUseMarkdownSplitTabsMode()
5124
+ ) {
5125
+ splitBtn = document.createElement('span');
5126
+ splitBtn.className = 'tab-action-btn markdown-split-btn';
5127
+ splitBtn.innerHTML = MARKDOWN_SPLIT_ENABLE_ICON_SVG;
5128
+ splitBtn.title = 'Show markdown editor and preview side by side';
5129
+ splitBtn.onclick = (event) => {
5130
+ event.stopPropagation();
5131
+ this.setMarkdownSplitView(
5132
+ path,
5133
+ true,
5134
+ this.currentSession
5135
+ );
5136
+ };
5137
+ previewTab.appendChild(splitBtn);
5138
+ }
5139
+
5140
+ previewTab.onclick = () => this.activateMarkdownPreviewTab(path);
5141
+ bindSingleTapActivation(
5142
+ previewTab,
5143
+ () => this.activateMarkdownPreviewTab(path),
5144
+ {
5145
+ ignoreSelector: '.tab-action-btn'
5146
+ }
5147
+ );
5148
+
5149
+ previewTab.appendChild(previewIcon);
5150
+ previewTab.appendChild(previewLabel);
5151
+ if (splitBtn) {
5152
+ previewTab.appendChild(splitBtn);
5153
+ }
5154
+ this.tabsContainer.appendChild(previewTab);
5155
+ }
3388
5156
  }
3389
5157
 
3390
5158
  for (const agentTab of getAgentTabsForSession(this.currentSession)) {
@@ -3436,6 +5204,13 @@ class EditorManager {
3436
5204
  this.activateAgentTab(workspaceTabKey, isRestore);
3437
5205
  return;
3438
5206
  }
5207
+ if (isMarkdownPreviewWorkspaceTabKey(workspaceTabKey)) {
5208
+ this.activateMarkdownPreviewTab(
5209
+ workspaceKeyToFilePath(workspaceTabKey),
5210
+ isRestore
5211
+ );
5212
+ return;
5213
+ }
3439
5214
  this.activateFileTab(workspaceKeyToFilePath(workspaceTabKey), isRestore);
3440
5215
  }
3441
5216
 
@@ -3461,12 +5236,14 @@ class EditorManager {
3461
5236
  TERMINAL_WORKSPACE_TAB_KEY;
3462
5237
  this.currentSession.needsAttention = false;
3463
5238
  if (!isRestore) {
3464
- this.currentSession.saveState();
5239
+ this.currentSession.saveState({ touchWorkspace: true });
3465
5240
  }
3466
5241
  this.renderEditorTabs();
3467
5242
  this.currentSession.updateTabUI();
3468
5243
  this.monacoContainer.style.display = 'none';
3469
5244
  this.imagePreviewContainer.style.display = 'none';
5245
+ this.hidePdfPreview();
5246
+ this.hideMarkdownPreview();
3470
5247
  this.agentContainer.style.display = 'none';
3471
5248
  this.emptyState.style.display = 'none';
3472
5249
  this.syncTerminalWorkspacePlacement(TERMINAL_WORKSPACE_TAB_KEY);
@@ -3479,6 +5256,60 @@ class EditorManager {
3479
5256
  });
3480
5257
  }
3481
5258
 
5259
+ activateMarkdownPreviewTab(filePath, isRestore = false) {
5260
+ if (!this.currentSession || !filePath) return;
5261
+
5262
+ const state = this.currentSession.editorState;
5263
+ if (!isRestore && state.activeFilePath && state.activeFilePath !== filePath) {
5264
+ const currentGlobal = this.getModel(state.activeFilePath);
5265
+ if (currentGlobal && currentGlobal.type === 'text' && this.editor) {
5266
+ state.viewStates.set(
5267
+ state.activeFilePath,
5268
+ this.editor.saveViewState()
5269
+ );
5270
+ }
5271
+ }
5272
+
5273
+ state.activeFilePath = filePath;
5274
+ this.currentSession.workspaceState.activeTabKey =
5275
+ makeMarkdownPreviewWorkspaceTabKey(filePath);
5276
+ this.currentSession.workspaceState.lastNonTerminalTabKey =
5277
+ makeMarkdownPreviewWorkspaceTabKey(filePath);
5278
+ if (!isRestore) {
5279
+ this.currentSession.saveState({ touchWorkspace: true });
5280
+ }
5281
+ const file = this.getModel(filePath);
5282
+
5283
+ this.renderEditorTabs();
5284
+ this.emptyState.style.display = 'none';
5285
+ this.syncTerminalWorkspacePlacement();
5286
+
5287
+ if (!file) {
5288
+ void this.openFile(filePath, true, {
5289
+ activatePreview: true,
5290
+ focusEditor: false
5291
+ });
5292
+ return;
5293
+ }
5294
+
5295
+ this.agentContainer.style.display = 'none';
5296
+ this.imagePreviewContainer.style.display = 'none';
5297
+ this.hidePdfPreview();
5298
+ if (this.isMarkdownSplitViewEnabled(this.currentSession, filePath)) {
5299
+ this.showMarkdownSplitView(filePath, {
5300
+ session: this.currentSession,
5301
+ focusEditor: false
5302
+ });
5303
+ } else {
5304
+ this.contentContainer.classList.remove('markdown-split-active');
5305
+ this.monacoContainer.style.display = 'none';
5306
+ this.markdownPreviewContainer.style.display = 'flex';
5307
+ void this.renderMarkdownPreview(filePath, {
5308
+ show: true
5309
+ });
5310
+ }
5311
+ }
5312
+
3482
5313
  activateFileTab(filePath, isRestore = false, options = {}) {
3483
5314
  if (!this.currentSession) return;
3484
5315
  if (!filePath) return;
@@ -3496,7 +5327,9 @@ class EditorManager {
3496
5327
  this.currentSession.workspaceState.activeTabKey = makeFileWorkspaceTabKey(filePath);
3497
5328
  this.currentSession.workspaceState.lastNonTerminalTabKey =
3498
5329
  makeFileWorkspaceTabKey(filePath);
3499
- this.currentSession.saveState();
5330
+ if (!isRestore) {
5331
+ this.currentSession.saveState({ touchWorkspace: true });
5332
+ }
3500
5333
  const file = this.getModel(filePath);
3501
5334
 
3502
5335
  this.renderEditorTabs();
@@ -3511,7 +5344,9 @@ class EditorManager {
3511
5344
  if (file.type === 'image') {
3512
5345
  this.agentContainer.style.display = 'none';
3513
5346
  this.monacoContainer.style.display = 'none';
5347
+ this.hideMarkdownPreview();
3514
5348
  this.imagePreviewContainer.style.display = 'flex';
5349
+ this.hidePdfPreview();
3515
5350
 
3516
5351
  this.imagePreview.onerror = () => {
3517
5352
  alert(`Failed to load image: ${filePath.split('/').pop()}`, { type: 'error', title: 'Error' });
@@ -3522,9 +5357,55 @@ class EditorManager {
3522
5357
  this.imagePreview.src = this.currentSession.server.resolveUrl(
3523
5358
  `/api/fs/raw?path=${encodeURIComponent(filePath)}&token=${this.currentSession.server.token}`
3524
5359
  );
5360
+ } else if (file.type === 'pdf') {
5361
+ this.agentContainer.style.display = 'none';
5362
+ this.monacoContainer.style.display = 'none';
5363
+ this.imagePreviewContainer.style.display = 'none';
5364
+ this.hideMarkdownPreview();
5365
+ this.pdfPreviewContainer.style.display = 'flex';
5366
+ void this.renderPdfPreview(filePath);
5367
+ } else if (isSupportedMarkdownPath(filePath)) {
5368
+ if (this.isMarkdownSplitViewEnabled(this.currentSession, filePath)) {
5369
+ this.showMarkdownSplitView(filePath, {
5370
+ session: this.currentSession,
5371
+ focusEditor
5372
+ });
5373
+ this.scheduleMarkdownPreviewRender(
5374
+ filePath,
5375
+ this.currentSession
5376
+ );
5377
+ return;
5378
+ }
5379
+ this.agentContainer.style.display = 'none';
5380
+ this.imagePreviewContainer.style.display = 'none';
5381
+ this.hidePdfPreview();
5382
+ this.hideMarkdownPreview();
5383
+ this.monacoContainer.style.display = 'block';
5384
+ if (!file.model && file.content !== null && this.monacoInstance) {
5385
+ file.model = this.monacoInstance.editor.createModel(
5386
+ file.content,
5387
+ undefined,
5388
+ this.monacoInstance.Uri.file(filePath)
5389
+ );
5390
+ }
5391
+ if (this.editor && file.model) {
5392
+ this.editor.setModel(file.model);
5393
+ this.editor.updateOptions({ readOnly: !!file.readonly });
5394
+ const savedViewState = state.viewStates.get(filePath);
5395
+ if (savedViewState) {
5396
+ this.editor.restoreViewState(savedViewState);
5397
+ }
5398
+ if (focusEditor) {
5399
+ this.editor.focus();
5400
+ }
5401
+ requestAnimationFrame(() => this.editor.layout());
5402
+ }
5403
+ this.scheduleMarkdownPreviewRender(filePath, this.currentSession);
3525
5404
  } else {
3526
5405
  this.agentContainer.style.display = 'none';
3527
5406
  this.imagePreviewContainer.style.display = 'none';
5407
+ this.hidePdfPreview();
5408
+ this.hideMarkdownPreview();
3528
5409
  this.monacoContainer.style.display = 'block';
3529
5410
 
3530
5411
  if (!file.model && file.content !== null && this.monacoInstance) {
@@ -3545,6 +5426,7 @@ class EditorManager {
3545
5426
  // Force layout to ensure content is visible
3546
5427
  requestAnimationFrame(() => this.editor.layout());
3547
5428
  }
5429
+ void this.checkActiveFileVersion();
3548
5430
  }
3549
5431
  }
3550
5432
 
@@ -3581,12 +5463,16 @@ class EditorManager {
3581
5463
  this.currentSession.workspaceState.lastNonTerminalTabKey = agentTabKey;
3582
5464
  noteRecentAgentTab(this.currentSession, agentTabKey);
3583
5465
  agentTab.needsAttention = false;
3584
- this.currentSession.saveState();
5466
+ if (!isRestore) {
5467
+ this.currentSession.saveState({ touchWorkspace: true });
5468
+ }
3585
5469
  this.renderEditorTabs();
3586
5470
  this.currentSession.updateTabUI();
3587
5471
  this.syncTerminalWorkspacePlacement(agentTabKey);
3588
5472
  this.monacoContainer.style.display = 'none';
3589
5473
  this.imagePreviewContainer.style.display = 'none';
5474
+ this.hidePdfPreview();
5475
+ this.hideMarkdownPreview();
3590
5476
  this.emptyState.style.display = 'none';
3591
5477
  this.agentContainer.style.display = 'flex';
3592
5478
  this.renderAgentPanel(agentTab);
@@ -5292,20 +7178,57 @@ class EditorManager {
5292
7178
  const session = agentTab.getLinkedSession();
5293
7179
  if (!session) return;
5294
7180
  try {
7181
+ let targetAgentTab = null;
7182
+ let targetPromptDraft = '';
5295
7183
  if (command.openTabKey) {
5296
7184
  const existingTab = state.agentTabs.get(command.openTabKey);
5297
7185
  const existingSession = existingTab?.getLinkedSession() || null;
5298
7186
  if (existingTab && existingSession) {
5299
- await activateAgentTab(existingSession, existingTab, {
7187
+ targetPromptDraft = String(
7188
+ existingTab.promptDraft || ''
7189
+ );
7190
+ targetAgentTab = await activateAgentTab(
7191
+ existingSession,
7192
+ existingTab,
7193
+ {
5300
7194
  switchSession: true
5301
- });
7195
+ }
7196
+ );
5302
7197
  } else {
5303
- await resumeAgentTabFromHistory(session, agentTab, command);
7198
+ targetAgentTab = await resumeAgentTabFromHistory(
7199
+ session,
7200
+ agentTab,
7201
+ command
7202
+ );
7203
+ targetPromptDraft = String(
7204
+ targetAgentTab?.promptDraft || ''
7205
+ );
5304
7206
  }
5305
7207
  } else {
5306
- await resumeAgentTabFromHistory(session, agentTab, command);
7208
+ targetAgentTab = await resumeAgentTabFromHistory(
7209
+ session,
7210
+ agentTab,
7211
+ command
7212
+ );
7213
+ targetPromptDraft = String(
7214
+ targetAgentTab?.promptDraft || ''
7215
+ );
5307
7216
  }
5308
- this.hideAgentCommandMenu();
7217
+ const targetPromptIntent = getAgentPromptIntent(
7218
+ targetAgentTab,
7219
+ targetPromptDraft
7220
+ );
7221
+ if (targetPromptIntent.kind === 'resume') {
7222
+ targetPromptDraft = '';
7223
+ if (targetAgentTab) {
7224
+ targetAgentTab.promptDraft = '';
7225
+ }
7226
+ }
7227
+ agentTab.promptDraft = '';
7228
+ this.setAgentPromptValue(
7229
+ targetPromptDraft,
7230
+ targetAgentTab || getActiveAgentTab() || agentTab
7231
+ );
5309
7232
  } catch (error) {
5310
7233
  alert(error.message, {
5311
7234
  type: 'error',
@@ -5327,6 +7250,8 @@ class EditorManager {
5327
7250
  showEmptyState() {
5328
7251
  this.monacoContainer.style.display = 'none';
5329
7252
  this.imagePreviewContainer.style.display = 'none';
7253
+ this.hidePdfPreview();
7254
+ this.hideMarkdownPreview();
5330
7255
  this.agentContainer.style.display = 'none';
5331
7256
  this.emptyState.style.display = 'flex';
5332
7257
  this.syncTerminalWorkspacePlacement('');
@@ -5994,13 +7919,31 @@ class Session {
5994
7919
  : Array.from(this.server.expandedPaths)
5995
7920
  }
5996
7921
  );
7922
+ const sharedActiveWorkspaceTabKey = typeof (
7923
+ this.sharedWorkspaceState.activeWorkspaceTabKey
7924
+ ) === 'string'
7925
+ ? this.sharedWorkspaceState.activeWorkspaceTabKey
7926
+ : '';
7927
+ const initialActiveWorkspaceTabKey = (
7928
+ isFileWorkspaceTabKey(sharedActiveWorkspaceTabKey)
7929
+ && !this.sharedWorkspaceState.openFiles.includes(
7930
+ workspaceKeyToFilePath(sharedActiveWorkspaceTabKey)
7931
+ )
7932
+ )
7933
+ ? ''
7934
+ : sharedActiveWorkspaceTabKey;
7935
+ const preferredActiveFilePath = isFileWorkspaceTabKey(
7936
+ initialActiveWorkspaceTabKey
7937
+ )
7938
+ ? workspaceKeyToFilePath(initialActiveWorkspaceTabKey)
7939
+ : legacyEditorState.activeFilePath;
5997
7940
  const initialActiveFilePath = (
5998
- typeof legacyEditorState.activeFilePath === 'string'
7941
+ typeof preferredActiveFilePath === 'string'
5999
7942
  && this.sharedWorkspaceState.openFiles.includes(
6000
- legacyEditorState.activeFilePath
7943
+ preferredActiveFilePath
6001
7944
  )
6002
7945
  )
6003
- ? legacyEditorState.activeFilePath
7946
+ ? preferredActiveFilePath
6004
7947
  : (this.sharedWorkspaceState.openFiles[0] || null);
6005
7948
 
6006
7949
  this.editorState = {
@@ -6011,15 +7954,15 @@ class Session {
6011
7954
  viewStates: new Map() // Path -> ViewState
6012
7955
  };
6013
7956
  this.workspaceState = {
6014
- activeTabKey: legacyEditorState.activeWorkspaceTabKey
7957
+ activeTabKey: initialActiveWorkspaceTabKey
6015
7958
  || (initialActiveFilePath
6016
7959
  ? makeFileWorkspaceTabKey(initialActiveFilePath)
6017
7960
  : ''),
6018
- lastNonTerminalTabKey: legacyEditorState.activeWorkspaceTabKey
7961
+ lastNonTerminalTabKey: initialActiveWorkspaceTabKey
6019
7962
  && !isTerminalWorkspaceTabKey(
6020
- legacyEditorState.activeWorkspaceTabKey
7963
+ initialActiveWorkspaceTabKey
6021
7964
  )
6022
- ? legacyEditorState.activeWorkspaceTabKey
7965
+ ? initialActiveWorkspaceTabKey
6023
7966
  : (initialActiveFilePath
6024
7967
  ? makeFileWorkspaceTabKey(initialActiveFilePath)
6025
7968
  : ''),
@@ -6029,7 +7972,8 @@ class Session {
6029
7972
  ? legacyEditorState.recentAgentTabKeys.filter(
6030
7973
  (key) => typeof key === 'string' && key.length > 0
6031
7974
  )
6032
- : []
7975
+ : [],
7976
+ markdownSplitPath: this.sharedWorkspaceState.markdownSplitPath || ''
6033
7977
  };
6034
7978
 
6035
7979
  this.layoutState = {
@@ -6166,6 +8110,7 @@ class Session {
6166
8110
  this.sharedWorkspaceState = normalized;
6167
8111
  this.editorState.isVisible = normalized.isVisible;
6168
8112
  this.editorState.openFiles = [...normalized.openFiles];
8113
+ this.workspaceState.markdownSplitPath = normalized.markdownSplitPath;
6169
8114
 
6170
8115
  if (
6171
8116
  this.editorState.activeFilePath
@@ -6180,7 +8125,13 @@ class Session {
6180
8125
  const activeKey = this.workspaceState.activeTabKey || '';
6181
8126
  if (isFileWorkspaceTabKey(activeKey)) {
6182
8127
  const filePath = workspaceKeyToFilePath(activeKey);
6183
- if (!this.editorState.openFiles.includes(filePath)) {
8128
+ if (
8129
+ !this.editorState.openFiles.includes(filePath)
8130
+ || (
8131
+ isMarkdownPreviewWorkspaceTabKey(activeKey)
8132
+ && !isSupportedMarkdownPath(filePath)
8133
+ )
8134
+ ) {
6184
8135
  this.workspaceState.activeTabKey = resolveFallbackActiveKey();
6185
8136
  }
6186
8137
  } else if (
@@ -6194,7 +8145,13 @@ class Session {
6194
8145
  this.workspaceState.lastNonTerminalTabKey || '';
6195
8146
  if (isFileWorkspaceTabKey(lastNonTerminalKey)) {
6196
8147
  const filePath = workspaceKeyToFilePath(lastNonTerminalKey);
6197
- if (!this.editorState.openFiles.includes(filePath)) {
8148
+ if (
8149
+ !this.editorState.openFiles.includes(filePath)
8150
+ || (
8151
+ isMarkdownPreviewWorkspaceTabKey(lastNonTerminalKey)
8152
+ && !isSupportedMarkdownPath(filePath)
8153
+ )
8154
+ ) {
6198
8155
  this.workspaceState.lastNonTerminalTabKey = '';
6199
8156
  }
6200
8157
  }
@@ -7818,6 +9775,25 @@ function getActiveServer() {
7818
9775
  return getActiveSession()?.server || getMainServer();
7819
9776
  }
7820
9777
 
9778
+ function getDocumentTitle() {
9779
+ const server = getActiveServer();
9780
+ if (!server) {
9781
+ return 'Tabminal';
9782
+ }
9783
+ const host = String(getDisplayHost(server) || '').trim();
9784
+ if (!host || host.toLowerCase() === 'unknown') {
9785
+ return 'Tabminal';
9786
+ }
9787
+ return `Tabminal: ${host}`;
9788
+ }
9789
+
9790
+ function updateDocumentTitle() {
9791
+ const nextTitle = getDocumentTitle();
9792
+ if (document.title !== nextTitle) {
9793
+ document.title = nextTitle;
9794
+ }
9795
+ }
9796
+
7821
9797
  function getSessionsForServer(serverId) {
7822
9798
  return Array.from(state.sessions.values()).filter(
7823
9799
  session => session.serverId === serverId
@@ -7984,6 +9960,9 @@ function getWorkspaceTabKeysForSession(session) {
7984
9960
  }
7985
9961
  for (const path of session.editorState?.openFiles || []) {
7986
9962
  keys.push(makeFileWorkspaceTabKey(path));
9963
+ if (isSupportedMarkdownPath(path)) {
9964
+ keys.push(makeMarkdownPreviewWorkspaceTabKey(path));
9965
+ }
7987
9966
  }
7988
9967
  for (const agentTab of getAgentTabsForSession(session)) {
7989
9968
  keys.push(agentTab.key);
@@ -8147,6 +10126,7 @@ function normalizeAgentSessionCapabilities(sessionCapabilities) {
8147
10126
  return {
8148
10127
  load: !!source.load,
8149
10128
  list: !!source.list,
10129
+ listAll: !!source.listAll,
8150
10130
  resume: !!source.resume,
8151
10131
  fork: !!source.fork
8152
10132
  };
@@ -8564,6 +10544,9 @@ function getAgentResumeSuggestions(agentTab, promptValue, sessions = []) {
8564
10544
  const intent = getAgentPromptIntent(agentTab, promptValue);
8565
10545
  if (intent.kind !== 'resume') return [];
8566
10546
  const query = String(intent.query || '').toLowerCase();
10547
+ const currentCwd = String(
10548
+ agentTab?.cwd || agentTab?.getLinkedSession?.()?.cwd || ''
10549
+ ).trim().toLowerCase();
8567
10550
  const openSessions = getOpenAgentSessionsForServer(
8568
10551
  agentTab?.serverId,
8569
10552
  agentTab?.agentId
@@ -8577,24 +10560,30 @@ function getAgentResumeSuggestions(agentTab, promptValue, sessions = []) {
8577
10560
  ).toLowerCase();
8578
10561
  const cwd = String(session.cwd || '').toLowerCase();
8579
10562
  const sessionId = String(session.sessionId || '').toLowerCase();
10563
+ const cwdMatch = !!currentCwd && cwd === currentCwd;
8580
10564
  const titleMatch = !query || displayName.includes(query);
8581
- const otherMatch = !query || cwd.includes(query) || sessionId.includes(query);
10565
+ const otherMatch = !query
10566
+ || cwd.includes(query)
10567
+ || sessionId.includes(query);
8582
10568
  return {
8583
10569
  session,
8584
10570
  index,
10571
+ cwdMatch,
8585
10572
  titleMatch,
8586
10573
  matched: titleMatch || otherMatch
8587
10574
  };
8588
10575
  })
8589
10576
  .filter(({ matched }) => matched)
8590
10577
  .sort((left, right) => {
10578
+ if (left.cwdMatch !== right.cwdMatch) {
10579
+ return left.cwdMatch ? -1 : 1;
10580
+ }
8591
10581
  if (left.titleMatch !== right.titleMatch) {
8592
10582
  return left.titleMatch ? -1 : 1;
8593
10583
  }
8594
10584
  return left.index - right.index;
8595
10585
  })
8596
10586
  .map(({ session }) => session)
8597
- .slice(0, 12)
8598
10587
  .map((session) => ({
8599
10588
  ...session,
8600
10589
  openTabKey: openSessions.get(session.sessionId)?.key || '',
@@ -11135,7 +13124,7 @@ async function syncAgentsForServer(server, { force = false } = {}) {
11135
13124
  && state.agentTabs.has(activeKey)
11136
13125
  ) {
11137
13126
  noteRecentAgentTab(session, activeKey);
11138
- session.saveState();
13127
+ session.saveState({ touchWorkspace: true });
11139
13128
  }
11140
13129
  }
11141
13130
 
@@ -11169,7 +13158,7 @@ async function syncAgentsForServer(server, { force = false } = {}) {
11169
13158
  getAgentTabsForSession(preferredSession)[0]?.key || ''
11170
13159
  );
11171
13160
  }
11172
- preferredSession.saveState();
13161
+ preferredSession.saveState({ touchWorkspace: true });
11173
13162
  if (state.activeSessionKey === preferredSession.key) {
11174
13163
  restoreWorkspaceForSession(preferredSession);
11175
13164
  } else {
@@ -11208,7 +13197,7 @@ async function activateAgentTab(session, agentTab, options = {}) {
11208
13197
  }
11209
13198
  session.workspaceState.activeTabKey = agentTab.key;
11210
13199
  noteRecentAgentTab(session, agentTab.key);
11211
- session.saveState();
13200
+ session.saveState({ touchWorkspace: true });
11212
13201
  if (state.activeSessionKey === session.key) {
11213
13202
  restoreWorkspaceForSession(session);
11214
13203
  requestAnimationFrame(() => {
@@ -11481,6 +13470,7 @@ async function syncServer(server) {
11481
13470
  }
11482
13471
 
11483
13472
  const updates = { sessions: [] };
13473
+ const sentFileWrites = new Map();
11484
13474
  for (const [sessionKey, pending] of pendingChanges.sessions) {
11485
13475
  const { serverId, sessionId } = splitSessionKey(sessionKey);
11486
13476
  if (serverId !== server.id) continue;
@@ -11497,10 +13487,31 @@ async function syncServer(server) {
11497
13487
  hasUpdate = true;
11498
13488
  }
11499
13489
  if (pending.fileWrites && pending.fileWrites.size > 0) {
11500
- sessionUpdate.fileWrites = Array.from(
13490
+ const fileWrites = Array.from(
11501
13491
  pending.fileWrites.entries()
11502
- ).map(([path, content]) => ({ path, content }));
11503
- hasUpdate = true;
13492
+ )
13493
+ .map(([path, write]) => ({
13494
+ path,
13495
+ write: editorManager.normalizePendingFileWrite(write)
13496
+ }))
13497
+ .filter(({ write }) => !write.blocked);
13498
+ if (fileWrites.length > 0) {
13499
+ sessionUpdate.fileWrites = fileWrites.map(
13500
+ ({ path, write }) => ({
13501
+ path,
13502
+ content: write.content,
13503
+ expectedVersion: write.expectedVersion,
13504
+ force: write.force === true
13505
+ })
13506
+ );
13507
+ sentFileWrites.set(
13508
+ sessionUpdate.id,
13509
+ new Map(
13510
+ fileWrites.map(({ path, write }) => [path, write])
13511
+ )
13512
+ );
13513
+ hasUpdate = true;
13514
+ }
11504
13515
  }
11505
13516
 
11506
13517
  if (hasUpdate) {
@@ -11544,11 +13555,6 @@ async function syncServer(server) {
11544
13555
 
11545
13556
  if (update.resize) delete pending.resize;
11546
13557
  if (update.workspaceState) delete pending.workspaceState;
11547
- if (update.fileWrites) {
11548
- for (const file of update.fileWrites) {
11549
- pending.fileWrites.delete(file.path);
11550
- }
11551
- }
11552
13558
  }
11553
13559
 
11554
13560
  const data = await response.json();
@@ -11575,6 +13581,33 @@ async function syncServer(server) {
11575
13581
  const sessions = Array.isArray(data) ? data : data.sessions;
11576
13582
  reconcileSessions(server, sessions || []);
11577
13583
  reconcileAgentInventory(server, data.agents);
13584
+ await editorManager.applyFileWriteResults(
13585
+ server,
13586
+ Array.isArray(data?.fileWriteResults)
13587
+ ? data.fileWriteResults
13588
+ : [],
13589
+ sentFileWrites
13590
+ );
13591
+
13592
+ for (const [sessionId, writes] of sentFileWrites.entries()) {
13593
+ const pending = pendingChanges.sessions.get(
13594
+ makeSessionKey(server.id, sessionId)
13595
+ );
13596
+ if (!pending?.fileWrites) {
13597
+ continue;
13598
+ }
13599
+ for (const [path] of writes.entries()) {
13600
+ if (!pending.fileWrites.has(path)) {
13601
+ continue;
13602
+ }
13603
+ const current = editorManager.normalizePendingFileWrite(
13604
+ pending.fileWrites.get(path)
13605
+ );
13606
+ if (!current.blocked) {
13607
+ pending.fileWrites.delete(path);
13608
+ }
13609
+ }
13610
+ }
11578
13611
  } catch (error) {
11579
13612
  if (!wasReconnecting) {
11580
13613
  console.warn(
@@ -12096,7 +14129,7 @@ function reconcileSessions(server, remoteSessions) {
12096
14129
  }
12097
14130
  }
12098
14131
 
12099
- async function createNewSession(server = getActiveServer()) {
14132
+ async function createNewSession(server = getActiveServer(), options = {}) {
12100
14133
  if (!server) return;
12101
14134
  if (server.needsLogin || !server.isAuthenticated) {
12102
14135
  const password = window.prompt(`Password for ${getDisplayHost(server)}`);
@@ -12104,16 +14137,15 @@ async function createNewSession(server = getActiveServer()) {
12104
14137
  await server.login(password);
12105
14138
  }
12106
14139
  try {
12107
- const options = {};
12108
- const activeSession = getActiveSession();
12109
- if (activeSession && activeSession.serverId === server.id && activeSession.cwd) {
12110
- options.cwd = activeSession.cwd;
14140
+ const request = {};
14141
+ if (typeof options.cwd === 'string' && options.cwd.trim()) {
14142
+ request.cwd = options.cwd.trim();
12111
14143
  }
12112
14144
 
12113
14145
  const response = await server.fetch('/api/sessions', {
12114
14146
  method: 'POST',
12115
14147
  headers: { 'Content-Type': 'application/json' },
12116
- body: JSON.stringify(options)
14148
+ body: JSON.stringify(request)
12117
14149
  });
12118
14150
  if (!response.ok) throw new Error('Failed to create session');
12119
14151
  const newSession = await response.json();
@@ -12141,6 +14173,7 @@ function removeSession(key) {
12141
14173
 
12142
14174
  // #region UI Logic
12143
14175
  function renderTabs() {
14176
+ updateDocumentTitle();
12144
14177
  if (!tabListEl) return;
12145
14178
 
12146
14179
  const newTabItem = document.getElementById('new-tab-item');
@@ -12561,7 +14594,8 @@ const confirmModalState = {
12561
14594
  resolve: null,
12562
14595
  returnFocus: null,
12563
14596
  preferredFocus: 'confirm',
12564
- hideCancel: false
14597
+ hideCancel: false,
14598
+ allowDismiss: true
12565
14599
  };
12566
14600
 
12567
14601
  function isConfirmModalOpen() {
@@ -12600,6 +14634,7 @@ function settleConfirmModal(result) {
12600
14634
  confirmModalState.returnFocus = null;
12601
14635
  confirmModalState.preferredFocus = 'confirm';
12602
14636
  confirmModalState.hideCancel = false;
14637
+ confirmModalState.allowDismiss = true;
12603
14638
  if (returnFocus instanceof HTMLElement) {
12604
14639
  requestAnimationFrame(() => {
12605
14640
  try {
@@ -12617,8 +14652,11 @@ function showConfirmModal({
12617
14652
  message = '',
12618
14653
  note = '',
12619
14654
  confirmLabel = 'Confirm',
14655
+ cancelLabel = 'Cancel',
12620
14656
  danger = false,
12621
14657
  hideCancel = false,
14658
+ preferredFocus = 'confirm',
14659
+ allowDismiss = true,
12622
14660
  returnFocus = null
12623
14661
  } = {}) {
12624
14662
  if (
@@ -12638,13 +14676,17 @@ function showConfirmModal({
12638
14676
  confirmModalMessage.textContent = message;
12639
14677
  confirmModalNote.textContent = note;
12640
14678
  confirmModalNote.style.display = note ? '' : 'none';
14679
+ confirmModalCancel.textContent = cancelLabel;
12641
14680
  confirmModalCancel.style.display = hideCancel ? 'none' : '';
12642
14681
  confirmModalConfirm.textContent = confirmLabel;
12643
14682
  confirmModalConfirm.classList.toggle('danger-button', danger);
12644
14683
  confirmModal.style.display = 'flex';
12645
14684
  confirmModalState.returnFocus = returnFocus;
12646
14685
  confirmModalState.hideCancel = hideCancel;
12647
- confirmModalState.preferredFocus = 'confirm';
14686
+ confirmModalState.preferredFocus = preferredFocus === 'cancel'
14687
+ ? 'cancel'
14688
+ : 'confirm';
14689
+ confirmModalState.allowDismiss = allowDismiss !== false;
12648
14690
  requestAnimationFrame(() => {
12649
14691
  getConfirmModalPreferredButton()?.focus({ preventScroll: true });
12650
14692
  });
@@ -12679,6 +14721,7 @@ function moveConfirmModalFocus(delta) {
12679
14721
  }
12680
14722
 
12681
14723
  function renderServerControls() {
14724
+ updateDocumentTitle();
12682
14725
  if (!serverControlsEl) return;
12683
14726
  serverControlsEl.innerHTML = '';
12684
14727
 
@@ -12834,12 +14877,14 @@ window.addEventListener('focus', () => {
12834
14877
  enterAppNotificationQuietPeriod();
12835
14878
  editorManager.refreshVisibleSessionTrees();
12836
14879
  editorManager.updateTreeAutoRefresh();
14880
+ void editorManager.checkActiveFileVersion();
12837
14881
  });
12838
14882
  window.addEventListener('pageshow', () => {
12839
14883
  noteAppInteraction();
12840
14884
  enterAppNotificationQuietPeriod();
12841
14885
  editorManager.refreshVisibleSessionTrees();
12842
14886
  editorManager.updateTreeAutoRefresh();
14887
+ void editorManager.checkActiveFileVersion();
12843
14888
  });
12844
14889
 
12845
14890
  document.addEventListener('click', () => {
@@ -12851,6 +14896,7 @@ document.addEventListener('visibilitychange', () => {
12851
14896
  enterAppNotificationQuietPeriod();
12852
14897
  clearVisibleAttentionState();
12853
14898
  editorManager.refreshVisibleSessionTrees();
14899
+ void editorManager.checkActiveFileVersion();
12854
14900
  }
12855
14901
  editorManager.updateTreeAutoRefresh();
12856
14902
  });
@@ -13048,19 +15094,19 @@ window.addEventListener('tabminal:layout-modechange', () => {
13048
15094
  && terminalEl.contains(activeElement)
13049
15095
  );
13050
15096
 
13051
- if (isForcedTerminalWorkspaceMode()) {
13052
- if (terminalHasFocus) {
13053
- session.workspaceState.activeTabKey = TERMINAL_WORKSPACE_TAB_KEY;
13054
- session.saveState();
15097
+ if (isForcedTerminalWorkspaceMode()) {
15098
+ if (terminalHasFocus) {
15099
+ session.workspaceState.activeTabKey = TERMINAL_WORKSPACE_TAB_KEY;
15100
+ session.saveState({ touchWorkspace: true });
15101
+ }
15102
+ } else if (
15103
+ !editorManager.isTerminalTabPinned(session)
15104
+ && isTerminalWorkspaceTabKey(session.workspaceState?.activeTabKey || '')
15105
+ ) {
15106
+ session.workspaceState.activeTabKey =
15107
+ editorManager.getPreferredNonTerminalWorkspaceTabKey(session);
15108
+ session.saveState({ touchWorkspace: true });
13055
15109
  }
13056
- } else if (
13057
- !editorManager.isTerminalTabPinned(session)
13058
- && isTerminalWorkspaceTabKey(session.workspaceState?.activeTabKey || '')
13059
- ) {
13060
- session.workspaceState.activeTabKey =
13061
- editorManager.getPreferredNonTerminalWorkspaceTabKey(session);
13062
- session.saveState();
13063
- }
13064
15110
 
13065
15111
  editorManager.switchTo(session);
13066
15112
  editorManager.updateEditorPaneVisibility();
@@ -13281,7 +15327,10 @@ if (
13281
15327
  });
13282
15328
 
13283
15329
  confirmModal.addEventListener('click', (event) => {
13284
- if (event.target === confirmModal) {
15330
+ if (
15331
+ event.target === confirmModal
15332
+ && confirmModalState.allowDismiss
15333
+ ) {
13285
15334
  settleConfirmModal(false);
13286
15335
  }
13287
15336
  });
@@ -13292,6 +15341,11 @@ if (
13292
15341
 
13293
15342
  confirmModal.addEventListener('keydown', (event) => {
13294
15343
  if (event.key === 'Escape') {
15344
+ if (!confirmModalState.allowDismiss) {
15345
+ event.preventDefault();
15346
+ event.stopPropagation();
15347
+ return;
15348
+ }
13295
15349
  event.preventDefault();
13296
15350
  settleConfirmModal(false);
13297
15351
  return;