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
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions internal/keybindings/keybindings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
3 changes: 3 additions & 0 deletions internal/panels/filetree/filetree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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":
Expand Down
102 changes: 102 additions & 0 deletions internal/panels/filetree/github.go
Original file line number Diff line number Diff line change
@@ -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}
}
}
102 changes: 102 additions & 0 deletions internal/panels/filetree/github_test.go
Original file line number Diff line number Diff line change
@@ -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: "[email protected]: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)
}
Loading