Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-edge-jump-reveal-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Keep explicit top and bottom jumps from being overridden by a pending selection reveal.
33 changes: 33 additions & 0 deletions src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2583,6 +2583,39 @@ describe("App interactions", () => {
}
});

test("G supersedes a pending selected-hunk reveal", async () => {
const setup = await testRender(
<AppHost bootstrap={createCrossFileHunkNavigationBootstrap()} />,
{
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(
Expand Down
53 changes: 28 additions & 25 deletions src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,30 @@ export function DiffPane({
// is required before passive viewport-follow selection can trigger.
const lastViewportSelectionTopRef = useRef<number | null>(null);
const lastViewportRowAnchorRef = useRef<ViewportRowAnchor | null>(null);
// Track the previous selected anchor to detect actual selection changes.
const prevSelectedAnchorIdRef = useRef<string | null>(null);
const prevPinnedHeaderFileIdRef = useRef<string | null>(null);
const pendingSelectionSettleRef = useRef(false);
const pendingSelectionRevealTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);

/** 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) => {
Expand Down Expand Up @@ -1139,6 +1163,8 @@ export function DiffPane({
return;
}

supersedePendingSelectionReveal();
clearPendingFileTopAlign();
previousScrollEdgeRequestIdRef.current = pendingScrollEdgeRequest.id;
const viewportHeight = scrollBox.viewport.height || scrollEdgeViewportHeight;
const nextTop = clampVerticalScrollTop(
Expand All @@ -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(
Expand Down Expand Up @@ -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<string | null>(null);
const prevPinnedHeaderFileIdRef = useRef<string | null>(null);
const pendingSelectionSettleRef = useRef(false);
const pendingSelectionRevealTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);

/** 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.
*
Expand Down
Loading