mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, number>()
|
||||
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<string, number>()
|
||||
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<string, number>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PreviewOffsetSample[][]> {
|
||||
return page.evaluate(async (draggedId) => {
|
||||
const scroller = document.querySelector<HTMLElement>('[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<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
if (document.querySelector(`[data-worktree-drag-id=${JSON.stringify(draggedId)}]`)) {
|
||||
continue
|
||||
}
|
||||
const frameSamples = [
|
||||
...document.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<string, PreviewOffsetSample[]>()
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user