Fix sticky workspace sidebar headers

Fix sticky workspace sidebar headers
This commit is contained in:
Neil 2026-05-19 01:18:35 -07:00 committed by GitHub
parent 5c93579e40
commit 363ddb5ef6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 134 additions and 13 deletions

View File

@ -1,9 +1,11 @@
/* eslint-disable max-lines */
import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react'
import {
defaultRangeExtractor,
measureElement as measureVirtualElementSize,
useVirtualizer
} from '@tanstack/react-virtual'
import type { Range } from '@tanstack/react-virtual'
import { ChevronDown, CircleX, Ellipsis, Plus, Trash2, Workflow } from 'lucide-react'
import { useAppStore } from '@/store'
import {
@ -92,6 +94,8 @@ 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
export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: {
isScrolling: boolean
@ -253,7 +257,42 @@ function getVirtualRowKey(element: Element): string | null {
return element.getAttribute('data-worktree-virtual-row-key')
}
function estimateRenderRowSize(row: RenderRow | undefined): number {
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
}
@ -264,6 +303,29 @@ 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,
@ -313,6 +375,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
getScrollContainer: () => scrollRef.current
})
const renderRows = useMemo(() => buildRenderableRows(rows), [rows])
const firstHeaderIndex = useMemo(
() => renderRows.findIndex((row) => row.type === 'header'),
[renderRows]
)
const firstHeaderIndexRef = useRef(firstHeaderIndex)
firstHeaderIndexRef.current = firstHeaderIndex
const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows])
const stickyHeaderIndexesRef = useRef(stickyHeaderIndexes)
stickyHeaderIndexesRef.current = stickyHeaderIndexes
const activeStickyHeaderIndexRef = useRef<number | null>(null)
const activeWorktreeRowIndex = useMemo(
() => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)),
[renderRows, activeWorktreeId]
@ -388,7 +460,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// into whichever row now owns the old data-index.
return (
measured?.size ??
estimateRenderRowSize(index === null ? undefined : renderRowsRef.current[index])
estimateRenderRowSize(
renderRowsRef.current,
index ?? -1,
firstHeaderIndexRef.current,
activeStickyHeaderIndexRef.current
)
)
}
const index = getVirtualRowIndex(element)
if (index !== null && renderRowsRef.current[index]?.type === 'header') {
return estimateRenderRowSize(
renderRowsRef.current,
index,
firstHeaderIndexRef.current,
activeStickyHeaderIndexRef.current
)
}
return measureVirtualElementSize(element, entry, instance)
@ -418,8 +504,30 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const virtualizer = useVirtualizer({
count: renderRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) => estimateRenderRowSize(renderRows[index]),
estimateSize: (index) =>
estimateRenderRowSize(
renderRows,
index,
firstHeaderIndex,
activeStickyHeaderIndexRef.current
),
measureElement: measureCurrentVirtualRowElement,
rangeExtractor: useCallback((range: Range) => {
const activeStickyHeaderIndex = getActiveStickyHeaderIndex(
stickyHeaderIndexesRef.current,
range.startIndex
)
activeStickyHeaderIndexRef.current = activeStickyHeaderIndex
if (activeStickyHeaderIndex === null) {
return defaultRangeExtractor(range)
}
// Why: this mirrors TanStack Virtual's sticky example — the active
// section header remains a real virtual row even after it scrolls out.
return Array.from(new Set([activeStickyHeaderIndex, ...defaultRangeExtractor(range)])).sort(
(a, b) => a - b
)
}, []),
overscan: 10,
gap: 6,
isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS,
@ -734,11 +842,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
[markDirectScrollInput]
)
const firstHeaderIndex = useMemo(
() => renderRows.findIndex((row) => row.type === 'header'),
[renderRows]
)
const activeDescendantId =
activeWorktreeId != null &&
activeWorktreeRowIndex !== -1 &&
@ -880,6 +983,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
if (row.type === 'header') {
const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index
const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({
rows: renderRows,
index: vItem.index,
firstHeaderIndex,
isActiveStickyHeader
})
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined
const isDraggingThis =
@ -906,10 +1016,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-sticky-header=""
data-worktree-sticky-header-active={isActiveStickyHeader ? '' : undefined}
data-index={vItem.index}
ref={measureVirtualRowElement}
className="absolute left-0 right-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
className={cn(
'left-0 right-0',
isActiveStickyHeader ? 'sticky -top-px z-20 bg-sidebar' : 'absolute top-0'
)}
style={
isActiveStickyHeader
? undefined
: { transform: getVirtualRowTransform(vItem.start) }
}
>
<div
role="button"
@ -931,7 +1050,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
'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.
vItem.index !== firstHeaderIndex && 'mt-2',
// Why: the active sticky header must paint flush to the
// scrollport top; a collapsed top margin leaks rows behind it.
hasHeaderTopSpacing && 'mt-2',
row.repo && 'overflow-hidden'
)}
onDragOver={
@ -1305,7 +1426,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-worktree-virtual-row-key={String(vItem.key)}
data-index={vItem.index}
ref={measureVirtualRowElement}
className="absolute left-0 right-0"
className="absolute left-0 right-0 top-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
>
<div className="overflow-visible">
@ -1339,7 +1460,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
ref={measureVirtualRowElement}
data-workspace-status-drop-target={itemWorkspaceStatus ? '' : undefined}
data-workspace-status={itemWorkspaceStatus ?? undefined}
className="absolute left-0 right-0"
className="absolute left-0 right-0 top-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
onDragOver={
itemWorkspaceStatus