From b211355a63aa6957300337b0f5cc29b8867a3f26 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:42:32 -0700 Subject: [PATCH] feat(filetree): open the selected file or directory on GitHub Add a `B` keybinding to the file tree that opens the entry under the cursor on github.com in the default browser, pinned to the current HEAD commit. Files open at their blob URL and directories at their tree URL. The tree could open files locally (editor, default app) but had no way to jump to the same file on GitHub. The handler resolves the git root and origin remote, converts the remote to an https github.com base with the existing RemoteToHTTPS helper, computes the repo-relative path, and builds `/blob//` for files or `/tree//` for directories. Path segments are percent-escaped. It is a no-op when nothing is selected and shows a clear toast when the repo has no github remote or the entry sits outside the repository. `o` (open in editor) and `O` (open in default app) were already taken, so this uses `B` for browse. keybindings.json and docs/keybindings.md are updated. Closes #325 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 1 + internal/keybindings/keybindings.json | 1 + internal/panels/filetree/filetree.go | 3 + internal/panels/filetree/github.go | 102 ++++++++++++++++++++++++ internal/panels/filetree/github_test.go | 102 ++++++++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 internal/panels/filetree/github.go create mode 100644 internal/panels/filetree/github_test.go 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) +}