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.
- package/README.en.md +15 -2
- package/README.md +12 -1
- package/lib/client.js +1507 -59
- 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/TerminalView.d.ts +3 -0
- package/lib/types/client/XTerm.d.ts +6 -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/remote-file-open-target.d.ts +0 -22
- package/lib/types/client/shell.d.ts +29 -0
- package/lib/types/client/store.d.ts +160 -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 +61 -0
- package/src/client/RemoteRoot.tsx +16 -7
- package/src/client/SearchView.tsx +49 -2
- package/src/client/TerminalView.tsx +53 -2
- package/src/client/XTerm.tsx +15 -0
- package/src/client/float-editor.ts +271 -0
- package/src/client/float-tab-trigger.ts +51 -0
- package/src/client/i18n.ts +32 -0
- package/src/client/index.tsx +48 -1
- package/src/client/live-editor.ts +155 -0
- package/src/client/remote-file-open-target.ts +1 -5
- package/src/client/remote-file-open.ts +2 -2
- package/src/client/scm/ScmPanel.tsx +42 -1
- package/src/client/shell.ts +35 -0
- package/src/client/store.tsx +367 -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1313
|
-
|
|
1314
|
-
|
|
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<
|
|
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
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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': {
|