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