snapshot.go

  1package screens
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"sync/atomic"
  7
  8	"charm.land/bubbles/v2/key"
  9	"charm.land/bubbles/v2/spinner"
 10	tea "charm.land/bubbletea/v2"
 11
 12	"charm.land/huh/v2"
 13
 14	"git.secluded.site/keld/internal/restic"
 15	"git.secluded.site/keld/internal/theme"
 16	"git.secluded.site/keld/internal/ui"
 17)
 18
 19// manualEntryValue is the sentinel option value returned by the
 20// snapshot select when the user chooses "Enter ID manually…".
 21const manualEntryValue = "__manual__"
 22
 23// snapshotPhase tracks where the snapshot screen is in its
 24// internal flow.
 25type snapshotPhase int
 26
 27const (
 28	phaseSnapshotLoading   snapshotPhase = iota // spinner while fetching
 29	phaseSnapshotSelecting                      // huh Select with results
 30	phaseSnapshotManual                         // huh Input fallback
 31)
 32
 33// SnapshotLoader fetches snapshots from the repository. Injected at
 34// construction so tests can provide a fake.
 35type SnapshotLoader func() ([]restic.Snapshot, error)
 36
 37// snapshotsLoadedMsg carries the result of an async snapshot load.
 38type snapshotsLoadedMsg struct {
 39	gen       int64
 40	snapshots []restic.Snapshot
 41	err       error
 42}
 43
 44// Snapshot is a Screen adapter for selecting a restic snapshot ID.
 45// It loads snapshots asynchronously, presents a filterable list, and
 46// falls back to manual text entry on failure or user choice.
 47type Snapshot struct {
 48	loader  SnapshotLoader
 49	styles  *theme.Styles
 50	spinner spinner.Model
 51
 52	phase snapshotPhase
 53	gen   int64 // generation counter for stale-result protection
 54
 55	// Selecting phase.
 56	selectForm *huh.Form
 57	selected   string
 58	snapshots  []restic.Snapshot // cached for rebuild after back-nav
 59
 60	// Manual entry phase.
 61	manualForm *huh.Form
 62	entered    string
 63	notice     string // displayed above the manual input
 64
 65	// Result.
 66	value     string
 67	selection string
 68}
 69
 70// NewSnapshot creates a snapshot selection screen. If loader is nil,
 71// the screen goes directly to manual entry (for when no repository
 72// is configured and the caller knows it upfront).
 73func NewSnapshot(loader SnapshotLoader, styles *theme.Styles) *Snapshot {
 74	sp := spinner.New(spinner.WithSpinner(spinner.Dot))
 75	sp.Style = sp.Style.Foreground(styles.Accent)
 76
 77	return &Snapshot{
 78		loader:  loader,
 79		styles:  styles,
 80		spinner: sp,
 81	}
 82}
 83
 84// Init starts the async snapshot load (or goes straight to manual
 85// entry if no loader is configured). Each call bumps the generation
 86// counter so stale results from prior loads are ignored.
 87func (s *Snapshot) Init() tea.Cmd {
 88	s.selection = ""
 89	s.value = ""
 90
 91	if s.loader == nil {
 92		s.phase = phaseSnapshotManual
 93		s.entered = ""
 94		s.buildManualForm()
 95		return s.manualForm.Init()
 96	}
 97
 98	s.phase = phaseSnapshotLoading
 99	gen := atomic.AddInt64(&s.gen, 1)
100
101	return tea.Batch(s.spinner.Tick, func() tea.Msg {
102		snaps, err := s.loader()
103		return snapshotsLoadedMsg{gen: gen, snapshots: snaps, err: err}
104	})
105}
106
107// Update handles messages across all phases.
108func (s *Snapshot) Update(msg tea.Msg) (ui.Screen, tea.Cmd) {
109	switch msg := msg.(type) {
110	case tea.BackgroundColorMsg:
111		s.spinner.Style = s.spinner.Style.Foreground(s.styles.Accent)
112		// Rebuild whichever form is active.
113		switch s.phase {
114		case phaseSnapshotSelecting:
115			// Rebuild would lose scroll position; skip for now.
116		case phaseSnapshotManual:
117			s.buildManualForm()
118			return s, s.manualForm.Init()
119		}
120		return s, nil
121
122	case tea.KeyPressMsg:
123		if msg.Code == tea.KeyEscape {
124			return s.handleEsc()
125		}
126
127	case snapshotsLoadedMsg:
128		return s.handleLoaded(msg)
129	}
130
131	// Delegate to the active phase.
132	switch s.phase {
133	case phaseSnapshotLoading:
134		return s.updateLoading(msg)
135	case phaseSnapshotSelecting:
136		return s.updateSelecting(msg)
137	case phaseSnapshotManual:
138		return s.updateManual(msg)
139	}
140
141	return s, nil
142}
143
144// handleEsc processes Esc across all phases.
145func (s *Snapshot) handleEsc() (ui.Screen, tea.Cmd) {
146	switch s.phase {
147	case phaseSnapshotLoading:
148		return s, ui.BackCmd
149	case phaseSnapshotSelecting:
150		return s, ui.BackCmd
151	case phaseSnapshotManual:
152		// If we came from the select phase (user chose manual entry),
153		// Esc should go back to the select. Otherwise, Esc backs out
154		// of the whole screen.
155		if s.selectForm != nil {
156			s.phase = phaseSnapshotSelecting
157			s.rebuildSelectForm()
158			return s, s.selectForm.Init()
159		}
160		return s, ui.BackCmd
161	}
162	return s, ui.BackCmd
163}
164
165// handleLoaded processes the async snapshot load result.
166func (s *Snapshot) handleLoaded(msg snapshotsLoadedMsg) (ui.Screen, tea.Cmd) {
167	// Ignore stale results from a prior generation.
168	if msg.gen != atomic.LoadInt64(&s.gen) {
169		return s, nil
170	}
171
172	if msg.err != nil {
173		if errors.Is(msg.err, restic.ErrNoRepo) {
174			// Silent fallback — no notice.
175			s.switchToManual("")
176		} else {
177			s.switchToManual(fmt.Sprintf("Could not list snapshots: %v", msg.err))
178		}
179		return s, s.manualForm.Init()
180	}
181
182	if len(msg.snapshots) == 0 {
183		s.switchToManual("No snapshots found in repository.")
184		return s, s.manualForm.Init()
185	}
186
187	s.buildSelectForm(msg.snapshots)
188	s.phase = phaseSnapshotSelecting
189	return s, s.selectForm.Init()
190}
191
192// updateLoading forwards messages to the spinner during the loading phase.
193func (s *Snapshot) updateLoading(msg tea.Msg) (ui.Screen, tea.Cmd) {
194	var cmd tea.Cmd
195	s.spinner, cmd = s.spinner.Update(msg)
196	return s, cmd
197}
198
199// updateSelecting forwards messages to the huh select form.
200func (s *Snapshot) updateSelecting(msg tea.Msg) (ui.Screen, tea.Cmd) {
201	if s.selectForm == nil {
202		return s, nil
203	}
204
205	model, cmd := s.selectForm.Update(msg)
206	if f, ok := model.(*huh.Form); ok {
207		s.selectForm = f
208	}
209
210	if s.selectForm.State == huh.StateCompleted {
211		if s.selected == manualEntryValue {
212			s.switchToManual("")
213			return s, s.manualForm.Init()
214		}
215		s.value = s.selected
216		s.selection = s.selected
217		return s, ui.DoneCmd
218	}
219
220	return s, cmd
221}
222
223// updateManual forwards messages to the huh manual input form.
224func (s *Snapshot) updateManual(msg tea.Msg) (ui.Screen, tea.Cmd) {
225	if s.manualForm == nil {
226		return s, nil
227	}
228
229	model, cmd := s.manualForm.Update(msg)
230	if f, ok := model.(*huh.Form); ok {
231		s.manualForm = f
232	}
233
234	if s.manualForm.State == huh.StateCompleted {
235		s.value = s.entered
236		s.selection = s.entered
237		return s, ui.DoneCmd
238	}
239
240	return s, cmd
241}
242
243// buildSelectForm constructs the huh Select from loaded snapshots.
244func (s *Snapshot) buildSelectForm(snapshots []restic.Snapshot) {
245	s.snapshots = snapshots
246	opts := make([]huh.Option[string], 0, len(snapshots)+1)
247	for _, snap := range snapshots {
248		opts = append(opts, huh.NewOption(restic.FormatSnapshotLine(snap), snap.ShortID))
249	}
250	opts = append(opts, huh.NewOption("Enter ID manually…", manualEntryValue))
251
252	s.selected = ""
253	sel := huh.NewSelect[string]().
254		Options(opts...).
255		Filtering(true).
256		Value(&s.selected)
257
258	s.selectForm = huh.NewForm(
259		huh.NewGroup(sel),
260	).WithTheme(s.styles.Huh).WithShowHelp(false)
261}
262
263// rebuildSelectForm reconstructs the select form from the cached
264// snapshots. Called when navigating back from manual entry to
265// ensure the form has fresh internal state (filter, cursor, focus).
266func (s *Snapshot) rebuildSelectForm() {
267	if len(s.snapshots) > 0 {
268		s.buildSelectForm(s.snapshots)
269	}
270}
271
272// buildManualForm constructs the huh Input for manual snapshot ID entry.
273// It does not reset s.entered so that callers rebuilding the form for
274// theme changes preserve the user's input. Callers that start a fresh
275// entry (Init, switchToManual) reset s.entered explicitly.
276func (s *Snapshot) buildManualForm() {
277	input := huh.NewInput().
278		Title("Snapshot ID").
279		Description("Supports snapshotID:subfolder syntax, or \"latest\".").
280		Placeholder("e.g. latest or a1b2c3d4").
281		Value(&s.entered).
282		Validate(huh.ValidateNotEmpty())
283
284	s.manualForm = huh.NewForm(
285		huh.NewGroup(input),
286	).WithTheme(s.styles.Huh).WithShowHelp(false)
287}
288
289// switchToManual transitions to the manual entry phase with an
290// optional notice message.
291func (s *Snapshot) switchToManual(notice string) {
292	s.phase = phaseSnapshotManual
293	s.notice = notice
294	s.entered = ""
295	s.buildManualForm()
296}
297
298// View renders the current phase.
299func (s *Snapshot) View() string {
300	switch s.phase {
301	case phaseSnapshotLoading:
302		return s.spinner.View() + " Loading snapshots…"
303	case phaseSnapshotSelecting:
304		if s.selectForm == nil {
305			return ""
306		}
307		return s.selectForm.View()
308	case phaseSnapshotManual:
309		var view string
310		if s.notice != "" {
311			view = s.notice + "\n\n"
312		}
313		if s.manualForm != nil {
314			view += s.manualForm.View()
315		}
316		return view
317	}
318	return ""
319}
320
321// Title returns the screen's display title.
322func (s *Snapshot) Title() string { return "Select a snapshot" }
323
324// KeyBindings returns bindings for the help bar.
325func (s *Snapshot) KeyBindings() []key.Binding {
326	switch s.phase {
327	case phaseSnapshotSelecting:
328		return []key.Binding{
329			key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")),
330			key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
331		}
332	default:
333		return []key.Binding{
334			key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
335		}
336	}
337}
338
339// Selection returns the chosen snapshot ID for breadcrumb display,
340// or "" if nothing has been selected yet.
341func (s *Snapshot) Selection() string { return s.selection }
342
343// Value returns the resolved snapshot ID.
344func (s *Snapshot) Value() string { return s.value }