diff --git a/.changeset/stable-sidebar-window.md b/.changeset/stable-sidebar-window.md new file mode 100644 index 000000000..61e496bcd --- /dev/null +++ b/.changeset/stable-sidebar-window.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Restore navigation performance by retaining the sidebar's bounded initial row window while preserving selected-file following during navigation and resizing. diff --git a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.test.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.test.tsx index a37d0f1ce..e17e36388 100644 --- a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.test.tsx +++ b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, forwardRef, useImperativeHandle, useState } from "react"; import { createTestDiffFile } from "../../../../../../../test/helpers/diff-helpers"; +import { fileRowId } from "../../../../ui/lib/ids"; import { resolveTheme } from "../../../../ui/themes"; import { toReadOnlyFileViews } from "../../../events"; import { FlexFileSidebar } from "./FileSidebars"; @@ -24,35 +25,38 @@ const files = toReadOnlyFileViews( interface SidebarSelectionHandle { select(id: string): void; + setWidth(width: number): void; } /** Host the bundled sidebar behind a selection setter so tests drive it like the review does. */ -const SidebarSelectionHarness = forwardRef( - function SidebarSelectionHarness({ initialFileId }, ref) { - const [selectedFileId, setSelectedFileId] = useState(initialFileId); - useImperativeHandle(ref, () => ({ select: setSelectedFileId }), []); - return ( - false, getKeys: () => [] }} - actions={{ - copyText: () => false, - selectFile: () => {}, - selectHunk: () => {}, - revealLine: () => {}, - notify: () => {}, - setPresentationScope: () => false, - clearPresentationScope: () => {}, - }} - /> - ); - }, -); +const SidebarSelectionHarness = forwardRef< + SidebarSelectionHandle, + { initialFileId: string; initialWidth?: number } +>(function SidebarSelectionHarness({ initialFileId, initialWidth = 30 }, ref) { + const [selectedFileId, setSelectedFileId] = useState(initialFileId); + const [width, setWidth] = useState(initialWidth); + useImperativeHandle(ref, () => ({ select: setSelectedFileId, setWidth }), []); + return ( + false, getKeys: () => [] }} + actions={{ + copyText: () => false, + selectFile: () => {}, + selectHunk: () => {}, + revealLine: () => {}, + notify: () => {}, + setPresentationScope: () => false, + clearPresentationScope: () => {}, + }} + /> + ); +}); /** Return the sidebar row carrying the selected-file marker, if one is on screen. */ function selectedRow(frame: string) { @@ -60,7 +64,7 @@ function selectedRow(frame: string) { } /** Mount a sidebar with controllable selection, viewport and mouse input. */ -async function renderSidebar(initialFileId: string) { +async function renderSidebar(initialFileId: string, initialWidth = 30) { const handle: { current: SidebarSelectionHandle | null } = { current: null }; const setup = await testRender( , { width: 36, height: FRAME_ROWS }, ); @@ -113,16 +118,88 @@ async function renderSidebar(initialFileId: string) { }); }; + /** Switch between compact and hierarchical projections without replacing the scrollbox. */ + const changeWidth = async (width: number) => { + await act(async () => { + handle.current?.setWidth(width); + await setup.renderOnce(); + }); + }; + + /** Collapse the directory at the top of the hierarchical projection with mouse input. */ + const collapseDirectory = async () => { + await act(async () => { + await setup.mockMouse.click(4, 0); + await setup.renderOnce(); + }); + }; + const destroy = async () => { await act(async () => { setup.renderer.destroy(); }); }; - return { selectWithoutFrame, selectAndSettle, frame, resize, scrollUp, destroy }; + return { + selectWithoutFrame, + selectAndSettle, + frame, + resize, + scrollUp, + changeWidth, + collapseDirectory, + destroy, + }; } describe("FlexFileSidebar selected-row reveal", () => { + test("does not dismantle initial overscan when layout measures a shorter pane", async () => { + const setup = await testRender( + + + , + { width: 36, height: FRAME_ROWS }, + ); + try { + const initialRow = setup.renderer.root.findDescendantById(fileRowId(fileId(10))); + expect(initialRow).toBeDefined(); + await act(async () => { + await setup.renderOnce(); + await setup.renderOnce(); + }); + + expect(setup.renderer.root.findDescendantById(fileRowId(fileId(10)))).toBe(initialRow); + expect(setup.renderer.root.findDescendantById(fileRowId(fileId(20)))).toBeUndefined(); + expect(selectedRow(setup.captureCharFrame())).toContain(`${fileId(0)}.ts`); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("preserves directory collapse and expands it for held navigation across projections", async () => { + const sidebar = await renderSidebar(fileId(0), 36); + try { + await sidebar.collapseDirectory(); + const collapsed = await sidebar.frame(); + expect(collapsed).toContain("40 files"); + expect(selectedRow(collapsed)).toBeUndefined(); + expect(selectedRow(await sidebar.frame())).toBeUndefined(); + + for (let index = 1; index <= 25; index += 1) { + await sidebar.selectWithoutFrame(fileId(index)); + } + expect(selectedRow(await sidebar.frame())).toContain(`${fileId(25)}.ts`); + await sidebar.changeWidth(30); + expect(selectedRow(await sidebar.frame())).toContain(`${fileId(25)}.ts`); + await sidebar.changeWidth(36); + expect(selectedRow(await sidebar.frame())).toContain(`${fileId(25)}.ts`); + } finally { + await sidebar.destroy(); + } + }); + test("follows an unhurried walk down the list", async () => { const sidebar = await renderSidebar(fileId(0)); try { diff --git a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx index 1cae665df..131c485a2 100644 --- a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx +++ b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx @@ -67,6 +67,7 @@ export function VirtualizedFileSidebarRows({ buildSidebarRenderWindow({ entries, estimatedViewportRows, + minimumWindowRows: estimatedViewportRows, overscanRows: 4, scrollTop, selectedFileId: selectedFileId ?? undefined, diff --git a/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts b/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts index 6f36bdcfe..17a5df7da 100644 --- a/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts +++ b/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts @@ -79,6 +79,43 @@ describe("buildSidebarRenderWindow", () => { expect(renderedHeight(plan.items)).toBe(entries.length); }); + test("retains a bounded initial window while visibility follows measured pane height", () => { + const entries = createEntries(Array.from({ length: 40 }, (_, index) => `file-${index}`)); + const input = { + entries, + estimatedViewportRows: 8, + minimumWindowRows: 8, + overscanRows: 4, + scrollTop: 0, + }; + const initial = buildSidebarRenderWindow({ ...input, viewportHeight: 0 }); + const measured = buildSidebarRenderWindow({ ...input, viewportHeight: 6 }); + + expect(measured.mountedEntryIndices).toEqual(initial.mountedEntryIndices); + expect(itemSummary(measured.items)).toEqual(itemSummary(initial.items)); + expect(measured.visibleEndIndex).toBe(5); + expect(measured.mountedEntryIndices).toHaveLength(12); + expect(renderedHeight(measured.items)).toBe(entries.length); + + const scrolled = buildSidebarRenderWindow({ + ...input, + scrollTop: 20, + viewportHeight: 6, + selectedFileId: "file-0", + }); + expect(scrolled.visibleStartIndex).toBe(20); + expect(scrolled.visibleEndIndex).toBe(25); + expect(scrolled.mountedEntryIndices).toEqual([ + 0, + ...Array.from({ length: 16 }, (_, index) => index + 16), + ]); + expect(renderedHeight(scrolled.items)).toBe(entries.length); + + const enlarged = buildSidebarRenderWindow({ ...input, viewportHeight: 16 }); + expect(enlarged.visibleEndIndex).toBe(15); + expect(enlarged.mountedEntryIndices).toHaveLength(20); + }); + test("clamps overscan at the last viewport", () => { const entries = createEntries(["file-0", "file-1", "file-2", "file-3", "file-4"]); const plan = buildSidebarRenderWindow({ diff --git a/packages/hunk/src/ui/lib/sidebarRenderWindow.ts b/packages/hunk/src/ui/lib/sidebarRenderWindow.ts index 96071c1a2..5f9cd0fd5 100644 --- a/packages/hunk/src/ui/lib/sidebarRenderWindow.ts +++ b/packages/hunk/src/ui/lib/sidebarRenderWindow.ts @@ -107,6 +107,7 @@ export function planSidebarRowReveal({ export function buildSidebarRenderWindow({ entries, estimatedViewportRows = 32, + minimumWindowRows = 0, overscanRows = 4, scrollTop, selectedFileId, @@ -114,6 +115,8 @@ export function buildSidebarRenderWindow({ }: { entries: SidebarEntry[]; estimatedViewportRows?: number; + /** Retain a bounded allocation budget without changing the measured visible range. */ + minimumWindowRows?: number; overscanRows?: number; scrollTop: number; selectedFileId?: string; @@ -123,13 +126,21 @@ export function buildSidebarRenderWindow({ const effectiveViewportHeight = viewportHeight > 0 ? viewportHeight : Math.max(0, Math.floor(estimatedViewportRows)); const visibleRange = findVisibleEntryRange(entries.length, scrollTop, effectiveViewportHeight); + // Keep the initial terminal-sized row budget after layout measures the smaller pane. + // Chrome and projection layout must not dismantle that bounded working set; visibility + // and selected-row reveal still use the measured viewport, not this allocation budget. + const mountedRange = findVisibleEntryRange( + entries.length, + scrollTop, + Math.max(effectiveViewportHeight, Math.max(0, Math.floor(minimumWindowRows))), + ); const clampedOverscanRows = Math.max(0, Math.floor(overscanRows)); - if (visibleRange) { + if (mountedRange) { addEntryIndexRange( mountedIndices, - visibleRange.startIndex - clampedOverscanRows, - visibleRange.endIndex + clampedOverscanRows, + mountedRange.startIndex - clampedOverscanRows, + mountedRange.endIndex + clampedOverscanRows, entries.length, ); } diff --git a/test/pty/sidebar-follow-integration.test.ts b/test/pty/sidebar-follow-integration.test.ts index de79607d4..71ead33e0 100644 --- a/test/pty/sidebar-follow-integration.test.ts +++ b/test/pty/sidebar-follow-integration.test.ts @@ -70,11 +70,21 @@ describe("PTY sidebar selection follow", () => { await Bun.sleep(250); expect(selectedSidebarFile(await session.text({ immediate: true }))).toBeNull(); + // Navigation resumes following after manual scrolling away from the selected row. + session.writeRaw("."); + await harness.waitForSnapshot( + session, + (text) => selectedSidebarFile(text) === "file-51.ts", + 5_000, + ); + session.writeRaw("\u001b[<64;5;8M".repeat(12)); + await harness.waitForSnapshot(session, (text) => selectedSidebarFile(text) === null, 5_000); + session.resize({ cols: 100, rows: 24 }); await Bun.sleep(250); await harness.waitForSnapshot( session, - (text) => selectedSidebarFile(text) === "file-50.ts", + (text) => selectedSidebarFile(text) === "file-51.ts", 5_000, ); } finally {