Files
orca/docs/design/share-text-search.md
JinjingandOrca 099c4089a7 refactor(search): share rg/git-grep logic between main and relay (#1157)
Extracts rg and git-grep search logic into src/shared/text-search.ts so
the local main and SSH relay paths stop reinventing arg construction,
JSON parsing, submatch regex, and accumulator/truncation semantics.

Fixes silent truncation in the relay: searchWithRg used execFile with
a 50MB maxBuffer cap that rg --json easily exceeds on large repos,
dropping matches with no error surfaced to the user. The relay now
streams via spawn, matching the local path.

See docs/design/share-text-search.md for full rationale.

Co-authored-by: Orca <help@stably.ai>
2026-04-27 00:06:00 -07:00

193 lines
13 KiB
Markdown

# Design: Share Text Search Logic Between Local Main and SSH Relay
**Branch:** `fix-ssh-keywords-search`
**Status:** Draft
## Problem
The right-sidebar keywords search (`fs:search`) and the Cmd+P quick-open file search historically shared no code between the local main process and the SSH relay. Each side reinvented: rg argument construction, rg `--json` stdout parsing, the git-grep fallback, the submatch regex, the `SearchFileResult` accumulator, and the "kill previous search on new query" logic.
This drift already caused one user-visible bug: the relay's `searchWithRg` in `src/relay/fs-handler-utils.ts:139` uses `execFile('rg', ..., { maxBuffer: 50 * 1024 * 1024 })`. `execFile` buffers stdout internally and kills the child when `maxBuffer` is exceeded, even when `data` listeners are attached. Under rg's `--json` output (one verbose JSON object per match), 50MB fills well before the match cap in large folders. The `child.once('error', () => resolveOnce())` then silently resolves with whatever was accumulated — users see "some files can't be found" with no error.
The local handler at `src/main/ipc/filesystem.ts:402` uses `wslAwareSpawn` (plain `spawn`) and has never had this bug. The two paths must not be allowed to drift again.
## Scope
**In scope:**
- Extract rg + git-grep search logic into `src/shared/text-search.ts`, matching the pattern already established by `src/shared/quick-open-filter.ts` for listFiles.
- Remove the `execFile`/`maxBuffer` footgun from the relay path.
- Unify the accumulator, truncation semantics, and submatch regex construction.
**Out of scope:**
- Changing the `fs.search` request shape or existing `SearchResult`/`SearchOptions` fields.
- Adding new search features (multiline, semantic, etc).
- Changing how quick-open lists files (already shared via `quick-open-filter.ts`).
- Re-homing WSL path translation — that stays in the local main process; the relay never sees WSL paths.
## Existing Code Map
| Concern | Local (main) | Remote (relay) |
|---|---|---|
| rg `--json` run + parse | `src/main/ipc/filesystem.ts:286-441` (inline in IPC handler) | `src/relay/fs-handler-utils.ts:78-231` (`searchWithRg`) |
| git-grep fallback | `src/main/ipc/filesystem-search-git.ts:43-220` (`searchWithGitGrep`) | `src/relay/fs-handler-git-fallback.ts:140-297` (`searchWithGitGrep`) |
| rg availability check | `src/main/ipc/rg-availability.ts` (`checkRgAvailable`) | `src/relay/fs-handler-utils.ts:241-260` (`checkRgAvailable`) |
| rg arg construction | inline in filesystem.ts | inline in fs-handler-utils.ts |
| git-grep arg construction | inline in filesystem-search-git.ts | inline in fs-handler-git-fallback.ts |
| Submatch regex | `filesystem-search-git.ts:115-119` | `fs-handler-git-fallback.ts:200-204` |
| Accumulator (fileMap, totalMatches, truncated) | duplicated in all four files | duplicated in all four files |
| Relative-path normalization | `normalizeRelativePath` (collapses `\\`/`/`, strips leading slashes) | plain `.replace(/\\/g, '/')` |
| git-grep signature | `searchWithGitGrep(rootPath, args, maxResults)` (maxResults positional) | `searchWithGitGrep(rootPath, query, opts)` (maxResults inside opts) |
| Process spawn | `wslAwareSpawn` (local) / `gitSpawn` (local) | `execFile` (rg, buggy) / `spawn` (git) |
Net duplicate: ~400 lines across four files that do nearly the same thing.
## Design
### New module: `src/shared/text-search.ts`
Pure, IO-agnostic helpers. No Electron, no child_process, no fs. Mirrors `quick-open-filter.ts` — the caller owns process execution and transport-specific path quirks.
```ts
// Types (re-exported from shared/types or defined here)
export type SearchAccumulator = {
fileMap: Map<string, SearchFileResult>
totalMatches: number
truncated: boolean
}
export function createAccumulator(): SearchAccumulator
// ── rg ─────────────────────────────────────────────────────────────
// Returns the full argv including '--', query, and target. Both callers
// pass `rootPath` unchanged as the target — the local side does NOT
// translate the target to a WSL-native path. WSL only affects the
// invocation (via `wslAwareSpawn`) and the *output* paths rg emits,
// which the caller translates back via `transformAbsPath` below.
export function buildRgArgs(
query: string,
target: string,
opts: SearchOptions
): string[]
// Ingest one rg --json stdout line. Mutates `acc`. Returns 'continue'
// or 'stop' (stop = totalMatches hit maxResults). Takes an optional
// path transform so the local caller can apply WSL translation.
export function ingestRgJsonLine(
line: string,
rootPath: string,
acc: SearchAccumulator,
maxResults: number,
transformAbsPath?: (p: string) => string
): 'continue' | 'stop'
// ── git grep ───────────────────────────────────────────────────────
// Also owns include/exclude glob → git pathspec translation
// (`toGitGlobPathspec`), which today is duplicated inline in both
// `filesystem-search-git.ts` and `fs-handler-git-fallback.ts`.
export function buildGitGrepArgs(
query: string,
opts: SearchOptions
): string[]
// Build the submatch regex used to locate column positions within a
// matched line (git grep only reports the first hit per line).
export function buildSubmatchRegex(
query: string,
opts: { useRegex?: boolean; wholeWord?: boolean; caseSensitive?: boolean }
): RegExp
export function ingestGitGrepLine(
line: string,
rootPath: string,
submatchRegex: RegExp,
acc: SearchAccumulator,
maxResults: number
): 'continue' | 'stop'
// ── finalize ───────────────────────────────────────────────────────
export function finalize(acc: SearchAccumulator): SearchResult
```
### What stays environment-specific
| Stays local | Stays in relay |
|---|---|
| `wslAwareSpawn`, `gitSpawn` | plain `spawn` |
| `parseWslPath` / `toWindowsWslPath` transform passed to `ingestRgJsonLine` | no-op transform |
| `activeTextSearches` kill-on-new-query map keyed by `sender.id` | single-search-at-a-time per client (already one channel) |
| `resolveAuthorizedPath` | `context.validatePathResolved` |
| `checkRgAvailable` wrapping `wslAwareSpawn` (accepts a `searchPath` for WSL resolution) | `checkRgAvailable` wrapping plain `execFile` (no WSL) |
### Call sites after refactor
**`src/main/ipc/filesystem.ts`** shrinks from ~180 lines of search logic to ~40:
```
const rgAvailable = await checkRgAvailable(rootPath)
if (!rgAvailable) return searchWithGitGrep(rootPath, args, maxResults)
const acc = createAccumulator()
const rgArgs = buildRgArgs(args.query, rootPath, args)
const child = wslAwareSpawn('rg', rgArgs, { cwd: rootPath, stdio: ... })
activeTextSearches.get(searchKey)?.kill()
activeTextSearches.set(searchKey, child)
// stream stdout → ingestRgJsonLine(line, rootPath, acc, maxResults, wslTransform)
// on 'stop' → child.kill()
// on close/error → resolve(finalize(acc))
// timeout → set acc.truncated = true, child.kill()
```
**`src/main/ipc/filesystem-search-git.ts`** becomes a thin wrapper around `buildGitGrepArgs` + `ingestGitGrepLine`. File drops from 220 → ~80 lines.
**`src/relay/fs-handler.ts::search`** and the relay's rg/git-grep helpers: identical shape to the local caller, minus the WSL transform and `activeTextSearches` tracking. `searchWithRg` in `fs-handler-utils.ts` is deleted; its callers inline the spawn loop or we keep a thin relay-side wrapper (`src/relay/fs-handler-search.ts`).
**Critical:** the relay's rg caller uses `spawn`, not `execFile`. This alone fixes the reported bug.
### Signature + path normalization (unified)
The shared helpers settle two small existing asymmetries:
- git-grep callers in main and relay take `maxResults` differently today (third positional arg vs. folded into opts). The shared `buildGitGrepArgs` / `ingestGitGrepLine` take `maxResults` on the accumulator-ingest side only, so callers no longer invent their own shape.
- Relative-path normalization is unified on `normalizeRelativePath` (collapse mixed separators, strip leading slashes). The relay's plain `replace(/\\/g, '/')` is replaced, removing a drift seam that would surface the first time someone passed a path with a leading slash through the relay.
### Truncation semantics (unified)
One rule: `acc.truncated = true` if and only if rg/git-grep would have emitted more matches after we stopped consuming. Specifically:
- `maxResults` reached while processing submatches for a match record → truncated.
- Kill-timeout fires → truncated.
- rg/git-grep exits with non-zero status → *not* truncated; this is a clean "no results or early termination" path. (Matches current local behavior.)
This removes the existing inconsistency where the relay's `execFile` maxBuffer overflow silently returned `truncated: false` despite dropping matches.
**Ordering invariant (do not break during migration).** Today the caller flips `truncated = true` synchronously in the same tick it calls `child.kill()`, before the `close` handler resolves the promise. The shared module must preserve that ordering: `ingestRgJsonLine` / `ingestGitGrepLine` mutate `acc.truncated` synchronously when they return `'stop'`, and the caller must kill the child *after* that mutation. If a naive refactor moves the kill inside the helper but leaves `truncated` setting in the caller — or vice versa — a `close` event can resolve the promise with `truncated: false` even though matches were dropped. This is the exact silent-truncation footgun the refactor is meant to kill; regressing it reintroduces the original bug in a harder-to-spot form.
### Regex parity
`buildSubmatchRegex` centralizes the "escape literal query, wrap in `\b` for whole-word, add `gi` flags" logic currently in two files. Includes the zero-length-match guard (`matchRegex.lastIndex++` when `m[0].length === 0`) that the relay version also has but that would regress if one side is touched without the other.
## Migration Plan
1. **Land the shared module with tests.** Unit tests live at `src/shared/text-search.test.ts`, modeled on `src/shared/quick-open-filter.test.ts`. Cover: arg construction (every flag combination), rg JSON line ingestion (match/non-match/malformed/multi-submatch/maxResults boundary), git-grep line parsing (null-byte delimiter, colons in filenames, unicode, zero-length regex), and finalize shape.
2. **Migrate the local path.** Replace the inline rg loop in `filesystem.ts` and rewrite `filesystem-search-git.ts` to use the shared helpers. Existing `filesystem-list-files.test.ts` + `filesystem.test.ts` catch regressions; add a test specifically for the `execFile` → `spawn` equivalence (large result set that would have overflowed 50MB under `execFile`).
3. **Migrate the relay path.** Replace `searchWithRg` (`execFile` → `spawn`) and consolidate `fs-handler-git-fallback.ts`'s search half. Delete `searchWithRg` and the relay's git-grep duplicate once the last caller is gone.
4. **Drift guard.** Add a short comment at the top of `shared/text-search.ts` pointing to this design doc and naming both call sites. The existing comment at the top of `shared/quick-open-filter.ts` is the template.
## Non-goals / Explicit Non-changes
- **Not merging `checkRgAvailable`.** Both versions already agreed (no caching — see the "Why no cache" comments in `src/main/ipc/rg-availability.ts` and `src/relay/fs-handler-utils.ts`), but they wrap different spawn primitives: the local side runs through `wslAwareSpawn` and accepts a `searchPath` so WSL distro resolution works, while the relay uses plain `execFile`. Sharing would force one side to import the other's spawn wrapper. Two thin files, one contract.
- **Not unifying spawn.** `wslAwareSpawn` and `gitSpawn` carry WSL and git-auth concerns the relay has no business with.
- **Not changing the request shape or existing result fields.** `SearchOptions` stays as-is, and
`SearchResult` keeps the same required fields. Long-line clamping may add optional display-only
coordinates to a match so the sidebar can highlight bounded snippets without corrupting the
source `column`/`matchLength` used for editor reveal.
- **Not touching the renderer.** `right-sidebar/Search.tsx` and `QuickOpen.tsx` are unchanged.
## Risks
- **Test coverage for relay search is thin.** Current tests exercise the local path. Plan: port the new shared-module tests plus add a relay-specific integration check (`fs-handler` test that streams a large mock rg stdout through the `spawn`-based loop to confirm no drop).
- **Behavioral parity is not 1:1 today.** The local path has WSL translation; the relay does not. Parity we're preserving is output *shape*, not output *paths*. Tests must not assume absolute-path equality across the two callers.
- **Kill-previous-search is only local.** The relay can process multiple concurrent `fs.search` requests over the mux. If that becomes a problem, add relay-side cancellation later — it is not part of this refactor.