diff --git a/.changeset/fix-edge-jump-reveal-race.md b/.changeset/fix-edge-jump-reveal-race.md
new file mode 100644
index 000000000..ab993741e
--- /dev/null
+++ b/.changeset/fix-edge-jump-reveal-race.md
@@ -0,0 +1,5 @@
+---
+"hunkdiff": patch
+---
+
+Keep explicit top and bottom jumps from being overridden by a pending selection reveal.
diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx
index 45d08a058..e7c707496 100644
--- a/src/ui/AppHost.interactions.test.tsx
+++ b/src/ui/AppHost.interactions.test.tsx
@@ -2583,6 +2583,39 @@ describe("App interactions", () => {
}
});
+ test("G supersedes a pending selected-hunk reveal", async () => {
+ const setup = await testRender(
+ ,
+ {
+ width: 120,
+ height: 16,
+ },
+ );
+
+ try {
+ await flush(setup);
+ await settleViewportMeasurement(setup);
+ await pressHunkNavigationKey(setup, "]", 1);
+
+ await act(async () => {
+ await setup.mockInput.pressKey("g", { shift: true });
+ });
+ await flush(setup);
+ await act(async () => {
+ await Bun.sleep(160);
+ await setup.renderOnce();
+ });
+
+ const frame = setup.captureCharFrame();
+ expect(frame).toContain("export const mid = 4;");
+ expect(frame).not.toContain("line 021 changed");
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
test("pager mode also supports G and g top/bottom jumps", async () => {
const before =
Array.from(
diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx
index 68c789690..1f1fcdb92 100644
--- a/src/ui/components/panes/DiffPane.tsx
+++ b/src/ui/components/panes/DiffPane.tsx
@@ -826,6 +826,30 @@ export function DiffPane({
// is required before passive viewport-follow selection can trigger.
const lastViewportSelectionTopRef = useRef(null);
const lastViewportRowAnchorRef = useRef(null);
+ // Track the previous selected anchor to detect actual selection changes.
+ const prevSelectedAnchorIdRef = useRef(null);
+ const prevPinnedHeaderFileIdRef = useRef(null);
+ const pendingSelectionSettleRef = useRef(false);
+ const pendingSelectionRevealTimeoutsRef = useRef[]>([]);
+
+ /** Clear scheduled selection-reveal retries without changing the resettle policy. */
+ const clearPendingSelectionRevealTimers = useCallback(() => {
+ for (const timeout of pendingSelectionRevealTimeoutsRef.current) {
+ clearTimeout(timeout);
+ }
+ pendingSelectionRevealTimeoutsRef.current = [];
+ }, []);
+
+ /** Retire selection reveal work once another explicit scroll policy becomes authoritative. */
+ const supersedePendingSelectionReveal = useCallback(() => {
+ clearPendingSelectionRevealTimers();
+ pendingSelectionSettleRef.current = false;
+ }, [clearPendingSelectionRevealTimers]);
+
+ /** Clear any pending "selected file to top" follow-up. */
+ const clearPendingFileTopAlign = useCallback(() => {
+ pendingFileTopAlignFileIdRef.current = null;
+ }, []);
/** Track the currently hover-owned file without making scroll handlers depend on render state. */
const setHoveredFileForRowActions = useCallback((fileId: string) => {
@@ -1139,6 +1163,8 @@ export function DiffPane({
return;
}
+ supersedePendingSelectionReveal();
+ clearPendingFileTopAlign();
previousScrollEdgeRequestIdRef.current = pendingScrollEdgeRequest.id;
const viewportHeight = scrollBox.viewport.height || scrollEdgeViewportHeight;
const nextTop = clampVerticalScrollTop(
@@ -1149,10 +1175,12 @@ export function DiffPane({
setScrollViewport({ top: nextTop, height: viewportHeight });
scrollBox.scrollTo(nextTop);
}, [
+ clearPendingFileTopAlign,
pendingScrollEdgeRequest,
requestedScrollEdgeTop,
scrollEdgeViewportHeight,
scrollRef,
+ supersedePendingSelectionReveal,
totalContentHeight,
]);
const fileSectionIndexById = useMemo(
@@ -2046,31 +2074,6 @@ export function DiffPane({
const selectedFileBodyTop =
selectedFileIndex >= 0 ? (fileSectionLayouts[selectedFileIndex]?.bodyTop ?? 0) : 0;
- // Track the previous selected anchor to detect actual selection changes.
- const prevSelectedAnchorIdRef = useRef(null);
- const prevPinnedHeaderFileIdRef = useRef(null);
- const pendingSelectionSettleRef = useRef(false);
- const pendingSelectionRevealTimeoutsRef = useRef[]>([]);
-
- /** Clear scheduled selection-reveal retries without changing the resettle policy. */
- const clearPendingSelectionRevealTimers = useCallback(() => {
- for (const timeout of pendingSelectionRevealTimeoutsRef.current) {
- clearTimeout(timeout);
- }
- pendingSelectionRevealTimeoutsRef.current = [];
- }, []);
-
- /** Retire selection reveal work once an explicit line alignment becomes authoritative. */
- const supersedePendingSelectionReveal = useCallback(() => {
- clearPendingSelectionRevealTimers();
- pendingSelectionSettleRef.current = false;
- }, [clearPendingSelectionRevealTimers]);
-
- /** Clear any pending "selected file to top" follow-up. */
- const clearPendingFileTopAlign = useCallback(() => {
- pendingFileTopAlignFileIdRef.current = null;
- }, []);
-
/**
* Report whether the align has landed as far as the rest of this pane can observe it.
*