perf(filesearch): Use async fzf for non-blocking file search (#5771)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
c38147a3a6
commit
9fc7115b86
|
@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue