Skip to content

Commit 127c48c

Browse files
committed
fix: keep active hunks clear of the viewport edge
1 parent 95d7e20 commit 127c48c

3 files changed

Lines changed: 104 additions & 19 deletions

File tree

src/ui/components/panes/DiffPane.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ScrollBoxRenderable } from "@opentui/core";
22
import { useCallback, useEffect, useLayoutEffect, useMemo, useState, type RefObject } from "react";
33
import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types";
44
import type { VisibleAgentNote } from "../../lib/agentAnnotations";
5-
import { estimateDiffBodyRows } from "../../lib/sectionHeights";
5+
import { estimateDiffBodyRows, estimateHunkAnchorRow } from "../../lib/sectionHeights";
66
import { diffHunkId, diffSectionId } from "../../lib/ids";
77
import type { AppTheme } from "../../themes";
88
import { DiffSection } from "./DiffSection";
@@ -185,18 +185,50 @@ export function DiffPane({
185185
return next;
186186
}, [adjacentPrefetchFileIds, selectedFileId, visibleViewportFileIds, windowingEnabled]);
187187

188-
const selectedFile = selectedFileId ? files.find((file) => file.id === selectedFileId) : undefined;
188+
const selectedFileIndex = selectedFileId ? files.findIndex((file) => file.id === selectedFileId) : -1;
189+
const selectedFile = selectedFileIndex >= 0 ? files[selectedFileIndex] : undefined;
189190
const selectedAnchorId = selectedFile
190191
? (selectedFile.metadata.hunks[selectedHunkIndex] ? diffHunkId(selectedFile.id, selectedHunkIndex) : diffSectionId(selectedFile.id))
191192
: null;
193+
const selectedEstimatedScrollTop = useMemo(() => {
194+
if (!selectedFile || selectedFileIndex < 0) {
195+
return null;
196+
}
197+
198+
let top = 0;
199+
for (let index = 0; index < selectedFileIndex; index += 1) {
200+
top += (index > 0 ? 1 : 0) + 1 + (estimatedBodyHeights[index] ?? 0);
201+
}
202+
203+
if (selectedFileIndex > 0) {
204+
top += 1;
205+
}
206+
207+
top += 1;
208+
top += estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex);
209+
return top;
210+
}, [estimatedBodyHeights, files, layout, selectedFile, selectedFileIndex, selectedHunkIndex, showHunkHeaders]);
192211

193212
useLayoutEffect(() => {
194213
if (!selectedAnchorId) {
195214
return;
196215
}
197216

198217
const scrollSelectionIntoView = () => {
199-
scrollRef.current?.scrollChildIntoView(selectedAnchorId);
218+
const scrollBox = scrollRef.current;
219+
if (!scrollBox) {
220+
return;
221+
}
222+
223+
// In the common no-wrap/no-note path we can estimate the selected hunk row and keep it
224+
// comfortably below the top edge instead of merely making it barely visible.
225+
if (!wrapLines && visibleAgentNotesByFile.size === 0 && selectedEstimatedScrollTop !== null) {
226+
const topPaddingRows = Math.max(2, Math.floor(scrollViewport.height * 0.25));
227+
scrollBox.scrollTo(Math.max(0, selectedEstimatedScrollTop - topPaddingRows));
228+
return;
229+
}
230+
231+
scrollBox.scrollChildIntoView(selectedAnchorId);
200232
};
201233

202234
// Run after this pane renders the selected section/hunk, then retry briefly while layout settles.
@@ -206,7 +238,7 @@ export function DiffPane({
206238
return () => {
207239
timeouts.forEach((timeout) => clearTimeout(timeout));
208240
};
209-
}, [selectedAnchorId, scrollRef]);
241+
}, [scrollRef, scrollViewport.height, selectedAnchorId, selectedEstimatedScrollTop, visibleAgentNotesByFile.size, wrapLines]);
210242

