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>
This commit is contained in:
Jinjing 2026-04-27 00:06:00 -07:00 committed by GitHub
parent afeac870b2
commit 099c4089a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1030 additions and 543 deletions

View File

@ -0,0 +1,192 @@
# 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.

View File

@ -1,38 +1,14 @@
import { join } from 'path'
import type { SearchOptions, SearchResult, SearchFileResult } from '../../shared/types'
import type { SearchOptions, SearchResult } from '../../shared/types'
import {
buildGitGrepArgs,
buildSubmatchRegex,
createAccumulator,
finalize,
ingestGitGrepLine,
SEARCH_TIMEOUT_MS
} from '../../shared/text-search'
import { gitSpawn } from '../git/runner'
const SEARCH_TIMEOUT_MS = 15000
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
// Why: esbuild's parser chokes on regex literals containing brace/bracket
// character classes, so we escape special chars with a simple loop instead.
const REGEX_SPECIAL = '.*+?^${}()|[]\\'
function escapeRegexSource(str: string): string {
let out = ''
for (let i = 0; i < str.length; i++) {
out += REGEX_SPECIAL.includes(str[i]) ? `\\${str[i]}` : str[i]
}
return out
}
/**
* Convert a user-facing glob pattern into a git pathspec.
*
* Why: rg globs like `*.ts` match at any directory depth, but a bare git
* pathspec `*.ts` only matches in the repo root. Wrapping with `:(glob)` and
* prepending `** /` for patterns without a path separator replicates rg's
* recursive-by-default behaviour.
*/
function toGitGlobPathspec(glob: string, exclude?: boolean): string {
const needsRecursive = !glob.includes('/')
const pattern = needsRecursive ? `**/${glob}` : glob
return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}`
}
/**
* Fallback text search using git grep. Used when rg is not available.
*
@ -46,143 +22,25 @@ export function searchWithGitGrep(
maxResults: number
): Promise<SearchResult> {
return new Promise((resolve) => {
// Why: --untracked searches untracked (but not ignored) files in addition
// to tracked ones, matching rg's default behaviour of respecting gitignore.
// Why: -I skips binary files (mirrors rg's default). --null uses \0 as
// the filename delimiter so filenames with colons parse unambiguously.
// --no-recurse-submodules is needed because users may have
// submodule.recurse=true in their git config, which conflicts with
// --untracked and would cause git grep to fail.
const gitArgs: string[] = [
'-c',
'submodule.recurse=false',
'grep',
'-n',
'-I',
'--null',
'--no-color',
'--untracked'
]
if (!args.caseSensitive) {
gitArgs.push('-i')
}
if (args.wholeWord) {
gitArgs.push('-w')
}
if (!args.useRegex) {
gitArgs.push('--fixed-strings')
} else {
gitArgs.push('--extended-regexp')
}
gitArgs.push('-e', args.query, '--')
let hasPathspecs = false
if (args.includePattern) {
for (const pat of args.includePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
gitArgs.push(toGitGlobPathspec(pat))
hasPathspecs = true
}
}
if (args.excludePattern) {
for (const pat of args.excludePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
gitArgs.push(toGitGlobPathspec(pat, true))
hasPathspecs = true
}
}
// Why: when no include patterns are given, git grep needs a pathspec to
// search the working tree. '.' means "everything under cwd".
if (!hasPathspecs) {
gitArgs.push('.')
}
const fileMap = new Map<string, SearchFileResult>()
let totalMatches = 0
let truncated = false
const gitArgs = buildGitGrepArgs(args.query, args)
const matchRegex = buildSubmatchRegex(args.query, args)
const acc = createAccumulator()
let stdoutBuffer = ''
let done = false
// Build a JS regex to locate all submatch positions within each matched
// line. git grep only reports the first match per line; we need byte
// offsets and lengths for every occurrence to populate SearchMatch[].
let pattern = args.useRegex ? args.query : escapeRegexSource(args.query)
if (args.wholeWord) {
pattern = `\\b${pattern}\\b`
}
const matchRegex = new RegExp(pattern, `g${args.caseSensitive ? '' : 'i'}`)
const resolveOnce = (): void => {
if (done) {
return
}
done = true
clearTimeout(killTimeout)
resolve({
files: Array.from(fileMap.values()),
totalMatches,
truncated
})
resolve(finalize(acc))
}
const processLine = (line: string): void => {
if (!line || totalMatches >= maxResults) {
return
}
// Why: with --null -n the output format is filename\0linenum:content.
// The null byte separates the filename unambiguously (colons in
// filenames would otherwise break parsing).
const nullIdx = line.indexOf('\0')
if (nullIdx === -1) {
return
}
const relPath = normalizeRelativePath(line.substring(0, nullIdx))
const rest = line.substring(nullIdx + 1)
const colonIdx = rest.indexOf(':')
if (colonIdx === -1) {
return
}
const lineNum = parseInt(rest.substring(0, colonIdx), 10)
if (isNaN(lineNum)) {
return
}
const lineContent = rest.substring(colonIdx + 1).replace(/\n$/, '')
const absPath = join(rootPath, relPath)
let fileResult = fileMap.get(absPath)
if (!fileResult) {
fileResult = { filePath: absPath, relativePath: relPath, matches: [] }
fileMap.set(absPath, fileResult)
}
// Find all match positions within the line
matchRegex.lastIndex = 0
let m: RegExpExecArray | null
while ((m = matchRegex.exec(lineContent)) !== null) {
fileResult.matches.push({
line: lineNum,
column: m.index + 1,
matchLength: m[0].length,
lineContent
})
totalMatches++
if (totalMatches >= maxResults) {
truncated = true
child.kill()
break
}
// Prevent infinite loop on zero-length regex matches
if (m[0].length === 0) {
matchRegex.lastIndex++
}
const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, maxResults)
if (verdict === 'stop') {
child.kill()
}
}
@ -213,7 +71,7 @@ export function searchWithGitGrep(
})
const killTimeout = setTimeout(() => {
truncated = true
acc.truncated = true
child.kill()
}, SEARCH_TIMEOUT_MS)
})

View File

@ -1,7 +1,7 @@
/* eslint-disable max-lines */
import { ipcMain, shell } from 'electron'
import { readdir, readFile, writeFile, stat, lstat } from 'fs/promises'
import { extname, relative } from 'path'
import { extname } from 'path'
import type { ChildProcess } from 'child_process'
import { wslAwareSpawn } from '../git/runner'
import { parseWslPath, toWindowsWslPath } from '../wsl'
@ -13,9 +13,16 @@ import type {
GitDiffResult,
GitStatusResult,
SearchOptions,
SearchResult,
SearchFileResult
SearchResult
} from '../../shared/types'
import {
buildRgArgs,
createAccumulator,
DEFAULT_SEARCH_MAX_RESULTS,
finalize,
ingestRgJsonLine,
SEARCH_TIMEOUT_MS
} from '../../shared/text-search'
import {
getStatus,
detectConflictOperation,
@ -53,9 +60,6 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
// so 50MB covers real-world PDFs (specs, datasheets, image-heavy contracts).
// See src/relay/fs-handler-utils.ts for the remote-side reasoning.
const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB
const DEFAULT_SEARCH_MAX_RESULTS = 2000
const MAX_MATCHES_PER_FILE = 100
const SEARCH_TIMEOUT_MS = 15000
const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
@ -68,10 +72,6 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.pdf': 'application/pdf'
}
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
/**
* Check if a buffer appears to be binary (contains null bytes in first 8KB).
*/
@ -284,44 +284,7 @@ export function registerFilesystemHandlers(store: Store): void {
}
return new Promise((resolvePromise) => {
const rgArgs: string[] = [
'--json',
'--hidden',
'--glob',
'!.git',
'--max-count',
String(MAX_MATCHES_PER_FILE),
'--max-filesize',
`${Math.floor(MAX_FILE_SIZE / 1024 / 1024)}M`
]
if (!args.caseSensitive) {
rgArgs.push('--ignore-case')
}
if (args.wholeWord) {
rgArgs.push('--word-regexp')
}
if (!args.useRegex) {
rgArgs.push('--fixed-strings')
}
if (args.includePattern) {
for (const pat of args.includePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
rgArgs.push('--glob', pat)
}
}
if (args.excludePattern) {
for (const pat of args.excludePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
rgArgs.push('--glob', `!${pat}`)
}
}
rgArgs.push('--', args.query, rootPath)
const rgArgs = buildRgArgs(args.query, rootPath, args)
// Why: search requests are fired on each query/options change. If the
// previous ripgrep process keeps running, it can continue streaming and
@ -330,13 +293,19 @@ export function registerFilesystemHandlers(store: Store): void {
// experience in large repos.
activeTextSearches.get(searchKey)?.kill()
const fileMap = new Map<string, SearchFileResult>()
let totalMatches = 0
let truncated = false
const acc = createAccumulator()
let stdoutBuffer = ''
let resolved = false
let child: ChildProcess | null = null
// Why: when rg runs inside WSL, output paths are Linux-native
// (e.g. /home/user/repo/src/file.ts). Translate them back to
// Windows UNC paths so path.relative() and Node fs APIs work.
const wslInfo = parseWslPath(rootPath)
const transformAbsPath = wslInfo
? (p: string): string => toWindowsWslPath(p, wslInfo.distro)
: undefined
const resolveOnce = (): void => {
if (resolved) {
return
@ -346,56 +315,13 @@ export function registerFilesystemHandlers(store: Store): void {
activeTextSearches.delete(searchKey)
}
clearTimeout(killTimeout)
resolvePromise({
files: Array.from(fileMap.values()),
totalMatches,
truncated
})
resolvePromise(finalize(acc))
}
const processLine = (line: string): void => {
if (!line || totalMatches >= maxResults) {
return
}
try {
const msg = JSON.parse(line)
if (msg.type !== 'match') {
return
}
const data = msg.data
// Why: when rg runs inside WSL, output paths are Linux-native
// (e.g. /home/user/repo/src/file.ts). Translate them back to
// Windows UNC paths so path.relative() and Node fs APIs work.
const wslInfo = parseWslPath(rootPath)
const absPath: string = wslInfo
? toWindowsWslPath(data.path.text, wslInfo.distro)
: data.path.text
const relPath = normalizeRelativePath(relative(rootPath, absPath))
let fileResult = fileMap.get(absPath)
if (!fileResult) {
fileResult = { filePath: absPath, relativePath: relPath, matches: [] }
fileMap.set(absPath, fileResult)
}
for (const sub of data.submatches) {
fileResult.matches.push({
line: data.line_number,
column: sub.start + 1,
matchLength: sub.end - sub.start,
lineContent: data.lines.text.replace(/\n$/, '')
})
totalMatches++
if (totalMatches >= maxResults) {
truncated = true
child?.kill()
break
}
}
} catch {
// skip malformed JSON lines
const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath)
if (verdict === 'stop') {
child?.kill()
}
}
@ -433,7 +359,7 @@ export function registerFilesystemHandlers(store: Store): void {
// Why: if the timeout fires, the child is killed and results are partial.
// We must mark them as truncated so the UI can indicate incomplete results.
const killTimeout = setTimeout(() => {
truncated = true
acc.truncated = true
child?.kill()
}, SEARCH_TIMEOUT_MS)
})

View File

@ -10,7 +10,8 @@ import type {
FsChangedPayload,
GhosttyImportPreview,
MemorySnapshot,
NotificationDispatchResult
NotificationDispatchResult,
SearchResult
} from '../shared/types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
import type { RateLimitState } from '../shared/rate-limit-types'
@ -1072,15 +1073,7 @@ const api = {
excludePattern?: string
maxResults?: number
connectionId?: string
}): Promise<{
files: {
filePath: string
relativePath: string
matches: { line: number; column: number; matchLength: number; lineContent: string }[]
}[]
totalMatches: number
truncated: boolean
}> => ipcRenderer.invoke('fs:search', args),
}): Promise<SearchResult> => ipcRenderer.invoke('fs:search', args),
importExternalPaths: (args: {
sourcePaths: string[]
destDir: string

View File

@ -6,29 +6,21 @@
* and git grep as universal fallbacks git is always available since this is
* a git-focused app.
*/
import { join } from 'path'
import { spawn } from 'child_process'
import { SEARCH_TIMEOUT_MS, type SearchOptions, type SearchResult } from './fs-handler-utils'
import { type SearchOptions, type SearchResult } from './fs-handler-utils'
import {
buildGitLsFilesArgsForQuickOpen,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../shared/quick-open-filter'
const REGEX_SPECIAL = '.*+?^${}()|[]\\'
function escapeRegexSource(str: string): string {
let out = ''
for (let i = 0; i < str.length; i++) {
out += REGEX_SPECIAL.includes(str[i]) ? `\\${str[i]}` : str[i]
}
return out
}
function toGitGlobPathspec(glob: string, exclude?: boolean): string {
const needsRecursive = !glob.includes('/')
const pattern = needsRecursive ? `**/${glob}` : glob
return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}`
}
import {
buildGitGrepArgs,
buildSubmatchRegex,
createAccumulator,
finalize,
ingestGitGrepLine,
SEARCH_TIMEOUT_MS
} from '../shared/text-search'
/**
* List files using `git ls-files`. Fallback when rg is not installed.
@ -123,17 +115,6 @@ export function listFilesWithGit(
return Promise.all([runGitLsFiles(primary), runGitLsFiles(envPass)]).then(() => Array.from(files))
}
type FileResult = {
filePath: string
relativePath: string
matches: {
line: number
column: number
matchLength: number
lineContent: string
}[]
}
/**
* Text search using `git grep`. Fallback when rg is not installed.
*/
@ -143,125 +124,25 @@ export function searchWithGitGrep(
opts: SearchOptions
): Promise<SearchResult> {
return new Promise((resolve) => {
const gitArgs: string[] = [
'-c',
'submodule.recurse=false',
'grep',
'-n',
'-I',
'--null',
'--no-color',
'--untracked'
]
if (!opts.caseSensitive) {
gitArgs.push('-i')
}
if (opts.wholeWord) {
gitArgs.push('-w')
}
if (!opts.useRegex) {
gitArgs.push('--fixed-strings')
} else {
gitArgs.push('--extended-regexp')
}
gitArgs.push('-e', query, '--')
let hasPathspecs = false
if (opts.includePattern) {
for (const pat of opts.includePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
gitArgs.push(toGitGlobPathspec(pat))
hasPathspecs = true
}
}
if (opts.excludePattern) {
for (const pat of opts.excludePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
gitArgs.push(toGitGlobPathspec(pat, true))
hasPathspecs = true
}
}
if (!hasPathspecs) {
gitArgs.push('.')
}
const fileMap = new Map<string, FileResult>()
let totalMatches = 0
let truncated = false
const gitArgs = buildGitGrepArgs(query, opts)
const matchRegex = buildSubmatchRegex(query, opts)
const acc = createAccumulator()
let stdoutBuffer = ''
let done = false
let pattern = opts.useRegex ? query : escapeRegexSource(query)
if (opts.wholeWord) {
pattern = `\\b${pattern}\\b`
}
const matchRegex = new RegExp(pattern, `g${opts.caseSensitive ? '' : 'i'}`)
const resolveOnce = (): void => {
if (done) {
return
}
done = true
clearTimeout(killTimeout)
resolve({ files: Array.from(fileMap.values()), totalMatches, truncated })
resolve(finalize(acc))
}
const processLine = (line: string): void => {
if (!line || totalMatches >= opts.maxResults) {
return
}
const nullIdx = line.indexOf('\0')
if (nullIdx === -1) {
return
}
const relPath = line
.substring(0, nullIdx)
.replace(/[\\/]+/g, '/')
.replace(/^\/+/, '')
const rest = line.substring(nullIdx + 1)
const colonIdx = rest.indexOf(':')
if (colonIdx === -1) {
return
}
const lineNum = parseInt(rest.substring(0, colonIdx), 10)
if (isNaN(lineNum)) {
return
}
const lineContent = rest.substring(colonIdx + 1).replace(/\n$/, '')
const absPath = join(rootPath, relPath)
let fileResult = fileMap.get(absPath)
if (!fileResult) {
fileResult = { filePath: absPath, relativePath: relPath, matches: [] }
fileMap.set(absPath, fileResult)
}
matchRegex.lastIndex = 0
let m: RegExpExecArray | null
while ((m = matchRegex.exec(lineContent)) !== null) {
fileResult.matches.push({
line: lineNum,
column: m.index + 1,
matchLength: m[0].length,
lineContent
})
totalMatches++
if (totalMatches >= opts.maxResults) {
truncated = true
child.kill()
break
}
if (m[0].length === 0) {
matchRegex.lastIndex++
}
const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, opts.maxResults)
if (verdict === 'stop') {
child.kill()
}
}
@ -290,7 +171,7 @@ export function searchWithGitGrep(
})
const killTimeout = setTimeout(() => {
truncated = true
acc.truncated = true
child.kill()
}, SEARCH_TIMEOUT_MS)
})

