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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ cdd forget ~/old # hide a Project for good, git repository or not
cdd forget # the current directory
```

In the Picker, `ctrl+d` forgets the selected Project after a `y/n` confirm.

An added directory is listed even with no Visits (after the visited ones)
and drops out once the directory is gone. A forgotten Project is never
listed, and `cdd scan` does not bring it back. `cdd add` on a forgotten
Expand Down Expand Up @@ -210,6 +212,14 @@ the fields it sets. The built-in Actions are:
| `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 |
| `forget` | `ctrl+d` | Forgets the Project, as `cdd forget` does, once you confirm |

`forget` asks `forget <name>? y/n` on the last line. `y` forgets the Project
and drops its row, leaving the cursor on the row that took its place; any
other key cancels, and is not typed into the filter. If cdd cannot write the
`projects` file, the reason shows in red and the row stays. `cdd add`
brings a forgotten Project back. Setting `run` or `jump` replaces it with an
ordinary command.

`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
Expand Down Expand Up @@ -303,7 +313,7 @@ hints = true
key map.
- `[actions.<name>]`: an Action, with `key`, `run`, `jump` and `detach`;
see [Actions](#actions). Not in the defaults above; `jump`, `files`,
`editor`, `remote` and `copy` exist unless you override them.
`editor`, `remote`, `copy` and `forget` 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
29 changes: 20 additions & 9 deletions internal/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,21 @@ type Action struct {
// 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
// Internal names a job the Picker does itself, with no Run:
// InternalCopy copies the Project's path to the clipboard (the OSC 52
// fallback has to go out through the Picker's own terminal), and
// InternalForget forgets the Project after a confirm. Either way the
// Picker stays open. Only a built-in sets it. A user's "run" or "jump"
// for the Action replaces it.
Internal string
}

// The Picker's own jobs, for Action.Internal.
const (
InternalCopy = "copy"
InternalForget = "forget"
)

// Override is the part of an Action a [actions.<name>] table sets. A nil
// field is one the table left out, so a built-in keeps its own value.
type Override struct {
Expand All @@ -61,7 +69,8 @@ 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},
{Name: "copy", Key: "ctrl+y", Internal: InternalCopy},
{Name: "forget", Key: "ctrl+d", Internal: InternalForget},
}

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

func TestBuiltins_CopyIsBoundToCtrlYWithNoCommand(t *testing.T) {
got := builtin(t, "copy")
want := action.Action{Name: "copy", Key: "ctrl+y", Copy: true}
want := action.Action{Name: "copy", Key: "ctrl+y", Internal: action.InternalCopy}
if got != want {
t.Errorf("copy = %+v, want %+v", got, want)
}
Expand All @@ -30,13 +30,13 @@ func TestMerge_CopyKeepsCopyUntilTheUserSetsRun(t *testing.T) {
return action.Action{}
}

if a := merged(action.Override{Key: ptr("ctrl+k")}); !a.Copy || a.Key != "ctrl+k" {
if a := merged(action.Override{Key: ptr("ctrl+k")}); a.Internal != action.InternalCopy || 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 != "" {
if a := merged(action.Override{Key: ptr("")}); a.Internal != action.InternalCopy || 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 {
if a := merged(action.Override{Run: ptr("wl-copy {path}"), Detach: ptr(true)}); a.Internal != "" || a.Run != "wl-copy {path}" || !a.Detach {
t.Errorf("run override = %+v, want a plain command, not Copy", a)
}
}
42 changes: 42 additions & 0 deletions internal/action/forget_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_ForgetIsBoundToCtrlDWithNoCommand(t *testing.T) {
got := builtin(t, "forget")
want := action.Action{Name: "forget", Key: "ctrl+d", Internal: action.InternalForget}
if got != want {
t.Errorf("forget = %+v, want %+v", got, want)
}
}

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

if a := merged(action.Override{Key: ptr("ctrl+k")}); a.Internal != action.InternalForget || a.Key != "ctrl+k" {
t.Errorf("rebound = %+v, want still forget on ctrl+k", a)
}
if a := merged(action.Override{Key: ptr("")}); a.Internal != action.InternalForget || a.Key != "" {
t.Errorf("unbound = %+v, want still forget, no key", a)
}
if a := merged(action.Override{Run: ptr("echo {path}")}); a.Internal != "" || a.Run != "echo {path}" {
t.Errorf("run override = %+v, want a plain command", a)
}
}
42 changes: 42 additions & 0 deletions internal/jump/forget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package jump_test

import (
"context"
"path/filepath"
"testing"

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

func TestResolve_PickerForgetWritesTheStore(t *testing.T) {
hist := newHistory(t)
cfg, root := mkProjects(t, hist, "tools/cdd", "tools/other")
path := filepath.Join(root, "tools", "cdd")
store := newStore(t)

pick := func(_ []picker.Row, _ picker.StatusFunc, o picker.Options) (picker.Choice, bool, error) {
if o.Forget == nil {
t.Fatal("Options.Forget = nil, want the Store's")
}
if err := o.Forget(path); err != nil {
t.Fatalf("Forget: %v", err)
}
return picker.Choice{}, false, nil
}
_, err := jump.Resolve(context.Background(), cfg, hist, store, pick, &fakeRunner{})

if err != jump.ErrCancelled {
t.Fatalf("Resolve error = %v, want ErrCancelled", err)
}
marks, err := store.Marks()
if err != nil {
t.Fatalf("Marks: %v", err)
}
if !marks.IsForgotten(path) {
t.Errorf("%s is not Forgotten after the Picker forgot it", path)
}
if marks.IsForgotten(filepath.Join(root, "tools", "other")) {
t.Error("another Project is Forgotten too")
}
}
7 changes: 4 additions & 3 deletions internal/jump/jump.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func Resolve(ctx context.Context, cfg config.Config, hist *history.History, stor
return "", fmt.Errorf("jump: %w", err)
}

choice, err := choose(cfg, listed(latest, marks), counts, pick, recorder{run, hist})
choice, err := choose(cfg, listed(latest, marks), counts, pick, recorder{run, hist}, store.Forget)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -114,8 +114,8 @@ func (r recorder) Start(a action.Action, path string) error {
}

// choose runs the Picker over latest, starting its detached Actions with
// run, and returns what it chose.
func choose(cfg config.Config, latest []history.Visit, counts map[string]int, pick PickFunc, run action.Runner) (picker.Choice, error) {
// run and forgetting Projects with forget, and returns what it chose.
func choose(cfg config.Config, latest []history.Visit, counts map[string]int, pick PickFunc, run action.Runner, forget func(string) error) (picker.Choice, error) {
home, _ := os.UserHomeDir()
rows := toRows(latest, counts, home)
status := func(c context.Context, dir string) git.Status {
Expand All @@ -129,6 +129,7 @@ func choose(cfg config.Config, latest []history.Visit, counts map[string]int, pi
Actions: cfg.ResolvedActions,
HideHints: !cfg.Picker.Hints,
Runner: run,
Forget: forget,
}
choice, ok, err := pick(rows, status, opts)
if err != nil {
Expand Down
41 changes: 41 additions & 0 deletions internal/picker/forget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package picker

import (
"fmt"
"slices"

tea "charm.land/bubbletea/v2"
)

// askForget puts the forget prompt on the footer for row. Until the next
// key it is the only thing that reads one. With no Options.Forget there is
// nothing to honour a yes, so it asks nothing.
func (m Model) askForget(row Row) (tea.Model, tea.Cmd) {
if m.forget != nil {
m.confirm = &row
}
return m, nil
}

// updateKeyConfirm handles a key press while the forget prompt is up: y
// forgets, any other key cancels. The key is consumed either way, so a
// "y" or "n" is never typed into the filter.
func (m Model) updateKeyConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
row := *m.confirm
m.confirm = nil
if msg.String() != "y" {
return m, nil
}
if err := m.forget(row.Project.Path); err != nil {
m.message = fmt.Sprintf("forget: %v", err)
return m, nil
}
// The row below, if any, takes the cursor's place, so the cursor stays
// at its index until clampCursor pulls it back off the end.
m.rows = slices.DeleteFunc(slices.Clone(m.rows), func(r Row) bool {
return r.Project.Path == row.Project.Path
})
m.clampCursor()
m.message, m.messageOK = "forgot "+row.Project.Name, true
return m, nil
}
Loading
Loading