From c2c0969ae6238979b7cb65afab62144c712ee8e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 19 May 2026 17:50:36 -0700 Subject: [PATCH] Smooth sidebar sticky header swaps Keep virtualized sidebar sticky header row heights stable during active header swaps to avoid scroll jitter. --- .../src/components/sidebar/WorktreeList.tsx | 96 ++++--------------- .../worktree-list-scroll-adjustment.test.ts | 28 ++++++ .../sidebar/worktree-list-virtual-rows.ts | 71 ++++++++++++++ 3 files changed, 116 insertions(+), 79 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 84b74a098..8907a64a0 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -53,6 +53,14 @@ import { getRepoGroupOrdering, getLineageGroupKey } from './worktree-list-groups' +import { + estimateRenderRowSize, + getActiveStickyHeaderIndex, + getStickyHeaderIndexes, + getVirtualRowTransform, + shouldUseHeaderTopSpacing, + type RenderRow +} from './worktree-list-virtual-rows' import { getWorkspaceStatus, getWorkspaceStatusFromGroupKey, @@ -96,8 +104,6 @@ const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { // fight virtual row measurement/remounts and produce visible jumps. overflowAnchor: 'none' } -const GROUP_HEADER_ROW_HEIGHT = 28 -const SECONDARY_GROUP_HEADER_TOP_MARGIN = 8 type ScrollVisibilityItem = { start: number @@ -287,7 +293,6 @@ type VirtualizedWorktreeViewportProps = { } type WorktreeItemRow = Extract -type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } function isWorktreeItemRow(row: Row): row is WorktreeItemRow { return row.type === 'item' @@ -357,75 +362,6 @@ function getVirtualRowKey(element: Element): string | null { return element.getAttribute('data-worktree-virtual-row-key') } -function shouldUseHeaderTopSpacing(args: { - rows: readonly RenderRow[] - index: number - firstHeaderIndex: number - isActiveStickyHeader: boolean -}): boolean { - const previousRenderRow = args.rows[args.index - 1] - const followsCollapsedPinnedHeader = - previousRenderRow?.type === 'header' && previousRenderRow.key === PINNED_GROUP_KEY - return ( - args.index !== args.firstHeaderIndex && - !args.isActiveStickyHeader && - !followsCollapsedPinnedHeader - ) -} - -function estimateRenderRowSize( - rows: readonly RenderRow[], - index: number, - firstHeaderIndex: number, - activeStickyHeaderIndex: number | null -): number { - const row = rows[index] - if (row?.type === 'header') { - return ( - GROUP_HEADER_ROW_HEIGHT + - (shouldUseHeaderTopSpacing({ - rows, - index, - firstHeaderIndex, - isActiveStickyHeader: activeStickyHeaderIndex === index - }) - ? SECONDARY_GROUP_HEADER_TOP_MARGIN - : 0) - ) - } - if (row?.type === 'lineage-group') { - return 100 + Math.max(0, row.rows.length - 1) * 96 - } - return 116 -} - -function getVirtualRowTransform(start: number): string { - return `translateY(${start}px)` -} - -function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { - const indexes: number[] = [] - rows.forEach((row, index) => { - if (row.type === 'header') { - indexes.push(index) - } - }) - return indexes -} - -function getActiveStickyHeaderIndex( - stickyHeaderIndexes: readonly number[], - rangeStartIndex: number -): number | null { - for (let index = stickyHeaderIndexes.length - 1; index >= 0; index--) { - const headerIndex = stickyHeaderIndexes[index] - if (headerIndex <= rangeStartIndex) { - return headerIndex - } - } - return null -} - const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({ rows, activeWorktreeId, @@ -1230,8 +1166,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ rows: renderRows, index: vItem.index, - firstHeaderIndex, - isActiveStickyHeader + firstHeaderIndex }) const isRepoHeader = groupBy === 'repo' && row.repo !== undefined const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined @@ -1265,6 +1200,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ref={measureVirtualRowElement} className={cn( 'left-0 right-0', + // Why: keep the secondary-header spacer on the measured + // virtual row so sticky swaps do not change row height. + hasHeaderTopSpacing && 'pt-2', isActiveStickyHeader ? 'sticky -top-px z-20 bg-sidebar' : 'absolute top-0' )} style={ @@ -1291,11 +1229,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp isPinnedHeader && pinDragOver && 'rounded-md bg-sidebar-accent ring-1 ring-sidebar-ring/40', - // First header sits directly under SidebarHeader, which already - // supplies its own spacing — only offset secondary group headers. - // Why: the active sticky header must paint flush to the - // scrollport top; a collapsed top margin leaks rows behind it. - hasHeaderTopSpacing && 'mt-2', + // First header sits directly under SidebarHeader, which + // already supplies its own spacing. Secondary sticky + // headers keep their spacer measured while the painted + // header stays flush to the scrollport top. + isActiveStickyHeader && hasHeaderTopSpacing && '-translate-y-2', row.repo && 'overflow-hidden' )} onDragOver={ diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index 2d3f67d3e..a4f64f139 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -6,6 +6,16 @@ import { shouldShowFloatingCurrentWorkspaceButton, shouldQueueStartupSidebarReveal } from './WorktreeList' +import { estimateRenderRowSize } from './worktree-list-virtual-rows' + +const makeHeaderRow = (key: string) => + ({ + type: 'header', + key, + label: key, + count: 0, + tone: 'text-foreground' + }) as const describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { it('suppresses measured-row scroll correction while TanStack is scrolling', () => { @@ -191,3 +201,21 @@ describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { ).toBe(false) }) }) + +describe('estimateRenderRowSize', () => { + it('keeps secondary group header size stable while it is the active sticky header', () => { + const rows = [makeHeaderRow('first'), makeHeaderRow('second')] + const firstHeaderIndex = 0 + const secondaryHeaderIndex = 1 + const inactiveSize = estimateRenderRowSize(rows, secondaryHeaderIndex, firstHeaderIndex, null) + const activeSize = estimateRenderRowSize( + rows, + secondaryHeaderIndex, + firstHeaderIndex, + secondaryHeaderIndex + ) + + expect(inactiveSize).toBe(36) + expect(activeSize).toBe(36) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts new file mode 100644 index 000000000..173f31c2e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -0,0 +1,71 @@ +import type { Row } from './worktree-list-groups' +import { PINNED_GROUP_KEY } from './worktree-list-groups' + +const GROUP_HEADER_ROW_HEIGHT = 28 +const SECONDARY_GROUP_HEADER_TOP_MARGIN = 8 + +type WorktreeItemRow = Extract +export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } + +export function shouldUseHeaderTopSpacing(args: { + rows: readonly RenderRow[] + index: number + firstHeaderIndex: number +}): boolean { + const previousRenderRow = args.rows[args.index - 1] + const followsCollapsedPinnedHeader = + previousRenderRow?.type === 'header' && previousRenderRow.key === PINNED_GROUP_KEY + return args.index !== args.firstHeaderIndex && !followsCollapsedPinnedHeader +} + +export function estimateRenderRowSize( + rows: readonly RenderRow[], + index: number, + firstHeaderIndex: number, + _activeStickyHeaderIndex: number | null +): number { + const row = rows[index] + if (row?.type === 'header') { + return ( + GROUP_HEADER_ROW_HEIGHT + + (shouldUseHeaderTopSpacing({ + rows, + index, + firstHeaderIndex + }) + ? SECONDARY_GROUP_HEADER_TOP_MARGIN + : 0) + ) + } + if (row?.type === 'lineage-group') { + return 100 + Math.max(0, row.rows.length - 1) * 96 + } + return 116 +} + +export function getVirtualRowTransform(start: number): string { + return `translateY(${start}px)` +} + +export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { + const indexes: number[] = [] + rows.forEach((row, index) => { + if (row.type === 'header') { + indexes.push(index) + } + }) + return indexes +} + +export function getActiveStickyHeaderIndex( + stickyHeaderIndexes: readonly number[], + rangeStartIndex: number +): number | null { + for (let index = stickyHeaderIndexes.length - 1; index >= 0; index--) { + const headerIndex = stickyHeaderIndexes[index] + if (headerIndex <= rangeStartIndex) { + return headerIndex + } + } + return null +}