2025-07-26 23:16:07 +00:00
|
|
|
// Adapted from: web/src/components/ui/multi-select.tsx
|
|
|
|
|
|
|
|
|
|
import * as React from "react";
|
|
|
|
|
import {
|
|
|
|
|
CheckIcon,
|
|
|
|
|
ChevronDown,
|
2025-07-29 01:12:21 +00:00
|
|
|
ScanSearchIcon,
|
2025-07-26 23:16:07 +00:00
|
|
|
} from "lucide-react";
|
|
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
import { cn } from "@/lib/utils";
|
2025-07-26 23:16:07 +00:00
|
|
|
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import {
|
|
|
|
|
Command,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandInput,
|
|
|
|
|
CommandItem,
|
|
|
|
|
CommandList,
|
|
|
|
|
CommandSeparator,
|
|
|
|
|
} from "@/components/ui/command";
|
2025-07-29 01:12:21 +00:00
|
|
|
import { RepoSetSearchScope, RepoSearchScope, SearchScope } from "../../types";
|
|
|
|
|
import { SearchScopeIcon } from "../searchScopeIcon";
|
2025-07-26 23:16:07 +00:00
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
2025-07-26 23:16:07 +00:00
|
|
|
repos: RepositoryQuery[];
|
|
|
|
|
searchContexts: SearchContextQuery[];
|
2025-07-29 01:12:21 +00:00
|
|
|
selectedSearchScopes: SearchScope[];
|
|
|
|
|
onSelectedSearchScopesChange: (items: SearchScope[]) => void;
|
2025-07-26 23:16:07 +00:00
|
|
|
className?: string;
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onOpenChanged: (isOpen: boolean) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
export const SearchScopeSelector = React.forwardRef<
|
2025-07-26 23:16:07 +00:00
|
|
|
HTMLButtonElement,
|
2025-07-29 01:12:21 +00:00
|
|
|
SearchScopeSelectorProps
|
2025-07-26 23:16:07 +00:00
|
|
|
>(
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
repos,
|
|
|
|
|
searchContexts,
|
|
|
|
|
className,
|
2025-07-29 01:12:21 +00:00
|
|
|
selectedSearchScopes,
|
|
|
|
|
onSelectedSearchScopesChange,
|
2025-07-26 23:16:07 +00:00
|
|
|
isOpen,
|
|
|
|
|
onOpenChanged,
|
|
|
|
|
...props
|
|
|
|
|
},
|
|
|
|
|
ref
|
|
|
|
|
) => {
|
|
|
|
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
const scrollPosition = React.useRef<number>(0);
|
2025-07-29 22:50:36 +00:00
|
|
|
const [hasSearchInput, setHasSearchInput] = React.useState(false);
|
2025-07-26 23:16:07 +00:00
|
|
|
|
|
|
|
|
const handleInputKeyDown = (
|
|
|
|
|
event: React.KeyboardEvent<HTMLInputElement>
|
|
|
|
|
) => {
|
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
|
onOpenChanged(true);
|
|
|
|
|
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
2025-07-29 01:12:21 +00:00
|
|
|
const newSelectedItems = [...selectedSearchScopes];
|
2025-07-26 23:16:07 +00:00
|
|
|
newSelectedItems.pop();
|
2025-07-29 01:12:21 +00:00
|
|
|
onSelectedSearchScopesChange(newSelectedItems);
|
2025-07-26 23:16:07 +00:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
const toggleItem = (item: SearchScope) => {
|
2025-07-26 23:16:07 +00:00
|
|
|
// Store current scroll position before state update
|
|
|
|
|
if (scrollContainerRef.current) {
|
|
|
|
|
scrollPosition.current = scrollContainerRef.current.scrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
const isSelected = selectedSearchScopes.some(
|
2025-07-26 23:16:07 +00:00
|
|
|
(selected) => selected.type === item.type && selected.value === item.value
|
|
|
|
|
);
|
2025-07-28 05:12:02 +00:00
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
const newSelectedItems = isSelected ?
|
|
|
|
|
selectedSearchScopes.filter(
|
2025-07-26 23:16:07 +00:00
|
|
|
(selected) => !(selected.type === item.type && selected.value === item.value)
|
2025-07-29 01:12:21 +00:00
|
|
|
) :
|
|
|
|
|
[...selectedSearchScopes, item];
|
2025-07-28 05:12:02 +00:00
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
onSelectedSearchScopesChange(newSelectedItems);
|
2025-07-26 23:16:07 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleClear = () => {
|
2025-07-29 01:12:21 +00:00
|
|
|
onSelectedSearchScopesChange([]);
|
2025-07-26 23:16:07 +00:00
|
|
|
};
|
|
|
|
|
|
2025-07-29 22:50:36 +00:00
|
|
|
const handleSelectAll = () => {
|
|
|
|
|
onSelectedSearchScopesChange(allSearchScopeItems);
|
|
|
|
|
};
|
|
|
|
|
|
2025-07-26 23:16:07 +00:00
|
|
|
const handleTogglePopover = () => {
|
|
|
|
|
onOpenChanged(!isOpen);
|
|
|
|
|
};
|
|
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
const allSearchScopeItems = React.useMemo(() => {
|
|
|
|
|
const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({
|
|
|
|
|
type: 'reposet' as const,
|
2025-07-26 23:16:07 +00:00
|
|
|
value: context.name,
|
|
|
|
|
name: context.name,
|
|
|
|
|
repoCount: context.repoNames.length
|
|
|
|
|
}));
|
2025-07-28 05:12:02 +00:00
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
const repoSearchScopeItems: RepoSearchScope[] = repos.map(repo => ({
|
2025-07-26 23:16:07 +00:00
|
|
|
type: 'repo' as const,
|
|
|
|
|
value: repo.repoName,
|
|
|
|
|
name: repo.repoDisplayName || repo.repoName.split('/').pop() || repo.repoName,
|
|
|
|
|
codeHostType: repo.codeHostType,
|
|
|
|
|
}));
|
2025-07-28 05:12:02 +00:00
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
return [...repoSetSearchScopeItems, ...repoSearchScopeItems];
|
2025-07-26 23:16:07 +00:00
|
|
|
}, [repos, searchContexts]);
|
|
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
const sortedSearchScopeItems = React.useMemo(() => {
|
|
|
|
|
return allSearchScopeItems
|
2025-07-26 23:16:07 +00:00
|
|
|
.map((item) => ({
|
|
|
|
|
item,
|
2025-07-29 01:12:21 +00:00
|
|
|
isSelected: selectedSearchScopes.some(
|
2025-07-26 23:16:07 +00:00
|
|
|
(selected) => selected.type === item.type && selected.value === item.value
|
|
|
|
|
)
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
// Selected items first
|
|
|
|
|
if (a.isSelected && !b.isSelected) return -1;
|
|
|
|
|
if (!a.isSelected && b.isSelected) return 1;
|
2025-07-29 01:12:21 +00:00
|
|
|
// Then reposets before repos
|
|
|
|
|
if (a.item.type === 'reposet' && b.item.type === 'repo') return -1;
|
|
|
|
|
if (a.item.type === 'repo' && b.item.type === 'reposet') return 1;
|
2025-07-26 23:16:07 +00:00
|
|
|
return 0;
|
|
|
|
|
})
|
2025-07-29 01:12:21 +00:00
|
|
|
}, [allSearchScopeItems, selectedSearchScopes]);
|
2025-07-26 23:16:07 +00:00
|
|
|
|
|
|
|
|
// Restore scroll position after re-render
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (scrollContainerRef.current && scrollPosition.current > 0) {
|
|
|
|
|
scrollContainerRef.current.scrollTop = scrollPosition.current;
|
|
|
|
|
}
|
2025-07-29 01:12:21 +00:00
|
|
|
}, [sortedSearchScopeItems]);
|
2025-07-26 23:16:07 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Popover
|
|
|
|
|
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">
|
2025-07-29 01:12:21 +00:00
|
|
|
<ScanSearchIcon className="h-4 w-4 text-muted-foreground mr-1" />
|
2025-07-26 23:16:07 +00:00
|
|
|
<span
|
|
|
|
|
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
|
|
|
|
|
>
|
|
|
|
|
{
|
2025-07-29 01:12:21 +00:00
|
|
|
selectedSearchScopes.length === 0 ? `Search scopes` :
|
|
|
|
|
selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
|
|
|
|
|
`${selectedSearchScopes.length} selected`
|
2025-07-26 23:16:07 +00:00
|
|
|
}
|
|
|
|
|
</span>
|
2025-07-30 05:19:36 +00:00
|
|
|
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
2025-07-26 23:16:07 +00:00
|
|
|
</div>
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="w-auto p-0"
|
|
|
|
|
align="start"
|
|
|
|
|
onEscapeKeyDown={() => onOpenChanged(false)}
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput
|
2025-07-29 01:12:21 +00:00
|
|
|
placeholder="Search scopes..."
|
2025-07-26 23:16:07 +00:00
|
|
|
onKeyDown={handleInputKeyDown}
|
2025-07-29 22:50:36 +00:00
|
|
|
onValueChange={(value) => setHasSearchInput(!!value)}
|
2025-07-26 23:16:07 +00:00
|
|
|
/>
|
|
|
|
|
<CommandList ref={scrollContainerRef}>
|
|
|
|
|
<CommandEmpty>No results found.</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
2025-07-29 22:50:36 +00:00
|
|
|
{!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"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-xs">Select all</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-07-29 01:12:21 +00:00
|
|
|
{sortedSearchScopeItems.map(({ item, isSelected }) => {
|
2025-07-26 23:16:07 +00:00
|
|
|
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">
|
2025-07-29 01:12:21 +00:00
|
|
|
<SearchScopeIcon searchScope={item} />
|
2025-07-26 23:16:07 +00:00
|
|
|
<div className="flex flex-col flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{item.name}
|
|
|
|
|
</span>
|
2025-07-29 01:12:21 +00:00
|
|
|
{item.type === 'reposet' && (
|
2025-07-28 05:12:02 +00:00
|
|
|
<Badge
|
|
|
|
|
variant="default"
|
2025-07-26 23:16:07 +00:00
|
|
|
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>
|
2025-07-29 01:12:21 +00:00
|
|
|
{selectedSearchScopes.length > 0 && (
|
2025-07-26 23:16:07 +00:00
|
|
|
<>
|
|
|
|
|
<CommandSeparator />
|
|
|
|
|
<CommandItem
|
|
|
|
|
onSelect={handleClear}
|
|
|
|
|
className="flex-1 justify-center cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
Clear
|
|
|
|
|
</CommandItem>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2025-07-29 01:12:21 +00:00
|
|
|
SearchScopeSelector.displayName = "SearchScopeSelector";
|