From 6cabd96a3749325f7b65d73aa356aababc5ce630 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:33:48 -0700 Subject: [PATCH] feat(commits): open the selected commit on GitHub Add an `o` keybinding to the commits panel that opens the selected commit's github.com page in the default browser. The commits panel could copy a commit hash but had no way to jump straight to the commit on GitHub, so reviewers had to build the URL by hand. The handler resolves the origin remote, converts it to an https github.com base with the existing RemoteToHTTPS helper, and appends `/commit/`. It is a no-op when nothing is selected and shows a clear toast when the repo has no github remote. The keybindings manifest already documented `o` for this panel; this wires it up and regenerates docs/keybindings.md. Closes #328 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 2 +- internal/keybindings/keybindings.json | 2 +- internal/panels/commits/commits.go | 3 + internal/panels/commits/github.go | 69 ++++++++++++++++ internal/panels/commits/github_test.go | 104 +++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 internal/panels/commits/github.go create mode 100644 internal/panels/commits/github_test.go diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3b..7b0699e 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -162,7 +162,7 @@ Commits panel (panel 4). |-----|--------| | `Enter` | View commit detail | | `Esc` | Back to list | -| `o` | Open in browser | +| `o` | Open commit on GitHub | | `y` | Copy SHA | | `a` | Filter the commit log by the selected commit's author | | `x` | Export the selected commit as a .patch file | diff --git a/internal/keybindings/keybindings.json b/internal/keybindings/keybindings.json index 6c9e704..9a583ae 100644 --- a/internal/keybindings/keybindings.json +++ b/internal/keybindings/keybindings.json @@ -140,7 +140,7 @@ "bindings": [ { "key": "Enter", "action": "View commit detail" }, { "key": "Esc", "action": "Back to list" }, - { "key": "o", "action": "Open in browser" }, + { "key": "o", "action": "Open commit on GitHub" }, { "key": "y", "action": "Copy SHA" }, { "key": "a", "action": "Filter the commit log by the selected commit's author" }, { "key": "x", "action": "Export the selected commit as a .patch file" }, diff --git a/internal/panels/commits/commits.go b/internal/panels/commits/commits.go index 53d2387..f4c6717 100644 --- a/internal/panels/commits/commits.go +++ b/internal/panels/commits/commits.go @@ -360,6 +360,7 @@ func (p *Panel) KeyBindings() []panels.KeyBinding { {Key: "g", Description: "Go to top", Action: "go_top"}, {Key: "G", Description: "Go to bottom", Action: "go_bottom"}, {Key: "y", Description: "Copy commit hash", Action: "copy_hash"}, + {Key: "o", Description: "Open commit on GitHub", Action: "open_on_github"}, {Key: "x", Description: "Export commit as a .patch file", Action: "export_patch"}, {Key: "/", Description: "Search commits", Action: "search"}, {Key: "S", Description: "Search commit content (pickaxe)", Action: "pickaxe"}, @@ -873,6 +874,8 @@ func (p *Panel) handleKey(msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) { p.goToBottom() case "y": return p.copyHash() + case "o": + return p.openCommitOnGitHub() case "x": return p.exportPatch() case "/": diff --git a/internal/panels/commits/github.go b/internal/panels/commits/github.go new file mode 100644 index 0000000..1aefecf --- /dev/null +++ b/internal/panels/commits/github.go @@ -0,0 +1,69 @@ +package commits + +import ( + "context" + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/jongio/grut/internal/git" + "github.com/jongio/grut/internal/notify" + "github.com/jongio/grut/internal/panels" +) + +// commitURL builds a github.com URL for a commit given a raw git remote URL +// and a commit hash. It returns "" when the remote is not a github.com remote +// or when the hash is empty. +func commitURL(remoteURL, hash string) string { + base := git.RemoteToHTTPS(remoteURL) + if base == "" || hash == "" { + return "" + } + if !strings.HasPrefix(base, "https://github.com/") { + return "" + } + return base + "/commit/" + hash +} + +// originRemoteURL returns the origin remote URL for the repo at root, or "". +func originRemoteURL(ctx context.Context, root string) string { + out, err := exec.CommandContext(ctx, "git", "-C", root, "remote", "get-url", "origin").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// openCommitOnGitHub opens the selected commit's github.com page in the +// browser. It is a no-op when no commit is selected and shows a clear toast +// when the repository has no github remote. +func (p *Panel) openCommitOnGitHub() (panels.Panel, tea.Cmd) { + if p.cursor < 0 || p.cursor >= p.activeLen() { + return p, nil + } + c := p.commitAt(p.cursor) + if c.Hash == "" { + return p, nil + } + hash := c.Hash + gc := p.gitClient + ctx := p.ctx + return p, func() tea.Msg { + if gc == nil { + return notify.ShowToastMsg{Message: "No git repository", Level: notify.Warn} + } + root, err := gc.RepoRoot(ctx) + if err != nil || root == "" { + return notify.ShowToastMsg{Message: "No git repository", Level: notify.Warn} + } + link := commitURL(originRemoteURL(ctx, root), hash) + if link == "" { + return notify.ShowToastMsg{Message: "No github remote to open", Level: notify.Warn} + } + if err := panels.OpenInBrowser(ctx, link); err != nil { + return notify.ShowToastMsg{Message: "Open failed: " + err.Error(), Level: notify.Error} + } + return notify.ShowToastMsg{Message: "Opened commit on GitHub", Level: notify.Success} + } +} diff --git a/internal/panels/commits/github_test.go b/internal/panels/commits/github_test.go new file mode 100644 index 0000000..28e95ba --- /dev/null +++ b/internal/panels/commits/github_test.go @@ -0,0 +1,104 @@ +package commits + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/jongio/grut/internal/notify" +) + +// --------------------------------------------------------------------------- +// commitURL +// --------------------------------------------------------------------------- + +func TestCommitURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + remote string + hash string + want string + }{ + { + name: "https github remote", + remote: "https://github.com/jongio/grut.git", + hash: "abc123", + want: "https://github.com/jongio/grut/commit/abc123", + }, + { + name: "ssh github remote", + remote: "git@github.com:jongio/grut.git", + hash: "def456", + want: "https://github.com/jongio/grut/commit/def456", + }, + { + name: "non-github remote returns empty", + remote: "https://gitlab.com/jongio/grut.git", + hash: "abc123", + want: "", + }, + { + name: "empty remote returns empty", + remote: "", + hash: "abc123", + want: "", + }, + { + name: "empty hash returns empty", + remote: "https://github.com/jongio/grut.git", + hash: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := commitURL(tt.remote, tt.hash); got != tt.want { + t.Errorf("commitURL(%q, %q) = %q, want %q", tt.remote, tt.hash, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// openCommitOnGitHub +// --------------------------------------------------------------------------- + +func TestOpenCommitOnGitHubEmptyListNoop(t *testing.T) { + t.Parallel() + + mock := &mockGitOps{commits: nil, root: t.TempDir()} + p := newTestPanel(mock) + p.Focus() + + _, cmd := p.Update(tea.KeyPressMsg{Code: 'o'}) + if cmd != nil { + t.Error("expected no command when there are no commits") + } +} + +func TestOpenCommitOnGitHubNoGitHubRemote(t *testing.T) { + t.Parallel() + + // A fresh temp dir is not a git repo, so origin lookup fails and the + // handler reports that there is no github remote to open. This exercises + // the guard without launching a browser. + mock := &mockGitOps{commits: defaultCommits(), root: t.TempDir()} + p := newTestPanel(mock) + p.Focus() + + _, cmd := p.Update(tea.KeyPressMsg{Code: 'o'}) + if cmd == nil { + t.Fatal("expected a command from o") + } + msg, ok := cmd().(notify.ShowToastMsg) + if !ok { + t.Fatalf("expected ShowToastMsg, got %T", cmd()) + } + if msg.Level != notify.Warn { + t.Errorf("toast level = %v, want Warn", msg.Level) + } +}