mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix: add git-based fallback for file search on Linux (#257)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
11c65ab3a7
commit
3c36da80a8
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { spawnMock, resolveAuthorizedPathMock } = vi.hoisted(() => ({
|
||||
const { spawnMock, resolveAuthorizedPathMock, checkRgAvailableMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
resolveAuthorizedPathMock: vi.fn()
|
||||
resolveAuthorizedPathMock: vi.fn(),
|
||||
checkRgAvailableMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
@@ -13,6 +14,10 @@ vi.mock('./filesystem-auth', () => ({
|
||||
resolveAuthorizedPath: resolveAuthorizedPathMock
|
||||
}))
|
||||
|
||||
vi.mock('./rg-availability', () => ({
|
||||
checkRgAvailable: checkRgAvailableMock
|
||||
}))
|
||||
|
||||
import { listQuickOpenFiles } from './filesystem-list-files'
|
||||
import { EventEmitter } from 'events'
|
||||
import type { Store } from '../persistence'
|
||||
@@ -36,6 +41,7 @@ describe('filesystem-list-files', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resolveAuthorizedPathMock.mockImplementation(async (path) => path)
|
||||
checkRgAvailableMock.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('merges normal files and env files and filters correctly', async () => {
|
||||
@@ -104,4 +110,115 @@ describe('filesystem-list-files', () => {
|
||||
|
||||
expect(result).toEqual(['valid.ts'])
|
||||
})
|
||||
|
||||
describe('git ls-files fallback', () => {
|
||||
it('falls back to git ls-files when rg is not available', async () => {
|
||||
checkRgAvailableMock.mockResolvedValue(false)
|
||||
|
||||
let callIndex = 0
|
||||
const gitP1 = createMockProcess()
|
||||
const gitP2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'git') {
|
||||
callIndex++
|
||||
return callIndex === 1 ? gitP1 : gitP2
|
||||
}
|
||||
return createMockProcess()
|
||||
})
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
|
||||
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'package.json\n')
|
||||
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'node_modules/dep/index.js\n')
|
||||
gitP1.emit('close')
|
||||
|
||||
;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\n')
|
||||
gitP2.emit('close')
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
|
||||
// Verify rg was never called
|
||||
const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg')
|
||||
expect(rgCalls.length).toBe(0)
|
||||
|
||||
// Verify git ls-files was called
|
||||
const gitCalls = spawnMock.mock.calls.filter((call) => call[0] === 'git')
|
||||
expect(gitCalls.length).toBe(2)
|
||||
expect(gitCalls[0][1]).toContain('ls-files')
|
||||
|
||||
// Should include valid files and filter node_modules
|
||||
expect(result).toContain('src/index.ts')
|
||||
expect(result).toContain('package.json')
|
||||
expect(result).toContain('.env.local')
|
||||
expect(result).not.toContain('node_modules/dep/index.js')
|
||||
})
|
||||
|
||||
it('git fallback applies hidden dir blocklist', async () => {
|
||||
checkRgAvailableMock.mockResolvedValue(false)
|
||||
|
||||
const gitP1 = createMockProcess()
|
||||
const gitP2 = createMockProcess()
|
||||
let callIndex = 0
|
||||
|
||||
spawnMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'git') {
|
||||
callIndex++
|
||||
return callIndex === 1 ? gitP1 : gitP2
|
||||
}
|
||||
return createMockProcess()
|
||||
})
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(gitP1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n')
|
||||
;(gitP1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n')
|
||||
;(gitP1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n')
|
||||
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n')
|
||||
gitP1.emit('close')
|
||||
|
||||
gitP2.emit('close')
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(result).toEqual(['.github/workflows/ci.yml', 'valid.ts'])
|
||||
})
|
||||
|
||||
it('does not fall back to git when rg is available', async () => {
|
||||
checkRgAvailableMock.mockResolvedValue(true)
|
||||
|
||||
const p1 = createMockProcess()
|
||||
const p2 = createMockProcess()
|
||||
|
||||
spawnMock.mockImplementation((_cmd, args: string[]) => {
|
||||
if (args.includes('**/.env*')) {
|
||||
return p2
|
||||
}
|
||||
return p1
|
||||
})
|
||||
|
||||
const storeMock = {} as unknown as Store
|
||||
const promise = listQuickOpenFiles('/mock/root', storeMock)
|
||||
|
||||
setTimeout(() => {
|
||||
;(p1.stdout as unknown as EventEmitter).emit('data', '/mock/root/file.ts\n')
|
||||
p1.emit('close')
|
||||
p2.emit('close')
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(result).toEqual(['file.ts'])
|
||||
// git should never have been called
|
||||
const gitCalls = spawnMock.mock.calls.filter((call) => call[0] === 'git')
|
||||
expect(gitCalls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn } from 'child_process'
|
||||
import { relative, sep } from 'path'
|
||||
import type { Store } from '../persistence'
|
||||
import { resolveAuthorizedPath } from './filesystem-auth'
|
||||
import { checkRgAvailable } from './rg-availability'
|
||||
|
||||
// Why: We use --hidden to surface dotfiles users commonly edit (e.g. .env,
|
||||
// .github workflows, .eslintrc) but must still exclude non-editable hidden
|
||||
@@ -46,6 +47,15 @@ function shouldIncludeQuickOpenPath(path: string): boolean {
|
||||
export async function listQuickOpenFiles(rootPath: string, store: Store): Promise<string[]> {
|
||||
const authorizedRootPath = await resolveAuthorizedPath(rootPath, store)
|
||||
|
||||
// Why: checking rg availability upfront avoids a race condition where
|
||||
// spawn('rg') emits 'close' before 'error' on some platforms, causing
|
||||
// the handler to resolve with empty results before the git fallback
|
||||
// can run. The result is cached after the first check.
|
||||
const rgAvailable = await checkRgAvailable()
|
||||
if (!rgAvailable) {
|
||||
return listFilesWithGit(authorizedRootPath)
|
||||
}
|
||||
|
||||
// Why: We try fast string slicing first (O(1) per file), but fall back to
|
||||
// path.relative() if the rg output doesn't start with the expected prefix.
|
||||
// This handles edge cases where symlinks, bind mounts, Windows junctions,
|
||||
@@ -168,3 +178,85 @@ export async function listQuickOpenFiles(rootPath: string, store: Store): Promis
|
||||
|
||||
return Array.from(files)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback file lister using git ls-files. Used when rg is not available.
|
||||
*
|
||||
* Why two git ls-files calls: the first lists tracked + untracked-but-not-ignored
|
||||
* files (mirrors rg --files --hidden with gitignore respect). The second specifically
|
||||
* surfaces .env* files that are typically gitignored but users frequently need in
|
||||
* quick-open (mirrors the second rg call with --no-ignore-vcs).
|
||||
*/
|
||||
function listFilesWithGit(rootPath: string): Promise<string[]> {
|
||||
const files = new Set<string>()
|
||||
|
||||
const runGitLsFiles = (args: string[]): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
let buf = ''
|
||||
let done = false
|
||||
const finish = (): void => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const processLine = (line: string): void => {
|
||||
if (line.charCodeAt(line.length - 1) === 13 /* \r */) {
|
||||
line = line.substring(0, line.length - 1)
|
||||
}
|
||||
if (!line) {
|
||||
return
|
||||
}
|
||||
if (shouldIncludeQuickOpenPath(line)) {
|
||||
files.add(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: git ls-files outputs paths relative to cwd, so we set cwd to
|
||||
// rootPath and use the output directly — no prefix stripping needed.
|
||||
const child = spawn('git', ['ls-files', ...args], {
|
||||
cwd: rootPath,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
child.stdout.setEncoding('utf-8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
buf += chunk
|
||||
let start = 0
|
||||
let newlineIdx = buf.indexOf('\n', start)
|
||||
while (newlineIdx !== -1) {
|
||||
processLine(buf.substring(start, newlineIdx))
|
||||
start = newlineIdx + 1
|
||||
newlineIdx = buf.indexOf('\n', start)
|
||||
}
|
||||
buf = start < buf.length ? buf.substring(start) : ''
|
||||
})
|
||||
child.stderr.on('data', () => {
|
||||
/* drain */
|
||||
})
|
||||
child.once('error', () => {
|
||||
finish()
|
||||
})
|
||||
child.once('close', () => {
|
||||
if (buf) {
|
||||
processLine(buf)
|
||||
}
|
||||
finish()
|
||||
})
|
||||
const timer = setTimeout(() => child.kill(), 10000)
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
// Why: --cached lists tracked files, --others lists untracked files,
|
||||
// --exclude-standard respects .gitignore. Together this mirrors
|
||||
// rg --files --hidden (which respects gitignore by default).
|
||||
runGitLsFiles(['--cached', '--others', '--exclude-standard']),
|
||||
// Why: surfaces .env* files that are typically gitignored. --others
|
||||
// without --exclude-standard lists all untracked files; the pathspec
|
||||
// restricts output to .env* only. Mirrors the rg --no-ignore-vcs call.
|
||||
runGitLsFiles(['--others', '--', '**/.env*'])
|
||||
]).then(() => Array.from(files))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
import { searchWithGitGrep } from './filesystem-search-git'
|
||||
import { EventEmitter } from 'events'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
|
||||
function createMockProcess(): ChildProcess {
|
||||
const p = new EventEmitter() as unknown as ChildProcess
|
||||
;(p as unknown as Record<string, unknown>).stdout = new EventEmitter()
|
||||
;(
|
||||
(p as unknown as Record<string, unknown>).stdout as EventEmitter & {
|
||||
setEncoding: () => void
|
||||
}
|
||||
).setEncoding = vi.fn()
|
||||
;(p as unknown as Record<string, unknown>).stderr = new EventEmitter()
|
||||
;(p as unknown as Record<string, unknown>).kill = vi.fn()
|
||||
return p
|
||||
}
|
||||
|
||||
describe('filesystem-search-git', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('parses git grep output and finds matches', async () => {
|
||||
const proc = createMockProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = searchWithGitGrep('/mock/root', { query: 'hello', rootPath: '/mock/root' }, 100)
|
||||
|
||||
setTimeout(() => {
|
||||
;(proc.stdout as unknown as EventEmitter).emit(
|
||||
'data',
|
||||
'src/index.ts\x005: console.log("hello world")\n'
|
||||
)
|
||||
;(proc.stdout as unknown as EventEmitter).emit('data', 'src/main.ts\x0012: return "hello"\n')
|
||||
proc.emit('close')
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(result.files).toHaveLength(2)
|
||||
expect(result.totalMatches).toBe(2)
|
||||
expect(result.truncated).toBe(false)
|
||||
|
||||
expect(result.files[0].relativePath).toBe('src/index.ts')
|
||||
expect(result.files[0].matches[0]).toEqual({
|
||||
line: 5,
|
||||
column: 16,
|
||||
matchLength: 5,
|
||||
lineContent: ' console.log("hello world")'
|
||||
})
|
||||
|
||||
expect(result.files[1].relativePath).toBe('src/main.ts')
|
||||
expect(result.files[1].matches[0].line).toBe(12)
|
||||
})
|
||||
|
||||
it('finds multiple matches per line', async () => {
|
||||
const proc = createMockProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = searchWithGitGrep('/mock/root', { query: 'ab', rootPath: '/mock/root' }, 100)
|
||||
|
||||
setTimeout(() => {
|
||||
;(proc.stdout as unknown as EventEmitter).emit('data', 'file.txt\x001:ab cd ab ef ab\n')
|
||||
proc.emit('close')
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(result.files).toHaveLength(1)
|
||||
expect(result.totalMatches).toBe(3)
|
||||
expect(result.files[0].matches).toEqual([
|
||||
{ line: 1, column: 1, matchLength: 2, lineContent: 'ab cd ab ef ab' },
|
||||
{ line: 1, column: 7, matchLength: 2, lineContent: 'ab cd ab ef ab' },
|
||||
{ line: 1, column: 13, matchLength: 2, lineContent: 'ab cd ab ef ab' }
|
||||
])
|
||||
})
|
||||
|
||||
it('respects maxResults and sets truncated', async () => {
|
||||
const proc = createMockProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = searchWithGitGrep('/mock/root', { query: 'x', rootPath: '/mock/root' }, 2)
|
||||
|
||||
setTimeout(() => {
|
||||
;(proc.stdout as unknown as EventEmitter).emit(
|
||||
'data',
|
||||
'a.ts\x001:x\n' + 'b.ts\x001:x\n' + 'c.ts\x001:x\n'
|
||||
)
|
||||
proc.emit('close')
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(result.totalMatches).toBe(2)
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes correct flags for case-insensitive fixed-string search', async () => {
|
||||
const proc = createMockProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = searchWithGitGrep(
|
||||
'/mock/root',
|
||||
{ query: 'test', rootPath: '/mock/root', caseSensitive: false, useRegex: false },
|
||||
100
|
||||
)
|
||||
|
||||
setTimeout(() => proc.emit('close'), 10)
|
||||
await promise
|
||||
|
||||
const gitArgs = spawnMock.mock.calls[0][1] as string[]
|
||||
expect(gitArgs).toContain('-i')
|
||||
expect(gitArgs).toContain('--fixed-strings')
|
||||
expect(gitArgs).not.toContain('--extended-regexp')
|
||||
})
|
||||
|
||||
it('passes correct flags for regex whole-word search', async () => {
|
||||
const proc = createMockProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = searchWithGitGrep(
|
||||
'/mock/root',
|
||||
{
|
||||
query: 'foo',
|
||||
rootPath: '/mock/root',
|
||||
caseSensitive: true,
|
||||
useRegex: true,
|
||||
wholeWord: true
|
||||
},
|
||||
100
|
||||
)
|
||||
|
||||
setTimeout(() => proc.emit('close'), 10)
|
||||
await promise
|
||||
|
||||
const gitArgs = spawnMock.mock.calls[0][1] as string[]
|
||||
expect(gitArgs).toContain('-w')
|
||||
expect(gitArgs).toContain('--extended-regexp')
|
||||
expect(gitArgs).not.toContain('-i')
|
||||
expect(gitArgs).not.toContain('--fixed-strings')
|
||||
})
|
||||
|
||||
it('returns empty result when git grep spawn fails', async () => {
|
||||
const proc = createMockProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = searchWithGitGrep('/mock/root', { query: 'test', rootPath: '/mock/root' }, 100)
|
||||
|
||||
setTimeout(() => proc.emit('error', new Error('spawn git ENOENT')), 10)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(result.files).toHaveLength(0)
|
||||
expect(result.totalMatches).toBe(0)
|
||||
})
|
||||
|
||||
it('skips lines without null separator', async () => {
|
||||
const proc = createMockProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100)
|
||||
|
||||
setTimeout(() => {
|
||||
// A line without \0 should be skipped (e.g. git header output)
|
||||
;(proc.stdout as unknown as EventEmitter).emit('data', 'no-null-here:1:ok\n')
|
||||
;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x003:ok\n')
|
||||
proc.emit('close')
|
||||
}, 10)
|
||||
|
||||
const result = await promise
|
||||
|
||||
expect(result.files).toHaveLength(1)
|
||||
expect(result.files[0].relativePath).toBe('valid.ts')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { join } from 'path'
|
||||
import type { SearchOptions, SearchResult, SearchFileResult } from '../../shared/types'
|
||||
|
||||
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.
|
||||
*
|
||||
* Why: On Linux, rg may not be installed or may not be in PATH when the app
|
||||
* is launched from a desktop entry (which inherits a minimal system PATH).
|
||||
* git grep is always available since this is a git-focused app.
|
||||
*/
|
||||
export function searchWithGitGrep(
|
||||
rootPath: string,
|
||||
args: SearchOptions,
|
||||
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
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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 child = spawn('git', gitArgs, {
|
||||
cwd: rootPath,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
child.stdout.setEncoding('utf-8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdoutBuffer += chunk
|
||||
const lines = stdoutBuffer.split('\n')
|
||||
stdoutBuffer = lines.pop() ?? ''
|
||||
for (const l of lines) {
|
||||
processLine(l)
|
||||
}
|
||||
})
|
||||
child.stderr.on('data', () => {
|
||||
/* drain */
|
||||
})
|
||||
child.once('error', () => {
|
||||
resolveOnce()
|
||||
})
|
||||
child.once('close', () => {
|
||||
if (stdoutBuffer) {
|
||||
processLine(stdoutBuffer)
|
||||
}
|
||||
resolveOnce()
|
||||
})
|
||||
|
||||
const killTimeout = setTimeout(() => {
|
||||
truncated = true
|
||||
child.kill()
|
||||
}, SEARCH_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
} from './filesystem-auth'
|
||||
import { listQuickOpenFiles } from './filesystem-list-files'
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import { searchWithGitGrep } from './filesystem-search-git'
|
||||
import { checkRgAvailable } from './rg-availability'
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_SEARCH_MAX_RESULTS = 2000
|
||||
@@ -180,6 +182,15 @@ export function registerFilesystemHandlers(store: Store): void {
|
||||
)
|
||||
const searchKey = `${event.sender.id}:${rootPath}`
|
||||
|
||||
// Why: checking rg availability upfront avoids a race condition where
|
||||
// spawn('rg') emits 'close' before 'error' on some platforms, causing
|
||||
// the handler to resolve with empty results before the git-grep
|
||||
// fallback can run. The result is cached after the first check.
|
||||
const rgAvailable = await checkRgAvailable()
|
||||
if (!rgAvailable) {
|
||||
return searchWithGitGrep(rootPath, args, maxResults)
|
||||
}
|
||||
|
||||
return new Promise((resolvePromise) => {
|
||||
const rgArgs: string[] = [
|
||||
'--json',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
// Why: when rg is not installed, spawn('rg', ...) emits both 'error' and
|
||||
// 'close' events but their ordering is non-deterministic across Node versions
|
||||
// and platforms. If 'close' fires first the handler resolves with empty
|
||||
// results before the 'error' handler can trigger the git-grep fallback.
|
||||
// Checking rg availability once upfront (cached) avoids the race entirely.
|
||||
let rgAvailableCache: boolean | null = null
|
||||
|
||||
export function checkRgAvailable(): Promise<boolean> {
|
||||
if (rgAvailableCache !== null) {
|
||||
return Promise.resolve(rgAvailableCache)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('rg', ['--version'], { stdio: 'ignore' })
|
||||
child.once('error', () => {
|
||||
rgAvailableCache = false
|
||||
resolve(false)
|
||||
})
|
||||
child.once('close', (code) => {
|
||||
if (rgAvailableCache !== null) {
|
||||
// error handler already resolved
|
||||
return
|
||||
}
|
||||
rgAvailableCache = code === 0
|
||||
resolve(rgAvailableCache)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user