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,494 @@
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 { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import { EditorPane } from './EditorPane.tsx'
18
+ import { useBootstrap, useExplorer, setFloatEditorAvailable } from './store.tsx'
19
+ import { DialogHost, IconClose } from './vsc/kit.tsx'
20
+ import {
21
+ FLOAT_BALL_SIZE,
22
+ FLOAT_FONT_DEFAULT,
23
+ ballFromRect,
24
+ clampBallPosition,
25
+ clampFloatRect,
26
+ defaultFloatRect,
27
+ dragFloatRect,
28
+ isBallClick,
29
+ maximizeFloatRect,
30
+ parseBallPosition,
31
+ parseFloatFontSize,
32
+ parseFloatRect,
33
+ resizeFloatRect,
34
+ serializeBallPosition,
35
+ serializeFloatRect,
36
+ stepFloatZoom,
37
+ type FloatPoint,
38
+ type FloatRect,
39
+ type FloatResizeHandle,
40
+ } from './float-editor.ts'
41
+
42
+ /** Where the last window rect is remembered (per browser/profile). */
43
+ const RECT_KEY = 'dsh-remote-vs.float.rect'
44
+ /** Where the last editor font size is remembered. */
45
+ const FONT_KEY = 'dsh-remote-vs.float.font'
46
+ /** Where the minimized ball's spot is remembered. */
47
+ const BALL_KEY = 'dsh-remote-vs.float.ball'
48
+
49
+ /** The resize grips, in the order they are rendered. */
50
+ const RESIZE_HANDLES: readonly FloatResizeHandle[] = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']
51
+
52
+ type DragState =
53
+ | { kind: 'move'; pointerX: number; pointerY: number; rect: FloatRect }
54
+ | { kind: 'resize'; handle: FloatResizeHandle; pointerX: number; pointerY: number; rect: FloatRect }
55
+ /** Dragging the minimized ball; `start` is its origin when the press began. */
56
+ | { kind: 'ball'; pointerX: number; pointerY: number; start: FloatPoint }
57
+
58
+ function readViewport(): { width: number; height: number } {
59
+ return { width: window.innerWidth, height: window.innerHeight }
60
+ }
61
+
62
+ function readStoredRect(): FloatRect {
63
+ const viewport = readViewport()
64
+ try {
65
+ const stored = parseFloatRect(localStorage.getItem(RECT_KEY), viewport)
66
+ if (stored !== undefined) return stored
67
+ } catch {
68
+ // Private mode: the default rect is fine.
69
+ }
70
+ return defaultFloatRect(viewport)
71
+ }
72
+
73
+ function readStoredFontSize(): number {
74
+ try {
75
+ return parseFloatFontSize(localStorage.getItem(FONT_KEY))
76
+ } catch {
77
+ return FLOAT_FONT_DEFAULT
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The ball's remembered spot, or undefined the first time.
83
+ *
84
+ * Read at open, not at minimize: the spot belongs to the feature (per browser
85
+ * profile), and re-deriving it from the window corner on every minimize is what
86
+ * made the "remembered" claim false. A stale spot from a bigger screen is
87
+ * clamped by the parser, so it stays clickable.
88
+ */
89
+ function readStoredBall(): FloatPoint | undefined {
90
+ const viewport = readViewport()
91
+ try {
92
+ return parseBallPosition(localStorage.getItem(BALL_KEY), viewport)
93
+ } catch {
94
+ return undefined
95
+ }
96
+ }
97
+
98
+ /** The window frame plus the editor. `ctx` is only needed for the boot hook. */
99
+ export function FloatingEditorWindow(props: { ctx: Context }): ReactNode {
100
+ useBootstrap(props.ctx)
101
+ const store = useExplorer()
102
+ const t = store.t
103
+ const open = store.floatOpen
104
+ const activeTab = store.activeTab
105
+
106
+ const [rect, setRect] = useState<FloatRect | undefined>(undefined)
107
+ const [viewport, setViewport] = useState(() => (typeof window === 'undefined' ? { width: 0, height: 0 } : readViewport()))
108
+ const [fontSize, setFontSize] = useState(FLOAT_FONT_DEFAULT)
109
+ const [maximized, setMaximized] = useState(false)
110
+ const [collapsed, setCollapsed] = useState(false)
111
+ const [dragging, setDragging] = useState(false)
112
+ /** The minimized ball's origin; undefined until the first minimize. */
113
+ const [ball, setBall] = useState<FloatPoint | undefined>(undefined)
114
+
115
+ const viewportRef = useRef(viewport)
116
+ viewportRef.current = viewport
117
+ const dragRef = useRef<DragState | undefined>(undefined)
118
+ /** The normal (non-maximized) rect, restored by the restore button. */
119
+ const restoreRectRef = useRef<FloatRect | undefined>(undefined)
120
+ const bodyRef = useRef<HTMLDivElement>(null)
121
+ /** Latest font size, so the wheel handler never has to read stale state. */
122
+ const fontRef = useRef(fontSize)
123
+ fontRef.current = fontSize
124
+ /** True once the frame has a resolved rect (body/zoom effects depend on it). */
125
+ const rectReady = rect !== undefined
126
+ /** Previous `open`, so "opened again" can reset the minimized state. */
127
+ const wasOpenRef = useRef(open)
128
+
129
+ /** The geometry a drag/resize gesture points at right now (move AND finalize). */
130
+ const resolveDrag = (drag: DragState, pointerX: number, pointerY: number): FloatRect | FloatPoint => {
131
+ const dx = pointerX - drag.pointerX
132
+ const dy = pointerY - drag.pointerY
133
+ if (drag.kind === 'ball') return clampBallPosition({ x: drag.start.x + dx, y: drag.start.y + dy }, viewportRef.current)
134
+ return drag.kind === 'move'
135
+ ? dragFloatRect(drag.rect, dx, dy, viewportRef.current)
136
+ : resizeFloatRect(drag.rect, drag.handle, dx, dy, viewportRef.current)
137
+ }
138
+
139
+ /**
140
+ * Runtime handshake with the store (see `setFloatEditorAvailable`): the menu
141
+ * entry is enabled only once this component really mounted inside the host's
142
+ * overlay slot. A host without that slot never reaches this effect.
143
+ */
144
+ useEffect(() => {
145
+ setFloatEditorAvailable(true)
146
+ return () => setFloatEditorAvailable(false)
147
+ }, [])
148
+
149
+ // First open: resolve the persisted frame, font size and ball spot.
150
+ useEffect(() => {
151
+ if (!open || rect !== undefined) return
152
+ const next = readViewport()
153
+ setViewport(next)
154
+ setRect(readStoredRect())
155
+ setFontSize(readStoredFontSize())
156
+ setBall(readStoredBall())
157
+ }, [open, rect])
158
+
159
+ /**
160
+ * A window that was minimized when it was docked must not come back as a
161
+ * bare 34px title bar: "open in floating editor" would look broken. Every
162
+ * fresh open starts expanded; a minimize made WHILE open is kept.
163
+ */
164
+ useEffect(() => {
165
+ if (open && !wasOpenRef.current) setCollapsed(false)
166
+ wasOpenRef.current = open
167
+ }, [open])
168
+
169
+ // The browser window changed size: a maximized frame follows it, a free one
170
+ // is clamped back on screen (a rect from a larger monitor must stay reachable),
171
+ // and a minimized ball is pulled back in so it can still be clicked.
172
+ useEffect(() => {
173
+ const onResize = (): void => {
174
+ const next = readViewport()
175
+ setViewport(next)
176
+ setBall(prev => (prev === undefined ? prev : clampBallPosition(prev, next)))
177
+ setRect(prev => {
178
+ if (prev === undefined) return prev
179
+ return maximized ? maximizeFloatRect(next) : clampFloatRect(prev, next)
180
+ })
181
+ }
182
+ window.addEventListener('resize', onResize)
183
+ return () => window.removeEventListener('resize', onResize)
184
+ }, [maximized])
185
+
186
+ const persistRect = useCallback((next: FloatRect): void => {
187
+ try {
188
+ localStorage.setItem(RECT_KEY, serializeFloatRect(next))
189
+ } catch {
190
+ // Private mode: the frame simply is not remembered.
191
+ }
192
+ }, [])
193
+
194
+ const persistBall = useCallback((next: FloatPoint): void => {
195
+ try {
196
+ localStorage.setItem(BALL_KEY, serializeBallPosition(next))
197
+ } catch {
198
+ // Private mode: the ball's spot simply is not remembered.
199
+ }
200
+ }, [])
201
+
202
+ /**
203
+ * Drag / resize move on the WINDOW, not on the grip: the pointer regularly
204
+ * leaves the 4px handle mid-gesture, and pointer capture on a grip that
205
+ * re-renders is easy to lose. One listener pair per gesture is enough.
206
+ */
207
+ useEffect(() => {
208
+ if (!dragging) return
209
+ const onMove = (event: PointerEvent): void => {
210
+ const drag = dragRef.current
211
+ if (drag === undefined) return
212
+ // A pointerup lost outside the window (another app, an iframe) must not
213
+ // leave the frame glued to the cursor: the same `buttons` guard the
214
+ // sidebar divider uses.
215
+ if ((event.buttons & 1) === 0) {
216
+ dragRef.current = undefined
217
+ setDragging(false)
218
+ return
219
+ }
220
+ const next = resolveDrag(drag, event.clientX, event.clientY)
221
+ if (drag.kind === 'ball') setBall(next as FloatPoint)
222
+ else setRect(next as FloatRect)
223
+ }
224
+ const onUp = (event: PointerEvent): void => {
225
+ // The pointerup may arrive before React re-rendered the last move, so the
226
+ // FINAL geometry is resolved from the event here instead of reading the
227
+ // state (a persisted rect one move behind loses the last few pixels).
228
+ const drag = dragRef.current
229
+ dragRef.current = undefined
230
+ setDragging(false)
231
+ if (drag === undefined) return
232
+ const final = resolveDrag(drag, event.clientX, event.clientY)
233
+ if (drag.kind === 'ball') {
234
+ // One small target, two actions: a press that never really moved
235
+ // RESTORES the window, a real drag parks the ball where it was dropped.
236
+ const dropped = final as FloatPoint
237
+ setBall(dropped)
238
+ persistBall(dropped)
239
+ if (isBallClick(event.clientX - drag.pointerX, event.clientY - drag.pointerY)) {
240
+ setCollapsed(false)
241
+ }
242
+ return
243
+ }
244
+ if (maximized) return
245
+ const nextRect = final as FloatRect
246
+ setRect(nextRect)
247
+ persistRect(nextRect)
248
+ }
249
+ /**
250
+ * A cancelled gesture must NOT be finalised from the event: Chromium
251
+ * reports `clientX/clientY = 0` on pointercancel, which would clamp the
252
+ * window to the top-left corner and PERSIST that. Keep the last painted
253
+ * rect instead.
254
+ */
255
+ const onCancel = (): void => {
256
+ dragRef.current = undefined
257
+ setDragging(false)
258
+ }
259
+ window.addEventListener('pointermove', onMove)
260
+ window.addEventListener('pointerup', onUp)
261
+ window.addEventListener('pointercancel', onCancel)
262
+ return () => {
263
+ window.removeEventListener('pointermove', onMove)
264
+ window.removeEventListener('pointerup', onUp)
265
+ window.removeEventListener('pointercancel', onCancel)
266
+ }
267
+ }, [dragging, maximized, persistRect, persistBall])
268
+
269
+ /**
270
+ * Ctrl/Cmd + wheel zooms the EDITOR TEXT, not the page.
271
+ *
272
+ * A native listener is required: React's `onWheel` is passive at the root, so
273
+ * `preventDefault()` there would be ignored (the browser would page-zoom).
274
+ * CodeMirror picks the new size up through its ResizeObserver; the dispatched
275
+ * resize event is the belt for hosts where the observer does not fire.
276
+ */
277
+ useEffect(() => {
278
+ const element = bodyRef.current
279
+ if (element === null) return
280
+ const onWheel = (event: WheelEvent): void => {
281
+ if (!(event.ctrlKey || event.metaKey)) return
282
+ event.preventDefault()
283
+ const next = stepFloatZoom(fontRef.current, event.deltaY)
284
+ if (next === fontRef.current) return
285
+ // Side effects stay OUT of a state updater: an updater may run twice
286
+ // (StrictMode/concurrent), which would double-dispatch the resize event.
287
+ fontRef.current = next
288
+ setFontSize(next)
289
+ try {
290
+ localStorage.setItem(FONT_KEY, String(next))
291
+ } catch {
292
+ // Private mode: zoom still works for this session.
293
+ }
294
+ window.requestAnimationFrame(() => window.dispatchEvent(new Event('resize')))
295
+ }
296
+ element.addEventListener('wheel', onWheel, { passive: false })
297
+ return () => element.removeEventListener('wheel', onWheel)
298
+ }, [open, collapsed, rectReady])
299
+
300
+ const beginDrag = (event: ReactPointerEvent, drag: DragState): void => {
301
+ if (event.button !== 0) return
302
+ event.preventDefault()
303
+ // Pointer capture keeps the gesture alive over an `<iframe>` (an HTML
304
+ // preview tab in the window) and over any element that would otherwise
305
+ // swallow the pointer. The window-level listeners still do the maths.
306
+ try {
307
+ event.currentTarget.setPointerCapture(event.pointerId)
308
+ } catch {
309
+ // Older engine: the window-level listeners still see the events.
310
+ }
311
+ dragRef.current = drag
312
+ setDragging(true)
313
+ }
314
+
315
+ const toggleMaximize = (): void => {
316
+ if (rect === undefined) return
317
+ const next = readViewport()
318
+ setViewport(next)
319
+ if (maximized) {
320
+ setMaximized(false)
321
+ // A restore always restores an expanded window (a minimized+maximized
322
+ // frame would otherwise stay at 34px with maximized=false).
323
+ setCollapsed(false)
324
+ setRect(clampFloatRect(restoreRectRef.current ?? defaultFloatRect(next), next))
325
+ return
326
+ }
327
+ restoreRectRef.current = rect
328
+ setMaximized(true)
329
+ setCollapsed(false)
330
+ setRect(maximizeFloatRect(next))
331
+ }
332
+
333
+ /**
334
+ * Minimize to the floating ball.
335
+ *
336
+ * The ball appears at the window's own corner first (a minimize should not
337
+ * teleport the affordance across the screen), and keeps wherever the user
338
+ * drags it afterwards — persisted per browser profile.
339
+ */
340
+ const toggleCollapse = (): void => {
341
+ if (!collapsed && rect !== undefined) {
342
+ setBall(ball === undefined ? ballFromRect(rect, viewportRef.current) : clampBallPosition(ball, viewportRef.current))
343
+ }
344
+ setCollapsed(!collapsed)
345
+ }
346
+
347
+ /**
348
+ * The body subtree is memoised on `collapsed` alone so a drag/resize update
349
+ * (one per pointermove) does not re-render the whole EditorPane through the
350
+ * parent: the element identity stays stable while only the frame geometry
351
+ * moves. The pane is still hidden, never unmounted, while minimized — it owns
352
+ * the live CodeMirror buffer.
353
+ *
354
+ * HOOK ORDER: this must stay ABOVE the `!open || rect === undefined` early
355
+ * return. Hooks cannot be skipped on a render, and the first open reaches
356
+ * this line one render later than every previous render did — placing it
357
+ * below the return throws "Rendered more hooks than during the previous
358
+ * render" exactly when the feature is used (in production too; React's
359
+ * error 310 is not dev-only).
360
+ */
361
+ const body = useMemo(() => (
362
+ <div className="rvs-float-body" ref={bodyRef} style={{ display: collapsed ? 'none' : 'flex' }}>
363
+ <EditorPane visible={!collapsed} />
364
+ </div>
365
+ ), [collapsed])
366
+
367
+ if (!open || rect === undefined) return null
368
+
369
+ /**
370
+ * A minimized window is a BALL, not a full-width bar: it keeps a title-bar
371
+ * strip's worth of information (that the editor is out, which file, whether it
372
+ * is dirty) in one small circle the user can park anywhere.
373
+ */
374
+ const ballPoint = clampBallPosition(ball ?? ballFromRect(rect, viewport), viewport)
375
+ const frameStyle: CSSProperties = collapsed
376
+ ? { left: ballPoint.x, top: ballPoint.y, width: FLOAT_BALL_SIZE, height: FLOAT_BALL_SIZE }
377
+ : {
378
+ left: rect.x,
379
+ top: rect.y,
380
+ width: rect.width,
381
+ height: rect.height,
382
+ // The editor theme reads this (see `vsc/editor-theme.ts`); only this
383
+ // frame sets it, so any other editor mount keeps the default 13px.
384
+ // Docking always renders at the default size (the variable is
385
+ // frame-scoped) — the zoom is a property of the floating window.
386
+ ['--rvs-editor-font-size' as never]: `${fontSize}px`,
387
+ }
388
+
389
+ return (
390
+ <div
391
+ className={`rvs-root rvs-float-window${collapsed ? ' rvs-float-mini' : ''}${dragging ? ' dragging' : ''}`}
392
+ style={frameStyle}
393
+ data-rvs-float={collapsed ? 'collapsed' : 'open'}
394
+ data-rvs-float-maximized={maximized ? 'true' : undefined}
395
+ title={collapsed ? t('float.ballHint') : undefined}
396
+ onPointerDown={collapsed
397
+ ? (event) => {
398
+ if (event.button !== 0) return
399
+ // The whole ball is the drag handle; a press that does not move
400
+ // restores the window on release (see the pointerup handler).
401
+ beginDrag(event, { kind: 'ball', pointerX: event.clientX, pointerY: event.clientY, start: ballPoint })
402
+ }
403
+ : undefined}
404
+ >
405
+ {collapsed
406
+ ? (
407
+ <div
408
+ className="rvs-float-ball"
409
+ role="button"
410
+ tabIndex={0}
411
+ aria-label={t('float.ballHint')}
412
+ title={t('float.ballHint')}
413
+ // Keyboard access: the ball is the only affordance left, so it must
414
+ // also restore without a pointer. Pointer clicks are decided in the
415
+ // pointerup handler (click vs drag), never here — a drag that ends
416
+ // on the ball must not also restore it.
417
+ onKeyDown={(event) => {
418
+ if (event.key === 'Enter' || event.key === ' ') {
419
+ event.preventDefault()
420
+ setCollapsed(false)
421
+ }
422
+ }}
423
+ >
424
+ <span className="rvs-float-ball-glyph" aria-hidden="true">▣</span>
425
+ {activeTab !== undefined && activeTab.dirty && (
426
+ <span className="rvs-float-ball-dot" title={t('editor.unsaved')} aria-hidden="true" />
427
+ )}
428
+ </div>
429
+ )
430
+ : (
431
+ <div
432
+ className="rvs-float-titlebar"
433
+ onPointerDown={(event) => {
434
+ if (rect === undefined || maximized) return
435
+ if ((event.target as HTMLElement).closest('button') !== null) return
436
+ beginDrag(event, { kind: 'move', pointerX: event.clientX, pointerY: event.clientY, rect })
437
+ }}
438
+ onDoubleClick={(event) => {
439
+ if ((event.target as HTMLElement).closest('button') !== null) return
440
+ toggleMaximize()
441
+ }}
442
+ title={t('float.dragHint')}
443
+ >
444
+ <span className="rvs-float-glyph" aria-hidden="true">▣</span>
445
+ <span className="rvs-float-title">{t('float.title')}</span>
446
+ {activeTab !== undefined && (
447
+ <span className="rvs-float-file" title={activeTab.fullPath}>{activeTab.rel}</span>
448
+ )}
449
+ <span className="rvs-float-spacer" />
450
+ <button
451
+ type="button"
452
+ className="rvs-float-btn"
453
+ title={t('float.minimize')}
454
+ aria-label={t('float.minimize')}
455
+ onClick={toggleCollapse}
456
+ >
457
+ —
458
+ </button>
459
+ <button
460
+ type="button"
461
+ className="rvs-float-btn"
462
+ title={maximized ? t('float.restore') : t('float.maximize')}
463
+ aria-label={maximized ? t('float.restore') : t('float.maximize')}
464
+ onClick={toggleMaximize}
465
+ >
466
+ {maximized ? '❐' : '□'}
467
+ </button>
468
+ <button
469
+ type="button"
470
+ className="rvs-float-btn rvs-float-close"
471
+ title={t('float.dock')}
472
+ aria-label={t('float.dock')}
473
+ onClick={() => { store.dockFloatingEditor() }}
474
+ >
475
+ <IconClose size={12} />
476
+ </button>
477
+ </div>
478
+ )}
479
+ {body}
480
+ <DialogHost />
481
+ {!collapsed && !maximized && RESIZE_HANDLES.map(handle => (
482
+ <div
483
+ key={handle}
484
+ className={`rvs-float-resize rvs-float-resize-${handle}`}
485
+ data-rvs-float-resize={handle}
486
+ onPointerDown={(event) => {
487
+ if (rect === undefined) return
488
+ beginDrag(event, { kind: 'resize', handle, pointerX: event.clientX, pointerY: event.clientY, rect })
489
+ }}
490
+ />
491
+ ))}
492
+ </div>
493
+ )
494
+ }
@@ -0,0 +1,22 @@
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
+ import { useExplorer } from './store.tsx'
10
+ import { Btn } from './ui.tsx'
11
+
12
+ export function FloatingEditorPlaceholder(): ReactNode {
13
+ const store = useExplorer()
14
+ const t = store.t
15
+ return (
16
+ <div className="rvs-float-placeholder">
17
+ <div className="rvs-float-placeholder-title">{t('float.placeholder')}</div>
18
+ <div className="rvs-float-placeholder-text">{t('float.placeholderHint')}</div>
19
+ <Btn kind="primary" onClick={() => { store.dockFloatingEditor() }}>{t('float.dockBack')}</Btn>
20
+ </div>
21
+ )
22
+ }
@@ -6,10 +6,15 @@
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'
12
16
  import { EditorPane } from './EditorPane.tsx'
17
+ import { FloatingEditorPlaceholder } from './FloatingPlaceholder.tsx'
13
18
  import { useBootstrap, useExplorer } from './store.tsx'
14
19
  import { DialogHost } from './vsc/kit.tsx'
15
20
 
@@ -27,7 +32,7 @@ export function RemoteEditorView(props: { ctx: Context }): ReactNode {
27
32
  <DialogHost />
28
33
  <div className="rvs-root rvs-center">
29
34
  <div className="rvs-column">
30
- <div className="rvs-globalbar">
35
+ <div className="rvs-globalbar" title={store.floatAvailable ? store.t('float.dockViewHint') : store.t('float.unavailable')}>
31
36
  <select
32
37
  className="rvs-select"
33
38
  value={store.workspace?.id ?? ''}
@@ -46,7 +51,7 @@ export function RemoteEditorView(props: { ctx: Context }): ReactNode {
46
51
  </span>
47
52
  )}
48
53
  </div>
49
- <EditorPane visible />
54
+ {store.editorSurface === 'center' ? <EditorPane visible /> : <FloatingEditorPlaceholder />}
50
55
  </div>
51
56
  </div>
52
57
  </>
@@ -1032,6 +1032,22 @@ export function RemoteExplorer(props: { showHidden: boolean; onToggleHidden: ()
1032
1032
  ]
1033
1033
  if (entry !== undefined) {
1034
1034
  entries.push({ label: t('files.open'), onSelect: () => openEntry(entry), separated: true })
1035
+ if (!entry.isDir) {
1036
+ // 「在悬浮编辑器中打开」: the editor surface floats above the app shell,
1037
+ // above whatever conversation view is selected. Disabled only when the
1038
+ // host has no frame-wide overlay slot to host the window (see
1039
+ // `FloatingEditorWindow`'s availability handshake).
1040
+ //
1041
+ // Deliberately opens the FILE, not the working-tree diff the adjacent
1042
+ // "Open" entry may choose for a changed file: this is the editor, and a
1043
+ // diff is not an editing surface (SCM's own entry keeps the diff
1044
+ // semantics because that list is about changes).
1045
+ entries.push({
1046
+ label: t('files.openInFloat'),
1047
+ onSelect: () => { store.openInFloatEditor(entry.path) },
1048
+ disabled: !store.floatAvailable,
1049
+ })
1050
+ }
1035
1051
  if (!entry.isDir && isPreviewOnlyFile(entry.path)) {
1036
1052
  entries.push({ label: t('files.openAsText'), onSelect: () => store.openFile(entry.path) })
1037
1053
  }
@@ -11,6 +11,7 @@
11
11
  import type { Context } from '@deepseek-ai/cordis'
12
12
  import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
13
13
  import { EditorPane } from './EditorPane.tsx'
14
+ import { FloatingEditorPlaceholder } from './FloatingPlaceholder.tsx'
14
15
  import { RemoteExplorer } from './RemoteExplorer.tsx'
15
16
  import { SearchView } from './SearchView.tsx'
16
17
  import { HistoryPanel } from './scm/HistoryPanel.tsx'
@@ -171,9 +172,14 @@ function Shell(props: { ctx: Context; visible: boolean }): ReactNode {
171
172
  * that owns it was gone AND the centre tab mounted a second EditorPane on
172
173
  * the same store tab — two live buffers with independent undo state, the one
173
174
  * state this file declares must never happen.
175
+ *
176
+ * Only the CENTRE pixel surface triggers this redirect. While the editor
177
+ * floats the sidebar keeps whatever view it had: the editor panel renders a
178
+ * "docked elsewhere" placeholder (see panelFor), and on docking the user
179
+ * lands back where they were.
174
180
  */
175
181
  useEffect(() => {
176
- if (store.editorSurface !== 'sidebar' && view === 'editor') go('explorer')
182
+ if (store.editorSurface === 'center' && view === 'editor') go('explorer')
177
183
  }, [store.editorSurface, view, go])
178
184
 
179
185
  useEffect(() => {
@@ -264,11 +270,14 @@ function Shell(props: { ctx: Context; visible: boolean }): ReactNode {
264
270
  // The sidebar editor is the FALLBACK surface, used only when the host
265
271
  // has no `conversation.view` slot to mount the editor in (index.tsx
266
272
  // stays on 'sidebar' then). It must not render while the centre surface
267
- // owns the editor: both read the same store, so two live editors for one
268
- // tab fight over the scroll position and the hidden one — sitting at
269
- // scrollTop 0 — wins, which is exactly why a tab switch kept snapping
270
- // back to the top of the file.
271
- return editorInSidebar ? <EditorPane visible={panelLive('editor')} /> : null
273
+ // or the floating window owns the editor: all of them read the same
274
+ // store, so two live editors for one tab fight over the scroll position
275
+ // and the hidden one — sitting at scrollTop 0 — wins, which is exactly
276
+ // why a tab switch kept snapping back to the top of the file.
277
+ if (editorInSidebar) return <EditorPane visible={panelLive('editor')} />
278
+ // Floating the editor away from the sidebar: the panel stays reachable
279
+ // (so docking returns here) but shows where the editor went.
280
+ return store.floatOpen ? <FloatingEditorPlaceholder /> : null
272
281
  default:
273
282
  return (
274
283
  <RemoteExplorer
@@ -240,12 +240,13 @@ export function SearchView(): ReactNode {
240
240
  }
241
241
 
242
242
  /**
243
- * Right-click on a name result: the four things one does with a found path.
243
+ * Right-click on a name result: what one does with a found path.
244
244
  *
245
245
  * "Relative" is relative to the REPOSITORY when the workspace sits inside one
246
246
  * (the same relative form the tree's own menu copies), else absolute — which
247
247
  * is what makes the copied value paste straight into a shell that already ran
248
- * `cd` into the repo.
248
+ * `cd` into the repo. Directories only get the copy/reveal/terminal entries:
249
+ * there is no file to open in the editor.
249
250
  */
250
251
  const openHitMenu = (event: React.MouseEvent, hit: SearchHit): void => {
251
252
  event.preventDefault()
@@ -255,7 +256,13 @@ export function SearchView(): ReactNode {
255
256
  menu.open(event.clientX, event.clientY, [
256
257
  { label: t('files.copyAbs'), onSelect: () => { void copyText(abs); toast(t('files.copiedClipboard')) } },
257
258
  { label: t('files.copyRel'), onSelect: () => { void copyText(rel); toast(t('files.copiedClipboard')) } },
258
- { label: t('search.revealInExplorer'), onSelect: () => { store.revealInExplorer(dir) }, separated: true },
259
+ {
260
+ label: t('files.openInFloat'),
261
+ onSelect: () => { store.openInFloatEditor(abs) },
262
+ disabled: !store.floatAvailable || hit.isDir,
263
+ separated: true,
264
+ },
265
+ { label: t('search.revealInExplorer'), onSelect: () => { store.revealInExplorer(dir) } },
259
266
  { label: t('search.openInTerminal'), onSelect: () => { store.openTerminalAt(dir) } },
260
267
  ])
261
268
  }