diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx new file mode 100644 index 00000000000..c955feef4eb --- /dev/null +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx @@ -0,0 +1,65 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ButtonHTMLAttributes, ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const state = { + activeModal: 'confirm-remove-folder', + modalData: {} as Record, + closeModal: vi.fn(), + removeProject: vi.fn() + } + return { state } +}) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + ...props + }: ButtonHTMLAttributes & { children: ReactNode }) => ( + + ) +})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
+})) + +describe('RemoveFolderDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.state.activeModal = 'confirm-remove-folder' + mocks.state.modalData = {} + }) + + it('renders batch project removal copy without implying disk deletion', async () => { + mocks.state.modalData = { + repoIds: ['repo-1', 'repo-2'], + displayNames: ['Orca', 'Noqa'] + } + + const { default: RemoveFolderDialog } = await import('./RemoveFolderDialog') + const markup = renderToStaticMarkup() + + expect(markup).toContain('Remove Projects') + expect(markup).toContain('2 projects') + expect(markup).toContain('Their folders and git worktrees stay on disk.') + expect(markup).toContain('Orca') + expect(markup).toContain('Noqa') + expect(markup).not.toContain('delete') + }) +}) diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index 74aae142c3a..3c5066397a0 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import { Dialog, DialogContent, @@ -8,6 +8,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' import { useAppStore } from '@/store' const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { @@ -18,14 +19,42 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { const isOpen = activeModal === 'confirm-remove-folder' const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : '' + const repoIds = useMemo( + () => + Array.isArray(modalData.repoIds) + ? modalData.repoIds.filter((id): id is string => typeof id === 'string') + : repoId + ? [repoId] + : [], + [modalData.repoIds, repoId] + ) const displayName = typeof modalData.displayName === 'string' ? modalData.displayName : '' + const displayNames = useMemo( + () => + Array.isArray(modalData.displayNames) + ? modalData.displayNames.filter((name): name is string => typeof name === 'string') + : displayName + ? [displayName] + : [], + [displayName, modalData.displayNames] + ) + const onRemoved = + typeof modalData.onRemoved === 'function' + ? (modalData.onRemoved as (repoIds: string[]) => void) + : null + const isBatchRemove = repoIds.length > 1 const handleConfirm = useCallback(() => { - if (repoId) { - void removeProject(repoId) + if (repoIds.length > 0) { + void (async () => { + for (const id of repoIds) { + await removeProject(id) + } + onRemoved?.(repoIds) + })() } closeModal() - }, [closeModal, removeProject, repoId]) + }, [closeModal, onRemoved, removeProject, repoIds]) const handleOpenChange = useCallback( (open: boolean) => { @@ -40,13 +69,42 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { - Remove Project + + {isBatchRemove ? 'Remove Projects' : 'Remove Project'} + - This only removes{' '} - {displayName} from Orca. - It is still on your disk. + {isBatchRemove ? ( + <> + This only removes{' '} + {repoIds.length} projects from + Orca. Their folders and git worktrees stay on disk. + + ) : ( + <> + This only removes{' '} + {displayName} from + Orca. It is still on your disk. + + )} + {isBatchRemove ? ( + +
+ {repoIds.map((id, index) => { + const name = displayNames[index] ?? id + return ( +
+ {name} +
+ ) + })} +
+
+ ) : null} + ) +} + +function SidebarBulkSelectionBar({ + projectCount, + worktreeCount, + onRemoveProjects, + onDeleteWorktrees, + onClear +}: { + projectCount: number + worktreeCount: number + onRemoveProjects: () => void + onDeleteWorktrees: () => void + onClear: () => void +}): React.JSX.Element | null { + if (projectCount === 0 && worktreeCount === 0) { + return null + } + + const summary = + projectCount > 0 + ? formatSelectedCount(projectCount, 'project') + : formatSelectedCount(worktreeCount, 'workspace') + + return ( +
+ {summary} + {projectCount > 0 ? ( + + ) : null} + {worktreeCount > 0 ? ( + + ) : null} + +
+ ) +} + const LINEAGE_INDENT = 18 // Why: top-level worktrees are children of their project header; indent the // group one step so the status dots nest under the folder icon for hierarchy. @@ -332,7 +437,11 @@ type VirtualizedWorktreeViewportProps = { worktrees: Worktree[] selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] + selectedProjectIds: ReadonlySet onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onWorktreeCheckboxToggle: (event: React.MouseEvent, worktreeId: string) => void + onProjectSelectionGesture: (event: React.MouseEvent, projectId: string) => boolean + onProjectCheckboxToggle: (event: React.MouseEvent, projectId: string) => void onContextMenuSelect: ( event: React.MouseEvent, worktree: Worktree @@ -673,7 +782,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktrees, selectedWorktreeIds, selectedWorktrees, + selectedProjectIds, onSelectionGesture, + onWorktreeCheckboxToggle, + onProjectSelectionGesture, + onProjectCheckboxToggle, onContextMenuSelect, repoMap, worktreeMap, @@ -2393,6 +2506,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const isRepoHeader = groupBy === 'repo' && row.repo !== undefined const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined + const isProjectSelected = + projectIdForHeader !== undefined && selectedProjectIds.has(projectIdForHeader) const isDraggingThis = canReorderRepoHeaders && repoDrag.state.draggingRepoId !== null && @@ -2456,6 +2571,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp className={cn( 'group flex h-7 w-full items-center gap-1.5 pr-1 text-left transition-all', 'cursor-pointer', + isProjectSelected && + 'rounded-md bg-sidebar-accent ring-1 ring-sidebar-ring/35', isDraggingThis && 'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]', headerWorkspaceStatus && @@ -2490,7 +2607,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) : undefined } - onClick={() => toggleGroupWithScrollAnchor(row.key)} + onClick={(event) => { + if ( + projectIdForHeader && + onProjectSelectionGesture(event, projectIdForHeader) + ) { + event.preventDefault() + event.stopPropagation() + return + } + toggleGroupWithScrollAnchor(row.key) + }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() @@ -2498,6 +2625,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } }} > + {projectIdForHeader ? ( + onProjectCheckboxToggle(event, projectIdForHeader)} + /> + ) : null} + {row.icon ? (
0 ? `${paddingLeft}px` : undefined }} > - onContextMenuSelect(event, itemRow.worktree)} - onCardDragStart={handleWorktreeCardDragStart} - onCardDragEnd={clearWorktreeDrag} - hideRepoBadge={groupBy === 'repo'} - lineageChildCount={itemRow.lineageChildCount} - lineageCollapsed={itemRow.lineageCollapsed} - lineageChildren={lineageChildren} - onLineageToggle={ - lineageToggleGroupKey - ? (event) => { - event.preventDefault() - event.stopPropagation() - toggleGroupWithScrollAnchor(lineageToggleGroupKey) - } - : undefined - } - /> +
+ onWorktreeCheckboxToggle(event, itemRow.worktree.id)} + /> +
+ + onContextMenuSelect(event, itemRow.worktree) + } + onCardDragStart={handleWorktreeCardDragStart} + onCardDragEnd={clearWorktreeDrag} + hideRepoBadge={groupBy === 'repo'} + lineageChildCount={itemRow.lineageChildCount} + lineageCollapsed={itemRow.lineageCollapsed} + lineageChildren={lineageChildren} + onLineageToggle={ + lineageToggleGroupKey + ? (event) => { + event.preventDefault() + event.stopPropagation() + toggleGroupWithScrollAnchor(lineageToggleGroupKey) + } + : undefined + } + /> +
+
) } @@ -2892,110 +3042,118 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp selectedWorktrees={selectedWorktrees} onContextMenuSelect={(event) => onContextMenuSelect(event, child.worktree)} > -
event.stopPropagation()} - > - - - -
-
- {child.worktree.displayName} -
-
- {child.repo && groupBy !== 'repo' ? ( - - - - {child.repo.displayName} - - - ) : null} - - {branchDisplayName(child.worktree.branch)} - -
- {child.worktree.linkedIssue || child.worktree.comment ? ( -
- {child.worktree.linkedIssue ? ( - - #{child.worktree.linkedIssue} +
+ onWorktreeCheckboxToggle(event, child.worktree.id)} + /> +
event.stopPropagation()} + > + + + +
+
+ {child.worktree.displayName} +
+
+ {child.repo && groupBy !== 'repo' ? ( + + + + {child.repo.displayName} + ) : null} - {child.worktree.linkedIssue && child.worktree.comment ? ' ' : null} - {child.worktree.comment} + + {branchDisplayName(child.worktree.branch)} +
- ) : null} - {showInlineAgentCards ? ( - // Why: nested lineage children use this lightweight - // renderer instead of WorktreeCard, so their inline - // agent rows must be mounted here explicitly. - - ) : null} - {child.lineageChildCount > 0 && lineageToggleGroupKey ? ( -
- - - - - - {child.lineageCollapsed - ? 'Show child workspaces' - : 'Hide child workspaces'} - - -
- ) : null} + {child.worktree.linkedIssue || child.worktree.comment ? ( +
+ {child.worktree.linkedIssue ? ( + + #{child.worktree.linkedIssue} + + ) : null} + {child.worktree.linkedIssue && child.worktree.comment ? ' ' : null} + {child.worktree.comment} +
+ ) : null} + {showInlineAgentCards ? ( + // Why: nested lineage children use this lightweight + // renderer instead of WorktreeCard, so their inline + // agent rows must be mounted here explicitly. + + ) : null} + {child.lineageChildCount > 0 && lineageToggleGroupKey ? ( +
+ + + + + + {child.lineageCollapsed + ? 'Show child workspaces' + : 'Hide child workspaces'} + + +
+ ) : null} +
@@ -3716,8 +3874,26 @@ const WorktreeList = React.memo(function WorktreeList({ () => renderedWorktrees.map((worktree) => worktree.id), [renderedWorktrees] ) + const renderedProjects = useMemo( + () => + groupBy === 'repo' + ? rows + .filter((row): row is GroupHeaderRow => row.type === 'header' && row.repo != null) + .map((row) => row.repo!) + : [], + [groupBy, rows] + ) + const renderedProjectIds = useMemo( + () => renderedProjects.map((project) => project.id), + [renderedProjects] + ) const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) const [selectionAnchorId, setSelectionAnchorId] = useState(null) + const [worktreeSelectionMode, setWorktreeSelectionMode] = useState<'navigation' | 'bulk'>( + 'navigation' + ) + const [selectedProjectIds, setSelectedProjectIds] = useState>(new Set()) + const [projectSelectionAnchorId, setProjectSelectionAnchorId] = useState(null) const prunedSelection = pruneWorktreeSelection( selectedWorktreeIds, @@ -3732,6 +3908,22 @@ const WorktreeList = React.memo(function WorktreeList({ if (selectionAnchorId !== prunedSelection.anchorId) { setSelectionAnchorId(prunedSelection.anchorId) } + if (worktreeSelectionMode === 'bulk' && prunedSelection.selectedIds.size === 0) { + setWorktreeSelectionMode('navigation') + } + const prunedProjectSelection = pruneWorktreeSelection( + selectedProjectIds, + projectSelectionAnchorId, + renderedProjectIds + ) + // Why: removing or filtering a project header must not leave its checkbox + // selected in the bulk bar after the row disappears. + if (!areWorktreeSelectionsEqual(selectedProjectIds, prunedProjectSelection.selectedIds)) { + setSelectedProjectIds(prunedProjectSelection.selectedIds) + } + if (projectSelectionAnchorId !== prunedProjectSelection.anchorId) { + setProjectSelectionAnchorId(prunedProjectSelection.anchorId) + } const selectedWorktrees = useMemo(() => { if (selectedWorktreeIds.size === 0) { @@ -3739,27 +3931,44 @@ const WorktreeList = React.memo(function WorktreeList({ } return renderedWorktrees.filter((worktree) => selectedWorktreeIds.has(worktree.id)) }, [renderedWorktrees, selectedWorktreeIds]) + const selectedProjects = useMemo(() => { + if (selectedProjectIds.size === 0) { + return [] + } + return renderedProjects.filter((project) => selectedProjectIds.has(project.id)) + }, [renderedProjects, selectedProjectIds]) + const bulkSelectedWorktrees = useMemo( + () => (worktreeSelectionMode === 'bulk' ? selectedWorktrees : []), + [selectedWorktrees, worktreeSelectionMode] + ) useEffect(() => { - if (selectedWorktreeIds.size === 0) { + if (selectedWorktreeIds.size === 0 && selectedProjectIds.size === 0) { return } const clearSelectionOutsideSidebar = (event: PointerEvent): void => { const target = event.target const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') - if (target instanceof Node && sidebarContainer?.contains(target)) { + const bulkSelectionBar = document.querySelector('[data-worktree-sidebar-bulk-selection]') + if ( + target instanceof Node && + (sidebarContainer?.contains(target) || bulkSelectionBar?.contains(target)) + ) { return } setSelectedWorktreeIds(new Set()) setSelectionAnchorId(null) + setWorktreeSelectionMode('navigation') + setSelectedProjectIds(new Set()) + setProjectSelectionAnchorId(null) } document.addEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) return () => { document.removeEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) } - }, [selectedWorktreeIds.size]) + }, [selectedProjectIds.size, selectedWorktreeIds.size]) const updateSelectionForGesture = useCallback( (event: React.MouseEvent, worktreeId: string): boolean => { @@ -3773,6 +3982,11 @@ const WorktreeList = React.memo(function WorktreeList({ }) setSelectedWorktreeIds(result.selectedIds) setSelectionAnchorId(result.anchorId) + setWorktreeSelectionMode(intent === 'replace' ? 'navigation' : 'bulk') + if (result.selectedIds.size > 0) { + setSelectedProjectIds(new Set()) + setProjectSelectionAnchorId(null) + } // Plain click keeps its existing navigation behavior; modifier gestures // are selection-only so users can build a batch without switching away. return intent !== 'replace' @@ -3780,6 +3994,75 @@ const WorktreeList = React.memo(function WorktreeList({ [renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId] ) + const updateWorktreeSelectionForCheckbox = useCallback( + (event: React.MouseEvent, worktreeId: string): void => { + event.preventDefault() + event.stopPropagation() + const result = updateWorktreeSelection({ + visibleIds: renderedWorktreeIds, + previousSelectedIds: selectedWorktreeIds, + previousAnchorId: selectionAnchorId, + targetId: worktreeId, + intent: event.shiftKey ? 'range' : 'toggle' + }) + setSelectedWorktreeIds(result.selectedIds) + setSelectionAnchorId(result.anchorId) + setWorktreeSelectionMode(result.selectedIds.size > 0 ? 'bulk' : 'navigation') + if (result.selectedIds.size > 0) { + setSelectedProjectIds(new Set()) + setProjectSelectionAnchorId(null) + } + }, + [renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId] + ) + + const updateProjectSelectionForGesture = useCallback( + (event: React.MouseEvent, projectId: string): boolean => { + const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) + if (intent === 'replace') { + return false + } + const result = updateWorktreeSelection({ + visibleIds: renderedProjectIds, + previousSelectedIds: selectedProjectIds, + previousAnchorId: projectSelectionAnchorId, + targetId: projectId, + intent + }) + setSelectedProjectIds(result.selectedIds) + setProjectSelectionAnchorId(result.anchorId) + if (result.selectedIds.size > 0) { + setSelectedWorktreeIds(new Set()) + setSelectionAnchorId(null) + setWorktreeSelectionMode('navigation') + } + return true + }, + [projectSelectionAnchorId, renderedProjectIds, selectedProjectIds] + ) + + const updateProjectSelectionForCheckbox = useCallback( + (event: React.MouseEvent, projectId: string): void => { + event.preventDefault() + event.stopPropagation() + const result = updateWorktreeSelection({ + visibleIds: renderedProjectIds, + previousSelectedIds: selectedProjectIds, + previousAnchorId: projectSelectionAnchorId, + targetId: projectId, + intent: event.shiftKey ? 'range' : 'toggle' + }) + setSelectedProjectIds(result.selectedIds) + setProjectSelectionAnchorId(result.anchorId) + if (result.selectedIds.size > 0) { + setSelectedWorktreeIds(new Set()) + setSelectionAnchorId(null) + setWorktreeSelectionMode('navigation') + } + }, + [projectSelectionAnchorId, renderedProjectIds, selectedProjectIds] + ) + const selectForContextMenu = useCallback( (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { @@ -3787,11 +4070,69 @@ const WorktreeList = React.memo(function WorktreeList({ } setSelectedWorktreeIds(new Set([worktree.id])) setSelectionAnchorId(worktree.id) + setWorktreeSelectionMode('navigation') + setSelectedProjectIds(new Set()) + setProjectSelectionAnchorId(null) return [worktree] }, [selectedWorktreeIds, selectedWorktrees] ) + const clearBulkSelection = useCallback(() => { + setSelectedWorktreeIds(new Set()) + setSelectionAnchorId(null) + setWorktreeSelectionMode('navigation') + setSelectedProjectIds(new Set()) + setProjectSelectionAnchorId(null) + }, []) + + const handleDeleteSelectedWorktrees = useCallback(() => { + if (bulkSelectedWorktrees.length === 0) { + return + } + runWorktreeBatchDelete( + bulkSelectedWorktrees.map((worktree) => worktree.id), + { + onDeleted: (deletedIds) => { + const deleted = new Set(deletedIds) + setSelectedWorktreeIds((current) => { + const next = new Set([...current].filter((id) => !deleted.has(id))) + if (next.size === 0) { + setSelectionAnchorId(null) + setWorktreeSelectionMode('navigation') + } + return next + }) + } + } + ) + }, [bulkSelectedWorktrees]) + + const handleRemoveSelectedProjects = useCallback(() => { + if (selectedProjects.length === 0) { + return + } + const [singleProject] = selectedProjects + openModal('confirm-remove-folder', { + ...(selectedProjects.length === 1 && singleProject + ? { repoId: singleProject.id, displayName: singleProject.displayName } + : { + repoIds: selectedProjects.map((project) => project.id), + displayNames: selectedProjects.map((project) => project.displayName) + }), + onRemoved: (removedIds: string[]) => { + const removed = new Set(removedIds) + setSelectedProjectIds((current) => { + const next = new Set([...current].filter((id) => !removed.has(id))) + if (next.size === 0) { + setProjectSelectionAnchorId(null) + } + return next + }) + } + }) + }, [openModal, selectedProjects]) + // Why: full-page navigation views are not scoped to one worktree, so no // sidebar card should appear selected while one of them is active. const selectedSidebarWorktreeId = @@ -4194,6 +4535,13 @@ const WorktreeList = React.memo(function WorktreeList({ }} onConfirm={handleConfirmDeleteProjectGroup} /> +