From 4c61faac9165a9f58bcc767c1224e7c8eb4630b7 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Thu, 9 Apr 2026 16:44:32 +0200 Subject: [PATCH 1/4] chore(code): introducing combobox filter for better searching --- .../ui/combobox/useComboboxFilter.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts 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..0fa5584f2 --- /dev/null +++ b/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts @@ -0,0 +1,113 @@ +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. + * + * Bypasses cmdk's built-in DOM-based filtering. The consumer should pass + * `shouldFilter={false}` to `Combobox.Content` and wire `onSearchChange` + * to `Combobox.Input`'s `onValueChange`. Do not pass a controlled `value` + * to the input -- let cmdk manage the input display natively. + */ +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), + }; +} From 674134805dcca90994a067ffb0c4f2155cc0db46 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Thu, 9 Apr 2026 16:44:56 +0200 Subject: [PATCH 2/4] chore(code): uptaking combobox filtering for branch selector --- .../components/BranchSelector.tsx | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx index e17840c8a..41cdbc71d 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -1,4 +1,5 @@ import { Combobox } from "@components/ui/combobox/Combobox"; +import { useComboboxFilter } from "@components/ui/combobox/useComboboxFilter"; import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; import { getSuggestedBranchName } from "@features/git-interaction/utils/getSuggestedBranchName"; import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; @@ -62,6 +63,17 @@ export function BranchSelector({ const branches = isCloudMode ? (cloudBranches ?? []) : localBranches; const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading); + const { + filtered: filteredBranches, + onSearchChange, + hasMore, + moreCount, + } = useComboboxFilter(branches, { + limit: 50, + pinned: [displayedBranch, defaultBranch].filter(Boolean) as string[], + open, + }); + const checkoutMutation = useMutation( trpc.git.checkoutBranch.mutationOptions({ onSuccess: () => { @@ -118,23 +130,34 @@ export function BranchSelector({ {triggerContent} - - + + No branches found. - - {branches.map((branch) => ( - } - > - {branch} - - ))} - + {filteredBranches.length > 0 && ( + + {filteredBranches.map((branch) => ( + } + > + {branch} + + ))} + {hasMore && ( +
+ {moreCount} more {moreCount === 1 ? "branch" : "branches"} — + type to filter +
+ )} +
+ )} {!isCloudMode && ( From 819bedcc59997d8e5fb3ce5048a44a4017e8c221 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Thu, 9 Apr 2026 16:47:23 +0200 Subject: [PATCH 3/4] chore(code): uptake combobox filter for the GH repo picker --- .../components/GitHubRepoPicker.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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..a5cfc4b92 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,8 @@ import { Combobox } from "@components/ui/combobox/Combobox"; +import { useComboboxFilter } from "@components/ui/combobox/useComboboxFilter"; 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 +23,14 @@ export function GitHubRepoPicker({ size = "1", disabled = false, }: GitHubRepoPickerProps) { + const [open, setOpen] = useState(false); + const { + filtered: filteredRepos, + onSearchChange, + hasMore, + moreCount, + } = useComboboxFilter(repositories, { limit: 50, open }); + if (isLoading) { return ( - + {!isCloudMode && ( + + + + )} + )}