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
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ sp completion zsh > "${fpath[1]}/_sp" # tab-completion for your shell
sp scan <id> # tripwire: does this scratch hold a secret?
sp dedup # report byte-identical scratches (read-only)
sp dedup --collapse # send redundant copies to the morgue (morgue-first)
sp tui # browse and manage scratches full-screen
sp resurrect <id> # changed your mind? pull it back
sp promote <id> # the good ones: graduate a scratch into your repo
```
Expand All @@ -44,7 +45,8 @@ into your repo; and a **secret tripwire** — **`sp scan`** flags scratches that
look like they hold credentials, `sp ls` marks them with a 🔑, and `sp promote`
refuses them unless you pass `--allow-secrets`; and **content dedup** —
**`sp dedup`** reports byte-identical scratches and `--collapse` sweeps the
redundant copies to the morgue (M6, in progress).
redundant copies to the morgue (M6, in progress); and an optional full-screen
**`sp tui`** browser over the same store (v0.2).

### `sp new [name]`

Expand Down Expand Up @@ -374,6 +376,36 @@ sp completion zsh > "${fpath[1]}/_sp"
sp completion fish > ~/.config/fish/completions/sp.fish
```

### `sp tui` — the full-screen browser

When you'd rather point-and-act than pipe, `sp tui` opens an interactive,
full-screen browser over the same store — no second source of truth, just a
keyboard-driven shell over `sp ls`/`rm`/`resurrect`/`open`. Live scratches on
the left (color-coded by expiry, 🔑 for anything the secret tripwire flagged),
the selected scratch's content previewed on the right. Secret scratches are
**never dumped** into the preview — you get a redaction notice pointing at
`sp scan` instead.

```bash
sp tui # needs an interactive terminal; scripts should use `sp ls --json`
```

Keybindings:

- `↑`/`↓` (or `k`/`j`) — move the selection; `g`/`G` jump to top/bottom
- `tab` (or `m`) — flip between the **live** and **morgue** panes
- `/` — live filter by name, id, or tag; `esc` clears it, `enter` accepts
- `o` / `enter` — open the selection in `$EDITOR` (suspends and restores the TUI)
- `d` (or `x`) — soft-delete the selection to the morgue (live pane)
- `r` — resurrect the selection (morgue pane)
- `R` — reload both panes from disk
- `q` / `esc` — quit and restore the terminal

Every file-moving action (`d`, `r`) asks for a `y/N` confirmation, and — like
the rest of scratchpatch — the TUI **never hard-deletes**: `rm` here is still
just a move to the morgue. Not attached to a terminal? `sp tui` bows out with a
clear pointer to `sp ls`.

## Install

### Prebuilt binaries
Expand Down
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/rwrife/scratchpatch
go 1.22

require (
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/lipgloss v1.1.0
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
Expand All @@ -14,12 +15,18 @@ require (
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
15 changes: 15 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
Expand All @@ -11,14 +13,22 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
Expand All @@ -34,7 +44,12 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func NewRootCommand() *cobra.Command {
newDoctorCommand(),
newDedupCommand(),
newScanCommand(),
newTUICommand(),
newCompletionCommand(),
)

Expand Down
90 changes: 90 additions & 0 deletions internal/cli/tui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package cli

import (
"errors"
"fmt"
"os"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"

"github.com/rwrife/scratchpatch/internal/index"
"github.com/rwrife/scratchpatch/internal/store"
"github.com/rwrife/scratchpatch/internal/tui"
)

// newTUICommand builds `sp tui`: an optional full-screen Bubble Tea browser
// over the same store the rest of the CLI uses. It is strictly additive —
// scripting and `--json` paths are untouched — and refuses to launch when
// stdout isn't a terminal, pointing scripts at `sp ls` instead.
func newTUICommand() *cobra.Command {
return &cobra.Command{
Use: "tui",
Short: "Browse and manage scratches in a full-screen terminal UI",
Long: "Launch an interactive, full-screen browser over your scratch store.\n\n" +
"Arrow-key (or j/k) through live scratches, press tab to flip to the\n" +
"morgue, and preview the selected scratch in the side pane. Act on the\n" +
"selection without leaving the terminal: open in $EDITOR (o), soft-delete\n" +
"to the morgue (d), or resurrect from it (r) — each file-moving action\n" +
"asks for a y/N confirmation, and nothing is ever hard-deleted here.\n" +
"Press / to filter by name, id, or tag; q or esc quits.\n\n" +
"`sp tui` needs an interactive terminal. For scripting, use `sp ls` or\n" +
"`sp ls --json`.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runTUI(cmd)
},
}
}

func runTUI(cmd *cobra.Command) error {
// The TUI takes over the whole screen and reads raw keys, so it is only
// meaningful on a real terminal on both ends. Degrade with a clear pointer
// rather than scribbling escape codes into a pipe or file.
if !isTerminal(cmd.OutOrStdout()) || !stdinIsTerminal(cmd) {
return errors.New("sp tui needs an interactive terminal; " +
"use `sp ls` or `sp ls --json` for scripting")
}

st, err := store.Open()
if err != nil {
return err
}

model := tui.New(tui.Deps{
Backend: st,
OpenEditor: editorLauncher(cmd, st),
})

// Full-screen (alt-screen) so the browser restores the user's terminal
// contents cleanly on quit.
prog := tea.NewProgram(model, tea.WithAltScreen(),
tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout))
if _, err := prog.Run(); err != nil {
return fmt.Errorf("tui: %w", err)
}
return nil
}

// editorLauncher builds the EditorFunc the TUI uses to open a scratch, reusing
// the exact same $EDITOR launcher `sp open` relies on so behavior is identical.
// Bubble Tea's program suspends the alt-screen around a blocking editor via
// tea.ExecProcess, but the model calls this synchronously; to keep the model
// backend-agnostic we run the editor here on the shared launcher, which works
// because Update runs on the program goroutine and Bubble Tea restores the
// terminal on the next render. Returning nil-safe: a missing $EDITOR surfaces
// as an error the model shows in its status line.
func editorLauncher(cmd *cobra.Command, st *store.Store) tui.EditorFunc {
return func(sc index.Scratch) error {
path := st.LivePath(sc)
if err := openInEditor(cmd, path); err != nil {
return err
}
if sc.Live() {
if _, terr := st.Touch(sc.ID); terr != nil {
return terr
}
}
return nil
}
}
Loading
Loading