mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
Tab opening ranking (#23435)
* Add literal-basename file matching for tab opening - Distinguish literal filename matches from fuzzy matches - Prioritize search for short tokens (1-2 Latin characters) - Sort literal matches naturally instead of by fuzzy score - Reserve search option even when literal matches fill results * Keep literal-basename file matches in tab entry options Ensure literal-basename file matches appear in tab creation suggestions as fallback options when exact-basename matches exist, improving file discovery for users.
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>()
|
||||
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<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user