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:
parent
afeac870b2
commit
099c4089a7
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1163,6 +1163,8 @@ export type SearchMatch = {
|
|||
column: number
|
||||
matchLength: number
|
||||
lineContent: string
|
||||
displayColumn?: number
|
||||
displayMatchLength?: number
|
||||
}
|
||||
|
||||
export type SearchFileResult = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue