Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/ui/src/hooks/filter-search.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
44 changes: 44 additions & 0 deletions packages/ui/src/hooks/filter-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import fuzzysort from "fuzzysort"

type Row<T> = { 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<string, unknown>)[part]
}
if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") return String(node)
return ""
}

const build = <T>(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 = <T>(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)
}
23 changes: 9 additions & 14 deletions packages/ui/src/hooks/use-filtered-list.tsx
Original file line number Diff line number Diff line change
@@ -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<T> {
items: T[] | ((filter: string) => T[] | Promise<T[]>)
Expand All @@ -27,18 +27,16 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
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<T[]> }) => {
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(),
Expand All @@ -50,11 +48,8 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
{ initialValue: empty },
)

const flat = createMemo(() => {
return pipe(
grouped.latest || [],
flatMap((x) => x.items),
)
const flat = createMemo<T[]>(() => {
return (grouped.latest || []).flatMap((item) => item.items)
})

function initialActive() {
Expand Down
Loading