From 6d63bcac0441ede2428550efcf4e261fabcd493f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:41:48 -0700 Subject: [PATCH] Virtualize workspace board lanes and defer card render for instant open --- .../sidebar/WorkspaceKanbanCard.tsx | 5 + .../sidebar/WorkspaceKanbanDrawer.tsx | 33 ++- .../WorkspaceKanbanLaneCardList.test.tsx | 105 ++++++++ .../sidebar/WorkspaceKanbanLaneCardList.tsx | 115 +++++++++ .../sidebar/WorkspaceKanbanLaneGrid.tsx | 8 +- .../sidebar/WorkspaceKanbanStatusLane.tsx | 49 ++-- ...kspace-kanban-area-selection-card-rects.ts | 96 +++++++ .../workspace-kanban-area-selection-dom.ts | 71 ++---- ...space-kanban-card-pointer-drag-dom.test.ts | 42 ++++ .../workspace-kanban-card-pointer-drag-dom.ts | 88 +++++-- .../workspace-kanban-sidebar-drop.test.ts | 71 ++++++ .../sidebar/workspace-kanban-sidebar-drop.ts | 20 ++ ...rkspace-kanban-virtual-lane-layout.test.ts | 134 ++++++++++ .../workspace-kanban-virtual-lane-layout.ts | 159 ++++++++++++ ...orkspace-board-lane-virtualization.spec.ts | 238 ++++++++++++++++++ 15 files changed, 1126 insertions(+), 108 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts create mode 100644 tests/e2e/workspace-board-lane-virtualization.spec.ts diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx index 601bef497d6..cd7b94dd725 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -7,6 +7,9 @@ import { translate } from '@/i18n/i18n' type WorkspaceKanbanCardProps = { worktree: Worktree + // Why: the lane virtualizes, so DOM order no longer implies list position. + // Drop-index math reads this instead of counting rendered siblings. + laneIndex: number repo: Repo | undefined isActive: boolean isSelected: boolean @@ -23,6 +26,7 @@ type WorkspaceKanbanCardProps = { function WorkspaceKanbanCard({ worktree, + laneIndex, repo, isActive, isSelected, @@ -40,6 +44,7 @@ function WorkspaceKanbanCard({
(null) const [dragOverStatus, setDragOverStatus] = useState(null) const [pinDragOver, setPinDragOver] = useState(false) + const [renderCards, setRenderCards] = useState(false) const { canCreateWorktree, createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree() const visibleWorktreeIdSet = useVisibleWorkspaceKanbanWorktreeIds({ allWorktrees, @@ -188,6 +198,12 @@ export default function WorkspaceKanbanDrawer({ })), [worktreesByStatus, workspaceStatuses] ) + useLayoutEffect(() => { + if (!open) { + return + } + return registerWorkspaceKanbanSidebarDropGroups(boardDragGroups) + }, [boardDragGroups, open]) const { selectedWorktreeIds, selectedWorktrees, @@ -628,6 +644,20 @@ export default function WorkspaceKanbanDrawer({ } ) + // Why: mounting every lane's cards in the same commit that opens the sheet + // blocks the slide-in for the whole card render. Paint the board chrome + // first, then fill the lanes in a non-blocking transition. + useEffect(() => { + if (!open) { + setRenderCards(false) + return + } + const frameId = window.requestAnimationFrame(() => { + startTransition(() => setRenderCards(true)) + }) + return () => window.cancelAnimationFrame(frameId) + }, [open]) + useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef) useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange }) useContextualTour('workspace-board', open && !dragPreview, 'workspace_board_visible') @@ -789,6 +819,7 @@ export default function WorkspaceKanbanDrawer({ isResizingColumn={isResizingColumn} dragOverStatus={dragOverStatus} canCreateWorktree={canCreateWorktree} + renderCards={renderCards} selectedWorktreeIds={selectedWorktreeIds} selectedWorktrees={selectedWorktrees} onDragOver={handleDragOver} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx new file mode 100644 index 00000000000..4497158ed3a --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom +/** + * Why a render test: the board's open cost was mounting every card in every + * lane at once. These assert the lane renders only the virtual window, and that + * each rendered card still advertises its true lane index — drop-index math + * reads that attribute now that DOM order no longer implies list order. + * + * The virtualizer itself is mocked (as the sidebar list tests do): it needs real + * layout, which the test DOM has none of, and the contract under test is ours. + */ +import { cleanup, render } from '@testing-library/react' +import { createRef } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree } from '../../../../shared/types' + +const WINDOW_START = 20 +const WINDOW_END = 25 +const TOTAL_SIZE = 21_992 + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => TOTAL_SIZE, + measurementsCache: Array.from({ length: count }, (_, index) => ({ + index, + start: index * 44, + end: index * 44 + 36 + })), + getVirtualItems: () => + Array.from({ length: WINDOW_END - WINDOW_START + 1 }, (_, offset) => { + const index = WINDOW_START + offset + return { index, key: `w${index}`, start: index * 44 } + }), + measureElement: () => {} + }) +})) + +vi.mock('./WorkspaceKanbanCard', () => ({ + default: ({ worktree, laneIndex }: { worktree: Worktree; laneIndex: number }) => ( +
+ ) +})) + +const { default: WorkspaceKanbanLaneCardList } = await import('./WorkspaceKanbanLaneCardList') + +const REPO_MAP = new Map() + +function renderLane(count: number): HTMLElement { + const scrollRef = createRef() + const { container } = render( +
+ ({ id: `w${index}` }) as Worktree)} + repoMap={REPO_MAP} + activeWorktreeId={null} + scrollRef={scrollRef} + selectedWorktreeIds={new Set()} + selectedWorktrees={[]} + nativeDragEnabled={false} + onActivate={() => {}} + onSelectionGesture={() => false} + onContextMenuSelect={() => []} + /> +
+ ) + return container +} + +afterEach(cleanup) + +describe('WorkspaceKanbanLaneCardList', () => { + it('mounts only the virtual window, not the whole lane', () => { + const container = renderLane(500) + + const ids = Array.from(container.querySelectorAll('[data-workspace-board-card-id]')).map( + (card) => card.getAttribute('data-workspace-board-card-id') + ) + expect(ids).toEqual(['w20', 'w21', 'w22', 'w23', 'w24', 'w25']) + }) + + it('gives every rendered card its lane index, not its rendered position', () => { + const container = renderLane(500) + + const indexes = Array.from(container.querySelectorAll('[data-workspace-board-card-index]')).map( + (card) => Number(card.getAttribute('data-workspace-board-card-index')) + ) + expect(indexes).toEqual([20, 21, 22, 23, 24, 25]) + }) + + it('reserves the full lane height so the scrollbar spans the whole list', () => { + const container = renderLane(500) + + const spacer = container.querySelector('[data-workspace-board-card-id]') + ?.parentElement?.parentElement + expect(spacer?.style.height).toBe(`${TOTAL_SIZE}px`) + }) + + it('positions each card at its virtual offset', () => { + const container = renderLane(500) + + const first = container.querySelector( + '[data-workspace-board-card-id]' + )?.parentElement + expect(first?.style.transform).toBe(`translateY(${WINDOW_START * 44}px)`) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx new file mode 100644 index 00000000000..57137ea709b --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useLayoutEffect, useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import WorkspaceKanbanCard from './WorkspaceKanbanCard' +import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout' + +// Why: board cards are uniform one-line rows; a close estimate keeps the first +// virtual window right so the lane does not reflow once cards measure. +const WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT = 36 +// Matches the `space-y-2` rhythm the lane used before virtualization. +const WORKSPACE_BOARD_CARD_GAP = 8 +const WORKSPACE_BOARD_CARD_OVERSCAN = 6 + +type WorkspaceKanbanLaneCardListProps = { + items: readonly Worktree[] + repoMap: Map + activeWorktreeId: string | null + scrollRef: React.RefObject + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + nativeDragEnabled: boolean + onActivate: () => void + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void +} + +function WorkspaceKanbanLaneCardList({ + items, + repoMap, + activeWorktreeId, + scrollRef, + selectedWorktreeIds, + selectedWorktrees, + nativeDragEnabled, + onActivate, + onSelectionGesture, + onContextMenuSelect, + onAssignWorkspaceStatus +}: WorkspaceKanbanLaneCardListProps): React.JSX.Element { + const spacerRef = useRef(null) + const itemIdsRef = useRef([]) + itemIdsRef.current = items.map((item) => item.id) + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT, + getItemKey: useCallback((index: number) => items[index]?.id ?? index, [items]), + overscan: WORKSPACE_BOARD_CARD_OVERSCAN, + gap: WORKSPACE_BOARD_CARD_GAP, + // Why: sync-flushing rich card renders inside the scroll listener stalls the + // wheel; async + overscan keeps the lane filled without blocking input. + useFlushSync: false + }) + + useLayoutEffect(() => { + const scrollElement = scrollRef.current + const spacerElement = spacerRef.current + if (!scrollElement || !spacerElement) { + return + } + return registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => itemIdsRef.current, + getMeasurements: () => virtualizer.measurementsCache + }) + }, [scrollRef, virtualizer]) + + return ( +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const worktree = items[virtualItem.index] + if (!worktree) { + return null + } + const isSelected = selectedWorktreeIds.has(worktree.id) + return ( +
+ 0 ? selectedWorktrees : undefined + } + onActivate={onActivate} + onSelectionGesture={onSelectionGesture} + onContextMenuSelect={onContextMenuSelect} + onAssignWorkspaceStatus={onAssignWorkspaceStatus} + /> +
+ ) + })} +
+ ) +} + +export default React.memo(WorkspaceKanbanLaneCardList) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx index 889b3cd3ba8..efb9d88c69d 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx @@ -7,6 +7,9 @@ import type { } from '../../../../shared/types' import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' +// Why: a fresh [] per render would defeat the memoized lane on empty lanes. +const EMPTY_LANE_ITEMS: readonly Worktree[] = [] + type WorkspaceKanbanLaneGridProps = { statuses: readonly WorkspaceStatusDefinition[] worktreesByStatus: ReadonlyMap @@ -16,6 +19,7 @@ type WorkspaceKanbanLaneGridProps = { isResizingColumn: boolean dragOverStatus: WorkspaceStatus | null canCreateWorktree: boolean + renderCards: boolean selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onDragOver: (event: React.DragEvent, statusId: string) => void @@ -42,6 +46,7 @@ export default function WorkspaceKanbanLaneGrid({ isResizingColumn, dragOverStatus, canCreateWorktree, + renderCards, selectedWorktreeIds, selectedWorktrees, onDragOver, @@ -67,13 +72,14 @@ export default function WorkspaceKanbanLaneGrid({ selectedWorktrees: readonly Worktree[] onDragOver: (event: React.DragEvent, statusId: string) => void @@ -44,7 +45,7 @@ type WorkspaceKanbanStatusLaneProps = { onColumnResizeKeyDown: (event: React.KeyboardEvent) => void } -export default function WorkspaceKanbanStatusLane({ +function WorkspaceKanbanStatusLane({ status, items, repoMap, @@ -54,6 +55,7 @@ export default function WorkspaceKanbanStatusLane({ isDragTarget, canCreateWorktree, nativeDragEnabled = true, + renderCards, selectedWorktreeIds, selectedWorktrees, onDragOver, @@ -67,6 +69,7 @@ export default function WorkspaceKanbanStatusLane({ onColumnResizeStart, onColumnResizeKeyDown }: WorkspaceKanbanStatusLaneProps): React.JSX.Element { + const laneScrollRef = useRef(null) const meta = getWorkspaceStatusVisualMeta(status) const createTooltip = canCreateWorktree ? `New workspace in ${status.label}` @@ -152,32 +155,26 @@ export default function WorkspaceKanbanStatusLane({
{items.length > 0 ? ( -
- {items.map((worktree) => { - const isSelected = selectedWorktreeIds.has(worktree.id) - return ( - 0 ? selectedWorktrees : undefined - } - onActivate={onActivate} - onSelectionGesture={onSelectionGesture} - onContextMenuSelect={onContextMenuSelect} - onAssignWorkspaceStatus={onAssignWorkspaceStatus} - /> - ) - })} -
+ renderCards ? ( + + ) : null ) : (
{translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')} @@ -208,3 +205,5 @@ export default function WorkspaceKanbanStatusLane({ ) } + +export default React.memo(WorkspaceKanbanStatusLane) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts new file mode 100644 index 00000000000..89766b4ecde --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts @@ -0,0 +1,96 @@ +import { getWorkspaceKanbanVirtualLaneItemRects } from './workspace-kanban-virtual-lane-layout' + +type AreaSelectionCardContentRect = { + top: number + bottom: number + containerTop: number + scrollTop: number +} + +export type AreaSelectionCardRect = { + id: string + element: HTMLElement | null + rect: DOMRect + scrollContainer: HTMLElement | null + contentRect: AreaSelectionCardContentRect | null +} + +export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]' + +export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] { + const cardRects = new Map() + const scrollMetrics = new Map() + const scrollContainers = board.querySelectorAll( + AREA_SELECTION_SCROLL_CONTAINER_SELECTOR + ) + for (const scrollContainer of scrollContainers) { + const virtualRects = getWorkspaceKanbanVirtualLaneItemRects(scrollContainer) + if (!virtualRects) { + continue + } + const containerTop = scrollContainer.getBoundingClientRect().top + const scrollTop = scrollContainer.scrollTop + scrollMetrics.set(scrollContainer, { containerTop, scrollTop }) + for (const virtualRect of virtualRects) { + cardRects.set(virtualRect.id, { + id: virtualRect.id, + element: null, + rect: makeDomRect(virtualRect), + scrollContainer, + contentRect: { + top: virtualRect.contentTop, + bottom: virtualRect.contentBottom, + containerTop, + scrollTop + } + }) + } + } + + const cards = board.querySelectorAll('[data-workspace-board-card-id]') + for (const card of cards) { + const id = card.dataset.workspaceBoardCardId + if (!id) { + continue + } + const rect = card.getBoundingClientRect() + const scrollContainer = card.closest(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR) + let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined + if (scrollContainer && !metrics) { + metrics = { + containerTop: scrollContainer.getBoundingClientRect().top, + scrollTop: scrollContainer.scrollTop + } + scrollMetrics.set(scrollContainer, metrics) + } + cardRects.set(id, { + id, + element: card, + rect, + scrollContainer, + contentRect: metrics + ? { + top: rect.top - metrics.containerTop + metrics.scrollTop, + bottom: rect.bottom - metrics.containerTop + metrics.scrollTop, + containerTop: metrics.containerTop, + scrollTop: metrics.scrollTop + } + : null + }) + } + return Array.from(cardRects.values()) +} + +function makeDomRect(rect: { left: number; top: number; right: number; bottom: number }): DOMRect { + return { + x: rect.left, + y: rect.top, + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + toJSON: () => ({}) + } as DOMRect +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts index a0a5c9d46b3..55227e4905a 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts @@ -1,3 +1,14 @@ +import { + AREA_SELECTION_SCROLL_CONTAINER_SELECTOR, + type AreaSelectionCardRect +} from './workspace-kanban-area-selection-card-rects' + +export { + AREA_SELECTION_SCROLL_CONTAINER_SELECTOR, + getAreaSelectionCardRects, + type AreaSelectionCardRect +} from './workspace-kanban-area-selection-card-rects' + export type AreaSelectionRect = { left: number top: number @@ -5,21 +16,6 @@ export type AreaSelectionRect = { height: number } -export type AreaSelectionCardRect = { - id: string - element: HTMLElement - rect: DOMRect - scrollContainer: HTMLElement | null - contentRect: AreaSelectionCardContentRect | null -} - -type AreaSelectionCardContentRect = { - top: number - bottom: number - containerTop: number - scrollTop: number -} - type AreaSelectionAutoScrollParams = { pointerY: number containerTop: number @@ -37,7 +33,6 @@ type AreaSelectionCardIdOptions = { } const AREA_SELECTED_ATTR = 'data-workspace-board-card-area-selected' -export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]' export const AREA_SELECTION_AUTO_SCROLL_EDGE_SIZE = 48 export const AREA_SELECTION_AUTO_SCROLL_MAX_DELTA = 22 @@ -93,45 +88,6 @@ export function isScrollbarPointerDown( return hitsVerticalScrollbar || hitsHorizontalScrollbar } -export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] { - const cardRects: AreaSelectionCardRect[] = [] - const seen = new Set() - const scrollMetrics = new Map() - const cards = board.querySelectorAll('[data-workspace-board-card-id]') - for (const card of cards) { - const id = card.dataset.workspaceBoardCardId - if (!id || seen.has(id)) { - continue - } - const rect = card.getBoundingClientRect() - const scrollContainer = card.closest(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR) - let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined - if (scrollContainer && !metrics) { - metrics = { - containerTop: scrollContainer.getBoundingClientRect().top, - scrollTop: scrollContainer.scrollTop - } - scrollMetrics.set(scrollContainer, metrics) - } - cardRects.push({ - id, - element: card, - rect, - scrollContainer, - contentRect: metrics - ? { - top: rect.top - metrics.containerTop + metrics.scrollTop, - bottom: rect.bottom - metrics.containerTop + metrics.scrollTop, - containerTop: metrics.containerTop, - scrollTop: metrics.scrollTop - } - : null - }) - seen.add(id) - } - return cardRects -} - export function getAreaSelectionCardIds( cardRects: readonly AreaSelectionCardRect[], selectionRect: AreaSelectionRect, @@ -256,7 +212,7 @@ export function clearPreviewSelection( previewIds: Set ): void { for (const card of cardRects) { - if (previewIds.has(card.id)) { + if (card.element && previewIds.has(card.id)) { card.element.removeAttribute(AREA_SELECTED_ATTR) } } @@ -276,6 +232,9 @@ export function updatePreviewSelection( } for (const card of cardRects) { + if (!card.element) { + continue + } const shouldPreview = nextIds.has(card.id) const isPreviewed = previewIds.has(card.id) if (shouldPreview === isPreviewed) { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts index 5a8698a1cb8..f8ab1df0f94 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveWorkspaceKanbanCardDropCommitTarget, resolveWorkspaceCardDropIndexFromRects, + resolveWorkspaceCardDropIndicatorY, resolveWorkspaceStatusDropTargetFromRects } from './workspace-kanban-card-pointer-drag-dom' @@ -41,6 +42,47 @@ describe('workspace kanban pointer drag card drop index', () => { it('inserts at the end when the pointer is below every card midpoint', () => { expect(resolveWorkspaceCardDropIndexFromRects(cardRects, 140)).toBe(3) }) + + // Why: a virtualized lane only renders its window, so rendered position is + // not lane position. Without the carried index a drop into a scrolled lane + // would land near the top of the list. + it('uses the lane index of a rendered card rather than its rendered position', () => { + const virtualized = [ + { top: 0, bottom: 40, index: 20 }, + { top: 48, bottom: 88, index: 21 }, + { top: 96, bottom: 136, index: 22 } + ] + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 10)).toBe(20) + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 70)).toBe(22) + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 140)).toBe(23) + }) +}) + +describe('workspace kanban drop indicator placement', () => { + const cardRects = [ + { top: 100, bottom: 140 }, + { top: 148, bottom: 188 } + ] + + it('places the line above, between, and below rendered cards', () => { + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 0, 90)).toBe(95) + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 1, 90)).toBe(144) + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 2, 90)).toBe(193) + }) + + it('falls back to the lane top when the lane renders no cards', () => { + expect(resolveWorkspaceCardDropIndicatorY([], 0, 90)).toBe(104) + }) + + it('anchors to the rendered card that owns the lane index', () => { + const virtualized = [ + { top: 100, bottom: 140, index: 20 }, + { top: 148, bottom: 188, index: 21 } + ] + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 20, 90)).toBe(95) + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 21, 90)).toBe(144) + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 22, 90)).toBe(193) + }) }) describe('workspace kanban pointer drag commit target', () => { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts index 9114a47a881..dd59d430ab9 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts @@ -1,4 +1,8 @@ import type { WorkspaceStatus } from '../../../../shared/types' +import { + resolveWorkspaceKanbanVirtualLaneDropIndex, + resolveWorkspaceKanbanVirtualLaneDropIndicatorY +} from './workspace-kanban-virtual-lane-layout' export const CARD_SELECTOR = '[data-workspace-board-card-id]' export const STATUS_DROP_TARGET = '[data-workspace-status-drop-target]' @@ -19,6 +23,9 @@ export type WorkspaceKanbanStatusDropRect = { export type WorkspaceKanbanCardDropRect = { top: number bottom: number + // Why: lanes virtualize, so a rendered card's DOM position is not its index + // in the lane. Carry the lane index when the card advertises one. + index?: number } export type WorkspaceKanbanLaneDropRect = { @@ -31,6 +38,7 @@ export type WorkspaceKanbanCardDropTarget = { status: WorkspaceStatus | null isPinDrop: boolean dropIndex: number + dropIndicatorY?: number laneRect?: WorkspaceKanbanLaneDropRect cardRects?: readonly WorkspaceKanbanCardDropRect[] } @@ -75,10 +83,51 @@ export function resolveWorkspaceCardDropIndexFromRects( for (let index = 0; index < rects.length; index++) { const rect = rects[index]! if (y < (rect.top + rect.bottom) / 2) { - return index + return rect.index ?? index } } - return rects.length + const last = rects.at(-1) + return last?.index !== undefined ? last.index + 1 : rects.length +} + +function getRenderedCardLanePosition(rect: WorkspaceKanbanCardDropRect, fallback: number): number { + return rect.index ?? fallback +} + +export function resolveWorkspaceCardDropIndicatorY( + rects: readonly WorkspaceKanbanCardDropRect[], + dropIndex: number, + laneTop: number +): number { + if (rects.length === 0) { + return laneTop + 14 + } + // Why: with a virtualized lane the drop index counts unrendered cards too, + // so place the line relative to the rendered card that owns that slot. + const slot = rects.findIndex( + (rect, index) => getRenderedCardLanePosition(rect, index) >= dropIndex + ) + if (slot === -1) { + return rects.at(-1)!.bottom + 5 + } + if (slot === 0) { + return rects[0]!.top - 5 + } + return (rects[slot - 1]!.bottom + rects[slot]!.top) / 2 +} + +function getLaneCardDropRects(lane: HTMLElement): WorkspaceKanbanCardDropRect[] { + return Array.from(lane.querySelectorAll(CARD_SELECTOR)) + .filter((card) => card.offsetParent !== null) + .map((card) => { + const rect = card.getBoundingClientRect() + const index = Number.parseInt(card.dataset.workspaceBoardCardIndex ?? '', 10) + return { + top: rect.top, + bottom: rect.bottom, + ...(Number.isInteger(index) ? { index } : {}) + } + }) } function hasWorkspaceKanbanCardDropTarget(target: WorkspaceKanbanCardDropTarget): boolean { @@ -176,16 +225,18 @@ export function getCardDropTarget( const laneContent = lane.querySelector('[data-workspace-board-lane-scroll]') const laneClientRect = (laneContent ?? lane).getBoundingClientRect() - const cardRects = Array.from(lane.querySelectorAll(CARD_SELECTOR)) - .filter((card) => card.offsetParent !== null) - .map((card) => { - const rect = card.getBoundingClientRect() - return { top: rect.top, bottom: rect.bottom } - }) - const dropIndex = resolveWorkspaceCardDropIndexFromRects(cardRects, y) + const cardRects = getLaneCardDropRects(lane) + const virtualDropIndex = laneContent + ? resolveWorkspaceKanbanVirtualLaneDropIndex(laneContent, y) + : null + const dropIndex = virtualDropIndex ?? resolveWorkspaceCardDropIndexFromRects(cardRects, y) + const dropIndicatorY = laneContent + ? resolveWorkspaceKanbanVirtualLaneDropIndicatorY(laneContent, dropIndex) + : null return { ...target, dropIndex, + ...(dropIndicatorY !== null ? { dropIndicatorY } : {}), laneRect: { left: laneClientRect.left, top: laneClientRect.top, @@ -245,23 +296,10 @@ export function updateCardDropIndicator( width: fallbackLaneRect.width } : { left: 0, top: 0, width: 0 }) - const cardRects = - target.cardRects ?? - Array.from(lane.querySelectorAll(CARD_SELECTOR)) - .filter((card) => card.offsetParent !== null) - .map((card) => { - const rect = card.getBoundingClientRect() - return { top: rect.top, bottom: rect.bottom } - }) - const boundedDropIndex = Math.max(0, Math.min(cardRects.length, target.dropIndex)) + const cardRects = target.cardRects ?? getLaneCardDropRects(lane) const y = - cardRects.length === 0 - ? laneRect.top + 14 - : boundedDropIndex === 0 - ? cardRects[0]!.top - 5 - : boundedDropIndex >= cardRects.length - ? cardRects.at(-1)!.bottom + 5 - : (cardRects[boundedDropIndex - 1]!.bottom + cardRects[boundedDropIndex]!.top) / 2 + target.dropIndicatorY ?? + resolveWorkspaceCardDropIndicatorY(cardRects, Math.max(0, target.dropIndex), laneRect.top) const horizontalInset = 8 const indicator = getOrCreateDropIndicator() indicator.dataset.workspaceStatus = target.status diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index e9ae00c76f1..8d450a2c600 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -6,8 +6,10 @@ import { getWorkspaceKanbanSidebarDropGroups, getWorkspaceKanbanSidebarDropTarget, isWorkspaceKanbanSidebarDropPointInBoard, + registerWorkspaceKanbanSidebarDropGroups, updateWorkspaceKanbanSidebarDropTargetVisual } from './workspace-kanban-sidebar-drop' +import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout' const workspaceStatuses: WorkspaceStatusDefinition[] = [ { id: 'todo', label: 'Todo' }, @@ -27,6 +29,7 @@ class FakeElement extends FakeNode { } } offsetParent: FakeElement | null = null + scrollTop = 0 private readonly attributes = new Map() private rect: Pick = { left: 0, @@ -157,6 +160,7 @@ class FakeDocument { } let fakeDocument: FakeDocument +let unregisterSidebarDropGroups: (() => void) | null = null function setRect( element: HTMLElement, @@ -219,6 +223,8 @@ function worktree(args: { } afterEach(() => { + unregisterSidebarDropGroups?.() + unregisterSidebarDropGroups = null clearWorkspaceKanbanSidebarDropTargetVisual() vi.unstubAllGlobals() vi.restoreAllMocks() @@ -247,6 +253,71 @@ describe('workspace kanban sidebar drop DOM bridge', () => { ]) }) + it('prefers complete registered groups and restores the DOM fallback on cleanup', () => { + appendBoard() + unregisterSidebarDropGroups = registerWorkspaceKanbanSidebarDropGroups([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] } + ]) + + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] } + ]) + + unregisterSidebarDropGroups() + unregisterSidebarDropGroups = null + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b'] } + ]) + }) + + it('uses the full virtual layout when the mounted card window is stale', () => { + const { lane } = appendBoard() + const laneScroll = document.createElement('div') + laneScroll.setAttribute('data-workspace-board-lane-scroll', '') + setRect(laneScroll, { + left: 0, + top: 0, + right: 200, + bottom: 220, + width: 200, + height: 220 + }) + const spacer = document.createElement('div') + setRect(spacer, { + left: 8, + top: -4260, + right: 192, + bottom: 220, + width: 184, + height: 4480 + }) + laneScroll.append(spacer) + lane.append(laneScroll) + setElementFromPoint(lane) + const unregister = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement: laneScroll, + spacerElement: spacer, + getItemIds: () => Array.from({ length: 102 }, (_, index) => `doing-${index}`), + getMeasurements: () => + Array.from({ length: 102 }, (_, index) => ({ + index, + start: index * 44, + end: index * 44 + 36 + })) + }) + + expect(getWorkspaceKanbanSidebarDropTarget(24, 210)).toMatchObject({ + status: 'doing', + dropIndex: 102, + dropIndicatorY: 225, + cardRects: [ + { top: 8, bottom: 48 }, + { top: 56, bottom: 96 } + ] + }) + unregister() + }) + it('marks and clears the external board hover target', () => { const { lane } = appendBoard() setElementFromPoint(lane) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index befa87d9eee..b29fa3e350f 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -25,6 +25,7 @@ const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]' const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target' let externalDragTargetElement: HTMLElement | null = null +let logicalDropGroupsRegistration: { groups: readonly WorktreeDragGroup[] } | null = null function getWorkspaceKanbanBoardElement(): HTMLElement | null { return document.querySelector(BOARD_SELECTOR) @@ -69,7 +70,26 @@ export function clearWorkspaceKanbanSidebarDropTargetVisual(): void { removeCardDropIndicator() } +export function registerWorkspaceKanbanSidebarDropGroups( + groups: readonly WorktreeDragGroup[] +): () => void { + const registration = { groups } + logicalDropGroupsRegistration = registration + return () => { + if (logicalDropGroupsRegistration === registration) { + logicalDropGroupsRegistration = null + } + } +} + export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] { + if (logicalDropGroupsRegistration) { + return logicalDropGroupsRegistration.groups.map((group) => ({ + key: group.key, + worktreeIds: [...group.worktreeIds] + })) + } + const board = getWorkspaceKanbanBoardElement() if (!board) { return [] diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts new file mode 100644 index 00000000000..40f028a2a1a --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { + getAreaSelectionCardIds, + getAreaSelectionCardRects +} from './workspace-kanban-area-selection-dom' +import { + getWorkspaceKanbanVirtualLaneItemRects, + registerWorkspaceKanbanVirtualLaneLayout, + resolveWorkspaceKanbanVirtualLaneDropIndex, + resolveWorkspaceKanbanVirtualLaneDropIndicatorY +} from './workspace-kanban-virtual-lane-layout' + +const ITEM_COUNT = 102 +const ITEM_STRIDE = 44 +const ITEM_HEIGHT = 36 + +function createLayoutElements(): { + scrollElement: HTMLElement + spacerElement: HTMLElement +} { + const scrollElement = { + scrollTop: 0, + getBoundingClientRect: () => ({ + left: 20, + top: 100, + right: 220, + bottom: 600, + width: 200, + height: 500 + }) + } as unknown as HTMLElement + const spacerElement = { + getBoundingClientRect: () => ({ + left: 26, + top: 108 - scrollElement.scrollTop, + right: 214, + bottom: 108 - scrollElement.scrollTop + ITEM_COUNT * ITEM_STRIDE, + width: 188, + height: ITEM_COUNT * ITEM_STRIDE + }) + } as unknown as HTMLElement + return { scrollElement, spacerElement } +} + +function registerLayout(scrollElement: HTMLElement, spacerElement: HTMLElement): () => void { + return registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => Array.from({ length: ITEM_COUNT }, (_, index) => `w${index}`), + getMeasurements: () => + Array.from({ length: ITEM_COUNT }, (_, index) => ({ + index, + start: index * ITEM_STRIDE, + end: index * ITEM_STRIDE + ITEM_HEIGHT + })) + }) +} + +describe('workspace kanban virtual lane layout', () => { + it('resolves mid-lane and bottom drops immediately after a large scroll jump', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 370)).toBe(97) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 590)).toBe(102) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 102)).toBe(613) + unregister() + }) + + it('exposes every logical item even when no card is mounted', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + + const rects = getWorkspaceKanbanVirtualLaneItemRects(scrollElement) + expect(rects).toHaveLength(ITEM_COUNT) + expect(rects?.[50]).toMatchObject({ + id: 'w50', + index: 50, + contentTop: 2_208, + contentBottom: 2_244 + }) + unregister() + }) + + it('selects intermediate never-mounted cards across a jumped lane range', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + const board = { + querySelectorAll: (selector: string) => + selector === '[data-workspace-board-lane-scroll]' ? [scrollElement] : [] + } as unknown as HTMLElement + + const cardRects = getAreaSelectionCardRects(board) + const selectedIds = getAreaSelectionCardIds( + cardRects, + { left: 0, top: 110, width: 300, height: 480 }, + { + scrollStartContentYByElement: new Map([[scrollElement, 18]]), + currentY: 590 + } + ) + + expect(cardRects.every((card) => card.element === null)).toBe(true) + expect(selectedIds).toHaveLength(ITEM_COUNT) + expect(selectedIds).toContain('w50') + unregister() + }) + + it('does not let stale cleanup remove a newer lane registration', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterFirst = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['old'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + const unregisterSecond = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['new'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + + unregisterFirst() + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)?.map((item) => item.id)).toEqual([ + 'new' + ]) + unregisterSecond() + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts new file mode 100644 index 00000000000..e142b3617d6 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts @@ -0,0 +1,159 @@ +import type { VirtualItem } from '@tanstack/react-virtual' + +type VirtualLaneLayoutRegistration = { + spacerElement: HTMLElement + getItemIds: () => readonly string[] + getMeasurements: () => readonly Pick[] +} + +type VirtualLaneLayoutSnapshot = { + registration: VirtualLaneLayoutRegistration + itemIds: readonly string[] + measurements: readonly Pick[] +} + +export type WorkspaceKanbanVirtualLaneItemRect = { + id: string + index: number + left: number + top: number + right: number + bottom: number + contentTop: number + contentBottom: number +} + +const virtualLaneLayouts = new WeakMap() + +export function registerWorkspaceKanbanVirtualLaneLayout(args: { + scrollElement: HTMLElement + spacerElement: HTMLElement + getItemIds: () => readonly string[] + getMeasurements: () => readonly Pick[] +}): () => void { + const registration = { + spacerElement: args.spacerElement, + getItemIds: args.getItemIds, + getMeasurements: args.getMeasurements + } + virtualLaneLayouts.set(args.scrollElement, registration) + return () => { + if (virtualLaneLayouts.get(args.scrollElement) === registration) { + virtualLaneLayouts.delete(args.scrollElement) + } + } +} + +export function getWorkspaceKanbanVirtualLaneItemRects( + scrollElement: HTMLElement +): WorkspaceKanbanVirtualLaneItemRect[] | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + + const spacerRect = snapshot.registration.spacerElement.getBoundingClientRect() + const containerRect = scrollElement.getBoundingClientRect() + const contentOffset = spacerRect.top - containerRect.top + scrollElement.scrollTop + const rects: WorkspaceKanbanVirtualLaneItemRect[] = [] + for (let index = 0; index < snapshot.itemIds.length; index++) { + const measurement = snapshot.measurements[index] + if (!isValidMeasurement(measurement, index)) { + return null + } + rects.push({ + id: snapshot.itemIds[index]!, + index, + left: spacerRect.left, + top: spacerRect.top + measurement.start, + right: spacerRect.right, + bottom: spacerRect.top + measurement.end, + contentTop: contentOffset + measurement.start, + contentBottom: contentOffset + measurement.end + }) + } + return rects +} + +export function resolveWorkspaceKanbanVirtualLaneDropIndex( + scrollElement: HTMLElement, + pointerY: number +): number | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + + const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top + let low = 0 + let high = snapshot.itemIds.length + while (low < high) { + const middle = Math.floor((low + high) / 2) + const measurement = snapshot.measurements[middle] + if (!isValidMeasurement(measurement, middle)) { + return null + } + if (pointerY < spacerTop + (measurement.start + measurement.end) / 2) { + high = middle + } else { + low = middle + 1 + } + } + return low +} + +export function resolveWorkspaceKanbanVirtualLaneDropIndicatorY( + scrollElement: HTMLElement, + dropIndex: number +): number | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + if (snapshot.itemIds.length === 0) { + return scrollElement.getBoundingClientRect().top + 14 + } + + const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top + const boundedIndex = Math.max(0, Math.min(snapshot.itemIds.length, dropIndex)) + if (boundedIndex === 0) { + const first = snapshot.measurements[0] + return isValidMeasurement(first, 0) ? spacerTop + first.start - 5 : null + } + if (boundedIndex === snapshot.itemIds.length) { + const lastIndex = snapshot.itemIds.length - 1 + const last = snapshot.measurements[lastIndex] + return isValidMeasurement(last, lastIndex) ? spacerTop + last.end + 5 : null + } + const previous = snapshot.measurements[boundedIndex - 1] + const next = snapshot.measurements[boundedIndex] + if (!isValidMeasurement(previous, boundedIndex - 1) || !isValidMeasurement(next, boundedIndex)) { + return null + } + return spacerTop + (previous.end + next.start) / 2 +} + +function getVirtualLaneLayoutSnapshot( + scrollElement: HTMLElement +): VirtualLaneLayoutSnapshot | null { + const registration = virtualLaneLayouts.get(scrollElement) + if (!registration) { + return null + } + const itemIds = registration.getItemIds() + const measurements = registration.getMeasurements() + return measurements.length >= itemIds.length ? { registration, itemIds, measurements } : null +} + +function isValidMeasurement( + measurement: Pick | undefined, + index: number +): measurement is Pick { + return Boolean( + measurement && + measurement.index === index && + Number.isFinite(measurement.start) && + Number.isFinite(measurement.end) && + measurement.end >= measurement.start + ) +} diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts new file mode 100644 index 00000000000..850a83024a7 --- /dev/null +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -0,0 +1,238 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const SEEDED_WORKSPACE_COUNT = 300 +const MARQUEE_WORKSPACE_COUNT = 102 + +/** + * Why: the board used to mount every workspace card in every lane in one + * commit, which blocked the sheet open for seconds on a large workspace set. + * These assert the lane renders a window instead, and that each rendered card + * still carries its true lane index so drop targeting stays correct. + */ +test.describe('Workspace board lane virtualization', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('mounts a window of cards for a large lane and keeps lane indexes', async ({ orcaPage }) => { + await orcaPage.evaluate((count) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => { + const suffix = String(index).padStart(3, '0') + return { + id: `${repo.id}::/virtual-board-${suffix}`, + instanceId: `virtual-board-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-board-${suffix}`, + displayName: `Virtual board ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-board-${suffix}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: 'in-progress' + } + }) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + }, SEEDED_WORKSPACE_COUNT) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const cards = orcaPage.locator('[data-workspace-board-card-id]') + // Why: an empty window would also satisfy "fewer than seeded"; the point of + // the change is a filled window, not a blank board. + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + expect(await cards.count()).toBeLessThan(SEEDED_WORKSPACE_COUNT / 2) + + const indexes = await cards.evaluateAll((elements) => + elements.map((element) => + Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1) + ) + ) + expect(indexes.every((index) => Number.isInteger(index) && index >= 0)).toBe(true) + expect(new Set(indexes).size).toBe(indexes.length) + }) + + test('renders later lane indexes after the lane scrolls', async ({ orcaPage }) => { + await orcaPage.evaluate((count) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => ({ + id: `${repo.id}::/virtual-scroll-${index}`, + instanceId: `virtual-scroll-${index}`, + repoId: repo.id, + path: `${repo.path}/../virtual-scroll-${index}`, + displayName: `Virtual scroll ${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-scroll-${index}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: 'in-progress' + })) + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + }, SEEDED_WORKSPACE_COUNT) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const cards = orcaPage.locator('[data-workspace-board-card-id]') + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + + const readMaxIndex = (): Promise => + cards.evaluateAll((elements) => + Math.max( + ...elements.map((element) => + Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1) + ) + ) + ) + const before = await readMaxIndex() + + await orcaPage + .locator('[data-workspace-status="in-progress"] [data-workspace-board-lane-scroll]') + .first() + .evaluate((element) => { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + + await expect.poll(readMaxIndex, { timeout: 15_000 }).toBeGreaterThan(before) + }) + + test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => { + const statusId = 'virtual-marquee' + await orcaPage.evaluate( + ({ count, status }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => ({ + id: `${repo.id}::/virtual-marquee-${index}`, + instanceId: `virtual-marquee-${index}`, + repoId: repo.id, + path: `${repo.path}/../virtual-marquee-${index}`, + displayName: `Virtual marquee ${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + manualOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-marquee-${index}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: status + })) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + state.setWorkspaceStatuses([ + { id: status, label: 'Virtual marquee' }, + ...state.workspaceStatuses.filter((entry) => entry.id !== status) + ]) + }, + { count: MARQUEE_WORKSPACE_COUNT, status: statusId } + ) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const lane = orcaPage.locator(`[data-workspace-status="${statusId}"]`) + await expect(lane.getByText(String(MARQUEE_WORKSPACE_COUNT), { exact: true })).toBeVisible() + const laneCards = lane.locator('[data-workspace-board-card-id]') + await expect.poll(() => laneCards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + const laneScroll = lane.locator('[data-workspace-board-lane-scroll]') + const box = await laneScroll.boundingBox() + if (!box) { + throw new Error('Expected the marquee lane to have a bounding box') + } + + await orcaPage.mouse.move(box.x + 2, box.y + 12) + await orcaPage.mouse.down() + await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80) + await laneScroll.evaluate(async (element) => { + for (let pass = 0; pass < 4; pass++) { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll', { bubbles: true })) + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + } + }) + await orcaPage.mouse.move(box.x + box.width - 18, box.y + box.height - 12) + await orcaPage.mouse.up() + + await expect( + orcaPage.getByText(`${MARQUEE_WORKSPACE_COUNT} selected`, { exact: true }) + ).toBeVisible() + }) +})