From e0c07c68c4ba83116019a2d42edf5610e30e09c9 Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 30 Mar 2026 19:30:56 -0600 Subject: [PATCH] Add snapshot selection screen adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add screens.Snapshot, the most complex screen adapter, with an internal state machine that handles three phases: - Loading: spinner while an injected SnapshotLoader fetches snapshots asynchronously - Selecting: filterable huh Select with formatted snapshot lines plus an "Enter ID manually…" option - Manual: huh Input fallback for typing arbitrary snapshot IDs Error handling follows the existing promptSnapshotID semantics: ErrNoRepo triggers silent fallback to manual entry, other errors show a notice above the input, empty repositories show a notice. A generation counter protects against stale async results when the user navigates back during a load and re-enters the screen. The loader is injected as a function for testability — tests simulate load results directly via snapshotsLoadedMsg without calling restic. --- internal/ui/screens/snapshot.go | 341 ++++++++++++++++++++++++ internal/ui/screens/snapshot_test.go | 372 +++++++++++++++++++++++++++ 2 files changed, 713 insertions(+) create mode 100644 internal/ui/screens/snapshot.go create mode 100644 internal/ui/screens/snapshot_test.go diff --git a/internal/ui/screens/snapshot.go b/internal/ui/screens/snapshot.go new file mode 100644 index 0000000000000000000000000000000000000000..b2567d4c078651f7b68e133f23088a3e1405f2b7 --- /dev/null +++ b/internal/ui/screens/snapshot.go @@ -0,0 +1,341 @@ +package screens + +import ( + "errors" + "fmt" + "sync/atomic" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + + "charm.land/huh/v2" + + "git.secluded.site/keld/internal/restic" + "git.secluded.site/keld/internal/theme" + "git.secluded.site/keld/internal/ui" +) + +// manualEntryValue is the sentinel option value returned by the +// snapshot select when the user chooses "Enter ID manually…". +const manualEntryValue = "__manual__" + +// snapshotPhase tracks where the snapshot screen is in its +// internal flow. +type snapshotPhase int + +const ( + phaseSnapshotLoading snapshotPhase = iota // spinner while fetching + phaseSnapshotSelecting // huh Select with results + phaseSnapshotManual // huh Input fallback +) + +// SnapshotLoader fetches snapshots from the repository. Injected at +// construction so tests can provide a fake. +type SnapshotLoader func() ([]restic.Snapshot, error) + +// snapshotsLoadedMsg carries the result of an async snapshot load. +type snapshotsLoadedMsg struct { + gen int64 + snapshots []restic.Snapshot + err error +} + +// Snapshot is a Screen adapter for selecting a restic snapshot ID. +// It loads snapshots asynchronously, presents a filterable list, and +// falls back to manual text entry on failure or user choice. +type Snapshot struct { + loader SnapshotLoader + styles *theme.Styles + spinner spinner.Model + + phase snapshotPhase + gen int64 // generation counter for stale-result protection + + // Selecting phase. + selectForm *huh.Form + selected string + snapshots []restic.Snapshot // cached for rebuild after back-nav + + // Manual entry phase. + manualForm *huh.Form + entered string + notice string // displayed above the manual input + + // Result. + value string + selection string +} + +// NewSnapshot creates a snapshot selection screen. If loader is nil, +// the screen goes directly to manual entry (for when no repository +// is configured and the caller knows it upfront). +func NewSnapshot(loader SnapshotLoader, styles *theme.Styles) *Snapshot { + sp := spinner.New(spinner.WithSpinner(spinner.Dot)) + sp.Style = sp.Style.Foreground(styles.Accent) + + return &Snapshot{ + loader: loader, + styles: styles, + spinner: sp, + } +} + +// Init starts the async snapshot load (or goes straight to manual +// entry if no loader is configured). Each call bumps the generation +// counter so stale results from prior loads are ignored. +func (s *Snapshot) Init() tea.Cmd { + s.selection = "" + s.value = "" + + if s.loader == nil { + s.phase = phaseSnapshotManual + s.buildManualForm() + return s.manualForm.Init() + } + + s.phase = phaseSnapshotLoading + gen := atomic.AddInt64(&s.gen, 1) + + return tea.Batch(s.spinner.Tick, func() tea.Msg { + snaps, err := s.loader() + return snapshotsLoadedMsg{gen: gen, snapshots: snaps, err: err} + }) +} + +// Update handles messages across all phases. +func (s *Snapshot) Update(msg tea.Msg) (ui.Screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + s.spinner.Style = s.spinner.Style.Foreground(s.styles.Accent) + // Rebuild whichever form is active. + switch s.phase { + case phaseSnapshotSelecting: + // Rebuild would lose scroll position; skip for now. + case phaseSnapshotManual: + s.buildManualForm() + return s, s.manualForm.Init() + } + return s, nil + + case tea.KeyPressMsg: + if msg.Code == tea.KeyEscape { + return s.handleEsc() + } + + case snapshotsLoadedMsg: + return s.handleLoaded(msg) + } + + // Delegate to the active phase. + switch s.phase { + case phaseSnapshotLoading: + return s.updateLoading(msg) + case phaseSnapshotSelecting: + return s.updateSelecting(msg) + case phaseSnapshotManual: + return s.updateManual(msg) + } + + return s, nil +} + +// handleEsc processes Esc across all phases. +func (s *Snapshot) handleEsc() (ui.Screen, tea.Cmd) { + switch s.phase { + case phaseSnapshotLoading: + return s, ui.BackCmd + case phaseSnapshotSelecting: + return s, ui.BackCmd + case phaseSnapshotManual: + // If we came from the select phase (user chose manual entry), + // Esc should go back to the select. Otherwise, Esc backs out + // of the whole screen. + if s.selectForm != nil { + s.phase = phaseSnapshotSelecting + s.rebuildSelectForm() + return s, s.selectForm.Init() + } + return s, ui.BackCmd + } + return s, ui.BackCmd +} + +// handleLoaded processes the async snapshot load result. +func (s *Snapshot) handleLoaded(msg snapshotsLoadedMsg) (ui.Screen, tea.Cmd) { + // Ignore stale results from a prior generation. + if msg.gen != atomic.LoadInt64(&s.gen) { + return s, nil + } + + if msg.err != nil { + if errors.Is(msg.err, restic.ErrNoRepo) { + // Silent fallback — no notice. + s.switchToManual("") + } else { + s.switchToManual(fmt.Sprintf("Could not list snapshots: %v", msg.err)) + } + return s, s.manualForm.Init() + } + + if len(msg.snapshots) == 0 { + s.switchToManual("No snapshots found in repository.") + return s, s.manualForm.Init() + } + + s.buildSelectForm(msg.snapshots) + s.phase = phaseSnapshotSelecting + return s, s.selectForm.Init() +} + +// updateLoading forwards messages to the spinner during the loading phase. +func (s *Snapshot) updateLoading(msg tea.Msg) (ui.Screen, tea.Cmd) { + var cmd tea.Cmd + s.spinner, cmd = s.spinner.Update(msg) + return s, cmd +} + +// updateSelecting forwards messages to the huh select form. +func (s *Snapshot) updateSelecting(msg tea.Msg) (ui.Screen, tea.Cmd) { + if s.selectForm == nil { + return s, nil + } + + model, cmd := s.selectForm.Update(msg) + if f, ok := model.(*huh.Form); ok { + s.selectForm = f + } + + if s.selectForm.State == huh.StateCompleted { + if s.selected == manualEntryValue { + s.switchToManual("") + return s, s.manualForm.Init() + } + s.value = s.selected + s.selection = s.selected + return s, ui.DoneCmd + } + + return s, cmd +} + +// updateManual forwards messages to the huh manual input form. +func (s *Snapshot) updateManual(msg tea.Msg) (ui.Screen, tea.Cmd) { + if s.manualForm == nil { + return s, nil + } + + model, cmd := s.manualForm.Update(msg) + if f, ok := model.(*huh.Form); ok { + s.manualForm = f + } + + if s.manualForm.State == huh.StateCompleted { + s.value = s.entered + s.selection = s.entered + return s, ui.DoneCmd + } + + return s, cmd +} + +// buildSelectForm constructs the huh Select from loaded snapshots. +func (s *Snapshot) buildSelectForm(snapshots []restic.Snapshot) { + s.snapshots = snapshots + opts := make([]huh.Option[string], 0, len(snapshots)+1) + for _, snap := range snapshots { + opts = append(opts, huh.NewOption(restic.FormatSnapshotLine(snap), snap.ShortID)) + } + opts = append(opts, huh.NewOption("Enter ID manually…", manualEntryValue)) + + s.selected = "" + sel := huh.NewSelect[string](). + Options(opts...). + Filtering(true). + Value(&s.selected) + + s.selectForm = huh.NewForm( + huh.NewGroup(sel), + ).WithTheme(s.styles.Huh).WithShowHelp(false) +} + +// rebuildSelectForm reconstructs the select form from the cached +// snapshots. Called when navigating back from manual entry to +// ensure the form has fresh internal state (filter, cursor, focus). +func (s *Snapshot) rebuildSelectForm() { + if len(s.snapshots) > 0 { + s.buildSelectForm(s.snapshots) + } +} + +// buildManualForm constructs the huh Input for manual snapshot ID entry. +func (s *Snapshot) buildManualForm() { + s.entered = "" + + input := huh.NewInput(). + Title("Snapshot ID"). + Description("Supports snapshotID:subfolder syntax, or \"latest\"."). + Placeholder("e.g. latest or a1b2c3d4"). + Value(&s.entered). + Validate(huh.ValidateNotEmpty()) + + s.manualForm = huh.NewForm( + huh.NewGroup(input), + ).WithTheme(s.styles.Huh).WithShowHelp(false) +} + +// switchToManual transitions to the manual entry phase with an +// optional notice message. +func (s *Snapshot) switchToManual(notice string) { + s.phase = phaseSnapshotManual + s.notice = notice + s.buildManualForm() +} + +// View renders the current phase. +func (s *Snapshot) View() string { + switch s.phase { + case phaseSnapshotLoading: + return s.spinner.View() + " Loading snapshots…" + case phaseSnapshotSelecting: + if s.selectForm == nil { + return "" + } + return s.selectForm.View() + case phaseSnapshotManual: + var view string + if s.notice != "" { + view = s.notice + "\n\n" + } + if s.manualForm != nil { + view += s.manualForm.View() + } + return view + } + return "" +} + +// Title returns the screen's display title. +func (s *Snapshot) Title() string { return "Select a snapshot" } + +// KeyBindings returns bindings for the help bar. +func (s *Snapshot) KeyBindings() []key.Binding { + switch s.phase { + case phaseSnapshotSelecting: + return []key.Binding{ + key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + } + default: + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + } + } +} + +// Selection returns the chosen snapshot ID for breadcrumb display, +// or "" if nothing has been selected yet. +func (s *Snapshot) Selection() string { return s.selection } + +// Value returns the resolved snapshot ID. +func (s *Snapshot) Value() string { return s.value } diff --git a/internal/ui/screens/snapshot_test.go b/internal/ui/screens/snapshot_test.go new file mode 100644 index 0000000000000000000000000000000000000000..babe79a542898d9dc17bebbe38cb86f22688eb4a --- /dev/null +++ b/internal/ui/screens/snapshot_test.go @@ -0,0 +1,372 @@ +package screens + +import ( + "errors" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/restic" + "git.secluded.site/keld/internal/ui" +) + +func testSnapshots() []restic.Snapshot { + return []restic.Snapshot{ + { + ID: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + ShortID: "abcdef01", + Time: time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC), + Hostname: "host1", + Paths: []string{"/home"}, + Tags: []string{"daily"}, + }, + { + ID: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + ShortID: "12345678", + Time: time.Date(2026, 3, 14, 9, 0, 0, 0, time.UTC), + Hostname: "host2", + Paths: []string{"/etc"}, + }, + } +} + +// drainSnapshot feeds commands back into the snapshot screen until a +// DoneMsg or BackMsg is produced, or the command chain is exhausted. +func drainSnapshot(s *Snapshot, initialCmd tea.Cmd) (*Snapshot, tea.Cmd) { + cmd := initialCmd + for cmd != nil { + msg := cmd() + if msg == nil { + return s, nil + } + switch msg.(type) { + case ui.DoneMsg: + return s, cmd + case ui.BackMsg: + return s, cmd + } + var screen ui.Screen + screen, cmd = s.Update(msg) + s = screen.(*Snapshot) + } + return s, nil +} + +// simulateLoad sends a snapshotsLoadedMsg directly to the screen, +// bypassing tea.Batch. This is how tests deliver async results — +// in the real runtime, the Batch command produces this message. +func simulateLoad(s *Snapshot, snaps []restic.Snapshot, err error) *Snapshot { + gen := atomic.LoadInt64(&s.gen) + msg := snapshotsLoadedMsg{gen: gen, snapshots: snaps, err: err} + screen, cmd := s.Update(msg) + s = screen.(*Snapshot) + // Drain any form init commands. + s, _ = drainSnapshot(s, cmd) + return s +} + +// simulateStaleLoad sends a snapshotsLoadedMsg with an old generation, +// simulating a result from a prior Init that should be ignored. +func simulateStaleLoad(s *Snapshot, snaps []restic.Snapshot, err error, staleGen int64) *Snapshot { + msg := snapshotsLoadedMsg{gen: staleGen, snapshots: snaps, err: err} + screen, _ := s.Update(msg) + return screen.(*Snapshot) +} + +func initSnapshot(s *Snapshot) *Snapshot { + s.Init() + screen, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + return screen.(*Snapshot) +} + +func TestSnapshotTitle(t *testing.T) { + t.Parallel() + + s := NewSnapshot(nil, testStyles()) + if got := s.Title(); got != "Select a snapshot" { + t.Errorf("Title() = %q, want %q", got, "Select a snapshot") + } +} + +func TestSnapshotSelectionEmpty(t *testing.T) { + t.Parallel() + + s := NewSnapshot(nil, testStyles()) + if got := s.Selection(); got != "" { + t.Errorf("Selection() before interaction = %q, want empty", got) + } +} + +func TestSnapshotKeyBindings(t *testing.T) { + t.Parallel() + + s := NewSnapshot(nil, testStyles()) + if len(s.KeyBindings()) == 0 { + t.Fatal("KeyBindings() returned no bindings") + } +} + +func TestSnapshotLoadsAndPresentsSelect(t *testing.T) { + t.Parallel() + + snaps := testSnapshots() + loader := func() ([]restic.Snapshot, error) { return snaps, nil } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + s = simulateLoad(s, snaps, nil) + + // Should now be in the selecting phase. Press enter to pick first. + screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + s, cmd = drainSnapshot(screen.(*Snapshot), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after selecting a snapshot") + } + if _, ok := cmd().(ui.DoneMsg); !ok { + t.Errorf("cmd produced %T, want ui.DoneMsg", cmd()) + } + + if got := s.Value(); got != "abcdef01" { + t.Errorf("Value() = %q, want %q", got, "abcdef01") + } +} + +func TestSnapshotSelectionShowsShortID(t *testing.T) { + t.Parallel() + + snaps := testSnapshots() + loader := func() ([]restic.Snapshot, error) { return snaps, nil } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + s = simulateLoad(s, snaps, nil) + + // Select first snapshot. + screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + s, _ = drainSnapshot(screen.(*Snapshot), cmd) + + if got := s.Selection(); got != "abcdef01" { + t.Errorf("Selection() = %q, want %q", got, "abcdef01") + } +} + +func TestSnapshotErrorFallsBackToManual(t *testing.T) { + t.Parallel() + + netErr := errors.New("network error") + loader := func() ([]restic.Snapshot, error) { return nil, netErr } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + s = simulateLoad(s, nil, netErr) + + // Should be in manual entry phase with error notice. + if s.notice == "" { + t.Error("notice should be set on error") + } + + // Type a snapshot ID and submit. + for _, ch := range "latest" { + screen, _ := s.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + s = screen.(*Snapshot) + } + + screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + s, cmd = drainSnapshot(screen.(*Snapshot), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after manual entry") + } + if _, ok := cmd().(ui.DoneMsg); !ok { + t.Errorf("cmd produced %T, want ui.DoneMsg", cmd()) + } + + if got := s.Value(); got != "latest" { + t.Errorf("Value() = %q, want %q", got, "latest") + } +} + +func TestSnapshotNoRepoFallsBackSilently(t *testing.T) { + t.Parallel() + + loader := func() ([]restic.Snapshot, error) { return nil, restic.ErrNoRepo } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + s = simulateLoad(s, nil, restic.ErrNoRepo) + + // Should be in manual entry phase with no error notice. + if s.notice != "" { + t.Errorf("notice = %q, want empty for ErrNoRepo (silent fallback)", s.notice) + } + + // Type and submit. + for _, ch := range "abc123" { + screen, _ := s.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + s = screen.(*Snapshot) + } + screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + s, cmd = drainSnapshot(screen.(*Snapshot), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after manual entry") + } + if got := s.Value(); got != "abc123" { + t.Errorf("Value() = %q, want %q", got, "abc123") + } +} + +func TestSnapshotEmptyRepoFallsBackWithNotice(t *testing.T) { + t.Parallel() + + loader := func() ([]restic.Snapshot, error) { return []restic.Snapshot{}, nil } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + s = simulateLoad(s, []restic.Snapshot{}, nil) + + // Should show a notice about empty repo. + if s.notice == "" { + t.Error("notice should be set when repository has no snapshots") + } +} + +func TestSnapshotNilLoaderGoesDirectToManual(t *testing.T) { + t.Parallel() + + s := NewSnapshot(nil, testStyles()) + cmd := s.Init() + screen, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + s = screen.(*Snapshot) + + // Drain any init commands from the manual form. + s, _ = drainSnapshot(s, cmd) + + // With no loader, should start in manual entry immediately. + for _, ch := range "latest" { + screen, _ := s.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + s = screen.(*Snapshot) + } + screen, cmd = s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + s, cmd = drainSnapshot(screen.(*Snapshot), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after manual entry") + } + if got := s.Value(); got != "latest" { + t.Errorf("Value() = %q, want %q", got, "latest") + } +} + +func TestSnapshotManualEntryFromSelect(t *testing.T) { + t.Parallel() + + snaps := testSnapshots() + loader := func() ([]restic.Snapshot, error) { return snaps, nil } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + s = simulateLoad(s, snaps, nil) + + // Navigate down to the "Enter ID manually…" option (last item, + // 2 snapshots + 1 manual = index 2, so press down twice). + screen, _ := s.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + s = screen.(*Snapshot) + screen, _ = s.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + s = screen.(*Snapshot) + + // Select the manual entry option. + screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + s, _ = drainSnapshot(screen.(*Snapshot), cmd) + + // Should now be in manual entry phase. Type an ID. + for _, ch := range "abc123:subfolder" { + screen, _ := s.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + s = screen.(*Snapshot) + } + screen, cmd = s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + s, cmd = drainSnapshot(screen.(*Snapshot), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after manual entry from select") + } + if got := s.Value(); got != "abc123:subfolder" { + t.Errorf("Value() = %q, want %q", got, "abc123:subfolder") + } +} + +func TestSnapshotEscDuringLoadingReturnsBack(t *testing.T) { + t.Parallel() + + loader := func() ([]restic.Snapshot, error) { return nil, nil } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + + // Press Esc during loading phase. + _, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if cmd == nil { + t.Fatal("expected BackCmd on Esc during loading") + } + if _, ok := cmd().(ui.BackMsg); !ok { + t.Errorf("cmd produced %T, want ui.BackMsg", cmd()) + } +} + +func TestSnapshotEscDuringSelectReturnsBack(t *testing.T) { + t.Parallel() + + snaps := testSnapshots() + loader := func() ([]restic.Snapshot, error) { return snaps, nil } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + s = simulateLoad(s, snaps, nil) + + // Esc during selection should back out. + _, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if cmd == nil { + t.Fatal("expected BackCmd on Esc during selection") + } + if _, ok := cmd().(ui.BackMsg); !ok { + t.Errorf("cmd produced %T, want ui.BackMsg", cmd()) + } +} + +func TestSnapshotStaleLoadIgnored(t *testing.T) { + t.Parallel() + + snaps := testSnapshots() + loader := func() ([]restic.Snapshot, error) { return snaps, nil } + + s := NewSnapshot(loader, testStyles()) + s = initSnapshot(s) + + // Record the current generation, then re-init to bump it. + staleGen := atomic.LoadInt64(&s.gen) + s.Init() + + // Deliver a result with the stale generation. Should be ignored. + s = simulateStaleLoad(s, snaps, nil, staleGen) + + // Should still be loading (stale result ignored). + if s.phase != phaseSnapshotLoading { + t.Fatalf("phase = %d, want %d (phaseSnapshotLoading) after stale result", s.phase, phaseSnapshotLoading) + } + + // Deliver with the current generation. + s = simulateLoad(s, snaps, nil) + + // Now should be in selecting phase. + screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + _, cmd = drainSnapshot(screen.(*Snapshot), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after selecting from fresh load") + } +}