dsh-remote-vs 0.8.8 → 0.9.0

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.
Files changed (38) hide show
  1. package/README.en.md +15 -2
  2. package/README.md +12 -1
  3. package/lib/client.js +1507 -59
  4. package/lib/types/client/CodeEditor.d.ts +27 -1
  5. package/lib/types/client/FloatingEditorWindow.d.ts +20 -0
  6. package/lib/types/client/FloatingPlaceholder.d.ts +9 -0
  7. package/lib/types/client/RemoteEditorView.d.ts +4 -0
  8. package/lib/types/client/TerminalView.d.ts +3 -0
  9. package/lib/types/client/XTerm.d.ts +6 -0
  10. package/lib/types/client/float-editor.d.ts +123 -0
  11. package/lib/types/client/float-tab-trigger.d.ts +24 -0
  12. package/lib/types/client/live-editor.d.ts +94 -0
  13. package/lib/types/client/remote-file-open-target.d.ts +0 -22
  14. package/lib/types/client/shell.d.ts +29 -0
  15. package/lib/types/client/store.d.ts +160 -6
  16. package/package.json +1 -1
  17. package/src/client/CodeEditor.tsx +95 -8
  18. package/src/client/EditorPane.tsx +45 -24
  19. package/src/client/FloatingEditorWindow.tsx +494 -0
  20. package/src/client/FloatingPlaceholder.tsx +22 -0
  21. package/src/client/RemoteEditorView.tsx +7 -2
  22. package/src/client/RemoteExplorer.tsx +61 -0
  23. package/src/client/RemoteRoot.tsx +16 -7
  24. package/src/client/SearchView.tsx +49 -2
  25. package/src/client/TerminalView.tsx +53 -2
  26. package/src/client/XTerm.tsx +15 -0
  27. package/src/client/float-editor.ts +271 -0
  28. package/src/client/float-tab-trigger.ts +51 -0
  29. package/src/client/i18n.ts +32 -0
  30. package/src/client/index.tsx +48 -1
  31. package/src/client/live-editor.ts +155 -0
  32. package/src/client/remote-file-open-target.ts +1 -5
  33. package/src/client/remote-file-open.ts +2 -2
  34. package/src/client/scm/ScmPanel.tsx +42 -1
  35. package/src/client/shell.ts +35 -0
  36. package/src/client/store.tsx +367 -16
  37. package/src/client/vsc/editor-theme.ts +6 -1
  38. package/src/client/vsc/vsc.css +235 -0
