dsh-remote-vs 0.8.6 → 0.8.8

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/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as pushPipeline, c as require_lib, d as ensurePrivateDir, f as keysDir, i as probe, l as aliasRoot, o as seedHelperCache, p as storePath, r as helperAvailability, s as RemoteFiles, t as RemoteWorlds, u as dshHome } from "./worlds-C6W225o9.js";
1
+ import { a as pushPipeline, c as require_lib, d as ensurePrivateDir, f as keysDir, i as probe, l as aliasRoot, o as seedHelperCache, p as storePath, r as helperAvailability, s as RemoteFiles, t as RemoteWorlds, u as dshHome } from "./worlds-DnzpX3Hv.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
4
4
  import { createHash, randomUUID } from "node:crypto";
@@ -1,4 +1,4 @@
1
- import { l as aliasRoot, n as isWindowsAbsolute } from "./worlds-C6W225o9.js";
1
+ import { l as aliasRoot, n as isWindowsAbsolute } from "./worlds-DnzpX3Hv.js";
2
2
  import { FileSystem, FsError } from "@deepseek-ai/dsh-fs";
3
3
  //#region src/p2/router-fs.ts
4
4
  /**
@@ -26,4 +26,34 @@ export declare function diffCounts(model: DiffModel): {
26
26
  added: number;
27
27
  removed: number;
28
28
  };
29
+ /**
30
+ * Where a prev/next jump lands, as an index into the ordered hunk list.
31
+ *
32
+ * Wraps at both ends (the last change's "next" is the first change, and the
33
+ * first one's "previous" is the last), because a diff toolbar button that does
34
+ * nothing at the end reads as broken. `cursor < 0` means "nothing jumped yet":
35
+ * the first `next` lands on the first hunk and the first `previous` on the last.
36
+ *
37
+ * Pure, so `scripts/check-diff-ui.mjs` can pin the wrapping without a browser.
38
+ *
39
+ * @param count - how many hunks the model has (0 → -1, i.e. nothing to jump to).
40
+ * @param cursor - the hunk the toolbar currently reports, or -1.
41
+ * @param direction - 1 for "next change", -1 for "previous change".
42
+ * @returns the hunk index to scroll to, or -1 when the model has no hunks.
43
+ */
44
+ export declare function stepHunk(count: number, cursor: number, direction: 1 | -1): number;
45
+ /**
46
+ * The hunk the viewport is sitting in, derived from the top visible row.
47
+ *
48
+ * "Sitting in" = the last change whose first row is at or ABOVE the top row, so
49
+ * scrolling into the middle of a change keeps reporting that change (and
50
+ * scrolling above the first one reports -1). This is what keeps the toolbar's
51
+ * `n / N` honest when the user scrolls by hand instead of using the buttons —
52
+ * the jump math alone would report the last button press, not the screen.
53
+ *
54
+ * @param startRows - first row index of each hunk, ASCENDING.
55
+ * @param topRow - the row index at the top of the viewport (may be fractional).
56
+ * @returns the hunk index, or -1 when the viewport is above the first hunk.
57
+ */
58
+ export declare function hunkAtRow(startRows: readonly number[], topRow: number): number;
29
59
  export {};
@@ -18,6 +18,8 @@ type IconProps = {
18
18
  };
19
19
  export declare function IconChevronRight(props: IconProps): ReactNode;
20
20
  export declare function IconChevronDown(props: IconProps): ReactNode;
21
+ /** Mirror of `IconChevronDown`, for the diff's "previous change" button. */
22
+ export declare function IconChevronUp(props: IconProps): ReactNode;
21
23
  export declare function IconFolder(props: IconProps & {
22
24
  open?: boolean;
23
25
  }): ReactNode;
