From 882afaa2009cfab2b2da106cd84b90dad1c493d4 Mon Sep 17 00:00:00 2001 From: ferdiangunawan <49526345+ferdiangunawan@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:58:37 +0700 Subject: [PATCH 01/12] feat(quick-open): support smart separator matching Make human word queries work across common filename conventions without regressing large-repository search responsiveness. --- .../src/components/quick-open-search.test.ts | 140 +++++++++- .../src/components/quick-open-search.ts | 244 ++++++++++++++++-- .../components/tab-bar/TabBarCreateEntry.tsx | 8 +- .../tab-create-entry-classifier.test.ts | 20 +- .../tab-bar/tab-create-entry-classifier.ts | 17 +- 5 files changed, 401 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index b38f2b1c44a..ce5e91ddc90 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,127 @@ 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('treats hyphens and underscores as interchangeable identifier separators', () => { + const variants = prepareQuickOpenFiles([ + 'lib/product-detail.dart', + 'lib/product_detail.dart', + 'lib/order_detail.dart' + ]) + + expect(rankQuickOpenFiles('product-detail', variants).map((item) => item.path)).toEqual([ + 'lib/product-detail.dart', + 'lib/product_detail.dart' + ]) + expect(rankQuickOpenFiles('product_detail', variants).map((item) => item.path)).toEqual([ + 'lib/product_detail.dart', + 'lib/product-detail.dart' + ]) + }) + + 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('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('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..74d0140e301 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -7,6 +7,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 +23,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 +57,27 @@ 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 compactFilenameQuery = normalizedQuery.includes(' ') + ? normalizedQuery.replace(/ /g, '') + : null + const hasFlexibleIdentifierSeparator = + normalizedQuery.includes('_') || normalizedQuery.includes('-') const results: QuickOpenRankedResult[] = [] for (const file of files) { - const score = fuzzyMatchIndexedFile(normalizedQuery, file) - if (score === -1) { + const score = fuzzyMatchIndexedFile( + normalizedQuery, + compactFilenameQuery, + hasFlexibleIdentifierSeparator, + file + ) + if (score === null) { continue } @@ -67,41 +87,225 @@ export function rankQuickOpenFiles( return results.map(({ path, score }) => ({ path, score })) } -function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number { +/** + * 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. + */ +function fuzzyMatchIndexedFile( + query: string, + compactFilenameQuery: string | null, + hasFlexibleIdentifierSeparator: boolean, + file: QuickOpenIndexedFile +): number | null { + const path = file.lowerPath + const wordStarts = file.wordStarts let qi = 0 let score = 0 let lastMatchIdx = -1 + 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])) { + const gap = lastMatchIdx === -1 ? 0 : nextIdx - lastMatchIdx - 1 + score += gap + // 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++ + } + 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 + } + + 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 + } - 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] === '-') - ) { + // 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++ + requireWordStart = false + matched = true + break } - } - if (qi < query.length) { - return -1 + if (!matched) { + return null + } } - if (file.lowerFilename.includes(query)) { + // Filename substring boost: allow human separators to hit equivalent names + // (`product detail` / `product-detail` → product_detail.dart). + if ( + filenameMatchesQuery( + file.lowerFilename, + query, + compactFilenameQuery, + hasFlexibleIdentifierSeparator + ) + ) { score -= 100 } return score } +function filenameMatchesQuery( + lowerFilename: string, + query: string, + compactQuery: string | null, + hasFlexibleIdentifierSeparator: boolean +): boolean { + if ( + lowerFilename.includes(query) || + (hasFlexibleIdentifierSeparator && + includesWithIdentifierSeparatorEquivalence(lowerFilename, query)) + ) { + return true + } + if (!compactQuery) { + return false + } + // Why: users type words with spaces; basenames use `_` / `-` / camelCase. + return includesIgnoringSeparators(lowerFilename, compactQuery) +} + +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): 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] + if (isPathSeparator(ch)) { + valueIndex++ + continue + } + if (ch !== wanted[wantedIndex]) { + break + } + valueIndex++ + wantedIndex++ + } + if (wantedIndex === wanted.length) { + return true + } + } + return false +} + +function isPathSeparator(ch: string): boolean { + return ch === '/' || ch === '_' || ch === '-' || ch === '.' || ch === ' ' +} + +function searchCharactersMatch(valueChar: string, queryChar: string): boolean { + return ( + valueChar === queryChar || + (isIdentifierSeparator(valueChar) && isIdentifierSeparator(queryChar)) + ) +} + +function isIdentifierSeparator(ch: string): boolean { + return ch === '_' || ch === '-' +} + +/** + * 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++) { + const curr = searchPath[i] + const prev = i > 0 ? searchPath[i - 1] : '' + const next = i + 1 < searchPath.length ? searchPath[i + 1] : '' + if ( + i === 0 || + isPathSeparator(prev) || + (isAsciiLowerOrDigit(prev) && isAsciiUpper(curr)) || + (isAsciiUpper(prev) && isAsciiUpper(curr) && isAsciiLower(next)) + ) { + starts[lowerIndex] = 1 + } + // Why: Unicode lowercasing can expand one source character, so boundary + // offsets must advance in lowerPath's coordinate space. + lowerIndex += curr.toLowerCase().length + } + return { lowerPath, wordStarts: starts } +} + +function isAsciiLowerOrDigit(ch: string): boolean { + const code = ch.charCodeAt(0) + return (code >= 48 && code <= 57) || (code >= 97 && code <= 122) +} + +function isAsciiUpper(ch: string): boolean { + const code = ch.charCodeAt(0) + return code >= 65 && code <= 90 +} + +function isAsciiLower(ch: string): boolean { + const code = ch.charCodeAt(0) + return code >= 97 && code <= 122 +} + type QuickOpenRankedResult = QuickOpenSearchResult & { inputIndex: number } 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') From 0df799fad0d054c0fa515dd383183c06e45707e2 Mon Sep 17 00:00:00 2001 From: ferdiangunawan <49526345+ferdiangunawan@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:57:02 +0700 Subject: [PATCH 02/12] fix(quick-open): match typed separators to camelCase boundaries Typed `-`/`_` required a literal separator in the path, so `product-detail` could not find ProductDetail.tsx while the spaced form could. Separators now bridge zero-width case transitions with a deterministic rank: literal < swapped separator < case transition. - extend the filename boost to camelCase names for `-`/`_` queries so they rank like spaced queries instead of trailing by ~100 points - tolerate a trailing separator mid-typing ("product-") instead of blanking the result list between keystrokes --- .../src/components/quick-open-search.test.ts | 56 ++++++++++++++++++- .../src/components/quick-open-search.ts | 36 ++++++++++-- .../components/quick-open-word-boundaries.ts | 20 +++++++ 3 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/quick-open-word-boundaries.ts diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index ce5e91ddc90..92ab9c282ca 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -208,20 +208,72 @@ describe('quick-open-search', () => { ]) }) + 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/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/product-detail.dart', + 'lib/ProductDetail.tsx' ]) }) diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index 74d0140e301..3d3034a3eaa 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -1,4 +1,8 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import { + hasIdentifierTransitionMatchBeforeSeparator, + isPathSeparator +} from './quick-open-word-boundaries' export const QUICK_OPEN_RESULT_LIMIT = 50 export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024 @@ -63,8 +67,10 @@ export function rankQuickOpenFiles( if (!normalizedQuery) { return files.slice(0, limit).map((file) => ({ path: file.path, score: 0 })) } - const compactFilenameQuery = normalizedQuery.includes(' ') - ? normalizedQuery.replace(/ /g, '') + // Why: spaces, hyphens, and underscores are equivalent human separators, so + // camelCase basenames earn the filename boost for all three query styles. + const compactFilenameQuery = /[ _-]/.test(normalizedQuery) + ? normalizedQuery.replace(/[ _-]/g, '') : null const hasFlexibleIdentifierSeparator = normalizedQuery.includes('_') || normalizedQuery.includes('-') @@ -136,6 +142,21 @@ function fuzzyMatchIndexedFile( continue } + if ( + isIdentifierSeparator(query[qi]) && + qi + 1 < query.length && + lastMatchIdx >= 0 && + 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++) { @@ -166,6 +187,13 @@ function fuzzyMatchIndexedFile( } 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 && lastMatchIdx >= 0) { + score += 2 + qi++ + continue + } return null } } @@ -246,10 +274,6 @@ function includesIgnoringSeparators(value: string, wanted: string): boolean { return false } -function isPathSeparator(ch: string): boolean { - return ch === '/' || ch === '_' || ch === '-' || ch === '.' || ch === ' ' -} - function searchCharactersMatch(valueChar: string, queryChar: string): boolean { return ( valueChar === queryChar || 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..db3e03851aa --- /dev/null +++ b/src/renderer/src/components/quick-open-word-boundaries.ts @@ -0,0 +1,20 @@ +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 + } + if (wordStarts[index] === 1 && path[index] === wanted) { + return true + } + } + return false +} From 8abc36dd75e8ce8d78a4b21cf1d746da137350d7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:15:55 -0700 Subject: [PATCH 03/12] perf(quick-open): fast-path ASCII in index build; drop dead gap math --- src/renderer/src/components/quick-open-search.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index 3d3034a3eaa..5fcd5d32424 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -124,8 +124,6 @@ function fuzzyMatchIndexedFile( const nextIdx = lastMatchIdx + 1 if (nextIdx < path.length && isPathSeparator(path[nextIdx])) { - const gap = lastMatchIdx === -1 ? 0 : nextIdx - lastMatchIdx - 1 - score += gap // Separator runs are a single conceptual word break for spaced queries. score -= 5 let sepIdx = nextIdx @@ -309,8 +307,9 @@ function buildSearchPathIndex(searchPath: string): { starts[lowerIndex] = 1 } // Why: Unicode lowercasing can expand one source character, so boundary - // offsets must advance in lowerPath's coordinate space. - lowerIndex += curr.toLowerCase().length + // offsets must advance in lowerPath's coordinate space. ASCII skips the + // per-character lowercase allocation; 100k-file indexing is ~2x faster. + lowerIndex += curr.charCodeAt(0) < 128 ? 1 : curr.toLowerCase().length } return { lowerPath, wordStarts: starts } } From 257f5fa1cd809da7ea9b0f96db71979d52342675 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:30:22 -0700 Subject: [PATCH 04/12] fix(quick-open): score basename anchor so nested camelCase files match separator queries --- .../src/components/quick-open-fuzzy-match.ts | 232 ++++++++++++++++++ .../src/components/quick-open-search.test.ts | 29 +++ .../src/components/quick-open-search.ts | 211 +--------------- 3 files changed, 265 insertions(+), 207 deletions(-) create mode 100644 src/renderer/src/components/quick-open-fuzzy-match.ts 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..68d6f342bd1 --- /dev/null +++ b/src/renderer/src/components/quick-open-fuzzy-match.ts @@ -0,0 +1,232 @@ +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 +} + +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('-') + } +} + +/** + * 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 (tab_bar_create_entry vs tab-bar/TabBarCreateEntry.tsx) and + // then dead-end or pay junk gaps at the '/'. Score the basename anchor + // too so every candidate competes from its best interpretation; plain + // queries skip this because a subrange can never beat the full range. + const lastSlash = file.lowerPath.lastIndexOf('/') + if (lastSlash !== -1) { + const basenameScore = matchQueryFromAnchor(query.normalized, file, lastSlash) + if (basenameScore !== null && (score === null || basenameScore < score)) { + score = basenameScore + } + } + } + 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++ + } + 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 && + 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. + return includesIgnoringSeparators(lowerFilename, query.compactFilename) +} + +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): 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] + if (isPathSeparator(ch)) { + valueIndex++ + continue + } + if (ch !== wanted[wantedIndex]) { + break + } + valueIndex++ + wantedIndex++ + } + if (wantedIndex === wanted.length) { + return true + } + } + return false +} + +function searchCharactersMatch(valueChar: string, queryChar: string): boolean { + return ( + valueChar === queryChar || + (isIdentifierSeparator(valueChar) && isIdentifierSeparator(queryChar)) + ) +} + +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 92ab9c282ca..9b5e663c831 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -348,6 +348,35 @@ describe('quick-open-search', () => { ]) }) + 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('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 5fcd5d32424..21154e554ea 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -1,8 +1,6 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' -import { - hasIdentifierTransitionMatchBeforeSeparator, - isPathSeparator -} from './quick-open-word-boundaries' +import { fuzzyMatchIndexedFile, prepareQuickOpenQuery } from './quick-open-fuzzy-match' +import { isPathSeparator } from './quick-open-word-boundaries' export const QUICK_OPEN_RESULT_LIMIT = 50 export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024 @@ -67,22 +65,11 @@ export function rankQuickOpenFiles( if (!normalizedQuery) { return files.slice(0, limit).map((file) => ({ path: file.path, score: 0 })) } - // Why: spaces, hyphens, and underscores are equivalent human separators, so - // camelCase basenames earn the filename boost for all three query styles. - const compactFilenameQuery = /[ _-]/.test(normalizedQuery) - ? normalizedQuery.replace(/[ _-]/g, '') - : null - const hasFlexibleIdentifierSeparator = - normalizedQuery.includes('_') || normalizedQuery.includes('-') + const preparedQuery = prepareQuickOpenQuery(normalizedQuery) const results: QuickOpenRankedResult[] = [] for (const file of files) { - const score = fuzzyMatchIndexedFile( - normalizedQuery, - compactFilenameQuery, - hasFlexibleIdentifierSeparator, - file - ) + const score = fuzzyMatchIndexedFile(preparedQuery, file) if (score === null) { continue } @@ -93,196 +80,6 @@ export function rankQuickOpenFiles( return results.map(({ path, score }) => ({ path, score })) } -/** - * 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. - */ -function fuzzyMatchIndexedFile( - query: string, - compactFilenameQuery: string | null, - hasFlexibleIdentifierSeparator: boolean, - file: QuickOpenIndexedFile -): number | null { - const path = file.lowerPath - const wordStarts = file.wordStarts - let qi = 0 - let score = 0 - let lastMatchIdx = -1 - 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++ - } - 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 && - lastMatchIdx >= 0 && - 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++ - 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 && lastMatchIdx >= 0) { - score += 2 - qi++ - continue - } - return null - } - } - - // Filename substring boost: allow human separators to hit equivalent names - // (`product detail` / `product-detail` → product_detail.dart). - if ( - filenameMatchesQuery( - file.lowerFilename, - query, - compactFilenameQuery, - hasFlexibleIdentifierSeparator - ) - ) { - score -= 100 - } - - return score -} - -function filenameMatchesQuery( - lowerFilename: string, - query: string, - compactQuery: string | null, - hasFlexibleIdentifierSeparator: boolean -): boolean { - if ( - lowerFilename.includes(query) || - (hasFlexibleIdentifierSeparator && - includesWithIdentifierSeparatorEquivalence(lowerFilename, query)) - ) { - return true - } - if (!compactQuery) { - return false - } - // Why: users type words with spaces; basenames use `_` / `-` / camelCase. - return includesIgnoringSeparators(lowerFilename, compactQuery) -} - -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): 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] - if (isPathSeparator(ch)) { - valueIndex++ - continue - } - if (ch !== wanted[wantedIndex]) { - break - } - valueIndex++ - wantedIndex++ - } - if (wantedIndex === wanted.length) { - return true - } - } - return false -} - -function searchCharactersMatch(valueChar: string, queryChar: string): boolean { - return ( - valueChar === queryChar || - (isIdentifierSeparator(valueChar) && isIdentifierSeparator(queryChar)) - ) -} - -function isIdentifierSeparator(ch: string): boolean { - return ch === '_' || ch === '-' -} - /** * Word starts from the original-case slash-normalized path so identifier * boundaries survive lowercasing in lowerPath. From 9fa19d09aa26c09c25133816fabed23534a06708 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:14:30 -0700 Subject: [PATCH 05/12] fix(quick-open): complete separator equivalence for nested dirs, extensions, and spaces Round-1 review fixes for smart separator matching: - Re-anchor at segment boundaries (bounded to segments with a first-char match) so separator queries reach camelCase names in inner directories (user-profile -> user/UserProfile/index.tsx), not just basenames. - Stop skipping a value separator that matches the query char, so extension-bearing queries (product-detail.dart) keep the camelCase filename boost instead of losing it to junk. - Treat a typed '-'/'_' as equivalent to a literal space in a filename, making the space/underscore/hyphen equivalence symmetric. - Add regression tests for all three plus the bare-separator guard. --- .../src/components/quick-open-fuzzy-match.ts | 45 ++++++++++++++----- .../src/components/quick-open-search.test.ts | 44 ++++++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/quick-open-fuzzy-match.ts b/src/renderer/src/components/quick-open-fuzzy-match.ts index 68d6f342bd1..3d4c47df641 100644 --- a/src/renderer/src/components/quick-open-fuzzy-match.ts +++ b/src/renderer/src/components/quick-open-fuzzy-match.ts @@ -41,15 +41,31 @@ export function fuzzyMatchIndexedFile( let score = matchQueryFromAnchor(query.normalized, file, -1) if (query.compactFilename !== null) { // Why: greedy matching can anchor query tokens in a same-named ancestor - // directory (tab_bar_create_entry vs tab-bar/TabBarCreateEntry.tsx) and - // then dead-end or pay junk gaps at the '/'. Score the basename anchor - // too so every candidate competes from its best interpretation; plain - // queries skip this because a subrange can never beat the full range. - const lastSlash = file.lowerPath.lastIndexOf('/') - if (lastSlash !== -1) { - const basenameScore = matchQueryFromAnchor(query.normalized, file, lastSlash) - if (basenameScore !== null && (score === null || basenameScore < score)) { - score = basenameScore + // 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++) { + if (path[i] === '/') { + slashIdx = i + segmentMatched = false + continue + } + 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 + } } } } @@ -203,7 +219,10 @@ function includesIgnoringSeparators(value: string, wanted: string): boolean { let wantedIndex = 0 while (valueIndex < value.length && wantedIndex < wanted.length) { const ch = value[valueIndex] - if (isPathSeparator(ch)) { + // Why: skip only a value separator we aren't currently matching, so an + // extension dot in the query ("product-detail.dart") still lands on the + // basename's dot instead of being skipped past into a dead-end. + if (isPathSeparator(ch) && ch !== wanted[wantedIndex]) { valueIndex++ continue } @@ -221,9 +240,13 @@ function includesIgnoringSeparators(value: string, wanted: string): boolean { } 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(valueChar) && isIdentifierSeparator(queryChar)) + (isIdentifierSeparator(queryChar) && + (isIdentifierSeparator(valueChar) || valueChar === ' ')) ) } diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index 9b5e663c831..4144fec8afa 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -377,6 +377,50 @@ describe('quick-open-search', () => { ]) }) + 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. + const results = rankQuickOpenFiles('foo-bar.ts', 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('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 } From 073267e44808fcbfd825002aeb857f7b654d6444 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:38:43 -0700 Subject: [PATCH 06/12] fix(quick-open): re-anchor slash-leading queries and stop dot-crossing boost Round-2 review fixes: - The segment-anchor loop tested the first-char match after consuming the '/' boundary, so '/'- or '\'-leading separator queries skipped all re-anchoring and dropped nested matches (user/UserProfile/index.tsx). Check the first-char match before the boundary; differential-fuzzed equivalent to anchoring at every slash (0 divergences / 6k cases incl. slash-leading and dot-separated basenames). - The filename boost ignored '.'/'.' for identifier-separated queries, so 'product-detail' wrongly boosted a dot-separated 'product.detail.dart'. Only spaced queries may bridge '/'/'.'; '-'/'_' keep them distinct. - Add regression tests for both. --- .../src/components/quick-open-fuzzy-match.ts | 35 ++++++++++------- .../src/components/quick-open-search.test.ts | 38 +++++++++++++++++++ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/quick-open-fuzzy-match.ts b/src/renderer/src/components/quick-open-fuzzy-match.ts index 3d4c47df641..7bb306113ad 100644 --- a/src/renderer/src/components/quick-open-fuzzy-match.ts +++ b/src/renderer/src/components/quick-open-fuzzy-match.ts @@ -14,6 +14,7 @@ export type PreparedQuickOpenQuery = { /** Query with ` `/`_`/`-` stripped; null when the query has none of them. */ compactFilename: string | null hasIdentifierSeparator: boolean + hasSpace: boolean } export function prepareQuickOpenQuery(normalized: string): PreparedQuickOpenQuery { @@ -22,7 +23,8 @@ export function prepareQuickOpenQuery(normalized: string): PreparedQuickOpenQuer return { normalized, compactFilename: /[ _-]/.test(normalized) ? normalized.replace(/[ _-]/g, '') : null, - hasIdentifierSeparator: normalized.includes('_') || normalized.includes('-') + hasIdentifierSeparator: normalized.includes('_') || normalized.includes('-'), + hasSpace: normalized.includes(' ') } } @@ -55,11 +57,9 @@ export function fuzzyMatchIndexedFile( let slashIdx = -1 let segmentMatched = false for (let i = 0; i < path.length; i++) { - if (path[i] === '/') { - slashIdx = i - segmentMatched = false - continue - } + // 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) @@ -67,6 +67,10 @@ export function fuzzyMatchIndexedFile( score = anchoredScore } } + if (path[i] === '/') { + slashIdx = i + segmentMatched = false + } } } if (score === null) { @@ -192,8 +196,10 @@ function filenameMatchesQuery(lowerFilename: string, query: PreparedQuickOpenQue if (!query.compactFilename) { return false } - // Why: users type words with spaces; basenames use `_` / `-` / camelCase. - return includesIgnoringSeparators(lowerFilename, query.compactFilename) + // 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 { @@ -210,7 +216,7 @@ function includesWithIdentifierSeparatorEquivalence(value: string, wanted: strin return false } -function includesIgnoringSeparators(value: string, wanted: string): boolean { +function includesIgnoringSeparators(value: string, wanted: string, crossPathSeparators: boolean): boolean { for (let start = 0; start < value.length; start++) { if (value[start] !== wanted[0]) { continue @@ -219,10 +225,13 @@ function includesIgnoringSeparators(value: string, wanted: string): boolean { let wantedIndex = 0 while (valueIndex < value.length && wantedIndex < wanted.length) { const ch = value[valueIndex] - // Why: skip only a value separator we aren't currently matching, so an - // extension dot in the query ("product-detail.dart") still lands on the - // basename's dot instead of being skipped past into a dead-end. - if (isPathSeparator(ch) && ch !== wanted[wantedIndex]) { + // 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 } diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index 4144fec8afa..24f6d7fcd31 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -412,6 +412,44 @@ describe('quick-open-search', () => { } }) + 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('does not let a bare separator query match a file without separators', () => { const files = prepareQuickOpenFiles(['a.ts']) From c8e5ef8c087f14e51c4a3772eda08f6f3793ef08 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:57:26 -0700 Subject: [PATCH 07/12] fix(quick-open): match names with a spaced separator run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review fix: a query space consumes the entire path separator run, so a literal separator typed right after ("Meeting - 2026", "a _ b", also space+/ and space+.) had nothing left to match and the file was dropped entirely — a regression vs the pre-PR literal-substring matcher that also broke exact-filename paste and blanked results mid-keystroke. Absorb the following query separators/spaces the consumed run already covers, staying strict for '/'/'.'. Differential-fuzzed equivalent to naive all-anchor (0 divergences / 7k cases). Add regression tests. --- .../src/components/quick-open-fuzzy-match.ts | 24 +++++++++++++++++++ .../src/components/quick-open-search.test.ts | 22 +++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/renderer/src/components/quick-open-fuzzy-match.ts b/src/renderer/src/components/quick-open-fuzzy-match.ts index 7bb306113ad..d8532a49214 100644 --- a/src/renderer/src/components/quick-open-fuzzy-match.ts +++ b/src/renderer/src/components/quick-open-fuzzy-match.ts @@ -115,6 +115,13 @@ function matchQueryFromAnchor( 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 { @@ -248,6 +255,23 @@ function includesIgnoringSeparators(value: string, wanted: string, crossPathSepa 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"). diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index 24f6d7fcd31..e7456b4f025 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -450,6 +450,28 @@ describe('quick-open-search', () => { ).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 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 let a bare separator query match a file without separators', () => { const files = prepareQuickOpenFiles(['a.ts']) From b27cb727e461305c29d50e0fde6c200a3bdfe4d5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:17:45 -0700 Subject: [PATCH 08/12] fix(quick-open): stop space+separator from bridging across / or . MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review fix: after a query space consumes a '/' or '.' run, lastMatchIdx lands on the separator, so the camelCase-bridge detector scanned from the char after it and misread that separator-induced word start as a zero-width case transition — letting a typed '-'/'_' cross a '/'/'.' boundary the spec keeps distinct (e.g. 'user _profile' fuzzy- matched and boosted user.profile.ts above the real user_my_profile.ts). Require the transition's prior char to be a non-separator so only genuine camelCase/acronym boundaries (ProductDetail, APIClient) bridge. Also cover the spaced extension-boost guard ('foo bar.ts') that lacked a test. --- .../src/components/quick-open-search.test.ts | 39 +++++++++++++++++-- .../components/quick-open-word-boundaries.ts | 11 +++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index e7456b4f025..d0189d3886e 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -394,10 +394,14 @@ describe('quick-open-search', () => { 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. - const results = rankQuickOpenFiles('foo-bar.ts', files) - expect(results[0].path).toBe('src/fooBar.ts') - expect(results[0].score).toBeLessThan(-100) + // 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', () => { @@ -461,6 +465,33 @@ describe('quick-open-search', () => { 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 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']) diff --git a/src/renderer/src/components/quick-open-word-boundaries.ts b/src/renderer/src/components/quick-open-word-boundaries.ts index db3e03851aa..4e1b2a42c9d 100644 --- a/src/renderer/src/components/quick-open-word-boundaries.ts +++ b/src/renderer/src/components/quick-open-word-boundaries.ts @@ -12,7 +12,16 @@ export function hasIdentifierTransitionMatchBeforeSeparator( if (isPathSeparator(path[index])) { return false } - if (wordStarts[index] === 1 && path[index] === wanted) { + // 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 } } From 556c3f93c1ef448fcf918e36ff1d5fe2934c0129 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:37:58 -0700 Subject: [PATCH 09/12] fix(quick-open): don't bridge a case transition across a consumed / or . Round-5 review fix: the round-4 guard made the bridge WITNESS strict, but the consumption loop still landed on the first word start, which is the separator-induced one when a space consumed a '/'/'.' run and a deeper same-letter camelCase decoy authorized the bridge (e.g. 'user -profile' matched src/user/profilePage.ts across '/', while the snake twin returned nothing). A zero-width transition can't sit on a just-consumed '/'/'.', so skip the bridge when lastMatchIdx is on one. Add decoy regression cases. --- src/renderer/src/components/quick-open-fuzzy-match.ts | 7 +++++++ src/renderer/src/components/quick-open-search.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/renderer/src/components/quick-open-fuzzy-match.ts b/src/renderer/src/components/quick-open-fuzzy-match.ts index d8532a49214..c4c53168be1 100644 --- a/src/renderer/src/components/quick-open-fuzzy-match.ts +++ b/src/renderer/src/components/quick-open-fuzzy-match.ts @@ -136,6 +136,13 @@ function matchQueryFromAnchor( isIdentifierSeparator(query[qi]) && qi + 1 < query.length && hasMatchedChar && + // Why: a zero-width case transition can only sit between two non-separator + // chars. When a preceding space already consumed a '/' or '.' run, + // lastMatchIdx is on that separator, so there is nothing to bridge — and + // firing here would let a typed '-'/'_' cross a '/'/'.' via a deeper + // same-letter camelCase decoy while consumption lands on the crossing. + path[lastMatchIdx] !== '/' && + path[lastMatchIdx] !== '.' && hasIdentifierTransitionMatchBeforeSeparator(path, wordStarts, lastMatchIdx + 1, query[qi + 1]) ) { // Why: typed separators also bridge zero-width case transitions. Rank diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index d0189d3886e..ecd0d5c5e6e 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -478,6 +478,16 @@ describe('quick-open-search', () => { 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( From 9fc5431f71545d54d355a909ada29b5b27038e52 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:59:34 -0700 Subject: [PATCH 10/12] test(quick-open): pin doubled-separator strictness of the transition guard The identifier-transition guard's residual behavior (a typed '-'/'_' not bridging a separator-induced word start, e.g. 'a--b' must not match 'a-b') had no coverage once the round-5 gate subsumed its '/'/'.' role. Pin it so a future simplification can't silently drop the guard. --- src/renderer/src/components/quick-open-search.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index ecd0d5c5e6e..d33410d9f69 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -513,6 +513,14 @@ describe('quick-open-search', () => { } }) + it('does not bridge a doubled typed separator onto a single-separator name', () => { + // Why: a separator-induced word start is not a case transition, so "a--b" + // must not bridge onto "a-b" — the identifier-transition guard keeps a typed + // `-`/`_` landing only on a genuine camelCase boundary. + expect(rankQuickOpenFiles('a--b', prepareQuickOpenFiles(['x/a-b.ts']))).toEqual([]) + expect(rankQuickOpenFiles('a_-b', prepareQuickOpenFiles(['x/a_b.ts']))).toEqual([]) + }) + it('does not let a bare separator query match a file without separators', () => { const files = prepareQuickOpenFiles(['a.ts']) From 27e8c5e32d0bfba1241e3877ccc7b89e79e96d21 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:19:32 -0700 Subject: [PATCH 11/12] fix(quick-open): don't bridge a case transition after any consumed separator Round-7 review fix: the round-5 gate only skipped the camelCase bridge after a consumed '/' or '.', so a doubled typed separator ('a--b') could still bridge onto a single-separator name when a deeper same-letter camelCase tail ('x/a-bcBx.ts') authorized it and consumption landed on the '-'-induced word start. A zero-width transition can't sit right after ANY matched separator, so widen the gate to !isPathSeparator(path[lastMatchIdx]). Extend the doubled- separator test with the decoy variant. --- src/renderer/src/components/quick-open-fuzzy-match.ts | 11 +++++------ src/renderer/src/components/quick-open-search.test.ts | 8 +++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/quick-open-fuzzy-match.ts b/src/renderer/src/components/quick-open-fuzzy-match.ts index c4c53168be1..ca21e54b7e1 100644 --- a/src/renderer/src/components/quick-open-fuzzy-match.ts +++ b/src/renderer/src/components/quick-open-fuzzy-match.ts @@ -137,12 +137,11 @@ function matchQueryFromAnchor( qi + 1 < query.length && hasMatchedChar && // Why: a zero-width case transition can only sit between two non-separator - // chars. When a preceding space already consumed a '/' or '.' run, - // lastMatchIdx is on that separator, so there is nothing to bridge — and - // firing here would let a typed '-'/'_' cross a '/'/'.' via a deeper - // same-letter camelCase decoy while consumption lands on the crossing. - path[lastMatchIdx] !== '/' && - path[lastMatchIdx] !== '.' && + // 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 diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index d33410d9f69..8904dae3092 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -514,11 +514,13 @@ describe('quick-open-search', () => { }) it('does not bridge a doubled typed separator onto a single-separator name', () => { - // Why: a separator-induced word start is not a case transition, so "a--b" - // must not bridge onto "a-b" — the identifier-transition guard keeps a typed - // `-`/`_` landing only on a genuine camelCase boundary. + // 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', () => { From 0e8de17e0a6acdeb50d6139096e00d43596a508e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:25:58 -0700 Subject: [PATCH 12/12] perf(quick-open): index numbered word boundaries efficiently --- .../src/components/quick-open-search.test.ts | 13 ++++++ .../src/components/quick-open-search.ts | 40 ++++++++++++------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index 8904dae3092..4fa77bf1948 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -300,6 +300,19 @@ describe('quick-open-search', () => { ]) }) + 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']) diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index 21154e554ea..7f64718d39c 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -1,6 +1,5 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import { fuzzyMatchIndexedFile, prepareQuickOpenQuery } from './quick-open-fuzzy-match' -import { isPathSeparator } from './quick-open-word-boundaries' export const QUICK_OPEN_RESULT_LIMIT = 50 export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024 @@ -92,40 +91,53 @@ function buildSearchPathIndex(searchPath: string): { const starts = new Uint8Array(lowerPath.length) let lowerIndex = 0 for (let i = 0; i < searchPath.length; i++) { - const curr = searchPath[i] - const prev = i > 0 ? searchPath[i - 1] : '' - const next = i + 1 < searchPath.length ? searchPath[i + 1] : '' + // 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 || - isPathSeparator(prev) || - (isAsciiLowerOrDigit(prev) && isAsciiUpper(curr)) || - (isAsciiUpper(prev) && isAsciiUpper(curr) && isAsciiLower(next)) + 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 += curr.charCodeAt(0) < 128 ? 1 : curr.toLowerCase().length + lowerIndex += currCode < 128 ? 1 : searchPath[i].toLowerCase().length } return { lowerPath, wordStarts: starts } } -function isAsciiLowerOrDigit(ch: string): boolean { - const code = ch.charCodeAt(0) +function isAsciiLowerOrDigitCode(code: number): boolean { return (code >= 48 && code <= 57) || (code >= 97 && code <= 122) } -function isAsciiUpper(ch: string): boolean { - const code = ch.charCodeAt(0) +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 isAsciiLower(ch: string): boolean { - const code = ch.charCodeAt(0) +function isAsciiLowerCode(code: number): boolean { return code >= 97 && code <= 122 } +function isPathSeparatorCode(code: number): boolean { + return code === 47 || code === 95 || code === 45 || code === 46 || code === 32 +} + type QuickOpenRankedResult = QuickOpenSearchResult & { inputIndex: number }