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