Skip to content
Closed
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added `,` and `.` shortcuts for previous and next file navigation in the review stream.

### Changed

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ export function App({
layoutMode,
moveToAnnotatedFile,
moveToAnnotatedHunk,
moveToFile: review.moveToFile,
moveToHunk: review.moveToHunk,
refreshCurrentInput: triggerRefreshCurrentInput,
requestQuit,
Expand All @@ -504,6 +505,7 @@ export function App({
layoutMode,
moveToAnnotatedFile,
moveToAnnotatedHunk,
review.moveToFile,
requestQuit,
review.moveToHunk,
selectLayoutMode,
Expand Down Expand Up @@ -550,6 +552,7 @@ export function App({
focusArea,
focusFilter,
moveToAnnotatedHunk,
moveToFile: review.moveToFile,
moveToHunk: review.moveToHunk,
moveMenuItem,
openMenu,
Expand Down
43 changes: 43 additions & 0 deletions src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2033,6 +2033,49 @@ describe("App interactions", () => {
}
});

test("file navigation shortcuts jump between file headers", async () => {
const setup = await testRender(<AppHost bootstrap={createTwoFileHunkBootstrap()} />, {
width: 220,
height: 10,
});

try {
await flush(setup);

await act(async () => {
await setup.mockInput.typeText(".");
});
await flush(setup);

let frame = await waitForFrame(
setup,
(nextFrame) =>
nextFrame.includes("second.ts") && (nextFrame.match(/first\.ts/g) ?? []).length === 1,
24,
);
expect(frame).toContain("second.ts");
expect((frame.match(/first\.ts/g) ?? []).length).toBe(1);

await act(async () => {
await setup.mockInput.typeText(",");
});
await flush(setup);

frame = await waitForFrame(
setup,
(nextFrame) =>
nextFrame.includes("first.ts") && (nextFrame.match(/second\.ts/g) ?? []).length === 1,
24,
);
expect(frame).toContain("first.ts");
expect((frame.match(/second\.ts/g) ?? []).length).toBe(1);
} 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
1 change: 1 addition & 0 deletions src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,7 @@ describe("UI components", () => {
"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
66 changes: 66 additions & 0 deletions src/ui/hooks/useReviewController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,72 @@ describe("useReviewController", () => {
}
});

test("moves through files using the current visible review order", async () => {
const controllerRef: { current: ReviewController | null } = { current: null };
const setup = await testRender(
<ReviewControllerHarness
initialFiles={[
createDiffFile(
"alpha",
"alpha.ts",
"export const alpha = 1;\n",
"export const alpha = 2;\n",
),
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);
expect(expectValue(controllerRef.current).selectedFile?.path).toBe("alpha.ts");

await act(async () => {
expectValue(controllerRef.current).moveToFile(1);
});
await flush(setup);
expect(expectValue(controllerRef.current).selectedFile?.path).toBe("beta.ts");

await act(async () => {
expectValue(controllerRef.current).moveToFile(1);
});
await flush(setup);
expect(expectValue(controllerRef.current).selectedFile?.path).toBe("gamma.ts");

await act(async () => {
expectValue(controllerRef.current).moveToFile(-1);
});
await flush(setup);
expect(expectValue(controllerRef.current).selectedFile?.path).toBe("beta.ts");

await act(async () => {
expectValue(controllerRef.current).setFilter("gamma");
});
await flush(setup);
expect(expectValue(controllerRef.current).selectedFile?.path).toBe("gamma.ts");

await act(async () => {
expectValue(controllerRef.current).moveToFile(-1);
});
await flush(setup);
expect(expectValue(controllerRef.current).selectedFile?.path).toBe("gamma.ts");
} 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
16 changes: 16 additions & 0 deletions src/ui/hooks/useReviewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
RemovedCommentResult,
SessionLiveCommentSummary,
} from "../../hunk-session/types";
import { findNextFile } from "../lib/files";
import { findNextHunkCursor } from "../lib/hunks";
import {
buildReviewState,
Expand Down Expand Up @@ -60,6 +61,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 +249,19 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
[selectFile, selectedFile?.id, visibleFiles],
);

/** Move through the currently visible files in review stream order. */
const moveToFile = useCallback(
(delta: number) => {
const nextFile = findNextFile(visibleFiles, selectedFile?.id, delta);
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 +519,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
clearLiveComments,
moveToAnnotatedFile,
moveToAnnotatedHunk,
moveToFile,
moveToHunk,
navigateToLocation,
removeLiveComment,
Expand Down
15 changes: 15 additions & 0 deletions src/ui/lib/appMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface BuildAppMenusOptions {
layoutMode: LayoutMode;
moveToAnnotatedFile: (delta: number) => void;
moveToAnnotatedHunk: (delta: number) => void;
moveToFile: (delta: number) => void;
moveToHunk: (delta: number) => void;
refreshCurrentInput: () => void;
requestQuit: () => void;
Expand Down Expand Up @@ -37,6 +38,7 @@ export function buildAppMenus({
layoutMode,
moveToAnnotatedFile,
moveToAnnotatedHunk,
moveToFile,
moveToHunk,
refreshCurrentInput,
requestQuit,
Expand Down Expand Up @@ -173,6 +175,19 @@ export function buildAppMenus({
action: () => moveToHunk(1),
},
{ kind: "separator" },
{
kind: "item",
label: "Previous file",
hint: ",",
action: () => moveToFile(-1),
},
{
kind: "item",
label: "Next file",
hint: ".",
action: () => moveToFile(1),
},
{ kind: "separator" },
{
kind: "item",
label: "Previous comment",
Expand Down
33 changes: 32 additions & 1 deletion src/ui/lib/files.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers";
import { buildSidebarEntries, fileLabelParts } from "./files";
import { buildSidebarEntries, fileLabelParts, findNextFile } from "./files";

describe("files helpers", () => {
test("buildSidebarEntries hides zero-value sidebar stats", () => {
Expand Down Expand Up @@ -134,4 +134,35 @@ describe("files helpers", () => {
stateLabel: null,
});
});

test("findNextFile follows visible file order and clamps at stream edges", () => {
const files = [
createTestDiffFile({
id: "alpha",
path: "alpha.ts",
before: lines("export const alpha = 1;"),
after: lines("export const alpha = 2;"),
}),
createTestDiffFile({
id: "beta",
path: "beta.ts",
before: lines("export const beta = 1;"),
after: lines("export const beta = 2;"),
}),
createTestDiffFile({
id: "gamma",
path: "gamma.ts",
before: lines("export const gamma = 1;"),
after: lines("export const gamma = 2;"),
}),
];

expect(findNextFile(files, "alpha", 1)?.id).toBe("beta");
expect(findNextFile(files, "beta", -1)?.id).toBe("alpha");
expect(findNextFile(files, "alpha", -1)?.id).toBe("alpha");
expect(findNextFile(files, "gamma", 1)?.id).toBe("gamma");
expect(findNextFile(files, undefined, 1)?.id).toBe("alpha");
expect(findNextFile(files, undefined, -1)?.id).toBe("gamma");
expect(findNextFile([], "alpha", 1)).toBeNull();
});
});
17 changes: 17 additions & 0 deletions src/ui/lib/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ export function filterReviewFiles(files: DiffFile[], query: string): DiffFile[]
});
}

/** Move forward or backward through the visible file order, clamping at stream edges. */
export function findNextFile(files: DiffFile[], currentFileId: string | undefined, delta: number) {
if (files.length === 0) {
return null;
}

const currentIndex = files.findIndex((file) => file.id === currentFileId);
const nextIndex =
currentIndex >= 0
? Math.min(Math.max(currentIndex + delta, 0), files.length - 1)
: delta >= 0
? 0
: files.length - 1;

return files[nextIndex] ?? null;
}

/** Build the grouped sidebar entries while preserving the review stream order. */
export function buildSidebarEntries(files: DiffFile[]): SidebarEntry[] {
const entries: SidebarEntry[] = [];
Expand Down
14 changes: 14 additions & 0 deletions src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ describe("ui helpers", () => {
layoutMode: "stack",
moveToAnnotatedFile: () => {},
moveToAnnotatedHunk: () => {},
moveToFile: () => {},
moveToHunk: () => {},
refreshCurrentInput: () => {},
requestQuit: () => {},
Expand Down Expand Up @@ -170,6 +171,19 @@ describe("ui helpers", () => {
.filter((entry): entry is Extract<MenuEntry, { kind: "item" }> => entry.kind === "item")
.map((entry) => entry.label),
).toEqual(["Graphite", "Midnight", "Paper", "Ember"]);
expect(
menus.navigate
.filter((entry): entry is Extract<MenuEntry, { kind: "item" }> => entry.kind === "item")
.map((entry) => [entry.label, entry.hint]),
).toEqual([
["Previous hunk", "["],
["Next hunk", "]"],
["Previous file", ","],
["Next file", "."],
["Previous comment", "{"],
["Next comment", "}"],
["Focus filter", "/"],
]);
expect(
menus.theme.some(
(entry) => entry.kind === "item" && entry.label === "Graphite" && entry.checked,
Expand Down
Loading