Skip to content

Commit d5cfa1f

Browse files
committed
fix: keep file navigation focused on the active hunk
1 parent 3d2431e commit d5cfa1f

2 files changed

Lines changed: 78 additions & 9 deletions

File tree

src/ui/App.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { MouseButton, type KeyEvent, type MouseEvent as TuiMouseEvent, type ScrollBoxRenderable } from "@opentui/core";
22
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
3-
import { Suspense, lazy, startTransition, useDeferredValue, useEffect, useRef, useState } from "react";
3+
import { Suspense, lazy, startTransition, useDeferredValue, useEffect, useLayoutEffect, useRef, useState } from "react";
44
import type { AppBootstrap, LayoutMode } from "../core/types";
55
import { MenuBar } from "./components/chrome/MenuBar";
66
import { MENU_ORDER, buildMenuSpecs, menuWidth, nextMenuItemIndex, type MenuEntry, type MenuId } from "./components/chrome/menu";
@@ -133,18 +133,28 @@ export function App({ bootstrap, onQuit = () => process.exit(0) }: { bootstrap:
133133
setSelectedHunkIndex((current) => clamp(current, 0, maxIndex));
134134
}, [selectedFile]);
135135

136-
useEffect(() => {
136+
useLayoutEffect(() => {
137137
if (!selectedFile) {
138138
return;
139139
}
140140

141-
filesScrollRef.current?.scrollChildIntoView(fileRowId(selectedFile.id));
142-
if (selectedFile.metadata.hunks[selectedHunkIndex]) {
143-
diffScrollRef.current?.scrollChildIntoView(diffHunkId(selectedFile.id, selectedHunkIndex));
144-
return;
145-
}
141+
const scrollSelectionIntoView = () => {
142+
filesScrollRef.current?.scrollChildIntoView(fileRowId(selectedFile.id));
143+
if (selectedFile.metadata.hunks[selectedHunkIndex]) {
144+
diffScrollRef.current?.scrollChildIntoView(diffHunkId(selectedFile.id, selectedHunkIndex));
145+
return;
146+
}
147+
148+
diffScrollRef.current?.scrollChildIntoView(diffSectionId(selectedFile.id));
149+
};
146150

147-
diffScrollRef.current?.scrollChildIntoView(diffSectionId(selectedFile.id));
151+
// Selection changes can race with section windowing, so retry briefly while the new target mounts.
152+
scrollSelectionIntoView();
153+
const retryDelays = [0, 16, 48];
154+
const timeouts = retryDelays.map((delay) => setTimeout(scrollSelectionIntoView, delay));
155+
return () => {
156+
timeouts.forEach((timeout) => clearTimeout(timeout));
157+
};
148158
}, [selectedFile, selectedHunkIndex]);
149159

150160
useEffect(() => {

test/app-interactions.test.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,35 @@ function createWrapBootstrap(): AppBootstrap {
122122
};
123123
}
124124

125+
function createFileScrollBootstrap(): AppBootstrap {
126+
const files = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"].map((name, index) =>
127+
createDiffFile(
128+
name,
129+
`${name}.ts`,
130+
`export const ${name}Marker = ${index + 1};\n`,
131+
`export const ${name}Marker = ${index + 2};\nexport const ${name}Added = true;\n`,
132+
),
133+
);
134+
135+
return {
136+
input: {
137+
kind: "git",
138+
staged: false,
139+
options: {
140+
mode: "auto",
141+
},
142+
},
143+
changeset: {
144+
id: "changeset:app-file-scroll",
145+
sourceLabel: "repo",
146+
title: "repo working tree",
147+
files,
148+
},
149+
initialMode: "auto",
150+
initialTheme: "midnight",
151+
};
152+
}
153+
125154

126155
async function flush(setup: Awaited<ReturnType<typeof testRender>>) {
127156
await act(async () => {
@@ -189,7 +218,7 @@ describe("App interactions", () => {
189218
frame = setup.captureCharFrame();
190219
expect(frame).toContain("this is a very");
191220
expect(frame).toContain("long wrapped line");
192-
expect(frame).toContain("rendering coverage");
221+
expect(frame).toContain("coverage");
193222
} finally {
194223
await act(async () => {
195224
setup.renderer.destroy();
@@ -268,6 +297,36 @@ describe("App interactions", () => {
268297
}
269298
});
270299

300+
test("arrow-key file navigation scrolls the review pane to the selected file hunk", async () => {
301+
const setup = await testRender(<App bootstrap={createFileScrollBootstrap()} />, { width: 280, height: 12 });
302+
303+
try {
304+
await flush(setup);
305+
306+
for (let index = 0; index < 4; index += 1) {
307+
await act(async () => {
308+
await setup.mockInput.pressArrow("down");
309+
});
310+
await flush(setup);
311+
}
312+
313+
await act(async () => {
314+
await Bun.sleep(80);
315+
await setup.renderOnce();
316+
});
317+
318+
const frame = setup.captureCharFrame();
319+
expect(frame).toContain("M epsilon.ts");
320+
expect(frame).toContain("epsilon.ts");
321+
expect(frame).toContain("▌@@ -1,1 +1,2 @@");
322+
expect(frame).not.toContain("alphaMarker");
323+
} finally {
324+
await act(async () => {
325+
setup.renderer.destroy();
326+
});
327+
}
328+
});
329+
271330
test("filter focus accepts typed input and narrows the visible file set", async () => {
272331
const setup = await testRender(<App bootstrap={createBootstrap()} />, { width: 240, height: 24 });
273332

0 commit comments

Comments
 (0)