diff --git a/apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx b/apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx index 09482224e..5c46c0cb2 100644 --- a/apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx +++ b/apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx @@ -338,6 +338,37 @@ export const EmptyState: Story = { }, }; +export const FilteredContent: Story = { + render: () => { + const [value, setValue] = useState(""); + const allFruits = [...fruits, ...tropicalFruits, ...citrusFruits]; + + return ( + + + f.label} limit={5}> + {({ filtered, hasMore, moreCount }) => ( + <> + + No fruits found. + {filtered.map((fruit) => ( + + {fruit.label} + + ))} + {hasMore && ( +
+ {moreCount} more — type to filter +
+ )} + + )} +
+
+ ); + }, +}; + export const ControlledSearch: Story = { render: () => { const [value, setValue] = useState(""); diff --git a/apps/code/src/renderer/components/ui/combobox/Combobox.tsx b/apps/code/src/renderer/components/ui/combobox/Combobox.tsx index ace83dd44..41a128115 100644 --- a/apps/code/src/renderer/components/ui/combobox/Combobox.tsx +++ b/apps/code/src/renderer/components/ui/combobox/Combobox.tsx @@ -12,6 +12,7 @@ import React, { } from "react"; import { Tooltip } from "../Tooltip"; import "./Combobox.css"; +import { useComboboxFilter } from "./useComboboxFilter"; type ComboboxSize = "1" | "2" | "3"; type ComboboxTriggerVariant = @@ -44,6 +45,12 @@ function useComboboxContext() { return context; } +interface FilterContextValue { + onSearchChange: (value: string) => void; +} + +const FilterContext = createContext(null); + interface ComboboxRootProps { children: React.ReactNode; value?: string; @@ -183,18 +190,44 @@ function ComboboxTrigger({ ); } -interface ComboboxContentProps { - children: React.ReactNode; +interface FilterResult { + filtered: T[]; + hasMore: boolean; + moreCount: number; +} + +interface ComboboxContentBaseProps { className?: string; variant?: ComboboxContentVariant; side?: "top" | "right" | "bottom" | "left"; sideOffset?: number; align?: "start" | "center" | "end"; style?: React.CSSProperties; +} + +interface ComboboxContentStaticProps extends ComboboxContentBaseProps { + items?: undefined; shouldFilter?: boolean; + children: React.ReactNode; } -function ComboboxContent({ +interface ComboboxContentFilteredProps extends ComboboxContentBaseProps { + /** Items to filter. Activates built-in fuzzy filtering (bypasses cmdk). */ + items: T[]; + /** Extract the searchable string from each item. Defaults to `String(item)`. */ + getValue?: (item: T) => string; + /** Maximum items to render. Defaults to 50. */ + limit?: number; + /** Values pinned to the top regardless of score. */ + pinned?: string[]; + children: (result: FilterResult) => React.ReactNode; +} + +type ComboboxContentProps = + | ComboboxContentStaticProps + | ComboboxContentFilteredProps; + +function ComboboxContent({ children, className = "", variant = "soft", @@ -202,15 +235,36 @@ function ComboboxContent({ sideOffset = 4, align = "start", style, - shouldFilter = true, -}: ComboboxContentProps) { - const { size, onOpenChange } = useComboboxContext(); + ...rest +}: ComboboxContentProps) { + const { size, open, onOpenChange } = useComboboxContext(); + + const hasItems = "items" in rest && rest.items !== undefined; + const filterItems = hasItems ? rest.items : ([] as T[]); + const getValue = hasItems ? rest.getValue : undefined; + const limit = hasItems ? rest.limit : undefined; + const pinned = hasItems ? rest.pinned : undefined; + const shouldFilter = hasItems + ? false + : "shouldFilter" in rest + ? (rest.shouldFilter ?? true) + : true; + + const filter = useComboboxFilter( + filterItems, + { limit, pinned, open }, + getValue, + ); - const hasInput = React.Children.toArray(children).some( + const resolvedChildren = hasItems + ? (children as (result: FilterResult) => React.ReactNode)(filter) + : (children as React.ReactNode); + + const hasInput = React.Children.toArray(resolvedChildren).some( (child) => React.isValidElement(child) && child.type === ComboboxInput, ); - return ( + const content = ( {hasInput && - React.Children.map(children, (child) => + React.Children.map(resolvedChildren, (child) => React.isValidElement(child) && child.type === ComboboxInput ? child : null, )} - {React.Children.map(children, (child) => + {React.Children.map(resolvedChildren, (child) => React.isValidElement(child) && child.type !== ComboboxInput && child.type !== ComboboxFooter @@ -247,7 +301,7 @@ function ComboboxContent({ : null, )} - {React.Children.map(children, (child) => + {React.Children.map(resolvedChildren, (child) => React.isValidElement(child) && child.type === ComboboxFooter ? child : null, @@ -255,6 +309,16 @@ function ComboboxContent({ ); + + if (hasItems) { + return ( + + {content} + + ); + } + + return content; } interface ComboboxInputProps { @@ -268,6 +332,9 @@ const ComboboxInput = React.forwardRef< React.ElementRef, ComboboxInputProps >(({ placeholder = "Search...", className, value, onValueChange }, ref) => { + const filterCtx = useContext(FilterContext); + const handleValueChange = onValueChange ?? filterCtx?.onSearchChange; + return (
diff --git a/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts b/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts new file mode 100644 index 000000000..a4fb76852 --- /dev/null +++ b/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts @@ -0,0 +1,112 @@ +import { defaultFilter } from "cmdk"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const DEFAULT_LIMIT = 50; +const MIN_FUZZY_SCORE = 0.1; +const DEBOUNCE_MS = 150; + +interface UseComboboxFilterOptions { + /** Maximum number of items to render. Defaults to 50. */ + limit?: number; + /** Values pinned to the top regardless of score. */ + pinned?: string[]; + /** Popover open state. Search resets when this becomes false. */ + open?: boolean; +} + +interface UseComboboxFilterResult { + filtered: T[]; + onSearchChange: (value: string) => void; + hasMore: boolean; + moreCount: number; +} + +/** + * Fuzzy-filters and caps a list of items for use with Combobox. + * + * Prefer passing `items` directly to `Combobox.Content` which handles all + * wiring automatically. Use this hook directly only when you need custom + * control over the filtering lifecycle. + */ +export function useComboboxFilter( + items: T[], + options?: UseComboboxFilterOptions, + getValue?: (item: T) => string, +): UseComboboxFilterResult { + const [search, setSearch] = useState(""); + const debounceRef = useRef>(undefined); + const limit = options?.limit ?? DEFAULT_LIMIT; + const pinned = options?.pinned; + const open = options?.open; + + const debouncedSetSearch = useCallback((value: string) => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setSearch(value), DEBOUNCE_MS); + }, []); + + useEffect(() => { + if (!open) { + clearTimeout(debounceRef.current); + setSearch(""); + } + }, [open]); + + useEffect(() => () => clearTimeout(debounceRef.current), []); + + const resolve = useCallback( + (item: T): string => (getValue ? getValue(item) : String(item)), + [getValue], + ); + + const { filtered, totalMatches } = useMemo(() => { + const query = search.trim(); + + // Score and filter items. cmdk's fuzzy matcher can produce very low scores + // for scattered single-character matches (e.g. "vojta" matching v-o-j-t-a + // across "chore-remoVe-cOhort-Join-aTtempt"), so we require a minimum score + // to avoid noisy results. + let scored: Array<{ item: T; score: number }>; + if (query) { + scored = []; + for (const item of items) { + const score = defaultFilter(resolve(item), query); + if (score >= MIN_FUZZY_SCORE) scored.push({ item, score }); + } + } else { + scored = items.map((item) => ({ item, score: 0 })); + } + + const total = scored.length; + + // Sort: pinned first (in order), then by score descending (stable for equal scores) + if (pinned) { + const pinnedSet = new Set(pinned); + scored.sort((a, b) => { + const aVal = resolve(a.item); + const bVal = resolve(b.item); + const aPinned = pinnedSet.has(aVal); + const bPinned = pinnedSet.has(bVal); + if (aPinned && !bPinned) return -1; + if (!aPinned && bPinned) return 1; + if (aPinned && bPinned) { + return pinned.indexOf(aVal) - pinned.indexOf(bVal); + } + return b.score - a.score; + }); + } else if (query) { + scored.sort((a, b) => b.score - a.score); + } + + return { + filtered: scored.slice(0, limit).map((s) => s.item), + totalMatches: total, + }; + }, [items, search, limit, pinned, resolve]); + + return { + filtered, + onSearchChange: debouncedSetSearch, + hasMore: totalMatches > filtered.length, + moreCount: Math.max(0, totalMatches - filtered.length), + }; +} diff --git a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx b/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx index d46370484..dadbc3189 100644 --- a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx +++ b/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx @@ -1,6 +1,7 @@ import { Combobox } from "@components/ui/combobox/Combobox"; import { GithubLogo } from "@phosphor-icons/react"; import { Button, Flex, Text } from "@radix-ui/themes"; +import { useState } from "react"; interface GitHubRepoPickerProps { value: string | null; @@ -21,6 +22,8 @@ export function GitHubRepoPicker({ size = "1", disabled = false, }: GitHubRepoPickerProps) { + const [open, setOpen] = useState(false); + if (isLoading) { return ( - + {filtered.map((branch) => ( + } + > + {branch} + + ))} + {hasMore && ( +
+ {moreCount} more {moreCount === 1 ? "branch" : "branches"}{" "} + — type to filter +
+ )} + + )} + + {!isCloudMode && ( + + + + )} + )}