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. // 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 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 } // 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: // 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() } 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.Done { 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": 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 }