diff --git a/packages/ui/src/hooks/filter-search.test.ts b/packages/ui/src/hooks/filter-search.test.ts new file mode 100644 index 00000000000..f3ffaa23f0f --- /dev/null +++ b/packages/ui/src/hooks/filter-search.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { fuzzy, normalize } from "./filter-search" + +describe("filter search", () => { + test("normalizes punctuation and separators", () => { + expect(normalize(" GPT_5,3-mini ")).toBe("gpt53mini") + }) + + test("matches locale punctuation on plain strings", () => { + const list = ["gpt-5.3", "gpt-5.2"] + expect(fuzzy(normalize("5,3"), list)[0]).toBe("gpt-5.3") + }) + + test("matches objects through normalized indexed keys", () => { + const list = [ + { id: "openai:gpt-5.3", name: "GPT-5.3", provider: { name: "OpenAI" } }, + { id: "openai:gpt-5.2", name: "GPT-5.2", provider: { name: "OpenAI" } }, + ] + const result = fuzzy(normalize("openai gpt_5,3"), list, ["provider.name", "name", "id"]) + expect(result[0]?.id).toBe("openai:gpt-5.3") + }) + + test("boosts normalized prefix matches", () => { + const list = ["my-gpt-53", "gpt-5.3", "x-gpt53"] + expect(fuzzy(normalize("gpt53"), list)[0]).toBe("gpt-5.3") + }) +}) diff --git a/packages/ui/src/hooks/filter-search.ts b/packages/ui/src/hooks/filter-search.ts new file mode 100644 index 00000000000..a5319c07b13 --- /dev/null +++ b/packages/ui/src/hooks/filter-search.ts @@ -0,0 +1,44 @@ +import fuzzysort from "fuzzysort" + +type Row = { val: T; text: string; ord: number } + +export const normalize = (value: string) => + value + .toLowerCase() + .normalize("NFKC") + .replaceAll(",", ".") + .replace(/[\s._\-/\\]+/g, "") + +const pull = (value: unknown, key: string) => { + let node = value + for (const part of key.split(".")) { + if (!node || typeof node !== "object") return "" + node = (node as Record)[part] + } + if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") return String(node) + return "" +} + +const build = (list: T[], keys?: string[]) => { + if (!keys || keys.length === 0) { + return list.map((val, ord) => ({ val, ord, text: normalize(String(val)) })) + } + return list.map((val, ord) => ({ + val, + ord, + text: normalize(keys.map((key) => pull(val, key)).join(" ")), + })) +} + +export const fuzzy = (needle: string, list: T[], keys?: string[]) => { + const rows = build(list, keys) + return Array.from(fuzzysort.go(needle, rows, { key: "text" })) + .sort((a, b) => { + const ab = Number(a.obj.text.startsWith(needle)) + const bb = Number(b.obj.text.startsWith(needle)) + if (ab !== bb) return bb - ab + if (a.score !== b.score) return b.score - a.score + return a.obj.ord - b.obj.ord + }) + .map((hit) => hit.obj.val) +} diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 2d4e2bdd1aa..b741cafb6de 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -1,8 +1,8 @@ -import fuzzysort from "fuzzysort" -import { entries, flatMap, groupBy, map, pipe } from "remeda" +import { entries, groupBy, map, pipe } from "remeda" import { createEffect, createMemo, createResource, on } from "solid-js" import { createStore } from "solid-js/store" import { createList } from "solid-list" +import { fuzzy, normalize } from "./filter-search" export interface FilteredListProps { items: T[] | ((filter: string) => T[] | Promise) @@ -27,18 +27,16 @@ export function useFilteredList(props: FilteredListProps) { filter: store.filter, items: typeof props.items === "function" ? props.items(store.filter) : props.items, }), - async ({ filter, items }) => { + async ({ filter, items }: { filter: string; items: T[] | Promise }) => { const query = filter ?? "" - const needle = query.toLowerCase() + const needle = normalize(query) const all = (await Promise.resolve(items)) || [] const result = pipe( all, - (x) => { + (x: T[]) => { if (!needle) return x - if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) { - return fuzzysort.go(needle, x).map((x) => x.target) as T[] - } - return fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj) + if (props.filterKeys) return fuzzy(needle, x, props.filterKeys) + return fuzzy(needle, x) }, groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), entries(), @@ -50,11 +48,8 @@ export function useFilteredList(props: FilteredListProps) { { initialValue: empty }, ) - const flat = createMemo(() => { - return pipe( - grouped.latest || [], - flatMap((x) => x.items), - ) + const flat = createMemo(() => { + return (grouped.latest || []).flatMap((item) => item.items) }) function initialActive() {