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
|
@@ -23,7 +23,18 @@ export interface CodeEditorViewState {
|
|
|
23
23
|
export interface CodeEditorHandle {
|
|
24
24
|
getContent: () => string;
|
|
25
25
|
isDirty: () => boolean;
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
442
|
-
|
|
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<
|
|
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.
|
|
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
|
-
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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.
|