From b72f215b339472fb7620b3af9b1680c0148f629e Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 10 May 2026 15:59:35 -0700 Subject: [PATCH] feat(ui): add , and . shortcuts to move between files --- src/ui/App.tsx | 2 + src/ui/AppHost.interactions.test.tsx | 74 ++++++++++++++++++ src/ui/components/chrome/HelpDialog.tsx | 1 + src/ui/hooks/useAppKeyboardShortcuts.ts | 12 +++ src/ui/hooks/useReviewController.test.tsx | 91 +++++++++++++++++++++++ src/ui/hooks/useReviewController.ts | 25 +++++++ 6 files changed, 205 insertions(+) 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,