Skip to content

Commit 31b994d

Browse files
committed
make every dialog close on ctrl+c, twice exits
1 parent c3f7fd1 commit 31b994d

4 files changed

Lines changed: 120 additions & 9 deletions

File tree

‎pkg/tui/dialog/dialog.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type Manager interface {
3131

3232
GetLayers() []*lipgloss.Layer
3333
Open() bool
34+
TopIsExitConfirmation() bool
3435
}
3536

3637
// dialogEntry pairs a dialog with its drag offset so the two stay in sync.
@@ -269,6 +270,18 @@ func (d *manager) Open() bool {
269270
return len(d.stack) > 0
270271
}
271272

273+
// TopIsExitConfirmation returns true if the topmost dialog is the exit
274+
// confirmation dialog. Used by the top-level key handler to route ctrl+c to
275+
// the exit confirmation (which exits the program) instead of stacking another
276+
// exit confirmation on top.
277+
func (d *manager) TopIsExitConfirmation() bool {
278+
if len(d.stack) == 0 {
279+
return false
280+
}
281+
_, ok := d.stack[len(d.stack)-1].dialog.(*exitConfirmationDialog)
282+
return ok
283+
}
284+
272285
func (d *manager) SetSize(width, height int) tea.Cmd {
273286
d.width = width
274287
d.height = height

‎pkg/tui/dialog/elicitation.go‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,6 @@ func (d *ElicitationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
173173
}
174174
return d, nil
175175
case tea.KeyPressMsg:
176-
if msg.String() == "ctrl+c" {
177-
cmd := d.close(tools.ElicitationActionDecline, nil)
178-
return d, tea.Sequence(cmd, tea.Quit)
179-
}
180176
return d.handleKeyPress(msg)
181177
}
182178
return d, nil

‎pkg/tui/tui.go‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,6 +1659,23 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
16591659
}
16601660
}
16611661

1662+
// Ctrl+c is intercepted before any dialog handling so that every dialog
1663+
// reacts to it consistently:
1664+
// - With no dialog open: open the exit confirmation dialog.
1665+
// - With any other dialog open: stack the exit confirmation on top so
1666+
// that the user can confirm exit (a second ctrl+c or Y exits) or
1667+
// cancel it (N/Esc) and return to the original dialog.
1668+
// - With the exit confirmation already on top: forward the key so it
1669+
// can exit the program via its own Yes binding.
1670+
if msg.String() == "ctrl+c" {
1671+
if m.dialogMgr.TopIsExitConfirmation() {
1672+
return m.forwardDialog(msg)
1673+
}
1674+
return m, core.CmdHandler(dialog.OpenDialogMsg{
1675+
Model: dialog.NewExitConfirmationDialog(),
1676+
})
1677+
}
1678+
16621679
// Dialog gets priority when open
16631680
if m.dialogMgr.Open() {
16641681
return m.forwardDialog(msg)
@@ -1686,11 +1703,6 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
16861703

16871704
// Global keyboard shortcuts (active even during history search)
16881705
switch {
1689-
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+c"))):
1690-
return m, core.CmdHandler(dialog.OpenDialogMsg{
1691-
Model: dialog.NewExitConfirmationDialog(),
1692-
})
1693-
16941706
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+z"))):
16951707
return m, tea.Suspend
16961708

‎pkg/tui/tui_ctrlc_test.go‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
6+
tea "charm.land/bubbletea/v2"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/docker/docker-agent/pkg/tui/dialog"
11+
)
12+
13+
// applyOpenDialogMsgs feeds every dialog.OpenDialogMsg in cmd back into the
14+
// model, so the dialog manager actually receives them. This mirrors how the
15+
// real bubbletea event loop drains commands.
16+
func applyOpenDialogMsgs(t *testing.T, m *appModel, cmd tea.Cmd) {
17+
t.Helper()
18+
for _, msg := range collectMsgs(cmd) {
19+
if open, ok := msg.(dialog.OpenDialogMsg); ok {
20+
_, _ = m.Update(open)
21+
}
22+
}
23+
}
24+
25+
func TestCtrlC_NoDialog_OpensExitConfirmation(t *testing.T) {
26+
t.Parallel()
27+
28+
m, _ := newTestModel()
29+
require.False(t, m.dialogMgr.Open(), "no dialog should be open initially")
30+
31+
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
32+
applyOpenDialogMsgs(t, m, cmd)
33+
34+
assert.True(t, m.dialogMgr.Open(), "ctrl+c should open a dialog")
35+
assert.True(t, m.dialogMgr.TopIsExitConfirmation(),
36+
"ctrl+c with no dialog should open the exit confirmation dialog")
37+
}
38+
39+
func TestCtrlC_OnOtherDialog_StacksExitConfirmation(t *testing.T) {
40+
t.Parallel()
41+
42+
m, _ := newTestModel()
43+
44+
// Open an arbitrary, non-exit dialog first.
45+
_, cmd := m.Update(dialog.OpenDialogMsg{Model: dialog.NewHelpDialog(nil)})
46+
applyOpenDialogMsgs(t, m, cmd)
47+
require.True(t, m.dialogMgr.Open(), "help dialog should be open")
48+
require.False(t, m.dialogMgr.TopIsExitConfirmation(),
49+
"help dialog should be on top, not exit confirmation")
50+
51+
// Press ctrl+c — must stack the exit confirmation, not exit immediately.
52+
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
53+
applyOpenDialogMsgs(t, m, cmd)
54+
55+
msgs := collectMsgs(cmd)
56+
assert.False(t, hasMsg[tea.QuitMsg](msgs),
57+
"first ctrl+c on a non-exit dialog must NOT quit the program")
58+
assert.True(t, m.dialogMgr.TopIsExitConfirmation(),
59+
"ctrl+c on a non-exit dialog should put the exit confirmation on top")
60+
}
61+
62+
func TestCtrlC_OnExitConfirmation_ForwardsAndExits(t *testing.T) {
63+
neutralizeExitFunc(t)
64+
65+
m, _ := newTestModel()
66+
67+
// Put the exit confirmation dialog on top first (via a regular ctrl+c).
68+
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
69+
applyOpenDialogMsgs(t, m, cmd)
70+
require.True(t, m.dialogMgr.TopIsExitConfirmation(),
71+
"exit confirmation should be the topmost dialog")
72+
73+
// A second ctrl+c is forwarded to the exit confirmation, which signals
74+
// ExitConfirmedMsg. Feed every produced message back through the model
75+
// so we end up at tea.Quit.
76+
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
77+
require.NotNil(t, cmd, "second ctrl+c should produce a command")
78+
79+
sawQuit := false
80+
for _, msg := range collectMsgs(cmd) {
81+
if _, ok := msg.(dialog.ExitConfirmedMsg); ok {
82+
_, exitCmd := m.Update(msg)
83+
if hasMsg[tea.QuitMsg](collectMsgs(exitCmd)) {
84+
sawQuit = true
85+
}
86+
}
87+
}
88+
assert.True(t, sawQuit,
89+
"two ctrl+c presses must result in tea.Quit (exit the program)")
90+
}

0 commit comments

Comments
 (0)