13 KiB
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 bysrc/shared/quick-open-filter.tsfor listFiles. - Remove the
execFile/maxBufferfootgun from the relay path. - Unify the accumulator, truncation semantics, and submatch regex construction.
Out of scope:
- Changing the
fs.searchrequest shape or existingSearchResult/SearchOptionsfields. - 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.
// 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
maxResultsdifferently today (third positional arg vs. folded into opts). The sharedbuildGitGrepArgs/ingestGitGrepLinetakemaxResultson 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 plainreplace(/\\/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:
maxResultsreached 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
-
Land the shared module with tests. Unit tests live at
src/shared/text-search.test.ts, modeled onsrc/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. -
Migrate the local path. Replace the inline rg loop in
filesystem.tsand rewritefilesystem-search-git.tsto use the shared helpers. Existingfilesystem-list-files.test.ts+filesystem.test.tscatch regressions; add a test specifically for theexecFile→spawnequivalence (large result set that would have overflowed 50MB underexecFile). -
Migrate the relay path. Replace
searchWithRg(execFile→spawn) and consolidatefs-handler-git-fallback.ts's search half. DeletesearchWithRgand the relay's git-grep duplicate once the last caller is gone. -
Drift guard. Add a short comment at the top of
shared/text-search.tspointing to this design doc and naming both call sites. The existing comment at the top ofshared/quick-open-filter.tsis the template.
Non-goals / Explicit Non-changes
- Not merging
checkRgAvailable. Both versions already agreed (no caching — see the "Why no cache" comments insrc/main/ipc/rg-availability.tsandsrc/relay/fs-handler-utils.ts), but they wrap different spawn primitives: the local side runs throughwslAwareSpawnand accepts asearchPathso WSL distro resolution works, while the relay uses plainexecFile. Sharing would force one side to import the other's spawn wrapper. Two thin files, one contract. - Not unifying spawn.
wslAwareSpawnandgitSpawncarry WSL and git-auth concerns the relay has no business with. - Not changing the request shape or existing result fields.
SearchOptionsstays as-is, andSearchResultkeeps 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 sourcecolumn/matchLengthused for editor reveal. - Not touching the renderer.
right-sidebar/Search.tsxandQuickOpen.tsxare 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-handlertest that streams a large mock rg stdout through thespawn-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.searchrequests over the mux. If that becomes a problem, add relay-side cancellation later — it is not part of this refactor.