From 0b0f148fea899922a3ad18d7fd2f6e0188878aab Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:28:02 -0700 Subject: [PATCH] feat(preview): open the previewed file on GitHub Add an O keybinding to the preview panel that opens the current file on github.com, pinned to the current HEAD commit and anchored to the selected line range (or the top visible line). It reuses the existing permalink builder and the browser launcher, and shows a clear toast when the preview is not an on-disk file or the repo has no github remote. Registers the binding in the panel, the keybindings manifest, the generated docs, and the help overlay. Adds unit tests for the no-file and github-mode guards. Closes #324 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 1 + internal/keybindings/keybindings.json | 1 + internal/panels/preview/permalink.go | 38 +++++++++++++++++++++++ internal/panels/preview/permalink_test.go | 15 +++++++++ internal/panels/preview/preview.go | 3 ++ internal/panels/preview/preview_test.go | 3 +- 6 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3b..4da2476 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -192,6 +192,7 @@ Preview panel (panel 5). | `B` | Toggle blame | | `y/Ctrl+C` | Copy selection | | `Y` | Copy GitHub permalink | +| `O` | Open file on GitHub | | `Esc` | Clear selection | Navigation keys (j/k/g/G/PgDn/PgUp) scroll content. Click+drag to select text, double-click to select a word. diff --git a/internal/keybindings/keybindings.json b/internal/keybindings/keybindings.json index 6c9e704..cb57f7c 100644 --- a/internal/keybindings/keybindings.json +++ b/internal/keybindings/keybindings.json @@ -168,6 +168,7 @@ { "key": "B", "action": "Toggle blame" }, { "key": "y/Ctrl+C", "action": "Copy selection" }, { "key": "Y", "action": "Copy GitHub permalink" }, + { "key": "O", "action": "Open file on GitHub" }, { "key": "Esc", "action": "Clear selection" } ], "note": "Navigation keys (j/k/g/G/PgDn/PgUp) scroll content. Click+drag to select text, double-click to select a word." diff --git a/internal/panels/preview/permalink.go b/internal/panels/preview/permalink.go index 656a3b7..175589b 100644 --- a/internal/panels/preview/permalink.go +++ b/internal/panels/preview/permalink.go @@ -112,3 +112,41 @@ func (p *Preview) copyPermalink() (panels.Panel, tea.Cmd) { return notify.ShowToastMsg{Message: "Copied GitHub permalink", Level: notify.Info} } } + +// openOnGitHub builds a github.com permalink for the current file and line +// (or selected range) and opens it in the default browser. It is a no-op with +// a clear message when the preview is not showing an on-disk file or when the +// repository has no github remote. +func (p *Preview) openOnGitHub() (panels.Panel, tea.Cmd) { + if p.ghMode || p.filePath == "" { + return p, nil + } + path := p.filePath + gc := p.gitClient + startLine, endLine := p.permalinkLineRange() + return p, func() tea.Msg { + ctx := context.Background() + 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: "File is outside the repository", Level: notify.Warn} + } + link := buildPermalink(gitRemoteURL(ctx, root), gitHeadSHA(ctx, root), relPath, startLine, endLine) + 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 file on GitHub", Level: notify.Info} + } +} diff --git a/internal/panels/preview/permalink_test.go b/internal/panels/preview/permalink_test.go index 08dcc8a..b7e88c6 100644 --- a/internal/panels/preview/permalink_test.go +++ b/internal/panels/preview/permalink_test.go @@ -121,3 +121,18 @@ func TestCopyPermalink_NoOpInGitHubMode(t *testing.T) { _, cmd := p.copyPermalink() assert.Nil(t, cmd) } + +func TestOpenOnGitHub_NoOpWhenNoFile(t *testing.T) { + p := newTestPreview([]string{"a"}) + p.filePath = "" + _, cmd := p.openOnGitHub() + assert.Nil(t, cmd) +} + +func TestOpenOnGitHub_NoOpInGitHubMode(t *testing.T) { + p := newTestPreview([]string{"a"}) + p.filePath = "main.go" + p.ghMode = true + _, cmd := p.openOnGitHub() + assert.Nil(t, cmd) +} diff --git a/internal/panels/preview/preview.go b/internal/panels/preview/preview.go index 9ef8dd3..7f0aa42 100644 --- a/internal/panels/preview/preview.go +++ b/internal/panels/preview/preview.go @@ -522,6 +522,8 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { return p.copyFilePath() case "Y": return p.copyPermalink() + case "O": + return p.openOnGitHub() case keyEscape, keyEsc: if p.hasSelection() { p.clearSelection() @@ -723,6 +725,7 @@ func (p *Preview) KeyBindings() []panels.KeyBinding { {Key: "B", Description: "Toggle blame", Action: "toggle_blame"}, {Key: "y/Ctrl+C", Description: "Copy selection or file path", Action: "copy_selection"}, {Key: "Y", Description: "Copy GitHub permalink", Action: "copy_permalink"}, + {Key: "O", Description: "Open file on GitHub", Action: "open_on_github"}, } } diff --git a/internal/panels/preview/preview_test.go b/internal/panels/preview/preview_test.go index e2b4040..8d010b6 100644 --- a/internal/panels/preview/preview_test.go +++ b/internal/panels/preview/preview_test.go @@ -778,7 +778,7 @@ func TestKeyBindings(t *testing.T) { bindings := p.KeyBindings() assert.NotEmpty(t, bindings) - assert.Len(t, bindings, 15) + assert.Len(t, bindings, 16) // Verify all expected bindings are present actions := make([]string, len(bindings)) @@ -802,6 +802,7 @@ func TestKeyBindings(t *testing.T) { assert.Contains(t, actions, "toggle_diff_mode") assert.Contains(t, actions, "copy_selection") assert.Contains(t, actions, "copy_permalink") + assert.Contains(t, actions, "open_on_github") } func TestKeysIgnoredWhenBlurred(t *testing.T) {