162 lines
5.3 KiB
Go
Raw Normal View History

2026-03-12 20:51:38 -07:00
package tui
import (
"bufio"
"fmt"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// ── Streaming Messages ──────────────────────────────────────────────
// ProgressMsg updates the progress bar in the output view.
type ProgressMsg struct {
Step int
Total int
Label string
}
// ── Step Definition ─────────────────────────────────────────────────
// CmdStep defines a single command to run in a streaming sequence.
type CmdStep struct {
Label string // Human-readable label (shown in output)
Args []string // Command + arguments
Dir string // Working directory (empty = inherit)
}
// ── Streaming Execution Engine ──────────────────────────────────────
// streamSteps runs a sequence of CmdSteps, sending OutputLineMsg per line
// and ProgressMsg per step, then DoneMsg when finished.
// It writes to a buffered channel that the TUI reads via waitForOutput().
func streamSteps(ch chan<- tea.Msg, steps []CmdStep) {
defer close(ch)
total := len(steps)
var errors []string
for i, step := range steps {
// Send progress update
ch <- ProgressMsg{
Step: i + 1,
Total: total,
Label: step.Label,
}
// Show command being executed
cmdStr := strings.Join(step.Args, " ")
ch <- OutputLineMsg(styleKey.Render(fmt.Sprintf("═══ [%d/%d] %s ═══", i+1, total, step.Label)))
ch <- OutputLineMsg(styleDim.Render(" $ " + cmdStr))
// Build command
cmd := exec.Command(step.Args[0], step.Args[1:]...)
if step.Dir != "" {
cmd.Dir = step.Dir
}
// Get pipes for real-time output
stdout, err := cmd.StdoutPipe()
if err != nil {
ch <- OutputLineMsg(styleError.Render(" Failed to create stdout pipe: " + err.Error()))
errors = append(errors, step.Label+": "+err.Error())
continue
}
cmd.Stderr = cmd.Stdout // merge stderr into stdout
// Start command
startTime := time.Now()
if err := cmd.Start(); err != nil {
ch <- OutputLineMsg(styleError.Render(" Failed to start: " + err.Error()))
errors = append(errors, step.Label+": "+err.Error())
continue
}
// Read output line by line
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 256*1024) // handle long lines
lineCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
// Parse apt/pip progress indicators for speed display
if parsed := parseProgressLine(line); parsed != "" {
ch <- OutputLineMsg(" " + parsed)
} else {
// Throttle verbose output: show every line for first 30,
// then every 5th line, but always show errors
if lineCount <= 30 || lineCount%5 == 0 || isErrorLine(line) {
ch <- OutputLineMsg(" " + line)
}
}
}
// Wait for command to finish
err = cmd.Wait()
elapsed := time.Since(startTime)
if err != nil {
ch <- OutputLineMsg(styleError.Render(fmt.Sprintf(" ✘ Failed (%s): %s", elapsed.Round(time.Millisecond), err.Error())))
errors = append(errors, step.Label+": "+err.Error())
} else {
ch <- OutputLineMsg(styleSuccess.Render(fmt.Sprintf(" ✔ Done (%s)", elapsed.Round(time.Millisecond))))
}
ch <- OutputLineMsg("")
}
// Final summary
if len(errors) > 0 {
ch <- OutputLineMsg(styleWarning.Render(fmt.Sprintf("═══ Completed with %d error(s) ═══", len(errors))))
for _, e := range errors {
ch <- OutputLineMsg(styleError.Render(" ✘ " + e))
}
ch <- DoneMsg{Err: fmt.Errorf("%d step(s) failed", len(errors))}
} else {
ch <- OutputLineMsg(styleSuccess.Render("═══ All steps completed successfully ═══"))
ch <- DoneMsg{}
}
}
// ── Progress Parsing ────────────────────────────────────────────────
// parseProgressLine extracts progress info from apt/pip/npm output.
func parseProgressLine(line string) string {
// apt progress: "Progress: [ 45%]" or percentage patterns
if strings.Contains(line, "Progress:") || strings.Contains(line, "progress:") {
return styleWarning.Render(strings.TrimSpace(line))
}
// pip: "Downloading foo-1.2.3.whl (2.3 MB)" or "Installing collected packages:"
if strings.HasPrefix(line, "Downloading ") || strings.HasPrefix(line, "Collecting ") {
return styleCyan.Render(strings.TrimSpace(line))
}
if strings.HasPrefix(line, "Installing collected packages:") {
return styleWarning.Render(strings.TrimSpace(line))
}
// npm: "added X packages"
if strings.Contains(line, "added") && strings.Contains(line, "packages") {
return styleSuccess.Render(strings.TrimSpace(line))
}
return ""
}
// isErrorLine checks if an output line looks like an error.
func isErrorLine(line string) bool {
lower := strings.ToLower(line)
return strings.Contains(lower, "error") ||
strings.Contains(lower, "failed") ||
strings.Contains(lower, "fatal") ||
strings.Contains(lower, "warning") ||
strings.Contains(lower, "unable to")
}
// ── Style for progress lines ────────────────────────────────────────
var styleCyan = styleKey // reuse existing cyan style