diff --git a/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx b/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx index eedd0f05..75987cab 100644 --- a/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx +++ b/apps/frontend/src/renderer/components/terminal/WorktreeSelector.tsx @@ -1,14 +1,12 @@ -import { useState, useEffect } from 'react'; -import { FolderGit, Plus, ChevronDown, Loader2, Trash2, ListTodo, GitFork } from 'lucide-react'; +import { useId, useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { FolderGit, Plus, ChevronDown, Loader2, Trash2, ListTodo, GitFork, Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TerminalWorktreeConfig, WorktreeListItem, OtherWorktreeInfo } from '../../../shared/types'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '../ui/dropdown-menu'; + Popover, + PopoverContent, + PopoverTrigger, +} from '../ui/popover'; import { AlertDialog, AlertDialogAction, @@ -19,9 +17,15 @@ import { AlertDialogHeader, AlertDialogTitle, } from '../ui/alert-dialog'; +import { ScrollArea } from '../ui/scroll-area'; import { cn } from '../../lib/utils'; import { useProjectStore } from '../../stores/project-store'; +type NavigableItem = + | { type: 'terminal'; data: TerminalWorktreeConfig } + | { type: 'task'; data: WorktreeListItem } + | { type: 'other'; data: OtherWorktreeInfo }; + interface WorktreeSelectorProps { terminalId: string; projectPath: string; @@ -33,6 +37,45 @@ interface WorktreeSelectorProps { onSelectWorktree: (config: TerminalWorktreeConfig) => void; } +function getItemName(item: NavigableItem): string { + switch (item.type) { + case 'terminal': + return item.data.name; + case 'task': + return item.data.specName; + case 'other': + return item.data.displayName; + } +} + +function getItemBranch(item: NavigableItem): string { + switch (item.type) { + case 'terminal': + return item.data.branchName ?? ''; + case 'task': + return item.data.branch ?? ''; + case 'other': + return item.data.branch ?? ''; + } +} + +const ITEM_ICONS = { + terminal: , + task: , + other: , +}; + +function getItemKey(item: NavigableItem): string { + switch (item.type) { + case 'terminal': + return `terminal-${item.data.name}`; + case 'task': + return `task-${item.data.specName}`; + case 'other': + return `other-${item.data.path}`; + } +} + export function WorktreeSelector({ terminalId, projectPath, @@ -41,6 +84,7 @@ export function WorktreeSelector({ onSelectWorktree, }: WorktreeSelectorProps) { const { t } = useTranslation(['terminal', 'common']); + const listboxId = useId(); const [worktrees, setWorktrees] = useState([]); const [taskWorktrees, setTaskWorktrees] = useState([]); const [otherWorktrees, setOtherWorktrees] = useState([]); @@ -48,6 +92,12 @@ export function WorktreeSelector({ const [isOpen, setIsOpen] = useState(false); const [deleteWorktree, setDeleteWorktree] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const searchInputRef = useRef(null); + const itemRefs = useRef>(new Map()); + + const getOptionId = (index: number) => `${listboxId}-option-${index}`; // Get project ID from projectPath for task worktrees API const project = useProjectStore((state) => @@ -68,7 +118,6 @@ export function WorktreeSelector({ // Process terminal worktrees if (terminalResult.success && terminalResult.data) { - // Filter out the current worktree from the list using path for consistency const available = currentWorktree ? terminalResult.data.filter((wt) => wt.worktreePath !== currentWorktree.worktreePath) : terminalResult.data; @@ -77,19 +126,16 @@ export function WorktreeSelector({ // Process task worktrees if (taskResult?.success && taskResult.data?.worktrees) { - // Filter out current worktree if it matches a task worktree const availableTaskWorktrees = currentWorktree ? taskResult.data.worktrees.filter((wt) => wt.path !== currentWorktree.worktreePath) : taskResult.data.worktrees; setTaskWorktrees(availableTaskWorktrees); } else { - // Clear task worktrees when project is null or fetch failed setTaskWorktrees([]); } // Process other worktrees if (otherResult?.success && otherResult.data) { - // Filter out current worktree if it matches const availableOtherWorktrees = currentWorktree ? otherResult.data.filter((wt) => wt.path !== currentWorktree.worktreePath) : otherResult.data; @@ -105,39 +151,158 @@ export function WorktreeSelector({ }; // Convert task worktree to terminal worktree config for selection - const selectTaskWorktree = (taskWt: WorktreeListItem) => { + const selectTaskWorktree = useCallback((taskWt: WorktreeListItem) => { const config: TerminalWorktreeConfig = { name: taskWt.specName, worktreePath: taskWt.path, branchName: taskWt.branch, baseBranch: taskWt.baseBranch, hasGitBranch: true, - // Note: This represents when the worktree was attached to this terminal, not when it was originally created createdAt: new Date().toISOString(), terminalId, }; onSelectWorktree(config); - }; + }, [terminalId, onSelectWorktree]); // Convert other worktree to terminal worktree config for selection - const selectOtherWorktree = (otherWt: OtherWorktreeInfo) => { + const selectOtherWorktree = useCallback((otherWt: OtherWorktreeInfo) => { const config: TerminalWorktreeConfig = { name: otherWt.displayName, worktreePath: otherWt.path, branchName: otherWt.branch ?? '', - baseBranch: '', // Unknown for external worktrees + baseBranch: '', hasGitBranch: otherWt.branch !== null, createdAt: new Date().toISOString(), terminalId, }; onSelectWorktree(config); + }, [terminalId, onSelectWorktree]); + + // Filter items based on search query + const filteredItems = useMemo(() => { + const query = searchQuery.toLowerCase().trim(); + + const matchesQuery = (item: NavigableItem) => { + if (!query) return true; + const name = getItemName(item).toLowerCase(); + const branch = getItemBranch(item).toLowerCase(); + return name.includes(query) || branch.includes(query); + }; + + const terminalItems: NavigableItem[] = worktrees + .map((wt) => ({ type: 'terminal' as const, data: wt })) + .filter(matchesQuery); + const taskItems: NavigableItem[] = taskWorktrees + .map((wt) => ({ type: 'task' as const, data: wt })) + .filter(matchesQuery); + const otherItems: NavigableItem[] = otherWorktrees + .map((wt) => ({ type: 'other' as const, data: wt })) + .filter(matchesQuery); + + return { terminalItems, taskItems, otherItems }; + }, [searchQuery, worktrees, taskWorktrees, otherWorktrees]); + + // Flatten all filtered items into a single navigable list + const allItems = useMemo(() => { + return [ + ...filteredItems.terminalItems, + ...filteredItems.taskItems, + ...filteredItems.otherItems, + ]; + }, [filteredItems]); + + // Compute active descendant for aria + const activeDescendant = allItems.length > 0 && focusedIndex < allItems.length + ? getOptionId(focusedIndex) + : undefined; + + // Select the focused item + const selectItem = useCallback((item: NavigableItem) => { + setIsOpen(false); + switch (item.type) { + case 'terminal': + onSelectWorktree(item.data); + break; + case 'task': + selectTaskWorktree(item.data); + break; + case 'other': + selectOtherWorktree(item.data); + break; + } + }, [onSelectWorktree, selectTaskWorktree, selectOtherWorktree]); + + // Keyboard handler for search input + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + setFocusedIndex((prev) => (prev + 1) % Math.max(allItems.length, 1)); + break; + } + case 'ArrowUp': { + e.preventDefault(); + setFocusedIndex((prev) => + prev <= 0 ? Math.max(allItems.length - 1, 0) : prev - 1 + ); + break; + } + case 'Home': { + e.preventDefault(); + setFocusedIndex(0); + break; + } + case 'End': { + e.preventDefault(); + setFocusedIndex(Math.max(allItems.length - 1, 0)); + break; + } + case 'Enter': { + e.preventDefault(); + if (allItems.length > 0 && focusedIndex < allItems.length) { + selectItem(allItems[focusedIndex]); + } + break; + } + case 'Escape': { + e.preventDefault(); + setIsOpen(false); + break; + } + } + }, + [allItems, focusedIndex, selectItem] + ); + + // Reset focused index when search query changes + // biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally reset focus when searchQuery changes + useEffect(() => { + setFocusedIndex(0); + }, [searchQuery]); + + // Scroll focused item into view + useEffect(() => { + const el = itemRefs.current.get(focusedIndex); + if (el) { + el.scrollIntoView({ block: 'nearest' }); + } + }, [focusedIndex]); + + // Handle open/close state changes + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (!open) { + setSearchQuery(''); + setFocusedIndex(0); + } }; + // biome-ignore lint/correctness/useExhaustiveDependencies: fetchWorktrees is intentionally excluded to prevent infinite loop useEffect(() => { if (isOpen && projectPath) { fetchWorktrees(); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- fetchWorktrees is intentionally excluded to prevent infinite loop }, [isOpen, projectPath]); // Handle delete worktree @@ -148,10 +313,9 @@ export function WorktreeSelector({ const result = await window.electronAPI.removeTerminalWorktree( projectPath, deleteWorktree.name, - deleteWorktree.hasGitBranch // Delete the branch too if it was created + deleteWorktree.hasGitBranch ); if (result.success) { - // Refresh the list await fetchWorktrees(); } else { console.error('Failed to delete worktree:', result.error); @@ -164,14 +328,80 @@ export function WorktreeSelector({ } }; - // If terminal already has a worktree, show worktree badge (handled in TerminalHeader) - // This component only shows when there's no worktree attached + const renderWorktreeItem = (item: NavigableItem, index: number) => { + const isFocused = index === focusedIndex; + const key = getItemKey(item); + const name = getItemName(item); + const branch = getItemBranch(item); + + const branchLabel = + item.type === 'other' && item.data.branch === null + ? `${item.data.commitSha} ${t('terminal:worktree.detached')}` + : branch; + + return ( +
{ + if (el) itemRefs.current.set(index, el); + else itemRefs.current.delete(index); + }} + role="option" + tabIndex={-1} + aria-selected={isFocused} + className={cn( + 'flex items-center text-xs px-2 py-1.5 rounded-sm cursor-pointer group', + isFocused + ? 'bg-accent text-accent-foreground' + : 'hover:bg-accent/50' + )} + onClick={(e) => { + e.stopPropagation(); + selectItem(item); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') selectItem(item); + }} + onMouseEnter={() => setFocusedIndex(index)} + > + {ITEM_ICONS[item.type]} +
+ {name} + {branchLabel && ( + + {branchLabel} + + )} +
+ {item.type === 'terminal' && ( + + )} +
+ ); + }; + + const { terminalItems, taskItems, otherItems } = filteredItems; + const hasResults = allItems.length > 0; return ( <> - - + + - - - {/* New Worktree - always at top */} - + { + e.preventDefault(); + searchInputRef.current?.focus(); + }} + > + {/* Pinned: Create new worktree */} + - {/* Fixed separator between "Create New" and scrollable content */} - +
- {/* Scrollable content with native browser scrolling */} -
- {isLoading ? ( -
- -
- ) : ( - <> - {/* Terminal Worktrees Section */} - {worktrees.length > 0 && ( - <> -
- {t('terminal:worktree.existing')} -
- {worktrees.map((wt) => ( - { - e.stopPropagation(); - setIsOpen(false); - onSelectWorktree(wt); - }} - className="text-xs group" - > - -
- {wt.name} - {wt.branchName && ( - - {wt.branchName} - - )} -
- -
- ))} - - )} - - {/* Task Worktrees Section */} - {taskWorktrees.length > 0 && ( - <> - -
- {t('terminal:worktree.taskWorktrees')} -
- {taskWorktrees.map((wt) => ( - { - e.stopPropagation(); - setIsOpen(false); - selectTaskWorktree(wt); - }} - className="text-xs group" - > - -
- {wt.specName} - {wt.branch && ( - - {wt.branch} - - )} -
-
- ))} - - )} - - {/* Other Worktrees Section */} - {otherWorktrees.length > 0 && ( - <> - -
- {t('terminal:worktree.otherWorktrees')} -
- {otherWorktrees.map((wt) => ( - { - e.stopPropagation(); - setIsOpen(false); - selectOtherWorktree(wt); - }} - className="text-xs group" - > - -
- {wt.displayName} - - {wt.branch !== null ? wt.branch : `${wt.commitSha} ${t('terminal:worktree.detached')}`} - -
-
- ))} - - )} - - )} + {/* Search input */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('terminal:worktree.searchPlaceholder')} + className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" + />
- - + +
+ + {/* Scrollable results */} + +
+ {isLoading ? ( +
+ +
+ ) : !hasResults ? ( +
+ {t('terminal:worktree.noResults')} +
+ ) : ( + <> + {/* Terminal Worktrees */} + {terminalItems.length > 0 && ( + <> +
+ {t('terminal:worktree.existing')} +
+ {terminalItems.map((item, i) => renderWorktreeItem(item, i))} + + )} + + {/* Task Worktrees */} + {taskItems.length > 0 && ( + <> + {terminalItems.length > 0 &&
} +
+ {t('terminal:worktree.taskWorktrees')} +
+ {taskItems.map((item, i) => renderWorktreeItem(item, terminalItems.length + i))} + + )} + + {/* Other Worktrees */} + {otherItems.length > 0 && ( + <> + {(terminalItems.length > 0 || taskItems.length > 0) && ( +
+ )} +
+ {t('terminal:worktree.otherWorktrees')} +
+ {otherItems.map((item, i) => renderWorktreeItem(item, terminalItems.length + taskItems.length + i))} + + )} + + )} +
+ + + {/* Delete Confirmation Dialog */} !open && setDeleteWorktree(null)}> diff --git a/apps/frontend/src/shared/i18n/locales/en/terminal.json b/apps/frontend/src/shared/i18n/locales/en/terminal.json index 93c9d86b..7d87ea7d 100644 --- a/apps/frontend/src/shared/i18n/locales/en/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/en/terminal.json @@ -39,6 +39,8 @@ "openInIDE": "Open in IDE", "maxReached": "Maximum of 12 terminal worktrees reached", "alreadyExists": "A worktree with this name already exists", + "searchPlaceholder": "Search worktrees...", + "noResults": "No worktrees found", "deleteTitle": "Delete Worktree?", "deleteDescription": "This will permanently delete the worktree and its branch. Any uncommitted changes will be lost.", "detached": "(detached)", diff --git a/apps/frontend/src/shared/i18n/locales/fr/terminal.json b/apps/frontend/src/shared/i18n/locales/fr/terminal.json index 742bdb6e..a13c0ab0 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/fr/terminal.json @@ -39,6 +39,8 @@ "openInIDE": "Ouvrir dans IDE", "maxReached": "Maximum de 12 worktrees terminal atteint", "alreadyExists": "Un worktree avec ce nom existe deja", + "searchPlaceholder": "Rechercher des worktrees...", + "noResults": "Aucun worktree trouvé", "deleteTitle": "Supprimer le Worktree?", "deleteDescription": "Ceci supprimera definitivement le worktree et sa branche. Les modifications non committées seront perdues.", "detached": "(détaché)",