diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx index b40166cbc1d..8a168bcb684 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx @@ -99,6 +99,66 @@ afterEach(() => { }) describe('TabBarCreateEntry search behavior', () => { + it.each([false, true])('searches weak file matches on Enter (delayed listing: %s)', (delayed) => { + const readyListing = { + files: [ + 'tests/e2e/terminal-split-activation-latency-main-probe.ts', + 'src/main/linear/issue-context-inline-media.test.ts', + 'src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts' + ], + loading: false, + loadError: null + } + fileListMock.current = delayed ? { files: [], loading: true, loadError: null } : readyListing + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery('asciinema') + + if (delayed) { + expect(container.querySelector('[aria-selected="true"]')).toBeNull() + submit() + expect(onOpenEntry).not.toHaveBeenCalled() + fileListMock.current = readyListing + renderEntry({ onOpenEntry }) + } + + expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google') + expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain( + 'Search Google' + ) + submit() + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ + classification: { kind: 'search', engine: 'google', query: 'asciinema' } + }) + ) + }) + + it.each(['a', 'ab', 'asciinema'])('waits for an exact filename before arming %s', (query) => { + fileListMock.current = { files: [], loading: true, loadError: null } + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + setQuery(query) + + expect(container.querySelector('[aria-selected="true"]')).toBeNull() + submit() + expect(onOpenEntry).not.toHaveBeenCalled() + + fileListMock.current = { files: [`docs/${query}`], loading: false, loadError: null } + renderEntry({ onOpenEntry }) + expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain('Open file') + submit() + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ + classification: { + kind: 'existing-file', + matchKind: 'exact-basename', + relativePath: `docs/${query}` + } + }) + ) + }) + it('tracks the configured provider and submits its exact classification', () => { const onOpenEntry = vi.fn().mockResolvedValue(undefined) renderEntry({ onOpenEntry }) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts index 54bb018c96e..0a272dad7e0 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts @@ -194,9 +194,9 @@ describe('tab create entry classification', () => { relativePath: 'src/components/Button.tsx' }) expect(classifyTabEntryQuery('btn', files)).toEqual({ - kind: 'existing-file', - matchKind: 'fuzzy', - relativePath: 'src/components/Button.tsx' + kind: 'search', + engine: 'google', + query: 'btn' }) }) @@ -212,6 +212,18 @@ describe('tab create entry classification', () => { ]) }) + it('keeps literal filename matches after an exact basename match', () => { + expect( + getTabEntryOptions('Makefile', readyFiles(['build/Makefile', 'build/Makefile.am'])).map( + (option) => option.classification + ) + ).toEqual([ + { kind: 'existing-file', matchKind: 'exact-basename', relativePath: 'build/Makefile' }, + { kind: 'search', engine: 'google', query: 'Makefile' }, + { kind: 'existing-file', matchKind: 'literal-basename', relativePath: 'build/Makefile.am' } + ]) + }) + it('prefers creating typed file paths over fuzzy matches', () => { expect( getTabEntryOptions('read.md', readyFiles(['README.md'])).map( @@ -224,7 +236,7 @@ describe('tab create entry classification', () => { ]) }) - it('keeps single-token quick-open matches ahead of search, but not phrases', () => { + it('keeps literal filename matches ahead of search, but not phrases', () => { expect( getTabEntryOptions('typescript', readyFiles(['docs/typescript-guide.md'])).map( (option) => option.classification @@ -232,7 +244,7 @@ describe('tab create entry classification', () => { ).toEqual([ { kind: 'existing-file', - matchKind: 'fuzzy', + matchKind: 'literal-basename', relativePath: 'docs/typescript-guide.md' }, { kind: 'search', engine: 'google', query: 'typescript' }, @@ -260,7 +272,7 @@ describe('tab create entry classification', () => { }) // Fuzzy matching is a subsequence scan, so a short token matches broadly. - it('keeps a search slot when fuzzy matches would fill the whole list', () => { + it('puts search ahead of fuzzy filename and path matches', () => { const files = [ 'src/components/Button.tsx', 'src/lib/bootstrap-nav.ts', @@ -269,15 +281,14 @@ describe('tab create entry classification', () => { 'src/bin/tune.ts' ] expect(getTabEntryOptions('btn', readyFiles(files)).map((o) => o.classification.kind)).toEqual([ + 'search', 'existing-file', 'existing-file', - 'existing-file', - 'search' + 'existing-file' ]) - // A one-slot list still answers with the file, so Enter keeps quick-open. expect( getTabEntryOptions('btn', readyFiles(files), 1).map((o) => o.classification.kind) - ).toEqual(['existing-file']) + ).toEqual(['search']) }) it('ranks search before ordinary create-file actions', () => { diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts index 78de06106a8..5c1b715d5f1 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts @@ -3,7 +3,11 @@ import type { RuntimeFileListState } from '../quick-open-file-list' import { translate } from '@/i18n/i18n' import { getTabEntryOmniboxPlaceholder } from './tab-create-entry-copy' import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../shared/browser-url' -import { findExistingFileMatches, isLikelyNewFileIntent } from './tab-create-entry-file-matches' +import { + findExistingFileMatches, + isLikelyNewFileIntent, + type ExistingFileMatch +} from './tab-create-entry-file-matches' import { parseForcedSearchQuery } from './tab-create-entry-forced-search' import { isTabEntryAbsolutePathLike, @@ -31,11 +35,7 @@ export const TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE = export type TabEntryClassification = | { kind: 'empty'; message: string } | { kind: 'explicit-url'; url: string } - | { - kind: 'existing-file' - matchKind: 'exact-path' | 'exact-basename' | 'fuzzy' - relativePath: string - } + | ExistingFileMatch | { kind: 'host-url'; url: string } | { kind: 'search'; engine: SearchEngine; query: string } | { kind: 'new-file'; relativePath: string } @@ -223,8 +223,9 @@ export function getTabEntryOptions( getPreparedQuickOpenFiles(fileList.files), Math.max(actionLimit, 1) ) - const exactExistingFiles = existingFiles.filter((file) => file.matchKind !== 'fuzzy') - const fuzzyExistingFiles = existingFiles.filter((file) => file.matchKind === 'fuzzy') + const exactExistingFiles = existingFiles.filter( + (file) => file.matchKind === 'exact-path' || file.matchKind === 'exact-basename' + ) if (exactExistingFiles.length > 0) { const options: TabEntryActionClassification[] = [...exactExistingFiles] @@ -233,37 +234,41 @@ export function getTabEntryOptions( } else if (!hostUrl && newFile) { options.push(search) } + options.push(...existingFiles.filter((file) => file.matchKind === 'literal-basename')) return toOptions(options, actionLimit) } if (hostUrl?.kind === 'blocked') { return [blockedOption('invalid-url', hostUrl.message)] } if (hostUrl?.kind === 'host-url') { - return toOptions([hostUrl, ...fuzzyExistingFiles], actionLimit) + return toOptions([hostUrl, ...existingFiles], actionLimit) } if (pathError || !newFile) { // Why: an unusable path is still a live quick-open prefix — "src/" cannot be // created, but it matches real files, and dropping them turns every typed // separator into an error row mid-keystroke. - return fuzzyExistingFiles.length > 0 - ? toOptions(fuzzyExistingFiles, actionLimit) + return existingFiles.length > 0 + ? toOptions(existingFiles, actionLimit) : [invalidPathOption(pathError)] } if (isLikelyNewFileIntent(trimmed)) { - return toOptions([newFile, search, ...fuzzyExistingFiles], actionLimit) + return toOptions([newFile, search, ...existingFiles], actionLimit) } // Why no create row: a spaced, extension-less phrase is a web query, and a // stray arrow/click on "Create file" leaves an empty `release notes` on disk // that then outranks search as an exact match forever after. if (/\s/.test(trimmed)) { - return toOptions([search, ...fuzzyExistingFiles], actionLimit) + return toOptions([search, ...existingFiles], actionLimit) } - // Why: a single token is still a quick-open attempt ("btn" → Button.tsx), so - // only phrases promote web search over fuzzy matches. Fuzzy matching is a - // subsequence scan that fills every slot in a real repo, so hold one back — - // otherwise search silently disappears from the list it should always offer. + // Short Latin tokens match too many filenames to imply file intent; CJK words can be short. + if (/^[\p{Script=Latin}\p{Nd}]{1,2}$/u.test(trimmed.normalize('NFC'))) { + return toOptions([search, ...existingFiles, newFile], actionLimit) + } + const literalFiles = existingFiles.filter((file) => file.matchKind === 'literal-basename') + const fuzzyFiles = existingFiles.filter((file) => file.matchKind === 'fuzzy') + // Reserve a search slot even when literal filename matches fill the list. return toOptions( - [...fuzzyExistingFiles.slice(0, Math.max(actionLimit - 1, 1)), search, newFile], + [...literalFiles.slice(0, Math.max(actionLimit - 1, 1)), search, ...fuzzyFiles, newFile], actionLimit ) } diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts index 8ac46512051..b271d168e78 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts @@ -2,6 +2,7 @@ import { expect, it, vi } from 'vitest' import { prepareQuickOpenFiles, rankQuickOpenFiles } from '../quick-open-search' import { findExistingFileMatches } from './tab-create-entry-file-matches' import type * as QuickOpenSearch from '../quick-open-search' +import { compareFileNames } from '../../../../shared/file-name-sort' const ranking = vi.hoisted(() => ({ calls: 0 })) vi.mock('../quick-open-search', async (importOriginal) => { @@ -28,6 +29,20 @@ it('skips fuzzy ranking when exact matches fill the requested window', () => { expect(ranking.calls).toBe(0) }) +it('selects literal filenames from a large inventory without running fuzzy ranking', () => { + const files = prepareQuickOpenFiles([ + ...Array.from({ length: 100_000 }, (_, index) => `a/s/c/i/i/n/e/m/a/file-${index}.ts`), + 'docs/asciinema-10.md', + 'docs/asciinema-2.md' + ]) + ranking.calls = 0 + expect(findExistingFileMatches('asciinema', files, 2).map((file) => file.relativePath)).toEqual([ + 'docs/asciinema-2.md', + 'docs/asciinema-10.md' + ]) + expect(ranking.calls).toBe(0) +}) + it('deduplicates exact paths and still fills remaining slots with fuzzy matches', () => { const files = prepareQuickOpenFiles(['a.md', 'a.md', 'other/a.md', 'abc.md']) expect(findExistingFileMatches('a.md', files, 4)).toEqual([ @@ -37,14 +52,12 @@ it('deduplicates exact paths and still fills remaining slots with fuzzy matches' ]) }) -// Why: skipping the ranker is only sound because a fuzzy hit can never outrank an -// exact one — exact results are concatenated first and dedupe is first-wins, so the -// exact prefix is exactly what a full ranked-then-sliced list would have returned. -// This re-runs the pre-skip pipeline and holds the shipped one to it. +// Sort every literal candidate to check the bounded selection against a full reference. function rankThenSlice( query: string, files: readonly QuickOpenSearch.QuickOpenIndexedFile[], - limit: number + limit: number, + preferLiteralFilenames = false ): { kind: string; matchKind: string; relativePath: string }[] { const normalized = query.trim().replace(/\\/g, '/') if (!normalized || limit <= 0) { @@ -56,6 +69,12 @@ function rankThenSlice( ...files .filter((f) => f.lowerFilename === lower) .map((f) => ['exact-basename', f.path] as const), + ...(preferLiteralFilenames + ? files + .filter((file) => file.lowerFilename.includes(lower)) + .sort((a, b) => compareFileNames(a.path, b.path)) + .map((file) => ['literal-basename', file.path] as const) + : []), ...rankQuickOpenFiles(normalized, files, limit).map((f) => ['fuzzy', f.path] as const) ] const seen = new Set() @@ -65,7 +84,7 @@ function rankThenSlice( .slice(0, limit) } -it('returns what rank-then-slice would have returned, including at limit 1', () => { +it('preserves quick-open ordering for explicit file and path queries, including at limit 1', () => { const corpus = [ ['a.md', 'ab.md', 'abc.md', 'deep/nested/a.md'], ['ab.md', 'abc.md', 'deep/nested/a.md'], @@ -74,7 +93,7 @@ it('returns what rank-then-slice would have returned, including at limit 1', () ['README.md', 'docs/readme.md', 'readme.mdx'], ['only-fuzzy.md'] ] - const queries = ['a.md', 'index.ts', 'readme.md', 'src/index.ts', 'a', 'nope.md'] + const queries = ['a.md', 'index.ts', 'readme.md', 'src/index.ts', 'nope.md'] for (const paths of corpus) { const files = prepareQuickOpenFiles(paths) for (const query of queries) { @@ -90,6 +109,46 @@ it('returns what rank-then-slice would have returned, including at limit 1', () } }) +it('matches the full literal-first reference for bare tokens across limits and input orders', () => { + const queries = ['a', 'ab', 'abc', 'BTN', 'button', 'asciinema', '工具', 'é'] + for (const query of queries) { + const paths = [ + ...Array.from({ length: 12 }, (_, index) => `${[...query].join('/')}/file-${index}.ts`), + `docs/${query}-10.md`, + `docs/${query}-2.md`, + `docs/${query}-2.md`, + `docs\\${query.toLowerCase()}-guide.md`, + `docs/a${'x'.repeat(120)}-${query.toUpperCase()}.md`, + 'unrelated.txt' + ] + for (const corpus of [paths, [...paths, query, `docs/${query}`, query]]) { + for (const orderedPaths of [corpus, corpus.toReversed()]) { + const files = prepareQuickOpenFiles(orderedPaths) + for (const limit of [0, 1, 2, 3, 5, 20]) { + expect({ + query, + limit, + matches: findExistingFileMatches(` ${query} `, files, limit) + }).toEqual({ query, limit, matches: rankThenSlice(query, files, limit, true) }) + } + } + } + } +}) + +it('does not let duplicate literal candidates consume the result limit', () => { + const files = prepareQuickOpenFiles([ + ...Array.from({ length: 10 }, () => 'docs/button-1.md'), + 'docs/button-2.md' + ]) + ranking.calls = 0 + expect(findExistingFileMatches('button', files, 2)).toEqual([ + { kind: 'existing-file', matchKind: 'literal-basename', relativePath: 'docs/button-1.md' }, + { kind: 'existing-file', matchKind: 'literal-basename', relativePath: 'docs/button-2.md' } + ]) + expect(ranking.calls).toBe(0) +}) + it('takes the exact match at limit 1 without consulting the ranker at all', () => { const files = prepareQuickOpenFiles(['a.md', 'ab.md', 'abc.md', 'deep/nested/a.md']) ranking.calls = 0 diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-file-matches.ts b/src/renderer/src/components/tab-bar/tab-create-entry-file-matches.ts index eb2f19576d8..72233be3075 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-file-matches.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-file-matches.ts @@ -1,8 +1,12 @@ -import { rankQuickOpenFiles, type QuickOpenIndexedFile } from '../quick-open-search' +import { + QuickOpenPathRanker, + rankQuickOpenFiles, + type QuickOpenIndexedFile +} from '../quick-open-search' export type ExistingFileMatch = { kind: 'existing-file' - matchKind: 'exact-path' | 'exact-basename' | 'fuzzy' + matchKind: 'exact-path' | 'exact-basename' | 'literal-basename' | 'fuzzy' relativePath: string } @@ -36,6 +40,27 @@ export function isLikelyNewFileIntent(query: string): boolean { return hasFilenameExtension(trimmed) || /^\.[^.].*$/.test(trimmed) } +function findLiteralFilenameMatches( + query: string, + files: readonly QuickOpenIndexedFile[], + limit: number +): ExistingFileMatch[] { + // Natural ordering avoids arbitrary fuzzy scores among literal filename matches. + const ranker = new QuickOpenPathRanker('', limit) + const seen = new Set() + for (const file of files) { + if (file.lowerFilename.includes(query) && !seen.has(file.path)) { + seen.add(file.path) + ranker.consider(file.path) + } + } + return ranker.result().paths.map((relativePath) => ({ + kind: 'existing-file', + matchKind: 'literal-basename', + relativePath + })) +} + function dedupeMatches(matches: ExistingFileMatch[]): ExistingFileMatch[] { const seen = new Set() return matches.filter((match) => { @@ -75,11 +100,21 @@ export function findExistingFileMatches( if (exactMatches.length >= limit) { return exactMatches.slice(0, limit) } + const preferFilenames = !isLikelyNewFileIntent(normalizedQuery) && !/\s/.test(normalizedQuery) + const preferredMatches = preferFilenames + ? dedupeMatches([ + ...exactMatches, + ...findLiteralFilenameMatches(lowerQuery, indexedFiles, limit) + ]) + : exactMatches + if (preferredMatches.length >= limit) { + return preferredMatches.slice(0, limit) + } const fuzzyMatches = rankQuickOpenFiles(normalizedQuery, indexedFiles, limit).map((file) => ({ kind: 'existing-file' as const, matchKind: 'fuzzy' as const, relativePath: file.path })) - return dedupeMatches([...exactMatches, ...fuzzyMatches]).slice(0, limit) + return dedupeMatches([...preferredMatches, ...fuzzyMatches]).slice(0, limit) } diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts index 7907a075b25..d0557dfd13a 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest' import { insertHistoryRowsBelowFileMatches } from './tab-create-entry-history-placement' import type { BrowserHistoryOmniboxRow } from './tab-create-entry-active-option' import type { TabEntryOption } from './tab-create-entry-action' +import type { ExistingFileMatch } from './tab-create-entry-file-matches' function fileOption( relativePath: string, - matchKind: 'exact-path' | 'exact-basename' | 'fuzzy' + matchKind: ExistingFileMatch['matchKind'] ): TabEntryOption { return { id: `existing-file:${relativePath}`, @@ -40,7 +41,7 @@ describe('tab create entry history placement', () => { const placed = insertHistoryRowsBelowFileMatches( [ fileOption('README.md', 'exact-basename'), - fileOption('docs/readme-notes.md', 'fuzzy'), + fileOption('docs/readme-notes.md', 'literal-basename'), newFileOption, searchOption ], @@ -53,9 +54,9 @@ describe('tab create entry history placement', () => { expect(placed[3]).toMatchObject({ option: { id: 'new-file:readme' } }) }) - it('stays below a fuzzy match that is the only file row', () => { + it.each(['literal-basename', 'fuzzy'] as const)('stays below a lone %s file row', (matchKind) => { const placed = insertHistoryRowsBelowFileMatches( - [fileOption('docs/readme-notes.md', 'fuzzy'), searchOption], + [fileOption('docs/readme-notes.md', matchKind), searchOption], rows ) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-search-ranking.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-search-ranking.test.ts new file mode 100644 index 00000000000..82a9a2bfb14 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-create-entry-search-ranking.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { classifyTabEntryQuery, getTabEntryOptions } from './tab-create-entry-classifier' +import { prepareQuickOpenFiles } from '../quick-open-search' +import { findExistingFileMatches } from './tab-create-entry-file-matches' + +const unrelatedFiles = [ + 'tests/e2e/terminal-split-activation-latency-main-probe.ts', + 'src/main/linear/issue-context-inline-media.test.ts', + 'src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts' +] +const readyFiles = (files: string[]) => ({ files, loading: false, loadError: null }) + +describe('new-tab file matches versus web search', () => { + it('puts search ahead of scattered path matches for asciinema', () => { + const options = getTabEntryOptions('asciinema', readyFiles(unrelatedFiles)) + expect(options.map((option) => option.classification.kind)).toEqual([ + 'search', + 'existing-file', + 'existing-file', + 'existing-file' + ]) + expect(classifyTabEntryQuery('asciinema', readyFiles(unrelatedFiles))).toEqual({ + kind: 'search', + engine: 'google', + query: 'asciinema' + }) + }) + + it('keeps an actual filename match ahead of search and weak matches below it', () => { + const options = getTabEntryOptions( + 'asciinema', + readyFiles([...unrelatedFiles, 'docs/asciinema-guide.md']) + ) + expect(options[0].classification).toMatchObject({ + kind: 'existing-file', + relativePath: 'docs/asciinema-guide.md' + }) + expect(options[1].classification.kind).toBe('search') + expect(options.slice(2).every((option) => option.classification.kind === 'existing-file')).toBe( + true + ) + }) + + it.each([1, 2, 4, 8])('finds a literal filename before limiting to %i actions', (limit) => { + const files = readyFiles([ + ...Array.from({ length: 10 }, (_, index) => `b/u/t/t/o/n/file-${index}.ts`), + 'src/components/Button.tsx' + ]) + expect(getTabEntryOptions('button', files, limit)[0].classification).toMatchObject({ + kind: 'existing-file', + relativePath: 'src/components/Button.tsx' + }) + }) + + it('offers fuzzy filename abbreviations below search', () => { + expect( + getTabEntryOptions('btn', readyFiles(['src/components/Button.tsx'])).map( + (option) => option.classification + ) + ).toEqual([ + { kind: 'search', engine: 'google', query: 'btn' }, + { kind: 'existing-file', matchKind: 'fuzzy', relativePath: 'src/components/Button.tsx' }, + { kind: 'new-file', relativePath: 'btn' } + ]) + }) + + it('finds literal filenames even when their old fuzzy score falls outside the result limit', () => { + const target = `docs/a${'x'.repeat(120)}-asciinema.md` + const files = readyFiles([ + ...Array.from({ length: 10 }, (_, index) => `a/s/c/i/i/n/e/m/a/file-${index}.ts`), + target + ]) + expect(getTabEntryOptions('asciinema', files)[0].classification).toMatchObject({ + kind: 'existing-file', + relativePath: target + }) + }) + + it.each([ + ['btn', 'src/components/Button.tsx', false], + ['button', 'src/components/Button.tsx', true], + ['asciinema', 'docs/ASCIINEMA-guide.md', true], + ['asciinema', 'docs\\ASCIINEMA-guide.md', true], + ['asciinema', `docs/a${'x'.repeat(120)}-asciinema.md`, true], + ['asciinema', unrelatedFiles[0], false], + ['asciinema', unrelatedFiles[1], false], + ['asciinema', unrelatedFiles[2], false], + ['abc', 'alphabet-biology-chemistry.md', false], + ['abc', 'a----b----c.md', false], + ['btn', 'src/base/tone.ts', false] + ])('reports literal filename matching for %s in %s', (query, path, matches) => { + const results = findExistingFileMatches(query, prepareQuickOpenFiles([path]), 1) + expect(results.some((result) => result.matchKind === 'literal-basename')).toBe(matches) + }) + + it.each(['a', 'AB', 'é', 'e\u0301', '12'])('puts search first for short token %s', (query) => { + const files = readyFiles( + Array.from({ length: 10 }, (_, index) => `docs/${query}-guide-${index}.md`) + ) + for (const limit of [1, 2, 4, 8]) { + const options = getTabEntryOptions(query, files, limit) + expect(options[0].classification).toEqual({ kind: 'search', engine: 'google', query }) + expect(options.slice(1).map((option) => option.classification)).toEqual( + Array.from({ length: limit - 1 }, (_, index) => ({ + kind: 'existing-file', + matchKind: 'literal-basename', + relativePath: `docs/${query}-guide-${index}.md` + })) + ) + } + }) + + it.each(['a', 'ab'])('keeps exact short filename %s ahead of search', (query) => { + for (const relativePath of [query, `docs/${query}`]) { + const files = readyFiles([`${query}-guide.md`, relativePath]) + expect(classifyTabEntryQuery(query, files)).toEqual({ + kind: 'existing-file', + matchKind: relativePath === query ? 'exact-path' : 'exact-basename', + relativePath + }) + } + }) + + it.each(['文', '工具'])('keeps short CJK filename matches ahead of search for %s', (query) => { + expect(classifyTabEntryQuery(query, readyFiles([`docs/${query}指南.md`]))).toEqual({ + kind: 'existing-file', + matchKind: 'literal-basename', + relativePath: `docs/${query}指南.md` + }) + }) + + it('reserves search when literal filenames fill the list', () => { + const files = readyFiles(['button.tsx', 'button.css', 'button.test.ts', 'button.md']) + expect(getTabEntryOptions('button', files).map((option) => option.classification.kind)).toEqual( + ['existing-file', 'existing-file', 'existing-file', 'search'] + ) + expect(classifyTabEntryQuery('button', files).kind).toBe('existing-file') + }) +})