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.
- package/README.en.md +15 -2
- package/README.md +12 -1
- package/lib/client.js +1235 -53
- package/lib/types/client/CodeEditor.d.ts +27 -1
- package/lib/types/client/FloatingEditorWindow.d.ts +20 -0
- package/lib/types/client/FloatingPlaceholder.d.ts +9 -0
- package/lib/types/client/RemoteEditorView.d.ts +4 -0
- package/lib/types/client/float-editor.d.ts +123 -0
- package/lib/types/client/float-tab-trigger.d.ts +24 -0
- package/lib/types/client/live-editor.d.ts +94 -0
- package/lib/types/client/store.d.ts +95 -6
- package/package.json +1 -1
- package/src/client/CodeEditor.tsx +95 -8
- package/src/client/EditorPane.tsx +45 -24
- package/src/client/FloatingEditorWindow.tsx +494 -0
- package/src/client/FloatingPlaceholder.tsx +22 -0
- package/src/client/RemoteEditorView.tsx +7 -2
- package/src/client/RemoteExplorer.tsx +16 -0
- package/src/client/RemoteRoot.tsx +15 -6
- package/src/client/SearchView.tsx +10 -3
- package/src/client/float-editor.ts +271 -0
- package/src/client/float-tab-trigger.ts +51 -0
- package/src/client/i18n.ts +28 -0
- package/src/client/index.tsx +48 -1
- package/src/client/live-editor.ts +155 -0
- package/src/client/remote-file-open.ts +2 -2
- package/src/client/scm/ScmPanel.tsx +42 -1
- package/src/client/store.tsx +274 -16
- package/src/client/vsc/editor-theme.ts +6 -1
- package/src/client/vsc/vsc.css +235 -0
package/src/client/store.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1406
|
-
|
|
1407
|
-
|
|
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<
|
|
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
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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': {
|
package/src/client/vsc/vsc.css
CHANGED
|
@@ -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 {
|