diff --git a/cmd/root.go b/cmd/root.go index 31d0f9dc22ed4d77aecf6a6dc399a24529f41dc3..080e062728e2c54e4c53bbe792c30a2846b7f2da 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -258,10 +258,29 @@ func runInteractive() (command, preset string, overrides map[string][]string, er } cmdScreens = buildCommandScreens(cmd, p, &styles) - if cmdScreens == nil { - return nil + + // Build the confirmation screen. Its preview function + // resolves the config with all current screen values so + // it shows exactly what will be executed. + previewFn := func() string { + var overrides map[string][]string + if cmdScreens != nil { + overrides = extractCommandOverrides(cmd, cmdScreens) + } + cfg, err := config.Resolve(p, cmd, overrides) + if err != nil { + return fmt.Sprintf("config error: %v\n", err) + } + return restic.DryRun(cfg) + } + confirmScreen := screens.NewConfirm(previewFn, flagShowCmd) + + var result []ui.Screen + if cmdScreens != nil { + result = append(result, cmdScreens.list...) } - return cmdScreens.list + result = append(result, confirmScreen) + return result }) screenList = append(screenList, resolveScreen) diff --git a/internal/ui/screens/confirm.go b/internal/ui/screens/confirm.go new file mode 100644 index 0000000000000000000000000000000000000000..fb7cfb6b1970c775672d5a6ec6c7430bb2299589 --- /dev/null +++ b/internal/ui/screens/confirm.go @@ -0,0 +1,95 @@ +package screens + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/ui" +) + +// PreviewFunc returns the formatted command preview text. It is +// called asynchronously from Init via a tea.Cmd. The returned string +// should match the format of [restic.DryRun]. +type PreviewFunc func() string + +// previewMsg carries the result of the async preview generation. +type previewMsg struct { + text string +} + +// Confirm is a Screen adapter that shows the resolved restic command +// and waits for the user to confirm execution. Enter proceeds, +// Esc goes back. +// +// When showCommand is true, Init returns DoneCmd immediately without +// calling the preview function — the session completes and the caller +// prints the dry-run output through the normal --show-command path. +type Confirm struct { + previewFn PreviewFunc + showCommand bool + preview string + selection string +} + +// NewConfirm creates a confirmation screen. The previewFn is called +// to generate the command preview text. When showCommand is true, +// the screen auto-completes without user interaction. +func NewConfirm(previewFn PreviewFunc, showCommand bool) *Confirm { + return &Confirm{ + previewFn: previewFn, + showCommand: showCommand, + } +} + +// Init starts the preview generation. In --show-command mode, it +// skips the preview and returns DoneCmd immediately. +func (c *Confirm) Init() tea.Cmd { + c.selection = "" + + if c.showCommand { + return ui.DoneCmd + } + + return func() tea.Msg { + return previewMsg{text: c.previewFn()} + } +} + +// Update handles messages. Esc navigates back, Enter confirms. +func (c *Confirm) Update(msg tea.Msg) (ui.Screen, tea.Cmd) { + switch msg := msg.(type) { + case previewMsg: + c.preview = msg.text + return c, nil + + case tea.KeyPressMsg: + if msg.Code == tea.KeyEscape { + return c, ui.BackCmd + } + if msg.Code == tea.KeyEnter && c.preview != "" { + c.selection = "confirmed" + return c, ui.DoneCmd + } + } + + return c, nil +} + +// View renders the command preview. +func (c *Confirm) View() string { + return c.preview +} + +// Title returns the screen's display title. +func (c *Confirm) Title() string { return "Confirm execution" } + +// KeyBindings returns bindings for the help bar. +func (c *Confirm) KeyBindings() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "execute")), + } +} + +// Selection returns "confirmed" after the user presses Enter, or "" +// if the screen has not been confirmed yet. +func (c *Confirm) Selection() string { return c.selection } diff --git a/internal/ui/screens/confirm_test.go b/internal/ui/screens/confirm_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2bd6c56ce32bcc16069fc37d6319b9d8ee4a8056 --- /dev/null +++ b/internal/ui/screens/confirm_test.go @@ -0,0 +1,219 @@ +package screens + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/ui" +) + +func TestConfirmTitle(t *testing.T) { + t.Parallel() + + c := NewConfirm(func() string { return "command: restic backup\n" }, false) + if got := c.Title(); got != "Confirm execution" { + t.Errorf("Title() = %q, want %q", got, "Confirm execution") + } +} + +func TestConfirmSelectionEmpty(t *testing.T) { + t.Parallel() + + c := NewConfirm(func() string { return "command: restic backup\n" }, false) + if got := c.Selection(); got != "" { + t.Errorf("Selection() before interaction = %q, want empty", got) + } +} + +func TestConfirmKeyBindings(t *testing.T) { + t.Parallel() + + c := NewConfirm(func() string { return "command: restic backup\n" }, false) + bindings := c.KeyBindings() + + if len(bindings) == 0 { + t.Fatal("KeyBindings() returned no bindings") + } +} + +func TestConfirmInitCallsPreviewFunc(t *testing.T) { + t.Parallel() + + called := false + c := NewConfirm(func() string { + called = true + return "command: restic backup\n" + }, false) + + cmd := c.Init() + // The preview func is called asynchronously via a tea.Cmd. + // Execute the returned cmd to get the message. + if cmd != nil { + msg := cmd() + c.Update(msg) + } + + if !called { + t.Error("Init() did not call the preview function") + } +} + +func TestConfirmViewShowsPreviewText(t *testing.T) { + t.Parallel() + + preview := "environ:\n RESTIC_REPOSITORY=/repo\ncommand: \"restic\" \"backup\" \"/src\"\n" + c := NewConfirm(func() string { return preview }, false) + + cmd := c.Init() + if cmd != nil { + msg := cmd() + c.Update(msg) + } + + view := c.View() + if !strings.Contains(view, "RESTIC_REPOSITORY") { + t.Errorf("View() missing environ; got:\n%s", view) + } + if !strings.Contains(view, "restic") { + t.Errorf("View() missing command; got:\n%s", view) + } +} + +func TestConfirmEscReturnsBack(t *testing.T) { + t.Parallel() + + c := NewConfirm(func() string { return "command: restic backup\n" }, false) + cmd := c.Init() + if cmd != nil { + msg := cmd() + c.Update(msg) + } + + _, cmd = c.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if cmd == nil { + t.Fatal("expected BackCmd on Esc") + } + msg := cmd() + if _, ok := msg.(ui.BackMsg); !ok { + t.Errorf("cmd produced %T, want ui.BackMsg", msg) + } +} + +func TestConfirmEnterReturnsDone(t *testing.T) { + t.Parallel() + + c := NewConfirm(func() string { return "command: restic backup\n" }, false) + cmd := c.Init() + if cmd != nil { + msg := cmd() + c.Update(msg) + } + + _, cmd = c.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if cmd == nil { + t.Fatal("expected DoneCmd on Enter") + } + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("cmd produced %T, want ui.DoneMsg", msg) + } +} + +func TestConfirmSelectionAfterEnter(t *testing.T) { + t.Parallel() + + c := NewConfirm(func() string { return "command: restic backup\n" }, false) + cmd := c.Init() + if cmd != nil { + msg := cmd() + c.Update(msg) + } + + c.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if got := c.Selection(); got != "confirmed" { + t.Errorf("Selection() after enter = %q, want %q", got, "confirmed") + } +} + +func TestConfirmShowCommandAutoCompletes(t *testing.T) { + t.Parallel() + + // When --show-command is active, Init should return DoneCmd + // immediately so the session completes without user interaction. + c := NewConfirm(func() string { return "command: restic backup\n" }, true) + cmd := c.Init() + + if cmd == nil { + t.Fatal("expected DoneCmd from Init when showCommand is true") + } + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("Init() with showCommand produced %T, want ui.DoneMsg", msg) + } +} + +func TestConfirmShowCommandSkipsPreview(t *testing.T) { + t.Parallel() + + called := false + c := NewConfirm(func() string { + called = true + return "command: restic backup\n" + }, true) + + c.Init() + + if called { + t.Error("preview function should not be called when showCommand is true") + } +} + +func TestConfirmViewEmptyBeforeInit(t *testing.T) { + t.Parallel() + + c := NewConfirm(func() string { return "command: restic backup\n" }, false) + if got := c.View(); got != "" { + t.Errorf("View() before Init = %q, want empty", got) + } +} + +func TestConfirmReinitAfterBack(t *testing.T) { + t.Parallel() + + c := NewConfirm(func() string { return "command: restic backup\n" }, false) + cmd := c.Init() + if cmd != nil { + msg := cmd() + c.Update(msg) + } + + // Confirm. + c.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + // Re-init (simulates back navigation and return). + cmd = c.Init() + if cmd != nil { + msg := cmd() + c.Update(msg) + } + + // Should be functional again — selection should be cleared. + if got := c.Selection(); got != "" { + t.Errorf("Selection() after re-init = %q, want empty", got) + } + + // Should still respond to Enter. + _, cmd = c.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected DoneCmd after re-init and Enter") + } + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("after re-init, cmd produced %T, want ui.DoneMsg", msg) + } +}