diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3b..d1fb987 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -59,6 +59,7 @@ File Tree panel (panel 1). | `h/l` | Collapse/expand directory | | `H/L` | Collapse/expand all directories | | `o` | Open in external editor | +| `B` | Open on GitHub | | `.` | Toggle hidden files | | `f` | Toggle git filter | | `v` | Toggle tree/list view | diff --git a/internal/keybindings/keybindings.json b/internal/keybindings/keybindings.json index 6c9e704..25a38e9 100644 --- a/internal/keybindings/keybindings.json +++ b/internal/keybindings/keybindings.json @@ -45,6 +45,7 @@ { "key": "h/l", "action": "Collapse/expand directory" }, { "key": "H/L", "action": "Collapse/expand all directories" }, { "key": "o", "action": "Open in external editor" }, + { "key": "B", "action": "Open on GitHub" }, { "key": ".", "action": "Toggle hidden files" }, { "key": "f", "action": "Toggle git filter" }, { "key": "v", "action": "Toggle tree/list view" }, diff --git a/internal/panels/filetree/filetree.go b/internal/panels/filetree/filetree.go index 21412ec..850327c 100644 --- a/internal/panels/filetree/filetree.go +++ b/internal/panels/filetree/filetree.go @@ -586,6 +586,7 @@ func (ft *FileTree) KeyBindings() []panels.KeyBinding { {Key: "d", Description: "Delete file(s)", Action: "item_delete"}, {Key: "e/F2", Description: "Rename file", Action: "item_edit"}, {Key: "o", Description: "Open in editor", Action: "item_open"}, + {Key: "B", Description: "Open on GitHub", Action: "open_on_github"}, {Key: "y", Description: "Copy file path", Action: "item_copy"}, {Key: "c", Description: "Copy selected to clipboard", Action: "copy"}, {Key: "x", Description: "Cut selected to clipboard", Action: "cut"}, @@ -973,6 +974,8 @@ func (ft *FileTree) handleKey(msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) { return ft.openInEditor() case "O": return ft.openInDefaultApp() + case "B": + return ft.openOnGitHub() case "y": return ft.copyPath() case "c": diff --git a/internal/panels/filetree/github.go b/internal/panels/filetree/github.go new file mode 100644 index 0000000..94e6685 --- /dev/null +++ b/internal/panels/filetree/github.go @@ -0,0 +1,102 @@ +package filetree + +import ( + "context" + "fmt" + "net/url" + "os/exec" + "path/filepath" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/jongio/grut/internal/git" + "github.com/jongio/grut/internal/notify" + "github.com/jongio/grut/internal/panels" +) + +// blobTreeURL builds a github.com URL for a file (blob) or directory (tree) +// pinned to sha. relPath is repo-relative; an empty or "." relPath refers to +// the repository root. Path segments are percent-escaped while slashes are +// preserved. Returns "" when the remote is not a github.com remote or when sha +// is empty. +func blobTreeURL(remoteURL, sha, relPath string, isDir bool) string { + base := git.RemoteToHTTPS(remoteURL) + if base == "" || sha == "" { + return "" + } + if !strings.HasPrefix(base, "https://github.com/") { + return "" + } + rel := filepath.ToSlash(relPath) + if rel == "" || rel == "." { + return fmt.Sprintf("%s/tree/%s", base, sha) + } + segments := strings.Split(rel, "/") + for i, s := range segments { + segments[i] = url.PathEscape(s) + } + escaped := strings.Join(segments, "/") + kind := "blob" + if isDir { + kind = "tree" + } + return fmt.Sprintf("%s/%s/%s/%s", base, kind, sha, escaped) +} + +// 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)) +} + +// headSHA returns the full HEAD commit SHA for the repo at root, or "". +func headSHA(ctx context.Context, root string) string { + out, err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// openOnGitHub opens the selected file (blob) or directory (tree) on +// github.com in the default browser, pinned to the current HEAD commit. It is a +// no-op when nothing is selected and shows a clear toast when the repository +// has no github remote. +func (ft *FileTree) openOnGitHub() (panels.Panel, tea.Cmd) { + n := ft.cursorNode() + if n == nil { + return ft, nil + } + path := n.path + isDir := n.isDir + gc := ft.gitClient + ctx := ft.safeCtx() + return ft, 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} + } + relPath := path + if rel, err := filepath.Rel(root, path); err == nil { + relPath = filepath.ToSlash(rel) + } + if strings.HasPrefix(relPath, "..") { + return notify.ShowToastMsg{Message: "Entry is outside the repository", Level: notify.Warn} + } + link := blobTreeURL(originRemoteURL(ctx, root), headSHA(ctx, root), relPath, isDir) + 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 on GitHub", Level: notify.Success} + } +} diff --git a/internal/panels/filetree/github_test.go b/internal/panels/filetree/github_test.go new file mode 100644 index 0000000..0a9c170 --- /dev/null +++ b/internal/panels/filetree/github_test.go @@ -0,0 +1,102 @@ +package filetree + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jongio/grut/internal/notify" +) + +func TestBlobTreeURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + remote string + sha string + rel string + isDir bool + want string + }{ + { + name: "file blob", + remote: "https://github.com/jongio/grut.git", + sha: "abc123", + rel: "internal/git/url.go", + isDir: false, + want: "https://github.com/jongio/grut/blob/abc123/internal/git/url.go", + }, + { + name: "directory tree", + remote: "git@github.com:jongio/grut.git", + sha: "def456", + rel: "internal/panels", + isDir: true, + want: "https://github.com/jongio/grut/tree/def456/internal/panels", + }, + { + name: "repository root", + remote: "https://github.com/jongio/grut.git", + sha: "abc123", + rel: ".", + isDir: true, + want: "https://github.com/jongio/grut/tree/abc123", + }, + { + name: "path with space is escaped", + remote: "https://github.com/jongio/grut.git", + sha: "abc123", + rel: "docs/my notes.md", + isDir: false, + want: "https://github.com/jongio/grut/blob/abc123/docs/my%20notes.md", + }, + { + name: "non-github remote returns empty", + remote: "https://gitlab.com/jongio/grut.git", + sha: "abc123", + rel: "main.go", + isDir: false, + want: "", + }, + { + name: "empty sha returns empty", + remote: "https://github.com/jongio/grut.git", + sha: "", + rel: "main.go", + isDir: false, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := blobTreeURL(tt.remote, tt.sha, tt.rel, tt.isDir) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestOpenOnGitHubEmptyTreeNoop(t *testing.T) { + ft := initFT(t, t.TempDir()) + require.Nil(t, ft.cursorNode()) + + _, cmd := ft.Update(keyMsg('B')) + assert.Nil(t, cmd, "expected no command when nothing is selected") +} + +func TestOpenOnGitHubNoGitClient(t *testing.T) { + dir := createTestTree(t) + ft := initFT(t, dir) + require.NotNil(t, ft.cursorNode()) + + _, cmd := ft.Update(keyMsg('B')) + require.NotNil(t, cmd) + + msg := runCmd(t, ft, cmd) + toast, ok := msg.(notify.ShowToastMsg) + require.True(t, ok, "expected ShowToastMsg, got %T", msg) + assert.Equal(t, notify.Warn, toast.Level) +}