Skip to content

Commit 02b26ae

Browse files
feat(code): add useComboboxFilter hook to improve branch and repo picker performance (#1588)
1 parent 46f2c67 commit 02b26ae

5 files changed

Lines changed: 304 additions & 60 deletions

File tree

apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,37 @@ export const EmptyState: Story = {
338338
},
339339
};
340340

341+
export const FilteredContent: Story = {
342+
render: () => {
343+
const [value, setValue] = useState("");
344+
const allFruits = [...fruits, ...tropicalFruits, ...citrusFruits];
345+
346+
return (
347+
<Combobox.Root value={value} onValueChange={setValue}>
348+
<Combobox.Trigger placeholder="Search fruits..." />
349+
<Combobox.Content items={allFruits} getValue={(f) => f.label} limit={5}>
350+
{({ filtered, hasMore, moreCount }) => (
351+
<>
352+
<Combobox.Input placeholder="Type to search..." />
353+
<Combobox.Empty>No fruits found.</Combobox.Empty>
354+
{filtered.map((fruit) => (
355+
<Combobox.Item key={fruit.value} value={fruit.value}>
356+
{fruit.label}
357+
</Combobox.Item>
358+
))}
359+
{hasMore && (
360+
<div className="combobox-label">
361+
{moreCount} more — type to filter
362+
</div>
363+
)}
364+
</>
365+
)}
366+
</Combobox.Content>
367+
</Combobox.Root>
368+
);
369+
},
370+
};
371+
341372
export const ControlledSearch: Story = {
342373
render: () => {
343374
const [value, setValue] = useState("");

apps/code/src/renderer/components/ui/combobox/Combobox.tsx

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import React, {
1212
} from "react";
1313
import { Tooltip } from "../Tooltip";
1414
import "./Combobox.css";
15+
import { useComboboxFilter } from "./useComboboxFilter";
1516

1617
type ComboboxSize = "1" | "2" | "3";
1718
type ComboboxTriggerVariant =
@@ -44,6 +45,12 @@ function useComboboxContext() {
4445
return context;
4546
}
4647

48+
interface FilterContextValue {
49+
onSearchChange: (value: string) => void;
50+
}
51+
52+
const FilterContext = createContext<FilterContextValue | null>(null);
53+
4754
interface ComboboxRootProps {
4855
children: React.ReactNode;
4956
value?: string;
@@ -183,34 +190,81 @@ function ComboboxTrigger({
183190
);
184191
}
185192

186-
interface ComboboxContentProps {
187-
children: React.ReactNode;
193+
interface FilterResult<T> {
194+
filtered: T[];
195+
hasMore: boolean;
196+
moreCount: number;
197+
}
198+
199+
interface ComboboxContentBaseProps {
188200
className?: string;
189201
variant?: ComboboxContentVariant;
190202
side?: "top" | "right" | "bottom" | "left";
191203
sideOffset?: number;
192204
align?: "start" | "center" | "end";
193205
style?: React.CSSProperties;
206+
}
207+
208+
interface ComboboxContentStaticProps extends ComboboxContentBaseProps {
209+
items?: undefined;
194210
shouldFilter?: boolean;
211+
children: React.ReactNode;
195212
}
196213

197-
function ComboboxContent({
214+
interface ComboboxContentFilteredProps<T> extends ComboboxContentBaseProps {
215+
/** Items to filter. Activates built-in fuzzy filtering (bypasses cmdk). */
216+
items: T[];
217+
/** Extract the searchable string from each item. Defaults to `String(item)`. */
218+
getValue?: (item: T) => string;
219+
/** Maximum items to render. Defaults to 50. */
220+
limit?: number;
221+
/** Values pinned to the top regardless of score. */
222+
pinned?: string[];
223+
children: (result: FilterResult<T>) => React.ReactNode;
224+
}
225+
226+
type ComboboxContentProps<T = never> =
227+
| ComboboxContentStaticProps
228+
| ComboboxContentFilteredProps<T>;
229+
230+
function ComboboxContent<T>({
198231
children,
199232
className = "",
200233
variant = "soft",
201234
side = "bottom",
202235
sideOffset = 4,
203236
align = "start",
204237
style,
205-
shouldFilter = true,
206-
}: ComboboxContentProps) {
207-
const { size, onOpenChange } = useComboboxContext();
238+
...rest
239+
}: ComboboxContentProps<T>) {
240+
const { size, open, onOpenChange } = useComboboxContext();
241+
242+
const hasItems = "items" in rest && rest.items !== undefined;
243+
const filterItems = hasItems ? rest.items : ([] as T[]);
244+
const getValue = hasItems ? rest.getValue : undefined;
245+
const limit = hasItems ? rest.limit : undefined;
246+
const pinned = hasItems ? rest.pinned : undefined;
247+
const shouldFilter = hasItems
248+
? false
249+
: "shouldFilter" in rest
250+
? (rest.shouldFilter ?? true)
251+
: true;
252+
253+
const filter = useComboboxFilter(
254+
filterItems,
255+
{ limit, pinned, open },
256+
getValue,
257+
);
208258

209-
const hasInput = React.Children.toArray(children).some(
259+
const resolvedChildren = hasItems
260+
? (children as (result: FilterResult<T>) => React.ReactNode)(filter)
261+
: (children as React.ReactNode);
262+
263+
const hasInput = React.Children.toArray(resolvedChildren).some(
210264
(child) => React.isValidElement(child) && child.type === ComboboxInput,
211265
);
212266

213-
return (
267+
const content = (
214268
<Popover.Content
215269
className={`combobox-content size-${size} variant-${variant} ${className}`}
216270
side={side}
@@ -233,28 +287,38 @@ function ComboboxContent({
233287
>
234288
<CmdkCommand shouldFilter={shouldFilter} loop>
235289
{hasInput &&
236-
React.Children.map(children, (child) =>
290+
React.Children.map(resolvedChildren, (child) =>
237291
React.isValidElement(child) && child.type === ComboboxInput
238292
? child
239293
: null,
240294
)}
241295
<CmdkCommand.List>
242-
{React.Children.map(children, (child) =>
296+
{React.Children.map(resolvedChildren, (child) =>
243297
React.isValidElement(child) &&
244298
child.type !== ComboboxInput &&
245299
child.type !== ComboboxFooter
246300
? child
247301
: null,
248302
)}
249303
</CmdkCommand.List>
250-
{React.Children.map(children, (child) =>
304+
{React.Children.map(resolvedChildren, (child) =>
251305
React.isValidElement(child) && child.type === ComboboxFooter
252306
? child
253307
: null,
254308
)}
255309
</CmdkCommand>
256310
</Popover.Content>
257311
);
312+
313+
if (hasItems) {
314+
return (
315+
<FilterContext.Provider value={{ onSearchChange: filter.onSearchChange }}>
316+
{content}
317+
</FilterContext.Provider>
318+
);
319+
}
320+
321+
return content;
258322
}
259323

260324
interface ComboboxInputProps {
@@ -268,6 +332,9 @@ const ComboboxInput = React.forwardRef<
268332
React.ElementRef<typeof CmdkCommand.Input>,
269333
ComboboxInputProps
270334
>(({ placeholder = "Search...", className, value, onValueChange }, ref) => {
335+
const filterCtx = useContext(FilterContext);
336+
const handleValueChange = onValueChange ?? filterCtx?.onSearchChange;
337+
271338
return (
272339
<div className="combobox-input-wrapper">
273340
<MagnifyingGlass
@@ -280,7 +347,7 @@ const ComboboxInput = React.forwardRef<
280347
className={className}
281348
placeholder={placeholder}
282349
value={value}
283-
onValueChange={onValueChange}
350+
onValueChange={handleValueChange}
284351
autoFocus
285352
/>
286353
</div>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { defaultFilter } from "cmdk";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3+
4+
const DEFAULT_LIMIT = 50;
5+
const MIN_FUZZY_SCORE = 0.1;
6+
const DEBOUNCE_MS = 150;
7+
8+
interface UseComboboxFilterOptions {
9+
/** Maximum number of items to render. Defaults to 50. */
10+
limit?: number;
11+
/** Values pinned to the top regardless of score. */
12+
pinned?: string[];
13+
/** Popover open state. Search resets when this becomes false. */
14+
open?: boolean;
15+
}
16+
17+
interface UseComboboxFilterResult<T> {
18+
filtered: T[];
19+
onSearchChange: (value: string) => void;
20+
hasMore: boolean;
21+
moreCount: number;
22+
}
23+
24+
/**
25+
* Fuzzy-filters and caps a list of items for use with Combobox.
26+
*
27+
* Prefer passing `items` directly to `Combobox.Content` which handles all
28+
* wiring automatically. Use this hook directly only when you need custom
29+
* control over the filtering lifecycle.
30+
*/
31+
export function useComboboxFilter<T>(
32+
items: T[],
33+
options?: UseComboboxFilterOptions,
34+
getValue?: (item: T) => string,
35+
): UseComboboxFilterResult<T> {
36+
const [search, setSearch] = useState("");
37+
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
38+
const limit = options?.limit ?? DEFAULT_LIMIT;
39+
const pinned = options?.pinned;
40+
const open = options?.open;
41+
42+
const debouncedSetSearch = useCallback((value: string) => {
43+
clearTimeout(debounceRef.current);
44+
debounceRef.current = setTimeout(() => setSearch(value), DEBOUNCE_MS);
45+
}, []);
46+
47+
useEffect(() => {
48+
if (!open) {
49+
clearTimeout(debounceRef.current);
50+
setSearch("");
51+
}
52+
}, [open]);
53+
54+
useEffect(() => () => clearTimeout(debounceRef.current), []);
55+
56+
const resolve = useCallback(
57+
(item: T): string => (getValue ? getValue(item) : String(item)),
58+
[getValue],
59+
);
60+
61+
const { filtered, totalMatches } = useMemo(() => {
62+
const query = search.trim();
63+
64+
// Score and filter items. cmdk's fuzzy matcher can produce very low scores
65+
// for scattered single-character matches (e.g. "vojta" matching v-o-j-t-a
66+
// across "chore-remoVe-cOhort-Join-aTtempt"), so we require a minimum score
67+
// to avoid noisy results.
68+
let scored: Array<{ item: T; score: number }>;
69+
if (query) {
70+
scored = [];
71+
for (const item of items) {
72+
const score = defaultFilter(resolve(item), query);
73+
if (score >= MIN_FUZZY_SCORE) scored.push({ item, score });
74+
}
75+
} else {
76+
scored = items.map((item) => ({ item, score: 0 }));
77+
}
78+
79+
const total = scored.length;
80+
81+
// Sort: pinned first (in order), then by score descending (stable for equal scores)
82+
if (pinned) {
83+
const pinnedSet = new Set(pinned);
84+
scored.sort((a, b) => {
85+
const aVal = resolve(a.item);
86+
const bVal = resolve(b.item);
87+
const aPinned = pinnedSet.has(aVal);
88+
const bPinned = pinnedSet.has(bVal);
89+
if (aPinned && !bPinned) return -1;
90+
if (!aPinned && bPinned) return 1;
91+
if (aPinned && bPinned) {
92+
return pinned.indexOf(aVal) - pinned.indexOf(bVal);
93+
}
94+
return b.score - a.score;
95+
});
96+
} else if (query) {
97+
scored.sort((a, b) => b.score - a.score);
98+
}
99+
100+
return {
101+
filtered: scored.slice(0, limit).map((s) => s.item),
102+
totalMatches: total,
103+
};
104+
}, [items, search, limit, pinned, resolve]);
105+
106+
return {
107+
filtered,
108+
onSearchChange: debouncedSetSearch,
109+
hasMore: totalMatches > filtered.length,
110+
moreCount: Math.max(0, totalMatches - filtered.length),
111+
};
112+
}

apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Combobox } from "@components/ui/combobox/Combobox";
22
import { GithubLogo } from "@phosphor-icons/react";
33
import { Button, Flex, Text } from "@radix-ui/themes";
4+
import { useState } from "react";
45

56
interface GitHubRepoPickerProps {
67
value: string | null;
@@ -21,6 +22,8 @@ export function GitHubRepoPicker({
2122
size = "1",
2223
disabled = false,
2324
}: GitHubRepoPickerProps) {
25+
const [open, setOpen] = useState(false);
26+
2427
if (isLoading) {
2528
return (
2629
<Button color="gray" variant="outline" size={size} disabled>
@@ -47,6 +50,8 @@ export function GitHubRepoPicker({
4750
<Combobox.Root
4851
value={value ?? ""}
4952
onValueChange={onChange}
53+
open={open}
54+
onOpenChange={setOpen}
5055
size={size}
5156
disabled={disabled}
5257
>
@@ -58,14 +63,24 @@ export function GitHubRepoPicker({
5863
</Text>
5964
</Flex>
6065
</Combobox.Trigger>
61-
<Combobox.Content>
62-
<Combobox.Input placeholder="Search repositories..." />
63-
<Combobox.Empty>No repositories found.</Combobox.Empty>
64-
{repositories.map((repo) => (
65-
<Combobox.Item key={repo} value={repo} textValue={repo}>
66-
{repo}
67-
</Combobox.Item>
68-
))}
66+
<Combobox.Content items={repositories} limit={50}>
67+
{({ filtered, hasMore, moreCount }) => (
68+
<>
69+
<Combobox.Input placeholder="Search repositories..." />
70+
<Combobox.Empty>No repositories found.</Combobox.Empty>
71+
{filtered.map((repo) => (
72+
<Combobox.Item key={repo} value={repo} textValue={repo}>
73+
{repo}
74+
</Combobox.Item>
75+
))}
76+
{hasMore && (
77+
<div className="combobox-label">
78+
{moreCount} more {moreCount === 1 ? "repo" : "repos"} — type to
79+
filter
80+
</div>
81+
)}
82+
</>
83+
)}
6984
</Combobox.Content>
7085
</Combobox.Root>
7186
);

0 commit comments

Comments
 (0)