Files
summercms/modules/bonfire/live.go

547 lines
15 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), without bubbletea's signal handler and with the command's
// context. While a view runs, runLive relays SIGINT and SIGTERM only to
// restore the terminal before the signal takes its usual effect.
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"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(Sanitize(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(Sanitize(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] {
name := Sanitize(it.name)
switch it.state {
case itemWaiting:
b.WriteString(p.Muted.Render(" "+symWaiting) + " " + p.Muted.Render(name))
case itemRunning:
b.WriteString(" " + m.spin.View() + " " + name)
case itemDone:
b.WriteString(p.Success.Render(" "+symCheck) + " " + name)
case itemFailed:
b.WriteString(p.Error.Render(" "+symCross) + " " + name)
if it.err != nil {
b.WriteString(" " + p.Error.Render(Sanitize(it.err.Error())))
}
}
b.WriteString("\n")
}
total := len(m.items)
pct := 0.0
if total > 0 {
pct = float64(m.done) / float64(total)
}
op := Sanitize(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())
}
// liveView is the running live program and the console that started it.
type liveView struct {
owner *console
prog *tea.Program
// done is closed when the program's Run has returned; printAbove never
// sends to a program whose Run has ended, whatever ended it.
done <-chan struct{}
}
// The running live view, for PrintAbove and the owning console's output.
// Only one live view runs at a time; a widget that cannot claim the slot
// (a nested widget, or a second console at the same moment) prints plain
// lines.
var (
liveMu sync.Mutex
activeView *liveView
)
// errLivePanic is the result the final frame shows when work panicked.
var errLivePanic = errors.New("bonfire: live view work panicked")
// runLive runs model as a bubbletea program on out while work runs on the
// calling goroutine, with c as the owner of the view. It first claims the
// single live slot under liveMu and only then builds the program; when
// another view holds the slot, runLive returns started false without
// building a program or running work, and the caller prints plain lines
// instead. work gets a send function for progress messages. When
// work returns or panics, the view is unregistered (so PrintAbove stops
// sending to it), told to finish and waited for, all under liveMu; it
// never receives lines after that, and a panic goes on after the cleanup.
//
// The program does not take ctx: when ctx is cancelled, a watcher ends
// the view with its last frame and unregisters it in the same step under
// liveMu, without waiting for work, so lines printed after the
// cancellation print plainly below the view instead of reaching a stopped
// program. A program whose Run ended on its own is unregistered as well
// and is never sent lines.
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) (started bool, err error) {
if ctx == nil {
ctx = context.Background()
}
// Claim the slot before building the program: a widget that loses it
// builds none, so it registers no child context on ctx and opens no
// TEA_TRACE file. NewProgram takes no bonfire lock and prints nothing.
finished := make(chan struct{})
liveMu.Lock()
if activeView != nil {
liveMu.Unlock()
return false, nil
}
prog := tea.NewProgram(model,
tea.WithInput(nil),
tea.WithOutput(out),
tea.WithoutSignalHandler(),
tea.WithColorProfile(c.profile()),
)
view := &liveView{owner: c, prog: prog, done: finished}
activeView = view
liveMu.Unlock()
go func() {
defer close(finished)
_, _ = prog.Run()
}()
stopWatch := watchContext(ctx, view, finished)
stopSignals := watchSignals(view, finished)
completed := false
defer func() {
result := err
if !completed {
result = errLivePanic
}
// 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()
if activeView == view {
activeView = nil
}
// When the context watcher already finished the program, its Run
// has returned: Send returns at once and finished is closed.
prog.Send(finishMsg{err: result})
<-finished
liveMu.Unlock()
stopWatch()
stopSignals()
}()
err = work(prog.Send)
completed = true
return true, err
}
// watchContext ends view when ctx is cancelled before the program
// finishes: under liveMu it unregisters the view, sends the finish message
// with ctx's error and waits for the program, the same single step as
// runLive's cleanup, so a printer either reaches the running program or
// waits and prints plainly after the last frame. When the program finishes
// first (normal end, a signal, Run failing), the view is only unregistered.
// The returned function waits for the watcher; call it after finished is
// closed and without holding liveMu.
func watchContext(ctx context.Context, view *liveView, finished <-chan struct{}) (wait func()) {
exited := make(chan struct{})
go func() {
defer close(exited)
select {
case <-ctx.Done():
liveMu.Lock()
if activeView == view {
activeView = nil
}
view.prog.Send(finishMsg{err: ctx.Err()})
<-finished
liveMu.Unlock()
case <-finished:
liveMu.Lock()
if activeView == view {
activeView = nil
}
liveMu.Unlock()
}
}()
return func() { <-exited }
}
// watchSignals relays SIGINT and SIGTERM (those not ignored) while view
// runs. The views run without bubbletea's own signal handling, so without
// this a Ctrl+C would end the process with the cursor still hidden. On a
// signal the program is killed (bubbletea then restores the cursor and the
// terminal), the view is unregistered and the signal is raised again on
// the process with the relay stopped, so its default action, or the
// command's own handler, still applies. The returned function stops the
// relay when the view ends normally; a signal that arrived meanwhile is
// raised again too.
func watchSignals(view *liveView, finished <-chan struct{}) (stop func()) {
var relay []os.Signal
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
if !signal.Ignored(sig) {
relay = append(relay, sig)
}
}
if len(relay) == 0 {
return func() {}
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, relay...)
quit := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
select {
case sig := <-sigs:
view.prog.Kill()
<-finished
liveMu.Lock()
if activeView == view {
activeView = nil
}
liveMu.Unlock()
signal.Stop(sigs)
raise(sig)
case <-quit:
}
}()
return func() {
close(quit)
<-done
signal.Stop(sigs)
select {
case sig := <-sigs:
raise(sig)
default:
}
}
}
// raise sends sig to the own process; when that fails, it exits with 128
// plus the signal number, as a shell reports a death by that signal.
func raise(sig os.Signal) {
p, err := os.FindProcess(os.Getpid())
if err == nil {
err = p.Signal(sig)
}
if err != nil {
code := 1
if s, ok := sig.(syscall.Signal); ok {
code = 128 + int(s)
}
os.Exit(code)
}
}
// printAboveLines prints s, one or more lines, above the running live view,
// whichever console started it (bonfire.PrintAbove). It reports false when
// no view runs.
func printAboveLines(s string) bool {
return printAbove(nil, s)
}
// printAbove prints s above the running live view when owner is nil or is
// the console that started the view, and reports whether it did. A view
// whose program has ended (its context was cancelled, or Run returned on
// its own) is not running: printAbove unregisters it if it is still
// registered and reports false, so the caller prints s plainly.
func printAbove(owner *console, s string) bool {
liveMu.Lock()
defer liveMu.Unlock()
v := activeView
if v == nil || (owner != nil && v.owner != owner) {
return false
}
select {
case <-v.done:
activeView = nil
return false
default:
}
// One message for the whole block, sent while holding liveMu: blocks
// from different goroutines keep the order of their calls.
v.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
return true
}