211243
return (
212244
<box

src/ui/lib/sectionHeights.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,40 @@ function trailingCollapsedLines(metadata: FileDiffMetadata) {
1818
return Math.max(additionRemaining, 0);
1919
}
2020

21+
/** Count render rows for one hunk when wrapping and note cards are off. */
22+
function estimateHunkRows(
23+
file: DiffFile,
24+
layout: Exclude<LayoutMode, "auto">,
25+
showHunkHeaders: boolean,
26+
hunkIndex: number,
27+
) {
28+
const hunk = file.metadata.hunks[hunkIndex];
29+
if (!hunk) {
30+
return 0;
31+
}
32+
33+
let rows = 0;
34+
35+
if (hunk.collapsedBefore > 0) {
36+
rows += 1;
37+
}
38+
39+
if (showHunkHeaders) {
40+
rows += 1;
41+
}
42+
43+
for (const content of hunk.hunkContent) {
44+
if (content.type === "context") {
45+
rows += content.lines;
46+
continue;
47+
}
48+
49+
rows += layout === "split" ? Math.max(content.deletions, content.additions) : content.deletions + content.additions;
50+
}
51+
52+
return rows;
53+
}
54+
2155
/** Estimate the number of diff-body rows for one file when wrapping and note cards are off. */
2256
export function estimateDiffBodyRows(
2357
file: DiffFile,
@@ -30,26 +64,37 @@ export function estimateDiffBodyRows(
3064

3165
let rows = 0;
3266

33-
for (const hunk of file.metadata.hunks) {
34-
if (hunk.collapsedBefore > 0) {
35-
rows += 1;
36-
}
67+
for (const [hunkIndex] of file.metadata.hunks.entries()) {
68+
rows += estimateHunkRows(file, layout, showHunkHeaders, hunkIndex);
69+
}
3770

38-
if (showHunkHeaders) {
39-
rows += 1;
40-
}
71+
if (trailingCollapsedLines(file.metadata) > 0) {
72+
rows += 1;
73+
}
4174

42-
for (const content of hunk.hunkContent) {
43-
if (content.type === "context") {
44-
rows += content.lines;
45-
continue;
46-
}
75+
return rows;
76+
}
4777

48-
rows += layout === "split" ? Math.max(content.deletions, content.additions) : content.deletions + content.additions;
49-
}
78+
/** Estimate the body-row offset for the anchor that should represent the selected hunk. */
79+
export function estimateHunkAnchorRow(
80+
file: DiffFile,
81+
layout: Exclude<LayoutMode, "auto">,
82+
showHunkHeaders: boolean,
83+
hunkIndex: number,
84+
) {
85+
if (file.metadata.hunks.length === 0) {
86+
return 0;
5087
}
5188

52-
if (trailingCollapsedLines(file.metadata) > 0) {
89+
const clampedHunkIndex = Math.max(0, Math.min(hunkIndex, file.metadata.hunks.length - 1));
90+
let rows = 0;
91+
92+
for (let index = 0; index < clampedHunkIndex; index += 1) {
93+
rows += estimateHunkRows(file, layout, showHunkHeaders, index);
94+
}
95+
96+
const selectedHunk = file.metadata.hunks[clampedHunkIndex]!;
97+
if (selectedHunk.collapsedBefore > 0) {
5398
rows += 1;
5499
}
55100

test/app-interactions.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ async function flush(setup: Awaited<ReturnType<typeof testRender>>) {
160160
});
161161
}
162162

163+
function lineIndexOf(frame: string, needle: string) {
164+
return frame.split("\n").findIndex((line) => line.includes(needle));
165+
}
166+
163167

164168
describe("App interactions", () => {
165169
test("keyboard shortcuts toggle notes, line numbers, and hunk metadata", async () => {
@@ -320,6 +324,10 @@ describe("App interactions", () => {
320324
expect(frame).toContain("epsilon.ts");
321325
expect(frame).toContain("▌@@ -1,1 +1,2 @@");
322326
expect(frame).not.toContain("alphaMarker");
327+
328+
const selectedHunkLine = lineIndexOf(frame, "▌@@ -1,1 +1,2 @@");
329+
expect(selectedHunkLine).toBeGreaterThanOrEqual(0);
330+
expect(selectedHunkLine).toBeLessThanOrEqual(8);
323331
} finally {
324332
await act(async () => {
325333
setup.renderer.destroy();

0 commit comments

Comments
 (0)