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.
- package/README.en.md +15 -2
- package/README.md +12 -1
- package/lib/client.js +1235 -53
- package/lib/index.js +1 -1
- package/lib/p2-router-fs.js +1 -1
- 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/lib/types/workspace-title.d.ts +2 -2
- package/lib/{worlds-DnzpX3Hv.js → worlds-a8nqW9mi.js} +2 -2
- 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
- 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
|
|
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
|
-
//
|
|
268
|
-
//
|
|
269
|
-
// scrollTop 0 — wins, which is exactly
|
|
270
|
-
// back to the top of the file.
|
|
271
|
-
|
|
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:
|
|
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
|
-
{
|
|
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
|
}
|