@@ -49,7 +49,7 @@ var rootCmd = &cobra.Command{
TraverseChildren: true,
RunE: func(cmd *cobra.Command, _ []string) error {
- commandName, preset, err := runInteractive()
+ commandName, preset, overrides, err := runInteractive()
if err != nil {
return err
}
@@ -65,7 +65,28 @@ var rootCmd = &cobra.Command{
// so runCommand does not re-prompt even when preset is "".
presetResolved = true
- return runCommand(commandName, cmd, nil)
+ // If the session collected command-specific overrides
+ // (e.g. restore flow), pass them as raw args so runCommand
+ // merges them and skips its own prompts.
+ var rawArgs []string
+ for k, vals := range overrides {
+ if k == overrideArgumentsKey {
+ // Positional args go after "--".
+ continue
+ }
+ for _, v := range vals {
+ rawArgs = append(rawArgs, "--"+k, v)
+ }
+ if vals == nil {
+ rawArgs = append(rawArgs, "--"+k)
+ }
+ }
+ if args := overrides[overrideArgumentsKey]; len(args) > 0 {
+ rawArgs = append(rawArgs, "--")
+ rawArgs = append(rawArgs, args...)
+ }
+
+ return runCommand(commandName, cmd, rawArgs)
},
}
@@ -220,9 +241,10 @@ func isFlagToken(arg string) bool {
}
// runInteractive launches the unified TUI session with a command
-// menu and (if needed) a preset selector. Returns the chosen command
-// name and preset, or ("", "") if the user cancelled.
-func runInteractive() (command, preset string, err error) {
+// menu, preset selector (if needed), and command-specific screens
+// (e.g. the restore flow). Returns the chosen command, preset, any
+// collected overrides, or ("", "", nil, nil) if the user cancelled.
+func runInteractive() (command, preset string, overrides map[string][]string, err error) {
styles := theme.New(true)
var screenList []ui.Screen
@@ -246,28 +268,198 @@ func runInteractive() (command, preset string, err error) {
preset = presets[0]
}
+ // Screen references for extracting results after the session.
+ // These are populated by the resolve screen's builder function.
+ var snapshotScreen *screens.Snapshot
+ var filePickerScreen *screens.FilePicker
+ var targetScreen *screens.Target
+ var overwriteScreen *screens.Overwrite
+
+ // The resolve screen sits between menu/preset and command-specific
+ // screens. It resolves the config and dynamically builds the
+ // remaining screens via ExtendMsg.
+ resolveScreen := screens.NewResolve(func() []ui.Screen {
+ cmd := menuScreen.Selection()
+ p := preset
+ if presetScreen != nil {
+ p = presetScreen.Value()
+ }
+
+ return buildCommandScreens(cmd, p, &styles,
+ &snapshotScreen, &filePickerScreen, &targetScreen, &overwriteScreen)
+ })
+ screenList = append(screenList, resolveScreen)
+
session := ui.New(screenList, &styles)
- p := tea.NewProgram(session)
- result, err := p.Run()
+ prog := tea.NewProgram(session)
+ result, err := prog.Run()
if err != nil {
- return "", "", fmt.Errorf("interactive session: %w", err)
+ return "", "", nil, fmt.Errorf("interactive session: %w", err)
}
s := result.(ui.Session)
if !s.Completed() {
- return "", "", nil
+ return "", "", nil, nil
}
command = menuScreen.Selection()
if !slices.Contains(knownCommands, command) {
- return "", "", fmt.Errorf("unknown menu command %q", command)
+ return "", "", nil, fmt.Errorf("unknown menu command %q", command)
}
if presetScreen != nil {
preset = presetScreen.Value()
}
- return command, preset, nil
+ // Extract overrides from the completed command-specific screens.
+ overrides = extractCommandOverrides(command, snapshotScreen, filePickerScreen, targetScreen, overwriteScreen)
+
+ return command, preset, overrides, nil
+}
+
+// buildCommandScreens resolves the config for the given command and
+// preset, then builds the command-specific screens that need user
+// input. Screen pointers are stored in the provided output parameters
+// so the caller can extract results after the session completes.
+func buildCommandScreens(
+ command, preset string,
+ styles *theme.Styles,
+ snapshotOut **screens.Snapshot,
+ filePickerOut **screens.FilePicker,
+ targetOut **screens.Target,
+ overwriteOut **screens.Overwrite,
+) []ui.Screen {
+ switch command {
+ case "restore":
+ return buildRestoreScreens(preset, styles, snapshotOut, filePickerOut, targetOut, overwriteOut)
+ default:
+ return nil
+ }
+}
+
+// buildRestoreScreens creates the restore flow screens, skipping any
+// that are already provided by the resolved config.
+func buildRestoreScreens(
+ preset string,
+ styles *theme.Styles,
+ snapshotOut **screens.Snapshot,
+ filePickerOut **screens.FilePicker,
+ targetOut **screens.Target,
+ overwriteOut **screens.Overwrite,
+) []ui.Screen {
+ cfg, err := config.Resolve(preset, "restore", nil)
+ if err != nil {
+ // Config resolution failed. Return no screens so the session
+ // completes immediately; runCommand will call config.Resolve
+ // again and surface the error to the user.
+ return nil
+ }
+
+ var list []ui.Screen
+
+ // Step 1: Snapshot ID (skip if already provided as an argument).
+ if len(cfg.Arguments) == 0 {
+ loader := screens.SnapshotLoader(func() ([]restic.Snapshot, error) {
+ return restic.ListSnapshots(cfg)
+ })
+ ss := screens.NewSnapshot(loader, styles)
+ *snapshotOut = ss
+ list = append(list, ss)
+ }
+
+ // Step 2: File selection (skip if include flags set or snapshot
+ // uses snapshotID:subfolder syntax). The file picker needs the
+ // snapshot ID — it reads it from a closure.
+ if !hasIncludeFlags(cfg) {
+ // Determine the snapshot ID source: from the snapshot screen
+ // if it exists, otherwise from the resolved config.
+ snapshotIDFn := func() string {
+ if *snapshotOut != nil {
+ return (*snapshotOut).Value()
+ }
+ if len(cfg.Arguments) > 0 {
+ return cfg.Arguments[0]
+ }
+ return ""
+ }
+
+ // Build a loader that checks for the snapshotID:subfolder
+ // skip condition at call time (the ID may not be known until
+ // the snapshot screen completes).
+ fileLoader := func(snapshotID string) ([]restic.LsNode, error) {
+ if strings.Contains(snapshotID, ":") || snapshotID == "" {
+ // Signal that listing should be skipped. Returning
+ // nil nodes causes the picker to auto-advance.
+ return nil, nil
+ }
+ return restic.RunLs(cfg, snapshotID)
+ }
+
+ fps := screens.NewFilePicker(fileLoader, snapshotIDFn, styles)
+ *filePickerOut = fps
+ list = append(list, fps)
+ }
+
+ // Step 3: Target directory (skip if already set).
+ if !cfg.HasFlag("target") {
+ ts := screens.NewTarget(styles)
+ *targetOut = ts
+ list = append(list, ts)
+ }
+
+ // Step 4: Overwrite behaviour (skip if already set).
+ if !cfg.HasFlag("overwrite") {
+ ows := screens.NewOverwrite(styles)
+ *overwriteOut = ows
+ list = append(list, ows)
+ }
+
+ return list
+}
+
+// extractCommandOverrides reads the completed screen values and
+// returns a map suitable for passing to runCommand as overrides.
+func extractCommandOverrides(
+ command string,
+ snapshotScreen *screens.Snapshot,
+ filePickerScreen *screens.FilePicker,
+ targetScreen *screens.Target,
+ overwriteScreen *screens.Overwrite,
+) map[string][]string {
+ if command != "restore" {
+ return nil
+ }
+
+ overrides := make(map[string][]string)
+
+ if snapshotScreen != nil {
+ if v := snapshotScreen.Value(); v != "" {
+ overrides[overrideArgumentsKey] = []string{v}
+ }
+ }
+
+ if filePickerScreen != nil {
+ if includes := filePickerScreen.Includes(); len(includes) > 0 {
+ overrides["include"] = includes
+ }
+ }
+
+ if targetScreen != nil {
+ if v := targetScreen.Value(); v != "" {
+ overrides["target"] = []string{v}
+ }
+ }
+
+ if overwriteScreen != nil {
+ if v := overwriteScreen.Value(); v != "" {
+ overrides["overwrite"] = []string{v}
+ }
+ }
+
+ if len(overrides) == 0 {
+ return nil
+ }
+ return overrides
}
// validatePreset checks that the given preset name matches one of the usable