From 801b2b2d5a4d1ddc32642ac8ef0eba9cbf12407d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=84=B1=EB=AF=BC?= Date: Thu, 17 Sep 2026 00:17:46 +0900 Subject: [PATCH] Fix localized application match ranking --- src/stores/ui.store.helpers.ts | 43 ++++++++++++++++++++++++++++++++++ src/stores/ui.store.tsx | 33 ++++++++++---------------- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/stores/ui.store.helpers.ts b/src/stores/ui.store.helpers.ts index ee1f9f7a..d82f5a1d 100644 --- a/src/stores/ui.store.helpers.ts +++ b/src/stores/ui.store.helpers.ts @@ -192,6 +192,49 @@ export function getInitials(name: string) { .join(""); } +function normalizeSearchText(value: string) { + return value + .normalize("NFC") + .toLowerCase() + .replace(/[\s.-]+/g, " "); +} + +function getTextMatchTier(value: string | undefined, query: string) { + if (!value) { + return 4; + } + + const normalizedValue = normalizeSearchText(value); + if (normalizedValue === query) { + return 0; + } + + if (normalizedValue.startsWith(query)) { + return 1; + } + + if (normalizedValue.split(/\s+/).some((word) => word.startsWith(query))) { + return 2; + } + + return normalizedValue.includes(query) ? 3 : 4; +} + +export function getSearchMatchTier( + item: Pick, + rawQuery: string, +) { + const query = normalizeSearchText(rawQuery).trim(); + if (!query) { + return 0; + } + + return Math.min( + getTextMatchTier(item.name, query), + getTextMatchTier(item.localizedName, query), + ); +} + export function traverse( bookmarks: Array<{ title: string; diff --git a/src/stores/ui.store.tsx b/src/stores/ui.store.tsx index c9aba150..325f081c 100644 --- a/src/stores/ui.store.tsx +++ b/src/stores/ui.store.tsx @@ -39,6 +39,7 @@ import { formatExpressionResult, type BookmarkNode, getInitials, + getSearchMatchTier, normalizeCalculatorQuery, parseFlightIdentifier, parseTimezoneConversion, @@ -144,6 +145,7 @@ const itemsThatShouldShowWindow = [ type RankedItem = Item & { score?: number; + matchTier?: number; }; export const createUIStore = (root: IRootStore) => { @@ -166,29 +168,15 @@ export const createUIStore = (root: IRootStore) => { }; // ponytail: coarse relevance tier so usage history can only reorder items of - // comparable match quality. A fuzzy hit ("Cap" for "cal") never outranks a - // direct prefix match ("Calendar") just because it was selected once. - const getMatchTier = (item: Pick) => { - const query = store.query.trim().toLowerCase(); - if (!query) { - return 0; - } - - const name = item.name.toLowerCase(); - if (name.startsWith(query)) { - return 0; - } - - // prefix of any word, e.g. "code" matching "Visual Studio Code" - if (name.split(/\s+/).some((word) => word.startsWith(query))) { - return 1; - } - - return name.includes(query) ? 2 : 3; - }; + // comparable match quality. Exact, prefix, word-prefix, substring, and fuzzy + // matches remain separate regardless of selection history. + const getMatchTier = (item: Pick) => + getSearchMatchTier(item, store.query); const compareRankedItems = (left: RankedItem, right: RankedItem) => { - const tierDiff = getMatchTier(left) - getMatchTier(right); + const tierDiff = + (left.matchTier ?? getMatchTier(left)) - + (right.matchTier ?? getMatchTier(right)); if (tierDiff !== 0) { return tierDiff; } @@ -545,6 +533,9 @@ export const createUIStore = (root: IRootStore) => { prefix: true, fuzzy: true, }) as unknown as RankedItem[]; + for (const result of results) { + result.matchTier = getMatchTier(result); + } results.sort(compareRankedItems);