fix(ui): Esc cancels delete confirmation instead of confirming it

In the remove-wizard's confirming state, pressing Esc was reflexively
calling removeForwardsCmd — i.e. confirming deletion. The on-screen
help text said 'Esc: Cancel'. Reflexive Esc-to-cancel destroyed data.

Esc now sets confirming=false and resets the cursor; deletion
requires Enter on Yes. Non-confirming Esc behavior (exit wizard with
ClearScreen) is unchanged.

Three regression tests added in handlers_test.go.
This commit is contained in:
2026-05-06 10:45:27 +01:00
parent 7a33e01863
commit 4fe3f6b21f
2 changed files with 104 additions and 7 deletions
+96
View File
@@ -901,3 +901,99 @@ func TestModel_ImplementsTeaModel(t *testing.T) {
var _ tea.Model = m
require.NotNil(t, m)
}
// TestHandleRemoveWizardKeys_EscInConfirmingCancels verifies that pressing Esc
// while the remove wizard is in confirming state CANCELS the confirmation
// instead of dispatching the deletion command. The help text on the
// confirmation screen advertises "Esc: Cancel" — destructive Esc was a P0 UX bug.
func TestHandleRemoveWizardKeys_EscInConfirmingCancels(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{
{ID: "fwd-1", Alias: "alpha"},
{ID: "fwd-2", Alias: "beta"},
},
selected: map[int]bool{0: true, 1: true},
confirming: true,
confirmCursor: 0, // cursor on "Yes" — worst case: reflexive Esc would have triggered Yes
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// No removal command must be dispatched.
assert.Nil(t, cmd, "Esc in confirming state must NOT dispatch removeForwardsCmd")
m.ui.mu.RLock()
defer m.ui.mu.RUnlock()
// Wizard must remain alive (we returned to selection, not aborted entirely).
require.NotNil(t, m.ui.removeWizard, "wizard should still exist after cancelling confirmation")
// Confirming flag must be cleared.
assert.False(t, m.ui.removeWizard.confirming, "wizard.confirming must be false after Esc cancels")
// View mode unchanged.
assert.Equal(t, ViewModeRemoveWizard, m.ui.viewMode, "view mode should remain in remove wizard")
// Selections preserved so user can re-confirm or adjust.
assert.True(t, m.ui.removeWizard.selected[0])
assert.True(t, m.ui.removeWizard.selected[1])
}
// TestHandleRemoveWizardKeys_EscNotConfirmingExits verifies that Esc still
// exits the wizard entirely when not in confirming state.
func TestHandleRemoveWizardKeys_EscNotConfirmingExits(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "fwd-1", Alias: "alpha"}},
selected: map[int]bool{},
confirming: false,
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// Should return tea.ClearScreen command on full exit.
assert.NotNil(t, cmd, "Esc outside confirmation should return ClearScreen cmd")
m.ui.mu.RLock()
defer m.ui.mu.RUnlock()
assert.Nil(t, m.ui.removeWizard, "wizard should be nil after exit")
assert.Equal(t, ViewModeMain, m.ui.viewMode)
}
// TestHandleRemoveWizardKeys_EnterOnYesStillConfirms verifies that the Enter-on-Yes
// path still produces a removal command (regression guard around the Esc fix).
func TestHandleRemoveWizardKeys_EnterOnYesStillConfirms(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "fwd-1", Alias: "alpha"}},
selected: map[int]bool{0: true},
confirming: true,
confirmCursor: 0, // Yes
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.NotNil(t, cmd, "Enter on Yes must still dispatch removeForwardsCmd")
}
+8 -7
View File
@@ -722,14 +722,15 @@ func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "esc":
if wizard.confirming {
// In confirmation mode, Esc confirms the removal (same as pressing Yes)
selectedForwards := wizard.getSelectedForwards()
return m, removeForwardsCmd(m.ui.mutator, selectedForwards)
} else {
// Not confirming yet - cancel entirely
m.ui.viewMode = ViewModeMain
m.ui.removeWizard = nil
// In confirmation mode, Esc cancels the confirmation (matches help text "Esc: Cancel")
// Returns to selection state without dispatching removal.
wizard.confirming = false
wizard.confirmCursor = 0
return m, nil
}
// Not confirming yet - cancel entirely
m.ui.viewMode = ViewModeMain
m.ui.removeWizard = nil
return m, tea.ClearScreen
case "up", "k":