diff --git a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx index 6b5b370d..e0b60aea 100644 --- a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx +++ b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx @@ -1,34 +1,29 @@ // Adapted from: web/src/components/ui/multi-select.tsx -import * as React from "react"; -import { - CheckIcon, - ChevronDown, - ScanSearchIcon, -} from "lucide-react"; - -import { cn } from "@/lib/utils"; -import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; -import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { RepositoryQuery, SearchContextQuery } from "@/lib/types"; +import { cn } from "@/lib/utils"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@/components/ui/command"; -import { RepoSetSearchScope, RepoSearchScope, SearchScope } from "../../types"; + CheckIcon, + ChevronDown, + ScanSearchIcon, +} from "lucide-react"; +import { ButtonHTMLAttributes, forwardRef, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { RepoSearchScope, RepoSetSearchScope, SearchScope } from "../../types"; import { SearchScopeIcon } from "../searchScopeIcon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { SearchScopeInfoCard } from "./searchScopeInfoCard"; -interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes { +interface SearchScopeSelectorProps extends ButtonHTMLAttributes { repos: RepositoryQuery[]; searchContexts: SearchContextQuery[]; selectedSearchScopes: SearchScope[]; @@ -38,7 +33,7 @@ interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes void; } -export const SearchScopeSelector = React.forwardRef< +export const SearchScopeSelector = forwardRef< HTMLButtonElement, SearchScopeSelectorProps >( @@ -55,23 +50,13 @@ export const SearchScopeSelector = React.forwardRef< }, ref ) => { - const scrollContainerRef = React.useRef(null); - const scrollPosition = React.useRef(0); - const [hasSearchInput, setHasSearchInput] = React.useState(false); + const scrollContainerRef = useRef(null); + const scrollPosition = useRef(0); + const [searchQuery, setSearchQuery] = useState(""); + const [isMounted, setIsMounted] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); - const handleInputKeyDown = ( - event: React.KeyboardEvent - ) => { - if (event.key === "Enter") { - onOpenChanged(true); - } else if (event.key === "Backspace" && !event.currentTarget.value) { - const newSelectedItems = [...selectedSearchScopes]; - newSelectedItems.pop(); - onSelectedSearchScopesChange(newSelectedItems); - } - }; - - const toggleItem = (item: SearchScope) => { + const toggleItem = useCallback((item: SearchScope) => { // Store current scroll position before state update if (scrollContainerRef.current) { scrollPosition.current = scrollContainerRef.current.scrollTop; @@ -88,21 +73,9 @@ export const SearchScopeSelector = React.forwardRef< [...selectedSearchScopes, item]; onSelectedSearchScopesChange(newSelectedItems); - }; + }, [selectedSearchScopes, onSelectedSearchScopesChange]); - const handleClear = () => { - onSelectedSearchScopesChange([]); - }; - - const handleSelectAll = () => { - onSelectedSearchScopesChange(allSearchScopeItems); - }; - - const handleTogglePopover = () => { - onOpenChanged(!isOpen); - }; - - const allSearchScopeItems = React.useMemo(() => { + const allSearchScopeItems = useMemo(() => { const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({ type: 'reposet' as const, value: context.name, @@ -120,8 +93,40 @@ export const SearchScopeSelector = React.forwardRef< return [...repoSetSearchScopeItems, ...repoSearchScopeItems]; }, [repos, searchContexts]); - const sortedSearchScopeItems = React.useMemo(() => { + const handleClear = useCallback(() => { + onSelectedSearchScopesChange([]); + setSearchQuery(""); + requestAnimationFrame(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }) + }, [onSelectedSearchScopesChange]); + + const handleSelectAll = useCallback(() => { + onSelectedSearchScopesChange(allSearchScopeItems); + requestAnimationFrame(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }); + }, [onSelectedSearchScopesChange, allSearchScopeItems]); + + const handleTogglePopover = useCallback(() => { + onOpenChanged(!isOpen); + }, [onOpenChanged, isOpen]); + + const sortedSearchScopeItems = useMemo(() => { + const query = searchQuery.toLowerCase(); + return allSearchScopeItems + .filter((item) => { + // Filter by search query + if (query && !item.name.toLowerCase().includes(query) && !item.value.toLowerCase().includes(query)) { + return false; + } + return true; + }) .map((item) => ({ item, isSelected: selectedSearchScopes.some( @@ -137,10 +142,77 @@ export const SearchScopeSelector = React.forwardRef< if (a.item.type === 'repo' && b.item.type === 'reposet') return 1; return 0; }) - }, [allSearchScopeItems, selectedSearchScopes]); + }, [allSearchScopeItems, selectedSearchScopes, searchQuery]); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setHighlightedIndex((prev) => + prev < sortedSearchScopeItems.length - 1 ? prev + 1 : prev + ); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setHighlightedIndex((prev) => prev > 0 ? prev - 1 : 0); + } else if (event.key === "Enter") { + event.preventDefault(); + if (sortedSearchScopeItems.length > 0 && highlightedIndex >= 0) { + toggleItem(sortedSearchScopeItems[highlightedIndex].item); + } + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedItems = [...selectedSearchScopes]; + newSelectedItems.pop(); + onSelectedSearchScopesChange(newSelectedItems); + } + }, [highlightedIndex, onSelectedSearchScopesChange, selectedSearchScopes, sortedSearchScopeItems, toggleItem]); + + const virtualizer = useVirtualizer({ + count: sortedSearchScopeItems.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 36, + overscan: 5, + }); + + // Reset highlighted index and scroll to top when search query changes + useEffect(() => { + setHighlightedIndex(0); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }, [searchQuery]); + + // Reset highlighted index when items change (but don't scroll) + useEffect(() => { + setHighlightedIndex(0); + }, [sortedSearchScopeItems.length]); + + // Measure virtualizer when popover opens and container is mounted + useEffect(() => { + if (isOpen) { + setIsMounted(true); + setHighlightedIndex(0); + // Give the DOM a tick to render before measuring + requestAnimationFrame(() => { + if (scrollContainerRef.current) { + virtualizer.measure(); + } + }); + } else { + setIsMounted(false); + } + }, [isOpen, virtualizer]); + + // Scroll highlighted item into view + useEffect(() => { + if (isMounted && highlightedIndex >= 0) { + virtualizer.scrollToIndex(highlightedIndex, { + align: 'auto', + }); + } + }, [highlightedIndex, isMounted, virtualizer]); // Restore scroll position after re-render - React.useEffect(() => { + useEffect(() => { if (scrollContainerRef.current && scrollPosition.current > 0) { scrollContainerRef.current.scrollTop = scrollPosition.current; } @@ -151,106 +223,142 @@ export const SearchScopeSelector = React.forwardRef< open={isOpen} onOpenChange={onOpenChanged} > - - - - onOpenChanged(false)} - > - - setHasSearchInput(!!value)} - /> - - No results found. - - {!hasSearchInput && ( -
+ + - Select all + { + selectedSearchScopes.length === 0 ? `Search scopes` : + selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name : + `${selectedSearchScopes.length} selected` + } + + +
+ + + + + + + onOpenChanged(false)} + > +
+
+ setSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-11" + /> +
+
+ {sortedSearchScopeItems.length === 0 ? ( +
+ No results found. +
+ ) : ( +
+ {!searchQuery && ( +
+ Select all +
+ )} +
+ {isMounted && virtualizer.getVirtualItems().map((virtualItem) => { + const { item, isSelected } = sortedSearchScopeItems[virtualItem.index]; + const isHighlighted = virtualItem.index === highlightedIndex; + return ( +
toggleItem(item)} + onMouseEnter={() => setHighlightedIndex(virtualItem.index)} + className={cn( + "cursor-pointer absolute top-0 left-0 w-full flex items-center px-2 py-1.5 text-sm rounded-sm", + isHighlighted ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground" + )} + style={{ + transform: `translateY(${virtualItem.start}px)`, + }} + > +
+ +
+
+ +
+
+ + {item.name} + + {item.type === 'reposet' && ( + + {item.repoCount} repo{item.repoCount === 1 ? '' : 's'} + + )} +
+
+
+
+ ); + })} +
)} - {sortedSearchScopeItems.map(({ item, isSelected }) => { - return ( - toggleItem(item)} - className="cursor-pointer" - > -
- -
-
- -
-
- - {item.name} - - {item.type === 'reposet' && ( - - {item.repoCount} repo{item.repoCount === 1 ? '' : 's'} - - )} -
-
-
-
- ); - })} - - - {selectedSearchScopes.length > 0 && ( - <> - - - Clear - - - )} - - +
+ {selectedSearchScopes.length > 0 && ( + <> + +
+ Clear +
+ + )} +
+
+ ); }