dsh-remote-vs 0.8.9 → 0.9.1

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.
Files changed (35) hide show
  1. package/README.en.md +15 -2
  2. package/README.md +12 -1
  3. package/lib/client.js +1235 -53
  4. package/lib/index.js +1 -1
  5. package/lib/p2-router-fs.js +1 -1
  6. package/lib/types/client/CodeEditor.d.ts +27 -1
  7. package/lib/types/client/FloatingEditorWindow.d.ts +20 -0
  8. package/lib/types/client/FloatingPlaceholder.d.ts +9 -0
  9. package/lib/types/client/RemoteEditorView.d.ts +4 -0
  10. package/lib/types/client/float-editor.d.ts +123 -0
  11. package/lib/types/client/float-tab-trigger.d.ts +24 -0
  12. package/lib/types/client/live-editor.d.ts +94 -0
  13. package/lib/types/client/store.d.ts +95 -6
  14. package/lib/types/workspace-title.d.ts +2 -2
  15. package/lib/{worlds-DnzpX3Hv.js → worlds-a8nqW9mi.js} +2 -2
  16. package/package.json +1 -1
  17. package/src/client/CodeEditor.tsx +95 -8
  18. package/src/client/EditorPane.tsx +45 -24
  19. package/src/client/FloatingEditorWindow.tsx +494 -0
  20. package/src/client/FloatingPlaceholder.tsx +22 -0
  21. package/src/client/RemoteEditorView.tsx +7 -2
  22. package/src/client/RemoteExplorer.tsx +16 -0
  23. package/src/client/RemoteRoot.tsx +15 -6
  24. package/src/client/SearchView.tsx +10 -3
  25. package/src/client/float-editor.ts +271 -0
  26. package/src/client/float-tab-trigger.ts +51 -0
  27. package/src/client/i18n.ts +28 -0
  28. package/src/client/index.tsx +48 -1
  29. package/src/client/live-editor.ts +155 -0
  30. package/src/client/remote-file-open.ts +2 -2
  31. package/src/client/scm/ScmPanel.tsx +42 -1
  32. package/src/client/store.tsx +274 -16
  33. package/src/client/vsc/editor-theme.ts +6 -1
  34. package/src/client/vsc/vsc.css +235 -0
  35. package/src/workspace-title.ts +2 -2
