1package screens
2
3import (
4 "fmt"
5 "strings"
6 "sync/atomic"
7
8 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
11
12 "git.secluded.site/keld/internal/picker"
13 "git.secluded.site/keld/internal/restic"
14 "git.secluded.site/keld/internal/theme"
15 "git.secluded.site/keld/internal/ui"
16)
17
18// filePickerPhase tracks the file picker's internal flow.
19type filePickerPhase int
20
21const (
22 phaseFileLoading filePickerPhase = iota // spinner while fetching
23 phaseFilePicking // interactive file picker
24 phaseFileNotice // notice before auto-advancing
25)
26
27// FileLoader fetches the file listing for a snapshot. Injected at
28// construction so tests can provide a fake.
29type FileLoader func(snapshotID string) ([]restic.LsNode, error)
30
31// filesLoadedMsg carries the result of an async file listing load.
32type filesLoadedMsg struct {
33 gen int64
34 nodes []restic.LsNode
35 err error
36}
37
38// FilePicker is a Screen adapter that wraps the vendored picker
39// component for selecting files to restore from a snapshot. It
40// handles async loading, error notices, and conversion of selected
41// paths to restic --include syntax.
42type FilePicker struct {
43 loader FileLoader
44 snapshotFn func() string
45 styles *theme.Styles
46 spinner spinner.Model
47
48 phase filePickerPhase
49 gen int64
50 snapshotID string
51
52 // Picking phase.
53 picker *picker.Model
54
55 // Notice phase.
56 notice string
57
58 // Layout.
59 lastSize *tea.WindowSizeMsg
60
61 // Result.
62 includes []string
63 selection string
64}
65
66// NewFilePicker creates a file picker screen. If loader is nil, the
67// screen auto-advances with no includes (full restore). snapshotFn
68// returns the snapshot ID from the previous screen.
69func NewFilePicker(loader FileLoader, snapshotFn func() string, styles *theme.Styles) *FilePicker {
70 sp := spinner.New(spinner.WithSpinner(spinner.Dot))
71 sp.Style = sp.Style.Foreground(styles.Accent)
72
73 return &FilePicker{
74 loader: loader,
75 snapshotFn: snapshotFn,
76 styles: styles,
77 spinner: sp,
78 }
79}
80
81// Init starts the async file listing load.
82func (fp *FilePicker) Init() tea.Cmd {
83 fp.selection = ""
84 fp.includes = nil
85
86 fp.snapshotID = fp.snapshotFn()
87
88 if fp.loader == nil {
89 return ui.DoneCmd
90 }
91
92 fp.phase = phaseFileLoading
93 gen := atomic.AddInt64(&fp.gen, 1)
94
95 return tea.Batch(fp.spinner.Tick, func() tea.Msg {
96 nodes, err := fp.loader(fp.snapshotID)
97 return filesLoadedMsg{gen: gen, nodes: nodes, err: err}
98 })
99}
100
101// Update handles messages across all phases.
102func (fp *FilePicker) Update(msg tea.Msg) (ui.Screen, tea.Cmd) {
103 // Always capture the latest window size so it can be applied
104 // to the picker when it is built after loading completes.
105 if wsm, ok := msg.(tea.WindowSizeMsg); ok {
106 fp.lastSize = &wsm
107 }
108
109 switch msg := msg.(type) {
110 case tea.KeyPressMsg:
111 if msg.Code == tea.KeyEscape {
112 return fp.handleEsc()
113 }
114
115 case filesLoadedMsg:
116 return fp.handleLoaded(msg)
117 }
118
119 switch fp.phase {
120 case phaseFileLoading:
121 return fp.updateLoading(msg)
122 case phaseFilePicking:
123 return fp.updatePicking(msg)
124 case phaseFileNotice:
125 return fp.updateNotice(msg)
126 }
127
128 return fp, nil
129}
130
131// handleEsc processes Esc across all phases.
132func (fp *FilePicker) handleEsc() (ui.Screen, tea.Cmd) {
133 switch fp.phase {
134 case phaseFileLoading:
135 return fp, ui.BackCmd
136 case phaseFilePicking:
137 if fp.picker != nil && fp.picker.CurrentDirectory == "." {
138 return fp, ui.BackCmd
139 }
140 // Let the picker navigate to parent directory.
141 return fp.updatePicking(tea.KeyPressMsg{Code: tea.KeyEscape})
142 case phaseFileNotice:
143 return fp, ui.BackCmd
144 }
145 return fp, ui.BackCmd
146}
147
148// handleLoaded processes the async file listing result.
149func (fp *FilePicker) handleLoaded(msg filesLoadedMsg) (ui.Screen, tea.Cmd) {
150 if msg.gen != atomic.LoadInt64(&fp.gen) {
151 return fp, nil
152 }
153
154 if msg.err != nil {
155 fp.phase = phaseFileNotice
156 fp.notice = fmt.Sprintf("Could not list snapshot contents: %v\nProceeding with full restore.", msg.err)
157 return fp, nil
158 }
159
160 if len(msg.nodes) == 0 {
161 fp.phase = phaseFileNotice
162 fp.notice = "Snapshot contains no files.\nProceeding with full restore."
163 return fp, nil
164 }
165
166 fp.buildPicker(msg.nodes)
167 fp.phase = phaseFilePicking
168 return fp, fp.picker.Init()
169}
170
171// updateLoading forwards messages to the spinner during loading.
172func (fp *FilePicker) updateLoading(msg tea.Msg) (ui.Screen, tea.Cmd) {
173 var cmd tea.Cmd
174 fp.spinner, cmd = fp.spinner.Update(msg)
175 return fp, cmd
176}
177
178// updatePicking forwards messages to the picker.
179func (fp *FilePicker) updatePicking(msg tea.Msg) (ui.Screen, tea.Cmd) {
180 if fp.picker == nil {
181 return fp, nil
182 }
183
184 // Handle window resize for the picker.
185 if wsm, ok := msg.(tea.WindowSizeMsg); ok {
186 fp.picker.AutoHeight = false
187 fp.picker.SetHeight(max(1, wsm.Height))
188 }
189
190 updated, cmd := fp.picker.Update(msg)
191 fp.picker = &updated
192
193 if fp.picker.Confirmed {
194 fp.resolveSelection()
195 return fp, ui.DoneCmd
196 }
197
198 return fp, cmd
199}
200
201// updateNotice waits for any key press to acknowledge, then advances.
202func (fp *FilePicker) updateNotice(msg tea.Msg) (ui.Screen, tea.Cmd) {
203 if _, ok := msg.(tea.KeyPressMsg); ok {
204 fp.selection = "all files"
205 return fp, ui.DoneCmd
206 }
207 return fp, nil
208}
209
210// buildPicker constructs the picker model from the loaded nodes.
211// If a WindowSizeMsg was received during loading, it is applied
212// to the picker immediately so it has correct dimensions.
213func (fp *FilePicker) buildPicker(nodes []restic.LsNode) {
214 sfs := restic.NewSnapshotFS(nodes)
215 p := picker.New(sfs)
216 p.Cursor = strings.TrimSuffix(theme.Cursor, " ")
217 p.Styles = fp.styles.Picker
218 fp.picker = &p
219
220 if fp.lastSize != nil {
221 fp.picker.AutoHeight = false
222 fp.picker.SetHeight(max(1, fp.lastSize.Height))
223 }
224}
225
226// resolveSelection reads the picker's selection state and converts
227// it to restic --include paths.
228func (fp *FilePicker) resolveSelection() {
229 sel := fp.picker.Selection
230 if sel.AllSelected() {
231 fp.selection = "all files"
232 fp.includes = nil
233 return
234 }
235
236 paths := sel.SelectedPaths()
237 if paths == nil {
238 // Nothing toggled β treat as full restore.
239 fp.selection = "all files"
240 fp.includes = nil
241 return
242 }
243
244 fp.includes = make([]string, len(paths))
245 for i, p := range paths {
246 fp.includes[i] = "/" + p
247 }
248 fp.selection = fmt.Sprintf("%d path(s)", len(fp.includes))
249}
250
251// View renders the current phase.
252func (fp *FilePicker) View() string {
253 switch fp.phase {
254 case phaseFileLoading:
255 return fp.spinner.View() + " Loading snapshot contentsβ¦"
256 case phaseFilePicking:
257 if fp.picker == nil {
258 return ""
259 }
260 return fp.picker.View()
261 case phaseFileNotice:
262 return fp.notice + "\n\nPress any key to continue."
263 }
264 return ""
265}
266
267// Title returns the screen's display title.
268func (fp *FilePicker) Title() string { return "Select files to restore" }
269
270// KeyBindings returns bindings for the help bar.
271func (fp *FilePicker) KeyBindings() []key.Binding {
272 switch fp.phase {
273 case phaseFilePicking:
274 return []key.Binding{
275 key.NewBinding(key.WithKeys("β/β"), key.WithHelp("β/β", "navigate")),
276 key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
277 key.NewBinding(key.WithKeys("β"), key.WithHelp("β", "open")),
278 key.NewBinding(key.WithKeys("β"), key.WithHelp("β", "back")),
279 key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
280 }
281 case phaseFileNotice:
282 return []key.Binding{
283 key.NewBinding(key.WithKeys("enter"), key.WithHelp("any key", "continue")),
284 }
285 default:
286 return []key.Binding{
287 key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
288 }
289 }
290}
291
292// Selection returns a summary for breadcrumb display.
293func (fp *FilePicker) Selection() string { return fp.selection }
294
295// Includes returns the restic --include paths, or nil for a full
296// restore. Each path has a leading "/" for restic's absolute syntax.
297func (fp *FilePicker) Includes() []string { return fp.includes }