dsh-remote-vs 0.8.8 → 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 +279 -13
- 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/shell.d.ts +29 -0
- package/lib/types/client/store.d.ts +65 -0
- 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 +4 -0
- package/src/client/remote-file-open-target.ts +1 -5
- package/src/client/shell.ts +35 -0
- package/src/client/store.tsx +93 -0
package/lib/client.js
CHANGED
|
@@ -1413,6 +1413,8 @@ window.__ModuleLoader__.load({
|
|
|
1413
1413
|
"editor.tokenCount": "语法着色 token 数(0 = 没有语法包或加载的是旧 bundle)",
|
|
1414
1414
|
"search.empty": "没有匹配的文件。",
|
|
1415
1415
|
"search.truncated": "结果过多,仅显示前一批。",
|
|
1416
|
+
"search.revealInExplorer": "打开文件所在位置",
|
|
1417
|
+
"search.openInTerminal": "在终端中打开",
|
|
1416
1418
|
"scm.title": "源代码管理",
|
|
1417
1419
|
"scm.refresh": "刷新",
|
|
1418
1420
|
"scm.changes": "更改",
|
|
@@ -1745,6 +1747,8 @@ window.__ModuleLoader__.load({
|
|
|
1745
1747
|
"editor.tokenCount": "Colored syntax tokens (0 = no grammar, or a stale client bundle)",
|
|
1746
1748
|
"search.empty": "No matching files.",
|
|
1747
1749
|
"search.truncated": "Too many hits; showing the first batch.",
|
|
1750
|
+
"search.revealInExplorer": "Open containing folder",
|
|
1751
|
+
"search.openInTerminal": "Open in terminal",
|
|
1748
1752
|
"scm.title": "Source control",
|
|
1749
1753
|
"scm.refresh": "Refresh",
|
|
1750
1754
|
"scm.changes": "Changes",
|
|
@@ -37990,7 +37994,7 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
37990
37994
|
const primary = host.querySelectorAll("[class*=\"rvs-ck-\"]").length;
|
|
37991
37995
|
const fallback = host.querySelectorAll("span[class^=\"rvs-t-\"]").length;
|
|
37992
37996
|
const count = primary > 0 ? primary : fallback;
|
|
37993
|
-
console.info(`[dsh-remote-vs] build
|
|
37997
|
+
console.info(`[dsh-remote-vs] build 1026-0953 · ${source} · colored tokens: ${count} (${propsRef.current.path})`);
|
|
37994
37998
|
propsRef.current.onTokens?.(count);
|
|
37995
37999
|
};
|
|
37996
38000
|
const armFallback = () => {
|
|
@@ -40323,7 +40327,64 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
40323
40327
|
return next ?? current;
|
|
40324
40328
|
}
|
|
40325
40329
|
//#endregion
|
|
40330
|
+
//#region src/client/shell.ts
|
|
40331
|
+
/**
|
|
40332
|
+
* Shell quoting for the lines this plugin types into a remote terminal.
|
|
40333
|
+
*
|
|
40334
|
+
* One rule, one place: the client builds POSIX shell text in three spots (the
|
|
40335
|
+
* `cd` typed into a fresh terminal, the bounded suffix search for an unresolved
|
|
40336
|
+
* click target, and the terminal command a menu entry sends), and a second copy
|
|
40337
|
+
* of the quoting rule is how an injection bug reappears after it was fixed once.
|
|
40338
|
+
*
|
|
40339
|
+
* Single quotes are the only form that is safe for arbitrary paths — double
|
|
40340
|
+
* quotes still expand `$`, backticks and `\` — and the closing quote is the one
|
|
40341
|
+
* character they cannot carry, so it is spliced in as `'\''`.
|
|
40342
|
+
*/
|
|
40343
|
+
/**
|
|
40344
|
+
* Quote one value for a POSIX shell, as a single word.
|
|
40345
|
+
*
|
|
40346
|
+
* @param value - the raw path or word.
|
|
40347
|
+
* @returns `'value'` with every embedded `'` spliced as `'\''`.
|
|
40348
|
+
*/
|
|
40349
|
+
function shellQuote(value) {
|
|
40350
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
40351
|
+
}
|
|
40352
|
+
/**
|
|
40353
|
+
* The command typed into a fresh remote terminal to land in one directory.
|
|
40354
|
+
*
|
|
40355
|
+
* A trailing newline is NOT included: the caller writes the line and the
|
|
40356
|
+
* terminal's Enter, which keeps the same string reusable for display.
|
|
40357
|
+
*
|
|
40358
|
+
* @param dir - absolute remote directory.
|
|
40359
|
+
* @returns `cd '<dir>'`.
|
|
40360
|
+
*/
|
|
40361
|
+
function cdCommand(dir) {
|
|
40362
|
+
return `cd ${shellQuote(dir)}`;
|
|
40363
|
+
}
|
|
40364
|
+
//#endregion
|
|
40326
40365
|
//#region src/client/remote-file-open-target.ts
|
|
40366
|
+
/**
|
|
40367
|
+
* Where a clicked file reference should open when the owning session lives in
|
|
40368
|
+
* a remote workspace (pure — no React, no fetch, so the gate can drive it).
|
|
40369
|
+
*
|
|
40370
|
+
* DSH renders every file reference as a `dsh-resource://file/…` address and
|
|
40371
|
+
* opens it through its own right-sidebar preview. That preview resolves the
|
|
40372
|
+
* address path against the SESSION WORKSPACE ROOT. A model that quotes a path
|
|
40373
|
+
* relative to its CURRENT directory (e.g. `_xfer/reports/x.md` while working
|
|
40374
|
+
* in `<workspace>/<inner-repo>`) therefore misses by one segment, and a
|
|
40375
|
+
* remote session's workspace root only exists as an EMPTY local alias dir —
|
|
40376
|
+
* the click dies with the host-side ENOENT the user sees.
|
|
40377
|
+
*
|
|
40378
|
+
* This module answers "which binding, which remote paths to try" for the
|
|
40379
|
+
* client interceptor:
|
|
40380
|
+
* - relative paths are tried against the workspace root first and the
|
|
40381
|
+
* session's current working directory second (the model's usual writing
|
|
40382
|
+
* base), which is what makes the one-segment miss heal;
|
|
40383
|
+
* - absolute spellings (local alias, POSIX remote, drive-qualified alias)
|
|
40384
|
+
* are folded back onto the binding;
|
|
40385
|
+
* - anything that does not name a known binding answers `undefined`, so the
|
|
40386
|
+
* native preview keeps handling local sessions and unknown addresses.
|
|
40387
|
+
*/
|
|
40327
40388
|
const FILE_ADDRESS_PREFIX = "dsh-resource://file/";
|
|
40328
40389
|
function stripQueryAndFragment(address) {
|
|
40329
40390
|
const end = address.search(/[?#]/);
|
|
@@ -40648,10 +40709,6 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
40648
40709
|
}
|
|
40649
40710
|
return bindingForHostPath(table, path);
|
|
40650
40711
|
}
|
|
40651
|
-
/** POSIX single-quote a word for a remote `bash -c` style command line. */
|
|
40652
|
-
function shellQuote(value) {
|
|
40653
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
40654
|
-
}
|
|
40655
40712
|
/**
|
|
40656
40713
|
* Bounded remote search for a clicked path that no candidate resolved.
|
|
40657
40714
|
*
|
|
@@ -40925,6 +40982,8 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
40925
40982
|
editorSurface: "sidebar",
|
|
40926
40983
|
view: readStoredView(),
|
|
40927
40984
|
searchRequest: void 0,
|
|
40985
|
+
revealRequest: void 0,
|
|
40986
|
+
terminalRequest: void 0,
|
|
40928
40987
|
untrackedDirs: /* @__PURE__ */ new Map()
|
|
40929
40988
|
};
|
|
40930
40989
|
/** Stable action identities — `build()` runs on every publish. */
|
|
@@ -40998,8 +41057,12 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
40998
41057
|
editorSurface: state.editorSurface,
|
|
40999
41058
|
view: state.view,
|
|
41000
41059
|
searchRequest: state.searchRequest,
|
|
41060
|
+
revealRequest: state.revealRequest,
|
|
41061
|
+
terminalRequest: state.terminalRequest,
|
|
41001
41062
|
setView,
|
|
41002
41063
|
requestSearch,
|
|
41064
|
+
revealInExplorer,
|
|
41065
|
+
openTerminalAt,
|
|
41003
41066
|
selectWorkspace,
|
|
41004
41067
|
setRepoSubdir,
|
|
41005
41068
|
discoverRepos,
|
|
@@ -41136,6 +41199,51 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
41136
41199
|
} });
|
|
41137
41200
|
setView("search");
|
|
41138
41201
|
}
|
|
41202
|
+
/** Monotonic ticket for "show this folder" requests (0 = none yet). */
|
|
41203
|
+
let revealSeq = 0;
|
|
41204
|
+
/**
|
|
41205
|
+
* Show one remote directory in the explorer.
|
|
41206
|
+
*
|
|
41207
|
+
* Two stages, because the tree is lazily loaded: this raises the request and
|
|
41208
|
+
* switches the panel, and the explorer itself expands the ancestors (loading
|
|
41209
|
+
* each level) and scrolls the row into view — it is the only place that knows
|
|
41210
|
+
* which directories are currently expanded.
|
|
41211
|
+
*
|
|
41212
|
+
* @param dir - absolute remote DIRECTORY (a file's parent for a file hit).
|
|
41213
|
+
*/
|
|
41214
|
+
function revealInExplorer(dir) {
|
|
41215
|
+
revealSeq += 1;
|
|
41216
|
+
patch({ revealRequest: {
|
|
41217
|
+
id: revealSeq,
|
|
41218
|
+
path: dir
|
|
41219
|
+
} });
|
|
41220
|
+
setView("explorer");
|
|
41221
|
+
}
|
|
41222
|
+
/** Monotonic ticket for "open a terminal here" requests. */
|
|
41223
|
+
let terminalSeq = 0;
|
|
41224
|
+
/**
|
|
41225
|
+
* Open a fresh terminal tab whose shell lands in `dir` — on the CURRENT
|
|
41226
|
+
* workspace's host.
|
|
41227
|
+
*
|
|
41228
|
+
* The terminal panel consumes the request (it owns the tabs) and the shell gets
|
|
41229
|
+
* a quoted `cd` once its PTY reports ready — the host's terminal route has no
|
|
41230
|
+
* "start in this directory" parameter, so a typed command is the honest way.
|
|
41231
|
+
*
|
|
41232
|
+
* The alias travels IN the request. The panel otherwise opens new tabs on the
|
|
41233
|
+
* host it last used, which put the shell on a different machine than the path
|
|
41234
|
+
* belongs to and made the `cd` fail with "No such file or directory".
|
|
41235
|
+
*
|
|
41236
|
+
* @param dir - absolute remote directory.
|
|
41237
|
+
*/
|
|
41238
|
+
function openTerminalAt(dir) {
|
|
41239
|
+
terminalSeq += 1;
|
|
41240
|
+
patch({ terminalRequest: {
|
|
41241
|
+
id: terminalSeq,
|
|
41242
|
+
alias: currentWorkspace()?.alias ?? "",
|
|
41243
|
+
dir
|
|
41244
|
+
} });
|
|
41245
|
+
setView("terminal");
|
|
41246
|
+
}
|
|
41139
41247
|
/**
|
|
41140
41248
|
* Select the binding every view works against — the USER's choice.
|
|
41141
41249
|
*
|
|
@@ -43206,8 +43314,8 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
43206
43314
|
}),
|
|
43207
43315
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
43208
43316
|
className: "rvs-build",
|
|
43209
|
-
title: `dsh-remote-vs build
|
|
43210
|
-
children: "
|
|
43317
|
+
title: `dsh-remote-vs build 1026-0953`,
|
|
43318
|
+
children: "1026-0953"
|
|
43211
43319
|
}),
|
|
43212
43320
|
tokenCount !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
43213
43321
|
className: "rvs-build",
|
|
@@ -43822,6 +43930,51 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
43822
43930
|
});
|
|
43823
43931
|
}
|
|
43824
43932
|
}, [alias]);
|
|
43933
|
+
/**
|
|
43934
|
+
* "Show this folder in the tree" — raised by a search result's menu.
|
|
43935
|
+
*
|
|
43936
|
+
* Two stages, because the tree is lazy: expand every level from the root down
|
|
43937
|
+
* to the target (the listing of each level is fetched here) and then scroll
|
|
43938
|
+
* its row into view. The scroll is retried rather than delayed once: over SFTP
|
|
43939
|
+
* a deep level can take a moment, and the request carries a monotonic id so
|
|
43940
|
+
* asking for the SAME folder again still re-scrolls.
|
|
43941
|
+
*/
|
|
43942
|
+
(0, react.useEffect)(() => {
|
|
43943
|
+
const request = store.revealRequest;
|
|
43944
|
+
if (request === void 0) return;
|
|
43945
|
+
const base = root.replace(/\/+$/, "");
|
|
43946
|
+
const target = request.path.replace(/\/+$/, "");
|
|
43947
|
+
if (target !== base && !target.startsWith(`${base}/`)) return;
|
|
43948
|
+
const chain = [];
|
|
43949
|
+
let walk = base;
|
|
43950
|
+
for (const part of target.slice(base.length).split("/")) {
|
|
43951
|
+
if (part === "") continue;
|
|
43952
|
+
walk = `${walk}/${part}`;
|
|
43953
|
+
chain.push(walk);
|
|
43954
|
+
}
|
|
43955
|
+
for (const dir of chain) loadDir(dir);
|
|
43956
|
+
if (chain.length > 0) setExpanded((previous) => {
|
|
43957
|
+
const next = new Set(previous);
|
|
43958
|
+
for (const dir of chain) next.add(dir);
|
|
43959
|
+
return next;
|
|
43960
|
+
});
|
|
43961
|
+
let attempts = 0;
|
|
43962
|
+
const timer = window.setInterval(() => {
|
|
43963
|
+
attempts += 1;
|
|
43964
|
+
const row = Array.from(document.querySelectorAll("[data-rvs-path]")).find((candidate) => candidate.dataset.rvsPath === target);
|
|
43965
|
+
if (row !== void 0) {
|
|
43966
|
+
window.clearInterval(timer);
|
|
43967
|
+
row.scrollIntoView({ block: "center" });
|
|
43968
|
+
return;
|
|
43969
|
+
}
|
|
43970
|
+
if (attempts > 20) window.clearInterval(timer);
|
|
43971
|
+
}, 150);
|
|
43972
|
+
return () => window.clearInterval(timer);
|
|
43973
|
+
}, [
|
|
43974
|
+
store.revealRequest,
|
|
43975
|
+
root,
|
|
43976
|
+
loadDir
|
|
43977
|
+
]);
|
|
43825
43978
|
const revision = store.revision;
|
|
43826
43979
|
/**
|
|
43827
43980
|
* The binding this tree was listed from and the revision it was listed at.
|
|
@@ -45256,7 +45409,7 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
45256
45409
|
}
|
|
45257
45410
|
function SearchView() {
|
|
45258
45411
|
const store = useExplorer();
|
|
45259
|
-
const { t, alias, root } = store;
|
|
45412
|
+
const { t, alias, root, repoRoot } = store;
|
|
45260
45413
|
const [mode, setModeState] = (0, react.useState)(readMode);
|
|
45261
45414
|
const [query, setQuery] = (0, react.useState)("");
|
|
45262
45415
|
/** Absolute remote directory the search runs in ('' = workspace root). */
|
|
@@ -45271,6 +45424,7 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
45271
45424
|
const [ran, setRan] = (0, react.useState)(false);
|
|
45272
45425
|
const [error, setError] = (0, react.useState)(void 0);
|
|
45273
45426
|
const seq = (0, react.useRef)(0);
|
|
45427
|
+
const menu = useContextMenu();
|
|
45274
45428
|
const effectiveScope = scope === "" ? root : scope;
|
|
45275
45429
|
const scopeLabel = (0, react.useMemo)(() => {
|
|
45276
45430
|
if (effectiveScope === "" || root === "") return "";
|
|
@@ -45383,6 +45537,57 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
45383
45537
|
const openHit = (path, line) => {
|
|
45384
45538
|
store.openFile(path, line === void 0 ? void 0 : { line });
|
|
45385
45539
|
};
|
|
45540
|
+
/** Absolute path of one NAME hit, plus the DIRECTORY a menu action should use. */
|
|
45541
|
+
const nameHitPaths = (hit) => {
|
|
45542
|
+
const abs = `${effectiveScope.replace(/\/+$/, "")}/${hit.path}`;
|
|
45543
|
+
return {
|
|
45544
|
+
abs,
|
|
45545
|
+
dir: hit.isDir ? abs : abs.slice(0, abs.lastIndexOf("/")) || "/"
|
|
45546
|
+
};
|
|
45547
|
+
};
|
|
45548
|
+
/**
|
|
45549
|
+
* Right-click on a name result: the four things one does with a found path.
|
|
45550
|
+
*
|
|
45551
|
+
* "Relative" is relative to the REPOSITORY when the workspace sits inside one
|
|
45552
|
+
* (the same relative form the tree's own menu copies), else absolute — which
|
|
45553
|
+
* is what makes the copied value paste straight into a shell that already ran
|
|
45554
|
+
* `cd` into the repo.
|
|
45555
|
+
*/
|
|
45556
|
+
const openHitMenu = (event, hit) => {
|
|
45557
|
+
event.preventDefault();
|
|
45558
|
+
event.stopPropagation();
|
|
45559
|
+
const { abs, dir } = nameHitPaths(hit);
|
|
45560
|
+
const rel = repoRelative(repoRoot, abs) || abs;
|
|
45561
|
+
menu.open(event.clientX, event.clientY, [
|
|
45562
|
+
{
|
|
45563
|
+
label: t("files.copyAbs"),
|
|
45564
|
+
onSelect: () => {
|
|
45565
|
+
copyText$1(abs);
|
|
45566
|
+
toast(t("files.copiedClipboard"));
|
|
45567
|
+
}
|
|
45568
|
+
},
|
|
45569
|
+
{
|
|
45570
|
+
label: t("files.copyRel"),
|
|
45571
|
+
onSelect: () => {
|
|
45572
|
+
copyText$1(rel);
|
|
45573
|
+
toast(t("files.copiedClipboard"));
|
|
45574
|
+
}
|
|
45575
|
+
},
|
|
45576
|
+
{
|
|
45577
|
+
label: t("search.revealInExplorer"),
|
|
45578
|
+
onSelect: () => {
|
|
45579
|
+
store.revealInExplorer(dir);
|
|
45580
|
+
},
|
|
45581
|
+
separated: true
|
|
45582
|
+
},
|
|
45583
|
+
{
|
|
45584
|
+
label: t("search.openInTerminal"),
|
|
45585
|
+
onSelect: () => {
|
|
45586
|
+
store.openTerminalAt(dir);
|
|
45587
|
+
}
|
|
45588
|
+
}
|
|
45589
|
+
]);
|
|
45590
|
+
};
|
|
45386
45591
|
const groups = (0, react.useMemo)(() => groupHits(contentHits), [contentHits]);
|
|
45387
45592
|
/**
|
|
45388
45593
|
* The content query, compiled ONCE per (text, flags) instead of once per
|
|
@@ -45542,6 +45747,9 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
45542
45747
|
className: "rvs-hit",
|
|
45543
45748
|
title: hit.path,
|
|
45544
45749
|
onClick: () => openHit(`${(effectiveScope === "" ? root : effectiveScope).replace(/\/+$/, "")}/${hit.path}`),
|
|
45750
|
+
onContextMenu: (event) => {
|
|
45751
|
+
openHitMenu(event, hit);
|
|
45752
|
+
},
|
|
45545
45753
|
children: [
|
|
45546
45754
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
45547
45755
|
className: "rvs-node-icon",
|
|
@@ -45614,6 +45822,12 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
45614
45822
|
})]
|
|
45615
45823
|
}, hit.line))] }, group.path))] })
|
|
45616
45824
|
]
|
|
45825
|
+
}),
|
|
45826
|
+
menu.menu !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ContextMenu, {
|
|
45827
|
+
x: menu.menu.x,
|
|
45828
|
+
y: menu.menu.y,
|
|
45829
|
+
entries: menu.menu.entries,
|
|
45830
|
+
onClose: menu.close
|
|
45617
45831
|
})
|
|
45618
45832
|
]
|
|
45619
45833
|
});
|
|
@@ -60401,6 +60615,8 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
60401
60615
|
fit.fit();
|
|
60402
60616
|
const ws = new WebSocket(propsRef.current.wsUrl);
|
|
60403
60617
|
let opened = false;
|
|
60618
|
+
/** The seeded line is typed at most once per mount (a reconnect re-mounts). */
|
|
60619
|
+
let seeded = false;
|
|
60404
60620
|
let exitReported = false;
|
|
60405
60621
|
const reportExit = (code, message) => {
|
|
60406
60622
|
if (exitReported) return;
|
|
@@ -60469,8 +60685,17 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
60469
60685
|
} catch {
|
|
60470
60686
|
return;
|
|
60471
60687
|
}
|
|
60472
|
-
if (frame.t === "ready")
|
|
60473
|
-
|
|
60688
|
+
if (frame.t === "ready") {
|
|
60689
|
+
propsRef.current.onReady(frame.id);
|
|
60690
|
+
const seed = propsRef.current.initialInput;
|
|
60691
|
+
if (!seeded && seed !== void 0 && seed !== "") {
|
|
60692
|
+
seeded = true;
|
|
60693
|
+
send(ws, {
|
|
60694
|
+
t: "in",
|
|
60695
|
+
d: `${seed}\n`
|
|
60696
|
+
});
|
|
60697
|
+
}
|
|
60698
|
+
} else if (frame.t === "out") term.write(frame.d);
|
|
60474
60699
|
else if (frame.t === "exit") {
|
|
60475
60700
|
reportExit(frame.code);
|
|
60476
60701
|
try {
|
|
@@ -60617,6 +60842,14 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
60617
60842
|
function newId() {
|
|
60618
60843
|
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
60619
60844
|
}
|
|
60845
|
+
/**
|
|
60846
|
+
* The last "open a terminal here" request this page already acted on.
|
|
60847
|
+
*
|
|
60848
|
+
* Module scope, not a component ref: the panel is normally kept mounted, but a
|
|
60849
|
+
* remount would reset a ref and re-open a tab for the SAME old request. A new
|
|
60850
|
+
* request always carries a larger id, so one counter serves both goals.
|
|
60851
|
+
*/
|
|
60852
|
+
let openedTerminalRequest = 0;
|
|
60620
60853
|
function TerminalView(props) {
|
|
60621
60854
|
const t = props.t;
|
|
60622
60855
|
const [alias, setAlias] = (0, react.useState)(readAlias);
|
|
@@ -60647,17 +60880,48 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
60647
60880
|
...next
|
|
60648
60881
|
} : tab));
|
|
60649
60882
|
}, []);
|
|
60650
|
-
const openTab = (0, react.useCallback)((host) => {
|
|
60883
|
+
const openTab = (0, react.useCallback)((host, initialCommand) => {
|
|
60651
60884
|
if (host === "") return;
|
|
60652
60885
|
const tab = {
|
|
60653
60886
|
id: newId(),
|
|
60654
60887
|
alias: host,
|
|
60655
60888
|
nonce: 0,
|
|
60656
|
-
connected: true
|
|
60889
|
+
connected: true,
|
|
60890
|
+
...initialCommand === void 0 ? {} : { initialCommand }
|
|
60657
60891
|
};
|
|
60658
60892
|
setTabs((prev) => [...prev, tab]);
|
|
60659
60893
|
setActiveId(tab.id);
|
|
60660
60894
|
}, []);
|
|
60895
|
+
/**
|
|
60896
|
+
* "Open a terminal here" (a search result's menu): open one tab whose shell
|
|
60897
|
+
* lands in that folder, on the host the folder BELONGS to.
|
|
60898
|
+
*
|
|
60899
|
+
* The alias must come from the request, not from `effectiveAlias`: the panel
|
|
60900
|
+
* remembers whatever host the user last ran a shell on, so a menu action on a
|
|
60901
|
+
* workspace bound elsewhere used to open the shell on the wrong machine and
|
|
60902
|
+
* the `cd` failed with "No such file or directory" for a path that is fine on
|
|
60903
|
+
* its own host.
|
|
60904
|
+
*
|
|
60905
|
+
* The folder is reached by TYPING `cd` once the PTY reports ready (see
|
|
60906
|
+
* `XTerm`'s `initialInput`) — the host's terminal route has no "start in this
|
|
60907
|
+
* directory" parameter, and inventing one would mean a host-side change for a
|
|
60908
|
+
* display feature. The request id makes a repeat for the same folder still
|
|
60909
|
+
* open a second tab.
|
|
60910
|
+
*/
|
|
60911
|
+
(0, react.useEffect)(() => {
|
|
60912
|
+
const request = props.request;
|
|
60913
|
+
if (request === void 0 || request.id === openedTerminalRequest) return;
|
|
60914
|
+
openedTerminalRequest = request.id;
|
|
60915
|
+
const target = request.alias !== "" && props.hosts.some((host) => host.alias === request.alias) ? request.alias : effectiveAlias;
|
|
60916
|
+
if (target !== alias) setAlias(target);
|
|
60917
|
+
openTab(target, cdCommand(request.dir));
|
|
60918
|
+
}, [
|
|
60919
|
+
props.request,
|
|
60920
|
+
props.hosts,
|
|
60921
|
+
effectiveAlias,
|
|
60922
|
+
alias,
|
|
60923
|
+
openTab
|
|
60924
|
+
]);
|
|
60661
60925
|
const closeTab = (0, react.useCallback)(async (tab) => {
|
|
60662
60926
|
if (!await confirmDialog({
|
|
60663
60927
|
title: t("term.closeTitle"),
|
|
@@ -60796,6 +61060,7 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
60796
61060
|
rows: 24
|
|
60797
61061
|
}),
|
|
60798
61062
|
visible: props.visible && tab.id === active?.id,
|
|
61063
|
+
...tab.initialCommand === void 0 ? {} : { initialInput: tab.initialCommand },
|
|
60799
61064
|
onReady: (id) => patchTab(tab.id, {
|
|
60800
61065
|
session: id,
|
|
60801
61066
|
connected: true,
|
|
@@ -61191,7 +61456,8 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
|
|
|
61191
61456
|
case "terminal": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TerminalView, {
|
|
61192
61457
|
t,
|
|
61193
61458
|
hosts: store.hosts,
|
|
61194
|
-
visible: panelLive("terminal")
|
|
61459
|
+
visible: panelLive("terminal"),
|
|
61460
|
+
request: store.terminalRequest
|
|
61195
61461
|
});
|
|
61196
61462
|
case "workspaces": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
61197
61463
|
className: "rvs-viewpad",
|
|
@@ -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;
|
|
@@ -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
|
*
|
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') {
|
package/src/client/i18n.ts
CHANGED
|
@@ -197,6 +197,8 @@ const zh: Record<string, string> = {
|
|
|
197
197
|
'editor.tokenCount': '语法着色 token 数(0 = 没有语法包或加载的是旧 bundle)',
|
|
198
198
|
'search.empty': '没有匹配的文件。',
|
|
199
199
|
'search.truncated': '结果过多,仅显示前一批。',
|
|
200
|
+
'search.revealInExplorer': '打开文件所在位置',
|
|
201
|
+
'search.openInTerminal': '在终端中打开',
|
|
200
202
|
'scm.title': '源代码管理',
|
|
201
203
|
'scm.refresh': '刷新',
|
|
202
204
|
'scm.changes': '更改',
|
|
@@ -533,6 +535,8 @@ const en: Record<string, string> = {
|
|
|
533
535
|
'editor.tokenCount': 'Colored syntax tokens (0 = no grammar, or a stale client bundle)',
|
|
534
536
|
'search.empty': 'No matching files.',
|
|
535
537
|
'search.truncated': 'Too many hits; showing the first batch.',
|
|
538
|
+
'search.revealInExplorer': 'Open containing folder',
|
|
539
|
+
'search.openInTerminal': 'Open in terminal',
|
|
536
540
|
'scm.title': 'Source control',
|
|
537
541
|
'scm.refresh': 'Refresh',
|
|
538
542
|
'scm.changes': 'Changes',
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* - anything that does not name a known binding answers `undefined`, so the
|
|
21
21
|
* native preview keeps handling local sessions and unknown addresses.
|
|
22
22
|
*/
|
|
23
|
+
import { shellQuote } from './shell.ts'
|
|
23
24
|
|
|
24
25
|
/** One remote binding as the client store exposes it. */
|
|
25
26
|
export interface RemoteOpenBinding {
|
|
@@ -429,11 +430,6 @@ export function bindingForSessionCwd(
|
|
|
429
430
|
return bindingForHostPath(table, path)
|
|
430
431
|
}
|
|
431
432
|
|
|
432
|
-
/** POSIX single-quote a word for a remote `bash -c` style command line. */
|
|
433
|
-
function shellQuote(value: string): string {
|
|
434
|
-
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
435
|
-
}
|
|
436
|
-
|
|
437
433
|
/**
|
|
438
434
|
* Bounded remote search for a clicked path that no candidate resolved.
|
|
439
435
|
*
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell quoting for the lines this plugin types into a remote terminal.
|
|
3
|
+
*
|
|
4
|
+
* One rule, one place: the client builds POSIX shell text in three spots (the
|
|
5
|
+
* `cd` typed into a fresh terminal, the bounded suffix search for an unresolved
|
|
6
|
+
* click target, and the terminal command a menu entry sends), and a second copy
|
|
7
|
+
* of the quoting rule is how an injection bug reappears after it was fixed once.
|
|
8
|
+
*
|
|
9
|
+
* Single quotes are the only form that is safe for arbitrary paths — double
|
|
10
|
+
* quotes still expand `$`, backticks and `\` — and the closing quote is the one
|
|
11
|
+
* character they cannot carry, so it is spliced in as `'\''`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Quote one value for a POSIX shell, as a single word.
|
|
16
|
+
*
|
|
17
|
+
* @param value - the raw path or word.
|
|
18
|
+
* @returns `'value'` with every embedded `'` spliced as `'\''`.
|
|
19
|
+
*/
|
|
20
|
+
export function shellQuote(value: string): string {
|
|
21
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The command typed into a fresh remote terminal to land in one directory.
|
|
26
|
+
*
|
|
27
|
+
* A trailing newline is NOT included: the caller writes the line and the
|
|
28
|
+
* terminal's Enter, which keeps the same string reusable for display.
|
|
29
|
+
*
|
|
30
|
+
* @param dir - absolute remote directory.
|
|
31
|
+
* @returns `cd '<dir>'`.
|
|
32
|
+
*/
|
|
33
|
+
export function cdCommand(dir: string): string {
|
|
34
|
+
return `cd ${shellQuote(dir)}`
|
|
35
|
+
}
|
package/src/client/store.tsx
CHANGED
|
@@ -131,6 +131,10 @@ interface State {
|
|
|
131
131
|
view: RailView
|
|
132
132
|
/** A pending search request raised by the explorer's context menu. */
|
|
133
133
|
searchRequest: SearchRequest | undefined
|
|
134
|
+
/** A pending "show this folder in the tree" request (search-result menu). */
|
|
135
|
+
revealRequest: RevealRequest | undefined
|
|
136
|
+
/** A pending "open a terminal in this folder" request (search-result menu). */
|
|
137
|
+
terminalRequest: TerminalRequest | undefined
|
|
134
138
|
/**
|
|
135
139
|
* Expanded untracked DIRECTORY rows (`-u normal` collapses a directory into
|
|
136
140
|
* one row, so the panel asks for its contents on demand — see
|
|
@@ -154,6 +158,32 @@ export interface SearchRequest {
|
|
|
154
158
|
scope: string
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
/** One "show this folder in the explorer tree" request. */
|
|
162
|
+
export interface RevealRequest {
|
|
163
|
+
/** Monotonic id so asking for the same folder twice still re-triggers. */
|
|
164
|
+
id: number
|
|
165
|
+
/** Absolute remote DIRECTORY to expand and scroll to. */
|
|
166
|
+
path: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One "open a terminal here" request. */
|
|
170
|
+
export interface TerminalRequest {
|
|
171
|
+
/** Monotonic id so the same folder can be opened twice. */
|
|
172
|
+
id: number
|
|
173
|
+
/**
|
|
174
|
+
* Host alias the directory lives on.
|
|
175
|
+
*
|
|
176
|
+
* Carried explicitly, and NOT taken from the terminal panel's own last-used
|
|
177
|
+
* host: the panel remembers whatever host the user ran a shell on last, so a
|
|
178
|
+
* menu action on a workspace bound elsewhere opened the shell on the WRONG
|
|
179
|
+
* machine — the `cd` then failed with "No such file or directory" for a path
|
|
180
|
+
* that exists perfectly well on its own host.
|
|
181
|
+
*/
|
|
182
|
+
alias: string
|
|
183
|
+
/** Absolute remote directory the fresh shell should `cd` into. */
|
|
184
|
+
dir: string
|
|
185
|
+
}
|
|
186
|
+
|
|
157
187
|
const WS_KEY = 'dsh-remote-vs.files.ws'
|
|
158
188
|
const VIEW_KEY = 'dsh-remote-vs.view'
|
|
159
189
|
/** Per-workspace repository sub-directory, keyed by workspace id. */
|
|
@@ -295,6 +325,8 @@ const state: State = {
|
|
|
295
325
|
editorSurface: 'sidebar',
|
|
296
326
|
view: readStoredView(),
|
|
297
327
|
searchRequest: undefined,
|
|
328
|
+
revealRequest: undefined,
|
|
329
|
+
terminalRequest: undefined,
|
|
298
330
|
untrackedDirs: new Map(),
|
|
299
331
|
}
|
|
300
332
|
|
|
@@ -337,10 +369,25 @@ export interface ExplorerValue {
|
|
|
337
369
|
editorSurface: 'center' | 'sidebar'
|
|
338
370
|
view: RailView
|
|
339
371
|
searchRequest: SearchRequest | undefined
|
|
372
|
+
/** Pending "show this folder in the tree" request (consumed by the explorer). */
|
|
373
|
+
revealRequest: RevealRequest | undefined
|
|
374
|
+
/** Pending "open a terminal here" request (consumed by the terminal panel). */
|
|
375
|
+
terminalRequest: TerminalRequest | undefined
|
|
340
376
|
setView: (view: RailView) => void
|
|
341
377
|
/** Switch to the search panel (optionally scoped to a folder) — the tree's
|
|
342
378
|
"find in folder" entry point. */
|
|
343
379
|
requestSearch: (request: { mode: 'name' | 'content'; query?: string; scope: string }) => void
|
|
380
|
+
/**
|
|
381
|
+
* Show one remote directory in the explorer: switch to it, expand the folder
|
|
382
|
+
* and its ancestors, then scroll its row into view. Raised by the search
|
|
383
|
+
* results' "open containing folder" entry.
|
|
384
|
+
*/
|
|
385
|
+
revealInExplorer: (dir: string) => void
|
|
386
|
+
/**
|
|
387
|
+
* Open a fresh terminal tab whose shell `cd`s into `dir`. Raised by the
|
|
388
|
+
* search results' "open in terminal" entry.
|
|
389
|
+
*/
|
|
390
|
+
openTerminalAt: (dir: string) => void
|
|
344
391
|
/**
|
|
345
392
|
* The USER's pick — the workspace dropdowns and the wizard's "open files".
|
|
346
393
|
*
|
|
@@ -463,8 +510,12 @@ function build(): ExplorerValue {
|
|
|
463
510
|
editorSurface: state.editorSurface,
|
|
464
511
|
view: state.view,
|
|
465
512
|
searchRequest: state.searchRequest,
|
|
513
|
+
revealRequest: state.revealRequest,
|
|
514
|
+
terminalRequest: state.terminalRequest,
|
|
466
515
|
setView,
|
|
467
516
|
requestSearch,
|
|
517
|
+
revealInExplorer,
|
|
518
|
+
openTerminalAt,
|
|
468
519
|
selectWorkspace,
|
|
469
520
|
setRepoSubdir,
|
|
470
521
|
discoverRepos,
|
|
@@ -622,6 +673,48 @@ export function requestSearch(request: { mode: 'name' | 'content'; query?: strin
|
|
|
622
673
|
setView('search')
|
|
623
674
|
}
|
|
624
675
|
|
|
676
|
+
/** Monotonic ticket for "show this folder" requests (0 = none yet). */
|
|
677
|
+
let revealSeq = 0
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Show one remote directory in the explorer.
|
|
681
|
+
*
|
|
682
|
+
* Two stages, because the tree is lazily loaded: this raises the request and
|
|
683
|
+
* switches the panel, and the explorer itself expands the ancestors (loading
|
|
684
|
+
* each level) and scrolls the row into view — it is the only place that knows
|
|
685
|
+
* which directories are currently expanded.
|
|
686
|
+
*
|
|
687
|
+
* @param dir - absolute remote DIRECTORY (a file's parent for a file hit).
|
|
688
|
+
*/
|
|
689
|
+
export function revealInExplorer(dir: string): void {
|
|
690
|
+
revealSeq += 1
|
|
691
|
+
patch({ revealRequest: { id: revealSeq, path: dir } })
|
|
692
|
+
setView('explorer')
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Monotonic ticket for "open a terminal here" requests. */
|
|
696
|
+
let terminalSeq = 0
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Open a fresh terminal tab whose shell lands in `dir` — on the CURRENT
|
|
700
|
+
* workspace's host.
|
|
701
|
+
*
|
|
702
|
+
* The terminal panel consumes the request (it owns the tabs) and the shell gets
|
|
703
|
+
* a quoted `cd` once its PTY reports ready — the host's terminal route has no
|
|
704
|
+
* "start in this directory" parameter, so a typed command is the honest way.
|
|
705
|
+
*
|
|
706
|
+
* The alias travels IN the request. The panel otherwise opens new tabs on the
|
|
707
|
+
* host it last used, which put the shell on a different machine than the path
|
|
708
|
+
* belongs to and made the `cd` fail with "No such file or directory".
|
|
709
|
+
*
|
|
710
|
+
* @param dir - absolute remote directory.
|
|
711
|
+
*/
|
|
712
|
+
export function openTerminalAt(dir: string): void {
|
|
713
|
+
terminalSeq += 1
|
|
714
|
+
patch({ terminalRequest: { id: terminalSeq, alias: currentWorkspace()?.alias ?? '', dir } })
|
|
715
|
+
setView('terminal')
|
|
716
|
+
}
|
|
717
|
+
|
|
625
718
|
/**
|
|
626
719
|
* Select the binding every view works against — the USER's choice.
|
|
627
720
|
*
|