@@ -0,0 +1,271 @@
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
+
16
+ /** Where the editor pane is mounted right now. */
17
+ export type EditorSurface = 'center' | 'sidebar' | 'float'
18
+
19
+ /** The persisted layout of the floating window, in viewport pixels. */
20
+ export interface FloatRect {
21
+ x: number
22
+ y: number
23
+ width: number
24
+ height: number
25
+ }
26
+
27
+ /** Viewport size the geometry is clamped to. */
28
+ export interface FloatViewport {
29
+ width: number
30
+ height: number
31
+ }
32
+
33
+ /** A resize grip: the compass directions of the eight handles. */
34
+ export type FloatResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
35
+
36
+ /** Smallest window the editor can be squeezed into and stay usable. */
37
+ export const FLOAT_MIN_WIDTH = 360
38
+ export const FLOAT_MIN_HEIGHT = 220
39
+ /** Height of the title bar; a minimized window keeps exactly this much. */
40
+ export const FLOAT_TITLEBAR_HEIGHT = 34
41
+ /** Gap a maximized window keeps from the viewport edges. */
42
+ export const FLOAT_VIEWPORT_EDGE = 8
43
+
44
+ /** Editor font-size range for Ctrl+wheel zoom (px). */
45
+ export const FLOAT_FONT_MIN = 9
46
+ export const FLOAT_FONT_MAX = 28
47
+ export const FLOAT_FONT_DEFAULT = 13
48
+
49
+ /** Diameter of the minimized ball, in pixels. */
50
+ export const FLOAT_BALL_SIZE = 44
51
+ /**
52
+ * How far a pointer may travel and still count as a CLICK: the minimized window
53
+ * is a single 44px ball, so "click restores / drag moves" has to be decided
54
+ * from one pointer gesture.
55
+ */
56
+ export const FLOAT_BALL_CLICK_SLOP = 4
57
+
58
+ /** A point in viewport coordinates (the minimized ball's origin). */
59
+ export interface FloatPoint {
60
+ x: number
61
+ y: number
62
+ }
63
+
64
+ function clamp(value: number, min: number, max: number): number {
65
+ return Math.min(max, Math.max(min, value))
66
+ }
67
+
68
+ /** True when the stored value is a usable finite number (not NaN/Infinity). */
69
+ function finite(value: unknown): value is number {
70
+ return typeof value === 'number' && Number.isFinite(value)
71
+ }
72
+
73
+ /** The effective editor surface: floating wins, otherwise the base mount. */
74
+ export function effectiveEditorSurface(base: 'center' | 'sidebar', floatOpen: boolean): EditorSurface {
75
+ return floatOpen ? 'float' : base
76
+ }
77
+
78
+ /**
79
+ * The rect a fresh floating window opens at: about two thirds of the frame,
80
+ * centred, with a hard size cap so a 4K monitor does not produce a 2600px
81
+ * editor for a 40-column file.
82
+ */
83
+ export function defaultFloatRect(viewport: FloatViewport): FloatRect {
84
+ const maxWidth = Math.max(FLOAT_MIN_WIDTH, viewport.width - FLOAT_VIEWPORT_EDGE * 2)
85
+ const maxHeight = Math.max(FLOAT_MIN_HEIGHT, viewport.height - FLOAT_VIEWPORT_EDGE * 2)
86
+ const width = Math.min(maxWidth, Math.max(FLOAT_MIN_WIDTH, Math.round(viewport.width * 0.62)), 1180)
87
+ const height = Math.min(maxHeight, Math.max(FLOAT_MIN_HEIGHT, Math.round(viewport.height * 0.7)), 860)
88
+ const x = Math.round((viewport.width - width) / 2)
89
+ const y = Math.round((viewport.height - height) / 2) - 12
90
+ return clampFloatRect({ x, y, width, height }, viewport)
91
+ }
92
+
93
+ /**
94
+ * Bring a rect back inside the viewport and to a legal size.
95
+ *
96
+ * Used on every drag step, on restore, and whenever the browser window is
97
+ * resized: a rect persisted on a 2560px screen must not open a window whose
98
+ * title bar is off-screen on a 1366px laptop.
99
+ */
100
+ export function clampFloatRect(rect: FloatRect, viewport: FloatViewport): FloatRect {
101
+ const width = clamp(rect.width, FLOAT_MIN_WIDTH, Math.max(FLOAT_MIN_WIDTH, viewport.width))
102
+ const height = clamp(rect.height, FLOAT_MIN_HEIGHT, Math.max(FLOAT_MIN_HEIGHT, viewport.height))
103
+ const x = clamp(rect.x, 0, Math.max(0, viewport.width - width))
104
+ const y = clamp(rect.y, 0, Math.max(0, viewport.height - height))
105
+ return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) }
106
+ }
107
+
108
+ /** Move the window by a pointer delta, staying inside the viewport. */
109
+ export function dragFloatRect(start: FloatRect, dx: number, dy: number, viewport: FloatViewport): FloatRect {
110
+ return clampFloatRect({ ...start, x: start.x + dx, y: start.y + dy }, viewport)
111
+ }
112
+
113
+ /**
114
+ * Resize from one grip.
115
+ *
116
+ * Each axis is adjusted from the edge that MOVED (west/north resize changes the
117
+ * origin too), then clamped: the minimum size is anchored at the opposite edge
118
+ * (so dragging the north grip past the bottom stops instead of flipping the
119
+ * rect) and no edge may leave the viewport.
120
+ */
121
+ export function resizeFloatRect(
122
+ start: FloatRect,
123
+ handle: FloatResizeHandle,
124
+ dx: number,
125
+ dy: number,
126
+ viewport: FloatViewport,
127
+ ): FloatRect {
128
+ let left = start.x
129
+ let top = start.y
130
+ let right = start.x + start.width
131
+ let bottom = start.y + start.height
132
+ if (handle.includes('e')) right += dx
133
+ if (handle.includes('w')) left += dx
134
+ if (handle.includes('s')) bottom += dy
135
+ if (handle.includes('n')) top += dy
136
+
137
+ // Minimum size, anchored at the edge that did NOT move.
138
+ if (right - left < FLOAT_MIN_WIDTH) {
139
+ if (handle.includes('w')) left = right - FLOAT_MIN_WIDTH
140
+ else right = left + FLOAT_MIN_WIDTH
141
+ }
142
+ if (bottom - top < FLOAT_MIN_HEIGHT) {
143
+ if (handle.includes('n')) top = bottom - FLOAT_MIN_HEIGHT
144
+ else bottom = top + FLOAT_MIN_HEIGHT
145
+ }
146
+
147
+ // Viewport clamp: keep the rect (and every edge) on screen.
148
+ left = clamp(left, 0, Math.max(0, viewport.width - FLOAT_MIN_WIDTH))
149
+ top = clamp(top, 0, Math.max(0, viewport.height - FLOAT_MIN_HEIGHT))
150
+ right = clamp(right, left + FLOAT_MIN_WIDTH, viewport.width)
151
+ bottom = clamp(bottom, top + FLOAT_MIN_HEIGHT, viewport.height)
152
+ return {
153
+ x: Math.round(left),
154
+ y: Math.round(top),
155
+ width: Math.round(right - left),
156
+ height: Math.round(bottom - top),
157
+ }
158
+ }
159
+
160
+ /** The rect a maximized window occupies: the frame minus a small margin. */
161
+ export function maximizeFloatRect(viewport: FloatViewport): FloatRect {
162
+ const edge = FLOAT_VIEWPORT_EDGE
163
+ return {
164
+ x: edge,
165
+ y: edge,
166
+ width: Math.max(FLOAT_MIN_WIDTH, viewport.width - edge * 2),
167
+ height: Math.max(FLOAT_MIN_HEIGHT, viewport.height - edge * 2),
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Parse a persisted rect.
173
+ *
174
+ * Anything malformed is discarded rather than repaired into a guess: the
175
+ * caller then opens the default rect. Values that are merely STALE (a rect from
176
+ * another screen) are legal input and get clamped.
177
+ */
178
+ export function parseFloatRect(raw: string | null | undefined, viewport: FloatViewport): FloatRect | undefined {
179
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
180
+ let parsed: unknown
181
+ try {
182
+ parsed = JSON.parse(raw)
183
+ } catch {
184
+ return undefined
185
+ }
186
+ if (typeof parsed !== 'object' || parsed === null) return undefined
187
+ const candidate = parsed as Record<string, unknown>
188
+ if (!finite(candidate.x) || !finite(candidate.y) || !finite(candidate.width) || !finite(candidate.height)) {
189
+ return undefined
190
+ }
191
+ return clampFloatRect(
192
+ { x: candidate.x, y: candidate.y, width: candidate.width, height: candidate.height },
193
+ viewport,
194
+ )
195
+ }
196
+
197
+ /** Serialize a rect for localStorage. */
198
+ export function serializeFloatRect(rect: FloatRect): string {
199
+ return JSON.stringify({ x: rect.x, y: rect.y, width: rect.width, height: rect.height })
200
+ }
201
+
202
+ /**
203
+ * One Ctrl+wheel step: up (negative deltaY) grows the text, down shrinks it.
204
+ * Values are whole pixels so the stored size stays stable across sessions.
205
+ */
206
+ export function stepFloatZoom(current: number, deltaY: number): number {
207
+ if (!finite(current)) return FLOAT_FONT_DEFAULT
208
+ if (deltaY === 0) return clamp(Math.round(current), FLOAT_FONT_MIN, FLOAT_FONT_MAX)
209
+ const direction = deltaY < 0 ? 1 : -1
210
+ return clamp(Math.round(current) + direction, FLOAT_FONT_MIN, FLOAT_FONT_MAX)
211
+ }
212
+
213
+ /** Parse the persisted editor font size (fallback = the default 13px). */
214
+ export function parseFloatFontSize(raw: string | null | undefined): number {
215
+ if (typeof raw !== 'string') return FLOAT_FONT_DEFAULT
216
+ const value = Number(raw)
217
+ if (!finite(value)) return FLOAT_FONT_DEFAULT
218
+ return clamp(Math.round(value), FLOAT_FONT_MIN, FLOAT_FONT_MAX)
219
+ }
220
+
221
+ /**
222
+ * Where the minimized ball may sit: fully on screen, whatever position was
223
+ * asked for (a ball restored on a smaller monitor must stay clickable).
224
+ */
225
+ export function clampBallPosition(pos: FloatPoint, viewport: FloatViewport): FloatPoint {
226
+ const maxX = Math.max(0, viewport.width - FLOAT_BALL_SIZE)
227
+ const maxY = Math.max(0, viewport.height - FLOAT_BALL_SIZE)
228
+ const x = finite(pos.x) ? pos.x : 0
229
+ const y = finite(pos.y) ? pos.y : 0
230
+ return { x: Math.round(clamp(x, 0, maxX)), y: Math.round(clamp(y, 0, maxY)) }
231
+ }
232
+
233
+ /**
234
+ * The ball's default spot when a window is minimized: its own top-left corner,
235
+ * so the ball appears where the window was instead of jumping to a corner.
236
+ */
237
+ export function ballFromRect(rect: FloatRect, viewport: FloatViewport): FloatPoint {
238
+ return clampBallPosition({ x: rect.x, y: rect.y }, viewport)
239
+ }
240
+
241
+ /**
242
+ * Whether a finished gesture was a CLICK rather than a drag.
243
+ *
244
+ * The minimized window is one small target with two actions on the same
245
+ * pointer: a press that never really moved restores the window, a real drag
246
+ * moves the ball. Without this distinction every move-by-3px would also
247
+ * restore, and every click would be a zero-distance drag.
248
+ */
249
+ export function isBallClick(dx: number, dy: number): boolean {
250
+ return Math.abs(dx) <= FLOAT_BALL_CLICK_SLOP && Math.abs(dy) <= FLOAT_BALL_CLICK_SLOP
251
+ }
252
+
253
+ /** Parse a persisted ball position (stale geometry is clamped, garbage dropped). */
254
+ export function parseBallPosition(raw: string | null | undefined, viewport: FloatViewport): FloatPoint | undefined {
255
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
256
+ let parsed: unknown
257
+ try {
258
+ parsed = JSON.parse(raw)
259
+ } catch {
260
+ return undefined
261
+ }
262
+ if (typeof parsed !== 'object' || parsed === null) return undefined
263
+ const candidate = parsed as Record<string, unknown>
264
+ if (!finite(candidate.x) || !finite(candidate.y)) return undefined
265
+ return clampBallPosition({ x: candidate.x, y: candidate.y }, viewport)
266
+ }
267
+
268
+ /** Serialize a ball position for localStorage. */
269
+ export function serializeBallPosition(pos: FloatPoint): string {
270
+ return JSON.stringify({ x: pos.x, y: pos.y })
271
+ }
@@ -0,0 +1,51 @@
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
+ /** Elements that behave like the shell's view tabs. */
18
+ const TAB_SELECTOR = 'button, [role="tab"], [role="button"]'
19
+
20
+ /**
21
+ * Install the double-click trigger.
22
+ *
23
+ * @param label - the tab's visible label (the same localized string the view
24
+ * registered with); matched against the tab's trimmed text.
25
+ * @param onTrigger - called once per recognised double-click.
26
+ * @returns disposer removing the listener.
27
+ */
28
+ export function installFloatTabTrigger(label: string, onTrigger: () => void): () => void {
29
+ if (typeof document === 'undefined' || label === '') return () => {}
30
+ const handler = (event: MouseEvent): void => {
31
+ if (event.button !== 0) return
32
+ const target = event.target
33
+ if (!(target instanceof Element)) return
34
+ // Our own surfaces (editor tabs, menus, the floating window) own their
35
+ // double-clicks — the window's title bar uses one to maximize.
36
+ if (target.closest('.rvs-root') !== null) return
37
+ const tab = target.closest<HTMLElement>(TAB_SELECTOR)
38
+ if (tab === null) return
39
+ if ((tab.textContent ?? '').trim() !== label) return
40
+ // A second click while the shell is mid-transition is harmless: the action
41
+ // is idempotent (an already-floating editor is left alone).
42
+ try {
43
+ onTrigger()
44
+ } catch {
45
+ // Never let a listener break the shell's own double-click handling.
46
+ }
47
+ }
48
+ // Capture phase: the shell's tab may stop propagation on its own handlers.
49
+ document.addEventListener('dblclick', handler, true)
50
+ return () => document.removeEventListener('dblclick', handler, true)
51
+ }
@@ -158,6 +158,20 @@ const zh: Record<string, string> = {
158
158
  'files.deleteConfirm': '确定删除 {target} 吗?此操作在远端不可撤销。',
159
159
  'files.open': '打开',
160
160
  'files.openAsText': '以文本打开',
161
+ 'files.openInFloat': '在悬浮编辑器中打开',
162
+ // -- floating editor window
163
+ 'float.title': '悬浮编辑器',
164
+ 'float.dragHint': '拖动标题栏移动窗口;拖动边缘缩放;双击标题栏最大化或还原',
165
+ 'float.minimize': '最小化',
166
+ 'float.ballHint': '点击恢复悬浮编辑器,拖动可移动',
167
+ 'float.dockViewHint': '双击「远程编辑器」标签可浮出编辑器',
168
+ 'float.maximize': '最大化',
169
+ 'float.restore': '还原窗口',
170
+ 'float.dock': '停靠回主界面',
171
+ 'float.placeholder': '远程编辑器正在悬浮窗口中',
172
+ 'float.placeholderHint': '编辑器只有一份实例,已浮出到窗口中央;关闭悬浮窗或点「停靠」后回到这里。',
173
+ 'float.dockBack': '停靠回主界面',
174
+ 'float.unavailable': '当前宿主没有全帧悬浮层(shell.overlay),悬浮编辑器不可用。',
161
175
  'files.rename': '重命名',
162
176
  'files.copy': '复制',
163
177
  'files.cut': '剪切',
@@ -496,6 +510,20 @@ const en: Record<string, string> = {
496
510
  'files.deleteConfirm': 'Delete {target}? This cannot be undone on the remote host.',
497
511
  'files.open': 'Open',
498
512
  'files.openAsText': 'Open as text',
513
+ 'files.openInFloat': 'Open in floating editor',
514
+ // -- floating editor window
515
+ 'float.title': 'Floating editor',
516
+ 'float.dragHint': 'Drag the title bar to move; drag an edge to resize; double-click the title bar to maximize or restore',
517
+ 'float.minimize': 'Minimize',
518
+ 'float.ballHint': 'Click to restore the floating editor, drag to move it',
519
+ 'float.dockViewHint': 'Double-click the "remote editor" tab to float the editor',
520
+ 'float.maximize': 'Maximize',
521
+ 'float.restore': 'Restore window',
522
+ 'float.dock': 'Dock back into the main layout',
523
+ 'float.placeholder': 'The remote editor is floating over the app',
524
+ 'float.placeholderHint': 'There is only one editor instance and it is out in the floating window; close that window or press Dock to bring it back here.',
525
+ 'float.dockBack': 'Dock back',
526
+ 'float.unavailable': 'This host has no frame-wide overlay slot (shell.overlay); the floating editor is unavailable.',
499
527
  'files.rename': 'Rename',
500
528
  'files.copy': 'Copy',
501
529
  'files.cut': 'Cut',
@@ -19,13 +19,15 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
19
19
  import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
20
20
  import './vsc/vsc.css'
21
21
  import { RemoteDirectoryFlow } from './DirectoryFlow.tsx'
22
+ import { FloatingEditorWindow } from './FloatingEditorWindow.tsx'
23
+ import { installFloatTabTrigger } from './float-tab-trigger.ts'
22
24
  import { registerNativeExplorer } from './NativeExplorer.tsx'
23
25
  import { installRemoteFileOpen } from './remote-file-open.ts'
24
26
  import { RemoteEditorView } from './RemoteEditorView.tsx'
25
27
  import { RemoteRoot } from './RemoteRoot.tsx'
26
28
  import { ChangesDock, type ChangesDockProps } from './changes/ChangesDock.tsx'
27
29
  import { SettingsHostsSection, SettingsWorkspacesSection } from './SettingsHosts.tsx'
28
- import { setEditorSurface } from './store.tsx'
30
+ import { openFloatingEditor, setEditorSurface } from './store.tsx'
29
31
  import { detectLang, makeT } from './i18n.ts'
30
32
  import { setIconService, type IconServiceLike } from './vsc/kit.tsx'
31
33
 
@@ -283,4 +285,49 @@ export function apply(ctx: Context): void {
283
285
  }
284
286
  }
285
287
  }, 'dsh-remote-vs: conversation view (editor + diff)')
288
+ // Double-click on the shell's 「远程编辑器」 tab floats the editor. The tab row
289
+ // belongs to the host's header (we only contribute a view), so the gesture is
290
+ // recognised by label — the same lookup `reveal.ts` already uses for clicks —
291
+ // and a miss is a silent no-op.
292
+ ctx.effect(
293
+ () => installFloatTabTrigger(viewT('view.editor'), () => { openFloatingEditor() }),
294
+ 'dsh-remote-vs: float on tab double-click',
295
+ )
296
+ // The floating editor window: hosted by the host's FRAME-WIDE overlay slot,
297
+ // which is the only seat that survives every sidebar tab and conversation
298
+ // view being unmounted. Registering from a narrower surface (the explorer
299
+ // tab, the editor view) would take the window away with that surface — and
300
+ // the editor's live buffer with it.
301
+ //
302
+ // The slot is looked up behind a try/catch: an older host without
303
+ // `shell.overlay` simply keeps the floating feature off. Availability is NOT
304
+ // assumed here — `FloatingEditorWindow` reports it once it really mounts, so
305
+ // the menu entry can never point at a window that cannot paint.
306
+ ctx.effect(() => {
307
+ let dispose: (() => void) | undefined
308
+ try {
309
+ const slots = ctx.slots as unknown as {
310
+ inject(slot: string, register: () => () => void): () => void
311
+ register(
312
+ options: { name: string; id: string; order: number },
313
+ component: () => ReactNode,
314
+ ): () => void
315
+ }
316
+ dispose = slots.inject('shell.overlay', () => slots.register({
317
+ name: 'shell.overlay',
318
+ id: 'remote-vs:floating-editor',
319
+ order: 40,
320
+ }, () => <FloatingEditorWindow ctx={ctx} />))
321
+ } catch (error) {
322
+ console.warn('[dsh-remote-vs] shell.overlay registration failed (floating editor unavailable):', error)
323
+ return () => {}
324
+ }
325
+ return () => {
326
+ try {
327
+ dispose?.()
328
+ } catch {
329
+ // Ignore.
330
+ }
331
+ }
332
+ }, 'dsh-remote-vs: floating editor window')
286
333
  }
