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:
@@ -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é)",
|
||||
|
||||
Reference in New Issue
Block a user