Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ the fields it sets. The built-in Actions are:
| `files` | `ctrl+o` | `xdg-open {path}` (`open {path}` on macOS), detached |
| `editor` | `ctrl+e` | `${VISUAL:-${EDITOR:-vi}} {path}`, on your terminal |
| `remote` | `ctrl+g` | `xdg-open {remote}` (`open {remote}` on macOS), detached |
| `copy` | `ctrl+y` | Copies the Project's path to the clipboard, and says "copied" on the last line |

`copy` uses the first of `wl-copy` (when `WAYLAND_DISPLAY` is set), `xclip`,
`xsel` and `pbcopy` it finds on `PATH`. With none of them it sends the OSC 52
escape to the terminal instead, which also works over SSH in terminals that
allow it. Setting `run` replaces it with an ordinary command, such as
`run = "printf %s {path} | wl-copy"` with `detach = true`.

`editor` reads `$VISUAL`, else `$EDITOR`, else `vi`, each time it runs.
Override `run` to pick a program, or set `key = ""` to unbind any of them:
Expand Down Expand Up @@ -260,7 +267,7 @@ layout = "list"
key map.
- `[actions.<name>]`: an Action, with `key`, `run`, `jump` and `detach`;
see [Actions](#actions). Not in the defaults above; `jump`, `files`,
`editor` and `remote` exist unless you override them.
`editor`, `remote` and `copy` exist unless you override them.
- `[picker].layout`: which Layout the Picker draws. `"list"`, described
above, is the default and, for now, the only one. Any other value is a
config error.
Expand Down
11 changes: 10 additions & 1 deletion internal/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type Action struct {
// Detach starts Run in its own session without waiting, and leaves the
// Picker open.
Detach bool

// Copy makes the Picker itself copy the Project's path to the clipboard
// and stay open, with no Run. Only a built-in sets it, since the OSC 52
// fallback has to go out through the Picker's own terminal. A user's
// "run" or "jump" for the Action replaces it.
Copy bool
}

// Override is the part of an Action a [actions.<name>] table sets. A nil
Expand All @@ -55,6 +61,7 @@ var builtins = []Action{
// $VISUAL or $EDITOR needs no restart.
{Name: "editor", Key: "ctrl+e", Run: "${VISUAL:-${EDITOR:-vi}} {path}"},
{Name: "remote", Key: "ctrl+g", Run: Opener(runtime.GOOS) + " {remote}", Detach: true},
{Name: "copy", Key: "ctrl+y", Copy: true},
}

// Builtins returns a copy of the built-in Actions.
Expand Down Expand Up @@ -113,9 +120,11 @@ func Merge(user map[string]Override, vim bool) ([]Action, error) {
}
if o.Run != nil {
a.Run = *o.Run
a.Copy = false
}
if o.Jump != nil {
a.Jump = *o.Jump
a.Copy = a.Copy && !a.Jump
}
if o.Detach != nil {
a.Detach = *o.Detach
Expand All @@ -125,7 +134,7 @@ func Merge(user map[string]Override, vim bool) ([]Action, error) {
owner := make(map[string]string, len(out))
for i := range out {
a := &out[i]
if a.Run == "" && !a.Jump {
if a.Run == "" && !a.Jump && !a.Copy {
return nil, &Error{a.Name, "run", errors.New("a command is required unless jump is true")}
}
key, err := NormalizeKey(a.Key, vim)
Expand Down
41 changes: 41 additions & 0 deletions internal/action/clipboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package action

import (
"os"
"os/exec"
"strings"
)

// ClipboardTool returns the command line of the first clipboard program
// found on PATH, in this order: wl-copy (only when WAYLAND_DISPLAY is set),
// xclip, xsel, pbcopy. Each reads the text from stdin. It returns nil when
// none is found, which is when the caller falls back to the OSC 52 escape.
func ClipboardTool() []string {
tools := [][]string{
{"xclip", "-selection", "clipboard"},
{"xsel", "--clipboard", "--input"},
{"pbcopy"},
}
if os.Getenv("WAYLAND_DISPLAY") != "" {
tools = append([][]string{{"wl-copy"}}, tools...)
}
for _, t := range tools {
if _, err := exec.LookPath(t[0]); err == nil {
return t
}
}
return nil
}

// Copy puts text on the clipboard with the program ClipboardTool picks. It
// reports false, without an error, when there is none, and true with the
// error when the program was found but failed.
func Copy(text string) (bool, error) {
tool := ClipboardTool()
if tool == nil {
return false, nil
}
cmd := exec.Command(tool[0], tool[1:]...)
cmd.Stdin = strings.NewReader(text)
return true, cmd.Run()
}
90 changes: 90 additions & 0 deletions internal/action/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package action_test

import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/kryft-dev/cdd/internal/action"
)

// fakeBin returns a directory holding an executable for each of names, to
// stand alone as PATH.
func fakeBin(t *testing.T, names ...string) string {
t.Helper()
dir := t.TempDir()
for _, n := range names {
if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
}
return dir
}

func TestClipboardTool_PicksTheFirstFoundInOrder(t *testing.T) {
all := []string{"wl-copy", "xclip", "xsel", "pbcopy"}
tests := []struct {
name string
have []string
wayland string
want []string
}{
{"wl-copy on wayland", all, "wayland-0", []string{"wl-copy"}},
{"wl-copy ignored without wayland", all, "", []string{"xclip", "-selection", "clipboard"}},
{"xclip when wl-copy is missing", all[1:], "wayland-0", []string{"xclip", "-selection", "clipboard"}},
{"xsel when xclip is missing", all[2:], "", []string{"xsel", "--clipboard", "--input"}},
{"pbcopy last", all[3:], "", []string{"pbcopy"}},
{"none found", nil, "wayland-0", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("PATH", fakeBin(t, tt.have...))
t.Setenv("WAYLAND_DISPLAY", tt.wayland)
if got := action.ClipboardTool(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("ClipboardTool() = %v, want %v", got, tt.want)
}
})
}
}

func TestCopy_PipesTheTextToTheTool(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "got")
script := "#!/bin/sh\nread -r line\necho \"$line\" > " + out + "\n"
if err := os.WriteFile(filepath.Join(dir, "xclip"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
t.Setenv("WAYLAND_DISPLAY", "")

ok, err := action.Copy("/home/me/my proj")
if !ok || err != nil {
t.Fatalf("Copy = %v, %v, want true, nil", ok, err)
}
got, _ := os.ReadFile(out)
if strings.TrimSpace(string(got)) != "/home/me/my proj" {
t.Errorf("tool read %q", got)
}
}

func TestCopy_NoToolIsNotCopied(t *testing.T) {
t.Setenv("PATH", t.TempDir())
ok, err := action.Copy("/p")
if ok || err != nil {
t.Errorf("Copy = %v, %v, want false, nil", ok, err)
}
}

func TestCopy_ToolFailureIsAnError(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "pbcopy"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
t.Setenv("WAYLAND_DISPLAY", "")
if ok, err := action.Copy("/p"); !ok || err == nil {
t.Errorf("Copy = %v, %v, want true and an error", ok, err)
}
}
42 changes: 42 additions & 0 deletions internal/action/copy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package action_test

import (
"testing"

"github.com/kryft-dev/cdd/internal/action"
)

func TestBuiltins_CopyIsBoundToCtrlYWithNoCommand(t *testing.T) {
got := builtin(t, "copy")
want := action.Action{Name: "copy", Key: "ctrl+y", Copy: true}
if got != want {
t.Errorf("copy = %+v, want %+v", got, want)
}
}

func TestMerge_CopyKeepsCopyUntilTheUserSetsRun(t *testing.T) {
merged := func(o action.Override) action.Action {
t.Helper()
got, err := action.Merge(map[string]action.Override{"copy": o}, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
for _, a := range got {
if a.Name == "copy" {
return a
}
}
t.Fatal("no copy")
return action.Action{}
}

if a := merged(action.Override{Key: ptr("ctrl+k")}); !a.Copy || a.Key != "ctrl+k" {
t.Errorf("rebound = %+v, want still Copy on ctrl+k", a)
}
if a := merged(action.Override{Key: ptr("")}); !a.Copy || a.Key != "" {
t.Errorf("unbound = %+v, want still Copy, no key", a)
}
if a := merged(action.Override{Run: ptr("wl-copy {path}"), Detach: ptr(true)}); a.Copy || a.Run != "wl-copy {path}" || !a.Detach {
t.Errorf("run override = %+v, want a plain command, not Copy", a)
}
}
94 changes: 94 additions & 0 deletions internal/picker/copy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package picker_test

import (
"errors"
"fmt"
"strings"
"testing"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"

"github.com/kryft-dev/cdd/internal/action"
"github.com/kryft-dev/cdd/internal/picker"
)

var ctrlY = tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}

// copyModel is an actionModel over the built-in copy Action, with copy as
// the clipboard function.
func copyModel(copyFn func(string) (bool, error)) picker.Model {
m := twoRowModel(picker.Options{
Actions: action.Builtins(),
Runner: &fakeRunner{},
Copy: copyFn,
})
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")})
return next.(picker.Model)
}

func TestModel_Copy_UsesTheClipboardProgramAndStaysOpen(t *testing.T) {
var got []string
m := copyModel(func(text string) (bool, error) {
got = append(got, text)
return true, nil
})

m, cmd := press(m, ctrlY)

if len(got) != 1 || got[0] != "/root/work/alpha" {
t.Errorf("copied %v, want the path of alpha", got)
}
if _, chosen := m.Chosen(); chosen || cmd != nil {
t.Errorf("chosen = %v, Cmd = %v, want the Picker open and no OSC 52", chosen, cmd != nil)
}
if v := plain(m.View().Content); !strings.Contains(v, "copied /root/work/alpha") {
t.Errorf("View lacks the copied message:\n%s", v)
}
}

func TestModel_Copy_WithoutAProgramFallsBackToOSC52(t *testing.T) {
m := copyModel(func(string) (bool, error) { return false, nil })

m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyDown})
m, cmd := press(m, ctrlY)

if cmd == nil {
t.Fatal("Cmd = nil, want the OSC 52 clipboard command")
}
msg := cmd()
if kind := fmt.Sprintf("%T", msg); kind != "tea.setClipboardMsg" {
t.Errorf("Cmd gave %s, want tea.setClipboardMsg", kind)
}
if text := fmt.Sprintf("%s", msg); text != "/root/work/beta" {
t.Errorf("OSC 52 text = %q, want the path of beta", text)
}
if v := plain(m.View().Content); !strings.Contains(v, "copied /root/work/beta") {
t.Errorf("View lacks the copied message:\n%s", v)
}
}

func TestModel_Copy_FailureShowsOnTheFooter(t *testing.T) {
m := copyModel(func(string) (bool, error) { return true, errors.New("exit status 1") })

m, cmd := press(m, ctrlY)

if v := plain(m.View().Content); !strings.Contains(v, "copy: exit status 1") || strings.Contains(v, "copied") {
t.Errorf("View lacks the failure:\n%s", v)
}
if cmd != nil {
t.Error("Cmd != nil, want no OSC 52 after a failed program")
}
}

func TestModel_Copy_MessageClearsOnTheNextKey(t *testing.T) {
m := copyModel(func(string) (bool, error) { return true, nil })

m, _ = press(m, ctrlY)
m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyDown})

if v := plain(m.View().Content); strings.Contains(v, "copied") {
t.Errorf("View still says copied after a key press:\n%s", v)
}
}
12 changes: 10 additions & 2 deletions internal/picker/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type Model struct {

actions map[string]action.Action // keyed by Action.Key
runner action.Runner
copy func(text string) (bool, error)

query string
focus focus
Expand All @@ -55,8 +56,10 @@ type Model struct {
paletteSettled bool

// message is the one line the footer shows in place of the key hints
// until the next key press: a detached Action's failure to start.
message string
// until the next key press: a detached Action's failure to start, or
// that a copy succeeded (messageOK, shown as good news, not an error).
message string
messageOK bool

chosen bool
chosenRow Row
Expand Down Expand Up @@ -85,9 +88,14 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model {
if runner == nil {
runner = action.ExecRunner{}
}
copyFn := opts.Copy
if copyFn == nil {
copyFn = action.Copy
}
return Model{
actions: actions,
runner: runner,
copy: copyFn,
rows: rows,
status: status,
vim: opts.Vim,
Expand Down
5 changes: 5 additions & 0 deletions internal/picker/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ type Options struct {
// Runner starts the detached Actions, which leave the Picker open. The
// zero value is action.ExecRunner.
Runner action.Runner

// Copy puts a Project's path on the clipboard for the Action with Copy
// set. It reports false when no clipboard program is available, and the
// Picker falls back to the OSC 52 escape. The zero value is action.Copy.
Copy func(text string) (bool, error)
}

// concurrency bounds how many StatusFunc calls run at once, so a large
Expand Down
Loading
Loading