Add target directory screen adapter

Amolith created

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.

Change summary

internal/ui/screens/target.go      | 115 +++++++++++++++++
internal/ui/screens/target_test.go | 208 ++++++++++++++++++++++++++++++++
2 files changed, 323 insertions(+)

Detailed changes

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 }

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