feat(core): improve @file autocomplete to prioritize filenames (#21064)

This commit is contained in:
Sehoon Shon
2026-03-04 12:24:34 -05:00
committed by GitHub
parent 66721379f8
commit bc89b05f01
3 changed files with 82 additions and 1 deletions

View File

@@ -120,8 +120,8 @@ describe('useAtCompletion', () => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/',
'src/components/',
'src/index.js',
'src/components/',
'src/components/Button.tsx',
]);
});

View File

@@ -421,6 +421,47 @@ describe('FileSearch', () => {
);
});
it('should prioritize filenames closer to the end of the path and shorter paths', async () => {
tmpDir = await createTmpDir({
src: {
'hooks.ts': '',
hooks: {
'index.ts': '',
},
utils: {
'hooks.tsx': '',
},
'hooks-dev': {
'test.ts': '',
},
},
});
const fileSearch = FileSearchFactory.create({
projectRoot: tmpDir,
fileDiscoveryService: new FileDiscoveryService(tmpDir, {
respectGitIgnore: false,
respectGeminiIgnore: false,
}),
ignoreDirs: [],
cache: false,
cacheTtl: 0,
enableRecursiveFileSearch: true,
enableFuzzySearch: true,
});
await fileSearch.initialize();
const results = await fileSearch.search('hooks');
// The order should prioritize matches closer to the end and shorter strings.
// FZF matches right-to-left.
expect(results[0]).toBe('src/hooks/');
expect(results[1]).toBe('src/hooks.ts');
expect(results[2]).toBe('src/utils/hooks.tsx');
expect(results[3]).toBe('src/hooks-dev/');
expect(results[4]).toBe('src/hooks/index.ts');
expect(results[5]).toBe('src/hooks-dev/test.ts');
});
it('should return empty array when no matches are found', async () => {
tmpDir = await createTmpDir({
src: ['file1.js'],

View File

@@ -13,6 +13,44 @@ import { AsyncFzf, type FzfResultItem } from 'fzf';
import { unescapePath } from '../paths.js';
import type { FileDiscoveryService } from '../../services/fileDiscoveryService.js';
// Tiebreaker: Prefers shorter paths.
const byLengthAsc = (a: { item: string }, b: { item: string }) =>
a.item.length - b.item.length;
// Tiebreaker: Prefers matches at the start of the filename (basename prefix).
const byBasenamePrefix = (
a: { item: string; positions: Set<number> },
b: { item: string; positions: Set<number> },
) => {
const getBasenameStart = (p: string) => {
const trimmed = p.endsWith('/') ? p.slice(0, -1) : p;
return Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + 1;
};
const aDiff = Math.min(...a.positions) - getBasenameStart(a.item);
const bDiff = Math.min(...b.positions) - getBasenameStart(b.item);
const aIsFilenameMatch = aDiff >= 0;
const bIsFilenameMatch = bDiff >= 0;
if (aIsFilenameMatch && !bIsFilenameMatch) return -1;
if (!aIsFilenameMatch && bIsFilenameMatch) return 1;
if (aIsFilenameMatch && bIsFilenameMatch) return aDiff - bDiff;
return 0; // Both are directory matches, let subsequent tiebreakers decide.
};
// Tiebreaker: Prefers matches closer to the end of the path.
const byMatchPosFromEnd = (
a: { item: string; positions: Set<number> },
b: { item: string; positions: Set<number> },
) => {
const maxPosA = Math.max(-1, ...a.positions);
const maxPosB = Math.max(-1, ...b.positions);
const distA = a.item.length - maxPosA;
const distB = b.item.length - maxPosB;
return distA - distB;
};
export interface FileSearchOptions {
projectRoot: string;
ignoreDirs: string[];
@@ -192,6 +230,8 @@ class RecursiveFileSearch implements FileSearch {
// 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',
forward: false,
tiebreakers: [byBasenamePrefix, byMatchPosFromEnd, byLengthAsc],
});
}
}