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