diff --git a/cmd/root.go b/cmd/root.go index df689371dc94940f4febcb27cbc939671c3788fd..f0c61c6fba4a0374697e9858d5c65f3138c3cae4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 diff --git a/internal/ui/screens/resolve.go b/internal/ui/screens/resolve.go new file mode 100644 index 0000000000000000000000000000000000000000..ff482abc3e6aa617f42bcd20ea13399a71b98261 --- /dev/null +++ b/internal/ui/screens/resolve.go @@ -0,0 +1,74 @@ +package screens + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/ui" +) + +// ResolveFunc is called by the resolve screen to build the next set +// of screens based on the selections made so far. It returns the +// screens to append to the session, or nil if no additional screens +// are needed (the session will complete immediately). +type ResolveFunc func() []ui.Screen + +// resolvedMsg carries the result of an async resolve. +type resolvedMsg struct { + screens []ui.Screen +} + +// Resolve is a transparent Screen adapter that sits between the +// menu/preset screens and the command-specific screens. It calls an +// injected function to build the next screens, extends the session +// via [ui.ExtendMsg], and immediately advances via [ui.DoneCmd]. +// +// The user never sees this screen — it completes within a single +// Update cycle. +type Resolve struct { + fn ResolveFunc +} + +// NewResolve creates a resolve screen with the given builder function. +func NewResolve(fn ResolveFunc) *Resolve { + return &Resolve{fn: fn} +} + +// Init triggers the async resolve. +func (r *Resolve) Init() tea.Cmd { + return func() tea.Msg { + screens := r.fn() + return resolvedMsg{screens: screens} + } +} + +// Update handles the resolve result. +func (r *Resolve) Update(msg tea.Msg) (ui.Screen, tea.Cmd) { + switch msg := msg.(type) { + case resolvedMsg: + if len(msg.screens) == 0 { + return r, ui.DoneCmd + } + return r, tea.Sequence( + func() tea.Msg { return ui.ExtendMsg{Screens: msg.screens} }, + ui.DoneCmd, + ) + case tea.KeyPressMsg: + if msg.Code == tea.KeyEscape { + return r, ui.BackCmd + } + } + return r, nil +} + +// View is intentionally empty — this screen should not be visible. +func (r *Resolve) View() string { return "" } + +// Title returns empty — this screen has no visible title. +func (r *Resolve) Title() string { return "" } + +// KeyBindings returns no bindings. +func (r *Resolve) KeyBindings() []key.Binding { return nil } + +// Selection returns empty — this screen has no user-visible selection. +func (r *Resolve) Selection() string { return "" }