Skip to content

Commit 68bad64

Browse files
authored
Merge pull request #72 from fulll/feat/tui-open-in-browser
feat: open in browser — `o` shortcut to open the focused result's GitHub URL
2 parents d240966 + b38cc03 commit 68bad64

4 files changed

Lines changed: 53 additions & 2 deletions

File tree

docs/reference/keyboard-shortcuts.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Section header rows (shown when `--group-by-team-prefix` is active) are skipped
2121
| `Space` | Toggle selection on the current repo or extract. On a repo row: cascades to all its extracts. |
2222
| `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. |
2323
| `n` | Select **none**. Same context rules as `a`. Respects active filters. |
24+
| `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. |
2425

2526
## Filtering
2627

src/render.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,12 @@ describe("renderHelpOverlay", () => {
909909
expect(stripped).toContain("fold / unfold all repos");
910910
});
911911

912+
it("documents the o open-in-browser shortcut", () => {
913+
const out = renderHelpOverlay();
914+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
915+
expect(stripped).toContain("open in browser");
916+
});
917+
912918
it("is returned by renderGroups when showHelp=true", () => {
913919
const groups = [makeGroup("org/repo", ["a.ts"])];
914920
const rows = buildRows(groups);
@@ -975,12 +981,13 @@ describe("renderGroups filter opts", () => {
975981
expect(stripped).not.toContain("Filter:");
976982
});
977983

978-
it("status bar hint line includes Z fold-all shortcut", () => {
984+
it("status bar hint line includes Z fold-all and o open shortcuts", () => {
979985
const groups = [makeGroup("org/repo", ["a.ts"])];
980986
const rows = buildRows(groups);
981987
const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", {});
982988
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
983989
expect(stripped).toContain("Z fold-all");
990+
expect(stripped).toContain("o open");
984991
});
985992

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

src/render.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function renderHelpOverlay(): string {
3737
` ${pc.yellow("Space")} toggle selection ${pc.yellow("Enter")} confirm & output`,
3838
` ${pc.yellow("a")} select all ${pc.yellow("n")} select none`,
3939
` ${pc.dim("(respects active filter)")}`,
40+
` ${pc.yellow("o")} open in browser ${pc.dim("(repo row → repo page, extract row → file)")}`,
4041
` ${pc.yellow("f")} enter filter mode ${pc.yellow("r")} reset filter`,
4142
` ${pc.yellow("t")} cycle filter target ${pc.dim("(path → content → repo)")}`,
4243
` ${pc.yellow("h")} / ${pc.yellow("?")} toggle this help ${pc.yellow("q")} / Ctrl+C quit`,
@@ -298,7 +299,7 @@ export function renderGroups(
298299

299300
lines.push(
300301
pc.dim(
301-
"← / → fold/unfold Z fold-all ↑ / ↓ navigate spc select a all n none f filter t target h help ↵ confirm q quit\n",
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",
302303
),
303304
);
304305

src/tui.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,35 @@ function nextWordBoundary(s: string, pos: number): number {
6262
return i;
6363
}
6464

65+
// ─── Browser helper ──────────────────────────────────────────────────────────
66+
67+
/**
68+
* Open a URL in the system default browser.
69+
* macOS: `open`, Linux: `xdg-open`, Windows: `cmd /c start "" <url>`.
70+
* Fire-and-forget with all stdio set to null so the TUI remains fully responsive.
71+
*/
72+
function openInBrowser(url: string): void {
73+
let command: string;
74+
let args: string[];
75+
76+
if (process.platform === "darwin") {
77+
command = "open";
78+
args = [url];
79+
} else if (process.platform === "win32") {
80+
// `start` is a cmd.exe built-in, not a standalone executable.
81+
// The empty string is the mandatory window-title argument; without it,
82+
// `start` mis-parses the URL as the title and may fail to open it.
83+
command = "cmd";
84+
args = ["/c", "start", "", url];
85+
} else {
86+
command = "xdg-open";
87+
args = [url];
88+
}
89+
90+
// Fire-and-forget: do not await, and set all stdio to null so the TUI stays responsive.
91+
Bun.spawn([command, ...args], { stdout: null, stderr: null, stdin: null });
92+
}
93+
6594
// ─── Interactive TUI ─────────────────────────────────────────────────────────
6695

6796
export async function runInteractive(
@@ -435,6 +464,19 @@ export async function runInteractive(
435464
applySelectNone(groups, row, filterPath, filterTarget, filterRegex);
436465
}
437466

467+
// `o` — open focused result (or repo) in the default browser
468+
if (key === "o" && row && row.type !== "section") {
469+
let url: string;
470+
if (row.type === "repo") {
471+
// Open the repository page on GitHub
472+
url = `https://github.com/${groups[row.repoIndex].repoFullName}`;
473+
} else {
474+
// Open the specific file at the matching line
475+
url = groups[row.repoIndex].matches[row.extractIndex!].htmlUrl;
476+
}
477+
openInBrowser(url);
478+
}
479+
438480
redraw();
439481
}
440482
}

0 commit comments

Comments
 (0)