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.
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
11
11
  import { api } from './api.ts'
12
12
  import { CodeEditor, type CodeEditorHandle, type CodeEditorViewState } from './CodeEditor.tsx'
13
13
  import { sameLocalDir } from './composer.ts'
14
+ import { editorViewStateOf, pruneEditorViewStates, registerLiveEditor, rememberEditorViewState } from './live-editor.ts'
14
15
  import { useRemoteChatTarget } from './use-remote-chat-target.ts'
15
16
  import { useExplorer, gotoChange, markChangeStale, undoChange, type EditorTab } from './store.tsx'
16
17
  import { DiffView } from './scm/DiffView.tsx'
@@ -229,23 +230,26 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
229
230
  * buffer rather than the previous file's undo stack, which is what we want,
230
231
  * but it also reset the scroll and the caret.
231
232
  *
232
- * The editor REPORTS its position (see `CodeEditor`'s `onViewState`) instead
233
- * of this component reading it back through `editorRef`, because the ref is
234
- * already `null` by the time any cleanup here could run: React clears a
235
- * child's ref before running the parent's cleanup. Both earlier attempts
236
- * failed on exactly that — a `useEffect` keyed on the active tab captured the
237
- * INCOMING tab's editor (always scrollTop 0), and a `useLayoutEffect` cleanup
238
- * found `editorRef.current === null`. `.spike/tabswitch-check.mjs` reproduces
239
- * both failures and pins the working wiring.
233
+ * The map lives in `live-editor.ts`, NOT in a `useRef` here, because the pane
234
+ * is unmounted when the editor floats out (and again when it docks back): a
235
+ * per-component map would lose the position on the one surface swap that
236
+ * most needs it. It survives there for the same reason the store does.
240
237
  *
241
238
  * Keyed by tab id alone, which is safe because only ONE `EditorPane` is ever
242
239
  * mounted: the centre surface when the host has a `conversation.view` slot,
243
- * otherwise the sidebar fallback. With two live editors for the same tab the
244
- * hidden one would report its untouched scrollTop 0 and overwrite the visible
245
- * one's, snapping every switch back to the top — so `RemoteRoot` gates both
246
- * mounts on `editorSurface` and must keep doing so.
240
+ * the floating window while the editor floats, otherwise the sidebar
241
+ * fallback. With two live editors for the same tab the hidden one would
242
+ * report its untouched scrollTop 0 and overwrite the visible one's, snapping
243
+ * every switch back to the top — so `RemoteRoot`, `RemoteEditorView` and the
244
+ * floating window all gate their mounts on `editorSurface` and must keep
245
+ * doing so.
246
+ *
247
+ * The editor REPORTS its position (see `CodeEditor`'s `onViewState`) instead
248
+ * of this component reading it back through `editorRef`, because the ref is
249
+ * already `null` by the time any cleanup here could run: React clears a
250
+ * child's ref before running the parent's cleanup. `.spike/tabswitch-check.mjs`
251
+ * reproduces the failed variants and pins the working wiring.
247
252
  */
248
- const viewStates = useRef(new Map<string, CodeEditorViewState>())
249
253
  /**
250
254
  * Store a reading under the tab that produced it.
251
255
  *
@@ -261,20 +265,17 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
261
265
  * accept a report for any tab.
262
266
  */
263
267
  const rememberViewState = useCallback((tabId: string, state: CodeEditorViewState): void => {
264
- viewStates.current.set(tabId, state)
268
+ rememberEditorViewState(tabId, state)
265
269
  }, [])
266
270
 
267
271
  /**
268
- * Closed tabs may not keep their scroll + caret. This map is keyed by tab id
269
- * and was only ever written, and the pane is mounted for the life of the
270
- * page: a session that opened a few hundred files held a state object for
271
- * every one of them, long after the tab was gone.
272
+ * Closed tabs may not keep their scroll + caret. The map is keyed by tab id
273
+ * and is only ever written: a session that opened a few hundred files would
274
+ * otherwise hold a state object for every one of them, long after the tab is
275
+ * gone.
272
276
  */
273
277
  useEffect(() => {
274
- const live = new Set(tabs.map(tab => tab.id))
275
- for (const id of [...viewStates.current.keys()]) {
276
- if (!live.has(id)) viewStates.current.delete(id)
277
- }
278
+ pruneEditorViewStates(new Set(tabs.map(tab => tab.id)))
278
279
  }, [tabs])
279
280
 
280
281
  /**
@@ -295,6 +296,20 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
295
296
  && !activeTab.loading
296
297
  && activeTab.error === undefined
297
298
 
299
+ /**
300
+ * Advertise the live editor so the store can snapshot it before a surface
301
+ * flip (see `captureLiveEditor`): the floating window is a different mount of
302
+ * the SAME pane, so switching to it unmounts this component.
303
+ *
304
+ * The getter is `() => editorRef.current`, not the handle itself: the handle
305
+ * only exists after `CodeEditor` mounts, and it is cleared before the
306
+ * cleanup that would otherwise run here.
307
+ */
308
+ useEffect(() => {
309
+ if (!editorMounted || activeTab === undefined) return
310
+ return registerLiveEditor(activeTab.id, () => editorRef.current)
311
+ }, [editorMounted, activeTab?.id])
312
+
298
313
  const saveActive = async (): Promise<void> => {
299
314
  const tab = activeTab
300
315
  if (tab === undefined) return
@@ -304,6 +319,11 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
304
319
  toast(t('editor.needSource'), true)
305
320
  return
306
321
  }
322
+ // The store advances the disk baseline and re-derives the dirty flag on the
323
+ // MOUNTED editor through the module-level registration (see
324
+ // `markLiveEditorSaved`), so this works even when the pane is remounted
325
+ // while the write is in flight. Nothing is left to do here beyond handing
326
+ // it the live buffer.
307
327
  await store.saveTab(tab.id, text)
308
328
  }
309
329
 
@@ -474,6 +494,7 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
474
494
  key={editorKey(activeTab)}
475
495
  ref={editorRef}
476
496
  initial={activeTab.text}
497
+ savedText={activeTab.baseline}
477
498
  path={activeTab.fullPath}
478
499
  tabId={activeTab.id}
479
500
  readOnly={activeTab.truncated}
@@ -488,9 +509,9 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
488
509
  {...(activeTab.changeSet === undefined ? {} : { changeSet: activeTab.changeSet })}
489
510
  {...(activeTab.revealHunkLine === undefined ? {} : { revealHunkLine: activeTab.revealHunkLine })}
490
511
  {...(activeTab.revealHunkNonce === undefined ? {} : { revealHunkNonce: activeTab.revealHunkNonce })}
491
- {...(viewStates.current.get(activeTab.id) === undefined
512
+ {...(editorViewStateOf(activeTab.id) === undefined
492
513
  ? {}
493
- : { viewState: viewStates.current.get(activeTab.id) })}
514
+ : { viewState: editorViewStateOf(activeTab.id) })}
494
515
  onTokens={setTokenCount}
495
516
  onViewState={rememberViewState}
496
517
  onStepHunk={(dir) => { gotoChange(activeTab.id, dir) }}
@@ -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
  </>