Add sidebar bulk close selection

This commit is contained in:
Neil
2026-05-31 04:35:03 -07:00
parent b88dc27e15
commit aabcd29d32
3 changed files with 622 additions and 147 deletions
@@ -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<string, unknown>,
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 ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { children: ReactNode }) => (
<button {...props}>{children}</button>
)
}))
vi.mock('@/components/ui/scroll-area', () => ({
ScrollArea: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
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(<RemoveFolderDialog />)
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')
})
})
@@ -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() {
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">Remove Project</DialogTitle>
<DialogTitle className="text-sm">
{isBatchRemove ? 'Remove Projects' : 'Remove Project'}
</DialogTitle>
<DialogDescription className="text-xs">
This only removes{' '}
<span className="break-all font-medium text-foreground">{displayName}</span> from Orca.
It is still on your disk.
{isBatchRemove ? (
<>
This only removes{' '}
<span className="font-medium text-foreground">{repoIds.length} projects</span> from
Orca. Their folders and git worktrees stay on disk.
</>
) : (
<>
This only removes{' '}
<span className="break-all font-medium text-foreground">{displayName}</span> from
Orca. It is still on your disk.
</>
)}
</DialogDescription>
</DialogHeader>
{isBatchRemove ? (
<ScrollArea className="max-h-44 rounded-md border border-border/70 bg-muted/35 text-xs">
<div className="space-y-1 px-3 py-2">
{repoIds.map((id, index) => {
const name = displayNames[index] ?? id
return (
<div
key={id}
className="break-all border-b border-border/50 py-1 font-medium text-foreground last:border-0"
>
{name}
</div>
)
})}
</div>
</ScrollArea>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
@@ -6,6 +6,7 @@ import {
} from '@tanstack/react-virtual'
import type { Range } from '@tanstack/react-virtual'
import {
Check,
ChevronDown,
CircleX,
Ellipsis,
@@ -16,7 +17,8 @@ import {
Shapes,
SlidersHorizontal,
Trash2,
Workflow
Workflow,
X
} from 'lucide-react'
import { useAppStore } from '@/store'
import {
@@ -184,6 +186,7 @@ import {
type WorktreeSectionActivityState,
type WorktreeSectionActivitySummary
} from './worktree-section-activity'
import { runWorktreeBatchDelete } from './delete-worktree-flow'
export {
getScrollTopToRevealBounds,
@@ -297,6 +300,108 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees'
}
function formatSelectedCount(count: number, singular: string): string {
return `${count} ${singular}${count === 1 ? '' : 's'} selected`
}
function SidebarSelectionCheckbox({
checked,
label,
className,
onClick
}: {
checked: boolean
label: string
className?: string
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
}): React.JSX.Element {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={label}
data-checked={checked ? 'true' : 'false'}
onClick={onClick}
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px] border text-[10px] transition-colors',
'border-sidebar-border bg-sidebar text-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring',
'data-[checked=true]:border-sidebar-primary data-[checked=true]:bg-sidebar-primary data-[checked=true]:text-sidebar-primary-foreground',
className
)}
>
{checked ? <Check className="size-3" strokeWidth={3} /> : null}
</button>
)
}
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 (
<div
data-worktree-sidebar-bulk-selection=""
className="mx-2 mb-1 flex min-h-8 items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-accent px-2 py-1 text-[11px] text-sidebar-accent-foreground"
>
<span className="min-w-0 flex-1 truncate font-medium">{summary}</span>
{projectCount > 0 ? (
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 gap-1 px-1.5 text-[11px] hover:bg-sidebar"
onClick={onRemoveProjects}
>
<Trash2 className="size-3" />
Remove
</Button>
) : null}
{worktreeCount > 0 ? (
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 gap-1 px-1.5 text-[11px] text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={onDeleteWorktrees}
>
<Trash2 className="size-3" />
Delete
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6 text-muted-foreground hover:bg-sidebar hover:text-foreground"
aria-label="Clear selection"
onClick={onClear}
>
<X className="size-3.5" />
</Button>
</div>
)
}
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<string>
selectedWorktrees: readonly Worktree[]
selectedProjectIds: ReadonlySet<string>
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onWorktreeCheckboxToggle: (event: React.MouseEvent<HTMLButtonElement>, worktreeId: string) => void
onProjectSelectionGesture: (event: React.MouseEvent<HTMLElement>, projectId: string) => boolean
onProjectCheckboxToggle: (event: React.MouseEvent<HTMLButtonElement>, projectId: string) => void
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
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 ? (
<SidebarSelectionCheckbox
checked={isProjectSelected}
label={`Select project ${row.label}`}
className="mr-0.5"
onClick={(event) => onProjectCheckboxToggle(event, projectIdForHeader)}
/>
) : null}
{row.icon ? (
<div
onPointerDown={
@@ -2823,39 +2959,53 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
paddingLeft: nested && paddingLeft > 0 ? `${paddingLeft}px` : undefined
}}
>
<WorktreeCard
worktree={itemRow.worktree}
repo={itemRow.repo}
isActive={activeWorktreeId === itemRow.worktree.id}
isCurrentWorktree={currentWorktreeId === itemRow.worktree.id}
// Why: a child-active parent should look active without
// running active-card side effects such as SSH reconnect UI.
isActiveSurface={forceActiveSurface || activeWorktreeId === itemRow.worktree.id}
isMultiSelected={selectedWorktreeIds.has(itemRow.worktree.id)}
revealHighlight={highlightedRevealWorktreeId === itemRow.worktree.id}
revealHighlightTone={revealHighlightTone}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={false}
contentIndent={nested ? 0 : paddingLeft}
flushSurface={!nested}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={(event) => 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
}
/>
<div className="flex min-w-0 items-start gap-1">
<SidebarSelectionCheckbox
checked={selectedWorktreeIds.has(itemRow.worktree.id)}
label={`Select workspace ${itemRow.worktree.displayName}`}
className="mt-2"
onClick={(event) => onWorktreeCheckboxToggle(event, itemRow.worktree.id)}
/>
<div className="min-w-0 flex-1">
<WorktreeCard
worktree={itemRow.worktree}
repo={itemRow.repo}
isActive={activeWorktreeId === itemRow.worktree.id}
isCurrentWorktree={currentWorktreeId === itemRow.worktree.id}
// Why: a child-active parent should look active without
// running active-card side effects such as SSH reconnect UI.
isActiveSurface={
forceActiveSurface || activeWorktreeId === itemRow.worktree.id
}
isMultiSelected={selectedWorktreeIds.has(itemRow.worktree.id)}
revealHighlight={highlightedRevealWorktreeId === itemRow.worktree.id}
revealHighlightTone={revealHighlightTone}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={false}
contentIndent={nested ? 0 : paddingLeft}
flushSurface={!nested}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={(event) =>
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
}
/>
</div>
</div>
</div>
)
}
@@ -2892,110 +3042,118 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
selectedWorktrees={selectedWorktrees}
onContextMenuSelect={(event) => onContextMenuSelect(event, child.worktree)}
>
<div
id={getWorktreeOptionId(child.worktree.id)}
role="option"
aria-selected={selectedWorktreeIds.has(child.worktree.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'relative flex cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors',
highlightedRevealWorktreeId === child.worktree.id && [
'scroll-to-current-workspace-reveal-highlight',
revealHighlightTone === 'ai' &&
'scroll-to-current-workspace-reveal-highlight--ai'
],
isActive
? 'border-black/[0.015] bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-border/40 dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: 'worktree-sidebar-card-hover'
)}
data-scroll-reveal-highlight={
highlightedRevealWorktreeId === child.worktree.id ? 'true' : undefined
}
onClick={handleClick}
onDoubleClick={(event) => event.stopPropagation()}
>
<span className="mt-[2px] flex w-4 shrink-0 justify-center pt-[2px]">
<WorktreeActivityStatusIndicator worktreeId={child.worktree.id} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] leading-tight text-foreground">
{child.worktree.displayName}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{child.repo && groupBy !== 'repo' ? (
<span className="flex h-[16px] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 text-[10px] font-semibold leading-none text-foreground dark:bg-accent/50 dark:border-border/60">
<RepoBadgeMark color={child.repo.badgeColor} />
<span className="max-w-[6rem] truncate lowercase">
{child.repo.displayName}
</span>
</span>
) : null}
<span className="truncate text-[10.5px] leading-none text-muted-foreground">
{branchDisplayName(child.worktree.branch)}
</span>
</div>
{child.worktree.linkedIssue || child.worktree.comment ? (
<div className="mt-1.5 truncate text-[10.5px] leading-tight text-muted-foreground">
{child.worktree.linkedIssue ? (
<span className="font-medium text-foreground/80">
#{child.worktree.linkedIssue}
<div className="flex min-w-0 items-start gap-1">
<SidebarSelectionCheckbox
checked={selectedWorktreeIds.has(child.worktree.id)}
label={`Select workspace ${child.worktree.displayName}`}
className="mt-2"
onClick={(event) => onWorktreeCheckboxToggle(event, child.worktree.id)}
/>
<div
id={getWorktreeOptionId(child.worktree.id)}
role="option"
aria-selected={selectedWorktreeIds.has(child.worktree.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'relative flex min-w-0 flex-1 cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors',
highlightedRevealWorktreeId === child.worktree.id && [
'scroll-to-current-workspace-reveal-highlight',
revealHighlightTone === 'ai' &&
'scroll-to-current-workspace-reveal-highlight--ai'
],
isActive
? 'border-black/[0.015] bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-border/40 dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: 'worktree-sidebar-card-hover'
)}
data-scroll-reveal-highlight={
highlightedRevealWorktreeId === child.worktree.id ? 'true' : undefined
}
onClick={handleClick}
onDoubleClick={(event) => event.stopPropagation()}
>
<span className="mt-[2px] flex w-4 shrink-0 justify-center pt-[2px]">
<WorktreeActivityStatusIndicator worktreeId={child.worktree.id} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] leading-tight text-foreground">
{child.worktree.displayName}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{child.repo && groupBy !== 'repo' ? (
<span className="flex h-[16px] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 text-[10px] font-semibold leading-none text-foreground dark:bg-accent/50 dark:border-border/60">
<RepoBadgeMark color={child.repo.badgeColor} />
<span className="max-w-[6rem] truncate lowercase">
{child.repo.displayName}
</span>
</span>
) : null}
{child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
{child.worktree.comment}
<span className="truncate text-[10.5px] leading-none text-muted-foreground">
{branchDisplayName(child.worktree.branch)}
</span>
</div>
) : null}
{showInlineAgentCards ? (
// Why: nested lineage children use this lightweight
// renderer instead of WorktreeCard, so their inline
// agent rows must be mounted here explicitly.
<WorktreeCardAgents
worktreeId={child.worktree.id}
className="mt-1 divide-y-0"
/>
) : null}
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
<div className="mt-1.5 flex min-w-0 justify-start">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="h-[18px] max-w-[8rem] gap-1 rounded-md border border-sidebar-border bg-sidebar px-1.5 text-[10px] font-medium leading-none text-muted-foreground shadow-none hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-sidebar-ring"
aria-label={`${child.lineageCollapsed ? 'Show' : 'Hide'} ${
child.lineageChildCount
} child ${
child.lineageChildCount === 1 ? 'workspace' : 'workspaces'
}`}
aria-expanded={!child.lineageCollapsed}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
}}
>
<Workflow className="size-2.5" />
<span className="truncate">
{child.lineageChildCount}{' '}
{child.lineageChildCount === 1 ? 'child' : 'children'}
</span>
<ChevronDown
className={cn(
'size-2.5 transition-transform',
child.lineageCollapsed && '-rotate-90'
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{child.lineageCollapsed
? 'Show child workspaces'
: 'Hide child workspaces'}
</TooltipContent>
</Tooltip>
</div>
) : null}
{child.worktree.linkedIssue || child.worktree.comment ? (
<div className="mt-1.5 truncate text-[10.5px] leading-tight text-muted-foreground">
{child.worktree.linkedIssue ? (
<span className="font-medium text-foreground/80">
#{child.worktree.linkedIssue}
</span>
) : null}
{child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
{child.worktree.comment}
</div>
) : null}
{showInlineAgentCards ? (
// Why: nested lineage children use this lightweight
// renderer instead of WorktreeCard, so their inline
// agent rows must be mounted here explicitly.
<WorktreeCardAgents
worktreeId={child.worktree.id}
className="mt-1 divide-y-0"
/>
) : null}
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
<div className="mt-1.5 flex min-w-0 justify-start">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="h-[18px] max-w-[8rem] gap-1 rounded-md border border-sidebar-border bg-sidebar px-1.5 text-[10px] font-medium leading-none text-muted-foreground shadow-none hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-sidebar-ring"
aria-label={`${child.lineageCollapsed ? 'Show' : 'Hide'} ${
child.lineageChildCount
} child ${
child.lineageChildCount === 1 ? 'workspace' : 'workspaces'
}`}
aria-expanded={!child.lineageCollapsed}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
}}
>
<Workflow className="size-2.5" />
<span className="truncate">
{child.lineageChildCount}{' '}
{child.lineageChildCount === 1 ? 'child' : 'children'}
</span>
<ChevronDown
className={cn(
'size-2.5 transition-transform',
child.lineageCollapsed && '-rotate-90'
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{child.lineageCollapsed
? 'Show child workspaces'
: 'Hide child workspaces'}
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
</div>
</div>
</WorktreeContextMenu>
@@ -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<Set<string>>(new Set())
const [selectionAnchorId, setSelectionAnchorId] = useState<string | null>(null)
const [worktreeSelectionMode, setWorktreeSelectionMode] = useState<'navigation' | 'bulk'>(
'navigation'
)
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
const [projectSelectionAnchorId, setProjectSelectionAnchorId] = useState<string | null>(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<HTMLElement>, 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<HTMLButtonElement>, 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<HTMLElement>, 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<HTMLButtonElement>, 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<HTMLElement>, 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}
/>
<SidebarBulkSelectionBar
projectCount={selectedProjects.length}
worktreeCount={bulkSelectedWorktrees.length}
onRemoveProjects={handleRemoveSelectedProjects}
onDeleteWorktrees={handleDeleteSelectedWorktrees}
onClear={clearBulkSelection}
/>
<VirtualizedWorktreeViewport
key={viewportResetKey}
rows={rows}
@@ -4222,7 +4570,11 @@ const WorktreeList = React.memo(function WorktreeList({
worktrees={worktrees}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
selectedProjectIds={selectedProjectIds}
onSelectionGesture={updateSelectionForGesture}
onWorktreeCheckboxToggle={updateWorktreeSelectionForCheckbox}
onProjectSelectionGesture={updateProjectSelectionForGesture}
onProjectCheckboxToggle={updateProjectSelectionForCheckbox}
onContextMenuSelect={selectForContextMenu}
repoMap={repoMap}
worktreeMap={worktreeMap}