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. *