From e92a4788ba744afdab41f74ade831c1c5c7b1d34 Mon Sep 17 00:00:00 2001 From: proxyctl coordinator Date: Fri, 17 Jul 2026 10:40:52 +0800 Subject: [PATCH] fix: guard terminal deferred quit state --- internal/tui/model.go | 9 +++- internal/tui/target_test.go | 90 +++++++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index beb2d76..f9d0e7b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -77,7 +77,7 @@ func (m Model) Init() tea.Cmd { return nil } // operation start checks it, so a completion can only belong to the one current // operation rather than racing another operation for shared model state. func (m Model) busy() bool { - return m.operationInFlight() || m.pendingActivation.Kind != 0 + return m.quitRequested || m.operationInFlight() || m.pendingActivation.Kind != 0 } func (m Model) operationInFlight() bool { @@ -85,6 +85,13 @@ func (m Model) operationInFlight() bool { } func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { + // A deferred tea.Quit is queued only after the in-flight completion has + // been consumed. Until Bubble Tea processes that QuitMsg, keep the model + // terminal so intervening input cannot cancel a confirmation or start a + // second mutation. + if m.quitRequested && !m.operationInFlight() { + return m, nil + } if key, ok := message.(tea.KeyMsg); ok && m.operationInFlight() && isQuitKey(key) { m.quitRequested = true return m, nil diff --git a/internal/tui/target_test.go b/internal/tui/target_test.go index 4fcda9c..1b4ceff 100644 --- a/internal/tui/target_test.go +++ b/internal/tui/target_test.go @@ -2,6 +2,7 @@ package tui import ( "errors" + "reflect" "strings" "testing" @@ -288,8 +289,8 @@ func TestSingleOperationGuardRejectsMismatchedCompletionAndPreventsOverlap(t *te refreshCompletion := refreshCommand() updated, command = model.Update(refreshCompletion) model = updated.(Model) - if command == nil || model.busy() || model.snapshot.ActiveProfile != "fresh" { - t.Fatal("current refresh completion did not apply fresh state and trigger deferred quit") + if command == nil || !model.busy() || model.snapshot.ActiveProfile != "fresh" { + t.Fatal("current refresh completion did not apply fresh state and enter terminal deferred-quit state") } if _, ok := command().(tea.QuitMsg); !ok { t.Fatal("current completion did not return tea.Quit") @@ -321,8 +322,8 @@ func TestBusyQuitWaitsForLongRunningTargetMutationCompletion(t *testing.T) { } updated, quit = model.Update(completion) model = updated.(Model) - if quit == nil || model.busy() || model.snapshot.Targets[0].State != target.Enabled { - t.Fatalf("mutation completion was not consumed before deferred quit: busy=%v snapshot=%#v", model.busy(), model.snapshot) + if quit == nil || !model.busy() || model.snapshot.Targets[0].State != target.Enabled { + t.Fatalf("mutation completion was not consumed before terminal deferred quit: busy=%v snapshot=%#v", model.busy(), model.snapshot) } if _, ok := quit().(tea.QuitMsg); !ok { t.Fatal("completion did not return tea.Quit") @@ -331,6 +332,87 @@ func TestBusyQuitWaitsForLongRunningTargetMutationCompletion(t *testing.T) { } } +func TestDeferredQuitIsTerminalAfterTargetConfirmationCompletion(t *testing.T) { + initial := app.Snapshot{ + ActiveProfile: "office", + Profiles: []profile.Profile{{Name: "office"}}, + Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Disabled}}, + } + module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Confirmation: "Docker will restart immediately."}} + model := NewModel(module) + updated, operation := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + updated, command := model.Update(keyMessage("q")) + model = updated.(Model) + if command != nil || !model.quitRequested { + t.Fatal("quit was not deferred during Target operation") + } + updated, queuedQuit := model.Update(operation()) + model = updated.(Model) + if queuedQuit == nil || model.targetConfirm == nil || len(module.intents) != 1 { + t.Fatalf("confirmation completion did not queue quit: command=%v confirmation=%v intents=%#v", queuedQuit != nil, model.targetConfirm != nil, module.intents) + } + + baselineSnapshot := model.snapshot + baselineStatus := model.status + baselinePage := model.page + baselineSelected := model.selected + baselineConfirmation := *model.targetConfirm + for _, message := range []tea.Msg{ + keyMessage("y"), keyMessage("n"), tea.KeyMsg{Type: tea.KeyEsc}, keyMessage(" "), + keyMessage("r"), keyMessage("p"), keyMessage("?"), keyMessage("j"), queuedQuit(), + } { + updated, command = model.Update(message) + model = updated.(Model) + if command != nil { + t.Fatalf("terminal deferred-quit model returned a command for %T", message) + } + if !model.quitRequested || !reflect.DeepEqual(model.snapshot, baselineSnapshot) || model.status != baselineStatus || model.page != baselinePage || model.selected != baselineSelected || model.targetConfirm == nil || !reflect.DeepEqual(*model.targetConfirm, baselineConfirmation) || len(module.intents) != 1 { + t.Fatalf("terminal deferred-quit model changed for %T: model=%#v intents=%#v", message, model, module.intents) + } + } +} + +func TestDeferredQuitIsTerminalAfterProfileConfirmationCompletion(t *testing.T) { + initial := app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}} + updates := make(chan app.Result, 1) + updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition"} + module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates} + model := updateKey(NewModel(module), "p") + updated, operation := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + updated, command := model.Update(keyMessage("q")) + model = updated.(Model) + if command != nil || !model.quitRequested { + t.Fatal("quit was not deferred during Profile activation") + } + updated, queuedQuit := model.Update(operation()) + model = updated.(Model) + if queuedQuit == nil || model.profileConfirm == nil || len(module.intents) != 1 { + t.Fatalf("Profile confirmation completion did not queue quit: command=%v confirmation=%v intents=%#v", queuedQuit != nil, model.profileConfirm != nil, module.intents) + } + + baselineSnapshot := model.snapshot + baselineStatus := model.status + baselinePage := model.page + baselineSelected := model.selected + baselinePending := model.pendingActivation + baselineConfirmation := *model.profileConfirm + for _, message := range []tea.Msg{ + keyMessage("y"), keyMessage("n"), tea.KeyMsg{Type: tea.KeyEsc}, tea.KeyMsg{Type: tea.KeyEnter}, + keyMessage("d"), keyMessage("e"), keyMessage("a"), keyMessage("r"), queuedQuit(), + } { + updated, command = model.Update(message) + model = updated.(Model) + if command != nil { + t.Fatalf("terminal deferred-quit Profile model returned a command for %T", message) + } + if !model.quitRequested || !reflect.DeepEqual(model.snapshot, baselineSnapshot) || model.status != baselineStatus || model.page != baselinePage || model.selected != baselineSelected || !reflect.DeepEqual(model.pendingActivation, baselinePending) || model.profileConfirm == nil || !reflect.DeepEqual(*model.profileConfirm, baselineConfirmation) || len(module.intents) != 1 { + t.Fatalf("terminal deferred-quit Profile model changed for %T: model=%#v intents=%#v", message, model, module.intents) + } + } +} + func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) { initial := app.Snapshot{Targets: []app.TargetSnapshot{ {ID: "one", Description: target.Description{Name: "One"}, Available: true},