@@ -0,0 +1,155 @@
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
+ interface LiveEditorRegistration {
27
+ tabId: string
28
+ getHandle: () => CodeEditorHandle | null
29
+ }
30
+
31
+ let registration: LiveEditorRegistration | undefined
32
+
33
+ /** Scroll + caret per tab id, surviving an unmount (see the header). */
34
+ const viewStates = new Map<string, CodeEditorViewState>()
35
+
36
+ /**
37
+ * Register the editor that is on screen right now.
38
+ *
39
+ * The caller passes a getter, not a handle: `editorRef.current` is `null`
40
+ * during the first render and is cleared before the parent's cleanup runs, so
41
+ * a snapshot taken later must read through the getter.
42
+ * @returns disposer; only the registration that is still current is cleared.
43
+ */
44
+ export function registerLiveEditor(
45
+ tabId: string,
46
+ getHandle: () => CodeEditorHandle | null,
47
+ ): () => void {
48
+ const entry: LiveEditorRegistration = { tabId, getHandle }
49
+ registration = entry
50
+ return () => {
51
+ if (registration === entry) registration = undefined
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Snapshot the live position (scroll + caret) without touching the document.
57
+ *
58
+ * Cheap, so the store can call it on EVERY switch: a surface flip remounts the
59
+ * very same tab, and the replacement's render happens before the old view's
60
+ * unmount report — the position has to be in the map before that render.
61
+ */
62
+ export function captureLiveEditorPosition(): void {
63
+ const entry = registration
64
+ if (entry === undefined) return
65
+ const handle = entry.getHandle()
66
+ if (handle === null) return
67
+ const viewState = handle.getViewState()
68
+ if (viewState !== undefined) viewStates.set(entry.tabId, viewState)
69
+ }
70
+
71
+ /**
72
+ * Whether the live buffer differs from its disk baseline.
73
+ *
74
+ * The store uses this to skip the O(document) text copy on clean tabs (see
75
+ * `captureLiveBuffer`): the boolean is cheap, `getContent()` materializes the
76
+ * whole file as a string.
77
+ */
78
+ export function isLiveEditorDirty(): boolean {
79
+ return registration?.getHandle()?.isDirty() === true
80
+ }
81
+
82
+ /**
83
+ * Copy the live buffer's text, for a switch that would otherwise lose it.
84
+ *
85
+ * Called from the store BEFORE the tab switch / surface flip: at that moment
86
+ * the editor is still mounted and its CodeMirror view still owns the edits.
87
+ * @returns undefined when no file editor is live (a diff tab, or a view that
88
+ * was never the selected one) — there is nothing to carry over then.
89
+ */
90
+ export function captureLiveEditorText(): { tabId: string; text: string } | undefined {
91
+ const entry = registration
92
+ if (entry === undefined) return undefined
93
+ const handle = entry.getHandle()
94
+ if (handle === null) return undefined
95
+ try {
96
+ return { tabId: entry.tabId, text: handle.getContent() }
97
+ } catch {
98
+ // A destroyed view is one race away from throwing; the switch must never
99
+ // be blocked by the handoff itself.
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Whether one tab's file editor is the one registered right now.
106
+ *
107
+ * The save path asks this before it clears a tab's dirty flag: if an editor IS
108
+ * mounted for that tab, the flag belongs to the live buffer and only the
109
+ * editor may clear it (see `markLiveEditorSaved`).
110
+ */
111
+ export function isLiveEditorFor(tabId: string): boolean {
112
+ return registration?.tabId === tabId
113
+ }
114
+
115
+ /**
116
+ * Advance the registered editor's dirty baseline to the bytes a save wrote.
117
+ *
118
+ * Routed through the MODULE-LEVEL registration rather than a React ref on
119
+ * purpose: the pane can be remounted while the remote write is in flight (a
120
+ * surface flip, a dock, a tab switch), and the ref the save started with is
121
+ * then detached — that is exactly how a tab ended up reported clean while its
122
+ * live buffer was newer than the file. The registration always names the editor
123
+ * mounted NOW, so the correction lands on whichever instance is on screen, and
124
+ * it is skipped (never misapplied) when that editor shows another tab: that tab
125
+ * heals on its next mount from `savedText` (see `CodeEditor`).
126
+ * @returns true when an editor for this tab took the new baseline.
127
+ */
128
+ export function markLiveEditorSaved(tabId: string, savedText: string): boolean {
129
+ const entry = registration
130
+ if (entry === undefined || entry.tabId !== tabId) return false
131
+ entry.getHandle()?.markSaved(savedText, tabId)
132
+ return true
133
+ }
134
+
135
+ /** Remember one tab's scroll + caret (reported by the editor, see EditorPane). */
136
+ export function rememberEditorViewState(tabId: string, state: CodeEditorViewState): void {
137
+ viewStates.set(tabId, state)
138
+ }
139
+
140
+ /** The stored position for one tab, or undefined when it was never seen. */
141
+ export function editorViewStateOf(tabId: string): CodeEditorViewState | undefined {
142
+ return viewStates.get(tabId)
143
+ }
144
+
145
+ /**
146
+ * Drop positions of tabs that no longer exist.
147
+ *
148
+ * The map used to live in a component with the same cleanup: without it a
149
+ * long session accumulated one state object per file ever opened.
150
+ */
151
+ export function pruneEditorViewStates(liveIds: ReadonlySet<string>): void {
152
+ for (const id of [...viewStates.keys()]) {
153
+ if (!liveIds.has(id)) viewStates.delete(id)
154
+ }
155
+ }
@@ -31,7 +31,7 @@ import {
31
31
  type ParsedFileAddress,
32
32
  type RemoteOpenBinding,
33
33
  } from './remote-file-open-target.ts'
34
- import { isEditorCentered, openRemoteFileInBinding, remoteOpenBindings } from './store.tsx'
34
+ import { isEditorTakeoverActive, openRemoteFileInBinding, remoteOpenBindings } from './store.tsx'
35
35
  import { workspacePathOf } from './use-remote-chat-target.ts'
36
36
 
37
37
  interface SidebarRightLike {
@@ -216,7 +216,7 @@ export function installRemoteFileOpen(ctx: Context): () => void {
216
216
  // Cheap, synchronous gate first: only a file address of a session in
217
217
  // one of our bindings is intercepted. Everything else keeps the native
218
218
  // call (and its synchronous throw semantics) untouched.
219
- if (!isEditorCentered()) return original(address, options)
219
+ if (!isEditorTakeoverActive()) return original(address, options)
220
220
  const parsed = parseFileAddress(address)
221
221
  if (parsed === undefined) return original(address, options)
222
222
  return interceptSync(ctx, parsed, address, options) === true ? undefined : original(address, options)