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 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 `gg` and `G` keyboard aliases for jump-to-top and jump-to-bottom review navigation.

### Changed

### Fixed
Expand Down
137 changes: 137 additions & 0 deletions src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AppHost bootstrap={bootstrap} />, {
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(<AppHost bootstrap={bootstrap} />, {
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(<AppHost bootstrap={createBootstrap()} />, {
width: 240,
Comment thread
SalzDevs marked this conversation as resolved.
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 @@ -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)"],
],
},
{
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 @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions src/ui/hooks/useAppKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
Comment thread
SalzDevs marked this conversation as resolved.

export interface UseAppKeyboardShortcutsOptions {
activeMenuId: MenuId | null;
activateCurrentMenuItem: () => void;
Expand Down Expand Up @@ -82,13 +101,34 @@ 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;
focusAreaRef.current = focusArea;
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();
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -388,6 +458,7 @@ export function useAppKeyboardShortcuts({

useKeyboard((key: KeyEvent) => {
if (handleMenuToggleShortcut(key)) {
pendingTopJumpRef.current = false;
return;
}

Expand All @@ -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;
}

Expand Down