From ebb35ec3e0d5fbc651a33922a23e97d23980c50d Mon Sep 17 00:00:00 2001 From: Ricardo-Ceia Date: Mon, 11 May 2026 13:59:07 +0100 Subject: [PATCH 1/4] 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 89505edf..ef485edc 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 63502a0f040be69de2f8eb6c375e24ea1e4a9459 Mon Sep 17 00:00:00 2001 From: Ricardo-Ceia Date: Tue, 12 May 2026 17:44:56 +0100 Subject: [PATCH 2/4] 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) ); } From 0dc4e2ecadcd34e591b1c6ddab5da57ee57a540f Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 14 May 2026 14:46:28 -0400 Subject: [PATCH 3/4] fix(ui): use less-style g navigation shortcut --- CHANGELOG.md | 2 +- src/ui/AppHost.interactions.test.tsx | 26 ++++++----------------- src/ui/components/chrome/HelpDialog.tsx | 2 +- src/ui/components/ui-components.test.tsx | 2 +- src/ui/hooks/useAppKeyboardShortcuts.ts | 27 ++++-------------------- 5 files changed, 13 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef485edc..6f0054c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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. +- Added `g` and `G` keyboard aliases for jump-to-top and jump-to-bottom review navigation. ### Changed diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index c471c106..79cd740c 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -1690,7 +1690,7 @@ describe("App interactions", () => { } }); - test("G jumps to the bottom and gg jumps back to the top", async () => { + test("G jumps to the bottom and g jumps back to the top", async () => { const before = Array.from( { length: 120 }, @@ -1711,10 +1711,10 @@ describe("App interactions", () => { }, }, changeset: { - id: "changeset:gg-capital-g", + id: "changeset:g-capital-g", sourceLabel: "repo", title: "repo working tree", - files: [createTestDiffFile("gg", "gg.ts", before, after)], + files: [createTestDiffFile("g", "g.ts", before, after)], }, initialMode: "split", initialTheme: "midnight", @@ -1738,13 +1738,6 @@ describe("App interactions", () => { 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"); }); @@ -1758,7 +1751,7 @@ describe("App interactions", () => { } }); - test("pager mode also supports G and gg top/bottom jumps", async () => { + test("pager mode also supports G and g top/bottom jumps", async () => { const before = Array.from( { length: 120 }, @@ -1780,10 +1773,10 @@ describe("App interactions", () => { }, }, changeset: { - id: "changeset:pager-gg-capital-g", + id: "changeset:pager-g-capital-g", sourceLabel: "repo", title: "repo working tree", - files: [createTestDiffFile("pager-gg", "pager-gg.ts", before, after)], + files: [createTestDiffFile("pager-g", "pager-g.ts", before, after)], }, initialMode: "split", initialTheme: "midnight", @@ -1807,13 +1800,6 @@ describe("App interactions", () => { 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"); }); diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 402cabd5..9c8d28a5 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -29,7 +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)"], + ["g / G", "jump to top / bottom (less-style)"], ], }, { diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 2821b309..5427c2bb 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1608,7 +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)", + "g / G jump to top / bottom (less-style)", "Mouse", "Wheel scroll vertically", "Shift+Wheel scroll code horizontally", diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index 45d8fee0..77d02691 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -19,8 +19,9 @@ type ScrollUnit = "step" | "viewport" | "content" | "half"; const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8; -type JumpShortcut = "top" | "bottom" | "pending"; +type JumpShortcut = "top" | "bottom"; +/** Detect an unmodified lowercase g keypress. */ function isLowercaseGKey(key: KeyEvent) { return ( (key.name === "g" || key.sequence === "g") && @@ -31,6 +32,7 @@ function isLowercaseGKey(key: KeyEvent) { ); } +/** Detect an unmodified uppercase G keypress. */ function isUppercaseGKey(key: KeyEvent) { return ( (key.sequence === "G" && !key.option && !key.ctrl && !key.meta) || @@ -101,7 +103,6 @@ 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; @@ -111,21 +112,13 @@ export function useAppKeyboardShortcuts({ 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"; + return "top"; } - pendingTopJumpRef.current = false; return null; }; @@ -164,10 +157,6 @@ export function useAppKeyboardShortcuts({ return; } - if (jumpShortcut === "pending") { - return; - } - if (key.name === "q" || isEscapeKey(key)) { requestQuit(); return; @@ -306,10 +295,6 @@ export function useAppKeyboardShortcuts({ return; } - if (jumpShortcut === "pending") { - return; - } - if (key.name === "q") { requestQuit(); return; @@ -458,7 +443,6 @@ export function useAppKeyboardShortcuts({ useKeyboard((key: KeyEvent) => { if (handleMenuToggleShortcut(key)) { - pendingTopJumpRef.current = false; return; } @@ -468,17 +452,14 @@ 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 b79df4a5bcbcc03cb1d08c3523bb8d8dc3d75974 Mon Sep 17 00:00:00 2001 From: Ricardo-Ceia Date: Thu, 14 May 2026 19:53:05 +0100 Subject: [PATCH 4/4] test(ui): send real page key sequences --- src/ui/AppHost.interactions.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 79cd740c..8a71fc6a 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -18,6 +18,9 @@ import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpe const { loadAppBootstrap } = await import("../core/loaders"); const { AppHost } = await import("./AppHost"); +const TEST_KEY_PAGE_UP = "\x1B[5~"; +const TEST_KEY_PAGE_DOWN = "\x1B[6~"; + function createTestDiffFile( id: string, path: string, @@ -1604,7 +1607,7 @@ describe("App interactions", () => { setup.captureCharFrame(); await act(async () => { - await setup.mockInput.pressKey("pageup"); + await setup.mockInput.pressKey(TEST_KEY_PAGE_UP); }); await flush(setup); @@ -2294,7 +2297,7 @@ describe("App interactions", () => { let snapshot = getLatestSnapshot(); for (let index = 0; index < 8; index += 1) { await act(async () => { - await setup.mockInput.pressKey("pagedown"); + await setup.mockInput.pressKey(TEST_KEY_PAGE_DOWN); }); await flush(setup); @@ -2318,7 +2321,7 @@ describe("App interactions", () => { for (let index = 0; index < 8; index += 1) { await act(async () => { - await setup.mockInput.pressKey("pageup"); + await setup.mockInput.pressKey(TEST_KEY_PAGE_UP); }); await flush(setup);