diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index e4b26441bde..d0fcc15636c 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -1,4 +1,9 @@ import { isCmdJPaletteQueryTooLarge } from './palette-results' +import { + cmdJPaletteTokenScore, + normalizeCmdJPaletteQuery, + uniqueNormalizedCmdJPaletteKeywords +} from './palette-query-tokens' import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' import { @@ -39,73 +44,6 @@ type RankedProjectResult = { const PROJECT_GROUP_ALIASES = ['group', 'repo group'] const PROJECT_ALIASES = ['project', 'repo'] -function normalizeQuery(value: string): string { - let normalized = '' - let pendingWhitespace = false - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (isCmdJPaletteWhitespace(code)) { - pendingWhitespace = normalized.length > 0 - continue - } - if (pendingWhitespace) { - normalized += ' ' - pendingWhitespace = false - } - normalized += value.charAt(index).toLowerCase() - } - return normalized -} - -function isCmdJPaletteWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -function uniqueNormalized(values: readonly string[]): string[] { - return [...new Set(values.map(normalizeQuery).filter(Boolean))] -} - -function tokenize(value: string): string[] { - return normalizeQuery(value) - .split(/[^a-z0-9]+/) - .filter(Boolean) -} - -function tokenScore(query: string, values: readonly string[]): number { - const candidateTokens = values.flatMap(tokenize) - if (candidateTokens.length === 0) { - return 0 - } - - let score = 0 - for (const queryToken of tokenize(query)) { - let best = 0 - for (const candidateToken of candidateTokens) { - if (candidateToken === queryToken) { - best = Math.max(best, 3) - } else if (candidateToken.startsWith(queryToken)) { - best = Math.max(best, 2) - } else if (candidateToken.includes(queryToken)) { - best = Math.max(best, 1) - } - } - score += best - } - return score -} - function buildCmdJProjectSearchCandidates({ projectGroups, repos, @@ -134,7 +72,7 @@ function buildCmdJProjectSearchCandidates({ ), rowKey: getProjectGroupHeaderKey(group.id), order, - keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES]) + keywords: uniqueNormalizedCmdJPaletteKeywords([group.name, ...PROJECT_GROUP_ALIASES]) }) }) @@ -156,7 +94,11 @@ function buildCmdJProjectSearchCandidates({ rowKey: target.key, repo: target.repo, order: projectGroups.length + repoIndex, - keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES]) + keywords: uniqueNormalizedCmdJPaletteKeywords([ + target.label, + repo.displayName, + ...PROJECT_ALIASES + ]) }) }) @@ -191,7 +133,7 @@ function projectRankingForCandidate( query: string, candidate: CmdJProjectSearchResult ): RankedProjectResult | null { - const title = normalizeQuery(candidate.title) + const title = normalizeCmdJPaletteQuery(candidate.title) if (query === title) { return { result: candidate, rule: 1, score: 0 } } @@ -199,13 +141,13 @@ function projectRankingForCandidate( return { result: candidate, rule: 2, score: 0 } } const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES - if (aliasKeywords.map(normalizeQuery).includes(query)) { + if (aliasKeywords.map(normalizeCmdJPaletteQuery).includes(query)) { return { result: candidate, rule: 3, score: 0 } } if (candidate.keywords.some((keyword) => keyword.startsWith(query))) { return { result: candidate, rule: 4, score: 0 } } - const score = tokenScore(query, [candidate.title, ...candidate.keywords]) + const score = cmdJPaletteTokenScore(query, [candidate.title, ...candidate.keywords]) return score > 0 ? { result: candidate, rule: 5, score } : null } @@ -242,7 +184,7 @@ export function searchCmdJProjectResults({ if (isCmdJPaletteQueryTooLarge(query)) { return [] } - const normalizedQuery = normalizeQuery(query) + const normalizedQuery = normalizeCmdJPaletteQuery(query) // Why: project/group rows sit after worktree matches, so one-character // searches would add broad noisy navigation targets before intent is clear. if (normalizedQuery.length < 2) { diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts new file mode 100644 index 00000000000..3f829ace14a --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { normalizeCmdJPaletteQuery, tokenizeCmdJPaletteQuery } from './palette-query-tokens' + +describe('Cmd+J palette query normalization', () => { + it('lowercases supplementary-plane characters as whole code points', () => { + // Why: code-unit iteration split '\u{10400}' into surrogate halves, which lowercase to + // themselves, so the query never matched its lowercase counterpart. + expect(normalizeCmdJPaletteQuery('\u{10400} HELLO')).toBe('\u{10428} hello') + expect(tokenizeCmdJPaletteQuery('\u{10400} HELLO')).toEqual(['\u{10428}', 'hello']) + }) + + it('collapses surrounding and repeated whitespace', () => { + expect(normalizeCmdJPaletteQuery(' New\n\tTerminal ')).toBe('new terminal') + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.ts new file mode 100644 index 00000000000..3ba9068b0c1 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.ts @@ -0,0 +1,116 @@ +// Why: the middle band and the project band scored queries with byte-identical copies of +// this logic, so ranking fixes only ever landed in one of the two. + +function isCmdJPaletteWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +// Why: iterating code units would lowercase surrogate halves separately, leaving +// supplementary-plane characters uncased and unmatchable. +export function normalizeCmdJPaletteQuery(value: string): string { + let normalized = '' + let pendingWhitespace = false + for (const character of value) { + if (isCmdJPaletteWhitespace(character.codePointAt(0) ?? 0)) { + pendingWhitespace = normalized.length > 0 + continue + } + if (pendingWhitespace) { + normalized += ' ' + pendingWhitespace = false + } + normalized += character.toLowerCase() + } + return normalized +} + +export function uniqueNormalizedCmdJPaletteKeywords(values: readonly string[]): string[] { + return [...new Set(values.map(normalizeCmdJPaletteQuery).filter(Boolean))] +} + +// Why: splitting on non-ASCII would drop CJK and accented words entirely, so a localized +// query would silently skip the coverage rule below instead of failing it. +export function tokenizeCmdJPaletteQuery(value: string): string[] { + return normalizeCmdJPaletteQuery(value) + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean) +} + +// Why: navigation filler carries no intent ("open ssh settings" means "ssh settings"), so an +// unmatched filler word must not count against a candidate's coverage. +const CMD_J_QUERY_FILLER_TOKENS = new Set([ + 'a', + 'an', + 'and', + 'change', + 'edit', + 'for', + 'from', + 'go', + 'goto', + 'in', + 'into', + 'jump', + 'me', + 'my', + 'of', + 'on', + 'open', + 'please', + 'set', + 'show', + 'the', + 'to', + 'view', + 'with' +]) + +export function cmdJPaletteTokenScore(query: string, values: readonly string[]): number { + const candidateTokens = values.flatMap(tokenizeCmdJPaletteQuery) + if (candidateTokens.length === 0) { + return 0 + } + + let score = 0 + let meaningful = 0 + let covered = 0 + for (const queryToken of tokenizeCmdJPaletteQuery(query)) { + let best = 0 + for (const candidateToken of candidateTokens) { + if (candidateToken === queryToken) { + best = Math.max(best, 3) + } else if (candidateToken.startsWith(queryToken)) { + best = Math.max(best, 2) + } else if (candidateToken.includes(queryToken)) { + best = Math.max(best, 1) + } + } + score += best + if (CMD_J_QUERY_FILLER_TOKENS.has(queryToken)) { + continue + } + meaningful += 1 + if (best > 0) { + covered += 1 + } + } + + // Why: "linear triage" matched the Linear pane as strongly as "linear" did, so a candidate + // now has to cover most of what was typed, not just one word of it. + if (meaningful > 0 && covered * 2 <= meaningful) { + return 0 + } + return score +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 73049bcc149..bf875ebe9a2 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -208,6 +208,66 @@ describe('Cmd+J palette middle-band ranking', () => { }) }) + it('drops candidates that cover a minority of the words typed', () => { + // Why: "linear triage" used to surface the Linear and Integrations panes on the + // "linear" token alone, with the unmatched word costing nothing. See screenshot report. + const integrationSections: SettingsNavSection[] = [ + { + id: 'linear', + title: 'Linear', + description: 'How Linear works in Orca.', + icon: Settings, + searchEntries: [], + group: 'capabilities' + }, + { + id: 'integrations', + title: 'Integrations', + description: 'Connect GitHub, GitLab, and Linear.', + icon: Settings, + searchEntries: [], + group: 'setup' + } + ] + const rank = (query: string): string[] => + rankCmdJMiddleResults({ + query, + settingsResults: buildCmdJSettingsResults(integrationSections), + actionResults: [] + }).map((result) => result.id) + + expect(rank('linear triage')).toEqual([]) + expect(rank('linear')).toEqual(['settings:linear', 'settings:integrations']) + // Why: a majority still counts, so one stray word cannot blank an otherwise good match. + expect(rank('linear integrations triage')).toEqual(['settings:integrations']) + }) + + it('drops verb-prefixed settings queries whose middle words match nothing', () => { + // Why: the verb-plus-settings-keyword rule reads only the head and tail of the query, so + // "new terminal browser settings" used to win on those two ends alone. + expect(top('new terminal sparkles glitter browser settings')).toBeUndefined() + expect(top('new terminal settings')).toBe('settings:terminal') + }) + + it('keeps out-of-order partial-word matches when every query word lands somewhere', () => { + expect(top('font term')).toBe('settings:terminal') + expect(top('font sparkles')).toBeUndefined() + }) + + it('ignores navigation filler words when measuring coverage', () => { + // Why: "open"/"go to" carry no intent, so they must not read as unmatched words + // and blank the band mid-query. + expect(top('open terminal settings')).toBe('settings:terminal') + expect(top('go to ssh settings')).toBe('settings:ssh') + expect(top('change the terminal font')).toBe('settings:terminal') + }) + + it('counts non-latin query words instead of discarding them', () => { + // Why: tokenizing on ASCII only let a localized query skip the coverage rule entirely. + expect(top('ssh 主机 设置')).toBeUndefined() + expect(top('ssh 设置')).toBeUndefined() + }) + it('does not match settings on one-character or description-only queries', () => { expect(top('t')).toBeUndefined() expect(top('cookie import')).toBeUndefined() @@ -316,6 +376,23 @@ function projectGroup(id: string, name: string, parentGroupId: string | null = n } describe('Cmd+J project and repo-group search', () => { + it('drops projects that only match the query through a generic alias word', () => { + // Why: every project carries the 'repo'/'project' aliases, so "repo triage" used to + // match all of them on that word alone — the same bug the middle band had. + const search = (query: string): string[] => + searchCmdJProjectResults({ + query, + projectGroups: [], + repos: [repo('repo-1', 'linear-sync'), repo('repo-2', 'billing')], + projects: [], + projectHostSetups: [] + }).map((result) => result.title) + + expect(search('repo triage')).toEqual([]) + expect(search('linear triage')).toEqual([]) + expect(search('linear repo')).toEqual(['linear-sync']) + }) + it('finds a Project Group by name', () => { const [result] = searchCmdJProjectResults({ query: 'infra', diff --git a/src/renderer/src/components/cmd-j/palette-results.ts b/src/renderer/src/components/cmd-j/palette-results.ts index 9246599843c..3b643c1fe8e 100644 --- a/src/renderer/src/components/cmd-j/palette-results.ts +++ b/src/renderer/src/components/cmd-j/palette-results.ts @@ -1,6 +1,11 @@ import type { SettingsNavIcon, SettingsNavSection } from '@/lib/settings-navigation-types' import type { CmdJQuickAction } from './quick-actions' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { + cmdJPaletteTokenScore, + normalizeCmdJPaletteQuery, + uniqueNormalizedCmdJPaletteKeywords +} from './palette-query-tokens' export type CmdJSettingsResult = { id: string @@ -53,40 +58,6 @@ export function isCmdJPaletteQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } -function normalizeQuery(value: string): string { - let normalized = '' - let pendingWhitespace = false - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (isCmdJPaletteWhitespace(code)) { - pendingWhitespace = normalized.length > 0 - continue - } - if (pendingWhitespace) { - normalized += ' ' - pendingWhitespace = false - } - normalized += value.charAt(index).toLowerCase() - } - return normalized -} - -function isCmdJPaletteWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - function keywordParts(section: SettingsNavSection): string[] { const baseId = section.id.startsWith('repo-') ? 'repo' : section.id const idWords = baseId.replace(/-/g, ' ') @@ -107,10 +78,6 @@ function targetEntryKeywordParts(entryTitle: string): string[] { return [entryTitle, `${entryTitle} settings`] } -function uniqueNormalized(values: readonly string[]): string[] { - return [...new Set(values.map(normalizeQuery).filter(Boolean))] -} - export function buildCmdJSettingsResults( sections: readonly SettingsNavSection[] ): CmdJSettingsResult[] { @@ -123,7 +90,7 @@ export function buildCmdJSettingsResults( icon: section.icon, sectionId: section.id, order, - configKeywords: uniqueNormalized(keywordParts(section)) + configKeywords: uniqueNormalizedCmdJPaletteKeywords(keywordParts(section)) } const targetedResults = section.searchEntries .filter((entry) => entry.targetSectionId) @@ -136,7 +103,7 @@ export function buildCmdJSettingsResults( sectionId: section.id, targetSectionId: entry.targetSectionId, order: order + (entryIndex + 1) / 100, - configKeywords: uniqueNormalized([ + configKeywords: uniqueNormalizedCmdJPaletteKeywords([ ...targetEntryKeywordParts(entry.title), ...(entry.cmdJKeywords ?? entry.keywords ?? []) ]) @@ -154,35 +121,6 @@ function startsOrIsStartedBy(query: string, keyword: string): boolean { return keyword.startsWith(query) || query.startsWith(keyword) } -function tokenize(value: string): string[] { - return normalizeQuery(value) - .split(/[^a-z0-9]+/) - .filter(Boolean) -} - -function tokenScore(query: string, values: readonly string[]): number { - const candidateTokens = values.flatMap(tokenize) - if (candidateTokens.length === 0) { - return 0 - } - - let score = 0 - for (const queryToken of tokenize(query)) { - let best = 0 - for (const candidateToken of candidateTokens) { - if (candidateToken === queryToken) { - best = Math.max(best, 3) - } else if (candidateToken.startsWith(queryToken)) { - best = Math.max(best, 2) - } else if (candidateToken.includes(queryToken)) { - best = Math.max(best, 1) - } - } - score += best - } - return score -} - function rankingForCandidate( query: string, candidate: CmdJMiddleResult, @@ -193,6 +131,17 @@ function rankingForCandidate( return null } + // Why: the shortcut rules below only inspect the head and tail of the query, so coverage has + // to gate all of them — otherwise "new terminal browser settings" still wins on rule 3. + const values = + candidate.kind === 'settings' + ? [candidate.title, ...candidate.configKeywords] + : [candidate.title, ...candidate.verbKeywords] + const score = cmdJPaletteTokenScore(query, values) + if (score === 0) { + return null + } + if (candidate.kind === 'action' && candidate.verbKeywords.some((keyword) => query === keyword)) { return { result: candidate, rule: 1, score: 0 } } @@ -227,12 +176,7 @@ function rankingForCandidate( return { result: candidate, rule: 5, score: 0 } } - const values = - candidate.kind === 'settings' - ? [candidate.title, ...candidate.configKeywords] - : [candidate.title, ...candidate.verbKeywords] - const score = tokenScore(query, values) - return score > 0 ? { result: candidate, rule: 6, score } : null + return { result: candidate, rule: 6, score } } function compareRanked(a: RankedResult, b: RankedResult): number { @@ -263,7 +207,7 @@ export function rankCmdJMiddleResults({ if (isCmdJPaletteQueryTooLarge(query)) { return [] } - const normalizedQuery = normalizeQuery(query) + const normalizedQuery = normalizeCmdJPaletteQuery(query) if (normalizedQuery.length < 2) { return [] }