diff --git a/README.md b/README.md index 619fcf1..3fe030e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ? 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 @@ -303,7 +313,7 @@ hints = true key map. - `[actions.]`: 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. diff --git a/internal/action/action.go b/internal/action/action.go index 3d826bd..a367b19 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -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.] table sets. A nil // field is one the table left out, so a built-in keeps its own value. type Override struct { @@ -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. @@ -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 @@ -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) diff --git a/internal/action/copy_test.go b/internal/action/copy_test.go index c1eb0bf..cfa47ef 100644 --- a/internal/action/copy_test.go +++ b/internal/action/copy_test.go @@ -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) } @@ -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) } } diff --git a/internal/action/forget_test.go b/internal/action/forget_test.go new file mode 100644 index 0000000..1da0a58 --- /dev/null +++ b/internal/action/forget_test.go @@ -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) + } +} diff --git a/internal/jump/forget_test.go b/internal/jump/forget_test.go new file mode 100644 index 0000000..a2e5cfa --- /dev/null +++ b/internal/jump/forget_test.go @@ -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") + } +} diff --git a/internal/jump/jump.go b/internal/jump/jump.go index a233a1b..180a643 100644 --- a/internal/jump/jump.go +++ b/internal/jump/jump.go @@ -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 } @@ -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 { @@ -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 { diff --git a/internal/picker/forget.go b/internal/picker/forget.go new file mode 100644 index 0000000..dbb20b2 --- /dev/null +++ b/internal/picker/forget.go @@ -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 +} diff --git a/internal/picker/forget_test.go b/internal/picker/forget_test.go new file mode 100644 index 0000000..d0e83ba --- /dev/null +++ b/internal/picker/forget_test.go @@ -0,0 +1,268 @@ +package picker_test + +import ( + "errors" + "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 ( + ctrlD = tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl} + keyY = tea.KeyPressMsg{Code: 'y', Text: "y"} + keyN = tea.KeyPressMsg{Code: 'n', Text: "n"} + down = tea.KeyPressMsg{Code: tea.KeyDown} +) + +// forgetAction is the built-in forget Action, bound to ctrl+d. +var forgetAction = action.Action{Name: "forget", Key: "ctrl+d", Internal: action.InternalForget} + +// forgetModel is a sized Model over alpha, beta and gamma with the Jump and +// forget Actions bound, and forget as the Store's Forget. It returns the +// paths forget was asked for. +func forgetModel(vim bool, forget func(string) error) (picker.Model, *[]string) { + var asked []string + rows := []picker.Row{ + {Project: picker.Project{Dir: "~/work/", Name: "alpha", Path: "/root/work/alpha"}}, + {Project: picker.Project{Dir: "~/work/", Name: "beta", Path: "/root/work/beta"}}, + {Project: picker.Project{Dir: "~/work/", Name: "gamma", Path: "/root/work/gamma"}}, + } + m := picker.NewModel(rows, noopStatus, picker.Options{ + Vim: vim, + Actions: []action.Action{jump, forgetAction}, + Forget: func(path string) error { + asked = append(asked, path) + if forget == nil { + return nil + } + return forget(path) + }, + }) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + return next.(picker.Model), &asked +} + +// aboveFooter is the View without its last line, where a message that names +// a Project would otherwise look like a row. +func aboveFooter(m picker.Model) string { + lines := strings.Split(plain(m.View().Content), "\n") + return strings.Join(lines[:len(lines)-1], "\n") +} + +// selected is the name of the row under the cursor, found by choosing it. +func selected(t *testing.T, m picker.Model) string { + t.Helper() + m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + row, ok := m.Chosen() + if !ok { + t.Fatal("enter chose nothing") + } + return row.Project.Name +} + +func TestModel_Forget_AsksOnTheFooterAndKeepsTheRowUntilConfirmed(t *testing.T) { + m, asked := forgetModel(false, nil) + + m, _ = press(m, down) + m, cmd := press(m, ctrlD) + + if line := footerOf(m); !strings.HasPrefix(line, "forget beta? y/n") { + t.Errorf("footer = %q, want the prompt in place of the hints", line) + } + if len(*asked) != 0 || cmd != nil { + t.Errorf("forgot %v, Cmd = %v, want nothing before the answer", *asked, cmd != nil) + } + if v := plain(m.View().Content); !strings.Contains(v, "beta") { + t.Errorf("View lost the row before the answer:\n%s", v) + } +} + +func TestModel_Forget_YForgetsAndDropsTheRow(t *testing.T) { + m, asked := forgetModel(false, nil) + + m, _ = press(m, down) + m, _ = press(m, ctrlD) + m, _ = press(m, keyY) + + if len(*asked) != 1 || (*asked)[0] != "/root/work/beta" { + t.Errorf("forgot %v, want the path of beta", *asked) + } + if v := aboveFooter(m); strings.Contains(v, "beta") || !strings.Contains(v, "alpha") || !strings.Contains(v, "gamma") { + t.Errorf("View still lists beta, or lost another row:\n%s", v) + } + if line := footerOf(m); !strings.HasPrefix(line, "forgot beta") || !strings.HasSuffix(line, "2/2") { + t.Errorf("footer = %q, want the confirmation and the new count", line) + } +} + +func TestModel_Forget_AnyOtherKeyCancelsAndIsConsumed(t *testing.T) { + for _, key := range []tea.KeyPressMsg{keyN, {Code: tea.KeyEscape}, {Code: tea.KeyEnter}, {Code: 'c', Mod: tea.ModCtrl}, down, {Code: 'x', Text: "x"}} { + m, asked := forgetModel(false, nil) + + m, _ = press(m, ctrlD) + m, cmd := press(m, key) + + if len(*asked) != 0 { + t.Errorf("%v: forgot %v, want nothing", key, *asked) + } + if _, chosen := m.Chosen(); chosen || cmd != nil { + t.Errorf("%v: Chosen = %v, Cmd = %v, want the key consumed", key, chosen, cmd != nil) + } + if line := footerOf(m); !strings.HasPrefix(line, "enter jump") { + t.Errorf("%v: footer = %q, want the hints back", key, line) + } + if got := selected(t, m); got != "alpha" { + t.Errorf("%v: cursor on %q, want it unmoved on alpha", key, got) + } + } +} + +func TestModel_Forget_TheAnswerIsNotTypedIntoTheFilter(t *testing.T) { + for _, vim := range []bool{false, true} { + m, _ := forgetModel(vim, nil) + if vim { + m, _ = press(m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + } + + m, _ = press(m, ctrlD) + m, _ = press(m, keyN) + m, _ = press(m, ctrlD) + m, _ = press(m, keyY) + + if v := plain(m.View().Content); !strings.Contains(v, "alpha") || !strings.Contains(v, "beta") { + t.Errorf("vim=%v: the filter ate an answer:\n%s", vim, v) + } + } +} + +func TestModel_Forget_CursorStaysOnTheNeighbouringRow(t *testing.T) { + tests := []struct { + name string + moves int + want string // the row under the cursor afterwards + }{ + {"first row, the next one takes its place", 0, "beta"}, + {"middle row, the one below takes its place", 1, "gamma"}, + {"last row, the cursor steps up", 2, "beta"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, _ := forgetModel(false, nil) + for range tt.moves { + m, _ = press(m, down) + } + + m, _ = press(m, ctrlD) + m, _ = press(m, keyY) + + if got := selected(t, m); got != tt.want { + t.Errorf("cursor on %q, want %q", got, tt.want) + } + }) + } +} + +func TestModel_Forget_TheOnlyRowLeavesAnEmptyList(t *testing.T) { + var asked []string + rows := []picker.Row{{Project: picker.Project{Dir: "~/work/", Name: "alpha", Path: "/root/work/alpha"}}} + m := picker.NewModel(rows, noopStatus, picker.Options{ + Actions: []action.Action{jump, forgetAction}, + Forget: func(path string) error { asked = append(asked, path); return nil }, + }) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + m = next.(picker.Model) + + m, _ = press(m, ctrlD) + m, _ = press(m, keyY) + + if len(asked) != 1 { + t.Errorf("forgot %v, want alpha", asked) + } + if v := plain(m.View().Content); strings.Contains(v, "alpha") { + t.Errorf("View still lists alpha:\n%s", v) + } + // With no row left, forgetting again does nothing. + m, _ = press(m, ctrlD) + if v := plain(m.View().Content); strings.Contains(v, "forget") { + t.Errorf("View = %q, want no prompt with no row", v) + } +} + +func TestModel_Forget_UnderAFilterDropsTheRowAmongTheVisibleOnes(t *testing.T) { + m, asked := forgetModel(false, nil) + m, _ = press(m, tea.KeyPressMsg{Code: 'a', Text: "a"}) + m, _ = press(m, tea.KeyPressMsg{Code: 'm', Text: "m"}) + + m, _ = press(m, ctrlD) + m, _ = press(m, keyY) + + if len(*asked) != 1 || (*asked)[0] != "/root/work/gamma" { + t.Errorf("forgot %v, want the path of gamma, the best match for \"am\"", *asked) + } + if v := aboveFooter(m); strings.Contains(v, "gamma") { + t.Errorf("View still lists gamma:\n%s", v) + } +} + +func TestModel_Forget_FailureShowsOnTheFooterAndKeepsTheRow(t *testing.T) { + m, _ := forgetModel(false, func(string) error { return errors.New("disk full") }) + + m, _ = press(m, down) + m, _ = press(m, ctrlD) + m, cmd := press(m, keyY) + + if line := footerOf(m); !strings.HasPrefix(line, "forget: disk full") || !strings.HasSuffix(line, "3/3") { + t.Errorf("footer = %q, want the error and the unchanged count", line) + } + if v := plain(m.View().Content); !strings.Contains(v, "beta") { + t.Errorf("View lost beta although forgetting failed:\n%s", v) + } + if got := selected(t, m); got != "beta" { + t.Errorf("cursor on %q, want it kept on beta", got) + } + if cmd != nil { + t.Error("Cmd != nil, want the Picker left open") + } +} + +func TestModel_Forget_WithoutAForgetFuncAsksNothing(t *testing.T) { + m := actionModel(&fakeRunner{}, false, jump, forgetAction) + + m, _ = press(m, ctrlD) + + if line := footerOf(m); strings.Contains(line, "forget alpha?") || !strings.HasPrefix(line, "enter jump") { + t.Errorf("footer = %q, want the hints and no prompt that cannot be honoured", line) + } +} + +func TestModel_Forget_TheVimHelpOverlayDescribesIt(t *testing.T) { + m, _ := forgetModel(true, nil) + + m, _ = press(m, tea.KeyPressMsg{Code: '?', Text: "?"}) + + if v := plain(m.View().Content); !strings.Contains(v, "ctrl+d") || !strings.Contains(v, "forgets the Project") { + t.Errorf("help overlay lacks forget:\n%s", v) + } +} + +func TestModel_Forget_ReboundKeyAsksToo(t *testing.T) { + moved := forgetAction + moved.Key = "ctrl+k" + m := twoRowModel(picker.Options{Actions: []action.Action{jump, moved}, Forget: func(string) error { return nil }}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + m = next.(picker.Model) + + m, _ = press(m, tea.KeyPressMsg{Code: 'k', Mod: tea.ModCtrl}) + + if line := footerOf(m); !strings.HasPrefix(line, "forget alpha? y/n") { + t.Errorf("footer = %q, want the prompt on the rebound key", line) + } +} diff --git a/internal/picker/model.go b/internal/picker/model.go index acfb690..58308bb 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -29,8 +29,9 @@ type hit struct { matches []int // rune indexes into the matched string, for highlighting } -// Model is the Picker's Bubble Tea Model. It never mutates rows: filtering -// and grouping are recomputed from it as the query and window size change. +// Model is the Picker's Bubble Tea Model. It never mutates rows in place: +// filtering and grouping are recomputed from it as the query and window +// size change, and forgetting a Project swaps in a copy without its row. type Model struct { rows []Row status StatusFunc @@ -43,6 +44,8 @@ type Model struct { help bool // the vim help overlay is open runner action.Runner copy func(text string) (bool, error) + forget func(path string) error // Options.Forget + confirm *Row // the row the forget prompt is asking about query string focus focus @@ -109,6 +112,7 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model { noHints: opts.HideHints, runner: runner, copy: copyFn, + forget: opts.Forget, rows: rows, status: status, vim: opts.Vim, diff --git a/internal/picker/picker.go b/internal/picker/picker.go index efeef49..5a9d0cd 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -102,6 +102,12 @@ type Options struct { // 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) + + // Forget records a Project as Forgotten, for the Action with Internal + // InternalForget once the user confirms. The Picker then drops the row. + // When it fails, the Picker shows why and keeps the row. With no + // Forget, the Action asks nothing and does nothing. + Forget func(path string) 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 ea578b4..2accefc 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -44,6 +44,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // the same name. func (m Model) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.message, m.messageOK = "", false + if m.confirm != nil { + return m.updateKeyConfirm(msg) + } if m.help { return m.updateKeyHelp(msg) } @@ -165,8 +168,11 @@ func (m Model) runAction(a action.Action) (tea.Model, tea.Cmd) { } row := rows[m.cursor].row - if a.Copy { + switch a.Internal { + case action.InternalCopy: return m.copyPath(a, row.Project.Path) + case action.InternalForget: + return m.askForget(row) } if !a.Detach { m.chosen = true diff --git a/internal/picker/view_footer.go b/internal/picker/view_footer.go index f2bf8e7..bb2f546 100644 --- a/internal/picker/view_footer.go +++ b/internal/picker/view_footer.go @@ -26,8 +26,8 @@ func (m Model) footerView(t theme, width, matched int, met Metrics) string { // keysLine is the footer's one line: on the left the message, else the key // hints, and on the right the match count, with a pointer to the help -// overlay ahead of it where `?` opens one. A message replaces the hints -// until the next key press. +// overlay ahead of it where `?` opens one. A message, or the forget +// prompt, replaces the hints until the next key press. func (m Model) keysLine(t theme, width, matched int) string { right := t.muted_().Render(fmt.Sprintf("%d/%d", matched, len(m.rows))) if m.helpAvailable() && !m.noHints { @@ -37,6 +37,8 @@ func (m Model) keysLine(t theme, width, matched int) string { var left string switch { + case m.confirm != nil: + left = t.fg(t.accent).Render(truncateName("forget "+m.confirm.Project.Name+"? y/n", room)) case m.message != "": left = t.messageLine(m.message, m.messageOK, room) case !m.noHints: diff --git a/internal/picker/view_help.go b/internal/picker/view_help.go index 59e0686..1d6738d 100644 --- a/internal/picker/view_help.go +++ b/internal/picker/view_help.go @@ -61,8 +61,10 @@ func (m Model) helpView(t theme, width, height int) string { // describe says in a few words what running a does. func describe(a action.Action) string { switch { - case a.Copy: + case a.Internal == action.InternalCopy: return "copies the path" + case a.Internal == action.InternalForget: + return "forgets the Project" case a.Run != "": return a.Run case a.Jump: