mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(sidebar): make nested workspace drops reliable
This commit is contained in:
@@ -1422,7 +1422,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
)}
|
||||
>
|
||||
{/* Header row: Title */}
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 items-center justify-between gap-2 rounded-md transition-[background-color,box-shadow]',
|
||||
isLineageDropTarget && 'bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{showPinnedRepoIcon && (
|
||||
<RepoIdentityChip repo={repo}>
|
||||
@@ -1759,6 +1764,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
worktreeId={worktree.id}
|
||||
agents={agentActivityDisplayMode === 'compact' ? compactInlineAgentRows : undefined}
|
||||
className={hasMetaRow || remoteBranchConflict ? 'mt-0' : '-mt-1'}
|
||||
isLineageDropTarget={isLineageDropTarget}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1806,7 +1812,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
)}
|
||||
|
||||
{!newCardStyle && lineageChildren && (
|
||||
<div className="-ml-[1.125rem] mt-1.5 w-[calc(100%+1.125rem)] space-y-1">
|
||||
<div
|
||||
className="-ml-[1.125rem] mt-1.5 w-[calc(100%+1.125rem)] space-y-1"
|
||||
data-worktree-legacy-lineage-children=""
|
||||
>
|
||||
{lineageChildren}
|
||||
</div>
|
||||
)}
|
||||
@@ -1873,13 +1882,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
'rounded-lg',
|
||||
// Why: the live data attribute updates before React state during navigation,
|
||||
// so it must own the complete active style without stale utility classes.
|
||||
isLineageDropTarget
|
||||
? 'border border-accent-foreground/20 bg-accent/80'
|
||||
: isActiveSurface
|
||||
? 'border border-transparent'
|
||||
: isMultiSelected
|
||||
? 'border border-worktree-sidebar-ring/35 bg-worktree-sidebar-accent/70 ring-1 ring-worktree-sidebar-ring/30'
|
||||
: 'border border-transparent worktree-sidebar-card-hover',
|
||||
isActiveSurface
|
||||
? 'border border-transparent'
|
||||
: isMultiSelected
|
||||
? 'border border-worktree-sidebar-ring/35 bg-worktree-sidebar-accent/70 ring-1 ring-worktree-sidebar-ring/30'
|
||||
: 'border border-transparent worktree-sidebar-card-hover',
|
||||
isActiveSurface && isMultiSelected && 'ring-1 ring-worktree-sidebar-ring/35',
|
||||
revealHighlight && [
|
||||
'scroll-to-current-workspace-reveal-highlight',
|
||||
@@ -1893,6 +1900,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
isRuntimeDisconnected && !isDeleting && 'opacity-60'
|
||||
)}
|
||||
data-worktree-card-surface="true"
|
||||
data-worktree-lineage-drop-target={isLineageDropTarget ? 'true' : undefined}
|
||||
data-worktree-card-active={isActiveSurface ? activeSurfaceVariant : undefined}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={affiliateListMode ? undefined : handleDoubleClick}
|
||||
|
||||
@@ -197,17 +197,21 @@ describe('WorktreeCardAgents', () => {
|
||||
capturedRowActivations = []
|
||||
})
|
||||
|
||||
it('renders ordinary rows in full mode without a child disclosure', async () => {
|
||||
it('renders full rows and scopes lineage feedback to the targeted list', async () => {
|
||||
mockAgentActivityDisplayMode = 'full'
|
||||
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
|
||||
|
||||
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
|
||||
const targetMarkup = renderToStaticMarkup(
|
||||
<WorktreeCardAgents worktreeId="target" isLineageDropTarget />
|
||||
)
|
||||
const markup =
|
||||
targetMarkup + renderToStaticMarkup(<WorktreeCardAgents worktreeId="descendant" />)
|
||||
|
||||
expect(markup).toContain('role="group"')
|
||||
expect(markup).toContain('aria-label="Agents"')
|
||||
expect(markup).toContain('data-testid="agent-row"')
|
||||
expect(markup).not.toContain('<button')
|
||||
expect(markup).not.toContain('aria-expanded')
|
||||
expect(markup.match(/data-worktree-card-agent-list-drop-target="true"/g)).toHaveLength(1)
|
||||
expect(markup.match(/bg-worktree-sidebar-accent/g)).toHaveLength(1)
|
||||
expect(markup.match(/ring-worktree-sidebar-ring\/50/g)).toHaveLength(1)
|
||||
}, 30_000)
|
||||
|
||||
it('uses compact mode when the display preference is absent', async () => {
|
||||
|
||||
@@ -48,13 +48,15 @@ type Props = {
|
||||
agents?: DashboardAgentRowData[]
|
||||
/** Spacing from the card body above; parent decides whether a divider is appropriate. */
|
||||
className?: string
|
||||
isLineageDropTarget?: boolean
|
||||
}
|
||||
|
||||
/** Inline agent list rendered inside WorktreeCard when 'inline-agents' is enabled. */
|
||||
const WorktreeCardAgents = React.memo(function WorktreeCardAgents({
|
||||
worktreeId,
|
||||
agents: precomputedAgents,
|
||||
className
|
||||
className,
|
||||
isLineageDropTarget = false
|
||||
}: Props) {
|
||||
const selectedAgents = useWorktreeAgentRows(worktreeId, precomputedAgents === undefined)
|
||||
const agents = precomputedAgents ?? selectedAgents
|
||||
@@ -62,19 +64,28 @@ const WorktreeCardAgents = React.memo(function WorktreeCardAgents({
|
||||
return null
|
||||
}
|
||||
// Why: mount the inner body (owns the 30s useNow tick) only for non-empty rows, so idle worktrees pay no timer cost.
|
||||
return <WorktreeCardAgentsBody worktreeId={worktreeId} agents={agents} className={className} />
|
||||
return (
|
||||
<WorktreeCardAgentsBody
|
||||
worktreeId={worktreeId}
|
||||
agents={agents}
|
||||
className={className}
|
||||
isLineageDropTarget={isLineageDropTarget}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
type BodyProps = {
|
||||
worktreeId: string
|
||||
agents: DashboardAgentRowData[]
|
||||
className?: string
|
||||
isLineageDropTarget: boolean
|
||||
}
|
||||
|
||||
const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
worktreeId,
|
||||
agents,
|
||||
className
|
||||
className,
|
||||
isLineageDropTarget
|
||||
}: BodyProps) {
|
||||
const agentActivityDisplayMode =
|
||||
useAppStore((s) => s.agentActivityDisplayMode) ?? DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE
|
||||
@@ -363,13 +374,19 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
return (
|
||||
<div
|
||||
ref={compactAgentListRootRef}
|
||||
className={cn('flex flex-col mt-1 gap-0.5', className)}
|
||||
className={cn(
|
||||
'flex flex-col mt-1 gap-0.5 rounded-md transition-[background-color,box-shadow]',
|
||||
isLineageDropTarget && 'bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
|
||||
className
|
||||
)}
|
||||
onClick={stopBubble}
|
||||
onDoubleClick={stopBubble}
|
||||
onMouseDown={stopBubble}
|
||||
onPointerDown={stopBubble}
|
||||
role={hasLineage ? 'tree' : 'group'}
|
||||
aria-label={translate('auto.components.sidebar.WorktreeCardAgents.1b0a156717', 'Agents')}
|
||||
data-worktree-card-agent-list=""
|
||||
data-worktree-card-agent-list-drop-target={isLineageDropTarget ? 'true' : undefined}
|
||||
data-compact-agent-list="true"
|
||||
>
|
||||
{agents.length === 0 ? null : shouldUseSummaryRow ? (
|
||||
@@ -405,13 +422,19 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
return (
|
||||
// Why: swallow bubbling so gutter clicks don't reach WorktreeCard's activate / edit-meta handlers.
|
||||
<div
|
||||
className={cn('flex flex-col mt-1', className)}
|
||||
className={cn(
|
||||
'flex flex-col mt-1 rounded-md transition-[background-color,box-shadow]',
|
||||
isLineageDropTarget && 'bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
|
||||
className
|
||||
)}
|
||||
onClick={stopBubble}
|
||||
onDoubleClick={stopBubble}
|
||||
onMouseDown={stopBubble}
|
||||
onPointerDown={stopBubble}
|
||||
role={hasLineage ? 'tree' : 'group'}
|
||||
aria-label={translate('auto.components.sidebar.WorktreeCardAgents.1b0a156717', 'Agents')}
|
||||
data-worktree-card-agent-list=""
|
||||
data-worktree-card-agent-list-drop-target={isLineageDropTarget ? 'true' : undefined}
|
||||
>
|
||||
{rootAgents.map((rootAgent) => renderAgentBranch(rootAgent))}
|
||||
</div>
|
||||
|
||||
@@ -189,10 +189,12 @@ import {
|
||||
setSidebarPointerDragDocumentStyles,
|
||||
updateSidebarDragPreviewPosition
|
||||
} from './worktree-sidebar-pointer-drag-dom'
|
||||
import { shouldStartWorkspaceBoardDragPreview } from './workspace-board-drag-preview-intent'
|
||||
import {
|
||||
getWorktreeSidebarDragAutoscroll,
|
||||
getWorktreeSidebarDragRectsForGroup,
|
||||
refreshWorktreeSidebarDragSession,
|
||||
shouldHoldWorktreeSidebarRowsAtPointerEdge,
|
||||
type WorktreeSidebarDragRect,
|
||||
type WorktreeSidebarDragSession,
|
||||
type WorktreeSidebarDragPoint
|
||||
@@ -212,6 +214,8 @@ import {
|
||||
} from './worktree-sidebar-drop-preview'
|
||||
import {
|
||||
getReorderedWorktreeIdsToUnnest,
|
||||
getTopLevelWorktreeLineageDragIds,
|
||||
getWorktreeLineageDragRootId,
|
||||
getWorktreeLineageDropTargetId
|
||||
} from './worktree-lineage-drag-drop'
|
||||
import { resolveProjectGroupHeaderColor } from './project-header-color'
|
||||
@@ -945,6 +949,7 @@ type WorktreePointerDrag = {
|
||||
currentX: number
|
||||
currentY: number
|
||||
worktreeId: string
|
||||
explicitDraggedIds: readonly string[]
|
||||
draggedIds: readonly string[]
|
||||
reorderDraggedIds: readonly string[]
|
||||
reorderUnitDraggedIds: readonly string[]
|
||||
@@ -1015,6 +1020,7 @@ function getPointerDropStatusTarget(args: {
|
||||
const lineageParentId = getWorktreeLineageDropTargetId({
|
||||
container: args.container,
|
||||
target,
|
||||
pointerX: args.x,
|
||||
pointerY: args.y
|
||||
})
|
||||
const statusTarget = target.closest<HTMLElement>('[data-workspace-status-drop-target]')
|
||||
@@ -1373,6 +1379,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const directScrollInputUntilRef = useRef(0)
|
||||
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
|
||||
const [pinDragOver, setPinDragOver] = useState(false)
|
||||
// Why: same-Y parent/child crossings change only target identity, so a ref would leave feedback stale.
|
||||
const [pointerLineageDropTargetId, setPointerLineageDropTargetId] = useState<string | null>(null)
|
||||
const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState<string | null>(null)
|
||||
const [worktreeDragState, setWorktreeDragState] = useState<WorktreeRowDragState>(
|
||||
WORKTREE_ROW_DRAG_INITIAL_STATE
|
||||
@@ -1544,6 +1552,27 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds),
|
||||
[worktreeLineageDragRows]
|
||||
)
|
||||
const getLineageDragRootIds = useCallback(
|
||||
(draggedIds: readonly string[]) =>
|
||||
getTopLevelWorktreeLineageDragIds({
|
||||
draggedIds,
|
||||
lineageById: worktreeLineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds
|
||||
}),
|
||||
[cyclicLineageIds, worktreeLineageById, worktreeMap]
|
||||
)
|
||||
const getLineageDragRootId = useCallback(
|
||||
(worktreeId: string, lineageRootIds: readonly string[]) =>
|
||||
getWorktreeLineageDragRootId({
|
||||
worktreeId,
|
||||
lineageRootIds,
|
||||
lineageById: worktreeLineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds
|
||||
}),
|
||||
[cyclicLineageIds, worktreeLineageById, worktreeMap]
|
||||
)
|
||||
const getReorderUnitDraggedIds = useCallback(
|
||||
(sourceGroupKey: string, reorderDraggedIds: readonly string[]) => {
|
||||
const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey)
|
||||
@@ -1595,7 +1624,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
return null
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
return computeWorktreeSidebarDropPreview({
|
||||
const preview = computeWorktreeSidebarDropPreview({
|
||||
pointerY: args.pointerY,
|
||||
containerTop: containerRect.top,
|
||||
scrollTop: container.scrollTop,
|
||||
@@ -1606,6 +1635,27 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
grab: args.grab,
|
||||
anchor: args.anchor
|
||||
})
|
||||
const pointerDrag = worktreePointerDragRef.current
|
||||
const point = pointerDrag?.active
|
||||
? { clientX: pointerDrag.currentX, clientY: pointerDrag.currentY }
|
||||
: worktreeNativeLatestPointRef.current
|
||||
if (
|
||||
!preview ||
|
||||
!point ||
|
||||
!shouldHoldWorktreeSidebarRowsAtPointerEdge({
|
||||
point,
|
||||
containerRect,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight
|
||||
})
|
||||
) {
|
||||
return preview
|
||||
}
|
||||
// Why: shifting a tall card while edge-scrolling can move its target surface by a full viewport.
|
||||
return {
|
||||
...preview,
|
||||
previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS
|
||||
}
|
||||
},
|
||||
[worktreeDragUnitGroups]
|
||||
)
|
||||
@@ -2617,6 +2667,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const cleanupWorktreePointerDrag = useCallback(() => {
|
||||
const drag = worktreePointerDragRef.current
|
||||
cancelWorktreePointerAutoscroll()
|
||||
setPointerLineageDropTargetId(null)
|
||||
setNativeLineageDropTargetId(null)
|
||||
if (!drag) {
|
||||
return
|
||||
@@ -2769,14 +2820,26 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
clearWorktreeDrag()
|
||||
return
|
||||
}
|
||||
// Why: show the board preview as soon as a card drag begins so the drop target is visible up front, not only at the sidebar edge.
|
||||
const previewSidebarContainer = scrollRef.current
|
||||
if (
|
||||
!drag.workspaceBoardDragPreviewRequested &&
|
||||
!workspaceBoardOpen &&
|
||||
!hasWorkspaceKanbanSidebarDropBoard()
|
||||
previewSidebarContainer
|
||||
) {
|
||||
drag.workspaceBoardDragPreviewRequested = true
|
||||
onWorkspaceBoardDragPreviewStart()
|
||||
const sidebarRect = previewSidebarContainer.getBoundingClientRect()
|
||||
// Why: mounting the companion board during vertical reorder stalls the
|
||||
// first drag frame; rightward edge movement signals board intent.
|
||||
if (
|
||||
shouldStartWorkspaceBoardDragPreview({
|
||||
pointerX: drag.currentX,
|
||||
startX: drag.startX,
|
||||
sidebarRight: sidebarRect.right
|
||||
}) &&
|
||||
!hasWorkspaceKanbanSidebarDropBoard()
|
||||
) {
|
||||
drag.workspaceBoardDragPreviewRequested = true
|
||||
onWorkspaceBoardDragPreviewStart()
|
||||
}
|
||||
}
|
||||
const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({
|
||||
x: drag.currentX,
|
||||
@@ -2797,6 +2860,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}
|
||||
if (boardTarget.status || boardTarget.isPinDrop) {
|
||||
drag.latestStatusDropTarget = null
|
||||
setPointerLineageDropTargetId(null)
|
||||
setDragOverStatus(null)
|
||||
setPinDragOver(false)
|
||||
setWorktreeDragState((prev) =>
|
||||
@@ -2829,6 +2893,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
)
|
||||
if (preferredStatusTarget.lineageParentId) {
|
||||
updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null)
|
||||
setPointerLineageDropTargetId(preferredStatusTarget.lineageParentId)
|
||||
clearWorkspaceKanbanSidebarDropTargetVisual()
|
||||
setDragOverStatus(null)
|
||||
setPinDragOver(false)
|
||||
@@ -2848,6 +2913,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
)
|
||||
return
|
||||
}
|
||||
setPointerLineageDropTargetId(null)
|
||||
if (
|
||||
shouldPreferSidebarStatusDropTarget({
|
||||
sourceGroupKey: drag.sourceGroupKey,
|
||||
@@ -3004,7 +3070,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
scrollTop: container.scrollTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
elapsedMs: frameTime - previousFrameTime
|
||||
elapsedMs: frameTime - previousFrameTime,
|
||||
lineageTargetActive: Boolean(drag.latestStatusDropTarget?.target.lineageParentId)
|
||||
})
|
||||
if (autoscroll) {
|
||||
markScrollMovement()
|
||||
@@ -3045,7 +3112,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
sourceRow: drag.sourceRow,
|
||||
pointerX: drag.currentX,
|
||||
pointerY: drag.currentY,
|
||||
draggedCount: drag.draggedIds.length
|
||||
draggedCount: drag.explicitDraggedIds.length
|
||||
})
|
||||
drag.active = true
|
||||
drag.preview = preview
|
||||
@@ -3084,13 +3151,42 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
if (event.button !== 0 || event.pointerType === 'touch') {
|
||||
return
|
||||
}
|
||||
const sourceRow = event.currentTarget
|
||||
if (isSidebarPointerDragBlocked(event.target, sourceRow)) {
|
||||
const pointerSourceRow = event.currentTarget
|
||||
if (isSidebarPointerDragBlocked(event.target, pointerSourceRow)) {
|
||||
return
|
||||
}
|
||||
const sourceGroupKey = groupKeyByRowKey.get(rowKey)
|
||||
const container = scrollRef.current
|
||||
if (!sourceGroupKey || !container) {
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const explicitDraggedIds =
|
||||
selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1
|
||||
? selectedWorktrees.map((worktree) => worktree.id)
|
||||
: [worktreeId]
|
||||
const draggedIds = getLineageDragRootIds(explicitDraggedIds)
|
||||
const lineageRootWorktreeId = getLineageDragRootId(worktreeId, draggedIds)
|
||||
const pointerSourceGroupKey = groupKeyByRowKey.get(rowKey)
|
||||
const lineageRootSourceRow =
|
||||
lineageRootWorktreeId === worktreeId
|
||||
? pointerSourceRow
|
||||
: [...container.querySelectorAll<HTMLElement>('[data-worktree-lineage-drop-id]')].find(
|
||||
(candidate) =>
|
||||
candidate.getAttribute('data-worktree-lineage-drop-id') === lineageRootWorktreeId &&
|
||||
candidate.contains(pointerSourceRow)
|
||||
)
|
||||
const lineageRootSourceRowKey = lineageRootSourceRow?.getAttribute('data-worktree-row-key')
|
||||
const lineageRootSourceGroupKey = lineageRootSourceRowKey
|
||||
? groupKeyByRowKey.get(lineageRootSourceRowKey)
|
||||
: null
|
||||
const useLineageRootGeometry = Boolean(
|
||||
pointerSourceGroupKey && lineageRootSourceGroupKey === pointerSourceGroupKey
|
||||
)
|
||||
const draggingWorktreeId = useLineageRootGeometry ? lineageRootWorktreeId : worktreeId
|
||||
const sourceRow =
|
||||
useLineageRootGeometry && lineageRootSourceRow ? lineageRootSourceRow : pointerSourceRow
|
||||
const sourceRowKey = sourceRow.getAttribute('data-worktree-row-key') ?? rowKey
|
||||
const sourceGroupKey = groupKeyByRowKey.get(sourceRowKey)
|
||||
if (!sourceGroupKey) {
|
||||
return
|
||||
}
|
||||
const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey)
|
||||
@@ -3104,11 +3200,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
) {
|
||||
return
|
||||
}
|
||||
const draggedIds =
|
||||
selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1
|
||||
? selectedWorktrees.map((worktree) => worktree.id)
|
||||
: [worktreeId]
|
||||
const reorderDraggedIds = getReorderDraggedIds(draggedIds)
|
||||
const reorderDraggedIds = getReorderDraggedIds(
|
||||
useLineageRootGeometry ? draggedIds : explicitDraggedIds
|
||||
)
|
||||
const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds)
|
||||
worktreePointerDragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
@@ -3117,7 +3211,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
startY: event.clientY,
|
||||
currentX: event.clientX,
|
||||
currentY: event.clientY,
|
||||
worktreeId,
|
||||
worktreeId: draggingWorktreeId,
|
||||
explicitDraggedIds,
|
||||
draggedIds,
|
||||
reorderDraggedIds,
|
||||
reorderUnitDraggedIds,
|
||||
@@ -3136,6 +3231,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
[
|
||||
getReorderDraggedIds,
|
||||
getReorderUnitDraggedIds,
|
||||
getLineageDragRootId,
|
||||
getLineageDragRootIds,
|
||||
groupKeyByRowKey,
|
||||
onWorkspaceBoardDragPreviewStart,
|
||||
selectedWorktreeIds,
|
||||
@@ -3199,7 +3296,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
onWorkspaceBoardDragPreviewCommit()
|
||||
}
|
||||
if (boardDropTarget.isPinDrop) {
|
||||
onPinWorktrees(drag.draggedIds)
|
||||
onPinWorktrees(drag.explicitDraggedIds)
|
||||
} else if (boardDropTarget.status) {
|
||||
onDropWorktreesOnWorkspaceBoard({
|
||||
worktreeIds: drag.reorderDraggedIds,
|
||||
@@ -3223,8 +3320,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
: { status: null, isPinDrop: false, lineageParentId: null },
|
||||
drag.draggedIds
|
||||
)
|
||||
if (preferredStatusTarget.lineageParentId) {
|
||||
commitWorktreeLineageParentDrop(drag.draggedIds, preferredStatusTarget.lineageParentId)
|
||||
// Why: a move and release can share a frame; the live gutter must beat last-frame lineage.
|
||||
const currentReorderDrop = preferredStatusTarget.lineageParentId
|
||||
? null
|
||||
: computeWorktreeDrop(event.clientY)
|
||||
const lineageCommitTarget = resolveWorktreeSidebarStatusDropCommitTarget({
|
||||
currentTarget: preferredStatusTarget,
|
||||
currentPreview: null,
|
||||
currentReorderTargetActive: currentReorderDrop !== null,
|
||||
latestTrackedTarget: drag.latestStatusDropTarget,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
}).target.lineageParentId
|
||||
if (lineageCommitTarget) {
|
||||
commitWorktreeLineageParentDrop(drag.draggedIds, lineageCommitTarget)
|
||||
clearWorktreeDrag()
|
||||
return
|
||||
}
|
||||
@@ -3243,7 +3352,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
})
|
||||
: null
|
||||
if (preferredStatusTarget.isPinDrop) {
|
||||
onPinWorktrees(drag.draggedIds)
|
||||
onPinWorktrees(drag.explicitDraggedIds)
|
||||
} else if (preferredStatusTarget.status) {
|
||||
if (statusDrop) {
|
||||
onMoveWorktreesToStatusAtIndex({
|
||||
@@ -3259,7 +3368,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
clearWorktreeDrag()
|
||||
return
|
||||
}
|
||||
const drop = computeWorktreeDrop(event.clientY)
|
||||
const drop = currentReorderDrop
|
||||
if (drop) {
|
||||
onReorderWorktrees({
|
||||
groups: worktreeDragGroups,
|
||||
@@ -3287,6 +3396,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({
|
||||
currentTarget,
|
||||
currentPreview,
|
||||
currentReorderTargetActive: false,
|
||||
latestTrackedTarget: drag.latestStatusDropTarget,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
@@ -3294,7 +3404,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
if (target.lineageParentId) {
|
||||
commitWorktreeLineageParentDrop(drag.draggedIds, target.lineageParentId)
|
||||
} else if (target.isPinDrop) {
|
||||
onPinWorktrees(drag.draggedIds)
|
||||
onPinWorktrees(drag.explicitDraggedIds)
|
||||
} else if (target.status) {
|
||||
if (statusDrop) {
|
||||
onMoveWorktreesToStatusAtIndex({
|
||||
@@ -3477,7 +3587,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
if (!sourceGroupKey) {
|
||||
return
|
||||
}
|
||||
const reorderDraggedIds = getReorderDraggedIds(draggedIds)
|
||||
const lineageDragRootIds = getLineageDragRootIds(draggedIds)
|
||||
const reorderDraggedIds = getReorderDraggedIds(lineageDragRootIds)
|
||||
const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds)
|
||||
const rects = scrollRef.current
|
||||
? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey)
|
||||
@@ -3486,7 +3597,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
worktreeDragSessionRef.current = {
|
||||
draggingWorktreeId: worktreeId,
|
||||
sourceGroupKey,
|
||||
draggedIds,
|
||||
draggedIds: lineageDragRootIds,
|
||||
reorderDraggedIds,
|
||||
reorderUnitDraggedIds,
|
||||
rects,
|
||||
@@ -3505,7 +3616,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
pointerY: null
|
||||
})
|
||||
},
|
||||
[getReorderDraggedIds, getReorderUnitDraggedIds, worktreeDragGroups]
|
||||
[getLineageDragRootIds, getReorderDraggedIds, getReorderUnitDraggedIds, worktreeDragGroups]
|
||||
)
|
||||
|
||||
const handleWorktreeDragOver = useCallback(
|
||||
@@ -4806,8 +4917,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default'
|
||||
const isLineageDropTarget =
|
||||
worktreeDragState.draggingWorktreeId &&
|
||||
(worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId ===
|
||||
itemRow.worktree.id ||
|
||||
(pointerLineageDropTargetId === itemRow.worktree.id ||
|
||||
nativeLineageDropTargetId === itemRow.worktree.id)
|
||||
const isPinnedOverlayRow = itemRow.sectionKey === PINNED_GROUP_KEY
|
||||
const isActiveWorktree = activeWorktreeId === itemRow.worktree.id
|
||||
@@ -4822,6 +4932,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
data-worktree-id={itemRow.worktree.id}
|
||||
data-worktree-row-key={itemRow.rowKey}
|
||||
data-worktree-section-key={itemRow.sectionKey}
|
||||
data-worktree-lineage-drop-id={itemRow.worktree.id}
|
||||
data-worktree-drag-id={worktreeDragGroupKey ? itemRow.worktree.id : undefined}
|
||||
data-worktree-drag-group-key={worktreeDragGroupKey}
|
||||
data-worktree-drag-group-index={worktreeDragGroupIndex}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldStartWorkspaceBoardDragPreview } from './workspace-board-drag-preview-intent'
|
||||
|
||||
describe('workspace board drag preview intent', () => {
|
||||
it('keeps vertical sidebar reorders from mounting the board preview', () => {
|
||||
expect(
|
||||
shouldStartWorkspaceBoardDragPreview({
|
||||
pointerX: 132,
|
||||
startX: 96,
|
||||
sidebarRight: 285
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('starts the preview after a rightward drag reaches the sidebar edge zone', () => {
|
||||
expect(
|
||||
shouldStartWorkspaceBoardDragPreview({
|
||||
pointerX: 252,
|
||||
startX: 96,
|
||||
sidebarRight: 285
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores the edge zone until the drag moves right far enough', () => {
|
||||
expect(
|
||||
shouldStartWorkspaceBoardDragPreview({
|
||||
pointerX: 252,
|
||||
startX: 242,
|
||||
sidebarRight: 285
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a deliberate rightward drag beyond the sidebar edge', () => {
|
||||
expect(
|
||||
shouldStartWorkspaceBoardDragPreview({
|
||||
pointerX: 310,
|
||||
startX: 260,
|
||||
sidebarRight: 285
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
const WORKSPACE_BOARD_DRAG_PREVIEW_EDGE_ZONE_PX = 48
|
||||
const WORKSPACE_BOARD_DRAG_PREVIEW_MIN_RIGHTWARD_PX = 16
|
||||
|
||||
export function shouldStartWorkspaceBoardDragPreview(args: {
|
||||
pointerX: number
|
||||
startX: number
|
||||
sidebarRight: number
|
||||
}): boolean {
|
||||
return (
|
||||
args.pointerX >= args.sidebarRight - WORKSPACE_BOARD_DRAG_PREVIEW_EDGE_ZONE_PX &&
|
||||
args.pointerX - args.startX >= WORKSPACE_BOARD_DRAG_PREVIEW_MIN_RIGHTWARD_PX
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection'
|
||||
import {
|
||||
getReorderedWorktreeIdsToUnnest,
|
||||
getTopLevelWorktreeLineageDragIds,
|
||||
getWorktreeLineageDragRootId,
|
||||
getWorktreeLineageDropTargetId,
|
||||
isWorktreeLineageDropZoneHit
|
||||
} from './worktree-lineage-drag-drop'
|
||||
@@ -11,26 +15,175 @@ describe('isWorktreeLineageDropZoneHit', () => {
|
||||
it('keeps the top and bottom of a card available for reorder drops', () => {
|
||||
const rect = { top: 100, bottom: 200 } as DOMRect
|
||||
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 120, rect })).toBe(false)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 107, rect })).toBe(false)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 180, rect })).toBe(false)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 193, rect })).toBe(false)
|
||||
})
|
||||
|
||||
it('caps the parent-drop band on tall cards', () => {
|
||||
const rect = { top: 0, bottom: 180 } as DOMRect
|
||||
it('keeps expanded owned content available without moving the target to its center', () => {
|
||||
const rect = { top: 0, bottom: 500 } as DOMRect
|
||||
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 67, rect })).toBe(false)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 113, rect })).toBe(false)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 7, rect })).toBe(false)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 24, rect })).toBe(true)
|
||||
expect(isWorktreeLineageDropZoneHit({ pointerY: 476, rect })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorktreeLineageDropTargetId', () => {
|
||||
it('returns the row id only when the pointer is in the card content middle band', () => {
|
||||
it('targets the owned title and agent surface while preserving edge reorder gutters', () => {
|
||||
const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 })
|
||||
|
||||
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBeNull()
|
||||
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent')
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 106 })
|
||||
).toBeNull()
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 120 })
|
||||
).toBe('parent')
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 150 })
|
||||
).toBe('parent')
|
||||
})
|
||||
|
||||
it('keeps a tall parent target stable across every owned agent row', () => {
|
||||
const { container, surface } = makeTarget({
|
||||
worktreeId: 'parent',
|
||||
top: 100,
|
||||
bottom: 600
|
||||
})
|
||||
const agentRow = document.createElement('div')
|
||||
surface.appendChild(agentRow)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: agentRow,
|
||||
pointerX: 100,
|
||||
pointerY: 124
|
||||
})
|
||||
).toBe('parent')
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: agentRow,
|
||||
pointerX: 100,
|
||||
pointerY: 480
|
||||
})
|
||||
).toBe('parent')
|
||||
})
|
||||
|
||||
it('targets the deepest child surface and leaves lineage-container gaps for reorder', () => {
|
||||
const { container, surface } = makeTarget({
|
||||
worktreeId: 'parent',
|
||||
top: 100,
|
||||
bottom: 600
|
||||
})
|
||||
const children = document.createElement('div')
|
||||
children.setAttribute('data-worktree-lineage-children', '')
|
||||
setVerticalRect(children, 260, 600)
|
||||
surface.appendChild(children)
|
||||
const childRow = document.createElement('div')
|
||||
childRow.setAttribute('data-worktree-lineage-drop-id', 'child')
|
||||
const childSurface = document.createElement('div')
|
||||
childSurface.setAttribute('data-worktree-card-surface', 'true')
|
||||
setVerticalRect(childSurface, 300, 360)
|
||||
childRow.appendChild(childSurface)
|
||||
children.appendChild(childRow)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: childSurface,
|
||||
pointerX: 100,
|
||||
pointerY: 330
|
||||
})
|
||||
).toBe('child')
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: children,
|
||||
pointerX: 100,
|
||||
pointerY: 280
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves legacy lineage-container gaps for reorder', () => {
|
||||
const { container, surface } = makeTarget({
|
||||
worktreeId: 'parent',
|
||||
top: 100,
|
||||
bottom: 600
|
||||
})
|
||||
const children = document.createElement('div')
|
||||
children.setAttribute('data-worktree-legacy-lineage-children', '')
|
||||
setVerticalRect(children, 260, 600)
|
||||
surface.appendChild(children)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: children,
|
||||
pointerX: 100,
|
||||
pointerY: 280
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('targets pinned copies that are not reorder sources', () => {
|
||||
const { container, target, row } = makeTarget({
|
||||
worktreeId: 'pinned-parent',
|
||||
top: 100,
|
||||
bottom: 140
|
||||
})
|
||||
|
||||
expect(row.hasAttribute('data-worktree-drag-id')).toBe(false)
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 120 })
|
||||
).toBe('pinned-parent')
|
||||
})
|
||||
|
||||
it('ignores worktree surfaces while deletion is in progress', () => {
|
||||
const { container, target, surface } = makeTarget({
|
||||
worktreeId: 'deleting-parent',
|
||||
top: 100,
|
||||
bottom: 200
|
||||
})
|
||||
surface.setAttribute('aria-busy', 'true')
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 150 })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores child surfaces beneath a deleting ancestor overlay', () => {
|
||||
const {
|
||||
container,
|
||||
target: overlay,
|
||||
surface
|
||||
} = makeTarget({
|
||||
worktreeId: 'deleting-parent',
|
||||
top: 100,
|
||||
bottom: 300
|
||||
})
|
||||
surface.setAttribute('aria-busy', 'true')
|
||||
const children = document.createElement('div')
|
||||
children.setAttribute('data-worktree-lineage-children', '')
|
||||
const childRow = document.createElement('div')
|
||||
childRow.setAttribute('data-worktree-lineage-drop-id', 'child')
|
||||
const childSurface = document.createElement('div')
|
||||
childSurface.setAttribute('data-worktree-card-surface', 'true')
|
||||
setVerticalRect(childSurface, 160, 220)
|
||||
childRow.appendChild(childSurface)
|
||||
children.appendChild(childRow)
|
||||
surface.appendChild(children)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: overlay,
|
||||
pointerX: 100,
|
||||
pointerY: 180
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores content targets outside the sidebar container', () => {
|
||||
@@ -41,7 +194,60 @@ describe('getWorktreeLineageDropTargetId', () => {
|
||||
contained: false
|
||||
})
|
||||
|
||||
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull()
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({ container, target, pointerX: 100, pointerY: 150 })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTopLevelWorktreeLineageDragIds', () => {
|
||||
it('keeps selected subtrees intact when an ancestor and descendant are selected', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
const grandchild = makeWorktree('grandchild')
|
||||
const root = makeWorktree('root')
|
||||
const lineageById = {
|
||||
[child.id]: makeLineage(child, parent),
|
||||
[grandchild.id]: makeLineage(grandchild, child)
|
||||
}
|
||||
const worktreeMap = new Map([parent, child, grandchild, root].map((item) => [item.id, item]))
|
||||
|
||||
expect(
|
||||
getTopLevelWorktreeLineageDragIds({
|
||||
draggedIds: ['grandchild', 'root', 'parent', 'child', 'root'],
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
})
|
||||
).toEqual(['root', 'parent'])
|
||||
})
|
||||
|
||||
it('uses the selected ancestor root as geometry when its child starts the drag', () => {
|
||||
const parent = makeWorktree('parent')
|
||||
const child = makeWorktree('child')
|
||||
const other = makeWorktree('other')
|
||||
const lineageById = { [child.id]: makeLineage(child, parent) }
|
||||
const worktreeMap = new Map([parent, child, other].map((item) => [item.id, item]))
|
||||
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDragRootId({
|
||||
worktreeId: child.id,
|
||||
lineageRootIds: [parent.id, other.id],
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds
|
||||
})
|
||||
).toBe(parent.id)
|
||||
expect(
|
||||
getWorktreeLineageDragRootId({
|
||||
worktreeId: other.id,
|
||||
lineageRootIds: [parent.id, other.id],
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
cyclicLineageIds
|
||||
})
|
||||
).toBe(other.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,21 +376,26 @@ function makeTarget(args: {
|
||||
}): {
|
||||
container: HTMLElement
|
||||
target: Element
|
||||
row: HTMLElement
|
||||
surface: HTMLElement
|
||||
} {
|
||||
const row = {
|
||||
getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null)
|
||||
} as HTMLElement
|
||||
const content = {
|
||||
getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }),
|
||||
closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null)
|
||||
} as HTMLElement
|
||||
const target = {
|
||||
closest: (selector: string) =>
|
||||
selector === '[data-worktree-card-hover-trigger]' ? content : null
|
||||
} as Element
|
||||
const contained = args.contained ?? true
|
||||
const container = {
|
||||
contains: (element: Element) => contained && (element === content || element === row)
|
||||
} as HTMLElement
|
||||
return { container, target }
|
||||
const container = document.createElement('div')
|
||||
setVerticalRect(container, 0, 1_000)
|
||||
const row = document.createElement('div')
|
||||
row.setAttribute('data-worktree-lineage-drop-id', args.worktreeId)
|
||||
const surface = document.createElement('div')
|
||||
surface.setAttribute('data-worktree-card-surface', 'true')
|
||||
const target = document.createElement('span')
|
||||
surface.appendChild(target)
|
||||
row.appendChild(surface)
|
||||
if (args.contained ?? true) {
|
||||
container.appendChild(row)
|
||||
}
|
||||
setVerticalRect(surface, args.top, args.bottom)
|
||||
return { container, target, row, surface }
|
||||
}
|
||||
|
||||
function setVerticalRect(element: HTMLElement, top: number, bottom: number): void {
|
||||
element.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 200, top, bottom, height: bottom - top }) as DOMRect
|
||||
}
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { getLineageRenderInfo } from './worktree-lineage-projection'
|
||||
import {
|
||||
getWorktreeSidebarStaticRect,
|
||||
getWorktreeSidebarVirtualRowStart,
|
||||
type WorktreeSidebarStaticGeometry,
|
||||
type WorktreeSidebarStaticRect
|
||||
} from './worktree-sidebar-static-geometry'
|
||||
|
||||
const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]'
|
||||
const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]'
|
||||
const WORKTREE_CARD_SURFACE_SELECTOR = '[data-worktree-card-surface]'
|
||||
const WORKTREE_BUSY_CARD_SURFACE_SELECTOR = `${WORKTREE_CARD_SURFACE_SELECTOR}[aria-busy='true']`
|
||||
const WORKTREE_LINEAGE_CHILDREN_SELECTOR =
|
||||
'[data-worktree-lineage-children],[data-worktree-legacy-lineage-children]'
|
||||
const WORKTREE_LINEAGE_DROP_ROW_SELECTOR = '[data-worktree-lineage-drop-id]'
|
||||
const WORKTREE_STICKY_HEADER_SELECTOR = '[data-worktree-sticky-header-active]'
|
||||
const WORKTREE_VIRTUAL_ROW_SELECTOR = '[data-worktree-virtual-row]'
|
||||
const WORKTREE_CARD_PARENT_CONTENT_SELECTOR = '[data-worktree-card-parent-content]'
|
||||
|
||||
const LINEAGE_DROP_ZONE_RATIO = 0.4
|
||||
const LINEAGE_DROP_ZONE_MAX_HEIGHT_PX = 44
|
||||
const LINEAGE_REORDER_GUTTER_PX = 8
|
||||
const LINEAGE_REORDER_GUTTER_RATIO = 0.25
|
||||
|
||||
type VerticalRect = Pick<DOMRect, 'top' | 'bottom'>
|
||||
type PointerPosition = { pointerX: number; pointerY: number }
|
||||
|
||||
type StaticVirtualRowHit = {
|
||||
foundVirtualRows: boolean
|
||||
row: HTMLElement | null
|
||||
}
|
||||
|
||||
export function isWorktreeLineageDropZoneHit(args: {
|
||||
pointerY: number
|
||||
@@ -18,38 +36,246 @@ export function isWorktreeLineageDropZoneHit(args: {
|
||||
return false
|
||||
}
|
||||
|
||||
const zoneHeight = Math.min(height * LINEAGE_DROP_ZONE_RATIO, LINEAGE_DROP_ZONE_MAX_HEIGHT_PX)
|
||||
const zoneTop = args.rect.top + (height - zoneHeight) / 2
|
||||
const zoneBottom = args.rect.bottom - (height - zoneHeight) / 2
|
||||
const gutter = Math.min(LINEAGE_REORDER_GUTTER_PX, height * LINEAGE_REORDER_GUTTER_RATIO)
|
||||
const zoneTop = args.rect.top + gutter
|
||||
const zoneBottom = args.rect.bottom - gutter
|
||||
return args.pointerY >= zoneTop && args.pointerY <= zoneBottom
|
||||
}
|
||||
|
||||
export function getWorktreeLineageDropTargetId(args: {
|
||||
container: HTMLElement
|
||||
target: Element
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
}): string | null {
|
||||
const contentTarget = args.target.closest<HTMLElement>(WORKTREE_CARD_CONTENT_TARGET_SELECTOR)
|
||||
if (!contentTarget || !args.container.contains(contentTarget)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: nesting should be deliberate; the top/bottom of a card stays available
|
||||
// for reorder drops instead of treating the whole card as a parent target.
|
||||
if (
|
||||
!isWorktreeLineageDropZoneHit({
|
||||
pointerY: args.pointerY,
|
||||
rect: contentTarget.getBoundingClientRect()
|
||||
})
|
||||
!args.container.contains(args.target) ||
|
||||
args.target.closest<HTMLElement>(WORKTREE_STICKY_HEADER_SELECTOR)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rowTarget = contentTarget.closest<HTMLElement>(WORKTREE_DRAG_ROW_SELECTOR)
|
||||
if (!rowTarget || !args.container.contains(rowTarget)) {
|
||||
const containerRect = args.container.getBoundingClientRect()
|
||||
if (!isPointInRect(args, containerRect)) {
|
||||
return null
|
||||
}
|
||||
return rowTarget.getAttribute('data-worktree-drag-id')
|
||||
|
||||
const geometry: WorktreeSidebarStaticGeometry = {
|
||||
containerTop: containerRect.top,
|
||||
scrollTop: args.container.scrollTop
|
||||
}
|
||||
const virtualRowHit = getStaticVirtualRowAtPoint(args, geometry)
|
||||
if (virtualRowHit.foundVirtualRows) {
|
||||
const cardSurface = virtualRowHit.row?.querySelector<HTMLElement>(
|
||||
WORKTREE_CARD_SURFACE_SELECTOR
|
||||
)
|
||||
return cardSurface ? getLineageTargetFromSurface(args, cardSurface, geometry) : null
|
||||
}
|
||||
|
||||
const cardSurface = args.target.closest<HTMLElement>(WORKTREE_CARD_SURFACE_SELECTOR)
|
||||
return cardSurface && args.container.contains(cardSurface)
|
||||
? getLineageTargetFromSurface(args, cardSurface, geometry)
|
||||
: null
|
||||
}
|
||||
|
||||
function getLineageTargetFromSurface(
|
||||
point: PointerPosition & { container: HTMLElement },
|
||||
cardSurface: HTMLElement,
|
||||
geometry: WorktreeSidebarStaticGeometry
|
||||
): string | null {
|
||||
const rect = getWorktreeSidebarStaticRect(point.container, cardSurface, geometry)
|
||||
if (cardSurface.closest(WORKTREE_BUSY_CARD_SURFACE_SELECTOR) || !isPointInRect(point, rect)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lineageChildren = getOwnedLineageChildren(cardSurface)
|
||||
if (
|
||||
lineageChildren &&
|
||||
isPointInRect(point, getWorktreeSidebarStaticRect(point.container, lineageChildren, geometry))
|
||||
) {
|
||||
const childSurface = findChildSurfaceAtPoint(point, lineageChildren, point.container, geometry)
|
||||
return childSurface ? getLineageTargetFromSurface(point, childSurface, geometry) : null
|
||||
}
|
||||
|
||||
if (!isWorktreeLineageDropZoneHit({ pointerY: point.pointerY, rect })) {
|
||||
return null
|
||||
}
|
||||
const rowTarget = cardSurface.closest<HTMLElement>(WORKTREE_LINEAGE_DROP_ROW_SELECTOR)
|
||||
return rowTarget && point.container.contains(rowTarget)
|
||||
? rowTarget.getAttribute('data-worktree-lineage-drop-id')
|
||||
: null
|
||||
}
|
||||
|
||||
function getStaticVirtualRowAtPoint(
|
||||
point: PointerPosition & { container: HTMLElement; target: Element },
|
||||
geometry: WorktreeSidebarStaticGeometry
|
||||
): StaticVirtualRowHit {
|
||||
const targetRow = point.target.closest<HTMLElement>(WORKTREE_VIRTUAL_ROW_SELECTOR)
|
||||
if (
|
||||
targetRow &&
|
||||
point.container.contains(targetRow) &&
|
||||
isPointInRect(point, getWorktreeSidebarStaticRect(point.container, targetRow, geometry))
|
||||
) {
|
||||
return { foundVirtualRows: true, row: targetRow }
|
||||
}
|
||||
|
||||
const firstChild = point.container.firstElementChild
|
||||
const rowsHost =
|
||||
targetRow?.parentElement ??
|
||||
(firstChild?.matches(WORKTREE_VIRTUAL_ROW_SELECTOR) ? point.container : firstChild)
|
||||
if (!(rowsHost instanceof HTMLElement)) {
|
||||
return { foundVirtualRows: false, row: null }
|
||||
}
|
||||
|
||||
const pointerContentY = point.pointerY - geometry.containerTop + geometry.scrollTop
|
||||
let foundVirtualRows = false
|
||||
let candidate: HTMLElement | null = null
|
||||
for (const child of rowsHost.children) {
|
||||
if (!(child instanceof HTMLElement) || !child.matches(WORKTREE_VIRTUAL_ROW_SELECTOR)) {
|
||||
continue
|
||||
}
|
||||
foundVirtualRows = true
|
||||
const start = getWorktreeSidebarVirtualRowStart(child)
|
||||
if (start === null) {
|
||||
continue
|
||||
}
|
||||
if (start > pointerContentY) {
|
||||
break
|
||||
}
|
||||
candidate = child
|
||||
}
|
||||
return { foundVirtualRows, row: candidate }
|
||||
}
|
||||
|
||||
function getOwnedLineageChildren(cardSurface: HTMLElement): HTMLElement | null {
|
||||
const directCandidate = cardSurface.lastElementChild
|
||||
if (
|
||||
directCandidate instanceof HTMLElement &&
|
||||
directCandidate.matches(WORKTREE_LINEAGE_CHILDREN_SELECTOR)
|
||||
) {
|
||||
return directCandidate
|
||||
}
|
||||
|
||||
// Why: legacy lineage is last in the content column; avoid walking long agent subtrees.
|
||||
const parentContent = cardSurface.querySelector<HTMLElement>(
|
||||
WORKTREE_CARD_PARENT_CONTENT_SELECTOR
|
||||
)
|
||||
const contentColumn = parentContent?.lastElementChild
|
||||
const legacyCandidate = contentColumn?.lastElementChild
|
||||
return legacyCandidate instanceof HTMLElement &&
|
||||
legacyCandidate.matches(WORKTREE_LINEAGE_CHILDREN_SELECTOR)
|
||||
? legacyCandidate
|
||||
: null
|
||||
}
|
||||
|
||||
function findChildSurfaceAtPoint(
|
||||
point: PointerPosition,
|
||||
lineageChildren: HTMLElement,
|
||||
container: HTMLElement,
|
||||
geometry: WorktreeSidebarStaticGeometry
|
||||
): HTMLElement | null {
|
||||
const rows = lineageChildren.children
|
||||
let low = 0
|
||||
let high = rows.length - 1
|
||||
let candidate: HTMLElement | null = null
|
||||
let candidateRect: WorktreeSidebarStaticRect | null = null
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2)
|
||||
const childRow = rows.item(middle)
|
||||
const childSurface = childRow?.querySelector<HTMLElement>(WORKTREE_CARD_SURFACE_SELECTOR)
|
||||
if (!childSurface) {
|
||||
return null
|
||||
}
|
||||
const rect = getWorktreeSidebarStaticRect(container, childSurface, geometry)
|
||||
if (rect.top <= point.pointerY) {
|
||||
candidate = childSurface
|
||||
candidateRect = rect
|
||||
low = middle + 1
|
||||
} else {
|
||||
high = middle - 1
|
||||
}
|
||||
}
|
||||
if (!candidate || !candidateRect) {
|
||||
return null
|
||||
}
|
||||
return isPointInRect(point, candidateRect) ? candidate : null
|
||||
}
|
||||
|
||||
function isPointInRect(
|
||||
point: PointerPosition,
|
||||
rect: Pick<WorktreeSidebarStaticRect, 'left' | 'right' | 'top' | 'bottom'>
|
||||
): boolean {
|
||||
return (
|
||||
point.pointerX >= rect.left &&
|
||||
point.pointerX <= rect.right &&
|
||||
point.pointerY >= rect.top &&
|
||||
point.pointerY <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
export function getTopLevelWorktreeLineageDragIds(args: {
|
||||
draggedIds: readonly string[]
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
}): string[] {
|
||||
const selectedIds = new Set(args.draggedIds)
|
||||
const included = new Set<string>()
|
||||
const roots: string[] = []
|
||||
for (const id of args.draggedIds) {
|
||||
if (included.has(id)) {
|
||||
continue
|
||||
}
|
||||
included.add(id)
|
||||
let current = args.worktreeMap.get(id)
|
||||
let selectedAncestor = false
|
||||
while (current) {
|
||||
const lineage = getLineageRenderInfo(
|
||||
current,
|
||||
args.lineageById,
|
||||
args.worktreeMap,
|
||||
args.cyclicLineageIds
|
||||
)
|
||||
if (lineage.state !== 'valid') {
|
||||
break
|
||||
}
|
||||
if (selectedIds.has(lineage.parent.id)) {
|
||||
selectedAncestor = true
|
||||
break
|
||||
}
|
||||
current = lineage.parent
|
||||
}
|
||||
if (!selectedAncestor) {
|
||||
roots.push(id)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
export function getWorktreeLineageDragRootId(args: {
|
||||
worktreeId: string
|
||||
lineageRootIds: readonly string[]
|
||||
lineageById: Readonly<Record<string, WorktreeLineage>>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
}): string {
|
||||
const rootIds = new Set(args.lineageRootIds)
|
||||
let current = args.worktreeMap.get(args.worktreeId)
|
||||
while (current) {
|
||||
if (rootIds.has(current.id)) {
|
||||
return current.id
|
||||
}
|
||||
const lineage = getLineageRenderInfo(
|
||||
current,
|
||||
args.lineageById,
|
||||
args.worktreeMap,
|
||||
args.cyclicLineageIds
|
||||
)
|
||||
if (lineage.state !== 'valid') {
|
||||
break
|
||||
}
|
||||
current = lineage.parent
|
||||
}
|
||||
return args.worktreeId
|
||||
}
|
||||
|
||||
export function getReorderedWorktreeIdsToUnnest(args: {
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorktreeLineageDropTargetId } from './worktree-lineage-drag-drop'
|
||||
import { getWorktreeSidebarStaticRect } from './worktree-sidebar-static-geometry'
|
||||
|
||||
describe('static worktree lineage hit testing', () => {
|
||||
it('keeps a tall target under the pointer when reorder preview moves it away', () => {
|
||||
const container = makeContainer({ scrollTop: 642, top: 200, bottom: 1_000 })
|
||||
const virtualRow = makeVirtualRow({ start: 1_280, top: 158 })
|
||||
const { row, surface } = makeWorktreeCard('target', {
|
||||
left: 56,
|
||||
right: 204,
|
||||
top: 158,
|
||||
bottom: 838
|
||||
})
|
||||
virtualRow.appendChild(row)
|
||||
container.appendChild(virtualRow)
|
||||
|
||||
expect(getWorktreeSidebarStaticRect(container, surface)).toMatchObject({
|
||||
top: 838,
|
||||
bottom: 1_518
|
||||
})
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: container,
|
||||
pointerX: 100,
|
||||
pointerY: 883
|
||||
})
|
||||
).toBe('target')
|
||||
})
|
||||
|
||||
it('uses static intent when a shifted sibling is visually under the pointer', () => {
|
||||
const container = makeContainer({ scrollTop: 642, top: 200, bottom: 1_000 })
|
||||
const targetVirtualRow = makeVirtualRow({ start: 1_280, top: 158 })
|
||||
const target = makeWorktreeCard('target', {
|
||||
left: 56,
|
||||
right: 204,
|
||||
top: 158,
|
||||
bottom: 838
|
||||
})
|
||||
targetVirtualRow.appendChild(target.row)
|
||||
container.appendChild(targetVirtualRow)
|
||||
|
||||
const siblingVirtualRow = makeVirtualRow({ start: 2_000, top: 850 })
|
||||
const sibling = makeWorktreeCard('shifted-sibling', {
|
||||
left: 56,
|
||||
right: 204,
|
||||
top: 850,
|
||||
bottom: 930
|
||||
})
|
||||
siblingVirtualRow.appendChild(sibling.row)
|
||||
container.appendChild(siblingVirtualRow)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: sibling.surface,
|
||||
pointerX: 100,
|
||||
pointerY: 883
|
||||
})
|
||||
).toBe('target')
|
||||
})
|
||||
|
||||
it('chooses the deepest child and keeps lineage-container gaps reorderable', () => {
|
||||
const container = makeContainer({ scrollTop: 200, top: 100, bottom: 1_000 })
|
||||
const virtualRow = makeVirtualRow({ start: 600, top: 120 })
|
||||
const parent = makeWorktreeCard('parent', {
|
||||
left: 40,
|
||||
right: 220,
|
||||
top: 120,
|
||||
bottom: 520
|
||||
})
|
||||
const children = document.createElement('div')
|
||||
children.setAttribute('data-worktree-lineage-children', '')
|
||||
setRect(children, { left: 48, right: 212, top: 250, bottom: 520 })
|
||||
const child = makeWorktreeCard('child', {
|
||||
left: 56,
|
||||
right: 204,
|
||||
top: 300,
|
||||
bottom: 380
|
||||
})
|
||||
children.appendChild(child.row)
|
||||
parent.surface.appendChild(children)
|
||||
virtualRow.appendChild(parent.row)
|
||||
container.appendChild(virtualRow)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: container,
|
||||
pointerX: 100,
|
||||
pointerY: 720
|
||||
})
|
||||
).toBe('child')
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: container,
|
||||
pointerX: 100,
|
||||
pointerY: 650
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves reorder gutters and rejects deleting targets', () => {
|
||||
const container = makeContainer({ scrollTop: 0, top: 100, bottom: 500 })
|
||||
const { row, surface } = makeWorktreeCard('target', {
|
||||
left: 40,
|
||||
right: 220,
|
||||
top: 160,
|
||||
bottom: 260
|
||||
})
|
||||
container.appendChild(row)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: surface,
|
||||
pointerX: 100,
|
||||
pointerY: 165
|
||||
})
|
||||
).toBeNull()
|
||||
surface.setAttribute('aria-busy', 'true')
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: surface,
|
||||
pointerX: 100,
|
||||
pointerY: 210
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('measures only the pointed lineage branch in a wide nested list', () => {
|
||||
let containerRectReads = 0
|
||||
let surfaceRectReads = 0
|
||||
const container = makeContainer({ scrollTop: 0, top: 0, bottom: 500 })
|
||||
setRect(container, { left: 20, right: 240, top: 0, bottom: 500 }, () => containerRectReads++)
|
||||
const virtualRow = makeVirtualRow({ start: 0, top: 0 })
|
||||
const parent = makeWorktreeCard('parent', {
|
||||
left: 40,
|
||||
right: 220,
|
||||
top: 20,
|
||||
bottom: 21_000
|
||||
})
|
||||
setRect(
|
||||
parent.surface,
|
||||
{ left: 40, right: 220, top: 20, bottom: 21_000 },
|
||||
() => surfaceRectReads++
|
||||
)
|
||||
const children = document.createElement('div')
|
||||
children.setAttribute('data-worktree-lineage-children', '')
|
||||
setRect(children, { left: 48, right: 212, top: 80, bottom: 20_980 })
|
||||
for (let index = 0; index < 256; index++) {
|
||||
const top = 100 + index * 80
|
||||
const child = makeWorktreeCard(`child-${index}`, {
|
||||
left: 56,
|
||||
right: 204,
|
||||
top,
|
||||
bottom: top + 60
|
||||
})
|
||||
setRect(
|
||||
child.surface,
|
||||
{ left: 56, right: 204, top, bottom: top + 60 },
|
||||
() => surfaceRectReads++
|
||||
)
|
||||
children.appendChild(child.row)
|
||||
}
|
||||
parent.surface.appendChild(children)
|
||||
virtualRow.appendChild(parent.row)
|
||||
container.appendChild(virtualRow)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: container,
|
||||
pointerX: 100,
|
||||
pointerY: 130
|
||||
})
|
||||
).toBe('child-0')
|
||||
expect(containerRectReads).toBe(1)
|
||||
expect(surfaceRectReads).toBeLessThanOrEqual(12)
|
||||
})
|
||||
|
||||
it('targets the deepest node with reads bounded by depth and sibling width', () => {
|
||||
const depth = 8
|
||||
const siblingsPerLevel = 16
|
||||
let surfaceRectReads = 0
|
||||
const container = makeContainer({ scrollTop: 0, top: 0, bottom: 600 })
|
||||
const virtualRow = makeVirtualRow({ start: 0, top: 0 })
|
||||
const root = makeWorktreeCard('root', {
|
||||
left: 40,
|
||||
right: 220,
|
||||
top: 20,
|
||||
bottom: 3_000
|
||||
})
|
||||
setRect(root.surface, { left: 40, right: 220, top: 20, bottom: 3_000 }, () => {
|
||||
surfaceRectReads++
|
||||
})
|
||||
let branchSurface = root.surface
|
||||
let branchBottom = 3_000
|
||||
let deepestId = 'root'
|
||||
for (let level = 1; level <= depth; level++) {
|
||||
const parentSurface = branchSurface
|
||||
const children = document.createElement('div')
|
||||
children.setAttribute('data-worktree-lineage-children', '')
|
||||
setRect(children, {
|
||||
left: 48,
|
||||
right: 212,
|
||||
top: 60 + level * 4,
|
||||
bottom: branchBottom - 1
|
||||
})
|
||||
const nextBottom = branchBottom - 100
|
||||
for (let sibling = 0; sibling < siblingsPerLevel; sibling++) {
|
||||
const worktreeId = sibling === 0 ? `branch-${level}` : `decoy-${level}-${sibling}`
|
||||
const top = sibling === 0 ? 80 + level * 4 : nextBottom + sibling * 2
|
||||
const bottom = sibling === 0 ? nextBottom : top + 1
|
||||
const child = makeWorktreeCard(worktreeId, { left: 56, right: 204, top, bottom })
|
||||
setRect(child.surface, { left: 56, right: 204, top, bottom }, () => {
|
||||
surfaceRectReads++
|
||||
})
|
||||
children.appendChild(child.row)
|
||||
if (sibling === 0) {
|
||||
branchSurface = child.surface
|
||||
deepestId = worktreeId
|
||||
}
|
||||
}
|
||||
parentSurface.appendChild(children)
|
||||
branchBottom = nextBottom
|
||||
}
|
||||
virtualRow.appendChild(root.row)
|
||||
container.appendChild(virtualRow)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: container,
|
||||
pointerX: 100,
|
||||
pointerY: 300
|
||||
})
|
||||
).toBe(deepestId)
|
||||
const logarithmicReadsPerLevel = Math.ceil(Math.log2(siblingsPerLevel)) + 2
|
||||
expect(surfaceRectReads).toBeLessThanOrEqual(1 + depth * logarithmicReadsPerLevel)
|
||||
})
|
||||
|
||||
it('descends legacy lineage after a long agent list', () => {
|
||||
const container = makeContainer({ scrollTop: 0, top: 0, bottom: 600 })
|
||||
const virtualRow = makeVirtualRow({ start: 0, top: 0 })
|
||||
const parent = makeWorktreeCard('parent', {
|
||||
left: 40,
|
||||
right: 220,
|
||||
top: 20,
|
||||
bottom: 500
|
||||
})
|
||||
const parentContent = document.createElement('div')
|
||||
parentContent.setAttribute('data-worktree-card-parent-content', '')
|
||||
const statusSlot = document.createElement('div')
|
||||
const contentColumn = document.createElement('div')
|
||||
const agentList = document.createElement('div')
|
||||
for (let index = 0; index < 128; index++) {
|
||||
agentList.appendChild(document.createElement('div'))
|
||||
}
|
||||
const legacyChildren = document.createElement('div')
|
||||
legacyChildren.setAttribute('data-worktree-legacy-lineage-children', '')
|
||||
setRect(legacyChildren, { left: 48, right: 212, top: 300, bottom: 500 })
|
||||
const child = makeWorktreeCard('child', {
|
||||
left: 56,
|
||||
right: 204,
|
||||
top: 340,
|
||||
bottom: 420
|
||||
})
|
||||
legacyChildren.appendChild(child.row)
|
||||
contentColumn.append(agentList, legacyChildren)
|
||||
parentContent.append(statusSlot, contentColumn)
|
||||
parent.surface.appendChild(parentContent)
|
||||
virtualRow.appendChild(parent.row)
|
||||
container.appendChild(virtualRow)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: child.surface,
|
||||
pointerX: 100,
|
||||
pointerY: 380
|
||||
})
|
||||
).toBe('child')
|
||||
})
|
||||
|
||||
it('does not hit shifted cards through chrome outside the scroller', () => {
|
||||
const container = makeContainer({ scrollTop: 642, top: 200, bottom: 1_000 })
|
||||
const virtualRow = makeVirtualRow({ start: 1_280, top: 158 })
|
||||
const { row } = makeWorktreeCard('target', {
|
||||
left: 56,
|
||||
right: 204,
|
||||
top: 158,
|
||||
bottom: 838
|
||||
})
|
||||
virtualRow.appendChild(row)
|
||||
container.appendChild(virtualRow)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: container,
|
||||
pointerX: 100,
|
||||
pointerY: 165
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not hit static cards through an active sticky header', () => {
|
||||
const container = makeContainer({ scrollTop: 642, top: 200, bottom: 1_000 })
|
||||
const virtualRow = makeVirtualRow({ start: 1_280, top: 158 })
|
||||
const { row } = makeWorktreeCard('target', {
|
||||
left: 56,
|
||||
right: 204,
|
||||
top: 158,
|
||||
bottom: 838
|
||||
})
|
||||
virtualRow.appendChild(row)
|
||||
container.appendChild(virtualRow)
|
||||
const stickyHeader = document.createElement('div')
|
||||
stickyHeader.setAttribute('data-worktree-sticky-header-active', '')
|
||||
container.appendChild(stickyHeader)
|
||||
|
||||
expect(
|
||||
getWorktreeLineageDropTargetId({
|
||||
container,
|
||||
target: stickyHeader,
|
||||
pointerX: 100,
|
||||
pointerY: 883
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function makeContainer(args: { scrollTop: number; top: number; bottom: number }): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
container.scrollTop = args.scrollTop
|
||||
setRect(container, { left: 20, right: 240, top: args.top, bottom: args.bottom })
|
||||
return container
|
||||
}
|
||||
|
||||
function makeVirtualRow(args: { start: number; top: number }): HTMLElement {
|
||||
const row = document.createElement('div')
|
||||
row.setAttribute('data-worktree-virtual-row', '')
|
||||
row.setAttribute('data-worktree-virtual-row-start', String(args.start))
|
||||
setRect(row, { left: 20, right: 240, top: args.top, bottom: args.top + 680 })
|
||||
return row
|
||||
}
|
||||
|
||||
function makeWorktreeCard(
|
||||
worktreeId: string,
|
||||
rect: { left: number; right: number; top: number; bottom: number }
|
||||
): { row: HTMLElement; surface: HTMLElement } {
|
||||
const row = document.createElement('div')
|
||||
row.setAttribute('data-worktree-lineage-drop-id', worktreeId)
|
||||
const surface = document.createElement('div')
|
||||
surface.setAttribute('data-worktree-card-surface', 'true')
|
||||
setRect(surface, rect)
|
||||
row.appendChild(surface)
|
||||
return { row, surface }
|
||||
}
|
||||
|
||||
function setRect(
|
||||
element: HTMLElement,
|
||||
rect: { left: number; right: number; top: number; bottom: number },
|
||||
onRead?: () => void
|
||||
): void {
|
||||
element.getBoundingClientRect = () => {
|
||||
onRead?.()
|
||||
return {
|
||||
...rect,
|
||||
height: rect.bottom - rect.top,
|
||||
width: rect.right - rect.left
|
||||
} as DOMRect
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getWorktreeSidebarBoundaryDrop,
|
||||
getWorktreeSidebarDragRectsForGroup,
|
||||
refreshWorktreeSidebarDragSession,
|
||||
shouldHoldWorktreeSidebarRowsAtPointerEdge,
|
||||
type WorktreeSidebarDragRect,
|
||||
type WorktreeSidebarDragSession
|
||||
} from './worktree-sidebar-drag-autoscroll'
|
||||
@@ -26,6 +27,57 @@ const SESSION: WorktreeSidebarDragSession = {
|
||||
anchor: null
|
||||
}
|
||||
|
||||
describe('shouldHoldWorktreeSidebarRowsAtPointerEdge', () => {
|
||||
it('holds preview rows throughout an overflowing edge zone', () => {
|
||||
expect(
|
||||
shouldHoldWorktreeSidebarRowsAtPointerEdge({
|
||||
point: { clientX: 80, clientY: 488 },
|
||||
containerRect: CONTAINER_RECT,
|
||||
scrollHeight: 1_000,
|
||||
clientHeight: 400
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps rows held when autoscroll reaches the list boundary', () => {
|
||||
const point = { clientX: 80, clientY: 488 }
|
||||
|
||||
expect(getAutoscroll(point, { scrollTop: 600 })).toBeNull()
|
||||
expect(
|
||||
shouldHoldWorktreeSidebarRowsAtPointerEdge({
|
||||
point,
|
||||
containerRect: CONTAINER_RECT,
|
||||
scrollHeight: 1_000,
|
||||
clientHeight: 400
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps normal previews in the interior, outside the sidebar, and without overflow', () => {
|
||||
const base = {
|
||||
point: { clientX: 80, clientY: 300 },
|
||||
containerRect: CONTAINER_RECT,
|
||||
scrollHeight: 1_000,
|
||||
clientHeight: 400
|
||||
}
|
||||
|
||||
expect(shouldHoldWorktreeSidebarRowsAtPointerEdge(base)).toBe(false)
|
||||
expect(
|
||||
shouldHoldWorktreeSidebarRowsAtPointerEdge({
|
||||
...base,
|
||||
point: { clientX: 4, clientY: 488 }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldHoldWorktreeSidebarRowsAtPointerEdge({
|
||||
...base,
|
||||
point: { clientX: 80, clientY: 488 },
|
||||
scrollHeight: 400
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorktreeSidebarDragAutoscroll', () => {
|
||||
it('scrolls up near the top edge', () => {
|
||||
expect(getAutoscroll({ clientX: 80, clientY: 112 })?.scrollTop).toBeCloseTo(187.93, 2)
|
||||
@@ -39,6 +91,13 @@ describe('getWorktreeSidebarDragAutoscroll', () => {
|
||||
expect(getAutoscroll({ clientX: 80, clientY: 300 })).toBeNull()
|
||||
})
|
||||
|
||||
it('holds an active lineage target unless the pointer reaches the extreme edge', () => {
|
||||
expect(getAutoscroll({ clientX: 80, clientY: 470 }, { lineageTargetActive: true })).toBeNull()
|
||||
expect(
|
||||
getAutoscroll({ clientX: 80, clientY: 496 }, { lineageTargetActive: true })?.scrollTop
|
||||
).toBeGreaterThan(200)
|
||||
})
|
||||
|
||||
it('does nothing when the pointer is outside horizontally', () => {
|
||||
expect(getAutoscroll({ clientX: 4, clientY: 488 })).toBeNull()
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ import type {
|
||||
WorktreeSidebarDragGrab,
|
||||
WorktreeSidebarDropAnchor
|
||||
} from './worktree-sidebar-drag-geometry'
|
||||
import { getWorktreeSidebarStaticRect } from './worktree-sidebar-static-geometry'
|
||||
|
||||
const EDGE_ZONE_PX = 56
|
||||
const ACTIVE_LINEAGE_TARGET_EDGE_ZONE_PX = 12
|
||||
const MAX_OUTSIDE_EDGE_PX = 48
|
||||
const MAX_SCROLL_SPEED_PX_PER_SECOND = 960
|
||||
const MAX_FRAME_MS = 32
|
||||
@@ -50,6 +52,23 @@ export type WorktreeSidebarBoundaryDropResult =
|
||||
| { kind: 'inside' }
|
||||
| { kind: 'outside' }
|
||||
|
||||
export function shouldHoldWorktreeSidebarRowsAtPointerEdge(args: {
|
||||
point: WorktreeSidebarDragPoint
|
||||
containerRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
}): boolean {
|
||||
if (
|
||||
args.scrollHeight <= args.clientHeight ||
|
||||
args.point.clientX < args.containerRect.left ||
|
||||
args.point.clientX > args.containerRect.right
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: edge intent must stay stable at scroll bounds even after autoscroll has no remaining delta.
|
||||
return Boolean(getVerticalEdgeIntensity(args.point.clientY, args.containerRect, EDGE_ZONE_PX))
|
||||
}
|
||||
|
||||
export function getWorktreeSidebarDragAutoscroll(args: {
|
||||
point: WorktreeSidebarDragPoint
|
||||
containerRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
|
||||
@@ -57,6 +76,7 @@ export function getWorktreeSidebarDragAutoscroll(args: {
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
elapsedMs: number
|
||||
lineageTargetActive?: boolean
|
||||
}): WorktreeSidebarAutoscrollResult | null {
|
||||
const { point, containerRect } = args
|
||||
if (point.clientX < containerRect.left || point.clientX > containerRect.right) {
|
||||
@@ -74,7 +94,11 @@ export function getWorktreeSidebarDragAutoscroll(args: {
|
||||
return null
|
||||
}
|
||||
|
||||
const edge = getVerticalEdgeIntensity(point.clientY, containerRect)
|
||||
const edge = getVerticalEdgeIntensity(
|
||||
point.clientY,
|
||||
containerRect,
|
||||
args.lineageTargetActive ? ACTIVE_LINEAGE_TARGET_EDGE_ZONE_PX : EDGE_ZONE_PX
|
||||
)
|
||||
if (!edge) {
|
||||
return null
|
||||
}
|
||||
@@ -141,13 +165,8 @@ export function getWorktreeSidebarDragRectsForGroup(
|
||||
if (!worktreeId || !Number.isFinite(groupIndex)) {
|
||||
return
|
||||
}
|
||||
const rect = element.getBoundingClientRect()
|
||||
const virtualRow = element.closest<HTMLElement>('[data-worktree-virtual-row]')
|
||||
const virtualRowStart = getWorktreeVirtualRowStart(virtualRow)
|
||||
const top =
|
||||
virtualRow && virtualRowStart !== null
|
||||
? virtualRowStart + rect.top - virtualRow.getBoundingClientRect().top
|
||||
: rect.top - containerRect.top + container.scrollTop
|
||||
const rect = getWorktreeSidebarStaticRect(container, element)
|
||||
const top = rect.top - containerRect.top + container.scrollTop
|
||||
rects.push({
|
||||
worktreeId,
|
||||
groupIndex,
|
||||
@@ -161,18 +180,6 @@ export function getWorktreeSidebarDragRectsForGroup(
|
||||
return rects
|
||||
}
|
||||
|
||||
function getWorktreeVirtualRowStart(virtualRow: HTMLElement | null): number | null {
|
||||
if (!virtualRow) {
|
||||
return null
|
||||
}
|
||||
const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start')
|
||||
if (rawStart === null) {
|
||||
return null
|
||||
}
|
||||
const start = Number(rawStart)
|
||||
return Number.isFinite(start) ? start : null
|
||||
}
|
||||
|
||||
export function refreshWorktreeSidebarDragSession(args: {
|
||||
session: WorktreeSidebarDragSession
|
||||
groups: readonly WorktreeDragGroup[]
|
||||
@@ -204,7 +211,8 @@ export function refreshWorktreeSidebarDragSession(args: {
|
||||
|
||||
function getVerticalEdgeIntensity(
|
||||
clientY: number,
|
||||
containerRect: Pick<DOMRect, 'top' | 'bottom'>
|
||||
containerRect: Pick<DOMRect, 'top' | 'bottom'>,
|
||||
edgeZonePx: number
|
||||
): { direction: -1 | 1; intensity: number } | null {
|
||||
if (clientY < containerRect.top - MAX_OUTSIDE_EDGE_PX) {
|
||||
return null
|
||||
@@ -212,16 +220,16 @@ function getVerticalEdgeIntensity(
|
||||
if (clientY > containerRect.bottom + MAX_OUTSIDE_EDGE_PX) {
|
||||
return null
|
||||
}
|
||||
if (clientY <= containerRect.top + EDGE_ZONE_PX) {
|
||||
if (clientY <= containerRect.top + edgeZonePx) {
|
||||
return {
|
||||
direction: -1,
|
||||
intensity: Math.min(1, (containerRect.top + EDGE_ZONE_PX - clientY) / EDGE_ZONE_PX)
|
||||
intensity: Math.min(1, (containerRect.top + edgeZonePx - clientY) / edgeZonePx)
|
||||
}
|
||||
}
|
||||
if (clientY >= containerRect.bottom - EDGE_ZONE_PX) {
|
||||
if (clientY >= containerRect.bottom - edgeZonePx) {
|
||||
return {
|
||||
direction: 1,
|
||||
intensity: Math.min(1, (clientY - (containerRect.bottom - EDGE_ZONE_PX)) / EDGE_ZONE_PX)
|
||||
intensity: Math.min(1, (clientY - (containerRect.bottom - edgeZonePx)) / edgeZonePx)
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -189,6 +189,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
|
||||
resolveWorktreeSidebarStatusDropCommitTarget({
|
||||
currentTarget: { status: 'completed', isPinDrop: false, lineageParentId: null },
|
||||
currentPreview: preview,
|
||||
currentReorderTargetActive: false,
|
||||
latestTrackedTarget: {
|
||||
target: { status: 'in-progress', isPinDrop: false, lineageParentId: null },
|
||||
preview: null,
|
||||
@@ -209,6 +210,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
|
||||
resolveWorktreeSidebarStatusDropCommitTarget({
|
||||
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
|
||||
currentPreview: null,
|
||||
currentReorderTargetActive: false,
|
||||
latestTrackedTarget: {
|
||||
target: { status: 'completed', isPinDrop: false, lineageParentId: null },
|
||||
preview,
|
||||
@@ -229,6 +231,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
|
||||
resolveWorktreeSidebarStatusDropCommitTarget({
|
||||
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
|
||||
currentPreview: null,
|
||||
currentReorderTargetActive: false,
|
||||
latestTrackedTarget: {
|
||||
target: { status: null, isPinDrop: false, lineageParentId: 'parent-worktree' },
|
||||
preview: null,
|
||||
@@ -244,11 +247,54 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the current status intent ahead of a nearby lineage fallback', () => {
|
||||
expect(
|
||||
resolveWorktreeSidebarStatusDropCommitTarget({
|
||||
currentTarget: { status: 'in-progress', isPinDrop: false, lineageParentId: null },
|
||||
currentPreview: null,
|
||||
currentReorderTargetActive: false,
|
||||
latestTrackedTarget: {
|
||||
target: { status: 'in-progress', isPinDrop: false, lineageParentId: 'parent-worktree' },
|
||||
preview: null,
|
||||
x: 100,
|
||||
y: 100
|
||||
},
|
||||
x: 102,
|
||||
y: 101
|
||||
})
|
||||
).toEqual({
|
||||
target: { status: 'in-progress', isPinDrop: false, lineageParentId: null },
|
||||
preview: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a reorder gutter ahead of a nearby lineage fallback', () => {
|
||||
expect(
|
||||
resolveWorktreeSidebarStatusDropCommitTarget({
|
||||
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
|
||||
currentPreview: null,
|
||||
currentReorderTargetActive: true,
|
||||
latestTrackedTarget: {
|
||||
target: { status: null, isPinDrop: false, lineageParentId: 'parent-worktree' },
|
||||
preview: null,
|
||||
x: 100,
|
||||
y: 100
|
||||
},
|
||||
x: 101,
|
||||
y: 100
|
||||
})
|
||||
).toEqual({
|
||||
target: { status: null, isPinDrop: false, lineageParentId: null },
|
||||
preview: null
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reuse a stale status target after the pointer has moved away', () => {
|
||||
expect(
|
||||
resolveWorktreeSidebarStatusDropCommitTarget({
|
||||
currentTarget: { status: null, isPinDrop: false, lineageParentId: null },
|
||||
currentPreview: null,
|
||||
currentReorderTargetActive: false,
|
||||
latestTrackedTarget: {
|
||||
target: { status: 'completed', isPinDrop: false, lineageParentId: null },
|
||||
preview,
|
||||
|
||||
@@ -82,6 +82,7 @@ function hasWorktreeSidebarStatusDropTarget(
|
||||
export function resolveWorktreeSidebarStatusDropCommitTarget(args: {
|
||||
currentTarget: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }
|
||||
currentPreview: WorktreeSidebarDropPreview | null
|
||||
currentReorderTargetActive: boolean
|
||||
latestTrackedTarget: WorktreeSidebarTrackedStatusDropTarget | null
|
||||
x: number
|
||||
y: number
|
||||
@@ -89,14 +90,19 @@ export function resolveWorktreeSidebarStatusDropCommitTarget(args: {
|
||||
target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }
|
||||
preview: WorktreeSidebarDropPreview | null
|
||||
} {
|
||||
if (hasWorktreeSidebarStatusDropTarget(args.currentTarget)) {
|
||||
if (hasWorktreeSidebarStatusDropTarget(args.currentTarget) || args.currentReorderTargetActive) {
|
||||
return { target: args.currentTarget, preview: args.currentPreview }
|
||||
}
|
||||
const latest = args.latestTrackedTarget
|
||||
const distance = latest
|
||||
? Math.hypot(args.x - latest.x, args.y - latest.y)
|
||||
: Number.POSITIVE_INFINITY
|
||||
if (latest?.target.lineageParentId && distance <= STATUS_DROP_TARGET_FALLBACK_TOLERANCE_PX) {
|
||||
return { target: latest.target, preview: latest.preview }
|
||||
}
|
||||
if (!latest || !hasWorktreeSidebarStatusDropTarget(latest.target)) {
|
||||
return { target: args.currentTarget, preview: args.currentPreview }
|
||||
}
|
||||
const distance = Math.hypot(args.x - latest.x, args.y - latest.y)
|
||||
return distance <= STATUS_DROP_TARGET_FALLBACK_TOLERANCE_PX
|
||||
? { target: latest.target, preview: latest.preview }
|
||||
: { target: args.currentTarget, preview: args.currentPreview }
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom'
|
||||
import {
|
||||
createSidebarDragPreview,
|
||||
isSidebarPointerDragBlocked
|
||||
} from './worktree-sidebar-pointer-drag-dom'
|
||||
|
||||
describe('worktree sidebar pointer drag DOM guards', () => {
|
||||
it('allows plain row targets to start pointer drags', () => {
|
||||
@@ -40,4 +43,36 @@ describe('worktree sidebar pointer drag DOM guards', () => {
|
||||
|
||||
expect(isSidebarPointerDragBlocked(icon, row)).toBe(true)
|
||||
})
|
||||
|
||||
it('builds a compact preview without agent trees, lineage children, or duplicate row ids', () => {
|
||||
const row = document.createElement('div')
|
||||
row.setAttribute('data-worktree-id', 'parent')
|
||||
row.setAttribute('data-worktree-drag-id', 'parent')
|
||||
row.setAttribute('data-worktree-lineage-drop-id', 'parent')
|
||||
row.getBoundingClientRect = () => ({ left: 10, top: 20, width: 220, height: 500 }) as DOMRect
|
||||
const title = document.createElement('div')
|
||||
title.textContent = 'Parent workspace'
|
||||
const agents = document.createElement('div')
|
||||
agents.setAttribute('data-worktree-card-agent-list', '')
|
||||
agents.textContent = '12 agents'
|
||||
const children = document.createElement('div')
|
||||
children.setAttribute('data-worktree-legacy-lineage-children', '')
|
||||
children.setAttribute('data-worktree-id', 'child')
|
||||
children.textContent = 'Child workspace'
|
||||
row.append(title, agents, children)
|
||||
|
||||
const { preview } = createSidebarDragPreview({
|
||||
sourceRow: row,
|
||||
pointerX: 80,
|
||||
pointerY: 40,
|
||||
draggedCount: 1
|
||||
})
|
||||
|
||||
expect(preview.textContent).toContain('Parent workspace')
|
||||
expect(preview.textContent).not.toContain('12 agents')
|
||||
expect(preview.textContent).not.toContain('Child workspace')
|
||||
expect(preview.querySelector('[data-worktree-id]')).toBeNull()
|
||||
expect(preview.querySelector('[data-worktree-lineage-drop-id]')).toBeNull()
|
||||
preview.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
const POINTER_DRAGGING_ATTR = 'data-worktree-sidebar-pointer-dragging'
|
||||
const POINTER_DRAG_PREVIEW_ATTR = 'data-worktree-sidebar-drag-preview'
|
||||
const POINTER_DRAG_COUNT_ATTR = 'data-worktree-sidebar-drag-count'
|
||||
const PREVIEW_OMITTED_CONTENT_SELECTOR =
|
||||
'[data-worktree-card-agent-list],[data-worktree-lineage-children],[data-worktree-legacy-lineage-children]'
|
||||
const PREVIEW_IDENTITY_ATTRIBUTES = [
|
||||
'data-worktree-drag-id',
|
||||
'data-worktree-id',
|
||||
'data-worktree-lineage-drop-id',
|
||||
'data-worktree-row-key'
|
||||
] as const
|
||||
|
||||
const INTERACTIVE_DRAG_BLOCKER_SELECTOR = [
|
||||
'button',
|
||||
@@ -42,13 +50,18 @@ export function setSidebarPointerDragDocumentStyles(enabled: boolean): void {
|
||||
function stripDuplicatePreviewAttributes(preview: HTMLElement): void {
|
||||
preview.removeAttribute('id')
|
||||
preview.removeAttribute('aria-describedby')
|
||||
preview.removeAttribute('data-worktree-drag-id')
|
||||
preview.querySelectorAll<HTMLElement>('[id],[aria-describedby]').forEach((element) => {
|
||||
element.removeAttribute('id')
|
||||
element.removeAttribute('aria-describedby')
|
||||
})
|
||||
preview.querySelectorAll<HTMLElement>('[data-worktree-drag-id]').forEach((element) => {
|
||||
element.removeAttribute('data-worktree-drag-id')
|
||||
for (const attribute of PREVIEW_IDENTITY_ATTRIBUTES) {
|
||||
preview.removeAttribute(attribute)
|
||||
preview.querySelectorAll<HTMLElement>(`[${attribute}]`).forEach((element) => {
|
||||
element.removeAttribute(attribute)
|
||||
})
|
||||
}
|
||||
preview.querySelectorAll<HTMLElement>(PREVIEW_OMITTED_CONTENT_SELECTOR).forEach((element) => {
|
||||
element.remove()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,7 +87,7 @@ export function createSidebarDragPreview(args: {
|
||||
const preview = document.createElement('div')
|
||||
const clone = args.sourceRow.cloneNode(true) as HTMLElement
|
||||
const offsetX = Math.min(Math.max(args.pointerX - rect.left, 0), rect.width)
|
||||
const offsetY = Math.min(Math.max(args.pointerY - rect.top, 0), rect.height)
|
||||
const sourceOffsetY = Math.min(Math.max(args.pointerY - rect.top, 0), rect.height)
|
||||
|
||||
stripDuplicatePreviewAttributes(clone)
|
||||
preview.setAttribute(POINTER_DRAG_PREVIEW_ATTR, 'true')
|
||||
@@ -92,9 +105,11 @@ export function createSidebarDragPreview(args: {
|
||||
preview.style.left = '0'
|
||||
preview.style.top = '0'
|
||||
preview.style.width = `${rect.width}px`
|
||||
preview.style.height = `${rect.height}px`
|
||||
preview.style.pointerEvents = 'none'
|
||||
preview.style.transformOrigin = 'top left'
|
||||
document.body.appendChild(preview)
|
||||
const height = preview.offsetHeight > 0 ? preview.offsetHeight : rect.height
|
||||
const offsetY = Math.min(sourceOffsetY, height)
|
||||
updateSidebarDragPreviewPosition({
|
||||
preview,
|
||||
pointerX: args.pointerX,
|
||||
@@ -102,6 +117,5 @@ export function createSidebarDragPreview(args: {
|
||||
offsetX,
|
||||
offsetY
|
||||
})
|
||||
document.body.appendChild(preview)
|
||||
return { preview, offsetX, offsetY, height: rect.height }
|
||||
return { preview, offsetX, offsetY, height }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export type WorktreeSidebarStaticRect = Pick<
|
||||
DOMRect,
|
||||
'left' | 'right' | 'top' | 'bottom' | 'height'
|
||||
>
|
||||
|
||||
export type WorktreeSidebarStaticGeometry = {
|
||||
containerTop: number
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
function getWorktreeSidebarStaticGeometry(container: HTMLElement): WorktreeSidebarStaticGeometry {
|
||||
return {
|
||||
containerTop: container.getBoundingClientRect().top,
|
||||
scrollTop: container.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeSidebarStaticRect(
|
||||
container: HTMLElement,
|
||||
element: HTMLElement,
|
||||
geometry?: WorktreeSidebarStaticGeometry
|
||||
): WorktreeSidebarStaticRect {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const virtualRow = element.closest<HTMLElement>('[data-worktree-virtual-row]')
|
||||
const virtualRowStart = getWorktreeSidebarVirtualRowStart(virtualRow)
|
||||
if (!virtualRow || virtualRowStart === null) {
|
||||
return rect
|
||||
}
|
||||
|
||||
const staticGeometry = geometry ?? getWorktreeSidebarStaticGeometry(container)
|
||||
const top =
|
||||
staticGeometry.containerTop -
|
||||
staticGeometry.scrollTop +
|
||||
virtualRowStart +
|
||||
rect.top -
|
||||
(virtualRow === element ? rect.top : virtualRow.getBoundingClientRect().top)
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top,
|
||||
bottom: top + rect.height,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeSidebarVirtualRowStart(virtualRow: HTMLElement | null): number | null {
|
||||
if (!virtualRow) {
|
||||
return null
|
||||
}
|
||||
const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start')
|
||||
if (rawStart === null) {
|
||||
return null
|
||||
}
|
||||
const start = Number(rawStart)
|
||||
return Number.isFinite(start) ? start : null
|
||||
}
|
||||
@@ -5,6 +5,102 @@ export type LineageScenario = {
|
||||
childId: string
|
||||
}
|
||||
|
||||
export type LongSidebarDragScenario = {
|
||||
sourceId: string
|
||||
targetId: string
|
||||
}
|
||||
|
||||
export async function seedLongSidebarDragScenario(page: Page): Promise<LongSidebarDragScenario> {
|
||||
return page.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const initial = store.getState()
|
||||
initial.setActiveView('terminal')
|
||||
initial.setSidebarOpen(true)
|
||||
initial.setGroupBy('none')
|
||||
initial.setSortBy('manual')
|
||||
initial.setShowActiveOnly(false)
|
||||
initial.setShowSleepingWorkspaces(true)
|
||||
initial.setHideDefaultBranchWorkspace(false)
|
||||
initial.setFilterRepoIds([])
|
||||
initial.setAgentActivityDisplayMode('full')
|
||||
if (!initial.worktreeCardProperties.includes('inline-agents')) {
|
||||
initial.toggleWorktreeCardProperty('inline-agents')
|
||||
}
|
||||
|
||||
const repo = initial.repos[0]
|
||||
const repoWorktrees = repo
|
||||
? (initial.worktreesByRepo[repo.id] ?? []).filter((worktree) => !worktree.isArchived)
|
||||
: []
|
||||
const target = repoWorktrees.find((worktree) => worktree.isMainWorktree) ?? repoWorktrees[0]
|
||||
const source = repoWorktrees.find((worktree) => worktree.id !== target?.id)
|
||||
if (!repo || !target?.instanceId || !source?.instanceId) {
|
||||
throw new Error('Long sidebar drag E2E needs two instance-stamped worktrees in one repo')
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
initial.updateWorktreeLineage(source.id, { noParent: true }),
|
||||
initial.updateWorktreeLineage(target.id, { noParent: true })
|
||||
])
|
||||
const clearedWorktrees = (store.getState().worktreesByRepo[repo.id] ?? []).filter(
|
||||
(worktree) => !worktree.isArchived
|
||||
)
|
||||
|
||||
store.setState((current) => ({
|
||||
worktreesByRepo: {
|
||||
...current.worktreesByRepo,
|
||||
[repo.id]: clearedWorktrees.map((worktree) =>
|
||||
worktree.id === source.id
|
||||
? {
|
||||
...worktree,
|
||||
displayName: 'Drag this workspace',
|
||||
manualOrder: 2,
|
||||
sortOrder: 2
|
||||
}
|
||||
: worktree.id === target.id
|
||||
? {
|
||||
...worktree,
|
||||
displayName: 'Long agent target',
|
||||
manualOrder: 1,
|
||||
sortOrder: 1
|
||||
}
|
||||
: { ...worktree, manualOrder: 0, sortOrder: 0 }
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
const seedAgents = (worktreeId: string, count: number, label: string): void => {
|
||||
if ((store.getState().tabsByWorktree[worktreeId] ?? []).length === 0) {
|
||||
store.getState().createTab(worktreeId)
|
||||
}
|
||||
const tab = store.getState().tabsByWorktree[worktreeId]?.[0]
|
||||
if (!tab) {
|
||||
throw new Error(`Could not seed ${label} agents`)
|
||||
}
|
||||
const now = Date.now()
|
||||
for (let index = 0; index < count; index++) {
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
`${tab.id}:${crypto.randomUUID()}`,
|
||||
{ state: 'working', prompt: `${label} agent ${index + 1}`, agentType: 'codex' },
|
||||
'codex',
|
||||
{ updatedAt: now + index, stateStartedAt: now + index }
|
||||
)
|
||||
}
|
||||
}
|
||||
store.getState().dropAgentStatusByWorktree(source.id)
|
||||
store.getState().dropAgentStatusByWorktree(target.id)
|
||||
seedAgents(source.id, 15, 'Source')
|
||||
seedAgents(target.id, 15, 'Target')
|
||||
store.getState().setActiveWorktree(source.id)
|
||||
|
||||
return { sourceId: source.id, targetId: target.id }
|
||||
})
|
||||
}
|
||||
|
||||
export async function seedLineageScenario(
|
||||
page: Page,
|
||||
options: { inlineOnly?: boolean } = {}
|
||||
|
||||
@@ -5,11 +5,12 @@ import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
markWorkspaceTerminalSlept,
|
||||
seedLongSidebarDragScenario,
|
||||
seedLineageScenario,
|
||||
seedWorkspaceAgentStatus,
|
||||
seedWorkspaceLiveTerminal
|
||||
} from './worktree-lineage-state'
|
||||
import { worktreeRow } from './worktree-row-locators'
|
||||
import { worktreeRow, worktreeRowSurface } from './worktree-row-locators'
|
||||
|
||||
function worktreeOption(page: Page, worktreeId: string) {
|
||||
return worktreeRow(page, worktreeId)
|
||||
@@ -33,6 +34,12 @@ async function captureSidebarEvidence(page: Page, name: string): Promise<void> {
|
||||
await captureEvidence(page, name, page.locator('[data-worktree-sidebar]').first())
|
||||
}
|
||||
|
||||
async function pauseRecordedProof(page: Page, durationMs = 600): Promise<void> {
|
||||
if (process.env.ORCA_E2E_RECORD_VIDEO === '1') {
|
||||
await page.waitForTimeout(durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Worktree Lineage', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
@@ -41,6 +48,146 @@ test.describe('Worktree Lineage', () => {
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
test('autoscrolls through nested agent rows and nests a workspace from its title', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const scenario = await seedLongSidebarDragScenario(orcaPage)
|
||||
const scroller = orcaPage.locator('[data-worktree-sidebar]')
|
||||
const sourceRow = worktreeRow(orcaPage, scenario.sourceId)
|
||||
const targetRow = worktreeRow(orcaPage, scenario.targetId)
|
||||
const sourceTitle = sourceRow.locator('[data-worktree-title-inline-rename]').first()
|
||||
const targetEndAgent = targetRow.getByText('Target agent 15')
|
||||
const targetDropAgent = targetRow.getByText('Target agent 13')
|
||||
|
||||
await expect(sourceRow).toBeVisible()
|
||||
await expect(sourceRow.getByText('Source agent 15')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller.evaluate((element) => Math.max(0, element.scrollHeight - element.clientHeight))
|
||||
)
|
||||
.toBeGreaterThan(80)
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = 0
|
||||
})
|
||||
await pauseRecordedProof(orcaPage)
|
||||
|
||||
const [sourceBox, sourceTitleBox, scrollerBox] = await Promise.all([
|
||||
sourceRow.boundingBox(),
|
||||
sourceTitle.boundingBox(),
|
||||
scroller.boundingBox()
|
||||
])
|
||||
if (!sourceBox || !sourceTitleBox || !scrollerBox) {
|
||||
throw new Error('Expected long sidebar drag geometry')
|
||||
}
|
||||
const dragX = sourceTitleBox.x + sourceTitleBox.width / 2
|
||||
await orcaPage.mouse.move(dragX, sourceTitleBox.y + sourceTitleBox.height / 2)
|
||||
await orcaPage.mouse.down()
|
||||
try {
|
||||
await orcaPage.mouse.move(dragX, sourceTitleBox.y + sourceTitleBox.height + 8, { steps: 4 })
|
||||
await expect(orcaPage.locator('html')).toHaveAttribute(
|
||||
'data-worktree-sidebar-pointer-dragging',
|
||||
''
|
||||
)
|
||||
const preview = orcaPage.locator('[data-worktree-sidebar-drag-preview="true"]')
|
||||
await expect(preview).toBeVisible()
|
||||
const previewBox = await preview.boundingBox()
|
||||
expect(previewBox?.height ?? Number.POSITIVE_INFINITY).toBeLessThan(sourceBox.height)
|
||||
|
||||
const initialScrollTop = await scroller.evaluate((element) => element.scrollTop)
|
||||
await orcaPage.mouse.move(dragX, scrollerBox.y + scrollerBox.height - 3, { steps: 12 })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollTop), { timeout: 15_000 })
|
||||
.toBeGreaterThan(initialScrollTop + 50)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const [agentBox, sidebarBox] = await Promise.all([
|
||||
targetEndAgent.boundingBox(),
|
||||
scroller.boundingBox()
|
||||
])
|
||||
return Boolean(
|
||||
agentBox &&
|
||||
sidebarBox &&
|
||||
agentBox.y >= sidebarBox.y &&
|
||||
agentBox.y + agentBox.height <= sidebarBox.y + sidebarBox.height
|
||||
)
|
||||
},
|
||||
{ timeout: 15_000, message: 'pointer autoscroll did not reveal the target agent' }
|
||||
)
|
||||
.toBe(true)
|
||||
const targetDropAgentBox = await targetDropAgent.boundingBox()
|
||||
if (!targetDropAgentBox) {
|
||||
throw new Error('Expected the autoscrolled lineage target agent')
|
||||
}
|
||||
// Why: the final row borders the reorder gutter; an interior row proves the owned agent surface.
|
||||
await orcaPage.mouse.move(
|
||||
targetDropAgentBox.x + targetDropAgentBox.width / 2,
|
||||
targetDropAgentBox.y + targetDropAgentBox.height / 2
|
||||
)
|
||||
await expect(worktreeRowSurface(orcaPage, scenario.targetId)).toHaveAttribute(
|
||||
'data-worktree-lineage-drop-target',
|
||||
'true'
|
||||
)
|
||||
const targetAgentList = worktreeRowSurface(orcaPage, scenario.targetId)
|
||||
.locator('[data-worktree-card-agent-list]')
|
||||
.first()
|
||||
const sourceAgentList = worktreeRowSurface(orcaPage, scenario.sourceId)
|
||||
.locator('[data-worktree-card-agent-list]')
|
||||
.first()
|
||||
await expect(targetAgentList).toHaveAttribute(
|
||||
'data-worktree-card-agent-list-drop-target',
|
||||
'true'
|
||||
)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [targetStyle, sourceStyle] = await Promise.all(
|
||||
[targetAgentList, sourceAgentList].map((agentList) =>
|
||||
agentList.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return { backgroundColor: style.backgroundColor, boxShadow: style.boxShadow }
|
||||
})
|
||||
)
|
||||
)
|
||||
return (
|
||||
targetStyle.backgroundColor !== sourceStyle.backgroundColor &&
|
||||
targetStyle.boxShadow !== sourceStyle.boxShadow
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
await pauseRecordedProof(orcaPage)
|
||||
await orcaPage.mouse.up()
|
||||
|
||||
await expect(
|
||||
targetRow.getByRole('button', {
|
||||
name: /Hide \d+ child workspaces?/
|
||||
})
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
targetRow.evaluate(
|
||||
(element, sourceId) =>
|
||||
Boolean(
|
||||
[...element.querySelectorAll<HTMLElement>('[data-worktree-id]')].find(
|
||||
(candidate) => candidate.dataset.worktreeId === sourceId
|
||||
)
|
||||
),
|
||||
scenario.sourceId
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(preview).toHaveCount(0)
|
||||
await expect(orcaPage.locator('html')).not.toHaveAttribute(
|
||||
'data-worktree-sidebar-pointer-dragging'
|
||||
)
|
||||
await pauseRecordedProof(orcaPage, 900)
|
||||
} finally {
|
||||
await orcaPage.mouse.up().catch(() => {})
|
||||
await orcaPage.evaluate(async (sourceId) => {
|
||||
await window.__store?.getState().updateWorktreeLineage(sourceId, { noParent: true })
|
||||
}, scenario.sourceId)
|
||||
}
|
||||
})
|
||||
|
||||
test('renders existing child lineage in the sidebar', async ({ orcaPage }) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
const parentRow = worktreeOption(orcaPage, parentId)
|
||||
|
||||
Reference in New Issue
Block a user