diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 8a5a9ce159f..cf65148bbe2 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -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) + }) + }) }) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 6aa36c8f187..71bcb651c7d 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -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 { 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 { + const files = new Set() + + const runGitLsFiles = (args: string[]): Promise => { + 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)) +} diff --git a/src/main/ipc/filesystem-search-git.test.ts b/src/main/ipc/filesystem-search-git.test.ts new file mode 100644 index 00000000000..21b36bccc75 --- /dev/null +++ b/src/main/ipc/filesystem-search-git.test.ts @@ -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).stdout = new EventEmitter() + ;( + (p as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(p as unknown as Record).stderr = new EventEmitter() + ;(p as unknown as Record).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') + }) +}) diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts new file mode 100644 index 00000000000..e3c9a3a6236 --- /dev/null +++ b/src/main/ipc/filesystem-search-git.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 { + 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() + 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) + }) +} diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 757aeceb53b..13beff98382 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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', diff --git a/src/main/ipc/rg-availability.ts b/src/main/ipc/rg-availability.ts new file mode 100644 index 00000000000..fafc7d4c9c8 --- /dev/null +++ b/src/main/ipc/rg-availability.ts @@ -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 { + 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) + }) + }) +}