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

### Changed

### Fixed
Expand Down
132 changes: 129 additions & 3 deletions src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -1690,6 +1693,129 @@ describe("App interactions", () => {
}
});

test("G jumps to the bottom and g 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:g-capital-g",
sourceLabel: "repo",
title: "repo working tree",
files: [createTestDiffFile("g", "g.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("line01 = 1001");
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("pager mode also supports G and g 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-g-capital-g",
sourceLabel: "repo",
title: "repo working tree",
files: [createTestDiffFile("pager-g", "pager-g.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("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,
Expand Down Expand Up @@ -2171,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);

Expand All @@ -2195,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);

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"],
["g / G", "jump to top / bottom (less-style)"],
],
},
{
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",
"g / G jump to top / bottom (less-style)",
"Mouse",
"Wheel scroll vertically",
"Shift+Wheel scroll code horizontally",
Expand Down
55 changes: 55 additions & 0 deletions src/ui/hooks/useAppKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,27 @@ type ScrollUnit = "step" | "viewport" | "content" | "half";

const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8;

type JumpShortcut = "top" | "bottom";

/** Detect an unmodified lowercase g keypress. */
function isLowercaseGKey(key: KeyEvent) {
return (
(key.name === "g" || key.sequence === "g") &&
!key.shift &&
!key.option &&
!key.ctrl &&
!key.meta
);
}

/** Detect an unmodified uppercase G keypress. */
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 @@ -89,6 +110,18 @@ export function useAppKeyboardShortcuts({
pagerModeRef.current = pagerMode;
showHelpRef.current = showHelp;

const resolveJumpShortcut = (key: KeyEvent): JumpShortcut | null => {
if (isUppercaseGKey(key)) {
return "bottom";
}

if (isLowercaseGKey(key)) {
return "top";
}

return null;
};

const runAndCloseMenu = (action: () => void) => {
action();
closeMenu();
Expand All @@ -113,6 +146,17 @@ 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 (key.name === "q" || isEscapeKey(key)) {
requestQuit();
return;
Expand Down Expand Up @@ -240,6 +284,17 @@ 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 (key.name === "q") {
requestQuit();
return;
Expand Down
Loading