Files
kandev/apps/web/lib/commands/search.ts
T
José Almeida 80b5516199 fix(web): improve command palette alias matching (#1718)
* fix(web): improve command palette alias matching

* docs(web): explain command priority tie-breaker

* fix(web): preserve command palette selection
2026-07-16 00:22:31 +01:00

94 lines
3.2 KiB
TypeScript

import { defaultFilter } from "cmdk";
import type { CommandItem } from "./types";
const MAX_COMBINED_TERMS_SCORE = 0.99;
const UNORDERED_TERMS_SCORE = 0.98;
export function getCommandSearchTerms(command: CommandItem): string[] {
return [command.label, ...(command.keywords ?? [])];
}
function tokenize(value: string): string[] {
return value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}
function scoreUnorderedTerms(terms: string[], search: string): number {
const queryTokens = tokenize(search);
if (queryTokens.length < 2) return 0;
const termTokens = terms.flatMap(tokenize);
const allTokensMatch = queryTokens.every((queryToken) =>
termTokens.some((termToken) => termToken.startsWith(queryToken)),
);
return allTokensMatch ? UNORDERED_TERMS_SCORE : 0;
}
export function scoreCommandSearch(_value: string, search: string, searchTerms?: string[]): number {
const normalizedSearch = search.trim();
const terms = searchTerms ?? [];
const bestTermScore = terms.reduce(
(bestScore, term) => Math.max(bestScore, defaultFilter(term, normalizedSearch, [])),
0,
);
const combinedTermsScore = defaultFilter(terms.join(" "), normalizedSearch, []);
return Math.max(
bestTermScore,
Math.min(combinedTermsScore, MAX_COMBINED_TERMS_SCORE),
scoreUnorderedTerms(terms, normalizedSearch),
);
}
function commandSearchScore(command: CommandItem, search: string): number {
return scoreCommandSearch(command.id, search, getCommandSearchTerms(command));
}
function compareCommandPriority(a: CommandItem, b: CommandItem): number {
return (a.priority ?? 100) - (b.priority ?? 100);
}
export function sortCommandsForSearch(commands: CommandItem[], search: string): CommandItem[] {
return [...commands].sort((a, b) => {
const scoreDifference = commandSearchScore(b, search) - commandSearchScore(a, search);
return scoreDifference || compareCommandPriority(a, b);
});
}
export function findFirstMatchingCommand(
commands: CommandItem[],
search: string,
preferredCommandId?: string,
): CommandItem | undefined {
const preferredCommand = preferredCommandId
? commands.find((command) => command.id === preferredCommandId)
: undefined;
if (preferredCommand && commandSearchScore(preferredCommand, search) > 0) {
return preferredCommand;
}
return sortCommandsForSearch(commands, search).find(
(command) => commandSearchScore(command, search) > 0,
);
}
export function selectCommandSearchResult(
commands: CommandItem[],
search: string,
leadingResultValues: string[],
preferredValue?: string,
): string {
const normalizedSearch = search.trim();
if (preferredValue) {
if (leadingResultValues.includes(preferredValue)) return preferredValue;
const preferredCommand = commands.find((command) => command.id === preferredValue);
const preferredCommandStillVisible =
preferredCommand &&
(!normalizedSearch ||
findFirstMatchingCommand(commands, normalizedSearch, preferredValue)?.id ===
preferredValue);
if (preferredCommandStillVisible) return preferredValue;
}
const firstLeadingResult = leadingResultValues[0];
if (firstLeadingResult) return firstLeadingResult;
return normalizedSearch ? (findFirstMatchingCommand(commands, normalizedSearch)?.id ?? "") : "";
}