From 69cf557a698bf25f4bae24d39c19fb1fd8bc13aa Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:09:06 -0700 Subject: [PATCH] fix(sidebar): keep project headers from overlapping the pinned host (#26775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host header lays out as a 32px card in 4px top padding (36px), but estimateRenderRowSize sized the row at 32px. The group tier's 36px pin offset then fired 4px early, so a project header could pin while its in-flow copy was still on screen — two headers at one coordinate. Size the host row 36px so it matches the pinned slot, and resolve each sticky handoff from the row's real start offset instead of only the lagging range start, so a header pins exactly when it reaches the slot. --- .../viewport/virtual-rows.test.ts | 78 +++++++++++++++++++ .../worktree-list/viewport/virtual-rows.ts | 19 +++-- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts index 82ce28019c8..e3036665a35 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts @@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { HOST_STICKY_PINNED_HEIGHT, buildLineageRowRekeyMap, + estimateRenderRowSize, extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, getStickyHeaderIndexes, @@ -77,6 +78,75 @@ describe('getRenderRowKey', () => { }) describe('getActiveStickyIndexesForScroll', () => { + it('hands off adjacent collapsed project headers at the slot below the host', () => { + const adjacentRows = [hostRow('local'), groupRow('collapsed'), groupRow('incoming')] + const items = [virtualItem(0, 0), virtualItem(1, 42), virtualItem(2, 80)] + const resolve = (scrollOffset: number) => + getActiveStickyIndexesForScroll({ + rows: adjacentRows, + rangeStartIndex: 1, + scrollOffset, + stickyHeaderIndexes: getStickyHeaderIndexes(adjacentRows), + virtualItems: items + }) + + expect(resolve(43)).toEqual({ hostIndex: 0, groupIndex: 1 }) + expect(resolve(44)).toEqual({ hostIndex: 0, groupIndex: 2 }) + expect(resolve(43)).toEqual({ hostIndex: 0, groupIndex: 1 }) + }) + + it('pins the first project once it reaches the host slot while the range still starts at the host', () => { + const firstRows = [hostRow('local'), groupRow('first')] + expect( + getActiveStickyIndexesForScroll({ + rows: firstRows, + rangeStartIndex: 0, + scrollOffset: 6, + stickyHeaderIndexes: getStickyHeaderIndexes(firstRows), + virtualItems: [virtualItem(0, 0), virtualItem(1, 42)] + }) + ).toEqual({ hostIndex: 0, groupIndex: 1 }) + }) + + it('selects the last header at the slot after crossing several collapsed groups', () => { + const adjacentRows = [ + hostRow('local'), + groupRow('first'), + groupRow('second'), + groupRow('third') + ] + expect( + getActiveStickyIndexesForScroll({ + rows: adjacentRows, + rangeStartIndex: 0, + scrollOffset: 82, + stickyHeaderIndexes: getStickyHeaderIndexes(adjacentRows), + virtualItems: [ + virtualItem(0, 0), + virtualItem(1, 42), + virtualItem(2, 80), + virtualItem(3, 118) + ] + }) + ).toEqual({ hostIndex: 0, groupIndex: 3 }) + }) + + it.each([ + { scrollOffset: 399, hostIndex: 0, groupIndex: 1 }, + { scrollOffset: 400, hostIndex: 4, groupIndex: null }, + { scrollOffset: 464, hostIndex: 4, groupIndex: 5 } + ])('resolves host transitions with a lagging range at $scrollOffset', (expected) => { + expect( + getActiveStickyIndexesForScroll({ + rows, + rangeStartIndex: 3, + scrollOffset: expected.scrollOffset, + stickyHeaderIndexes, + virtualItems + }) + ).toEqual({ hostIndex: expected.hostIndex, groupIndex: expected.groupIndex }) + }) + it('pins the host and its inner group while scrolled inside a section', () => { expect( getActiveStickyIndexesForScroll({ @@ -195,6 +265,14 @@ describe('getActiveStickyIndexesForScroll', () => { }) }) +describe('host header row height', () => { + it('reserves the host card height and its padding before the next row', () => { + const hostRows = [hostRow('local'), groupRow('first'), hostRow('ssh:remote')] + expect(estimateRenderRowSize(hostRows, 0, 0, null)).toBe(36) + expect(estimateRenderRowSize(hostRows, 2, 0, null)).toBe(40) + }) +}) + describe('extractWorktreeVirtualRowIndexes', () => { it('keeps the pinned host mounted even when scrolled out of range', () => { const indexes = extractWorktreeVirtualRowIndexes({ diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts index 8414e583679..ea1e0e708de 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts @@ -5,7 +5,8 @@ import { getRenderRowKey } from '../listing/render-row' import type { RenderRow } from '../listing/render-row' export const GROUP_HEADER_ROW_HEIGHT = 28 -export const HOST_HEADER_ROW_HEIGHT = 32 +// Why: HostSectionHeader wraps its h-8 (32px) card in pt-1 (4px), so the row is 36px. +export const HOST_HEADER_ROW_HEIGHT = 36 export const WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP = 6 const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4 const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 @@ -162,7 +163,7 @@ export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { // 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 const HOST_STICKY_PINNED_HEIGHT = HOST_HEADER_ROW_HEIGHT export type ActiveStickyIndexes = { /** Pinned host card (tier 1), or null outside host sections. */ @@ -186,17 +187,25 @@ export function getActiveStickyIndexesForScroll(args: { virtualItems: readonly VirtualItem[] }): ActiveStickyIndexes { const hostIndexes = getHostStickyIndexes(args.rows, args.stickyHeaderIndexes) + const virtualItemsByIndex = new Map(args.virtualItems.map((item) => [item.index, item])) const resolveWithHandoff = ( candidates: readonly number[], pinnedOffset: number, fallbackToCandidate: boolean ): number | null => { - const candidateIndex = getActiveStickyHeaderIndex(candidates, args.rangeStartIndex) + // The group slot can reach a header before the viewport's range start does. + const candidateIndex = + candidates.findLast( + (index) => + index <= args.rangeStartIndex || + (virtualItemsByIndex.get(index)?.start ?? Number.POSITIVE_INFINITY) <= + args.scrollOffset + pinnedOffset + ) ?? null if (candidateIndex === null) { return null } - const candidate = args.virtualItems.find((item) => item.index === candidateIndex) + const candidate = virtualItemsByIndex.get(candidateIndex) if (!candidate) { // Why: scrollToIndex/reveal can advance rangeStartIndex before TanStack // mounts the candidate row. Pinning without geometry lets a Project @@ -204,7 +213,7 @@ export function getActiveStickyIndexesForScroll(args: { // sticky; group tier waits for geometry, host tier may keep the id. const previous = getPreviousStickyHeaderIndex(candidates, candidateIndex) if (previous !== null) { - const previousItem = args.virtualItems.find((item) => item.index === previous) + const previousItem = virtualItemsByIndex.get(previous) if (previousItem) { return previous }