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 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 1025-0858 · ${source} · colored tokens: ${count} (${propsRef.current.path})`);
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 1025-0858`,
43210
- children: "1025-0858"
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") propsRef.current.onReady(frame.id);
60473
- else if (frame.t === "out") term.write(frame.d);
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.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 = { id: newId(), alias: host, nonce: 0, connected: true }
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') {
@@ -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') {
@@ -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
+ }
@@ -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
  *