From 552e86d2d0796faaeae1e666841435545e23b864 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 23:00:49 -0700 Subject: [PATCH] fix: stabilize multi-delete sidebar rows (#2073) --- src/renderer/src/assets/main.css | 12 -- .../src/components/sidebar/WorktreeList.tsx | 121 +++++++++++++++--- 2 files changed, 102 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 5819aca9f..339035659 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -822,18 +822,6 @@ padding: 0 4px; } -.worktree-sidebar-layout-animate [data-worktree-virtual-row] { - transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1); - will-change: transform; -} - -@media (prefers-reduced-motion: reduce) { - .worktree-sidebar-layout-animate [data-worktree-virtual-row] { - transition: none; - will-change: auto; - } -} - .worktree-item { display: flex; flex-direction: column; diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 8119ada05..d0f5ed660 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -80,6 +80,7 @@ const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { overflowAnchor: 'none' } const WORKTREE_REMOVE_LAYOUT_ANIMATION_MS = 180 +const WORKTREE_REMOVE_LAYOUT_ANIMATION_EASING = 'cubic-bezier(0.16, 1, 0.3, 1)' function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { @@ -216,6 +217,18 @@ function getRenderRowKey(row: RenderRow): string { return `wt:${row.worktree.id}` } +function getVirtualRowTransform(start: number): string { + return `translateY(${start}px)` +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({ rows, activeWorktreeId, @@ -263,8 +276,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp () => buildRenderableRows(rows, showWorkspaceLineage), [rows, showWorkspaceLineage] ) - const [layoutAnimationActive, setLayoutAnimationActive] = useState(false) const previousRenderRowCountRef = useRef(renderRows.length) + const previousVirtualRowTopByKeyRef = useRef(new Map()) + const activeVirtualRowAnimationsRef = useRef(new Map()) const activeWorktreeRowIndex = useMemo( () => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)), [renderRows, activeWorktreeId] @@ -388,6 +402,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const prCacheLen = useAppStore((s) => Object.keys(s.prCache).length) const issueCacheLen = useAppStore((s) => Object.keys(s.issueCache).length) const totalSize = virtualizer.getTotalSize() + const virtualItems = virtualizer.getVirtualItems() useVirtualizedScrollAnchor({ anchorRef: scrollAnchorRef, getRowKey: getRenderRowKey, @@ -399,19 +414,88 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }) useEffect(() => { + const activeAnimations = activeVirtualRowAnimationsRef.current + return () => { + activeAnimations.forEach((animation) => animation.cancel()) + activeAnimations.clear() + } + }, []) + + useLayoutEffect(() => { const previousCount = previousRenderRowCountRef.current - previousRenderRowCountRef.current = renderRows.length - if (renderRows.length >= previousCount) { - return + const rowCountDecreased = renderRows.length < previousCount + const previousTopByKey = previousVirtualRowTopByKeyRef.current + const nextTopByKey = new Map() + const activeRowKeys = new Set() + const scrollElement = scrollRef.current + const scrollTop = scrollElement?.scrollTop ?? 0 + const viewportTop = scrollElement?.getBoundingClientRect().top ?? 0 + + const elementByRowKey = new Map() + scrollElement + ?.querySelectorAll('[data-worktree-virtual-row-key]') + .forEach((element) => { + const rowKey = element.dataset.worktreeVirtualRowKey + if (rowKey) { + elementByRowKey.set(rowKey, element) + } + }) + + for (const item of virtualItems) { + const rowKey = String(item.key) + const currentTop = viewportTop + item.start - scrollTop + nextTopByKey.set(rowKey, currentTop) + activeRowKeys.add(rowKey) + + if (!rowCountDecreased || prefersReducedMotion()) { + continue + } + + const element = elementByRowKey.get(rowKey) + const previousTop = previousTopByKey.get(rowKey) + if (!element || previousTop === undefined || typeof element.animate !== 'function') { + continue + } + + const delta = previousTop - currentTop + if (Math.abs(delta) < 1) { + continue + } + + const runningAnimation = activeVirtualRowAnimationsRef.current.get(rowKey) + const fromTransform = runningAnimation + ? window.getComputedStyle(element).transform + : getVirtualRowTransform(item.start + delta) + runningAnimation?.cancel() + + // Why: CSS transition classes are one render behind delete commits. FLIP + // keeps rapid multi-delete rows anchored to their current visual position. + const animation = element.animate( + [{ transform: fromTransform }, { transform: getVirtualRowTransform(item.start) }], + { + duration: WORKTREE_REMOVE_LAYOUT_ANIMATION_MS, + easing: WORKTREE_REMOVE_LAYOUT_ANIMATION_EASING + } + ) + activeVirtualRowAnimationsRef.current.set(rowKey, animation) + void animation.finished + .catch(() => undefined) + .finally(() => { + if (activeVirtualRowAnimationsRef.current.get(rowKey) === animation) { + activeVirtualRowAnimationsRef.current.delete(rowKey) + } + }) } - setLayoutAnimationActive(true) - const timeoutId = window.setTimeout( - () => setLayoutAnimationActive(false), - WORKTREE_REMOVE_LAYOUT_ANIMATION_MS - ) - return () => window.clearTimeout(timeoutId) - }, [renderRows.length]) + activeVirtualRowAnimationsRef.current.forEach((animation, rowKey) => { + if (!activeRowKeys.has(rowKey)) { + animation.cancel() + activeVirtualRowAnimationsRef.current.delete(rowKey) + } + }) + previousRenderRowCountRef.current = renderRows.length + previousVirtualRowTopByKeyRef.current = nextTopByKey + }, [renderRows.length, virtualItems]) useLayoutEffect(() => { virtualizer.elementsCache.forEach((element) => { @@ -542,7 +626,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [renderRows] ) - const virtualItems = virtualizer.getVirtualItems() const activeDescendantId = activeWorktreeId != null && activeWorktreeRowIndex !== -1 && @@ -631,10 +714,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp aria-multiselectable="true" aria-activedescendant={activeDescendantId} onKeyDown={handleContainerKeyDown} - className={cn( - 'worktree-sidebar-scrollbar flex-1 overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px', - layoutAnimationActive && 'worktree-sidebar-layout-animate' - )} + className="worktree-sidebar-scrollbar flex-1 overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px" style={WORKTREE_SIDEBAR_SCROLL_STYLE} >
{parent @@ -1013,12 +1095,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp key={vItem.key} role="presentation" data-worktree-virtual-row + data-worktree-virtual-row-key={String(vItem.key)} data-index={vItem.index} ref={virtualizer.measureElement} data-workspace-status-drop-target={itemWorkspaceStatus ? '' : undefined} data-workspace-status={itemWorkspaceStatus ?? undefined} className="absolute left-0 right-0" - style={{ transform: `translateY(${vItem.start}px)` }} + style={{ transform: getVirtualRowTransform(vItem.start) }} onDragOver={ itemWorkspaceStatus ? (event) => handleWorkspaceStatusDragOver(event, itemWorkspaceStatus)