Files
summercms/modules/bonfire/live.go
Jakub Zych d151a252c5 fix(bonfire): restore the terminal when a live view is interrupted
- while a view runs, runLive relays SIGINT and SIGTERM (unless ignored);
  on a signal it kills the program, which shows the cursor and restores
  the terminal, unregisters the view, stops relaying and raises the same
  signal again, so the default action or the command's own handler still
  applies
- the generated main, bonfire.Execute's context and Ctrl+C for commands
  without a live view are unchanged
- TestLiveViewRestoresTerminalOnSignal pins both paths in a re-executed
  helper process (WR-01)
2026-10-22 02:14:10 +02:00

487 lines
13 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(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())
}
// liveView is the running live program and the console that started it.
type liveView struct {
owner *console
prog *tea.Program
}
// 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; when another view holds it, runLive
// returns started false without 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.
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()
}
prog := tea.NewProgram(model,
tea.WithInput(nil),
tea.WithOutput(out),
tea.WithoutSignalHandler(),
tea.WithContext(ctx),
tea.WithColorProfile(c.profile()),
)
view := &liveView{owner: c, prog: prog}
liveMu.Lock()
if activeView != nil {
liveMu.Unlock()
return false, nil
}
activeView = view
liveMu.Unlock()
finished := make(chan struct{})
go func() {
defer close(finished)
_, _ = prog.Run()
}()
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
}
prog.Send(finishMsg{err: result})
<-finished
liveMu.Unlock()
stopSignals()
}()
err = work(prog.Send)
completed = true
return true, err
}
// 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.
func printAbove(owner *console, s string) bool {
liveMu.Lock()
defer liveMu.Unlock()
if activeView == nil || (owner != nil && activeView.owner != owner) {
return false
}
// One message for the whole block, sent while holding liveMu: blocks
// from different goroutines keep the order of their calls.
activeView.prog.Send(printLine(strings.TrimSuffix(s, "\n")))
return true
}