From 3d47bb68cce63967164802135f41a630f662a865 Mon Sep 17 00:00:00 2001 From: Ricardo-Ceia Date: Mon, 11 May 2026 13:59:07 +0100 Subject: [PATCH 1/2] feat(ui): add gg and G navigation shortcuts --- CHANGELOG.md | 2 + src/ui/AppHost.interactions.test.tsx | 68 ++++++++++++++++++++++ src/ui/components/chrome/HelpDialog.tsx | 1 + src/ui/components/ui-components.test.tsx | 1 + src/ui/hooks/useAppKeyboardShortcuts.ts | 73 ++++++++++++++++++++++++ 5 files changed, 145 insertions(+) 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..7b2320e1 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -1690,6 +1690,74 @@ 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("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..1bad7cc9 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -19,6 +19,24 @@ 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.name === "g" && key.shift && !key.option && !key.ctrl && !key.meta) + ); +} + export interface UseAppKeyboardShortcutsOptions { activeMenuId: MenuId | null; activateCurrentMenuItem: () => void; @@ -82,6 +100,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 +108,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 +152,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 +294,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 +457,7 @@ export function useAppKeyboardShortcuts({ useKeyboard((key: KeyEvent) => { if (handleMenuToggleShortcut(key)) { + pendingTopJumpRef.current = false; return; } @@ -397,14 +467,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; } From 706eae367e093a7f408c8dec0d0a8260dd9c8217 Mon Sep 17 00:00:00 2001 From: Ricardo-Ceia Date: Tue, 12 May 2026 17:44:56 +0100 Subject: [PATCH 2/2] test(ui): cover pager-mode gg/G shortcuts --- src/ui/AppHost.interactions.test.tsx | 69 +++++++++++++++++++++++++ src/ui/hooks/useAppKeyboardShortcuts.ts | 3 +- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 7b2320e1..c471c106 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -1758,6 +1758,75 @@ describe("App interactions", () => { } }); + 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/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index 1bad7cc9..45d8fee0 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -33,7 +33,8 @@ function isLowercaseGKey(key: KeyEvent) { function isUppercaseGKey(key: KeyEvent) { return ( - key.sequence === "G" || (key.name === "g" && key.shift && !key.option && !key.ctrl && !key.meta) + (key.sequence === "G" && !key.option && !key.ctrl && !key.meta) || + (key.name === "g" && key.shift && !key.option && !key.ctrl && !key.meta) ); }