Add Kanban marquee workspace selection (#2072)
This commit is contained in:
parent
86192ef53d
commit
33be589fc9
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue