From e46b8af36bbfefcd61c602dcdcfbcf5f7b529a9d Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 30 Mar 2026 17:13:32 -0600 Subject: [PATCH] Add overwrite selector screen adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add screens.Overwrite, a Screen adapter wrapping a huh Select for choosing the restore --overwrite behaviour. Options are if-changed (default), if-newer, never, and always — matching the existing form.SelectOverwrite choices. Follows the same huh-embedding pattern as Preset and Target. --- internal/ui/screens/overwrite.go | 119 ++++++++++++++++++ internal/ui/screens/overwrite_test.go | 166 ++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 internal/ui/screens/overwrite.go create mode 100644 internal/ui/screens/overwrite_test.go diff --git a/internal/ui/screens/overwrite.go b/internal/ui/screens/overwrite.go new file mode 100644 index 0000000000000000000000000000000000000000..9ab4141461408327fc6836628c64ac6c30efe017 --- /dev/null +++ b/internal/ui/screens/overwrite.go @@ -0,0 +1,119 @@ +package screens + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "charm.land/huh/v2" + + "git.secluded.site/keld/internal/theme" + "git.secluded.site/keld/internal/ui" +) + +// overwriteOptions defines the choices for restic's --overwrite flag +// in the order they are presented to the user. +var overwriteOptions = []huh.Option[string]{ + huh.NewOption("if-changed (recommended — only restore what differs)", "if-changed"), + huh.NewOption("if-newer (only overwrite older files)", "if-newer"), + huh.NewOption("never (skip existing files entirely)", "never"), + huh.NewOption("always (restic default — overwrite everything)", "always"), +} + +// Overwrite is a Screen adapter that wraps a huh Select for choosing +// the restore --overwrite behaviour. It follows the same pattern as +// [Preset]: builds the huh form on Init, intercepts Esc for back +// navigation, and returns DoneCmd on completion. +type Overwrite struct { + styles *theme.Styles + form *huh.Form + selected string + selection string +} + +// NewOverwrite creates an overwrite selector screen. The styles +// pointer should come from the session so theme updates propagate. +func NewOverwrite(styles *theme.Styles) *Overwrite { + return &Overwrite{ + styles: styles, + } +} + +// buildForm constructs the huh form and select field. +func (o *Overwrite) buildForm() { + o.selection = "" + + sel := huh.NewSelect[string](). + Options(overwriteOptions...). + Value(&o.selected) + + o.form = huh.NewForm( + huh.NewGroup(sel), + ).WithTheme(o.styles.Huh).WithShowHelp(false) +} + +// Init initialises the embedded form. On first call or after +// completion, the form is (re)built. +func (o *Overwrite) Init() tea.Cmd { + if o.form == nil || o.form.State != huh.StateNormal { + o.buildForm() + } + return o.form.Init() +} + +// Update handles messages. Esc is intercepted for back navigation. +func (o *Overwrite) Update(msg tea.Msg) (ui.Screen, tea.Cmd) { + if o.form == nil { + return o, nil + } + + switch msg.(type) { + case tea.BackgroundColorMsg: + o.buildForm() + return o, o.form.Init() + } + + if kp, ok := msg.(tea.KeyPressMsg); ok { + if kp.Code == tea.KeyEscape { + return o, ui.BackCmd + } + } + + model, cmd := o.form.Update(msg) + if f, ok := model.(*huh.Form); ok { + o.form = f + } + + if o.form.State == huh.StateCompleted { + o.selection = o.selected + return o, ui.DoneCmd + } + + return o, cmd +} + +// View renders the form. +func (o *Overwrite) View() string { + if o.form == nil { + return "" + } + return o.form.View() +} + +// Title returns the screen's display title. +func (o *Overwrite) Title() string { return "Overwrite existing files?" } + +// KeyBindings returns bindings for the help bar. +func (o *Overwrite) KeyBindings() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + } +} + +// Selection returns the chosen overwrite mode for breadcrumb display, +// or "" if nothing has been selected yet. +func (o *Overwrite) Selection() string { return o.selection } + +// Value returns the chosen overwrite mode value (one of: if-changed, +// if-newer, never, always). +func (o *Overwrite) Value() string { return o.selected } diff --git a/internal/ui/screens/overwrite_test.go b/internal/ui/screens/overwrite_test.go new file mode 100644 index 0000000000000000000000000000000000000000..653b50f944a28c2d148fc878c23a4e9c76dc6c93 --- /dev/null +++ b/internal/ui/screens/overwrite_test.go @@ -0,0 +1,166 @@ +package screens + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/ui" +) + +// drainOverwrite feeds commands back into the overwrite screen until +// a DoneMsg or BackMsg is produced, or the command chain is exhausted. +func drainOverwrite(o *Overwrite, initialCmd tea.Cmd) (*Overwrite, tea.Cmd) { + cmd := initialCmd + for cmd != nil { + msg := cmd() + if msg == nil { + return o, nil + } + switch msg.(type) { + case ui.DoneMsg: + return o, cmd + case ui.BackMsg: + return o, cmd + } + var screen ui.Screen + screen, cmd = o.Update(msg) + o = screen.(*Overwrite) + } + return o, nil +} + +func TestOverwriteTitle(t *testing.T) { + t.Parallel() + + o := NewOverwrite(testStyles()) + if got := o.Title(); got != "Overwrite existing files?" { + t.Errorf("Title() = %q, want %q", got, "Overwrite existing files?") + } +} + +func TestOverwriteSelectionEmpty(t *testing.T) { + t.Parallel() + + o := NewOverwrite(testStyles()) + if got := o.Selection(); got != "" { + t.Errorf("Selection() before interaction = %q, want empty", got) + } +} + +func TestOverwriteKeyBindings(t *testing.T) { + t.Parallel() + + o := NewOverwrite(testStyles()) + bindings := o.KeyBindings() + + if len(bindings) == 0 { + t.Fatal("KeyBindings() returned no bindings") + } +} + +func TestOverwriteEscReturnsBack(t *testing.T) { + t.Parallel() + + o := NewOverwrite(testStyles()) + o.Init() + o.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + _, cmd := o.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 TestOverwriteCompleteReturnsDone(t *testing.T) { + t.Parallel() + + o := NewOverwrite(testStyles()) + o.Init() + o.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Default selection is the first option (if-changed). Press enter. + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = o.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + _, cmd = drainOverwrite(screen.(*Overwrite), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after form completion") + } + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("cmd produced %T, want ui.DoneMsg", msg) + } +} + +func TestOverwriteDefaultIsIfChanged(t *testing.T) { + t.Parallel() + + o := NewOverwrite(testStyles()) + o.Init() + o.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Press enter on the default selection. + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = o.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + o, _ = drainOverwrite(screen.(*Overwrite), cmd) + + if got := o.Value(); got != "if-changed" { + t.Errorf("Value() = %q, want %q (default should be if-changed)", got, "if-changed") + } +} + +func TestOverwriteSelectionShowsValue(t *testing.T) { + t.Parallel() + + o := NewOverwrite(testStyles()) + o.Init() + o.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Press enter on the default. + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = o.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + o, _ = drainOverwrite(screen.(*Overwrite), cmd) + + if got := o.Selection(); got != "if-changed" { + t.Errorf("Selection() = %q, want %q", got, "if-changed") + } +} + +func TestOverwriteReinitAfterBack(t *testing.T) { + t.Parallel() + + o := NewOverwrite(testStyles()) + o.Init() + o.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Complete the form. + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = o.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + o, _ = drainOverwrite(screen.(*Overwrite), cmd) + + // Re-init (simulates back navigation). + o.Init() + o.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Should be functional again. + screen, cmd = o.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + _, cmd = drainOverwrite(screen.(*Overwrite), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after re-init") + } + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("after re-init, cmd produced %T, want ui.DoneMsg", msg) + } +}