162 lines
5.3 KiB
Go
162 lines
5.3 KiB
Go
|
|
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
|