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