diff --git a/cmd/root.go b/cmd/root.go index d22692bd953cb5a63e2fb4054ab6a43c69016e89..67430f2d7c63fee2bbfbd1eb6154f64781810331 100644 --- a/cmd/root.go +++ b/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 {