filepicker.go

  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 }