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 && (