Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion internal/keybindings/keybindings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
3 changes: 3 additions & 0 deletions internal/panels/commits/commits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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 "/":
Expand Down
69 changes: 69 additions & 0 deletions internal/panels/commits/github.go
Original file line number Diff line number Diff line change
@@ -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}
}
}
104 changes: 104 additions & 0 deletions internal/panels/commits/github_test.go
Original file line number Diff line number Diff line change
@@ -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: "[email protected]: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)
}
}
Loading