diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 56b71523..521c3001 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -125,6 +125,7 @@ export function App({
const selectedHunkIndex = review.selectedHunkIndex;
const moveToAnnotatedFile = review.moveToAnnotatedFile;
const moveToAnnotatedHunk = review.moveToAnnotatedHunk;
+ const moveToFile = review.moveToFile;
const jumpToFile = useCallback(
(fileId: string, nextHunkIndex = 0, options?: { alignFileHeaderTop?: boolean }) => {
@@ -548,6 +549,7 @@ export function App({
focusArea,
focusFilter,
moveToAnnotatedHunk,
+ moveToFile,
moveToHunk: review.moveToHunk,
moveMenuItem,
openMenu,
diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx
index 82dc9e45..876be02b 100644
--- a/src/ui/AppHost.interactions.test.tsx
+++ b/src/ui/AppHost.interactions.test.tsx
@@ -2033,6 +2033,80 @@ describe("App interactions", () => {
}
});
+ test("file navigation shortcuts jump between visible files outside filter focus", async () => {
+ const { getLatestSnapshot, hostClient } = createMockHostClient();
+ const setup = await testRender(
+ ,
+ {
+ width: 220,
+ height: 10,
+ },
+ );
+
+ try {
+ await flush(setup);
+
+ for (let index = 0; index < 10; index += 1) {
+ await act(async () => {
+ await setup.mockInput.pressArrow("down");
+ });
+ await flush(setup);
+ }
+
+ await act(async () => {
+ await setup.mockInput.typeText(".");
+ });
+ await flush(setup);
+
+ let snapshot = await waitForSnapshot(
+ setup,
+ getLatestSnapshot,
+ (nextSnapshot) => nextSnapshot.selectedFileId === "second",
+ 24,
+ );
+ expect(snapshot?.selectedFileId).toBe("second");
+ expect(snapshot?.selectedHunkIndex).toBe(0);
+
+ let frame = await waitForFrame(
+ setup,
+ (nextFrame) =>
+ nextFrame.includes("second.ts") && (nextFrame.match(/first\.ts/g) ?? []).length === 1,
+ 24,
+ );
+ expect(frame).toContain("second.ts");
+
+ await act(async () => {
+ await setup.mockInput.typeText(",");
+ });
+ await flush(setup);
+
+ snapshot = await waitForSnapshot(
+ setup,
+ getLatestSnapshot,
+ (nextSnapshot) => nextSnapshot.selectedFileId === "first",
+ 24,
+ );
+ expect(snapshot?.selectedFileId).toBe("first");
+
+ await act(async () => {
+ await setup.mockInput.pressTab();
+ });
+ await flush(setup);
+ await act(async () => {
+ await setup.mockInput.typeText(".");
+ });
+ await flush(setup);
+
+ frame = setup.captureCharFrame();
+ expect(frame).toContain("filter:");
+ expect(getLatestSnapshot()?.selectedFileId).toBe("first");
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
test("forward cross-file hunk navigation keeps the destination file owning the review pane", async () => {
const setup = await testRender(
,
diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx
index 1ed1f595..24291758 100644
--- a/src/ui/components/chrome/HelpDialog.tsx
+++ b/src/ui/components/chrome/HelpDialog.tsx
@@ -26,6 +26,7 @@ export function HelpDialog({
["Shift+Space", "page up (alt)"],
["d / u", "half page down / up"],
["[ / ]", "previous / next hunk"],
+ [", / .", "previous / next file"],
["{ / }", "previous / next comment"],
["← / →", "scroll code left / right (Shift = faster)"],
["Home / End", "jump to top / bottom"],
diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts
index f2349d9e..4f118de8 100644
--- a/src/ui/hooks/useAppKeyboardShortcuts.ts
+++ b/src/ui/hooks/useAppKeyboardShortcuts.ts
@@ -29,6 +29,7 @@ export interface UseAppKeyboardShortcutsOptions {
focusArea: FocusArea;
focusFilter: () => void;
moveToAnnotatedHunk: (delta: number) => void;
+ moveToFile: (delta: number) => void;
moveToHunk: (delta: number) => void;
moveMenuItem: (delta: number) => void;
openMenu: (menuId: MenuId) => void;
@@ -60,6 +61,7 @@ export function useAppKeyboardShortcuts({
focusArea,
focusFilter,
moveToAnnotatedHunk,
+ moveToFile,
moveToHunk,
moveMenuItem,
openMenu,
@@ -376,6 +378,16 @@ export function useAppKeyboardShortcuts({
return;
}
+ if (key.name === "," || key.sequence === ",") {
+ runAndCloseMenu(() => moveToFile(-1));
+ return;
+ }
+
+ if (key.name === "." || key.sequence === ".") {
+ runAndCloseMenu(() => moveToFile(1));
+ return;
+ }
+
if (key.sequence === "{") {
runAndCloseMenu(() => moveToAnnotatedHunk(-1));
return;
diff --git a/src/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx
index d0885f59..84ebe386 100644
--- a/src/ui/hooks/useReviewController.test.tsx
+++ b/src/ui/hooks/useReviewController.test.tsx
@@ -198,6 +198,97 @@ describe("useReviewController", () => {
}
});
+ test("moves through visible files with clamped file-header alignment", async () => {
+ const controllerRef: { current: ReviewController | null } = { current: null };
+ const setup = await testRender(
+ {
+ controllerRef.current = nextController;
+ }}
+ />,
+ { width: 80, height: 4 },
+ );
+
+ try {
+ await flush(setup);
+
+ await act(async () => {
+ expectValue(controllerRef.current).selectHunk("alpha", 1);
+ });
+ await flush(setup);
+ expect(expectValue(controllerRef.current).selectedHunkIndex).toBe(1);
+
+ await act(async () => {
+ expectValue(controllerRef.current).moveToFile(1);
+ });
+ await flush(setup);
+
+ let controller = expectValue(controllerRef.current);
+ expect(controller.selectedFile?.path).toBe("beta.ts");
+ expect(controller.selectedHunkIndex).toBe(0);
+ expect(controller.selectedFileTopAlignRequestId).toBe(1);
+
+ await act(async () => {
+ expectValue(controllerRef.current).moveToFile(1);
+ });
+ await flush(setup);
+
+ controller = expectValue(controllerRef.current);
+ expect(controller.selectedFile?.path).toBe("gamma.ts");
+ expect(controller.selectedFileTopAlignRequestId).toBe(2);
+
+ await act(async () => {
+ expectValue(controllerRef.current).moveToFile(1);
+ });
+ await flush(setup);
+
+ controller = expectValue(controllerRef.current);
+ expect(controller.selectedFile?.path).toBe("gamma.ts");
+ expect(controller.selectedFileTopAlignRequestId).toBe(2);
+
+ await act(async () => {
+ expectValue(controllerRef.current).moveToFile(-1);
+ });
+ await flush(setup);
+
+ controller = expectValue(controllerRef.current);
+ expect(controller.selectedFile?.path).toBe("beta.ts");
+ expect(controller.selectedFileTopAlignRequestId).toBe(3);
+
+ await act(async () => {
+ expectValue(controllerRef.current).moveToFile(-1);
+ });
+ await flush(setup);
+
+ controller = expectValue(controllerRef.current);
+ expect(controller.selectedFile?.path).toBe("alpha.ts");
+ expect(controller.selectedFileTopAlignRequestId).toBe(4);
+
+ await act(async () => {
+ expectValue(controllerRef.current).moveToFile(-1);
+ });
+ await flush(setup);
+
+ controller = expectValue(controllerRef.current);
+ expect(controller.selectedFile?.path).toBe("alpha.ts");
+ expect(controller.selectedFileTopAlignRequestId).toBe(4);
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
test("live comment mutations update annotated navigation without remounting the app", async () => {
const controllerRef: { current: ReviewController | null } = { current: null };
const setup = await testRender(
diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts
index cd2508dc..80d7614f 100644
--- a/src/ui/hooks/useReviewController.ts
+++ b/src/ui/hooks/useReviewController.ts
@@ -60,6 +60,7 @@ export interface ReviewController {
liveCommentsByFileId: Record;
moveToAnnotatedFile: (delta: number) => void;
moveToAnnotatedHunk: (delta: number) => void;
+ moveToFile: (delta: number) => void;
moveToHunk: (delta: number) => void;
scrollToNote: boolean;
selectedFile: DiffFile | undefined;
@@ -247,6 +248,29 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
[selectFile, selectedFile?.id, visibleFiles],
);
+ /** Move through all currently visible files without wrapping past either end. */
+ const moveToFile = useCallback(
+ (delta: number) => {
+ const currentIndex = visibleFiles.findIndex((file) => file.id === selectedFile?.id);
+ if (currentIndex < 0) {
+ return;
+ }
+
+ const nextIndex = clamp(currentIndex + delta, 0, visibleFiles.length - 1);
+ if (nextIndex === currentIndex) {
+ return;
+ }
+
+ const nextFile = visibleFiles[nextIndex];
+ if (!nextFile) {
+ return;
+ }
+
+ selectFile(nextFile.id, 0, { alignFileHeaderTop: true });
+ },
+ [selectFile, selectedFile?.id, visibleFiles],
+ );
+
/** Clear the active file filter without touching the current selection. */
const clearFilter = useCallback(() => {
setFilter("");
@@ -504,6 +528,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
clearLiveComments,
moveToAnnotatedFile,
moveToAnnotatedHunk,
+ moveToFile,
moveToHunk,
navigateToLocation,
removeLiveComment,