mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Virtualize workspace board lanes and defer card render for instant open
This commit is contained in:
@@ -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({
|
||||
<div
|
||||
className="relative rounded-lg data-[workspace-board-card-area-selected=true]:ring-1 data-[workspace-board-card-area-selected=true]:ring-worktree-sidebar-ring/40"
|
||||
data-workspace-board-card-id={worktree.id}
|
||||
data-workspace-board-card-index={laneIndex}
|
||||
data-workspace-board-card-mode="detailed"
|
||||
data-workspace-board-card-selected={isSelected ? 'true' : 'false'}
|
||||
data-workspace-board-pointer-draggable={nativeDragEnabled ? undefined : 'true'}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* eslint-disable max-lines -- Why: the board drawer owns shared board state, drag/drop, and settings callbacks that need one coordinated surface. */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees, useRepoMap } from '@/store/selectors'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
@@ -43,6 +51,7 @@ import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
|
||||
import { STATUS_BAR_RESERVE_HEIGHT, WORKSPACE_TOP_CHROME_HEIGHT } from './workspace-chrome-metrics'
|
||||
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { registerWorkspaceKanbanSidebarDropGroups } from './workspace-kanban-sidebar-drop'
|
||||
|
||||
type WorkspaceKanbanDrawerProps = {
|
||||
leftSidebarStyle?: React.CSSProperties
|
||||
@@ -159,6 +168,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
const areaSelectionOverlayRef = useRef<HTMLDivElement>(null)
|
||||
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(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}
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-workspace-board-card-id={worktree.id} data-workspace-board-card-index={laneIndex} />
|
||||
)
|
||||
}))
|
||||
|
||||
const { default: WorkspaceKanbanLaneCardList } = await import('./WorkspaceKanbanLaneCardList')
|
||||
|
||||
const REPO_MAP = new Map<string, Repo>()
|
||||
|
||||
function renderLane(count: number): HTMLElement {
|
||||
const scrollRef = createRef<HTMLDivElement>()
|
||||
const { container } = render(
|
||||
<div ref={scrollRef}>
|
||||
<WorkspaceKanbanLaneCardList
|
||||
items={Array.from({ length: count }, (_, index) => ({ id: `w${index}` }) as Worktree)}
|
||||
repoMap={REPO_MAP}
|
||||
activeWorktreeId={null}
|
||||
scrollRef={scrollRef}
|
||||
selectedWorktreeIds={new Set()}
|
||||
selectedWorktrees={[]}
|
||||
nativeDragEnabled={false}
|
||||
onActivate={() => {}}
|
||||
onSelectionGesture={() => false}
|
||||
onContextMenuSelect={() => []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
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<HTMLElement>('[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<HTMLElement>(
|
||||
'[data-workspace-board-card-id]'
|
||||
)?.parentElement
|
||||
expect(first?.style.transform).toBe(`translateY(${WINDOW_START * 44}px)`)
|
||||
})
|
||||
})
|
||||
@@ -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<string, Repo>
|
||||
activeWorktreeId: string | null
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
nativeDragEnabled: boolean
|
||||
onActivate: () => void
|
||||
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
|
||||
onContextMenuSelect: (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
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<HTMLDivElement | null>(null)
|
||||
const itemIdsRef = useRef<readonly string[]>([])
|
||||
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 (
|
||||
<div
|
||||
ref={spacerRef}
|
||||
className="relative w-full"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const worktree = items[virtualItem.index]
|
||||
if (!worktree) {
|
||||
return null
|
||||
}
|
||||
const isSelected = selectedWorktreeIds.has(worktree.id)
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||
>
|
||||
<WorkspaceKanbanCard
|
||||
worktree={worktree}
|
||||
laneIndex={virtualItem.index}
|
||||
repo={repoMap.get(worktree.repoId)}
|
||||
isActive={activeWorktreeId === worktree.id}
|
||||
isSelected={isSelected}
|
||||
nativeDragEnabled={nativeDragEnabled}
|
||||
selectedWorktrees={
|
||||
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
|
||||
}
|
||||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(WorkspaceKanbanLaneCardList)
|
||||
@@ -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<WorkspaceStatus, readonly Worktree[]>
|
||||
@@ -16,6 +19,7 @@ type WorkspaceKanbanLaneGridProps = {
|
||||
isResizingColumn: boolean
|
||||
dragOverStatus: WorkspaceStatus | null
|
||||
canCreateWorktree: boolean
|
||||
renderCards: boolean
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
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({
|
||||
<WorkspaceKanbanStatusLane
|
||||
key={status.id}
|
||||
status={status}
|
||||
items={worktreesByStatus.get(status.id) ?? []}
|
||||
items={worktreesByStatus.get(status.id) ?? EMPTY_LANE_ITEMS}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
columnWidth={columnWidth}
|
||||
isResizingColumn={isResizingColumn}
|
||||
isDragTarget={dragOverStatus === status.id}
|
||||
canCreateWorktree={canCreateWorktree}
|
||||
renderCards={renderCards}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
nativeDragEnabled={false}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type {
|
||||
Repo,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import WorkspaceKanbanCard from './WorkspaceKanbanCard'
|
||||
import WorkspaceKanbanLaneCardList from './WorkspaceKanbanLaneCardList'
|
||||
import { getWorkspaceStatusVisualMeta } from './workspace-status'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -27,6 +27,7 @@ type WorkspaceKanbanStatusLaneProps = {
|
||||
isDragTarget: boolean
|
||||
canCreateWorktree: boolean
|
||||
nativeDragEnabled?: boolean
|
||||
renderCards: boolean
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
onDragOver: (event: React.DragEvent, statusId: string) => void
|
||||
@@ -44,7 +45,7 @@ type WorkspaceKanbanStatusLaneProps = {
|
||||
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => 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<HTMLDivElement | null>(null)
|
||||
const meta = getWorkspaceStatusVisualMeta(status)
|
||||
const createTooltip = canCreateWorktree
|
||||
? `New workspace in ${status.label}`
|
||||
@@ -152,32 +155,26 @@ export default function WorkspaceKanbanStatusLane({
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={laneScrollRef}
|
||||
data-workspace-board-lane-scroll=""
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-2 scrollbar-sleek"
|
||||
>
|
||||
{items.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{items.map((worktree) => {
|
||||
const isSelected = selectedWorktreeIds.has(worktree.id)
|
||||
return (
|
||||
<WorkspaceKanbanCard
|
||||
key={worktree.id}
|
||||
worktree={worktree}
|
||||
repo={repoMap.get(worktree.repoId)}
|
||||
isActive={activeWorktreeId === worktree.id}
|
||||
isSelected={isSelected}
|
||||
nativeDragEnabled={nativeDragEnabled}
|
||||
selectedWorktrees={
|
||||
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
|
||||
}
|
||||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
renderCards ? (
|
||||
<WorkspaceKanbanLaneCardList
|
||||
items={items}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
scrollRef={laneScrollRef}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
nativeDragEnabled={nativeDragEnabled}
|
||||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex h-20 items-center justify-center rounded-md border border-dashed border-border/70 text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')}
|
||||
@@ -208,3 +205,5 @@ export default function WorkspaceKanbanStatusLane({
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(WorkspaceKanbanStatusLane)
|
||||
|
||||
@@ -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<string, AreaSelectionCardRect>()
|
||||
const scrollMetrics = new Map<HTMLElement, { containerTop: number; scrollTop: number }>()
|
||||
const scrollContainers = board.querySelectorAll<HTMLElement>(
|
||||
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<HTMLElement>('[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<HTMLElement>(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
|
||||
}
|
||||
@@ -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<string>()
|
||||
const scrollMetrics = new Map<HTMLElement, { containerTop: number; scrollTop: number }>()
|
||||
const cards = board.querySelectorAll<HTMLElement>('[data-workspace-board-card-id]')
|
||||
for (const card of cards) {
|
||||
const id = card.dataset.workspaceBoardCardId
|
||||
if (!id || seen.has(id)) {
|
||||
continue
|
||||
}
|
||||
const rect = card.getBoundingClientRect()
|
||||
const scrollContainer = card.closest<HTMLElement>(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<string>
|
||||
): 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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<HTMLElement>(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<HTMLElement>('[data-workspace-board-lane-scroll]')
|
||||
const laneClientRect = (laneContent ?? lane).getBoundingClientRect()
|
||||
const cardRects = Array.from(lane.querySelectorAll<HTMLElement>(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<HTMLElement>(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
|
||||
|
||||
@@ -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<string, string>()
|
||||
private rect: Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom' | 'width' | 'height'> = {
|
||||
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)
|
||||
|
||||
@@ -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<HTMLElement>(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 []
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { VirtualItem } from '@tanstack/react-virtual'
|
||||
|
||||
type VirtualLaneLayoutRegistration = {
|
||||
spacerElement: HTMLElement
|
||||
getItemIds: () => readonly string[]
|
||||
getMeasurements: () => readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
|
||||
}
|
||||
|
||||
type VirtualLaneLayoutSnapshot = {
|
||||
registration: VirtualLaneLayoutRegistration
|
||||
itemIds: readonly string[]
|
||||
measurements: readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
|
||||
}
|
||||
|
||||
export type WorkspaceKanbanVirtualLaneItemRect = {
|
||||
id: string
|
||||
index: number
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
contentTop: number
|
||||
contentBottom: number
|
||||
}
|
||||
|
||||
const virtualLaneLayouts = new WeakMap<HTMLElement, VirtualLaneLayoutRegistration>()
|
||||
|
||||
export function registerWorkspaceKanbanVirtualLaneLayout(args: {
|
||||
scrollElement: HTMLElement
|
||||
spacerElement: HTMLElement
|
||||
getItemIds: () => readonly string[]
|
||||
getMeasurements: () => readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
|
||||
}): () => 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<VirtualItem, 'index' | 'start' | 'end'> | undefined,
|
||||
index: number
|
||||
): measurement is Pick<VirtualItem, 'index' | 'start' | 'end'> {
|
||||
return Boolean(
|
||||
measurement &&
|
||||
measurement.index === index &&
|
||||
Number.isFinite(measurement.start) &&
|
||||
Number.isFinite(measurement.end) &&
|
||||
measurement.end >= measurement.start
|
||||
)
|
||||
}
|
||||
@@ -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<number> =>
|
||||
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<void>((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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user