From 248189acc9be7bd4528b9173b5b089bf0117202d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Mon, 2 Mar 2026 00:02:42 +0100 Subject: [PATCH 1/2] Add o shortcut to open focused result in the default browser Closes #69 - openInBrowser() helper: open/xdg-open/start depending on platform, spawned detached so the TUI stays responsive - Repo row -> opens https://github.com// - Extract row -> opens the file htmlUrl returned by the GitHub API - Section header rows are a no-op (type === 'section') - renderHelpOverlay: adds 'o open in browser' entry - Status bar hint line: adds 'o open' - Tests: overlay contains 'open in browser', status bar contains 'o open' - docs/reference/keyboard-shortcuts.md: documents the shortcut in the Selection table --- docs/reference/keyboard-shortcuts.md | 1 + src/render.test.ts | 9 ++++++++- src/render.ts | 3 ++- src/tui.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 8b74257..71641ff 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -21,6 +21,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/src/render.test.ts b/src/render.test.ts index 9f29bcc..d992e31 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -909,6 +909,12 @@ describe("renderHelpOverlay", () => { 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("is returned by renderGroups when showHelp=true", () => { const groups = [makeGroup("org/repo", ["a.ts"])]; const rows = buildRows(groups); @@ -975,12 +981,13 @@ describe("renderGroups filter opts", () => { expect(stripped).not.toContain("Filter:"); }); - it("status bar hint line includes Z fold-all shortcut", () => { + it("status bar hint line includes Z fold-all and o open 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"); }); it("shows mode badge [content] when filterTarget=content", () => { diff --git a/src/render.ts b/src/render.ts index 91a3032..ba09e02 100644 --- a/src/render.ts +++ b/src/render.ts @@ -37,6 +37,7 @@ export function renderHelpOverlay(): string { ` ${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`, @@ -298,7 +299,7 @@ export function renderGroups( lines.push( pc.dim( - "← / → fold/unfold Z fold-all ↑ / ↓ navigate spc select a all n none f filter t target h help ↵ confirm q quit\n", + "← / → fold/unfold Z fold-all ↑ / ↓ navigate 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 990c0a0..63f1e2f 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -62,6 +62,20 @@ 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: `start`. + * Uses Bun.spawn detached so the TUI remains fully responsive. + */ +function openInBrowser(url: string): void { + const cmd = + process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; + // Spawn detached: fire-and-forget, do not await or pipe stdio. + Bun.spawn([cmd, url], { stdout: null, stderr: null, stdin: null }); +} + // ─── Interactive TUI ───────────────────────────────────────────────────────── export async function runInteractive( @@ -435,6 +449,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(); } } From b38cc03765e83c5d0636f1860b3dd3118ce7e37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Mon, 2 Mar 2026 00:13:14 +0100 Subject: [PATCH 2/2] Fix openInBrowser: use cmd /c start on Windows, fix JSDoc --- src/tui.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/tui.ts b/src/tui.ts index 63f1e2f..a620e87 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -66,14 +66,29 @@ function nextWordBoundary(s: string, pos: number): number { /** * Open a URL in the system default browser. - * macOS: `open`, Linux: `xdg-open`, Windows: `start`. - * Uses Bun.spawn detached so the TUI remains fully responsive. + * 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 { - const cmd = - process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; - // Spawn detached: fire-and-forget, do not await or pipe stdio. - Bun.spawn([cmd, url], { stdout: null, stderr: null, stdin: null }); + 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 ─────────────────────────────────────────────────────────