fix(terminal): improve worktree name input UX (#1012)
* fix(terminal): improve worktree name input to not strip trailing characters while typing - Allow trailing hyphens/underscores during input (only trim on submit) - Add preview name that shows the final sanitized value for branch preview - Remove invalid characters instead of replacing with hyphens - Collapse consecutive underscores in addition to hyphens - Final sanitization happens on submit to match backend WORKTREE_NAME_REGEX Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(terminal): address PR review findings for worktree name validation - Fix submit button disabled check to use sanitized name instead of raw input - Simplify trailing trim logic (apply once after all transformations) - Apply lowercase in handleNameChange to reduce input/preview gap - Internationalize 'name' fallback using existing translation key Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(terminal): improve header responsiveness for multiple terminals - Hide text labels (Claude, Open in IDE) when ≥4 terminals, show icon only - Add dynamic max-width to worktree name badge with truncation - Add tooltips to all icon-only elements for accessibility - Maintain full functionality while reducing header width requirements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Test User <test@example.com>
This commit is contained in:
@@ -32,20 +32,27 @@ const PROJECT_DEFAULT_BRANCH = '__project_default__';
|
||||
* - Converts to lowercase
|
||||
* - Replaces spaces and invalid characters with hyphens
|
||||
* - Collapses consecutive hyphens
|
||||
* - Trims leading/trailing hyphens
|
||||
* - Trims leading hyphens (but allows trailing during input)
|
||||
* - Ensures name ends with alphanumeric (matching backend WORKTREE_NAME_REGEX)
|
||||
*
|
||||
* @param trimTrailing - If true, trims trailing hyphens/underscores (for final validation)
|
||||
*/
|
||||
function sanitizeWorktreeName(value: string, maxLength?: number): string {
|
||||
function sanitizeWorktreeName(value: string, maxLength?: number, trimTrailing = false): 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(/[^a-z0-9_-]/g, '') // Remove invalid chars (only allow letters, numbers, hyphens, underscores)
|
||||
.replace(/-{2,}/g, '-') // Collapse consecutive hyphens
|
||||
.replace(/^[-_]+|[-_]+$/g, ''); // Trim leading and trailing hyphens/underscores
|
||||
.replace(/_{2,}/g, '_') // Collapse consecutive underscores
|
||||
.replace(/^[-_]+/, ''); // Trim leading hyphens/underscores only
|
||||
|
||||
if (maxLength) {
|
||||
sanitized = sanitized.slice(0, maxLength);
|
||||
// Trim trailing hyphens/underscores again after slicing
|
||||
}
|
||||
|
||||
// Only trim trailing hyphens/underscores when explicitly requested (final validation)
|
||||
// Applied once at the end after all other transformations including maxLength slice
|
||||
if (trimTrailing) {
|
||||
sanitized = sanitized.replace(/[-_]+$/, '');
|
||||
}
|
||||
|
||||
@@ -93,6 +100,12 @@ export function CreateWorktreeDialog({
|
||||
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
|
||||
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
|
||||
|
||||
// Sanitized name for validation (without display fallback)
|
||||
const sanitizedName = useMemo(() => sanitizeWorktreeName(name, undefined, true), [name]);
|
||||
|
||||
// Preview name with fallback for display (using i18n)
|
||||
const previewName = sanitizedName || t('terminal:worktree.namePlaceholder');
|
||||
|
||||
// Fetch branches when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || !projectPath) return;
|
||||
@@ -138,8 +151,11 @@ export function CreateWorktreeDialog({
|
||||
}, [open, projectPath, project?.settings?.mainBranch]);
|
||||
|
||||
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const sanitized = sanitizeWorktreeName(e.target.value);
|
||||
setName(sanitized);
|
||||
// Apply lowercase and convert spaces to hyphens as user types
|
||||
// This reduces the visual gap between input and preview
|
||||
// Full sanitization (removing invalid chars) happens on submit
|
||||
const rawValue = e.target.value.toLowerCase().replace(/\s+/g, '-');
|
||||
setName(rawValue);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
@@ -153,21 +169,25 @@ export function CreateWorktreeDialog({
|
||||
if (!name) {
|
||||
const task = backlogTasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
const autoName = sanitizeWorktreeName(task.title, 40);
|
||||
// Trim trailing when auto-filling from task title (complete value)
|
||||
const autoName = sanitizeWorktreeName(task.title, 40, true);
|
||||
setName(autoName);
|
||||
}
|
||||
}
|
||||
}, [backlogTasks, name]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
// Final sanitization: trim trailing hyphens/underscores for submission
|
||||
const finalName = sanitizeWorktreeName(name, undefined, true);
|
||||
|
||||
if (!finalName) {
|
||||
setError(t('terminal:worktree.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$/.test(finalName) && !/^[a-z0-9]$/.test(finalName)) {
|
||||
setError(t('terminal:worktree.nameInvalid'));
|
||||
return;
|
||||
}
|
||||
@@ -178,7 +198,7 @@ export function CreateWorktreeDialog({
|
||||
try {
|
||||
const result = await window.electronAPI.createTerminalWorktree({
|
||||
terminalId,
|
||||
name: name.trim(),
|
||||
name: finalName,
|
||||
taskId: selectedTaskId,
|
||||
createGitBranch,
|
||||
projectPath,
|
||||
@@ -297,7 +317,7 @@ export function CreateWorktreeDialog({
|
||||
{t('terminal:worktree.createBranch')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('terminal:worktree.branchHelp', { branch: `terminal/${name || 'name'}` })}
|
||||
{t('terminal:worktree.branchHelp', { branch: `terminal/${previewName}` })}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -338,7 +358,7 @@ export function CreateWorktreeDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
|
||||
{t('common:buttons.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating || !name.trim()}>
|
||||
<Button onClick={handleCreate} disabled={isCreating || !sanitizedName}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
|
||||
@@ -98,9 +98,12 @@ export function TerminalHeader({
|
||||
/>
|
||||
</div>
|
||||
{isClaudeMode && (
|
||||
<span className="flex items-center gap-1 text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||
<span
|
||||
className="flex items-center gap-1 text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded"
|
||||
title="Claude"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
Claude
|
||||
{terminalCount < 4 && <span>Claude</span>}
|
||||
</span>
|
||||
)}
|
||||
{isClaudeMode && (
|
||||
@@ -115,9 +118,15 @@ export function TerminalHeader({
|
||||
)}
|
||||
{/* Worktree selector or badge - placed next to task selector */}
|
||||
{worktreeConfig ? (
|
||||
<span className="flex items-center gap-1 text-[10px] font-medium text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded">
|
||||
<FolderGit className="h-2.5 w-2.5" />
|
||||
{worktreeConfig.name}
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-[10px] font-medium text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded',
|
||||
terminalCount >= 6 ? 'max-w-20' : terminalCount >= 4 ? 'max-w-28' : 'max-w-40'
|
||||
)}
|
||||
title={worktreeConfig.name}
|
||||
>
|
||||
<FolderGit className="h-2.5 w-2.5 flex-shrink-0" />
|
||||
<span className="truncate">{worktreeConfig.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
projectPath && onCreateWorktree && onSelectWorktree && (
|
||||
@@ -136,29 +145,37 @@ export function TerminalHeader({
|
||||
{worktreeConfig && onOpenInIDE && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1 hover:bg-muted"
|
||||
size={terminalCount >= 4 ? 'icon' : 'sm'}
|
||||
className={cn(
|
||||
'h-6 hover:bg-muted',
|
||||
terminalCount >= 4 ? 'w-6' : 'px-2 text-xs gap-1'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenInIDE();
|
||||
}}
|
||||
title={t('terminal:worktree.openInIDE')}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t('terminal:worktree.openInIDE')}
|
||||
{terminalCount < 4 && t('terminal:worktree.openInIDE')}
|
||||
</Button>
|
||||
)}
|
||||
{!isClaudeMode && status !== 'exited' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1 hover:bg-primary/10 hover:text-primary"
|
||||
size={terminalCount >= 4 ? 'icon' : 'sm'}
|
||||
className={cn(
|
||||
'h-6 hover:bg-primary/10 hover:text-primary',
|
||||
terminalCount >= 4 ? 'w-6' : 'px-2 text-xs gap-1'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInvokeClaude();
|
||||
}}
|
||||
title="Claude"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Claude
|
||||
{terminalCount < 4 && <span>Claude</span>}
|
||||
</Button>
|
||||
)}
|
||||
{/* Expand/collapse button */}
|
||||
|
||||
Reference in New Issue
Block a user