Files
summercms/modules/bonfire/live.go
Jakub Zych b077dc3598 fix(bonfire): keep the order of lines printed above a live view and drain them before it ends
- each PrintAbove block is one bubbletea print message sent straight to
  the program's queue under liveMu, so the event loop prints it in call
  order instead of a command goroutine racing it back
- runLive unregisters, sends finishMsg and waits under liveMu, so a late
  printer writes after the final frame and no line is lost
- TestPrintAboveOrderedNoLoss pins order and no loss for spinner,
  progress, process and a printer racing the finish (CR-01)
2026-10-22 02:07:45 +02:00

382 lines
9.8 KiB
Go

package bonfire
// Live views ported from SSU's terminal UI (internal/cli/tui spinner.go,
// process.go and progress.go) to bubbletea v2, without SSU's git and
// engine types. Each runs as a bubbletea program with input off (stdin
// stays in cooked mode, so Ctrl+C reaches the command's own signal
// handling), no signal handler of its own and the command's context.
import (
"context"
"fmt"
"io"
"strings"
"sync"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// Progress bar colors: the cyan to green gradient of the earlier bonfire
// bar (256-color 37 and 34).
var (
barFrom = lipgloss.Color("#00afaf")
barTo = lipgloss.Color("#00af00")
)
const barWidth = 40
// Messages sent to the live models from the command's goroutine.
type (
// itemMsg reports progress: done of total, the current item, and a
// failure of the current item when err is set.
itemMsg struct {
done, total int
item string
err error
}
// stepMsg reports one item of the process view changing state.
stepMsg struct {
item string
state itemState
err error
done int
}
// finishMsg ends a live view; err is the result of the work.
finishMsg struct{ err error }
)
// printLine is the message that prints s, one or more lines, above the
// running view. It is bubbletea's own Println message, sent straight to
// the program's queue: the event loop prints it itself, in the order the
// messages arrive. Returning tea.Println as a command instead would run
// it on a goroutine of its own, so lines would race each other and a line
// still in flight when the view quits would be lost.
func printLine(s string) tea.Msg {
return tea.Println(s)()
}
// itemState is the display state of one item in the process view.
type itemState int
const (
itemWaiting itemState = iota
itemRunning
itemDone
itemFailed
)
const symWaiting = "○"
// newSpinner is SSU's MiniDot spinner in the current (cyan) style.
func newSpinner(p Palette) spinner.Model {
return spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(p.Current))
}
// newBar is the gradient progress bar without its own percentage.
func newBar() progress.Model {
bar := progress.New(progress.WithColors(barFrom, barTo), progress.WithoutPercentage())
bar.SetWidth(barWidth)
return bar
}
// spinnerModel shows a spinner, the label and, once known, a done/total
// counter with the current item; it ends with ✓ or ✗ and the label.
type spinnerModel struct {
palette Palette
spin spinner.Model
label string
done int
total int
current string
failed int
finished bool
err error
}
func newSpinnerModel(p Palette, label string) spinnerModel {
return spinnerModel{palette: p, spin: newSpinner(p), label: label}
}
func (m spinnerModel) Init() tea.Cmd { return m.spin.Tick }
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case itemMsg:
m.done, m.total, m.current = msg.done, msg.total, msg.item
if msg.err != nil {
m.failed++
}
return m, nil
case finishMsg:
m.finished, m.err = true, msg.err
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
return m, nil
}
func (m spinnerModel) View() tea.View {
p := m.palette
if m.finished {
if m.err != nil {
return tea.NewView(p.Error.Render(symCross) + " " + m.label + "\n")
}
return tea.NewView(p.Success.Render(symCheck) + " " + m.label + "\n")
}
line := m.spin.View() + " " + m.label
if m.total > 0 {
line += fmt.Sprintf(" %d/%d", m.done, m.total)
}
if m.current != "" {
line += " " + p.Muted.Render(m.current+"…")
}
if m.failed > 0 {
line += " " + p.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed))
}
return tea.NewView(line)
}
// progressModel shows the gradient bar, done/total and the current item.
type progressModel struct {
palette Palette
bar progress.Model
done int
total int
current string
failed int
finished bool
}
func newProgressModel(p Palette, total int) progressModel {
return progressModel{palette: p, bar: newBar(), total: total}
}
func (m progressModel) Init() tea.Cmd { return nil }
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.bar.SetWidth(min(max(msg.Width-30, 10), barWidth))
return m, nil
case itemMsg:
m.done, m.total = msg.done, msg.total
if msg.item != "" {
m.current = msg.item
}
if msg.err != nil {
m.failed++
}
return m, nil
case finishMsg:
m.finished = true
return m, tea.Quit
}
return m, nil
}
func (m progressModel) View() tea.View {
pct := 0.0
if m.total > 0 {
pct = float64(m.done) / float64(m.total)
}
line := " " + m.bar.ViewAs(pct) + m.palette.Muted.Render(fmt.Sprintf(" %d/%d", m.done, m.total))
if m.current != "" && !m.finished {
line += " " + m.palette.Muted.Render(m.current)
}
if m.failed > 0 {
line += " " + m.palette.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed))
}
if m.finished {
line += "\n"
}
return tea.NewView(line)
}
// processItem is one row of the process view.
type processItem struct {
name string
state itemState
err error
}
// processModel is SSU's multi-row view: one row per item (waiting, running
// with a spinner, done or failed with its error) and the bar below.
type processModel struct {
palette Palette
items []processItem
spin spinner.Model
bar progress.Model
operation string
done int
height int
finished bool
}
func newProcessModel(p Palette, operation string, items []string) processModel {
rows := make([]processItem, len(items))
for i, name := range items {
rows[i] = processItem{name: name}
}
return processModel{palette: p, items: rows, spin: newSpinner(p), bar: newBar(), operation: operation}
}
func (m processModel) Init() tea.Cmd { return m.spin.Tick }
func (m processModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
return m, nil
case stepMsg:
m.done = msg.done
for i := range m.items {
if m.items[i].name == msg.item && m.items[i].state < msg.state {
m.items[i].state, m.items[i].err = msg.state, msg.err
break
}
}
return m, nil
case finishMsg:
m.finished = true
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
return m, nil
}
// window is the range of rows that fits the terminal: around the running
// item, or the last finished ones.
func (m processModel) window() (int, int) {
visible := len(m.items)
if m.height > 0 && visible > m.height-3 {
visible = max(m.height-3, 3)
}
start := 0
if visible < len(m.items) {
active := -1
for i, it := range m.items {
if it.state == itemRunning {
active = i
break
}
}
if active >= 0 {
start = min(max(active-visible/2, 0), len(m.items)-visible)
} else {
start = max(m.done-visible, 0)
}
}
return start, min(start+visible, len(m.items))
}
func (m processModel) View() tea.View {
p := m.palette
var b strings.Builder
start, end := m.window()
for _, it := range m.items[start:end] {
switch it.state {
case itemWaiting:
b.WriteString(p.Muted.Render(" "+symWaiting) + " " + p.Muted.Render(it.name))
case itemRunning:
b.WriteString(" " + m.spin.View() + " " + it.name)
case itemDone:
b.WriteString(p.Success.Render(" "+symCheck) + " " + it.name)
case itemFailed:
b.WriteString(p.Error.Render(" "+symCross) + " " + it.name)
if it.err != nil {
b.WriteString(" " + p.Error.Render(it.err.Error()))
}
}
b.WriteString("\n")
}
total := len(m.items)
pct := 0.0
if total > 0 {
pct = float64(m.done) / float64(total)
}
op := m.operation
if op == "" {
op = "Processing"
}
b.WriteString(fmt.Sprintf("\n %s %d/%d %s %s", op, m.done, total, m.bar.ViewAs(pct), p.Muted.Render(fmt.Sprintf("%d%%", int(pct*100)))))
if m.finished {
b.WriteString("\n")
}
return tea.NewView(b.String())
}
// The running live program, for PrintAbove. Only one live view runs at a
// time; widgets started while one runs print plain lines.
var (
liveMu sync.Mutex
activeProg *tea.Program
)
// liveActive reports whether a live view is running.
func liveActive() bool {
liveMu.Lock()
defer liveMu.Unlock()
return activeProg != nil
}
// runLive runs model as a bubbletea program on out while work runs on the
// calling goroutine. work gets a send function for progress messages. When
// work returns, the program is unregistered (so PrintAbove stops sending to
// it), told to finish and waited for, all under liveMu; it never receives
// lines after that.
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) error {
if ctx == nil {
ctx = context.Background()
}
prog := tea.NewProgram(model,
tea.WithInput(nil),
tea.WithOutput(out),
tea.WithoutSignalHandler(),
tea.WithContext(ctx),
tea.WithColorProfile(c.profile()),
)
finished := make(chan struct{})
go func() {
defer close(finished)
_, _ = prog.Run()
}()
liveMu.Lock()
activeProg = prog
liveMu.Unlock()
err := work(prog.Send)
// Unregister, finish and wait as one step: a printer that arrives
// meanwhile waits for the lock and then writes after the final frame,
// so no line reaches a stopped program or races the last frame.
liveMu.Lock()
activeProg = nil
prog.Send(finishMsg{err: err})
<-finished
liveMu.Unlock()
return err
}
// printAboveLines prints s, one or more lines, above the running live view.
// It reports false when no view runs.
func printAboveLines(s string) bool {
liveMu.Lock()
defer liveMu.Unlock()
if activeProg == nil {
return false
}
// One message for the whole block, sent while holding liveMu: blocks
// from different goroutines keep the order of their calls.
activeProg.Send(printLine(strings.TrimSuffix(s, "\n")))
return true
}