diff --git a/README.md b/README.md index 8376e77..3cd171c 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ it reports purge timing instead. The JSON path is always color- and personality-free, and an empty store emits `[]` rather than `null`, so `sp ls --json | jq` stays predictable. -### `sp cat ` / `sp open ` +### `sp cat ` / `sp open [id]` Read or re-open a scratch. The `` may be an **unambiguous prefix** — you rarely need to type the full 8-char id. @@ -92,6 +92,7 @@ rarely need to type the full 8-char id. ```bash sp cat 1a2b # print a scratch's contents to stdout sp open 1a2b # re-open it in $EDITOR +sp open # no id → interactive picker over live scratches ``` - Both work on live scratches **and** ones sitting in the morgue. @@ -100,6 +101,18 @@ sp open 1a2b # re-open it in $EDITOR - As with `sp new`, `sp open` falls back to printing the path when `$EDITOR` is unset — the scratch is never inaccessible. +**Interactive picker.** Run `sp open` with no id to choose from the live +scratches without typing one out. Each row shows id, name, age, time-to-expiry, +and tags; type to fuzzy-filter (subsequence match, so `tdo` finds `todo`), then +pick: + +- If [`fzf`](https://github.com/junegunn/fzf) is on your `PATH`, it drives the + picker. Pass `--no-fzf` to force the built-in one instead. +- Otherwise a built-in prompt lists the scratches: type to filter, enter a + number to choose, a blank line to take the top match, or `q` to cancel. +- Piped / non-interactive input degrades to a one-shot numbered choice. +- **Esc / Ctrl-C / `q` cancels cleanly** — nothing is opened or changed. + ### `sp rm ` — soft-delete to the morgue Moves a scratch into the morgue. **This never destroys content** — it just diff --git a/internal/cli/lifecycle.go b/internal/cli/lifecycle.go index 6b5b586..5d6b207 100644 --- a/internal/cli/lifecycle.go +++ b/internal/cli/lifecycle.go @@ -3,10 +3,14 @@ package cli import ( "errors" "fmt" + "os" + "time" "github.com/spf13/cobra" "github.com/rwrife/scratchpatch/internal/index" + "github.com/rwrife/scratchpatch/internal/picker" + "github.com/rwrife/scratchpatch/internal/render" "github.com/rwrife/scratchpatch/internal/store" ) @@ -62,18 +66,31 @@ func runCat(cmd *cobra.Command, ref string) error { } func newOpenCommand() *cobra.Command { - return &cobra.Command{ - Use: "open ", + var noFzf bool + + cmd := &cobra.Command{ + Use: "open [id]", Short: "Re-open a scratch in $EDITOR", Long: "Re-open an existing scratch in $EDITOR. The id may be an unambiguous\n" + "prefix. A morgued scratch is opened in place in the morgue (resurrect\n" + "it first if you want it back among the living). When $EDITOR is unset,\n" + - "the scratch's path is printed so you can open it yourself.", - Args: cobra.ExactArgs(1), + "the scratch's path is printed so you can open it yourself.\n\n" + + "Called with no id, `sp open` launches an interactive picker over the\n" + + "live scratches: type to fuzzy-filter, pick one, and it opens. If `fzf`\n" + + "is installed it drives the picker; otherwise a built-in numbered filter\n" + + "prompt is used. Piped or non-interactive input degrades to a one-shot\n" + + "numbered choice. Esc / Ctrl-C / q cancels without changing anything.", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runOpen(cmd, args[0]) + if len(args) == 1 { + return runOpen(cmd, args[0]) + } + return runOpenPicker(cmd, !noFzf) }, } + + cmd.Flags().BoolVar(&noFzf, "no-fzf", false, "use the built-in picker even when fzf is installed") + return cmd } func runOpen(cmd *cobra.Command, ref string) error { @@ -85,7 +102,60 @@ func runOpen(cmd *cobra.Command, ref string) error { if err != nil { return err } + return openScratch(cmd, st, sc) +} +// runOpenPicker handles `sp open` with no id: it gathers the live scratches, +// lets the user pick one interactively, then opens the chosen scratch exactly +// as an explicit id would. allowFzf mirrors the inverse of --no-fzf. An empty +// store, or a user who cancels, is a clean no-op with a friendly line rather +// than an error — backing out of a picker should never feel like a failure. +func runOpenPicker(cmd *cobra.Command, allowFzf bool) error { + st, err := store.Open() + if err != nil { + return err + } + + live, err := st.ListLive() + if err != nil { + return err + } + if len(live) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no live scratches to open — create one with `sp new`") + return nil + } + + now := time.Now() + cands := make([]picker.Candidate, 0, len(live)) + for _, sc := range live { + cands = append(cands, picker.NewCandidate(sc, render.PickerLabel(sc, now))) + } + + streams := picker.IO{ + In: cmd.InOrStdin(), + Out: cmd.OutOrStdout(), + Err: cmd.ErrOrStderr(), + } + opts := picker.SelectDefaults(stdinIsTerminal(cmd)) + opts.AllowFzf = allowFzf + + chosen, err := picker.Select(streams, cands, opts) + if err != nil { + if errors.Is(err, picker.ErrCanceled) { + fmt.Fprintln(cmd.OutOrStdout(), "nothing opened — the slab stays as it was.") + return nil + } + return err + } + return openScratch(cmd, st, chosen.Scratch) +} + +// openScratch launches $EDITOR on a resolved scratch and prints the confirming +// line, refreshing the recorded size for live scratches afterward. It's the +// shared tail of both the id path (runOpen) and the picker path (runOpenPicker) +// so a scratch opens identically however it was chosen, and $EDITOR is still +// launched from exactly one place per command. +func openScratch(cmd *cobra.Command, st *store.Store, sc index.Scratch) error { path := st.LivePath(sc) out := cmd.OutOrStdout() @@ -109,6 +179,24 @@ func runOpen(cmd *cobra.Command, ref string) error { return nil } +// stdinIsTerminal reports whether the command's input is an interactive +// terminal, which the picker uses to decide between its interactive front-ends +// (fzf / filter loop) and the one-shot numbered degradation. It mirrors ls.go's +// isTerminal but inspects the input side; anything that isn't provably a +// character device (pipes, files, the buffers tests use) is treated as +// not-a-TTY so tests and scripts take the deterministic numbered path. +func stdinIsTerminal(cmd *cobra.Command) bool { + f, ok := cmd.InOrStdin().(*os.File) + if !ok { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + func newRmCommand() *cobra.Command { return &cobra.Command{ Use: "rm ", diff --git a/internal/cli/open_picker_test.go b/internal/cli/open_picker_test.go new file mode 100644 index 0000000..c54f5f6 --- /dev/null +++ b/internal/cli/open_picker_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +// runResult captures the combined output and error of a root invocation driven +// with a canned stdin, for the picker tests that need to feed the interactive +// prompt. It mirrors session.run but wires SetIn so `sp open`'s no-id picker has +// something to read. +type runResult struct { + out *bytes.Buffer + err error +} + +// newRootWithInput runs one `sp ...` invocation with stdin bound to input. It +// assumes SCRATCHPATCH_HOME/EDITOR are already set by the enclosing session, so +// it shares that store. Because a *bytes.Reader is not a TTY, the picker takes +// its deterministic non-TTY numbered path — exactly what these tests want. +func newRootWithInput(t *testing.T, input string, args ...string) runResult { + t.Helper() + root := NewRootCommand() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetIn(strings.NewReader(input)) + root.SetArgs(args) + err := root.Execute() + return runResult{out: &out, err: err} +} + +// TestOpenPickerNoScratchesIsFriendly verifies `sp open` with no id and an +// empty store prints a gentle pointer instead of an error. +func TestOpenPickerNoScratchesIsFriendly(t *testing.T) { + s := newSession(t) + out, err := s.run("open") + if err != nil { + t.Fatalf("open on empty store should not error; got %v (out=%s)", err, out) + } + if !strings.Contains(out, "no live scratches") { + t.Errorf("expected a friendly empty-store message; got %q", out) + } +} + +// TestOpenPickerNumberedSelectsAndOpens drives the no-id picker down its +// non-TTY numbered path (buffers aren't a TTY). Choosing "1" should resolve to +// the sole scratch; with $EDITOR unset the command falls back to printing the +// scratch's path, which proves the picked scratch flowed into the open logic. +func TestOpenPickerNumberedSelectsAndOpens(t *testing.T) { + s := newSession(t) + id := s.newScratchID("pick-me") + + root := newRootWithInput(t, "1\n", "open") + out := root.out.String() + if err := root.err; err != nil { + t.Fatalf("open picker: %v (out=%s)", err, out) + } + // The numbered list should have offered our scratch... + if !strings.Contains(out, id) { + t.Errorf("picker list should include scratch id %q; got %q", id, out) + } + // ...and selecting it should have driven the open path. $EDITOR is unset in + // tests, so we see the "is at " fallback naming the scratch. + if !strings.Contains(out, "is at") || !strings.Contains(out, id) { + t.Errorf("selecting the scratch should open it (path fallback expected); got %q", out) + } +} + +// TestOpenPickerCancelIsNoOp verifies that backing out of the picker (an empty +// line on the numbered path cancels) changes nothing and reports gently. +func TestOpenPickerCancelIsNoOp(t *testing.T) { + s := newSession(t) + _ = s.newScratchID("leave-me") + + root := newRootWithInput(t, "\n", "open") + out := root.out.String() + if err := root.err; err != nil { + t.Fatalf("cancelling the picker should not error; got %v (out=%s)", err, out) + } + if !strings.Contains(out, "nothing opened") { + t.Errorf("a cancelled picker should say nothing was opened; got %q", out) + } +} + +// TestOpenStillTakesExplicitID guards the original behavior: `sp open ` +// bypasses the picker entirely and opens the named scratch. +func TestOpenStillTakesExplicitID(t *testing.T) { + s := newSession(t) + id := s.newScratchID("direct") + + out, err := s.run("open", id) + if err != nil { + t.Fatalf("open : %v (out=%s)", err, out) + } + // EDITOR unset → path fallback naming the scratch. + if !strings.Contains(out, id) { + t.Errorf("open should act on that scratch; got %q", out) + } +} diff --git a/internal/picker/match.go b/internal/picker/match.go new file mode 100644 index 0000000..67cbc59 --- /dev/null +++ b/internal/picker/match.go @@ -0,0 +1,153 @@ +// Package picker turns "which scratch did you mean?" into a small, testable +// core plus a couple of thin front-ends. +// +// The heart of the package is pure and I/O-free: given the live scratches and a +// query string, Filter ranks them by a subsequence fuzzy match so the caller +// can show the best candidates first. The interactive front-ends (an +// fzf hand-off and a built-in numbered/filter prompt) live alongside it but keep +// all their terminal and process concerns out of the matcher, so ranking stays +// trivially unit-testable. +// +// picker never opens anything itself. It only decides *which* scratch the user +// picked and hands that back; `sp open` owns the $EDITOR launch, exactly as it +// does for an explicit id. That keeps the "one place opens editors" boundary +// (new.go's openInEditor) intact. +package picker + +import ( + "sort" + "strings" + + "github.com/rwrife/scratchpatch/internal/index" +) + +// Candidate is a scratch paired with everything the picker needs to display and +// rank it, flattened so the picker never reaches back into the store. label is +// the human line shown in a prompt; haystack is the lowercased text a query is +// matched against (id + name + tags), so filtering by any of those just works. +type Candidate struct { + Scratch index.Scratch + Label string + haystack string + score int +} + +// NewCandidate builds a Candidate from a scratch and its pre-rendered display +// label. The label is supplied by the caller (render owns presentation), while +// the match haystack is derived here from the fields a user is likely to type: +// the id, the name, and any tags. +func NewCandidate(sc index.Scratch, label string) Candidate { + parts := make([]string, 0, 2+len(sc.Tags)) + parts = append(parts, sc.ID, sc.Name) + parts = append(parts, sc.Tags...) + return Candidate{ + Scratch: sc, + Label: label, + haystack: strings.ToLower(strings.Join(parts, " ")), + } +} + +// Filter returns the candidates whose haystack fuzzily matches query, best +// match first. An empty (or whitespace-only) query matches everything and +// preserves the input order, so it doubles as "show me all of them". Matching +// is case-insensitive subsequence matching: the query's characters must appear +// in order but not necessarily adjacently, the same feel as fzf, so "tdo" +// matches "todo". +// +// The input slice is never mutated; a new, ranked slice is returned. +func Filter(cands []Candidate, query string) []Candidate { + q := strings.ToLower(strings.TrimSpace(query)) + + if q == "" { + out := make([]Candidate, len(cands)) + copy(out, cands) + return out + } + + type ranked struct { + cand Candidate + idx int // original position, for a stable tie-break + } + var hits []ranked + for i, c := range cands { + score, ok := fuzzyScore(c.haystack, q) + if !ok { + continue + } + c.score = score + hits = append(hits, ranked{cand: c, idx: i}) + } + + // Higher score first; ties fall back to original order so the result is + // deterministic and the newest-first listing order shows through. + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].cand.score != hits[j].cand.score { + return hits[i].cand.score > hits[j].cand.score + } + return hits[i].idx < hits[j].idx + }) + + out := make([]Candidate, len(hits)) + for i, h := range hits { + out[i] = h.cand + } + return out +} + +// fuzzyScore reports whether query is a subsequence of haystack and, if so, how +// good a match it is. Both are expected to already be lowercased. The score +// rewards matches that are contiguous and that land at word boundaries, so a +// tight, prefix-y hit ("todo" in "todo-list") outranks a scattered one ("tol" +// in "trouble-loop"). A query longer than the haystack, or one whose characters +// don't all appear in order, is not a match. +func fuzzyScore(haystack, query string) (int, bool) { + if query == "" { + return 0, true + } + if len(query) > len(haystack) { + return 0, false + } + + hs := []rune(haystack) + qs := []rune(query) + + score := 0 + qi := 0 + prevMatch := -2 // so the first match is never counted as "adjacent" + for hi := 0; hi < len(hs) && qi < len(qs); hi++ { + if hs[hi] != qs[qi] { + continue + } + // Base point for the matched character. + score++ + // Bonus for consecutive matches — contiguous runs feel like the + // "real" match a user typed. + if hi == prevMatch+1 { + score += 3 + } + // Bonus for matching at the start or just after a separator, which is + // where meaningful words begin (id start, name start, tag start). + if hi == 0 || isBoundary(hs[hi-1]) { + score += 2 + } + prevMatch = hi + qi++ + } + + if qi != len(qs) { + return 0, false // ran out of haystack before consuming the query + } + return score, true +} + +// isBoundary reports whether r is the kind of character that precedes the start +// of a new "word" in a scratch's searchable text, so a match right after it +// earns the word-boundary bonus. +func isBoundary(r rune) bool { + switch r { + case ' ', '-', '_', '.', '/', ':': + return true + default: + return false + } +} diff --git a/internal/picker/match_test.go b/internal/picker/match_test.go new file mode 100644 index 0000000..b78c4a2 --- /dev/null +++ b/internal/picker/match_test.go @@ -0,0 +1,123 @@ +package picker + +import ( + "testing" + "time" + + "github.com/rwrife/scratchpatch/internal/index" +) + +// cand is a tiny constructor for tests: it builds a Candidate from an id, name, +// and tags, using the id as the label (labels are opaque to matching, so the +// exact string doesn't matter here — only the derived haystack does). +func cand(id, name string, tags ...string) Candidate { + sc := index.Scratch{ID: id, Name: name, Tags: tags, CreatedAt: time.Now()} + return NewCandidate(sc, id+" "+name) +} + +// ids extracts the scratch ids from a candidate slice, in order, so tests can +// assert on ranking with a compact []string comparison. +func ids(cands []Candidate) []string { + out := make([]string, len(cands)) + for i, c := range cands { + out[i] = c.Scratch.ID + } + return out +} + +func TestFilterEmptyQueryReturnsAllInOrder(t *testing.T) { + in := []Candidate{cand("aaa1", "alpha"), cand("bbb2", "beta"), cand("ccc3", "gamma")} + got := Filter(in, " ") + if want := []string{"aaa1", "bbb2", "ccc3"}; !equal(ids(got), want) { + t.Errorf("empty query should return all in input order; got %v want %v", ids(got), want) + } + // The input slice must not be mutated. + if in[0].Scratch.ID != "aaa1" { + t.Error("Filter mutated its input slice") + } +} + +func TestFilterMatchesByNameSubsequence(t *testing.T) { + in := []Candidate{cand("1", "todo-list"), cand("2", "README"), cand("3", "grocery")} + got := Filter(in, "tdo") // subsequence of "todo" + if len(got) != 1 || got[0].Scratch.ID != "1" { + t.Fatalf("expected only todo-list to match \"tdo\"; got %v", ids(got)) + } +} + +func TestFilterMatchesByIDAndTag(t *testing.T) { + in := []Candidate{ + cand("deadbeef", "notes"), + cand("cafef00d", "budget", "finance", "q3"), + } + // Match by a slice of the id. + if got := Filter(in, "beef"); len(got) != 1 || got[0].Scratch.ID != "deadbeef" { + t.Errorf("id substring should match; got %v", ids(got)) + } + // Match by a tag. + if got := Filter(in, "finance"); len(got) != 1 || got[0].Scratch.ID != "cafef00d" { + t.Errorf("tag should be searchable; got %v", ids(got)) + } +} + +func TestFilterIsCaseInsensitive(t *testing.T) { + in := []Candidate{cand("1", "MyNotes")} + if got := Filter(in, "mynotes"); len(got) != 1 { + t.Errorf("matching should ignore case; got %v", ids(got)) + } +} + +func TestFilterRanksContiguousAndBoundaryHigher(t *testing.T) { + // "todo" appears as a clean word-start run in the first, and only as a + // scattered subsequence in the second, so the first should rank ahead. + in := []Candidate{ + cand("2", "t-o-d-o-scattered"), // t...o...d...o but broken up + cand("1", "todo-list"), // contiguous, at the start + } + got := Filter(in, "todo") + if len(got) != 2 { + t.Fatalf("both should match \"todo\"; got %v", ids(got)) + } + if got[0].Scratch.ID != "1" { + t.Errorf("contiguous word-boundary match should rank first; got order %v", ids(got)) + } +} + +func TestFilterNoMatchReturnsEmpty(t *testing.T) { + in := []Candidate{cand("1", "alpha"), cand("2", "beta")} + if got := Filter(in, "zzz"); len(got) != 0 { + t.Errorf("a query with no subsequence match should return empty; got %v", ids(got)) + } +} + +func TestFilterQueryLongerThanHaystackDoesNotMatch(t *testing.T) { + in := []Candidate{cand("ab", "x")} // haystack "ab x" — shorter than the query + if got := Filter(in, "abcdefghij"); len(got) != 0 { + t.Errorf("an over-long query cannot match; got %v", ids(got)) + } +} + +func TestFuzzyScoreOrderMatters(t *testing.T) { + // "ba" is not a subsequence of "abc" (b comes after a, but a-then-b order + // fails since there's no b after the a? actually a,b are in order) — use a + // clearer non-match: "ca" is not a subsequence of "abc". + if _, ok := fuzzyScore("abc", "ca"); ok { + t.Error(`"ca" should not be a subsequence of "abc"`) + } + if _, ok := fuzzyScore("abc", "ac"); !ok { + t.Error(`"ac" should be a subsequence of "abc"`) + } +} + +// equal compares two string slices element-wise. +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/picker/select.go b/internal/picker/select.go new file mode 100644 index 0000000..8389ddc --- /dev/null +++ b/internal/picker/select.go @@ -0,0 +1,241 @@ +package picker + +import ( + "bufio" + "fmt" + "io" + "os/exec" + "strconv" + "strings" +) + +// ErrCanceled is returned when the user backs out of the picker (Esc / Ctrl-C / +// EOF at the prompt, or an fzf that exited without a selection). It's a normal, +// no-op outcome — the caller should report it gently and change nothing, per +// scratchpatch's "cancelling is always safe" stance. +var ErrCanceled = fmt.Errorf("selection canceled") + +// IO bundles the streams the interactive picker talks to. Threading these +// through (rather than reaching for os.Stdin/Stdout directly) keeps the prompt +// testable: a test drives it with buffers, real use wires it to the process +// stdio. +type IO struct { + In io.Reader + Out io.Writer + Err io.Writer +} + +// Options tunes how Select chooses a front-end. TTY reports whether the session +// is attached to a real terminal (the caller detects this); when false, Select +// skips both fzf and the interactive filter loop and uses the one-shot numbered +// prompt so piped/non-interactive use still degrades cleanly. AllowFzf lets the +// caller force the built-in picker even when fzf is installed (e.g. a future +// --no-fzf flag); it defaults to true via SelectDefaults. +type Options struct { + TTY bool + AllowFzf bool + // lookFzf resolves the fzf binary; overridable in tests. nil means "use + // exec.LookPath". Unexported so the public surface stays small. + lookFzf func() (string, bool) + // runFzf runs fzf with the given candidate labels and returns the chosen + // label; overridable in tests. nil means "spawn the real fzf". + runFzf func(io IO, labels []string) (string, error) +} + +// Select is the picker entry point `sp open` uses when given no id. It presents +// the candidates and returns the chosen scratch, or ErrCanceled if the user +// backs out. The front-end is chosen in priority order: +// +// 1. fzf, when it's installed, allowed, and we're on a TTY — the issue's +// "detect and defer to fzf if present". +// 2. the built-in interactive filter prompt, on a TTY without fzf — a +// keyboard-driven, fuzzy-filterable numbered list. +// 3. a one-shot numbered prompt, when not a TTY — the graceful degradation +// required for pipes and dumb terminals. +// +// An empty candidate slice is a caller error (there's nothing to pick); Select +// returns ErrCanceled so `sp open` can print a friendly "no scratches" line. +func Select(streams IO, cands []Candidate, opts Options) (Candidate, error) { + if len(cands) == 0 { + return Candidate{}, ErrCanceled + } + + look := opts.lookFzf + if look == nil { + look = lookPathFzf + } + + if opts.TTY && opts.AllowFzf { + if path, ok := look(); ok { + run := opts.runFzf + if run == nil { + run = func(streams IO, labels []string) (string, error) { + return runRealFzf(streams, path, labels) + } + } + return selectViaFzf(streams, cands, run) + } + } + + if opts.TTY { + return selectInteractive(streams, cands) + } + return selectNumbered(streams, cands) +} + +// SelectDefaults returns Options wired for real use: fzf allowed, TTY-ness as +// detected by the caller. Kept separate from Select so tests can construct +// Options with fakes without going through defaulting. +func SelectDefaults(tty bool) Options { + return Options{TTY: tty, AllowFzf: true} +} + +// lookPathFzf reports whether an `fzf` binary is on PATH. +func lookPathFzf() (string, bool) { + path, err := exec.LookPath("fzf") + if err != nil { + return "", false + } + return path, true +} + +// selectViaFzf hands the candidate labels to fzf (through run), then maps the +// chosen line back to its Candidate. fzf exiting with no selection (the user hit +// Esc, or filtered to nothing and pressed enter) surfaces as ErrCanceled. +func selectViaFzf(streams IO, cands []Candidate, run func(IO, []string) (string, error)) (Candidate, error) { + labels := make([]string, len(cands)) + byLabel := make(map[string]Candidate, len(cands)) + for i, c := range cands { + labels[i] = c.Label + byLabel[c.Label] = c + } + + chosen, err := run(streams, labels) + if err != nil { + return Candidate{}, err + } + chosen = strings.TrimRight(chosen, "\r\n") + if chosen == "" { + return Candidate{}, ErrCanceled + } + c, ok := byLabel[chosen] + if !ok { + // fzf returned something we didn't offer (shouldn't happen); treat it + // as a cancel rather than opening the wrong thing. + return Candidate{}, ErrCanceled + } + return c, nil +} + +// runRealFzf spawns fzf, feeding it the labels on stdin and reading the picked +// line from stdout. fzf draws its own UI on the terminal via /dev/tty, so we +// leave the process's stderr attached for that. A non-zero exit with no output +// (code 130 = Esc/Ctrl-C, 1 = no match selected) is a cancel, not an error. +func runRealFzf(streams IO, path string, labels []string) (string, error) { + cmd := exec.Command(path, + "--prompt", "open scratch> ", + "--height", "40%", + "--reverse", + "--no-multi", + ) + cmd.Stdin = strings.NewReader(strings.Join(labels, "\n") + "\n") + cmd.Stderr = streams.Err + + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + // fzf's documented cancel codes: 130 (interrupt) and 1 (no match). + if code := exitErr.ExitCode(); code == 130 || code == 1 { + return "", ErrCanceled + } + } + return "", fmt.Errorf("run fzf: %w", err) + } + return string(out), nil +} + +// selectInteractive runs the built-in, dependency-free picker on a TTY: it +// prints the numbered candidate list, then reads lines from the user. A number +// picks that row; any other text is treated as a fuzzy query that re-filters and +// re-prints the list. An empty line accepts the current top candidate, a bare +// "q" or EOF cancels. This gives the "keyboard-driven filter/fuzzy match" the +// issue asks for without putting the terminal into raw mode. +func selectInteractive(streams IO, cands []Candidate) (Candidate, error) { + reader := bufio.NewReader(streams.In) + filtered := cands + + fmt.Fprintln(streams.Out, "pick a scratch to open — type to filter, a number to choose, q to cancel:") + printList(streams.Out, filtered) + + for { + fmt.Fprint(streams.Out, "> ") + line, err := reader.ReadString('\n') + if err != nil && line == "" { + // EOF or read error with nothing typed: treat as cancel. + fmt.Fprintln(streams.Out) + return Candidate{}, ErrCanceled + } + input := strings.TrimSpace(line) + + switch { + case input == "q": + return Candidate{}, ErrCanceled + case input == "": + // Accept the current best (top of the filtered list). + if len(filtered) == 0 { + fmt.Fprintln(streams.Out, "nothing matches — refine your filter or q to cancel.") + continue + } + return filtered[0], nil + } + + // A pure number in range selects that row directly. + if n, perr := strconv.Atoi(input); perr == nil { + if n >= 1 && n <= len(filtered) { + return filtered[n-1], nil + } + fmt.Fprintf(streams.Out, "pick a number between 1 and %d, or type to filter.\n", len(filtered)) + continue + } + + // Otherwise treat it as a fuzzy query and re-render. + filtered = Filter(cands, input) + if len(filtered) == 0 { + fmt.Fprintf(streams.Out, "no scratch matches %q — try fewer characters, or q to cancel.\n", input) + continue + } + printList(streams.Out, filtered) + } +} + +// selectNumbered is the non-TTY degradation: it prints the numbered list once +// and reads a single line. A valid number picks that row; anything else (or +// EOF) cancels. No re-filtering loop, because without a terminal there's no +// interactive session to speak of — this is the "degrades to a numbered prompt" +// path for pipes and scripts that still want to choose. +func selectNumbered(streams IO, cands []Candidate) (Candidate, error) { + printList(streams.Out, cands) + fmt.Fprintf(streams.Out, "choose 1-%d (anything else cancels): ", len(cands)) + + reader := bufio.NewReader(streams.In) + line, _ := reader.ReadString('\n') + input := strings.TrimSpace(line) + if input == "" { + return Candidate{}, ErrCanceled + } + n, err := strconv.Atoi(input) + if err != nil || n < 1 || n > len(cands) { + return Candidate{}, ErrCanceled + } + return cands[n-1], nil +} + +// printList writes the numbered candidate labels to w. It's the one place the +// row numbering is rendered, so the interactive and numbered front-ends stay +// consistent. An empty list prints nothing (callers handle the "no matches" +// message with more context). +func printList(w io.Writer, cands []Candidate) { + for i, c := range cands { + fmt.Fprintf(w, " %2d. %s\n", i+1, c.Label) + } +} diff --git a/internal/picker/select_test.go b/internal/picker/select_test.go new file mode 100644 index 0000000..acf1ba4 --- /dev/null +++ b/internal/picker/select_test.go @@ -0,0 +1,210 @@ +package picker + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/rwrife/scratchpatch/internal/index" +) + +// threeCands builds a small, stable candidate set for the front-end tests. +func threeCands() []Candidate { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + mk := func(id, name string) Candidate { + return NewCandidate(index.Scratch{ID: id, Name: name, CreatedAt: base}, id+" "+name) + } + return []Candidate{mk("aaa1", "todo"), mk("bbb2", "budget"), mk("ccc3", "notes")} +} + +// drive builds an IO around a canned stdin string and captured out/err buffers. +func drive(input string) (IO, *bytes.Buffer, *bytes.Buffer) { + var out, errb bytes.Buffer + return IO{In: strings.NewReader(input), Out: &out, Err: &errb}, &out, &errb +} + +func TestSelectEmptyCandidatesCancels(t *testing.T) { + streams, _, _ := drive("") + _, err := Select(streams, nil, Options{TTY: true, AllowFzf: false}) + if !errors.Is(err, ErrCanceled) { + t.Errorf("selecting from an empty set should cancel; got %v", err) + } +} + +func TestNumberedPromptPicksByNumber(t *testing.T) { + // Non-TTY path: one-shot numbered prompt. "2" selects the second candidate. + streams, out, _ := drive("2\n") + got, err := Select(streams, threeCands(), Options{TTY: false, AllowFzf: true}) + if err != nil { + t.Fatalf("unexpected error: %v (out=%q)", err, out.String()) + } + if got.Scratch.ID != "bbb2" { + t.Errorf("chose wrong scratch; got %q want bbb2", got.Scratch.ID) + } + // fzf must never be consulted on a non-TTY, even when AllowFzf is true. + if strings.Contains(out.String(), "fzf") { + t.Error("non-TTY path should not mention fzf") + } +} + +func TestNumberedPromptOutOfRangeCancels(t *testing.T) { + streams, _, _ := drive("9\n") + _, err := Select(streams, threeCands(), Options{TTY: false}) + if !errors.Is(err, ErrCanceled) { + t.Errorf("an out-of-range number should cancel on the numbered path; got %v", err) + } +} + +func TestNumberedPromptEmptyLineCancels(t *testing.T) { + streams, _, _ := drive("\n") + if _, err := Select(streams, threeCands(), Options{TTY: false}); !errors.Is(err, ErrCanceled) { + t.Errorf("an empty line should cancel the numbered prompt; got %v", err) + } +} + +func TestInteractivePicksByNumber(t *testing.T) { + // TTY, no fzf allowed → interactive loop. First line "3" picks the third. + streams, out, _ := drive("3\n") + got, err := Select(streams, threeCands(), Options{TTY: true, AllowFzf: false}) + if err != nil { + t.Fatalf("unexpected error: %v (out=%q)", err, out.String()) + } + if got.Scratch.ID != "ccc3" { + t.Errorf("interactive pick wrong; got %q want ccc3", got.Scratch.ID) + } +} + +func TestInteractiveFiltersThenPicks(t *testing.T) { + // Type a query that narrows to one, then accept with an empty line (which + // takes the top of the filtered list). + streams, out, _ := drive("budg\n\n") + got, err := Select(streams, threeCands(), Options{TTY: true, AllowFzf: false}) + if err != nil { + t.Fatalf("unexpected error: %v (out=%q)", err, out.String()) + } + if got.Scratch.ID != "bbb2" { + t.Errorf("filter+accept picked wrong; got %q want bbb2", got.Scratch.ID) + } +} + +func TestInteractiveEmptyLineAcceptsTop(t *testing.T) { + // With no filter, an immediate empty line accepts the first candidate. + streams, _, _ := drive("\n") + got, err := Select(streams, threeCands(), Options{TTY: true, AllowFzf: false}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Scratch.ID != "aaa1" { + t.Errorf("empty line should accept the top candidate; got %q", got.Scratch.ID) + } +} + +func TestInteractiveQCancels(t *testing.T) { + streams, _, _ := drive("q\n") + if _, err := Select(streams, threeCands(), Options{TTY: true, AllowFzf: false}); !errors.Is(err, ErrCanceled) { + t.Errorf("q should cancel the interactive picker; got %v", err) + } +} + +func TestInteractiveEOFCancels(t *testing.T) { + // Reader that yields no newline and then EOF: the loop should cancel, not spin. + streams, _, _ := drive("") + if _, err := Select(streams, threeCands(), Options{TTY: true, AllowFzf: false}); !errors.Is(err, ErrCanceled) { + t.Errorf("EOF at the prompt should cancel; got %v", err) + } +} + +func TestInteractiveNoMatchThenValidQuery(t *testing.T) { + // A query that matches nothing keeps the loop alive; a following good query + // + empty-line accept should still succeed. + streams, out, _ := drive("zzz\nnotes\n\n") + got, err := Select(streams, threeCands(), Options{TTY: true, AllowFzf: false}) + if err != nil { + t.Fatalf("unexpected error: %v (out=%q)", err, out.String()) + } + if got.Scratch.ID != "ccc3" { + t.Errorf("recovered pick wrong; got %q want ccc3", got.Scratch.ID) + } + if !strings.Contains(out.String(), "no scratch matches") { + t.Error("a no-match query should print a hint before continuing") + } +} + +func TestSelectPrefersFzfWhenPresentAndAllowed(t *testing.T) { + // Fake fzf: report it's installed, and have it return the label of the + // second candidate. Select should map that back to bbb2. + cands := threeCands() + wantLabel := cands[1].Label + opts := Options{ + TTY: true, + AllowFzf: true, + lookFzf: func() (string, bool) { return "/fake/fzf", true }, + runFzf: func(_ IO, labels []string) (string, error) { + if len(labels) != len(cands) { + t.Fatalf("fzf got %d labels, want %d", len(labels), len(cands)) + } + return wantLabel + "\n", nil + }, + } + streams, _, _ := drive("") + got, err := Select(streams, cands, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Scratch.ID != "bbb2" { + t.Errorf("fzf selection mapped wrong; got %q want bbb2", got.Scratch.ID) + } +} + +func TestSelectFzfNoSelectionCancels(t *testing.T) { + opts := Options{ + TTY: true, + AllowFzf: true, + lookFzf: func() (string, bool) { return "/fake/fzf", true }, + runFzf: func(_ IO, _ []string) (string, error) { return "", ErrCanceled }, + } + streams, _, _ := drive("") + if _, err := Select(streams, threeCands(), opts); !errors.Is(err, ErrCanceled) { + t.Errorf("fzf cancel should propagate as ErrCanceled; got %v", err) + } +} + +func TestSelectNoFzfOptionSkipsFzf(t *testing.T) { + // AllowFzf=false must not call lookFzf/runFzf even on a TTY; it should fall + // through to the interactive loop. We prove it by failing the test if the + // fzf fakes are ever invoked. + opts := Options{ + TTY: true, + AllowFzf: false, + lookFzf: func() (string, bool) { t.Fatal("lookFzf called despite AllowFzf=false"); return "", false }, + runFzf: func(_ IO, _ []string) (string, error) { + t.Fatal("runFzf called despite AllowFzf=false") + return "", nil + }, + } + streams, _, _ := drive("1\n") + got, err := Select(streams, threeCands(), opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Scratch.ID != "aaa1" { + t.Errorf("expected interactive path to pick aaa1; got %q", got.Scratch.ID) + } +} + +func TestSelectFzfUnknownLabelCancels(t *testing.T) { + // If fzf echoes back something we never offered, treat it as a cancel + // rather than opening the wrong scratch. + opts := Options{ + TTY: true, + AllowFzf: true, + lookFzf: func() (string, bool) { return "/fake/fzf", true }, + runFzf: func(_ IO, _ []string) (string, error) { return "not-a-real-label\n", nil }, + } + streams, _, _ := drive("") + if _, err := Select(streams, threeCands(), opts); !errors.Is(err, ErrCanceled) { + t.Errorf("an unrecognized fzf line should cancel; got %v", err) + } +} diff --git a/internal/render/picker_label_test.go b/internal/render/picker_label_test.go new file mode 100644 index 0000000..88ddb83 --- /dev/null +++ b/internal/render/picker_label_test.go @@ -0,0 +1,45 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/rwrife/scratchpatch/internal/index" +) + +func TestPickerLabelShowsKeyFields(t *testing.T) { + now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + sc := index.Scratch{ + ID: "deadbeef", + Name: "todo-list", + CreatedAt: now.Add(-3 * 24 * time.Hour), // 3d old + ExpiresAt: now.Add(4 * 24 * time.Hour), // expires in 4d + Tags: []string{"work", "q3"}, + } + label := PickerLabel(sc, now) + + for _, want := range []string{"deadbeef", "todo-list", "3d", "in 4d", "work,q3"} { + if !strings.Contains(label, want) { + t.Errorf("picker label missing %q; got %q", want, label) + } + } + // Labels are consumed as raw lines (and matched against) by the picker, so + // they must not carry ANSI escapes. + if strings.Contains(label, "\x1b[") { + t.Errorf("picker label should be plain (no ANSI); got %q", label) + } +} + +func TestPickerLabelUnnamedAndUntagged(t *testing.T) { + now := time.Now() + sc := index.Scratch{ID: "abcd1234", CreatedAt: now, ExpiresAt: now.Add(time.Hour)} + label := PickerLabel(sc, now) + if !strings.Contains(label, "abcd1234") { + t.Errorf("label should include the id; got %q", label) + } + // Name and tags fall back to a dash rather than blanks. + if !strings.Contains(label, "-") { + t.Errorf("unnamed/untagged scratch should show dashes; got %q", label) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 277edaa..777532c 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -284,6 +284,27 @@ func humanSize(n int64) string { return fmt.Sprintf("%.1f%cB", float64(n)/float64(div), "KMGTPE"[exp]) } +// PickerLabel renders a single scratch as a compact, fixed-shape line for the +// interactive `sp open` picker (issue #10): id, name, age, time-to-expiry, and +// tags, in that order, so a chooser sees everything the ls table shows without +// a full grid. It is deliberately plain (no color): the picker front-ends — +// including fzf — consume these as raw lines and match against them, so escape +// codes would corrupt both the display and the filtering. now is passed in for +// deterministic, testable output, matching the table renderers. +// +// The columns are space-padded to a stable width so a stack of labels lines up +// in a numbered list. Keeping this in render preserves the boundary that only +// render decides how a scratch is presented; the picker package supplies the +// interaction, not the formatting. +func PickerLabel(s index.Scratch, now time.Time) string { + id := s.ID + name := pad(nameOrDash(s.Name), 20) + age := pad(humanAge(now.Sub(s.CreatedAt)), 5) + expires := pad(humanExpiry(s.ExpiresAt.Sub(now)), 10) + tags := tagsOrDash(s.Tags) + return fmt.Sprintf("%s %s %s %s %s", id, name, age, expires, tags) +} + // MorgueRow pairs a soft-deleted scratch with the moment it becomes eligible // for hard-deletion. render takes this plain data (computed by the store/config // layer) rather than reaching for the grace window itself, keeping the "render