Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 31 additions & 27 deletions internal/ui/wizard/flows/cd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 41 additions & 4 deletions internal/ui/wizard/flows/cd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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())
}
})
}
8 changes: 5 additions & 3 deletions internal/ui/wizard/framework/step.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/ui/wizard/framework/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
95 changes: 93 additions & 2 deletions internal/ui/wizard/framework/wizard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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")

Expand Down
69 changes: 55 additions & 14 deletions internal/ui/wizard/steps/filterable_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading