Skip to content

Commit f062f1d

Browse files
AndyMik90claude
andcommitted
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>
1 parent 3c56a1b commit f062f1d

4 files changed

Lines changed: 224 additions & 40 deletions

File tree

‎apps/frontend/src/renderer/components/terminal/CreateWorktreeDialog.tsx‎

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useCallback, useEffect } from 'react';
1+
import { useState, useCallback, useEffect, useMemo } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { GitBranch, Loader2, FolderGit, ListTodo } from 'lucide-react';
44
import {
@@ -20,6 +20,7 @@ import {
2020
SelectTrigger,
2121
SelectValue,
2222
} from '../ui/select';
23+
import { Combobox, type ComboboxOption } from '../ui/combobox';
2324
import type { Task, TerminalWorktreeConfig } from '../../../shared/types';
2425
import { useProjectStore } from '../../stores/project-store';
2526

@@ -99,12 +100,15 @@ export function CreateWorktreeDialog({
99100
}, [open, projectPath, project?.settings?.mainBranch]);
100101

101102
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
102-
// Auto-sanitize: lowercase, replace spaces and invalid chars
103+
// Auto-sanitize: lowercase, allow letters, numbers, dashes, underscores, and dots
104+
// Replace spaces with dashes, remove other invalid characters
103105
const sanitized = e.target.value
104106
.toLowerCase()
105-
.replace(/[^a-z0-9-_]/g, '-')
106-
.replace(/-+/g, '-')
107-
.replace(/^-|-$/g, '');
107+
.replace(/\s+/g, '-')
108+
.replace(/[^a-z0-9._-]/g, '')
109+
.replace(/\.{2,}/g, '.')
110+
.replace(/-{2,}/g, '-')
111+
.replace(/^[.-]+/, '');
108112
setName(sanitized);
109113
setError(null);
110114
}, []);
@@ -119,12 +123,13 @@ export function CreateWorktreeDialog({
119123
if (!name) {
120124
const task = backlogTasks.find(t => t.id === taskId);
121125
if (task) {
122-
// Convert task title to valid name
126+
// Convert task title to valid name - allow dots and underscores
123127
const autoName = task.title
124128
.toLowerCase()
125-
.replace(/[^a-z0-9]+/g, '-')
126-
.replace(/^-|-$/g, '')
127-
.slice(0, 30);
129+
.replace(/\s+/g, '-')
130+
.replace(/[^a-z0-9._-]/g, '')
131+
.replace(/^[.-]+/, '')
132+
.slice(0, 40);
128133
setName(autoName);
129134
}
130135
}
@@ -136,8 +141,9 @@ export function CreateWorktreeDialog({
136141
return;
137142
}
138143

139-
// Validate name format
140-
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
144+
// Validate name format - allow letters, numbers, dashes, underscores, and dots
145+
// Must start with letter or number, must not end with dot
146+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name) || name.endsWith('.')) {
141147
setError(t('terminal:worktree.nameInvalid'));
142148
return;
143149
}
@@ -185,6 +191,30 @@ export function CreateWorktreeDialog({
185191
onOpenChange(newOpen);
186192
};
187193

194+
// Memoized branch options for the Combobox
195+
const branchOptions: ComboboxOption[] = useMemo(() => {
196+
const options: ComboboxOption[] = [
197+
{
198+
value: PROJECT_DEFAULT_BRANCH,
199+
label: t('terminal:worktree.useProjectDefault', { branch: projectDefaultBranch || 'main' }),
200+
},
201+
];
202+
203+
// Add all branches except the project default (which is already shown as "Use project default")
204+
branches
205+
.filter((b) => b !== projectDefaultBranch)
206+
.forEach((branch) => {
207+
options.push({ value: branch, label: branch });
208+
});
209+
210+
// If the project default branch is not in the list, add it
211+
if (projectDefaultBranch && !branches.includes(projectDefaultBranch)) {
212+
options.push({ value: projectDefaultBranch, label: projectDefaultBranch });
213+
}
214+
215+
return options;
216+
}, [branches, projectDefaultBranch, t]);
217+
188218
return (
189219
<Dialog open={open} onOpenChange={handleOpenChange}>
190220
<DialogContent className="sm:max-w-[425px]">
@@ -256,39 +286,22 @@ export function CreateWorktreeDialog({
256286
/>
257287
</div>
258288

259-
{/* Base Branch Selection */}
289+
{/* Base Branch Selection - Searchable */}
260290
<div className="space-y-2">
261291
<Label htmlFor="base-branch" className="flex items-center gap-2">
262292
<GitBranch className="h-4 w-4" />
263293
{t('terminal:worktree.baseBranch')}
264294
</Label>
265-
<Select
295+
<Combobox
296+
id="base-branch"
266297
value={baseBranch}
267298
onValueChange={setBaseBranch}
299+
options={branchOptions}
300+
placeholder={t('terminal:worktree.selectBaseBranch')}
301+
searchPlaceholder={t('terminal:worktree.searchBranch')}
302+
emptyMessage={t('terminal:worktree.noBranchFound')}
268303
disabled={isCreating || isLoadingBranches}
269-
>
270-
<SelectTrigger id="base-branch">
271-
<SelectValue placeholder={t('terminal:worktree.selectBaseBranch')} />
272-
</SelectTrigger>
273-
<SelectContent>
274-
<SelectItem value={PROJECT_DEFAULT_BRANCH}>
275-
{t('terminal:worktree.useProjectDefault', { branch: projectDefaultBranch || 'main' })}
276-
</SelectItem>
277-
{branches
278-
.filter(b => b !== projectDefaultBranch)
279-
.slice(0, 15)
280-
.map((branch) => (
281-
<SelectItem key={branch} value={branch}>
282-
{branch}
283-
</SelectItem>
284-
))}
285-
{projectDefaultBranch && !branches.includes(projectDefaultBranch) && (
286-
<SelectItem value={projectDefaultBranch}>
287-
{projectDefaultBranch}
288-
</SelectItem>
289-
)}
290-
</SelectContent>
291-
</Select>
304+
/>
292305
<p className="text-xs text-muted-foreground">
293306
{t('terminal:worktree.baseBranchHelp')}
294307
</p>
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import * as React from 'react';
2+
import { Check, ChevronDown, Search } from 'lucide-react';
3+
import { cn } from '../../lib/utils';
4+
import { Popover, PopoverContent, PopoverTrigger } from './popover';
5+
import { ScrollArea } from './scroll-area';
6+
7+
export interface ComboboxOption {
8+
value: string;
9+
label: string;
10+
description?: string;
11+
}
12+
13+
interface ComboboxProps {
14+
/** Currently selected value */
15+
value: string;
16+
/** Callback when value changes */
17+
onValueChange: (value: string) => void;
18+
/** Available options */
19+
options: ComboboxOption[];
20+
/** Placeholder text for the trigger button */
21+
placeholder?: string;
22+
/** Placeholder text for the search input */
23+
searchPlaceholder?: string;
24+
/** Message shown when no results match the search */
25+
emptyMessage?: string;
26+
/** Whether the combobox is disabled */
27+
disabled?: boolean;
28+
/** Additional class names for the trigger */
29+
className?: string;
30+
/** ID for the trigger element */
31+
id?: string;
32+
}
33+
34+
export function Combobox({
35+
value,
36+
onValueChange,
37+
options,
38+
placeholder = 'Select...',
39+
searchPlaceholder = 'Search...',
40+
emptyMessage = 'No results found',
41+
disabled = false,
42+
className,
43+
id,
44+
}: ComboboxProps) {
45+
const [open, setOpen] = React.useState(false);
46+
const [search, setSearch] = React.useState('');
47+
const inputRef = React.useRef<HTMLInputElement>(null);
48+
49+
// Find the selected option's label
50+
const selectedOption = options.find((opt) => opt.value === value);
51+
const displayValue = selectedOption?.label || placeholder;
52+
53+
// Filter options based on search
54+
const filteredOptions = React.useMemo(() => {
55+
if (!search.trim()) return options;
56+
const searchLower = search.toLowerCase();
57+
return options.filter(
58+
(opt) =>
59+
opt.label.toLowerCase().includes(searchLower) ||
60+
opt.description?.toLowerCase().includes(searchLower)
61+
);
62+
}, [options, search]);
63+
64+
// Focus input when popover opens
65+
React.useEffect(() => {
66+
if (open) {
67+
// Small delay to ensure the popover is rendered
68+
const timer = setTimeout(() => {
69+
inputRef.current?.focus();
70+
}, 0);
71+
return () => clearTimeout(timer);
72+
} else {
73+
// Clear search when closing
74+
setSearch('');
75+
}
76+
}, [open]);
77+
78+
const handleSelect = (optionValue: string) => {
79+
onValueChange(optionValue);
80+
setOpen(false);
81+
setSearch('');
82+
};
83+
84+
return (
85+
<Popover open={open} onOpenChange={setOpen}>
86+
<PopoverTrigger asChild disabled={disabled}>
87+
<button
88+
type="button"
89+
role="combobox"
90+
aria-expanded={open}
91+
aria-haspopup="listbox"
92+
id={id}
93+
className={cn(
94+
'flex h-10 w-full items-center justify-between rounded-lg',
95+
'border border-border bg-card px-3 py-2 text-sm',
96+
'text-foreground placeholder:text-muted-foreground',
97+
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary',
98+
'disabled:cursor-not-allowed disabled:opacity-50',
99+
'transition-colors duration-200',
100+
className
101+
)}
102+
>
103+
<span className={cn('truncate', !selectedOption && 'text-muted-foreground')}>
104+
{displayValue}
105+
</span>
106+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
107+
</button>
108+
</PopoverTrigger>
109+
<PopoverContent
110+
className="w-[var(--radix-popover-trigger-width)] p-0"
111+
align="start"
112+
sideOffset={4}
113+
>
114+
{/* Search input */}
115+
<div className="flex items-center border-b border-border px-3">
116+
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
117+
<input
118+
ref={inputRef}
119+
type="text"
120+
value={search}
121+
onChange={(e) => setSearch(e.target.value)}
122+
placeholder={searchPlaceholder}
123+
className={cn(
124+
'flex h-10 w-full bg-transparent py-3 px-2 text-sm',
125+
'placeholder:text-muted-foreground',
126+
'focus:outline-none',
127+
'disabled:cursor-not-allowed disabled:opacity-50'
128+
)}
129+
/>
130+
</div>
131+
132+
{/* Options list */}
133+
<ScrollArea className="max-h-[300px]">
134+
<div className="p-1">
135+
{filteredOptions.length === 0 ? (
136+
<div className="py-6 text-center text-sm text-muted-foreground">
137+
{emptyMessage}
138+
</div>
139+
) : (
140+
filteredOptions.map((option) => (
141+
<button
142+
key={option.value}
143+
type="button"
144+
role="option"
145+
aria-selected={value === option.value}
146+
onClick={() => handleSelect(option.value)}
147+
className={cn(
148+
'relative flex w-full cursor-default select-none items-center',
149+
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
150+
'hover:bg-accent hover:text-accent-foreground',
151+
'focus:bg-accent focus:text-accent-foreground',
152+
'transition-colors duration-150'
153+
)}
154+
>
155+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
156+
{value === option.value && <Check className="h-4 w-4 text-primary" />}
157+
</span>
158+
<span className="truncate">{option.label}</span>
159+
</button>
160+
))
161+
)}
162+
</div>
163+
</ScrollArea>
164+
</PopoverContent>
165+
</Popover>
166+
);
167+
}

‎apps/frontend/src/shared/i18n/locales/en/terminal.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@
1616
"name": "Worktree Name",
1717
"namePlaceholder": "my-feature",
1818
"nameRequired": "Worktree name is required",
19-
"nameInvalid": "Name must start and end with a letter or number",
20-
"nameHelp": "Lowercase letters, numbers, dashes, and underscores only",
19+
"nameInvalid": "Name must start with a letter or number and not end with a dot",
20+
"nameHelp": "Lowercase letters, numbers, dashes, underscores, and dots (spaces become hyphens)",
2121
"associateTask": "Link to Task",
2222
"selectTask": "Select a task...",
2323
"noTask": "No task (standalone worktree)",
2424
"createBranch": "Create Git Branch",
2525
"branchHelp": "Creates branch: {{branch}}",
2626
"baseBranch": "Base Branch",
2727
"selectBaseBranch": "Select base branch...",
28+
"searchBranch": "Search branches...",
29+
"noBranchFound": "No branch found",
2830
"useProjectDefault": "Use project default ({{branch}})",
2931
"baseBranchHelp": "The branch to create the worktree from",
3032
"openInIDE": "Open in IDE",

‎apps/frontend/src/shared/i18n/locales/fr/terminal.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@
1616
"name": "Nom du Worktree",
1717
"namePlaceholder": "ma-fonctionnalite",
1818
"nameRequired": "Le nom du worktree est requis",
19-
"nameInvalid": "Le nom doit commencer et se terminer par une lettre ou un chiffre",
20-
"nameHelp": "Lettres minuscules, chiffres, tirets et underscores uniquement",
19+
"nameInvalid": "Le nom doit commencer par une lettre ou un chiffre et ne pas se terminer par un point",
20+
"nameHelp": "Lettres minuscules, chiffres, tirets, underscores et points (les espaces deviennent des tirets)",
2121
"associateTask": "Lier a une Tache",
2222
"selectTask": "Selectionner une tache...",
2323
"noTask": "Pas de tache (worktree autonome)",
2424
"createBranch": "Creer une Branche Git",
2525
"branchHelp": "Cree la branche: {{branch}}",
2626
"baseBranch": "Branche de Base",
2727
"selectBaseBranch": "Selectionner la branche de base...",
28+
"searchBranch": "Rechercher des branches...",
29+
"noBranchFound": "Aucune branche trouvee",
2830
"useProjectDefault": "Utiliser la valeur par defaut du projet ({{branch}})",
2931
"baseBranchHelp": "La branche a partir de laquelle creer le worktree",
3032
"openInIDE": "Ouvrir dans IDE",

0 commit comments

Comments
 (0)