diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 574dc53bd15..c7d70bae92e 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -652,10 +652,18 @@ export default function WorkspaceKanbanDrawer({ setRenderCards(false) return } + let cancelled = false const frameId = window.requestAnimationFrame(() => { - startTransition(() => setRenderCards(true)) + startTransition(() => { + if (!cancelled) { + setRenderCards(true) + } + }) }) - return () => window.cancelAnimationFrame(frameId) + return () => { + cancelled = true + window.cancelAnimationFrame(frameId) + } }, [open]) useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx index 4497158ed3a..d5b9b5c4176 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx @@ -18,20 +18,24 @@ 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: () => {} - }) + useVirtualizer: ({ count }: { count: number }) => { + const start = count === 0 ? 0 : Math.min(WINDOW_START, count - 1) + const end = count === 0 ? -1 : Math.min(WINDOW_END, count - 1) + return { + getTotalSize: () => (count === 0 ? 0 : count * 44 - 8), + measurementsCache: Array.from({ length: count }, (_, index) => ({ + index, + start: index * 44, + end: index * 44 + 36 + })), + getVirtualItems: () => + Array.from({ length: Math.max(0, end - start + 1) }, (_, offset) => { + const index = start + offset + return { index, key: `w${index}`, start: index * 44 } + }), + measureElement: () => {} + } + } })) vi.mock('./WorkspaceKanbanCard', () => ({ @@ -102,4 +106,14 @@ describe('WorkspaceKanbanLaneCardList', () => { )?.parentElement expect(first?.style.transform).toBe(`translateY(${WINDOW_START * 44}px)`) }) + + it('renders nothing for an empty lane and a single card at index 0', () => { + const empty = renderLane(0) + expect(empty.querySelectorAll('[data-workspace-board-card-id]')).toHaveLength(0) + + const single = renderLane(1) + const card = single.querySelector('[data-workspace-board-card-id]') + expect(card?.getAttribute('data-workspace-board-card-id')).toBe('w0') + expect(card?.getAttribute('data-workspace-board-card-index')).toBe('0') + }) }) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx index 57137ea709b..c5eb6837f03 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useLayoutEffect, useRef } from 'react' +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' import WorkspaceKanbanCard from './WorkspaceKanbanCard' @@ -11,6 +11,10 @@ const WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT = 36 const WORKSPACE_BOARD_CARD_GAP = 8 const WORKSPACE_BOARD_CARD_OVERSCAN = 6 +function estimateWorkspaceBoardCardSize(): number { + return WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT +} + type WorkspaceKanbanLaneCardListProps = { items: readonly Worktree[] repoMap: Map @@ -42,12 +46,13 @@ function WorkspaceKanbanLaneCardList({ onAssignWorkspaceStatus }: WorkspaceKanbanLaneCardListProps): React.JSX.Element { const spacerRef = useRef(null) - const itemIdsRef = useRef([]) - itemIdsRef.current = items.map((item) => item.id) + const itemIds = useMemo(() => items.map((item) => item.id), [items]) + const itemIdsRef = useRef(itemIds) + itemIdsRef.current = itemIds const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollRef.current, - estimateSize: () => WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT, + estimateSize: estimateWorkspaceBoardCardSize, getItemKey: useCallback((index: number) => items[index]?.id ?? index, [items]), overscan: WORKSPACE_BOARD_CARD_OVERSCAN, gap: WORKSPACE_BOARD_CARD_GAP, diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts index a767e73fe95..8ddaf9116b9 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts @@ -1,18 +1,20 @@ /* eslint-disable max-lines -- Why: marquee selection coordinates pointer capture, lane-scroll refresh, auto-scroll, preview cleanup, and final commit against one drag state. Splitting those phases would make the interaction easier to desynchronize. */ import React, { useCallback, useEffect, useRef } from 'react' +import { + getAreaSelectionCardRects, + type AreaSelectionCardRect +} from './workspace-kanban-area-selection-card-rects' import { clearPreviewSelection, getAreaSelectionAutoScrollDelta, getAreaSelectionCardIds, - getAreaSelectionCardRects, getAreaSelectionRect, getAreaSelectionScrollContainer, getAreaSelectionScrollStartContentYByElement, isScrollbarPointerDown, setOverlayRect, shouldIgnoreAreaSelectionStart, - updatePreviewSelection, - type AreaSelectionCardRect + updatePreviewSelection } from './workspace-kanban-area-selection-dom' type AreaSelectionDragState = { @@ -110,6 +112,20 @@ export function useWorkspaceKanbanAreaSelection({ state.started = true + // Why: deferred card mount can start a marquee against empty rects; pick up + // virtual layout once lanes register without waiting for a scroll event. + if (state.cardRects.length === 0) { + const board = boardRef.current + if (board) { + state.boardRect = board.getBoundingClientRect() + state.cardRects = getAreaSelectionCardRects(board) + state.scrollStartContentYByElement = getAreaSelectionScrollStartContentYByElement( + board, + state.startY + ) + } + } + const viewportRect = getAreaSelectionRect( state.startX, state.startY, @@ -153,7 +169,7 @@ export function useWorkspaceKanbanAreaSelection({ state.additive, areaIds ) - }, [overlayRef]) + }, [boardRef, overlayRef]) const refreshAreaSelectionMeasurements = useCallback(() => { const state = dragRef.current diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts index 14f635896fc..5375041d125 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts @@ -11,7 +11,6 @@ import { } from './workspace-kanban-card-pointer-drag-dom' import { createDragPreview, - getDraggedCards, setDragDocumentStyles, setDraggedCardsDragging, updateDragPreviewPosition @@ -33,7 +32,6 @@ type DragState = { currentY: number worktreeIds: string[] sourceCard: HTMLElement - draggedCards: HTMLElement[] preview: HTMLElement | null previewOffsetX: number previewOffsetY: number @@ -117,7 +115,11 @@ export function useWorkspaceKanbanCardPointerDrag({ if (state.frameId !== null) { window.cancelAnimationFrame(state.frameId) } - setDraggedCardsDragging(state.draggedCards, false) + setDraggedCardsDragging({ + board: boardRef.current, + worktreeIds: state.worktreeIds, + enabled: false + }) removeCardDropIndicator() state.preview?.remove() setDragDocumentStyles(false) @@ -146,13 +148,20 @@ export function useWorkspaceKanbanCardPointerDrag({ [boardRef, clearDragTarget] ) - const startPointerDrag = useCallback((state: DragState) => { - state.started = true - isPointerDragActiveRef.current = true - setDraggedCardsDragging(state.draggedCards, true) - state.preview = createDragPreview(state) - setDragDocumentStyles(true) - }, []) + const startPointerDrag = useCallback( + (state: DragState) => { + state.started = true + isPointerDragActiveRef.current = true + setDraggedCardsDragging({ + board: boardRef.current, + worktreeIds: state.worktreeIds, + enabled: true + }) + state.preview = createDragPreview(state) + setDragDocumentStyles(true) + }, + [boardRef] + ) const updatePointerDragTarget = useCallback( (state: DragState) => { @@ -303,7 +312,6 @@ export function useWorkspaceKanbanCardPointerDrag({ currentY: event.clientY, worktreeIds, sourceCard: card, - draggedCards: getDraggedCards(board, worktreeIds, card), preview: null, previewOffsetX: 0, previewOffsetY: 0, 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 index 89766b4ecde..95d315966f2 100644 --- 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 @@ -7,10 +7,17 @@ type AreaSelectionCardContentRect = { scrollTop: number } +export type AreaSelectionViewportRect = { + left: number + top: number + right: number + bottom: number +} + export type AreaSelectionCardRect = { id: string element: HTMLElement | null - rect: DOMRect + rect: AreaSelectionViewportRect scrollContainer: HTMLElement | null contentRect: AreaSelectionCardContentRect | null } @@ -35,7 +42,12 @@ export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCard cardRects.set(virtualRect.id, { id: virtualRect.id, element: null, - rect: makeDomRect(virtualRect), + rect: { + left: virtualRect.left, + top: virtualRect.top, + right: virtualRect.right, + bottom: virtualRect.bottom + }, scrollContainer, contentRect: { top: virtualRect.contentTop, @@ -66,7 +78,12 @@ export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCard cardRects.set(id, { id, element: card, - rect, + rect: { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom + }, scrollContainer, contentRect: metrics ? { @@ -80,17 +97,3 @@ export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCard } 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 55227e4905a..d023c468fbd 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 @@ -3,12 +3,6 @@ import { 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 @@ -212,7 +206,8 @@ export function clearPreviewSelection( previewIds: Set ): void { for (const card of cardRects) { - if (card.element && previewIds.has(card.id)) { + // Why: virtual remounts replace the element; clear via live connected node when present. + if (card.element?.isConnected && previewIds.has(card.id)) { card.element.removeAttribute(AREA_SELECTED_ATTR) } } @@ -232,19 +227,29 @@ export function updatePreviewSelection( } for (const card of cardRects) { - if (!card.element) { + const element = card.element?.isConnected ? card.element : null + if (!element) { + if (!nextIds.has(card.id)) { + previewIds.delete(card.id) + } continue } const shouldPreview = nextIds.has(card.id) - const isPreviewed = previewIds.has(card.id) + // Why: virtualization can remount the same card id; trust the live attr, not previewIds alone. + const isPreviewed = element.getAttribute(AREA_SELECTED_ATTR) === 'true' if (shouldPreview === isPreviewed) { + if (shouldPreview) { + previewIds.add(card.id) + } else { + previewIds.delete(card.id) + } continue } if (shouldPreview) { - card.element.setAttribute(AREA_SELECTED_ATTR, 'true') + element.setAttribute(AREA_SELECTED_ATTR, 'true') previewIds.add(card.id) } else { - card.element.removeAttribute(AREA_SELECTED_ATTR) + element.removeAttribute(AREA_SELECTED_ATTR) previewIds.delete(card.id) } } diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts index d04f34ca121..86a51e1e8a2 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts @@ -1,8 +1,10 @@ +// @vitest-environment happy-dom import { describe, expect, it } from 'vitest' +import type { AreaSelectionCardRect } from './workspace-kanban-area-selection-card-rects' import { getAreaSelectionAutoScrollDelta, getAreaSelectionCardIds, - type AreaSelectionCardRect + updatePreviewSelection } from './workspace-kanban-area-selection-dom' import { shouldCommitWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection' @@ -166,16 +168,52 @@ function makeRect({ top: number right: number bottom: number -}): DOMRect { - return { - left, - top, - right, - bottom, - width: right - left, - height: bottom - top, - x: left, - y: top, - toJSON: () => ({}) - } as DOMRect +}): AreaSelectionCardRect['rect'] { + return { left, top, right, bottom } } + +describe('workspace kanban area selection preview remount', () => { + it('re-applies the preview attribute when a card remounts under the same id', () => { + const first = document.createElement('div') + first.setAttribute('data-workspace-board-card-id', 'card-a') + Object.defineProperty(first, 'isConnected', { value: true }) + const previewIds = new Set() + updatePreviewSelection( + [ + { + id: 'card-a', + element: first, + rect: makeRect({ left: 0, top: 0, right: 100, bottom: 40 }), + scrollContainer: null, + contentRect: null + } + ], + previewIds, + new Set(), + false, + ['card-a'] + ) + expect(first.getAttribute('data-workspace-board-card-area-selected')).toBe('true') + expect(previewIds.has('card-a')).toBe(true) + + const remounted = document.createElement('div') + remounted.setAttribute('data-workspace-board-card-id', 'card-a') + Object.defineProperty(remounted, 'isConnected', { value: true }) + updatePreviewSelection( + [ + { + id: 'card-a', + element: remounted, + rect: makeRect({ left: 0, top: 0, right: 100, bottom: 40 }), + scrollContainer: null, + contentRect: null + } + ], + previewIds, + new Set(), + false, + ['card-a'] + ) + expect(remounted.getAttribute('data-workspace-board-card-area-selected')).toBe('true') + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts index 887d7ebcac2..58dafc5db3f 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts @@ -37,12 +37,27 @@ export function getDraggedCards( return cards.length > 0 ? cards : [fallbackCard] } -export function setDraggedCardsDragging(cards: readonly HTMLElement[], enabled: boolean): void { - for (const card of cards) { - if (enabled) { - card.setAttribute(POINTER_CARD_DRAGGING_ATTR, 'true') - } else { +export function setDraggedCardsDragging(args: { + board: HTMLElement | null + worktreeIds: readonly string[] + enabled: boolean +}): void { + const { board, worktreeIds, enabled } = args + if (!board) { + return + } + // Why: virtual lanes remount cards during scroll; query live nodes by id + // instead of holding HTMLElement references across unmounts. + if (!enabled) { + board.querySelectorAll(`[${POINTER_CARD_DRAGGING_ATTR}]`).forEach((card) => { card.removeAttribute(POINTER_CARD_DRAGGING_ATTR) + }) + return + } + const ids = new Set(worktreeIds) + for (const card of board.querySelectorAll(CARD_SELECTOR)) { + if (ids.has(card.dataset.workspaceBoardCardId ?? '')) { + card.setAttribute(POINTER_CARD_DRAGGING_ATTR, 'true') } } } 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 index 40f028a2a1a..003cdf73784 100644 --- 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 @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - getAreaSelectionCardIds, - getAreaSelectionCardRects -} from './workspace-kanban-area-selection-dom' +import { getAreaSelectionCardRects } from './workspace-kanban-area-selection-card-rects' +import { getAreaSelectionCardIds } from './workspace-kanban-area-selection-dom' import { getWorkspaceKanbanVirtualLaneItemRects, registerWorkspaceKanbanVirtualLaneLayout, @@ -131,4 +129,45 @@ describe('workspace kanban virtual lane layout', () => { unregisterSecond() expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() }) + + it('places empty-lane and boundary drop indicators from the spacer', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterEmpty = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => [], + getMeasurements: () => [] + }) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 200)).toBe(0) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 0)).toBe(114) + unregisterEmpty() + + const unregister = registerLayout(scrollElement, spacerElement) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 90)).toBe(0) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 0)).toBe(103) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 1)).toBe(148) + unregister() + }) + + it('returns null when measurements are incomplete or mismatched', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterShort = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['a', 'b'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 120)).toBeNull() + unregisterShort() + + const unregisterBadIndex = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['a'], + getMeasurements: () => [{ index: 3, start: 0, end: ITEM_HEIGHT }] + }) + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + unregisterBadIndex() + }) })