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