230 lines
7.4 KiB
TypeScript
230 lines
7.4 KiB
TypeScript
/**
|
|
* RepositoryDropdown - Dropdown for selecting repository groups.
|
|
*
|
|
* Features:
|
|
* - Shows repository groups (not individual worktrees)
|
|
* - Displays worktree count and total sessions
|
|
* - Click outside to close
|
|
* - Keyboard navigation (Escape to close)
|
|
* - Filter out already selected items
|
|
*/
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { useStore } from '@renderer/store';
|
|
import { ChevronDown, FolderOpen, GitBranch } from 'lucide-react';
|
|
|
|
import type { RepositoryDropdownItem } from '@renderer/components/settings/hooks/useSettingsConfig';
|
|
|
|
interface RepositoryDropdownProps {
|
|
/** Callback when a repository is selected */
|
|
onSelect: (item: RepositoryDropdownItem) => void;
|
|
/** IDs of items to exclude from the list */
|
|
excludeIds?: string[];
|
|
/** Placeholder text */
|
|
placeholder?: string;
|
|
/** Whether the dropdown is disabled */
|
|
disabled?: boolean;
|
|
/** Whether to drop up instead of down */
|
|
dropUp?: boolean;
|
|
/** Custom class for the container */
|
|
className?: string;
|
|
}
|
|
|
|
export const RepositoryDropdown = ({
|
|
onSelect,
|
|
excludeIds = [],
|
|
placeholder = 'Select repository...',
|
|
disabled = false,
|
|
dropUp = false,
|
|
className = '',
|
|
}: Readonly<RepositoryDropdownProps>): React.JSX.Element => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Get repository groups from store
|
|
const repositoryGroups = useStore((state) => state.repositoryGroups);
|
|
const fetchRepositoryGroups = useStore((state) => state.fetchRepositoryGroups);
|
|
|
|
// Fetch data if not loaded
|
|
useEffect(() => {
|
|
if (repositoryGroups.length === 0) {
|
|
void fetchRepositoryGroups();
|
|
}
|
|
}, [repositoryGroups.length, fetchRepositoryGroups]);
|
|
|
|
// Convert repository groups to dropdown items
|
|
const allItems = useMemo((): RepositoryDropdownItem[] => {
|
|
return repositoryGroups.map((group) => ({
|
|
id: group.id,
|
|
name: group.name,
|
|
path: group.worktrees[0]?.path ?? '',
|
|
worktreeCount: group.worktrees.length,
|
|
totalSessions: group.totalSessions,
|
|
}));
|
|
}, [repositoryGroups]);
|
|
|
|
// Filter out excluded items
|
|
const availableItems = useMemo(() => {
|
|
return allItems.filter((item) => !excludeIds.includes(item.id));
|
|
}, [allItems, excludeIds]);
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent): void => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Close on escape
|
|
useEffect(() => {
|
|
const handleEscape = (event: KeyboardEvent): void => {
|
|
if (event.key === 'Escape') {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('keydown', handleEscape);
|
|
return () => document.removeEventListener('keydown', handleEscape);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleSelect = (item: RepositoryDropdownItem): void => {
|
|
onSelect(item);
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const isEmpty = availableItems.length === 0;
|
|
|
|
return (
|
|
<div ref={containerRef} className={`relative ${className}`}>
|
|
{/* Trigger Button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => !disabled && !isEmpty && setIsOpen(!isOpen)}
|
|
disabled={disabled || isEmpty}
|
|
className={`flex w-full items-center justify-between gap-2 rounded border border-border bg-transparent px-2 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised ${disabled || isEmpty ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} `}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<FolderOpen className="size-3" />
|
|
{isEmpty ? 'No repositories available' : placeholder}
|
|
</span>
|
|
<ChevronDown
|
|
className={`size-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Dropdown Menu */}
|
|
{isOpen && !isEmpty && (
|
|
<div
|
|
className={`absolute inset-x-0 z-50 max-h-64 overflow-y-auto rounded border border-border bg-surface-overlay py-1 shadow-lg ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} `}
|
|
>
|
|
{availableItems.map((item) => (
|
|
<RepositoryDropdownItemComponent
|
|
key={item.id}
|
|
item={item}
|
|
onSelect={() => handleSelect(item)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Individual item in the dropdown.
|
|
*/
|
|
const RepositoryDropdownItemComponentInner = ({
|
|
item,
|
|
onSelect,
|
|
}: Readonly<{
|
|
item: RepositoryDropdownItem;
|
|
onSelect: () => void;
|
|
}>): React.JSX.Element => {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onSelect}
|
|
className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-surface-raised"
|
|
>
|
|
<FolderOpen className="size-3 shrink-0 text-indigo-400" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate text-xs text-text">{item.name}</span>
|
|
{item.worktreeCount > 1 && (
|
|
<span className="flex shrink-0 items-center gap-0.5 rounded bg-surface-raised px-1 py-0.5 text-[10px] text-text-muted">
|
|
<GitBranch className="size-2.5" />
|
|
{item.worktreeCount}
|
|
</span>
|
|
)}
|
|
<span className="shrink-0 text-[10px] text-text-muted">
|
|
{item.totalSessions} session{item.totalSessions !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
<span className="block truncate text-[10px] text-text-muted">{item.path}</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
const RepositoryDropdownItemComponent = React.memo(RepositoryDropdownItemComponentInner);
|
|
|
|
/**
|
|
* Selected repository item with remove button.
|
|
*/
|
|
const SelectedRepositoryItemInner = ({
|
|
item,
|
|
onRemove,
|
|
disabled = false,
|
|
}: Readonly<{
|
|
item: RepositoryDropdownItem;
|
|
onRemove: () => void;
|
|
disabled?: boolean;
|
|
}>): React.JSX.Element => {
|
|
return (
|
|
<div className="flex items-center gap-2 border-b border-border-subtle py-1.5">
|
|
<FolderOpen className="size-3 shrink-0 text-indigo-400" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate text-xs text-text">{item.name}</span>
|
|
{item.worktreeCount > 1 && (
|
|
<span className="flex shrink-0 items-center gap-0.5 rounded bg-surface-raised px-1 py-0.5 text-[10px] text-text-muted">
|
|
<GitBranch className="size-2.5" />
|
|
{item.worktreeCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="truncate text-[10px] text-text-muted" title={item.path}>
|
|
{item.path}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
disabled={disabled}
|
|
className={`shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}
|
|
aria-label="Remove repository"
|
|
>
|
|
<svg className="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const SelectedRepositoryItem = React.memo(SelectedRepositoryItemInner);
|