dsh-remote-vs 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -178,11 +178,13 @@ dsh_remote_vs/
178
178
  When a new helper is built, swap the 4-piece set (tarball + sha256 + helper.json + patch notes)
179
179
  into vendor and commit (details live in the local-only `docs/MAINTENANCE.md` §4, which is gitignored and not distributed).
180
180
 
181
- `.spike/` (gitignored) holds live-host verification scripts plus the local self-checks:
182
- `p2-router.ts` main regression (real remote + real Cordis + real router classes) and probes like
183
- `grep-probe.mjs` (build with `tsconfig.scratch.json`), plus `model-check.mjs` / `reveal-check.mjs` /
184
- `fallback-check.mjs` / `client-load-check.mjs` / `tool-schema-check.mjs` which need no host at all
185
- (see "Development").
181
+ `.spike/` and `scripts/` (both gitignored, **never distributed with the repository**) hold the
182
+ development-time verification and self-check scripts: `.spike/` has the live-host ones — `p2-router.ts`
183
+ main regression (real remote + real Cordis + real router classes) and probes like `grep-probe.mjs`
184
+ (build with `tsconfig.scratch.json`), plus `model-check.mjs` / `reveal-check.mjs` /
185
+ `fallback-check.mjs` / `client-load-check.mjs` / `tool-schema-check.mjs` which need no host at all;
186
+ `scripts/` has the plain-node regression gates (`node scripts/check-*.mjs`, with `verify-lib.mjs`
187
+ checking the build artifact). Both are covered under "Development".
186
188
 
187
189
  ## Remote workbench (VS Code style)
188
190
 
@@ -322,8 +324,9 @@ dsh plugin --profile web add link:<absolute path of this directory>
322
324
  **How a change goes live: `pnpm run build`, then restart `dsh web` — for the host half and the client half alike.**
323
325
  **`lib/` is committed.** The package has no `prepare` script (pnpm 12 refuses to run one for a git
324
326
  dependency), so a `dsh plugin add github:…` user receives the repository as it is: **after editing
325
- source you must `npm run build` and commit `lib/` with it.** The first step of `pnpm check`
326
- (`scripts/verify-lib.mjs`) fails when `lib/` is untracked or throws while loading.
327
+ source you must `npm run build` and commit `lib/` with it** (`node scripts/verify-lib.mjs` checks that the
328
+ entries are present, committed and loadable; the self-check scripts live on the maintainer's machine only,
329
+ see below).
327
330
  The Host reads plugin bundles into memory at startup (`initialBundleSnapshot`: it stats mtime/ctime/size into a
328
331
  revision, then reads the bytes; the response carries `Cache-Control: public, max-age=31536000, immutable`), so a
329
332
  **client-only** change (`src/client/**`) behaves exactly the same way: a browser refresh — `Ctrl+Shift+R`
package/README.md CHANGED
@@ -169,10 +169,12 @@ dsh_remote_vs/
169
169
  新版本 helper 编好后把 4 件套(tarball + sha256 + helper.json + 补丁说明)
170
170
  换进 vendor 并提交即可(详细步骤在本地维护文档 `docs/MAINTENANCE.md` §4,该文件被 .gitignore,不随仓库分发)。
171
171
 
172
- `.spike/`(gitignored)是真机验证脚本 + 本地自检脚本:`p2-router.ts` 主回归(真远端 + 真
173
- Cordis + 真路由类)+ `grep-probe.mjs` 等探针(构建见 `tsconfig.scratch.json`),以及不需要真机的
172
+ `.spike/` 与 `scripts/`(都 gitignored,**不随仓库分发**)是开发期的验证与自检脚本:
173
+ `.spike/` 里是真机验证脚本 —— `p2-router.ts` 主回归(真远端 + 真 Cordis + 真路由类)+
174
+ `grep-probe.mjs` 等探针(构建见 `tsconfig.scratch.json`),以及不需要真机的
174
175
  `model-check.mjs` / `reveal-check.mjs` / `fallback-check.mjs` / `client-load-check.mjs` /
