- 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)
487 lines
13 KiB
Go
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
|
|
}
|