@@ -0,0 +1,47 @@
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
+ * The title this binding SHOULD have: `alias > workspace name`.
25
+ *
26
+ * @param binding - alias, the name the user typed, and the remote path.
27
+ * @returns `alias > name`, falling back to the path's last segment when no name
28
+ * was given (bindings created before names existed, or an empty field).
29
+ */
30
+ export declare function workspaceTitle(binding: {
31
+ alias: string;
32
+ name?: string;
33
+ remotePath: string;
34
+ }): string;
35
+ /**
36
+ * The title the OLD code produced: `alias > <last path segment>`, name ignored.
37
+ *
38
+ * Kept so an existing registration can be retitled ONLY when its current title
39
+ * is still exactly this. That is what keeps the upgrade from ever touching a
40
+ * title the user renamed by hand in the shell's workspace settings.
41
+ *
42
+ * @param binding - alias and remote path; the name is deliberately unused.
43
+ */
44
+ export declare function legacyWorkspaceTitle(binding: {
45
+ alias: string;
46
+ remotePath: string;
47
+ }): string;
@@ -18233,6 +18233,61 @@ var RpcConnection = class {
18233
18233
  }
18234
18234
  };
18235
18235
  //#endregion
18236
+ //#region src/workspace-title.ts
18237
+ /**
18238
+ * The title one remote binding gets in DSH's own workspace sidebar.
18239
+ *
18240
+ * The sidebar (and the session grouping under it) is rendered by the shell from
18241
+ * the workspace REGISTRY, so this string is the only label the user ever sees
18242
+ * there. It used to be built from the remote PATH's last segment, which threw
18243
+ * away the one name the user had chosen: a workspace created as
18244
+ * "P710_userdebug" showed up as `… > P710`, and two bindings that differ only
18245
+ * deeper down (`…/userdebug/P710` and `…/userdebug/P710_fac`) became
18246
+ * indistinguishable.
18247
+ *
18248
+ * The alias stays in front: it is what tells two hosts apart, and the part after
18249
+ * it is what takes the workspace name.
18250
+ *
18251
+ * DISPLAY ONLY. The registry resolves a workspace by its canonical PATH
18252
+ * (`resolveByPath`) and its own documentation says the title is "used only when
18253
+ * a new record is created" and that different paths may share one — so a title
18254
+ * never decides routing, and none of the plugin's routing reads it.
18255
+ *
18256
+ * Pure and dependency-free, so `scripts/check-diff-ui.mjs` can pin it without a
18257
+ * host (the host half imports cordis and cannot load outside DSH).
18258
+ */
18259
+ /** Last path segment of an absolute remote path ('' when the path is `/`). */
18260
+ function pathBase(remotePath) {
18261
+ return remotePath.split("/").filter((part) => part !== "").pop() ?? remotePath;
18262
+ }
18263
+ /** The alias part of a title, with a readable fallback. */
18264
+ function aliasOf(alias) {
18265
+ return alias.trim() === "" ? "remote" : alias.trim();
18266
+ }
18267
+ /**
18268
+ * The title this binding SHOULD have: `alias > workspace name`.
18269
+ *
18270
+ * @param binding - alias, the name the user typed, and the remote path.
18271
+ * @returns `alias > name`, falling back to the path's last segment when no name
18272
+ * was given (bindings created before names existed, or an empty field).
18273
+ */
18274
+ function workspaceTitle(binding) {
18275
+ const named = (binding.name ?? "").trim();
18276
+ return `${aliasOf(binding.alias)} > ${named !== "" ? named : pathBase(binding.remotePath)}`;
18277
+ }
18278
+ /**
18279
+ * The title the OLD code produced: `alias > <last path segment>`, name ignored.
18280
+ *
18281
+ * Kept so an existing registration can be retitled ONLY when its current title
18282
+ * is still exactly this. That is what keeps the upgrade from ever touching a
18283
+ * title the user renamed by hand in the shell's workspace settings.
18284
+ *
18285
+ * @param binding - alias and remote path; the name is deliberately unused.
18286
+ */
18287
+ function legacyWorkspaceTitle(binding) {
18288
+ return `${aliasOf(binding.alias)} > ${pathBase(binding.remotePath)}`;
18289
+ }
18290
+ //#endregion
18236
18291
  //#region src/p2/worlds.ts
