From 9fc7115b8654fc193f948570293485f16d89f60a Mon Sep 17 00:00:00 2001 From: Bryant Chandler Date: Thu, 7 Aug 2025 15:24:55 -0700 Subject: [PATCH] perf(filesearch): Use async fzf for non-blocking file search (#5771) Co-authored-by: Jacob Richman --- .../cli/src/ui/hooks/useAtCompletion.test.ts | 6 +-- packages/cli/src/ui/hooks/useAtCompletion.ts | 2 +- .../core/src/utils/filesearch/fileSearch.ts | 49 +++++++++++-------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index aa198fc1..599f8fdf 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -157,7 +157,7 @@ describe('useAtCompletion', () => { }); }); - it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => { + it('should NOT show a loading indicator for subsequent searches that complete under 200ms', async () => { const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; testRootDir = await createTmpDir(structure); @@ -186,7 +186,7 @@ describe('useAtCompletion', () => { expect(result.current.isLoadingSuggestions).toBe(false); }); - it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => { + it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 200ms', async () => { const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; testRootDir = await createTmpDir(structure); @@ -194,7 +194,7 @@ describe('useAtCompletion', () => { const originalSearch = FileSearch.prototype.search; vi.spyOn(FileSearch.prototype, 'search').mockImplementation( async function (...args) { - await new Promise((resolve) => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 300)); return originalSearch.apply(this, args); }, ); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 82439c14..f6835dc8 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -194,7 +194,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void { slowSearchTimer.current = setTimeout(() => { dispatch({ type: 'SET_LOADING', payload: true }); - }, 100); + }, 200); try { const results = await fileSearch.current.search(state.pattern, { diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 76a099f7..480d5815 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -11,7 +11,7 @@ import picomatch from 'picomatch'; import { Ignore } from './ignore.js'; import { ResultCache } from './result-cache.js'; import * as cache from './crawlCache.js'; -import { Fzf, FzfResultItem } from 'fzf'; +import { AsyncFzf, FzfResultItem } from 'fzf'; export type FileSearchOptions = { projectRoot: string; @@ -78,18 +78,6 @@ export async function filter( return results; } -/** - * Filters a list of paths based on a given pattern using fzf. - * @param allPaths The list of all paths to filter. - * @param pattern The fzf pattern to filter by. - * @returns The filtered and sorted list of paths. - */ -function filterByFzf(allPaths: string[], pattern: string) { - return new Fzf(allPaths) - .find(pattern) - .map((entry: FzfResultItem) => entry.item); -} - export type SearchOptions = { signal?: AbortSignal; maxResults?: number; @@ -105,6 +93,7 @@ export class FileSearch { private readonly ignore: Ignore = new Ignore(); private resultCache: ResultCache | undefined; private allFiles: string[] = []; + private fzf: AsyncFzf | undefined; /** * Constructs a new `FileSearch` instance. @@ -136,24 +125,38 @@ export class FileSearch { pattern: string, options: SearchOptions = {}, ): Promise { - if (!this.resultCache) { + if (!this.resultCache || !this.fzf) { throw new Error('Engine not initialized. Call initialize() first.'); } pattern = pattern || '*'; + let filteredCandidates; const { files: candidates, isExactMatch } = await this.resultCache!.get(pattern); - let filteredCandidates; if (isExactMatch) { + // Use the cached result. filteredCandidates = candidates; } else { - // Apply the user's picomatch pattern filter - filteredCandidates = pattern.includes('*') - ? await filter(candidates, pattern, options.signal) - : filterByFzf(this.allFiles, pattern); - this.resultCache!.set(pattern, filteredCandidates); + let shouldCache = true; + if (pattern.includes('*')) { + filteredCandidates = await filter(candidates, pattern, options.signal); + } else { + filteredCandidates = await this.fzf + .find(pattern) + .then((results: Array>) => + results.map((entry: FzfResultItem) => entry.item), + ) + .catch(() => { + shouldCache = false; + return []; + }); + } + + if (shouldCache) { + this.resultCache!.set(pattern, filteredCandidates); + } } // Trade-off: We apply a two-stage filtering process. @@ -287,5 +290,11 @@ export class FileSearch { */ private buildResultCache(): void { this.resultCache = new ResultCache(this.allFiles, this.absoluteDir); + // The v1 algorithm is much faster since it only looks at the first + // occurence of the pattern. We use it for search spaces that have >20k + // files, because the v2 algorithm is just too slow in those cases. + this.fzf = new AsyncFzf(this.allFiles, { + fuzzy: this.allFiles.length > 20000 ? 'v1' : 'v2', + }); } }