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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,15 @@ 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 <id>` / `sp open <id>`
### `sp cat <id>` / `sp open [id]`

Read or re-open a scratch. The `<id>` may be an **unambiguous prefix** — you
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.
Expand All @@ -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 <id>` — soft-delete to the morgue

Moves a scratch into the morgue. **This never destroys content** — it just
Expand Down
98 changes: 93 additions & 5 deletions internal/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -62,18 +66,31 @@ func runCat(cmd *cobra.Command, ref string) error {
}

func newOpenCommand() *cobra.Command {
return &cobra.Command{
Use: "open <id>",
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 {
Expand All @@ -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()

Expand All @@ -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 <id>",
Expand Down
101 changes: 101 additions & 0 deletions internal/cli/open_picker_test.go
Original file line number Diff line number Diff line change
@@ -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 <path>" 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 <id>`
// 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 <id>: %v (out=%s)", err, out)
}
// EDITOR unset → path fallback naming the scratch.
if !strings.Contains(out, id) {
t.Errorf("open <id> should act on that scratch; got %q", out)
}
}
Loading
Loading