Add file picker screen adapter

Amolith created

Add screens.FilePicker, a Screen adapter that wraps the vendored picker
component for selecting files to restore from a snapshot.

Internal state machine with three phases:
- Loading: spinner while an injected FileLoader fetches the
snapshot's file listing
- Picking: interactive file picker with theme styling, tri-state
checkboxes, and directory navigation
- Notice: displayed when loading fails or returns empty, with a
"press any key" prompt before auto-advancing with a full restore

The snapshot ID comes from an injected closure so it reads the value
from the preceding snapshot screen. Esc at the picker's root directory
returns BackCmd; deeper Esc navigates to the parent.

Includes() returns restic --include paths with leading "/" for absolute
syntax, or nil for a full restore.

Change summary

internal/ui/screens/filepicker.go      | 281 ++++++++++++++++++++
internal/ui/screens/filepicker_test.go | 380 ++++++++++++++++++++++++++++
2 files changed, 661 insertions(+)

Detailed changes

internal/ui/screens/filepicker.go πŸ”—

@@ -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 }

internal/ui/screens/filepicker_test.go πŸ”—

@@ -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
+}