From 2a2dc3b8c76f9731d3d871f3b006afa571f9cc72 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:12:08 +0100 Subject: [PATCH] feat(frontend): add searchable branch combobox to worktree creation dialog (#979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.5 --- .../terminal/CreateWorktreeDialog.tsx | 161 ++++++----- .../src/renderer/components/ui/combobox.tsx | 261 ++++++++++++++++++ .../src/renderer/components/ui/index.ts | 1 + .../src/shared/i18n/locales/en/terminal.json | 4 +- .../src/shared/i18n/locales/fr/terminal.json | 4 +- 5 files changed, 366 insertions(+), 65 deletions(-) create mode 100644 apps/frontend/src/renderer/components/ui/combobox.tsx diff --git a/apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx b/apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx index facb5adf..a6179280 100644 --- a/apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx +++ b/apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx @@ -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) => { - // 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 ( @@ -256,39 +308,22 @@ export function CreateWorktreeDialog({ /> - {/* Base Branch Selection */} + {/* Base Branch Selection - Searchable */}
- + />

{t('terminal:worktree.baseBranchHelp')}

diff --git a/apps/frontend/src/renderer/components/ui/combobox.tsx b/apps/frontend/src/renderer/components/ui/combobox.tsx new file mode 100644 index 00000000..41dea671 --- /dev/null +++ b/apps/frontend/src/renderer/components/ui/combobox.tsx @@ -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( + ( + { + 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(null); + const optionRefs = React.useRef>(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 ( + + + + + + {/* Search input */} +
+ + 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' + )} + /> +
+ + {/* Options list */} + +
+ {filteredOptions.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + filteredOptions.map((option, index) => ( + + )) + )} +
+
+
+
+ ); + } +); + +Combobox.displayName = 'Combobox'; + +export { Combobox }; diff --git a/apps/frontend/src/renderer/components/ui/index.ts b/apps/frontend/src/renderer/components/ui/index.ts index e9be7e28..c3085e12 100644 --- a/apps/frontend/src/renderer/components/ui/index.ts +++ b/apps/frontend/src/renderer/components/ui/index.ts @@ -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'; diff --git a/apps/frontend/src/shared/i18n/locales/en/terminal.json b/apps/frontend/src/shared/i18n/locales/en/terminal.json index b29808a2..9552256e 100644 --- a/apps/frontend/src/shared/i18n/locales/en/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/en/terminal.json @@ -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", diff --git a/apps/frontend/src/shared/i18n/locales/fr/terminal.json b/apps/frontend/src/shared/i18n/locales/fr/terminal.json index 80867cde..d691fefb 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/terminal.json +++ b/apps/frontend/src/shared/i18n/locales/fr/terminal.json @@ -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",