mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
perf: add virtualized scrolling to search scope selector
This commit is contained in:
parent
154c95f4ee
commit
d0cb69fdbe
1 changed files with 258 additions and 150 deletions
|
|
@ -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<HTMLButtonElement> {
|
||||
interface SearchScopeSelectorProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
repos: RepositoryQuery[];
|
||||
searchContexts: SearchContextQuery[];
|
||||
selectedSearchScopes: SearchScope[];
|
||||
|
|
@ -38,7 +33,7 @@ interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes<HTMLButton
|
|||
onOpenChanged: (isOpen: boolean) => 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<HTMLDivElement>(null);
|
||||
const scrollPosition = React.useRef<number>(0);
|
||||
const [hasSearchInput, setHasSearchInput] = React.useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollPosition = useRef<number>(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(0);
|
||||
|
||||
const handleInputKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
onClick={handleTogglePopover}
|
||||
className={cn(
|
||||
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full mx-auto">
|
||||
<ScanSearchIcon className="h-4 w-4 text-muted-foreground mr-1" />
|
||||
<span
|
||||
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
|
||||
<Tooltip>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
onClick={handleTogglePopover}
|
||||
className={cn(
|
||||
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{
|
||||
selectedSearchScopes.length === 0 ? `Search scopes` :
|
||||
selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
|
||||
`${selectedSearchScopes.length} selected`
|
||||
}
|
||||
</span>
|
||||
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
onEscapeKeyDown={() => onOpenChanged(false)}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search scopes..."
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onValueChange={(value) => setHasSearchInput(!!value)}
|
||||
/>
|
||||
<CommandList ref={scrollContainerRef}>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{!hasSearchInput && (
|
||||
<div
|
||||
onClick={handleSelectAll}
|
||||
className="flex items-center px-2 py-1.5 text-sm text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
||||
<div className="flex items-center justify-between w-full mx-auto">
|
||||
<ScanSearchIcon className="h-4 w-4 text-muted-foreground mr-1" />
|
||||
<span
|
||||
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
|
||||
>
|
||||
<span className="text-xs">Select all</span>
|
||||
{
|
||||
selectedSearchScopes.length === 0 ? `Search scopes` :
|
||||
selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
|
||||
`${selectedSearchScopes.length} selected`
|
||||
}
|
||||
</span>
|
||||
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
|
||||
<SearchScopeInfoCard />
|
||||
</TooltipContent>
|
||||
<PopoverContent
|
||||
className="w-[400px] p-0"
|
||||
align="start"
|
||||
onEscapeKeyDown={() => onOpenChanged(false)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Input
|
||||
placeholder="Search scopes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-11"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="max-h-[300px] overflow-auto"
|
||||
>
|
||||
{sortedSearchScopeItems.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{!searchQuery && (
|
||||
<div
|
||||
onClick={handleSelectAll}
|
||||
className="flex items-center px-2 py-1.5 text-sm text-muted-foreground hover:text-foreground cursor-pointer transition-colors rounded-sm hover:bg-accent"
|
||||
>
|
||||
<span className="text-xs">Select all</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{isMounted && virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const { item, isSelected } = sortedSearchScopeItems[virtualItem.index];
|
||||
const isHighlighted = virtualItem.index === highlightedIndex;
|
||||
return (
|
||||
<div
|
||||
key={`${item.type}-${item.value}`}
|
||||
onClick={() => 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)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<SearchScopeIcon searchScope={item} />
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{item.name}
|
||||
</span>
|
||||
{item.type === 'reposet' && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground"
|
||||
>
|
||||
{item.repoCount} repo{item.repoCount === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sortedSearchScopeItems.map(({ item, isSelected }) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${item.type}-${item.value}`}
|
||||
onSelect={() => toggleItem(item)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<SearchScopeIcon searchScope={item} />
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{item.name}
|
||||
</span>
|
||||
{item.type === 'reposet' && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground"
|
||||
>
|
||||
{item.repoCount} repo{item.repoCount === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
{selectedSearchScopes.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandItem
|
||||
onSelect={handleClear}
|
||||
className="flex-1 justify-center cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
</CommandItem>
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
{selectedSearchScopes.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div
|
||||
onClick={handleClear}
|
||||
className="flex items-center justify-center px-2 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Clear
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue