From eb456a94e6ff257f42bafbf2ffe33c0d2f2c8197 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 9 Jun 2026 23:04:15 -0700 Subject: [PATCH] Two-tier sticky headers: pinned host card above pinned group header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When scrolling inside a host section, the host card now stays pinned at the top (z-30) while project/status group headers hand off beneath it (z-20, offset by the pinned card height). The host is the outer hierarchy level, so it is the most persistent context — previously the first repo header replaced it, losing 'which machine am I on' exactly when it mattered. The pinned card keeps its collapse/menu/warning affordances. Handoff rules: the next host card pushes the previous one out at the viewport top; a group pins only once it reaches the slot beneath the host card, and a previous host's group can never pin under the next host. Without host sections the logic degrades to the original single-tier behavior. Co-authored-by: Orca --- .../src/components/sidebar/WorktreeList.tsx | 35 +++-- .../worktree-list-virtual-rows.test.ts | 146 ++++++++++++++++++ .../sidebar/worktree-list-virtual-rows.ts | 87 +++++++++++ 3 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 9a5c5a2d353..9da6a434e4c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -78,7 +78,7 @@ import { import { estimateRenderRowSize, extractWorktreeVirtualRowIndexes, - getActiveStickyHeaderIndexForScroll, + getActiveStickyIndexesForScroll, getStickyHeaderIndexes, getVirtualRowTransform, shouldUseHeaderTopSpacing, @@ -1125,6 +1125,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const stickyHeaderIndexesRef = useRef(stickyHeaderIndexes) stickyHeaderIndexesRef.current = stickyHeaderIndexes const activeStickyHeaderIndexRef = useRef(null) + const activeStickyHostIndexRef = useRef(null) const stickyRangeStartIndexRef = useRef(0) const activeWorktreeRowIndex = useMemo( () => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)), @@ -1236,7 +1237,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp rangeExtractor: useCallback( (range: Range) => { stickyRangeStartIndexRef.current = range.startIndex - return extractWorktreeVirtualRowIndexes({ range, stickyHeaderIndexes }) + return extractWorktreeVirtualRowIndexes({ + range, + stickyHeaderIndexes, + rows: renderRowsRef.current + }) }, [stickyHeaderIndexes] ), @@ -1456,13 +1461,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() - const activeStickyHeaderIndex = getActiveStickyHeaderIndexForScroll({ + const activeStickyIndexes = getActiveStickyIndexesForScroll({ + rows: renderRows, rangeStartIndex: stickyRangeStartIndexRef.current, scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, stickyHeaderIndexes, virtualItems }) - activeStickyHeaderIndexRef.current = activeStickyHeaderIndex + activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex + activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex const measureMountedRows = useCallback(() => { virtualizer.elementsCache.forEach((element) => { @@ -2881,7 +2888,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } if (row.type === 'host-header') { - const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index + // Why: the host card is the outer hierarchy tier — it pins above + // group headers (z-30 vs z-20) and stays put while they hand off. + const isActiveStickyHost = activeStickyHostIndexRef.current === vItem.index const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ rows: renderRows, index: vItem.index, @@ -2894,18 +2903,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-worktree-virtual-row data-worktree-virtual-row-key={String(vItem.key)} data-worktree-sticky-header="" - data-worktree-sticky-header-active={isActiveStickyHeader ? '' : undefined} + data-worktree-sticky-header-active={isActiveStickyHost ? '' : undefined} data-index={vItem.index} ref={measureVirtualRowElement} className={cn( 'left-0 right-0', - hasHeaderTopSpacing && !isActiveStickyHeader && 'pt-1', - isActiveStickyHeader - ? 'sticky -top-px z-20 bg-worktree-sidebar' + hasHeaderTopSpacing && !isActiveStickyHost && 'pt-1', + isActiveStickyHost + ? 'sticky -top-px z-30 bg-worktree-sidebar' : 'absolute top-0' )} style={ - isActiveStickyHeader + isActiveStickyHost ? undefined : { transform: getVirtualRowTransform(vItem.start) } } @@ -2920,6 +2929,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp if (row.type === 'header') { const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index + // Why: when a host card is pinned, the group tier pins flush + // beneath it instead of at the viewport top. + const stickyTopClass = + activeStickyHostIndexRef.current !== null ? 'top-[35px]' : '-top-px' const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ rows: renderRows, index: vItem.index, @@ -2979,7 +2992,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp // so the previous repo no longer stays pinned over it. hasHeaderTopSpacing && !isActiveStickyHeader && 'pt-1', isActiveStickyHeader - ? 'sticky -top-px z-20 bg-worktree-sidebar' + ? cn('sticky z-20 bg-worktree-sidebar', stickyTopClass) : 'absolute top-0' )} style={ diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts new file mode 100644 index 00000000000..e0449b6eeca --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import type { VirtualItem } from '@tanstack/react-virtual' +import { + HOST_STICKY_PINNED_HEIGHT, + extractWorktreeVirtualRowIndexes, + getActiveStickyIndexesForScroll, + getStickyHeaderIndexes, + type RenderRow +} from './worktree-list-virtual-rows' + +function hostRow(hostId: string): RenderRow { + return { + type: 'host-header', + key: `host:${hostId}`, + hostId: hostId as never, + kind: 'ssh', + label: hostId, + detail: 'SSH', + health: 'available', + collapsed: false, + count: 1 + } +} + +function groupRow(key: string): RenderRow { + return { type: 'header', key, label: key, count: 1, tone: 'text-foreground' } +} + +function itemStub(id: string): RenderRow { + return { type: 'item', key: id } as unknown as RenderRow +} + +function virtualItem(index: number, start: number): VirtualItem { + return { index, start } as VirtualItem +} + +// rows: [host-a, group-a1, item, item, host-b, group-b1, item] +const rows: RenderRow[] = [ + hostRow('a'), + groupRow('a1'), + itemStub('wt-1'), + itemStub('wt-2'), + hostRow('b'), + groupRow('b1'), + itemStub('wt-3') +] +const stickyHeaderIndexes = getStickyHeaderIndexes(rows) +// Geometry: each row 100px tall for easy math. +const virtualItems = rows.map((_, index) => virtualItem(index, index * 100)) + +describe('getActiveStickyIndexesForScroll', () => { + it('pins the host and its inner group while scrolled inside a section', () => { + expect( + getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 2, + scrollOffset: 250, + stickyHeaderIndexes, + virtualItems + }) + ).toEqual({ hostIndex: 0, groupIndex: 1 }) + }) + + it('hands the host tier off when the next host card reaches the top', () => { + expect( + getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 4, + scrollOffset: 400, + stickyHeaderIndexes, + virtualItems + }) + ).toMatchObject({ hostIndex: 4 }) + }) + + it('never pins the previous host group beneath the next host card', () => { + const result = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 4, + scrollOffset: 400, + stickyHeaderIndexes, + virtualItems + }) + // group-a1 (index 1) must not survive into host b's tenure; group-b1 only + // pins once it reaches the slot beneath the pinned host card. + expect(result.groupIndex === 1).toBe(false) + }) + + it('offsets the group handoff by the pinned host height', () => { + // group-b1 starts at 500; with host pinned it should activate once + // scrollOffset + HOST_STICKY_PINNED_HEIGHT reaches 500. + const before = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 5, + scrollOffset: 500 - HOST_STICKY_PINNED_HEIGHT - 1, + stickyHeaderIndexes, + virtualItems + }) + const after = getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 5, + scrollOffset: 500 - HOST_STICKY_PINNED_HEIGHT, + stickyHeaderIndexes, + virtualItems + }) + expect(before.groupIndex).not.toBe(5) + expect(after).toEqual({ hostIndex: 4, groupIndex: 5 }) + }) + + it('degrades to single-tier rules when no host sections exist', () => { + const flatRows: RenderRow[] = [ + groupRow('g1'), + itemStub('wt-1'), + groupRow('g2'), + itemStub('wt-2') + ] + const flatSticky = getStickyHeaderIndexes(flatRows) + const flatItems = flatRows.map((_, index) => virtualItem(index, index * 100)) + expect( + getActiveStickyIndexesForScroll({ + rows: flatRows, + rangeStartIndex: 1, + scrollOffset: 150, + stickyHeaderIndexes: flatSticky, + virtualItems: flatItems + }) + ).toEqual({ hostIndex: null, groupIndex: 0 }) + }) +}) + +describe('extractWorktreeVirtualRowIndexes', () => { + it('keeps the pinned host mounted even when scrolled out of range', () => { + const indexes = extractWorktreeVirtualRowIndexes({ + range: { + startIndex: 3, + endIndex: 3, + overscan: 0, + count: rows.length, + getItemIndex: (i: number) => i + } as never, + stickyHeaderIndexes, + rows + }) + expect(indexes).toContain(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 0aedcdf8b17..8a132c45bc8 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -87,6 +87,87 @@ export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { return indexes } +// Why: the pinned host card is h-8 (32px) inside a pt-1 (4px) wrapper; the +// group tier pins one pixel up to sit flush beneath it. Keep in sync with +// HostSectionHeader's layout. +export const HOST_STICKY_PINNED_HEIGHT = 36 + +export type ActiveStickyIndexes = { + /** Pinned host card (tier 1), or null outside host sections. */ + hostIndex: number | null + /** Pinned group header (tier 2), offset below the host when one is pinned. */ + groupIndex: number | null +} + +function getHostStickyIndexes(rows: readonly RenderRow[], sticky: readonly number[]): number[] { + return sticky.filter((index) => rows[index]?.type === 'host-header') +} + +/** Two-tier sticky resolution: the host card is the outer hierarchy level so + * it stays pinned for the whole section while group headers hand off beneath + * it. Without host sections this degrades to the original single-tier rules. */ +export function getActiveStickyIndexesForScroll(args: { + rows: readonly RenderRow[] + rangeStartIndex: number + scrollOffset: number + stickyHeaderIndexes: readonly number[] + virtualItems: readonly VirtualItem[] +}): ActiveStickyIndexes { + const hostIndexes = getHostStickyIndexes(args.rows, args.stickyHeaderIndexes) + + const resolveWithHandoff = ( + candidates: readonly number[], + pinnedOffset: number, + fallbackToCandidate: boolean + ): number | null => { + const candidateIndex = getActiveStickyHeaderIndex(candidates, args.rangeStartIndex) + if (candidateIndex === null) { + return null + } + const candidate = args.virtualItems.find((item) => item.index === candidateIndex) + if (!candidate) { + return candidateIndex + } + // Why: hand off the moment the incoming header reaches its pinned slot + // (top of the viewport, or the bottom edge of the pinned host card). + if (args.scrollOffset + pinnedOffset >= candidate.start) { + return candidateIndex + } + const previous = getPreviousStickyHeaderIndex(candidates, candidateIndex) + if (previous !== null) { + return previous + } + // Why: a host section's first group is still in flow below the pinned + // host card until it reaches the slot — pinning it early would double + // it up. The host tier keeps the legacy fallback. + return fallbackToCandidate ? candidateIndex : null + } + + const hostIndex = resolveWithHandoff(hostIndexes, 0, true) + + const hostPosition = hostIndex === null ? -1 : hostIndexes.indexOf(hostIndex) + const nextHostIndex = + hostPosition >= 0 ? (hostIndexes[hostPosition + 1] ?? Number.POSITIVE_INFINITY) : null + const groupIndexes = args.stickyHeaderIndexes.filter((index) => { + if (args.rows[index]?.type !== 'header') { + return false + } + // Why: a group from the previous host must never pin beneath the next + // host's card — only groups inside the pinned host's section qualify. + if (hostIndex !== null) { + return index > hostIndex && index < (nextHostIndex ?? Number.POSITIVE_INFINITY) + } + return true + }) + const groupIndex = resolveWithHandoff( + groupIndexes, + hostIndex !== null ? HOST_STICKY_PINNED_HEIGHT : 0, + hostIndex === null + ) + + return { hostIndex, groupIndex } +} + export function getActiveStickyHeaderIndex( stickyHeaderIndexes: readonly number[], rangeStartIndex: number @@ -114,6 +195,7 @@ export function getPreviousStickyHeaderIndex( export function extractWorktreeVirtualRowIndexes(args: { range: Range stickyHeaderIndexes: readonly number[] + rows?: readonly RenderRow[] }): number[] { const activeStickyHeaderIndex = getActiveStickyHeaderIndex( args.stickyHeaderIndexes, @@ -127,10 +209,15 @@ export function extractWorktreeVirtualRowIndexes(args: { args.stickyHeaderIndexes, activeStickyHeaderIndex ) + // Why: the pinned host card (tier 1) can be far above the visible range + // while group headers hand off beneath it — keep it mounted regardless. + const hostIndexes = args.rows ? getHostStickyIndexes(args.rows, args.stickyHeaderIndexes) : [] + const activeHostIndex = getActiveStickyHeaderIndex(hostIndexes, args.range.startIndex) return Array.from( new Set([ activeStickyHeaderIndex, ...(previousStickyHeaderIndex === null ? [] : [previousStickyHeaderIndex]), + ...(activeHostIndex === null ? [] : [activeHostIndex]), ...defaultRangeExtractor(args.range) ]) ).sort((a, b) => a - b)