From 602f0cbe63cb3016f2c8f076896921117fb5ad76 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:08:37 -0700 Subject: [PATCH] fix(sidebar): stabilize downward worktree card dragging Fix downward worktree card dragging with virtualization-safe global indices and stable preview geometry. Add unit and Electron regression coverage. --- .../src/components/sidebar/WorktreeList.tsx | 4 +- .../sidebar/worktree-drag-preview-offsets.ts | 74 ++++- .../sidebar/worktree-list-virtual-rows.ts | 1 + .../sidebar/worktree-manual-order.test.ts | 22 ++ .../worktree-sidebar-drag-geometry.test.ts | 8 +- .../sidebar/worktree-sidebar-drag-geometry.ts | 16 +- .../worktree-sidebar-drop-preview.test.ts | 74 +++++ .../sidebar/worktree-sidebar-drop-preview.ts | 24 +- tests/e2e/worktree-card-downward-drag.spec.ts | 278 ++++++++++++++++++ 9 files changed, 468 insertions(+), 33 deletions(-) create mode 100644 tests/e2e/worktree-card-downward-drag.spec.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 25dccde1957..be462b94bf0 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -104,6 +104,7 @@ import { getVirtualRowTransform, pruneStaleVirtualRowElementCache, shouldUseHeaderTopSpacing, + WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, type RenderRow } from './worktree-list-virtual-rows' import { @@ -1625,6 +1626,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp groupIds: group.worktreeIds, draggedIds: args.draggedIds, draggingWorktreeId: args.draggingWorktreeId, + fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, grab: args.grab, anchor: args.anchor }) @@ -2109,7 +2111,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [stickyHeaderIndexes] ), overscan: 10, - gap: 6, + gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, // Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal. scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, diff --git a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts index cf97cad8958..86bf48a9b0a 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts @@ -11,28 +11,37 @@ function arraysEqual(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]) } -function getFallbackStride(rects: readonly WorktreeDragPreviewRect[]): number { +function getFallbackStride(rects: readonly WorktreeDragPreviewRect[], defaultGap: number): number { const sortedRects = [...rects].sort((a, b) => a.groupIndex - b.groupIndex) const strides: number[] = [] for (let index = 1; index < sortedRects.length; index++) { - strides.push(sortedRects[index]!.top - sortedRects[index - 1]!.top) + const previous = sortedRects[index - 1]! + const current = sortedRects[index]! + const indexDelta = current.groupIndex - previous.groupIndex + if (indexDelta > 0) { + strides.push((current.top - previous.top) / indexDelta) + } } if (strides.length > 0) { strides.sort((a, b) => a - b) return strides[Math.floor(strides.length / 2)]! } const firstRect = sortedRects[0] - return firstRect ? firstRect.bottom - firstRect.top : 0 + return firstRect ? firstRect.bottom - firstRect.top + defaultGap : 0 } -function getFallbackGap(rects: readonly WorktreeDragPreviewRect[]): number { +function getFallbackGap(rects: readonly WorktreeDragPreviewRect[], defaultGap: number): number { const sortedRects = [...rects].sort((a, b) => a.groupIndex - b.groupIndex) const gaps: number[] = [] for (let index = 1; index < sortedRects.length; index++) { - gaps.push(Math.max(0, sortedRects[index]!.top - sortedRects[index - 1]!.bottom)) + const previous = sortedRects[index - 1]! + const current = sortedRects[index]! + if (current.groupIndex === previous.groupIndex + 1) { + gaps.push(Math.max(0, current.top - previous.bottom)) + } } if (gaps.length === 0) { - return 0 + return defaultGap } gaps.sort((a, b) => a - b) return gaps[Math.floor(gaps.length / 2)]! @@ -70,6 +79,8 @@ export function buildWorktreeDragPreviewOffsets(args: { groupIds: readonly string[] draggedIds: readonly string[] draggingWorktreeId?: string | null + draggedPreviewHeight?: number | null + fallbackGap?: number dropIndex: number rects: readonly WorktreeDragPreviewRect[] }): WorktreeDragPreviewLayout { @@ -106,13 +117,18 @@ export function buildWorktreeDragPreviewOffsets(args: { } } - const fallbackStride = getFallbackStride(args.rects) - const fallbackGap = getFallbackGap(args.rects) + const defaultGap = + typeof args.fallbackGap === 'number' && + Number.isFinite(args.fallbackGap) && + args.fallbackGap >= 0 + ? args.fallbackGap + : 0 + const fallbackStride = getFallbackStride(args.rects, defaultGap) + const fallbackGap = getFallbackGap(args.rects, defaultGap) const groupRects = args.groupIds.flatMap((id) => { const rect = rectById.get(id) return rect ? [rect] : [] }) - const baseTop = groupRects[0]?.top ?? 0 const fallbackHeight = Math.max(0, fallbackStride - fallbackGap) const gapAfterById = new Map() for (let index = 0; index < groupRects.length; index++) { @@ -120,19 +136,51 @@ export function buildWorktreeDragPreviewOffsets(args: { const nextRect = groupRects[index + 1] gapAfterById.set( rect.worktreeId, - nextRect ? Math.max(0, nextRect.top - rect.bottom) : fallbackGap + nextRect?.groupIndex === rect.groupIndex + 1 + ? Math.max(0, nextRect.top - rect.bottom) + : fallbackGap ) } + const previewDraggedId = layoutDraggedIds[0] ?? null + const draggedPreviewHeight = + previewDraggedId === args.draggingWorktreeId && + typeof args.draggedPreviewHeight === 'number' && + Number.isFinite(args.draggedPreviewHeight) && + args.draggedPreviewHeight > 0 + ? args.draggedPreviewHeight + : null + const getHeight = (id: string): number => { + const rect = rectById.get(id) + if (rect) { + return rect.bottom - rect.top + } + return id === previewDraggedId && draggedPreviewHeight !== null + ? draggedPreviewHeight + : fallbackHeight + } + const getStride = (id: string): number => getHeight(id) + (gapAfterById.get(id) ?? fallbackGap) + + const firstGroupRect = groupRects[0] + let baseTop = firstGroupRect?.top ?? 0 + if (firstGroupRect) { + // Why: virtualized leading rows are absent from rects, so derive the full + // list origin from the first mounted row before replaying its order. + for (let index = 0; index < firstGroupRect.groupIndex; index++) { + const id = args.groupIds[index] + if (id) { + baseTop -= getStride(id) + } + } + } + const targetTopById = new Map() let nextTop = baseTop // Why: lineage drag units can be much taller than ordinary cards, so replay // layout with measured heights instead of mapping indexes to old slot tops. for (const id of nextIds) { targetTopById.set(id, nextTop) - const rect = rectById.get(id) - const height = rect ? rect.bottom - rect.top : fallbackHeight - nextTop += height + (gapAfterById.get(id) ?? fallbackGap) + nextTop += getStride(id) } const offsets = new Map() diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 69b8c71c010..4d6b0b7162c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -5,6 +5,7 @@ import { getLineageGroupKey, PINNED_GROUP_KEY } from './worktree-list-groups' export const GROUP_HEADER_ROW_HEIGHT = 28 export const HOST_HEADER_ROW_HEIGHT = 32 +export const WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP = 6 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4 const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 const PENDING_CREATION_ROW_HEIGHT = 56 diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts index 47e728e1cb6..5d7839956c5 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -87,6 +87,28 @@ describe('buildWorktreeDragPreviewOffsets', () => { ]) }) + it('keeps downward offsets stable after virtualization unmounts the leading rows', () => { + const { offsets, placeholderTop } = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], + draggedIds: ['a'], + draggingWorktreeId: 'a', + draggedPreviewHeight: 50, + dropIndex: 6, + rects: [ + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }, + { worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 } + ] + }) + + expect(Array.from(offsets)).toEqual([ + ['d', -56], + ['e', -56], + ['f', -56] + ]) + expect(placeholderTop).toBe(280) + }) + it('slides intervening rows down while dragging a row up', () => { const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['a', 'b', 'c'], diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts index 1f44a94f7fa..2a1d9652713 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts @@ -159,20 +159,20 @@ describe('worktree sidebar drag geometry under mid-drag card growth', () => { scrollTop: 0 } - expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, rects: COLLAPSED })).toBeNull() + expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, groupIds: GROUP_IDS })).toBeNull() expect( resolveWorktreeSidebarDropAnchorIndex({ anchor: { beforeWorktreeId: 'c', pointerY: 0, scrollTop: 0 }, - rects: COLLAPSED + groupIds: GROUP_IDS }) ).toBe(2) // A null anchor id means end-of-group, which survives any row count change. expect( resolveWorktreeSidebarDropAnchorIndex({ anchor: { beforeWorktreeId: null, pointerY: 0, scrollTop: 0 }, - rects: COLLAPSED + groupIds: GROUP_IDS }) - ).toBe(COLLAPSED.length) + ).toBe(GROUP_IDS.length) }) it('keeps one live coordinate space across a session refresh', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts index c903868b7ca..691bd7ab67e 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts @@ -70,25 +70,25 @@ export function shouldReevaluateWorktreeSidebarDropAnchor(args: { /** * Resolve a held anchor back to a drop index in the current layout. Returns null - * when the anchored card is gone (deleted, filtered, or unmounted by - * virtualization), so the caller falls back to a fresh geometric decision. + * when the anchored card is gone (deleted or filtered), so the caller falls + * back to a fresh geometric decision. */ export function resolveWorktreeSidebarDropAnchorIndex(args: { anchor: WorktreeSidebarDropAnchor - rects: readonly WorktreeSidebarDragRect[] + groupIds: readonly string[] }): number | null { if (args.anchor.beforeWorktreeId === null) { - return args.rects.length + return args.groupIds.length } - const target = args.rects.find((rect) => rect.worktreeId === args.anchor.beforeWorktreeId) - return target ? target.groupIndex : null + const targetIndex = args.groupIds.indexOf(args.anchor.beforeWorktreeId) + return targetIndex !== -1 ? targetIndex : null } export function getWorktreeSidebarDropAnchorId(args: { - rects: readonly WorktreeSidebarDragRect[] + groupIds: readonly string[] dropIndex: number }): string | null { - return args.rects.find((rect) => rect.groupIndex === args.dropIndex)?.worktreeId ?? null + return args.groupIds[args.dropIndex] ?? null } /** diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts index ef6147458a1..e1f1b870287 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts @@ -174,6 +174,80 @@ describe('computeWorktreeSidebarDropPreview', () => { expect(new Set(dropIndexes).size).toBe(1) }) + + it('keeps a downward end drop stable when leading rows are virtualized', () => { + const groupIds = ['a', 'b', 'c', 'd', 'e', 'f'] + const mountedRects = [ + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }, + { worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 } + ] + const input = { + pointerY: 320, + containerTop: 0, + scrollTop: 0, + rects: mountedRects, + groupIds, + draggedIds: ['a'], + draggingWorktreeId: 'a', + grab: { offsetY: 25, height: 50 } + } + + const first = computeWorktreeSidebarDropPreview(input)! + const held = computeWorktreeSidebarDropPreview({ + ...input, + anchor: { beforeWorktreeId: first.dropAnchorId, pointerY: 320, scrollTop: 0 } + })! + + for (const preview of [first, held]) { + expect(preview).toMatchObject({ + dropIndex: 6, + dropIndicatorY: 277, + dropAnchorId: null + }) + expect(Array.from(preview.previewOffsetsByWorktreeId)).toEqual([ + ['d', -56], + ['e', -56], + ['f', -56] + ]) + } + }) + + it('uses the full group index when the dragged row is outside the mounted window', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 193, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }, + { worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], + draggedIds: ['e'], + draggingWorktreeId: 'e', + grab: { offsetY: 25, height: 50 } + }) + + expect(preview?.dropIndex).toBe(3) + }) + + it('preserves the virtual row gap when only one row remains mounted', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 305, + containerTop: 0, + scrollTop: 0, + rects: [{ worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 }], + groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], + draggedIds: ['a'], + draggingWorktreeId: 'a', + fallbackGap: 6, + grab: { offsetY: 25, height: 50 } + }) + + expect(preview).toMatchObject({ dropIndex: 6, dropIndicatorY: 277 }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['f', -56]]) + }) }) describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index e25f2dd2b37..c2d5012a9be 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -178,6 +178,7 @@ export function computeWorktreeSidebarDropPreview(args: { groupIds: readonly string[] draggedIds: readonly string[] draggingWorktreeId?: string | null + fallbackGap?: number // Where the card was grabbed, so the drop follows the card rather than the bare // pointer. Omitted for native HTML5 drags, which have no reliable grab offset. grab?: WorktreeSidebarDragGrab | null @@ -194,10 +195,13 @@ export function computeWorktreeSidebarDropPreview(args: { } const localY = args.pointerY - args.containerTop + args.scrollTop - const activeIndex = args.draggingWorktreeId - ? rects.findIndex((rect) => rect.worktreeId === args.draggingWorktreeId) + const activeGroupIndex = args.draggingWorktreeId + ? args.groupIds.indexOf(args.draggingWorktreeId) : -1 - const activeRect = activeIndex >= 0 ? rects[activeIndex]! : null + const activeRect = + activeGroupIndex >= 0 + ? (rects.find((rect) => rect.worktreeId === args.draggingWorktreeId) ?? null) + : null const referenceY = getWorktreeSidebarDragReferenceY({ localY, grab: args.grab ?? null, @@ -217,15 +221,19 @@ export function computeWorktreeSidebarDropPreview(args: { } const heldIndex = args.anchor - ? resolveWorktreeSidebarDropAnchorIndex({ anchor: args.anchor, rects }) + ? resolveWorktreeSidebarDropAnchorIndex({ anchor: args.anchor, groupIds: args.groupIds }) : null let dropIndex: number if (heldIndex !== null) { dropIndex = heldIndex } else if (boundaryDrop.kind === 'drop') { dropIndex = boundaryDrop.dropIndex - } else if (activeRect) { - dropIndex = getWorktreeSidebarClosestCenterDropIndex({ referenceY, rects, activeIndex }) + } else if (activeGroupIndex >= 0) { + dropIndex = getWorktreeSidebarClosestCenterDropIndex({ + referenceY, + rects, + activeIndex: activeGroupIndex + }) } else { dropIndex = getWorktreeSidebarPointerDropIndex({ referenceY, rects }) } @@ -234,6 +242,8 @@ export function computeWorktreeSidebarDropPreview(args: { groupIds: args.groupIds, draggedIds: args.draggedIds, draggingWorktreeId: args.draggingWorktreeId, + draggedPreviewHeight: args.grab?.height, + fallbackGap: args.fallbackGap, dropIndex, rects }) @@ -246,6 +256,6 @@ export function computeWorktreeSidebarDropPreview(args: { activeRect }), previewOffsetsByWorktreeId: offsets, - dropAnchorId: getWorktreeSidebarDropAnchorId({ rects, dropIndex }) + dropAnchorId: getWorktreeSidebarDropAnchorId({ groupIds: args.groupIds, dropIndex }) } } diff --git a/tests/e2e/worktree-card-downward-drag.spec.ts b/tests/e2e/worktree-card-downward-drag.spec.ts new file mode 100644 index 00000000000..b5cb685a4a6 --- /dev/null +++ b/tests/e2e/worktree-card-downward-drag.spec.ts @@ -0,0 +1,278 @@ +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const SYNTHETIC_COUNT = 60 + +type PreviewOffsetSample = { + worktreeId: string + targetOffset: number + renderedOffset: number +} + +async function seedVirtualizedManualWorktrees(page: Page): Promise<{ + sourceId: string + nextId: string + idPrefix: string +}> { + const repo = await page.evaluate(() => { + 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') + } + return { id: repo.id, path: repo.path } + }) + const now = Date.now() + const syntheticWorktrees = Array.from({ length: SYNTHETIC_COUNT }, (_, index) => { + const suffix = String(index).padStart(2, '0') + const worktreePath = path.join(repo.path, '..', `downward-drag-${suffix}`) + return { + id: `${repo.id}::downward-drag-${suffix}`, + instanceId: `downward-drag-${suffix}`, + repoId: repo.id, + path: worktreePath, + displayName: `Downward drag ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 100_000 - index, + manualOrder: 100_000 - index, + lastActivityAt: now - index, + head: '0000000000000000000000000000000000000000', + branch: `downward-drag-${suffix}`, + isBare: false, + isMainWorktree: false + } + }) + await page.evaluate( + ({ repoId, worktrees }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const seededWorktrees = (state.worktreesByRepo[repoId] ?? []).map((worktree, index) => ({ + ...worktree, + manualOrder: -1_000 - index + })) + + store.setState({ + groupBy: 'none', + sortBy: 'manual', + showActiveOnly: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + filterRepoIds: [], + sidebarOpen: true, + worktreesByRepo: { + ...state.worktreesByRepo, + [repoId]: [...worktrees, ...seededWorktrees] + }, + updateWorktreesMeta: async (updatesByWorktreeId) => { + store.setState((current) => ({ + sortEpoch: current.sortEpoch + 1, + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => ({ + ...worktree, + ...updatesByWorktreeId.get(worktree.id) + })) + ]) + ) + })) + } + }) + }, + { repoId: repo.id, worktrees: syntheticWorktrees } + ) + const sourceId = syntheticWorktrees[0]!.id + return { + sourceId, + nextId: syntheticWorktrees[1]!.id, + idPrefix: sourceId.slice(0, -2) + } +} + +async function sampleMountedPreviewOffsets( + page: Page, + sourceId: string +): Promise { + return page.evaluate(async (draggedId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + if (!scroller) { + throw new Error('Worktree sidebar is not available') + } + const samples: PreviewOffsetSample[][] = [] + for (let frame = 0; frame < 20; frame++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + if (document.querySelector(`[data-worktree-drag-id=${JSON.stringify(draggedId)}]`)) { + continue + } + const frameSamples = [ + ...document.querySelectorAll('[data-worktree-virtual-row]') + ].flatMap((row) => { + const rowRect = row.getBoundingClientRect() + const scrollerRect = scroller.getBoundingClientRect() + if (rowRect.bottom <= scrollerRect.top || rowRect.top >= scrollerRect.bottom) { + return [] + } + const worktree = row.querySelector('[data-worktree-drag-id]') + const worktreeId = worktree?.getAttribute('data-worktree-drag-id') + const rowStart = Number(row.dataset.worktreeVirtualRowStart) + const matches = [...row.style.transform.matchAll(/translateY\((-?[\d.]+)px\)/g)] + const targetOffset = Number(matches[1]?.[1] ?? 0) + const renderedTop = new DOMMatrixReadOnly(getComputedStyle(row).transform).m42 + const renderedOffset = renderedTop - rowStart + return worktreeId && + Number.isFinite(rowStart) && + Number.isFinite(targetOffset) && + Number.isFinite(renderedOffset) + ? [{ worktreeId, targetOffset, renderedOffset }] + : [] + }) + if (frameSamples.length > 0) { + samples.push(frameSamples) + } + } + return samples + }, sourceId) +} + +test('dragging a virtualized worktree downward keeps rows stable', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1_000, height: 620 }) + const { sourceId, nextId, idPrefix } = await seedVirtualizedManualWorktrees(orcaPage) + const scroller = orcaPage.locator('[data-worktree-sidebar]') + const source = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(sourceId)}]` + ) + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect(source).toBeVisible() + + const sourceBox = await source.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height } + }) + const nextSource = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(nextId)}]` + ) + const sourceStride = await nextSource.evaluate( + (element, sourceTop) => element.getBoundingClientRect().top - sourceTop, + sourceBox.y + ) + expect(Number.isFinite(sourceStride)).toBe(true) + expect(sourceStride).toBeGreaterThan(1) + const scrollerBox = await scroller.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height } + }) + + await orcaPage.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await orcaPage.mouse.down() + try { + const edgeX = scrollerBox.x + 2 + const edgeY = scrollerBox.y + scrollerBox.height - 8 + // Keep the pointer in the edge zone while the renderer advances autoscroll. + for (let step = 0; step < 12; step++) { + await orcaPage.mouse.move(edgeX, edgeY, { steps: 2 }) + if ((await source.count()) === 0) { + break + } + await orcaPage.waitForTimeout(100) + } + if ((await source.count()) > 0) { + for (let step = 0; step < 8 && (await source.count()) > 0; step++) { + await scroller.evaluate((element) => { + element.scrollTop = Math.min( + element.scrollHeight, + element.scrollTop + element.clientHeight + ) + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await orcaPage.waitForTimeout(100) + } + } + await expect + .poll(() => source.count(), { + timeout: 10_000, + message: 'Downward autoscroll did not virtualize the dragged source row' + }) + .toBe(0) + + const samples = await sampleMountedPreviewOffsets(orcaPage, sourceId) + expect(samples.length).toBeGreaterThan(0) + const observationsById = new Map() + for (const sample of samples.flat()) { + const distanceFromValidTarget = Math.min( + Math.abs(sample.targetOffset), + Math.abs(sample.targetOffset + sourceStride) + ) + expect(distanceFromValidTarget).toBeLessThanOrEqual(1) + expect(sample.renderedOffset).toBeGreaterThanOrEqual(-sourceStride - 2) + expect(sample.renderedOffset).toBeLessThanOrEqual(2) + const observations = observationsById.get(sample.worktreeId) ?? [] + observations.push(sample) + observationsById.set(sample.worktreeId, observations) + } + + const recurrentRows = [...observationsById.values()].filter( + (observations) => observations.length > 1 + ) + expect(recurrentRows.length).toBeGreaterThan(0) + expect( + recurrentRows.some((observations) => + observations.some((sample) => sample.renderedOffset <= -sourceStride * 0.8) + ) + ).toBe(true) + for (const observations of recurrentRows) { + let renderedReversal = 0 + for (let index = 1; index < observations.length; index++) { + renderedReversal += Math.max( + 0, + observations[index]!.renderedOffset - observations[index - 1]!.renderedOffset + ) + } + expect(renderedReversal).toBeLessThanOrEqual(sourceStride) + } + } finally { + await orcaPage.mouse.up() + } + + await expect(orcaPage.locator('[data-worktree-sidebar-drag-preview="true"]')).toHaveCount(0) + await expect(orcaPage.locator('html')).not.toHaveAttribute( + 'data-worktree-sidebar-pointer-dragging' + ) + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + scroller + .locator('[role="option"]') + .evaluateAll( + (options, prefix) => + options + .map((option) => option.getAttribute('data-worktree-id')) + .find((worktreeId) => worktreeId?.startsWith(prefix)) ?? null, + idPrefix + ), + { message: 'The downward drop did not move the source away from the first slot' } + ) + .toBe(nextId) +})