diff --git a/internal/ui/wizard/flows/cd.go b/internal/ui/wizard/flows/cd.go index 5690906..22bfa7c 100644 --- a/internal/ui/wizard/flows/cd.go +++ b/internal/ui/wizard/flows/cd.go @@ -47,40 +47,44 @@ func (m *cdListModel) Init() tea.Cmd { return m.step.Init() } -// Update handles incoming messages. Only tea.KeyPressMsg is processed; -// other message types (window size, mouse, etc.) are safely ignored -// because FilterableListStep only operates on key events. +// Update handles incoming messages. Processes tea.KeyPressMsg for navigation +// and tea.PasteMsg for filter input. Other message types are ignored. func (m *cdListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - keyMsg, ok := msg.(tea.KeyPressMsg) - if !ok { - return m, nil - } - - switch keyMsg.String() { - case "esc": - if m.step.HasClearableInput() { - cmd := m.step.ClearInput() - return m, cmd + switch msg := msg.(type) { + case tea.PasteMsg: + // Paste never triggers navigation; discard StepResult. + _, cmd, _ := m.step.Update(msg) + return m, cmd + + case tea.KeyPressMsg: + switch msg.String() { + case "esc": + if m.step.HasClearableInput() { + cmd := m.step.ClearInput() + return m, cmd + } + m.cancelled = true + return m, tea.Quit + case "ctrl+c": + m.cancelled = true + return m, tea.Quit } - m.cancelled = true - return m, tea.Quit - case "ctrl+c": - m.cancelled = true - return m, tea.Quit - } - _, cmd, result := m.step.Update(keyMsg) + _, cmd, result := m.step.Update(msg) - if result == framework.StepSubmitIfReady || result == framework.StepAdvance { - val := m.step.GetSelectedValue() - if val != nil { - m.selectedAt = val.(int) - m.done = true - return m, tea.Quit + if result == framework.StepSubmitIfReady || result == framework.StepAdvance { + val := m.step.GetSelectedValue() + if val != nil { + m.selectedAt = val.(int) + m.done = true + return m, tea.Quit + } } + + return m, cmd } - return m, cmd + return m, nil } func (m *cdListModel) View() tea.View { diff --git a/internal/ui/wizard/flows/cd_test.go b/internal/ui/wizard/flows/cd_test.go index f531318..50b4f13 100644 --- a/internal/ui/wizard/flows/cd_test.go +++ b/internal/ui/wizard/flows/cd_test.go @@ -2,6 +2,11 @@ package flows import ( "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/raphi011/wt/internal/ui/wizard/framework" + "github.com/raphi011/wt/internal/ui/wizard/steps" ) func TestCdInteractive_EmptyWorktrees(t *testing.T) { @@ -59,7 +64,39 @@ func TestCdWorktreeInfo_Structure(t *testing.T) { } } -// Note: Full interactive testing of CdInteractive requires calling -// cdListModel.Update() directly with synthetic tea.KeyPressMsg values, -// bypassing the BubbleTea runtime. The model only processes KeyPressMsg; -// other message types are safely ignored. +func TestCdListModel_Paste(t *testing.T) { + worktrees := []CdWorktreeInfo{ + {RepoName: "repo1", Branch: "feature-alpha", Path: "/path/alpha"}, + {RepoName: "repo2", Branch: "feature-beta", Path: "/path/beta"}, + {RepoName: "repo1", Branch: "main", Path: "/path/main"}, + } + + t.Run("paste filters worktree list", func(t *testing.T) { + options := make([]framework.Option, len(worktrees)) + for i, wt := range worktrees { + options[i] = framework.Option{ + Label: wt.RepoName + ":" + wt.Branch, + Value: i, + } + } + + selectStep := steps.NewFilterableList("worktree", "Worktree", "", options) + model := &cdListModel{ + step: selectStep, + worktrees: worktrees, + selectedAt: -1, + } + model.Init() + + // Paste "beta" to filter the list + m, _ := model.Update(tea.PasteMsg{Content: "beta"}) + model = m.(*cdListModel) + + if model.step.GetFilter() != "beta" { + t.Errorf("Filter = %q, want %q", model.step.GetFilter(), "beta") + } + if model.step.FilteredCount() != 1 { + t.Errorf("FilteredCount = %d, want 1", model.step.FilteredCount()) + } + }) +} diff --git a/internal/ui/wizard/framework/step.go b/internal/ui/wizard/framework/step.go index ed6a42e..e4509fc 100644 --- a/internal/ui/wizard/framework/step.go +++ b/internal/ui/wizard/framework/step.go @@ -34,9 +34,11 @@ type Step interface { // Init returns an initial command when entering this step. Init() tea.Cmd - // Update handles key events and returns the updated step, - // a command to run, and a result indicating navigation. - Update(msg tea.KeyPressMsg) (Step, tea.Cmd, StepResult) + // Update handles input messages forwarded by the Wizard. + // Callers only forward tea.KeyPressMsg and tea.PasteMsg; + // implementations must return (self, nil, StepContinue) for + // any other message type. + Update(msg tea.Msg) (Step, tea.Cmd, StepResult) // View renders the step content. View() string diff --git a/internal/ui/wizard/framework/wizard.go b/internal/ui/wizard/framework/wizard.go index 47271c7..6a2ef86 100644 --- a/internal/ui/wizard/framework/wizard.go +++ b/internal/ui/wizard/framework/wizard.go @@ -204,6 +204,17 @@ func (w *Wizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) { w.height = msg.Height return w, nil + case tea.PasteMsg: + // Forward paste to current step (not summary). + // Paste never triggers navigation; all steps return StepContinue. + if w.currentStep < len(w.steps) { + step := w.steps[w.currentStep] + newStep, cmd, _ := step.Update(msg) + w.steps[w.currentStep] = newStep + return w, cmd + } + return w, nil + case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "esc": diff --git a/internal/ui/wizard/framework/wizard_test.go b/internal/ui/wizard/framework/wizard_test.go index cfc1b3a..ec5bd03 100644 --- a/internal/ui/wizard/framework/wizard_test.go +++ b/internal/ui/wizard/framework/wizard_test.go @@ -29,8 +29,12 @@ func (s *mockStep) ID() string { return s.id } func (s *mockStep) Title() string { return s.title } func (s *mockStep) Init() tea.Cmd { return nil } -func (s *mockStep) Update(msg tea.KeyPressMsg) (Step, tea.Cmd, StepResult) { - switch msg.String() { +func (s *mockStep) Update(msg tea.Msg) (Step, tea.Cmd, StepResult) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return s, nil, StepContinue + } + switch keyMsg.String() { case "left": return s, nil, StepBack case "right": @@ -618,6 +622,93 @@ func TestWizard_InfoLine(t *testing.T) { } } +// mockPasteStep extends mockStep to track paste messages. +type mockPasteStep struct { + mockStep + pasteReceived string +} + +func newMockPasteStep(id, title string) *mockPasteStep { + return &mockPasteStep{ + mockStep: *newMockStep(id, title), + } +} + +func (s *mockPasteStep) Update(msg tea.Msg) (Step, tea.Cmd, StepResult) { + switch msg := msg.(type) { + case tea.PasteMsg: + s.pasteReceived = msg.Content + return s, nil, StepContinue + case tea.KeyPressMsg: + return s.mockStep.Update(msg) + } + return s, nil, StepContinue +} + +func TestWizard_PasteMsg(t *testing.T) { + t.Run("paste is forwarded to current step", func(t *testing.T) { + step1 := newMockPasteStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1) + w.Init() + + m, _ := w.Update(tea.PasteMsg{Content: "pasted"}) + w = m.(*Wizard) + + if step1.pasteReceived != "pasted" { + t.Errorf("pasteReceived = %q, want %q", step1.pasteReceived, "pasted") + } + if w.CurrentStepID() != "step1" { + t.Errorf("CurrentStepID = %s, want step1", w.CurrentStepID()) + } + }) + + t.Run("paste is forwarded to correct step in multi-step wizard", func(t *testing.T) { + step1 := newMockPasteStep("step1", "Step 1") + step2 := newMockPasteStep("step2", "Step 2") + + w := NewWizard("Test").AddStep(step1).AddStep(step2) + w.Init() + + // Advance to step2 + w = updateWizard(t, w, "enter") + if w.CurrentStepID() != "step2" { + t.Fatalf("Should be on step2, got %s", w.CurrentStepID()) + } + + m, _ := w.Update(tea.PasteMsg{Content: "for-step2"}) + w = m.(*Wizard) + + if step1.pasteReceived != "" { + t.Errorf("step1 received paste %q, want empty", step1.pasteReceived) + } + if step2.pasteReceived != "for-step2" { + t.Errorf("step2.pasteReceived = %q, want %q", step2.pasteReceived, "for-step2") + } + }) + + t.Run("paste on summary step is ignored", func(t *testing.T) { + step1 := newMockPasteStep("step1", "Step 1") + + w := NewWizard("Test").AddStep(step1) + w.Init() + + // Advance to summary + w = updateWizard(t, w, "enter") + if w.CurrentStepID() != "summary" { + t.Fatalf("Should be on summary, got %s", w.CurrentStepID()) + } + + // Paste on summary should not crash or change state + m, _ := w.Update(tea.PasteMsg{Content: "pasted"}) + w = m.(*Wizard) + + if w.CurrentStepID() != "summary" { + t.Errorf("CurrentStepID = %s, want summary", w.CurrentStepID()) + } + }) +} + func TestWizard_WindowSizeMsg(t *testing.T) { step1 := newMockStep("step1", "Step 1") diff --git a/internal/ui/wizard/steps/filterable_list.go b/internal/ui/wizard/steps/filterable_list.go index e470caf..e3b3a7d 100644 --- a/internal/ui/wizard/steps/filterable_list.go +++ b/internal/ui/wizard/steps/filterable_list.go @@ -223,22 +223,30 @@ func (s *FilterableListStep) Init() tea.Cmd { return nil } -func (s *FilterableListStep) Update(msg tea.KeyPressMsg) (framework.Step, tea.Cmd, framework.StepResult) { - // Handle global navigation keys regardless of focus - switch msg.String() { - case "left": - return s, nil, framework.StepBack - case "right": - return s.handleSelect(framework.StepAdvance) - case "enter": - return s.handleSelect(framework.StepSubmitIfReady) - } +func (s *FilterableListStep) Update(msg tea.Msg) (framework.Step, tea.Cmd, framework.StepResult) { + switch msg := msg.(type) { + case tea.PasteMsg: + return s.handlePaste(msg) - // Delegate to focus-specific handler based on textinput focus state - if s.filterInput.Focused() { - return s.updateFilterFocused(msg) + case tea.KeyPressMsg: + // Handle global navigation keys regardless of focus + switch msg.String() { + case "left": + return s, nil, framework.StepBack + case "right": + return s.handleSelect(framework.StepAdvance) + case "enter": + return s.handleSelect(framework.StepSubmitIfReady) + } + + // Delegate to focus-specific handler based on textinput focus state + if s.filterInput.Focused() { + return s.updateFilterFocused(msg) + } + return s.updateListFocused(msg) } - return s.updateListFocused(msg) + + return s, nil, framework.StepContinue } // updateFilterFocused handles input when the filter text input has focus. @@ -388,6 +396,39 @@ func (s *FilterableListStep) handleSelect(result framework.StepResult) (framewor return s, nil, framework.StepContinue } +// handlePaste applies the rune filter to pasted content, auto-focuses the +// filter input if needed, and forwards the sanitized text to the textinput. +func (s *FilterableListStep) handlePaste(msg tea.PasteMsg) (framework.Step, tea.Cmd, framework.StepResult) { + content := msg.Content + if content == "" { + return s, nil, framework.StepContinue + } + + // Apply rune filter to pasted content + filtered := framework.FilterRunes([]rune(content), s.runeFilter) + if filtered == "" { + return s, nil, framework.StepContinue + } + + // Auto-focus filter if list was focused + if !s.filterInput.Focused() { + s.filterInput.Focus() + } + + // Forward filtered paste to textinput + var cmd tea.Cmd + s.filterInput, cmd = s.filterInput.Update(tea.PasteMsg{Content: filtered}) + + // Sync filter value and re-apply fuzzy matching + newFilter := s.filterInput.Value() + if newFilter != s.filter { + s.filter = newFilter + s.applyFilter() + } + + return s, cmd, framework.StepContinue +} + // canAdvanceMulti returns true if multi-select constraints are met. func (s *FilterableListStep) canAdvanceMulti() bool { if s.minSelect > 0 && len(s.multiSelected) < s.minSelect { diff --git a/internal/ui/wizard/steps/filterable_list_test.go b/internal/ui/wizard/steps/filterable_list_test.go index fe5776e..a3210a2 100644 --- a/internal/ui/wizard/steps/filterable_list_test.go +++ b/internal/ui/wizard/steps/filterable_list_test.go @@ -8,6 +8,92 @@ import ( "github.com/raphi011/wt/internal/ui/wizard/framework" ) +func TestFilterableListStep_Paste(t *testing.T) { + options := []framework.Option{ + {Label: "apple", Value: "apple"}, + {Label: "apricot", Value: "apricot"}, + {Label: "banana", Value: "banana"}, + {Label: "cherry", Value: "cherry"}, + } + + t.Run("paste into filter with filter focused", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + updateStep(t, step, keyMsg("a")) + if step.GetFilter() != "a" { + t.Fatalf("Filter = %q, want %q", step.GetFilter(), "a") + } + + result, _, stepResult := step.Update(tea.PasteMsg{Content: "ppl"}) + if stepResult != framework.StepContinue { + t.Errorf("Result = %v, want StepContinue", stepResult) + } + step = result.(*FilterableListStep) + + if step.GetFilter() != "appl" { + t.Errorf("Filter after paste = %q, want %q", step.GetFilter(), "appl") + } + if step.FilteredCount() != 1 { + t.Errorf("FilteredCount = %d, want 1", step.FilteredCount()) + } + }) + + t.Run("paste with list focused auto-focuses filter", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + result, _, stepResult := step.Update(tea.PasteMsg{Content: "ban"}) + if stepResult != framework.StepContinue { + t.Errorf("Result = %v, want StepContinue", stepResult) + } + step = result.(*FilterableListStep) + + if step.GetFilter() != "ban" { + t.Errorf("Filter after paste = %q, want %q", step.GetFilter(), "ban") + } + if step.FilteredCount() != 1 { + t.Errorf("FilteredCount = %d, want 1", step.FilteredCount()) + } + }) + + t.Run("paste with RuneFilter strips disallowed chars", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithRuneFilter(framework.RuneFilterNoSpaces) + + result, _, _ := step.Update(tea.PasteMsg{Content: "a p p"}) + step = result.(*FilterableListStep) + + if step.GetFilter() != "app" { + t.Errorf("Filter after paste with RuneFilter = %q, want %q", step.GetFilter(), "app") + } + }) + + t.Run("paste empty string is no-op", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options) + + result, _, stepResult := step.Update(tea.PasteMsg{Content: ""}) + if stepResult != framework.StepContinue { + t.Errorf("Result = %v, want StepContinue", stepResult) + } + step = result.(*FilterableListStep) + + if step.GetFilter() != "" { + t.Errorf("Filter after empty paste = %q, want empty", step.GetFilter()) + } + }) + + t.Run("paste with RuneFilter all chars filtered is no-op", func(t *testing.T) { + step := NewFilterableList("test", "Test", "Select", options). + WithRuneFilter(framework.RuneFilterNoSpaces) + + result, _, _ := step.Update(tea.PasteMsg{Content: " "}) + step = result.(*FilterableListStep) + + if step.GetFilter() != "" { + t.Errorf("Filter after all-filtered paste = %q, want empty", step.GetFilter()) + } + }) +} + func TestFilterableListStep_BasicNavigation(t *testing.T) { options := []framework.Option{ {Label: "apple", Value: "apple"}, diff --git a/internal/ui/wizard/steps/single_select.go b/internal/ui/wizard/steps/single_select.go index c5f7e40..e4e54ff 100644 --- a/internal/ui/wizard/steps/single_select.go +++ b/internal/ui/wizard/steps/single_select.go @@ -47,8 +47,12 @@ func (s *SingleSelectStep) Init() tea.Cmd { return nil } -func (s *SingleSelectStep) Update(msg tea.KeyPressMsg) (framework.Step, tea.Cmd, framework.StepResult) { - switch msg.String() { +func (s *SingleSelectStep) Update(msg tea.Msg) (framework.Step, tea.Cmd, framework.StepResult) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return s, nil, framework.StepContinue + } + switch keyMsg.String() { case "up", "k": s.moveCursorUp() case "down", "j": diff --git a/internal/ui/wizard/steps/single_select_test.go b/internal/ui/wizard/steps/single_select_test.go index 59a92c8..630ab6a 100644 --- a/internal/ui/wizard/steps/single_select_test.go +++ b/internal/ui/wizard/steps/single_select_test.go @@ -50,7 +50,7 @@ func keyMsg(key string) tea.KeyPressMsg { } // updateStep is a helper that performs Update and returns the concrete type. -func updateStep[T framework.Step](t *testing.T, s T, msg tea.KeyPressMsg) (T, framework.StepResult) { +func updateStep[T framework.Step](t *testing.T, s T, msg tea.Msg) (T, framework.StepResult) { t.Helper() result, _, stepResult := s.Update(msg) concrete, ok := result.(T) @@ -60,6 +60,29 @@ func updateStep[T framework.Step](t *testing.T, s T, msg tea.KeyPressMsg) (T, fr return concrete, stepResult } +func TestSingleSelectStep_Paste(t *testing.T) { + options := []framework.Option{ + {Label: "Option 1", Value: "opt1"}, + {Label: "Option 2", Value: "opt2"}, + } + + t.Run("paste is a no-op", func(t *testing.T) { + step := NewSingleSelect("test", "Test", "Select:", options) + step.SetCursor(1) + + _, result := updateStep(t, step, tea.PasteMsg{Content: "text"}) + if result != framework.StepContinue { + t.Errorf("Result = %v, want StepContinue", result) + } + if step.GetCursor() != 1 { + t.Errorf("Cursor changed to %d, want 1", step.GetCursor()) + } + if step.IsComplete() { + t.Error("Step should not be complete after paste") + } + }) +} + func TestSingleSelectStep_Navigation(t *testing.T) { options := []framework.Option{ {Label: "Option 1", Value: "opt1"}, diff --git a/internal/ui/wizard/steps/text_input.go b/internal/ui/wizard/steps/text_input.go index 18e5067..c61dbd6 100644 --- a/internal/ui/wizard/steps/text_input.go +++ b/internal/ui/wizard/steps/text_input.go @@ -52,51 +52,62 @@ func (s *TextInputStep) Init() tea.Cmd { return textinput.Blink } -func (s *TextInputStep) Update(msg tea.KeyPressMsg) (framework.Step, tea.Cmd, framework.StepResult) { - switch msg.String() { - case "enter": - value := strings.TrimSpace(s.input.Value()) - if value == "" { - s.validationError = "Value cannot be empty" - return s, nil, framework.StepContinue - } - if s.validate != nil { - if err := s.validate(value); err != nil { - s.validationError = err.Error() +func (s *TextInputStep) Update(msg tea.Msg) (framework.Step, tea.Cmd, framework.StepResult) { + switch msg := msg.(type) { + case tea.PasteMsg: + s.validationError = "" + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return s, cmd, framework.StepContinue + + case tea.KeyPressMsg: + switch msg.String() { + case "enter": + value := strings.TrimSpace(s.input.Value()) + if value == "" { + s.validationError = "Value cannot be empty" return s, nil, framework.StepContinue } - } - s.validationError = "" - s.submitted = true - s.submitValue = value - return s, nil, framework.StepSubmitIfReady - case "right": - value := strings.TrimSpace(s.input.Value()) - if value == "" { - s.validationError = "Value cannot be empty" - return s, nil, framework.StepContinue - } - if s.validate != nil { - if err := s.validate(value); err != nil { - s.validationError = err.Error() + if s.validate != nil { + if err := s.validate(value); err != nil { + s.validationError = err.Error() + return s, nil, framework.StepContinue + } + } + s.validationError = "" + s.submitted = true + s.submitValue = value + return s, nil, framework.StepSubmitIfReady + case "right": + value := strings.TrimSpace(s.input.Value()) + if value == "" { + s.validationError = "Value cannot be empty" return s, nil, framework.StepContinue } + if s.validate != nil { + if err := s.validate(value); err != nil { + s.validationError = err.Error() + return s, nil, framework.StepContinue + } + } + s.validationError = "" + s.submitted = true + s.submitValue = value + return s, nil, framework.StepAdvance + case "left": + return s, nil, framework.StepBack } + + // Clear error when user types s.validationError = "" - s.submitted = true - s.submitValue = value - return s, nil, framework.StepAdvance - case "left": - return s, nil, framework.StepBack - } - // Clear error when user types - s.validationError = "" + // Let textinput handle other keys + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return s, cmd, framework.StepContinue + } - // Let textinput handle other keys - var cmd tea.Cmd - s.input, cmd = s.input.Update(msg) - return s, cmd, framework.StepContinue + return s, nil, framework.StepContinue } func (s *TextInputStep) View() string { diff --git a/internal/ui/wizard/steps/text_input_test.go b/internal/ui/wizard/steps/text_input_test.go index 339e38c..3f7cc83 100644 --- a/internal/ui/wizard/steps/text_input_test.go +++ b/internal/ui/wizard/steps/text_input_test.go @@ -2,11 +2,66 @@ package steps import ( "errors" + "strings" "testing" + tea "charm.land/bubbletea/v2" + "github.com/raphi011/wt/internal/ui/wizard/framework" ) +func TestTextInputStep_Paste(t *testing.T) { + t.Run("paste inserts text", func(t *testing.T) { + step := NewTextInput("test", "Test", "Enter name:", "") + step.Init() // Focus the input + + result, _, stepResult := step.Update(tea.PasteMsg{Content: "pasted-text"}) + if stepResult != framework.StepContinue { + t.Errorf("Result = %v, want StepContinue", stepResult) + } + step = result.(*TextInputStep) + + if step.GetValue() != "pasted-text" { + t.Errorf("GetValue() = %q, want %q", step.GetValue(), "pasted-text") + } + }) + + t.Run("paste clears validation error", func(t *testing.T) { + step := NewTextInput("test", "Test", "Enter name:", "") + step.Init() + + // Trigger validation error by submitting empty + updateStep(t, step, keyMsg("enter")) + if !strings.Contains(step.View(), "cannot be empty") { + t.Fatal("Expected validation error after empty submit") + } + + // Paste should clear the error + result, _, _ := step.Update(tea.PasteMsg{Content: "hello"}) + step = result.(*TextInputStep) + + if strings.Contains(step.View(), "cannot be empty") { + t.Error("Validation error should be cleared after paste") + } + if step.GetValue() != "hello" { + t.Errorf("GetValue() = %q, want %q", step.GetValue(), "hello") + } + }) + + t.Run("paste appends to existing text", func(t *testing.T) { + step := NewTextInput("test", "Test", "Enter name:", "") + step.Init() + step.SetValue("hello-") + + result, _, _ := step.Update(tea.PasteMsg{Content: "world"}) + step = result.(*TextInputStep) + + if step.GetValue() != "hello-world" { + t.Errorf("GetValue() = %q, want %q", step.GetValue(), "hello-world") + } + }) +} + func TestTextInputStep_BasicInput(t *testing.T) { t.Run("typing updates input value", func(t *testing.T) { step := NewTextInput("test", "Test", "Enter name:", "placeholder")