From 9074ed31b170eee074c8b343d48536c83d2bbc33 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 18:42:19 -0700 Subject: [PATCH] Add workspace board multi-select drag moves (#2010) * Fix workspace board trigger and pinned badge layout * Add workspace board multi-select drag moves * Batch workspace board status drops --- .../src/components/sidebar/SidebarHeader.tsx | 123 ++++++++++-- .../sidebar/WorkspaceKanbanCard.tsx | 73 ++++++- .../sidebar/WorkspaceKanbanDrawer.tsx | 188 +++++++----------- .../sidebar/WorkspaceKanbanDrawerHeader.tsx | 93 +++++++++ .../sidebar/WorkspaceKanbanStatusLane.tsx | 100 ++++++++++ .../src/components/sidebar/WorktreeCard.tsx | 18 +- .../sidebar/WorktreeContextMenu.tsx | 2 +- .../src/components/sidebar/WorktreeList.tsx | 8 +- .../sidebar/use-workspace-kanban-selection.ts | 75 +++++++ .../sidebar/use-workspace-status-drop.ts | 14 +- .../sidebar/workspace-status.test.ts | 57 ++++++ .../components/sidebar/workspace-status.ts | 40 +++- .../src/store/slices/worktree-helpers.ts | 3 + .../src/store/slices/worktrees.test.ts | 28 +++ src/renderer/src/store/slices/worktrees.ts | 28 +++ 15 files changed, 686 insertions(+), 164 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx create mode 100644 src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts create mode 100644 src/renderer/src/components/sidebar/workspace-status.test.ts diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 1cd6b264c..83622aa6a 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Plus, SlidersHorizontal } from 'lucide-react' +import { Kanban, Plus, SlidersHorizontal } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -55,12 +55,18 @@ const WORKSPACE_BOARD_HOVER_OPEN_DELAY_MS = 50 // Why: gives the pointer room to travel from the header into the board before // the temporary hover preview collapses. const WORKSPACE_BOARD_HOVER_CLOSE_DELAY_MS = 220 +type WorkspaceBoardOpenMode = 'closed' | 'hover' | 'persistent' const SidebarHeader = React.memo(function SidebarHeader() { - const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false) - // Why: entering the board turns the hover preview into a persistent drawer - // until the user explicitly closes it or clicks outside. - const workspaceBoardPinnedOpenRef = useRef(false) + const [workspaceBoardOpenMode, setWorkspaceBoardOpenMode] = + useState('closed') + const workspaceBoardOpen = workspaceBoardOpenMode !== 'closed' + const workspaceBoardPersistentOpen = workspaceBoardOpenMode === 'persistent' + // Why: hover-open and manual-open have different close semantics; keeping + // the mode explicit prevents a button click from closing a hover preview. + const workspaceBoardOpenModeRef = useRef('closed') + const workspaceBoardHoverSuppressedRef = useRef(false) + const workspaceHeaderHoveredRef = useRef(false) const workspaceBoardHoverOpenTimerRef = useRef(null) const workspaceBoardHoverCloseTimerRef = useRef(null) const openModal = useAppStore((s) => s.openModal) @@ -100,20 +106,24 @@ const SidebarHeader = React.memo(function SidebarHeader() { [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen] ) - const setWorkspaceBoardPinned = useCallback((pinned: boolean) => { - workspaceBoardPinnedOpenRef.current = pinned + const setWorkspaceBoardMode = useCallback((mode: WorkspaceBoardOpenMode) => { + workspaceBoardOpenModeRef.current = mode + setWorkspaceBoardOpenMode(mode) }, []) const handleWorkspaceBoardOpenChange = useCallback( (open: boolean) => { clearWorkspaceBoardHoverOpen() clearWorkspaceBoardHoverClose() - setWorkspaceBoardOpen(open) - if (!open) { - setWorkspaceBoardPinned(false) + if (open) { + workspaceBoardHoverSuppressedRef.current = false + setWorkspaceBoardMode('persistent') + return } + setWorkspaceBoardMode('closed') + workspaceBoardHoverSuppressedRef.current = workspaceHeaderHoveredRef.current }, - [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardPinned] + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode] ) const handleWorkspaceHeaderPointerEnter = useCallback( @@ -121,35 +131,58 @@ const SidebarHeader = React.memo(function SidebarHeader() { if (event.pointerType !== 'mouse') { return } + workspaceHeaderHoveredRef.current = true clearWorkspaceBoardHoverClose() - if (workspaceBoardOpen) { + if ( + workspaceBoardOpenModeRef.current !== 'closed' || + workspaceBoardHoverSuppressedRef.current + ) { return } clearWorkspaceBoardHoverOpen() workspaceBoardHoverOpenTimerRef.current = window.setTimeout(() => { workspaceBoardHoverOpenTimerRef.current = null - setWorkspaceBoardOpen(true) + if ( + workspaceBoardHoverSuppressedRef.current || + workspaceBoardOpenModeRef.current !== 'closed' + ) { + return + } + setWorkspaceBoardMode('hover') }, WORKSPACE_BOARD_HOVER_OPEN_DELAY_MS) }, - [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, workspaceBoardOpen] + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode] ) const handleWorkspaceHeaderPointerLeave = useCallback( (event: React.PointerEvent) => { + if (event.pointerType === 'mouse') { + const rect = event.currentTarget.getBoundingClientRect() + if ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ) { + return + } + } + workspaceHeaderHoveredRef.current = false + workspaceBoardHoverSuppressedRef.current = false clearWorkspaceBoardHoverOpen() - if (event.pointerType !== 'mouse' || workspaceBoardPinnedOpenRef.current) { + if (event.pointerType !== 'mouse' || workspaceBoardOpenModeRef.current === 'persistent') { return } clearWorkspaceBoardHoverClose() workspaceBoardHoverCloseTimerRef.current = window.setTimeout(() => { workspaceBoardHoverCloseTimerRef.current = null - if (workspaceBoardPinnedOpenRef.current) { + if (workspaceBoardOpenModeRef.current === 'persistent') { return } - setWorkspaceBoardOpen(false) + setWorkspaceBoardMode('closed') }, WORKSPACE_BOARD_HOVER_CLOSE_DELAY_MS) }, - [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen] + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode] ) const handleWorkspaceBoardPointerEnter = useCallback( @@ -159,12 +192,37 @@ const SidebarHeader = React.memo(function SidebarHeader() { } clearWorkspaceBoardHoverOpen() clearWorkspaceBoardHoverClose() - setWorkspaceBoardPinned(true) - setWorkspaceBoardOpen(true) + workspaceBoardHoverSuppressedRef.current = false + setWorkspaceBoardMode('persistent') }, - [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardPinned] + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode] ) + const handleWorkspaceBoardButtonPointerDown = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) { + return + } + clearWorkspaceBoardHoverOpen() + clearWorkspaceBoardHoverClose() + }, + [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen] + ) + + const handleWorkspaceBoardToggle = useCallback(() => { + clearWorkspaceBoardHoverOpen() + clearWorkspaceBoardHoverClose() + + if (workspaceBoardOpenModeRef.current === 'persistent') { + workspaceBoardHoverSuppressedRef.current = workspaceHeaderHoveredRef.current + setWorkspaceBoardMode('closed') + return + } + + workspaceBoardHoverSuppressedRef.current = false + setWorkspaceBoardMode('persistent') + }, [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode]) + return ( <>
Workspaces + + + + + + {workspaceBoardPersistentOpen + ? 'Close workspace board' + : workspaceBoardOpen + ? 'Keep workspace board open' + : 'Workspace board'} + +
diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx index adde53c34..bb78177d4 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -18,16 +18,27 @@ type WorkspaceKanbanCardProps = { worktree: Worktree repo: Repo | undefined isActive: boolean + isSelected: boolean + selectedWorktrees: readonly Worktree[] compact: boolean onActivate: () => void + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] } export default function WorkspaceKanbanCard({ worktree, repo, isActive, + isSelected, + selectedWorktrees, compact, - onActivate + onActivate, + onSelectionGesture, + onContextMenuSelect }: WorkspaceKanbanCardProps): React.JSX.Element { if (compact) { return ( @@ -35,7 +46,11 @@ export default function WorkspaceKanbanCard({ worktree={worktree} repo={repo} isActive={isActive} + isSelected={isSelected} + selectedWorktrees={selectedWorktrees} onActivate={onActivate} + onSelectionGesture={onSelectionGesture} + onContextMenuSelect={onContextMenuSelect} /> ) } @@ -45,13 +60,23 @@ export default function WorkspaceKanbanCard({ {worktree.isPinned ? ( - Pinned ) : null} - + onContextMenuSelect(event, worktree)} + />
) } @@ -60,7 +85,11 @@ function WorkspaceKanbanCompactCard({ worktree, repo, isActive, - onActivate + isSelected, + selectedWorktrees, + onActivate, + onSelectionGesture, + onContextMenuSelect }: Omit): React.JSX.Element { const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const isDeleting = deleteState?.isDeleting ?? false @@ -74,31 +103,55 @@ function WorkspaceKanbanCompactCard({ onActivate() }, [isDeleting, onActivate, worktree.id]) + const handleClick = useCallback( + (event: React.MouseEvent) => { + const selectionOnly = onSelectionGesture(event, worktree.id) + if (selectionOnly) { + event.preventDefault() + event.stopPropagation() + return + } + handleActivate() + }, + [handleActivate, onSelectionGesture, worktree.id] + ) + const handleDragStart = useCallback( (event: React.DragEvent) => { if (isDeleting) { event.preventDefault() return } - writeWorkspaceDragData(event.dataTransfer, worktree.id) + const dragIds = + isSelected && selectedWorktrees.length > 1 + ? selectedWorktrees.map((item) => item.id) + : worktree.id + writeWorkspaceDragData(event.dataTransfer, dragIds) }, - [isDeleting, worktree.id] + [isDeleting, isSelected, selectedWorktrees, worktree.id] ) return ( - + onContextMenuSelect(event, worktree)} + > - - - {workspaceBoardCompact ? 'Show detailed cards' : 'Show compact cards'} - - - - - - - +
{workspaceStatuses.map((status) => { - const meta = getWorkspaceStatusVisualMeta(status) const items = worktreesByStatus.get(status.id) ?? [] - const isDragTarget = dragOverStatus === status.id return ( -
handleDragOver(event, status.id)} + status={status} + items={items} + repoMap={repoMap} + activeWorktreeId={activeWorktreeId} + compact={workspaceBoardCompact} + isDragTarget={dragOverStatus === status.id} + selectedWorktreeIds={selectedWorktreeIds} + selectedWorktrees={selectedWorktrees} + onDragOver={handleDragOver} onDragLeave={handleDragLeave} - onDrop={(event) => handleDrop(event, status.id)} - > -
- -
- {status.label} -
-
- {items.length} -
-
- -
- {items.length > 0 ? ( -
- {items.map((worktree) => ( - - ))} -
- ) : ( -
- Empty -
- )} -
-
+ onDrop={handleDrop} + onActivate={handleWorktreeActivate} + onSelectionGesture={updateSelectionForGesture} + onContextMenuSelect={selectForContextMenu} + /> ) })}
diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx new file mode 100644 index 000000000..10ca50e70 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import { LayoutList, Rows3, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { SheetClose, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' + +type WorkspaceKanbanDrawerHeaderProps = { + selectedCount: number + compact: boolean + opacityPercent: number + workspaceStatuses: readonly WorkspaceStatusDefinition[] + onCompactChange: (compact: boolean) => void + onOpacityChange: (event: React.ChangeEvent) => void + onRenameStatus: (statusId: string, label: string) => void + onChangeStatusColor: (statusId: string, color: string) => void + onChangeStatusIcon: (statusId: string, icon: string) => void + onMoveStatus: (statusId: string, direction: -1 | 1) => void + onRemoveStatus: (statusId: string) => void + onAddStatus: () => void +} + +export default function WorkspaceKanbanDrawerHeader({ + selectedCount, + compact, + opacityPercent, + workspaceStatuses, + onCompactChange, + onOpacityChange, + onRenameStatus, + onChangeStatusColor, + onChangeStatusIcon, + onMoveStatus, + onRemoveStatus, + onAddStatus +}: WorkspaceKanbanDrawerHeaderProps): React.JSX.Element { + const BoardModeIcon = compact ? Rows3 : LayoutList + + return ( + <> + + + Workspace board + {selectedCount > 1 ? ( + + {selectedCount} selected + + ) : null} + + + Organize workspaces by status and open workspace cards. + + + +
+ + + + + + {compact ? 'Show detailed cards' : 'Show compact cards'} + + + + + + +
+ + ) +} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx new file mode 100644 index 000000000..65fe5bae0 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import type { Repo, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types' +import { cn } from '@/lib/utils' +import WorkspaceKanbanCard from './WorkspaceKanbanCard' +import { getWorkspaceStatusVisualMeta } from './workspace-status' + +type WorkspaceKanbanStatusLaneProps = { + status: WorkspaceStatusDefinition + items: readonly Worktree[] + repoMap: Map + activeWorktreeId: string | null + compact: boolean + isDragTarget: boolean + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + onDragOver: (event: React.DragEvent, statusId: string) => void + onDragLeave: (event: React.DragEvent) => void + onDrop: (event: React.DragEvent, statusId: string) => void + onActivate: () => void + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] +} + +export default function WorkspaceKanbanStatusLane({ + status, + items, + repoMap, + activeWorktreeId, + compact, + isDragTarget, + selectedWorktreeIds, + selectedWorktrees, + onDragOver, + onDragLeave, + onDrop, + onActivate, + onSelectionGesture, + onContextMenuSelect +}: WorkspaceKanbanStatusLaneProps): React.JSX.Element { + const meta = getWorkspaceStatusVisualMeta(status) + + return ( +
onDragOver(event, status.id)} + onDragLeave={onDragLeave} + onDrop={(event) => onDrop(event, status.id)} + > +
+ +
+ {status.label} +
+
+ {items.length} +
+
+ +
+ {items.length > 0 ? ( +
+ {items.map((worktree) => { + const isSelected = selectedWorktreeIds.has(worktree.id) + return ( + 0 ? selectedWorktrees : [worktree] + } + compact={compact} + onActivate={onActivate} + onSelectionGesture={onSelectionGesture} + onContextMenuSelect={onContextMenuSelect} + /> + ) + })} +
+ ) : ( +
+ Empty +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 7b73a95ee..702bc3ef3 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -45,6 +45,7 @@ type WorktreeCardProps = { isMultiSelected?: boolean selectedWorktrees?: readonly Worktree[] hideRepoBadge?: boolean + hideCiCheck?: boolean parentLabel?: string lineageState?: 'valid' | 'missing' lineageChildCount?: number @@ -52,8 +53,8 @@ type WorktreeCardProps = { lineageChildren?: React.ReactNode onLineageToggle?: (event: React.MouseEvent) => void onActivate?: () => void - onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean - onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] + onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] } function formatSparseDirectoryPreview(directories: string[]): string { @@ -71,6 +72,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ onSelectionGesture, onContextMenuSelect, hideRepoBadge, + hideCiCheck = false, parentLabel, lineageState, lineageChildCount = 0, @@ -193,7 +195,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const status = useWorktreeActivityStatus(worktree.id) const showPR = cardProps.includes('pr') - const showCI = cardProps.includes('ci') + const showCI = !hideCiCheck && cardProps.includes('ci') const showIssue = cardProps.includes('issue') // Skip hosted-review fetches when the corresponding card sections are hidden. @@ -320,9 +322,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ event.preventDefault() return } - writeWorkspaceDragData(event.dataTransfer, worktree.id) + const dragIds = + isMultiSelected && selectedWorktrees && selectedWorktrees.length > 1 + ? selectedWorktrees.map((item) => item.id) + : worktree.id + writeWorkspaceDragData(event.dataTransfer, dragIds) }, - [isDeleting, worktree.id] + [isDeleting, isMultiSelected, selectedWorktrees, worktree.id] ) // Why: the 'unread' card property is the user's opt-out. When off, we render @@ -483,7 +489,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* CI Checks & PR state on the right */} - {cardProps.includes('ci') && hostedReview && hostedReview.status !== 'neutral' && ( + {showCI && hostedReview && hostedReview.status !== 'neutral' && ( diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 9a4f4c4f7..200adde85 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -45,7 +45,7 @@ type Props = { children: React.ReactNode contentClassName?: string selectedWorktrees?: readonly Worktree[] - onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] + onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[] } const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 503c16edc..1ccd2503a 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -112,9 +112,9 @@ type VirtualizedWorktreeViewportProps = { worktrees: Worktree[] selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] - onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean onContextMenuSelect: ( - event: React.MouseEvent, + event: React.MouseEvent, worktree: Worktree ) => readonly Worktree[] repoMap: Map @@ -1456,7 +1456,7 @@ const WorktreeList = React.memo(function WorktreeList() { }, [selectedWorktreeIds.size]) const updateSelectionForGesture = useCallback( - (event: React.MouseEvent, worktreeId: string): boolean => { + (event: React.MouseEvent, worktreeId: string): boolean => { const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) const result = updateWorktreeSelection({ visibleIds: renderedWorktreeIds, @@ -1475,7 +1475,7 @@ const WorktreeList = React.memo(function WorktreeList() { ) const selectForContextMenu = useCallback( - (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { + (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { return selectedWorktrees } diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts new file mode 100644 index 000000000..75c6a4c64 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts @@ -0,0 +1,75 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import type { Worktree } from '../../../../shared/types' +import { + areWorktreeSelectionsEqual, + getWorktreeSelectionIntent, + pruneWorktreeSelection, + updateWorktreeSelection +} from './worktree-multi-selection' + +export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: readonly Worktree[]) { + const boardWorktreeIds = useMemo( + () => boardWorktrees.map((worktree) => worktree.id), + [boardWorktrees] + ) + const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) + const [selectionAnchorId, setSelectionAnchorId] = useState(null) + const selectedWorktrees = useMemo( + () => boardWorktrees.filter((worktree) => selectedWorktreeIds.has(worktree.id)), + [boardWorktrees, selectedWorktreeIds] + ) + + useEffect(() => { + if (!open) { + setSelectedWorktreeIds(new Set()) + setSelectionAnchorId(null) + return + } + + setSelectedWorktreeIds((previous) => { + const pruned = pruneWorktreeSelection(previous, selectionAnchorId, boardWorktreeIds) + if (pruned.anchorId !== selectionAnchorId) { + setSelectionAnchorId(pruned.anchorId) + } + return areWorktreeSelectionsEqual(previous, pruned.selectedIds) + ? previous + : pruned.selectedIds + }) + }, [boardWorktreeIds, open, selectionAnchorId]) + + const updateSelectionForGesture = useCallback( + (event: React.MouseEvent, worktreeId: string): boolean => { + const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) + const result = updateWorktreeSelection({ + visibleIds: boardWorktreeIds, + previousSelectedIds: selectedWorktreeIds, + previousAnchorId: selectionAnchorId, + targetId: worktreeId, + intent + }) + setSelectedWorktreeIds(result.selectedIds) + setSelectionAnchorId(result.anchorId) + return intent !== 'replace' + }, + [boardWorktreeIds, selectedWorktreeIds, selectionAnchorId] + ) + + const selectForContextMenu = useCallback( + (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { + if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { + return selectedWorktrees + } + setSelectedWorktreeIds(new Set([worktree.id])) + setSelectionAnchorId(worktree.id) + return [worktree] + }, + [selectedWorktreeIds, selectedWorktrees] + ) + + return { + selectedWorktreeIds, + selectedWorktrees, + updateSelectionForGesture, + selectForContextMenu + } +} diff --git a/src/renderer/src/components/sidebar/use-workspace-status-drop.ts b/src/renderer/src/components/sidebar/use-workspace-status-drop.ts index 2dcb4c706..c3dc00823 100644 --- a/src/renderer/src/components/sidebar/use-workspace-status-drop.ts +++ b/src/renderer/src/components/sidebar/use-workspace-status-drop.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type React from 'react' import type { WorkspaceStatus } from '../../../../shared/types' -import { hasWorkspaceDragData, readWorkspaceDragData } from './workspace-status' +import { hasWorkspaceDragData, readWorkspaceDragDataIds } from './workspace-status' const WORKSPACE_STATUS_DROP_TARGET = '[data-workspace-status-drop-target]' const WORKSPACE_PIN_DROP_TARGET = '[data-workspace-pin-drop-target]' @@ -47,8 +47,8 @@ export function useWorkspaceStatusDocumentDrop( return } - const worktreeId = readWorkspaceDragData(dataTransfer) - if (!worktreeId) { + const worktreeIds = readWorkspaceDragDataIds(dataTransfer) + if (worktreeIds.length === 0) { return } @@ -57,13 +57,17 @@ export function useWorkspaceStatusDocumentDrop( event.preventDefault() event.stopPropagation() if (dropTarget === pinTarget) { - onPinWorktree(worktreeId) + for (const worktreeId of worktreeIds) { + onPinWorktree(worktreeId) + } return } const status = dropTarget.dataset.workspaceStatus if (status) { - onMoveWorktreeToStatus(worktreeId, status) + for (const worktreeId of worktreeIds) { + onMoveWorktreeToStatus(worktreeId, status) + } } } diff --git a/src/renderer/src/components/sidebar/workspace-status.test.ts b/src/renderer/src/components/sidebar/workspace-status.test.ts new file mode 100644 index 000000000..539b54bd2 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-status.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + WORKSPACE_STATUS_DRAG_IDS_TYPE, + WORKSPACE_STATUS_DRAG_TYPE, + hasWorkspaceDragData, + readWorkspaceDragData, + readWorkspaceDragDataIds, + writeWorkspaceDragData +} from './workspace-status' + +class TestDataTransfer { + effectAllowed = 'uninitialized' + private readonly values = new Map() + + get types(): string[] { + return [...this.values.keys()] + } + + getData(type: string): string { + return this.values.get(type) ?? '' + } + + setData(type: string, value: string): void { + this.values.set(type, value) + } +} + +describe('workspace status drag data', () => { + it('keeps the legacy single worktree payload when writing a selected batch', () => { + const dataTransfer = new TestDataTransfer() as unknown as DataTransfer + + writeWorkspaceDragData(dataTransfer, ['wt-1', 'wt-2', 'wt-3']) + + expect(dataTransfer.effectAllowed).toBe('move') + expect(dataTransfer.getData(WORKSPACE_STATUS_DRAG_TYPE)).toBe('wt-1') + expect(dataTransfer.getData('text/plain')).toBe('wt-1') + expect(readWorkspaceDragData(dataTransfer)).toBe('wt-1') + }) + + it('round-trips selected worktree ids for board batch drops', () => { + const dataTransfer = new TestDataTransfer() as unknown as DataTransfer + + writeWorkspaceDragData(dataTransfer, ['wt-1', 'wt-2']) + + expect(dataTransfer.getData(WORKSPACE_STATUS_DRAG_IDS_TYPE)).toBe('["wt-1","wt-2"]') + expect(readWorkspaceDragDataIds(dataTransfer)).toEqual(['wt-1', 'wt-2']) + expect(hasWorkspaceDragData(dataTransfer)).toBe(true) + }) + + it('falls back to the single worktree payload for older drag sources', () => { + const dataTransfer = new TestDataTransfer() as unknown as DataTransfer + dataTransfer.setData(WORKSPACE_STATUS_DRAG_TYPE, 'wt-1') + + expect(readWorkspaceDragDataIds(dataTransfer)).toEqual(['wt-1']) + expect(hasWorkspaceDragData(dataTransfer)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-status.ts b/src/renderer/src/components/sidebar/workspace-status.ts index 79f91afc6..f1959c384 100644 --- a/src/renderer/src/components/sidebar/workspace-status.ts +++ b/src/renderer/src/components/sidebar/workspace-status.ts @@ -41,6 +41,7 @@ export { } export const WORKSPACE_STATUS_DRAG_TYPE = 'application/x-orca-worktree-id' +export const WORKSPACE_STATUS_DRAG_IDS_TYPE = 'application/x-orca-worktree-ids' type WorkspaceStatusColorOption = { id: string @@ -212,10 +213,21 @@ export function getWorkspaceStatusVisualMeta(status: WorkspaceStatus | Workspace } } -export function writeWorkspaceDragData(dataTransfer: DataTransfer, worktreeId: string): void { +export function writeWorkspaceDragData( + dataTransfer: DataTransfer, + worktreeIdOrIds: string | readonly string[] +): void { + const worktreeIds = Array.isArray(worktreeIdOrIds) ? worktreeIdOrIds : [worktreeIdOrIds] + const [firstWorktreeId] = worktreeIds + if (!firstWorktreeId) { + return + } dataTransfer.effectAllowed = 'move' - dataTransfer.setData(WORKSPACE_STATUS_DRAG_TYPE, worktreeId) - dataTransfer.setData('text/plain', worktreeId) + // Why: keep the original single-id payload for older drop targets while + // board-to-board drags can move the whole selected batch. + dataTransfer.setData(WORKSPACE_STATUS_DRAG_TYPE, firstWorktreeId) + dataTransfer.setData(WORKSPACE_STATUS_DRAG_IDS_TYPE, JSON.stringify(worktreeIds)) + dataTransfer.setData('text/plain', firstWorktreeId) } export function readWorkspaceDragData(dataTransfer: DataTransfer): string | null { @@ -226,7 +238,27 @@ export function readWorkspaceDragData(dataTransfer: DataTransfer): string | null return dataTransfer.getData('text/plain') || null } +export function readWorkspaceDragDataIds(dataTransfer: DataTransfer): string[] { + const rawIds = dataTransfer.getData(WORKSPACE_STATUS_DRAG_IDS_TYPE) + if (rawIds) { + try { + const parsed: unknown = JSON.parse(rawIds) + if (Array.isArray(parsed)) { + return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0) + } + } catch { + // Fall back to the legacy single-card payload below. + } + } + const singleId = readWorkspaceDragData(dataTransfer) + return singleId ? [singleId] : [] +} + export function hasWorkspaceDragData(dataTransfer: DataTransfer): boolean { const types = Array.from(dataTransfer.types) - return types.includes(WORKSPACE_STATUS_DRAG_TYPE) || types.includes('text/plain') + return ( + types.includes(WORKSPACE_STATUS_DRAG_IDS_TYPE) || + types.includes(WORKSPACE_STATUS_DRAG_TYPE) || + types.includes('text/plain') + ) } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 0b4e2baff..5dcc863c4 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -92,6 +92,9 @@ export type WorktreeSlice = { ) => Promise<{ ok: true } | { ok: false; error: string }> clearWorktreeDeleteState: (worktreeId: string) => void updateWorktreeMeta: (worktreeId: string, updates: Partial) => Promise + updateWorktreesMeta: ( + updatesByWorktreeId: ReadonlyMap> + ) => Promise markWorktreeUnread: (worktreeId: string) => void /** Clear the worktree's unread dot. Called on user interaction with any * terminal pane inside the worktree (keystroke, click) — matches diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index f2eddb900..22ec15d7b 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -1175,6 +1175,34 @@ describe('worktree remote runtime mutations', () => { force: true }) }) + + it('applies batch metadata updates in one store transition', async () => { + const store = createTestStore() + const first = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + const second = makeWorktree({ id: 'repo1::/path/wt2', repoId: 'repo1', path: '/path/wt2' }) + const subscriber = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [first, second] }, + sortEpoch: 7 + } as Partial) + + const unsubscribe = store.subscribe(subscriber) + await store.getState().updateWorktreesMeta( + new Map([ + [first.id, { workspaceStatus: 'in-review' }], + [second.id, { workspaceStatus: 'completed' }] + ]) + ) + unsubscribe() + + expect(store.getState().worktreesByRepo.repo1.map((w) => w.workspaceStatus)).toEqual([ + 'in-review', + 'completed' + ]) + expect(store.getState().sortEpoch).toBe(8) + expect(subscriber).toHaveBeenCalledTimes(1) + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledTimes(2) + }) }) // Why: ghostty "show until interact" model — BEL must raise the sidebar dot diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 75f9d7de5..1b10165f0 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -827,6 +827,34 @@ export const createWorktreeSlice: StateCreator } }, + updateWorktreesMeta: async (updatesByWorktreeId) => { + if (updatesByWorktreeId.size === 0) { + return + } + + set((s) => { + let nextWorktrees = s.worktreesByRepo + for (const [worktreeId, updates] of updatesByWorktreeId) { + nextWorktrees = applyWorktreeUpdates(nextWorktrees, worktreeId, updates) + } + return nextWorktrees === s.worktreesByRepo + ? {} + : { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } + }) + + const settings = get().settings + await Promise.all( + Array.from(updatesByWorktreeId, async ([worktreeId, updates]) => { + try { + await persistWorktreeMeta(settings, worktreeId, updates) + } catch (err) { + console.error('Failed to update worktree meta:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + } + }) + ) + }, + markWorktreeUnread: (worktreeId) => { // Why: BEL must fire regardless of focus (ghostty semantics — "show // until interact"). Interaction with a pane inside the worktree