From 5c5076f1f12dbf4f42b184982f1f7e413b22c068 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 13 Apr 2026 22:09:08 +0200 Subject: [PATCH] fix(tui): guard against late Progress/Log after DoneMsg; drop dead quitting field --- internal/tui/dashboard/model.go | 11 +++++++---- internal/tui/dashboard/model_test.go | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index f5c855d..0667cea 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -19,6 +19,8 @@ type DoneMsg struct { } // rowState tracks per-module progress in the dashboard. +// Name, Started, and Ended are populated for future use by elapsed-time +// rendering (planned for a later iteration); they are currently not read by View. type rowState struct { ID string Name string @@ -41,7 +43,6 @@ type Model struct { showLogs bool summary *engine.Summary runErr error - quitting bool } // NewModel seeds the dashboard from the list of module IDs selected to run. @@ -65,7 +66,10 @@ func (m *Model) Init() tea.Cmd { return nil } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tui.ProgressMsg: - if r, ok := m.rowIndex[msg.ModuleID]; ok { + // Guard: ignore late Progress events that arrive after DoneMsg has + // finalised this row. tea.Program.Send is asynchronous and concurrent + // goroutines have no arrival-order guarantee. + if r, ok := m.rowIndex[msg.ModuleID]; ok && !r.Done { if r.Started.IsZero() { r.Started = time.Now() } @@ -75,7 +79,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { r.StatusIcon = "⠋" } case tui.LogMsg: - if r, ok := m.rowIndex[msg.ModuleID]; ok { + if r, ok := m.rowIndex[msg.ModuleID]; ok && !r.Done { r.LogTail = appendCapped(r.LogTail, msg.Line, 200) } case DoneMsg: @@ -101,7 +105,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": - m.quitting = true return m, tea.Quit case "up", "k": if m.cursor > 0 { diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 9e6e91d..814c8c0 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -45,6 +45,28 @@ func TestDoneMsgFlipsIcons(t *testing.T) { } } +func TestProgressAfterDoneDoesNotRevertIcon(t *testing.T) { + m := NewModel([]string{"a"}, nil) + m.Update(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{ + {ID: "a", Status: engine.StatusSucceeded}, + }}}) + if got := m.rowIndex["a"].StatusIcon; got != "✓" { + t.Fatalf("after Done icon = %q, want ✓", got) + } + // Late Progress event — tea.Program.Send has no ordering guarantee across goroutines. + m.Update(tui.ProgressMsg{ModuleID: "a", Current: 9, Total: 10, Message: "late"}) + if got := m.rowIndex["a"].StatusIcon; got != "✓" { + t.Fatalf("late Progress reverted icon to %q", got) + } + // Late Log event must also be dropped — it would otherwise pollute the + // log tail of a completed row. + before := len(m.rowIndex["a"].LogTail) + m.Update(tui.LogMsg{ModuleID: "a", Line: "late log"}) + if after := len(m.rowIndex["a"].LogTail); after != before { + t.Fatalf("late Log appended to tail: before=%d after=%d", before, after) + } +} + func TestViewMentionsModules(t *testing.T) { m := NewModel([]string{"alpha", "bravo"}, nil) out := m.View()