Wire file picker into interactive restore flow

Amolith created

Change summary

cmd/root.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 134 insertions(+), 2 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -16,6 +16,7 @@ import (
 	"git.secluded.site/keld/internal/config"
 	"git.secluded.site/keld/internal/form"
 	"git.secluded.site/keld/internal/menu"
+	"git.secluded.site/keld/internal/picker"
 	"git.secluded.site/keld/internal/restic"
 )
 
@@ -290,7 +291,21 @@ func promptRestore(cfg *config.ResolvedConfig) (map[string][]string, error) {
 		overrides[overrideArgumentsKey] = []string{snapshotID}
 	}
 
-	// Step 2: Target directory.
+	// Step 2: File selection (unless include flags are already set or
+	// the snapshot ID uses snapshotID:subfolder syntax).
+	snapshotID := ""
+	if args := overrides[overrideArgumentsKey]; len(args) > 0 {
+		snapshotID = args[0]
+	} else if len(cfg.Arguments) > 0 {
+		snapshotID = cfg.Arguments[0]
+	}
+	includes, err := promptFileSelection(cfg, snapshotID)
+	if err != nil {
+		return nil, err
+	}
+	overrides["include"] = append(overrides["include"], includes...)
+
+	// Step 3: Target directory.
 	if !cfg.HasFlag("target") {
 		target, err := form.TargetDirectory()
 		if err != nil {
@@ -299,7 +314,7 @@ func promptRestore(cfg *config.ResolvedConfig) (map[string][]string, error) {
 		overrides["target"] = []string{target}
 	}
 
-	// Step 3: Overwrite behavior.
+	// Step 4: Overwrite behavior.
 	if !cfg.HasFlag("overwrite") {
 		overwrite, err := form.SelectOverwrite()
 		if err != nil {
@@ -371,6 +386,123 @@ func isNoRepoError(err error) bool {
 	return errors.Is(err, restic.ErrNoRepo)
 }
 
+// includeFlags lists the restic flags that provide explicit file
+// selection for restore. When any of these are already present in the
+// resolved config, the interactive file picker is skipped — the user
+// has already told restic what to include.
+var includeFlags = []string{"include", "i", "include-file", "iinclude", "iinclude-file"}
+
+// hasIncludeFlags reports whether the resolved config already contains
+// any include-style flags.
+func hasIncludeFlags(cfg *config.ResolvedConfig) bool {
+	for _, name := range includeFlags {
+		if cfg.HasFlag(name) {
+			return true
+		}
+	}
+	return false
+}
+
+// promptFileSelection runs the interactive file picker for a restore
+// snapshot and returns --include values (with leading "/" for restic's
+// absolute-path syntax). Returns nil when the picker is skipped or
+// when the user confirms a full restore.
+//
+// The picker is skipped when:
+//   - Include-style flags already exist in the resolved config
+//   - The snapshot ID uses snapshotID:subfolder syntax (contains ":")
+//   - The snapshot contents cannot be listed (terse note, continues)
+func promptFileSelection(cfg *config.ResolvedConfig, snapshotID string) ([]string, error) {
+	if hasIncludeFlags(cfg) {
+		return nil, nil
+	}
+	if strings.Contains(snapshotID, ":") {
+		return nil, nil
+	}
+	if snapshotID == "" {
+		return nil, nil
+	}
+
+	// Fetch the snapshot's file listing behind a spinner.
+	var nodes []restic.LsNode
+	var lsErr error
+
+	err := spinner.New().
+		Title("Loading snapshot contents…").
+		Action(func() {
+			nodes, lsErr = restic.RunLs(cfg, snapshotID)
+		}).
+		Run()
+	if err != nil {
+		// Spinner itself failed (unlikely); skip the picker.
+		fmt.Fprintf(os.Stderr, "Could not load file picker: %v\n", err)
+		return nil, nil
+	}
+	if lsErr != nil {
+		fmt.Fprintf(os.Stderr, "Could not list snapshot contents: %v\n", lsErr)
+		return nil, nil
+	}
+	if len(nodes) == 0 {
+		return nil, nil
+	}
+
+	// Build an in-memory filesystem and selection tracker.
+	sfs := restic.NewSnapshotFS(nodes)
+	sel := picker.NewSelection(sfs)
+
+	fp := picker.New(sfs)
+	fp.Selection = sel
+
+	p := tea.NewProgram(pickerModel{picker: fp})
+	result, err := p.Run()
+	if err != nil {
+		return nil, fmt.Errorf("file picker: %w", err)
+	}
+	m, ok := result.(pickerModel)
+	if !ok {
+		return nil, fmt.Errorf("unexpected file picker model type %T", result)
+	}
+
+	if !m.picker.Confirmed {
+		// User cancelled (Ctrl+C / quit without confirming).
+		return nil, form.ErrAborted
+	}
+
+	// Full restore: everything selected or nothing toggled.
+	if sel.AllSelected() {
+		return nil, nil
+	}
+	paths := sel.SelectedPaths()
+	if paths == nil {
+		// Nothing was selected — treat as full restore.
+		return nil, nil
+	}
+
+	// Convert FS-relative paths to restic's absolute-path --include syntax.
+	includes := make([]string, len(paths))
+	for i, p := range paths {
+		includes[i] = "/" + p
+	}
+	return includes, nil
+}
+
+// pickerModel wraps picker.Model so it satisfies the tea.Model interface
+// for standalone use with tea.NewProgram. The picker follows the bubbles
+// component convention (returning a concrete type from Update and a
+// string from View), so this thin adapter bridges the gap.
+type pickerModel struct {
+	picker picker.Model
+}
+
+func (m pickerModel) Init() tea.Cmd { return m.picker.Init() }
+
+func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	updated, cmd := m.picker.Update(msg)
+	return pickerModel{picker: updated}, cmd
+}
+
+func (m pickerModel) View() tea.View { return tea.NewView(m.picker.View()) }
+
 // promptBackup collects backup paths when none are configured in the preset.
 func promptBackup(cfg *config.ResolvedConfig) (map[string][]string, error) {
 	if len(cfg.Arguments) > 0 {