Add overwrite selector screen adapter

Amolith created

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.

Change summary

internal/ui/screens/overwrite.go      | 119 ++++++++++++++++++++
internal/ui/screens/overwrite_test.go | 166 +++++++++++++++++++++++++++++
2 files changed, 285 insertions(+)

Detailed changes

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 }

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)
+	}
+}