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/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
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { type ReactNode } from 'react';
|
|
11
11
|
import type { RemoteHostSummary } from '../protocol.ts';
|
|
12
|
+
import type { TerminalRequest } from './store.tsx';
|
|
12
13
|
export declare function TerminalView(props: {
|
|
13
14
|
t: (key: string) => string;
|
|
14
15
|
hosts: RemoteHostSummary[];
|
|
15
16
|
visible: boolean;
|
|
17
|
+
/** A pending "open a terminal here" request from the search results' menu. */
|
|
18
|
+
request?: TerminalRequest;
|
|
16
19
|
}): ReactNode;
|
|
@@ -12,4 +12,10 @@ export declare function XTerm(props: {
|
|
|
12
12
|
fontFamily?: string;
|
|
13
13
|
/** When the parent sub-tab hides/shows this view without unmounting. */
|
|
14
14
|
visible?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Line typed into the fresh shell once its PTY reports ready (a menu entry's
|
|
17
|
+
* `cd`). Sent ONCE per mount: a reconnect is a new mount and thus a new shell,
|
|
18
|
+
* which correctly re-runs the line.
|
|
19
|
+
*/
|
|
20
|
+
initialInput?: string;
|
|
15
21
|
}): ReactNode;
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where a clicked file reference should open when the owning session lives in
|
|
3
|
-
* a remote workspace (pure — no React, no fetch, so the gate can drive it).
|
|
4
|
-
*
|
|
5
|
-
* DSH renders every file reference as a `dsh-resource://file/…` address and
|
|
6
|
-
* opens it through its own right-sidebar preview. That preview resolves the
|
|
7
|
-
* address path against the SESSION WORKSPACE ROOT. A model that quotes a path
|
|
8
|
-
* relative to its CURRENT directory (e.g. `_xfer/reports/x.md` while working
|
|
9
|
-
* in `<workspace>/<inner-repo>`) therefore misses by one segment, and a
|
|
10
|
-
* remote session's workspace root only exists as an EMPTY local alias dir —
|
|
11
|
-
* the click dies with the host-side ENOENT the user sees.
|
|
12
|
-
*
|
|
13
|
-
* This module answers "which binding, which remote paths to try" for the
|
|
14
|
-
* client interceptor:
|
|
15
|
-
* - relative paths are tried against the workspace root first and the
|
|
16
|
-
* session's current working directory second (the model's usual writing
|
|
17
|
-
* base), which is what makes the one-segment miss heal;
|
|
18
|
-
* - absolute spellings (local alias, POSIX remote, drive-qualified alias)
|
|
19
|
-
* are folded back onto the binding;
|
|
20
|
-
* - anything that does not name a known binding answers `undefined`, so the
|
|
21
|
-
* native preview keeps handling local sessions and unknown addresses.
|
|
22
|
-
*/
|
|
23
1
|
/** One remote binding as the client store exposes it. */
|
|
24
2
|
export interface RemoteOpenBinding {
|
|
25
3
|
id: string;
|
|
@@ -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 {};
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
* Quote one value for a POSIX shell, as a single word.
|
|
15
|
+
*
|
|
16
|
+
* @param value - the raw path or word.
|
|
17
|
+
* @returns `'value'` with every embedded `'` spliced as `'\''`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function shellQuote(value: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* The command typed into a fresh remote terminal to land in one directory.
|
|
22
|
+
*
|
|
23
|
+
* A trailing newline is NOT included: the caller writes the line and the
|
|
24
|
+
* terminal's Enter, which keeps the same string reusable for display.
|
|
25
|
+
*
|
|
26
|
+
* @param dir - absolute remote directory.
|
|
27
|
+
* @returns `cd '<dir>'`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function cdCommand(dir: string): string;
|
|
@@ -82,6 +82,30 @@ export interface SearchRequest {
|
|
|
82
82
|
/** Absolute remote directory the search is scoped to. */
|
|
83
83
|
scope: string;
|
|
84
84
|
}
|
|
85
|
+
/** One "show this folder in the explorer tree" request. */
|
|
86
|
+
export interface RevealRequest {
|
|
87
|
+
/** Monotonic id so asking for the same folder twice still re-triggers. */
|
|
88
|
+
id: number;
|
|
89
|
+
/** Absolute remote DIRECTORY to expand and scroll to. */
|
|
90
|
+
path: string;
|
|
91
|
+
}
|
|
92
|
+
/** One "open a terminal here" request. */
|
|
93
|
+
export interface TerminalRequest {
|
|
94
|
+
/** Monotonic id so the same folder can be opened twice. */
|
|
95
|
+
id: number;
|
|
96
|
+
/**
|
|
97
|
+
* Host alias the directory lives on.
|
|
98
|
+
*
|
|
99
|
+
* Carried explicitly, and NOT taken from the terminal panel's own last-used
|
|
100
|
+
* host: the panel remembers whatever host the user ran a shell on last, so a
|
|
101
|
+
* menu action on a workspace bound elsewhere opened the shell on the WRONG
|
|
102
|
+
* machine — the `cd` then failed with "No such file or directory" for a path
|
|
103
|
+
* that exists perfectly well on its own host.
|
|
104
|
+
*/
|
|
105
|
+
alias: string;
|
|
106
|
+
/** Absolute remote directory the fresh shell should `cd` into. */
|
|
107
|
+
dir: string;
|
|
108
|
+
}
|
|
85
109
|
/** Stored repository sub-directory for one binding ('' = the workspace root). */
|
|
86
110
|
export declare function readRepoSubdir(workspaceId: string): string;
|
|
87
111
|
/** Everything a view can read, plus the actions. */
|
|
@@ -123,6 +147,10 @@ export interface ExplorerValue {
|
|
|
123
147
|
editorSurface: 'center' | 'sidebar';
|
|
124
148
|
view: RailView;
|
|
125
149
|
searchRequest: SearchRequest | undefined;
|
|
150
|
+
/** Pending "show this folder in the tree" request (consumed by the explorer). */
|
|
151
|
+
revealRequest: RevealRequest | undefined;
|
|
152
|
+
/** Pending "open a terminal here" request (consumed by the terminal panel). */
|
|
153
|
+
terminalRequest: TerminalRequest | undefined;
|
|
126
154
|
setView: (view: RailView) => void;
|
|
127
155
|
/** Switch to the search panel (optionally scoped to a folder) — the tree's
|
|
128
156
|
"find in folder" entry point. */
|
|
@@ -131,6 +159,17 @@ export interface ExplorerValue {
|
|
|
131
159
|
query?: string;
|
|
132
160
|
scope: string;
|
|
133
161
|
}) => void;
|
|
162
|
+
/**
|
|
163
|
+
* Show one remote directory in the explorer: switch to it, expand the folder
|
|
164
|
+
* and its ancestors, then scroll its row into view. Raised by the search
|
|
165
|
+
* results' "open containing folder" entry.
|
|
166
|
+
*/
|
|
167
|
+
revealInExplorer: (dir: string) => void;
|
|
168
|
+
/**
|
|
169
|
+
* Open a fresh terminal tab whose shell `cd`s into `dir`. Raised by the
|
|
170
|
+
* search results' "open in terminal" entry.
|
|
171
|
+
*/
|
|
172
|
+
openTerminalAt: (dir: string) => void;
|
|
134
173
|
/**
|
|
135
174
|
* The USER's pick — the workspace dropdowns and the wizard's "open files".
|
|
136
175
|
*
|
|
@@ -197,6 +236,32 @@ export declare function requestSearch(request: {
|
|
|
197
236
|
query?: string;
|
|
198
237
|
scope: string;
|
|
199
238
|
}): void;
|
|
239
|
+
/**
|
|
240
|
+
* Show one remote directory in the explorer.
|
|
241
|
+
*
|
|
242
|
+
* Two stages, because the tree is lazily loaded: this raises the request and
|
|
243
|
+
* switches the panel, and the explorer itself expands the ancestors (loading
|
|
244
|
+
* each level) and scrolls the row into view — it is the only place that knows
|
|
245
|
+
* which directories are currently expanded.
|
|
246
|
+
*
|
|
247
|
+
* @param dir - absolute remote DIRECTORY (a file's parent for a file hit).
|
|
248
|
+
*/
|
|
249
|
+
export declare function revealInExplorer(dir: string): void;
|
|
250
|
+
/**
|
|
251
|
+
* Open a fresh terminal tab whose shell lands in `dir` — on the CURRENT
|
|
252
|
+
* workspace's host.
|
|
253
|
+
*
|
|
254
|
+
* The terminal panel consumes the request (it owns the tabs) and the shell gets
|
|
255
|
+
* a quoted `cd` once its PTY reports ready — the host's terminal route has no
|
|
256
|
+
* "start in this directory" parameter, so a typed command is the honest way.
|
|
257
|
+
*
|
|
258
|
+
* The alias travels IN the request. The panel otherwise opens new tabs on the
|
|
259
|
+
* host it last used, which put the shell on a different machine than the path
|
|
260
|
+
* belongs to and made the `cd` fail with "No such file or directory".
|
|
261
|
+
*
|
|
262
|
+
* @param dir - absolute remote directory.
|
|
263
|
+
*/
|
|
264
|
+
export declare function openTerminalAt(dir: string): void;
|
|
200
265
|
/**
|
|
201
266
|
* Select the binding every view works against — the USER's choice.
|
|
202
267
|
*
|
|
@@ -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.9",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -265,6 +265,51 @@ export function RemoteExplorer(props: { showHidden: boolean; onToggleHidden: ()
|
|
|
265
265
|
}
|
|
266
266
|
}, [alias])
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* "Show this folder in the tree" — raised by a search result's menu.
|
|
270
|
+
*
|
|
271
|
+
* Two stages, because the tree is lazy: expand every level from the root down
|
|
272
|
+
* to the target (the listing of each level is fetched here) and then scroll
|
|
273
|
+
* its row into view. The scroll is retried rather than delayed once: over SFTP
|
|
274
|
+
* a deep level can take a moment, and the request carries a monotonic id so
|
|
275
|
+
* asking for the SAME folder again still re-scrolls.
|
|
276
|
+
*/
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
const request = store.revealRequest
|
|
279
|
+
if (request === undefined) return
|
|
280
|
+
const base = root.replace(/\/+$/, '')
|
|
281
|
+
const target = request.path.replace(/\/+$/, '')
|
|
282
|
+
if (target !== base && !target.startsWith(`${base}/`)) return
|
|
283
|
+
const chain: string[] = []
|
|
284
|
+
let walk = base
|
|
285
|
+
for (const part of target.slice(base.length).split('/')) {
|
|
286
|
+
if (part === '') continue
|
|
287
|
+
walk = `${walk}/${part}`
|
|
288
|
+
chain.push(walk)
|
|
289
|
+
}
|
|
290
|
+
for (const dir of chain) void loadDir(dir)
|
|
291
|
+
if (chain.length > 0) {
|
|
292
|
+
setExpanded((previous) => {
|
|
293
|
+
const next = new Set(previous)
|
|
294
|
+
for (const dir of chain) next.add(dir)
|
|
295
|
+
return next
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
let attempts = 0
|
|
299
|
+
const timer = window.setInterval(() => {
|
|
300
|
+
attempts += 1
|
|
301
|
+
const row = Array.from(document.querySelectorAll<HTMLElement>('[data-rvs-path]'))
|
|
302
|
+
.find((candidate) => candidate.dataset.rvsPath === target)
|
|
303
|
+
if (row !== undefined) {
|
|
304
|
+
window.clearInterval(timer)
|
|
305
|
+
row.scrollIntoView({ block: 'center' })
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
if (attempts > 20) window.clearInterval(timer)
|
|
309
|
+
}, 150)
|
|
310
|
+
return () => window.clearInterval(timer)
|
|
311
|
+
}, [store.revealRequest, root, loadDir])
|
|
312
|
+
|
|
268
313
|
// Root listing + refresh on the shared revision bump (git writes, saves...).
|
|
269
314
|
const revision = store.revision
|
|
270
315
|
/**
|
|
@@ -246,7 +246,7 @@ function Shell(props: { ctx: Context; visible: boolean }): ReactNode {
|
|
|
246
246
|
case 'terminal':
|
|
247
247
|
// The terminal draws its own header + tab strip, so it takes the whole
|
|
248
248
|
// slot (no extra padding wrapper).
|
|
249
|
-
return <TerminalView t={t} hosts={store.hosts} visible={panelLive('terminal')} />
|
|
249
|
+
return <TerminalView t={t} hosts={store.hosts} visible={panelLive('terminal')} request={store.terminalRequest} />
|
|
250
250
|
case 'workspaces':
|
|
251
251
|
return (
|
|
252
252
|
<div className="rvs-viewpad" style={{ height: '100%' }}>
|
|
@@ -20,9 +20,10 @@ import {
|
|
|
20
20
|
type ContentHit,
|
|
21
21
|
} from '../search-ops.ts'
|
|
22
22
|
import { searchFiles, type SearchHit } from './scm/actions.ts'
|
|
23
|
-
import { useExplorer } from './store.tsx'
|
|
23
|
+
import { repoRelative, useExplorer } from './store.tsx'
|
|
24
24
|
import { api } from './api.ts'
|
|
25
25
|
import {
|
|
26
|
+
ContextMenu,
|
|
26
27
|
FileIcon,
|
|
27
28
|
IconCase,
|
|
28
29
|
IconContentSearch,
|
|
@@ -33,6 +34,9 @@ import {
|
|
|
33
34
|
MiniSpinner,
|
|
34
35
|
Segmented,
|
|
35
36
|
baseName,
|
|
37
|
+
copyText,
|
|
38
|
+
toast,
|
|
39
|
+
useContextMenu,
|
|
36
40
|
} from './vsc/kit.tsx'
|
|
37
41
|
|
|
38
42
|
type Mode = 'name' | 'content'
|
|
@@ -77,7 +81,7 @@ function relativeTo(root: string, full: string): string {
|
|
|
77
81
|
|
|
78
82
|
export function SearchView(): ReactNode {
|
|
79
83
|
const store = useExplorer()
|
|
80
|
-
const { t, alias, root } = store
|
|
84
|
+
const { t, alias, root, repoRoot } = store
|
|
81
85
|
const [mode, setModeState] = useState<Mode>(readMode)
|
|
82
86
|
const [query, setQuery] = useState('')
|
|
83
87
|
/** Absolute remote directory the search runs in ('' = workspace root). */
|
|
@@ -93,6 +97,7 @@ export function SearchView(): ReactNode {
|
|
|
93
97
|
const [ran, setRan] = useState(false)
|
|
94
98
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
95
99
|
const seq = useRef(0)
|
|
100
|
+
const menu = useContextMenu()
|
|
96
101
|
|
|
97
102
|
const effectiveScope = scope === '' ? root : scope
|
|
98
103
|
const scopeLabel = useMemo(() => {
|
|
@@ -224,6 +229,37 @@ export function SearchView(): ReactNode {
|
|
|
224
229
|
store.openFile(path, line === undefined ? undefined : { line })
|
|
225
230
|
}
|
|
226
231
|
|
|
232
|
+
/** Absolute path of one NAME hit, plus the DIRECTORY a menu action should use. */
|
|
233
|
+
const nameHitPaths = (hit: SearchHit): { abs: string; dir: string } => {
|
|
234
|
+
const base = effectiveScope.replace(/\/+$/, '')
|
|
235
|
+
const abs = `${base}/${hit.path}`
|
|
236
|
+
// A directory hit (only the `find` fallback reports those) is its own
|
|
237
|
+
// folder; a file hit's actions target the folder it lives in.
|
|
238
|
+
const dir = hit.isDir ? abs : (abs.slice(0, abs.lastIndexOf('/')) || '/')
|
|
239
|
+
return { abs, dir }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Right-click on a name result: the four things one does with a found path.
|
|
244
|
+
*
|
|
245
|
+
* "Relative" is relative to the REPOSITORY when the workspace sits inside one
|
|
246
|
+
* (the same relative form the tree's own menu copies), else absolute — which
|
|
247
|
+
* is what makes the copied value paste straight into a shell that already ran
|
|
248
|
+
* `cd` into the repo.
|
|
249
|
+
*/
|
|
250
|
+
const openHitMenu = (event: React.MouseEvent, hit: SearchHit): void => {
|
|
251
|
+
event.preventDefault()
|
|
252
|
+
event.stopPropagation()
|
|
253
|
+
const { abs, dir } = nameHitPaths(hit)
|
|
254
|
+
const rel = repoRelative(repoRoot, abs) || abs
|
|
255
|
+
menu.open(event.clientX, event.clientY, [
|
|
256
|
+
{ label: t('files.copyAbs'), onSelect: () => { void copyText(abs); toast(t('files.copiedClipboard')) } },
|
|
257
|
+
{ label: t('files.copyRel'), onSelect: () => { void copyText(rel); toast(t('files.copiedClipboard')) } },
|
|
258
|
+
{ label: t('search.revealInExplorer'), onSelect: () => { store.revealInExplorer(dir) }, separated: true },
|
|
259
|
+
{ label: t('search.openInTerminal'), onSelect: () => { store.openTerminalAt(dir) } },
|
|
260
|
+
])
|
|
261
|
+
}
|
|
262
|
+
|
|
227
263
|
const groups = useMemo(() => groupHits(contentHits), [contentHits])
|
|
228
264
|
|
|
229
265
|
/**
|
|
@@ -351,6 +387,7 @@ export function SearchView(): ReactNode {
|
|
|
351
387
|
className="rvs-hit"
|
|
352
388
|
title={hit.path}
|
|
353
389
|
onClick={() => openHit(`${(effectiveScope === '' ? root : effectiveScope).replace(/\/+$/, '')}/${hit.path}`)}
|
|
390
|
+
onContextMenu={(event) => { openHitMenu(event, hit) }}
|
|
354
391
|
>
|
|
355
392
|
<span className="rvs-node-icon">
|
|
356
393
|
{hit.isDir ? <span style={{ color: 'var(--rvs-fg3)' }}>▸</span> : <FileIcon path={hit.path} size={14} />}
|
|
@@ -404,6 +441,9 @@ export function SearchView(): ReactNode {
|
|
|
404
441
|
</>
|
|
405
442
|
)}
|
|
406
443
|
</div>
|
|
444
|
+
{menu.menu !== undefined && (
|
|
445
|
+
<ContextMenu x={menu.menu.x} y={menu.menu.y} entries={menu.menu.entries} onClose={menu.close} />
|
|
446
|
+
)}
|
|
407
447
|
</div>
|
|
408
448
|
)
|
|
409
449
|
}
|
|
@@ -11,6 +11,8 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
|
11
11
|
import type { RemoteHostSummary } from '../protocol.ts'
|
|
12
12
|
import { RemoteApiError, api } from './api.ts'
|
|
13
13
|
import { XTerm } from './XTerm.tsx'
|
|
14
|
+
import { cdCommand } from './shell.ts'
|
|
15
|
+
import type { TerminalRequest } from './store.tsx'
|
|
14
16
|
import { optionStyle } from './ui.tsx'
|
|
15
17
|
import {
|
|
16
18
|
IconButton,
|
|
@@ -32,6 +34,8 @@ interface TermTab {
|
|
|
32
34
|
/** Bumped to force a fresh connection (connect / reconnect). */
|
|
33
35
|
nonce: number
|
|
34
36
|
connected: boolean
|
|
37
|
+
/** Command typed once the shell reports ready (a menu entry's `cd`). */
|
|
38
|
+
initialCommand?: string
|
|
35
39
|
/** Human-readable reason the tab is not connected. */
|
|
36
40
|
note?: string
|
|
37
41
|
}
|
|
@@ -84,10 +88,21 @@ function newId(): string {
|
|
|
84
88
|
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The last "open a terminal here" request this page already acted on.
|
|
93
|
+
*
|
|
94
|
+
* Module scope, not a component ref: the panel is normally kept mounted, but a
|
|
95
|
+
* remount would reset a ref and re-open a tab for the SAME old request. A new
|
|
96
|
+
* request always carries a larger id, so one counter serves both goals.
|
|
97
|
+
*/
|
|
98
|
+
let openedTerminalRequest = 0
|
|
99
|
+
|
|
87
100
|
export function TerminalView(props: {
|
|
88
101
|
t: (key: string) => string
|
|
89
102
|
hosts: RemoteHostSummary[]
|
|
90
103
|
visible: boolean
|
|
104
|
+
/** A pending "open a terminal here" request from the search results' menu. */
|
|
105
|
+
request?: TerminalRequest
|
|
91
106
|
}): ReactNode {
|
|
92
107
|
const t = props.t
|
|
93
108
|
const [alias, setAlias] = useState<string>(readAlias)
|
|
@@ -126,13 +141,48 @@ export function TerminalView(props: {
|
|
|
126
141
|
setTabs(prev => prev.map(tab => (tab.id === id ? { ...tab, ...next } : tab)))
|
|
127
142
|
}, [])
|
|
128
143
|
|
|
129
|
-
const openTab = useCallback((host: string): void => {
|
|
144
|
+
const openTab = useCallback((host: string, initialCommand?: string): void => {
|
|
130
145
|
if (host === '') return
|
|
131
|
-
const tab: TermTab = {
|
|
146
|
+
const tab: TermTab = {
|
|
147
|
+
id: newId(),
|
|
148
|
+
alias: host,
|
|
149
|
+
nonce: 0,
|
|
150
|
+
connected: true,
|
|
151
|
+
...(initialCommand === undefined ? {} : { initialCommand }),
|
|
152
|
+
}
|
|
132
153
|
setTabs(prev => [...prev, tab])
|
|
133
154
|
setActiveId(tab.id)
|
|
134
155
|
}, [])
|
|
135
156
|
|
|
157
|
+
/**
|
|
158
|
+
* "Open a terminal here" (a search result's menu): open one tab whose shell
|
|
159
|
+
* lands in that folder, on the host the folder BELONGS to.
|
|
160
|
+
*
|
|
161
|
+
* The alias must come from the request, not from `effectiveAlias`: the panel
|
|
162
|
+
* remembers whatever host the user last ran a shell on, so a menu action on a
|
|
163
|
+
* workspace bound elsewhere used to open the shell on the wrong machine and
|
|
164
|
+
* the `cd` failed with "No such file or directory" for a path that is fine on
|
|
165
|
+
* its own host.
|
|
166
|
+
*
|
|
167
|
+
* The folder is reached by TYPING `cd` once the PTY reports ready (see
|
|
168
|
+
* `XTerm`'s `initialInput`) — the host's terminal route has no "start in this
|
|
169
|
+
* directory" parameter, and inventing one would mean a host-side change for a
|
|
170
|
+
* display feature. The request id makes a repeat for the same folder still
|
|
171
|
+
* open a second tab.
|
|
172
|
+
*/
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const request = props.request
|
|
175
|
+
if (request === undefined || request.id === openedTerminalRequest) return
|
|
176
|
+
openedTerminalRequest = request.id
|
|
177
|
+
const target = request.alias !== '' && props.hosts.some(host => host.alias === request.alias)
|
|
178
|
+
? request.alias
|
|
179
|
+
: effectiveAlias
|
|
180
|
+
// Keep the header picker in step with the tab that just opened, so the next
|
|
181
|
+
// "+" continues on the same host.
|
|
182
|
+
if (target !== alias) setAlias(target)
|
|
183
|
+
openTab(target, cdCommand(request.dir))
|
|
184
|
+
}, [props.request, props.hosts, effectiveAlias, alias, openTab])
|
|
185
|
+
|
|
136
186
|
const closeTab = useCallback(async (tab: TermTab): Promise<void> => {
|
|
137
187
|
const ok = await confirmDialog({
|
|
138
188
|
title: t('term.closeTitle'),
|
|
@@ -276,6 +326,7 @@ export function TerminalView(props: {
|
|
|
276
326
|
rows: 24,
|
|
277
327
|
})}
|
|
278
328
|
visible={props.visible && tab.id === active?.id}
|
|
329
|
+
{...(tab.initialCommand === undefined ? {} : { initialInput: tab.initialCommand })}
|
|
279
330
|
onReady={(id) => patchTab(tab.id, { session: id, connected: true, note: undefined })}
|
|
280
331
|
onExit={(code, message) => {
|
|
281
332
|
if (message === 'terminal session expired') {
|
package/src/client/XTerm.tsx
CHANGED
|
@@ -106,6 +106,12 @@ export function XTerm(props: {
|
|
|
106
106
|
fontFamily?: string
|
|
107
107
|
/** When the parent sub-tab hides/shows this view without unmounting. */
|
|
108
108
|
visible?: boolean
|
|
109
|
+
/**
|
|
110
|
+
* Line typed into the fresh shell once its PTY reports ready (a menu entry's
|
|
111
|
+
* `cd`). Sent ONCE per mount: a reconnect is a new mount and thus a new shell,
|
|
112
|
+
* which correctly re-runs the line.
|
|
113
|
+
*/
|
|
114
|
+
initialInput?: string
|
|
109
115
|
}): ReactNode {
|
|
110
116
|
const hostRef = useRef<HTMLDivElement>(null)
|
|
111
117
|
const propsRef = useRef(props)
|
|
@@ -143,6 +149,8 @@ export function XTerm(props: {
|
|
|
143
149
|
|
|
144
150
|
const ws = new WebSocket(propsRef.current.wsUrl)
|
|
145
151
|
let opened = false
|
|
152
|
+
/** The seeded line is typed at most once per mount (a reconnect re-mounts). */
|
|
153
|
+
let seeded = false
|
|
146
154
|
let exitReported = false
|
|
147
155
|
const reportExit = (code: number | null, message?: string): void => {
|
|
148
156
|
if (exitReported) return
|
|
@@ -208,6 +216,13 @@ export function XTerm(props: {
|
|
|
208
216
|
}
|
|
209
217
|
if (frame.t === 'ready') {
|
|
210
218
|
propsRef.current.onReady(frame.id)
|
|
219
|
+
// The shell exists now: type the seeded line (a menu entry's `cd`), as a
|
|
220
|
+
// normal input line so the shell echoes it and redraws its prompt.
|
|
221
|
+
const seed = propsRef.current.initialInput
|
|
222
|
+
if (!seeded && seed !== undefined && seed !== '') {
|
|
223
|
+
seeded = true
|
|
224
|
+
send(ws, { t: 'in', d: `${seed}\n` })
|
|
225
|
+
}
|
|
211
226
|
} else if (frame.t === 'out') {
|
|
212
227
|
term.write(frame.d)
|
|
213
228
|
} else if (frame.t === 'exit') {
|