Skip to content

Commit c672d7d

Browse files
authored
Merge pull request #71 from fulll/feat/tui-fast-navigation
feat: fast navigation — gg/G (top/bottom) and Page Up/Down
2 parents 68bad64 + 54460e9 commit c672d7d

4 files changed

Lines changed: 107 additions & 9 deletions

File tree

docs/reference/keyboard-shortcuts.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ All shortcuts are active in the interactive TUI. Keys are **case-sensitive** —
44

55
## Navigation
66

7-
| Key | Action |
8-
| --------- | ------------------------------------------------------------------------------------------ |
9-
| `` / `k` | Move cursor up (repos and extracts) |
10-
| `` / `j` | Move cursor down (repos and extracts) |
11-
| `` | Fold the repo under the cursor |
12-
| `` | Unfold the repo under the cursor |
13-
| `Z` | **Global fold / unfold** — fold all repos if any is unfolded; unfold all if all are folded |
7+
| Key | Action |
8+
| ---------------------- | ------------------------------------------------------------------------------------------ |
9+
| `` / `k` | Move cursor up (repos and extracts) |
10+
| `` / `j` | Move cursor down (repos and extracts) |
11+
| `` | Fold the repo under the cursor |
12+
| `` | Unfold the repo under the cursor |
13+
| `Z` | **Global fold / unfold** — fold all repos if any is unfolded; unfold all if all are folded |
14+
| `gg` | Jump to the **top** (first result) |
15+
| `G` | Jump to the **bottom** (last result) |
16+
| `Page Up` / `Ctrl+U` | Scroll up one full page |
17+
| `Page Down` / `Ctrl+D` | Scroll down one full page |
1418

1519
Section header rows (shown when `--group-by-team-prefix` is active) are skipped automatically during navigation.
1620

src/render.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,24 @@ describe("renderHelpOverlay", () => {
915915
expect(stripped).toContain("open in browser");
916916
});
917917

