Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -548,6 +549,7 @@ export function App({
focusArea,
focusFilter,
moveToAnnotatedHunk,
moveToFile,
moveToHunk: review.moveToHunk,
moveMenuItem,
openMenu,
Expand Down
74 changes: 74 additions & 0 deletions src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<AppHost bootstrap={createTwoFileHunkBootstrap()} hostClient={hostClient} />,
{
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(
<AppHost bootstrap={createCrossFileHunkNavigationBootstrap()} />,
Expand Down
1 change: 1 addition & 0 deletions src/ui/components/chrome/HelpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
12 changes: 12 additions & 0 deletions src/ui/hooks/useAppKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,6 +61,7 @@ export function useAppKeyboardShortcuts({
focusArea,
focusFilter,
moveToAnnotatedHunk,
moveToFile,
moveToHunk,
moveMenuItem,
openMenu,
Expand Down Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions src/ui/hooks/useReviewController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ReviewControllerHarness
initialFiles={[
createTwoHunkFile(),
createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const beta = 2;\n"),
createDiffFile(
"gamma",
"gamma.ts",
"export const gamma = 1;\n",
"export const gamma = 2;\n",
),
]}
onController={(nextController) => {
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(
Expand Down
25 changes: 25 additions & 0 deletions src/ui/hooks/useReviewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface ReviewController {
liveCommentsByFileId: Record<string, LiveComment[]>;
moveToAnnotatedFile: (delta: number) => void;
moveToAnnotatedHunk: (delta: number) => void;
moveToFile: (delta: number) => void;
moveToHunk: (delta: number) => void;
scrollToNote: boolean;
selectedFile: DiffFile | undefined;
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -504,6 +528,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
clearLiveComments,
moveToAnnotatedFile,
moveToAnnotatedHunk,
moveToFile,
moveToHunk,
navigateToLocation,
removeLiveComment,
Expand Down