diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3b..a3e97aa 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 | +| `Ctrl+G` | Create secret gist | | `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/github/gist.go b/internal/github/gist.go new file mode 100644 index 0000000..d398831 --- /dev/null +++ b/internal/github/gist.go @@ -0,0 +1,11 @@ +package github + +import "context" + +// CreateGist creates a secret gist from the file at path using the gh CLI and +// returns the gist's URL. The gist is secret (unlisted) rather than public. +// The `--` separator stops a path that begins with a dash from being parsed as +// a flag. +func CreateGist(ctx context.Context, path string) (string, error) { + return ghExec(ctx, "gist", "create", "--secret", "--", path) +} diff --git a/internal/keybindings/keybindings.json b/internal/keybindings/keybindings.json index 6c9e704..cdbf4b9 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": "Ctrl+G", "action": "Create secret gist" }, { "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/gist.go b/internal/panels/preview/gist.go new file mode 100644 index 0000000..eb07ec0 --- /dev/null +++ b/internal/panels/preview/gist.go @@ -0,0 +1,32 @@ +package preview + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + "github.com/jongio/grut/internal/github" + "github.com/jongio/grut/internal/notify" + "github.com/jongio/grut/internal/panels" +) + +// createGist creates a secret github gist from the previewed file and copies +// the gist URL to the clipboard. It is a no-op when the preview is not showing +// an on-disk file (for example GitHub content or an empty view). +func (p *Preview) createGist() (panels.Panel, tea.Cmd) { + if p.ghMode || p.filePath == "" { + return p, nil + } + path := p.filePath + return p, func() tea.Msg { + ctx := context.Background() + link, err := github.CreateGist(ctx, path) + if err != nil { + return notify.ShowToastMsg{Message: "Gist failed: " + err.Error(), Level: notify.Error} + } + if cerr := panels.CopyToClipboard(ctx, link); cerr != nil { + return notify.ShowToastMsg{Message: "Created gist (copy failed): " + link, Level: notify.Warn} + } + return notify.ShowToastMsg{Message: "Created secret gist (URL copied)", Level: notify.Success} + } +} diff --git a/internal/panels/preview/gist_test.go b/internal/panels/preview/gist_test.go new file mode 100644 index 0000000..2cf936c --- /dev/null +++ b/internal/panels/preview/gist_test.go @@ -0,0 +1,32 @@ +package preview + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateGist_NoOpWhenNoFile(t *testing.T) { + p := newTestPreview([]string{"a"}) + p.filePath = "" + _, cmd := p.createGist() + assert.Nil(t, cmd) +} + +func TestCreateGist_NoOpInGitHubMode(t *testing.T) { + p := newTestPreview([]string{"a"}) + p.filePath = "main.go" + p.ghMode = true + _, cmd := p.createGist() + assert.Nil(t, cmd) +} + +func TestCreateGist_ReturnsCommandForLocalFile(t *testing.T) { + p := newTestPreview([]string{"a"}) + p.filePath = "main.go" + p.ghMode = false + _, cmd := p.createGist() + // A local on-disk file yields a command; it is not executed here because + // doing so would shell out to the gh CLI and create a real gist. + assert.NotNil(t, cmd) +} diff --git a/internal/panels/preview/preview.go b/internal/panels/preview/preview.go index 9ef8dd3..796a2bb 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 "ctrl+g": + return p.createGist() 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: "Ctrl+G", Description: "Create secret gist", Action: "create_gist"}, } } diff --git a/internal/panels/preview/preview_test.go b/internal/panels/preview/preview_test.go index e2b4040..d327560 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, "create_gist") } func TestKeysIgnoredWhenBlurred(t *testing.T) {