diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go new file mode 100644 index 0000000..f5c855d --- /dev/null +++ b/internal/tui/dashboard/model.go @@ -0,0 +1,175 @@ +package dashboard + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +// DoneMsg is sent from the engine goroutine once engine.Run returns. +type DoneMsg struct { + Summary engine.Summary + Err error +} + +// rowState tracks per-module progress in the dashboard. +type rowState struct { + ID string + Name string + Current int + Total int + LastMsg string + LogTail []string + StatusIcon string // ·, ⠋ (running), ✓, ✗, ⊘ + Done bool + Started time.Time + Ended time.Time + Err error +} + +// Model is the Bubble Tea model for the Apply-phase dashboard. +type Model struct { + rows []*rowState + rowIndex map[string]*rowState + cursor int + showLogs bool + summary *engine.Summary + runErr error + quitting bool +} + +// NewModel seeds the dashboard from the list of module IDs selected to run. +// Rows start in the "pending" state and flip to running on first ProgressMsg. +func NewModel(selected []string, nameByID map[string]string) *Model { + m := &Model{rowIndex: make(map[string]*rowState, len(selected))} + for _, id := range selected { + name := id + if n, ok := nameByID[id]; ok { + name = n + } + r := &rowState{ID: id, Name: name, StatusIcon: "·"} + m.rows = append(m.rows, r) + m.rowIndex[id] = r + } + return m +} + +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 { + if r.Started.IsZero() { + r.Started = time.Now() + } + r.Current = msg.Current + r.Total = msg.Total + r.LastMsg = msg.Message + r.StatusIcon = "⠋" + } + case tui.LogMsg: + if r, ok := m.rowIndex[msg.ModuleID]; ok { + r.LogTail = appendCapped(r.LogTail, msg.Line, 200) + } + case DoneMsg: + m.summary = &msg.Summary + m.runErr = msg.Err + for _, res := range msg.Summary.Results { + r, ok := m.rowIndex[res.ID] + if !ok { + continue + } + r.Done = true + r.Ended = time.Now() + switch res.Status { + case engine.StatusSucceeded, engine.StatusPlanned: + r.StatusIcon = "✓" + case engine.StatusFailed: + r.StatusIcon = "✗" + r.Err = res.Err + case engine.StatusSkipped: + r.StatusIcon = "⊘" + } + } + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.rows)-1 { + m.cursor++ + } + case "enter": + m.showLogs = !m.showLogs + } + } + return m, nil +} + +func (m *Model) View() string { + var b strings.Builder + header := lipgloss.NewStyle().Bold(true).Render("archduino install") + b.WriteString(header + "\n") + + for i, r := range m.rows { + cursor := " " + if i == m.cursor { + cursor = "▸ " + } + progress := "" + if r.Total > 0 { + progress = fmt.Sprintf("%d/%d", r.Current, r.Total) + } + detail := r.LastMsg + if r.Err != nil { + detail = "failed — " + r.Err.Error() + } + fmt.Fprintf(&b, "%s%s %-16s %-6s %s\n", cursor, r.StatusIcon, r.ID, progress, detail) + } + + if m.showLogs && len(m.rows) > 0 { + active := m.rows[m.cursor] + b.WriteString("\n─ log tail ─\n") + for _, line := range active.LogTail { + b.WriteString(" " + line + "\n") + } + } + + if m.summary != nil { + ok, fail, skip := m.summary.Counts() + fmt.Fprintf(&b, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skip) + if m.runErr != nil { + fmt.Fprintf(&b, "error: %v\n", m.runErr) + } + b.WriteString("\n[q] quit\n") + } else { + b.WriteString("\n[↑/↓] navigate [enter] toggle log [q] abort\n") + } + return b.String() +} + +// Summary returns the engine summary once the run has completed, or nil. +func (m *Model) Summary() *engine.Summary { return m.summary } + +// RunErr returns the engine's top-level error, if any. +func (m *Model) RunErr() error { return m.runErr } + +func appendCapped(s []string, line string, max int) []string { + s = append(s, line) + if len(s) > max { + s = s[len(s)-max:] + } + return s +} diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go new file mode 100644 index 0000000..9e6e91d --- /dev/null +++ b/internal/tui/dashboard/model_test.go @@ -0,0 +1,56 @@ +package dashboard + +import ( + "errors" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +func TestProgressUpdatesRow(t *testing.T) { + m := NewModel([]string{"a"}, map[string]string{"a": "Alpha"}) + m.Update(tui.ProgressMsg{ModuleID: "a", Current: 3, Total: 10, Message: "doing"}) + if r := m.rowIndex["a"]; r.Current != 3 || r.Total != 10 || r.LastMsg != "doing" { + t.Fatalf("row state wrong: %+v", r) + } +} + +func TestLogAppendedBounded(t *testing.T) { + m := NewModel([]string{"a"}, nil) + for i := 0; i < 300; i++ { + m.Update(tui.LogMsg{ModuleID: "a", Line: "x"}) + } + if got := len(m.rowIndex["a"].LogTail); got != 200 { + t.Fatalf("log tail = %d, want 200", got) + } +} + +func TestDoneMsgFlipsIcons(t *testing.T) { + m := NewModel([]string{"ok", "bad", "skip"}, nil) + m.Update(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{ + {ID: "ok", Status: engine.StatusSucceeded}, + {ID: "bad", Status: engine.StatusFailed, Err: errors.New("boom")}, + {ID: "skip", Status: engine.StatusSkipped}, + }}}) + if m.rowIndex["ok"].StatusIcon != "✓" { + t.Errorf("ok icon = %q", m.rowIndex["ok"].StatusIcon) + } + if m.rowIndex["bad"].StatusIcon != "✗" { + t.Errorf("bad icon = %q", m.rowIndex["bad"].StatusIcon) + } + if m.rowIndex["skip"].StatusIcon != "⊘" { + t.Errorf("skip icon = %q", m.rowIndex["skip"].StatusIcon) + } +} + +func TestViewMentionsModules(t *testing.T) { + m := NewModel([]string{"alpha", "bravo"}, nil) + out := m.View() + for _, want := range []string{"alpha", "bravo", "archduino install"} { + if !strings.Contains(out, want) { + t.Errorf("view missing %q:\n%s", want, out) + } + } +}