Add snapshot selection screen adapter

Amolith created

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.

Change summary

internal/ui/screens/snapshot.go      | 341 +++++++++++++++++++++++++++
internal/ui/screens/snapshot_test.go | 372 ++++++++++++++++++++++++++++++
2 files changed, 713 insertions(+)

Detailed changes

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 }

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")
+	}
+}