perf(filesearch): Use async fzf for non-blocking file search (#5771)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Bryant Chandler 2025-08-07 15:24:55 -07:00 committed by GitHub
parent c38147a3a6
commit 9fc7115b86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 33 additions and 24 deletions

View File

@ -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': '' }; const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure); testRootDir = await createTmpDir(structure);
@ -186,7 +186,7 @@ describe('useAtCompletion', () => {
expect(result.current.isLoadingSuggestions).toBe(false); 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': '' }; const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure); testRootDir = await createTmpDir(structure);
@ -194,7 +194,7 @@ describe('useAtCompletion', () => {
const originalSearch = FileSearch.prototype.search; const originalSearch = FileSearch.prototype.search;
vi.spyOn(FileSearch.prototype, 'search').mockImplementation( vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
async function (...args) { async function (...args) {
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 300));
return originalSearch.apply(this, args); return originalSearch.apply(this, args);
}, },
); );

View File

@ -194,7 +194,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
slowSearchTimer.current = setTimeout(() => { slowSearchTimer.current = setTimeout(() => {
dispatch({ type: 'SET_LOADING', payload: true }); dispatch({ type: 'SET_LOADING', payload: true });
}, 100); }, 200);
try { try {
const results = await fileSearch.current.search(state.pattern, { const results = await fileSearch.current.search(state.pattern, {

View File

@ -11,7 +11,7 @@ import picomatch from 'picomatch';
import { Ignore } from './ignore.js'; import { Ignore } from './ignore.js';
import { ResultCache } from './result-cache.js'; import { ResultCache } from './result-cache.js';
import * as cache from './crawlCache.js'; import * as cache from './crawlCache.js';
import { Fzf, FzfResultItem } from 'fzf'; import { AsyncFzf, FzfResultItem } from 'fzf';
export type FileSearchOptions = { export type FileSearchOptions = {
projectRoot: string; projectRoot: string;
@ -78,18 +78,6 @@ export async function filter(
return results; 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 = { export type SearchOptions = {
signal?: AbortSignal; signal?: AbortSignal;
maxResults?: number; maxResults?: number;
@ -105,6 +93,7 @@ export class FileSearch {
private readonly ignore: Ignore = new Ignore(); private readonly ignore: Ignore = new Ignore();
private resultCache: ResultCache | undefined; private resultCache: ResultCache | undefined;
private allFiles: string[] = []; private allFiles: string[] = [];
private fzf: AsyncFzf<string[]> | undefined;
/** /**
* Constructs a new `FileSearch` instance. * Constructs a new `FileSearch` instance.
@ -136,25 +125,39 @@ export class FileSearch {
pattern: string, pattern: string,
options: SearchOptions = {}, options: SearchOptions = {},
): Promise<string[]> { ): Promise<string[]> {
if (!this.resultCache) { if (!this.resultCache || !this.fzf) {
throw new Error('Engine not initialized. Call initialize() first.'); throw new Error('Engine not initialized. Call initialize() first.');
} }
pattern = pattern || '*'; pattern = pattern || '*';
let filteredCandidates;
const { files: candidates, isExactMatch } = const { files: candidates, isExactMatch } =
await this.resultCache!.get(pattern); await this.resultCache!.get(pattern);
let filteredCandidates;
if (isExactMatch) { if (isExactMatch) {
// Use the cached result.
filteredCandidates = candidates; filteredCandidates = candidates;
} else { } else {
// Apply the user's picomatch pattern filter let shouldCache = true;
filteredCandidates = pattern.includes('*') if (pattern.includes('*')) {
? await filter(candidates, pattern, options.signal) filteredCandidates = await filter(candidates, pattern, options.signal);
: filterByFzf(this.allFiles, pattern); } else {
filteredCandidates = await this.fzf
.find(pattern)
.then((results: Array<FzfResultItem<string>>) =>
results.map((entry: FzfResultItem<string>) => entry.item),
)
.catch(() => {
shouldCache = false;
return [];
});
}
if (shouldCache) {
this.resultCache!.set(pattern, filteredCandidates); this.resultCache!.set(pattern, filteredCandidates);
} }
}
// Trade-off: We apply a two-stage filtering process. // Trade-off: We apply a two-stage filtering process.
// 1. During the file system crawl (`performCrawl`), we only apply directory-level // 1. During the file system crawl (`performCrawl`), we only apply directory-level
@ -287,5 +290,11 @@ export class FileSearch {
*/ */
private buildResultCache(): void { private buildResultCache(): void {
this.resultCache = new ResultCache(this.allFiles, this.absoluteDir); 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',
});
} }
} }