View File

@ -5,16 +5,22 @@
* These functions depend only on their arguments (plus `rg` being on PATH),
* so they are straightforward to test independently.
*/
import { relative } from 'path'
import { execFile, type ChildProcess } from 'child_process'
import { spawn, execFile } from 'child_process'
import {
buildRgArgs,
createAccumulator,
finalize,
ingestRgJsonLine,
SEARCH_TIMEOUT_MS as SHARED_SEARCH_TIMEOUT_MS
} from '../shared/text-search'
import type { SearchResult as SharedSearchResult } from '../shared/types'
// ─── Constants ───────────────────────────────────────────────────────
export const MAX_FILE_SIZE = 5 * 1024 * 1024
// 10MB for relayed binaries (base64 → ~13.3MB frame payload at 16MB relay cap)
export const MAX_PREVIEWABLE_BINARY_SIZE = 10 * 1024 * 1024
export const SEARCH_TIMEOUT_MS = 15_000
export const MAX_MATCHES_PER_FILE = 100
export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS
export const DEFAULT_MAX_RESULTS = 2000
export const IMAGE_MIME_TYPES: Record<string, string> = {
@ -52,28 +58,19 @@ export type SearchOptions = {
maxResults: number
}
type FileResult = {
filePath: string
relativePath: string
matches: {
line: number
column: number
matchLength: number
lineContent: string
}[]
}
export type SearchResult = {
files: FileResult[]
totalMatches: number
truncated: boolean
}
export type SearchResult = SharedSearchResult
// ─── rg-based search ─────────────────────────────────────────────────
/**
* Run ripgrep (`rg`) with JSON output to collect text matches.
* Returns a structured result that the relay can send to the client.
*
* Why `spawn` and not `execFile`: `execFile` buffers stdout internally and
* kills the child when `maxBuffer` is exceeded, even when 'data' listeners
* are attached. Under rg's verbose `--json` output, a 50MB buffer fills
* well before the match cap in large folders, and `execFile`'s silent
* buffer-exceeded error resolves the result as `truncated: false` despite
* dropping matches. See docs/design/share-text-search.md.
*/
export function searchWithRg(
rootPath: string,
@ -81,65 +78,40 @@ export function searchWithRg(
opts: SearchOptions
): Promise<SearchResult> {
return new Promise((resolve) => {
const rgArgs = [
'--json',
'--hidden',
'--glob',
'!.git',
'--max-count',
String(MAX_MATCHES_PER_FILE),
'--max-filesize',
`${Math.floor(MAX_FILE_SIZE / 1024 / 1024)}M`
]
if (!opts.caseSensitive) {
rgArgs.push('--ignore-case')
}
if (opts.wholeWord) {
rgArgs.push('--word-regexp')
}
if (!opts.useRegex) {
rgArgs.push('--fixed-strings')
}
if (opts.includePattern) {
for (const p of opts.includePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
rgArgs.push('--glob', p)
}
}
if (opts.excludePattern) {
for (const p of opts.excludePattern
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
rgArgs.push('--glob', `!${p}`)
}
}
rgArgs.push('--', query, rootPath)
const fileMap = new Map<string, FileResult>()
let totalMatches = 0
let truncated = false
const rgArgs = buildRgArgs(query, rootPath, opts)
const acc = createAccumulator()
let buffer = ''
let resolved = false
let child: ChildProcess | null = null
const resolveOnce = () => {
// Why: spawn can throw synchronously on invalid options (e.g. bad cwd),
// which would leak out of the `new Promise` executor and leave the
// promise forever pending. Treat a synchronous throw as a clean
// "no results" fallback, the same way an async 'error' event is handled.
let child: ReturnType<typeof spawn>
try {
child = spawn('rg', rgArgs, {
cwd: rootPath,
stdio: ['ignore', 'pipe', 'pipe']
})
} catch {
resolve(finalize(acc))
return
}
const resolveOnce = (): void => {
if (resolved) {
return
}
resolved = true
clearTimeout(killTimeout)
resolve({ files: Array.from(fileMap.values()), totalMatches, truncated })
resolve(finalize(acc))
}
try {
child = execFile('rg', rgArgs, { maxBuffer: 50 * 1024 * 1024 })
} catch {
resolve({ files: [], totalMatches: 0, truncated: false })
return
const processLine = (line: string): void => {
const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults)
if (verdict === 'stop') {
child.kill()
}
}
child.stdout!.setEncoding('utf-8')
@ -148,40 +120,7 @@ export function searchWithRg(
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line || totalMatches >= opts.maxResults) {
continue
}
try {
const msg = JSON.parse(line)
if (msg.type !== 'match') {
continue
}
const data = msg.data
const absPath = data.path.text as string
const relPath = relative(rootPath, absPath).replace(/\\/g, '/')
let fileResult = fileMap.get(absPath)
if (!fileResult) {
fileResult = { filePath: absPath, relativePath: relPath, matches: [] }
fileMap.set(absPath, fileResult)
}
for (const sub of data.submatches) {
fileResult.matches.push({
line: data.line_number,
column: sub.start + 1,
matchLength: sub.end - sub.start,
lineContent: data.lines.text.replace(/\n$/, '')
})
totalMatches++
if (totalMatches >= opts.maxResults) {
truncated = true
child?.kill()
break
}
}
} catch {
/* skip malformed */
}
processLine(line)
}
})
child.stderr!.on('data', () => {
@ -189,43 +128,15 @@ export function searchWithRg(
})
child.once('error', () => resolveOnce())
child.once('close', () => {
if (buffer && totalMatches < opts.maxResults) {
try {
const msg = JSON.parse(buffer)
if (msg.type === 'match') {
const data = msg.data
const absPath = data.path.text as string
const relPath = relative(rootPath, absPath).replace(/\\/g, '/')
let fileResult = fileMap.get(absPath)
if (!fileResult) {
fileResult = { filePath: absPath, relativePath: relPath, matches: [] }
fileMap.set(absPath, fileResult)
}
for (const sub of data.submatches) {
fileResult.matches.push({
line: data.line_number,
column: sub.start + 1,
matchLength: sub.end - sub.start,
lineContent: data.lines.text.replace(/\n$/, '')
})
totalMatches++
if (totalMatches >= opts.maxResults) {
truncated = true
break
}
}
}
} catch {
/* skip malformed */
}
if (buffer) {
processLine(buffer)
}
resolveOnce()
})
const killTimeout = setTimeout(() => {
truncated = true
child?.kill()
acc.truncated = true
child.kill()
}, SEARCH_TIMEOUT_MS)
})
}

View File

@ -130,8 +130,8 @@ export function MatchResultRow({
// Highlight the matched text within the line
const parts = useMemo(() => {
const content = match.lineContent
const col = match.column - 1 // convert to 0-indexed
const len = match.matchLength
const col = (match.displayColumn ?? match.column) - 1 // convert to 0-indexed
const len = match.displayMatchLength ?? match.matchLength
if (col >= 0 && col + len <= content.length) {
// Why: left-truncate the pre-match text so the highlight stays visible at
@ -153,7 +153,13 @@ export function MatchResultRow({
// Fallback
return { before: content, match: '', after: '' }
}, [match.lineContent, match.column, match.matchLength])
}, [
match.lineContent,
match.column,
match.matchLength,
match.displayColumn,
match.displayMatchLength
])
return (
<ContextMenu>

View File

@ -0,0 +1,285 @@
import { describe, expect, it } from 'vitest'
import {
buildGitGrepArgs,
buildRgArgs,
buildSubmatchRegex,
createAccumulator,
finalize,
ingestGitGrepLine,
ingestRgJsonLine,
MAX_LINE_CONTENT_LENGTH,
normalizeRelativePath,
toGitGlobPathspec
} from './text-search'
describe('normalizeRelativePath', () => {
it('collapses mixed separators and strips leading slashes', () => {
expect(normalizeRelativePath('a\\b\\c')).toBe('a/b/c')
expect(normalizeRelativePath('/a/b')).toBe('a/b')
expect(normalizeRelativePath('///a//b')).toBe('a/b')
})
})
describe('buildRgArgs', () => {
it('defaults to case-insensitive, fixed-strings, hidden, exclude .git', () => {
const args = buildRgArgs('needle', '/root', {})
expect(args).toContain('--json')
expect(args).toContain('--hidden')
expect(args).toContain('--ignore-case')
expect(args).toContain('--fixed-strings')
expect(args.indexOf('!.git')).toBeGreaterThan(args.indexOf('--glob'))
expect(args.slice(-3)).toEqual(['--', 'needle', '/root'])
})
it('honors caseSensitive, wholeWord, useRegex', () => {
const args = buildRgArgs('q', '/r', { caseSensitive: true, wholeWord: true, useRegex: true })
expect(args).not.toContain('--ignore-case')
expect(args).toContain('--word-regexp')
expect(args).not.toContain('--fixed-strings')
})
it('splits comma-separated include/exclude patterns', () => {
const args = buildRgArgs('q', '/r', { includePattern: '*.ts, *.tsx', excludePattern: '*.md' })
expect(args).toContain('*.ts')
expect(args).toContain('*.tsx')
expect(args).toContain('!*.md')
})
})
describe('ingestRgJsonLine', () => {
const makeMatch = (path: string, line: number, subs: { start: number; end: number }[], text = 'abc') =>
JSON.stringify({
type: 'match',
data: {
path: { text: path },
line_number: line,
lines: { text: `${text}\n` },
submatches: subs
}
})
it('populates accumulator for a match', () => {
const acc = createAccumulator()
const verdict = ingestRgJsonLine(
makeMatch('/root/src/a.ts', 2, [{ start: 0, end: 3 }]),
'/root',
acc,
100
)
expect(verdict).toBe('continue')
expect(acc.totalMatches).toBe(1)
const files = Array.from(acc.fileMap.values())
expect(files[0].relativePath).toBe('src/a.ts')
expect(files[0].matches[0]).toEqual({ line: 2, column: 1, matchLength: 3, lineContent: 'abc' })
})
it('ignores non-match messages', () => {
const acc = createAccumulator()
ingestRgJsonLine(JSON.stringify({ type: 'begin', data: {} }), '/root', acc, 100)
expect(acc.totalMatches).toBe(0)
})
it('skips malformed JSON', () => {
const acc = createAccumulator()
const verdict = ingestRgJsonLine('not json', '/root', acc, 100)
expect(verdict).toBe('continue')
expect(acc.totalMatches).toBe(0)
})
it('stops at maxResults and sets truncated synchronously', () => {
const acc = createAccumulator()
const verdict = ingestRgJsonLine(
makeMatch('/root/a.ts', 1, [
{ start: 0, end: 1 },
{ start: 2, end: 3 },
{ start: 4, end: 5 }
]),
'/root',
acc,
2
)
expect(verdict).toBe('stop')
expect(acc.truncated).toBe(true)
expect(acc.totalMatches).toBe(2)
})
it('clamps huge lineContent around the match to bound payload size', () => {
const acc = createAccumulator()
const huge = `${'x'.repeat(200_000)}NEEDLE${'y'.repeat(200_000)}`
const matchStart = 200_000
ingestRgJsonLine(
JSON.stringify({
type: 'match',
data: {
path: { text: '/root/big.js' },
line_number: 1,
lines: { text: `${huge}\n` },
submatches: [{ start: matchStart, end: matchStart + 6 }]
}
}),
'/root',
acc,
100
)
const match = Array.from(acc.fileMap.values())[0].matches[0]
expect(match.lineContent.length).toBeLessThanOrEqual(MAX_LINE_CONTENT_LENGTH + 2)
expect(match.column).toBe(matchStart + 1)
expect(match.matchLength).toBe(6)
expect(match.displayColumn).toBeDefined()
expect(match.displayMatchLength).toBe(6)
// Extracted column should still align with NEEDLE inside the snippet.
const displayColumn = match.displayColumn ?? match.column
const displayMatchLength = match.displayMatchLength ?? match.matchLength
const sliced = match.lineContent.slice(
displayColumn - 1,
displayColumn - 1 + displayMatchLength
)
expect(sliced).toBe('NEEDLE')
})
it('applies transformAbsPath (WSL translation)', () => {
const acc = createAccumulator()
ingestRgJsonLine(
makeMatch('/home/u/repo/a.ts', 1, [{ start: 0, end: 1 }]),
'\\\\wsl$\\Ubuntu\\home\\u\\repo',
acc,
100,
(p) => p.replace('/home/u/repo', '\\\\wsl$\\Ubuntu\\home\\u\\repo')
)
const files = Array.from(acc.fileMap.values())
// Transform replaces the root prefix; tail separators are as rg emitted them.
expect(files[0].filePath).toBe('\\\\wsl$\\Ubuntu\\home\\u\\repo/a.ts')
})
})
describe('buildGitGrepArgs', () => {
it('adds -i, --fixed-strings and `.` default pathspec', () => {
const args = buildGitGrepArgs('q', {})
expect(args).toContain('-i')
expect(args).toContain('--fixed-strings')
expect(args).toContain('--no-recurse-submodules')
expect(args.at(-1)).toBe('.')
})
it('uses --extended-regexp when useRegex is true', () => {
const args = buildGitGrepArgs('q', { useRegex: true })
expect(args).toContain('--extended-regexp')
expect(args).not.toContain('--fixed-strings')
})
it('includes/excludes use :(glob) pathspecs', () => {
const args = buildGitGrepArgs('q', { includePattern: '*.ts', excludePattern: 'dist/**' })
expect(args).toContain(':(glob)**/*.ts')
expect(args).toContain(':(exclude,glob)dist/**')
})
})
describe('toGitGlobPathspec', () => {
it('wraps bare globs with **/ to match recursively', () => {
expect(toGitGlobPathspec('*.ts')).toBe(':(glob)**/*.ts')
expect(toGitGlobPathspec('src/*.ts')).toBe(':(glob)src/*.ts')
expect(toGitGlobPathspec('*.ts', true)).toBe(':(exclude,glob)**/*.ts')
})
})
describe('buildSubmatchRegex', () => {
it('escapes literal query by default, gi flags', () => {
const re = buildSubmatchRegex('a.b', {})
expect(re?.flags).toBe('gi')
expect(re?.source).toBe('a\\.b')
})
it('wholeWord wraps in \\b', () => {
const re = buildSubmatchRegex('foo', { wholeWord: true })
expect(re?.source).toBe('\\bfoo\\b')
})
it('useRegex passes through', () => {
const re = buildSubmatchRegex('a|b', { useRegex: true, caseSensitive: true })
expect(re?.source).toBe('a|b')
expect(re?.flags).toBe('g')
})
it('returns null for queries git grep accepts but JS RegExp rejects', () => {
// Unbalanced group — git grep ERE accepts it as literal, JS RegExp throws.
expect(buildSubmatchRegex('(foo', { useRegex: true })).toBeNull()
// Unterminated character class.
expect(buildSubmatchRegex('[abc', { useRegex: true })).toBeNull()
})
})
describe('ingestGitGrepLine', () => {
it('parses null-byte delimited line, finds all submatch positions', () => {
const acc = createAccumulator()
const re = buildSubmatchRegex('foo', {})
const verdict = ingestGitGrepLine('src/a.ts\x005:foo and foo again\n', '/root', re, acc, 100)
expect(verdict).toBe('continue')
const f = Array.from(acc.fileMap.values())[0]
expect(f.matches).toHaveLength(2)
expect(f.matches[0]).toMatchObject({ line: 5, column: 1 })
expect(f.matches[1]).toMatchObject({ line: 5, column: 9 })
})
it('handles colons in filenames via null delimiter', () => {
const acc = createAccumulator()
const re = buildSubmatchRegex('x', {})
ingestGitGrepLine('weird:name.ts\x001:x', '/root', re, acc, 100)
const f = Array.from(acc.fileMap.values())[0]
expect(f.relativePath).toBe('weird:name.ts')
})
it('skips malformed lines (no null, no colon, bad line num)', () => {
const acc = createAccumulator()
const re = buildSubmatchRegex('q', {})
ingestGitGrepLine('no-null-byte', '/r', re, acc, 100)
ingestGitGrepLine('a.ts\x00no-colon', '/r', re, acc, 100)
ingestGitGrepLine('a.ts\x00NaN:content', '/r', re, acc, 100)
expect(acc.totalMatches).toBe(0)
})
it('zero-length regex does not loop', () => {
const acc = createAccumulator()
// A pattern that matches zero-length at every position.
const re = new RegExp('', 'g')
ingestGitGrepLine('a.ts\x001:abc', '/r', re, acc, 5)
expect(acc.totalMatches).toBeGreaterThan(0)
expect(acc.totalMatches).toBeLessThanOrEqual(5)
})
it('stops at maxResults boundary and sets truncated synchronously', () => {
const acc = createAccumulator()
const re = buildSubmatchRegex('a', {})
const verdict = ingestGitGrepLine('f\x001:aaaa', '/r', re, acc, 2)
expect(verdict).toBe('stop')
expect(acc.truncated).toBe(true)
expect(acc.totalMatches).toBe(2)
})
it('falls back to whole-line highlight when submatchRegex is null', () => {
const acc = createAccumulator()
const verdict = ingestGitGrepLine('a.ts\x003:hello world', '/r', null, acc, 100)
expect(verdict).toBe('continue')
const f = Array.from(acc.fileMap.values())[0]
expect(f.matches).toHaveLength(1)
expect(f.matches[0]).toMatchObject({
line: 3,
column: 1,
matchLength: 'hello world'.length,
lineContent: 'hello world'
})
})
})
describe('finalize', () => {
it('returns the expected SearchResult shape', () => {
const acc = createAccumulator()
acc.fileMap.set('/r/a.ts', { filePath: '/r/a.ts', relativePath: 'a.ts', matches: [] })
acc.totalMatches = 3
acc.truncated = true
expect(finalize(acc)).toEqual({
files: [{ filePath: '/r/a.ts', relativePath: 'a.ts', matches: [] }],
totalMatches: 3,
truncated: true
})
})
})

433
src/shared/text-search.ts Normal file
View File

@ -0,0 +1,433 @@
/**
* Shared, pure text-search helpers used by both the local main process and the
* SSH relay. No Electron, no child_process, no fs the caller owns process
* execution and transport-specific path translation (WSL).
*
* Why this module exists (design doc: docs/design/share-text-search.md):
* Before extraction, the local (`src/main/ipc/filesystem.ts`,
* `filesystem-search-git.ts`) and relay (`src/relay/fs-handler-utils.ts`,
* `fs-handler-git-fallback.ts`) search implementations had diverged on
* rg arg construction, rg --json parsing, the git-grep submatch regex,
* relative-path normalization, and most consequentially the relay's
* `execFile` + `maxBuffer: 50MB` footgun that silently dropped matches on
* large repos. Centralizing the policy prevents future drift. Both call
* sites must use this module; see filesystem.ts and relay/fs-handler.ts.
*/
import { join, relative } from 'path'
import type {
SearchFileResult,
SearchOptions,
SearchResult
} from './types'
export type SearchAccumulator = {
fileMap: Map<string, SearchFileResult>
totalMatches: number
truncated: boolean
}
export function createAccumulator(): SearchAccumulator {
return { fileMap: new Map(), totalMatches: 0, truncated: false }
}
// Why: collapse mixed separators and strip leading slashes so results are
// stable across Windows/Linux and never start with `/` (which would break
// `join(rootPath, relPath)` in callers).
export function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
// ─── Constants shared by both callers ────────────────────────────────
export const MAX_MATCHES_PER_FILE = 100
export const DEFAULT_SEARCH_MAX_RESULTS = 2000
export const SEARCH_TIMEOUT_MS = 15_000
// Why: the text-search path only reads files up to 5MB — this mirrors both
// the local MAX_FILE_SIZE and the relay's MAX_FILE_SIZE. Kept here so the rg
// `--max-filesize` flag stays in sync across callers.
const SEARCH_MAX_FILE_SIZE = 5 * 1024 * 1024
// Why: `lineContent` is carried per-match to the renderer. Minified bundles
// and generated files can have single lines in the megabytes; at 2000-match
// caps that serializes into frames that blow past the 16MB SSH relay
// `MAX_MESSAGE_SIZE`, producing "Message too large" errors on fs:search for
// large folders (and bloats local IPC unnecessarily). Clamping to a window
// around each match keeps the payload bounded while preserving enough
// context for the sidebar to render the highlight.
export const MAX_LINE_CONTENT_LENGTH = 500
const TRUNCATION_MARKER = '…'
function clampLineContext(
text: string,
matchStart: number,
matchLength: number
): {
lineContent: string
column: number
matchLength: number
displayColumn?: number
displayMatchLength?: number
} {
if (text.length <= MAX_LINE_CONTENT_LENGTH) {
return { lineContent: text, column: matchStart + 1, matchLength }
}
// Clamp the match itself first so a pathological multi-MB regex hit
// cannot defeat the windowing below.
const clampedMatchLength = Math.min(matchLength, MAX_LINE_CONTENT_LENGTH)
const remaining = MAX_LINE_CONTENT_LENGTH - clampedMatchLength
const leftBudget = Math.floor(remaining / 2)
let windowStart = Math.max(0, matchStart - leftBudget)
let windowEnd = Math.min(text.length, windowStart + MAX_LINE_CONTENT_LENGTH)
windowStart = Math.max(0, windowEnd - MAX_LINE_CONTENT_LENGTH)
let snippet = text.slice(windowStart, windowEnd)
let column = matchStart - windowStart + 1
if (windowStart > 0) {
snippet = TRUNCATION_MARKER + snippet
column += TRUNCATION_MARKER.length
}
if (windowEnd < text.length) {
snippet = snippet + TRUNCATION_MARKER
}
return {
lineContent: snippet,
column: matchStart + 1,
matchLength,
displayColumn: column,
displayMatchLength: clampedMatchLength
}
}
// ─── rg ─────────────────────────────────────────────────────────────
export type SearchOptionsLike = Pick<
SearchOptions,
'caseSensitive' | 'wholeWord' | 'useRegex' | 'includePattern' | 'excludePattern'
>
/**
* Build the rg argv used by both callers. The returned array is the COMPLETE
* argv (flags + `--` + query + target); the caller spawns rg with it as-is.
*
* Both callers pass `rootPath` unchanged as `target` do NOT translate the
* target to a WSL-native path on the local side. On Windows/WSL, only the
* rg *invocation* is routed through `wslAwareSpawn`; the target string keeps
* its original shape, and rg's output paths are translated back to Windows
* UNC via the `transformAbsPath` callback in `ingestRgJsonLine`.
*/
export function buildRgArgs(
query: string,
target: string,
opts: SearchOptionsLike
): string[] {
const args: string[] = [
'--json',
'--hidden',
'--glob',
'!.git',
'--max-count',
String(MAX_MATCHES_PER_FILE),
'--max-filesize',
`${Math.floor(SEARCH_MAX_FILE_SIZE / 1024 / 1024)}M`
]
if (!opts.caseSensitive) {
args.push('--ignore-case')
}
if (opts.wholeWord) {
args.push('--word-regexp')
}
if (!opts.useRegex) {
args.push('--fixed-strings')
}
if (opts.includePattern) {
for (const pat of opts.includePattern.split(',').map((s) => s.trim()).filter(Boolean)) {
args.push('--glob', pat)
}
}
if (opts.excludePattern) {
for (const pat of opts.excludePattern.split(',').map((s) => s.trim()).filter(Boolean)) {
args.push('--glob', `!${pat}`)
}
}
args.push('--', query, target)
return args
}
/**
* Ingest a single line of rg `--json` stdout. Mutates `acc`. Returns 'stop'
* when `maxResults` is reached so the caller can kill the child; 'continue'
* otherwise. `transformAbsPath` lets the local caller apply WSL translation
* (parseWslPath + toWindowsWslPath); the relay passes no transform.
*
* Truncation ordering invariant (see design doc): this function sets
* `acc.truncated = true` SYNCHRONOUSLY in the same tick it returns 'stop',
* before any child-kill. Callers must NOT flip `truncated` in their own
* code and must NOT resolve the promise before the 'stop'-return tick has
* completed. Breaking that ordering re-introduces the silent-truncation
* bug the relay had with execFile's maxBuffer overflow.
*/
export function ingestRgJsonLine(
line: string,
rootPath: string,
acc: SearchAccumulator,
maxResults: number,
transformAbsPath?: (p: string) => string
): 'continue' | 'stop' {
if (acc.totalMatches >= maxResults) {
return 'stop'
}
if (!line) {
return 'continue'
}
let msg: { type?: string; data?: { path?: { text?: string }; submatches?: { start: number; end: number }[]; line_number?: number; lines?: { text?: string } } }
try {
msg = JSON.parse(line)
} catch {
return 'continue'
}
if (msg.type !== 'match' || !msg.data) {
return 'continue'
}
const data = msg.data
const rawPath = data.path?.text
if (typeof rawPath !== 'string') {
return 'continue'
}
const absPath = transformAbsPath ? transformAbsPath(rawPath) : rawPath
const relPath = normalizeRelativePath(relative(rootPath, absPath))
const lineContent = (data.lines?.text ?? '').replace(/\n$/, '')
const lineNumber = data.line_number ?? 0
const submatches = data.submatches ?? []
let fileResult = acc.fileMap.get(absPath)
if (!fileResult) {
fileResult = { filePath: absPath, relativePath: relPath, matches: [] }
acc.fileMap.set(absPath, fileResult)
}
for (const sub of submatches) {
const clamped = clampLineContext(lineContent, sub.start, sub.end - sub.start)
fileResult.matches.push({
line: lineNumber,
column: clamped.column,
matchLength: clamped.matchLength,
lineContent: clamped.lineContent,
...(clamped.displayColumn !== undefined ? { displayColumn: clamped.displayColumn } : {}),
...(clamped.displayMatchLength !== undefined
? { displayMatchLength: clamped.displayMatchLength }
: {})
})
acc.totalMatches++
if (acc.totalMatches >= maxResults) {
acc.truncated = true
return 'stop'
}
}
return 'continue'
}
// ─── git grep ───────────────────────────────────────────────────────
// Why: esbuild's parser chokes on regex literals containing brace/bracket
// character classes, so we escape special chars with a simple loop.
const REGEX_SPECIAL = '.*+?^${}()|[]\\'
function escapeRegexSource(str: string): string {
let out = ''
for (let i = 0; i < str.length; i++) {
out += REGEX_SPECIAL.includes(str[i]) ? `\\${str[i]}` : str[i]
}
return out
}
/**
* Convert a user-facing glob pattern into a git pathspec.
*
* Why: rg globs like `*.ts` match at any directory depth, but a bare git
* pathspec `*.ts` only matches in the repo root. Wrapping with `:(glob)` and
* prepending `**\/` for patterns without a path separator replicates rg's
* recursive-by-default behaviour.
*/
export function toGitGlobPathspec(glob: string, exclude?: boolean): string {
const needsRecursive = !glob.includes('/')
const pattern = needsRecursive ? `**/${glob}` : glob
return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}`
}
export function buildGitGrepArgs(query: string, opts: SearchOptionsLike): string[] {
// Why: --untracked searches untracked (but not ignored) files in addition
// to tracked ones, matching rg's default behaviour of respecting gitignore.
// -I skips binary files; --null uses \0 as filename delimiter so filenames
// with colons parse unambiguously. --no-recurse-submodules is needed because
// users may have submodule.recurse=true in their git config, which conflicts
// with --untracked and would cause git grep to fail.
const gitArgs: string[] = [
'-c',
'submodule.recurse=false',
'grep',
'-n',
'-I',
'--null',
'--no-color',
'--untracked',
'--no-recurse-submodules'
]
if (!opts.caseSensitive) {
gitArgs.push('-i')
}
if (opts.wholeWord) {
gitArgs.push('-w')
}
if (!opts.useRegex) {
gitArgs.push('--fixed-strings')
} else {
gitArgs.push('--extended-regexp')
}
gitArgs.push('-e', query, '--')
let hasPathspecs = false
if (opts.includePattern) {
for (const pat of opts.includePattern.split(',').map((s) => s.trim()).filter(Boolean)) {
gitArgs.push(toGitGlobPathspec(pat))
hasPathspecs = true
}
}
if (opts.excludePattern) {
for (const pat of opts.excludePattern.split(',').map((s) => s.trim()).filter(Boolean)) {
gitArgs.push(toGitGlobPathspec(pat, true))
hasPathspecs = true
}
}
// Why: when no include patterns are given, git grep needs a pathspec to
// search the working tree. '.' means "everything under cwd".
if (!hasPathspecs) {
gitArgs.push('.')
}
return gitArgs
}
/**
* Build the JS regex used to locate all submatch column positions within a
* matched line. git grep only reports the first hit per line; we need this
* to populate SearchMatch[] for every occurrence.
*
* Returns `null` when `useRegex` is true and the query is valid ERE for git
* grep but not a valid JS `RegExp` (common mismatches: POSIX classes like
* `[[:alpha:]]`, back-reference numbering differences, `\<` / `\>` word
* anchors). Callers fall back to a whole-line highlight so git-reported
* hits still appear in results instead of silently failing the request.
*/
export function buildSubmatchRegex(
query: string,
opts: { useRegex?: boolean; wholeWord?: boolean; caseSensitive?: boolean }
): RegExp | null {
let pattern = opts.useRegex ? query : escapeRegexSource(query)
if (opts.wholeWord) {
pattern = `\\b${pattern}\\b`
}
try {
return new RegExp(pattern, `g${opts.caseSensitive ? '' : 'i'}`)
} catch {
return null
}
}
export function ingestGitGrepLine(
line: string,
rootPath: string,
submatchRegex: RegExp | null,
acc: SearchAccumulator,
maxResults: number
): 'continue' | 'stop' {
if (acc.totalMatches >= maxResults) {
return 'stop'
}
if (!line) {
return 'continue'
}
// Why: with --null -n the output format is filename\0linenum:content.
const nullIdx = line.indexOf('\0')
if (nullIdx === -1) {
return 'continue'
}
const relPath = normalizeRelativePath(line.substring(0, nullIdx))
const rest = line.substring(nullIdx + 1)
const colonIdx = rest.indexOf(':')
if (colonIdx === -1) {
return 'continue'
}
const lineNum = parseInt(rest.substring(0, colonIdx), 10)
if (isNaN(lineNum)) {
return 'continue'
}
const lineContent = rest.substring(colonIdx + 1).replace(/\n$/, '')
const absPath = join(rootPath, relPath)
let fileResult = acc.fileMap.get(absPath)
if (!fileResult) {
fileResult = { filePath: absPath, relativePath: relPath, matches: [] }
acc.fileMap.set(absPath, fileResult)
}
// Why: git grep already confirmed the line matched — if we can't build a
// JS-side regex to find exact submatch positions (e.g. user typed a POSIX
// class that git accepts but JS RegExp rejects), fall back to a single
// whole-line highlight so the result still shows up in the UI.
if (submatchRegex === null) {
const clamped = clampLineContext(lineContent, 0, lineContent.length)
fileResult.matches.push({
line: lineNum,
column: clamped.column,
matchLength: clamped.matchLength,
lineContent: clamped.lineContent,
...(clamped.displayColumn !== undefined ? { displayColumn: clamped.displayColumn } : {}),
...(clamped.displayMatchLength !== undefined
? { displayMatchLength: clamped.displayMatchLength }
: {})
})
acc.totalMatches++
if (acc.totalMatches >= maxResults) {
acc.truncated = true
return 'stop'
}
return 'continue'
}
submatchRegex.lastIndex = 0
let m: RegExpExecArray | null
while ((m = submatchRegex.exec(lineContent)) !== null) {
const clamped = clampLineContext(lineContent, m.index, m[0].length)
fileResult.matches.push({
line: lineNum,
column: clamped.column,
matchLength: clamped.matchLength,
lineContent: clamped.lineContent,
...(clamped.displayColumn !== undefined ? { displayColumn: clamped.displayColumn } : {}),
...(clamped.displayMatchLength !== undefined
? { displayMatchLength: clamped.displayMatchLength }
: {})
})
acc.totalMatches++
if (acc.totalMatches >= maxResults) {
acc.truncated = true
return 'stop'
}
// Prevent infinite loop on zero-length regex matches.
if (m[0].length === 0) {
submatchRegex.lastIndex++
}
}
return 'continue'
}
// ─── finalize ───────────────────────────────────────────────────────
export function finalize(acc: SearchAccumulator): SearchResult {
return {
files: Array.from(acc.fileMap.values()),
totalMatches: acc.totalMatches,
truncated: acc.truncated
}
}

View File

@ -1163,6 +1163,8 @@ export type SearchMatch = {
column: number
matchLength: number
lineContent: string
displayColumn?: number
displayMatchLength?: number
}
export type SearchFileResult = {