Smooth sidebar sticky header swaps

Keep virtualized sidebar sticky header row heights stable during active header swaps to avoid scroll jitter.
This commit is contained in:
Neil 2026-05-19 17:50:36 -07:00 committed by GitHub
parent 8321ea2e03
commit c2c0969ae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 116 additions and 79 deletions

View File

@ -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<Row, { type: 'item' }>
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={

View File

@ -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)
})
})

View File

@ -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<Row, { type: 'item' }>
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
}