918+
it("documents gg/G fast navigation shortcuts", () => {
919+
const out = renderHelpOverlay();
920+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
921+
expect(stripped).toContain("gg");
922+
expect(stripped).toContain("jump to top");
923+
expect(stripped).toContain("G");
924+
expect(stripped).toContain("jump to bottom");
925+
});
926+
927+
it("documents Page Up/Down fast navigation shortcuts", () => {
928+
const out = renderHelpOverlay();
929+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
930+
expect(stripped).toContain("PgUp");
931+
expect(stripped).toContain("PgDn");
932+
expect(stripped).toContain("page up");
933+
expect(stripped).toContain("page down");
934+
});
935+
918936
it("is returned by renderGroups when showHelp=true", () => {
919937
const groups = [makeGroup("org/repo", ["a.ts"])];
920938
const rows = buildRows(groups);
@@ -981,13 +999,15 @@ describe("renderGroups filter opts", () => {
981999
expect(stripped).not.toContain("Filter:");
9821000
});
9831001

984-
it("status bar hint line includes Z fold-all and o open shortcuts", () => {
1002+
it("status bar hint line includes all navigation hint shortcuts", () => {
9851003
const groups = [makeGroup("org/repo", ["a.ts"])];
9861004
const rows = buildRows(groups);
9871005
const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", {});
9881006
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
9891007
expect(stripped).toContain("Z fold-all");
9901008
expect(stripped).toContain("o open");
1009+
expect(stripped).toContain("gg/G top/bot");
1010+
expect(stripped).toContain("PgUp/Dn page");
9911011
});
9921012

9931013
it("shows mode badge [content] when filterTarget=content", () => {

src/render.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export function renderHelpOverlay(): string {
3434
` ${pc.yellow("↑")} / ${pc.yellow("k")} navigate up ${pc.yellow("↓")} / ${pc.yellow("j")} navigate down`,
3535
` ${pc.yellow("←")} fold repo ${pc.yellow("→")} unfold repo`,
3636
` ${pc.yellow("Z")} fold / unfold all repos`,
37+
` ${pc.yellow("gg")} jump to top ${pc.yellow("G")} jump to bottom`,
38+
` ${pc.yellow("PgUp")} / ${pc.yellow("Ctrl+U")} page up ${pc.yellow("PgDn")} / ${pc.yellow("Ctrl+D")} page down`,
3739
` ${pc.yellow("Space")} toggle selection ${pc.yellow("Enter")} confirm & output`,
3840
` ${pc.yellow("a")} select all ${pc.yellow("n")} select none`,
3941
` ${pc.dim("(respects active filter)")}`,
@@ -299,7 +301,7 @@ export function renderGroups(
299301

300302
lines.push(
301303
pc.dim(
302-
"← / → fold/unfold Z fold-all ↑ / ↓ navigate spc select a all n none o open f filter t target h help ↵ confirm q quit\n",
304+
"← / → fold/unfold Z fold-all ↑ / ↓ navigate gg/G top/bot PgUp/Dn page spc select a all n none o open f filter t target h help ↵ confirm q quit\n",
303305
),
304306
);
305307

src/tui.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const KEY_ALT_B = "\x1bb";
3838
const KEY_ALT_F = "\x1bf";
3939
const KEY_DELETE = "\x1b[3~";
4040
const KEY_SHIFT_TAB = "\x1b[Z"; // Shift+Tab — cycle filter target in filter mode
41+
const KEY_PAGE_UP = "\x1b[5~"; // Page Up — scroll up one page
42+
const KEY_PAGE_DOWN = "\x1b[6~"; // Page Down — scroll down one page
43+
const KEY_CTRL_U = "\x15"; // Ctrl+U — page up (Vim-style)
44+
const KEY_CTRL_D = "\x04"; // Ctrl+D — page down (Vim-style)
4145

4246
// ─── Word-boundary helpers ────────────────────────────────────────────────────
4347

@@ -141,6 +145,8 @@ export async function runInteractive(
141145
let filterLiveStats: FilterStats | null = null;
142146
let statsDebounceTimer: ReturnType<typeof setTimeout> | null = null;
143147
let showHelp = false;
148+
// Track first 'g' keypress so that a second consecutive 'g' jumps to the top.
149+
let pendingFirstG = false;
144150

145151
/** Schedule a debounced stats recompute (while typing in filter bar). */
146152
const scheduleStatsUpdate = () => {
@@ -176,6 +182,12 @@ export async function runInteractive(
176182
for await (const chunk of process.stdin) {
177183
const key = chunk.toString();
178184

185+
// Reset the gg pending state on every key that isn't a sequence of one
186+
// or more plain "g" characters. This allows terminals that batch key
187+
// repeats (e.g. delivering "gg" in a single chunk) to still participate
188+
// in the gg shortcut without interfering with any other shortcut.
189+
if (!/^g+$/.test(key)) pendingFirstG = false;
190+
179191
// ── Filter input mode ────────────────────────────────────────────────────
180192
if (filterMode) {
181193
if (key === KEY_CTRL_C) {
@@ -441,6 +453,66 @@ export async function runInteractive(
441453
}
442454
}
443455

456+
// `gg` — jump to top (first non-section row).
457+
// Handles both two consecutive single-g chunks and a single "gg" chunk
458+
// (terminals that batch repeated keypresses into one read() call).
459+
if (/^g+$/.test(key)) {
460+
if (pendingFirstG || key.length >= 2) {
461+
// Second g (or a multi-g chunk) — jump to the first non-section row
462+
cursor = 0;
463+
while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++;
464+
scrollOffset = 0;
465+
pendingFirstG = false;
466+
} else {
467+
pendingFirstG = true;
468+
}
469+
redraw();
470+
continue;
471+
}
472+
473+
// `G` — jump to last row (bottom)
474+
if (key === "G") {
475+
if (rows.length === 0) {
476+
// No rows to jump to; avoid putting cursor into an invalid state
477+
pendingFirstG = false;
478+
continue;
479+
}
480+
cursor = rows.length - 1;
481+
while (cursor > 0 && rows[cursor]?.type === "section") cursor--;
482+
while (
483+
scrollOffset < cursor &&
484+
!isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight())
485+
) {
486+
scrollOffset++;
487+
}
488+
}
489+
490+
// Page Up / Ctrl+U — scroll up by a full page
491+
if (key === KEY_PAGE_UP || key === KEY_CTRL_U) {
492+
const pageSize = Math.max(1, getViewportHeight());
493+
cursor = Math.max(0, cursor - pageSize);
494+
while (cursor > 0 && rows[cursor]?.type === "section") cursor--;
495+
// If we've paged up to the top and the first row is a section,
496+
// advance to the first non-section row (mirror `gg` behavior).
497+
if (cursor === 0 && rows[0]?.type === "section") {
498+
while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++;
499+
}
500+
if (cursor < scrollOffset) scrollOffset = cursor;
501+
}
502+
503+
// Page Down / Ctrl+D — scroll down by a full page
504+
if (key === KEY_PAGE_DOWN || key === KEY_CTRL_D) {
505+
const pageSize = Math.max(1, getViewportHeight());
506+
cursor = Math.min(rows.length - 1, cursor + pageSize);
507+
while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++;
508+
while (
509+
scrollOffset < cursor &&
510+
!isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight())
511+
) {
512+
scrollOffset++;
513+
}
514+
}
515+
444516
if (key === " " && row && row.type !== "section") {
445517
if (row.type === "repo") {
446518
const group = groups[row.repoIndex];

0 commit comments

Comments
 (0)