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.
@@ -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={rowsInView.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
+ }
@@ -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
  *
@@ -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)
@@ -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
- if (existing !== undefined) continue
400
- const base = binding.remotePath.split('/').filter(Boolean).pop() ?? binding.remotePath
401
- await registry.create(aliasPath, `${binding.alias} > ${base}`)
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
+ }