Add Kanban marquee workspace selection (#2072)

This commit is contained in:
Neil 2026-05-16 00:36:58 -07:00 committed by GitHub
parent 86192ef53d
commit 33be589fc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 805 additions and 55 deletions

View File

@ -0,0 +1,19 @@
import React from 'react'
type WorkspaceKanbanAreaSelectionOverlayProps = React.HTMLAttributes<HTMLDivElement>
const WorkspaceKanbanAreaSelectionOverlay = React.forwardRef<
HTMLDivElement,
WorkspaceKanbanAreaSelectionOverlayProps
>(function WorkspaceKanbanAreaSelectionOverlay(props, ref): React.JSX.Element {
return (
<div
{...props}
ref={ref}
data-workspace-board-selection-rect=""
className="pointer-events-none absolute left-0 top-0 z-30 hidden rounded-md border border-sidebar-ring bg-sidebar-ring/15 will-change-transform"
/>
)
})
export default WorkspaceKanbanAreaSelectionOverlay

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'
import React, { useCallback, useMemo } from 'react'
import { Pin } from 'lucide-react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
@ -19,7 +19,7 @@ type WorkspaceKanbanCardProps = {
repo: Repo | undefined
isActive: boolean
isSelected: boolean
selectedWorktrees: readonly Worktree[]
selectedWorktrees?: readonly Worktree[]
compact: boolean
onActivate: () => void
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
@ -29,7 +29,7 @@ type WorkspaceKanbanCardProps = {
) => readonly Worktree[]
}
export default function WorkspaceKanbanCard({
function WorkspaceKanbanCard({
worktree,
repo,
isActive,
@ -55,8 +55,16 @@ export default function WorkspaceKanbanCard({
)
}
const contextWorktrees =
isSelected && selectedWorktrees && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
return (
<div className="relative" data-workspace-board-card-mode="detailed">
<div
className="relative rounded-lg data-[workspace-board-card-area-selected=true]:ring-1 data-[workspace-board-card-area-selected=true]:ring-sidebar-ring/40"
data-workspace-board-card-id={worktree.id}
data-workspace-board-card-mode="detailed"
data-workspace-board-card-selected={isSelected ? 'true' : 'false'}
>
{worktree.isPinned ? (
<Badge
variant="outline"
@ -71,7 +79,7 @@ export default function WorkspaceKanbanCard({
repo={repo}
isActive={isActive}
isMultiSelected={isSelected}
selectedWorktrees={selectedWorktrees}
selectedWorktrees={contextWorktrees}
hideCiCheck={worktree.isPinned}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
@ -81,6 +89,8 @@ export default function WorkspaceKanbanCard({
)
}
export default React.memo(WorkspaceKanbanCard)
function WorkspaceKanbanCompactCard({
worktree,
repo,
@ -94,6 +104,13 @@ function WorkspaceKanbanCompactCard({
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const isDeleting = deleteState?.isDeleting ?? false
const status = useWorktreeActivityStatus(worktree.id)
const contextWorktrees = useMemo(
() =>
isSelected && selectedWorktrees && selectedWorktrees.length > 0
? selectedWorktrees
: [worktree],
[isSelected, selectedWorktrees, worktree]
)
const handleActivate = useCallback(() => {
if (isDeleting) {
@ -123,18 +140,18 @@ function WorkspaceKanbanCompactCard({
return
}
const dragIds =
isSelected && selectedWorktrees.length > 1
? selectedWorktrees.map((item) => item.id)
isSelected && contextWorktrees.length > 1
? contextWorktrees.map((item) => item.id)
: worktree.id
writeWorkspaceDragData(event.dataTransfer, dragIds)
},
[isDeleting, isSelected, selectedWorktrees, worktree.id]
[contextWorktrees, isDeleting, isSelected, worktree.id]
)
return (
<WorktreeContextMenu
worktree={worktree}
selectedWorktrees={selectedWorktrees}
selectedWorktrees={contextWorktrees}
onContextMenuSelect={(event) => onContextMenuSelect(event, worktree)}
>
<HoverCard openDelay={450} closeDelay={100}>
@ -152,9 +169,12 @@ function WorkspaceKanbanCompactCard({
? 'border-sidebar-ring/50 bg-sidebar-accent/75 text-foreground ring-1 ring-sidebar-ring/30'
: 'border-transparent text-foreground hover:bg-sidebar-accent/60 focus-visible:border-sidebar-ring',
isActive && isSelected && 'ring-1 ring-sidebar-ring/35',
'data-[workspace-board-card-area-selected=true]:border-sidebar-ring/50 data-[workspace-board-card-area-selected=true]:bg-sidebar-accent/75 data-[workspace-board-card-area-selected=true]:ring-1 data-[workspace-board-card-area-selected=true]:ring-sidebar-ring/30',
isDeleting && 'cursor-not-allowed opacity-50 grayscale'
)}
data-workspace-board-card-mode="compact"
data-workspace-board-card-id={worktree.id}
data-workspace-board-card-selected={isSelected ? 'true' : 'false'}
aria-label={`Open ${worktree.displayName}`}
aria-busy={isDeleting}
>

View File

@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { useAllWorktrees, useRepoMap } from '@/store/selectors'
import { cn } from '@/lib/utils'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Pin } from 'lucide-react'
import WorkspaceKanbanAreaSelectionOverlay from './WorkspaceKanbanAreaSelectionOverlay'
import WorkspaceKanbanDrawerHeader from './WorkspaceKanbanDrawerHeader'
import WorkspaceKanbanPinDropTarget from './WorkspaceKanbanPinDropTarget'
import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane'
import {
getWorkspaceStatus,
@ -12,6 +12,7 @@ import {
readWorkspaceDragDataIds
} from './workspace-status'
import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop'
import { useWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection'
import { useWorkspaceKanbanSelection } from './use-workspace-kanban-selection'
import type { WorkspaceStatus, Worktree } from '../../../../shared/types'
import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
@ -43,6 +44,7 @@ export default function WorkspaceKanbanDrawer({
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
const boardRef = useRef<HTMLDivElement>(null)
const areaSelectionOverlayRef = useRef<HTMLDivElement>(null)
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
const [pinDragOver, setPinDragOver] = useState(false)
@ -61,6 +63,10 @@ export default function WorkspaceKanbanDrawer({
}
return grouped
}, [allWorktrees, workspaceStatuses])
const worktreeById = useMemo(
() => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])),
[allWorktrees]
)
const boardWorktrees = useMemo(
() => workspaceStatuses.flatMap((status) => worktreesByStatus.get(status.id) ?? []),
@ -69,26 +75,36 @@ export default function WorkspaceKanbanDrawer({
const {
selectedWorktreeIds,
selectedWorktrees,
selectionAnchorId,
updateSelectionForGesture,
updateSelectionForArea,
selectForContextMenu
} = useWorkspaceKanbanSelection(open, boardWorktrees)
const { handleAreaSelectionPointerDown } = useWorkspaceKanbanAreaSelection({
open,
boardRef,
overlayRef: areaSelectionOverlayRef,
selectedWorktreeIds,
selectionAnchorId,
updateSelectionForArea
})
const moveWorktreeToStatus = useCallback(
(worktreeId: string, status: WorkspaceStatus) => {
const current = allWorktrees.find((worktree) => worktree.id === worktreeId)
const current = worktreeById.get(worktreeId)
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
return
}
void updateWorktreeMeta(worktreeId, { workspaceStatus: status })
},
[allWorktrees, updateWorktreeMeta, workspaceStatuses]
[updateWorktreeMeta, workspaceStatuses, worktreeById]
)
const moveWorktreesToStatus = useCallback(
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
const updates = new Map<string, { workspaceStatus: WorkspaceStatus }>()
for (const worktreeId of worktreeIds) {
const current = allWorktrees.find((worktree) => worktree.id === worktreeId)
const current = worktreeById.get(worktreeId)
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
continue
}
@ -98,18 +114,35 @@ export default function WorkspaceKanbanDrawer({
void updateWorktreesMeta(updates)
}
},
[allWorktrees, updateWorktreesMeta, workspaceStatuses]
[updateWorktreesMeta, workspaceStatuses, worktreeById]
)
const pinWorktree = useCallback(
(worktreeId: string) => {
const current = allWorktrees.find((worktree) => worktree.id === worktreeId)
const current = worktreeById.get(worktreeId)
if (!current || current.isPinned) {
return
}
void updateWorktreeMeta(worktreeId, { isPinned: true })
},
[allWorktrees, updateWorktreeMeta]
[updateWorktreeMeta, worktreeById]
)
const pinWorktrees = useCallback(
(worktreeIds: readonly string[]) => {
const updates = new Map<string, { isPinned: true }>()
for (const worktreeId of worktreeIds) {
const current = worktreeById.get(worktreeId)
if (!current || current.isPinned) {
continue
}
updates.set(worktreeId, { isPinned: true })
}
if (updates.size > 0) {
void updateWorktreesMeta(updates)
}
},
[updateWorktreesMeta, worktreeById]
)
const handleDragOver = useCallback((event: React.DragEvent, status: WorkspaceStatus) => {
@ -257,7 +290,11 @@ export default function WorkspaceKanbanDrawer({
moveWorktreeToStatus,
pinWorktree,
handleDragFinish,
open
open,
{
onMoveWorktreesToStatus: moveWorktreesToStatus,
onPinWorktrees: pinWorktrees
}
)
useEffect(() => {
@ -337,22 +374,18 @@ export default function WorkspaceKanbanDrawer({
onRemoveStatus={handleRemoveStatus}
onAddStatus={handleAddStatus}
/>
<div ref={boardRef} className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
<div
data-workspace-pin-drop-target=""
className={cn(
'mb-3 flex h-8 shrink-0 items-center gap-2 rounded-md border border-dashed border-sidebar-border bg-background/45 px-3 text-[12px] text-muted-foreground transition-colors',
pinDragOver && 'border-sidebar-ring bg-sidebar-accent text-foreground'
)}
<div
ref={boardRef}
className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-3"
data-workspace-board-selection-surface=""
onPointerDown={handleAreaSelectionPointerDown}
>
<WorkspaceKanbanAreaSelectionOverlay ref={areaSelectionOverlayRef} />
<WorkspaceKanbanPinDropTarget
isDragOver={pinDragOver}
onDragOver={handlePinDragOver}
onDragLeave={handlePinDragLeave}
>
<Pin className="size-3.5" />
<span className="font-medium">Pinned</span>
<span className="truncate">Drop here to pin without changing status.</span>
</div>
/>
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-sleek">
<div
className="grid h-full min-h-0 min-w-full grid-rows-[minmax(0,1fr)] gap-3"

View File

@ -0,0 +1,31 @@
import React from 'react'
import { Pin } from 'lucide-react'
import { cn } from '@/lib/utils'
type WorkspaceKanbanPinDropTargetProps = {
isDragOver: boolean
onDragOver: (event: React.DragEvent) => void
onDragLeave: (event: React.DragEvent) => void
}
export default function WorkspaceKanbanPinDropTarget({
isDragOver,
onDragOver,
onDragLeave
}: WorkspaceKanbanPinDropTargetProps): React.JSX.Element {
return (
<div
data-workspace-pin-drop-target=""
className={cn(
'mb-3 flex h-8 shrink-0 items-center gap-2 rounded-md border border-dashed border-sidebar-border bg-background/45 px-3 text-[12px] text-muted-foreground transition-colors',
isDragOver && 'border-sidebar-ring bg-sidebar-accent text-foreground'
)}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<Pin className="size-3.5" />
<span className="font-medium">Pinned</span>
<span className="truncate">Drop here to pin without changing status.</span>
</div>
)
}

View File

@ -79,7 +79,7 @@ export default function WorkspaceKanbanStatusLane({
isActive={activeWorktreeId === worktree.id}
isSelected={isSelected}
selectedWorktrees={
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : [worktree]
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
}
compact={compact}
onActivate={onActivate}

View File

@ -413,7 +413,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={deletingContext}>
<Kanban className="size-3.5" />
{isMultiContext ? 'Move Selected to Status' : 'Move to Status'}
{isMultiContext ? 'Move Statuses To' : 'Move to Status'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-44">
<DropdownMenuRadioGroup value={contextWorkspaceStatus}>

View File

@ -45,7 +45,7 @@ import {
getWorkspaceStatus,
getWorkspaceStatusFromGroupKey,
hasWorkspaceDragData,
readWorkspaceDragData
readWorkspaceDragDataIds
} from './workspace-status'
import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop'
import {
@ -143,7 +143,9 @@ type VirtualizedWorktreeViewportProps = {
prCache: Record<string, unknown> | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void
onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onPinWorktree: (worktreeId: string) => void
onPinWorktrees: (worktreeIds: readonly string[]) => void
// Why: broad grouping changes still remount the viewport, while add/delete
// stays mounted for row-key anchoring and layout animation. These refs bridge
// both paths so the virtualizer never falls back to scrollTop 0.
@ -256,7 +258,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
prCache,
workspaceStatuses,
onMoveWorktreeToStatus,
onMoveWorktreesToStatus,
onPinWorktree,
onPinWorktrees,
scrollOffsetRef,
scrollAnchorRef
}: VirtualizedWorktreeViewportProps) {
@ -684,15 +688,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const handleWorkspaceStatusDrop = useCallback(
(event: React.DragEvent, status: WorkspaceStatus) => {
const worktreeId = readWorkspaceDragData(event.dataTransfer)
if (!worktreeId) {
const worktreeIds = readWorkspaceDragDataIds(event.dataTransfer)
if (worktreeIds.length === 0) {
return
}
event.preventDefault()
setDragOverStatus(null)
onMoveWorktreeToStatus(worktreeId, status)
onMoveWorktreesToStatus(worktreeIds, status)
},
[onMoveWorktreeToStatus]
[onMoveWorktreesToStatus]
)
useWorkspaceStatusDocumentDrop(
@ -700,7 +704,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onMoveWorktreeToStatus,
onPinWorktree,
handleWorkspaceStatusDragFinish,
hasWorkspaceDropTargets
hasWorkspaceDropTargets,
{
onMoveWorktreesToStatus,
onPinWorktrees
}
)
return (
@ -1148,6 +1156,7 @@ const WorktreeList = React.memo(function WorktreeList({
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const openModal = useAppStore((s) => s.openModal)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta)
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktreeId = useAppStore((s) => s.pendingRevealWorktreeId)
@ -1614,6 +1623,23 @@ const WorktreeList = React.memo(function WorktreeList({
[updateWorktreeMeta, worktreeMap, workspaceStatuses]
)
const moveWorktreesToStatus = useCallback(
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
const updates = new Map<string, { workspaceStatus: WorkspaceStatus }>()
for (const worktreeId of worktreeIds) {
const current = worktreeMap.get(worktreeId)
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
continue
}
updates.set(worktreeId, { workspaceStatus: status })
}
if (updates.size > 0) {
void updateWorktreesMeta(updates)
}
},
[updateWorktreesMeta, worktreeMap, workspaceStatuses]
)
const pinWorktree = useCallback(
(worktreeId: string) => {
const current = worktreeMap.get(worktreeId)
@ -1625,6 +1651,23 @@ const WorktreeList = React.memo(function WorktreeList({
[updateWorktreeMeta, worktreeMap]
)
const pinWorktrees = useCallback(
(worktreeIds: readonly string[]) => {
const updates = new Map<string, { isPinned: true }>()
for (const worktreeId of worktreeIds) {
const current = worktreeMap.get(worktreeId)
if (!current || current.isPinned) {
continue
}
updates.set(worktreeId, { isPinned: true })
}
if (updates.size > 0) {
void updateWorktreesMeta(updates)
}
},
[updateWorktreesMeta, worktreeMap]
)
// Why: hideDefaultBranchWorkspace is counted as a filter here so the
// empty-sidebar escape hatch (Clear Filters button below) is reachable when
// it's the only reason the list is empty — otherwise a user whose only
@ -1702,7 +1745,9 @@ const WorktreeList = React.memo(function WorktreeList({
prCache={prCache}
workspaceStatuses={workspaceStatuses}
onMoveWorktreeToStatus={moveWorktreeToStatus}
onMoveWorktreesToStatus={moveWorktreesToStatus}
onPinWorktree={pinWorktree}
onPinWorktrees={pinWorktrees}
scrollOffsetRef={scrollOffsetRef}
scrollAnchorRef={scrollAnchorRef}
/>

View File

@ -0,0 +1,249 @@
import React, { useCallback, useEffect, useRef } from 'react'
import {
clearPreviewSelection,
getAreaSelectionCardIds,
getAreaSelectionCardRects,
getAreaSelectionRect,
isScrollbarPointerDown,
setOverlayRect,
shouldIgnoreAreaSelectionStart,
updatePreviewSelection,
type AreaSelectionCardRect
} from './workspace-kanban-area-selection-dom'
type AreaSelectionDragState = {
startX: number
startY: number
currentX: number
currentY: number
additive: boolean
baseSelectedIds: Set<string>
baseAnchorId: string | null
boardRect: DOMRect
cardRects: readonly AreaSelectionCardRect[]
previewIds: Set<string>
finalAreaIds: string[]
started: boolean
frameId: number | null
}
type UpdateSelectionForArea = (
areaIds: readonly string[],
additive: boolean,
baseSelectedIds?: ReadonlySet<string>,
baseAnchorId?: string | null
) => void
type UseWorkspaceKanbanAreaSelectionParams = {
open: boolean
boardRef: React.RefObject<HTMLDivElement | null>
overlayRef: React.RefObject<HTMLDivElement | null>
selectedWorktreeIds: ReadonlySet<string>
selectionAnchorId: string | null
updateSelectionForArea: UpdateSelectionForArea
}
const AREA_SELECTION_DRAG_THRESHOLD = 4
export function useWorkspaceKanbanAreaSelection({
open,
boardRef,
overlayRef,
selectedWorktreeIds,
selectionAnchorId,
updateSelectionForArea
}: UseWorkspaceKanbanAreaSelectionParams): {
handleAreaSelectionPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
} {
const dragRef = useRef<AreaSelectionDragState | null>(null)
const updateSelectionForAreaRef = useRef(updateSelectionForArea)
useEffect(() => {
updateSelectionForAreaRef.current = updateSelectionForArea
}, [updateSelectionForArea])
const cancelAreaSelectionDrag = useCallback(() => {
const state = dragRef.current
if (state?.frameId !== null && state?.frameId !== undefined) {
window.cancelAnimationFrame(state.frameId)
}
if (state) {
clearPreviewSelection(state.cardRects, state.previewIds)
}
dragRef.current = null
setOverlayRect(overlayRef.current, null)
}, [overlayRef])
const flushAreaSelectionDrag = useCallback(() => {
const state = dragRef.current
if (!state) {
return
}
state.frameId = null
const deltaX = state.currentX - state.startX
const deltaY = state.currentY - state.startY
if (!state.started && Math.hypot(deltaX, deltaY) < AREA_SELECTION_DRAG_THRESHOLD) {
return
}
state.started = true
const viewportRect = getAreaSelectionRect(
state.startX,
state.startY,
state.currentX,
state.currentY
)
const clippedLeft = Math.max(viewportRect.left, state.boardRect.left)
const clippedTop = Math.max(viewportRect.top, state.boardRect.top)
const clippedRight = Math.min(viewportRect.left + viewportRect.width, state.boardRect.right)
const clippedBottom = Math.min(viewportRect.top + viewportRect.height, state.boardRect.bottom)
if (clippedRight <= clippedLeft || clippedBottom <= clippedTop) {
state.finalAreaIds = []
setOverlayRect(overlayRef.current, null)
updatePreviewSelection(
state.cardRects,
state.previewIds,
state.baseSelectedIds,
state.additive,
[]
)
return
}
setOverlayRect(overlayRef.current, {
left: clippedLeft - state.boardRect.left,
top: clippedTop - state.boardRect.top,
width: clippedRight - clippedLeft,
height: clippedBottom - clippedTop
})
const areaIds = getAreaSelectionCardIds(state.cardRects, viewportRect)
state.finalAreaIds = areaIds
updatePreviewSelection(
state.cardRects,
state.previewIds,
state.baseSelectedIds,
state.additive,
areaIds
)
}, [overlayRef])
const scheduleAreaSelectionDragFlush = useCallback(() => {
const state = dragRef.current
if (!state || state.frameId !== null) {
return
}
// Why: the hot path stays imperative and frame-throttled so a Notion-like
// marquee drag does not re-render every workspace card on pointermove.
state.frameId = window.requestAnimationFrame(flushAreaSelectionDrag)
}, [flushAreaSelectionDrag])
const finishAreaSelectionDrag = useCallback(
(event: PointerEvent) => {
const state = dragRef.current
if (!state) {
return
}
state.currentX = event.clientX
state.currentY = event.clientY
if (state.frameId !== null) {
window.cancelAnimationFrame(state.frameId)
state.frameId = null
}
flushAreaSelectionDrag()
if (state.started) {
updateSelectionForAreaRef.current(
state.finalAreaIds,
state.additive,
state.baseSelectedIds,
state.baseAnchorId
)
}
clearPreviewSelection(state.cardRects, state.previewIds)
dragRef.current = null
setOverlayRect(overlayRef.current, null)
},
[flushAreaSelectionDrag, overlayRef]
)
const handleAreaSelectionPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (
event.button !== 0 ||
event.pointerType === 'touch' ||
isScrollbarPointerDown(event.nativeEvent) ||
shouldIgnoreAreaSelectionStart(event.target)
) {
return
}
const board = boardRef.current
if (!board) {
return
}
cancelAreaSelectionDrag()
const isMac = navigator.userAgent.includes('Mac')
const additive =
event.shiftKey ||
(isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey)
dragRef.current = {
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
currentY: event.clientY,
additive,
baseSelectedIds: new Set(selectedWorktreeIds),
baseAnchorId: selectionAnchorId,
boardRect: board.getBoundingClientRect(),
cardRects: getAreaSelectionCardRects(board),
previewIds: new Set(),
finalAreaIds: [],
started: false,
frameId: null
}
event.preventDefault()
},
[boardRef, cancelAreaSelectionDrag, selectedWorktreeIds, selectionAnchorId]
)
useEffect(() => {
if (!open) {
cancelAreaSelectionDrag()
return
}
const handlePointerMove = (event: PointerEvent): void => {
const state = dragRef.current
if (!state) {
return
}
state.currentX = event.clientX
state.currentY = event.clientY
event.preventDefault()
scheduleAreaSelectionDragFlush()
}
const handlePointerUp = (event: PointerEvent): void => {
if (!dragRef.current) {
return
}
event.preventDefault()
finishAreaSelectionDrag(event)
}
document.addEventListener('pointermove', handlePointerMove, true)
document.addEventListener('pointerup', handlePointerUp, true)
document.addEventListener('pointercancel', handlePointerUp, true)
return () => {
document.removeEventListener('pointermove', handlePointerMove, true)
document.removeEventListener('pointerup', handlePointerUp, true)
document.removeEventListener('pointercancel', handlePointerUp, true)
cancelAreaSelectionDrag()
}
}, [cancelAreaSelectionDrag, finishAreaSelectionDrag, open, scheduleAreaSelectionDragFlush])
return { handleAreaSelectionPointerDown }
}

View File

@ -4,6 +4,7 @@ import {
areWorktreeSelectionsEqual,
getWorktreeSelectionIntent,
pruneWorktreeSelection,
updateWorktreeAreaSelection,
updateWorktreeSelection
} from './worktree-multi-selection'
@ -66,10 +67,36 @@ export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: reado
[selectedWorktreeIds, selectedWorktrees]
)
const updateSelectionForArea = useCallback(
(
areaIds: readonly string[],
additive: boolean,
baseSelectedIds: ReadonlySet<string> = selectedWorktreeIds,
baseAnchorId: string | null = selectionAnchorId
): void => {
const result = updateWorktreeAreaSelection({
visibleIds: boardWorktreeIds,
previousSelectedIds: baseSelectedIds,
previousAnchorId: baseAnchorId,
areaIds,
additive
})
setSelectedWorktreeIds((previous) =>
areWorktreeSelectionsEqual(previous, result.selectedIds) ? previous : result.selectedIds
)
setSelectionAnchorId((previous) =>
previous === result.anchorId ? previous : result.anchorId
)
},
[boardWorktreeIds, selectedWorktreeIds, selectionAnchorId]
)
return {
selectedWorktreeIds,
selectedWorktrees,
selectionAnchorId,
updateSelectionForGesture,
updateSelectionForArea,
selectForContextMenu
}
}

View File

@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { commitWorkspaceStatusDocumentDrop } from './use-workspace-status-drop'
describe('workspace status document drop', () => {
it('commits multi-worktree status drops through the batched callback once', () => {
const moveOne = vi.fn()
const moveMany = vi.fn()
const pinOne = vi.fn()
commitWorkspaceStatusDocumentDrop({
worktreeIds: ['wt-1', 'wt-2', 'wt-3'],
status: 'in-review',
isPinDrop: false,
onMoveWorktreeToStatus: moveOne,
onMoveWorktreesToStatus: moveMany,
onPinWorktree: pinOne
})
expect(moveMany).toHaveBeenCalledWith(['wt-1', 'wt-2', 'wt-3'], 'in-review')
expect(moveMany).toHaveBeenCalledTimes(1)
expect(moveOne).not.toHaveBeenCalled()
expect(pinOne).not.toHaveBeenCalled()
})
it('commits multi-worktree pin drops through the batched callback once', () => {
const moveOne = vi.fn()
const pinOne = vi.fn()
const pinMany = vi.fn()
commitWorkspaceStatusDocumentDrop({
worktreeIds: ['wt-1', 'wt-2'],
status: null,
isPinDrop: true,
onMoveWorktreeToStatus: moveOne,
onPinWorktree: pinOne,
onPinWorktrees: pinMany
})
expect(pinMany).toHaveBeenCalledWith(['wt-1', 'wt-2'])
expect(pinMany).toHaveBeenCalledTimes(1)
expect(pinOne).not.toHaveBeenCalled()
expect(moveOne).not.toHaveBeenCalled()
})
})

View File

@ -7,15 +7,69 @@ const WORKSPACE_STATUS_DROP_TARGET = '[data-workspace-status-drop-target]'
const WORKSPACE_PIN_DROP_TARGET = '[data-workspace-pin-drop-target]'
type MoveWorktreeToStatus = (worktreeId: string, status: WorkspaceStatus) => void
type MoveWorktreesToStatus = (worktreeIds: readonly string[], status: WorkspaceStatus) => void
type PinWorktree = (worktreeId: string) => void
type PinWorktrees = (worktreeIds: readonly string[]) => void
type WorkspaceStatusDocumentDropOptions = {
onMoveWorktreesToStatus?: MoveWorktreesToStatus
onPinWorktrees?: PinWorktrees
}
export function commitWorkspaceStatusDocumentDrop(params: {
worktreeIds: readonly string[]
status: WorkspaceStatus | null
isPinDrop: boolean
onMoveWorktreeToStatus: MoveWorktreeToStatus
onMoveWorktreesToStatus?: MoveWorktreesToStatus
onPinWorktree: PinWorktree
onPinWorktrees?: PinWorktrees
}): void {
const {
worktreeIds,
status,
isPinDrop,
onMoveWorktreeToStatus,
onMoveWorktreesToStatus,
onPinWorktree,
onPinWorktrees
} = params
if (isPinDrop) {
if (onPinWorktrees) {
onPinWorktrees(worktreeIds)
return
}
for (const worktreeId of worktreeIds) {
onPinWorktree(worktreeId)
}
return
}
if (!status) {
return
}
if (onMoveWorktreesToStatus) {
onMoveWorktreesToStatus(worktreeIds, status)
return
}
for (const worktreeId of worktreeIds) {
onMoveWorktreeToStatus(worktreeId, status)
}
}
export function useWorkspaceStatusDocumentDrop<T extends HTMLElement>(
containerRef: React.RefObject<T | null>,
onMoveWorktreeToStatus: MoveWorktreeToStatus,
onPinWorktree: PinWorktree,
onDragFinish: () => void,
enabled = true
enabled = true,
options?: WorkspaceStatusDocumentDropOptions
): void {
const { onMoveWorktreesToStatus, onPinWorktrees } = options ?? {}
useEffect(() => {
if (!enabled) {
return
@ -56,19 +110,15 @@ export function useWorkspaceStatusDocumentDrop<T extends HTMLElement>(
// them, so board drops commit from this scoped capture listener.
event.preventDefault()
event.stopPropagation()
if (dropTarget === pinTarget) {
for (const worktreeId of worktreeIds) {
onPinWorktree(worktreeId)
}
return
}
const status = dropTarget.dataset.workspaceStatus
if (status) {
for (const worktreeId of worktreeIds) {
onMoveWorktreeToStatus(worktreeId, status)
}
}
commitWorkspaceStatusDocumentDrop({
worktreeIds,
status: dropTarget.dataset.workspaceStatus ?? null,
isPinDrop: dropTarget === pinTarget,
onMoveWorktreeToStatus,
onMoveWorktreesToStatus,
onPinWorktree,
onPinWorktrees
})
}
const handleDragFinish = (): void => {
@ -81,5 +131,13 @@ export function useWorkspaceStatusDocumentDrop<T extends HTMLElement>(
document.removeEventListener('drop', handleDrop, true)
document.removeEventListener('dragend', handleDragFinish, true)
}
}, [containerRef, enabled, onDragFinish, onMoveWorktreeToStatus, onPinWorktree])
}, [
containerRef,
enabled,
onDragFinish,
onMoveWorktreeToStatus,
onMoveWorktreesToStatus,
onPinWorktree,
onPinWorktrees
])
}

View File

@ -0,0 +1,151 @@
export type AreaSelectionRect = {
left: number
top: number
width: number
height: number
}
export type AreaSelectionCardRect = {
id: string
element: HTMLElement
rect: DOMRect
}
const AREA_SELECTED_ATTR = 'data-workspace-board-card-area-selected'
export function getAreaSelectionRect(
startX: number,
startY: number,
currentX: number,
currentY: number
): AreaSelectionRect {
const left = Math.min(startX, currentX)
const top = Math.min(startY, currentY)
return {
left,
top,
width: Math.abs(currentX - startX),
height: Math.abs(currentY - startY)
}
}
export function shouldIgnoreAreaSelectionStart(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false
}
return Boolean(
target.closest(
[
'[data-workspace-board-card-id]',
'[data-workspace-pin-drop-target]',
'a',
'button',
'input',
'select',
'textarea',
'[role="button"]',
'[role="menu"]',
'[role="menuitem"]'
].join(',')
)
)
}
export function isScrollbarPointerDown(
event: Pick<PointerEvent, 'target' | 'clientX' | 'clientY'>
): boolean {
const target = event.target
if (!(target instanceof HTMLElement)) {
return false
}
const rect = target.getBoundingClientRect()
const hitsVerticalScrollbar =
target.scrollHeight > target.clientHeight && event.clientX >= rect.right - 14
const hitsHorizontalScrollbar =
target.scrollWidth > target.clientWidth && event.clientY >= rect.bottom - 14
return hitsVerticalScrollbar || hitsHorizontalScrollbar
}
export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] {
const cardRects: AreaSelectionCardRect[] = []
const seen = new Set<string>()
const cards = board.querySelectorAll<HTMLElement>('[data-workspace-board-card-id]')
for (const card of cards) {
const id = card.dataset.workspaceBoardCardId
if (!id || seen.has(id)) {
continue
}
cardRects.push({ id, element: card, rect: card.getBoundingClientRect() })
seen.add(id)
}
return cardRects
}
export function getAreaSelectionCardIds(
cardRects: readonly AreaSelectionCardRect[],
selectionRect: AreaSelectionRect
): string[] {
const ids: string[] = []
for (const card of cardRects) {
if (
selectionRect.left <= card.rect.right &&
selectionRect.left + selectionRect.width >= card.rect.left &&
selectionRect.top <= card.rect.bottom &&
selectionRect.top + selectionRect.height >= card.rect.top
) {
ids.push(card.id)
}
}
return ids
}
export function setOverlayRect(overlay: HTMLElement | null, rect: AreaSelectionRect | null): void {
if (!overlay || !rect) {
overlay?.classList.add('hidden')
return
}
overlay.classList.remove('hidden')
overlay.style.transform = `translate3d(${rect.left}px, ${rect.top}px, 0)`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
}
export function clearPreviewSelection(
cardRects: readonly AreaSelectionCardRect[],
previewIds: Set<string>
): void {
for (const card of cardRects) {
if (previewIds.has(card.id)) {
card.element.removeAttribute(AREA_SELECTED_ATTR)
}
}
previewIds.clear()
}
export function updatePreviewSelection(
cardRects: readonly AreaSelectionCardRect[],
previewIds: Set<string>,
baseSelectedIds: ReadonlySet<string>,
additive: boolean,
areaIds: readonly string[]
): void {
const nextIds = additive ? new Set(baseSelectedIds) : new Set<string>()
for (const id of areaIds) {
nextIds.add(id)
}
for (const card of cardRects) {
const shouldPreview = nextIds.has(card.id)
const isPreviewed = previewIds.has(card.id)
if (shouldPreview === isPreviewed) {
continue
}
if (shouldPreview) {
card.element.setAttribute(AREA_SELECTED_ATTR, 'true')
previewIds.add(card.id)
} else {
card.element.removeAttribute(AREA_SELECTED_ATTR)
previewIds.delete(card.id)
}
}
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
getWorktreeSelectionIntent,
pruneWorktreeSelection,
updateWorktreeAreaSelection,
updateWorktreeSelection
} from './worktree-multi-selection'
@ -78,4 +79,43 @@ describe('worktree multi selection', () => {
expect([...result.selectedIds]).toEqual(['wt-3'])
expect(result.anchorId).toBe('wt-3')
})
it('replaces selection from an area in visible order', () => {
const result = updateWorktreeAreaSelection({
visibleIds,
previousSelectedIds: new Set(['wt-4']),
previousAnchorId: 'wt-4',
areaIds: ['wt-3', 'wt-1'],
additive: false
})
expect([...result.selectedIds]).toEqual(['wt-1', 'wt-3'])
expect(result.anchorId).toBe('wt-3')
})
it('adds area selection to the existing batch with modifier keys', () => {
const result = updateWorktreeAreaSelection({
visibleIds,
previousSelectedIds: new Set(['wt-4']),
previousAnchorId: 'wt-4',
areaIds: ['wt-1', 'wt-2'],
additive: true
})
expect([...result.selectedIds]).toEqual(['wt-4', 'wt-1', 'wt-2'])
expect(result.anchorId).toBe('wt-2')
})
it('does not clear the existing batch for an empty additive area', () => {
const result = updateWorktreeAreaSelection({
visibleIds,
previousSelectedIds: new Set(['wt-2']),
previousAnchorId: 'wt-2',
areaIds: [],
additive: true
})
expect([...result.selectedIds]).toEqual(['wt-2'])
expect(result.anchorId).toBe('wt-2')
})
})

View File

@ -5,6 +5,11 @@ export type WorktreeSelectionResult = {
anchorId: string
}
export type WorktreeAreaSelectionResult = {
selectedIds: Set<string>
anchorId: string | null
}
export function getWorktreeSelectionIntent(
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
isMac: boolean
@ -75,6 +80,34 @@ export function pruneWorktreeSelection(
}
}
export function updateWorktreeAreaSelection(params: {
visibleIds: readonly string[]
previousSelectedIds: ReadonlySet<string>
previousAnchorId: string | null
areaIds: readonly string[]
additive: boolean
}): WorktreeAreaSelectionResult {
const { visibleIds, previousSelectedIds, previousAnchorId, areaIds, additive } = params
const areaIdSet = new Set(areaIds)
const orderedAreaIds = visibleIds.filter((id) => areaIdSet.has(id))
if (additive) {
const selectedIds = new Set(previousSelectedIds)
for (const id of orderedAreaIds) {
selectedIds.add(id)
}
return {
selectedIds,
anchorId: orderedAreaIds.at(-1) ?? previousAnchorId
}
}
return {
selectedIds: new Set(orderedAreaIds),
anchorId: orderedAreaIds.at(-1) ?? null
}
}
export function areWorktreeSelectionsEqual(
a: ReadonlySet<string>,
b: ReadonlySet<string>