diff --git a/CHANGELOG.md b/CHANGELOG.md
index 712d8b44..c9c81d9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.
### Added
+- Added `gg` and `G` keyboard aliases for jump-to-top and jump-to-bottom review navigation.
+
### Changed
### Fixed
diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx
index 82dc9e45..c471c106 100644
--- a/src/ui/AppHost.interactions.test.tsx
+++ b/src/ui/AppHost.interactions.test.tsx
@@ -1690,6 +1690,143 @@ describe("App interactions", () => {
}
});
+ test("G jumps to the bottom and gg jumps back to the top", async () => {
+ const before =
+ Array.from(
+ { length: 120 },
+ (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`,
+ ).join("\n") + "\n";
+ const after =
+ Array.from(
+ { length: 120 },
+ (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1001};`,
+ ).join("\n") + "\n";
+
+ const bootstrap: AppBootstrap = {
+ input: {
+ kind: "vcs",
+ staged: false,
+ options: {
+ mode: "split",
+ },
+ },
+ changeset: {
+ id: "changeset:gg-capital-g",
+ sourceLabel: "repo",
+ title: "repo working tree",
+ files: [createTestDiffFile("gg", "gg.ts", before, after)],
+ },
+ initialMode: "split",
+ initialTheme: "midnight",
+ };
+
+ const setup = await testRender(, {
+ width: 220,
+ height: 12,
+ otherModifiersMode: true,
+ });
+
+ try {
+ await flush(setup);
+ let frame = setup.captureCharFrame();
+ expect(frame).toContain("line01 = 1001");
+
+ await act(async () => {
+ await setup.mockInput.pressKey("g", { shift: true });
+ });
+ await flush(setup);
+ frame = setup.captureCharFrame();
+ expect(frame).toContain("line120 = 1120");
+
+ await act(async () => {
+ await setup.mockInput.pressKey("g");
+ });
+ await flush(setup);
+ frame = setup.captureCharFrame();
+ expect(frame).toContain("line120 = 1120");
+
+ await act(async () => {
+ await setup.mockInput.pressKey("g");
+ });
+ await flush(setup);
+ frame = setup.captureCharFrame();
+ expect(frame).toContain("line01 = 1001");
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
+ test("pager mode also supports G and gg top/bottom jumps", async () => {
+ const before =
+ Array.from(
+ { length: 120 },
+ (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`,
+ ).join("\n") + "\n";
+ const after =
+ Array.from(
+ { length: 120 },
+ (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1001};`,
+ ).join("\n") + "\n";
+
+ const bootstrap: AppBootstrap = {
+ input: {
+ kind: "vcs",
+ staged: false,
+ options: {
+ mode: "split",
+ pager: true,
+ },
+ },
+ changeset: {
+ id: "changeset:pager-gg-capital-g",
+ sourceLabel: "repo",
+ title: "repo working tree",
+ files: [createTestDiffFile("pager-gg", "pager-gg.ts", before, after)],
+ },
+ initialMode: "split",
+ initialTheme: "midnight",
+ };
+
+ const setup = await testRender(, {
+ width: 220,
+ height: 12,
+ otherModifiersMode: true,
+ });
+
+ try {
+ await flush(setup);
+ let frame = setup.captureCharFrame();
+ expect(frame).toContain("line01 = 1001");
+
+ await act(async () => {
+ await setup.mockInput.pressKey("g", { shift: true });
+ });
+ await flush(setup);
+ frame = setup.captureCharFrame();
+ expect(frame).toContain("line120 = 1120");
+
+ await act(async () => {
+ await setup.mockInput.pressKey("g");
+ });
+ await flush(setup);
+ frame = setup.captureCharFrame();
+ expect(frame).toContain("line120 = 1120");
+
+ await act(async () => {
+ await setup.mockInput.pressKey("g");
+ });
+ await flush(setup);
+ frame = setup.captureCharFrame();
+ expect(frame).toContain("line01 = 1001");
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
test("filter focus accepts typed input and narrows the visible file set", async () => {
const setup = await testRender(, {
width: 240,
diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx
index 1ed1f595..402cabd5 100644
--- a/src/ui/components/chrome/HelpDialog.tsx
+++ b/src/ui/components/chrome/HelpDialog.tsx
@@ -29,6 +29,7 @@ export function HelpDialog({
["{ / }", "previous / next comment"],
["← / →", "scroll code left / right (Shift = faster)"],
["Home / End", "jump to top / bottom"],
+ ["gg / G", "jump to top / bottom (Vim aliases)"],
],
},
{
diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx
index 668a3f15..2821b309 100644
--- a/src/ui/components/ui-components.test.tsx
+++ b/src/ui/components/ui-components.test.tsx
@@ -1608,6 +1608,7 @@ describe("UI components", () => {
"{ / } previous / next comment",
"← / → scroll code left / right (Shift = faster)",
"Home / End jump to top / bottom",
+ "gg / G jump to top / bottom (Vim aliases)",
"Mouse",
"Wheel scroll vertically",
"Shift+Wheel scroll code horizontally",
diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts
index f2349d9e..45d8fee0 100644
--- a/src/ui/hooks/useAppKeyboardShortcuts.ts
+++ b/src/ui/hooks/useAppKeyboardShortcuts.ts
@@ -19,6 +19,25 @@ type ScrollUnit = "step" | "viewport" | "content" | "half";
const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8;
+type JumpShortcut = "top" | "bottom" | "pending";
+
+function isLowercaseGKey(key: KeyEvent) {
+ return (
+ (key.name === "g" || key.sequence === "g") &&
+ !key.shift &&
+ !key.option &&
+ !key.ctrl &&
+ !key.meta
+ );
+}
+
+function isUppercaseGKey(key: KeyEvent) {
+ return (
+ (key.sequence === "G" && !key.option && !key.ctrl && !key.meta) ||
+ (key.name === "g" && key.shift && !key.option && !key.ctrl && !key.meta)
+ );
+}
+
export interface UseAppKeyboardShortcutsOptions {
activeMenuId: MenuId | null;
activateCurrentMenuItem: () => void;
@@ -82,6 +101,7 @@ export function useAppKeyboardShortcuts({
const activeMenuIdRef = useRef(activeMenuId);
const focusAreaRef = useRef(focusArea);
const pagerModeRef = useRef(pagerMode);
+ const pendingTopJumpRef = useRef(false);
const showHelpRef = useRef(showHelp);
activeMenuIdRef.current = activeMenuId;
@@ -89,6 +109,26 @@ export function useAppKeyboardShortcuts({
pagerModeRef.current = pagerMode;
showHelpRef.current = showHelp;
+ const resolveJumpShortcut = (key: KeyEvent): JumpShortcut | null => {
+ if (isUppercaseGKey(key)) {
+ pendingTopJumpRef.current = false;
+ return "bottom";
+ }
+
+ if (isLowercaseGKey(key)) {
+ if (pendingTopJumpRef.current) {
+ pendingTopJumpRef.current = false;
+ return "top";
+ }
+
+ pendingTopJumpRef.current = true;
+ return "pending";
+ }
+
+ pendingTopJumpRef.current = false;
+ return null;
+ };
+
const runAndCloseMenu = (action: () => void) => {
action();
closeMenu();
@@ -113,6 +153,21 @@ export function useAppKeyboardShortcuts({
};
const handlePagerShortcut = (key: KeyEvent) => {
+ const jumpShortcut = resolveJumpShortcut(key);
+ if (jumpShortcut === "top") {
+ scrollDiff(-1, "content");
+ return;
+ }
+
+ if (jumpShortcut === "bottom") {
+ scrollDiff(1, "content");
+ return;
+ }
+
+ if (jumpShortcut === "pending") {
+ return;
+ }
+
if (key.name === "q" || isEscapeKey(key)) {
requestQuit();
return;
@@ -240,6 +295,21 @@ export function useAppKeyboardShortcuts({
};
const handleAppShortcut = (key: KeyEvent) => {
+ const jumpShortcut = resolveJumpShortcut(key);
+ if (jumpShortcut === "top") {
+ scrollDiff(-1, "content");
+ return;
+ }
+
+ if (jumpShortcut === "bottom") {
+ scrollDiff(1, "content");
+ return;
+ }
+
+ if (jumpShortcut === "pending") {
+ return;
+ }
+
if (key.name === "q") {
requestQuit();
return;
@@ -388,6 +458,7 @@ export function useAppKeyboardShortcuts({
useKeyboard((key: KeyEvent) => {
if (handleMenuToggleShortcut(key)) {
+ pendingTopJumpRef.current = false;
return;
}
@@ -397,14 +468,17 @@ export function useAppKeyboardShortcuts({
}
if (handleHelpShortcut(key)) {
+ pendingTopJumpRef.current = false;
return;
}
if (handleMenuShortcut(key)) {
+ pendingTopJumpRef.current = false;
return;
}
if (handleFilterShortcut(key)) {
+ pendingTopJumpRef.current = false;
return;
}