From 704675a86af324d6b1dca7a9e38f56356823756a Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 30 Mar 2026 17:02:37 -0600 Subject: [PATCH] Add target directory screen adapter Add screens.Target, a Screen adapter wrapping a huh Input for entering the restore target directory. Follows the same pattern as the Preset adapter: builds the huh form on Init, intercepts Esc for back navigation, returns DoneCmd on completion, and rebuilds on BackgroundColorMsg for theme changes. Value() returns the entered path for config overrides. Selection() returns the same for the breadcrumb. --- internal/ui/screens/target.go | 115 ++++++++++++++++ internal/ui/screens/target_test.go | 208 +++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 internal/ui/screens/target.go create mode 100644 internal/ui/screens/target_test.go diff --git a/internal/ui/screens/target.go b/internal/ui/screens/target.go new file mode 100644 index 0000000000000000000000000000000000000000..a8c414a62490406bf77961c923bbe0e2a6165055 --- /dev/null +++ b/internal/ui/screens/target.go @@ -0,0 +1,115 @@ +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" +) + +// Target is a Screen adapter that wraps a huh Input for entering +// the restore target directory. It validates that the input is +// non-empty and returns the entered path via [Value]. +type Target struct { + styles *theme.Styles + form *huh.Form + entered string + selection string +} + +// NewTarget creates a target directory screen. The styles pointer +// should come from the session so theme updates propagate. +func NewTarget(styles *theme.Styles) *Target { + return &Target{ + styles: styles, + } +} + +// buildForm constructs the huh form. Called from Init when the form +// needs (re)building. +func (t *Target) buildForm() { + // Preserve any previous selection so back-navigation shows the + // user's earlier input. Reset the breadcrumb label since the + // screen is being re-activated. + t.selection = "" + + input := huh.NewInput(). + Title("Restore destination"). + Placeholder("e.g. /tmp/restore"). + Value(&t.entered). + Validate(huh.ValidateNotEmpty()) + + t.form = huh.NewForm( + huh.NewGroup(input), + ).WithTheme(t.styles.Huh).WithShowHelp(false) +} + +// Init initialises the embedded form. On first call or after +// completion, the form is (re)built so it picks up the current +// theme and can accept input again. +func (t *Target) Init() tea.Cmd { + if t.form == nil || t.form.State != huh.StateNormal { + t.buildForm() + } + return t.form.Init() +} + +// Update handles messages. Esc is intercepted for back navigation. +// All other messages are forwarded to the huh form. +func (t *Target) Update(msg tea.Msg) (ui.Screen, tea.Cmd) { + if t.form == nil { + return t, nil + } + + switch msg.(type) { + case tea.BackgroundColorMsg: + t.buildForm() + return t, t.form.Init() + } + + if kp, ok := msg.(tea.KeyPressMsg); ok { + if kp.Code == tea.KeyEscape { + return t, ui.BackCmd + } + } + + model, cmd := t.form.Update(msg) + if f, ok := model.(*huh.Form); ok { + t.form = f + } + + if t.form.State == huh.StateCompleted { + t.selection = t.entered + return t, ui.DoneCmd + } + + return t, cmd +} + +// View renders the form. +func (t *Target) View() string { + if t.form == nil { + return "" + } + return t.form.View() +} + +// Title returns the screen's display title. +func (t *Target) Title() string { return "Target directory" } + +// KeyBindings returns bindings for the help bar. +func (t *Target) KeyBindings() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + } +} + +// Selection returns the entered path for breadcrumb display, or "" +// if nothing has been entered yet. +func (t *Target) Selection() string { return t.selection } + +// Value returns the entered target directory path. +func (t *Target) Value() string { return t.entered } diff --git a/internal/ui/screens/target_test.go b/internal/ui/screens/target_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f9546cd2b9887a2f36d2a39fad08aaf8850a1884 --- /dev/null +++ b/internal/ui/screens/target_test.go @@ -0,0 +1,208 @@ +package screens + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/ui" +) + +// drainTarget feeds commands back into the target screen until a +// DoneMsg or BackMsg is produced, or the command chain is exhausted. +// This is necessary because huh uses internal message chains +// (nextFieldMsg → nextGroupMsg → StateCompleted) that must be +// processed sequentially. +func drainTarget(t *Target, initialCmd tea.Cmd) (*Target, tea.Cmd) { + cmd := initialCmd + for cmd != nil { + msg := cmd() + if msg == nil { + return t, nil + } + switch msg.(type) { + case ui.DoneMsg: + return t, cmd + case ui.BackMsg: + return t, cmd + } + var screen ui.Screen + screen, cmd = t.Update(msg) + t = screen.(*Target) + } + return t, nil +} + +func TestTargetTitle(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + if got := tgt.Title(); got != "Target directory" { + t.Errorf("Title() = %q, want %q", got, "Target directory") + } +} + +func TestTargetSelectionEmpty(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + if got := tgt.Selection(); got != "" { + t.Errorf("Selection() before interaction = %q, want empty", got) + } +} + +func TestTargetKeyBindings(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + bindings := tgt.KeyBindings() + + if len(bindings) == 0 { + t.Fatal("KeyBindings() returned no bindings") + } +} + +func TestTargetEscReturnsBack(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + tgt.Init() + tgt.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + _, cmd := tgt.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 TestTargetCompleteReturnsDone(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + tgt.Init() + tgt.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Type a path, then press enter. + for _, ch := range "/tmp/restore" { + tgt.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + } + + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = tgt.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + _, cmd = drainTarget(screen.(*Target), 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 TestTargetSelectionShowsPath(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + tgt.Init() + tgt.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + for _, ch := range "/tmp/restore" { + tgt.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + } + + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = tgt.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + tgt, _ = drainTarget(screen.(*Target), cmd) + + if got := tgt.Selection(); got != "/tmp/restore" { + t.Errorf("Selection() = %q, want %q", got, "/tmp/restore") + } +} + +func TestTargetValueReturnsPath(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + tgt.Init() + tgt.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + for _, ch := range "/tmp/restore" { + tgt.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + } + + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = tgt.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + tgt, _ = drainTarget(screen.(*Target), cmd) + + if got := tgt.Value(); got != "/tmp/restore" { + t.Errorf("Value() = %q, want %q", got, "/tmp/restore") + } +} + +func TestTargetReinitAfterBack(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + tgt.Init() + tgt.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Complete the form. + for _, ch := range "/tmp/restore" { + tgt.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + } + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = tgt.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + tgt, _ = drainTarget(screen.(*Target), cmd) + + // Re-init (simulates back navigation). + tgt.Init() + tgt.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Should be functional again. + for _, ch := range "/var/data" { + tgt.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + } + screen, cmd = tgt.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + _, cmd = drainTarget(screen.(*Target), 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) + } +} + +func TestTargetPreservesValueOnReinit(t *testing.T) { + t.Parallel() + + tgt := NewTarget(testStyles()) + tgt.Init() + tgt.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + for _, ch := range "/tmp/restore" { + tgt.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + } + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = tgt.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + tgt, _ = drainTarget(screen.(*Target), cmd) + + // Re-init should preserve the entered value. + tgt.Init() + + if tgt.entered != "/tmp/restore" { + t.Errorf("after re-init, entered = %q, want %q", tgt.entered, "/tmp/restore") + } +}