18237
18292
  /**
18238
18293
  * Remote worlds: alias registry, tombstones, cwd-keyed routing decisions,
@@ -18522,9 +18577,13 @@ var RemoteWorlds = class {
18522
18577
  }
18523
18578
  if (registry === void 0) continue;
18524
18579
  try {
18525
- if (await registry.resolveByPath(aliasPath) !== void 0) continue;
18526
- const base = binding.remotePath.split("/").filter(Boolean).pop() ?? binding.remotePath;
18527
- await registry.create(aliasPath, `${binding.alias} > ${base}`);
18580
+ const existing = await registry.resolveByPath(aliasPath);
18581
+ const desired = workspaceTitle(binding);
18582
+ if (existing !== void 0) {
18583
+ if (desired !== existing.title && existing.title === legacyWorkspaceTitle(binding) && typeof existing.setTitle === "function") await existing.setTitle(desired);
18584
+ continue;
18585
+ }
18586
+ await registry.create(aliasPath, desired);
18528
18587
  } catch (error) {
18529
18588
  try {
18530
18589
  this.ctx.logger.warn(`[remote-vs] workspace registration failed for ${aliasPath}: ${error instanceof Error ? error.message : String(error)}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote-vs",
3
3
  "description": "VSCode-style remote development for DSH: SSH host manager (key/password), helper deploy pipeline with version check, remote file explorer + editor + terminal in dsh-better-sidebar.",
4
- "version": "0.8.6",
4
+ "version": "0.8.8",
5
5
  "icon": "icon.svg",
6
6
  "keywords": [
7
7
  "dsh",
@@ -20,7 +20,7 @@ import {
20
20
  type ContentHit,
21
21
  } from '../search-ops.ts'
22
22
  import { searchFiles, type SearchHit } from './scm/actions.ts'
23
- import { repoRelative, useExplorer } from './store.tsx'
23
+ import { useExplorer } from './store.tsx'
24
24
  import { api } from './api.ts'
25
25
  import {
26
26
  FileIcon,
@@ -77,7 +77,7 @@ function relativeTo(root: string, full: string): string {
77
77
 
78
78
  export function SearchView(): ReactNode {
79
79
  const store = useExplorer()
80
- const { t, alias, root, repoRoot } = store
80
+ const { t, alias, root } = store
81
81
  const [mode, setModeState] = useState<Mode>(readMode)
82
82
  const [query, setQuery] = useState('')
83
83
  /** Absolute remote directory the search runs in ('' = workspace root). */
@@ -209,17 +209,19 @@ export function SearchView(): ReactNode {
209
209
  // eslint-disable-next-line react-hooks/exhaustive-deps
210
210
  }, [request?.id])
211
211
 
212
+ /**
213
+ * A hit ALWAYS opens the file.
214
+ *
215
+ * It used to open the diff instead whenever the file had a status mark, which
216
+ * is wrong for a search result: the user is looking for the file, not for its
217
+ * change, and for a path the index knows nothing about (untracked, or a mark
218
+ * that belongs to another repository root) that "diff" rendered EMPTY — the
219
+ * panel called it "no differences" and there was no way to see the content,
220
+ * not even by double-clicking. The diff has its own entry points (the source
221
+ * control panel and the change review dock).
222
+ */
212
223
  const openHit = (path: string, line?: number): void => {
213
- if (line === undefined) {
214
- const rel = repoRelative(repoRoot, path)
215
- if (rel !== '' && store.marks.files.has(rel)) {
216
- store.openDiff(rel, false, store.marks.files.get(rel) === '?')
217
- return
218
- }
219
- store.openFile(path)
220
- return
221
- }
222
- store.openFile(path, { line })
224
+ store.openFile(path, line === undefined ? undefined : { line })
223
225
  }
224
226
 
225
227
  const groups = useMemo(() => groupHits(contentHits), [contentHits])
