Skip to content

Commit 1fe977c

Browse files
authored
fix: prevent scroll snapback when using Space/PgUp/PgDown (#105)
1 parent 7bb5dc9 commit 1fe977c

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

src/ui/components/panes/DiffPane.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,20 @@ export function DiffPane({
305305
return top;
306306
}, [estimatedBodyHeights, sectionMetrics, selectedFile, selectedFileIndex, selectedHunkIndex]);
307307

308+
// Track the previous selected anchor to detect actual selection changes
309+
const prevSelectedAnchorIdRef = useRef<string | null>(null);
310+
308311
useLayoutEffect(() => {
309312
if (!selectedAnchorId) {
313+
prevSelectedAnchorIdRef.current = null;
314+
return;
315+
}
316+
317+
// Only auto-scroll when the selection actually changes, not when metrics update during scrolling
318+
const isSelectionChange = prevSelectedAnchorIdRef.current !== selectedAnchorId;
319+
prevSelectedAnchorIdRef.current = selectedAnchorId;
320+
321+
if (!isSelectionChange) {
310322
return;
311323
}
312324

test/app-interactions.test.tsx

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,194 @@ describe("App interactions", () => {
617617
}
618618
});
619619

620+
test("Space scrolls down by viewport", async () => {
621+
// Create a file with many lines so Space has room to scroll
622+
const before =
623+
Array.from(
624+
{ length: 50 },
625+
(_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`,
626+
).join("\n") + "\n";
627+
const after =
628+
Array.from(
629+
{ length: 50 },
630+
(_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1001};`,
631+
).join("\n") + "\n";
632+
633+
const bootstrap: AppBootstrap = {
634+
input: {
635+
kind: "git",
636+
staged: false,
637+
options: {
638+
mode: "split",
639+
},
640+
},
641+
changeset: {
642+
id: "changeset:space-scroll",
643+
sourceLabel: "repo",
644+
title: "repo working tree",
645+
files: [createDiffFile("space", "space.ts", before, after)],
646+
},
647+
initialMode: "split",
648+
initialTheme: "midnight",
649+
};
650+
651+
const setup = await testRender(<App bootstrap={bootstrap} />, {
652+
width: 220,
653+
height: 12,
654+
});
655+
656+
try {
657+
await flush(setup);
658+
setup.captureCharFrame(); // Capture initial state
659+
660+
// Press Space to scroll down - should not crash and should change view
661+
await act(async () => {
662+
await setup.mockInput.pressKey(" ");
663+
});
664+
await flush(setup);
665+
666+
// App should still be rendering without errors
667+
const frame = setup.captureCharFrame();
668+
expect(frame).toContain("export const line");
669+
} finally {
670+
await act(async () => {
671+
setup.renderer.destroy();
672+
});
673+
}
674+
});
675+
676+
test("PageUp scrolls up by viewport", async () => {
677+
const before =
678+
Array.from(
679+
{ length: 50 },
680+
(_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`,
681+
).join("\n") + "\n";
682+
const after =
683+
Array.from(
684+
{ length: 50 },
685+
(_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1001};`,
686+
).join("\n") + "\n";
687+
688+
const bootstrap: AppBootstrap = {
689+
input: {
690+
kind: "git",
691+
staged: false,
692+
options: {
693+
mode: "split",
694+
},
695+
},
696+
changeset: {
697+
id: "changeset:pageup-scroll",
698+
sourceLabel: "repo",
699+
title: "repo working tree",
700+
files: [createDiffFile("pageup", "pageup.ts", before, after)],
701+
},
702+
initialMode: "split",
703+
initialTheme: "midnight",
704+
};
705+
706+
const setup = await testRender(<App bootstrap={bootstrap} />, {
707+
width: 220,
708+
height: 12,
709+
});
710+
711+
try {
712+
await flush(setup);
713+
714+
// First scroll down to have room to scroll back up
715+
await act(async () => {
716+
await setup.mockInput.pressKey(" ");
717+
});
718+
await flush(setup);
719+
setup.captureCharFrame(); // Capture after Space
720+
721+
// Now scroll back up with PageUp - should not crash
722+
await act(async () => {
723+
await setup.mockInput.pressKey("pageup");
724+
});
725+
await flush(setup);
726+
727+
// App should still be rendering
728+
const frame = setup.captureCharFrame();
729+
expect(frame).toContain("export const line");
730+
} finally {
731+
await act(async () => {
732+
setup.renderer.destroy();
733+
});
734+
}
735+
});
736+
737+
test("new shortcuts d, u, and f work without errors", async () => {
738+
const before =
739+
Array.from(
740+
{ length: 50 },
741+
(_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`,
742+
).join("\n") + "\n";
743+
const after =
744+
Array.from(
745+
{ length: 50 },
746+
(_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1001};`,
747+
).join("\n") + "\n";
748+
749+
const bootstrap: AppBootstrap = {
750+
input: {
751+
kind: "git",
752+
staged: false,
753+
options: {
754+
mode: "split",
755+
},
756+
},
757+
changeset: {
758+
id: "changeset:half-scroll",
759+
sourceLabel: "repo",
760+
title: "repo working tree",
761+
files: [createDiffFile("half", "half.ts", before, after)],
762+
},
763+
initialMode: "split",
764+
initialTheme: "midnight",
765+
};
766+
767+
const setup = await testRender(<App bootstrap={bootstrap} />, {
768+
width: 220,
769+
height: 12,
770+
});
771+
772+
try {
773+
await flush(setup);
774+
const initialFrame = setup.captureCharFrame();
775+
776+
// Press 'd' (half page down) - should not crash
777+
await act(async () => {
778+
await setup.mockInput.pressKey("d");
779+
});
780+
await flush(setup);
781+
let frame = setup.captureCharFrame();
782+
expect(frame).toContain("export const line");
783+
784+
// Press 'u' (half page up) - should not crash
785+
await act(async () => {
786+
await setup.mockInput.pressKey("u");
787+
});
788+
await flush(setup);
789+
frame = setup.captureCharFrame();
790+
expect(frame).toContain("export const line");
791+
792+
// Press 'f' (page down, less-style) - should not crash
793+
await act(async () => {
794+
await setup.mockInput.pressKey("f");
795+
});
796+
await flush(setup);
797+
frame = setup.captureCharFrame();
798+
expect(frame).toContain("export const line");
799+
// Content should have changed after scrolling
800+
expect(frame).not.toEqual(initialFrame);
801+
} finally {
802+
await act(async () => {
803+
setup.renderer.destroy();
804+
});
805+
}
806+
});
807+
620808
test("filter focus accepts typed input and narrows the visible file set", async () => {
621809
const setup = await testRender(<App bootstrap={createBootstrap()} />, {
622810
width: 240,

0 commit comments

Comments
 (0)