diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 02eb4c47f8..03e9383833 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -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', ]); }); diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts index 3c2506cb13..1c001eeead 100644 --- a/packages/core/src/utils/filesearch/fileSearch.test.ts +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -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'], diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 3536eb6205..e3f608e508 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -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 }, + b: { item: string; positions: Set }, +) => { + 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 }, + b: { item: string; positions: Set }, +) => { + 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], }); } }