dsh-remote-vs 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as pushPipeline, c as require_lib, d as ensurePrivateDir, f as keysDir, i as probe, l as aliasRoot, o as seedHelperCache, p as storePath, r as helperAvailability, s as RemoteFiles, t as RemoteWorlds, u as dshHome } from "./worlds-C6W225o9.js";
1
+ import { a as pushPipeline, c as require_lib, d as ensurePrivateDir, f as keysDir, i as probe, l as aliasRoot, o as seedHelperCache, p as storePath, r as helperAvailability, s as RemoteFiles, t as RemoteWorlds, u as dshHome } from "./worlds-DnzpX3Hv.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
4
4
  import { createHash, randomUUID } from "node:crypto";
@@ -1,4 +1,4 @@
1
- import { l as aliasRoot, n as isWindowsAbsolute } from "./worlds-C6W225o9.js";
1
+ import { l as aliasRoot, n as isWindowsAbsolute } from "./worlds-DnzpX3Hv.js";
2
2
  import { FileSystem, FsError } from "@deepseek-ai/dsh-fs";
3
3
  //#region src/p2/router-fs.ts
4
4
  /**
@@ -9,8 +9,11 @@
9
9
  */
10
10
  import { type ReactNode } from 'react';
11
11
  import type { RemoteHostSummary } from '../protocol.ts';
12
+ import type { TerminalRequest } from './store.tsx';
12
13
  export declare function TerminalView(props: {
13
14
  t: (key: string) => string;
14
15
  hosts: RemoteHostSummary[];
15
16
  visible: boolean;
17
+ /** A pending "open a terminal here" request from the search results' menu. */
18
+ request?: TerminalRequest;
16
19
  }): ReactNode;
@@ -12,4 +12,10 @@ export declare function XTerm(props: {
12
12
  fontFamily?: string;
13
13
  /** When the parent sub-tab hides/shows this view without unmounting. */
14
14
  visible?: boolean;
15
+ /**
16
+ * Line typed into the fresh shell once its PTY reports ready (a menu entry's
17
+ * `cd`). Sent ONCE per mount: a reconnect is a new mount and thus a new shell,
18
+ * which correctly re-runs the line.
19
+ */
20
+ initialInput?: string;
15
21
  }): ReactNode;
@@ -1,25 +1,3 @@
1
- /**
2
- * Where a clicked file reference should open when the owning session lives in
3
- * a remote workspace (pure — no React, no fetch, so the gate can drive it).
4
- *
5
- * DSH renders every file reference as a `dsh-resource://file/…` address and
6
- * opens it through its own right-sidebar preview. That preview resolves the
7
- * address path against the SESSION WORKSPACE ROOT. A model that quotes a path
8
- * relative to its CURRENT directory (e.g. `_xfer/reports/x.md` while working
9
- * in `<workspace>/<inner-repo>`) therefore misses by one segment, and a
10
- * remote session's workspace root only exists as an EMPTY local alias dir —
11
- * the click dies with the host-side ENOENT the user sees.
12
- *
13
- * This module answers "which binding, which remote paths to try" for the
14
- * client interceptor:
15
- * - relative paths are tried against the workspace root first and the
16
- * session's current working directory second (the model's usual writing
17
- * base), which is what makes the one-segment miss heal;
18
- * - absolute spellings (local alias, POSIX remote, drive-qualified alias)
19
- * are folded back onto the binding;
20
- * - anything that does not name a known binding answers `undefined`, so the
21
- * native preview keeps handling local sessions and unknown addresses.
22
- */
23
1
  /** One remote binding as the client store exposes it. */
