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 := 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 }