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 }