dsh-remote-vs 0.8.8 → 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 +1507 -59
- 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/TerminalView.d.ts +3 -0
- package/lib/types/client/XTerm.d.ts +6 -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/remote-file-open-target.d.ts +0 -22
- package/lib/types/client/shell.d.ts +29 -0
- package/lib/types/client/store.d.ts +160 -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 +61 -0
- package/src/client/RemoteRoot.tsx +16 -7
- package/src/client/SearchView.tsx +49 -2
- package/src/client/TerminalView.tsx +53 -2
- package/src/client/XTerm.tsx +15 -0
- package/src/client/float-editor.ts +271 -0
- package/src/client/float-tab-trigger.ts +51 -0
- package/src/client/i18n.ts +32 -0
- package/src/client/index.tsx +48 -1
- package/src/client/live-editor.ts +155 -0
- package/src/client/remote-file-open-target.ts +1 -5
- package/src/client/remote-file-open.ts +2 -2
- package/src/client/scm/ScmPanel.tsx +42 -1
- package/src/client/shell.ts +35 -0
- package/src/client/store.tsx +367 -16
- package/src/client/vsc/editor-theme.ts +6 -1
- package/src/client/vsc/vsc.css +235 -0
|
@@ -20,9 +20,10 @@ import {
|
|
|
20
20
|
type ContentHit,
|
|
21
21
|
} from '../search-ops.ts'
|
|
22
22
|
import { searchFiles, type SearchHit } from './scm/actions.ts'
|
|
23
|
-
import { useExplorer } from './store.tsx'
|
|
23
|
+
import { repoRelative, useExplorer } from './store.tsx'
|
|
24
24
|
import { api } from './api.ts'
|
|
25
25
|
import {
|
|
26
|
+
ContextMenu,
|
|
26
27
|
FileIcon,
|
|
27
28
|
IconCase,
|
|
28
29
|
IconContentSearch,
|
|
@@ -33,6 +34,9 @@ import {
|
|
|
33
34
|
MiniSpinner,
|
|
34
35
|
Segmented,
|
|
35
36
|
baseName,
|
|
37
|
+
copyText,
|
|
38
|
+
toast,
|
|
39
|
+
useContextMenu,
|
|
36
40
|
} from './vsc/kit.tsx'
|
|
37
41
|
|
|
38
42
|
type Mode = 'name' | 'content'
|
|
@@ -77,7 +81,7 @@ function relativeTo(root: string, full: string): string {
|
|
|
77
81
|
|
|
78
82
|
export function SearchView(): ReactNode {
|
|
79
83
|
const store = useExplorer()
|
|
80
|
-
const { t, alias, root } = store
|
|
84
|
+
const { t, alias, root, repoRoot } = store
|
|
81
85
|
const [mode, setModeState] = useState<Mode>(readMode)
|
|
82
86
|
const [query, setQuery] = useState('')
|
|
83
87
|
/** Absolute remote directory the search runs in ('' = workspace root). */
|
|
@@ -93,6 +97,7 @@ export function SearchView(): ReactNode {
|
|
|
93
97
|
const [ran, setRan] = useState(false)
|
|
94
98
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
95
99
|
const seq = useRef(0)
|
|
100
|
+
const menu = useContextMenu()
|
|
96
101
|
|
|
97
102
|
const effectiveScope = scope === '' ? root : scope
|
|
98
103
|
const scopeLabel = useMemo(() => {
|
|
@@ -224,6 +229,44 @@ export function SearchView(): ReactNode {
|
|
|
224
229
|
store.openFile(path, line === undefined ? undefined : { line })
|
|
225
230
|
}
|
|
226
231
|
|
|
232
|
+
/** Absolute path of one NAME hit, plus the DIRECTORY a menu action should use. */
|
|
233
|
+
const nameHitPaths = (hit: SearchHit): { abs: string; dir: string } => {
|
|
234
|
+
const base = effectiveScope.replace(/\/+$/, '')
|
|
235
|
+
const abs = `${base}/${hit.path}`
|
|
236
|
+
// A directory hit (only the `find` fallback reports those) is its own
|
|
237
|
+
// folder; a file hit's actions target the folder it lives in.
|
|
238
|
+
const dir = hit.isDir ? abs : (abs.slice(0, abs.lastIndexOf('/')) || '/')
|
|
239
|
+
return { abs, dir }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Right-click on a name result: what one does with a found path.
|
|
244
|
+
*
|
|
245
|
+
* "Relative" is relative to the REPOSITORY when the workspace sits inside one
|
|
246
|
+
* (the same relative form the tree's own menu copies), else absolute — which
|
|
247
|
+
* is what makes the copied value paste straight into a shell that already ran
|
|
248
|
+
* `cd` into the repo. Directories only get the copy/reveal/terminal entries:
|
|
249
|
+
* there is no file to open in the editor.
|
|
250
|
+
*/
|
|
251
|
+
const openHitMenu = (event: React.MouseEvent, hit: SearchHit): void => {
|
|
252
|
+
event.preventDefault()
|
|
253
|
+
event.stopPropagation()
|
|
254
|
+
const { abs, dir } = nameHitPaths(hit)
|
|
255
|
+
const rel = repoRelative(repoRoot, abs) || abs
|
|
256
|
+
menu.open(event.clientX, event.clientY, [
|
|
257
|
+
{ label: t('files.copyAbs'), onSelect: () => { void copyText(abs); toast(t('files.copiedClipboard')) } },
|
|
258
|
+
{ label: t('files.copyRel'), onSelect: () => { void copyText(rel); toast(t('files.copiedClipboard')) } },
|
|
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) } },
|
|
266
|
+
{ label: t('search.openInTerminal'), onSelect: () => { store.openTerminalAt(dir) } },
|
|
267
|
+
])
|
|
268
|
+
}
|
|
269
|
+
|
|
227
270
|
const groups = useMemo(() => groupHits(contentHits), [contentHits])
|
|
228
271
|
|
|
229
272
|
/**
|
|
@@ -351,6 +394,7 @@ export function SearchView(): ReactNode {
|
|
|
351
394
|
className="rvs-hit"
|
|
352
395
|
title={hit.path}
|
|
353
396
|
onClick={() => openHit(`${(effectiveScope === '' ? root : effectiveScope).replace(/\/+$/, '')}/${hit.path}`)}
|
|
397
|
+
onContextMenu={(event) => { openHitMenu(event, hit) }}
|
|
354
398
|
>
|
|
355
399
|
<span className="rvs-node-icon">
|
|
356
400
|
{hit.isDir ? <span style={{ color: 'var(--rvs-fg3)' }}>▸</span> : <FileIcon path={hit.path} size={14} />}
|
|
@@ -404,6 +448,9 @@ export function SearchView(): ReactNode {
|
|
|
404
448
|
</>
|
|
405
449
|
)}
|
|
406
450
|
</div>
|
|
451
|
+
{menu.menu !== undefined && (
|
|
452
|
+
<ContextMenu x={menu.menu.x} y={menu.menu.y} entries={menu.menu.entries} onClose={menu.close} />
|
|
453
|
+
)}
|
|
407
454
|
</div>
|
|
408
455
|
)
|
|
409
456
|
}
|
|
@@ -11,6 +11,8 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
|
11
11
|
import type { RemoteHostSummary } from '../protocol.ts'
|
|
12
12
|
import { RemoteApiError, api } from './api.ts'
|
|
13
13
|
import { XTerm } from './XTerm.tsx'
|
|
14
|
+
import { cdCommand } from './shell.ts'
|
|
15
|
+
import type { TerminalRequest } from './store.tsx'
|
|
14
16
|
import { optionStyle } from './ui.tsx'
|
|
15
17
|
import {
|
|
16
18
|
IconButton,
|
|
@@ -32,6 +34,8 @@ interface TermTab {
|
|
|
32
34
|
/** Bumped to force a fresh connection (connect / reconnect). */
|
|
33
35
|
nonce: number
|
|
34
36
|
connected: boolean
|
|
37
|
+
/** Command typed once the shell reports ready (a menu entry's `cd`). */
|
|
38
|
+
initialCommand?: string
|
|
35
39
|
/** Human-readable reason the tab is not connected. */
|
|
36
40
|
note?: string
|
|
37
41
|
}
|
|
@@ -84,10 +88,21 @@ function newId(): string {
|
|
|
84
88
|
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The last "open a terminal here" request this page already acted on.
|
|
93
|
+
*
|
|
94
|
+
* Module scope, not a component ref: the panel is normally kept mounted, but a
|
|
95
|
+
* remount would reset a ref and re-open a tab for the SAME old request. A new
|
|
96
|
+
* request always carries a larger id, so one counter serves both goals.
|
|
97
|
+
*/
|
|
98
|
+
let openedTerminalRequest = 0
|
|
99
|
+
|
|
87
100
|
export function TerminalView(props: {
|
|
88
101
|
t: (key: string) => string
|
|
89
102
|
hosts: RemoteHostSummary[]
|
|
90
103
|
visible: boolean
|
|
104
|
+
/** A pending "open a terminal here" request from the search results' menu. */
|
|
105
|
+
request?: TerminalRequest
|
|
91
106
|
}): ReactNode {
|
|
92
107
|
const t = props.t
|
|
93
108
|
const [alias, setAlias] = useState<string>(readAlias)
|
|
@@ -126,13 +141,48 @@ export function TerminalView(props: {
|
|
|
126
141
|
setTabs(prev => prev.map(tab => (tab.id === id ? { ...tab, ...next } : tab)))
|
|
127
142
|
}, [])
|
|
128
143
|
|
|
129
|
-
const openTab = useCallback((host: string): void => {
|
|
144
|
+
const openTab = useCallback((host: string, initialCommand?: string): void => {
|
|
130
145
|
if (host === '') return
|
|
131
|
-
const tab: TermTab = {
|
|
146
|
+
const tab: TermTab = {
|
|
147
|
+
id: newId(),
|
|
148
|
+
alias: host,
|
|
149
|
+
nonce: 0,
|
|
150
|
+
connected: true,
|
|
151
|
+
...(initialCommand === undefined ? {} : { initialCommand }),
|
|
152
|
+
}
|
|
132
153
|
setTabs(prev => [...prev, tab])
|
|
133
154
|
setActiveId(tab.id)
|
|
134
155
|
}, [])
|
|
135
156
|
|
|
157
|
+
/**
|
|
158
|
+
* "Open a terminal here" (a search result's menu): open one tab whose shell
|
|
159
|
+
* lands in that folder, on the host the folder BELONGS to.
|
|
160
|
+
*
|
|
161
|
+
* The alias must come from the request, not from `effectiveAlias`: the panel
|
|
162
|
+
* remembers whatever host the user last ran a shell on, so a menu action on a
|
|
163
|
+
* workspace bound elsewhere used to open the shell on the wrong machine and
|
|
164
|
+
* the `cd` failed with "No such file or directory" for a path that is fine on
|
|
165
|
+
* its own host.
|
|
166
|
+
*
|
|
167
|
+
* The folder is reached by TYPING `cd` once the PTY reports ready (see
|
|
168
|
+
* `XTerm`'s `initialInput`) — the host's terminal route has no "start in this
|
|
169
|
+
* directory" parameter, and inventing one would mean a host-side change for a
|
|
170
|
+
* display feature. The request id makes a repeat for the same folder still
|
|
171
|
+
* open a second tab.
|
|
172
|
+
*/
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const request = props.request
|
|
175
|
+
if (request === undefined || request.id === openedTerminalRequest) return
|
|
176
|
+
openedTerminalRequest = request.id
|
|
177
|
+
const target = request.alias !== '' && props.hosts.some(host => host.alias === request.alias)
|
|
178
|
+
? request.alias
|
|
179
|
+
: effectiveAlias
|
|
180
|
+
// Keep the header picker in step with the tab that just opened, so the next
|
|
181
|
+
// "+" continues on the same host.
|
|
182
|
+
if (target !== alias) setAlias(target)
|
|
183
|
+
openTab(target, cdCommand(request.dir))
|
|
184
|
+
}, [props.request, props.hosts, effectiveAlias, alias, openTab])
|
|
185
|
+
|
|
136
186
|
const closeTab = useCallback(async (tab: TermTab): Promise<void> => {
|
|
137
187
|
const ok = await confirmDialog({
|
|
138
188
|
title: t('term.closeTitle'),
|
|
@@ -276,6 +326,7 @@ export function TerminalView(props: {
|
|
|
276
326
|
rows: 24,
|
|
277
327
|
})}
|
|
278
328
|
visible={props.visible && tab.id === active?.id}
|
|
329
|
+
{...(tab.initialCommand === undefined ? {} : { initialInput: tab.initialCommand })}
|
|
279
330
|
onReady={(id) => patchTab(tab.id, { session: id, connected: true, note: undefined })}
|
|
280
331
|
onExit={(code, message) => {
|
|
281
332
|
if (message === 'terminal session expired') {
|
package/src/client/XTerm.tsx
CHANGED
|
@@ -106,6 +106,12 @@ export function XTerm(props: {
|
|
|
106
106
|
fontFamily?: string
|
|
107
107
|
/** When the parent sub-tab hides/shows this view without unmounting. */
|
|
108
108
|
visible?: boolean
|
|
109
|
+
/**
|
|
110
|
+
* Line typed into the fresh shell once its PTY reports ready (a menu entry's
|
|
111
|
+
* `cd`). Sent ONCE per mount: a reconnect is a new mount and thus a new shell,
|
|
112
|
+
* which correctly re-runs the line.
|
|
113
|
+
*/
|
|
114
|
+
initialInput?: string
|
|
109
115
|
}): ReactNode {
|
|
110
116
|
const hostRef = useRef<HTMLDivElement>(null)
|
|
111
117
|
const propsRef = useRef(props)
|
|
@@ -143,6 +149,8 @@ export function XTerm(props: {
|
|
|
143
149
|
|
|
144
150
|
const ws = new WebSocket(propsRef.current.wsUrl)
|
|
145
151
|
let opened = false
|
|
152
|
+
/** The seeded line is typed at most once per mount (a reconnect re-mounts). */
|
|
153
|
+
let seeded = false
|
|
146
154
|
let exitReported = false
|
|
147
155
|
const reportExit = (code: number | null, message?: string): void => {
|
|
148
156
|
if (exitReported) return
|
|
@@ -208,6 +216,13 @@ export function XTerm(props: {
|
|
|
208
216
|
}
|
|
209
217
|
if (frame.t === 'ready') {
|
|
210
218
|
propsRef.current.onReady(frame.id)
|
|
219
|
+
// The shell exists now: type the seeded line (a menu entry's `cd`), as a
|
|
220
|
+
// normal input line so the shell echoes it and redraws its prompt.
|
|
221
|
+
const seed = propsRef.current.initialInput
|
|
222
|
+
if (!seeded && seed !== undefined && seed !== '') {
|
|
223
|
+
seeded = true
|
|
224
|
+
send(ws, { t: 'in', d: `${seed}\n` })
|
|
225
|
+
}
|
|
211
226
|
} else if (frame.t === 'out') {
|
|
212
227
|
term.write(frame.d)
|
|
213
228
|
} else if (frame.t === 'exit') {
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure geometry and surface rules for the floating editor.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of React and of the DOM on purpose: the drag / resize / zoom maths
|
|
5
|
+
* is the part that is easy to get subtly wrong (negative sizes, a window that
|
|
6
|
+
* escapes the viewport, a stale stored rect from a larger screen), and this
|
|
7
|
+
* module is what `scripts/check-float-editor.mjs` drives directly. The window
|
|
8
|
+
* component is then only wiring.
|
|
9
|
+
*
|
|
10
|
+
* The SURFACE rule lives here too: the plugin has exactly one live editor, and
|
|
11
|
+
* which mount owns it is a three-way decision (centre conversation view,
|
|
12
|
+
* sidebar fallback, floating window). Encoding it as a pure function keeps the
|
|
13
|
+
* invariant testable and out of three separate `=== 'center'` comparisons.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Where the editor pane is mounted right now. */
|
|
17
|
+
export type EditorSurface = 'center' | 'sidebar' | 'float'
|
|
18
|
+
|
|
19
|
+
/** The persisted layout of the floating window, in viewport pixels. */
|
|
20
|
+
export interface FloatRect {
|
|
21
|
+
x: number
|
|
22
|
+
y: number
|
|
23
|
+
width: number
|
|
24
|
+
height: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Viewport size the geometry is clamped to. */
|
|
28
|
+
export interface FloatViewport {
|
|
29
|
+
width: number
|
|
30
|
+
height: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A resize grip: the compass directions of the eight handles. */
|
|
34
|
+
export type FloatResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
|
35
|
+
|
|
36
|
+
/** Smallest window the editor can be squeezed into and stay usable. */
|
|
37
|
+
export const FLOAT_MIN_WIDTH = 360
|
|
38
|
+
export const FLOAT_MIN_HEIGHT = 220
|
|
39
|
+
/** Height of the title bar; a minimized window keeps exactly this much. */
|
|
40
|
+
export const FLOAT_TITLEBAR_HEIGHT = 34
|
|
41
|
+
/** Gap a maximized window keeps from the viewport edges. */
|
|
42
|
+
export const FLOAT_VIEWPORT_EDGE = 8
|
|
43
|
+
|
|
44
|
+
/** Editor font-size range for Ctrl+wheel zoom (px). */
|
|
45
|
+
export const FLOAT_FONT_MIN = 9
|
|
46
|
+
export const FLOAT_FONT_MAX = 28
|
|
47
|
+
export const FLOAT_FONT_DEFAULT = 13
|
|
48
|
+
|
|
49
|
+
/** Diameter of the minimized ball, in pixels. */
|
|
50
|
+
export const FLOAT_BALL_SIZE = 44
|
|
51
|
+
/**
|
|
52
|
+
* How far a pointer may travel and still count as a CLICK: the minimized window
|
|
53
|
+
* is a single 44px ball, so "click restores / drag moves" has to be decided
|
|
54
|
+
* from one pointer gesture.
|
|
55
|
+
*/
|
|
56
|
+
export const FLOAT_BALL_CLICK_SLOP = 4
|
|
57
|
+
|
|
58
|
+
/** A point in viewport coordinates (the minimized ball's origin). */
|
|
59
|
+
export interface FloatPoint {
|
|
60
|
+
x: number
|
|
61
|
+
y: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clamp(value: number, min: number, max: number): number {
|
|
65
|
+
return Math.min(max, Math.max(min, value))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when the stored value is a usable finite number (not NaN/Infinity). */
|
|
69
|
+
function finite(value: unknown): value is number {
|
|
70
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The effective editor surface: floating wins, otherwise the base mount. */
|
|
74
|
+
export function effectiveEditorSurface(base: 'center' | 'sidebar', floatOpen: boolean): EditorSurface {
|
|
75
|
+
return floatOpen ? 'float' : base
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The rect a fresh floating window opens at: about two thirds of the frame,
|
|
80
|
+
* centred, with a hard size cap so a 4K monitor does not produce a 2600px
|
|
81
|
+
* editor for a 40-column file.
|
|
82
|
+
*/
|
|
83
|
+
export function defaultFloatRect(viewport: FloatViewport): FloatRect {
|
|
84
|
+
const maxWidth = Math.max(FLOAT_MIN_WIDTH, viewport.width - FLOAT_VIEWPORT_EDGE * 2)
|
|
85
|
+
const maxHeight = Math.max(FLOAT_MIN_HEIGHT, viewport.height - FLOAT_VIEWPORT_EDGE * 2)
|
|
86
|
+
const width = Math.min(maxWidth, Math.max(FLOAT_MIN_WIDTH, Math.round(viewport.width * 0.62)), 1180)
|
|
87
|
+
const height = Math.min(maxHeight, Math.max(FLOAT_MIN_HEIGHT, Math.round(viewport.height * 0.7)), 860)
|
|
88
|
+
const x = Math.round((viewport.width - width) / 2)
|
|
89
|
+
const y = Math.round((viewport.height - height) / 2) - 12
|
|
90
|
+
return clampFloatRect({ x, y, width, height }, viewport)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Bring a rect back inside the viewport and to a legal size.
|
|
95
|
+
*
|
|
96
|
+
* Used on every drag step, on restore, and whenever the browser window is
|
|
97
|
+
* resized: a rect persisted on a 2560px screen must not open a window whose
|
|
98
|
+
* title bar is off-screen on a 1366px laptop.
|
|
99
|
+
*/
|
|
100
|
+
export function clampFloatRect(rect: FloatRect, viewport: FloatViewport): FloatRect {
|
|
101
|
+
const width = clamp(rect.width, FLOAT_MIN_WIDTH, Math.max(FLOAT_MIN_WIDTH, viewport.width))
|
|
102
|
+
const height = clamp(rect.height, FLOAT_MIN_HEIGHT, Math.max(FLOAT_MIN_HEIGHT, viewport.height))
|
|
103
|
+
const x = clamp(rect.x, 0, Math.max(0, viewport.width - width))
|
|
104
|
+
const y = clamp(rect.y, 0, Math.max(0, viewport.height - height))
|
|
105
|
+
return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Move the window by a pointer delta, staying inside the viewport. */
|
|
109
|
+
export function dragFloatRect(start: FloatRect, dx: number, dy: number, viewport: FloatViewport): FloatRect {
|
|
110
|
+
return clampFloatRect({ ...start, x: start.x + dx, y: start.y + dy }, viewport)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resize from one grip.
|
|
115
|
+
*
|
|
116
|
+
* Each axis is adjusted from the edge that MOVED (west/north resize changes the
|
|
117
|
+
* origin too), then clamped: the minimum size is anchored at the opposite edge
|
|
118
|
+
* (so dragging the north grip past the bottom stops instead of flipping the
|
|
119
|
+
* rect) and no edge may leave the viewport.
|
|
120
|
+
*/
|
|
121
|
+
export function resizeFloatRect(
|
|
122
|
+
start: FloatRect,
|
|
123
|
+
handle: FloatResizeHandle,
|
|
124
|
+
dx: number,
|
|
125
|
+
dy: number,
|
|
126
|
+
viewport: FloatViewport,
|
|
127
|
+
): FloatRect {
|
|
128
|
+
let left = start.x
|
|
129
|
+
let top = start.y
|
|
130
|
+
let right = start.x + start.width
|
|
131
|
+
let bottom = start.y + start.height
|
|
132
|
+
if (handle.includes('e')) right += dx
|
|
133
|
+
if (handle.includes('w')) left += dx
|
|
134
|
+
if (handle.includes('s')) bottom += dy
|
|
135
|
+
if (handle.includes('n')) top += dy
|
|
136
|
+
|
|
137
|
+
// Minimum size, anchored at the edge that did NOT move.
|
|
138
|
+
if (right - left < FLOAT_MIN_WIDTH) {
|
|
139
|
+
if (handle.includes('w')) left = right - FLOAT_MIN_WIDTH
|
|
140
|
+
else right = left + FLOAT_MIN_WIDTH
|
|
141
|
+
}
|
|
142
|
+
if (bottom - top < FLOAT_MIN_HEIGHT) {
|
|
143
|
+
if (handle.includes('n')) top = bottom - FLOAT_MIN_HEIGHT
|
|
144
|
+
else bottom = top + FLOAT_MIN_HEIGHT
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Viewport clamp: keep the rect (and every edge) on screen.
|
|
148
|
+
left = clamp(left, 0, Math.max(0, viewport.width - FLOAT_MIN_WIDTH))
|
|
149
|
+
top = clamp(top, 0, Math.max(0, viewport.height - FLOAT_MIN_HEIGHT))
|
|
150
|
+
right = clamp(right, left + FLOAT_MIN_WIDTH, viewport.width)
|
|
151
|
+
bottom = clamp(bottom, top + FLOAT_MIN_HEIGHT, viewport.height)
|
|
152
|
+
return {
|
|
153
|
+
x: Math.round(left),
|
|
154
|
+
y: Math.round(top),
|
|
155
|
+
width: Math.round(right - left),
|
|
156
|
+
height: Math.round(bottom - top),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The rect a maximized window occupies: the frame minus a small margin. */
|
|
161
|
+
export function maximizeFloatRect(viewport: FloatViewport): FloatRect {
|
|
162
|
+
const edge = FLOAT_VIEWPORT_EDGE
|
|
163
|
+
return {
|
|
164
|
+
x: edge,
|
|
165
|
+
y: edge,
|
|
166
|
+
width: Math.max(FLOAT_MIN_WIDTH, viewport.width - edge * 2),
|
|
167
|
+
height: Math.max(FLOAT_MIN_HEIGHT, viewport.height - edge * 2),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse a persisted rect.
|
|
173
|
+
*
|
|
174
|
+
* Anything malformed is discarded rather than repaired into a guess: the
|
|
175
|
+
* caller then opens the default rect. Values that are merely STALE (a rect from
|
|
176
|
+
* another screen) are legal input and get clamped.
|
|
177
|
+
*/
|
|
178
|
+
export function parseFloatRect(raw: string | null | undefined, viewport: FloatViewport): FloatRect | undefined {
|
|
179
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
180
|
+
let parsed: unknown
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(raw)
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
187
|
+
const candidate = parsed as Record<string, unknown>
|
|
188
|
+
if (!finite(candidate.x) || !finite(candidate.y) || !finite(candidate.width) || !finite(candidate.height)) {
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
return clampFloatRect(
|
|
192
|
+
{ x: candidate.x, y: candidate.y, width: candidate.width, height: candidate.height },
|
|
193
|
+
viewport,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Serialize a rect for localStorage. */
|
|
198
|
+
export function serializeFloatRect(rect: FloatRect): string {
|
|
199
|
+
return JSON.stringify({ x: rect.x, y: rect.y, width: rect.width, height: rect.height })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One Ctrl+wheel step: up (negative deltaY) grows the text, down shrinks it.
|
|
204
|
+
* Values are whole pixels so the stored size stays stable across sessions.
|
|
205
|
+
*/
|
|
206
|
+
export function stepFloatZoom(current: number, deltaY: number): number {
|
|
207
|
+
if (!finite(current)) return FLOAT_FONT_DEFAULT
|
|
208
|
+
if (deltaY === 0) return clamp(Math.round(current), FLOAT_FONT_MIN, FLOAT_FONT_MAX)
|
|
209
|
+
const direction = deltaY < 0 ? 1 : -1
|
|
210
|
+
return clamp(Math.round(current) + direction, FLOAT_FONT_MIN, FLOAT_FONT_MAX)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Parse the persisted editor font size (fallback = the default 13px). */
|
|
214
|
+
export function parseFloatFontSize(raw: string | null | undefined): number {
|
|
215
|
+
if (typeof raw !== 'string') return FLOAT_FONT_DEFAULT
|
|
216
|
+
const value = Number(raw)
|
|
217
|
+
if (!finite(value)) return FLOAT_FONT_DEFAULT
|
|
218
|
+
return clamp(Math.round(value), FLOAT_FONT_MIN, FLOAT_FONT_MAX)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Where the minimized ball may sit: fully on screen, whatever position was
|
|
223
|
+
* asked for (a ball restored on a smaller monitor must stay clickable).
|
|
224
|
+
*/
|
|
225
|
+
export function clampBallPosition(pos: FloatPoint, viewport: FloatViewport): FloatPoint {
|
|
226
|
+
const maxX = Math.max(0, viewport.width - FLOAT_BALL_SIZE)
|
|
227
|
+
const maxY = Math.max(0, viewport.height - FLOAT_BALL_SIZE)
|
|
228
|
+
const x = finite(pos.x) ? pos.x : 0
|
|
229
|
+
const y = finite(pos.y) ? pos.y : 0
|
|
230
|
+
return { x: Math.round(clamp(x, 0, maxX)), y: Math.round(clamp(y, 0, maxY)) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The ball's default spot when a window is minimized: its own top-left corner,
|
|
235
|
+
* so the ball appears where the window was instead of jumping to a corner.
|
|
236
|
+
*/
|
|
237
|
+
export function ballFromRect(rect: FloatRect, viewport: FloatViewport): FloatPoint {
|
|
238
|
+
return clampBallPosition({ x: rect.x, y: rect.y }, viewport)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether a finished gesture was a CLICK rather than a drag.
|
|
243
|
+
*
|
|
244
|
+
* The minimized window is one small target with two actions on the same
|
|
245
|
+
* pointer: a press that never really moved restores the window, a real drag
|
|
246
|
+
* moves the ball. Without this distinction every move-by-3px would also
|
|
247
|
+
* restore, and every click would be a zero-distance drag.
|
|
248
|
+
*/
|
|
249
|
+
export function isBallClick(dx: number, dy: number): boolean {
|
|
250
|
+
return Math.abs(dx) <= FLOAT_BALL_CLICK_SLOP && Math.abs(dy) <= FLOAT_BALL_CLICK_SLOP
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Parse a persisted ball position (stale geometry is clamped, garbage dropped). */
|
|
254
|
+
export function parseBallPosition(raw: string | null | undefined, viewport: FloatViewport): FloatPoint | undefined {
|
|
255
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined
|
|
256
|
+
let parsed: unknown
|
|
257
|
+
try {
|
|
258
|
+
parsed = JSON.parse(raw)
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined
|
|
261
|
+
}
|
|
262
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
263
|
+
const candidate = parsed as Record<string, unknown>
|
|
264
|
+
if (!finite(candidate.x) || !finite(candidate.y)) return undefined
|
|
265
|
+
return clampBallPosition({ x: candidate.x, y: candidate.y }, viewport)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Serialize a ball position for localStorage. */
|
|
269
|
+
export function serializeBallPosition(pos: FloatPoint): string {
|
|
270
|
+
return JSON.stringify({ x: pos.x, y: pos.y })
|
|
271
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Double-click on the shell's 「远程编辑器」 tab floats the editor.
|
|
3
|
+
*
|
|
4
|
+
* That tab belongs to the host's conversation-view header, not to us: the plugin
|
|
5
|
+
* registers a VIEW into the shell's tab strip and never sees the tab row's DOM.
|
|
6
|
+
* There is no public service for "the user double-clicked my tab" either, so the
|
|
7
|
+
* only way to honour the gesture is a document-level `dblclick` listener that
|
|
8
|
+
* recognises the tab the same way `reveal.ts` does for clicks — by its label,
|
|
9
|
+
* skipping our own surfaces.
|
|
10
|
+
*
|
|
11
|
+
* Fail-safe by construction: a miss (another host build, a renamed tab, a
|
|
12
|
+
* double-click somewhere else) simply does nothing. It never prevents the
|
|
13
|
+
* default action, never throws, and does not steal double-clicks from the
|
|
14
|
+
* plugin's own UI (`.rvs-root`) or from dialogs.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Elements that behave like the shell's view tabs. */
|
|
18
|
+
const TAB_SELECTOR = 'button, [role="tab"], [role="button"]'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Install the double-click trigger.
|
|
22
|
+
*
|
|
23
|
+
* @param label - the tab's visible label (the same localized string the view
|
|
24
|
+
* registered with); matched against the tab's trimmed text.
|
|
25
|
+
* @param onTrigger - called once per recognised double-click.
|
|
26
|
+
* @returns disposer removing the listener.
|
|
27
|
+
*/
|
|
28
|
+
export function installFloatTabTrigger(label: string, onTrigger: () => void): () => void {
|
|
29
|
+
if (typeof document === 'undefined' || label === '') return () => {}
|
|
30
|
+
const handler = (event: MouseEvent): void => {
|
|
31
|
+
if (event.button !== 0) return
|
|
32
|
+
const target = event.target
|
|
33
|
+
if (!(target instanceof Element)) return
|
|
34
|
+
// Our own surfaces (editor tabs, menus, the floating window) own their
|
|
35
|
+
// double-clicks — the window's title bar uses one to maximize.
|
|
36
|
+
if (target.closest('.rvs-root') !== null) return
|
|
37
|
+
const tab = target.closest<HTMLElement>(TAB_SELECTOR)
|
|
38
|
+
if (tab === null) return
|
|
39
|
+
if ((tab.textContent ?? '').trim() !== label) return
|
|
40
|
+
// A second click while the shell is mid-transition is harmless: the action
|
|
41
|
+
// is idempotent (an already-floating editor is left alone).
|
|
42
|
+
try {
|
|
43
|
+
onTrigger()
|
|
44
|
+
} catch {
|
|
45
|
+
// Never let a listener break the shell's own double-click handling.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Capture phase: the shell's tab may stop propagation on its own handlers.
|
|
49
|
+
document.addEventListener('dblclick', handler, true)
|
|
50
|
+
return () => document.removeEventListener('dblclick', handler, true)
|
|
51
|
+
}
|
package/src/client/i18n.ts
CHANGED
|
@@ -158,6 +158,20 @@ const zh: Record<string, string> = {
|
|
|
158
158
|
'files.deleteConfirm': '确定删除 {target} 吗?此操作在远端不可撤销。',
|
|
159
159
|
'files.open': '打开',
|
|
160
160
|
'files.openAsText': '以文本打开',
|
|
161
|
+
'files.openInFloat': '在悬浮编辑器中打开',
|
|
162
|
+
// -- floating editor window
|
|
163
|
+
'float.title': '悬浮编辑器',
|
|
164
|
+
'float.dragHint': '拖动标题栏移动窗口;拖动边缘缩放;双击标题栏最大化或还原',
|
|
165
|
+
'float.minimize': '最小化',
|
|
166
|
+
'float.ballHint': '点击恢复悬浮编辑器,拖动可移动',
|
|
167
|
+
'float.dockViewHint': '双击「远程编辑器」标签可浮出编辑器',
|
|
168
|
+
'float.maximize': '最大化',
|
|
169
|
+
'float.restore': '还原窗口',
|
|
170
|
+
'float.dock': '停靠回主界面',
|
|
171
|
+
'float.placeholder': '远程编辑器正在悬浮窗口中',
|
|
172
|
+
'float.placeholderHint': '编辑器只有一份实例,已浮出到窗口中央;关闭悬浮窗或点「停靠」后回到这里。',
|
|
173
|
+
'float.dockBack': '停靠回主界面',
|
|
174
|
+
'float.unavailable': '当前宿主没有全帧悬浮层(shell.overlay),悬浮编辑器不可用。',
|
|
161
175
|
'files.rename': '重命名',
|
|
162
176
|
'files.copy': '复制',
|
|
163
177
|
'files.cut': '剪切',
|
|
@@ -197,6 +211,8 @@ const zh: Record<string, string> = {
|
|
|
197
211
|
'editor.tokenCount': '语法着色 token 数(0 = 没有语法包或加载的是旧 bundle)',
|
|
198
212
|
'search.empty': '没有匹配的文件。',
|
|
199
213
|
'search.truncated': '结果过多,仅显示前一批。',
|
|
214
|
+
'search.revealInExplorer': '打开文件所在位置',
|
|
215
|
+
'search.openInTerminal': '在终端中打开',
|
|
200
216
|
'scm.title': '源代码管理',
|
|
201
217
|
'scm.refresh': '刷新',
|
|
202
218
|
'scm.changes': '更改',
|
|
@@ -494,6 +510,20 @@ const en: Record<string, string> = {
|
|
|
494
510
|
'files.deleteConfirm': 'Delete {target}? This cannot be undone on the remote host.',
|
|
495
511
|
'files.open': 'Open',
|
|
496
512
|
'files.openAsText': 'Open as text',
|
|
513
|
+
'files.openInFloat': 'Open in floating editor',
|
|
514
|
+
// -- floating editor window
|
|
515
|
+
'float.title': 'Floating editor',
|
|
516
|
+
'float.dragHint': 'Drag the title bar to move; drag an edge to resize; double-click the title bar to maximize or restore',
|
|
517
|
+
'float.minimize': 'Minimize',
|
|
518
|
+
'float.ballHint': 'Click to restore the floating editor, drag to move it',
|
|
519
|
+
'float.dockViewHint': 'Double-click the "remote editor" tab to float the editor',
|
|
520
|
+
'float.maximize': 'Maximize',
|
|
521
|
+
'float.restore': 'Restore window',
|
|
522
|
+
'float.dock': 'Dock back into the main layout',
|
|
523
|
+
'float.placeholder': 'The remote editor is floating over the app',
|
|
524
|
+
'float.placeholderHint': 'There is only one editor instance and it is out in the floating window; close that window or press Dock to bring it back here.',
|
|
525
|
+
'float.dockBack': 'Dock back',
|
|
526
|
+
'float.unavailable': 'This host has no frame-wide overlay slot (shell.overlay); the floating editor is unavailable.',
|
|
497
527
|
'files.rename': 'Rename',
|
|
498
528
|
'files.copy': 'Copy',
|
|
499
529
|
'files.cut': 'Cut',
|
|
@@ -533,6 +563,8 @@ const en: Record<string, string> = {
|
|
|
533
563
|
'editor.tokenCount': 'Colored syntax tokens (0 = no grammar, or a stale client bundle)',
|
|
534
564
|
'search.empty': 'No matching files.',
|
|
535
565
|
'search.truncated': 'Too many hits; showing the first batch.',
|
|
566
|
+
'search.revealInExplorer': 'Open containing folder',
|
|
567
|
+
'search.openInTerminal': 'Open in terminal',
|
|
536
568
|
'scm.title': 'Source control',
|
|
537
569
|
'scm.refresh': 'Refresh',
|
|
538
570
|
'scm.changes': 'Changes',
|
package/src/client/index.tsx
CHANGED
|
@@ -19,13 +19,15 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
|
19
19
|
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
|
20
20
|
import './vsc/vsc.css'
|
|
21
21
|
import { RemoteDirectoryFlow } from './DirectoryFlow.tsx'
|
|
22
|
+
import { FloatingEditorWindow } from './FloatingEditorWindow.tsx'
|
|
23
|
+
import { installFloatTabTrigger } from './float-tab-trigger.ts'
|
|
22
24
|
import { registerNativeExplorer } from './NativeExplorer.tsx'
|
|
23
25
|
import { installRemoteFileOpen } from './remote-file-open.ts'
|
|
24
26
|
import { RemoteEditorView } from './RemoteEditorView.tsx'
|
|
25
27
|
import { RemoteRoot } from './RemoteRoot.tsx'
|
|
26
28
|
import { ChangesDock, type ChangesDockProps } from './changes/ChangesDock.tsx'
|
|
27
29
|
import { SettingsHostsSection, SettingsWorkspacesSection } from './SettingsHosts.tsx'
|
|
28
|
-
import { setEditorSurface } from './store.tsx'
|
|
30
|
+
import { openFloatingEditor, setEditorSurface } from './store.tsx'
|
|
29
31
|
import { detectLang, makeT } from './i18n.ts'
|
|
30
32
|
import { setIconService, type IconServiceLike } from './vsc/kit.tsx'
|
|
31
33
|
|
|
@@ -283,4 +285,49 @@ export function apply(ctx: Context): void {
|
|
|
283
285
|
}
|
|
284
286
|
}
|
|
285
287
|
}, 'dsh-remote-vs: conversation view (editor + diff)')
|
|
288
|
+
// Double-click on the shell's 「远程编辑器」 tab floats the editor. The tab row
|
|
289
|
+
// belongs to the host's header (we only contribute a view), so the gesture is
|
|
290
|
+
// recognised by label — the same lookup `reveal.ts` already uses for clicks —
|
|
291
|
+
// and a miss is a silent no-op.
|
|
292
|
+
ctx.effect(
|
|
293
|
+
() => installFloatTabTrigger(viewT('view.editor'), () => { openFloatingEditor() }),
|
|
294
|
+
'dsh-remote-vs: float on tab double-click',
|
|
295
|
+
)
|
|
296
|
+
// The floating editor window: hosted by the host's FRAME-WIDE overlay slot,
|
|
297
|
+
// which is the only seat that survives every sidebar tab and conversation
|
|
298
|
+
// view being unmounted. Registering from a narrower surface (the explorer
|
|
299
|
+
// tab, the editor view) would take the window away with that surface — and
|
|
300
|
+
// the editor's live buffer with it.
|
|
301
|
+
//
|
|
302
|
+
// The slot is looked up behind a try/catch: an older host without
|
|
303
|
+
// `shell.overlay` simply keeps the floating feature off. Availability is NOT
|
|
304
|
+
// assumed here — `FloatingEditorWindow` reports it once it really mounts, so
|
|
305
|
+
// the menu entry can never point at a window that cannot paint.
|
|
306
|
+
ctx.effect(() => {
|
|
307
|
+
let dispose: (() => void) | undefined
|
|
308
|
+
try {
|
|
309
|
+
const slots = ctx.slots as unknown as {
|
|
310
|
+
inject(slot: string, register: () => () => void): () => void
|
|
311
|
+
register(
|
|
312
|
+
options: { name: string; id: string; order: number },
|
|
313
|
+
component: () => ReactNode,
|
|
314
|
+
): () => void
|
|
315
|
+
}
|
|
316
|
+
dispose = slots.inject('shell.overlay', () => slots.register({
|
|
317
|
+
name: 'shell.overlay',
|
|
318
|
+
id: 'remote-vs:floating-editor',
|
|
319
|
+
order: 40,
|
|
320
|
+
}, () => <FloatingEditorWindow ctx={ctx} />))
|
|
321
|
+
} catch (error) {
|
|
322
|
+
console.warn('[dsh-remote-vs] shell.overlay registration failed (floating editor unavailable):', error)
|
|
323
|
+
return () => {}
|
|
324
|
+
}
|
|
325
|
+
return () => {
|
|
326
|
+
try {
|
|
327
|
+
dispose?.()
|
|
328
|
+
} catch {
|
|
329
|
+
// Ignore.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}, 'dsh-remote-vs: floating editor window')
|
|
286
333
|
}
|