diff --git a/internal/ui/screens/filepicker.go b/internal/ui/screens/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..fa2d0dc150b0f4a804923682e422c3288181ba56 --- /dev/null +++ b/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 } diff --git a/internal/ui/screens/filepicker_test.go b/internal/ui/screens/filepicker_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9d50b5ede9c89dff363cbc9da13b17c6dd245a59 --- /dev/null +++ b/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 +}