diff --git a/docs/keybindings.md b/docs/keybindings.md index bd4b122..03e8545 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -191,6 +191,7 @@ Preview panel (panel 5). | `B` | Toggle blame | | `y/Ctrl+C` | Copy selection | | `Y` | Copy GitHub permalink | +| `p` | Copy local file location | | `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 a5184fb..f4345f0 100644 --- a/internal/keybindings/keybindings.json +++ b/internal/keybindings/keybindings.json @@ -167,6 +167,7 @@ { "key": "B", "action": "Toggle blame" }, { "key": "y/Ctrl+C", "action": "Copy selection" }, { "key": "Y", "action": "Copy GitHub permalink" }, + { "key": "p", "action": "Copy local file location" }, { "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..d6f0b94 100644 --- a/internal/panels/preview/permalink.go +++ b/internal/panels/preview/permalink.go @@ -112,3 +112,46 @@ func (p *Preview) copyPermalink() (panels.Panel, tea.Cmd) { return notify.ShowToastMsg{Message: "Copied GitHub permalink", Level: notify.Info} } } + +func buildLocalLocation(root, path string, startLine, endLine int) string { + location := filepath.ToSlash(filepath.Clean(path)) + if root != "" { + if rel, err := filepath.Rel(root, path); err == nil { + rel = filepath.ToSlash(rel) + if rel != ".." && !strings.HasPrefix(rel, "../") { + location = rel + } + } + } + switch { + case startLine <= 0: + return location + case endLine > startLine: + return fmt.Sprintf("%s:%d-%d", location, startLine, endLine) + default: + return fmt.Sprintf("%s:%d", location, startLine) + } +} + +func (p *Preview) copyLocalLocation() (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() + root := "" + if gc != nil { + if repoRoot, err := gc.RepoRoot(ctx); err == nil { + root = repoRoot + } + } + location := buildLocalLocation(root, path, startLine, endLine) + if err := panels.CopyToClipboard(ctx, location); err != nil { + return notify.ShowToastMsg{Message: "Copy failed: " + err.Error(), Level: notify.Error} + } + return notify.ShowToastMsg{Message: "Copied file location", Level: notify.Info} + } +} diff --git a/internal/panels/preview/permalink_test.go b/internal/panels/preview/permalink_test.go index 08dcc8a..d1cf013 100644 --- a/internal/panels/preview/permalink_test.go +++ b/internal/panels/preview/permalink_test.go @@ -1,6 +1,7 @@ package preview import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -121,3 +122,30 @@ func TestCopyPermalink_NoOpInGitHubMode(t *testing.T) { _, cmd := p.copyPermalink() assert.Nil(t, cmd) } + +func TestBuildLocalLocation_SingleLine(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "internal", "git", "status.go") + + location := buildLocalLocation(root, path, 42, 42) + + assert.Equal(t, "internal/git/status.go:42", location) +} + +func TestBuildLocalLocation_Range(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "internal", "panels", "preview", "preview.go") + + location := buildLocalLocation(root, path, 10, 14) + + assert.Equal(t, "internal/panels/preview/preview.go:10-14", location) +} + +func TestBuildLocalLocation_OutsideRepoFallsBackToAbsolutePath(t *testing.T) { + root := t.TempDir() + outside := filepath.Join(filepath.Dir(root), "outside.go") + + location := buildLocalLocation(root, outside, 7, 7) + + assert.Equal(t, filepath.ToSlash(filepath.Clean(outside))+":7", location) +} diff --git a/internal/panels/preview/preview.go b/internal/panels/preview/preview.go index 9ef8dd3..288324f 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 "p": + return p.copyLocalLocation() 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: "p", Description: "Copy local file location", Action: "copy_local_location"}, } } diff --git a/internal/panels/preview/preview_test.go b/internal/panels/preview/preview_test.go index e2b4040..cd136c4 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, "copy_local_location") } func TestKeysIgnoredWhenBlurred(t *testing.T) {