diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cfea87..d8e0724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,13 @@ Release notes and changelogs are published on the **[project blog](https://fulll Each release entry covers the motivation, new features, breaking changes (if any), and upgrade notes. -| Version | Blog post | -| ------------------------------------------------------------------------ | ---------------------------------------------------------- | -| [v1.5.0](https://fulll.github.io/github-code-search/blog/release-v1-5-0) | Advanced filter targets, regex mode, word-jump, scroll fix | -| [v1.4.0](https://fulll.github.io/github-code-search/blog/release-v1-4-0) | TUI visual overhaul, community files, demo animation | -| [v1.3.0](https://fulll.github.io/github-code-search/blog/release-v1-3-0) | Team-prefix grouping, replay command, JSON output | -| [v1.0.0](https://fulll.github.io/github-code-search/blog/release-v1-0-0) | Initial release | +| Version | Blog post | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| [v1.6.0](https://fulll.github.io/github-code-search/blog/release-v1-6-0) | Power navigation: global fold/unfold, gg/G top/bottom, paged scroll, open-in-browser | +| [v1.5.0](https://fulll.github.io/github-code-search/blog/release-v1-5-0) | Advanced filter targets, regex mode, word-jump, scroll fix | +| [v1.4.0](https://fulll.github.io/github-code-search/blog/release-v1-4-0) | TUI visual overhaul, community files, demo animation | +| [v1.3.0](https://fulll.github.io/github-code-search/blog/release-v1-3-0) | Team-prefix grouping, replay command, JSON output | +| [v1.0.0](https://fulll.github.io/github-code-search/blog/release-v1-0-0) | Initial release | > For the full list of commits between releases, see the > [GitHub Releases page](https://github.com/fulll/github-code-search/releases). diff --git a/demo/demo.gif b/demo/demo.gif index 22d4607..e03a0af 100644 Binary files a/demo/demo.gif and b/demo/demo.gif differ diff --git a/demo/demo.tape b/demo/demo.tape index bce0904..cbef9c0 100644 --- a/demo/demo.tape +++ b/demo/demo.tape @@ -36,17 +36,15 @@ Enter # Wait for results to load (API call + TUI render) Sleep 4s -# ── Navigate down a few rows ────────────────────────────────────────────────── -Down -Sleep 300ms -Down -Sleep 300ms +# ── Fold all repos — Z gives a compact bird's-eye view ──────────────────────── +Type "Z" +Sleep 800ms -# ── Toggle selection off on current extract ─────────────────────────────────── -Space -Sleep 400ms +# ── Unfold all repos — Z again expands everything ───────────────────────────── +Type "Z" +Sleep 800ms -# ── Filter by file path (default target) ────────────────────────────────────── +# ── Filter by file path to focus on YAML files ──────────────────────────────── Type "f" Sleep 300ms Type ".yml" @@ -54,34 +52,25 @@ Sleep 600ms Enter Sleep 500ms -# ── Select all visible (filtered) rows ──────────────────────────────────────── +# ── Jump to top with gg ─────────────────────────────────────────────────────── +Type "g" +Sleep 150ms +Type "g" +Sleep 500ms + +# ── Select all visible (filtered) results ───────────────────────────────────── Type "a" Sleep 500ms -# ── Reset filter ────────────────────────────────────────────────────────────── -Type "r" +# ── Jump to bottom with G to inspect the last match ─────────────────────────── +Type "G" Sleep 600ms -# ── Switch to repo filter mode and filter by repo name ──────────────────────── -# Press t twice: path → content → repo -Type "t" -Sleep 300ms -Type "t" -Sleep 400ms - -# Enter filter mode and type a repo name fragment -Type "f" -Sleep 300ms -Type "toolkit" -Sleep 700ms -Enter -Sleep 500ms - -# Select all repos matching the filter -Type "a" -Sleep 500ms +# ── Open the focused file directly in the browser ───────────────────────────── +Type "o" +Sleep 600ms -# ── Reset and confirm ───────────────────────────────────────────────────────── +# ── Reset filter and confirm selection ──────────────────────────────────────── Type "r" Sleep 600ms Enter diff --git a/docs/blog/index.md b/docs/blog/index.md index ae09778..6e655d6 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -11,6 +11,7 @@ Full release notes and changelogs are always available on | Release | Highlights | | -------------------------- | ----------------------------------------------------------------------------------------------------- | +| [v1.6.0](./release-v1-6-0) | Power navigation: global fold/unfold (`Z`), Vim `gg`/`G` jumps, paged scroll, open-in-browser (`o`) | | [v1.5.0](./release-v1-5-0) | Advanced filter targets (content/path/repo), regex mode, word-jump shortcuts, scroll accuracy fix | | [v1.4.0](./release-v1-4-0) | TUI visual overhaul, violet branding, demo animation, SECURITY / Code of Conduct, README improvements | | [v1.3.0](./release-v1-3-0) | Richer upgrade output, update-available notice, colorized `--help`, deep doc links, What's New blog | diff --git a/docs/blog/release-v1-6-0.md b/docs/blog/release-v1-6-0.md new file mode 100644 index 0000000..30449aa --- /dev/null +++ b/docs/blog/release-v1-6-0.md @@ -0,0 +1,64 @@ +--- +title: "What's new in v1.6.0" +description: "Power navigation shortcuts: global fold/unfold, Vim-style gg/G top/bottom jumps, paged scrolling with Page Up/Down and Ctrl+U/D, and open-in-browser for any result." +date: 2026-03-02 +--- + +# What's new in github-code-search v1.6.0 + +> Full release notes: + +## Highlights + +### Global fold / unfold — `Z` + +Press **`Z`** to collapse every repository in one keystroke. Press `Z` again to expand them all at once. + +The logic is straightforward: + +- If at least one repo is currently expanded, `Z` folds everything. +- If every repo is already folded, `Z` unfolds everything. + +The cursor tracks the repository it was positioned on before the fold, so you never lose your place in the list. + +### Vim-style top / bottom navigation — `gg` and `G` + +| Shortcut | Action | +| -------- | ---------------------------- | +| `gg` | Jump to the **first** result | +| `G` | Jump to the **last** result | + +`gg` triggers on two consecutive `g` keypresses. Both shortcuts skip over section headers produced by `--group-by-team-prefix`, landing only on repo or extract rows. + +### Paged scrolling — `Page Up`/`Page Down` and `Ctrl+U`/`Ctrl+D` + +Scroll through large result sets without holding an arrow key: + +| Shortcut | Action | +| ---------------------- | -------------------- | +| `Page Up` / `Ctrl+U` | Scroll up one page | +| `Page Down` / `Ctrl+D` | Scroll down one page | + +Page size is computed from the actual terminal height, so the jump covers exactly what is visible on screen. + +### Open-in-browser — `o` + +Press **`o`** on any focused row to open the corresponding page directly in your default browser — no copy-paste required. + +| Row type | Opens | +| ----------- | -------------------------------- | +| Repo row | The GitHub repository page | +| Extract row | The exact file in the repository | + +Works on macOS (`open`), Linux (`xdg-open`), and Windows (`cmd /c start`). + +--- + +## Upgrade + +```bash +github-code-search upgrade +``` + +Or grab the latest binary directly from the +[GitHub Releases page](https://github.com/fulll/github-code-search/releases/tag/v1.6.0). diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 49377c5..bf4a8b0 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -1,15 +1,20 @@ # Keyboard shortcuts -All shortcuts are active in the interactive TUI. Keys are **case-sensitive** and must be typed in lowercase. +All shortcuts are active in the interactive TUI. Keys are **case-sensitive** — most use lowercase letters, but a few bindings (such as `Z` and `G`) require an uppercase letter. ## Navigation -| Key | Action | -| --------- | ------------------------------------- | -| `↑` / `k` | Move cursor up (repos and extracts) | -| `↓` / `j` | Move cursor down (repos and extracts) | -| `←` | Fold the repo under the cursor | -| `→` | Unfold the repo under the cursor | +| Key | Action | +| ---------------------- | ------------------------------------------------------------------------------------------ | +| `↑` / `k` | Move cursor up (repos and extracts) | +| `↓` / `j` | Move cursor down (repos and extracts) | +| `←` | Fold the repo under the cursor | +| `→` | Unfold the repo under the cursor | +| `Z` | **Global fold / unfold** — fold all repos if any is unfolded; unfold all if all are folded | +| `gg` | Jump to the **top** (first result) | +| `G` | Jump to the **bottom** (last result) | +| `Page Up` / `Ctrl+U` | Scroll up one full page | +| `Page Down` / `Ctrl+D` | Scroll down one full page | Section header rows (shown when `--group-by-team-prefix` is active) are skipped automatically during navigation. @@ -20,6 +25,7 @@ Section header rows (shown when `--group-by-team-prefix` is active) are skipped | `Space` | Toggle selection on the current repo or extract. On a repo row: cascades to all its extracts. | | `a` | Select **all**. On a repo row: selects all repos and their extracts. On an extract row: selects all extracts in the current repo. Respects active filters. | | `n` | Select **none**. Same context rules as `a`. Respects active filters. | +| `o` | **Open in browser** — opens the focused item in the default browser. On a repo row: opens the repository page. On an extract row: opens the file directly. | ## Filtering diff --git a/package.json b/package.json index 557897d..9e9b523 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-code-search", - "version": "1.5.0", + "version": "1.6.0", "description": "Interactive GitHub code search with per-repo aggregation", "keywords": [ "bun", diff --git a/src/render.test.ts b/src/render.test.ts index 3e33c48..6e020cd 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -902,6 +902,37 @@ describe("renderHelpOverlay", () => { expect(stripped).toContain("Filter mode:"); }); + it("documents the Z global fold/unfold shortcut", () => { + const out = renderHelpOverlay(); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("Z"); + expect(stripped).toContain("fold / unfold all repos"); + }); + + it("documents the o open-in-browser shortcut", () => { + const out = renderHelpOverlay(); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("open in browser"); + }); + + it("documents gg/G fast navigation shortcuts", () => { + const out = renderHelpOverlay(); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("gg"); + expect(stripped).toContain("jump to top"); + expect(stripped).toContain("G"); + expect(stripped).toContain("jump to bottom"); + }); + + it("documents Page Up/Down fast navigation shortcuts", () => { + const out = renderHelpOverlay(); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("PgUp"); + expect(stripped).toContain("PgDn"); + expect(stripped).toContain("page up"); + expect(stripped).toContain("page down"); + }); + it("is returned by renderGroups when showHelp=true", () => { const groups = [makeGroup("org/repo", ["a.ts"])]; const rows = buildRows(groups); @@ -968,6 +999,17 @@ describe("renderGroups filter opts", () => { expect(stripped).not.toContain("Filter:"); }); + it("status bar hint line includes all navigation hint shortcuts", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", {}); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("Z fold-all"); + expect(stripped).toContain("o open"); + expect(stripped).toContain("gg/G top/bot"); + expect(stripped).toContain("PgUp/Dn page"); + }); + it("shows mode badge [content] when filterTarget=content", () => { const groups = [makeGroup("org/repo", ["a.ts"], false, true)]; const rows = buildRows(groups, "code", "content"); diff --git a/src/render.ts b/src/render.ts index 0e96728..cf4b73f 100644 --- a/src/render.ts +++ b/src/render.ts @@ -33,9 +33,13 @@ export function renderHelpOverlay(): string { bar, ` ${pc.yellow("↑")} / ${pc.yellow("k")} navigate up ${pc.yellow("↓")} / ${pc.yellow("j")} navigate down`, ` ${pc.yellow("←")} fold repo ${pc.yellow("→")} unfold repo`, + ` ${pc.yellow("Z")} fold / unfold all repos`, + ` ${pc.yellow("gg")} jump to top ${pc.yellow("G")} jump to bottom`, + ` ${pc.yellow("PgUp")} / ${pc.yellow("Ctrl+U")} page up ${pc.yellow("PgDn")} / ${pc.yellow("Ctrl+D")} page down`, ` ${pc.yellow("Space")} toggle selection ${pc.yellow("Enter")} confirm & output`, ` ${pc.yellow("a")} select all ${pc.yellow("n")} select none`, ` ${pc.dim("(respects active filter)")}`, + ` ${pc.yellow("o")} open in browser ${pc.dim("(repo row → repo page, extract row → file)")}`, ` ${pc.yellow("f")} enter filter mode ${pc.yellow("r")} reset filter`, ` ${pc.yellow("t")} cycle filter target ${pc.dim("(path → content → repo)")}`, ` ${pc.yellow("h")} / ${pc.yellow("?")} toggle this help ${pc.yellow("q")} / Ctrl+C quit`, @@ -297,7 +301,7 @@ export function renderGroups( lines.push( pc.dim( - "← / → fold/unfold ↑ / ↓ navigate spc select a all n none f filter t target h help ↵ confirm q quit\n", + "← / → 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", ), ); diff --git a/src/tui.ts b/src/tui.ts index 8f269a7..b75f78e 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -38,6 +38,10 @@ const KEY_ALT_B = "\x1bb"; const KEY_ALT_F = "\x1bf"; const KEY_DELETE = "\x1b[3~"; const KEY_SHIFT_TAB = "\x1b[Z"; // Shift+Tab — cycle filter target in filter mode +const KEY_PAGE_UP = "\x1b[5~"; // Page Up — scroll up one page +const KEY_PAGE_DOWN = "\x1b[6~"; // Page Down — scroll down one page +const KEY_CTRL_U = "\x15"; // Ctrl+U — page up (Vim-style) +const KEY_CTRL_D = "\x04"; // Ctrl+D — page down (Vim-style) // ─── Word-boundary helpers ──────────────────────────────────────────────────── @@ -62,6 +66,35 @@ function nextWordBoundary(s: string, pos: number): number { return i; } +// ─── Browser helper ────────────────────────────────────────────────────────── + +/** + * Open a URL in the system default browser. + * macOS: `open`, Linux: `xdg-open`, Windows: `cmd /c start "" `. + * Fire-and-forget with all stdio set to null so the TUI remains fully responsive. + */ +function openInBrowser(url: string): void { + let command: string; + let args: string[]; + + if (process.platform === "darwin") { + command = "open"; + args = [url]; + } else if (process.platform === "win32") { + // `start` is a cmd.exe built-in, not a standalone executable. + // The empty string is the mandatory window-title argument; without it, + // `start` mis-parses the URL as the title and may fail to open it. + command = "cmd"; + args = ["/c", "start", "", url]; + } else { + command = "xdg-open"; + args = [url]; + } + + // Fire-and-forget: do not await, and set all stdio to null so the TUI stays responsive. + Bun.spawn([command, ...args], { stdout: null, stderr: null, stdin: null }); +} + // ─── Interactive TUI ───────────────────────────────────────────────────────── export async function runInteractive( @@ -112,6 +145,8 @@ export async function runInteractive( let filterLiveStats: FilterStats | null = null; let statsDebounceTimer: ReturnType | null = null; let showHelp = false; + // Track first 'g' keypress so that a second consecutive 'g' jumps to the top. + let pendingFirstG = false; /** Schedule a debounced stats recompute (while typing in filter bar). */ const scheduleStatsUpdate = () => { @@ -147,6 +182,12 @@ export async function runInteractive( for await (const chunk of process.stdin) { const key = chunk.toString(); + // Reset the gg pending state on every key that isn't a sequence of one + // or more plain "g" characters. This allows terminals that batch key + // repeats (e.g. delivering "gg" in a single chunk) to still participate + // in the gg shortcut without interfering with any other shortcut. + if (!/^g+$/.test(key)) pendingFirstG = false; + // ── Filter input mode ──────────────────────────────────────────────────── if (filterMode) { if (key === KEY_CTRL_C) { @@ -389,6 +430,89 @@ export async function runInteractive( } } + // `Z` — global fold / unfold: fold all if any repo is unfolded, else unfold all + if (key === "Z") { + const anyUnfolded = groups.some((g) => !g.folded); + for (const g of groups) { + g.folded = anyUnfolded; + } + // Adjust scroll so cursor stays aligned with the same repo after bulk fold. + // When folding, extract rows disappear: map the current row's repoIndex to + // its repo header row so the cursor does not jump to a different repository. + if (anyUnfolded) { + const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); + if (row && (row.type === "repo" || row.type === "extract")) { + const headerIdx = newRows.findIndex( + (r) => r.type === "repo" && r.repoIndex === row.repoIndex, + ); + cursor = headerIdx !== -1 ? headerIdx : Math.min(cursor, Math.max(0, newRows.length - 1)); + } else { + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + } + scrollOffset = Math.min(scrollOffset, cursor); + } + } + + // `gg` — jump to top (first non-section row). + // Handles both two consecutive single-g chunks and a single "gg" chunk + // (terminals that batch repeated keypresses into one read() call). + if (/^g+$/.test(key)) { + if (pendingFirstG || key.length >= 2) { + // Second g (or a multi-g chunk) — jump to the first non-section row + cursor = 0; + while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++; + scrollOffset = 0; + pendingFirstG = false; + } else { + pendingFirstG = true; + } + redraw(); + continue; + } + + // `G` — jump to last row (bottom) + if (key === "G") { + if (rows.length === 0) { + // No rows to jump to; avoid putting cursor into an invalid state + pendingFirstG = false; + continue; + } + cursor = rows.length - 1; + while (cursor > 0 && rows[cursor]?.type === "section") cursor--; + while ( + scrollOffset < cursor && + !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight()) + ) { + scrollOffset++; + } + } + + // Page Up / Ctrl+U — scroll up by a full page + if (key === KEY_PAGE_UP || key === KEY_CTRL_U) { + const pageSize = Math.max(1, getViewportHeight()); + cursor = Math.max(0, cursor - pageSize); + while (cursor > 0 && rows[cursor]?.type === "section") cursor--; + // If we've paged up to the top and the first row is a section, + // advance to the first non-section row (mirror `gg` behavior). + if (cursor === 0 && rows[0]?.type === "section") { + while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++; + } + if (cursor < scrollOffset) scrollOffset = cursor; + } + + // Page Down / Ctrl+D — scroll down by a full page + if (key === KEY_PAGE_DOWN || key === KEY_CTRL_D) { + const pageSize = Math.max(1, getViewportHeight()); + cursor = Math.min(rows.length - 1, cursor + pageSize); + while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++; + while ( + scrollOffset < cursor && + !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight()) + ) { + scrollOffset++; + } + } + if (key === " " && row && row.type !== "section") { if (row.type === "repo") { const group = groups[row.repoIndex]; @@ -412,6 +536,19 @@ export async function runInteractive( applySelectNone(groups, row, filterPath, filterTarget, filterRegex); } + // `o` — open focused result (or repo) in the default browser + if (key === "o" && row && row.type !== "section") { + let url: string; + if (row.type === "repo") { + // Open the repository page on GitHub + url = `https://github.com/${groups[row.repoIndex].repoFullName}`; + } else { + // Open the specific file at the matching line + url = groups[row.repoIndex].matches[row.extractIndex!].htmlUrl; + } + openInBrowser(url); + } + redraw(); } }