175
- `tool-schema-check.mjs`(见下文“开发”)。
176
+ `tool-schema-check.mjs`;`scripts/` 里是纯 node 的回归闸门(`node scripts/check-*.mjs`,
177
+ 其中 `verify-lib.mjs` 核对构建产物)。两者都见下文"开发"。
176
178
 
177
179
  ## 远端工作台(VS Code 式)
178
180
 
@@ -291,8 +293,8 @@ dsh plugin --profile web add link:<本目录绝对路径>
291
293
 
292
294
  **改完怎么生效:`pnpm run build` 之后重启 `dsh web` —— host 半与客户端半都是。**
293
295
  **`lib/` 是入库的**:本包没有 `prepare`(pnpm 12 会拦),别人 `dsh plugin add github:…` 拿到的是仓库内容,
294
- 所以**改完源码必须 `npm run build` 并把 `lib/` 一起提交**——`pnpm check` 的第一步
295
- (`scripts/verify-lib.mjs`)会在 `lib/` 没入库或加载即抛错时直接失败。
296
+ 所以**改完源码必须 `npm run build` 并把 `lib/` 一起提交**(`node scripts/verify-lib.mjs` 会核对入口齐全、
297
+ 已入库、能加载;自检脚本只在本机,见下)。
296
298
  Host 在启动时把插件包读进内存(`initialBundleSnapshot`:先记录 mtime/ctime/size 作为 rev,再读字节;
297
299
  响应头 `Cache-Control: public, max-age=31536000, immutable`),所以**只改 `src/client/**` 也一样**:
298
300
  浏览器刷新——包括 `Ctrl+Shift+R`——只会取回启动时抓下的那批字节,rev 不变。判定方法:
package/lib/client.js CHANGED
@@ -37978,7 +37978,7 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
37978
37978
  const primary = host.querySelectorAll("[class*=\"rvs-ck-\"]").length;
37979
37979
  const fallback = host.querySelectorAll("span[class^=\"rvs-t-\"]").length;
37980
37980
  const count = primary > 0 ? primary : fallback;
37981
- console.info(`[dsh-remote-vs] build 1021-1839 · ${source} · colored tokens: ${count} (${propsRef.current.path})`);
37981
+ console.info(`[dsh-remote-vs] build 1022-1539 · ${source} · colored tokens: ${count} (${propsRef.current.path})`);
37982
37982
  propsRef.current.onTokens?.(count);
37983
37983
  };
37984
37984
  const armFallback = () => {
@@ -39920,13 +39920,47 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
39920
39920
  return clean;
39921
39921
  }
39922
39922
  /**
39923
- * Name search under a remote directory. Depth-capped and `.git`/`node_modules`
39924
- * pruned so a huge tree cannot turn one keystroke into a minute of walking.
39923
+ * Directory names a NAME search never walks.
39924
+ *
39925
+ * The old `find` prune list plus one addition: `out` is build output (the
39926
+ * content search already prunes it), and on an AOSP tree with a populated `out/`
39927
+ * walking it would cost far more than the depth cap it replaces ever did.
39925
39928
  */
