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
This commit is contained in:
Neil 2026-05-15 18:42:19 -07:00 committed by GitHub
parent 727ccf7c69
commit 9074ed31b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 686 additions and 164 deletions

View File

@ -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<WorkspaceBoardOpenMode>('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<WorkspaceBoardOpenMode>('closed')
const workspaceBoardHoverSuppressedRef = useRef(false)
const workspaceHeaderHoveredRef = useRef(false)
const workspaceBoardHoverOpenTimerRef = useRef<number | null>(null)
const workspaceBoardHoverCloseTimerRef = useRef<number | null>(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<HTMLDivElement>) => {
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<HTMLButtonElement>) => {
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 (
<>
<div
@ -176,6 +234,29 @@ const SidebarHeader = React.memo(function SidebarHeader() {
<span className="px-2 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
Workspaces
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={workspaceBoardOpen ? 'secondary' : 'ghost'}
size="icon-xs"
className="text-muted-foreground"
aria-label="Workspace board"
aria-pressed={workspaceBoardPersistentOpen}
data-workspace-board-trigger=""
onPointerDown={handleWorkspaceBoardButtonPointerDown}
onClick={handleWorkspaceBoardToggle}
>
<Kanban className="size-3.5" strokeWidth={2.25} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{workspaceBoardPersistentOpen
? 'Close workspace board'
: workspaceBoardOpen
? 'Keep workspace board open'
: 'Workspace board'}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<SidebarFilter />

View File

@ -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<HTMLElement>, worktreeId: string) => boolean
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
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 ? (
<Badge
variant="outline"
className="pointer-events-none absolute right-2 top-1.5 z-10 h-4 gap-1 rounded-full bg-background/90 px-1.5 text-[9px] leading-none text-muted-foreground"
className="pointer-events-none absolute right-2 top-1.5 z-10 flex size-4 items-center justify-center rounded-full bg-background/90 p-0 text-muted-foreground"
aria-label="Pinned"
>
<Pin className="size-2.5" />
Pinned
</Badge>
) : null}
<WorktreeCard worktree={worktree} repo={repo} isActive={isActive} onActivate={onActivate} />
<WorktreeCard
worktree={worktree}
repo={repo}
isActive={isActive}
isMultiSelected={isSelected}
selectedWorktrees={selectedWorktrees}
hideCiCheck={worktree.isPinned}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={(event) => onContextMenuSelect(event, worktree)}
/>
</div>
)
}
@ -60,7 +85,11 @@ function WorkspaceKanbanCompactCard({
worktree,
repo,
isActive,
onActivate
isSelected,
selectedWorktrees,
onActivate,
onSelectionGesture,
onContextMenuSelect
}: Omit<WorkspaceKanbanCardProps, 'compact'>): 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<HTMLButtonElement>) => {
const selectionOnly = onSelectionGesture(event, worktree.id)
if (selectionOnly) {
event.preventDefault()
event.stopPropagation()
return
}
handleActivate()
},
[handleActivate, onSelectionGesture, worktree.id]
)
const handleDragStart = useCallback(
(event: React.DragEvent<HTMLButtonElement>) => {
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 (
<WorktreeContextMenu worktree={worktree}>
<WorktreeContextMenu
worktree={worktree}
selectedWorktrees={selectedWorktrees}
onContextMenuSelect={(event) => onContextMenuSelect(event, worktree)}
>
<HoverCard openDelay={450} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
draggable={!isDeleting}
onDragStart={handleDragStart}
onClick={handleActivate}
onClick={handleClick}
className={cn(
'flex h-8 w-full min-w-0 cursor-pointer items-center rounded-md border px-2 text-left text-[12px] outline-none transition-colors',
isActive
? 'border-sidebar-ring bg-sidebar-accent text-sidebar-accent-foreground'
: 'border-transparent text-foreground hover:bg-sidebar-accent/60 focus-visible:border-sidebar-ring',
: isSelected
? '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',
isDeleting && 'cursor-not-allowed opacity-50 grayscale'
)}
data-workspace-board-card-mode="compact"

View File

@ -2,26 +2,17 @@ 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,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { LayoutList, Pin, Rows3, X } from 'lucide-react'
import WorkspaceKanbanCard from './WorkspaceKanbanCard'
import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Pin } from 'lucide-react'
import WorkspaceKanbanDrawerHeader from './WorkspaceKanbanDrawerHeader'
import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane'
import {
getWorkspaceStatus,
hasWorkspaceDragData,
readWorkspaceDragData,
getWorkspaceStatusVisualMeta
readWorkspaceDragDataIds
} from './workspace-status'
import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop'
import { useWorkspaceKanbanSelection } from './use-workspace-kanban-selection'
import type { WorkspaceStatus, Worktree } from '../../../../shared/types'
import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
@ -44,6 +35,7 @@ export default function WorkspaceKanbanDrawer({
const repoMap = useRepoMap()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const setWorkspaceStatuses = useAppStore((s) => s.setWorkspaceStatuses)
const workspaceBoardOpacity = useAppStore((s) => s.workspaceBoardOpacity)
@ -72,6 +64,17 @@ export default function WorkspaceKanbanDrawer({
return grouped
}, [allWorktrees, workspaceStatuses])
const boardWorktrees = useMemo(
() => workspaceStatuses.flatMap((status) => worktreesByStatus.get(status.id) ?? []),
[worktreesByStatus, workspaceStatuses]
)
const {
selectedWorktreeIds,
selectedWorktrees,
updateSelectionForGesture,
selectForContextMenu
} = useWorkspaceKanbanSelection(open, boardWorktrees)
const moveWorktreeToStatus = useCallback(
(worktreeId: string, status: WorkspaceStatus) => {
const current = allWorktrees.find((worktree) => worktree.id === worktreeId)
@ -83,6 +86,23 @@ export default function WorkspaceKanbanDrawer({
[allWorktrees, updateWorktreeMeta, workspaceStatuses]
)
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)
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
continue
}
updates.set(worktreeId, { workspaceStatus: status })
}
if (updates.size > 0) {
void updateWorktreesMeta(updates)
}
},
[allWorktrees, updateWorktreesMeta, workspaceStatuses]
)
const pinWorktree = useCallback(
(worktreeId: string) => {
const current = allWorktrees.find((worktree) => worktree.id === worktreeId)
@ -135,15 +155,15 @@ export default function WorkspaceKanbanDrawer({
const handleDrop = 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)
moveWorktreeToStatus(worktreeId, status)
moveWorktreesToStatus(worktreeIds, status)
},
[moveWorktreeToStatus]
[moveWorktreesToStatus]
)
const handleWorktreeActivate = useCallback(() => {
@ -252,8 +272,7 @@ export default function WorkspaceKanbanDrawer({
if (!content) {
return
}
const target = event.target
if (target instanceof Node && content.contains(target)) {
if (event.target instanceof Node && content.contains(event.target)) {
return
}
const rect = content.getBoundingClientRect()
@ -268,7 +287,6 @@ export default function WorkspaceKanbanDrawer({
const opacityPercent = Math.round(workspaceBoardOpacity * 100)
const drawerLeft = sidebarOpen ? sidebarWidth : 0
const BoardModeIcon = workspaceBoardCompact ? Rows3 : LayoutList
return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
@ -297,57 +315,31 @@ export default function WorkspaceKanbanDrawer({
}}
onInteractOutside={(event) => {
const originalEvent = event.detail.originalEvent
const target = originalEvent.target
if (target instanceof Element && target.closest('[data-workspace-board-trigger]')) {
event.preventDefault()
return
}
if (originalEvent instanceof PointerEvent && originalEvent.clientX < drawerLeft) {
// Why: users need to scroll, click, and drag from the workspace
// sidebar while the companion board stays open.
// Why: keep the workspace sidebar interactive while the companion board stays open.
event.preventDefault()
}
}}
>
<SheetHeader className="border-b border-sidebar-border px-4 py-3 pr-24">
<SheetTitle className="text-sm">Workspace board</SheetTitle>
<SheetDescription className="sr-only">
Organize workspaces by status and open workspace cards.
</SheetDescription>
</SheetHeader>
<div className="absolute right-3 top-2.5 flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={workspaceBoardCompact ? 'secondary' : 'ghost'}
size="icon-xs"
aria-pressed={workspaceBoardCompact}
aria-label={
workspaceBoardCompact ? 'Compact workspace cards' : 'Detailed workspace cards'
}
onClick={() => setWorkspaceBoardCompact(!workspaceBoardCompact)}
>
<BoardModeIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{workspaceBoardCompact ? 'Show detailed cards' : 'Show compact cards'}
</TooltipContent>
</Tooltip>
<WorkspaceKanbanSettingsMenu
opacityPercent={opacityPercent}
workspaceStatuses={workspaceStatuses}
onOpacityChange={handleOpacityChange}
onRenameStatus={handleRenameStatus}
onChangeStatusColor={handleChangeStatusColor}
onChangeStatusIcon={handleChangeStatusIcon}
onMoveStatus={handleMoveStatus}
onRemoveStatus={handleRemoveStatus}
onAddStatus={handleAddStatus}
/>
<SheetClose asChild>
<Button variant="ghost" size="icon-xs" aria-label="Close">
<X className="size-3.5" />
</Button>
</SheetClose>
</div>
<WorkspaceKanbanDrawerHeader
selectedCount={selectedWorktrees.length}
compact={workspaceBoardCompact}
opacityPercent={opacityPercent}
workspaceStatuses={workspaceStatuses}
onCompactChange={setWorkspaceBoardCompact}
onOpacityChange={handleOpacityChange}
onRenameStatus={handleRenameStatus}
onChangeStatusColor={handleChangeStatusColor}
onChangeStatusIcon={handleChangeStatusIcon}
onMoveStatus={handleMoveStatus}
onRemoveStatus={handleRemoveStatus}
onAddStatus={handleAddStatus}
/>
<div ref={boardRef} className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
<div
@ -372,56 +364,26 @@ export default function WorkspaceKanbanDrawer({
}}
>
{workspaceStatuses.map((status) => {
const meta = getWorkspaceStatusVisualMeta(status)
const items = worktreesByStatus.get(status.id) ?? []
const isDragTarget = dragOverStatus === status.id
return (
<section
<WorkspaceKanbanStatusLane
key={status.id}
data-workspace-status-drop-target=""
data-workspace-status={status.id}
className={cn(
'flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-t-2 border-sidebar-border transition-colors',
meta.border,
meta.laneTint,
isDragTarget && 'border-sidebar-ring bg-sidebar-accent/70'
)}
onDragOver={(event) => 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)}
>
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/70 px-3">
<meta.icon className={cn('size-3.5', meta.tone)} />
<div className="min-w-0 flex-1 truncate text-[12px] font-semibold text-foreground">
{status.label}
</div>
<div className="rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
{items.length}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-2 scrollbar-sleek">
{items.length > 0 ? (
<div className="space-y-2">
{items.map((worktree) => (
<WorkspaceKanbanCard
key={worktree.id}
worktree={worktree}
repo={repoMap.get(worktree.repoId)}
isActive={activeWorktreeId === worktree.id}
compact={workspaceBoardCompact}
onActivate={handleWorktreeActivate}
/>
))}
</div>
) : (
<div className="flex h-20 items-center justify-center rounded-md border border-dashed border-border/70 text-[11px] text-muted-foreground">
Empty
</div>
)}
</div>
</section>
onDrop={handleDrop}
onActivate={handleWorktreeActivate}
onSelectionGesture={updateSelectionForGesture}
onContextMenuSelect={selectForContextMenu}
/>
)
})}
</div>

View File

@ -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<HTMLInputElement>) => 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 (
<>
<SheetHeader className="border-b border-sidebar-border px-4 py-3 pr-24">
<SheetTitle className="flex items-center gap-2 text-sm">
<span>Workspace board</span>
{selectedCount > 1 ? (
<span className="rounded-full bg-sidebar-accent px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{selectedCount} selected
</span>
) : null}
</SheetTitle>
<SheetDescription className="sr-only">
Organize workspaces by status and open workspace cards.
</SheetDescription>
</SheetHeader>
<div className="absolute right-3 top-2.5 flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={compact ? 'secondary' : 'ghost'}
size="icon-xs"
aria-pressed={compact}
aria-label={compact ? 'Compact workspace cards' : 'Detailed workspace cards'}
onClick={() => onCompactChange(!compact)}
>
<BoardModeIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{compact ? 'Show detailed cards' : 'Show compact cards'}
</TooltipContent>
</Tooltip>
<WorkspaceKanbanSettingsMenu
opacityPercent={opacityPercent}
workspaceStatuses={workspaceStatuses}
onOpacityChange={onOpacityChange}
onRenameStatus={onRenameStatus}
onChangeStatusColor={onChangeStatusColor}
onChangeStatusIcon={onChangeStatusIcon}
onMoveStatus={onMoveStatus}
onRemoveStatus={onRemoveStatus}
onAddStatus={onAddStatus}
/>
<SheetClose asChild>
<Button variant="ghost" size="icon-xs" aria-label="Close">
<X className="size-3.5" />
</Button>
</SheetClose>
</div>
</>
)
}

View File

@ -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<string, Repo>
activeWorktreeId: string | null
compact: boolean
isDragTarget: boolean
selectedWorktreeIds: ReadonlySet<string>
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<HTMLElement>, worktreeId: string) => boolean
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
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 (
<section
data-workspace-status-drop-target=""
data-workspace-status={status.id}
className={cn(
'flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-t-2 border-sidebar-border transition-colors',
meta.border,
meta.laneTint,
isDragTarget && 'border-sidebar-ring bg-sidebar-accent/70'
)}
onDragOver={(event) => onDragOver(event, status.id)}
onDragLeave={onDragLeave}
onDrop={(event) => onDrop(event, status.id)}
>
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/70 px-3">
<meta.icon className={cn('size-3.5', meta.tone)} />
<div className="min-w-0 flex-1 truncate text-[12px] font-semibold text-foreground">
{status.label}
</div>
<div className="rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
{items.length}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-2 scrollbar-sleek">
{items.length > 0 ? (
<div className="space-y-2">
{items.map((worktree) => {
const isSelected = selectedWorktreeIds.has(worktree.id)
return (
<WorkspaceKanbanCard
key={worktree.id}
worktree={worktree}
repo={repoMap.get(worktree.repoId)}
isActive={activeWorktreeId === worktree.id}
isSelected={isSelected}
selectedWorktrees={
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : [worktree]
}
compact={compact}
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
/>
)
})}
</div>
) : (
<div className="flex h-20 items-center justify-center rounded-md border border-dashed border-border/70 text-[11px] text-muted-foreground">
Empty
</div>
)}
</div>
</section>
)
}

View File

@ -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<HTMLButtonElement>) => void
onActivate?: () => void
onSelectionGesture?: (event: React.MouseEvent<HTMLDivElement>, worktreeId: string) => boolean
onContextMenuSelect?: (event: React.MouseEvent<HTMLDivElement>) => readonly Worktree[]
onSelectionGesture?: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onContextMenuSelect?: (event: React.MouseEvent<HTMLElement>) => 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({
<div className="flex items-center gap-1 shrink-0">
{/* CI Checks & PR state on the right */}
{cardProps.includes('ci') && hostedReview && hostedReview.status !== 'neutral' && (
{showCI && hostedReview && hostedReview.status !== 'neutral' && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center opacity-80 hover:opacity-100 transition-opacity">

View File

@ -45,7 +45,7 @@ type Props = {
children: React.ReactNode
contentClassName?: string
selectedWorktrees?: readonly Worktree[]
onContextMenuSelect?: (event: React.MouseEvent<HTMLDivElement>) => readonly Worktree[]
onContextMenuSelect?: (event: React.MouseEvent<HTMLElement>) => readonly Worktree[]
}
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'

View File

@ -112,9 +112,9 @@ type VirtualizedWorktreeViewportProps = {
worktrees: Worktree[]
selectedWorktreeIds: ReadonlySet<string>
selectedWorktrees: readonly Worktree[]
onSelectionGesture: (event: React.MouseEvent<HTMLDivElement>, worktreeId: string) => boolean
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onContextMenuSelect: (
event: React.MouseEvent<HTMLDivElement>,
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
repoMap: Map<string, Repo>
@ -1456,7 +1456,7 @@ const WorktreeList = React.memo(function WorktreeList() {
}, [selectedWorktreeIds.size])
const updateSelectionForGesture = useCallback(
(event: React.MouseEvent<HTMLDivElement>, worktreeId: string): boolean => {
(event: React.MouseEvent<HTMLElement>, 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<HTMLDivElement>, worktree: Worktree): readonly Worktree[] => {
(_event: React.MouseEvent<HTMLElement>, worktree: Worktree): readonly Worktree[] => {
if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) {
return selectedWorktrees
}

View File

@ -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<Set<string>>(new Set())
const [selectionAnchorId, setSelectionAnchorId] = useState<string | null>(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<HTMLElement>, 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<HTMLElement>, 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
}
}

View File

@ -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<T extends HTMLElement>(
return
}
const worktreeId = readWorkspaceDragData(dataTransfer)
if (!worktreeId) {
const worktreeIds = readWorkspaceDragDataIds(dataTransfer)
if (worktreeIds.length === 0) {
return
}
@ -57,13 +57,17 @@ export function useWorkspaceStatusDocumentDrop<T extends HTMLElement>(
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)
}
}
}

View File

@ -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<string, string>()
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)
})
})

View File

@ -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')
)
}

View File

@ -92,6 +92,9 @@ export type WorktreeSlice = {
) => Promise<{ ok: true } | { ok: false; error: string }>
clearWorktreeDeleteState: (worktreeId: string) => void
updateWorktreeMeta: (worktreeId: string, updates: Partial<WorktreeMeta>) => Promise<void>
updateWorktreesMeta: (
updatesByWorktreeId: ReadonlyMap<string, Partial<WorktreeMeta>>
) => Promise<void>
markWorktreeUnread: (worktreeId: string) => void
/** Clear the worktree's unread dot. Called on user interaction with any
* terminal pane inside the worktree (keystroke, click) matches

View File

@ -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<AppState>)
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

View File

@ -827,6 +827,34 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
},
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