fix: rank command palette results by match score, not section order
When searching in Cmd+K, groups are now ordered by their highest-scoring match instead of the fixed section order. Empty query preserves the default section ordering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -165,4 +165,64 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByText('↵ select')).toBeInTheDocument()
|
||||
expect(screen.getByText('esc close')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('relevance ranking', () => {
|
||||
const relevanceCommands: CommandAction[] = [
|
||||
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }),
|
||||
makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }),
|
||||
makeCommand({ id: 'switch-theme', label: 'Switch Theme', group: 'Appearance', keywords: ['dark', 'light'] }),
|
||||
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }),
|
||||
]
|
||||
|
||||
function getVisibleLabels() {
|
||||
return screen.getAllByText(
|
||||
(_content, el) =>
|
||||
el?.tagName === 'SPAN' &&
|
||||
el.classList.contains('text-foreground') &&
|
||||
!!el.textContent,
|
||||
).map(el => el.textContent)
|
||||
}
|
||||
|
||||
it('ranks "Toggle Raw Editor" before "Create New Note" for query "raw"', () => {
|
||||
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } })
|
||||
|
||||
const labels = getVisibleLabels()
|
||||
const rawIdx = labels.indexOf('Toggle Raw Editor')
|
||||
const createIdx = labels.indexOf('Create New Note')
|
||||
expect(rawIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(createIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(rawIdx).toBeLessThan(createIdx)
|
||||
})
|
||||
|
||||
it('ranks "Create New Note" first for query "new note"', () => {
|
||||
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } })
|
||||
|
||||
const labels = getVisibleLabels()
|
||||
expect(labels[0]).toBe('Create New Note')
|
||||
})
|
||||
|
||||
it('ranks theme commands first for query "theme"', () => {
|
||||
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'theme' } })
|
||||
|
||||
const labels = getVisibleLabels()
|
||||
expect(labels[0]).toBe('Switch Theme')
|
||||
})
|
||||
|
||||
it('preserves default section order with empty query', () => {
|
||||
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
||||
|
||||
const groupHeaders = screen.getAllByText(
|
||||
(_content, el) =>
|
||||
el?.tagName === 'DIV' &&
|
||||
el.classList.contains('uppercase') &&
|
||||
!!el.textContent,
|
||||
).map(el => el.textContent)
|
||||
|
||||
// Default order: Navigation < Note < View < Appearance
|
||||
expect(groupHeaders).toEqual(['Navigation', 'Note', 'View', 'Appearance'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,16 +30,21 @@ function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function groupResults(commands: CommandAction[]): { group: CommandGroup; items: CommandAction[] }[] {
|
||||
function groupResults(
|
||||
commands: CommandAction[],
|
||||
byRelevance: boolean,
|
||||
): { group: CommandGroup; items: CommandAction[] }[] {
|
||||
const map = new Map<CommandGroup, CommandAction[]>()
|
||||
for (const cmd of commands) {
|
||||
const list = map.get(cmd.group)
|
||||
if (list) list.push(cmd)
|
||||
else map.set(cmd.group, [cmd])
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
|
||||
.map(([group, items]) => ({ group, items }))
|
||||
const entries = Array.from(map.entries())
|
||||
if (!byRelevance) {
|
||||
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
|
||||
}
|
||||
return entries.map(([group, items]) => ({ group, items }))
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
|
||||
@@ -70,7 +75,8 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
|
||||
.map(r => r.command)
|
||||
}, [enabledCommands, query])
|
||||
|
||||
const groups = useMemo(() => groupResults(filtered), [filtered])
|
||||
const hasQuery = !!query.trim()
|
||||
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
|
||||
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user