diff --git a/src/renderer/src/components/quick-open-fuzzy-match.ts b/src/renderer/src/components/quick-open-fuzzy-match.ts new file mode 100644 index 00000000000..ca21e54b7e1 --- /dev/null +++ b/src/renderer/src/components/quick-open-fuzzy-match.ts @@ -0,0 +1,294 @@ +import { + hasIdentifierTransitionMatchBeforeSeparator, + isPathSeparator +} from './quick-open-word-boundaries' + +export type QuickOpenMatchCandidate = { + lowerPath: string + lowerFilename: string + wordStarts: Uint8Array +} + +export type PreparedQuickOpenQuery = { + normalized: string + /** Query with ` `/`_`/`-` stripped; null when the query has none of them. */ + compactFilename: string | null + hasIdentifierSeparator: boolean + hasSpace: boolean +} + +export function prepareQuickOpenQuery(normalized: string): PreparedQuickOpenQuery { + // Why: spaces, hyphens, and underscores are equivalent human separators, so + // camelCase basenames earn the filename boost for all three query styles. + return { + normalized, + compactFilename: /[ _-]/.test(normalized) ? normalized.replace(/[ _-]/g, '') : null, + hasIdentifierSeparator: normalized.includes('_') || normalized.includes('-'), + hasSpace: normalized.includes(' ') + } +} + +/** + * Fuzzy subsequence match with VS Code-like word separators: + * - Spaces in the query match path separators (`_`, `-`, `/`, `.`) or a + * zero-width camelCase / word boundary. + * - Hyphens and underscores are interchangeable identifier separators. + * - Word-start hits score better so basename/token starts rank above distant + * character soup. + */ +export function fuzzyMatchIndexedFile( + query: PreparedQuickOpenQuery, + file: QuickOpenMatchCandidate +): number | null { + let score = matchQueryFromAnchor(query.normalized, file, -1) + if (query.compactFilename !== null) { + // Why: greedy matching can anchor query tokens in a same-named ancestor + // directory (user-profile vs user/UserProfile/index.tsx, or + // tab_bar_create_entry vs tab-bar/TabBarCreateEntry.tsx) and then dead-end + // at the next '/'. Re-anchor at segment boundaries so each candidate competes + // from its best interpretation, whether the meaningful name is the basename + // or an inner directory. The optimal anchor for any match is the '/' just + // before the first matched char, so only segments that actually contain a + // first-char match are worth re-anchoring; that keeps this bounded on deep + // 100k-file paths. Plain queries skip this to preserve the previous scorer's + // whole-path-only ranking. + const path = file.lowerPath + const first = query.normalized[0] + let slashIdx = -1 + let segmentMatched = false + for (let i = 0; i < path.length; i++) { + // Why: test the first-char match before consuming a '/' boundary so a + // '/'-leading query (typed '/', or '\' after normalization) still re-anchors + // — its first matched char is itself a '/', anchored at the preceding slash. + if (!segmentMatched && slashIdx !== -1 && searchCharactersMatch(path[i], first)) { + segmentMatched = true + const anchoredScore = matchQueryFromAnchor(query.normalized, file, slashIdx) + if (anchoredScore !== null && (score === null || anchoredScore < score)) { + score = anchoredScore + } + } + if (path[i] === '/') { + slashIdx = i + segmentMatched = false + } + } + } + if (score === null) { + return null + } + + // Filename substring boost: allow human separators to hit equivalent names + // (`product detail` / `product-detail` → product_detail.dart). + if (filenameMatchesQuery(file.lowerFilename, query)) { + score -= 100 + } + + return score +} + +function matchQueryFromAnchor( + query: string, + file: QuickOpenMatchCandidate, + anchorIndex: number +): number | null { + const path = file.lowerPath + const wordStarts = file.wordStarts + let qi = 0 + let score = 0 + let lastMatchIdx = anchorIndex + let hasMatchedChar = false + let requireWordStart = false + + while (qi < query.length) { + if (query[qi] === ' ') { + qi++ + // Trailing space should not occur after normalize, but stay safe. + if (qi >= query.length) { + break + } + + const nextIdx = lastMatchIdx + 1 + if (nextIdx < path.length && isPathSeparator(path[nextIdx])) { + // Separator runs are a single conceptual word break for spaced queries. + score -= 5 + let sepIdx = nextIdx + while (sepIdx + 1 < path.length && isPathSeparator(path[sepIdx + 1])) { + sepIdx++ + } + // Why: a pasted literal separator run ("Meeting - 2026") puts a query + // separator right after the space. The consumed path run already covers + // it, so absorb following query separators/spaces instead of dead-ending; + // stays strict for '/' and '.' (only absorbed if the run holds one). + while (qi < query.length && pathRunCoversQueryChar(path, nextIdx, sepIdx, query[qi])) { + qi++ + } + lastMatchIdx = sepIdx + requireWordStart = false + } else { + // Why: "Product Detail" vs ProductDetail — no separator char, only a + // camelCase boundary. Force the next letter onto a word start. + requireWordStart = true + } + continue + } + + if ( + isIdentifierSeparator(query[qi]) && + qi + 1 < query.length && + hasMatchedChar && + // Why: a zero-width case transition can only sit between two non-separator + // chars. When the last matched char is any separator — a space-consumed + // '/'/'.' run or a literal '-'/'_'/space (e.g. the doubled "a--b") — there + // is nothing to bridge; firing here would let a deeper same-letter camelCase + // decoy authorize a bridge whose match then lands on that separator. + !isPathSeparator(path[lastMatchIdx]) && + hasIdentifierTransitionMatchBeforeSeparator(path, wordStarts, lastMatchIdx + 1, query[qi + 1]) + ) { + // Why: typed separators also bridge zero-width case transitions. Rank + // literal (0) < swapped separator (+1) < case transition (+2) so the + // order stays deterministic regardless of the filename boost. + score += 2 + qi++ + requireWordStart = true + continue + } + + const wanted = query[qi] + let matched = false + for (let ti = lastMatchIdx + 1; ti < path.length; ti++) { + if (!searchCharactersMatch(path[ti], wanted)) { + continue + } + if (requireWordStart && wordStarts[ti] !== 1) { + continue + } + + const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 + score += gap + // Why: equivalent separators should match, but the literally typed form + // should rank first when both filename variants exist. + if (path[ti] !== wanted) { + score++ + } + // Why: skip path index 0 so a leading `s` in `src/...` stays score 0 + // (previous scorer only bonused matches after `/` `.` `-`). + if (ti > 0 && wordStarts[ti] === 1) { + score -= 5 + } + lastMatchIdx = ti + qi++ + hasMatchedChar = true + requireWordStart = false + matched = true + break + } + + if (!matched) { + // Why: a trailing separator mid-typing ("product-") must not blank out + // results that a trailing space would keep; literal was tried above. + if (isIdentifierSeparator(wanted) && qi === query.length - 1 && hasMatchedChar) { + score += 2 + qi++ + continue + } + return null + } + } + + return score +} + +function filenameMatchesQuery(lowerFilename: string, query: PreparedQuickOpenQuery): boolean { + if ( + lowerFilename.includes(query.normalized) || + (query.hasIdentifierSeparator && + includesWithIdentifierSeparatorEquivalence(lowerFilename, query.normalized)) + ) { + return true + } + if (!query.compactFilename) { + return false + } + // Why: users type words with spaces; basenames use `_` / `-` / camelCase. Only + // a spaced query may bridge `/`/`.`; a typed `-`/`_` keeps them distinct so it + // doesn't wrongly boost a dot-separated basename (product-detail vs a.b.c). + return includesIgnoringSeparators(lowerFilename, query.compactFilename, query.hasSpace) +} + +function includesWithIdentifierSeparatorEquivalence(value: string, wanted: string): boolean { + const lastStart = value.length - wanted.length + for (let start = 0; start <= lastStart; start++) { + let offset = 0 + while (offset < wanted.length && searchCharactersMatch(value[start + offset], wanted[offset])) { + offset++ + } + if (offset === wanted.length) { + return true + } + } + return false +} + +function includesIgnoringSeparators(value: string, wanted: string, crossPathSeparators: boolean): boolean { + for (let start = 0; start < value.length; start++) { + if (value[start] !== wanted[0]) { + continue + } + let valueIndex = start + let wantedIndex = 0 + while (valueIndex < value.length && wantedIndex < wanted.length) { + const ch = value[valueIndex] + // Why: always ignore `-`/`_`/space (the separators the compact query + // dropped); ignore `/`/`.` only for spaced queries, and never when the + // value char is the one we're currently matching (an extension dot in the + // query must still land on the basename's dot). + const ignorable = + isIdentifierSeparator(ch) || ch === ' ' || (crossPathSeparators && (ch === '/' || ch === '.')) + if (ignorable && ch !== wanted[wantedIndex]) { + valueIndex++ + continue + } + if (ch !== wanted[wantedIndex]) { + break + } + valueIndex++ + wantedIndex++ + } + if (wantedIndex === wanted.length) { + return true + } + } + return false +} + +function pathRunCoversQueryChar( + path: string, + from: number, + to: number, + queryChar: string +): boolean { + if (queryChar === ' ') { + return true + } + for (let index = from; index <= to; index++) { + if (searchCharactersMatch(path[index], queryChar)) { + return true + } + } + return false +} + +function searchCharactersMatch(valueChar: string, queryChar: string): boolean { + // Why: `-`/`_`/space are equivalent human separators, so a typed identifier + // separator also matches a literal space in a filename ("Meeting Notes.md"). + // `/` and `.` stay distinct so path/extension boundaries aren't crossed. + return ( + valueChar === queryChar || + (isIdentifierSeparator(queryChar) && + (isIdentifierSeparator(valueChar) || valueChar === ' ')) + ) +} + +function isIdentifierSeparator(ch: string): boolean { + return ch === '_' || ch === '-' +} diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index b38f2b1c44a..4fa77bf1948 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -90,7 +90,8 @@ describe('quick-open-search', () => { 'legacy\\provider\\raw-path.ts' ] - expect(prepareQuickOpenFiles(files)).toEqual([ + const indexed = prepareQuickOpenFiles(files) + expect(indexed).toMatchObject([ { path: 'src/renderer/src/components/QuickOpen.tsx', lowerPath: 'src/renderer/src/components/quickopen.tsx', @@ -116,6 +117,17 @@ describe('quick-open-search', () => { inputIndex: 3 } ]) + + // Why: camelCase "QuickOpen" / "App" must keep word starts after lowercasing. + const quickOpen = 'src/renderer/src/components/QuickOpen.tsx' + const quickOpenStarts = Array.from(indexed[0].wordStarts) + expect(quickOpenStarts[0]).toBe(1) + expect(quickOpenStarts[quickOpen.indexOf('Q')]).toBe(1) + expect(quickOpenStarts[quickOpen.indexOf('O')]).toBe(1) + expect(quickOpenStarts[quickOpen.indexOf('u')]).toBe(0) + + const appPath = 'packages/windows-origin/src/App.tsx' + expect(indexed[1].wordStarts[appPath.indexOf('A')]).toBe(1) }) it('returns no results for non-positive limits', () => { @@ -135,6 +147,9 @@ describe('quick-open-search', () => { }, get lowerFilename(): string { throw new Error('oversized queries must not scan indexed filenames') + }, + get wordStarts(): Uint8Array { + throw new Error('oversized queries must not scan word starts') } } as QuickOpenIndexedFile @@ -163,4 +178,376 @@ describe('quick-open-search', () => { 'src/components/ButtonGroup.tsx' ]) }) + + it('matches spaced human queries to snake_case basenames like VS Code', () => { + const files = prepareQuickOpenFiles([ + 'lib/screens/product_detail.dart', + 'lib/screens/product_list.dart', + 'lib/models/order_summary.dart', + 'docs/unrelated.txt' + ]) + + const paths = rankQuickOpenFiles('Product Detail', files).map((item) => item.path) + expect(paths).toContain('lib/screens/product_detail.dart') + expect(paths[0]).toBe('lib/screens/product_detail.dart') + }) + + it('matches spaced queries across kebab-case, snake_case, and camelCase names', () => { + const files = prepareQuickOpenFiles([ + 'ui/product-detail.tsx', + 'ui/product_detail.tsx', + 'ui/ProductDetail.tsx', + 'ui/cart.tsx' + ]) + + const paths = rankQuickOpenFiles('product detail', files).map((item) => item.path) + expect(paths).toEqual([ + 'ui/product-detail.tsx', + 'ui/product_detail.tsx', + 'ui/ProductDetail.tsx' + ]) + }) + + it('matches spaced and identifier-separated queries to PascalCase names', () => { + const files = prepareQuickOpenFiles(['ui/ProductDetail.tsx', 'ui/OrderDetail.tsx']) + + for (const query of ['product detail', 'product-detail', 'product_detail']) { + expect(rankQuickOpenFiles(query, files).map((item) => item.path)).toEqual([ + 'ui/ProductDetail.tsx' + ]) + } + }) + + it('gives PascalCase filenames the same boost for spaced and separator queries', () => { + const files = prepareQuickOpenFiles([ + 'archive/old-product-detail-backup.txt', + 'ui/ProductDetail.tsx' + ]) + + for (const query of ['product detail', 'product-detail', 'product_detail']) { + const results = rankQuickOpenFiles(query, files) + expect(results.map((item) => item.path)).toEqual([ + 'archive/old-product-detail-backup.txt', + 'ui/ProductDetail.tsx' + ]) + // Why: without boost parity the camelCase file trailed by ~100 points + // for `-`/`_` queries while staying competitive for spaced ones. + expect(results[1].score).toBeLessThan(-100) + } + }) + + it('keeps camelCase matches while typing a trailing separator', () => { + const files = prepareQuickOpenFiles(['lib/product-x.ts', 'ui/ProductDetail.tsx']) + + expect(rankQuickOpenFiles('product-', files).map((item) => item.path)).toEqual([ + 'lib/product-x.ts', + 'ui/ProductDetail.tsx' + ]) + expect(rankQuickOpenFiles('product_', files).map((item) => item.path)).toEqual([ + 'lib/product-x.ts', + 'ui/ProductDetail.tsx' + ]) + }) + + it('requires a literal separator for leading separator queries', () => { + const files = prepareQuickOpenFiles(['ui/ProductDetail.tsx', 'ui/-detail.tsx']) + + expect(rankQuickOpenFiles('-detail', files).map((item) => item.path)).toEqual([ + 'ui/-detail.tsx' + ]) + }) + + it('treats hyphens and underscores as interchangeable identifier separators', () => { + const variants = prepareQuickOpenFiles([ + 'lib/product-detail.dart', + 'lib/product_detail.dart', + 'lib/ProductDetail.tsx', + 'lib/order_detail.dart' + ]) + + expect(rankQuickOpenFiles('product-detail', variants).map((item) => item.path)).toEqual([ + 'lib/product-detail.dart', + 'lib/product_detail.dart', + 'lib/ProductDetail.tsx' + ]) + expect(rankQuickOpenFiles('product_detail', variants).map((item) => item.path)).toEqual([ + 'lib/product_detail.dart', + 'lib/product-detail.dart', + 'lib/ProductDetail.tsx' + ]) + }) + + it('keeps path and extension separators distinct from identifier separators', () => { + const files = prepareQuickOpenFiles([ + 'lib/product/detail.dart', + 'lib/product.detail.dart', + 'lib/product_detail.dart' + ]) + + expect(rankQuickOpenFiles('product-detail', files).map((item) => item.path)).toEqual([ + 'lib/product_detail.dart' + ]) + }) + + it('recognizes capitalized words after uppercase acronym runs', () => { + const files = prepareQuickOpenFiles(['src/APIClient.ts', 'src/HTTPServer.ts']) + + expect(rankQuickOpenFiles('api client', files).map((item) => item.path)).toEqual([ + 'src/APIClient.ts' + ]) + expect(rankQuickOpenFiles('http server', files).map((item) => item.path)).toEqual([ + 'src/HTTPServer.ts' + ]) + }) + + it('recognizes words at letter-number transitions', () => { + const files = prepareQuickOpenFiles(['notes/MeetingNotes2026.md', 'src/OAuth2Client.ts']) + + // Why: version/year suffixes and numbered identifiers are common filename + // words, so every supported query separator must reach their zero-width boundary. + for (const query of ['meeting notes 2026', 'meeting-notes-2026', 'meeting_notes_2026']) { + expect(rankQuickOpenFiles(query, files)[0]?.path).toBe('notes/MeetingNotes2026.md') + } + for (const query of ['oauth 2 client', 'oauth-2-client', 'oauth_2_client']) { + expect(rankQuickOpenFiles(query, files)[0]?.path).toBe('src/OAuth2Client.ts') + } + }) + + it('keeps word starts aligned when Unicode lowercasing expands a character', () => { + const files = prepareQuickOpenFiles(['İ/FooBar.ts']) + + expect(files[0].lowerPath).toBe('i̇/foobar.ts') + expect(files[0].wordStarts[files[0].lowerPath.indexOf('b')]).toBe(1) + expect(rankQuickOpenFiles('foo bar', files).map((item) => item.path)).toEqual(['İ/FooBar.ts']) + }) + + it('matches spaced path segments to directory separators', () => { + const files = prepareQuickOpenFiles([ + 'src/components/Button.tsx', + 'src/routes/About.tsx', + 'packages/other/Button.tsx' + ]) + + expect(rankQuickOpenFiles('src components button', files).map((item) => item.path)).toEqual([ + 'src/components/Button.tsx' + ]) + }) + + it('matches partial tokens with spaces (prod det → product_detail)', () => { + const files = prepareQuickOpenFiles([ + 'lib/product_detail.dart', + 'lib/production_settings.dart', + 'lib/profile.dart' + ]) + + const paths = rankQuickOpenFiles('prod det', files).map((item) => item.path) + expect(paths).toContain('lib/product_detail.dart') + expect(paths[0]).toBe('lib/product_detail.dart') + }) + + it('collapses internal whitespace in queries', () => { + const files = prepareQuickOpenFiles(['lib/product_detail.dart']) + + expect(rankQuickOpenFiles('Product Detail', files).map((item) => item.path)).toEqual([ + 'lib/product_detail.dart' + ]) + }) + + it('still matches continuous snake_case queries without spaces', () => { + const files = prepareQuickOpenFiles(['lib/product_detail.dart', 'lib/product_list.dart']) + + expect(rankQuickOpenFiles('product_detail', files).map((item) => item.path)).toEqual([ + 'lib/product_detail.dart' + ]) + }) + + it('matches separator queries to camelCase files shadowed by same-named directories', () => { + const files = prepareQuickOpenFiles([ + 'src/components/tab-bar/tab-create-entry-action.ts', + 'src/components/tab-bar/TabBarCreateEntry.tsx' + ]) + + // Why: greedy anchoring in the `tab-bar/` directory must not dead-end the + // camelCase basename for typed-separator queries; all three separator + // styles must reach the same file. + for (const query of ['tab_bar_create_entry', 'tab-bar-create-entry', 'tab bar create entry']) { + const paths = rankQuickOpenFiles(query, files).map((item) => item.path) + expect(paths[0]).toBe('src/components/tab-bar/TabBarCreateEntry.tsx') + } + }) + + it('keeps the literally typed separator ranked first under same-named directories', () => { + const files = prepareQuickOpenFiles([ + 'src/panel/product/productDetail.dart', + 'src/order/product/product-detail.dart' + ]) + + // Why: both candidates must compete from the basename anchor, or the + // camelCase file wins by dodging the directory-crossing gap penalty. + expect(rankQuickOpenFiles('product-detail', files).map((item) => item.path)).toEqual([ + 'src/order/product/product-detail.dart', + 'src/panel/product/productDetail.dart' + ]) + }) + + it('reaches camelCase names nested in a directory shadowed by an ancestor', () => { + const files = prepareQuickOpenFiles(['src/components/user/UserProfile/index.tsx']) + + // Why: the meaningful token lives in an inner directory (Component/index.tsx + // layout), so anchoring only at the basename ('index.tsx') dead-ends. Every + // separator style must still reach it. + for (const query of ['user-profile', 'user_profile', 'user profile']) { + expect(rankQuickOpenFiles(query, files).map((item) => item.path)).toEqual([ + 'src/components/user/UserProfile/index.tsx' + ]) + } + }) + + it('boosts camelCase filenames for identifier-separated queries with an extension', () => { + const files = prepareQuickOpenFiles(['src/fooBar.ts', 'src/unrelated-foo-bxar.ts']) + + // Why: an extension dot in the query must not skip past the basename dot and + // strip the camelCase file's filename boost, letting junk outrank it. Covers + // both the identifier-separated form and the spaced form (which reaches the + // dot via the `crossPathSeparators` gate). + for (const query of ['foo-bar.ts', 'foo bar.ts']) { + const results = rankQuickOpenFiles(query, files) + expect(results[0].path).toBe('src/fooBar.ts') + expect(results[0].score).toBeLessThan(-100) + } + }) + + it('treats a typed identifier separator as equivalent to a literal space in a name', () => { + const files = prepareQuickOpenFiles(['notes/Meeting Notes 2026.md']) + + // Why: spaces, `_`, and `-` are equivalent separators in both directions, so + // a hyphen/underscore query still reaches a space-named file. + for (const query of ['meeting-notes', 'meeting_notes', 'meeting notes']) { + expect(rankQuickOpenFiles(query, files).map((item) => item.path)).toEqual([ + 'notes/Meeting Notes 2026.md' + ]) + } + }) + + it('re-anchors nested-directory matches for slash- and backslash-leading queries', () => { + const files = prepareQuickOpenFiles(['src/components/user/UserProfile/index.tsx']) + + // Why: a leading '/' (or '\' after normalization) makes the first matched + // char a separator; segment re-anchoring must still fire so the file isn't + // dropped entirely. + for (const query of ['/user-profile', '\\user-profile', '/user_profile']) { + expect(rankQuickOpenFiles(query, files).map((item) => item.path)).toEqual([ + 'src/components/user/UserProfile/index.tsx' + ]) + } + }) + + it('does not boost dot-separated basenames for identifier-separated queries', () => { + const files = prepareQuickOpenFiles([ + 'apps/product-suite/product.detail.dart', + 'apps/product-suite/product_detail.dart' + ]) + + // Why: `product-detail` matches via the hyphenated directory, but `.` is not + // an identifier separator, so the dot-separated basename must not earn the + // -100 filename boost and leapfrog the real product_detail.dart. + const results = rankQuickOpenFiles('product-detail', files) + expect(results[0].path).toBe('apps/product-suite/product_detail.dart') + expect(results[0].score).toBeLessThan(-100) + // The dot-separated basename must stay unboosted (boost would put it near + // -91); anything above -50 proves the -100 filename boost was not applied. + const dotVariant = results.find((r) => r.path === 'apps/product-suite/product.detail.dart') + expect(dotVariant?.score).toBeGreaterThan(-50) + + // Why: a spaced query may bridge `.` (space matches any separator), so it + // still boosts the dot-separated basename. + expect( + rankQuickOpenFiles('product detail', prepareQuickOpenFiles(['lib/product.detail.dart']))[0] + .score + ).toBeLessThan(-100) + }) + + it('matches names with a spaced separator run and their exact-filename paste', () => { + const files = prepareQuickOpenFiles(['notes/Meeting - 2026.md', 'audio/a _ b.mp3']) + + // Why: a query space consumes the whole path separator run; a literal + // separator typed right after (" - ", " _ ") must not dead-end, so pasting + // an exact filename or typing "Meeting - 2026" still finds the file. + expect(rankQuickOpenFiles('meeting - 2026', files)[0]?.path).toBe('notes/Meeting - 2026.md') + expect(rankQuickOpenFiles('Meeting - 2026.md', files)[0]?.path).toBe('notes/Meeting - 2026.md') + expect(rankQuickOpenFiles('a _ b', files)[0]?.path).toBe('audio/a _ b.mp3') + }) + + it('does not let a space before a typed separator bridge across / or .', () => { + // Why: after a space consumes a `/` or `.` run, a typed `-`/`_` must not + // treat the separator-induced word start as a camelCase transition, or it + // would cross a boundary the spec keeps distinct (and win via the boost). + expect( + rankQuickOpenFiles( + 'user _profile', + prepareQuickOpenFiles(['src/user_my_profile.ts', 'src/user.profile.ts']) + ).map((item) => item.path) + ).toEqual(['src/user_my_profile.ts']) + expect(rankQuickOpenFiles('a -b', prepareQuickOpenFiles(['src/a/b.ts']))).toEqual([]) + expect(rankQuickOpenFiles('foo -ts', prepareQuickOpenFiles(['src/foo.ts']))).toEqual([]) + + // Why: a deeper same-letter camelCase transition ("Page", "tsTest") must not + // authorize a bridge whose match then lands on the /- or .-induced word start. + expect( + rankQuickOpenFiles( + 'user -profile', + prepareQuickOpenFiles(['src/user/profilePage.ts', 'src/user/profile_page.ts']) + ) + ).toEqual([]) + expect(rankQuickOpenFiles('foo -ts', prepareQuickOpenFiles(['src/foo.tsTest.ts']))).toEqual([]) + + // Why: a genuine camelCase/acronym transition (prior char is a letter) must + // still bridge for a space-then-separator query. + expect( + rankQuickOpenFiles('product -detail', prepareQuickOpenFiles(['ui/ProductDetail.tsx'])).map( + (item) => item.path + ) + ).toEqual(['ui/ProductDetail.tsx']) + expect( + rankQuickOpenFiles('api -client', prepareQuickOpenFiles(['src/APIClient.ts'])).map( + (item) => item.path + ) + ).toEqual(['src/APIClient.ts']) + }) + + it('does not blank a spaced-separator query mid-keystroke', () => { + const files = prepareQuickOpenFiles(['notes/Meeting - 2026.md']) + + // Why: results must not vanish between "meeting -" and "meeting - 2". + for (const query of ['meeting -', 'meeting - ', 'meeting - 2', 'meeting - 2026']) { + expect(rankQuickOpenFiles(query, files).map((item) => item.path)).toEqual([ + 'notes/Meeting - 2026.md' + ]) + } + }) + + it('does not bridge a doubled typed separator onto a single-separator name', () => { + // Why: a case transition can't sit right after a matched separator, so "a--b" + // must not bridge onto "a-b" — even when a deeper same-letter camelCase tail + // ("a-bcBx") would otherwise authorize the bridge and land it on the '-'. + expect(rankQuickOpenFiles('a--b', prepareQuickOpenFiles(['x/a-b.ts']))).toEqual([]) + expect(rankQuickOpenFiles('a_-b', prepareQuickOpenFiles(['x/a_b.ts']))).toEqual([]) + expect(rankQuickOpenFiles('a--b', prepareQuickOpenFiles(['x/a-bcBx.ts']))).toEqual([]) + expect(rankQuickOpenFiles('a_-b', prepareQuickOpenFiles(['x/a_bcBx.ts']))).toEqual([]) + }) + + it('does not let a bare separator query match a file without separators', () => { + const files = prepareQuickOpenFiles(['a.ts']) + + // Why: trailing-separator forgiveness only applies once a real char matched; + // a lone `-`/`_` must not match every file. + expect(rankQuickOpenFiles('-', files)).toEqual([]) + expect(rankQuickOpenFiles('_', files)).toEqual([]) + }) + + it('does not confuse a valid negative-one score with no match', () => { + expect(rankQuickOpenFiles('ab', prepareQuickOpenFiles(['axxx_b/file.txt']))).toEqual([ + { path: 'axxx_b/file.txt', score: -1 } + ]) + }) }) diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index 5413c151193..7f64718d39c 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -1,4 +1,5 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import { fuzzyMatchIndexedFile, prepareQuickOpenQuery } from './quick-open-fuzzy-match' export const QUICK_OPEN_RESULT_LIMIT = 50 export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024 @@ -7,6 +8,12 @@ export type QuickOpenIndexedFile = { path: string lowerPath: string lowerFilename: string + /** + * Per lowerPath index: 1 when the char is a word start (path start, after a + * separator, or identifier transition). Built from the original-case path + * so ProductDetail stays two words after lowercasing. + */ + wordStarts: Uint8Array inputIndex: number } @@ -17,12 +24,15 @@ export type QuickOpenSearchResult = { export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexedFile[] { return files.map((path, inputIndex) => { + // Why: Quick Open presents slash-normalized paths even on Windows. const searchPath = path.replace(/\\/g, '/') const lastSlash = searchPath.lastIndexOf('/') + const { lowerPath, wordStarts } = buildSearchPathIndex(searchPath) return { path, - lowerPath: searchPath.toLowerCase(), + lowerPath, lowerFilename: searchPath.slice(lastSlash + 1).toLowerCase(), + wordStarts, inputIndex } }) @@ -48,16 +58,18 @@ export function rankQuickOpenFiles( } // Why: Quick Open presents slash-normalized paths even on Windows; users - // still naturally type backslashes in path queries. - const normalizedQuery = query.trim().replace(/\\/g, '/').toLowerCase() + // still naturally type backslashes in path queries. Collapse internal + // whitespace so "Product Detail" behaves like "Product Detail". + const normalizedQuery = query.trim().replace(/\\/g, '/').toLowerCase().replace(/\s+/g, ' ') if (!normalizedQuery) { return files.slice(0, limit).map((file) => ({ path: file.path, score: 0 })) } + const preparedQuery = prepareQuickOpenQuery(normalizedQuery) const results: QuickOpenRankedResult[] = [] for (const file of files) { - const score = fuzzyMatchIndexedFile(normalizedQuery, file) - if (score === -1) { + const score = fuzzyMatchIndexedFile(preparedQuery, file) + if (score === null) { continue } @@ -67,39 +79,63 @@ export function rankQuickOpenFiles( return results.map(({ path, score }) => ({ path, score })) } -function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number { - let qi = 0 - let score = 0 - let lastMatchIdx = -1 - - for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { - if (file.lowerPath[ti] === query[qi]) { - // Preserve the existing Quick Open score semantics while avoiding - // repeated lowercase work for each candidate on every keystroke. - const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 - score += gap - if ( - ti > 0 && - (file.lowerPath[ti - 1] === '/' || - file.lowerPath[ti - 1] === '.' || - file.lowerPath[ti - 1] === '-') - ) { - score -= 5 - } - lastMatchIdx = ti - qi++ +/** + * Word starts from the original-case slash-normalized path so identifier + * boundaries survive lowercasing in lowerPath. + */ +function buildSearchPathIndex(searchPath: string): { + lowerPath: string + wordStarts: Uint8Array +} { + const lowerPath = searchPath.toLowerCase() + const starts = new Uint8Array(lowerPath.length) + let lowerIndex = 0 + for (let i = 0; i < searchPath.length; i++) { + // Why: this loop touches every character in repositories that can exceed + // 100k paths; read each code unit once instead of repeatedly decoding it. + const currCode = searchPath.charCodeAt(i) + const prevCode = i > 0 ? searchPath.charCodeAt(i - 1) : -1 + const nextCode = i + 1 < searchPath.length ? searchPath.charCodeAt(i + 1) : -1 + if ( + i === 0 || + isPathSeparatorCode(prevCode) || + (isAsciiLetterCode(prevCode) && isAsciiDigitCode(currCode)) || + (isAsciiDigitCode(prevCode) && isAsciiLetterCode(currCode)) || + (isAsciiLowerOrDigitCode(prevCode) && isAsciiUpperCode(currCode)) || + (isAsciiUpperCode(prevCode) && isAsciiUpperCode(currCode) && isAsciiLowerCode(nextCode)) + ) { + starts[lowerIndex] = 1 } + // Why: Unicode lowercasing can expand one source character, so boundary + // offsets must advance in lowerPath's coordinate space. ASCII skips the + // per-character lowercase allocation; 100k-file indexing is ~2x faster. + lowerIndex += currCode < 128 ? 1 : searchPath[i].toLowerCase().length } + return { lowerPath, wordStarts: starts } +} - if (qi < query.length) { - return -1 - } +function isAsciiLowerOrDigitCode(code: number): boolean { + return (code >= 48 && code <= 57) || (code >= 97 && code <= 122) +} - if (file.lowerFilename.includes(query)) { - score -= 100 - } +function isAsciiDigitCode(code: number): boolean { + return code >= 48 && code <= 57 +} + +function isAsciiLetterCode(code: number): boolean { + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) +} + +function isAsciiUpperCode(code: number): boolean { + return code >= 65 && code <= 90 +} + +function isAsciiLowerCode(code: number): boolean { + return code >= 97 && code <= 122 +} - return score +function isPathSeparatorCode(code: number): boolean { + return code === 47 || code === 95 || code === 45 || code === 46 || code === 32 } type QuickOpenRankedResult = QuickOpenSearchResult & { diff --git a/src/renderer/src/components/quick-open-word-boundaries.ts b/src/renderer/src/components/quick-open-word-boundaries.ts new file mode 100644 index 00000000000..4e1b2a42c9d --- /dev/null +++ b/src/renderer/src/components/quick-open-word-boundaries.ts @@ -0,0 +1,29 @@ +export function isPathSeparator(ch: string): boolean { + return ch === '/' || ch === '_' || ch === '-' || ch === '.' || ch === ' ' +} + +export function hasIdentifierTransitionMatchBeforeSeparator( + path: string, + wordStarts: Uint8Array, + startIndex: number, + wanted: string +): boolean { + for (let index = startIndex; index < path.length; index++) { + if (isPathSeparator(path[index])) { + return false + } + // Why: a typed `-`/`_` bridges only a zero-width case transition, never a + // real separator. When a preceding query space consumed a `/` or `.` run, + // startIndex sits on the char after it — a word start induced by that + // separator, not a camelCase change — so require the prior char to be a + // non-separator (a genuine intra-word boundary like ProductDetail/APIClient). + if ( + wordStarts[index] === 1 && + path[index] === wanted && + (index === 0 || !isPathSeparator(path[index - 1])) + ) { + return true + } + } + return false +} diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index dd2772f36ea..ef817f18f76 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Input } from '@/components/ui/input' import { useRuntimeFileListForWorktree } from '../quick-open-file-list' +import { prepareQuickOpenFiles } from '../quick-open-search' import { getTabEntryOptions, type TabCreateEntryArgs } from './tab-create-entry-action' import { findMatchingTabAgentLaunchOptions, @@ -104,14 +105,17 @@ export default function TabBarCreateEntry({ () => findMatchingTabCreateMenuOptions(query, menuOptions), [menuOptions, query] ) + // Why: repositories can contain 100k files; rebuilding boundary metadata on + // every keystroke causes visible renderer stalls and avoidable GC pressure. + const indexedFiles = useMemo(() => prepareQuickOpenFiles(fileList.files), [fileList.files]) const options = useMemo(() => { - const entryOptions = getTabEntryOptions(query, fileList) + const entryOptions = getTabEntryOptions(query, fileList, { indexedFiles }) if (matchingMenuOptions.length === 0) { return entryOptions } // Why: a matched create-menu action should win over a generic new-file fallback. return entryOptions.filter((option) => option.classification.kind !== 'new-file') - }, [fileList, matchingMenuOptions.length, query]) + }, [fileList, indexedFiles, matchingMenuOptions.length, query]) const matchingAgentOptions = useMemo( () => findMatchingTabAgentLaunchOptions(query, agentOptions), [agentOptions, query] 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 3dae3be6826..0ebcce1e7cf 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 @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { QUICK_OPEN_QUERY_MAX_BYTES } from '../quick-open-search' +import { QUICK_OPEN_QUERY_MAX_BYTES, prepareQuickOpenFiles } from '../quick-open-search' import { classifyTabEntryQuery, getTabEntryOptions, @@ -163,6 +163,24 @@ describe('tab create entry classification', () => { }) }) + it('uses a prepared file index without rereading the file list', () => { + const indexedFiles = prepareQuickOpenFiles(['lib/product_detail.dart']) + const fileList = { + get files(): string[] { + throw new Error('prepared searches must not rebuild the file index') + }, + loading: false, + loadError: null + } + + expect(getTabEntryOptions('product detail', fileList, { indexedFiles })[0]).toMatchObject({ + classification: { + kind: 'existing-file', + relativePath: 'lib/product_detail.dart' + } + }) + }) + it('offers both exact file and URL actions for host-like filenames', () => { expect( getTabEntryOptions('example.com', readyFiles(['example.com'])).map( 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 5207fc9d991..8ff444ce576 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 @@ -1,4 +1,8 @@ -import { isQuickOpenQueryTooLarge, prepareQuickOpenFiles } from '../quick-open-search' +import { + isQuickOpenQueryTooLarge, + prepareQuickOpenFiles, + type QuickOpenIndexedFile +} from '../quick-open-search' import type { RuntimeFileListState } from '../quick-open-file-list' import { translate } from '@/i18n/i18n' import { findExistingFileMatches, isLikelyNewFileIntent } from './tab-create-entry-file-matches' @@ -29,12 +33,17 @@ export type TabEntryOption = { id: string } +type TabEntryOptionsConfig = { + indexedFiles?: readonly QuickOpenIndexedFile[] + limit?: number +} + export function classifyTabEntryQuery( query: string, fileList: RuntimeFileListState ): TabEntryClassification { return ( - getTabEntryOptions(query, fileList, 1)[0]?.classification ?? { + getTabEntryOptions(query, fileList, { limit: 1 })[0]?.classification ?? { kind: 'empty', message: translate( 'auto.components.tab.bar.tab.create.entry.classifier.5553b283ce', @@ -47,7 +56,7 @@ export function classifyTabEntryQuery( export function getTabEntryOptions( query: string, fileList: RuntimeFileListState, - limit = 4 + { indexedFiles, limit = 4 }: TabEntryOptionsConfig = {} ): TabEntryOption[] { if (isQuickOpenQueryTooLarge(query)) { return [ @@ -109,7 +118,7 @@ export function getTabEntryOptions( } const existingFiles = findExistingFileMatches( trimmed, - prepareQuickOpenFiles(fileList.files), + indexedFiles ?? prepareQuickOpenFiles(fileList.files), Math.max(limit, 1) ) const exactExistingFiles = existingFiles.filter((file) => file.matchKind !== 'fuzzy')