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 }