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/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/client.js +166 -29
- package/lib/index.js +1 -1
- package/lib/p2-router-fs.js +1 -1
- package/lib/types/client/scm/diff-model.d.ts +30 -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/SearchView.tsx +14 -12
- package/src/client/i18n.ts +8 -0
- package/src/client/scm/DiffView.tsx +95 -13
- package/src/client/scm/diff-model.ts +44 -0
- package/src/client/vsc/kit.tsx +5 -0
- package/src/client/vsc/vsc.css +55 -10
- package/src/p2/worlds.ts +18 -4
- package/src/workspace-title.ts +58 -0
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-
|
|
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";
|
package/lib/p2-router-fs.js
CHANGED
|
@@ -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
|
-
|
|
18526
|
-
const
|
|
18527
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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])
|
package/src/client/i18n.ts
CHANGED
|
@@ -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
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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
|
|
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
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* the
|
|
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
|
|
281
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
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);
|
|
@@ -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
|
|
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:
|
|
1152
|
+
height: 15px;
|
|
1126
1153
|
overflow-x: auto;
|
|
1127
1154
|
overflow-y: hidden;
|
|
1128
1155
|
border-bottom: 1px solid var(--rvs-border);
|
|
1129
|
-
|
|
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:
|
|
1164
|
+
height: 11px;
|
|
1138
1165
|
}
|
|
1139
1166
|
|
|
1140
1167
|
.rvs-hbar::-webkit-scrollbar-thumb {
|
|
1141
|
-
background: var(--dsw-alias-scrollbar-bg-l2, rgba(
|
|
1142
|
-
border-radius:
|
|
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(
|
|
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)
|
|
1163
|
-
*
|
|
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:
|
|
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
|
-
|
|
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)}`)
|