Files
summercms/modules/bonfire/helpers.go
Jakub Zych 3f38862b22 feat(bonfire): SSU widgets on charm v2 behind the Output interface
- Spinner, gradient progress bar and the multi-row process view ported
  from SSU to bubbletea v2 (input off, no signal handler, command
  context); boxed tables through lipgloss/table
- Prompts use huh fields and a single-select port of SSU's selector with
  its confirmation screen on an interactive terminal; line prompts off a
  terminal and under --no-ansi are unchanged (piped passwords still work)
- -q hides decoration but keeps data and errors; -n returns defaults and
  Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY
  runs print today's plain lines
- Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed,
  Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted
- diary's styled console lines print above a running live view
2026-10-21 12:30:53 +02:00

143 lines
4.3 KiB
Go

package bonfire
import (
"context"
"errors"
tea "charm.land/bubbletea/v2"
)
// consoleState is the optional side of an Output that knows the running
// command's global options. The bonfire console implements it; other
// Output implementations give the zero values.
type consoleState interface {
consoleGlobals() Globals
consoleInteractive() bool
}
func (c *console) consoleGlobals() Globals { return c.globals }
func (c *console) consoleInteractive() bool { return c.canPrompt() }
// GlobalsOf returns the global options of the command out belongs to, or
// the zero Globals when out is not a bonfire console.
func GlobalsOf(out Output) Globals {
if s, ok := out.(consoleState); ok {
return s.consoleGlobals()
}
return Globals{}
}
// VerbosityOf returns the -v level (0 to 3) of the command out belongs to.
func VerbosityOf(out Output) int {
return GlobalsOf(out).Verbosity
}
// IsQuiet reports whether the command runs with -q.
func IsQuiet(out Output) bool {
return GlobalsOf(out).Quiet
}
// Interactive reports whether out can ask a person: stdin and stdout are
// terminals and neither -n nor -q is set. It is false for other Output
// implementations and inside bonfire.Call.
func Interactive(out Output) bool {
if s, ok := out.(consoleState); ok {
return s.consoleInteractive()
}
return false
}
// ConfirmProceed asks question before a destructive step, defaulting to
// no, only when out is Interactive. Off a terminal, with -n or -q and in
// bonfire.Call it returns true without asking, so deploys over SSH and
// scheduled runs never block (D-28).
func ConfirmProceed(out Output, question string) (bool, error) {
if !Interactive(out) {
return true, nil
}
return out.Confirm(question, false)
}
// Current tells p which item is being worked on, when p is an ItemProgress.
func Current(p Progress, item string) {
if ip, ok := p.(ItemProgress); ok {
ip.Current(item)
}
}
// Failed records a failed item on p, when p is an ItemProgress.
func Failed(p Progress, item string, err error) {
if ip, ok := p.(ItemProgress); ok {
ip.Fail(item, err)
}
}
// Process runs fn for each item in turn. On a live console it shows SSU's
// process view: one row per item (waiting, running, done or failed with its
// error) and a progress bar labelled operation. Otherwise it prints one line
// per item, "✓ item" through Output.Success or "✗ item: error" through
// Output.Error, so -q prints only the failures. It stops early when ctx is
// cancelled and returns errors.Join of the failures.
func Process(ctx context.Context, out Output, operation string, items []string, fn func(ctx context.Context, item string) error) error {
if ctx == nil {
ctx = context.Background()
}
if c, ok := out.(*console); ok && c.live() && len(items) > 0 {
var errs []error
_ = runLive(ctx, c.out, c, newProcessModel(c.palette, operation, items), func(send func(tea.Msg)) error {
for i, item := range items {
if err := ctx.Err(); err != nil {
errs = append(errs, err)
return nil
}
send(stepMsg{item: item, state: itemRunning, done: i})
if err := fn(ctx, item); err != nil {
errs = append(errs, failure(item, err))
send(stepMsg{item: item, state: itemFailed, err: err, done: i + 1})
continue
}
send(stepMsg{item: item, state: itemDone, done: i + 1})
}
return nil
})
return errors.Join(errs...)
}
var errs []error
for _, item := range items {
if err := ctx.Err(); err != nil {
errs = append(errs, err)
break
}
if err := fn(ctx, item); err != nil {
errs = append(errs, failure(item, err))
out.Error(failureLine(item, err))
continue
}
out.Success(item)
}
return errors.Join(errs...)
}
// failure wraps err with the item it belongs to.
func failure(item string, err error) error {
return &itemError{item: item, err: err}
}
type itemError struct {
item string
err error
}
func (e *itemError) Error() string { return failureLine(e.item, e.err) }
func (e *itemError) Unwrap() error { return e.err }
// PrintAbove prints line above the running live view (spinner, progress or
// process view) and reports whether one was running. diary's console
// channel uses it so log lines from other goroutines never tear a view; it
// writes directly when PrintAbove returns false.
func PrintAbove(line string) bool {
return printAboveLines(line)
}