262 lines
9.2 KiB
TypeScript
262 lines
9.2 KiB
TypeScript
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
|
import { Pin } from 'lucide-react'
|
|
import { useAppStore } from '@/store'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
|
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
|
import { cn } from '@/lib/utils'
|
|
import type { Repo, Worktree } from '../../../../shared/types'
|
|
import WorktreeCard from './WorktreeCard'
|
|
import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator'
|
|
import WorktreeContextMenu from './WorktreeContextMenu'
|
|
import { getWorkspaceKanbanDetailsHoverOpenState } from './workspace-kanban-details-hover'
|
|
import { writeWorkspaceDragData } from './workspace-status'
|
|
|
|
type WorkspaceKanbanCardProps = {
|
|
worktree: Worktree
|
|
repo: Repo | undefined
|
|
isActive: boolean
|
|
isSelected: boolean
|
|
selectedWorktrees?: readonly Worktree[]
|
|
compact: boolean
|
|
nativeDragEnabled?: boolean
|
|
onActivate: () => void
|
|
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
|
|
onContextMenuSelect: (
|
|
event: React.MouseEvent<HTMLElement>,
|
|
worktree: Worktree
|
|
) => readonly Worktree[]
|
|
}
|
|
|
|
function WorkspaceKanbanCard({
|
|
worktree,
|
|
repo,
|
|
isActive,
|
|
isSelected,
|
|
selectedWorktrees,
|
|
compact,
|
|
nativeDragEnabled = true,
|
|
onActivate,
|
|
onSelectionGesture,
|
|
onContextMenuSelect
|
|
}: WorkspaceKanbanCardProps): React.JSX.Element {
|
|
if (compact) {
|
|
return (
|
|
<WorkspaceKanbanCompactCard
|
|
worktree={worktree}
|
|
repo={repo}
|
|
isActive={isActive}
|
|
isSelected={isSelected}
|
|
selectedWorktrees={selectedWorktrees}
|
|
onActivate={onActivate}
|
|
onSelectionGesture={onSelectionGesture}
|
|
onContextMenuSelect={onContextMenuSelect}
|
|
nativeDragEnabled={nativeDragEnabled}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const contextWorktrees =
|
|
isSelected && selectedWorktrees && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
|
|
|
|
return (
|
|
<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'}
|
|
data-workspace-board-pointer-draggable={nativeDragEnabled ? undefined : 'true'}
|
|
>
|
|
{worktree.isPinned ? (
|
|
<Badge
|
|
variant="outline"
|
|
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" />
|
|
</Badge>
|
|
) : null}
|
|
<WorktreeCard
|
|
worktree={worktree}
|
|
repo={repo}
|
|
isActive={isActive}
|
|
isMultiSelected={isSelected}
|
|
selectedWorktrees={contextWorktrees}
|
|
nativeDragEnabled={nativeDragEnabled}
|
|
onActivate={onActivate}
|
|
onSelectionGesture={onSelectionGesture}
|
|
onContextMenuSelect={(event) => onContextMenuSelect(event, worktree)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default React.memo(WorkspaceKanbanCard)
|
|
|
|
function WorkspaceKanbanCompactCard({
|
|
worktree,
|
|
repo,
|
|
isActive,
|
|
isSelected,
|
|
selectedWorktrees,
|
|
nativeDragEnabled = true,
|
|
onActivate,
|
|
onSelectionGesture,
|
|
onContextMenuSelect
|
|
}: Omit<WorkspaceKanbanCardProps, 'compact'>): React.JSX.Element {
|
|
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
|
|
const isDeleting = deleteState?.isDeleting ?? false
|
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
const contextMenuOpenRef = useRef(false)
|
|
const contextWorktrees = useMemo(
|
|
() =>
|
|
isSelected && selectedWorktrees && selectedWorktrees.length > 0
|
|
? selectedWorktrees
|
|
: [worktree],
|
|
[isSelected, selectedWorktrees, worktree]
|
|
)
|
|
|
|
const handleActivate = useCallback(() => {
|
|
if (isDeleting) {
|
|
return
|
|
}
|
|
activateAndRevealWorktree(worktree.id)
|
|
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
|
|
}
|
|
const dragIds =
|
|
isSelected && contextWorktrees.length > 1
|
|
? contextWorktrees.map((item) => item.id)
|
|
: worktree.id
|
|
writeWorkspaceDragData(event.dataTransfer, dragIds)
|
|
},
|
|
[contextWorktrees, isDeleting, isSelected, worktree.id]
|
|
)
|
|
|
|
const handleDetailsOpenChange = useCallback((requestedOpen: boolean) => {
|
|
setDetailsOpen(
|
|
getWorkspaceKanbanDetailsHoverOpenState({
|
|
contextMenuOpen: contextMenuOpenRef.current,
|
|
requestedOpen
|
|
})
|
|
)
|
|
}, [])
|
|
|
|
const handleContextMenuOpenChange = useCallback((open: boolean) => {
|
|
contextMenuOpenRef.current = open
|
|
if (open) {
|
|
// Why: the preview sits beside the compact card, so it should disappear
|
|
// as soon as the card's context menu becomes the active surface.
|
|
setDetailsOpen(false)
|
|
}
|
|
}, [])
|
|
|
|
const handleContextMenuSelect = useCallback(
|
|
(event: React.MouseEvent<HTMLElement>) => {
|
|
setDetailsOpen(false)
|
|
return onContextMenuSelect(event, worktree)
|
|
},
|
|
[onContextMenuSelect, worktree]
|
|
)
|
|
|
|
return (
|
|
<WorktreeContextMenu
|
|
worktree={worktree}
|
|
selectedWorktrees={contextWorktrees}
|
|
onContextMenuSelect={handleContextMenuSelect}
|
|
onOpenChange={handleContextMenuOpenChange}
|
|
>
|
|
<HoverCard
|
|
open={detailsOpen}
|
|
onOpenChange={handleDetailsOpenChange}
|
|
openDelay={450}
|
|
closeDelay={100}
|
|
>
|
|
<HoverCardTrigger asChild>
|
|
<button
|
|
type="button"
|
|
draggable={nativeDragEnabled && !isDeleting}
|
|
onDragStart={nativeDragEnabled ? handleDragStart : undefined}
|
|
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'
|
|
: 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',
|
|
'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',
|
|
!nativeDragEnabled && !isDeleting && '!cursor-grab',
|
|
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'}
|
|
data-workspace-board-pointer-draggable={
|
|
nativeDragEnabled || isDeleting ? undefined : 'true'
|
|
}
|
|
aria-label={`Open ${worktree.displayName}`}
|
|
aria-busy={isDeleting}
|
|
>
|
|
<WorktreeActivityStatusIndicator worktreeId={worktree.id} className="mr-1" />
|
|
<span className="min-w-0 flex-1 truncate">{worktree.displayName}</span>
|
|
{repo ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span
|
|
className="ml-2 flex max-w-[25%] shrink-0 items-center gap-1 rounded-[4px] border border-border bg-accent px-1.5 py-0.5 leading-none dark:border-border/60 dark:bg-accent/50"
|
|
data-workspace-board-repo-badge=""
|
|
>
|
|
<RepoBadgeMark color={repo.badgeColor} />
|
|
<span className="min-w-0 truncate text-[10px] font-semibold lowercase text-foreground">
|
|
{repo.displayName}
|
|
</span>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={4}>
|
|
{repo.displayName}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
</button>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="right"
|
|
align="start"
|
|
sideOffset={8}
|
|
className="w-72 p-1.5 data-[state=closed]:hidden"
|
|
>
|
|
<WorktreeCard
|
|
worktree={worktree}
|
|
repo={repo}
|
|
isActive={isActive}
|
|
onActivate={onActivate}
|
|
/>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</WorktreeContextMenu>
|
|
)
|
|
}
|