From 2647ea63c2bef88ebe660ae4abef4e35e34a338e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 31 May 2026 11:35:58 -0700 Subject: [PATCH] Revert sidebar bulk close selection (#4298) --- .../sidebar/RemoveFolderDialog.test.tsx | 65 -- .../components/sidebar/RemoveFolderDialog.tsx | 74 +- .../src/components/sidebar/WorktreeList.tsx | 630 ++++-------------- 3 files changed, 147 insertions(+), 622 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx deleted file mode 100644 index c955feef4eb..00000000000 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 3c5066397a0..74aae142c3a 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback } from 'react' import { Dialog, DialogContent, @@ -8,7 +8,6 @@ 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() { @@ -19,42 +18,14 @@ 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 (repoIds.length > 0) { - void (async () => { - for (const id of repoIds) { - await removeProject(id) - } - onRemoved?.(repoIds) - })() + if (repoId) { + void removeProject(repoId) } closeModal() - }, [closeModal, onRemoved, removeProject, repoIds]) + }, [closeModal, removeProject, repoId]) const handleOpenChange = useCallback( (open: boolean) => { @@ -69,42 +40,13 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { - - {isBatchRemove ? 'Remove Projects' : 'Remove Project'} - + Remove Project - {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. - - )} + 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. @@ -437,11 +332,7 @@ 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 @@ -782,11 +673,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktrees, selectedWorktreeIds, selectedWorktrees, - selectedProjectIds, onSelectionGesture, - onWorktreeCheckboxToggle, - onProjectSelectionGesture, - onProjectCheckboxToggle, onContextMenuSelect, repoMap, worktreeMap, @@ -2550,8 +2437,6 @@ 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 && @@ -2615,8 +2500,6 @@ 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 && @@ -2651,17 +2534,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) : undefined } - onClick={(event) => { - if ( - projectIdForHeader && - onProjectSelectionGesture(event, projectIdForHeader) - ) { - event.preventDefault() - event.stopPropagation() - return - } - toggleGroupWithScrollAnchor(row.key) - }} + onClick={() => toggleGroupWithScrollAnchor(row.key)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() @@ -2669,15 +2542,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } }} > - {projectIdForHeader ? ( - onProjectCheckboxToggle(event, projectIdForHeader)} - /> - ) : null} - {row.icon ? (
0 ? `${paddingLeft}px` : 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 - } - /> -
-
+ 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 + } + />
) } @@ -3086,118 +2936,110 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp selectedWorktrees={selectedWorktrees} onContextMenuSelect={(event) => onContextMenuSelect(event, child.worktree)} > -
- onWorktreeCheckboxToggle(event, child.worktree.id)} - /> -
event.stopPropagation()} - > - - - -
-
- {child.worktree.displayName} -
-
- {child.repo && groupBy !== 'repo' ? ( - - - - {child.repo.displayName} - +
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} ) : null} - - {branchDisplayName(child.worktree.branch)} - + {child.worktree.linkedIssue && child.worktree.comment ? ' ' : null} + {child.worktree.comment}
- {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} -
+ ) : 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}
@@ -3918,26 +3760,8 @@ 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, @@ -3952,22 +3776,6 @@ 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) { @@ -3975,44 +3783,27 @@ 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 && selectedProjectIds.size === 0) { + if (selectedWorktreeIds.size === 0) { return } const clearSelectionOutsideSidebar = (event: PointerEvent): void => { const target = event.target const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') - const bulkSelectionBar = document.querySelector('[data-worktree-sidebar-bulk-selection]') - if ( - target instanceof Node && - (sidebarContainer?.contains(target) || bulkSelectionBar?.contains(target)) - ) { + if (target instanceof Node && sidebarContainer?.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 }) } - }, [selectedProjectIds.size, selectedWorktreeIds.size]) + }, [selectedWorktreeIds.size]) const updateSelectionForGesture = useCallback( (event: React.MouseEvent, worktreeId: string): boolean => { @@ -4026,11 +3817,6 @@ 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' @@ -4038,75 +3824,6 @@ 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) { @@ -4114,69 +3831,11 @@ 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 = @@ -4579,13 +4238,6 @@ const WorktreeList = React.memo(function WorktreeList({ }} onConfirm={handleConfirmDeleteProjectGroup} /> -