Skip to content

Commit f6de1cb

Browse files
authored
fix(ui): stop bottom-edge scroll snap-back (#195)
1 parent 1c2686e commit f6de1cb

File tree

5 files changed

+209
-22
lines changed

5 files changed

+209
-22
lines changed

CHANGELOG.md

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

1515
### Fixed
1616

17+
- Fixed a bottom-edge scroll trap that could snap the review pane back when the last short file or hunk was selected near the end of the stream.
18+
1719
## [0.9.1] - 2026-04-10
1820

1921
### Fixed

src/ui/components/panes/DiffPane.tsx

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/Ve
3939
import { prefetchHighlightedDiff } from "../../diff/useHighlightedDiff";
4040

4141
const EMPTY_VISIBLE_AGENT_NOTES: VisibleAgentNote[] = [];
42+
const EMPTY_VISIBLE_AGENT_NOTES_BY_FILE = new Map<string, VisibleAgentNote[]>();
43+
44+
/**
45+
* Clamp one vertical scroll target into the currently reachable review-stream extent.
46+
*
47+
* Selection-driven scroll requests can legitimately aim past the last reachable row — for example
48+
* when the user selects a short trailing file but asks for that file body to own the viewport top.
49+
* Every settle check must compare against this clamped value, not the raw request, or the pane can
50+
* keep re-applying a bottom-edge scroll and trap manual upward scrolling.
51+
*/
52+
function clampVerticalScrollTop(scrollTop: number, contentHeight: number, viewportHeight: number) {
53+
const maxScrollTop = Math.max(0, contentHeight - viewportHeight);
54+
return Math.min(Math.max(0, scrollTop), maxScrollTop);
55+
}
4256

4357
/** Keep syntax-highlight warm for the files immediately adjacent to the current selection. */
4458
function buildAdjacentPrefetchFileIds(files: DiffFile[], selectedFileId?: string) {
@@ -365,7 +379,7 @@ export function DiffPane({
365379
const next = new Map<string, VisibleAgentNote[]>();
366380

367381
if (!showAgentNotes) {
368-
return next;
382+
return EMPTY_VISIBLE_AGENT_NOTES_BY_FILE;
369383
}
370384

371385
const fileIdsToMeasure = new Set(visibleViewportFileIds);
@@ -426,6 +440,13 @@ export function DiffPane({
426440
);
427441
const totalContentHeight = fileSectionLayouts[fileSectionLayouts.length - 1]?.sectionBottom ?? 0;
428442

443+
/** Clamp one requested review scroll target against the latest planned content height. */
444+
const clampReviewScrollTop = useCallback(
445+
(requestedTop: number, viewportHeight: number) =>
446+
clampVerticalScrollTop(requestedTop, totalContentHeight, viewportHeight),
447+
[totalContentHeight],
448+
);
449+
429450
const highlightPrefetchFileIds = useMemo(
430451
() =>
431452
buildHighlightPrefetchFileIds({
@@ -620,6 +641,12 @@ export function DiffPane({
620641
height: noteRow.height,
621642
};
622643
}, [scrollToNote, sectionGeometry, selectedEstimatedHunkBounds, selectedFileIndex]);
644+
const selectedEstimatedHunkTop = selectedEstimatedHunkBounds?.top ?? null;
645+
const selectedEstimatedHunkHeight = selectedEstimatedHunkBounds?.height ?? null;
646+
const selectedEstimatedHunkStartRowId = selectedEstimatedHunkBounds?.startRowId ?? null;
647+
const selectedEstimatedHunkEndRowId = selectedEstimatedHunkBounds?.endRowId ?? null;
648+
const selectedNoteTop = selectedNoteBounds?.top ?? null;
649+
const selectedNoteHeight = selectedNoteBounds?.height ?? null;
623650

624651
// Track the previous selected anchor to detect actual selection changes.
625652
const prevSelectedAnchorIdRef = useRef<string | null>(null);
@@ -644,11 +671,14 @@ export function DiffPane({
644671
return false;
645672
}
646673

647-
// The pinned header owns the top row, so align the review stream to the file body.
648-
scrollBox.scrollTo(targetSection.bodyTop);
674+
const viewportHeight = Math.max(scrollViewport.height, scrollBox.viewport.height ?? 0);
675+
676+
// The pinned header owns the top row, so align the review stream to the file body. Clamp the
677+
// request so short trailing files can still settle cleanly at the reachable bottom edge.
678+
scrollBox.scrollTo(clampReviewScrollTop(targetSection.bodyTop, viewportHeight));
649679
return true;
650680
},
651-
[fileSectionLayouts, scrollRef],
681+
[clampReviewScrollTop, fileSectionLayouts, scrollRef, scrollViewport.height],
652682
);
653683

654684
useLayoutEffect(() => {
@@ -789,7 +819,11 @@ export function DiffPane({
789819
return;
790820
}
791821

792-
const desiredTop = targetSection.bodyTop;
822+
const viewportHeight = Math.max(scrollViewport.height, scrollRef.current?.viewport.height ?? 0);
823+
// Compare against the reachable target, not the raw file body top. The last short file often
824+
// cannot actually own the viewport top near EOF, and treating that unreachable top as pending
825+
// would keep snapping manual upward scrolling back down to the bottom edge.
826+
const desiredTop = clampReviewScrollTop(targetSection.bodyTop, viewportHeight);
793827

794828
const currentTop = scrollRef.current?.scrollTop ?? scrollViewport.top;
795829
if (Math.abs(currentTop - desiredTop) <= 0.5) {
@@ -800,17 +834,18 @@ export function DiffPane({
800834
suppressViewportSelectionSync();
801835
scrollFileHeaderToTop(pendingFileId);
802836
}, [
837+
clampReviewScrollTop,
803838
clearPendingFileTopAlign,
804839
fileSectionLayouts,
805840
files,
806841
scrollFileHeaderToTop,
807842
scrollRef,
843+
scrollViewport.height,
808844
scrollViewport.top,
809845
suppressViewportSelectionSync,
810846
]);
811847

812848
useLayoutEffect(() => {
813-
const pinnedHeaderFileId = pinnedHeaderFile?.id ?? null;
814849
const revealFollowsSelectionChange = selectedHunkRevealRequestId === undefined;
815850
const revealRequested = revealFollowsSelectionChange
816851
? prevSelectedAnchorIdRef.current !== selectedAnchorId
@@ -847,16 +882,20 @@ export function DiffPane({
847882
const preferredTopPadding = Math.max(2, Math.floor(viewportHeight * 0.25));
848883

849884
// When navigating comment-to-comment, scroll the inline note card near the viewport top
850-
// instead of positioning the entire hunk. Uses the same reveal function so the padding
851-
// behavior matches regular hunk navigation.
885+
// instead of positioning the entire hunk. Clamp the reveal target too: notes in the final
886+
// hunk can request a top offset that is no longer reachable once the viewport hits EOF.
887+
// Using the reachable value keeps the reveal logic from fighting later manual scrolling.
852888
if (selectedNoteBounds) {
853889
scrollBox.scrollTo(
854-
computeHunkRevealScrollTop({
855-
hunkTop: selectedNoteBounds.top,
856-
hunkHeight: selectedNoteBounds.height,
857-
preferredTopPadding,
890+
clampReviewScrollTop(
891+
computeHunkRevealScrollTop({
892+
hunkTop: selectedNoteBounds.top,
893+
hunkHeight: selectedNoteBounds.height,
894+
preferredTopPadding,
895+
viewportHeight,
896+
}),
858897
viewportHeight,
859-
}),
898+
),
860899
);
861900
return;
862901
}
@@ -871,6 +910,8 @@ export function DiffPane({
871910

872911
// Prefer exact mounted bounds when both edges are available. If only one edge has mounted
873912
// so far, fall back to the planned bounds as one atomic estimate instead of mixing sources.
913+
// The final reveal target still gets clamped below so a bottom-edge hunk does not keep
914+
// re-requesting an impossible scrollTop after the selection settles.
874915
const renderedTop = startRow ? currentScrollTop + (startRow.y - viewportTop) : null;
875916
const renderedBottom = endRow
876917
? currentScrollTop + (endRow.y + endRow.height - viewportTop)
@@ -882,12 +923,15 @@ export function DiffPane({
882923
: selectedEstimatedHunkBounds.height;
883924

884925
scrollBox.scrollTo(
885-
computeHunkRevealScrollTop({
886-
hunkTop,
887-
hunkHeight,
888-
preferredTopPadding,
926+
clampReviewScrollTop(
927+
computeHunkRevealScrollTop({
928+
hunkTop,
929+
hunkHeight,
930+
preferredTopPadding,
931+
viewportHeight,
932+
}),
889933
viewportHeight,
890-
}),
934+
),
891935
);
892936
return;
893937
}
@@ -916,15 +960,20 @@ export function DiffPane({
916960
}
917961
};
918962
}, [
919-
pinnedHeaderFile?.id,
963+
clampReviewScrollTop,
964+
pinnedHeaderFileId,
920965
scrollRef,
921966
scrollViewport.height,
922967
selectedAnchorId,
923-
selectedEstimatedHunkBounds,
968+
selectedEstimatedHunkEndRowId,
969+
selectedEstimatedHunkHeight,
970+
selectedEstimatedHunkStartRowId,
971+
selectedEstimatedHunkTop,
924972
selectedFileIndex,
925973
selectedHunkIndex,
926974
selectedHunkRevealRequestId,
927-
selectedNoteBounds,
975+
selectedNoteHeight,
976+
selectedNoteTop,
928977
suppressViewportSelectionSync,
929978
]);
930979

src/ui/components/ui-components.test.tsx

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22
import type { ScrollBoxRenderable } from "@opentui/core";
33
import { testRender } from "@opentui/react/test-utils";
4-
import { act, createRef, useState, type ReactNode } from "react";
4+
import { act, createRef, useEffect, useState, type ReactNode } from "react";
55
import type { AppBootstrap, DiffFile } from "../../core/types";
66
import { createTestGitAppBootstrap } from "../../../test/helpers/app-bootstrap";
77
import { createTestDiffFile as buildTestDiffFile, lines } from "../../../test/helpers/diff-helpers";
@@ -817,6 +817,75 @@ describe("UI components", () => {
817817
}
818818
});
819819

820+
test("DiffPane lets manual scrolling move away from a bottom-clamped file-top alignment", async () => {
821+
const theme = resolveTheme("midnight", null);
822+
const files = [
823+
createTallDiffFile("first", "first.ts", 30),
824+
createTestDiffFile(
825+
"second",
826+
"second.ts",
827+
lines(
828+
"export const shortLine1 = 1;",
829+
"export const shortLine2 = 2;",
830+
"export const shortLine3 = 3;",
831+
),
832+
lines(
833+
"export const shortLine1 = 10;",
834+
"export const shortLine2 = 20;",
835+
"export const shortLine3 = 30;",
836+
),
837+
),
838+
];
839+
const scrollRef = createRef<ScrollBoxRenderable>();
840+
841+
function BottomAlignedFileHarness() {
842+
const [selectedFileTopAlignRequestId, setSelectedFileTopAlignRequestId] = useState(0);
843+
844+
useEffect(() => {
845+
setSelectedFileTopAlignRequestId(1);
846+
}, []);
847+
848+
return (
849+
<DiffPane
850+
{...createDiffPaneProps(files, theme, {
851+
diffContentWidth: 88,
852+
headerLabelWidth: 48,
853+
headerStatsWidth: 16,
854+
scrollRef,
855+
selectedFileId: "second",
856+
selectedHunkIndex: 0,
857+
selectedFileTopAlignRequestId,
858+
separatorWidth: 84,
859+
width: 92,
860+
})}
861+
/>
862+
);
863+
}
864+
865+
const setup = await testRender(<BottomAlignedFileHarness />, {
866+
width: 96,
867+
height: 10,
868+
});
869+
870+
try {
871+
await settleDiffPane(setup);
872+
873+
const bottomScrollTop = scrollRef.current?.scrollTop ?? 0;
874+
expect(bottomScrollTop).toBeGreaterThan(0);
875+
876+
await act(async () => {
877+
scrollRef.current?.scrollTo(bottomScrollTop - 1);
878+
});
879+
await settleDiffPane(setup);
880+
881+
expect(scrollRef.current?.scrollTop ?? 0).toBe(bottomScrollTop - 1);
882+
} finally {
883+
await act(async () => {
884+
setup.renderer.destroy();
885+
});
886+
}
887+
});
888+
820889
test("DiffPane keeps a viewport-sized selected hunk fully visible when it fits", async () => {
821890
const theme = resolveTheme("midnight", null);
822891
const props = createDiffPaneProps(

test/pty/harness.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,32 @@ export function createPtyHarness() {
301301
]);
302302
}
303303

304+
/** Build a repo whose final short file can only align to the reachable bottom edge. */
305+
function createBottomClampedRepoFixture() {
306+
return createGitRepoFixture([
307+
{
308+
path: "first.ts",
309+
before: `${createNumberedExportLines(1, 30)}\n`,
310+
after: `${createNumberedExportLines(1, 30, 100)}\n`,
311+
},
312+
{
313+
path: "second.ts",
314+
before:
315+
[
316+
"export const shortLine1 = 1;",
317+
"export const shortLine2 = 2;",
318+
"export const shortLine3 = 3;",
319+
].join("\n") + "\n",
320+
after:
321+
[
322+
"export const shortLine1 = 10;",
323+
"export const shortLine2 = 20;",
324+
"export const shortLine3 = 30;",
325+
].join("\n") + "\n",
326+
},
327+
]);
328+
}
329+
304330
function createPagerPatchFixture(lines = 40) {
305331
const dir = makeTempDir("hunk-tuistory-pager-");
306332
const beforeDir = join(dir, "before");
@@ -386,6 +412,7 @@ export function createPtyHarness() {
386412
cleanup,
387413
countMatches,
388414
createAgentFilePair,
415+
createBottomClampedRepoFixture,
389416
createCollapsedTopRepoFixture,
390417
createLongWrapFilePair,
391418
createMultiHunkFilePair,

test/pty/ui-integration.test.ts

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

125+
test("a short last file does not trap upward scrolling at the bottom edge", async () => {
126+
const fixture = harness.createBottomClampedRepoFixture();
127+
const session = await harness.launchHunk({
128+
args: ["diff", "--mode", "split"],
129+
cwd: fixture.dir,
130+
cols: 220,
131+
rows: 10,
132+
});
133+
134+
try {
135+
await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, {
136+
timeout: 15_000,
137+
});
138+
139+
await session.press("]");
140+
const bottomAligned = await harness.waitForSnapshot(
141+
session,
142+
(text) => text.includes("shortLine1 = 10;"),
143+
5_000,
144+
);
145+
146+
expect(bottomAligned).not.toContain("line30 = 130");
147+
148+
for (let iteration = 0; iteration < 4; iteration += 1) {
149+
await session.press("up");
150+
await session.waitIdle({ timeout: 200 });
151+
}
152+
153+
const movedUp = await harness.waitForSnapshot(
154+
session,
155+
(text) => text.includes("line30 = 130"),
156+
5_000,
157+
);
158+
159+
expect(movedUp).toContain("line30 = 130");
160+
} finally {
161+
session.close();
162+
}
163+
});
164+
125165
test("auto layout responds to live terminal resize in a real PTY", async () => {
126166
const fixture = harness.createTwoFileRepoFixture();
127167
const session = await harness.launchHunk({

0 commit comments

Comments
 (0)