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) + } +}