diff --git a/README.md b/README.md index bbc5727..e108b5b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ sp completion zsh > "${fpath[1]}/_sp" # tab-completion for your shell sp scan # 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 # changed your mind? pull it back sp promote # the good ones: graduate a scratch into your repo ``` @@ -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]` @@ -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 diff --git a/go.mod b/go.mod index b329175..9cbdc0e 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 ) diff --git a/go.sum b/go.sum index 035ee83..5918759 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= diff --git a/internal/cli/root.go b/internal/cli/root.go index a655788..2d894bb 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -56,6 +56,7 @@ func NewRootCommand() *cobra.Command { newDoctorCommand(), newDedupCommand(), newScanCommand(), + newTUICommand(), newCompletionCommand(), ) diff --git a/internal/cli/tui.go b/internal/cli/tui.go new file mode 100644 index 0000000..422f580 --- /dev/null +++ b/internal/cli/tui.go @@ -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 + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..53598b6 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,263 @@ +// Package tui is scratchpatch's optional full-screen browser. +// +// It is a thin interactive shell over the existing store/index/render/secret +// logic β€” never a second source of truth. The Bubble Tea model reads scratches +// through a small Backend interface (satisfied by *store.Store) and drives the +// same MoveToMorgue / Resurrect / Promote / open-in-$EDITOR operations the CLI +// commands use, so nothing here can hard-delete or diverge from `sp ls`. +// +// Everything the model needs from the outside world is behind Backend and the +// Deps struct: the store operations, "now", and the $EDITOR launcher. That +// keeps the model pure enough to unit-test its update/transition logic with a +// fake backend and no real terminal, per the issue's acceptance criteria. +// +// The scripting/`--json` paths are deliberately untouched: the TUI is opt-in +// (`sp tui`) and the cli layer refuses to launch it without an interactive +// terminal, degrading to a clear "use `sp ls`" error. +package tui + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/rwrife/scratchpatch/internal/index" + "github.com/rwrife/scratchpatch/internal/secret" +) + +// Backend is the slice of store behavior the TUI needs. *store.Store satisfies +// it; tests supply a fake so the model's logic can be exercised without a real +// store or filesystem. Every method mirrors an existing store operation β€” the +// TUI invents no new mutation. +type Backend interface { + // ListLive returns live (non-morgued) scratches, newest-first. + ListLive() ([]index.Scratch, error) + // ListMorgue returns soft-deleted scratches, newest-first. + ListMorgue() ([]index.Scratch, error) + // ReadContent returns a scratch's raw content (live or morgue). + ReadContent(sc index.Scratch) ([]byte, error) + // PurgeAt reports when a morgued scratch becomes eligible for hard-delete. + PurgeAt(sc index.Scratch) (time.Time, bool) + // MoveToMorgue soft-deletes a live scratch. + MoveToMorgue(sc index.Scratch) (index.Scratch, error) + // Resurrect restores a morgued scratch to the living. + Resurrect(sc index.Scratch) (index.Scratch, error) +} + +// EditorFunc opens a scratch's content path in the user's editor, suspending +// and restoring the TUI around it. The cli layer supplies the real one (built +// on the same $EDITOR launcher `sp open` uses); tests pass a no-op. +type EditorFunc func(sc index.Scratch) error + +// Deps bundles the model's collaborators so New has a single, testable seam. +type Deps struct { + Backend Backend + // Now returns the current time; injected for deterministic tests. + Now func() time.Time + // OpenEditor opens a scratch in $EDITOR. May be nil (open is then a no-op + // that reports the disabled state). + OpenEditor EditorFunc +} + +// view is which pane of scratches is showing. +type view int + +const ( + viewLive view = iota + viewMorgue +) + +// pending is a two-step confirmation for a file-moving action. +type pending int + +const ( + pendingNone pending = iota + pendingMorgue + pendingResurrect +) + +// row is a scratch plus the derived display bits the model precomputes once per +// refresh: whether it tripped the secret tripwire and (for morgue rows) its +// purge deadline. Keeping this alongside the scratch avoids re-scanning content +// on every keystroke. +type row struct { + sc index.Scratch + secret bool + purgeAt time.Time + hasPurge bool +} + +// Model is the Bubble Tea model backing `sp tui`. +type Model struct { + deps Deps + + live []row + morgue []row + + view view + cursor int + filter string + filtering bool + + pending pending + + preview string + previewSecret bool + + width int + height int + + status string + err error + quit bool +} + +// New builds a Model with an initial load already applied. It surfaces a load +// error into the model rather than failing construction so the cli layer can +// still start the program and show the error in-frame. +func New(deps Deps) Model { + if deps.Now == nil { + deps.Now = time.Now + } + m := Model{deps: deps, view: viewLive} + m.reload() + m.syncPreview() + return m +} + +// Init satisfies tea.Model; the TUI has no startup command. +func (m Model) Init() tea.Cmd { return nil } + +// reload refreshes both panes from the backend, re-scanning content for the +// secret marker and recomputing purge deadlines. It clamps the cursor so it +// never dangles past a now-shorter list. +func (m *Model) reload() { + live, lerr := m.deps.Backend.ListLive() + dead, derr := m.deps.Backend.ListMorgue() + if lerr != nil { + m.err = lerr + return + } + if derr != nil { + m.err = derr + return + } + m.err = nil + m.live = m.toRows(live, false) + m.morgue = m.toRows(dead, true) + m.clampCursor() +} + +// toRows converts scratches into display rows, marking secrets and (for morgue +// rows) computing purge deadlines. Content that can't be read just goes +// unmarked β€” a browse should never fail because one file vanished, matching +// `sp ls`'s best-effort tripwire. +func (m *Model) toRows(scs []index.Scratch, morgue bool) []row { + rows := make([]row, 0, len(scs)) + for _, sc := range scs { + r := row{sc: sc} + if content, err := m.deps.Backend.ReadContent(sc); err == nil { + r.secret = secret.Tripped(content) + } + if morgue { + if at, ok := m.deps.Backend.PurgeAt(sc); ok { + r.purgeAt, r.hasPurge = at, true + } + } + rows = append(rows, r) + } + return rows +} + +// rows returns the currently-visible pane's rows after applying the live +// filter (case-insensitive substring over name, id, and tags). +func (m Model) rows() []row { + src := m.live + if m.view == viewMorgue { + src = m.morgue + } + if strings.TrimSpace(m.filter) == "" { + return src + } + needle := strings.ToLower(strings.TrimSpace(m.filter)) + out := make([]row, 0, len(src)) + for _, r := range src { + if rowMatches(r.sc, needle) { + out = append(out, r) + } + } + return out +} + +// rowMatches reports whether a scratch matches the filter needle by name, id, +// or any tag. +func rowMatches(sc index.Scratch, needle string) bool { + if strings.Contains(strings.ToLower(sc.Name), needle) { + return true + } + if strings.Contains(strings.ToLower(sc.ID), needle) { + return true + } + for _, t := range sc.Tags { + if strings.Contains(strings.ToLower(t), needle) { + return true + } + } + return false +} + +// current returns the scratch under the cursor, or false when the visible list +// is empty. +func (m Model) current() (row, bool) { + rows := m.rows() + if len(rows) == 0 || m.cursor < 0 || m.cursor >= len(rows) { + return row{}, false + } + return rows[m.cursor], true +} + +// clampCursor keeps the cursor within the visible list bounds. +func (m *Model) clampCursor() { + n := len(m.rows()) + if n == 0 { + m.cursor = 0 + return + } + if m.cursor >= n { + m.cursor = n - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } +} + +// syncPreview loads the content preview for the current selection, respecting +// secret scratches: their content is never dumped, only a redaction notice is +// shown, honoring the issue's "does not dump raw credentials by default" rule. +func (m *Model) syncPreview() { + r, ok := m.current() + if !ok { + m.preview = "" + m.previewSecret = false + return + } + m.previewSecret = r.secret + if r.secret { + m.preview = "πŸ”‘ this scratch tripped the secret tripwire.\n\n" + + "Its content is hidden here so credentials aren't dumped to your\n" + + "terminal. Run `sp scan " + r.sc.ID + "` for masked findings, or open it\n" + + "in your editor if you really mean to look." + return + } + content, err := m.deps.Backend.ReadContent(r.sc) + if err != nil { + m.preview = "(content unavailable β€” the file may be missing; run `sp doctor`)" + return + } + if len(content) == 0 { + m.preview = "(empty scratch)" + return + } + m.preview = string(content) +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..ab1a7a7 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,275 @@ +package tui + +import ( + "errors" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/rwrife/scratchpatch/internal/index" +) + +// fakeBackend is an in-memory Backend for exercising the model's transitions +// without a real store or filesystem. It records mutations so tests can assert +// the TUI drives the same operations the CLI would. +type fakeBackend struct { + live []index.Scratch + morgue []index.Scratch + content map[string][]byte + + morgued []string + resurrected []string + + morgueErr error +} + +func (f *fakeBackend) ListLive() ([]index.Scratch, error) { return f.live, nil } +func (f *fakeBackend) ListMorgue() ([]index.Scratch, error) { return f.morgue, nil } + +func (f *fakeBackend) ReadContent(sc index.Scratch) ([]byte, error) { + if b, ok := f.content[sc.ID]; ok { + return b, nil + } + return nil, errors.New("missing") +} + +func (f *fakeBackend) PurgeAt(sc index.Scratch) (time.Time, bool) { + if sc.DeletedAt == nil { + return time.Time{}, false + } + return sc.DeletedAt.Add(72 * time.Hour), true +} + +func (f *fakeBackend) MoveToMorgue(sc index.Scratch) (index.Scratch, error) { + if f.morgueErr != nil { + return index.Scratch{}, f.morgueErr + } + f.morgued = append(f.morgued, sc.ID) + // Move from live to morgue for a faithful reload. + now := time.Now() + sc.DeletedAt = &now + f.live = removeByID(f.live, sc.ID) + f.morgue = append(f.morgue, sc) + return sc, nil +} + +func (f *fakeBackend) Resurrect(sc index.Scratch) (index.Scratch, error) { + f.resurrected = append(f.resurrected, sc.ID) + sc.DeletedAt = nil + f.morgue = removeByID(f.morgue, sc.ID) + f.live = append(f.live, sc) + return sc, nil +} + +func removeByID(scs []index.Scratch, id string) []index.Scratch { + out := scs[:0:0] + for _, s := range scs { + if s.ID != id { + out = append(out, s) + } + } + return out +} + +func fixedNow() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) } + +func sampleModel(fb *fakeBackend) Model { + return New(Deps{Backend: fb, Now: fixedNow, OpenEditor: nil}) +} + +func key(s string) tea.KeyMsg { + switch s { + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func send(m Model, keys ...string) Model { + for _, k := range keys { + nm, _ := m.Update(key(k)) + m = nm.(Model) + } + return m +} + +func baseBackend() *fakeBackend { + del := fixedNow().Add(-1 * time.Hour) + return &fakeBackend{ + live: []index.Scratch{ + {ID: "aaaa1111", Name: "notes", CreatedAt: fixedNow().Add(-2 * time.Hour), ExpiresAt: fixedNow().Add(48 * time.Hour), Tags: []string{"work"}}, + {ID: "bbbb2222", Name: "todo", CreatedAt: fixedNow().Add(-1 * time.Hour), ExpiresAt: fixedNow().Add(10 * time.Hour)}, + }, + morgue: []index.Scratch{ + {ID: "cccc3333", Name: "old", CreatedAt: fixedNow().Add(-5 * time.Hour), ExpiresAt: fixedNow(), DeletedAt: &del}, + }, + content: map[string][]byte{ + "aaaa1111": []byte("hello notes"), + "bbbb2222": []byte("AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE1234567890abcdef"), + "cccc3333": []byte("dead content"), + }, + } +} + +func TestInitialLoadPopulatesPanes(t *testing.T) { + m := sampleModel(baseBackend()) + if got := len(m.live); got != 2 { + t.Fatalf("live rows = %d, want 2", got) + } + if got := len(m.morgue); got != 1 { + t.Fatalf("morgue rows = %d, want 1", got) + } + if r, ok := m.current(); !ok || r.sc.ID != "aaaa1111" { + t.Fatalf("initial selection = %+v, ok=%v", r, ok) + } +} + +func TestSecretPreviewIsRedacted(t *testing.T) { + m := sampleModel(baseBackend()) + m = send(m, "down") // move to the secret scratch bbbb2222 + if !m.previewSecret { + t.Fatal("expected secret scratch to be flagged") + } + if wantNot := "AKIA"; contains(m.preview, wantNot) { + t.Fatalf("preview leaked raw secret content: %q", m.preview) + } + if !contains(m.preview, "tripwire") { + t.Fatalf("preview should explain the redaction, got %q", m.preview) + } +} + +func TestToggleViewSwitchesPanes(t *testing.T) { + m := sampleModel(baseBackend()) + m = send(m, "tab") + if m.view != viewMorgue { + t.Fatal("tab should switch to the morgue") + } + if r, ok := m.current(); !ok || r.sc.ID != "cccc3333" { + t.Fatalf("morgue selection = %+v ok=%v", r, ok) + } +} + +func TestMorgueRequiresConfirmation(t *testing.T) { + fb := baseBackend() + m := sampleModel(fb) + // Arm, then cancel with a non-confirm key: no move should happen. + m = send(m, "d") + if m.pending != pendingMorgue { + t.Fatal("d should arm a morgue confirmation") + } + m = send(m, "n") + if len(fb.morgued) != 0 { + t.Fatalf("cancelled confirmation still moved: %v", fb.morgued) + } + // Arm and confirm with y: exactly one move, and it leaves the live pane. + m = send(m, "d", "y") + if len(fb.morgued) != 1 || fb.morgued[0] != "aaaa1111" { + t.Fatalf("confirmed morgue = %v, want [aaaa1111]", fb.morgued) + } + if len(m.live) != 1 { + t.Fatalf("live pane should shrink to 1 after morgue, got %d", len(m.live)) + } +} + +func TestResurrectOnlyOnMorguePane(t *testing.T) { + fb := baseBackend() + m := sampleModel(fb) + // r on the live pane is a no-op with a hint. + m = send(m, "r") + if m.pending != pendingNone { + t.Fatal("r on live pane should not arm resurrect") + } + // Switch to morgue, resurrect with confirm. + m = send(m, "tab", "r") + if m.pending != pendingResurrect { + t.Fatal("r on morgue pane should arm resurrect") + } + m = send(m, "y") + if len(fb.resurrected) != 1 || fb.resurrected[0] != "cccc3333" { + t.Fatalf("resurrected = %v, want [cccc3333]", fb.resurrected) + } +} + +func TestMorgueErrorSurfacesInStatus(t *testing.T) { + fb := baseBackend() + fb.morgueErr = errors.New("disk full") + m := sampleModel(fb) + m = send(m, "d", "y") + if !contains(m.status, "rm failed") { + t.Fatalf("expected failure in status, got %q", m.status) + } + if len(m.live) != 2 { + t.Fatal("failed morgue should not change the live pane") + } +} + +func TestFilterNarrowsList(t *testing.T) { + m := sampleModel(baseBackend()) + m = send(m, "/", "t", "o", "d", "o") + if got := len(m.rows()); got != 1 { + t.Fatalf("filter 'todo' matched %d rows, want 1", got) + } + // esc clears the filter back to the full list. + m = send(m, "esc") + if got := len(m.rows()); got != 2 { + t.Fatalf("after esc, rows = %d, want 2", got) + } +} + +func TestQuitSetsQuitFlag(t *testing.T) { + m := sampleModel(baseBackend()) + nm, cmd := m.Update(key("q")) + m = nm.(Model) + if !m.quit { + t.Fatal("q should set the quit flag") + } + if cmd == nil { + t.Fatal("q should return a quit command") + } +} + +func TestOpenWithoutEditorReportsDisabled(t *testing.T) { + m := sampleModel(baseBackend()) // OpenEditor is nil + m = send(m, "o") + if !contains(m.status, "editor unavailable") { + t.Fatalf("expected disabled-editor status, got %q", m.status) + } +} + +func TestViewRendersWithoutPanic(t *testing.T) { + m := sampleModel(baseBackend()) + nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = nm.(Model) + out := m.View() + if !contains(out, "scratchpatch") { + t.Fatalf("view should include the title, got:\n%s", out) + } + if !contains(out, "notes") { + t.Fatalf("view should list a scratch name, got:\n%s", out) + } +} + +func contains(haystack, needle string) bool { + return len(needle) == 0 || (len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..b27d033 --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,229 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Update is the Bubble Tea update loop. It routes window-size and key events; +// all mutation goes through the same store operations the CLI uses, and any +// file-moving action (morgue/resurrect) requires an explicit second keypress +// via the pending-confirmation state, satisfying the "confirmation for anything +// that moves files" criterion. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +// handleKey dispatches a keypress. Filter-entry mode captures text; otherwise +// keys are commands. A pending confirmation intercepts y/n first so an +// accidental keystroke can't move a file. +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.pending != pendingNone { + return m.handlePending(msg) + } + if m.filtering { + return m.handleFilterKey(msg) + } + + switch msg.String() { + case "q", "esc", "ctrl+c": + m.quit = true + return m, tea.Quit + + case "up", "k": + m.moveCursor(-1) + case "down", "j": + m.moveCursor(1) + case "g", "home": + m.cursor = 0 + m.syncPreview() + case "G", "end": + m.cursor = len(m.rows()) - 1 + m.clampCursor() + m.syncPreview() + + case "tab", "m": + m.toggleView() + + case "/": + m.filtering = true + m.status = "" + + case "o", "enter": + return m.openSelection() + + case "d", "x": + m.requestMorgue() + case "r": + m.requestResurrect() + + case "R": + m.reload() + m.syncPreview() + m.status = "refreshed" + } + return m, nil +} + +// handlePending resolves a two-step confirmation: y/enter commits the pending +// action, anything else cancels it. This is the single choke point through +// which morgue and resurrect actually touch files. +func (m Model) handlePending(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y", "enter": + p := m.pending + m.pending = pendingNone + return m.commitPending(p) + default: + m.pending = pendingNone + m.status = "cancelled" + return m, nil + } +} + +// commitPending performs the confirmed file move through the backend, then +// reloads so both panes reflect the change. Errors surface into the status line +// rather than crashing the browser. +func (m Model) commitPending(p pending) (tea.Model, tea.Cmd) { + r, ok := m.current() + if !ok { + m.status = "nothing selected" + return m, nil + } + switch p { + case pendingMorgue: + if _, err := m.deps.Backend.MoveToMorgue(r.sc); err != nil { + m.status = "rm failed: " + err.Error() + return m, nil + } + m.status = "moved " + r.sc.ID + " to the morgue" + case pendingResurrect: + if _, err := m.deps.Backend.Resurrect(r.sc); err != nil { + m.status = "resurrect failed: " + err.Error() + return m, nil + } + m.status = "resurrected " + r.sc.ID + } + m.reload() + m.syncPreview() + return m, nil +} + +// handleFilterKey edits the filter string live; esc cancels back to the full +// list, enter accepts the filter and returns to navigation. +func (m Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.filtering = false + m.filter = "" + m.cursor = 0 + m.syncPreview() + case "enter": + m.filtering = false + m.clampCursor() + m.syncPreview() + case "backspace": + if m.filter != "" { + m.filter = m.filter[:len(m.filter)-1] + } + m.cursor = 0 + m.syncPreview() + default: + if msg.Type == tea.KeyRunes { + m.filter += string(msg.Runes) + m.cursor = 0 + m.syncPreview() + } + } + return m, nil +} + +// moveCursor shifts the selection by delta, clamped, and refreshes the preview. +func (m *Model) moveCursor(delta int) { + n := len(m.rows()) + if n == 0 { + m.cursor = 0 + return + } + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= n { + m.cursor = n - 1 + } + m.syncPreview() +} + +// toggleView flips between the live and morgue panes, resetting the cursor and +// clearing any half-typed filter so the two views don't share a stale needle. +func (m *Model) toggleView() { + if m.view == viewLive { + m.view = viewMorgue + } else { + m.view = viewLive + } + m.cursor = 0 + m.filter = "" + m.filtering = false + m.pending = pendingNone + m.syncPreview() +} + +// openSelection launches $EDITOR on the current scratch, then reloads (an edit +// may have changed size/secret status). A nil editor reports the disabled +// state instead of doing nothing silently. +func (m Model) openSelection() (tea.Model, tea.Cmd) { + r, ok := m.current() + if !ok { + m.status = "nothing to open" + return m, nil + } + if m.deps.OpenEditor == nil { + m.status = "editor unavailable ($EDITOR not set)" + return m, nil + } + if err := m.deps.OpenEditor(r.sc); err != nil { + m.status = "open failed: " + err.Error() + return m, nil + } + m.reload() + m.syncPreview() + m.status = "opened " + r.sc.ID + return m, nil +} + +// requestMorgue arms a morgue confirmation for a live selection. It refuses on +// the morgue pane (already dead) so `d` there is a clear no-op message. +func (m *Model) requestMorgue() { + if m.view != viewLive { + m.status = "already in the morgue β€” press r to resurrect" + return + } + if _, ok := m.current(); !ok { + m.status = "nothing selected" + return + } + m.pending = pendingMorgue +} + +// requestResurrect arms a resurrect confirmation for a morgue selection. On the +// live pane there's nothing to resurrect, so it says so. +func (m *Model) requestResurrect() { + if m.view != viewMorgue { + m.status = "resurrect works on morgue scratches β€” press tab to switch" + return + } + if _, ok := m.current(); !ok { + m.status = "nothing selected" + return + } + m.pending = pendingResurrect +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..a07d01b --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,319 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/rwrife/scratchpatch/internal/index" + "github.com/rwrife/scratchpatch/internal/ttl" +) + +// Styles for the browser. Colors echo the `sp ls` palette (green/amber/red by +// expiry, violet header) so the TUI and the table feel like one tool. +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) + selStyle = lipgloss.NewStyle().Bold(true).Reverse(true) + freshStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + soonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + expiredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) + footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + warnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + previewBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1) +) + +// View renders the full-screen browser: a title bar, a two-column body (list + +// preview), and a help/status footer. It degrades gracefully before the first +// WindowSizeMsg by assuming a sane default size. +func (m Model) View() string { + if m.quit { + return "" + } + + width, height := m.width, m.height + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + + var b strings.Builder + b.WriteString(m.titleBar()) + b.WriteByte('\n') + + // Body height budget: total minus title, filter line, footer. + bodyHeight := height - 4 + if bodyHeight < 3 { + bodyHeight = 3 + } + + listW := width / 2 + if listW < 24 { + listW = 24 + } + previewW := width - listW - 4 + if previewW < 10 { + previewW = 10 + } + + list := m.listPane(listW, bodyHeight) + preview := m.previewPane(previewW, bodyHeight) + body := lipgloss.JoinHorizontal(lipgloss.Top, list, " ", preview) + b.WriteString(body) + b.WriteByte('\n') + + b.WriteString(m.filterLine()) + b.WriteByte('\n') + b.WriteString(m.footer()) + return b.String() +} + +// titleBar shows the tool name, active pane, and counts. +func (m Model) titleBar() string { + pane := "live" + if m.view == viewMorgue { + pane = "morgue" + } + counts := fmt.Sprintf("%d live Β· %d morgue", len(m.live), len(m.morgue)) + return titleStyle.Render("scratchpatch") + " " + + dimStyle.Render("["+pane+"]") + " " + dimStyle.Render(counts) +} + +// listPane renders the scrollable scratch list for the active pane, tinted by +// expiry (live) or purge proximity (morgue), with the cursor row reversed. +func (m Model) listPane(width, height int) string { + rows := m.rows() + now := m.deps.Now() + + if m.err != nil { + return expiredStyle.Render("error: " + m.err.Error()) + } + if len(rows) == 0 { + if strings.TrimSpace(m.filter) != "" { + return dimStyle.Render("(no scratches match \"" + m.filter + "\")") + } + if m.view == viewMorgue { + return dimStyle.Render("the morgue is empty") + } + return dimStyle.Render("no scratches yet β€” create one with `sp new`") + } + + // Simple scroll window so the cursor stays visible in tall lists. + start := 0 + if m.cursor >= height { + start = m.cursor - height + 1 + } + end := start + height + if end > len(rows) { + end = len(rows) + } + + var b strings.Builder + for i := start; i < end; i++ { + line := m.renderRow(rows[i], now, width) + if i == m.cursor { + line = selStyle.Render(padRight(stripToWidth(line, width), width)) + } + b.WriteString(line) + if i < end-1 { + b.WriteByte('\n') + } + } + return b.String() +} + +// renderRow formats a single list row: id, name (with πŸ”‘ for secrets), age, and +// expiry-or-purge, tinted by lifecycle. +func (m Model) renderRow(r row, now time.Time, width int) string { + name := r.sc.Name + if name == "" { + name = "-" + } + if r.secret { + name = "πŸ”‘ " + name + } + + var right string + var style lipgloss.Style + if m.view == viewMorgue { + right = purgeLabel(r, now) + style = soonStyle + if r.hasPurge && !now.Before(r.purgeAt) { + style = expiredStyle + } + } else { + right = expiryLabel(r.sc, now) + style = styleForExpiry(r.sc, now) + } + + left := fmt.Sprintf("%s %-16s", r.sc.ID, truncate(name, 16)) + age := humanDur(now.Sub(r.sc.CreatedAt)) + line := fmt.Sprintf("%s %4s %s", left, age, right) + return style.Render(stripToWidth(line, width)) +} + +// previewPane shows the selected scratch's content (or the redaction notice for +// secrets), wrapped in a rounded box sized to the available height. +func (m Model) previewPane(width, height int) string { + content := m.preview + if content == "" { + content = dimStyle.Render("(nothing selected)") + } + // Bound the preview to the visible height so a huge scratch doesn't blow + // past the frame. + lines := strings.Split(content, "\n") + max := height - 2 // account for the box border + if max < 1 { + max = 1 + } + if len(lines) > max { + lines = lines[:max] + lines[max-1] = truncate(lines[max-1], width-2) + " …" + } + for i, ln := range lines { + lines[i] = stripToWidth(ln, width-2) + } + body := strings.Join(lines, "\n") + if m.previewSecret { + body = warnStyle.Render(body) + } + return previewBox.Width(width).Height(height - 2).Render(body) +} + +// filterLine shows the active filter box while filtering, or the pending +// confirmation prompt, or the last status message. +func (m Model) filterLine() string { + if m.pending != pendingNone { + var what string + switch m.pending { + case pendingMorgue: + what = "move this scratch to the morgue?" + case pendingResurrect: + what = "resurrect this scratch?" + } + return warnStyle.Render("confirm: " + what + " [y/N]") + } + if m.filtering { + return titleStyle.Render("/") + m.filter + dimStyle.Render("▏") + } + if m.filter != "" { + return dimStyle.Render("filter: " + m.filter + " (/ to edit, esc clears)") + } + if m.status != "" { + return dimStyle.Render(m.status) + } + return "" +} + +// footer is the keybinding cheat sheet, context-sensitive to the active pane. +func (m Model) footer() string { + keys := []string{ + "↑/↓ move", + "tab live/morgue", + "/ filter", + "o open", + } + if m.view == viewLive { + keys = append(keys, "d rmβ†’morgue") + } else { + keys = append(keys, "r resurrect") + } + keys = append(keys, "R refresh", "q quit") + return footerStyle.Render(strings.Join(keys, " Β· ")) +} + +// --- small formatting helpers (kept local so the TUI owns its own layout) --- + +func styleForExpiry(sc index.Scratch, now time.Time) lipgloss.Style { + switch ttl.Classify(sc.ExpiresAt, now) { + case ttl.Expired: + return expiredStyle + case ttl.Soon: + return soonStyle + default: + return freshStyle + } +} + +func expiryLabel(sc index.Scratch, now time.Time) string { + remaining := sc.ExpiresAt.Sub(now) + if remaining <= 0 { + return "expired" + } + return "in " + humanDur(remaining) +} + +func purgeLabel(r row, now time.Time) string { + if !r.hasPurge { + return "-" + } + remaining := r.purgeAt.Sub(now) + if remaining <= 0 { + return "purges now" + } + return "purges in " + humanDur(remaining) +} + +// humanDur renders a positive duration as its largest unit, matching render's +// glanceable style ("3d", "5h", "12m", "8s"). +func humanDur(d time.Duration) string { + if d < 0 { + d = 0 + } + switch { + case d >= 24*time.Hour: + return fmt.Sprintf("%dd", int(d/(24*time.Hour))) + case d >= time.Hour: + return fmt.Sprintf("%dh", int(d/time.Hour)) + case d >= time.Minute: + return fmt.Sprintf("%dm", int(d/time.Minute)) + default: + return fmt.Sprintf("%ds", int(d/time.Second)) + } +} + +// truncate shortens s to at most width display columns, adding an ellipsis when +// it cuts. Uses lipgloss.Width for wide-rune correctness. +func truncate(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + // Trim runes until it fits, leaving room for the ellipsis. + runes := []rune(s) + for len(runes) > 0 && lipgloss.Width(string(runes))+1 > width { + runes = runes[:len(runes)-1] + } + return string(runes) + "…" +} + +// stripToWidth clips s to width display columns without adding an ellipsis, +// used to keep styled rows from overflowing the pane. +func stripToWidth(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + runes := []rune(s) + for len(runes) > 0 && lipgloss.Width(string(runes)) > width { + runes = runes[:len(runes)-1] + } + return string(runes) +} + +// padRight pads s with spaces to width display columns. +func padRight(s string, width int) string { + gap := width - lipgloss.Width(s) + if gap <= 0 { + return s + } + return s + strings.Repeat(" ", gap) +}