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.
@@ -23,7 +23,18 @@ export interface CodeEditorViewState {
23
23
  export interface CodeEditorHandle {
24
24
  getContent: () => string;
25
25
  isDirty: () => boolean;
26
- markSaved: () => void;
26
+ /**
27
+ * Advance the dirty baseline to the bytes a save just wrote to the remote
28
+ * file, and re-derive the dirty flag from the LIVE buffer against them.
29
+ *
30
+ * Takes the saved text explicitly because the user may have typed while the
31
+ * remote write was in flight: the buffer is then NEWER than the file, and
32
+ * declaring it saved would be the same silent-loss bug in the other
33
+ * direction. `tabId` guards against the pane having switched to another tab
34
+ * while the save awaited — marking that editor saved would corrupt ITS
35
+ * baseline.
36
+ */
37
+ markSaved: (savedText: string, tabId: string) => void;
27
38
  /** Snapshot scroll + selection, for restoring after a remount. */
28
39
  getViewState: () => CodeEditorViewState | undefined;
29
40
  /** Scroll back and put the caret where it was. */
@@ -120,6 +131,21 @@ export declare const CodeEditor: import("react").ForwardRefExoticComponent<{
120
131
  * where a file was when its tab is re-selected.
121
132
  */
122
133
  viewState?: CodeEditorViewState;
134
+ /**
135
+ * The text as it was last LOADED from / SAVED to the remote file — the dirty
136
+ * baseline.
137
+ *
138
+ * Separate from `initial` because a tab switch or a surface flip remounts the
139
+ * editor with the LIVE buffer as `initial` (that is how unsaved edits survive
140
+ * the remount), while the file on disk is still the older `savedText`. Using
141
+ * `initial` as the baseline would declare the unsaved text clean: the dirty
142
+ * dot and status would lie, the close guard would stop prompting and Save
143
+ * would disable — a silent way to lose the very edits the handoff preserved.
144
+ *
145
+ * Absent (diff panes, older callers): the mount's own document is the
146
+ * baseline, which is the historical behaviour.
147
+ */
148
+ savedText?: string;
123
149
  onSave: () => void;
124
150
  onDirty: (dirty: boolean) => void;
125
151
  } & import("react").RefAttributes<CodeEditorHandle>>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The floating editor window: the plugin's ONE editor pane hosted in a
3
+ * draggable, resizable frame above the app shell.
4
+ *
5
+ * It is mounted by the client entry into the host's frame-wide overlay slot
6
+ * (`shell.overlay`) and reads the same module-level store as every other
7
+ * surface, so tabs / previews / diffs / find / change marks / undo / @file are
8
+ * not a re-implementation: this file only owns the FRAME (position, size,
9
+ * z-order, drag + resize + zoom) and the handoff around the mount swap.
10
+ *
11
+ * The mount is exclusive: `EditorPane` must exist exactly once, so the centre
12
+ * conversation view and the sidebar fallback render a placeholder while
13
+ * `editorSurface === 'float'` (see `FloatingEditorPlaceholder`).
14
+ */
15
+ import { type ReactNode } from 'react';
16
+ import type { Context } from '@deepseek-ai/cordis';
17
+ /** The window frame plus the editor. `ctx` is only needed for the boot hook. */
18
+ export declare function FloatingEditorWindow(props: {
19
+ ctx: Context;
20
+ }): ReactNode;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * What the centre conversation view and the sidebar fallback show while the
3
+ * editor is popped out: the editor itself is a single instance and lives in the
4
+ * floating window, so these surfaces must not mount a second one (that would
5
+ * be two live CodeMirror buffers for one tab — the exact defect `EditorPane`'s
6
+ * header warns about). A dock button keeps the surface from looking broken.
7
+ */
8
+ import type { ReactNode } from 'react';
9
+ export declare function FloatingEditorPlaceholder(): ReactNode;
@@ -6,6 +6,10 @@
6
6
  * sidebar then keeps the whole column for the explorer. Both surfaces read
7
7
  * the same module-level store, so a diff opened from source control appears
8
8
  * here, and a save here repaints the sidebar's change list.
9
+ *
10
+ * While the editor floats (`editorSurface === 'float'`) this tab is NOT the
11
+ * editor's mount any more, so it renders a placeholder instead: mounting a
12
+ * second `EditorPane` would give the same tab two live buffers.
9
13
  */
10
14
  import type { Context } from '@deepseek-ai/cordis';
11
15
  import type { ReactNode } from 'react';
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Pure geometry and surface rules for the floating editor.
3
+ *
4
+ * Kept free of React and of the DOM on purpose: the drag / resize / zoom maths
5
+ * is the part that is easy to get subtly wrong (negative sizes, a window that
6
+ * escapes the viewport, a stale stored rect from a larger screen), and this
7
+ * module is what `scripts/check-float-editor.mjs` drives directly. The window
8
+ * component is then only wiring.
9
+ *
10
+ * The SURFACE rule lives here too: the plugin has exactly one live editor, and
11
+ * which mount owns it is a three-way decision (centre conversation view,
12
+ * sidebar fallback, floating window). Encoding it as a pure function keeps the
13
+ * invariant testable and out of three separate `=== 'center'` comparisons.
14
+ */
15
+ /** Where the editor pane is mounted right now. */
16
+ export type EditorSurface = 'center' | 'sidebar' | 'float';
17
+ /** The persisted layout of the floating window, in viewport pixels. */
18
+ export interface FloatRect {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ }
24
+ /** Viewport size the geometry is clamped to. */
25
+ export interface FloatViewport {
26
+ width: number;
27
+ height: number;
28
+ }
29
+ /** A resize grip: the compass directions of the eight handles. */
30
+ export type FloatResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
31
+ /** Smallest window the editor can be squeezed into and stay usable. */
32
+ export declare const FLOAT_MIN_WIDTH = 360;
33
+ export declare const FLOAT_MIN_HEIGHT = 220;
34
+ /** Height of the title bar; a minimized window keeps exactly this much. */
35
+ export declare const FLOAT_TITLEBAR_HEIGHT = 34;
36
+ /** Gap a maximized window keeps from the viewport edges. */
37
+ export declare const FLOAT_VIEWPORT_EDGE = 8;
38
+ /** Editor font-size range for Ctrl+wheel zoom (px). */
39
+ export declare const FLOAT_FONT_MIN = 9;
40
+ export declare const FLOAT_FONT_MAX = 28;
41
+ export declare const FLOAT_FONT_DEFAULT = 13;
42
+ /** Diameter of the minimized ball, in pixels. */
43
+ export declare const FLOAT_BALL_SIZE = 44;
44
+ /**
45
+ * How far a pointer may travel and still count as a CLICK: the minimized window
46
+ * is a single 44px ball, so "click restores / drag moves" has to be decided
47
+ * from one pointer gesture.
48
+ */
49
+ export declare const FLOAT_BALL_CLICK_SLOP = 4;
50
+ /** A point in viewport coordinates (the minimized ball's origin). */
51
+ export interface FloatPoint {
52
+ x: number;
53
+ y: number;
54
+ }
55
+ /** The effective editor surface: floating wins, otherwise the base mount. */
56
+ export declare function effectiveEditorSurface(base: 'center' | 'sidebar', floatOpen: boolean): EditorSurface;
57
+ /**
58
+ * The rect a fresh floating window opens at: about two thirds of the frame,
59
+ * centred, with a hard size cap so a 4K monitor does not produce a 2600px
60
+ * editor for a 40-column file.
61
+ */
62
+ export declare function defaultFloatRect(viewport: FloatViewport): FloatRect;
63
+ /**
64
+ * Bring a rect back inside the viewport and to a legal size.
65
+ *
66
+ * Used on every drag step, on restore, and whenever the browser window is
67
+ * resized: a rect persisted on a 2560px screen must not open a window whose
68
+ * title bar is off-screen on a 1366px laptop.
69
+ */
70
+ export declare function clampFloatRect(rect: FloatRect, viewport: FloatViewport): FloatRect;
71
+ /** Move the window by a pointer delta, staying inside the viewport. */
72
+ export declare function dragFloatRect(start: FloatRect, dx: number, dy: number, viewport: FloatViewport): FloatRect;
73
+ /**
74
+ * Resize from one grip.
75
+ *
76
+ * Each axis is adjusted from the edge that MOVED (west/north resize changes the
77
+ * origin too), then clamped: the minimum size is anchored at the opposite edge
78
+ * (so dragging the north grip past the bottom stops instead of flipping the
79
+ * rect) and no edge may leave the viewport.
80
+ */
81
+ export declare function resizeFloatRect(start: FloatRect, handle: FloatResizeHandle, dx: number, dy: number, viewport: FloatViewport): FloatRect;
82
+ /** The rect a maximized window occupies: the frame minus a small margin. */
83
+ export declare function maximizeFloatRect(viewport: FloatViewport): FloatRect;
84
+ /**
85
+ * Parse a persisted rect.
86
+ *
87
+ * Anything malformed is discarded rather than repaired into a guess: the
88
+ * caller then opens the default rect. Values that are merely STALE (a rect from
89
+ * another screen) are legal input and get clamped.
90
+ */
91
+ export declare function parseFloatRect(raw: string | null | undefined, viewport: FloatViewport): FloatRect | undefined;
92
+ /** Serialize a rect for localStorage. */
93
+ export declare function serializeFloatRect(rect: FloatRect): string;
94
+ /**
95
+ * One Ctrl+wheel step: up (negative deltaY) grows the text, down shrinks it.
96
+ * Values are whole pixels so the stored size stays stable across sessions.
97
+ */
98
+ export declare function stepFloatZoom(current: number, deltaY: number): number;
99
+ /** Parse the persisted editor font size (fallback = the default 13px). */
100
+ export declare function parseFloatFontSize(raw: string | null | undefined): number;
101
+ /**
102
+ * Where the minimized ball may sit: fully on screen, whatever position was
103
+ * asked for (a ball restored on a smaller monitor must stay clickable).
104
+ */
105
+ export declare function clampBallPosition(pos: FloatPoint, viewport: FloatViewport): FloatPoint;
106
+ /**
107
+ * The ball's default spot when a window is minimized: its own top-left corner,
108
+ * so the ball appears where the window was instead of jumping to a corner.
109
+ */
110
+ export declare function ballFromRect(rect: FloatRect, viewport: FloatViewport): FloatPoint;
111
+ /**
112
+ * Whether a finished gesture was a CLICK rather than a drag.
113
+ *
114
+ * The minimized window is one small target with two actions on the same
115
+ * pointer: a press that never really moved restores the window, a real drag
116
+ * moves the ball. Without this distinction every move-by-3px would also
117
+ * restore, and every click would be a zero-distance drag.
118
+ */
119
+ export declare function isBallClick(dx: number, dy: number): boolean;
120
+ /** Parse a persisted ball position (stale geometry is clamped, garbage dropped). */
121
+ export declare function parseBallPosition(raw: string | null | undefined, viewport: FloatViewport): FloatPoint | undefined;
122
+ /** Serialize a ball position for localStorage. */
123
+ export declare function serializeBallPosition(pos: FloatPoint): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Double-click on the shell's 「远程编辑器」 tab floats the editor.
3
+ *
4
+ * That tab belongs to the host's conversation-view header, not to us: the plugin
5
+ * registers a VIEW into the shell's tab strip and never sees the tab row's DOM.
6
+ * There is no public service for "the user double-clicked my tab" either, so the
7
+ * only way to honour the gesture is a document-level `dblclick` listener that
8
+ * recognises the tab the same way `reveal.ts` does for clicks — by its label,
9
+ * skipping our own surfaces.
10
+ *
11
+ * Fail-safe by construction: a miss (another host build, a renamed tab, a
12
+ * double-click somewhere else) simply does nothing. It never prevents the
13
+ * default action, never throws, and does not steal double-clicks from the
14
+ * plugin's own UI (`.rvs-root`) or from dialogs.
15
+ */
16
+ /**
17
+ * Install the double-click trigger.
18
+ *
19
+ * @param label - the tab's visible label (the same localized string the view
20
+ * registered with); matched against the tab's trimmed text.
21
+ * @param onTrigger - called once per recognised double-click.
22
+ * @returns disposer removing the listener.
23
+ */
24
+ export declare function installFloatTabTrigger(label: string, onTrigger: () => void): () => void;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Handoff for the ONE live editor buffer when its mount is replaced.
3
+ *
4
+ * The plugin mounts exactly one `EditorPane` at a time (centre conversation
5
+ * view, sidebar fallback, or the floating window), and `CodeEditor` reads its
6
+ * `initial` text once on mount. So moving the editor between surfaces is
7
+ * effectively a remount: without a handoff the buffer typed since the last
8
+ * load is gone and the tab's stored text is the pre-edit one.
9
+ *
10
+ * This module is the handoff, and it is module-level for the same reason the
11
+ * store is: `EditorPane` is unmounted before the replacement mounts, so no
12
+ * React state survives the swap.
13
+ *
14
+ * Two things travel, and they are captured SEPARATELY because their costs
15
+ * differ by orders of magnitude:
16
+ * - the buffer text (the store copies it onto the tab before the surface
17
+ * flips, so the replacement mounts with it as `initial`) — materializes the
18
+ * whole document, so it is taken only when the tab may differ from disk;
19
+ * - scroll + caret (kept here and read back by the replacement, which is how
20
+ * a tab switch already restored its position — same storage, now shared by
21
+ * every surface instead of one component's `useRef`) — cheap, taken on
22
+ * every switch.
23
+ */
24
+ import type { CodeEditorHandle, CodeEditorViewState } from './CodeEditor.tsx';
25
+ /**
26
+ * Register the editor that is on screen right now.
27
+ *
28
+ * The caller passes a getter, not a handle: `editorRef.current` is `null`
29
+ * during the first render and is cleared before the parent's cleanup runs, so
30
+ * a snapshot taken later must read through the getter.
31
+ * @returns disposer; only the registration that is still current is cleared.
32
+ */
33
+ export declare function registerLiveEditor(tabId: string, getHandle: () => CodeEditorHandle | null): () => void;
34
+ /**
35
+ * Snapshot the live position (scroll + caret) without touching the document.
36
+ *
37
+ * Cheap, so the store can call it on EVERY switch: a surface flip remounts the
38
+ * very same tab, and the replacement's render happens before the old view's
39
+ * unmount report — the position has to be in the map before that render.
40
+ */
41
+ export declare function captureLiveEditorPosition(): void;
42
+ /**
43
+ * Whether the live buffer differs from its disk baseline.
44
+ *
45
+ * The store uses this to skip the O(document) text copy on clean tabs (see
46
+ * `captureLiveBuffer`): the boolean is cheap, `getContent()` materializes the
47
+ * whole file as a string.
48
+ */
49
+ export declare function isLiveEditorDirty(): boolean;
50
+ /**
51
+ * Copy the live buffer's text, for a switch that would otherwise lose it.
52
+ *
53
+ * Called from the store BEFORE the tab switch / surface flip: at that moment
54
+ * the editor is still mounted and its CodeMirror view still owns the edits.
55
+ * @returns undefined when no file editor is live (a diff tab, or a view that
56
+ * was never the selected one) — there is nothing to carry over then.
57
+ */
58
+ export declare function captureLiveEditorText(): {
59
+ tabId: string;
60
+ text: string;
61
+ } | undefined;
62
+ /**
63
+ * Whether one tab's file editor is the one registered right now.
64
+ *
65
+ * The save path asks this before it clears a tab's dirty flag: if an editor IS
66
+ * mounted for that tab, the flag belongs to the live buffer and only the
67
+ * editor may clear it (see `markLiveEditorSaved`).
68
+ */
69
+ export declare function isLiveEditorFor(tabId: string): boolean;
70
+ /**
71
+ * Advance the registered editor's dirty baseline to the bytes a save wrote.
72
+ *
73
+ * Routed through the MODULE-LEVEL registration rather than a React ref on
74
+ * purpose: the pane can be remounted while the remote write is in flight (a
75
+ * surface flip, a dock, a tab switch), and the ref the save started with is
76
+ * then detached — that is exactly how a tab ended up reported clean while its
77
+ * live buffer was newer than the file. The registration always names the editor
78
+ * mounted NOW, so the correction lands on whichever instance is on screen, and
79
+ * it is skipped (never misapplied) when that editor shows another tab: that tab
80
+ * heals on its next mount from `savedText` (see `CodeEditor`).
81
+ * @returns true when an editor for this tab took the new baseline.
82
+ */
83
+ export declare function markLiveEditorSaved(tabId: string, savedText: string): boolean;
84
+ /** Remember one tab's scroll + caret (reported by the editor, see EditorPane). */
85
+ export declare function rememberEditorViewState(tabId: string, state: CodeEditorViewState): void;
86
+ /** The stored position for one tab, or undefined when it was never seen. */
87
+ export declare function editorViewStateOf(tabId: string): CodeEditorViewState | undefined;
88
+ /**
89
+ * Drop positions of tabs that no longer exist.
90
+ *
91
+ * The map used to live in a component with the same cleanup: without it a
92
+ * long session accumulated one state object per file ever opened.
93
+ */
94
+ export declare function pruneEditorViewStates(liveIds: ReadonlySet<string>): void;
@@ -5,6 +5,7 @@ import { type StatusBuckets, type TreeMarks } from './scm/status-map.ts';
5
5
  import { type DiffPayload } from './scm/actions.ts';
6
6
  import { type DirExpansions } from './scm/untracked-dirs.ts';
7
7
  import { type Lang } from './i18n.ts';
8
+ import { type EditorSurface } from './float-editor.ts';
8
9
  /** One editor tab (code file, preview or diff). */
9
10
  export interface EditorTab {
10
11
  id: string;
@@ -17,6 +18,16 @@ export interface EditorTab {
17
18
  error: string | undefined;
18
19
  /** File payload. */
19
20
  text: string;
21
+ /**
22
+ * The text as last LOADED from / SAVED to the remote file.
23
+ *
24
+ * `text` is the live-buffer handoff copy (see `captureLiveBuffer`) and may
25
+ * therefore hold unsaved edits; this is the disk-side reference the editor
26
+ * uses as its dirty baseline. Without the split, a remount with the handoff
27
+ * text would label unsaved edits "clean" (dirty dot off, Save disabled, close
28
+ * guard silent) — the exact silent-loss path the handoff exists to avoid.
29
+ */
30
+ baseline?: string;
20
31
  dirty: boolean;
21
32
  saving: boolean;
22
33
  binary: boolean;
@@ -144,7 +155,10 @@ export interface ExplorerValue {
144
155
  activeTab: EditorTab | undefined;
145
156
  activeTabId: string | undefined;
146
157
  revision: number;
147
- editorSurface: 'center' | 'sidebar';
158
+ /** Effective editor mount: the floating window wins over the base surface. */
159
+ editorSurface: EditorSurface;
160
+ floatOpen: boolean;
161
+ floatAvailable: boolean;
148
162
  view: RailView;
149
163
  searchRequest: SearchRequest | undefined;
150
164
  /** Pending "show this folder in the tree" request (consumed by the explorer). */
@@ -199,6 +213,21 @@ export interface ExplorerValue {
199
213
  openFile: (fullPath: string, options?: {
200
214
  line?: number;
201
215
  }) => void;
216
+ /**
217
+ * 「在悬浮编辑器中打开」:把编辑器整体浮出,再打开这个文件。
218
+ *
219
+ * 先捕获当前编辑器缓冲区再切表面(见 `captureLiveBuffer`):主界面里没保存
220
+ * 的编辑不会因为这次切换丢掉。
221
+ */
222
+ openInFloatEditor: (fullPath: string, options?: {
223
+ line?: number;
224
+ }) => void;
225
+ /** 同上,只是打开的是工作区 diff(SCM 更改列表的悬浮入口)。 */
226
+ openDiffInFloatEditor: (rel: string, staged: boolean, untracked?: boolean) => void;
227
+ /** 把当前编辑器整体浮出(双击会话视图的「远程编辑器」标签)。 */
228
+ openFloatingEditor: () => void;
229
+ /** 停靠回主界面(底层的中心视图或侧栏表面)。 */
230
+ dockFloatingEditor: () => void;
202
231
  openDiff: (rel: string, staged: boolean, untracked?: boolean) => void;
203
232
  openCommitFile: (hash: string, rel: string) => void;
204
233
  activateTab: (id: string) => void;
@@ -215,7 +244,12 @@ export interface ExplorerValue {
215
244
  * `changeSet.marks`,`hunk` 是 `patches` 的下标)。
216
245
  */
217
246
  undoChange: (tabId: string, mark: ChangeMark) => Promise<void>;
218
- saveTab: (id: string, content: string) => Promise<void>;
247
+ /**
248
+ * Write one file tab back to the remote host.
249
+ * @returns true only when the write really succeeded — the caller uses it to
250
+ * advance the editor's dirty baseline (see `CodeEditorHandle.markSaved`).
251
+ */
252
+ saveTab: (id: string, content: string) => Promise<boolean>;
219
253
  reloadTab: (id: string) => Promise<void>;
220
254
  info: (text: string) => void;
221
255
  fail: (text: string) => void;
@@ -438,8 +472,16 @@ export declare function remoteOpenBindings(): {
438
472
  export declare function openRemoteFileInBinding(bindingId: string, remotePath: string, options?: {
439
473
  line?: number;
440
474
  }): boolean;
441
- /** Whether the editor tab is mounted in the centre conversation view. */
442
- export declare function isEditorCentered(): boolean;
475
+ /**
476
+ * Whether the plugin's own rich editor is the place a remote file click should
477
+ * land (the click interceptor's gate, see `remote-file-open.ts`).
478
+ *
479
+ * The centre conversation view is the normal case; while the editor floats it
480
+ * is the floating window instead — the file still belongs in OUR editor, not in
481
+ * the host's native preview. On the sidebar-fallback layout (`sidebar`) the
482
+ * interceptor stays off, exactly as before this feature.
483
+ */
484
+ export declare function isEditorTakeoverActive(): boolean;
443
485
  /** Open (or focus) a working-tree diff of one repository path. */
444
486
  export declare function openDiff(rel: string, staged: boolean, untracked?: boolean): void;
445
487
  /** Open (or focus) one file of one commit (`ref^` against `ref`). */
@@ -447,8 +489,8 @@ export declare function openCommitFile(hash: string, rel: string): void;
447
489
  export declare function activateTab(id: string): void;
448
490
  export declare function closeTab(id: string): void;
449
491
  export declare function updateTab(id: string, next: Partial<EditorTab>): void;
450
- /** Write one tab back to the remote host. */
451
- export declare function saveTab(id: string, content: string): Promise<void>;
492
+ /** Write one tab back to the remote host. @returns true when the write landed. */
493
+ export declare function saveTab(id: string, content: string): Promise<boolean>;
452
494
  /**
453
495
  * Re-read one tab (file content or diff payload).
454
496
  *
@@ -470,6 +512,53 @@ export declare function saveTab(id: string, content: string): Promise<void>;
470
512
  export declare function reloadTab(id: string): Promise<void>;
471
513
  /** Note where the editor pane is mounted (set by the client entry). */
472
514
  export declare function setEditorSurface(surface: 'center' | 'sidebar'): void;
515
+ /**
516
+ * Record that the host's frame-wide overlay slot actually rendered the
517
+ * floating window host (see `FloatingEditorWindow`).
518
+ *
519
+ * This is a runtime handshake, not a configuration flag: on a host whose
520
+ * `shell.overlay` slot is missing, the entry never mounts and the menu entries
521
+ * stay disabled instead of opening a surface that cannot paint.
522
+ */
523
+ export declare function setFloatEditorAvailable(available: boolean): void;
524
+ /**
525
+ * Float the editor in its current state, without opening anything.
526
+ *
527
+ * The entry is a double-click on the shell's 「远程编辑器」 tab (see
528
+ * `float-tab-trigger.ts`): the user is already looking at the editor and wants
529
+ * it out of the layout — no file, no diff, same tabs and buffers.
530
+ *
531
+ * Idempotent: while the editor already floats this does nothing (the window is
532
+ * on screen; a second trigger must not restart it).
533
+ */
534
+ export declare function openFloatingEditor(): void;
535
+ /**
536
+ * Open one file in the floating editor (file-tree / search / SCM menu action).
537
+ *
538
+ * Order matters twice:
539
+ * 1. the live buffer is captured BEFORE the surface flips (the flip unmounts
540
+ * the editor that owns it);
541
+ * 2. a second capture runs even when already floating, because opening
542
+ * another file is a tab switch — the very action that remounts the editor
543
+ * and would otherwise drop the edits typed in the tab being left.
544
+ * A host whose overlay slot is unavailable gets the file in the normal surface
545
+ * instead (with the reason toasted), so the click is never a dead end.
546
+ */
547
+ export declare function openInFloatEditor(fullPath: string, options?: {
548
+ line?: number;
549
+ }): void;
550
+ /**
551
+ * The floating variant of `openDiff` — the SCM change list's entry.
552
+ *
553
+ * Same handoff order as `openInFloatEditor`; a diff tab has no text buffer of
554
+ * its own, but the file editor that WAS on screen still does.
555
+ */
556
+ export declare function openDiffInFloatEditor(rel: string, staged: boolean, untracked?: boolean): void;
557
+ /**
558
+ * Dock the editor back onto its base surface (centre conversation view or the
559
+ * sidebar fallback), carrying the live buffer with it.
560
+ */
561
+ export declare function dockFloatingEditor(): void;
473
562
  /**
474
563
  * 换一份「本会话改了什么」的标记。
475
564
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote-vs",
3
3
  "description": "VSCode-style remote development for DSH: SSH host manager (key/password), helper deploy pipeline with version check, remote file explorer + editor + terminal in dsh-better-sidebar.",
4
- "version": "0.8.9",
4
+ "version": "0.9.0",
5
5
  "icon": "icon.svg",
6
6
  "keywords": [
7
7
  "dsh",
@@ -60,7 +60,18 @@ export interface CodeEditorViewState {
60
60
  export interface CodeEditorHandle {
61
61
  getContent: () => string
62
62
  isDirty: () => boolean
63
- markSaved: () => void
63
+ /**
64
+ * Advance the dirty baseline to the bytes a save just wrote to the remote
65
+ * file, and re-derive the dirty flag from the LIVE buffer against them.
66
+ *
67
+ * Takes the saved text explicitly because the user may have typed while the
68
+ * remote write was in flight: the buffer is then NEWER than the file, and
69
+ * declaring it saved would be the same silent-loss bug in the other
70
+ * direction. `tabId` guards against the pane having switched to another tab
71
+ * while the save awaited — marking that editor saved would corrupt ITS
72
+ * baseline.
73
+ */
74
+ markSaved: (savedText: string, tabId: string) => void
64
75
  /** Snapshot scroll + selection, for restoring after a remount. */
65
76
  getViewState: () => CodeEditorViewState | undefined
66
77
  /** Scroll back and put the caret where it was. */
@@ -190,6 +201,21 @@ export const CodeEditor = forwardRef<CodeEditorHandle, {
190
201
  * where a file was when its tab is re-selected.
191
202
  */
192
203
  viewState?: CodeEditorViewState
204
+ /**
205
+ * The text as it was last LOADED from / SAVED to the remote file — the dirty
206
+ * baseline.
207
+ *
208
+ * Separate from `initial` because a tab switch or a surface flip remounts the
209
+ * editor with the LIVE buffer as `initial` (that is how unsaved edits survive
210
+ * the remount), while the file on disk is still the older `savedText`. Using
211
+ * `initial` as the baseline would declare the unsaved text clean: the dirty
212
+ * dot and status would lie, the close guard would stop prompting and Save
213
+ * would disable — a silent way to lose the very edits the handoff preserved.
214
+ *
215
+ * Absent (diff panes, older callers): the mount's own document is the
216
+ * baseline, which is the historical behaviour.
217
+ */
218
+ savedText?: string
193
219
  onSave: () => void
194
220
  onDirty: (dirty: boolean) => void
195
221
  }>(function CodeEditor(props, ref) {
@@ -402,14 +428,23 @@ export const CodeEditor = forwardRef<CodeEditorHandle, {
402
428
  useImperativeHandle(ref, () => ({
403
429
  getContent: () => viewRef.current?.state.doc.toString() ?? savedRef.current,
404
430
  isDirty: () => dirtyRef.current,
405
- markSaved: () => {
431
+ markSaved: (savedText, tabId) => {
406
432
  const view = viewRef.current
407
- if (view !== undefined) {
408
- savedRef.current = view.state.doc.toString()
409
- savedDocRef.current = view.state.doc
433
+ if (view === undefined) return
434
+ // The pane may have switched tabs while the save was in flight; this
435
+ // editor is then a different file and must not adopt the other tab's
436
+ // baseline.
437
+ if (propsRef.current.tabId !== tabId) return
438
+ // The written bytes themselves: no document copy needed (this string came
439
+ // from the caller's own `getContent`, or from the tab's baseline).
440
+ savedRef.current = savedText
441
+ const saved = EditorState.create({ doc: savedText }).doc
442
+ savedDocRef.current = saved
443
+ const dirty = view.state.doc !== saved && !view.state.doc.eq(saved)
444
+ if (dirty !== dirtyRef.current) {
445
+ dirtyRef.current = dirty
446
+ propsRef.current.onDirty(dirty)
410
447
  }
411
- dirtyRef.current = false
412
- propsRef.current.onDirty(false)
413
448
  },
414
449
  getViewState: () => {
415
450
  const view = viewRef.current
@@ -459,6 +494,34 @@ export const CodeEditor = forwardRef<CodeEditorHandle, {
459
494
  viewRef.current?.requestMeasure()
460
495
  }, [props.visible])
461
496
 
497
+ /**
498
+ * A save that landed AFTER this editor mounted.
499
+ *
500
+ * The save path moves the tab's `baseline` (the bytes now on disk) and asks
501
+ * the live editor to re-derive its dirty flag. That works while the same
502
+ * instance is mounted, but the pane can be remounted while the remote write
503
+ * is in flight (a surface flip, a dock, a tab switch): the replacement
504
+ * derives against the baseline it saw at MOUNT, and the corrected value only
505
+ * arrives later as a prop. Without this effect the tab keeps the `dirty:false`
506
+ * the save optimistically wrote, while the buffer holds newer text — dot off,
507
+ * Save disabled, close guard silent. Re-derive, never touch the document.
508
+ */
509
+ useEffect(() => {
510
+ const view = viewRef.current
511
+ const savedText = props.savedText
512
+ if (view === undefined || savedText === undefined) return
513
+ const baseline = EditorState.create({ doc: savedText }).doc
514
+ const previous = savedDocRef.current
515
+ if (previous !== undefined && previous.eq(baseline)) return
516
+ savedDocRef.current = baseline
517
+ savedRef.current = savedText
518
+ const dirty = view.state.doc !== baseline && !view.state.doc.eq(baseline)
519
+ if (dirty !== dirtyRef.current) {
520
+ dirtyRef.current = dirty
521
+ propsRef.current.onDirty(dirty)
522
+ }
523
+ }, [props.savedText])
524
+
462
525
  // The chat gate may flip while a popup is open (session switched away):
463
526
  // never commit into a session the file no longer belongs to.
464
527
  const chatGate = props.chatEnabled === true && props.sessionId !== undefined && props.remoteRoot !== undefined
@@ -670,7 +733,31 @@ export const CodeEditor = forwardRef<CodeEditorHandle, {
670
733
  viewRef.current = view
671
734
  // Baseline for the dirty check, kept as a `Text` so comparing on a
672
735
  // keystroke does not have to materialize the document as a string.
673
- savedDocRef.current = view.state.doc
736
+ //
737
+ // `savedText` (the last loaded/saved bytes) wins over the mounted document:
738
+ // a remount can carry the UNSAVED buffer as `initial` (see the prop's doc),
739
+ // and that text must never become the "clean" reference.
740
+ const savedText = propsRef.current.savedText
741
+ const baseline = savedText !== undefined
742
+ ? EditorState.create({ doc: savedText }).doc
743
+ : view.state.doc
744
+ savedDocRef.current = baseline
745
+ if (savedText !== undefined) {
746
+ savedRef.current = savedText
747
+ // Report the dirty state AT MOUNT: the buffer can already be newer than
748
+ // the disk bytes (a handoff remount). Waiting for the first keystroke
749
+ // left exactly such a file shown as clean — dot off, Save disabled,
750
+ // close guard silent — which is the failure this baseline exists to
751
+ // prevent.
752
+ const dirty = view.state.doc !== baseline && !view.state.doc.eq(baseline)
753
+ // Only a CHANGE is worth a store write: this runs on every mount, and a
754
+ // clean tab reporting `false` would cost every subscriber a re-render for
755
+ // nothing.
756
+ if (dirty !== dirtyRef.current) {
757
+ dirtyRef.current = dirty
758
+ propsRef.current.onDirty(dirty)
759
+ }
760
+ }
674
761
  setView(view)
675
762
  /**
676
763
  * Come back to where this file was left.