@@ -0,0 +1,281 @@
+package screens
+
+import (
+ "fmt"
+ "strings"
+ "sync/atomic"
+
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/spinner"
+ tea "charm.land/bubbletea/v2"
+
+ "git.secluded.site/keld/internal/picker"
+ "git.secluded.site/keld/internal/restic"
+ "git.secluded.site/keld/internal/theme"
+ "git.secluded.site/keld/internal/ui"
+)
+
+// filePickerPhase tracks the file picker's internal flow.
+type filePickerPhase int
+
+const (
+ phaseFileLoading filePickerPhase = iota // spinner while fetching
+ phaseFilePicking // interactive file picker
+ phaseFileNotice // notice before auto-advancing
+)
+
+// FileLoader fetches the file listing for a snapshot. Injected at
+// construction so tests can provide a fake.
+type FileLoader func(snapshotID string) ([]restic.LsNode, error)
+
+// filesLoadedMsg carries the result of an async file listing load.
+type filesLoadedMsg struct {
+ gen int64
+ nodes []restic.LsNode
+ err error
+}
+
+// FilePicker is a Screen adapter that wraps the vendored picker
+// component for selecting files to restore from a snapshot. It
+// handles async loading, error notices, and conversion of selected
+// paths to restic --include syntax.
+type FilePicker struct {
+ loader FileLoader
+ snapshotFn func() string
+ styles *theme.Styles
+ spinner spinner.Model
+
+ phase filePickerPhase
+ gen int64
+ snapshotID string
+
+ // Picking phase.
+ picker *picker.Model
+
+ // Notice phase.
+ notice string
+
+ // Result.
+ includes []string
+ selection string
+}
+
+// NewFilePicker creates a file picker screen. If loader is nil, the
+// screen auto-advances with no includes (full restore). snapshotFn
+// returns the snapshot ID from the previous screen.
+func NewFilePicker(loader FileLoader, snapshotFn func() string, styles *theme.Styles) *FilePicker {
+ sp := spinner.New(spinner.WithSpinner(spinner.Dot))
+ sp.Style = sp.Style.Foreground(styles.Accent)
+
+ return &FilePicker{
+ loader: loader,
+ snapshotFn: snapshotFn,
+ styles: styles,
+ spinner: sp,
+ }
+}
+
+// Init starts the async file listing load.
+func (fp *FilePicker) Init() tea.Cmd {
+ fp.selection = ""
+ fp.includes = nil
+
+ fp.snapshotID = fp.snapshotFn()
+
+ if fp.loader == nil {
+ return ui.DoneCmd
+ }
+
+ fp.phase = phaseFileLoading
+ gen := atomic.AddInt64(&fp.gen, 1)
+
+ return tea.Batch(fp.spinner.Tick, func() tea.Msg {
+ nodes, err := fp.loader(fp.snapshotID)
+ return filesLoadedMsg{gen: gen, nodes: nodes, err: err}
+ })
+}
+
+// Update handles messages across all phases.
+func (fp *FilePicker) Update(msg tea.Msg) (ui.Screen, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ if msg.Code == tea.KeyEscape {
+ return fp.handleEsc()
+ }
+
+ case filesLoadedMsg:
+ return fp.handleLoaded(msg)
+ }
+
+ switch fp.phase {
+ case phaseFileLoading:
+ return fp.updateLoading(msg)
+ case phaseFilePicking:
+ return fp.updatePicking(msg)
+ case phaseFileNotice:
+ return fp.updateNotice(msg)
+ }
+
+ return fp, nil
+}
+
+// handleEsc processes Esc across all phases.
+func (fp *FilePicker) handleEsc() (ui.Screen, tea.Cmd) {
+ switch fp.phase {
+ case phaseFileLoading:
+ return fp, ui.BackCmd
+ case phaseFilePicking:
+ if fp.picker != nil && fp.picker.CurrentDirectory == "." {
+ return fp, ui.BackCmd
+ }
+ // Let the picker navigate to parent directory.
+ return fp.updatePicking(tea.KeyPressMsg{Code: tea.KeyEscape})
+ case phaseFileNotice:
+ return fp, ui.BackCmd
+ }
+ return fp, ui.BackCmd
+}
+
+// handleLoaded processes the async file listing result.
+func (fp *FilePicker) handleLoaded(msg filesLoadedMsg) (ui.Screen, tea.Cmd) {
+ if msg.gen != atomic.LoadInt64(&fp.gen) {
+ return fp, nil
+ }
+
+ if msg.err != nil {
+ fp.phase = phaseFileNotice
+ fp.notice = fmt.Sprintf("Could not list snapshot contents: %v\nProceeding with full restore.", msg.err)
+ return fp, nil
+ }
+
+ if len(msg.nodes) == 0 {
+ fp.phase = phaseFileNotice
+ fp.notice = "Snapshot contains no files.\nProceeding with full restore."
+ return fp, nil
+ }
+
+ fp.buildPicker(msg.nodes)
+ fp.phase = phaseFilePicking
+ return fp, fp.picker.Init()
+}
+
+// updateLoading forwards messages to the spinner during loading.
+func (fp *FilePicker) updateLoading(msg tea.Msg) (ui.Screen, tea.Cmd) {
+ var cmd tea.Cmd
+ fp.spinner, cmd = fp.spinner.Update(msg)
+ return fp, cmd
+}
+
+// updatePicking forwards messages to the picker.
+func (fp *FilePicker) updatePicking(msg tea.Msg) (ui.Screen, tea.Cmd) {
+ if fp.picker == nil {
+ return fp, nil
+ }
+
+ // Handle window resize for the picker.
+ if wsm, ok := msg.(tea.WindowSizeMsg); ok {
+ fp.picker.AutoHeight = false
+ fp.picker.SetHeight(max(1, wsm.Height))
+ }
+
+ updated, cmd := fp.picker.Update(msg)
+ fp.picker = &updated
+
+ if fp.picker.Confirmed {
+ fp.resolveSelection()
+ return fp, ui.DoneCmd
+ }
+
+ return fp, cmd
+}
+
+// updateNotice waits for any key press to acknowledge, then advances.
+func (fp *FilePicker) updateNotice(msg tea.Msg) (ui.Screen, tea.Cmd) {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
+ fp.selection = "all files"
+ return fp, ui.DoneCmd
+ }
+ return fp, nil
+}
+
+// buildPicker constructs the picker model from the loaded nodes.
+func (fp *FilePicker) buildPicker(nodes []restic.LsNode) {
+ sfs := restic.NewSnapshotFS(nodes)
+ p := picker.New(sfs)
+ p.Cursor = strings.TrimSuffix(theme.Cursor, " ")
+ p.Styles = fp.styles.Picker
+ fp.picker = &p
+}
+
+// resolveSelection reads the picker's selection state and converts
+// it to restic --include paths.
+func (fp *FilePicker) resolveSelection() {
+ sel := fp.picker.Selection
+ if sel.AllSelected() {
+ fp.selection = "all files"
+ fp.includes = nil
+ return
+ }
+
+ paths := sel.SelectedPaths()
+ if paths == nil {
+ // Nothing toggled β treat as full restore.
+ fp.selection = "all files"
+ fp.includes = nil
+ return
+ }
+
+ fp.includes = make([]string, len(paths))
+ for i, p := range paths {
+ fp.includes[i] = "/" + p
+ }
+ fp.selection = fmt.Sprintf("%d path(s)", len(fp.includes))
+}
+
+// View renders the current phase.
+func (fp *FilePicker) View() string {
+ switch fp.phase {
+ case phaseFileLoading:
+ return fp.spinner.View() + " Loading snapshot contentsβ¦"
+ case phaseFilePicking:
+ if fp.picker == nil {
+ return ""
+ }
+ return fp.picker.View()
+ case phaseFileNotice:
+ return fp.notice + "\n\nPress any key to continue."
+ }
+ return ""
+}
+
+// Title returns the screen's display title.
+func (fp *FilePicker) Title() string { return "Select files to restore" }
+
+// KeyBindings returns bindings for the help bar.
+func (fp *FilePicker) KeyBindings() []key.Binding {
+ switch fp.phase {
+ case phaseFilePicking:
+ return []key.Binding{
+ key.NewBinding(key.WithKeys("β/β"), key.WithHelp("β/β", "navigate")),
+ key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
+ key.NewBinding(key.WithKeys("β"), key.WithHelp("β", "open")),
+ key.NewBinding(key.WithKeys("β"), key.WithHelp("β", "back")),
+ key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
+ }
+ case phaseFileNotice:
+ return []key.Binding{
+ key.NewBinding(key.WithKeys("enter"), key.WithHelp("any key", "continue")),
+ }
+ default:
+ return []key.Binding{
+ key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
+ }
+ }
+}
+
+// Selection returns a summary for breadcrumb display.
+func (fp *FilePicker) Selection() string { return fp.selection }
+
+// Includes returns the restic --include paths, or nil for a full
+// restore. Each path has a leading "/" for restic's absolute syntax.
+func (fp *FilePicker) Includes() []string { return fp.includes }
@@ -0,0 +1,380 @@
+package screens
+
+import (
+ "errors"
+ "sync/atomic"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+
+ "git.secluded.site/keld/internal/restic"
+ "git.secluded.site/keld/internal/ui"
+)
+
+// testNodes returns LsNode entries matching testFS.
+func testNodes() []restic.LsNode {
+ return []restic.LsNode{
+ {Name: "dir1", Type: "dir", Path: "/dir1", Mode: 0o755},
+ {Name: "file1.txt", Type: "file", Path: "/dir1/file1.txt", Size: 5, Mode: 0o644},
+ {Name: "file2.txt", Type: "file", Path: "/dir1/file2.txt", Size: 5, Mode: 0o644},
+ {Name: "dir2", Type: "dir", Path: "/dir2", Mode: 0o755},
+ {Name: "file3.txt", Type: "file", Path: "/dir2/file3.txt", Size: 4, Mode: 0o644},
+ {Name: "root.txt", Type: "file", Path: "/root.txt", Size: 4, Mode: 0o644},
+ }
+}
+
+// drainFilePicker feeds commands back into the file picker screen
+// until a DoneMsg or BackMsg is produced, or the chain is exhausted.
+func drainFilePicker(fp *FilePicker, initialCmd tea.Cmd) (*FilePicker, tea.Cmd) {
+ cmd := initialCmd
+ for cmd != nil {
+ msg := cmd()
+ if msg == nil {
+ return fp, nil
+ }
+ switch msg.(type) {
+ case ui.DoneMsg:
+ return fp, cmd
+ case ui.BackMsg:
+ return fp, cmd
+ }
+ var screen ui.Screen
+ screen, cmd = fp.Update(msg)
+ fp = screen.(*FilePicker)
+ }
+ return fp, nil
+}
+
+// simulateFileLoad sends a filesLoadedMsg directly to the screen.
+func simulateFileLoad(fp *FilePicker, nodes []restic.LsNode, err error) *FilePicker {
+ gen := atomic.LoadInt64(&fp.gen)
+ msg := filesLoadedMsg{gen: gen, nodes: nodes, err: err}
+ screen, cmd := fp.Update(msg)
+ fp = screen.(*FilePicker)
+ fp, _ = drainFilePicker(fp, cmd)
+ return fp
+}
+
+func initFilePicker(fp *FilePicker) *FilePicker {
+ fp.Init()
+ screen, _ := fp.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
+ return screen.(*FilePicker)
+}
+
+func TestFilePickerTitle(t *testing.T) {
+ t.Parallel()
+
+ fp := NewFilePicker(nil, func() string { return "abc123" }, testStyles())
+ if got := fp.Title(); got != "Select files to restore" {
+ t.Errorf("Title() = %q, want %q", got, "Select files to restore")
+ }
+}
+
+func TestFilePickerSelectionEmpty(t *testing.T) {
+ t.Parallel()
+
+ fp := NewFilePicker(nil, func() string { return "abc123" }, testStyles())
+ if got := fp.Selection(); got != "" {
+ t.Errorf("Selection() before interaction = %q, want empty", got)
+ }
+}
+
+func TestFilePickerKeyBindings(t *testing.T) {
+ t.Parallel()
+
+ fp := NewFilePicker(nil, func() string { return "abc123" }, testStyles())
+ if len(fp.KeyBindings()) == 0 {
+ t.Fatal("KeyBindings() returned no bindings")
+ }
+}
+
+func TestFilePickerLoadsAndPresents(t *testing.T) {
+ t.Parallel()
+
+ nodes := testNodes()
+ loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+ fp = simulateFileLoad(fp, nodes, nil)
+
+ // Should be in picking phase. The picker should show files.
+ if fp.phase != phaseFilePicking {
+ t.Fatalf("phase = %d, want %d (phaseFilePicking)", fp.phase, phaseFilePicking)
+ }
+
+ view := fp.View()
+ if view == "" {
+ t.Error("View() should not be empty in picking phase")
+ }
+}
+
+func TestFilePickerConfirmAllFiles(t *testing.T) {
+ t.Parallel()
+
+ nodes := testNodes()
+ loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+ fp = simulateFileLoad(fp, nodes, nil)
+
+ // Press enter to confirm with all files selected (default).
+ screen, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ fp = screen.(*FilePicker)
+
+ if cmd == nil {
+ t.Fatal("expected DoneCmd after confirming all files")
+ }
+ if _, ok := cmd().(ui.DoneMsg); !ok {
+ t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
+ }
+
+ // All files selected β no includes.
+ if got := fp.Includes(); got != nil {
+ t.Errorf("Includes() = %v, want nil for full restore", got)
+ }
+
+ if got := fp.Selection(); got != "all files" {
+ t.Errorf("Selection() = %q, want %q", got, "all files")
+ }
+}
+
+func TestFilePickerErrorAutoAdvances(t *testing.T) {
+ t.Parallel()
+
+ loader := func(_ string) ([]restic.LsNode, error) {
+ return nil, errors.New("connection failed")
+ }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+
+ // Simulate error result.
+ gen := atomic.LoadInt64(&fp.gen)
+ msg := filesLoadedMsg{gen: gen, nodes: nil, err: errors.New("connection failed")}
+ screen, _ := fp.Update(msg)
+ fp = screen.(*FilePicker)
+
+ // Should show a notice phase, not immediately DoneCmd.
+ if fp.phase != phaseFileNotice {
+ t.Fatalf("phase = %d, want %d (phaseFileNotice)", fp.phase, phaseFileNotice)
+ }
+ if fp.notice == "" {
+ t.Error("notice should be set on error")
+ }
+
+ // Press any key to acknowledge and advance.
+ screen, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ fp = screen.(*FilePicker)
+
+ if cmd == nil {
+ t.Fatal("expected DoneCmd after acknowledging notice")
+ }
+ if _, ok := cmd().(ui.DoneMsg); !ok {
+ t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
+ }
+
+ // No includes β full restore.
+ if got := fp.Includes(); got != nil {
+ t.Errorf("Includes() = %v, want nil after error", got)
+ }
+}
+
+func TestFilePickerEmptyNodesAutoAdvances(t *testing.T) {
+ t.Parallel()
+
+ loader := func(_ string) ([]restic.LsNode, error) {
+ return []restic.LsNode{}, nil
+ }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+
+ gen := atomic.LoadInt64(&fp.gen)
+ msg := filesLoadedMsg{gen: gen, nodes: []restic.LsNode{}, err: nil}
+ screen, _ := fp.Update(msg)
+ fp = screen.(*FilePicker)
+
+ // Should show notice phase.
+ if fp.phase != phaseFileNotice {
+ t.Fatalf("phase = %d, want %d (phaseFileNotice)", fp.phase, phaseFileNotice)
+ }
+
+ // Press key to advance.
+ screen, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ _ = screen.(*FilePicker)
+
+ if cmd == nil {
+ t.Fatal("expected DoneCmd after acknowledging empty notice")
+ }
+ if _, ok := cmd().(ui.DoneMsg); !ok {
+ t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
+ }
+}
+
+func TestFilePickerNilLoaderAutoAdvances(t *testing.T) {
+ t.Parallel()
+
+ fp := NewFilePicker(nil, func() string { return "abc123" }, testStyles())
+ cmd := fp.Init()
+
+ // With nil loader, should immediately return DoneCmd.
+ if cmd == nil {
+ t.Fatal("expected DoneCmd from Init with nil loader")
+ }
+ if _, ok := cmd().(ui.DoneMsg); !ok {
+ t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
+ }
+}
+
+func TestFilePickerEscAtRootReturnsBack(t *testing.T) {
+ t.Parallel()
+
+ nodes := testNodes()
+ loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+ fp = simulateFileLoad(fp, nodes, nil)
+
+ // At root directory, Esc should return BackCmd.
+ _, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
+
+ if cmd == nil {
+ t.Fatal("expected BackCmd on Esc at root")
+ }
+ if _, ok := cmd().(ui.BackMsg); !ok {
+ t.Errorf("cmd produced %T, want ui.BackMsg", cmd())
+ }
+}
+
+func TestFilePickerEscDuringLoadingReturnsBack(t *testing.T) {
+ t.Parallel()
+
+ loader := func(_ string) ([]restic.LsNode, error) { return nil, nil }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+
+ _, cmd := fp.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 TestFilePickerStaleLoadIgnored(t *testing.T) {
+ t.Parallel()
+
+ nodes := testNodes()
+ loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+
+ staleGen := atomic.LoadInt64(&fp.gen)
+ fp.Init() // bumps generation
+
+ // Deliver stale result.
+ msg := filesLoadedMsg{gen: staleGen, nodes: nodes, err: nil}
+ screen, _ := fp.Update(msg)
+ fp = screen.(*FilePicker)
+
+ // Should still be loading.
+ if fp.phase != phaseFileLoading {
+ t.Fatalf("phase = %d, want %d (phaseFileLoading) after stale result", fp.phase, phaseFileLoading)
+ }
+}
+
+func TestFilePickerIncludesWithPartialSelection(t *testing.T) {
+ t.Parallel()
+
+ nodes := testNodes()
+ loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+ fp = simulateFileLoad(fp, nodes, nil)
+
+ // The picker should have files loaded. Toggle the first item
+ // (should be dir1, sorted dirs first) via space.
+ screen, cmd := fp.Update(tea.KeyPressMsg{Code: ' ', Text: " "})
+ fp = screen.(*FilePicker)
+ fp, _ = drainFilePicker(fp, cmd)
+
+ // Confirm with enter.
+ screen, cmd = fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ fp = screen.(*FilePicker)
+
+ if cmd == nil {
+ t.Fatal("expected DoneCmd after partial selection confirm")
+ }
+
+ // Should have includes (partial selection).
+ includes := fp.Includes()
+ if includes == nil {
+ t.Error("Includes() should not be nil for partial selection")
+ }
+
+ // All includes should have leading "/".
+ for _, inc := range includes {
+ if inc[0] != '/' {
+ t.Errorf("include %q should start with /", inc)
+ }
+ }
+}
+
+func TestFilePickerSelectionShowsCount(t *testing.T) {
+ t.Parallel()
+
+ nodes := testNodes()
+ loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
+
+ fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
+ fp = initFilePicker(fp)
+ fp = simulateFileLoad(fp, nodes, nil)
+
+ // Toggle first item and confirm.
+ screen, cmd := fp.Update(tea.KeyPressMsg{Code: ' ', Text: " "})
+ fp = screen.(*FilePicker)
+ fp, _ = drainFilePicker(fp, cmd)
+
+ screen, _ = fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ fp = screen.(*FilePicker)
+
+ sel := fp.Selection()
+ if sel == "" {
+ t.Error("Selection() should not be empty after partial selection")
+ }
+ if sel == "all files" {
+ t.Error("Selection() should not be 'all files' for partial selection")
+ }
+}
+
+func TestFilePickerSnapshotIDFromClosure(t *testing.T) {
+ t.Parallel()
+
+ var receivedID string
+ loader := func(snapshotID string) ([]restic.LsNode, error) {
+ receivedID = snapshotID
+ return testNodes(), nil
+ }
+
+ fp := NewFilePicker(loader, func() string { return "my-snapshot-id" }, testStyles())
+ fp = initFilePicker(fp)
+
+ // The loader should have received the snapshot ID from the closure.
+ // Since we're simulating, we need to actually call the loader.
+ // In the real flow, Init() creates a Cmd that calls the loader.
+ // Let's verify the snapshotID func is called properly by
+ // checking the stored value.
+ if fp.snapshotID != "my-snapshot-id" {
+ t.Errorf("snapshotID = %q, want %q", fp.snapshotID, "my-snapshot-id")
+ }
+
+ _ = receivedID // used by the loader
+}