feat: add search/filter to WorktreeSelector dropdown (#1754)

* feat: add search/filter to WorktreeSelector dropdown

Replace DropdownMenu with Popover and add inline search input for
quickly finding worktrees. Supports keyboard navigation (Arrow keys,
Enter, Escape, Home/End), case-insensitive filtering by name and
branch, and preserves all existing functionality (create, select, delete).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings for WorktreeSelector search

- Add ARIA attributes (aria-activedescendant, aria-controls, role IDs)
  matching the existing Combobox accessibility pattern
- Replace native overflow-y-auto with ScrollArea component for
  consistent styled scrollbars
- Replace mutable runningIndex with declarative index offsets
- Add aria-label to listbox element
- Use type="search" instead of role="searchbox"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add ARIA combobox role and remove redundant focus call

- Add role="combobox", aria-expanded, aria-haspopup="listbox" to search
  input for WAI-ARIA combobox pattern compliance
- Remove redundant requestAnimationFrame focus since onOpenAutoFocus
  already handles it

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move icon map to module scope, add aria-label to delete button

- Move ITEM_ICONS to module scope to avoid recreation per render
- Add aria-label to delete button for screen reader support
- Keep onKeyDown on options minimal (Enter only) for a11y compliance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-06 22:01:50 +01:00
committed by GitHub
parent b5de0d9ffa
commit 09f059ca3b
3 changed files with 345 additions and 144 deletions
@@ -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: <FolderGit className="h-3 w-3 mr-2 text-amber-500/70 shrink-0" />,
task: <ListTodo className="h-3 w-3 mr-2 text-cyan-500/70 shrink-0" />,
other: <GitFork className="h-3 w-3 mr-2 text-purple-500/70 shrink-0" />,
};
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<TerminalWorktreeConfig[]>([]);
const [taskWorktrees, setTaskWorktrees] = useState<WorktreeListItem[]>([]);
const [otherWorktrees, setOtherWorktrees] = useState<OtherWorktreeInfo[]>([]);
@@ -48,6 +92,12 @@ export function WorktreeSelector({
const [isOpen, setIsOpen] = useState(false);
const [deleteWorktree, setDeleteWorktree] = useState<TerminalWorktreeConfig | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [focusedIndex, setFocusedIndex] = useState(0);
const searchInputRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Map<number, HTMLDivElement>>(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 (
<div
key={key}
id={getOptionId(index)}
ref={(el) => {
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]}
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{name}</span>
{branchLabel && (
<span className="text-[10px] text-muted-foreground truncate">
{branchLabel}
</span>
)}
</div>
{item.type === 'terminal' && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDeleteWorktree(item.data);
}}
className="ml-2 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
aria-label={t('common:delete')}
title={t('common:delete')}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
);
};
const { terminalItems, taskItems, otherItems } = filteredItems;
const hasResults = allItems.length > 0;
return (
<>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'flex items-center gap-1 h-6 px-2 rounded text-xs font-medium transition-colors',
'hover:bg-amber-500/10 hover:text-amber-500 text-muted-foreground'
@@ -182,137 +412,104 @@ export function WorktreeSelector({
<span>{t('terminal:worktree.create')}</span>
<ChevronDown className="h-2.5 w-2.5 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* New Worktree - always at top */}
<DropdownMenuItem
</PopoverTrigger>
<PopoverContent
align="start"
className="w-56 p-0"
onOpenAutoFocus={(e) => {
e.preventDefault();
searchInputRef.current?.focus();
}}
>
{/* Pinned: Create new worktree */}
<button
type="button"
className="flex items-center text-xs px-2 py-1.5 m-1 rounded-sm cursor-pointer text-amber-500 hover:bg-accent/50 w-[calc(100%-0.5rem)] text-left"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
onCreateWorktree();
}}
className="text-xs text-amber-500"
>
<Plus className="h-3 w-3 mr-2" />
{t('terminal:worktree.createNew')}
</DropdownMenuItem>
</button>
{/* Fixed separator between "Create New" and scrollable content */}
<DropdownMenuSeparator />
<div className="border-t border-border" />
{/* Scrollable content with native browser scrolling */}
<div className="max-h-[300px] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Terminal Worktrees Section */}
{worktrees.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('terminal:worktree.existing')}
</div>
{worktrees.map((wt) => (
<DropdownMenuItem
key={wt.name}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
onSelectWorktree(wt);
}}
className="text-xs group"
>
<FolderGit className="h-3 w-3 mr-2 text-amber-500/70 shrink-0" />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{wt.name}</span>
{wt.branchName && (
<span className="text-[10px] text-muted-foreground truncate">
{wt.branchName}
</span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDeleteWorktree(wt);
}}
className="ml-2 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
title={t('common:delete')}
>
<Trash2 className="h-3 w-3" />
</button>
</DropdownMenuItem>
))}
</>
)}
{/* Task Worktrees Section */}
{taskWorktrees.length > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('terminal:worktree.taskWorktrees')}
</div>
{taskWorktrees.map((wt) => (
<DropdownMenuItem
key={wt.specName}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
selectTaskWorktree(wt);
}}
className="text-xs group"
>
<ListTodo className="h-3 w-3 mr-2 text-cyan-500/70 shrink-0" />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{wt.specName}</span>
{wt.branch && (
<span className="text-[10px] text-muted-foreground truncate">
{wt.branch}
</span>
)}
</div>
</DropdownMenuItem>
))}
</>
)}
{/* Other Worktrees Section */}
{otherWorktrees.length > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('terminal:worktree.otherWorktrees')}
</div>
{otherWorktrees.map((wt) => (
<DropdownMenuItem
key={wt.path}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
selectOtherWorktree(wt);
}}
className="text-xs group"
>
<GitFork className="h-3 w-3 mr-2 text-purple-500/70 shrink-0" />
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">{wt.displayName}</span>
<span className="text-[10px] text-muted-foreground truncate">
{wt.branch !== null ? wt.branch : `${wt.commitSha} ${t('terminal:worktree.detached')}`}
</span>
</div>
</DropdownMenuItem>
))}
</>
)}
</>
)}
{/* Search input */}
<div className="flex items-center px-2 py-1.5">
<Search className="h-3 w-3 mr-2 text-muted-foreground shrink-0" />
<input
ref={searchInputRef}
type="search"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={activeDescendant}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('terminal:worktree.searchPlaceholder')}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
<div className="border-t border-border" />
{/* Scrollable results */}
<ScrollArea className="max-h-[300px]">
<div id={listboxId} role="listbox" aria-label={t('terminal:worktree.searchPlaceholder')} className="p-1">
{isLoading ? (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : !hasResults ? (
<div className="py-2 text-center text-xs text-muted-foreground">
{t('terminal:worktree.noResults')}
</div>
) : (
<>
{/* Terminal Worktrees */}
{terminalItems.length > 0 && (
<>
<div className="px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
{t('terminal:worktree.existing')}
</div>
{terminalItems.map((item, i) => renderWorktreeItem(item, i))}
</>
)}
{/* Task Worktrees */}
{taskItems.length > 0 && (
<>
{terminalItems.length > 0 && <div className="border-t border-border my-1" />}
<div className="px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
{t('terminal:worktree.taskWorktrees')}
</div>
{taskItems.map((item, i) => renderWorktreeItem(item, terminalItems.length + i))}
</>
)}
{/* Other Worktrees */}
{otherItems.length > 0 && (
<>
{(terminalItems.length > 0 || taskItems.length > 0) && (
<div className="border-t border-border my-1" />
)}
<div className="px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
{t('terminal:worktree.otherWorktrees')}
</div>
{otherItems.map((item, i) => renderWorktreeItem(item, terminalItems.length + taskItems.length + i))}
</>
)}
</>
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteWorktree} onOpenChange={(open) => !open && setDeleteWorktree(null)}>
@@ -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)",
@@ -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é)",