From b03d080e20f2019b32d1458af0940c45605b506d Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:35:31 +0500 Subject: [PATCH 1/5] feat(action): Copy puts text on the clipboard with the first tool found (#66) --- internal/action/clipboard.go | 41 ++++++++++++++ internal/action/clipboard_test.go | 90 +++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 internal/action/clipboard.go create mode 100644 internal/action/clipboard_test.go diff --git a/internal/action/clipboard.go b/internal/action/clipboard.go new file mode 100644 index 0000000..2a18416 --- /dev/null +++ b/internal/action/clipboard.go @@ -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() +} diff --git a/internal/action/clipboard_test.go b/internal/action/clipboard_test.go new file mode 100644 index 0000000..d03dd4c --- /dev/null +++ b/internal/action/clipboard_test.go @@ -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) + } +} From e7ae0eb80301485310cce513f156f4fc5cddc628 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:35:58 +0500 Subject: [PATCH 2/5] feat(action): built-in copy Action on ctrl+y, replaced by a user's run (#66) --- internal/action/action.go | 11 +++++++++- internal/action/copy_test.go | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 internal/action/copy_test.go diff --git a/internal/action/action.go b/internal/action/action.go index 8793ae2..dfdbd2b 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -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.] table sets. A nil @@ -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. @@ -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 @@ -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) diff --git a/internal/action/copy_test.go b/internal/action/copy_test.go new file mode 100644 index 0000000..5317af2 --- /dev/null +++ b/internal/action/copy_test.go @@ -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) + } +} From da515461d30f0b40bc80383040ba899953a91517 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:36:35 +0500 Subject: [PATCH 3/5] feat(picker): the copy Action copies the path, or sends OSC 52, and says so on the footer (#66) --- internal/picker/copy_test.go | 94 ++++++++++++++++++++++++++++++++++++ internal/picker/model.go | 12 ++++- internal/picker/picker.go | 5 ++ internal/picker/update.go | 21 +++++++- internal/picker/view.go | 6 ++- 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 internal/picker/copy_test.go diff --git a/internal/picker/copy_test.go b/internal/picker/copy_test.go new file mode 100644 index 0000000..9fd9c36 --- /dev/null +++ b/internal/picker/copy_test.go @@ -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, cmd := 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) + } +} diff --git a/internal/picker/model.go b/internal/picker/model.go index 52cfd88..daa6827 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -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 @@ -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 @@ -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, diff --git a/internal/picker/picker.go b/internal/picker/picker.go index 6c7d9eb..ac33466 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -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 diff --git a/internal/picker/update.go b/internal/picker/update.go index f5ced0d..94775fa 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -43,7 +43,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // active key map and focus. An Action's key overrides a navigation key of // the same name. func (m Model) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - m.message = "" + m.message, m.messageOK = "", false if a, ok := m.boundAction(msg.String()); ok { return m.runAction(a) } @@ -146,6 +146,9 @@ func (m Model) runAction(a action.Action) (tea.Model, tea.Cmd) { } row := rows[m.cursor].row + if a.Copy { + return m.copyPath(a, row.Project.Path) + } if !a.Detach { m.chosen = true m.chosenRow = row @@ -159,6 +162,22 @@ func (m Model) runAction(a action.Action) (tea.Model, tea.Cmd) { return m, nil } +// copyPath copies path with the clipboard program there is, or else asks +// Bubble Tea for the OSC 52 escape, which reaches the terminal even over +// SSH. Either way the Picker stays open and the footer says "copied". +func (m Model) copyPath(a action.Action, path string) (tea.Model, tea.Cmd) { + copied, err := m.copy(path) + if err != nil { + m.message = fmt.Sprintf("%s: %v", a.Name, err) + return m, nil + } + m.message, m.messageOK = "copied "+path, true + if copied { + return m, nil + } + return m, tea.SetClipboard(path) +} + // moveCursor shifts the cursor by delta rows, clamped to the visible range. func (m *Model) moveCursor(delta int) { m.cursor += delta diff --git a/internal/picker/view.go b/internal/picker/view.go index f993101..e6d9b47 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -116,7 +116,11 @@ func (m Model) footerView(t theme, width, matched int, met Metrics) string { count := t.muted_().Render(fmt.Sprintf("%d/%d", matched, len(m.rows))) gap := max(width-lipgloss.Width(keys)-lipgloss.Width(count), 1) if m.message != "" { - keys = t.fg(t.red).Render(truncateName(m.message, max(width-lipgloss.Width(count)-1, 1))) + colour := t.red + if m.messageOK { + colour = t.green + } + keys = t.fg(colour).Render(truncateName(m.message, max(width-lipgloss.Width(count)-1, 1))) gap = max(width-lipgloss.Width(keys)-lipgloss.Width(count), 1) } lines = append(lines, keys+strings.Repeat(" ", gap)+count) From a3b3216b46f56fbf092af5523866165ada12cf8d Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:36:44 +0500 Subject: [PATCH 4/5] docs: document the copy built-in Action (#66) --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9acaf56..7f20376 100644 --- a/README.md +++ b/README.md @@ -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: @@ -260,7 +267,7 @@ layout = "list" key map. - `[actions.]`: 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. From 4c851f45b4aba91c4781fa6346a1d3297e0ae529 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Fri, 2 Oct 2026 01:37:04 +0500 Subject: [PATCH 5/5] test(picker): drop an unused assignment (#66) --- internal/picker/copy_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/picker/copy_test.go b/internal/picker/copy_test.go index 9fd9c36..00e59ff 100644 --- a/internal/picker/copy_test.go +++ b/internal/picker/copy_test.go @@ -51,8 +51,8 @@ func TestModel_Copy_UsesTheClipboardProgramAndStaysOpen(t *testing.T) { func TestModel_Copy_WithoutAProgramFallsBackToOSC52(t *testing.T) { m := copyModel(func(string) (bool, error) { return false, nil }) - m, cmd := press(m, tea.KeyPressMsg{Code: tea.KeyDown}) - m, cmd = press(m, ctrlY) + 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")