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) {