diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts index eb075805183..9c0b3a67449 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts @@ -97,7 +97,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-b') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-b', { revealInSidebar: false }) }) it('falls back to the base/primary worktree when no other workspace remains', () => { @@ -108,7 +108,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main', { revealInSidebar: false }) }) it('does not re-focus a sibling hosted on a torn-down runtime-owned SSH target', () => { @@ -140,7 +140,9 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main-1') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main-1', { + revealInSidebar: false + }) }) it('does not steal focus when the deleted worktree was not the active one', () => { @@ -229,7 +231,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { mocks.state.deleteStateByWorktreeId = { 'wt-a': { isDeleting: true } } commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main', { revealInSidebar: false }) }) it('does not steal focus when a non-worktree workspace is active', () => { diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts index 1993e044f4b..6d55dba2fca 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts @@ -83,7 +83,8 @@ function focusNextWorktreeAfterActiveDelete( } const nextWorktreeId = pickNextWorktreeIdAfterDelete(state, repoId, deletedWorktreeId) if (nextWorktreeId) { - activateAndRevealWorktree(nextWorktreeId) + // Keep successor focus from replacing the deleted row's spatial context. + activateAndRevealWorktree(nextWorktreeId, { revealInSidebar: false }) } } diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts index 5526c2d39c9..ff812a950be 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts @@ -14,6 +14,7 @@ import { import { getRenderRowKey } from '../listing/render-row' import type { RenderRow } from '../listing/render-row' import type { WorktreeListVirtualizer } from './use-virtualizer' +import { useVirtualRowRemovalAnimation } from './use-row-removal-animation' const recordKeyCountCache = new WeakMap, number>() @@ -123,5 +124,12 @@ export function useVirtualRowMeasurementSync(args: { virtualizer }) + useVirtualRowRemovalAnimation({ + renderRows, + rekeyedRowKeys: lineageRowRekeys, + scrollRef, + virtualItems + }) + return { virtualItems, measureVirtualRowElement } } diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts new file mode 100644 index 00000000000..23980e38d39 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + buildVirtualRowRemovalMotions, + type VirtualRowLayoutSnapshot +} from './use-row-removal-animation' + +function snapshot(args: { + identities: string[] + scrollTop: number + starts: [string, number][] +}): VirtualRowLayoutSnapshot { + return { + rowIdentityKeys: new Set(args.identities), + scrollTop: args.scrollTop, + startsByKey: new Map(args.starts) + } +} + +describe('buildVirtualRowRemovalMotions', () => { + it('moves surviving rows from their pre-delete viewport positions', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:a', 'wt:b', 'wt:c'], + scrollTop: 100, + starts: [ + ['wt:a', 100], + ['wt:b', 220], + ['wt:c', 340] + ] + }), + current: snapshot({ + identities: ['wt:a', 'wt:c'], + scrollTop: 100, + starts: [ + ['wt:a', 100], + ['wt:c', 220] + ] + }), + rekeyedRowKeys: new Map() + }) + + expect(motions).toEqual([{ key: 'wt:c', deltaY: 120 }]) + }) + + it('does not double-move rows when anchor restoration offsets a deletion above the viewport', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:deleted', 'wt:a'], + scrollTop: 240, + starts: [['wt:a', 240]] + }), + current: snapshot({ + identities: ['wt:a'], + scrollTop: 120, + starts: [['wt:a', 120]] + }), + rekeyedRowKeys: new Map() + }) + + expect(motions).toEqual([]) + }) + + it('follows a surviving row through a lineage rekey', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:parent', 'wt:child'], + scrollTop: 0, + starts: [['lineage-group:parent', 100]] + }), + current: snapshot({ + identities: ['wt:parent'], + scrollTop: 0, + starts: [['wt:parent', 100]] + }), + rekeyedRowKeys: new Map([['lineage-group:parent', 'wt:parent']]) + }) + + expect(motions).toEqual([]) + }) + + it('ignores additions and measurement-only movement', () => { + const previous = snapshot({ + identities: ['wt:a'], + scrollTop: 0, + starts: [['wt:a', 100]] + }) + expect( + buildVirtualRowRemovalMotions({ + previous, + current: snapshot({ + identities: ['wt:a', 'wt:b'], + scrollTop: 0, + starts: [['wt:a', 140]] + }), + rekeyedRowKeys: new Map() + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts new file mode 100644 index 00000000000..8656cc03107 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts @@ -0,0 +1,112 @@ +import { useLayoutEffect, useRef } from 'react' +import type React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { RenderRow } from '../listing/render-row' +import { getRenderRowKey } from '../listing/render-row' + +export const WORKTREE_ROW_REMOVAL_ANIMATION_MS = 180 + +export type VirtualRowLayoutSnapshot = { + rowIdentityKeys: ReadonlySet + scrollTop: number + startsByKey: ReadonlyMap +} + +export type VirtualRowRemovalMotion = { + deltaY: number + key: string +} + +export function getSidebarRowIdentityKeys(rows: readonly RenderRow[]): ReadonlySet { + const keys = new Set() + for (const row of rows) { + if (row.type === 'lineage-group') { + row.rows.forEach((member) => keys.add(`wt:${member.rowKey}`)) + } else { + keys.add(getRenderRowKey(row)) + } + } + return keys +} + +export function buildVirtualRowRemovalMotions(args: { + previous: VirtualRowLayoutSnapshot | null + current: VirtualRowLayoutSnapshot + rekeyedRowKeys: ReadonlyMap +}): VirtualRowRemovalMotion[] { + const { previous, current, rekeyedRowKeys } = args + if ( + previous === null || + ![...previous.rowIdentityKeys].some((key) => !current.rowIdentityKeys.has(key)) + ) { + return [] + } + + const previousKeyByCurrentKey = new Map() + rekeyedRowKeys.forEach((currentKey, previousKey) => { + previousKeyByCurrentKey.set(currentKey, previousKey) + }) + const motions: VirtualRowRemovalMotion[] = [] + current.startsByKey.forEach((currentStart, key) => { + const previousKey = previous.startsByKey.has(key) ? key : previousKeyByCurrentKey.get(key) + const previousStart = previousKey ? previous.startsByKey.get(previousKey) : undefined + if (previousStart === undefined) { + return + } + const deltaY = previousStart - previous.scrollTop - (currentStart - current.scrollTop) + if (Math.abs(deltaY) > 0.5) { + motions.push({ deltaY, key }) + } + }) + return motions +} + +export function useVirtualRowRemovalAnimation(args: { + renderRows: readonly RenderRow[] + rekeyedRowKeys: ReadonlyMap + scrollRef: React.RefObject + virtualItems: readonly VirtualItem[] +}): void { + const previousSnapshotRef = useRef(null) + + useLayoutEffect(() => { + const scrollElement = args.scrollRef.current + if (!scrollElement) { + return + } + const current: VirtualRowLayoutSnapshot = { + rowIdentityKeys: getSidebarRowIdentityKeys(args.renderRows), + scrollTop: scrollElement.scrollTop, + startsByKey: new Map(args.virtualItems.map((item) => [String(item.key), item.start])) + } + const motions = buildVirtualRowRemovalMotions({ + previous: previousSnapshotRef.current, + current, + rekeyedRowKeys: args.rekeyedRowKeys + }) + previousSnapshotRef.current = current + if (motions.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return + } + + const elementsByKey = new Map( + Array.from( + scrollElement.querySelectorAll('[data-worktree-virtual-row-key]') + ).map((element) => [element.dataset.worktreeVirtualRowKey ?? '', element]) + ) + for (const motion of motions) { + const element = elementsByKey.get(motion.key) + if (!element || element.hasAttribute('data-worktree-sticky-header-active')) { + continue + } + const content = element.firstElementChild + if (!(content instanceof HTMLElement)) { + continue + } + content.animate([{ translate: `0 ${motion.deltaY}px` }, { translate: '0 0' }], { + duration: WORKTREE_ROW_REMOVAL_ANIMATION_MS, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)' + }) + } + }, [args.rekeyedRowKeys, args.renderRows, args.scrollRef, args.virtualItems]) +} diff --git a/tests/e2e/worktree-active-delete-scroll-position.spec.ts b/tests/e2e/worktree-active-delete-scroll-position.spec.ts new file mode 100644 index 00000000000..0a706bdfc15 --- /dev/null +++ b/tests/e2e/worktree-active-delete-scroll-position.spec.ts @@ -0,0 +1,264 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const TARGET_INDEX = 24 +const SYNTHETIC_COUNT = 40 + +type RowRemovalFrame = { + belowTop: number | null + scrollTop: number + targetExists: boolean +} + +async function seedActiveDeletionRows(page: Page): Promise<{ + belowId: string + successorId: string + targetId: string +}> { + return page.evaluate( + ({ count, targetIndex }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + const source = repo ? state.worktreesByRepo[repo.id]?.[0] : null + if (!repo || !source) { + throw new Error('Expected a seeded e2e worktree') + } + + const now = Date.now() + const worktrees = Array.from({ length: count }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + ...source, + id: `${repo.id}::active-delete-${suffix}`, + instanceId: `active-delete-${suffix}`, + path: source.path, + displayName: `Active delete row ${suffix}`, + branch: `active-delete-${suffix}`, + isMainWorktree: false, + isPinned: false, + isUnread: false, + sortOrder: count - index, + manualOrder: count - index, + lastActivityAt: now - index, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + } + }) + const targetId = worktrees[targetIndex]!.id + const belowId = worktrees[targetIndex + 1]!.id + const successorId = worktrees[0]!.id + + store.setState({ + activeRepoId: repo.id, + activeView: 'terminal', + activeWorktreeId: targetId, + activeWorkspaceKey: `worktree:${targetId}`, + filterRepoIds: [], + groupBy: 'none', + hideDefaultBranchWorkspace: false, + lastVisitedAtByWorktreeId: { [successorId]: now + 1_000 }, + pendingRevealSidebarRow: null, + pendingRevealWorktree: null, + repos: state.repos.map((candidate) => + candidate.id === repo.id + ? { + ...candidate, + hookSettings: { + ...candidate.hookSettings, + scripts: { ...candidate.hookSettings?.scripts, setup: 'true' } + } + } + : candidate + ), + settings: { ...state.settings, skipDeleteWorktreeConfirm: true }, + setupScriptPromptDismissedRepoIds: [`generation-v1:local\0${repo.id}`], + showActiveOnly: false, + showSleepingWorkspaces: true, + sidebarOpen: true, + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: worktrees }, + removeWorktree: async (target) => { + const id = typeof target === 'string' ? target : target.id + store.setState((current) => ({ + activeWorktreeId: current.activeWorktreeId === id ? null : current.activeWorktreeId, + activeWorkspaceKey: current.activeWorktreeId === id ? null : current.activeWorkspaceKey, + worktreesByRepo: { + ...current.worktreesByRepo, + [repo.id]: (current.worktreesByRepo[repo.id] ?? []).filter( + (worktree) => worktree.id !== id + ) + } + })) + return { ok: true } + } + }) + return { belowId, successorId, targetId } + }, + { count: SYNTHETIC_COUNT, targetIndex: TARGET_INDEX } + ) +} + +async function prepareScrolledActiveRow(page: Page, targetId: string): Promise { + const target = page.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + const scroller = page.locator('[data-worktree-sidebar]') + await expect + .poll(async () => { + if ((await target.count()) > 0) { + return true + } + await scroller.evaluate((element) => { + element.scrollTop = Math.min( + element.scrollHeight, + element.scrollTop + Math.max(100, element.clientHeight / 2) + ) + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + return false + }) + .toBe(true) + await target.evaluate((element) => element.scrollIntoView({ block: 'center' })) + await expect(target).toBeVisible() + await expect(target).toHaveAttribute('aria-current', 'page') + return scroller.evaluate((element) => element.scrollTop) +} + +async function startRowRemovalSampling( + page: Page, + targetId: string, + belowId: string +): Promise { + await page.evaluate( + ({ belowId, targetId }) => { + const sample = async (): Promise => { + const frames: RowRemovalFrame[] = [] + for (let index = 0; index < 50; index += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const scroller = document.querySelector('[data-worktree-sidebar]') + const below = document.querySelector( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]` + ) + frames.push({ + belowTop: below?.getBoundingClientRect().top ?? null, + scrollTop: scroller?.scrollTop ?? 0, + targetExists: Boolean( + document.querySelector( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + ) + }) + } + return frames + } + Reflect.set(window, '__activeDeleteRowRemovalFrames', sample()) + }, + { belowId, targetId } + ) +} + +async function finishRowRemovalSampling(page: Page): Promise { + return page.evaluate(async () => { + const pending = Reflect.get(window, '__activeDeleteRowRemovalFrames') + if (!(pending instanceof Promise)) { + throw new Error('Row removal sampling was not started') + } + return pending + }) +} + +test('deleting the active scrolled worktree preserves position and closes the row gap', async ({ + orcaPage +}) => { + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1_200, height: 800 }) + const { belowId, successorId, targetId } = await seedActiveDeletionRows(orcaPage) + const initialScrollTop = await prepareScrolledActiveRow(orcaPage, targetId) + const target = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + const below = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]` + ) + await target.evaluate((element) => { + const scope = element.querySelector( + '[data-worktree-context-menu-scope="worktree"]' + ) + if (!scope) { + throw new Error('Worktree context-menu scope is unavailable') + } + scope.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + button: 2, + cancelable: true, + clientX: scope.getBoundingClientRect().left + 10, + clientY: scope.getBoundingClientRect().top + 10 + }) + ) + }) + const deleteItem = orcaPage.getByRole('menuitem', { name: 'Delete', exact: true }) + await expect(deleteItem).toBeVisible() + await startRowRemovalSampling(orcaPage, targetId, belowId) + await deleteItem.click() + + await expect(target).toHaveCount(0) + await expect(below).toBeVisible() + const frames = await finishRowRemovalSampling(orcaPage) + const mountedTops = frames.flatMap((frame) => (frame.belowTop === null ? [] : [frame.belowTop])) + const distinctTops = new Set(mountedTops.map((top) => Math.round(top * 10) / 10)) + + expect(distinctTops.size).toBeGreaterThanOrEqual(4) + expect(Math.max(...mountedTops) - Math.min(...mountedTops)).toBeGreaterThan(30) + expect(Math.max(...frames.map((frame) => frame.scrollTop))).toBeLessThanOrEqual( + initialScrollTop + 1 + ) + expect(Math.min(...frames.map((frame) => frame.scrollTop))).toBeGreaterThanOrEqual( + initialScrollTop - 1 + ) + await expect( + orcaPage.locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(successorId)}]`) + ).toHaveCount(0) +}) + +test('reduced motion removes the active row without animating its neighbor', async ({ + orcaPage +}) => { + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + await waitForSessionReady(orcaPage) + const { belowId, targetId } = await seedActiveDeletionRows(orcaPage) + await prepareScrolledActiveRow(orcaPage, targetId) + + const animationCount = await orcaPage.evaluate( + async ({ belowId, targetId }) => { + const store = window.__store! + const state = store.getState() + const repoId = state.repos[0]!.id + store.setState({ + activeWorktreeId: null, + activeWorkspaceKey: null, + worktreesByRepo: { + ...state.worktreesByRepo, + [repoId]: state.worktreesByRepo[repoId]!.filter((worktree) => worktree.id !== targetId) + } + }) + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const below = document.querySelector( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]` + ) + return ( + below?.closest('[data-worktree-virtual-row]')?.firstElementChild?.getAnimations().length ?? + 0 + ) + }, + { belowId, targetId } + ) + + expect(animationCount).toBe(0) +})