Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stable-sidebar-window.md
Original file line number Diff line number Diff line change
@@ -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.
133 changes: 105 additions & 28 deletions packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,50 +25,54 @@ 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<SidebarSelectionHandle, { initialFileId: string }>(
function SidebarSelectionHarness({ initialFileId }, ref) {
const [selectedFileId, setSelectedFileId] = useState(initialFileId);
useImperativeHandle(ref, () => ({ select: setSelectedFileId }), []);
return (
<FlexFileSidebar
files={files}
selectedFileId={selectedFileId}
selectedHunkIndex={0}
reviewGeneration="test-review"
theme={resolveTheme("github-dark-default", null)}
width={30}
keybindings={{ matches: () => 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 (
<FlexFileSidebar
files={files}
selectedFileId={selectedFileId}
selectedHunkIndex={0}
reviewGeneration="test-review"
theme={resolveTheme("github-dark-default", null)}
width={width}
keybindings={{ matches: () => 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) {
return frame.split("\n").find((line) => line.includes("▌"));
}

/** 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(
<SidebarSelectionHarness
ref={(value) => {
handle.current = value;
}}
initialFileId={initialFileId}
initialWidth={initialWidth}
/>,
{ width: 36, height: FRAME_ROWS },
);
Expand Down Expand Up @@ -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(
<box height={6}>
<SidebarSelectionHarness initialFileId={fileId(0)} />
</box>,
{ 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function VirtualizedFileSidebarRows({
buildSidebarRenderWindow({
entries,
estimatedViewportRows,
minimumWindowRows: estimatedViewportRows,
overscanRows: 4,
scrollTop,
selectedFileId: selectedFileId ?? undefined,
Expand Down
37 changes: 37 additions & 0 deletions packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
17 changes: 14 additions & 3 deletions packages/hunk/src/ui/lib/sidebarRenderWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,16 @@ export function planSidebarRowReveal({
export function buildSidebarRenderWindow({
entries,
estimatedViewportRows = 32,
minimumWindowRows = 0,
overscanRows = 4,
scrollTop,
selectedFileId,
viewportHeight,
}: {
entries: SidebarEntry[];
estimatedViewportRows?: number;
/** Retain a bounded allocation budget without changing the measured visible range. */
minimumWindowRows?: number;
overscanRows?: number;
scrollTop: number;
selectedFileId?: string;
Expand All @@ -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,
);
}
Expand Down
12 changes: 11 additions & 1 deletion test/pty/sidebar-follow-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading