feat(frontend): add searchable branch combobox to worktree creation dialog (#979)

* feat(frontend): add searchable branch combobox to worktree creation dialog

- Replace limited Select dropdown with searchable Combobox for branch selection
- Add new Combobox UI component with search filtering and scroll support
- Remove 15-branch limit - now shows all branches with search
- Improve worktree name validation to allow dots and underscores
- Better sanitization: spaces become hyphens, preserve valid characters
- Add i18n keys for branch search UI in English and French

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

* fix(frontend): address PR review feedback for worktree dialog

- Extract sanitizeWorktreeName utility function to avoid duplication
- Replace invalid chars with hyphens instead of removing them (feat/new → feat-new)
- Trim trailing hyphens and dots from sanitized names
- Add validation to forbid '..' in names (invalid for Git branch names)
- Refactor branchOptions to use map/spread instead of forEach/push
- Add ARIA accessibility: listboxId, aria-controls, role="listbox"

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

* fix(frontend): align worktree name validation with backend regex

- Fix frontend validation to match backend WORKTREE_NAME_REGEX (no dots,
  must end with alphanumeric)
- Update sanitizeWorktreeName to exclude dots from allowed characters
- Update i18n messages (en/fr) to remove mention of dots
- Add displayName to Combobox component for React DevTools
- Export Combobox from UI component index.ts
- Add aria-label to Combobox listbox for accessibility

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

* fix(frontend): address PR review accessibility and cleanup issues

- Add forwardRef pattern to Combobox for consistency with other UI components
- Add keyboard navigation (ArrowUp/Down, Enter, Escape, Home, End)
- Add aria-activedescendant for screen reader focus tracking
- Add unique option IDs for ARIA compliance
- Add cleanup for async branch fetching to prevent state updates on unmounted component

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-13 10:12:08 +01:00
committed by AndyMik90
parent 750ea8d188
commit 2a2dc3b8c7
5 changed files with 366 additions and 65 deletions
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GitBranch, Loader2, FolderGit, ListTodo } from 'lucide-react';
import {
@@ -20,12 +20,38 @@ import {
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import type { Task, TerminalWorktreeConfig } from '../../../shared/types';
import { useProjectStore } from '../../stores/project-store';
// Special value to represent "use project default" since Radix UI Select doesn't allow empty string values
const PROJECT_DEFAULT_BRANCH = '__project_default__';
/**
* Sanitizes a string into a valid worktree/branch name.
* - Converts to lowercase
* - Replaces spaces and invalid characters with hyphens
* - Collapses consecutive hyphens
* - Trims leading/trailing hyphens
* - Ensures name ends with alphanumeric (matching backend WORKTREE_NAME_REGEX)
*/
function sanitizeWorktreeName(value: string, maxLength?: number): string {
let sanitized = value
.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/[^a-z0-9_-]/g, '-') // Replace invalid chars (including dots) with hyphens
.replace(/-{2,}/g, '-') // Collapse consecutive hyphens
.replace(/^[-_]+|[-_]+$/g, ''); // Trim leading and trailing hyphens/underscores
if (maxLength) {
sanitized = sanitized.slice(0, maxLength);
// Trim trailing hyphens/underscores again after slicing
sanitized = sanitized.replace(/[-_]+$/, '');
}
return sanitized;
}
interface CreateWorktreeDialogProps {
/** Whether the dialog is open */
open: boolean;
@@ -69,42 +95,50 @@ export function CreateWorktreeDialog({
// Fetch branches when dialog opens
useEffect(() => {
if (open && projectPath) {
const fetchBranches = async () => {
setIsLoadingBranches(true);
try {
const result = await window.electronAPI.getGitBranches(projectPath);
if (result.success && result.data) {
setBranches(result.data);
}
if (!open || !projectPath) return;
// Use project settings mainBranch if available, otherwise auto-detect
if (project?.settings?.mainBranch) {
setProjectDefaultBranch(project.settings.mainBranch);
} else {
// Fallback to auto-detect if no project setting
const defaultResult = await window.electronAPI.detectMainBranch(projectPath);
if (defaultResult.success && defaultResult.data) {
setProjectDefaultBranch(defaultResult.data);
}
let isMounted = true;
const fetchBranches = async () => {
setIsLoadingBranches(true);
try {
const result = await window.electronAPI.getGitBranches(projectPath);
if (!isMounted) return;
if (result.success && result.data) {
setBranches(result.data);
}
// Use project settings mainBranch if available, otherwise auto-detect
if (project?.settings?.mainBranch) {
setProjectDefaultBranch(project.settings.mainBranch);
} else {
// Fallback to auto-detect if no project setting
const defaultResult = await window.electronAPI.detectMainBranch(projectPath);
if (!isMounted) return;
if (defaultResult.success && defaultResult.data) {
setProjectDefaultBranch(defaultResult.data);
}
} catch (err) {
console.error('Failed to fetch branches:', err);
} finally {
}
} catch (err) {
console.error('Failed to fetch branches:', err);
} finally {
if (isMounted) {
setIsLoadingBranches(false);
}
};
fetchBranches();
}
}
};
fetchBranches();
return () => {
isMounted = false;
};
}, [open, projectPath, project?.settings?.mainBranch]);
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
// Auto-sanitize: lowercase, replace spaces and invalid chars
const sanitized = e.target.value
.toLowerCase()
.replace(/[^a-z0-9-_]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const sanitized = sanitizeWorktreeName(e.target.value);
setName(sanitized);
setError(null);
}, []);
@@ -119,12 +153,7 @@ export function CreateWorktreeDialog({
if (!name) {
const task = backlogTasks.find(t => t.id === taskId);
if (task) {
// Convert task title to valid name
const autoName = task.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 30);
const autoName = sanitizeWorktreeName(task.title, 40);
setName(autoName);
}
}
@@ -136,8 +165,9 @@ export function CreateWorktreeDialog({
return;
}
// Validate name format
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
// Validate name format - allow letters, numbers, dashes, and underscores
// Must start and end with letter or number (matching backend WORKTREE_NAME_REGEX)
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
setError(t('terminal:worktree.nameInvalid'));
return;
}
@@ -185,6 +215,28 @@ export function CreateWorktreeDialog({
onOpenChange(newOpen);
};
// Memoized branch options for the Combobox
const branchOptions: ComboboxOption[] = useMemo(() => {
const regularBranchOptions = branches
.filter((b) => b !== projectDefaultBranch)
.map((branch) => ({ value: branch, label: branch }));
const options: ComboboxOption[] = [
{
value: PROJECT_DEFAULT_BRANCH,
label: t('terminal:worktree.useProjectDefault', { branch: projectDefaultBranch || 'main' }),
},
...regularBranchOptions,
];
// If the project default branch is not in the list of existing branches, add it as a selectable option
if (projectDefaultBranch && !branches.includes(projectDefaultBranch)) {
options.push({ value: projectDefaultBranch, label: projectDefaultBranch });
}
return options;
}, [branches, projectDefaultBranch, t]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
@@ -256,39 +308,22 @@ export function CreateWorktreeDialog({
/>
</div>
{/* Base Branch Selection */}
{/* Base Branch Selection - Searchable */}
<div className="space-y-2">
<Label htmlFor="base-branch" className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
{t('terminal:worktree.baseBranch')}
</Label>
<Select
<Combobox
id="base-branch"
value={baseBranch}
onValueChange={setBaseBranch}
options={branchOptions}
placeholder={t('terminal:worktree.selectBaseBranch')}
searchPlaceholder={t('terminal:worktree.searchBranch')}
emptyMessage={t('terminal:worktree.noBranchFound')}
disabled={isCreating || isLoadingBranches}
>
<SelectTrigger id="base-branch">
<SelectValue placeholder={t('terminal:worktree.selectBaseBranch')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={PROJECT_DEFAULT_BRANCH}>
{t('terminal:worktree.useProjectDefault', { branch: projectDefaultBranch || 'main' })}
</SelectItem>
{branches
.filter(b => b !== projectDefaultBranch)
.slice(0, 15)
.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
{projectDefaultBranch && !branches.includes(projectDefaultBranch) && (
<SelectItem value={projectDefaultBranch}>
{projectDefaultBranch}
</SelectItem>
)}
</SelectContent>
</Select>
/>
<p className="text-xs text-muted-foreground">
{t('terminal:worktree.baseBranchHelp')}
</p>
@@ -0,0 +1,261 @@
import * as React from 'react';
import { Check, ChevronDown, Search } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area';
export interface ComboboxOption {
value: string;
label: string;
description?: string;
}
interface ComboboxProps {
/** Currently selected value */
value: string;
/** Callback when value changes */
onValueChange: (value: string) => void;
/** Available options */
options: ComboboxOption[];
/** Placeholder text for the trigger button */
placeholder?: string;
/** Placeholder text for the search input */
searchPlaceholder?: string;
/** Message shown when no results match the search */
emptyMessage?: string;
/** Whether the combobox is disabled */
disabled?: boolean;
/** Additional class names for the trigger */
className?: string;
/** ID for the trigger element */
id?: string;
}
const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>(
(
{
value,
onValueChange,
options,
placeholder = 'Select...',
searchPlaceholder = 'Search...',
emptyMessage = 'No results found',
disabled = false,
className,
id,
},
ref
) => {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [focusedIndex, setFocusedIndex] = React.useState(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
const optionRefs = React.useRef<Map<number, HTMLButtonElement>>(new Map());
const listboxId = React.useId();
// Find the selected option's label
const selectedOption = options.find((opt) => opt.value === value);
const displayValue = selectedOption?.label || placeholder;
// Filter options based on search
const filteredOptions = React.useMemo(() => {
if (!search.trim()) return options;
const searchLower = search.toLowerCase();
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(searchLower) ||
opt.description?.toLowerCase().includes(searchLower)
);
}, [options, search]);
// Get option ID for aria-activedescendant
const getOptionId = (index: number) => `${listboxId}-option-${index}`;
// Get the currently focused option ID
const activeDescendant =
focusedIndex >= 0 && focusedIndex < filteredOptions.length
? getOptionId(focusedIndex)
: undefined;
// Focus input when popover opens, reset focused index
React.useEffect(() => {
if (open) {
// Small delay to ensure the popover is rendered
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 0);
// Reset focused index when opening
setFocusedIndex(-1);
return () => clearTimeout(timer);
} else {
// Clear search when closing
setSearch('');
setFocusedIndex(-1);
}
}, [open]);
// Reset focused index when filtered options change
React.useEffect(() => {
setFocusedIndex(-1);
}, [filteredOptions.length]);
// Scroll focused option into view
React.useEffect(() => {
if (focusedIndex >= 0) {
const optionEl = optionRefs.current.get(focusedIndex);
optionEl?.scrollIntoView({ block: 'nearest' });
}
}, [focusedIndex]);
const handleSelect = (optionValue: string) => {
onValueChange(optionValue);
setOpen(false);
setSearch('');
setFocusedIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex((prev) =>
prev > 0 ? prev - 1 : filteredOptions.length - 1
);
break;
case 'Enter':
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
handleSelect(filteredOptions[focusedIndex].value);
}
break;
case 'Escape':
e.preventDefault();
setOpen(false);
break;
case 'Home':
e.preventDefault();
if (filteredOptions.length > 0) {
setFocusedIndex(0);
}
break;
case 'End':
e.preventDefault();
if (filteredOptions.length > 0) {
setFocusedIndex(filteredOptions.length - 1);
}
break;
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild disabled={disabled}>
<button
ref={ref}
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={open ? listboxId : undefined}
id={id}
className={cn(
'flex h-10 w-full items-center justify-between rounded-lg',
'border border-border bg-card px-3 py-2 text-sm',
'text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-200',
className
)}
>
<span className={cn('truncate', !selectedOption && 'text-muted-foreground')}>
{displayValue}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
sideOffset={4}
onKeyDown={handleKeyDown}
>
{/* Search input */}
<div className="flex items-center border-b border-border px-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
type="text"
role="searchbox"
aria-controls={listboxId}
aria-activedescendant={activeDescendant}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className={cn(
'flex h-10 w-full bg-transparent py-3 px-2 text-sm',
'placeholder:text-muted-foreground',
'focus:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
/>
</div>
{/* Options list */}
<ScrollArea className="max-h-[300px]">
<div id={listboxId} role="listbox" aria-label={searchPlaceholder || placeholder} className="p-1">
{filteredOptions.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
) : (
filteredOptions.map((option, index) => (
<button
key={option.value}
ref={(el) => {
if (el) {
optionRefs.current.set(index, el);
} else {
optionRefs.current.delete(index);
}
}}
id={getOptionId(index)}
type="button"
role="option"
aria-selected={value === option.value}
onClick={() => handleSelect(option.value)}
onMouseEnter={() => setFocusedIndex(index)}
className={cn(
'relative flex w-full cursor-default select-none items-center',
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
'hover:bg-accent hover:text-accent-foreground',
'transition-colors duration-150',
focusedIndex === index && 'bg-accent text-accent-foreground'
)}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{value === option.value && <Check className="h-4 w-4 text-primary" />}
</span>
<span className="truncate">{option.label}</span>
</button>
))
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
);
}
);
Combobox.displayName = 'Combobox';
export { Combobox };
@@ -2,6 +2,7 @@
export * from './badge';
export * from './button';
export * from './card';
export * from './combobox';
export * from './dialog';
export * from './input';
export * from './label';
@@ -17,7 +17,7 @@
"namePlaceholder": "my-feature",
"nameRequired": "Worktree name is required",
"nameInvalid": "Name must start and end with a letter or number",
"nameHelp": "Lowercase letters, numbers, dashes, and underscores only",
"nameHelp": "Lowercase letters, numbers, dashes, and underscores (spaces become hyphens)",
"associateTask": "Link to Task",
"selectTask": "Select a task...",
"noTask": "No task (standalone worktree)",
@@ -25,6 +25,8 @@
"branchHelp": "Creates branch: {{branch}}",
"baseBranch": "Base Branch",
"selectBaseBranch": "Select base branch...",
"searchBranch": "Search branches...",
"noBranchFound": "No branch found",
"useProjectDefault": "Use project default ({{branch}})",
"baseBranchHelp": "The branch to create the worktree from",
"openInIDE": "Open in IDE",
@@ -17,7 +17,7 @@
"namePlaceholder": "ma-fonctionnalite",
"nameRequired": "Le nom du worktree est requis",
"nameInvalid": "Le nom doit commencer et se terminer par une lettre ou un chiffre",
"nameHelp": "Lettres minuscules, chiffres, tirets et underscores uniquement",
"nameHelp": "Lettres minuscules, chiffres, tirets et underscores (les espaces deviennent des tirets)",
"associateTask": "Lier a une Tache",
"selectTask": "Selectionner une tache...",
"noTask": "Pas de tache (worktree autonome)",
@@ -25,6 +25,8 @@
"branchHelp": "Cree la branche: {{branch}}",
"baseBranch": "Branche de Base",
"selectBaseBranch": "Selectionner la branche de base...",
"searchBranch": "Rechercher des branches...",
"noBranchFound": "Aucune branche trouvee",
"useProjectDefault": "Utiliser la valeur par defaut du projet ({{branch}})",
"baseBranchHelp": "La branche a partir de laquelle creer le worktree",
"openInIDE": "Ouvrir dans IDE",