From 8833feb2b70d68671d4d57a7177b053294c2d8f9 Mon Sep 17 00:00:00 2001
From: Andy <119136210+AndyMik90@users.noreply.github.com>
Date: Sat, 17 Jan 2026 19:25:56 +0100
Subject: [PATCH] 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
* 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
* 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
* 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
* 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
* 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
* 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
* 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
Co-authored-by: Claude Opus 4.5
---
.../src/renderer/components/Worktrees.tsx | 351 +++++++++++++++---
.../src/shared/i18n/locales/en/common.json | 16 +-
.../src/shared/i18n/locales/en/dialogs.json | 6 +-
.../src/shared/i18n/locales/fr/common.json | 16 +-
.../src/shared/i18n/locales/fr/dialogs.json | 6 +-
5 files changed, 346 insertions(+), 49 deletions(-)
diff --git a/apps/frontend/src/renderer/components/Worktrees.tsx b/apps/frontend/src/renderer/components/Worktrees.tsx
index 6967aa0e..f5a77e70 100644
--- a/apps/frontend/src/renderer/components/Worktrees.tsx
+++ b/apps/frontend/src/renderer/components/Worktrees.tsx
@@ -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(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(null);
const [prTask, setPrTask] = useState(null);
+ // Selection state
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
+ const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(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
-
+
+
+
+
+ {/* Selection controls bar - visible when selection mode is enabled */}
+ {isSelectionMode && totalWorktrees > 0 && (
+
+
+
+
+ {t('common:selection.selectedOfTotal', { selected: selectedCount, total: totalWorktrees })}
+
+
+
+
+
+
+ )}
+
{/* Error message */}
{error && (
@@ -310,7 +511,7 @@ export function Worktrees({ projectId }: WorktreesProps) {
Error
-
{error}
+
{error}
@@ -350,20 +551,30 @@ export function Worktrees({ projectId }: WorktreesProps) {
{worktrees.map((worktree) => {
const task = findTaskForWorktree(worktree.specName);
+ const taskId = `${TASK_PREFIX}${worktree.specName}`;
return (
-
-
-
- {worktree.branch}
-
- {task && (
-
- {task.title}
-
+
+ {isSelectionMode && (
+
toggleWorktree(taskId)}
+ className="mt-1"
+ />
)}
+
+
+
+ {worktree.branch}
+
+ {task && (
+
+ {task.title}
+
+ )}
+
{worktree.specName}
@@ -465,28 +676,39 @@ export function Worktrees({ projectId }: WorktreesProps) {
Terminal Worktrees
- {terminalWorktrees.map((wt) => (
-
-
-
-
-
-
- {wt.name}
-
- {wt.branchName && (
-
- {wt.branchName}
-
+ {terminalWorktrees.map((wt) => {
+ const terminalId = `${TERMINAL_PREFIX}${wt.name}`;
+ return (
+
+
+
+
+ {isSelectionMode && (
+
toggleWorktree(terminalId)}
+ className="mt-1"
+ />
+ )}
+
+
+
+ {wt.name}
+
+ {wt.branchName && (
+
+ {wt.branchName}
+
+ )}
+
+
+ {wt.taskId && (
+
+ {wt.taskId}
+
)}
- {wt.taskId && (
-
- {wt.taskId}
-
- )}
-
-
+
{/* Branch info */}
{wt.baseBranch && wt.branchName && (
@@ -529,7 +751,8 @@ export function Worktrees({ projectId }: WorktreesProps) {
- ))}
+ );
+ })}
)}
@@ -719,6 +942,44 @@ export function Worktrees({ projectId }: WorktreesProps) {
+ {/* Bulk Delete Confirmation Dialog */}
+
+
+
+
+
+ {t('dialogs:worktrees.bulkDeleteTitle', { count: selectedWorktreeIds.size })}
+
+
+ {t('dialogs:worktrees.bulkDeleteDescription')}
+
+
+
+ {t('common:buttons.cancel')}
+ {
+ e.preventDefault();
+ executeBulkDelete();
+ }}
+ disabled={isBulkDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isBulkDeleting ? (
+ <>
+
+ {t('dialogs:worktrees.deleting')}
+ >
+ ) : (
+ <>
+
+ {t('dialogs:worktrees.deleteSelected')}
+ >
+ )}
+
+
+
+
+
{/* Create PR Dialog */}
{prTask && prWorktree && (