dsh-remote-vs 0.8.7 → 0.8.9
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/lib/client.js +389 -15
- package/lib/index.js +1 -1
- package/lib/p2-router-fs.js +1 -1
- package/lib/types/client/TerminalView.d.ts +3 -0
- package/lib/types/client/XTerm.d.ts +6 -0
- package/lib/types/client/remote-file-open-target.d.ts +0 -22
- package/lib/types/client/scm/diff-model.d.ts +30 -0
- package/lib/types/client/shell.d.ts +29 -0
- package/lib/types/client/store.d.ts +65 -0
- package/lib/types/client/vsc/kit.d.ts +2 -0
- package/lib/types/workspace-title.d.ts +47 -0
- package/lib/{worlds-C6W225o9.js → worlds-DnzpX3Hv.js} +62 -3
- package/package.json +1 -1
- package/src/client/RemoteExplorer.tsx +45 -0
- package/src/client/RemoteRoot.tsx +1 -1
- package/src/client/SearchView.tsx +42 -2
- package/src/client/TerminalView.tsx +53 -2
- package/src/client/XTerm.tsx +15 -0
- package/src/client/i18n.ts +10 -0
- package/src/client/remote-file-open-target.ts +1 -5
- package/src/client/scm/DiffView.tsx +57 -2
- package/src/client/scm/diff-model.ts +44 -0
- package/src/client/shell.ts +35 -0
- package/src/client/store.tsx +93 -0
- package/src/client/vsc/kit.tsx +5 -0
- package/src/client/vsc/vsc.css +23 -0
- package/src/p2/worlds.ts +18 -4
- package/src/workspace-title.ts +58 -0
package/src/client/i18n.ts
CHANGED
|
@@ -197,6 +197,8 @@ const zh: Record<string, string> = {
|
|
|
197
197
|
'editor.tokenCount': '语法着色 token 数(0 = 没有语法包或加载的是旧 bundle)',
|
|
198
198
|
'search.empty': '没有匹配的文件。',
|
|
199
199
|
'search.truncated': '结果过多,仅显示前一批。',
|
|
200
|
+
'search.revealInExplorer': '打开文件所在位置',
|
|
201
|
+
'search.openInTerminal': '在终端中打开',
|
|
200
202
|
'scm.title': '源代码管理',
|
|
201
203
|
'scm.refresh': '刷新',
|
|
202
204
|
'scm.changes': '更改',
|
|
@@ -298,6 +300,9 @@ const zh: Record<string, string> = {
|
|
|
298
300
|
'scm.stagedReadonly': '已暂存(只读)',
|
|
299
301
|
'scm.reloadDiff': '重新载入差异',
|
|
300
302
|
'scm.hScrollHint': '有比窗口更宽的行 —— 拖动这里左右滚动(或按住 Shift 滚滚轮)',
|
|
303
|
+
'scm.prevHunk': '上一处改动',
|
|
304
|
+
'scm.nextHunk': '下一处改动',
|
|
305
|
+
'scm.hunkNavHint': '在改动之间跳转(显示第 n 处 / 共 N 处)',
|
|
301
306
|
'scm.stageHunk': '暂存该块',
|
|
302
307
|
'scm.unstageHunk': '取消暂存该块',
|
|
303
308
|
'scm.revertHunk': '还原该块',
|
|
@@ -530,6 +535,8 @@ const en: Record<string, string> = {
|
|
|
530
535
|
'editor.tokenCount': 'Colored syntax tokens (0 = no grammar, or a stale client bundle)',
|
|
531
536
|
'search.empty': 'No matching files.',
|
|
532
537
|
'search.truncated': 'Too many hits; showing the first batch.',
|
|
538
|
+
'search.revealInExplorer': 'Open containing folder',
|
|
539
|
+
'search.openInTerminal': 'Open in terminal',
|
|
533
540
|
'scm.title': 'Source control',
|
|
534
541
|
'scm.refresh': 'Refresh',
|
|
535
542
|
'scm.changes': 'Changes',
|
|
@@ -631,6 +638,9 @@ const en: Record<string, string> = {
|
|
|
631
638
|
'scm.stagedReadonly': 'staged (read-only)',
|
|
632
639
|
'scm.reloadDiff': 'Reload diff',
|
|
633
640
|
'scm.hScrollHint': 'A line is wider than the view — drag here to scroll sideways (or hold Shift and wheel)',
|
|
641
|
+
'scm.prevHunk': 'Previous change',
|
|
642
|
+
'scm.nextHunk': 'Next change',
|
|
643
|
+
'scm.hunkNavHint': 'Jump between changes (shows change n of N)',
|
|
634
644
|
'scm.stageHunk': 'Stage this hunk',
|
|
635
645
|
'scm.unstageHunk': 'Unstage this hunk',
|
|
636
646
|
'scm.revertHunk': 'Revert this hunk',
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* - anything that does not name a known binding answers `undefined`, so the
|
|
21
21
|
* native preview keeps handling local sessions and unknown addresses.
|
|
22
22
|
*/
|
|
23
|
+
import { shellQuote } from './shell.ts'
|
|
23
24
|
|
|
24
25
|
/** One remote binding as the client store exposes it. */
|
|
25
26
|
export interface RemoteOpenBinding {
|
|
@@ -429,11 +430,6 @@ export function bindingForSessionCwd(
|
|
|
429
430
|
return bindingForHostPath(table, path)
|
|
430
431
|
}
|
|
431
432
|
|
|
432
|
-
/** POSIX single-quote a word for a remote `bash -c` style command line. */
|
|
433
|
-
function shellQuote(value: string): string {
|
|
434
|
-
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
435
|
-
}
|
|
436
|
-
|
|
437
433
|
/**
|
|
438
434
|
* Bounded remote search for a clicked path that no candidate resolved.
|
|
439
435
|
*
|
|
@@ -20,7 +20,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ
|
|
|
20
20
|
import type { EditorTab } from '../store.tsx'
|
|
21
21
|
import { useExplorer } from '../store.tsx'
|
|
22
22
|
import { applyHunk, GitOpError } from './actions.ts'
|
|
23
|
-
import { diffCounts } from './diff-model.ts'
|
|
23
|
+
import { diffCounts, hunkAtRow, stepHunk } from './diff-model.ts'
|
|
24
24
|
import type { DiffRow } from '../../protocol.ts'
|
|
25
25
|
import { api } from '../api.ts'
|
|
26
26
|
import { CodeEditor, type CodeEditorHandle } from '../CodeEditor.tsx'
|
|
@@ -29,6 +29,8 @@ import { useRemoteChatTarget } from '../use-remote-chat-target.ts'
|
|
|
29
29
|
import { langFromPath, tokenizeDocument, type Token } from '../vsc/tokenize.ts'
|
|
30
30
|
import {
|
|
31
31
|
IconButton,
|
|
32
|
+
IconChevronDown,
|
|
33
|
+
IconChevronUp,
|
|
32
34
|
IconRefresh,
|
|
33
35
|
IconSave,
|
|
34
36
|
MiniSpinner,
|
|
@@ -283,6 +285,43 @@ export function DiffView(props: { tab: EditorTab; visible: boolean }): ReactNode
|
|
|
283
285
|
const [hscroll, setHscroll] = useState({ content: 0, viewport: 0 })
|
|
284
286
|
const rowsInView = useRowWindow(tab.model?.rows ?? [])
|
|
285
287
|
|
|
288
|
+
/** First row of every change, ascending — the prev/next jump targets. */
|
|
289
|
+
const hunkRows = useMemo(() => (tab.model === undefined ? [] : tab.model.hunks.map(hunk => hunk.startRow).sort((a, b) => a - b)), [tab.model])
|
|
290
|
+
/**
|
|
291
|
+
* The change the toolbar's `n / N` reports.
|
|
292
|
+
*
|
|
293
|
+
* A jump sets it explicitly instead of re-deriving it from `scrollTop`: near
|
|
294
|
+
* the bottom of a file the browser CLAMPS the offset, so re-deriving would
|
|
295
|
+
* report the change above the one the button just went to and the next click
|
|
296
|
+
* would refuse to advance. A manual scroll does re-derive it (`hunkAtRow`),
|
|
297
|
+
* which is what keeps the counter honest when the user drags instead of
|
|
298
|
+
* clicking. `-1` = nothing jumped yet / the viewport is above the first
|
|
299
|
+
* change.
|
|
300
|
+
*/
|
|
301
|
+
const [hunkCursor, setHunkCursor] = useState(-1)
|
|
302
|
+
/** The `scrollTop` a jump just wrote, so its own scroll event is not read as a manual scroll. */
|
|
303
|
+
const jumpedTopRef = useRef(-1)
|
|
304
|
+
|
|
305
|
+
/** Scroll to the next (1) or previous (-1) change, wrapping at both ends. */
|
|
306
|
+
const jumpHunk = (direction: 1 | -1): void => {
|
|
307
|
+
const grid = gridRef.current
|
|
308
|
+
if (grid === null) return
|
|
309
|
+
const target = stepHunk(hunkRows.length, hunkCursor, direction)
|
|
310
|
+
if (target < 0) return
|
|
311
|
+
const top = (hunkRows[target] as number) * ROW_HEIGHT
|
|
312
|
+
jumpedTopRef.current = top
|
|
313
|
+
grid.scrollTop = top
|
|
314
|
+
setHunkCursor(target)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Keep `n / N` in step with the viewport while the user scrolls by hand. */
|
|
318
|
+
const syncHunkCursor = (grid: HTMLDivElement): void => {
|
|
319
|
+
if (hunkRows.length === 0) return
|
|
320
|
+
if (Math.abs(grid.scrollTop - jumpedTopRef.current) < 1) return
|
|
321
|
+
const next = hunkAtRow(hunkRows, grid.scrollTop / ROW_HEIGHT)
|
|
322
|
+
setHunkCursor(previous => (previous === next ? previous : next))
|
|
323
|
+
}
|
|
324
|
+
|
|
286
325
|
/**
|
|
287
326
|
* Measure the widest pane and the grid's own width. Runs after every model
|
|
288
327
|
* change, on container resizes (a pane that is empty until its fonts settle
|
|
@@ -435,6 +474,19 @@ export function DiffView(props: { tab: EditorTab; visible: boolean }): ReactNode
|
|
|
435
474
|
<span style={{ color: 'var(--rvs-git-d)' }}>-{counts.removed}</span>
|
|
436
475
|
{tab.staged && <span className="rvs-pill">{t('scm.stagedReadonly')}</span>}
|
|
437
476
|
<span className="rvs-einfo-spacer" />
|
|
477
|
+
{/* Prev/next change: only the split view is windowed, so only it has a
|
|
478
|
+
row offset to jump to. The counter doubles as the "where am I" cue. */}
|
|
479
|
+
{mode === 'split' && hunkRows.length > 0 && (
|
|
480
|
+
<span className="rvs-hunknav" title={t('scm.hunkNavHint')}>
|
|
481
|
+
<IconButton title={t('scm.prevHunk')} onClick={() => jumpHunk(-1)}>
|
|
482
|
+
<IconChevronUp size={13} />
|
|
483
|
+
</IconButton>
|
|
484
|
+
<span className="rvs-hunknav-pos">{hunkCursor < 0 ? `-/${hunkRows.length}` : `${hunkCursor + 1}/${hunkRows.length}`}</span>
|
|
485
|
+
<IconButton title={t('scm.nextHunk')} onClick={() => jumpHunk(1)}>
|
|
486
|
+
<IconChevronDown size={13} />
|
|
487
|
+
</IconButton>
|
|
488
|
+
</span>
|
|
489
|
+
)}
|
|
438
490
|
{busy && <MiniSpinner />}
|
|
439
491
|
<IconButton title={t('scm.reloadDiff')} onClick={() => { void reload() }} disabled={busy}>
|
|
440
492
|
<IconRefresh size={13} />
|
|
@@ -460,7 +512,10 @@ export function DiffView(props: { tab: EditorTab; visible: boolean }): ReactNode
|
|
|
460
512
|
rowsInView.attachGrid(element)
|
|
461
513
|
gridRef.current = element
|
|
462
514
|
}}
|
|
463
|
-
onScroll={
|
|
515
|
+
onScroll={(event) => {
|
|
516
|
+
rowsInView.onScroll(event)
|
|
517
|
+
syncHunkCursor(event.currentTarget)
|
|
518
|
+
}}
|
|
464
519
|
>
|
|
465
520
|
<div className="rvs-diff-cols">
|
|
466
521
|
{/* Left pane: fixed 50% (see .rvs-diff-col) with its own hidden
|
|
@@ -204,3 +204,47 @@ export function diffCounts(model: DiffModel): { added: number; removed: number }
|
|
|
204
204
|
}
|
|
205
205
|
return { added, removed }
|
|
206
206
|
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Where a prev/next jump lands, as an index into the ordered hunk list.
|
|
210
|
+
*
|
|
211
|
+
* Wraps at both ends (the last change's "next" is the first change, and the
|
|
212
|
+
* first one's "previous" is the last), because a diff toolbar button that does
|
|
213
|
+
* nothing at the end reads as broken. `cursor < 0` means "nothing jumped yet":
|
|
214
|
+
* the first `next` lands on the first hunk and the first `previous` on the last.
|
|
215
|
+
*
|
|
216
|
+
* Pure, so `scripts/check-diff-ui.mjs` can pin the wrapping without a browser.
|
|
217
|
+
*
|
|
218
|
+
* @param count - how many hunks the model has (0 → -1, i.e. nothing to jump to).
|
|
219
|
+
* @param cursor - the hunk the toolbar currently reports, or -1.
|
|
220
|
+
* @param direction - 1 for "next change", -1 for "previous change".
|
|
221
|
+
* @returns the hunk index to scroll to, or -1 when the model has no hunks.
|
|
222
|
+
*/
|
|
223
|
+
export function stepHunk(count: number, cursor: number, direction: 1 | -1): number {
|
|
224
|
+
if (count <= 0) return -1
|
|
225
|
+
if (cursor < 0 || cursor >= count) return direction === 1 ? 0 : count - 1
|
|
226
|
+
return (cursor + direction + count) % count
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The hunk the viewport is sitting in, derived from the top visible row.
|
|
231
|
+
*
|
|
232
|
+
* "Sitting in" = the last change whose first row is at or ABOVE the top row, so
|
|
233
|
+
* scrolling into the middle of a change keeps reporting that change (and
|
|
234
|
+
* scrolling above the first one reports -1). This is what keeps the toolbar's
|
|
235
|
+
* `n / N` honest when the user scrolls by hand instead of using the buttons —
|
|
236
|
+
* the jump math alone would report the last button press, not the screen.
|
|
237
|
+
*
|
|
238
|
+
* @param startRows - first row index of each hunk, ASCENDING.
|
|
239
|
+
* @param topRow - the row index at the top of the viewport (may be fractional).
|
|
240
|
+
* @returns the hunk index, or -1 when the viewport is above the first hunk.
|
|
241
|
+
*/
|
|
242
|
+
export function hunkAtRow(startRows: readonly number[], topRow: number): number {
|
|
243
|
+
let found = -1
|
|
244
|
+
for (let index = 0; index < startRows.length; index += 1) {
|
|
245
|
+
const start = startRows[index] as number
|
|
246
|
+
if (start > topRow) break
|
|
247
|
+
found = index
|
|
248
|
+
}
|
|
249
|
+
return found
|
|
250
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell quoting for the lines this plugin types into a remote terminal.
|
|
3
|
+
*
|
|
4
|
+
* One rule, one place: the client builds POSIX shell text in three spots (the
|
|
5
|
+
* `cd` typed into a fresh terminal, the bounded suffix search for an unresolved
|
|
6
|
+
* click target, and the terminal command a menu entry sends), and a second copy
|
|
7
|
+
* of the quoting rule is how an injection bug reappears after it was fixed once.
|
|
8
|
+
*
|
|
9
|
+
* Single quotes are the only form that is safe for arbitrary paths — double
|
|
10
|
+
* quotes still expand `$`, backticks and `\` — and the closing quote is the one
|
|
11
|
+
* character they cannot carry, so it is spliced in as `'\''`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Quote one value for a POSIX shell, as a single word.
|
|
16
|
+
*
|
|
17
|
+
* @param value - the raw path or word.
|
|
18
|
+
* @returns `'value'` with every embedded `'` spliced as `'\''`.
|
|
19
|
+
*/
|
|
20
|
+
export function shellQuote(value: string): string {
|
|
21
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The command typed into a fresh remote terminal to land in one directory.
|
|
26
|
+
*
|
|
27
|
+
* A trailing newline is NOT included: the caller writes the line and the
|
|
28
|
+
* terminal's Enter, which keeps the same string reusable for display.
|
|
29
|
+
*
|
|
30
|
+
* @param dir - absolute remote directory.
|
|
31
|
+
* @returns `cd '<dir>'`.
|
|
32
|
+
*/
|
|
33
|
+
export function cdCommand(dir: string): string {
|
|
34
|
+
return `cd ${shellQuote(dir)}`
|
|
35
|
+
}
|
package/src/client/store.tsx
CHANGED
|
@@ -131,6 +131,10 @@ interface State {
|
|
|
131
131
|
view: RailView
|
|
132
132
|
/** A pending search request raised by the explorer's context menu. */
|
|
133
133
|
searchRequest: SearchRequest | undefined
|
|
134
|
+
/** A pending "show this folder in the tree" request (search-result menu). */
|
|
135
|
+
revealRequest: RevealRequest | undefined
|
|
136
|
+
/** A pending "open a terminal in this folder" request (search-result menu). */
|
|
137
|
+
terminalRequest: TerminalRequest | undefined
|
|
134
138
|
/**
|
|
135
139
|
* Expanded untracked DIRECTORY rows (`-u normal` collapses a directory into
|
|
136
140
|
* one row, so the panel asks for its contents on demand — see
|
|
@@ -154,6 +158,32 @@ export interface SearchRequest {
|
|
|
154
158
|
scope: string
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
/** One "show this folder in the explorer tree" request. */
|
|
162
|
+
export interface RevealRequest {
|
|
163
|
+
/** Monotonic id so asking for the same folder twice still re-triggers. */
|
|
164
|
+
id: number
|
|
165
|
+
/** Absolute remote DIRECTORY to expand and scroll to. */
|
|
166
|
+
path: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One "open a terminal here" request. */
|
|
170
|
+
export interface TerminalRequest {
|
|
171
|
+
/** Monotonic id so the same folder can be opened twice. */
|
|
172
|
+
id: number
|
|
173
|
+
/**
|
|
174
|
+
* Host alias the directory lives on.
|
|
175
|
+
*
|
|
176
|
+
* Carried explicitly, and NOT taken from the terminal panel's own last-used
|
|
177
|
+
* host: the panel remembers whatever host the user ran a shell on last, so a
|
|
178
|
+
* menu action on a workspace bound elsewhere opened the shell on the WRONG
|
|
179
|
+
* machine — the `cd` then failed with "No such file or directory" for a path
|
|
180
|
+
* that exists perfectly well on its own host.
|
|
181
|
+
*/
|
|
182
|
+
alias: string
|
|
183
|
+
/** Absolute remote directory the fresh shell should `cd` into. */
|
|
184
|
+
dir: string
|
|
185
|
+
}
|
|
186
|
+
|
|
157
187
|
const WS_KEY = 'dsh-remote-vs.files.ws'
|
|
158
188
|
const VIEW_KEY = 'dsh-remote-vs.view'
|
|
159
189
|
/** Per-workspace repository sub-directory, keyed by workspace id. */
|
|
@@ -295,6 +325,8 @@ const state: State = {
|
|
|
295
325
|
editorSurface: 'sidebar',
|
|
296
326
|
view: readStoredView(),
|
|
297
327
|
searchRequest: undefined,
|
|
328
|
+
revealRequest: undefined,
|
|
329
|
+
terminalRequest: undefined,
|
|
298
330
|
untrackedDirs: new Map(),
|
|
299
331
|
}
|
|
300
332
|
|
|
@@ -337,10 +369,25 @@ export interface ExplorerValue {
|
|
|
337
369
|
editorSurface: 'center' | 'sidebar'
|
|
338
370
|
view: RailView
|
|
339
371
|
searchRequest: SearchRequest | undefined
|
|
372
|
+
/** Pending "show this folder in the tree" request (consumed by the explorer). */
|
|
373
|
+
revealRequest: RevealRequest | undefined
|
|
374
|
+
/** Pending "open a terminal here" request (consumed by the terminal panel). */
|
|
375
|
+
terminalRequest: TerminalRequest | undefined
|
|
340
376
|
setView: (view: RailView) => void
|
|
341
377
|
/** Switch to the search panel (optionally scoped to a folder) — the tree's
|
|
342
378
|
"find in folder" entry point. */
|
|
343
379
|
requestSearch: (request: { mode: 'name' | 'content'; query?: string; scope: string }) => void
|
|
380
|
+
/**
|
|
381
|
+
* Show one remote directory in the explorer: switch to it, expand the folder
|
|
382
|
+
* and its ancestors, then scroll its row into view. Raised by the search
|
|
383
|
+
* results' "open containing folder" entry.
|
|
384
|
+
*/
|
|
385
|
+
revealInExplorer: (dir: string) => void
|
|
386
|
+
/**
|
|
387
|
+
* Open a fresh terminal tab whose shell `cd`s into `dir`. Raised by the
|
|
388
|
+
* search results' "open in terminal" entry.
|
|
389
|
+
*/
|
|
390
|
+
openTerminalAt: (dir: string) => void
|
|
344
391
|
/**
|
|
345
392
|
* The USER's pick — the workspace dropdowns and the wizard's "open files".
|
|
346
393
|
*
|
|
@@ -463,8 +510,12 @@ function build(): ExplorerValue {
|
|
|
463
510
|
editorSurface: state.editorSurface,
|
|
464
511
|
view: state.view,
|
|
465
512
|
searchRequest: state.searchRequest,
|
|
513
|
+
revealRequest: state.revealRequest,
|
|
514
|
+
terminalRequest: state.terminalRequest,
|
|
466
515
|
setView,
|
|
467
516
|
requestSearch,
|
|
517
|
+
revealInExplorer,
|
|
518
|
+
openTerminalAt,
|
|
468
519
|
selectWorkspace,
|
|
469
520
|
setRepoSubdir,
|
|
470
521
|
discoverRepos,
|
|
@@ -622,6 +673,48 @@ export function requestSearch(request: { mode: 'name' | 'content'; query?: strin
|
|
|
622
673
|
setView('search')
|
|
623
674
|
}
|
|
624
675
|
|
|
676
|
+
/** Monotonic ticket for "show this folder" requests (0 = none yet). */
|
|
677
|
+
let revealSeq = 0
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Show one remote directory in the explorer.
|
|
681
|
+
*
|
|
682
|
+
* Two stages, because the tree is lazily loaded: this raises the request and
|
|
683
|
+
* switches the panel, and the explorer itself expands the ancestors (loading
|
|
684
|
+
* each level) and scrolls the row into view — it is the only place that knows
|
|
685
|
+
* which directories are currently expanded.
|
|
686
|
+
*
|
|
687
|
+
* @param dir - absolute remote DIRECTORY (a file's parent for a file hit).
|
|
688
|
+
*/
|
|
689
|
+
export function revealInExplorer(dir: string): void {
|
|
690
|
+
revealSeq += 1
|
|
691
|
+
patch({ revealRequest: { id: revealSeq, path: dir } })
|
|
692
|
+
setView('explorer')
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Monotonic ticket for "open a terminal here" requests. */
|
|
696
|
+
let terminalSeq = 0
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Open a fresh terminal tab whose shell lands in `dir` — on the CURRENT
|
|
700
|
+
* workspace's host.
|
|
701
|
+
*
|
|
702
|
+
* The terminal panel consumes the request (it owns the tabs) and the shell gets
|
|
703
|
+
* a quoted `cd` once its PTY reports ready — the host's terminal route has no
|
|
704
|
+
* "start in this directory" parameter, so a typed command is the honest way.
|
|
705
|
+
*
|
|
706
|
+
* The alias travels IN the request. The panel otherwise opens new tabs on the
|
|
707
|
+
* host it last used, which put the shell on a different machine than the path
|
|
708
|
+
* belongs to and made the `cd` fail with "No such file or directory".
|
|
709
|
+
*
|
|
710
|
+
* @param dir - absolute remote directory.
|
|
711
|
+
*/
|
|
712
|
+
export function openTerminalAt(dir: string): void {
|
|
713
|
+
terminalSeq += 1
|
|
714
|
+
patch({ terminalRequest: { id: terminalSeq, alias: currentWorkspace()?.alias ?? '', dir } })
|
|
715
|
+
setView('terminal')
|
|
716
|
+
}
|
|
717
|
+
|
|
625
718
|
/**
|
|
626
719
|
* Select the binding every view works against — the USER's choice.
|
|
627
720
|
*
|
package/src/client/vsc/kit.tsx
CHANGED
|
@@ -55,6 +55,11 @@ export function IconChevronDown(props: IconProps): ReactNode {
|
|
|
55
55
|
return svg(props.size ?? 14, <path d="M3.5 6 L8 10.5 L12.5 6" />, props.className)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** Mirror of `IconChevronDown`, for the diff's "previous change" button. */
|
|
59
|
+
export function IconChevronUp(props: IconProps): ReactNode {
|
|
60
|
+
return svg(props.size ?? 14, <path d="M3.5 10 L8 5.5 L12.5 10" />, props.className)
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
export function IconFolder(props: IconProps & { open?: boolean }): ReactNode {
|
|
59
64
|
return props.open === true
|
|
60
65
|
? svg(props.size ?? 16, <path d="M1.8 12.6V4.2a1 1 0 0 1 1-1h3l1.4 1.6h6a1 1 0 0 1 1 1v1H2.6l1 5.8a1 1 0 0 0 1 .8h7.6" />, props.className)
|
package/src/client/vsc/vsc.css
CHANGED
|
@@ -1073,6 +1073,29 @@ body[data-ds-dark-theme] .rvs-root {
|
|
|
1073
1073
|
color: var(--rvs-fg3);
|
|
1074
1074
|
}
|
|
1075
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* Prev/next change, in the toolbar's free space.
|
|
1078
|
+
*
|
|
1079
|
+
* Two chevrons around a tabular counter: the pair then reads as "jump to change
|
|
1080
|
+
* n of N" instead of two anonymous arrows, and the counter doubles as the "where
|
|
1081
|
+
* am I" cue while scrolling by hand.
|
|
1082
|
+
*/
|
|
1083
|
+
.rvs-hunknav {
|
|
1084
|
+
display: inline-flex;
|
|
1085
|
+
align-items: center;
|
|
1086
|
+
gap: 1px;
|
|
1087
|
+
margin-right: 2px;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
.rvs-hunknav-pos {
|
|
1091
|
+
min-width: 30px;
|
|
1092
|
+
text-align: center;
|
|
1093
|
+
font-size: 11px;
|
|
1094
|
+
font-variant-numeric: tabular-nums;
|
|
1095
|
+
color: var(--rvs-fg3);
|
|
1096
|
+
user-select: none;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1076
1099
|
.rvs-seg {
|
|
1077
1100
|
display: inline-flex;
|
|
1078
1101
|
border: 1px solid var(--rvs-border2);
|
package/src/p2/worlds.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { aliasRoot } from '../paths.ts'
|
|
|
23
23
|
import type { RemoteHostEntry, RemoteWorkspaceBinding } from '../protocol.ts'
|
|
24
24
|
import { RpcConnection } from './helper-rpc.ts'
|
|
25
25
|
import { SshProcRuntime } from './ssh-proc.ts'
|
|
26
|
+
import { legacyWorkspaceTitle, workspaceTitle } from '../workspace-title.ts'
|
|
26
27
|
|
|
27
28
|
export type RoutingMode = 'off' | 'shadow' | 'enforce'
|
|
28
29
|
|
|
@@ -395,10 +396,23 @@ export class RemoteWorlds {
|
|
|
395
396
|
}
|
|
396
397
|
if (registry === undefined) continue
|
|
397
398
|
try {
|
|
398
|
-
const existing = await registry.resolveByPath(aliasPath)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
399
|
+
const existing = await registry.resolveByPath(aliasPath) as
|
|
400
|
+
| { title?: string; setTitle?: (title: string) => Promise<unknown> }
|
|
401
|
+
| undefined
|
|
402
|
+
const desired = workspaceTitle(binding)
|
|
403
|
+
if (existing !== undefined) {
|
|
404
|
+
// DISPLAY ONLY: the registry resolves workspaces by canonical PATH
|
|
405
|
+
// (`resolveByPath`) and its own docs call the title "used only when a
|
|
406
|
+
// new record is created", so nothing here can move a route. The
|
|
407
|
+
// retitle is additionally gated on the current title still being
|
|
408
|
+
// EXACTLY what the old code produced (`alias > <last path segment>`),
|
|
409
|
+
// so a title the user renamed by hand is never overwritten.
|
|
410
|
+
if (desired !== existing.title && existing.title === legacyWorkspaceTitle(binding) && typeof existing.setTitle === 'function') {
|
|
411
|
+
await existing.setTitle(desired)
|
|
412
|
+
}
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
415
|
+
await registry.create(aliasPath, desired)
|
|
402
416
|
} catch (error) {
|
|
403
417
|
try {
|
|
404
418
|
this.ctx.logger.warn(`[remote-vs] workspace registration failed for ${aliasPath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The title one remote binding gets in DSH's own workspace sidebar.
|
|
3
|
+
*
|
|
4
|
+
* The sidebar (and the session grouping under it) is rendered by the shell from
|
|
5
|
+
* the workspace REGISTRY, so this string is the only label the user ever sees
|
|
6
|
+
* there. It used to be built from the remote PATH's last segment, which threw
|
|
7
|
+
* away the one name the user had chosen: a workspace created as
|
|
8
|
+
* "P710_userdebug" showed up as `… > P710`, and two bindings that differ only
|
|
9
|
+
* deeper down (`…/userdebug/P710` and `…/userdebug/P710_fac`) became
|
|
10
|
+
* indistinguishable.
|
|
11
|
+
*
|
|
12
|
+
* The alias stays in front: it is what tells two hosts apart, and the part after
|
|
13
|
+
* it is what takes the workspace name.
|
|
14
|
+
*
|
|
15
|
+
* DISPLAY ONLY. The registry resolves a workspace by its canonical PATH
|
|
16
|
+
* (`resolveByPath`) and its own documentation says the title is "used only when
|
|
17
|
+
* a new record is created" and that different paths may share one — so a title
|
|
18
|
+
* never decides routing, and none of the plugin's routing reads it.
|
|
19
|
+
*
|
|
20
|
+
* Pure and dependency-free, so `scripts/check-diff-ui.mjs` can pin it without a
|
|
21
|
+
* host (the host half imports cordis and cannot load outside DSH).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Last path segment of an absolute remote path ('' when the path is `/`). */
|
|
25
|
+
function pathBase(remotePath: string): string {
|
|
26
|
+
const parts = remotePath.split('/').filter(part => part !== '')
|
|
27
|
+
return parts.pop() ?? remotePath
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The alias part of a title, with a readable fallback. */
|
|
31
|
+
function aliasOf(alias: string): string {
|
|
32
|
+
return alias.trim() === '' ? 'remote' : alias.trim()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The title this binding SHOULD have: `alias > workspace name`.
|
|
37
|
+
*
|
|
38
|
+
* @param binding - alias, the name the user typed, and the remote path.
|
|
39
|
+
* @returns `alias > name`, falling back to the path's last segment when no name
|
|
40
|
+
* was given (bindings created before names existed, or an empty field).
|
|
41
|
+
*/
|
|
42
|
+
export function workspaceTitle(binding: { alias: string; name?: string; remotePath: string }): string {
|
|
43
|
+
const named = (binding.name ?? '').trim()
|
|
44
|
+
return `${aliasOf(binding.alias)} > ${named !== '' ? named : pathBase(binding.remotePath)}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The title the OLD code produced: `alias > <last path segment>`, name ignored.
|
|
49
|
+
*
|
|
50
|
+
* Kept so an existing registration can be retitled ONLY when its current title
|
|
51
|
+
* is still exactly this. That is what keeps the upgrade from ever touching a
|
|
52
|
+
* title the user renamed by hand in the shell's workspace settings.
|
|
53
|
+
*
|
|
54
|
+
* @param binding - alias and remote path; the name is deliberately unused.
|
|
55
|
+
*/
|
|
56
|
+
export function legacyWorkspaceTitle(binding: { alias: string; remotePath: string }): string {
|
|
57
|
+
return `${aliasOf(binding.alias)} > ${pathBase(binding.remotePath)}`
|
|
58
|
+
}
|