Skip to content

Commit 834abfe

Browse files
authored
fix(review): keep cross-file hunk navigation anchored (#222)
1 parent 7804b73 commit 834abfe

6 files changed

Lines changed: 250 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ All notable user-visible changes to Hunk are documented in this file.
1212

1313
### Fixed
1414

15+
- Fixed cross-file hunk navigation so near-boundary jumps keep the selected file pinned and backward jumps reveal the target hunk instead of the file top.
16+
1517
## [0.10.0] - 2026-04-21
1618

1719
### Added

src/ui/AppHost.interactions.test.tsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,51 @@ function createTwoFileHunkBootstrap(): AppBootstrap {
238238
});
239239
}
240240

241+
/** Build the cross-file hunk-navigation shape that used to flash the previous pinned header. */
242+
function createCrossFileHunkNavigationBootstrap(): AppBootstrap {
243+
const longBeforeLines = Array.from(
244+
{ length: 342 },
245+
(_, index) => `line ${String(index + 1).padStart(3, "0")}`,
246+
);
247+
const longAfterLines = [...longBeforeLines];
248+
for (const lineNumber of [
249+
2, 21, 41, 61, 81, 101, 121, 141, 161, 181, 201, 221, 241, 261, 281, 301, 321, 341,
250+
]) {
251+
longAfterLines[lineNumber - 1] = `line ${String(lineNumber).padStart(3, "0")} changed`;
252+
}
253+
254+
const shortBeforeLines = [
255+
"// hunk 0 - at the very top of the file",
256+
"export const top = 1;",
257+
"",
258+
"",
259+
...Array.from({ length: 25 }, (_, index) => `// filler ${index + 1}`),
260+
"// hunk 1 - mid-file",
261+
"export const mid = 3;",
262+
];
263+
const shortAfterLines = [...shortBeforeLines];
264+
shortAfterLines[1] = "export const top = 2;";
265+
shortAfterLines[30] = "export const mid = 4;";
266+
267+
return createTestGitAppBootstrap({
268+
changesetId: "changeset:cross-file-hunk-navigation",
269+
files: [
270+
createTestDiffFile(
271+
"long-file",
272+
"long-file.txt",
273+
lines(...longBeforeLines),
274+
lines(...longAfterLines),
275+
),
276+
createTestDiffFile(
277+
"short-file",
278+
"short-file.ts",
279+
lines(...shortBeforeLines),
280+
lines(...shortAfterLines),
281+
),
282+
],
283+
});
284+
}
285+
241286
function createMouseScrollSelectionBootstrap(): AppBootstrap {
242287
const firstBeforeLines = createNumberedAssignmentLines(1, 12);
243288
const secondBeforeLines = Array.from(
@@ -339,6 +384,28 @@ async function waitForFrame(
339384
return frame;
340385
}
341386

387+
async function pressHunkNavigationKey(
388+
setup: Awaited<ReturnType<typeof testRender>>,
389+
key: "]" | "[",
390+
count: number,
391+
) {
392+
for (let index = 0; index < count; index += 1) {
393+
await act(async () => {
394+
await setup.mockInput.typeText(key);
395+
});
396+
await flush(setup);
397+
}
398+
}
399+
400+
function firstCrossFileHunkNavigationHeader(frame: string) {
401+
return (
402+
frame
403+
.split("\n")
404+
.map((line) => line.trim())
405+
.find((line) => line.startsWith("long-file.txt") || line.startsWith("short-file.ts")) ?? ""
406+
);
407+
}
408+
342409
async function waitForSnapshot(
343410
setup: Awaited<ReturnType<typeof testRender>>,
344411
getSnapshot: () => HunkSessionSnapshot["state"] | null,
@@ -1837,6 +1904,74 @@ describe("App interactions", () => {
18371904
}
18381905
});
18391906

1907+
test("forward cross-file hunk navigation keeps the destination file owning the review pane", async () => {
1908+
const setup = await testRender(
1909+
<AppHost bootstrap={createCrossFileHunkNavigationBootstrap()} />,
1910+
{
1911+
width: 120,
1912+
height: 16,
1913+
},
1914+
);
1915+
1916+
try {
1917+
await flush(setup);
1918+
await pressHunkNavigationKey(setup, "]", 18);
1919+
1920+
let frame = await waitForFrame(
1921+
setup,
1922+
(nextFrame) =>
1923+
nextFrame.includes("short-file.ts") && nextFrame.includes("export const top = 2;"),
1924+
24,
1925+
);
1926+
expect(firstCrossFileHunkNavigationHeader(frame)).toContain("short-file.ts");
1927+
1928+
await pressHunkNavigationKey(setup, "]", 1);
1929+
frame = await waitForFrame(
1930+
setup,
1931+
(nextFrame) => nextFrame.includes("export const mid = 4;"),
1932+
24,
1933+
);
1934+
1935+
expect(firstCrossFileHunkNavigationHeader(frame)).toContain("short-file.ts");
1936+
expect(frame).not.toContain("line 341 changed");
1937+
} finally {
1938+
await act(async () => {
1939+
setup.renderer.destroy();
1940+
});
1941+
}
1942+
});
1943+
1944+
test("backward cross-file hunk navigation reveals the target hunk instead of the file top", async () => {
1945+
const setup = await testRender(
1946+
<AppHost bootstrap={createCrossFileHunkNavigationBootstrap()} />,
1947+
{
1948+
width: 120,
1949+
height: 16,
1950+
},
1951+
);
1952+
1953+
try {
1954+
await flush(setup);
1955+
await pressHunkNavigationKey(setup, "]", 19);
1956+
await waitForFrame(setup, (nextFrame) => nextFrame.includes("export const mid = 4;"), 24);
1957+
1958+
await pressHunkNavigationKey(setup, "[", 2);
1959+
const frame = await waitForFrame(
1960+
setup,
1961+
(nextFrame) =>
1962+
nextFrame.includes("line 341 changed") || nextFrame.includes("line 002 changed"),
1963+
24,
1964+
);
1965+
1966+
expect(frame).toContain("line 341 changed");
1967+
expect(frame).not.toContain("line 002 changed");
1968+
} finally {
1969+
await act(async () => {
1970+
setup.renderer.destroy();
1971+
});
1972+
}
1973+
});
1974+
18401975
test("mouse wheel scrolling updates the active file and hunk to the viewport center", async () => {
18411976
const { getLatestSnapshot, hostClient } = createMockHostClient();
18421977
const setup = await testRender(

src/ui/components/panes/DiffPane.tsx

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,10 @@ export function DiffPane({
664664
const selectedNoteTop = selectedNoteBounds?.top ?? null;
665665
const selectedNoteHeight = selectedNoteBounds?.height ?? null;
666666

667+
/** The bodyTop of the currently selected file's section layout, used to floor hunk reveal scroll targets so they never cross above the owning file boundary. */
668+
const selectedFileBodyTop =
669+
selectedFileIndex >= 0 ? (fileSectionLayouts[selectedFileIndex]?.bodyTop ?? 0) : 0;
670+
667671
// Track the previous selected anchor to detect actual selection changes.
668672
const prevSelectedAnchorIdRef = useRef<string | null>(null);
669673
const prevPinnedHeaderFileIdRef = useRef<string | null>(null);
@@ -902,17 +906,16 @@ export function DiffPane({
902906
// hunk can request a top offset that is no longer reachable once the viewport hits EOF.
903907
// Using the reachable value keeps the reveal logic from fighting later manual scrolling.
904908
if (selectedNoteBounds) {
905-
scrollBox.scrollTo(
906-
clampReviewScrollTop(
907-
computeHunkRevealScrollTop({
908-
hunkTop: selectedNoteBounds.top,
909-
hunkHeight: selectedNoteBounds.height,
910-
preferredTopPadding,
911-
viewportHeight,
912-
}),
913-
viewportHeight,
914-
),
915-
);
909+
const revealScrollTop = computeHunkRevealScrollTop({
910+
hunkTop: selectedNoteBounds.top,
911+
hunkHeight: selectedNoteBounds.height,
912+
preferredTopPadding,
913+
viewportHeight,
914+
});
915+
// Floor against the owning file's body boundary so the viewport never crosses above it
916+
// and triggers a pinned-header flash.
917+
const flooredScrollTop = Math.max(revealScrollTop, selectedFileBodyTop);
918+
scrollBox.scrollTo(clampReviewScrollTop(flooredScrollTop, viewportHeight));
916919
return;
917920
}
918921

@@ -938,17 +941,16 @@ export function DiffPane({
938941
? Math.max(0, renderedBottom - renderedTop)
939942
: selectedEstimatedHunkBounds.height;
940943

941-
scrollBox.scrollTo(
942-
clampReviewScrollTop(
943-
computeHunkRevealScrollTop({
944-
hunkTop,
945-
hunkHeight,
946-
preferredTopPadding,
947-
viewportHeight,
948-
}),
949-
viewportHeight,
950-
),
951-
);
944+
const revealScrollTop = computeHunkRevealScrollTop({
945+
hunkTop,
946+
hunkHeight,
947+
preferredTopPadding,
948+
viewportHeight,
949+
});
950+
// Floor against the owning file's body boundary so the viewport never crosses above it
951+
// and triggers a pinned-header flash.
952+
const flooredScrollTop = Math.max(revealScrollTop, selectedFileBodyTop);
953+
scrollBox.scrollTo(clampReviewScrollTop(flooredScrollTop, viewportHeight));
952954
return;
953955
}
954956

@@ -988,6 +990,7 @@ export function DiffPane({
988990
selectedFileIndex,
989991
selectedHunkIndex,
990992
selectedHunkRevealRequestId,
993+
selectedFileBodyTop,
991994
selectedNoteHeight,
992995
selectedNoteTop,
993996
suppressViewportSelectionSync,

src/ui/hooks/useReviewController.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,13 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
204204
return;
205205
}
206206

207+
const crossingFileBoundary = nextCursor.fileId !== selectedFile?.id;
207208
selectHunk(nextCursor.fileId, nextCursor.hunkIndex, {
208-
alignFileHeaderTop: nextCursor.fileId !== selectedFile?.id,
209+
// Align the file header to top only for forward cross-file jumps so the new file
210+
// starts at its header. Backward jumps should reveal the target hunk directly,
211+
// since the target is often near the bottom of the previous file and the file-top
212+
// align would require an extra navigation press to reach it.
213+
alignFileHeaderTop: crossingFileBoundary && delta > 0,
209214
});
210215
},
211216
[hunkCursors, selectHunk, selectedFile?.id, selectedHunkIndex],

test/pty/harness.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,46 @@ export function createPtyHarness() {
332332
]);
333333
}
334334

335+
/** Build the cross-file hunk-navigation shape that used to jump backward to the file top. */
336+
function createCrossFileHunkNavigationRepoFixture() {
337+
const longBeforeLines = Array.from(
338+
{ length: 342 },
339+
(_, index) => `line ${String(index + 1).padStart(3, "0")}`,
340+
);
341+
const longAfterLines = [...longBeforeLines];
342+
for (const lineNumber of [
343+
2, 21, 41, 61, 81, 101, 121, 141, 161, 181, 201, 221, 241, 261, 281, 301, 321, 341,
344+
]) {
345+
longAfterLines[lineNumber - 1] = `line ${String(lineNumber).padStart(3, "0")} changed`;
346+
}
347+
348+
const shortBeforeLines = [
349+
"// hunk 0 - at the very top of the file",
350+
"export const top = 1;",
351+
"",
352+
"",
353+
...Array.from({ length: 25 }, (_, index) => `// filler ${index + 1}`),
354+
"// hunk 1 - mid-file",
355+
"export const mid = 3;",
356+
];
357+
const shortAfterLines = [...shortBeforeLines];
358+
shortAfterLines[1] = "export const top = 2;";
359+
shortAfterLines[30] = "export const mid = 4;";
360+
361+
return createGitRepoFixture([
362+
{
363+
path: "long-file.txt",
364+
before: `${longBeforeLines.join("\n")}\n`,
365+
after: `${longAfterLines.join("\n")}\n`,
366+
},
367+
{
368+
path: "short-file.ts",
369+
before: `${shortBeforeLines.join("\n")}\n`,
370+
after: `${shortAfterLines.join("\n")}\n`,
371+
},
372+
]);
373+
}
374+
335375
function createPagerPatchFixture(lines = 40) {
336376
const dir = makeTempDir("hunk-tuistory-pager-");
337377
const beforeDir = join(dir, "before");
@@ -477,6 +517,7 @@ export function createPtyHarness() {
477517
createAgentFilePair,
478518
createBottomClampedRepoFixture,
479519
createCollapsedTopRepoFixture,
520+
createCrossFileHunkNavigationRepoFixture,
480521
createLongWrapFilePair,
481522
createMultiHunkFilePair,
482523
createPagerPatchFixture,

test/pty/ui-integration.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,47 @@ describe("live UI integration", () => {
122122
}
123123
});
124124

125+
test("backward cross-file hunk navigation reveals the target hunk in a real PTY", async () => {
126+
const fixture = harness.createCrossFileHunkNavigationRepoFixture();
127+
const session = await harness.launchHunk({
128+
args: ["diff", "--mode", "split"],
129+
cwd: fixture.dir,
130+
cols: 120,
131+
rows: 16,
132+
});
133+
134+
try {
135+
await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
136+
timeout: 15_000,
137+
});
138+
139+
for (let index = 0; index < 19; index += 1) {
140+
await session.press("]");
141+
await session.waitIdle({ timeout: 40 });
142+
}
143+
144+
await harness.waitForSnapshot(
145+
session,
146+
(text) => text.includes("export const mid = 4;"),
147+
5_000,
148+
);
149+
150+
await session.press("[");
151+
await session.waitIdle({ timeout: 80 });
152+
await session.press("[");
153+
const backward = await harness.waitForSnapshot(
154+
session,
155+
(text) => text.includes("line 341 changed") || text.includes("line 002 changed"),
156+
5_000,
157+
);
158+
159+
expect(backward).toContain("line 341 changed");
160+
expect(backward).not.toContain("line 002 changed");
161+
} finally {
162+
session.close();
163+
}
164+
});
165+
125166
test("a short last file does not trap upward scrolling at the bottom edge", async () => {
126167
const fixture = harness.createBottomClampedRepoFixture();
127168
const session = await harness.launchHunk({

0 commit comments

Comments
 (0)