@@ -33,6 +33,8 @@ import { loadCommitFileDiff, findRepos, loadDiff, loadHead, loadStatusDetail, pr
33
33
  import { StageCoordinator } from './scm/stage-queue.ts'
34
34
  import { collapseDir, dropExpandedRows, expandDir, pruneDirExpansions, type DirExpansions } from './scm/untracked-dirs.ts'
35
35
  import { detectLang, makeT, type Lang } from './i18n.ts'
36
+ import { effectiveEditorSurface, type EditorSurface } from './float-editor.ts'
37
+ import { captureLiveEditorPosition, captureLiveEditorText, isLiveEditorDirty, isLiveEditorFor, markLiveEditorSaved } from './live-editor.ts'
36
38
  import { planWorkspaceFollow, selectionIsSessionBinding, type WorkspaceFollowDecision } from './remote-file-open-target.ts'
37
39
  import { revealViewTab } from './reveal.ts'
38
40
  import { isHtmlFile, isImageFile, isMarkdownFile, toast } from './vsc/kit.tsx'
@@ -49,6 +51,16 @@ export interface EditorTab {
49
51
  error: string | undefined
50
52
  /** File payload. */
51
53
  text: string
54
+ /**
55
+ * The text as last LOADED from / SAVED to the remote file.
56
+ *
57
+ * `text` is the live-buffer handoff copy (see `captureLiveBuffer`) and may
58
+ * therefore hold unsaved edits; this is the disk-side reference the editor
59
+ * uses as its dirty baseline. Without the split, a remount with the handoff
60
+ * text would label unsaved edits "clean" (dirty dot off, Save disabled, close
61
+ * guard silent) — the exact silent-loss path the handoff exists to avoid.
62
+ */
63
+ baseline?: string
52
64
  dirty: boolean
53
65
  saving: boolean
54
66
  binary: boolean
@@ -127,10 +139,22 @@ interface State {
127
139
  revision: number
128
140
  /** Where the editor pane is mounted ('center' = conversation view). */
129
141
  editorSurface: 'center' | 'sidebar'
142
+ /** True while the editor is popped out into the floating window. */
143
+ floatOpen: boolean
144
+ /**
145
+ * True once the host's frame-wide overlay slot actually rendered the
146
+ * floating window host. Until then the menu entry stays disabled: a surface
147
+ * that can never paint is worse than a missing entry.
148
+ */
149
+ floatAvailable: boolean
130
150
  /** The active sidebar panel (shared so any surface can switch it). */
131
151
  view: RailView
132
152
  /** A pending search request raised by the explorer's context menu. */
133
153
  searchRequest: SearchRequest | undefined
154
+ /** A pending "show this folder in the tree" request (search-result menu). */
155
+ revealRequest: RevealRequest | undefined
156
+ /** A pending "open a terminal in this folder" request (search-result menu). */
157
+ terminalRequest: TerminalRequest | undefined
134
158
  /**
135
159
  * Expanded untracked DIRECTORY rows (`-u normal` collapses a directory into
136
160
  * one row, so the panel asks for its contents on demand — see
@@ -154,6 +178,32 @@ export interface SearchRequest {
154
178
  scope: string
155
179
  }
156
180
 
181
+ /** One "show this folder in the explorer tree" request. */
182
+ export interface RevealRequest {
183
+ /** Monotonic id so asking for the same folder twice still re-triggers. */
184
+ id: number
185
+ /** Absolute remote DIRECTORY to expand and scroll to. */
186
+ path: string
187
+ }
188
+
189
+ /** One "open a terminal here" request. */
190
+ export interface TerminalRequest {
191
+ /** Monotonic id so the same folder can be opened twice. */
192
+ id: number
193
+ /**
194
+ * Host alias the directory lives on.
195
+ *
196
+ * Carried explicitly, and NOT taken from the terminal panel's own last-used
197
+ * host: the panel remembers whatever host the user ran a shell on last, so a
198
+ * menu action on a workspace bound elsewhere opened the shell on the WRONG
199
+ * machine — the `cd` then failed with "No such file or directory" for a path
200
+ * that exists perfectly well on its own host.
201
+ */
202
+ alias: string
203
+ /** Absolute remote directory the fresh shell should `cd` into. */
204
+ dir: string
205
+ }
206
+
157
207
  const WS_KEY = 'dsh-remote-vs.files.ws'
158
208
  const VIEW_KEY = 'dsh-remote-vs.view'
159
209
  /** Per-workspace repository sub-directory, keyed by workspace id. */
@@ -293,8 +343,12 @@ const state: State = {
293
343
  activeTabId: undefined,
294
344
  revision: 0,
295
345
  editorSurface: 'sidebar',
346
+ floatOpen: false,
347
+ floatAvailable: false,
296
348
  view: readStoredView(),
297
349
  searchRequest: undefined,
350
+ revealRequest: undefined,
351
+ terminalRequest: undefined,
298
352
  untrackedDirs: new Map(),
299
353
  }
300
354
 
@@ -334,13 +388,31 @@ export interface ExplorerValue {
334
388
  activeTab: EditorTab | undefined
335
389
  activeTabId: string | undefined
336
390
  revision: number
337
- editorSurface: 'center' | 'sidebar'
391
+ /** Effective editor mount: the floating window wins over the base surface. */
392
+ editorSurface: EditorSurface
393
+ floatOpen: boolean
394
+ floatAvailable: boolean
338
395
  view: RailView
339
396
  searchRequest: SearchRequest | undefined
397
+ /** Pending "show this folder in the tree" request (consumed by the explorer). */
398
+ revealRequest: RevealRequest | undefined
399
+ /** Pending "open a terminal here" request (consumed by the terminal panel). */
400
+ terminalRequest: TerminalRequest | undefined
340
401
  setView: (view: RailView) => void
341
402
  /** Switch to the search panel (optionally scoped to a folder) — the tree's
342
403
  "find in folder" entry point. */
343
404
  requestSearch: (request: { mode: 'name' | 'content'; query?: string; scope: string }) => void
405
+ /**
406
+ * Show one remote directory in the explorer: switch to it, expand the folder
407
+ * and its ancestors, then scroll its row into view. Raised by the search
408
+ * results' "open containing folder" entry.
409
+ */
410
+ revealInExplorer: (dir: string) => void
411
+ /**
412
+ * Open a fresh terminal tab whose shell `cd`s into `dir`. Raised by the
413
+ * search results' "open in terminal" entry.
414
+ */
415
+ openTerminalAt: (dir: string) => void
344
416
  /**
345
417
  * The USER's pick — the workspace dropdowns and the wizard's "open files".
346
418
  *
@@ -368,6 +440,19 @@ export interface ExplorerValue {
368
440
  /** Expand / collapse one untracked directory row (one path-limited status). */
369
441
  toggleUntrackedDir: (dirPath: string) => Promise<void>
370
442
  openFile: (fullPath: string, options?: { line?: number }) => void
443
+ /**
444
+ * 「在悬浮编辑器中打开」:把编辑器整体浮出,再打开这个文件。
445
+ *
446
+ * 先捕获当前编辑器缓冲区再切表面(见 `captureLiveBuffer`):主界面里没保存
447
+ * 的编辑不会因为这次切换丢掉。
448
+ */
449
+ openInFloatEditor: (fullPath: string, options?: { line?: number }) => void
450
+ /** 同上,只是打开的是工作区 diff(SCM 更改列表的悬浮入口)。 */
451
+ openDiffInFloatEditor: (rel: string, staged: boolean, untracked?: boolean) => void
452
+ /** 把当前编辑器整体浮出(双击会话视图的「远程编辑器」标签)。 */
453
+ openFloatingEditor: () => void
454
+ /** 停靠回主界面(底层的中心视图或侧栏表面)。 */
455
+ dockFloatingEditor: () => void
371
456
  openDiff: (rel: string, staged: boolean, untracked?: boolean) => void
372
457
  openCommitFile: (hash: string, rel: string) => void
373
458
  activateTab: (id: string) => void
@@ -384,7 +469,12 @@ export interface ExplorerValue {
384
469
  * `changeSet.marks`,`hunk` 是 `patches` 的下标)。
385
470
  */
386
471
  undoChange: (tabId: string, mark: ChangeMark) => Promise<void>
387
- saveTab: (id: string, content: string) => Promise<void>
472
+ /**
473
+ * Write one file tab back to the remote host.
474
+ * @returns true only when the write really succeeded — the caller uses it to
475
+ * advance the editor's dirty baseline (see `CodeEditorHandle.markSaved`).
476
+ */
477
+ saveTab: (id: string, content: string) => Promise<boolean>
388
478
  reloadTab: (id: string) => Promise<void>
389
479
  info: (text: string) => void
390
480
  fail: (text: string) => void
@@ -460,11 +550,17 @@ function build(): ExplorerValue {
460
550
  activeTab: state.tabs.find(item => item.id === state.activeTabId),
461
551
  activeTabId: state.activeTabId,
462
552
  revision: state.revision,
463
- editorSurface: state.editorSurface,
553
+ editorSurface: effectiveEditorSurface(state.editorSurface, state.floatOpen),
554
+ floatOpen: state.floatOpen,
555
+ floatAvailable: state.floatAvailable,
464
556
  view: state.view,
465
557
  searchRequest: state.searchRequest,
558
+ revealRequest: state.revealRequest,
559
+ terminalRequest: state.terminalRequest,
466
560
  setView,
467
561
  requestSearch,
562
+ revealInExplorer,
563
+ openTerminalAt,
468
564
  selectWorkspace,
469
565
  setRepoSubdir,
470
566
  discoverRepos,
@@ -475,6 +571,10 @@ function build(): ExplorerValue {
475
571
  untrackedDirs: state.untrackedDirs,
476
572
  toggleUntrackedDir,
477
573
  openFile,
574
+ openInFloatEditor,
575
+ openDiffInFloatEditor,
576
+ openFloatingEditor,
577
+ dockFloatingEditor,
478
578
  openDiff,
479
579
  openCommitFile,
480
580
  activateTab,
@@ -622,6 +722,48 @@ export function requestSearch(request: { mode: 'name' | 'content'; query?: strin
622
722
  setView('search')
623
723
  }
624
724
 
725
+ /** Monotonic ticket for "show this folder" requests (0 = none yet). */
726
+ let revealSeq = 0
727
+
728
+ /**
729
+ * Show one remote directory in the explorer.
730
+ *
731
+ * Two stages, because the tree is lazily loaded: this raises the request and
732
+ * switches the panel, and the explorer itself expands the ancestors (loading
733
+ * each level) and scrolls the row into view — it is the only place that knows
734
+ * which directories are currently expanded.
735
+ *
736
+ * @param dir - absolute remote DIRECTORY (a file's parent for a file hit).
737
+ */
738
+ export function revealInExplorer(dir: string): void {
739
+ revealSeq += 1
740
+ patch({ revealRequest: { id: revealSeq, path: dir } })
741
+ setView('explorer')
742
+ }
743
+
744
+ /** Monotonic ticket for "open a terminal here" requests. */
745
+ let terminalSeq = 0
746
+
747
+ /**
748
+ * Open a fresh terminal tab whose shell lands in `dir` — on the CURRENT
749
+ * workspace's host.
750
+ *
751
+ * The terminal panel consumes the request (it owns the tabs) and the shell gets
752
+ * a quoted `cd` once its PTY reports ready — the host's terminal route has no
753
+ * "start in this directory" parameter, so a typed command is the honest way.
754
+ *
755
+ * The alias travels IN the request. The panel otherwise opens new tabs on the
756
+ * host it last used, which put the shell on a different machine than the path
757
+ * belongs to and made the `cd` fail with "No such file or directory".
758
+ *
759
+ * @param dir - absolute remote directory.
760
+ */
761
+ export function openTerminalAt(dir: string): void {
762
+ terminalSeq += 1
763
+ patch({ terminalRequest: { id: terminalSeq, alias: currentWorkspace()?.alias ?? '', dir } })
764
+ setView('terminal')
765
+ }
766
+
625
767
  /**
626
768
  * Select the binding every view works against — the USER's choice.
627
769
  *
@@ -1153,7 +1295,9 @@ let revealRetry: ReturnType<typeof setTimeout> | undefined
1153
1295
  * is told where the file opened instead of being left guessing.
1154
1296
  */
1155
1297
  function revealEditor(): void {
1156
- if (state.editorSurface !== 'center') return
1298
+ // While the editor floats, the shell tab has no editor to reveal: the file
1299
+ // just went into the floating window (which is already on screen).
1300
+ if (effectiveEditorSurface(state.editorSurface, state.floatOpen) !== 'center') return
1157
1301
  const label = t('view.editor')
1158
1302
  // A newer open supersedes the pending retry: arming a second one left the
1159
1303
  // timers piling up one per opened file (each doing its own full-document tab
@@ -1176,6 +1320,63 @@ function patchTab(id: string, next: Partial<EditorTab>): void {
1176
1320
  patch({ tabs: state.tabs.map(tab => (tab.id === id ? { ...tab, ...next } : tab)) })
1177
1321
  }
1178
1322
 
1323
+ /**
1324
+ * Copy the live editor buffer onto its tab before the editor is remounted.
1325
+ *
1326
+ * Why this exists: `CodeEditor` reads its `initial` text ONCE, on mount
1327
+ * (`src/client/CodeEditor.tsx`), and the tab's stored text is only ever written
1328
+ * by a load/reload/handoff — typing lives exclusively in CodeMirror. Every tab
1329
+ * switch (a normal file click, another tab in the strip, a diff) and every
1330
+ * editor-surface flip therefore unmounts and remounts the editor, and without
1331
+ * this handoff the edits typed since the load would silently disappear (and a
1332
+ * later Ctrl+S would write the pre-edit text back).
1333
+ *
1334
+ * The text is written into the tab, which is exactly the input the replacement
1335
+ * mount reads: no extra channel exists or is needed. The DISK-side baseline is
1336
+ * deliberately left alone (`EditorTab.baseline`), so the remount still knows
1337
+ * the text is unsaved. The scroll + caret half of the handoff lives in
1338
+ * `live-editor.ts`.
1339
+ */
1340
+ function captureLiveBuffer(): void {
1341
+ // The position is cheap and is needed even for a CLEAN tab: a surface flip
1342
+ // remounts the very same tab, and the replacement's render happens before
1343
+ // the old view's unmount report.
1344
+ captureLiveEditorPosition()
1345
+ const active = state.tabs.find(item => item.id === state.activeTabId)
1346
+ // The whole document is only materialized when the tab may hold something the
1347
+ // store does not: the buffer is edited, or a previous handoff already copied
1348
+ // unsaved text into `text` (so undoing back to the disk bytes must still be
1349
+ // carried). A clean, already-synced tab skips the O(file) copy.
1350
+ const synced = active !== undefined
1351
+ && active.kind === 'file'
1352
+ && active.text === (active.baseline ?? active.text)
1353
+ if (synced && !isLiveEditorDirty()) return
1354
+ const captured = captureLiveEditorText()
1355
+ if (captured === undefined) return
1356
+ const tab = state.tabs.find(item => item.id === captured.tabId)
1357
+ // Diffs and previews have no CodeMirror buffer; and an unchanged buffer
1358
+ // would just hand the same string back, so neither case touches the store.
1359
+ if (tab === undefined || tab.kind !== 'file' || tab.text === captured.text) return
1360
+ patchTab(captured.tabId, { text: captured.text })
1361
+ }
1362
+
1363
+ /**
1364
+ * Flip the editor into the floating surface, capturing the live buffer first.
1365
+ *
1366
+ * A no-op while already floating (the editor is not remounted then), and a
1367
+ * toast-only refusal on a host that cannot host the window — the caller still
1368
+ * opens the file, just on the normal surface.
1369
+ */
1370
+ function enterFloatSurface(): void {
1371
+ if (!state.floatAvailable) {
1372
+ toast(t('float.unavailable'), true)
1373
+ return
1374
+ }
1375
+ if (state.floatOpen) return
1376
+ captureLiveBuffer()
1377
+ patch({ floatOpen: true })
1378
+ }
1379
+
1179
1380
  /**
1180
1381
  * Deadline for one git round trip.
1181
1382
  *
@@ -1217,6 +1418,7 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
1217
1418
 
1218
1419
  /** Open (or focus) a remote file in the editor tab strip. */
1219
1420
  export function openFile(fullPath: string, options: { line?: number } = {}): void {
1421
+ captureLiveBuffer()
1220
1422
  const workspace = currentWorkspace()
1221
1423
  if (workspace === undefined || fullPath === '') return
1222
1424
  const id = `file:${workspace.alias}:${fullPath}`
@@ -1266,6 +1468,8 @@ export function openFile(fullPath: string, options: { line?: number } = {}): voi
1266
1468
  binary: read.binary,
1267
1469
  truncated: read.truncated,
1268
1470
  text: read.binary ? '' : (read.text ?? ''),
1471
+ // The disk side of the dirty comparison (see EditorTab.baseline).
1472
+ ...(read.binary ? {} : { baseline: read.text ?? '' }),
1269
1473
  })
1270
1474
  } catch (error) {
1271
1475
  patchTab(id, { loading: false, error: error instanceof Error ? error.message : String(error) })
@@ -1309,13 +1513,22 @@ export function openRemoteFileInBinding(
1309
1513
  return true
1310
1514
  }
1311
1515
 
1312
- /** Whether the editor tab is mounted in the centre conversation view. */
1313
- export function isEditorCentered(): boolean {
1314
- return state.editorSurface === 'center'
1516
+ /**
1517
+ * Whether the plugin's own rich editor is the place a remote file click should
1518
+ * land (the click interceptor's gate, see `remote-file-open.ts`).
1519
+ *
1520
+ * The centre conversation view is the normal case; while the editor floats it
1521
+ * is the floating window instead — the file still belongs in OUR editor, not in
1522
+ * the host's native preview. On the sidebar-fallback layout (`sidebar`) the
1523
+ * interceptor stays off, exactly as before this feature.
1524
+ */
1525
+ export function isEditorTakeoverActive(): boolean {
1526
+ return effectiveEditorSurface(state.editorSurface, state.floatOpen) !== 'sidebar'
1315
1527
  }
1316
1528
 
1317
1529
  /** Open (or focus) a working-tree diff of one repository path. */
1318
1530
  export function openDiff(rel: string, staged: boolean, untracked = false): void {
1531
+ captureLiveBuffer()
1319
1532
  const workspace = currentWorkspace()
1320
1533
  if (workspace === undefined || state.repoRoot === undefined || rel === '') {
1321
1534
  toast(t('git.notARepo'), true)
@@ -1362,6 +1575,7 @@ export function openDiff(rel: string, staged: boolean, untracked = false): void
1362
1575
 
1363
1576
  /** Open (or focus) one file of one commit (`ref^` against `ref`). */
1364
1577
  export function openCommitFile(hash: string, rel: string): void {
1578
+ captureLiveBuffer()
1365
1579
  const workspace = currentWorkspace()
1366
1580
  const root = state.repoRoot
1367
1581
  if (workspace === undefined || root === undefined || rel === '') return
@@ -1405,6 +1619,12 @@ export function openCommitFile(hash: string, rel: string): void {
1405
1619
  }
1406
1620
 
1407
1621
  export function activateTab(id: string): void {
1622
+ // Switching away from a file tab remounts the editor, so the outgoing live
1623
+ // buffer has to be captured first — otherwise every tab click silently
1624
+ // discards edits typed since that tab was loaded (the tab's `text` is only
1625
+ // the last load/handoff copy). Same protection for the pre-existing paths
1626
+ // that switch tabs below (`openFile`/`openDiff`/`openCommitFile`).
1627
+ captureLiveBuffer()
1408
1628
  patch({ activeTabId: id })
1409
1629
  }
1410
1630
 
@@ -1421,30 +1641,57 @@ export function updateTab(id: string, next: Partial<EditorTab>): void {
1421
1641
  patchTab(id, next)
1422
1642
  }
1423
1643
 
1424
- /** Write one tab back to the remote host. */
1425
- export async function saveTab(id: string, content: string): Promise<void> {
1644
+ /** Write one tab back to the remote host. @returns true when the write landed. */
1645
+ export async function saveTab(id: string, content: string): Promise<boolean> {
1426
1646
  const workspace = currentWorkspace()
1427
1647
  const tab = state.tabs.find(item => item.id === id)
1428
- if (workspace === undefined || tab === undefined || tab.kind !== 'file' || tab.binary || tab.truncated) return
1648
+ if (workspace === undefined || tab === undefined || tab.kind !== 'file' || tab.binary || tab.truncated) return false
1429
1649
  // Never fall back to the tab's loaded text. `tab.text` is only ever written
1430
- // by a load/reload, never by typing, so substituting it here would push the
1431
- // PRE-EDIT bytes to the remote file and report success — destroying the
1432
- // user's edit with a confirmation. A caller without a live buffer must
1433
- // refuse to save instead (see EditorPane.saveActive).
1650
+ // by a load/reload or by the surface handoff (`captureLiveBuffer`), never as
1651
+ // a fallback for a caller without a live buffer, so substituting it here
1652
+ // would push the PRE-EDIT (or the lost-buffer) bytes to the remote file and
1653
+ // report success — destroying the user's edit with a confirmation. A caller
1654
+ // without a live buffer must refuse to save instead (see EditorPane.saveActive).
1434
1655
  if (typeof content !== 'string') {
1435
1656
  toast(t('editor.needSource'), true)
1436
- return
1657
+ return false
1437
1658
  }
1438
1659
  const text = content
1439
1660
  patchTab(id, { saving: true, text })
1440
1661
  try {
1441
1662
  await api.fileOp.write(workspace.alias, tab.fullPath, text)
1442
- patchTab(id, { saving: false, dirty: false })
1663
+ /**
1664
+ * The remote file now holds `text`, so that becomes the disk-side dirty
1665
+ * baseline (a later remount compares against it).
1666
+ *
1667
+ * The dirty FLAG is deliberately NOT cleared while the tab may still hold
1668
+ * something newer than what was written. Two ways that happens, and both
1669
+ * must keep the flag:
1670
+ *
1671
+ * - a live editor is mounted for this tab: the user may have typed while
1672
+ * the write was in flight, and only the editor can tell (it re-derives
1673
+ * through `markLiveEditorSaved` below);
1674
+ * - the tab was switched away from mid-write: its stored `text` is then the
1675
+ * handoff copy of that newer buffer, so comparing it with what was
1676
+ * written detects the difference without an editor.
1677
+ *
1678
+ * Only a tab whose stored text IS the written text, with no mounted editor,
1679
+ * has nothing newer — there the flag is cleared directly. (Clearing it
1680
+ * otherwise is how a tab read "saved" while its buffer was not: dot off,
1681
+ * Save disabled, close guard silent.)
1682
+ */
1683
+ const liveEditor = isLiveEditorFor(id)
1684
+ const current = state.tabs.find(item => item.id === id)
1685
+ const newer = current !== undefined && current.text !== text
1686
+ patchTab(id, { saving: false, baseline: text, ...(liveEditor || newer ? {} : { dirty: false }) })
1687
+ markLiveEditorSaved(id, text)
1443
1688
  toast(`${t('editor.saved')} · ${tab.rel}`, false)
1444
1689
  await afterWrite()
1690
+ return true
1445
1691
  } catch (error) {
1446
1692
  patchTab(id, { saving: false })
1447
1693
  toast(error instanceof Error ? error.message : String(error), true)
1694
+ return false
1448
1695
  }
1449
1696
  }
1450
1697
 
@@ -1480,6 +1727,7 @@ export async function reloadTab(id: string): Promise<void> {
1480
1727
  binary: read.binary,
1481
1728
  truncated: read.truncated,
1482
1729
  dirty: false,
1730
+ ...(read.binary ? {} : { baseline: read.text ?? '' }),
1483
1731
  })
1484
1732
  // Only now: the new text must already be in the tab when the editor remounts.
1485
1733
  patchTab(id, { reloadNonce: (tab.reloadNonce ?? 0) + 1 })
@@ -1512,6 +1760,109 @@ export function setEditorSurface(surface: 'center' | 'sidebar'): void {
1512
1760
  patch({ editorSurface: surface })
1513
1761
  }
1514
1762
 
1763
+ /**
1764
+ * Record that the host's frame-wide overlay slot actually rendered the
1765
+ * floating window host (see `FloatingEditorWindow`).
1766
+ *
1767
+ * This is a runtime handshake, not a configuration flag: on a host whose
1768
+ * `shell.overlay` slot is missing, the entry never mounts and the menu entries
1769
+ * stay disabled instead of opening a surface that cannot paint.
1770
+ */
1771
+ export function setFloatEditorAvailable(available: boolean): void {
1772
+ if (available) {
1773
+ if (state.floatAvailable) return
1774
+ patch({ floatAvailable: true })
1775
+ return
1776
+ }
1777
+ // The host went away while the editor floated (plugin unmount / host swap).
1778
+ // Keeping `floatOpen` would leave the main surfaces showing the "docked
1779
+ // elsewhere" placeholder with no window to dock from, so the flag has to go.
1780
+ //
1781
+ // The capture below is BEST EFFORT and usually cannot carry the text: React
1782
+ // detaches a child's ref before the parent's passive cleanup runs, so the
1783
+ // editor handle is already gone by the time the overlay entry unmounts. The
1784
+ // tab therefore keeps whatever the last switch captured — this call must not
1785
+ // be read as a guarantee.
1786
+ if (!state.floatAvailable && !state.floatOpen) return
1787
+ captureLiveBuffer()
1788
+ patch({ floatAvailable: false, floatOpen: false })
1789
+ }
1790
+
1791
+ /**
1792
+ * Float the editor in its current state, without opening anything.
1793
+ *
1794
+ * The entry is a double-click on the shell's 「远程编辑器」 tab (see
1795
+ * `float-tab-trigger.ts`): the user is already looking at the editor and wants
1796
+ * it out of the layout — no file, no diff, same tabs and buffers.
1797
+ *
1798
+ * Idempotent: while the editor already floats this does nothing (the window is
1799
+ * on screen; a second trigger must not restart it).
1800
+ */
1801
+ export function openFloatingEditor(): void {
1802
+ if (state.floatOpen) return
1803
+ captureLiveBuffer()
1804
+ enterFloatSurface()
1805
+ // A fresh open must not come back as a 34px bar: the ball is the minimized
1806
+ // state, and the store carries no knowledge of the window's local collapse
1807
+ // flag, so the window resets it when `floatOpen` flips (see its `wasOpenRef`).
1808
+ }
1809
+
1810
+ /**
1811
+ * Open one file in the floating editor (file-tree / search / SCM menu action).
1812
+ *
1813
+ * Order matters twice:
1814
+ * 1. the live buffer is captured BEFORE the surface flips (the flip unmounts
1815
+ * the editor that owns it);
1816
+ * 2. a second capture runs even when already floating, because opening
1817
+ * another file is a tab switch — the very action that remounts the editor
1818
+ * and would otherwise drop the edits typed in the tab being left.
1819
+ * A host whose overlay slot is unavailable gets the file in the normal surface
1820
+ * instead (with the reason toasted), so the click is never a dead end.
1821
+ */
1822
+ export function openInFloatEditor(fullPath: string, options: { line?: number } = {}): void {
1823
+ // Mirror `openFile`'s precondition: with no binding or no path there is no tab
1824
+ // to show, and floating an empty editor is worse than not floating at all.
1825
+ if (currentWorkspace() === undefined || fullPath === '') {
1826
+ openFile(fullPath, options)
1827
+ return
1828
+ }
1829
+ captureLiveBuffer()
1830
+ enterFloatSurface()
1831
+ openFile(fullPath, options)
1832
+ }
1833
+
1834
+ /**
1835
+ * The floating variant of `openDiff` — the SCM change list's entry.
1836
+ *
1837
+ * Same handoff order as `openInFloatEditor`; a diff tab has no text buffer of
1838
+ * its own, but the file editor that WAS on screen still does.
1839
+ */
1840
+ export function openDiffInFloatEditor(rel: string, staged: boolean, untracked = false): void {
1841
+ // `openDiff` refuses (with its own toast) without a repository or a path;
1842
+ // floating the editor onto a tab that will never open is worse than not
1843
+ // floating at all.
1844
+ if (currentWorkspace() === undefined || state.repoRoot === undefined || rel === '') {
1845
+ openDiff(rel, staged, untracked)
1846
+ return
1847
+ }
1848
+ captureLiveBuffer()
1849
+ enterFloatSurface()
1850
+ openDiff(rel, staged, untracked)
1851
+ }
1852
+
1853
+ /**
1854
+ * Dock the editor back onto its base surface (centre conversation view or the
1855
+ * sidebar fallback), carrying the live buffer with it.
1856
+ */
1857
+ export function dockFloatingEditor(): void {
1858
+ if (!state.floatOpen) return
1859
+ captureLiveBuffer()
1860
+ patch({ floatOpen: false })
1861
+ // The shell renders only the selected conversation view; a docked editor
1862
+ // behind another tab would look like the window simply vanished.
1863
+ revealEditor()
1864
+ }
1865
+
1515
1866
  /**
1516
1867
  * 换一份「本会话改了什么」的标记。
1517
1868
  *
@@ -29,7 +29,12 @@ function chrome(p: Palette, dark: boolean): Extension {
29
29
  '&': {
30
30
  backgroundColor: p.bg,
31
31
  color: p.fg,
32
- fontSize: '13px',
32
+ // The floating window changes ONLY its own editor's type size (Ctrl+wheel,
33
+ // see FloatingEditorWindow); the variable is unset everywhere else, so the
34
+ // normal editor keeps 13px. A plain literal here would freeze the size and
35
+ // make the zoom silently do nothing. The zoom is a property of the window,
36
+ // so a docked editor always renders at the default size.
37
+ fontSize: 'var(--rvs-editor-font-size, 13px)',
33
38
  height: '100%',
34
39
  },
35
40
  '.cm-content': {