@@ -297,6 +297,10 @@ const zh: Record<string, string> = {
297
297
  'scm.afterEditable': '变更后(可编辑)',
298
298
  'scm.stagedReadonly': '已暂存(只读)',
299
299
  'scm.reloadDiff': '重新载入差异',
300
+ 'scm.hScrollHint': '有比窗口更宽的行 —— 拖动这里左右滚动(或按住 Shift 滚滚轮)',
301
+ 'scm.prevHunk': '上一处改动',
302
+ 'scm.nextHunk': '下一处改动',
303
+ 'scm.hunkNavHint': '在改动之间跳转(显示第 n 处 / 共 N 处)',
300
304
  'scm.stageHunk': '暂存该块',
301
305
  'scm.unstageHunk': '取消暂存该块',
302
306
  'scm.revertHunk': '还原该块',
@@ -629,6 +633,10 @@ const en: Record<string, string> = {
629
633
  'scm.afterEditable': 'After (editable)',
630
634
  'scm.stagedReadonly': 'staged (read-only)',
631
635
  'scm.reloadDiff': 'Reload diff',
636
+ 'scm.hScrollHint': 'A line is wider than the view — drag here to scroll sideways (or hold Shift and wheel)',
637
+ 'scm.prevHunk': 'Previous change',
638
+ 'scm.nextHunk': 'Next change',
639
+ 'scm.hunkNavHint': 'Jump between changes (shows change n of N)',
632
640
  'scm.stageHunk': 'Stage this hunk',
633
641
  'scm.unstageHunk': 'Unstage this hunk',
634
642
  'scm.revertHunk': 'Revert this hunk',
@@ -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,
@@ -225,19 +227,24 @@ function Line(props: { tokens: Token[]; empty?: boolean }): ReactNode {
225
227
 
226
228
  /**
227
229
  * The top horizontal scrollbar: a scroll container whose only content is a
228
- * spacer as wide as the widest pane. Dragging it drives both panes, so the
229
- * user never has to scroll to the bottom of a file to reach a long line's
230
- * horizontal bar.
230
+ * spacer as wide as the widest line. Dragging it drives both panes, so a long
231
+ * line can be reached without scrolling to the bottom of the file first.
232
+ *
233
+ * Rendered only when a line really is wider than the grid (see `hscroll`), and
234
+ * styled tall enough to see and grab: as a permanently mounted 13 px band with
235
+ * a `thin`, low-contrast thumb it read as "there is no horizontal scrollbar".
231
236
  */
232
237
  function HScrollBar(props: {
233
238
  barRef: React.RefObject<HTMLDivElement>
234
239
  width: number
240
+ hint: string
235
241
  onScroll: (left: number) => void
236
242
  }): ReactNode {
237
243
  return (
238
244
  <div
239
245
  className="rvs-hbar"
240
246
  ref={props.barRef}
247
+ title={props.hint}
241
248
  onScroll={(e) => props.onScroll(e.currentTarget.scrollLeft)}
242
249
  >
243
250
  <div className="rvs-hbar-inner" style={{ width: props.width }} />
@@ -265,20 +272,68 @@ export function DiffView(props: { tab: EditorTab; visible: boolean }): ReactNode
265
272
  const leftRef = useRef<HTMLDivElement>(null)
266
273
  const rightRef = useRef<HTMLDivElement>(null)
267
274
  const hbarRef = useRef<HTMLDivElement>(null)
268
- const [barWidth, setBarWidth] = useState(0)
275
+ const gridRef = useRef<HTMLDivElement | null>(null)
276
+ /**
277
+ * Widest rendered line vs the width the grid actually shows.
278
+ *
279
+ * Only the PAIR matters: the top bar exists for one reason — telling the user
280
+ * "this line continues past the edge, grab me" — so it is rendered only when
281
+ * `content > viewport`. A permanently mounted 13 px band was invisible in
282
+ * practice (a 1 px spacer, `thin` scrollbar, low-contrast thumb) and read as
283
+ * "there is no horizontal scrollbar at all".
284
+ */
285
+ const [hscroll, setHscroll] = useState({ content: 0, viewport: 0 })
269
286
  const rowsInView = useRowWindow(tab.model?.rows ?? [])
270
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])
271
290
  /**
272
- * Measure the widest pane and size the top bar's spacer to it. Runs after
273
- * every model change, on container resizes (a pane that is empty until
274
- * its fonts settle would otherwise leave the bar too short) and whenever
275
- * the window slides, since the widest line on screen changes with it.
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
+
325
+ /**
326
+ * Measure the widest pane and the grid's own width. Runs after every model
327
+ * change, on container resizes (a pane that is empty until its fonts settle
328
+ * would otherwise leave the bar too short) and whenever the window slides,
329
+ * since the widest line on screen changes with it.
276
330
  */
277
331
  useLayoutEffect(() => {
278
332
  if (mode !== 'split') return
279
333
  const measure = (): void => {
280
- const widest = Math.max(leftRef.current?.scrollWidth ?? 0, rightRef.current?.scrollWidth ?? 0)
281
- setBarWidth(widest)
334
+ const content = Math.max(leftRef.current?.scrollWidth ?? 0, rightRef.current?.scrollWidth ?? 0)
335
+ const viewport = gridRef.current?.clientWidth ?? 0
336
+ setHscroll(previous => (previous.content === content && previous.viewport === viewport ? previous : { content, viewport }))
282
337
  }
283
338
  measure()
284
339
  const timer = window.setTimeout(measure, 80)
@@ -419,6 +474,19 @@ export function DiffView(props: { tab: EditorTab; visible: boolean }): ReactNode
419
474
  <span style={{ color: 'var(--rvs-git-d)' }}>-{counts.removed}</span>
420
475
  {tab.staged && <span className="rvs-pill">{t('scm.stagedReadonly')}</span>}
421
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
+ )}
422
490
  {busy && <MiniSpinner />}
423
491
  <IconButton title={t('scm.reloadDiff')} onClick={() => { void reload() }} disabled={busy}>
424
492
  <IconRefresh size={13} />
@@ -431,10 +499,24 @@ export function DiffView(props: { tab: EditorTab; visible: boolean }): ReactNode
431
499
  </div>
432
500
  {mode === 'split' && (
433
501
  <>
434
- <HScrollBar barRef={hbarRef} width={barWidth} onScroll={syncFromBar} />
502
+ {hscroll.content > hscroll.viewport + 1 && (
503
+ <HScrollBar barRef={hbarRef} width={hscroll.content} hint={t('scm.hScrollHint')} onScroll={syncFromBar} />
504
+ )}
435
505
  {/* The grid owns the vertical scroll for all three columns, so the
436
506
  window below is shared and the panes cannot drift apart. */}
437
- <div className="rvs-diff-grid" ref={rowsInView.attachGrid} onScroll={rowsInView.onScroll}>
507
+ <div
508
+ className="rvs-diff-grid"
509
+ ref={(element) => {
510
+ // Two consumers, one element: the row window's observer and the
511
+ // horizontal measurement above.
512
+ rowsInView.attachGrid(element)
513
+ gridRef.current = element
514
+ }}
515
+ onScroll={(event) => {
516
+ rowsInView.onScroll(event)
517
+ syncHunkCursor(event.currentTarget)
518
+ }}
519
+ >
438
520
  <div className="rvs-diff-cols">
439
521
  {/* Left pane: fixed 50% (see .rvs-diff-col) with its own hidden
440
522
  horizontal scroll, driven from the bar above. */}
@@ -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
+ }
@@ -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);
@@ -1118,15 +1141,19 @@ body[data-ds-dark-theme] .rvs-root {
1118
1141
  * The two panes scroll horizontally on their own — so a long line can never
1119
1142
  * widen the layout — and this proxy bar drives both of them from the TOP of
1120
1143
  * the view, instead of forcing a scroll to the bottom of the file to reach a
1121
- * horizontal bar. Its inner spacer is sized to the widest pane.
1144
+ * horizontal bar. Its inner spacer is sized to the widest line, and the
1145
+ * component only renders it when a line really is wider than the grid.
1146
+ *
1147
+ * Sized and coloured to be SEEN: as a 13 px band with `scrollbar-width: thin`
1148
+ * and a faint thumb, it read as "there is no horizontal scrollbar at all".
1122
1149
  */
1123
1150
  .rvs-hbar {
1124
1151
  flex: none;
1125
- height: 13px;
1152
+ height: 15px;
1126
1153
  overflow-x: auto;
1127
1154
  overflow-y: hidden;
1128
1155
  border-bottom: 1px solid var(--rvs-border);
1129
- scrollbar-width: thin;
1156
+ background: var(--rvs-hover);
1130
1157
  }
1131
1158
 
1132
1159
  .rvs-hbar-inner {
@@ -1134,16 +1161,19 @@ body[data-ds-dark-theme] .rvs-root {
1134
1161
  }
1135
1162
 
1136
1163
  .rvs-hbar::-webkit-scrollbar {
1137
- height: 9px;
1164
+ height: 11px;
1138
1165
  }
1139
1166
 
1140
1167
  .rvs-hbar::-webkit-scrollbar-thumb {
1141
- background: var(--dsw-alias-scrollbar-bg-l2, rgba(127, 127, 127, .45));
1142
- border-radius: 5px;
1168
+ background: var(--dsw-alias-scrollbar-bg-l2, rgba(150, 160, 180, .75));
1169
+ border-radius: 6px;
1170
+ border: 2px solid transparent;
1171
+ background-clip: padding-box;
1143
1172
  }
1144
1173
 
1145
1174
  .rvs-hbar::-webkit-scrollbar-thumb:hover {
1146
- background: var(--dsw-alias-scrollbar-hover-l2, rgba(127, 127, 127, .65));
1175
+ background: var(--dsw-alias-scrollbar-hover-l2, rgba(170, 180, 200, .95));
1176
+ background-clip: padding-box;
1147
1177
  }
1148
1178
 
1149
1179
  .rvs-hbar::-webkit-scrollbar-track {
@@ -1159,8 +1189,10 @@ body[data-ds-dark-theme] .rvs-root {
1159
1189
  /**
1160
1190
  * One diff pane. `flex: 1 1 0` keeps the panes EXACTLY equal however long a
1161
1191
  * line is (the previous row markup let a long line widen its own pane, which
1162
- * is what broke the symmetry), and each pane carries its own horizontal
1163
- * scroll — hidden, because the top bar is the user-facing control.
1192
+ * is what broke the symmetry). Each pane keeps its own horizontal scroll, and
1193
+ * its scrollbar is now VISIBLE: the top proxy remains the convenient control,
1194
+ * but the pane's own bar is what a user reaches for first, and it sits right
1195
+ * under the content when the file is shorter than the view.
1164
1196
  */
1165
1197
  .rvs-diff-col {
1166
1198
  flex: 1 1 0;
@@ -1170,10 +1202,23 @@ body[data-ds-dark-theme] .rvs-root {
1170
1202
  }
1171
1203
 
1172
1204
  .rvs-diff-col::-webkit-scrollbar {
1173
- height: 0;
1205
+ height: 9px;
1174
1206
  width: 0;
1175
1207
  }
1176
1208
 
1209
+ .rvs-diff-col::-webkit-scrollbar-thumb {
1210
+ background: var(--dsw-alias-scrollbar-bg-l2, rgba(150, 160, 180, .6));
1211
+ border-radius: 5px;
1212
+ }
1213
+
1214
+ .rvs-diff-col::-webkit-scrollbar-thumb:hover {
1215
+ background: var(--dsw-alias-scrollbar-hover-l2, rgba(170, 180, 200, .85));
1216
+ }
1217
+
1218
+ .rvs-diff-col::-webkit-scrollbar-track {
1219
+ background: transparent;
1220
+ }
1221
+
1177
1222
  .rvs-dline {
1178
1223
  display: flex;
1179
1224
  align-items: flex-start;
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)}`)