24
2
  export interface RemoteOpenBinding {
25
3
  id: string;
@@ -26,4 +26,34 @@ export declare function diffCounts(model: DiffModel): {
26
26
  added: number;
27
27
  removed: number;
28
28
  };
29
+ /**
30
+ * Where a prev/next jump lands, as an index into the ordered hunk list.
31
+ *
32
+ * Wraps at both ends (the last change's "next" is the first change, and the
33
+ * first one's "previous" is the last), because a diff toolbar button that does
34
+ * nothing at the end reads as broken. `cursor < 0` means "nothing jumped yet":
35
+ * the first `next` lands on the first hunk and the first `previous` on the last.
36
+ *
37
+ * Pure, so `scripts/check-diff-ui.mjs` can pin the wrapping without a browser.
38
+ *
39
+ * @param count - how many hunks the model has (0 → -1, i.e. nothing to jump to).
40
+ * @param cursor - the hunk the toolbar currently reports, or -1.
41
+ * @param direction - 1 for "next change", -1 for "previous change".
42
+ * @returns the hunk index to scroll to, or -1 when the model has no hunks.
43
+ */
44
+ export declare function stepHunk(count: number, cursor: number, direction: 1 | -1): number;
45
+ /**
46
+ * The hunk the viewport is sitting in, derived from the top visible row.
47
+ *
48
+ * "Sitting in" = the last change whose first row is at or ABOVE the top row, so
49
+ * scrolling into the middle of a change keeps reporting that change (and
50
+ * scrolling above the first one reports -1). This is what keeps the toolbar's
51
+ * `n / N` honest when the user scrolls by hand instead of using the buttons —
52
+ * the jump math alone would report the last button press, not the screen.
53
+ *
54
+ * @param startRows - first row index of each hunk, ASCENDING.
55
+ * @param topRow - the row index at the top of the viewport (may be fractional).
56
+ * @returns the hunk index, or -1 when the viewport is above the first hunk.
57
+ */
58
+ export declare function hunkAtRow(startRows: readonly number[], topRow: number): number;
29
59
  export {};
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shell quoting for the lines this plugin types into a remote terminal.
3
+ *
4
+ * One rule, one place: the client builds POSIX shell text in three spots (the
5
+ * `cd` typed into a fresh terminal, the bounded suffix search for an unresolved
6
+ * click target, and the terminal command a menu entry sends), and a second copy
7
+ * of the quoting rule is how an injection bug reappears after it was fixed once.
8
+ *
9
+ * Single quotes are the only form that is safe for arbitrary paths — double
10
+ * quotes still expand `$`, backticks and `\` — and the closing quote is the one
11
+ * character they cannot carry, so it is spliced in as `'\''`.
12
+ */
13
+ /**
14
+ * Quote one value for a POSIX shell, as a single word.
15
+ *
16
+ * @param value - the raw path or word.
17
+ * @returns `'value'` with every embedded `'` spliced as `'\''`.
18
+ */
19
+ export declare function shellQuote(value: string): string;
20
+ /**
21
+ * The command typed into a fresh remote terminal to land in one directory.
22
+ *
23
+ * A trailing newline is NOT included: the caller writes the line and the
24
+ * terminal's Enter, which keeps the same string reusable for display.
25
+ *
26
+ * @param dir - absolute remote directory.
27
+ * @returns `cd '<dir>'`.
28
+ */
29
+ export declare function cdCommand(dir: string): string;
@@ -82,6 +82,30 @@ export interface SearchRequest {
82
82
  /** Absolute remote directory the search is scoped to. */
83
83
  scope: string;
84
84
  }
85
+ /** One "show this folder in the explorer tree" request. */
86
+ export interface RevealRequest {
87
+ /** Monotonic id so asking for the same folder twice still re-triggers. */
88
+ id: number;
89
+ /** Absolute remote DIRECTORY to expand and scroll to. */
90
+ path: string;
91
+ }
92
+ /** One "open a terminal here" request. */
93
+ export interface TerminalRequest {
94
+ /** Monotonic id so the same folder can be opened twice. */
95
+ id: number;
96
+ /**
97
+ * Host alias the directory lives on.
98
+ *
99
+ * Carried explicitly, and NOT taken from the terminal panel's own last-used
100
+ * host: the panel remembers whatever host the user ran a shell on last, so a
101
+ * menu action on a workspace bound elsewhere opened the shell on the WRONG
102
+ * machine — the `cd` then failed with "No such file or directory" for a path
103
+ * that exists perfectly well on its own host.
104
+ */
105
+ alias: string;
106
+ /** Absolute remote directory the fresh shell should `cd` into. */
107
+ dir: string;
108
+ }
85
109
  /** Stored repository sub-directory for one binding ('' = the workspace root). */
86
110
  export declare function readRepoSubdir(workspaceId: string): string;
87
111
  /** Everything a view can read, plus the actions. */
@@ -123,6 +147,10 @@ export interface ExplorerValue {
123
147
  editorSurface: 'center' | 'sidebar';
124
148
  view: RailView;
125
149
  searchRequest: SearchRequest | undefined;
150
+ /** Pending "show this folder in the tree" request (consumed by the explorer). */
151
+ revealRequest: RevealRequest | undefined;
152
+ /** Pending "open a terminal here" request (consumed by the terminal panel). */
153
+ terminalRequest: TerminalRequest | undefined;
126
154
  setView: (view: RailView) => void;
127
155
  /** Switch to the search panel (optionally scoped to a folder) — the tree's
128
156
  "find in folder" entry point. */
@@ -131,6 +159,17 @@ export interface ExplorerValue {
131
159
  query?: string;
132
160
  scope: string;
133
161
  }) => void;
162
+ /**
163
+ * Show one remote directory in the explorer: switch to it, expand the folder
164
+ * and its ancestors, then scroll its row into view. Raised by the search
165
+ * results' "open containing folder" entry.
166
+ */
167
+ revealInExplorer: (dir: string) => void;
168
+ /**
169
+ * Open a fresh terminal tab whose shell `cd`s into `dir`. Raised by the
170
+ * search results' "open in terminal" entry.
171
+ */
172
+ openTerminalAt: (dir: string) => void;
134
173
  /**
135
174
  * The USER's pick — the workspace dropdowns and the wizard's "open files".
136
175
  *
@@ -197,6 +236,32 @@ export declare function requestSearch(request: {
197
236
  query?: string;
198
237
  scope: string;
199
238
  }): void;
239
+ /**
240
+ * Show one remote directory in the explorer.
241
+ *
242
+ * Two stages, because the tree is lazily loaded: this raises the request and
243
+ * switches the panel, and the explorer itself expands the ancestors (loading
244
+ * each level) and scrolls the row into view — it is the only place that knows
245
+ * which directories are currently expanded.
246
+ *
247
+ * @param dir - absolute remote DIRECTORY (a file's parent for a file hit).
248
+ */
249
+ export declare function revealInExplorer(dir: string): void;
250
+ /**
251
+ * Open a fresh terminal tab whose shell lands in `dir` — on the CURRENT
252
+ * workspace's host.
253
+ *
254
+ * The terminal panel consumes the request (it owns the tabs) and the shell gets
255
+ * a quoted `cd` once its PTY reports ready — the host's terminal route has no
256
+ * "start in this directory" parameter, so a typed command is the honest way.
257
+ *
258
+ * The alias travels IN the request. The panel otherwise opens new tabs on the
259
+ * host it last used, which put the shell on a different machine than the path
260
+ * belongs to and made the `cd` fail with "No such file or directory".
261
+ *
262
+ * @param dir - absolute remote directory.
263
+ */
264
+ export declare function openTerminalAt(dir: string): void;
200
265
  /**
201
266
  * Select the binding every view works against — the USER's choice.
202
267
  *
@@ -18,6 +18,8 @@ type IconProps = {
18
18
  };
19
19
  export declare function IconChevronRight(props: IconProps): ReactNode;
20
20
  export declare function IconChevronDown(props: IconProps): ReactNode;
21
+ /** Mirror of `IconChevronDown`, for the diff's "previous change" button. */
22
+ export declare function IconChevronUp(props: IconProps): ReactNode;
21
23
  export declare function IconFolder(props: IconProps & {
22
24
  open?: boolean;
23
25
  }): ReactNode;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The title one remote binding gets in DSH's own workspace sidebar.
3
+ *
4
+ * The sidebar (and the session grouping under it) is rendered by the shell from
5
+ * the workspace REGISTRY, so this string is the only label the user ever sees
6
+ * there. It used to be built from the remote PATH's last segment, which threw
7
+ * away the one name the user had chosen: a workspace created as
8
+ * "P710_userdebug" showed up as `… > P710`, and two bindings that differ only
9
+ * deeper down (`…/userdebug/P710` and `…/userdebug/P710_fac`) became
10
+ * indistinguishable.
11
+ *
12
+ * The alias stays in front: it is what tells two hosts apart, and the part after
13
+ * it is what takes the workspace name.
14
+ *
15
+ * DISPLAY ONLY. The registry resolves a workspace by its canonical PATH
16
+ * (`resolveByPath`) and its own documentation says the title is "used only when
17
+ * a new record is created" and that different paths may share one — so a title
18
+ * never decides routing, and none of the plugin's routing reads it.
19
+ *
20
+ * Pure and dependency-free, so `scripts/check-diff-ui.mjs` can pin it without a
21
+ * host (the host half imports cordis and cannot load outside DSH).
22
+ */
23
+ /**
24
+ * The title this binding SHOULD have: `alias > workspace name`.
25
+ *
26
+ * @param binding - alias, the name the user typed, and the remote path.
27
+ * @returns `alias > name`, falling back to the path's last segment when no name
28
+ * was given (bindings created before names existed, or an empty field).
29
+ */
30
+ export declare function workspaceTitle(binding: {
31
+ alias: string;
32
+ name?: string;
33
+ remotePath: string;
34
+ }): string;
35
+ /**
36
+ * The title the OLD code produced: `alias > <last path segment>`, name ignored.
37
+ *
38
+ * Kept so an existing registration can be retitled ONLY when its current title
39
+ * is still exactly this. That is what keeps the upgrade from ever touching a
40
+ * title the user renamed by hand in the shell's workspace settings.
41
+ *
42
+ * @param binding - alias and remote path; the name is deliberately unused.
43
+ */
44
+ export declare function legacyWorkspaceTitle(binding: {
45
+ alias: string;
46
+ remotePath: string;
47
+ }): string;
@@ -18233,6 +18233,61 @@ var RpcConnection = class {
18233
18233
  }
18234
18234
  };
18235
18235
  //#endregion
18236
+ //#region src/workspace-title.ts
18237
+ /**
18238
+ * The title one remote binding gets in DSH's own workspace sidebar.
18239
+ *
18240
+ * The sidebar (and the session grouping under it) is rendered by the shell from
18241
+ * the workspace REGISTRY, so this string is the only label the user ever sees
18242
+ * there. It used to be built from the remote PATH's last segment, which threw
18243
+ * away the one name the user had chosen: a workspace created as
18244
+ * "P710_userdebug" showed up as `… > P710`, and two bindings that differ only
18245
+ * deeper down (`…/userdebug/P710` and `…/userdebug/P710_fac`) became
18246
+ * indistinguishable.
18247
+ *
18248
+ * The alias stays in front: it is what tells two hosts apart, and the part after
18249
+ * it is what takes the workspace name.
18250
+ *
18251
+ * DISPLAY ONLY. The registry resolves a workspace by its canonical PATH
18252
+ * (`resolveByPath`) and its own documentation says the title is "used only when
18253
+ * a new record is created" and that different paths may share one — so a title
18254
+ * never decides routing, and none of the plugin's routing reads it.
18255
+ *
18256
+ * Pure and dependency-free, so `scripts/check-diff-ui.mjs` can pin it without a
18257
+ * host (the host half imports cordis and cannot load outside DSH).
18258
+ */
18259
+ /** Last path segment of an absolute remote path ('' when the path is `/`). */
18260
+ function pathBase(remotePath) {
18261
+ return remotePath.split("/").filter((part) => part !== "").pop() ?? remotePath;
18262
+ }
18263
+ /** The alias part of a title, with a readable fallback. */
18264
+ function aliasOf(alias) {
18265
+ return alias.trim() === "" ? "remote" : alias.trim();
18266
+ }
18267
+ /**
18268
+ * The title this binding SHOULD have: `alias > workspace name`.
18269
+ *
18270
+ * @param binding - alias, the name the user typed, and the remote path.
18271
+ * @returns `alias > name`, falling back to the path's last segment when no name
18272
+ * was given (bindings created before names existed, or an empty field).
18273
+ */
18274
+ function workspaceTitle(binding) {
18275
+ const named = (binding.name ?? "").trim();
18276
+ return `${aliasOf(binding.alias)} > ${named !== "" ? named : pathBase(binding.remotePath)}`;
18277
+ }
18278
+ /**
18279
+ * The title the OLD code produced: `alias > <last path segment>`, name ignored.
18280
+ *
18281
+ * Kept so an existing registration can be retitled ONLY when its current title
18282
+ * is still exactly this. That is what keeps the upgrade from ever touching a
18283
+ * title the user renamed by hand in the shell's workspace settings.
18284
+ *
18285
+ * @param binding - alias and remote path; the name is deliberately unused.
18286
+ */
18287
+ function legacyWorkspaceTitle(binding) {
18288
+ return `${aliasOf(binding.alias)} > ${pathBase(binding.remotePath)}`;
18289
+ }
18290
+ //#endregion
18236
18291
  //#region src/p2/worlds.ts
18237
18292
  /**
18238
18293
  * Remote worlds: alias registry, tombstones, cwd-keyed routing decisions,
@@ -18522,9 +18577,13 @@ var RemoteWorlds = class {
18522
18577
  }
18523
18578
  if (registry === void 0) continue;
18524
18579
  try {
18525
- if (await registry.resolveByPath(aliasPath) !== void 0) continue;
18526
- const base = binding.remotePath.split("/").filter(Boolean).pop() ?? binding.remotePath;
18527
- await registry.create(aliasPath, `${binding.alias} > ${base}`);
18580
+ const existing = await registry.resolveByPath(aliasPath);
18581
+ const desired = workspaceTitle(binding);
18582
+ if (existing !== void 0) {
18583
+ if (desired !== existing.title && existing.title === legacyWorkspaceTitle(binding) && typeof existing.setTitle === "function") await existing.setTitle(desired);
18584
+ continue;
18585
+ }
18586
+ await registry.create(aliasPath, desired);
18528
18587
  } catch (error) {
18529
18588
  try {
18530
18589
  this.ctx.logger.warn(`[remote-vs] workspace registration failed for ${aliasPath}: ${error instanceof Error ? error.message : String(error)}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote-vs",
3
3
  "description": "VSCode-style remote development for DSH: SSH host manager (key/password), helper deploy pipeline with version check, remote file explorer + editor + terminal in dsh-better-sidebar.",
4
- "version": "0.8.7",
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') {