Add bulk delete functionality to worktree overview (#1208)

* auto-claude: subtask-1-1 - Add English translation keys for bulk delete dialog

- Add bulkDeleteTitle, bulkDeleteDescription, deleting, deleteSelected to worktrees section in dialogs.json
- Add selection section with selected, selectAll, clearSelection, deleteSelected to common.json

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

* auto-claude: subtask-1-2 - Add French translation keys for bulk delete dialog

Added French translations for:
- dialogs.json: bulkDeleteTitle, bulkDeleteDescription, deleting, deleteSelected
- common.json: selection section with selected, selectAll, clearSelection, deleteSelected

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

* auto-claude: subtask-2-1 - Add selection mode toggle state (isSelectionMode)

* auto-claude: subtask-3-1 - Add selection mode toggle button to Worktrees header

* auto-claude: subtask-3-2 - Add selection controls bar below header (visible w

* auto-claude: subtask-3-3 - Add Checkbox component to worktree Cards

- Import Checkbox from ui/checkbox
- Add Checkbox to Task Worktrees Card (visible only in selection mode)
- Add Checkbox to Terminal Worktrees Card (visible only in selection mode)
- Use prefixed IDs: 'task:{specName}' and 'terminal:{name}'
- Update selection callbacks to handle prefixed IDs for both worktree types
- Update selectAll/deselectAll to work with both task and terminal worktrees
- Update isAllSelected/isSomeSelected computed values for combined worktrees

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

* auto-claude: subtask-4-1 - Add bulk delete button that appears when items are selected

- Add bulk delete button in selection controls bar with destructive styling
- Button shows count of selected worktrees
- Button is disabled when no items are selected
- Add handleBulkDelete callback (handler implementation in next subtask)

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

* auto-claude: subtask-4-2 - Add bulk delete confirmation AlertDialog with i18n

* auto-claude: subtask-4-3 - Implement handleBulkDelete function. Parse prefixe

* auto-claude: subtask-5-1 - Clear selection when loadWorktrees is called

- Clear selectedWorktreeIds when worktrees list is refreshed
- Exit selection mode when list refreshes to prevent stale state
- Existing single-delete functionality remains unchanged

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

* fix: use i18n keys for selection control text (qa-requested)

Fixes:
- Replace hardcoded 'Select all' / 'Deselect all' text with i18n keys
- Replace hardcoded selection count text with i18n interpolation
- Add 'selectedOfTotal' key to both EN and FR translation files

Verification:
- All hardcoded strings removed from Worktrees.tsx
- TypeScript lint passes
- Build completes successfully
- All 1761 tests pass

QA Fix Session: 1

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

* fix: resolve i18n violations and code quality issues in bulk delete

- Replace hardcoded 'Refresh' button text with t('common:buttons.refresh')
- Add i18n keys for all bulk delete error messages (en/fr)
- Wrap findTaskForWorktree in useCallback to prevent unnecessary re-renders
- Use named constants TASK_PREFIX/TERMINAL_PREFIX for ID parsing
- Add whitespace-pre-line class to error display for proper newline rendering

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

* fix(worktrees): use prefix constants consistently and fix stale selection count

- Replace hardcoded 'task:' and 'terminal:' strings with TASK_PREFIX/TERMINAL_PREFIX
  constants in selectAll, isAllSelected, isSomeSelected, and JSX rendering
- Change selectedCount from raw Set.size to useMemo that filters against current
  worktrees arrays, preventing stale counts for externally deleted worktrees

Addresses PR review feedback for bulk delete functionality.

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-17 19:25:56 +01:00
committed by GitHub
parent 76f077200b
commit 8833feb2b7
5 changed files with 346 additions and 49 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
GitBranch,
@@ -16,10 +16,14 @@ import {
ChevronRight,
Check,
X,
Terminal
Terminal,
CheckSquare2,
CheckSquare,
Square
} from 'lucide-react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Checkbox } from './ui/checkbox';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card';
import { ScrollArea } from './ui/scroll-area';
import {
@@ -45,12 +49,16 @@ import { useTaskStore } from '../stores/task-store';
import type { WorktreeListItem, WorktreeMergeResult, TerminalWorktreeConfig, WorktreeStatus, Task, WorktreeCreatePROptions, WorktreeCreatePRResult } from '../../shared/types';
import { CreatePRDialog } from './task-detail/task-review/CreatePRDialog';
// Prefix constants for worktree ID parsing
const TASK_PREFIX = 'task:';
const TERMINAL_PREFIX = 'terminal:';
interface WorktreesProps {
projectId: string;
}
export function Worktrees({ projectId }: WorktreesProps) {
const { t } = useTranslation(['common']);
const { t } = useTranslation(['common', 'dialogs']);
const projects = useProjectStore((state) => state.projects);
const selectedProject = projects.find((p) => p.id === projectId);
const tasks = useTaskStore((state) => state.tasks);
@@ -75,15 +83,83 @@ export function Worktrees({ projectId }: WorktreesProps) {
const [worktreeToDelete, setWorktreeToDelete] = useState<WorktreeListItem | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Bulk delete confirmation state
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
// Create PR dialog state
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [prWorktree, setPrWorktree] = useState<WorktreeListItem | null>(null);
const [prTask, setPrTask] = useState<Task | null>(null);
// Selection state
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedWorktreeIds, setSelectedWorktreeIds] = useState<Set<string>>(new Set());
// Selection callbacks
const toggleWorktree = useCallback((id: string) => {
setSelectedWorktreeIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const selectAll = useCallback(() => {
const allIds = [
...worktrees.map(w => `${TASK_PREFIX}${w.specName}`),
...terminalWorktrees.map(wt => `${TERMINAL_PREFIX}${wt.name}`)
];
setSelectedWorktreeIds(new Set(allIds));
}, [worktrees, terminalWorktrees]);
const deselectAll = useCallback(() => {
setSelectedWorktreeIds(new Set());
}, []);
// Computed selection values
const totalWorktrees = worktrees.length + terminalWorktrees.length;
const isAllSelected = useMemo(
() => totalWorktrees > 0 &&
worktrees.every(w => selectedWorktreeIds.has(`${TASK_PREFIX}${w.specName}`)) &&
terminalWorktrees.every(wt => selectedWorktreeIds.has(`${TERMINAL_PREFIX}${wt.name}`)),
[worktrees, terminalWorktrees, selectedWorktreeIds, totalWorktrees]
);
const isSomeSelected = useMemo(
() => (
worktrees.some(w => selectedWorktreeIds.has(`${TASK_PREFIX}${w.specName}`)) ||
terminalWorktrees.some(wt => selectedWorktreeIds.has(`${TERMINAL_PREFIX}${wt.name}`))
) && !isAllSelected,
[worktrees, terminalWorktrees, selectedWorktreeIds, isAllSelected]
);
// Compute selectedCount by filtering against current worktrees to exclude stale selections
const selectedCount = useMemo(() => {
const validTaskIds = new Set(worktrees.map(w => `${TASK_PREFIX}${w.specName}`));
const validTerminalIds = new Set(terminalWorktrees.map(wt => `${TERMINAL_PREFIX}${wt.name}`));
let count = 0;
selectedWorktreeIds.forEach(id => {
if (validTaskIds.has(id) || validTerminalIds.has(id)) {
count++;
}
});
return count;
}, [worktrees, terminalWorktrees, selectedWorktreeIds]);
// Load worktrees (both task and terminal worktrees)
const loadWorktrees = useCallback(async () => {
if (!projectId || !selectedProject) return;
// Clear selection when refreshing list to prevent stale selections
setSelectedWorktreeIds(new Set());
setIsSelectionMode(false);
setIsLoading(true);
setError(null);
@@ -123,9 +199,9 @@ export function Worktrees({ projectId }: WorktreesProps) {
}, [loadWorktrees]);
// Find task for a worktree
const findTaskForWorktree = (specName: string) => {
const findTaskForWorktree = useCallback((specName: string) => {
return tasks.find(t => t.specId === specName);
};
}, [tasks]);
// Handle merge
const handleMerge = async () => {
@@ -246,6 +322,84 @@ export function Worktrees({ projectId }: WorktreesProps) {
}
};
// Handle bulk delete - triggered from selection bar
const handleBulkDelete = useCallback(() => {
if (selectedWorktreeIds.size === 0) return;
setShowBulkDeleteConfirm(true);
}, [selectedWorktreeIds]);
// Execute bulk delete - called when user confirms in dialog
const executeBulkDelete = useCallback(async () => {
if (selectedWorktreeIds.size === 0 || !selectedProject) return;
setIsBulkDeleting(true);
const errors: string[] = [];
// Parse selected IDs and separate by type
const taskSpecNames: string[] = [];
const terminalNames: string[] = [];
selectedWorktreeIds.forEach((id) => {
if (id.startsWith(TASK_PREFIX)) {
taskSpecNames.push(id.slice(TASK_PREFIX.length));
} else if (id.startsWith(TERMINAL_PREFIX)) {
terminalNames.push(id.slice(TERMINAL_PREFIX.length));
}
});
// Delete task worktrees
for (const specName of taskSpecNames) {
const task = findTaskForWorktree(specName);
if (!task) {
errors.push(t('common:errors.taskNotFoundForWorktree', { specName }));
continue;
}
try {
const result = await window.electronAPI.discardWorktree(task.id);
if (!result.success) {
errors.push(result.error || t('common:errors.failedToDeleteTaskWorktree', { specName }));
}
} catch (err) {
errors.push(err instanceof Error ? err.message : t('common:errors.failedToDeleteTaskWorktree', { specName }));
}
}
// Delete terminal worktrees
for (const name of terminalNames) {
const terminalWt = terminalWorktrees.find((wt) => wt.name === name);
if (!terminalWt) {
errors.push(t('common:errors.terminalWorktreeNotFound', { name }));
continue;
}
try {
const result = await window.electronAPI.removeTerminalWorktree(
selectedProject.path,
terminalWt.name,
terminalWt.hasGitBranch // Delete the branch too if it was created
);
if (!result.success) {
errors.push(result.error || t('common:errors.failedToDeleteTerminalWorktree', { name }));
}
} catch (err) {
errors.push(err instanceof Error ? err.message : t('common:errors.failedToDeleteTerminalWorktree', { name }));
}
}
// Clear selection and refresh list
setSelectedWorktreeIds(new Set());
setShowBulkDeleteConfirm(false);
await loadWorktrees();
// Show error if any failures occurred
if (errors.length > 0) {
setError(`${t('common:errors.bulkDeletePartialFailure')}\n${errors.join('\n')}`);
}
setIsBulkDeleting(false);
}, [selectedWorktreeIds, selectedProject, terminalWorktrees, findTaskForWorktree, loadWorktrees, t]);
// Handle terminal worktree delete
const handleDeleteTerminalWorktree = async () => {
if (!terminalWorktreeToDelete || !selectedProject) return;
@@ -292,17 +446,64 @@ export function Worktrees({ projectId }: WorktreesProps) {
Manage isolated workspaces for your Auto Claude tasks
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={loadWorktrees}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className="flex items-center gap-2">
<Button
variant={isSelectionMode ? 'default' : 'outline'}
size="sm"
onClick={() => {
if (isSelectionMode) {
setIsSelectionMode(false);
setSelectedWorktreeIds(new Set());
} else {
setIsSelectionMode(true);
}
}}
>
<CheckSquare2 className="h-4 w-4 mr-2" />
{isSelectionMode ? t('common:selection.done') : t('common:selection.select')}
</Button>
<Button
variant="outline"
size="sm"
onClick={loadWorktrees}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
{t('common:buttons.refresh')}
</Button>
</div>
</div>
{/* Selection controls bar - visible when selection mode is enabled */}
{isSelectionMode && totalWorktrees > 0 && (
<div className="flex items-center justify-between py-2 mb-4 border-b border-border shrink-0">
<div className="flex items-center gap-3">
<button
onClick={isAllSelected ? deselectAll : selectAll}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
{/* tri-state icon: isAllSelected -> CheckSquare, isSomeSelected -> Minus, none -> Square */}
{isAllSelected ? <CheckSquare className="h-4 w-4 text-primary" /> : isSomeSelected ? <Minus className="h-4 w-4" /> : <Square className="h-4 w-4" />}
{isAllSelected ? t('common:selection.clearSelection') : t('common:selection.selectAll')}
</button>
<span className="text-xs text-muted-foreground">
{t('common:selection.selectedOfTotal', { selected: selectedCount, total: totalWorktrees })}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
disabled={selectedWorktreeIds.size === 0}
onClick={handleBulkDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('common:buttons.delete')} ({selectedWorktreeIds.size})
</Button>
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm">
@@ -310,7 +511,7 @@ export function Worktrees({ projectId }: WorktreesProps) {
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<div>
<p className="font-medium text-destructive">Error</p>
<p className="text-muted-foreground mt-1">{error}</p>
<p className="text-muted-foreground mt-1 whitespace-pre-line">{error}</p>
</div>
</div>
</div>
@@ -350,20 +551,30 @@ export function Worktrees({ projectId }: WorktreesProps) {
</h3>
{worktrees.map((worktree) => {
const task = findTaskForWorktree(worktree.specName);
const taskId = `${TASK_PREFIX}${worktree.specName}`;
return (
<Card key={worktree.specName} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-base flex items-center gap-2">
<GitBranch className="h-4 w-4 text-info shrink-0" />
<span className="truncate">{worktree.branch}</span>
</CardTitle>
{task && (
<CardDescription className="mt-1 truncate">
{task.title}
</CardDescription>
<div className="flex items-start gap-3 flex-1 min-w-0">
{isSelectionMode && (
<Checkbox
checked={selectedWorktreeIds.has(taskId)}
onCheckedChange={() => toggleWorktree(taskId)}
className="mt-1"
/>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-base flex items-center gap-2">
<GitBranch className="h-4 w-4 text-info shrink-0" />
<span className="truncate">{worktree.branch}</span>
</CardTitle>
{task && (
<CardDescription className="mt-1 truncate">
{task.title}
</CardDescription>
)}
</div>
</div>
<Badge variant="outline" className="shrink-0 ml-2">
{worktree.specName}
@@ -465,28 +676,39 @@ export function Worktrees({ projectId }: WorktreesProps) {
<Terminal className="h-4 w-4" />
Terminal Worktrees
</h3>
{terminalWorktrees.map((wt) => (
<Card key={wt.name} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-base flex items-center gap-2">
<FolderGit className="h-4 w-4 text-amber-500 shrink-0" />
<span className="truncate">{wt.name}</span>
</CardTitle>
{wt.branchName && (
<CardDescription className="mt-1 truncate font-mono text-xs">
{wt.branchName}
</CardDescription>
{terminalWorktrees.map((wt) => {
const terminalId = `${TERMINAL_PREFIX}${wt.name}`;
return (
<Card key={wt.name} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1 min-w-0">
{isSelectionMode && (
<Checkbox
checked={selectedWorktreeIds.has(terminalId)}
onCheckedChange={() => toggleWorktree(terminalId)}
className="mt-1"
/>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-base flex items-center gap-2">
<FolderGit className="h-4 w-4 text-amber-500 shrink-0" />
<span className="truncate">{wt.name}</span>
</CardTitle>
{wt.branchName && (
<CardDescription className="mt-1 truncate font-mono text-xs">
{wt.branchName}
</CardDescription>
)}
</div>
</div>
{wt.taskId && (
<Badge variant="outline" className="shrink-0 ml-2">
{wt.taskId}
</Badge>
)}
</div>
{wt.taskId && (
<Badge variant="outline" className="shrink-0 ml-2">
{wt.taskId}
</Badge>
)}
</div>
</CardHeader>
</CardHeader>
<CardContent className="pt-0">
{/* Branch info */}
{wt.baseBranch && wt.branchName && (
@@ -529,7 +751,8 @@ export function Worktrees({ projectId }: WorktreesProps) {
</div>
</CardContent>
</Card>
))}
);
})}
</div>
)}
</div>
@@ -719,6 +942,44 @@ export function Worktrees({ projectId }: WorktreesProps) {
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation Dialog */}
<AlertDialog open={showBulkDeleteConfirm} onOpenChange={setShowBulkDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
{t('dialogs:worktrees.bulkDeleteTitle', { count: selectedWorktreeIds.size })}
</AlertDialogTitle>
<AlertDialogDescription>
{t('dialogs:worktrees.bulkDeleteDescription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBulkDeleting}>{t('common:buttons.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
executeBulkDelete();
}}
disabled={isBulkDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isBulkDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('dialogs:worktrees.deleting')}
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
{t('dialogs:worktrees.deleteSelected')}
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Create PR Dialog */}
{prTask && prWorktree && (
<CreatePRDialog
@@ -93,6 +93,15 @@
"required": "Required",
"dismiss": "Dismiss"
},
"selection": {
"select": "Select",
"done": "Done",
"selected": "{{count}} selected",
"selectAll": "Select All",
"clearSelection": "Clear Selection",
"deleteSelected": "Delete Selected",
"selectedOfTotal": "{{selected}} of {{total}} selected"
},
"time": {
"justNow": "Just now",
"minutesAgo": "{{count}}m ago",
@@ -105,7 +114,12 @@
"operationFailed": "Operation failed",
"networkError": "Network error",
"notFound": "Not found",
"unauthorized": "Unauthorized"
"unauthorized": "Unauthorized",
"bulkDeletePartialFailure": "Some worktrees could not be deleted:",
"taskNotFoundForWorktree": "Task not found for worktree: {{specName}}",
"failedToDeleteTaskWorktree": "Failed to delete task worktree: {{specName}}",
"terminalWorktreeNotFound": "Terminal worktree not found: {{name}}",
"failedToDeleteTerminalWorktree": "Failed to delete terminal worktree: {{name}}"
},
"notification": {
"accountSwitched": "Account Switched",
@@ -54,7 +54,11 @@
"merge": "Merge Worktree",
"mergeDescription": "Merge changes from this worktree into the base branch.",
"delete": "Delete Worktree?",
"deleteDescription": "This will permanently delete the worktree and all uncommitted changes. This action cannot be undone."
"deleteDescription": "This will permanently delete the worktree and all uncommitted changes. This action cannot be undone.",
"bulkDeleteTitle": "Delete {{count}} Worktrees?",
"bulkDeleteDescription": "This will permanently delete the selected worktrees and all their uncommitted changes. This action cannot be undone.",
"deleting": "Deleting...",
"deleteSelected": "Delete Selected"
},
"worktreeCleanup": {
"title": "Complete Task",
@@ -93,6 +93,15 @@
"required": "Requis",
"dismiss": "Ignorer"
},
"selection": {
"select": "Sélectionner",
"done": "Terminé",
"selected": "{{count}} sélectionné(s)",
"selectAll": "Tout sélectionner",
"clearSelection": "Effacer la sélection",
"deleteSelected": "Supprimer la sélection",
"selectedOfTotal": "{{selected}} sur {{total}} sélectionné(s)"
},
"time": {
"justNow": "À l'instant",
"minutesAgo": "Il y a {{count}} min",
@@ -105,7 +114,12 @@
"operationFailed": "Opération échouée",
"networkError": "Erreur réseau",
"notFound": "Non trouvé",
"unauthorized": "Non autorisé"
"unauthorized": "Non autorisé",
"bulkDeletePartialFailure": "Certains worktrees n'ont pas pu être supprimés :",
"taskNotFoundForWorktree": "Tâche introuvable pour le worktree : {{specName}}",
"failedToDeleteTaskWorktree": "Échec de la suppression du worktree de tâche : {{specName}}",
"terminalWorktreeNotFound": "Worktree terminal introuvable : {{name}}",
"failedToDeleteTerminalWorktree": "Échec de la suppression du worktree terminal : {{name}}"
},
"notification": {
"accountSwitched": "Compte changé",
@@ -54,7 +54,11 @@
"merge": "Fusionner le worktree",
"mergeDescription": "Fusionner les modifications de ce worktree dans la branche de base.",
"delete": "Supprimer le worktree ?",
"deleteDescription": "Ceci supprimera définitivement le worktree et toutes les modifications non committées. Cette action est irréversible."
"deleteDescription": "Ceci supprimera définitivement le worktree et toutes les modifications non committées. Cette action est irréversible.",
"bulkDeleteTitle": "Supprimer {{count}} worktrees ?",
"bulkDeleteDescription": "Ceci supprimera définitivement les worktrees sélectionnés et toutes leurs modifications non committées. Cette action est irréversible.",
"deleting": "Suppression...",
"deleteSelected": "Supprimer la sélection"
},
"worktreeCleanup": {
"title": "Terminer la tâche",