39926
- async function searchFiles(alias, root, query, limit = 120, depth = 6) {
39929
+ const NAME_PRUNE_DIRS = [
39930
+ ".git",
39931
+ "node_modules",
39932
+ ".venv",
39933
+ "dist",
39934
+ "build",
39935
+ "out"
39936
+ ];
39937
+ /**
39938
+ * Name search under a remote directory: `rg` when the host has it, `find` when
39939
+ * it does not — decided BY THE HOST inside one command, so there is no probe
39940
+ * round trip and no per-alias cache to invalidate.
39941
+ *
39942
+ * Why the fallback exists at all: the previous implementation was `find` with a
39943
+ * hard `-maxdepth 6`, and that cap silently hid real files — in a monorepo of a
39944
+ * few million files, a source file ten levels down is simply invisible to it.
39945
+ * Measured on such a tree: `find -maxdepth 6` → 0 hits (2.0 s), `find -maxdepth
39946
+ * 12` → the hit, but 13.5 s, an uncapped `find` → does not finish at all, and
39947
+ * `rg --files --iglob` → the hit in ~4 s with no depth limit, because it walks
39948
+ * the tree once with a prefiltered glob instead of stat-ing every entry.
39949
+ *
39950
+ * Coverage: `--no-ignore --hidden` keeps what `find` did — it never consulted
39951
+ * .gitignore — so a generated or ignored file is still findable by name. The
39952
+ * fallback keeps a cap (raised from 6 to 12) because an uncapped walk is not
39953
+ * viable on a tree that size.
39954
+ *
39955
+ * The query is a GLOB fragment (as it always was): `*.java` still works as a
39956
+ * pattern, and `--iglob` matches case-insensitively like `find -iname`.
39957
+ */
39958
+ async function searchFiles(alias, root, query, limit = 120, depth = 12) {
39927
39959
  const clean = assertQuery$1(query);
39928
39960
  const cap = Math.min(Math.max(Math.floor(limit), 1), 400);
39929
- const lines = (await tryRun(alias, `find ${shQuote(root)} -maxdepth ${depth} \\( -name .git -o -name node_modules -o -name .venv -o -name dist -o -name build \\) -prune -o \\( -type f -o -type d \\) -iname ${shQuote(`*${clean}*`)} -printf '%p\\t%y\\n' 2>/dev/null | head -n ${cap + 1}`, 9e4)).split("\n").map((line) => line.trim()).filter((line) => line !== "");
39961
+ const pattern = `*${clean}*`;
39962
+ const prunes = NAME_PRUNE_DIRS.map((name) => `-g ${shQuote(`!**/${name}/**`)}`).join(" ");
39963
+ const lines = (await tryRun(alias, `if command -v rg >/dev/null 2>&1; then ${`rg --files --no-ignore --hidden --iglob ${shQuote(pattern)} ${prunes} ${shQuote(root)} 2>/dev/null`}; else ${`find ${shQuote(root)} -maxdepth ${depth} \\( ${NAME_PRUNE_DIRS.map((name) => `-name ${shQuote(name)}`).join(" -o ")} \\) -prune -o \\( -type f -o -type d \\) -iname ${shQuote(pattern)} -printf '%p\\t%y\\n' 2>/dev/null`}; fi | head -n ${cap + 1}`, 9e4)).split("\n").map((line) => line.trim()).filter((line) => line !== "");
39930
39964
  const truncated = lines.length > cap;
39931
39965
  const base = root.replace(/\/+$/, "");
39932
39966
  const hits = [];
@@ -43037,8 +43071,8 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
43037
43071
  }),
43038
43072
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
43039
43073
  className: "rvs-build",
43040
- title: `dsh-remote-vs build 1021-1839`,
43041
- children: "1021-1839"
43074
+ title: `dsh-remote-vs build 1022-1539`,
43075
+ children: "1022-1539"
43042
43076
  }),
43043
43077
  tokenCount !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
43044
43078
  className: "rvs-build",
@@ -44859,7 +44893,25 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
44859
44893
  return trimmed;
44860
44894
  }
44861
44895
  /**
44862
- * Recursive content search under one absolute remote directory.
44896
+ * Recursive content search under one absolute remote directory: `rg` when the
44897
+ * host has it, `grep -r` when it does not.
44898
+ *
44899
+ * Measured on a monorepo of a few million files (two kernel source trees among
44900
+ * them): the `grep` form was still running when a 300 s timeout killed it, while
44901
+ * the same query scoped to one subtree answers in milliseconds — on a tree that
44902
+ * size the SCOPE matters more than the tool, but rg is the faster one, so it is
44903
+ * preferred, with the previous grep command kept verbatim as the fallback so
44904
+ * behaviour can never get worse than today.
44905
+ *
44906
+ * Two deliberate differences on the rg path:
44907
+ * - it honours .gitignore (grep never did), which is what keeps build outputs
44908
+ * and kernel artifacts out of an interactive search — the same default VS
44909
+ * Code ships, and the reason `--no-ignore` is NOT passed here;
44910
+ * - `--hidden` keeps dotfiles searchable (`.github/workflows/*.yml`), with
44911
+ * `.git` excluded explicitly so `--hidden` cannot walk a nested repository.
44912
+ *
44913
+ * The output SHAPE is identical either way (`path\0line:text`, verified byte for
44914
+ * byte against both tools on a real host), so `parseContentHits` is unchanged.
44863
44915
  *
44864
44916
  * `--null` puts a NUL between the path and the `line:text` half so a path
44865
44917
  * containing `:` still parses; `cut` bounds each line and `head` bounds the
@@ -44869,16 +44921,28 @@ Integer Float Double Long Int32 Int64 Uint8 uint32_t int32_t uint64_t int64_t si
44869
44921
  const pattern = assertQuery(query);
44870
44922
  const max = Math.min(Math.max(Math.floor(options.maxResults), 1), 2e3);
44871
44923
  const lineChars = Math.min(Math.max(Math.floor(options.maxLineChars), 80), 4e3);
44872
- return `${[
44924
+ const prune = [...PRUNE_DIRS, ...options.prune ?? []];
44925
+ const grepParts = [
44873
44926
  "grep -rIn --null --binary-files=without-match",
44874
- ...[...PRUNE_DIRS, ...options.prune ?? []].map((name) => `--exclude-dir=${shQuote(name)}`),
44927
+ ...prune.map((name) => `--exclude-dir=${shQuote(name)}`),
44875
44928
  options.caseSensitive ? "" : "-i",
44876
44929
  options.wholeWord ? "-w" : "",
44877
44930
  options.regex ? "-E" : "-F",
44878
44931
  `-e ${shQuote(pattern)}`,
44879
44932
  "--",
44880
44933
  shQuote(assertRemoteDir(dir))
44881
- ].filter((part) => part !== "").join(" ")} 2>/dev/null | cut -c1-${lineChars} | head -n ${max + 1}`;
44934
+ ].filter((part) => part !== "");
44935
+ const rgParts = [
44936
+ "rg -n --no-heading --null --color=never --with-filename --hidden",
44937
+ ...prune.map((name) => `-g ${shQuote(`!**/${name}/**`)}`),
44938
+ options.caseSensitive ? "" : "-i",
44939
+ options.wholeWord ? "-w" : "",
44940
+ options.regex ? "" : "-F",
44941
+ `-e ${shQuote(pattern)}`,
44942
+ shQuote(assertRemoteDir(dir))
44943
+ ].filter((part) => part !== "");
44944
+ const grep = `${grepParts.join(" ")} 2>/dev/null`;
44945
+ return `if command -v rg >/dev/null 2>&1; then ${`${rgParts.join(" ")} 2>/dev/null`}; else ${grep}; fi | cut -c1-${lineChars} | head -n ${max + 1}`;
44882
44946
  }
44883
44947
  /**
44884
44948
  * Parse the NUL-separated grep output (tolerating a fallback colon format).
@@ -157,8 +157,25 @@ export interface SearchHit {
157
157
  isDir: boolean;
158
158
  }
159
159
  /**
160
- * Name search under a remote directory. Depth-capped and `.git`/`node_modules`
161
- * pruned so a huge tree cannot turn one keystroke into a minute of walking.
160
+ * Name search under a remote directory: `rg` when the host has it, `find` when
161
+ * it does not — decided BY THE HOST inside one command, so there is no probe
162
+ * round trip and no per-alias cache to invalidate.
163
+ *
164
+ * Why the fallback exists at all: the previous implementation was `find` with a
165
+ * hard `-maxdepth 6`, and that cap silently hid real files — in a monorepo of a
166
+ * few million files, a source file ten levels down is simply invisible to it.
167
+ * Measured on such a tree: `find -maxdepth 6` → 0 hits (2.0 s), `find -maxdepth
168
+ * 12` → the hit, but 13.5 s, an uncapped `find` → does not finish at all, and
169
+ * `rg --files --iglob` → the hit in ~4 s with no depth limit, because it walks
170
+ * the tree once with a prefiltered glob instead of stat-ing every entry.
171
+ *
172
+ * Coverage: `--no-ignore --hidden` keeps what `find` did — it never consulted
173
+ * .gitignore — so a generated or ignored file is still findable by name. The
174
+ * fallback keeps a cap (raised from 6 to 12) because an uncapped walk is not
175
+ * viable on a tree that size.
176
+ *
177
+ * The query is a GLOB fragment (as it always was): `*.java` still works as a
178
+ * pattern, and `--iglob` matches case-insensitively like `find -iname`.
162
179
  */
163
180
  export declare function searchFiles(alias: string, root: string, query: string, limit?: number, depth?: number): Promise<{
164
181
  hits: SearchHit[];
@@ -16,7 +16,25 @@ export declare const DEFAULT_CONTENT_OPTIONS: ContentSearchOptions;
16
16
  /** Assert a search query (printable, bounded, no NUL/newline). */
17
17
  export declare function assertQuery(query: string): string;
18
18
  /**
19
- * Recursive content search under one absolute remote directory.
19
+ * Recursive content search under one absolute remote directory: `rg` when the
20
+ * host has it, `grep -r` when it does not.
21
+ *
22
+ * Measured on a monorepo of a few million files (two kernel source trees among
23
+ * them): the `grep` form was still running when a 300 s timeout killed it, while
24
+ * the same query scoped to one subtree answers in milliseconds — on a tree that
25
+ * size the SCOPE matters more than the tool, but rg is the faster one, so it is
26
+ * preferred, with the previous grep command kept verbatim as the fallback so
27
+ * behaviour can never get worse than today.
28
+ *
29
+ * Two deliberate differences on the rg path:
30
+ * - it honours .gitignore (grep never did), which is what keeps build outputs
31
+ * and kernel artifacts out of an interactive search — the same default VS
32
+ * Code ships, and the reason `--no-ignore` is NOT passed here;
33
+ * - `--hidden` keeps dotfiles searchable (`.github/workflows/*.yml`), with
34
+ * `.git` excluded explicitly so `--hidden` cannot walk a nested repository.
35
+ *
36
+ * The output SHAPE is identical either way (`path\0line:text`, verified byte for
37
+ * byte against both tools on a real host), so `parseContentHits` is unchanged.
20
38
  *
21
39
  * `--null` puts a NUL between the path and the `line:text` half so a path
22
40
  * containing `:` still parses; `cut` bounds each line and `head` bounds the
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.3",
4
+ "version": "0.8.5",
5
5
  "icon": "icon.svg",
6
6
  "keywords": [
7
7
  "dsh",
@@ -182,8 +182,6 @@
182
182
  },
183
183
  "scripts": {
184
184
  "build": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsdown",
185
- "verify:lib": "node scripts/verify-lib.mjs",
186
- "check": "node scripts/verify-lib.mjs && node scripts/check-changes.mjs && node scripts/check-ledger.mjs && node scripts/check-status.mjs && node scripts/check-text-diff.mjs && node scripts/check-workspace-follow.mjs && node scripts/check-scm-stage.mjs",
187
185
  "bundle": "tsdown",
188
186
  "watch": "tsdown --watch",
189
187
  "typecheck": "tsc --noEmit"
@@ -411,26 +411,57 @@ function assertQuery(query: string): string {
411
411
  }
412
412
 
413
413
  /**
414
- * Name search under a remote directory. Depth-capped and `.git`/`node_modules`
415
- * pruned so a huge tree cannot turn one keystroke into a minute of walking.
414
+ * Directory names a NAME search never walks.
415
+ *
416
+ * The old `find` prune list plus one addition: `out` is build output (the
417
+ * content search already prunes it), and on an AOSP tree with a populated `out/`
418
+ * walking it would cost far more than the depth cap it replaces ever did.
419
+ */
420
+ const NAME_PRUNE_DIRS = ['.git', 'node_modules', '.venv', 'dist', 'build', 'out']
421
+
422
+ /**
423
+ * Name search under a remote directory: `rg` when the host has it, `find` when
424
+ * it does not — decided BY THE HOST inside one command, so there is no probe
425
+ * round trip and no per-alias cache to invalidate.
426
+ *
427
+ * Why the fallback exists at all: the previous implementation was `find` with a
428
+ * hard `-maxdepth 6`, and that cap silently hid real files — in a monorepo of a
429
+ * few million files, a source file ten levels down is simply invisible to it.
430
+ * Measured on such a tree: `find -maxdepth 6` → 0 hits (2.0 s), `find -maxdepth
431
+ * 12` → the hit, but 13.5 s, an uncapped `find` → does not finish at all, and
432
+ * `rg --files --iglob` → the hit in ~4 s with no depth limit, because it walks
433
+ * the tree once with a prefiltered glob instead of stat-ing every entry.
434
+ *
435
+ * Coverage: `--no-ignore --hidden` keeps what `find` did — it never consulted
436
+ * .gitignore — so a generated or ignored file is still findable by name. The
437
+ * fallback keeps a cap (raised from 6 to 12) because an uncapped walk is not
438
+ * viable on a tree that size.
439
+ *
440
+ * The query is a GLOB fragment (as it always was): `*.java` still works as a
441
+ * pattern, and `--iglob` matches case-insensitively like `find -iname`.
416
442
  */
417
443
  export async function searchFiles(
418
444
  alias: string,
419
445
  root: string,
420
446
  query: string,
421
447
  limit = 120,
422
- depth = 6,
448
+ depth = 12,
423
449
  ): Promise<{ hits: SearchHit[]; truncated: boolean }> {
424
450
  const clean = assertQuery(query)
425
451
  const cap = Math.min(Math.max(Math.floor(limit), 1), 400)
426
- // `-printf '%p\t%y'` carries the entry type, because `find -print` never
427
- // emits a trailing slash for directories — the old `line.endsWith('/')`
428
- // test could therefore never be true and every hit was reported as a file
429
- // (so a directory hit was routed into openFile). GNU/BusyBox find support
430
- // -printf; the content-search builder already depends on GNU grep flags.
431
- const command = `find ${shQuote(root)} -maxdepth ${depth} `
432
- + `\\( -name .git -o -name node_modules -o -name .venv -o -name dist -o -name build \\) -prune -o `
433
- + `\\( -type f -o -type d \\) -iname ${shQuote(`*${clean}*`)} -printf '%p\\t%y\\n' 2>/dev/null | head -n ${cap + 1}`
452
+ const pattern = `*${clean}*`
453
+ const prunes = NAME_PRUNE_DIRS.map(name => `-g ${shQuote(`!**/${name}/**`)}`).join(' ')
454
+ const rg = `rg --files --no-ignore --hidden --iglob ${shQuote(pattern)} ${prunes} ${shQuote(root)} 2>/dev/null`
455
+ // `-printf '%p\t%y'` carries the entry type, because `find -print` never emits
456
+ // a trailing slash for directories — the old `line.endsWith('/')` test could
457
+ // therefore never be true and every hit was reported as a file (so a directory
458
+ // hit was routed into openFile). GNU/BusyBox find support -printf; the
459
+ // content-search builder already depends on GNU grep flags. rg's `--files`
460
+ // prints bare paths (files only), which the parser below already tolerates.
461
+ const find = `find ${shQuote(root)} -maxdepth ${depth} `
462
+ + `\\( ${NAME_PRUNE_DIRS.map(name => `-name ${shQuote(name)}`).join(' -o ')} \\) -prune -o `
463
+ + `\\( -type f -o -type d \\) -iname ${shQuote(pattern)} -printf '%p\\t%y\\n' 2>/dev/null`
464
+ const command = `if command -v rg >/dev/null 2>&1; then ${rg}; else ${find}; fi | head -n ${cap + 1}`
434
465
  const out = await tryRun(alias, command, 90_000)
435
466
  const lines = out.split('\n').map(line => line.trim()).filter(line => line !== '')
436
467
  const truncated = lines.length > cap
package/src/search-ops.ts CHANGED
@@ -61,7 +61,25 @@ export function assertQuery(query: string): string {
61
61
  }
62
62
 
63
63
  /**
64
- * Recursive content search under one absolute remote directory.
64
+ * Recursive content search under one absolute remote directory: `rg` when the
65
+ * host has it, `grep -r` when it does not.
66
+ *
67
+ * Measured on a monorepo of a few million files (two kernel source trees among
68
+ * them): the `grep` form was still running when a 300 s timeout killed it, while
69
+ * the same query scoped to one subtree answers in milliseconds — on a tree that
70
+ * size the SCOPE matters more than the tool, but rg is the faster one, so it is
71
+ * preferred, with the previous grep command kept verbatim as the fallback so
72
+ * behaviour can never get worse than today.
73
+ *
74
+ * Two deliberate differences on the rg path:
75
+ * - it honours .gitignore (grep never did), which is what keeps build outputs
76
+ * and kernel artifacts out of an interactive search — the same default VS
77
+ * Code ships, and the reason `--no-ignore` is NOT passed here;
78
+ * - `--hidden` keeps dotfiles searchable (`.github/workflows/*.yml`), with
79
+ * `.git` excluded explicitly so `--hidden` cannot walk a nested repository.
80
+ *
81
+ * The output SHAPE is identical either way (`path\0line:text`, verified byte for
82
+ * byte against both tools on a real host), so `parseContentHits` is unchanged.
65
83
  *
66
84
  * `--null` puts a NUL between the path and the `line:text` half so a path
67
85
  * containing `:` still parses; `cut` bounds each line and `head` bounds the
@@ -72,7 +90,7 @@ export function contentSearchCommand(dir: string, query: string, options: Conten
72
90
  const max = Math.min(Math.max(Math.floor(options.maxResults), 1), 2000)
73
91
  const lineChars = Math.min(Math.max(Math.floor(options.maxLineChars), 80), 4000)
74
92
  const prune = [...PRUNE_DIRS, ...(options.prune ?? [])]
75
- const parts = [
93
+ const grepParts = [
76
94
  'grep -rIn --null --binary-files=without-match',
77
95
  ...prune.map(name => `--exclude-dir=${shQuote(name)}`),
78
96
  options.caseSensitive ? '' : '-i',
@@ -82,7 +100,19 @@ export function contentSearchCommand(dir: string, query: string, options: Conten
82
100
  '--',
83
101
  shQuote(assertRemoteDir(dir)),
84
102
  ].filter(part => part !== '')
85
- return `${parts.join(' ')} 2>/dev/null | cut -c1-${lineChars} | head -n ${max + 1}`
103
+ const rgParts = [
104
+ 'rg -n --no-heading --null --color=never --with-filename --hidden',
105
+ ...prune.map(name => `-g ${shQuote(`!**/${name}/**`)}`),
106
+ options.caseSensitive ? '' : '-i',
107
+ options.wholeWord ? '-w' : '',
108
+ // rg is regex by default, so literal mode is the one that needs a flag.
109
+ options.regex ? '' : '-F',
110
+ `-e ${shQuote(pattern)}`,
111
+ shQuote(assertRemoteDir(dir)),
112
+ ].filter(part => part !== '')
113
+ const grep = `${grepParts.join(' ')} 2>/dev/null`
114
+ const rg = `${rgParts.join(' ')} 2>/dev/null`
115
+ return `if command -v rg >/dev/null 2>&1; then ${rg}; else ${grep}; fi | cut -c1-${lineChars} | head -n ${max + 1}`
86
116
  }
87
117
 
88
118
  /** One content-search hit. */