dsh-remote-vs 0.8.9 → 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.
@@ -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,6 +139,14 @@ 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. */
@@ -323,6 +343,8 @@ const state: State = {
323
343
  activeTabId: undefined,
324
344
  revision: 0,
325
345
  editorSurface: 'sidebar',
346
+ floatOpen: false,
347
+ floatAvailable: false,
326
348
  view: readStoredView(),
327
349
  searchRequest: undefined,
328
350
  revealRequest: undefined,
@@ -366,7 +388,10 @@ export interface ExplorerValue {
366
388
  activeTab: EditorTab | undefined
367
389
  activeTabId: string | undefined
368
390
  revision: number
369
- editorSurface: 'center' | 'sidebar'
391
+ /** Effective editor mount: the floating window wins over the base surface. */
392
+ editorSurface: EditorSurface
393
+ floatOpen: boolean
394
+ floatAvailable: boolean
370
395
  view: RailView
371
396
  searchRequest: SearchRequest | undefined
372
397
  /** Pending "show this folder in the tree" request (consumed by the explorer). */
@@ -415,6 +440,19 @@ export interface ExplorerValue {
415
440
  /** Expand / collapse one untracked directory row (one path-limited status). */
416
441
  toggleUntrackedDir: (dirPath: string) => Promise<void>
417
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
418
456
  openDiff: (rel: string, staged: boolean, untracked?: boolean) => void
419
457
  openCommitFile: (hash: string, rel: string) => void
420
458
  activateTab: (id: string) => void
@@ -431,7 +469,12 @@ export interface ExplorerValue {
431
469
  * `changeSet.marks`,`hunk` 是 `patches` 的下标)。
432
470
  */
433
471
  undoChange: (tabId: string, mark: ChangeMark) => Promise<void>
434
- 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>
435
478
  reloadTab: (id: string) => Promise<void>
436
479
  info: (text: string) => void
437
480
  fail: (text: string) => void
@@ -507,7 +550,9 @@ function build(): ExplorerValue {
507
550
  activeTab: state.tabs.find(item => item.id === state.activeTabId),
508
551
  activeTabId: state.activeTabId,
509
552
  revision: state.revision,
510
- editorSurface: state.editorSurface,
553
+ editorSurface: effectiveEditorSurface(state.editorSurface, state.floatOpen),
554
+ floatOpen: state.floatOpen,
555
+ floatAvailable: state.floatAvailable,
511
556
  view: state.view,
512
557
  searchRequest: state.searchRequest,
513
558
  revealRequest: state.revealRequest,
@@ -526,6 +571,10 @@ function build(): ExplorerValue {
526
571
  untrackedDirs: state.untrackedDirs,
527
572
  toggleUntrackedDir,
528
573
  openFile,
574
+ openInFloatEditor,
575
+ openDiffInFloatEditor,
576
+ openFloatingEditor,
577
+ dockFloatingEditor,
529
578
  openDiff,
530
579
  openCommitFile,
531
580
  activateTab,
@@ -1246,7 +1295,9 @@ let revealRetry: ReturnType<typeof setTimeout> | undefined
1246
1295
  * is told where the file opened instead of being left guessing.
1247
1296
  */
1248
1297
  function revealEditor(): void {
1249
- 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
1250
1301
  const label = t('view.editor')
1251
1302
  // A newer open supersedes the pending retry: arming a second one left the
1252
1303
  // timers piling up one per opened file (each doing its own full-document tab
@@ -1269,6 +1320,63 @@ function patchTab(id: string, next: Partial<EditorTab>): void {
1269
1320
  patch({ tabs: state.tabs.map(tab => (tab.id === id ? { ...tab, ...next } : tab)) })
1270
1321
  }
1271
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
+
1272
1380
  /**
1273
1381
  * Deadline for one git round trip.
1274
1382
  *
@@ -1310,6 +1418,7 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
1310
1418
 
1311
1419
  /** Open (or focus) a remote file in the editor tab strip. */
1312
1420
  export function openFile(fullPath: string, options: { line?: number } = {}): void {
1421
+ captureLiveBuffer()
1313
1422
  const workspace = currentWorkspace()
1314
1423
  if (workspace === undefined || fullPath === '') return
1315
1424
  const id = `file:${workspace.alias}:${fullPath}`
@@ -1359,6 +1468,8 @@ export function openFile(fullPath: string, options: { line?: number } = {}): voi
1359
1468
  binary: read.binary,
1360
1469
  truncated: read.truncated,
1361
1470
  text: read.binary ? '' : (read.text ?? ''),
1471
+ // The disk side of the dirty comparison (see EditorTab.baseline).
1472
+ ...(read.binary ? {} : { baseline: read.text ?? '' }),
1362
1473
  })
1363
1474
  } catch (error) {
1364
1475
  patchTab(id, { loading: false, error: error instanceof Error ? error.message : String(error) })
@@ -1402,13 +1513,22 @@ export function openRemoteFileInBinding(
1402
1513
  return true
1403
1514
  }
1404
1515
 
1405
- /** Whether the editor tab is mounted in the centre conversation view. */
1406
- export function isEditorCentered(): boolean {
1407
- 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'
1408
1527
  }
1409
1528
 
1410
1529
  /** Open (or focus) a working-tree diff of one repository path. */
1411
1530
  export function openDiff(rel: string, staged: boolean, untracked = false): void {
1531
+ captureLiveBuffer()
1412
1532
  const workspace = currentWorkspace()
1413
1533
  if (workspace === undefined || state.repoRoot === undefined || rel === '') {
1414
1534
  toast(t('git.notARepo'), true)
@@ -1455,6 +1575,7 @@ export function openDiff(rel: string, staged: boolean, untracked = false): void
1455
1575
 
1456
1576
  /** Open (or focus) one file of one commit (`ref^` against `ref`). */
1457
1577
  export function openCommitFile(hash: string, rel: string): void {
1578
+ captureLiveBuffer()
1458
1579
  const workspace = currentWorkspace()
1459
1580
  const root = state.repoRoot
1460
1581
  if (workspace === undefined || root === undefined || rel === '') return
@@ -1498,6 +1619,12 @@ export function openCommitFile(hash: string, rel: string): void {
1498
1619
  }
1499
1620
 
1500
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()
1501
1628
  patch({ activeTabId: id })
1502
1629
  }
1503
1630
 
@@ -1514,30 +1641,57 @@ export function updateTab(id: string, next: Partial<EditorTab>): void {
1514
1641
  patchTab(id, next)
1515
1642
  }
1516
1643
 
1517
- /** Write one tab back to the remote host. */
1518
- 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> {
1519
1646
  const workspace = currentWorkspace()
1520
1647
  const tab = state.tabs.find(item => item.id === id)
1521
- 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
1522
1649
  // Never fall back to the tab's loaded text. `tab.text` is only ever written
1523
- // by a load/reload, never by typing, so substituting it here would push the
1524
- // PRE-EDIT bytes to the remote file and report success — destroying the
1525
- // user's edit with a confirmation. A caller without a live buffer must
1526
- // 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).
1527
1655
  if (typeof content !== 'string') {
1528
1656
  toast(t('editor.needSource'), true)
1529
- return
1657
+ return false
1530
1658
  }
1531
1659
  const text = content
1532
1660
  patchTab(id, { saving: true, text })
1533
1661
  try {
1534
1662
  await api.fileOp.write(workspace.alias, tab.fullPath, text)
1535
- 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)
1536
1688
  toast(`${t('editor.saved')} · ${tab.rel}`, false)
1537
1689
  await afterWrite()
1690
+ return true
1538
1691
  } catch (error) {
1539
1692
  patchTab(id, { saving: false })
1540
1693
  toast(error instanceof Error ? error.message : String(error), true)
1694
+ return false
1541
1695
  }
1542
1696
  }
1543
1697
 
@@ -1573,6 +1727,7 @@ export async function reloadTab(id: string): Promise<void> {
1573
1727
  binary: read.binary,
1574
1728
  truncated: read.truncated,
1575
1729
  dirty: false,
1730
+ ...(read.binary ? {} : { baseline: read.text ?? '' }),
1576
1731
  })
1577
1732
  // Only now: the new text must already be in the tab when the editor remounts.
1578
1733
  patchTab(id, { reloadNonce: (tab.reloadNonce ?? 0) + 1 })
@@ -1605,6 +1760,109 @@ export function setEditorSurface(surface: 'center' | 'sidebar'): void {
1605
1760
  patch({ editorSurface: surface })
1606
1761
  }
1607
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
+
1608
1866
  /**
1609
1867
  * 换一份「本会话改了什么」的标记。
1610
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': {
@@ -2065,6 +2065,241 @@ body:not([data-ds-dark-theme]) .rvs-undo-affordance:focus-visible {
2065
2065
  font-size: 11px;
2066
2066
  }
2067
2067
 
2068
+ /* ---------------------------------------------------- floating editor window */
2069
+
2070
+ /**
2071
+ * The floating editor lives in the host's frame-wide overlay layer
2072
+ * (`position: absolute; inset: 0; z-index: 20`, click-through, children opt
2073
+ * back into pointer events). It is placed with viewport coordinates, so it
2074
+ * floats above every column without joining any of their stacking contexts.
2075
+ *
2076
+ * `.rvs-root` carries the whole design-system token set and is row-flex by
2077
+ * default; this frame overrides the direction to a column and takes its size
2078
+ * from the inline rect the component keeps.
2079
+ */
2080
+ .rvs-float-window {
2081
+ position: fixed;
2082
+ flex-direction: column;
2083
+ /* The rect maths (clamp/persist) is in border-box terms; without this the
2084
+ painted border would sit one pixel outside the computed rectangle. */
2085
+ box-sizing: border-box;
2086
+ min-width: 360px;
2087
+ min-height: 220px;
2088
+ overflow: hidden;
2089
+ border: 1px solid var(--rvs-border2);
2090
+ border-radius: 10px;
2091
+ background: var(--rvs-code-bg);
2092
+ color: var(--rvs-code-fg);
2093
+ box-shadow: 0 18px 60px rgba(0, 0, 0, .45), 0 2px 8px rgba(0, 0, 0, .28);
2094
+ z-index: 30;
2095
+ /* The overlay layer itself is click-through; the window opts back in even if
2096
+ the host wraps each overlay entry in its own element. */
2097
+ pointer-events: auto;
2098
+ }
2099
+
2100
+ /* Minimized: the frame IS the ball (see `.rvs-float-mini`); the body stays
2101
+ mounted but hidden, and it must not impose a minimum height. */
2102
+ .rvs-float-window[data-rvs-float="collapsed"] {
2103
+ min-height: 0;
2104
+ }
2105
+
2106
+ /**
2107
+ * Minimized window = floating ball.
2108
+ *
2109
+ * A full-width 34px bar was too long: it read as a broken window and covered a
2110
+ * whole strip. The ball is a small circle at the window's own corner, draggable
2111
+ * anywhere, restored by a click (or Enter/Space). It keeps the dirty dot, so
2112
+ * "the editor is out and has unsaved edits" is still one glance away.
2113
+ */
2114
+ .rvs-float-window.rvs-float-mini {
2115
+ min-width: 0;
2116
+ min-height: 0;
2117
+ display: flex;
2118
+ align-items: center;
2119
+ justify-content: center;
2120
+ border-radius: 50%;
2121
+ cursor: move;
2122
+ touch-action: none;
2123
+ }
2124
+
2125
+ .rvs-float-ball {
2126
+ flex: 1;
2127
+ /* Stretch to the circle: the frame is a COLUMN flex box with centred items,
2128
+ so without this the hit area (and the absolutely positioned dot) would be
2129
+ sized by the 16px glyph instead of the 44px ball. */
2130
+ width: 100%;
2131
+ height: 100%;
2132
+ box-sizing: border-box;
2133
+ display: flex;
2134
+ align-items: center;
2135
+ justify-content: center;
2136
+ position: relative;
2137
+ border-radius: 50%;
2138
+ color: var(--rvs-brand);
2139
+ font-size: 16px;
2140
+ line-height: 1;
2141
+ cursor: move;
2142
+ outline: none;
2143
+ }
2144
+
2145
+ .rvs-float-ball:hover,
2146
+ .rvs-float-ball:focus-visible {
2147
+ background: var(--rvs-hover);
2148
+ }
2149
+
2150
+ .rvs-float-ball-glyph {
2151
+ pointer-events: none;
2152
+ }
2153
+
2154
+ /* Unsaved edits stay visible while minimized. */
2155
+ .rvs-float-ball-dot {
2156
+ position: absolute;
2157
+ top: 8px;
2158
+ right: 8px;
2159
+ width: 8px;
2160
+ height: 8px;
2161
+ border-radius: 50%;
2162
+ background: var(--rvs-git-m);
2163
+ box-shadow: 0 0 0 2px var(--rvs-code-bg);
2164
+ pointer-events: none;
2165
+ }
2166
+
2167
+ /* While a drag/resize gesture runs the pointer must not select editor text. */
2168
+ .rvs-float-window.dragging {
2169
+ user-select: none;
2170
+ }
2171
+
2172
+ .rvs-float-titlebar {
2173
+ flex: none;
2174
+ display: flex;
2175
+ align-items: center;
2176
+ gap: 7px;
2177
+ height: 34px;
2178
+ padding: 0 6px 0 10px;
2179
+ border-bottom: 1px solid var(--rvs-border);
2180
+ background: var(--rvs-layer3);
2181
+ cursor: move;
2182
+ user-select: none;
2183
+ touch-action: none;
2184
+ }
2185
+
2186
+ .rvs-float-glyph {
2187
+ color: var(--rvs-brand);
2188
+ font-size: 11px;
2189
+ }
2190
+
2191
+ .rvs-float-title {
2192
+ flex: none;
2193
+ font-size: 12px;
2194
+ font-weight: 600;
2195
+ color: var(--rvs-fg2);
2196
+ }
2197
+
2198
+ .rvs-float-file {
2199
+ min-width: 0;
2200
+ overflow: hidden;
2201
+ text-overflow: ellipsis;
2202
+ white-space: nowrap;
2203
+ font-size: 11.5px;
2204
+ color: var(--rvs-fg3);
2205
+ direction: rtl;
2206
+ unicode-bidi: plaintext;
2207
+ text-align: left;
2208
+ }
2209
+
2210
+ .rvs-float-spacer {
2211
+ flex: 1;
2212
+ }
2213
+
2214
+ .rvs-float-btn {
2215
+ flex: none;
2216
+ width: 22px;
2217
+ height: 22px;
2218
+ display: flex;
2219
+ align-items: center;
2220
+ justify-content: center;
2221
+ padding: 0;
2222
+ border: none;
2223
+ border-radius: 5px;
2224
+ background: transparent;
2225
+ color: var(--rvs-fg3);
2226
+ font: inherit;
2227
+ font-size: 12px;
2228
+ line-height: 1;
2229
+ cursor: pointer;
2230
+ }
2231
+
2232
+ .rvs-float-btn:hover {
2233
+ background: var(--rvs-hover);
2234
+ color: var(--rvs-fg);
2235
+ }
2236
+
2237
+ .rvs-float-close:hover {
2238
+ background: rgba(241, 76, 76, .18);
2239
+ color: var(--rvs-danger);
2240
+ }
2241
+
2242
+ .rvs-float-body {
2243
+ flex: 1 1 0;
2244
+ min-height: 0;
2245
+ min-width: 0;
2246
+ display: flex;
2247
+ overflow: hidden;
2248
+ background: var(--rvs-code-bg);
2249
+ }
2250
+
2251
+ /* Keep the height chain pinned: the editor pane grows inside this box. */
2252
+ .rvs-float-body > .rvs-pane {
2253
+ flex: 1 1 0;
2254
+ min-height: 0;
2255
+ min-width: 0;
2256
+ }
2257
+
2258
+ /* Eight grips, placed INSIDE the border box: the window clips overflow (rounded
2259
+ corners), so grips hanging outside would lose half their hit area. Corners
2260
+ are larger so they can actually be hit. */
2261
+ .rvs-float-resize {
2262
+ position: absolute;
2263
+ z-index: 3;
2264
+ }
2265
+
2266
+ .rvs-float-resize-n { top: 0; left: 10px; right: 10px; height: 6px; cursor: ns-resize; }
2267
+ .rvs-float-resize-s { bottom: 0; left: 10px; right: 10px; height: 6px; cursor: ns-resize; }
2268
+ .rvs-float-resize-e { top: 10px; bottom: 10px; right: 0; width: 6px; cursor: ew-resize; }
2269
+ .rvs-float-resize-w { top: 10px; bottom: 10px; left: 0; width: 6px; cursor: ew-resize; }
2270
+ .rvs-float-resize-ne { top: 0; right: 0; width: 12px; height: 12px; cursor: nesw-resize; }
2271
+ .rvs-float-resize-nw { top: 0; left: 0; width: 12px; height: 12px; cursor: nwse-resize; }
2272
+ .rvs-float-resize-se { bottom: 0; right: 0; width: 12px; height: 12px; cursor: nwse-resize; }
2273
+ .rvs-float-resize-sw { bottom: 0; left: 0; width: 12px; height: 12px; cursor: nesw-resize; }
2274
+
2275
+ /* Shown by whichever surface the single editor left behind (centre tab or the
2276
+ sidebar's editor panel). */
2277
+ .rvs-float-placeholder {
2278
+ flex: 1;
2279
+ min-height: 0;
2280
+ height: 100%;
2281
+ display: flex;
2282
+ flex-direction: column;
2283
+ align-items: center;
2284
+ justify-content: center;
2285
+ gap: 10px;
2286
+ padding: 24px;
2287
+ text-align: center;
2288
+ color: var(--rvs-fg3);
2289
+ font-size: 12.5px;
2290
+ }
2291
+
2292
+ .rvs-float-placeholder-title {
2293
+ color: var(--rvs-fg2);
2294
+ font-size: 13px;
2295
+ font-weight: 600;
2296
+ }
2297
+
2298
+ .rvs-float-placeholder-text {
2299
+ max-width: 360px;
2300
+ line-height: 1.6;
2301
+ }
2302
+
2068
2303
  /* --------------------------------------------------------- context menu */
2069
2304
 
2070
2305
  .rvs-portal {