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
|
@@ -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
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
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
|
-
|
|
268
|
+
rememberEditorViewState(tabId, state)
|
|
265
269
|
}, [])
|
|
266
270
|
|
|
267
271
|
/**
|
|
268
|
-
* Closed tabs may not keep their scroll + caret.
|
|
269
|
-
* and
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
-
|
|
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
|
-
{...(
|
|
512
|
+
{...(editorViewStateOf(activeTab.id) === undefined
|
|
492
513
|
? {}
|
|
493
|
-
: { viewState:
|
|
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
|
</>
|