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
This commit is contained in:
@@ -27,8 +27,18 @@ Use cases:
|
||||
- Usage strings and argument-count checks derived from the declared arguments (`<name>` for required, `[name]` for optional).
|
||||
- Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`).
|
||||
- Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream).
|
||||
- Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal.
|
||||
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive.
|
||||
- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command. Log lines and output printed while a view runs appear above it (`bonfire.PrintAbove`).
|
||||
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm` and `bonfire.Output.Secret` use huh fields on an interactive terminal, and `bonfire.Output.Choice` an arrow-key selector with a confirmation screen. Off a terminal they read lines from stdin as before (a piped password still works), return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking. A cancelled prompt returns `bonfire.ErrAborted`. `bonfire.ConfirmProceed` asks before a destructive step only on an interactive terminal.
|
||||
- Degradation: every widget follows the terminal and the global options, and the `bonfire.Output` interface is unchanged for plugins:
|
||||
|
||||
| Run | Animation | Decoration | Data | Prompts |
|
||||
|-----|-----------|------------|------|---------|
|
||||
| Terminal | spinner, progress bar, process view | colors, symbols, boxed tables | printed | huh fields and the selector |
|
||||
| Not a terminal | none: `[...] message` and a line per tenth of progress | symbols, no colors unless `FORCE_COLOR` | printed, tables tab-separated | read a line from stdin; `Confirm` returns its default |
|
||||
| `-q` | none | info, success and warning lines hidden; errors kept | printed (`Println`, `Printf`, tables) | defaults, as with `-n` |
|
||||
| `--no-ansi` | none | symbols, no colors | printed | plain line prompts |
|
||||
| `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint |
|
||||
- Command state for helpers: `bonfire.GlobalsOf`, `bonfire.VerbosityOf`, `bonfire.IsQuiet` and `bonfire.Interactive` read the running command's global options from its `bonfire.Output`.
|
||||
- Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description.
|
||||
- Artisan-style command list: a bare binary (or `--help`, or `list`) prints a version header (`bonfire.Version.Header`, read from the build information by `bonfire.BuildVersion`), the usage line, the global options and the commands, top-level names first and then one block per namespace, with aligned descriptions.
|
||||
- Global options on every command: `-v`/`-vv`/`-vvv` (`--verbose`), `-q` (`--quiet`, implies `-n`), `--ansi`/`--no-ansi` (last wins), `-n` (`--no-interaction`) and `--env`. `bonfire.PeekGlobals` reads them from the raw arguments before the root exists, so the generated main can pick the configuration environment and the log verbosity before plugins boot; `bonfire.OnGlobals` hooks receive the parsed `bonfire.Globals` before every command runs (roots built by `bonfire.Call` run no hook).
|
||||
@@ -139,6 +149,17 @@ if catalog.Has("blog:import") {
|
||||
| `bonfire.Describe` | Builds the `bonfire.Listing` of a binary from its commands and `bonfire.Version`. |
|
||||
| `bonfire.CheckDocs` | Reports commands lacking a description, long help, examples or argument and option descriptions, as `bonfire.DocProblem` values. |
|
||||
| `bonfire.DocProblem` | One command's documentation gaps; `bonfire.DocProblem.String` reads "<command>: missing description, examples". |
|
||||
| `bonfire.ItemProgress` | The optional side of a `bonfire.Progress` that names the current item (`Current`) and records failures (`Fail`); `bonfire.Output.Progress` hands one over. |
|
||||
| `bonfire.Current` | Names the current item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. |
|
||||
| `bonfire.Failed` | Records a failed item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. |
|
||||
| `bonfire.Process` | Runs a function per item with the process view on a terminal, or one `✓`/`✗` line per item; returns the joined failures. |
|
||||
| `bonfire.PrintAbove` | Prints a line above the running live view and reports whether one was running. |
|
||||
| `bonfire.GlobalsOf` | The `bonfire.Globals` of the command an Output belongs to (zero for other Output implementations). |
|
||||
| `bonfire.VerbosityOf` | The `-v` level of the command an Output belongs to. |
|
||||
| `bonfire.IsQuiet` | Whether the command runs with `-q`. |
|
||||
| `bonfire.Interactive` | Whether the Output can ask a person: stdin and stdout are terminals and neither `-n` nor `-q` is set. |
|
||||
| `bonfire.ConfirmProceed` | Asks before a destructive step on an interactive terminal; returns true without asking otherwise. |
|
||||
| `bonfire.ErrAborted` | Returned by a prompt the person cancelled. |
|
||||
| `bonfire.ErrDuplicateCommand` | Returned by `bonfire.NewRoot` when two commands share a name or a command is named `list` or `help`. |
|
||||
| `bonfire.ErrReservedFlag` | Returned by `bonfire.NewRoot` when a command flag reuses a global option's name or shorthand. |
|
||||
| `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. |
|
||||
@@ -162,12 +183,12 @@ bonfire reads no config keys. Output color follows these environment variables:
|
||||
| `TERM` | The value `dumb` disables color. |
|
||||
| `FORCE_COLOR` | Any non-empty value enables color even when output is not a terminal (ignored when color is disabled by `NO_COLOR` or `TERM`). |
|
||||
|
||||
Without these variables, color is enabled only when stdout is a terminal.
|
||||
Without these variables, color is enabled only when stdout is a terminal. `--ansi` forces color and `--no-ansi` turns it off whatever the environment says.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: `sunscreen` (scrubs error messages).
|
||||
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
|
||||
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
|
||||
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
|
||||
|
||||
## Testing
|
||||
|
||||
142
modules/bonfire/helpers.go
Normal file
142
modules/bonfire/helpers.go
Normal file
@@ -0,0 +1,142 @@
|
||||
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)
|
||||
}
|
||||
387
modules/bonfire/live.go
Normal file
387
modules/bonfire/live.go
Normal file
@@ -0,0 +1,387 @@
|
||||
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), no signal handler of its own and the command's context.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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 }
|
||||
// printAboveMsg is a line to print above the view.
|
||||
printAboveMsg string
|
||||
)
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// liveModel wraps a view so lines from PrintAbove print above it.
|
||||
type liveModel struct {
|
||||
inner tea.Model
|
||||
}
|
||||
|
||||
func (m liveModel) Init() tea.Cmd { return m.inner.Init() }
|
||||
|
||||
func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if line, ok := msg.(printAboveMsg); ok {
|
||||
return m, tea.Println(string(line))
|
||||
}
|
||||
inner, cmd := m.inner.Update(msg)
|
||||
m.inner = inner
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m liveModel) View() tea.View { return m.inner.View() }
|
||||
|
||||
// The running live program, for PrintAbove. Only one live view runs at a
|
||||
// time; widgets started while one runs print plain lines.
|
||||
var (
|
||||
liveMu sync.Mutex
|
||||
activeProg *tea.Program
|
||||
)
|
||||
|
||||
// liveActive reports whether a live view is running.
|
||||
func liveActive() bool {
|
||||
liveMu.Lock()
|
||||
defer liveMu.Unlock()
|
||||
return activeProg != nil
|
||||
}
|
||||
|
||||
// runLive runs model as a bubbletea program on out while work runs on the
|
||||
// calling goroutine. work gets a send function for progress messages. When
|
||||
// work returns, the program is unregistered (so PrintAbove stops sending to
|
||||
// it), told to finish and waited for; it never receives lines after that.
|
||||
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
prog := tea.NewProgram(liveModel{inner: model},
|
||||
tea.WithInput(nil),
|
||||
tea.WithOutput(out),
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithContext(ctx),
|
||||
tea.WithColorProfile(c.profile()),
|
||||
)
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
_, _ = prog.Run()
|
||||
}()
|
||||
liveMu.Lock()
|
||||
activeProg = prog
|
||||
liveMu.Unlock()
|
||||
|
||||
err := work(prog.Send)
|
||||
|
||||
liveMu.Lock()
|
||||
activeProg = nil
|
||||
liveMu.Unlock()
|
||||
prog.Send(finishMsg{err: err})
|
||||
<-finished
|
||||
return err
|
||||
}
|
||||
|
||||
// printAboveLines prints s, one or more lines, above the running live view.
|
||||
// It reports false when no view runs.
|
||||
func printAboveLines(s string) bool {
|
||||
liveMu.Lock()
|
||||
defer liveMu.Unlock()
|
||||
if activeProg == nil {
|
||||
return false
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSuffix(s, "\n"), "\n") {
|
||||
activeProg.Send(printAboveMsg(line))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -4,18 +4,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
ansiReset = "\x1b[0m"
|
||||
ansiRed = "\x1b[31m"
|
||||
ansiGreen = "\x1b[32m"
|
||||
ansiYellow = "\x1b[33m"
|
||||
ansiCyan = "\x1b[36m"
|
||||
ansiDim = "\x1b[90m"
|
||||
|
||||
symCheck = "✓"
|
||||
symCross = "✗"
|
||||
symWarn = "⚠"
|
||||
@@ -48,16 +44,25 @@ type Progress interface {
|
||||
}
|
||||
|
||||
type console struct {
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
err io.Writer
|
||||
lookup func(string) string
|
||||
tty bool
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
err io.Writer
|
||||
lookup func(string) string
|
||||
// tty is true when stdout is a terminal; it picks animated widgets
|
||||
// and boxed tables.
|
||||
tty bool
|
||||
// interactive is true when stdin and stdout are terminals.
|
||||
interactive bool
|
||||
color bool
|
||||
// inTTY is true when stdin is a terminal; Secret then reads a hidden
|
||||
// value instead of a line.
|
||||
inTTY bool
|
||||
// color is the environment's color policy (NO_COLOR, TERM=dumb,
|
||||
// FORCE_COLOR, terminal) for ColorAuto.
|
||||
color bool
|
||||
// globals are the parsed global options of the running command, set
|
||||
// by the root before the command runs.
|
||||
globals Globals
|
||||
palette Palette
|
||||
}
|
||||
|
||||
// NewOutput builds Output from injected streams and the process color policy.
|
||||
@@ -78,16 +83,17 @@ func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, t
|
||||
if lookup == nil {
|
||||
lookup = os.Getenv
|
||||
}
|
||||
c := &console{in: in, out: out, err: errW, lookup: lookup}
|
||||
c := &console{in: in, out: out, err: errW, lookup: lookup, palette: DefaultPalette()}
|
||||
if tty != nil {
|
||||
c.tty = *tty
|
||||
} else {
|
||||
c.tty = isTerminal(out)
|
||||
}
|
||||
c.inTTY = isTerminal(in)
|
||||
if interactive != nil {
|
||||
c.interactive = *interactive
|
||||
} else {
|
||||
c.interactive = isTerminal(in) && c.tty
|
||||
c.interactive = c.inTTY && c.tty
|
||||
}
|
||||
c.color = supportsColor(lookup, c.tty)
|
||||
return c
|
||||
@@ -115,6 +121,67 @@ func isTerminal(rw any) bool {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// setGlobals records the parsed global options of the running command.
|
||||
func (c *console) setGlobals(g Globals) {
|
||||
c.globals = g
|
||||
}
|
||||
|
||||
// live reports whether widgets animate: stdout is a terminal and none of
|
||||
// -q, --no-ansi and -n is set (D-03). A live view that is already running
|
||||
// makes nested widgets print plain lines.
|
||||
func (c *console) live() bool {
|
||||
g := c.globals
|
||||
return c.tty && !g.Quiet && g.Color != ColorNever && !g.NoInteraction && !liveActive()
|
||||
}
|
||||
|
||||
// canPrompt reports whether prompts may wait for input from a person:
|
||||
// stdin and stdout are terminals and -n (or -q) is not set.
|
||||
func (c *console) canPrompt() bool {
|
||||
return c.interactive && !c.globals.NoInteraction
|
||||
}
|
||||
|
||||
// profile is the color profile of styled output: none for --no-ansi or
|
||||
// when the environment turns color off, at least 256 colors otherwise.
|
||||
func (c *console) profile() colorprofile.Profile {
|
||||
switch c.globals.Color {
|
||||
case ColorNever:
|
||||
return colorprofile.NoTTY
|
||||
case ColorAuto:
|
||||
if !c.color {
|
||||
return colorprofile.NoTTY
|
||||
}
|
||||
}
|
||||
return DetectProfile(c.out, ColorAlways, c.environ())
|
||||
}
|
||||
|
||||
// environ rebuilds the color-related environment from lookup.
|
||||
func (c *console) environ() []string {
|
||||
var env []string
|
||||
for _, k := range []string{"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "CLICOLOR", "CLICOLOR_FORCE"} {
|
||||
if v := c.lookup(k); v != "" {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// styled wraps w so palette styles follow the color profile.
|
||||
func (c *console) styled(w io.Writer) io.Writer {
|
||||
return &colorprofile.Writer{Forward: w, Profile: c.profile()}
|
||||
}
|
||||
|
||||
// emit writes s, a styled line or block, to w. While a live view runs on
|
||||
// stdout, lines for stdout print above it instead of tearing it.
|
||||
func (c *console) emit(w io.Writer, s string) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if w == c.out && printAboveLines(s) {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(c.styled(w), s)
|
||||
}
|
||||
|
||||
func (c *console) Write(p []byte) (int, error) {
|
||||
if c.out == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
@@ -126,22 +193,38 @@ func (c *console) Println(a ...any) {
|
||||
if c.out == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(c.out, a...)
|
||||
s := fmt.Sprintln(a...)
|
||||
if printAboveLines(s) {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(c.out, s)
|
||||
}
|
||||
|
||||
func (c *console) Printf(format string, a ...any) {
|
||||
if c.out == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(c.out, format, a...)
|
||||
s := fmt.Sprintf(format, a...)
|
||||
if strings.HasSuffix(s, "\n") && printAboveLines(s) {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(c.out, s)
|
||||
}
|
||||
|
||||
// status prints a status line unless -q is set.
|
||||
func (c *console) status(style lipgloss.Style, symbol, message string) {
|
||||
if c.globals.Quiet {
|
||||
return
|
||||
}
|
||||
c.emit(c.out, style.Render(symbol)+" "+message+"\n")
|
||||
}
|
||||
|
||||
func (c *console) Info(message string) {
|
||||
c.Println(c.style(ansiCyan, symInfo) + " " + message)
|
||||
c.status(c.palette.Current, symInfo, message)
|
||||
}
|
||||
|
||||
func (c *console) Success(message string) {
|
||||
c.Println(c.style(ansiGreen, symCheck) + " " + message)
|
||||
c.status(c.palette.Success, symCheck, message)
|
||||
}
|
||||
|
||||
func (c *console) Error(message string) {
|
||||
@@ -149,30 +232,11 @@ func (c *console) Error(message string) {
|
||||
if w == nil {
|
||||
w = c.out
|
||||
}
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, c.style(ansiRed, symCross)+" "+message)
|
||||
c.emit(w, c.palette.Error.Render(symCross)+" "+message+"\n")
|
||||
}
|
||||
|
||||
func (c *console) Warning(message string) {
|
||||
c.Println(c.style(ansiYellow, symWarn) + " " + message)
|
||||
}
|
||||
|
||||
func (c *console) style(code, s string) string {
|
||||
if !c.color {
|
||||
return s
|
||||
}
|
||||
return code + s + ansiReset
|
||||
}
|
||||
|
||||
func (c *console) dim(s string) string {
|
||||
return c.style(ansiDim, s)
|
||||
}
|
||||
|
||||
// setGlobals records the parsed global options of the running command.
|
||||
func (c *console) setGlobals(g Globals) {
|
||||
c.globals = g
|
||||
c.status(c.palette.Warning, symWarn, message)
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
@@ -2,16 +2,60 @@ package bonfire
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/huh/v2"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// ErrAborted is returned by a prompt that the person cancelled (esc or
|
||||
// ctrl+c on a terminal).
|
||||
var ErrAborted = errors.New("bonfire: prompt aborted")
|
||||
|
||||
// errSecretNoInteraction is the error of Secret under -n when stdin is a
|
||||
// terminal: a secret has no default.
|
||||
var errSecretNoInteraction = errors.New("bonfire: a secret is needed and -n turned prompts off")
|
||||
|
||||
// richPrompts reports whether prompts use huh and the selector: the run is
|
||||
// interactive and --no-ansi is not set (with --no-ansi a terminal gets the
|
||||
// plain line prompts).
|
||||
func (c *console) richPrompts() bool {
|
||||
return c.canPrompt() && c.globals.Color != ColorNever
|
||||
}
|
||||
|
||||
// runForm runs a huh form on the console's streams.
|
||||
func (c *console) runForm(field huh.Field) error {
|
||||
err := huh.NewForm(huh.NewGroup(field)).WithInput(c.in).WithOutput(c.out).Run()
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return ErrAborted
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *console) Ask(question, def string) (string, error) {
|
||||
if c.globals.NoInteraction {
|
||||
return def, nil
|
||||
}
|
||||
if c.richPrompts() {
|
||||
var answer string
|
||||
input := huh.NewInput().Title(question).Value(&answer)
|
||||
if def != "" {
|
||||
input = input.Placeholder(def)
|
||||
}
|
||||
if err := c.runForm(input); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(answer) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return strings.TrimSpace(answer), nil
|
||||
}
|
||||
c.writeQuestion(question, def)
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
@@ -27,9 +71,16 @@ func (c *console) Ask(question, def string) (string, error) {
|
||||
}
|
||||
|
||||
func (c *console) Confirm(question string, def bool) (bool, error) {
|
||||
if !c.interactive {
|
||||
if c.globals.NoInteraction || !c.interactive {
|
||||
return def, nil
|
||||
}
|
||||
if c.richPrompts() {
|
||||
answer := def
|
||||
if err := c.runForm(huh.NewConfirm().Title(question).Value(&answer)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
hint := "y/N"
|
||||
if def {
|
||||
hint = "Y/n"
|
||||
@@ -38,9 +89,6 @@ func (c *console) Confirm(question string, def bool) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if line == "" {
|
||||
return def, nil
|
||||
}
|
||||
switch strings.ToLower(line) {
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
@@ -58,7 +106,13 @@ func (c *console) Choice(question string, choices []string, def int) (string, er
|
||||
if def < 0 || def >= len(choices) {
|
||||
def = 0
|
||||
}
|
||||
c.Println(c.style(ansiCyan, symPrompt) + " " + question)
|
||||
if c.globals.NoInteraction {
|
||||
return choices[def], nil
|
||||
}
|
||||
if c.richPrompts() {
|
||||
return c.selectOne(question, choices, def)
|
||||
}
|
||||
c.Println(c.styledText(c.palette.Current.Render(symPrompt)) + " " + question)
|
||||
for i, choice := range choices {
|
||||
mark := " "
|
||||
if i == def {
|
||||
@@ -83,11 +137,47 @@ func (c *console) Choice(question string, choices []string, def int) (string, er
|
||||
return choices[n-1], nil
|
||||
}
|
||||
|
||||
// Secret reads a hidden password on a TTY via term.ReadPassword.
|
||||
// When stdin is not a terminal it reads a plain line and never logs the value.
|
||||
// selectOne runs the ported selector on the terminal.
|
||||
func (c *console) selectOne(question string, choices []string, def int) (string, error) {
|
||||
prog := tea.NewProgram(newSelectorModel(c.palette, question, choices, def),
|
||||
tea.WithInput(c.in),
|
||||
tea.WithOutput(c.out),
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithColorProfile(c.profile()),
|
||||
)
|
||||
final, err := prog.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m, ok := final.(selectorModel)
|
||||
if !ok || m.cancelled || m.chosen < 0 {
|
||||
return "", ErrAborted
|
||||
}
|
||||
return choices[m.chosen], nil
|
||||
}
|
||||
|
||||
// Secret reads a hidden value: through a password field on an interactive
|
||||
// terminal, through term.ReadPassword when only stdin is a terminal, and as
|
||||
// a plain line when stdin is not a terminal (piped input). Under -n a
|
||||
// terminal stdin fails with a hint, because a secret has no default.
|
||||
func (c *console) Secret(question string) (string, error) {
|
||||
if c.globals.NoInteraction {
|
||||
if c.inTTY {
|
||||
return "", WithHint(errSecretNoInteraction,
|
||||
"This command reads the value from a prompt or from stdin.",
|
||||
`printf '%s\n' "$VALUE" | {bin} <command> -n`)
|
||||
}
|
||||
return c.readSecretLine()
|
||||
}
|
||||
if c.richPrompts() {
|
||||
var answer string
|
||||
if err := c.runForm(huh.NewInput().Title(question).EchoMode(huh.EchoModePassword).Value(&answer)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
c.writeQuestion(question, "")
|
||||
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
|
||||
if f, ok := c.in.(*os.File); ok && c.inTTY {
|
||||
b, err := term.ReadPassword(int(f.Fd()))
|
||||
c.Println()
|
||||
if err != nil {
|
||||
@@ -95,6 +185,11 @@ func (c *console) Secret(question string) (string, error) {
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
return c.readSecretLine()
|
||||
}
|
||||
|
||||
// readSecretLine reads one line of piped input; the value is never logged.
|
||||
func (c *console) readSecretLine() (string, error) {
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
@@ -105,12 +200,19 @@ func (c *console) Secret(question string) (string, error) {
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// styledText applies the color profile to s for output written with Printf.
|
||||
func (c *console) styledText(s string) string {
|
||||
var b strings.Builder
|
||||
_, _ = io.WriteString(c.styled(&b), s)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c *console) writeQuestion(question, def string) {
|
||||
suffix := ""
|
||||
if def != "" {
|
||||
suffix = " " + c.dim("("+def+")")
|
||||
suffix = " " + c.palette.Muted.Render("("+def+")")
|
||||
}
|
||||
c.Printf("%s %s%s %s ", c.style(ansiCyan, symPrompt), question, suffix, symArrow)
|
||||
c.Printf("%s", c.styledText(c.palette.Current.Render(symPrompt)+" "+question+suffix+" "+symArrow+" "))
|
||||
}
|
||||
|
||||
func (c *console) readLine() (string, error) {
|
||||
|
||||
140
modules/bonfire/selector.go
Normal file
140
modules/bonfire/selector.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package bonfire
|
||||
|
||||
// A single-select port of SSU's selector (internal/cli/tui selector.go,
|
||||
// selector_keys.go and confirm.go) on bubbletea v2: arrow keys or k/j move,
|
||||
// enter selects and opens SSU's confirmation screen, enter there confirms
|
||||
// and esc goes back; esc or ctrl+c in the list cancels. Multi-select, the
|
||||
// filter and the detail pane are not ported (D-30).
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// selectorKeys are the selector's key bindings.
|
||||
type selectorKeys struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Confirm key.Binding
|
||||
Back key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultSelectorKeys() selectorKeys {
|
||||
return selectorKeys{
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
|
||||
Quit: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "cancel")),
|
||||
}
|
||||
}
|
||||
|
||||
// selectorModel picks one item from a list and asks for confirmation.
|
||||
type selectorModel struct {
|
||||
palette Palette
|
||||
keys selectorKeys
|
||||
title string
|
||||
items []string
|
||||
cursor int
|
||||
height int
|
||||
confirming bool
|
||||
chosen int
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
func newSelectorModel(p Palette, title string, items []string, def int) selectorModel {
|
||||
if def < 0 || def >= len(items) {
|
||||
def = 0
|
||||
}
|
||||
return selectorModel{palette: p, keys: defaultSelectorKeys(), title: title, items: items, cursor: def, chosen: -1}
|
||||
}
|
||||
|
||||
func (m selectorModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.confirming {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Confirm):
|
||||
m.chosen = m.cursor
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
m.confirming = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.cursor < len(m.items)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case key.Matches(msg, m.keys.Confirm):
|
||||
if len(m.items) > 0 {
|
||||
m.confirming = true
|
||||
}
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// visible is the range of items that fits the terminal around the cursor.
|
||||
func (m selectorModel) visible() (int, int) {
|
||||
rows := len(m.items)
|
||||
if m.height > 0 {
|
||||
rows = min(rows, max(m.height-4, 3))
|
||||
}
|
||||
start := 0
|
||||
if m.cursor >= rows {
|
||||
start = m.cursor - rows + 1
|
||||
}
|
||||
return start, min(start+rows, len(m.items))
|
||||
}
|
||||
|
||||
func (m selectorModel) View() tea.View {
|
||||
p := m.palette
|
||||
var b strings.Builder
|
||||
if m.chosen >= 0 || m.cancelled {
|
||||
// Leave one summary line once the program ends.
|
||||
b.WriteString(p.Current.Render(symPrompt) + " " + m.title)
|
||||
if m.chosen >= 0 {
|
||||
b.WriteString(" " + p.Success.Render(m.items[m.chosen]))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
if m.confirming {
|
||||
b.WriteString(p.Header.Render("Selected:") + "\n\n")
|
||||
b.WriteString(" " + m.items[m.cursor] + "\n\n")
|
||||
b.WriteString(p.Muted.Render("enter confirm • esc back"))
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
b.WriteString(p.Current.Render(symPrompt) + " " + p.Header.Render(m.title) + "\n")
|
||||
start, end := m.visible()
|
||||
for i := start; i < end; i++ {
|
||||
if i == m.cursor {
|
||||
b.WriteString(p.Current.Render("> ") + m.items[i] + "\n")
|
||||
continue
|
||||
}
|
||||
b.WriteString(" " + m.items[i] + "\n")
|
||||
}
|
||||
b.WriteString(p.Muted.Render("↑/k up • ↓/j down • enter select • esc cancel"))
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
@@ -1,85 +1,60 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
)
|
||||
|
||||
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
|
||||
|
||||
const (
|
||||
boxTopLeft = "┌"
|
||||
boxTopRight = "┐"
|
||||
boxBottomLeft = "└"
|
||||
boxBottomRight = "┘"
|
||||
boxHorizontal = "─"
|
||||
boxVertical = "│"
|
||||
boxTeeRight = "├"
|
||||
boxTeeLeft = "┤"
|
||||
boxTeeDown = "┬"
|
||||
boxTeeUp = "┴"
|
||||
boxCross = "┼"
|
||||
)
|
||||
// ItemProgress is the optional side of a Progress that knows about items:
|
||||
// Current names the item being worked on, Fail records a failed item. The
|
||||
// Progress handed to Output.Progress implements it; Current and Failed call
|
||||
// it when present.
|
||||
type ItemProgress interface {
|
||||
Progress
|
||||
Current(item string)
|
||||
Fail(item string, err error)
|
||||
}
|
||||
|
||||
func (c *console) Spinner(message string, fn func() error) error {
|
||||
if fn == nil {
|
||||
fn = func() error { return nil }
|
||||
}
|
||||
if !c.tty {
|
||||
if c.globals.Quiet {
|
||||
return fn()
|
||||
}
|
||||
if !c.live() {
|
||||
c.Printf("[...] %s\n", message)
|
||||
return fn()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
i := 0
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
c.Printf("\r%s %s", c.style(ansiCyan, string(spinnerFrames[0])), message)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
i++
|
||||
frame := spinnerFrames[i%len(spinnerFrames)]
|
||||
c.Printf("\r%s %s", c.style(ansiCyan, string(frame)), message)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := fn()
|
||||
close(done)
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
c.Printf("\r%s %s\n", c.style(ansiRed, symCross), message)
|
||||
return err
|
||||
}
|
||||
c.Printf("\r%s %s\n", c.style(ansiGreen, symCheck), message)
|
||||
return nil
|
||||
return runLive(context.Background(), c.out, c, newSpinnerModel(c.palette, message), func(func(tea.Msg)) error {
|
||||
return fn()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *console) Progress(total int, fn func(Progress) error) error {
|
||||
if total < 1 {
|
||||
total = 1
|
||||
}
|
||||
bar := &progressBar{c: c, total: total, lastPct: -1}
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
err := fn(bar)
|
||||
if c.tty && bar.current >= bar.total {
|
||||
c.Println()
|
||||
if c.globals.Quiet {
|
||||
return fn(&quietProgress{c: c})
|
||||
}
|
||||
return err
|
||||
if !c.live() {
|
||||
return fn(&progressBar{c: c, total: total, lastPct: -1})
|
||||
}
|
||||
return runLive(context.Background(), c.out, c, newProgressModel(c.palette, total), func(send func(tea.Msg)) error {
|
||||
return fn(&liveProgress{send: send, total: total})
|
||||
})
|
||||
}
|
||||
|
||||
// progressBar prints a line at every tenth of the work when widgets do not
|
||||
// animate.
|
||||
type progressBar struct {
|
||||
c *console
|
||||
total int
|
||||
@@ -99,10 +74,6 @@ func (p *progressBar) Advance(step int) {
|
||||
if p.total > 0 {
|
||||
pct = p.current * 100 / p.total
|
||||
}
|
||||
if p.c.tty {
|
||||
p.renderTTY(pct)
|
||||
return
|
||||
}
|
||||
if p.lastPct >= 0 && pct/10 == p.lastPct/10 && pct != 100 {
|
||||
return
|
||||
}
|
||||
@@ -116,29 +87,56 @@ func (p *progressBar) Advance(step int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progressBar) renderTTY(pct int) {
|
||||
width := 40
|
||||
filled := 0
|
||||
if p.total > 0 {
|
||||
filled = p.current * width / p.total
|
||||
}
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat(" ", width-filled)
|
||||
color := progressColor(pct)
|
||||
p.c.Printf("\r %s %5.1f%% %d/%d", p.c.style(color, bar), float64(pct), p.current, p.total)
|
||||
func (p *progressBar) Current(string) {}
|
||||
|
||||
func (p *progressBar) Fail(item string, err error) {
|
||||
p.c.Error(failureLine(item, err))
|
||||
}
|
||||
|
||||
func progressColor(pct int) string {
|
||||
switch {
|
||||
case pct >= 100:
|
||||
return "\x1b[38;5;34m"
|
||||
case pct >= 50:
|
||||
return "\x1b[38;5;36m"
|
||||
default:
|
||||
return "\x1b[38;5;37m"
|
||||
// quietProgress prints nothing but failures (-q).
|
||||
type quietProgress struct {
|
||||
c *console
|
||||
}
|
||||
|
||||
func (p *quietProgress) Advance(int) {}
|
||||
|
||||
func (p *quietProgress) Current(string) {}
|
||||
|
||||
func (p *quietProgress) Fail(item string, err error) {
|
||||
p.c.Error(failureLine(item, err))
|
||||
}
|
||||
|
||||
// liveProgress drives the live progress model.
|
||||
type liveProgress struct {
|
||||
send func(tea.Msg)
|
||||
total int
|
||||
current int
|
||||
item string
|
||||
}
|
||||
|
||||
func (p *liveProgress) Advance(step int) {
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
p.current = min(p.current+step, p.total)
|
||||
p.send(itemMsg{done: p.current, total: p.total, item: p.item})
|
||||
}
|
||||
|
||||
func (p *liveProgress) Current(item string) {
|
||||
p.item = item
|
||||
p.send(itemMsg{done: p.current, total: p.total, item: item})
|
||||
}
|
||||
|
||||
func (p *liveProgress) Fail(item string, err error) {
|
||||
p.send(itemMsg{done: p.current, total: p.total, item: item, err: err})
|
||||
p.send(printAboveMsg(failureLine(item, err)))
|
||||
}
|
||||
|
||||
func failureLine(item string, err error) string {
|
||||
if err == nil {
|
||||
return item
|
||||
}
|
||||
return item + ": " + err.Error()
|
||||
}
|
||||
|
||||
func (c *console) Table(headers []string, rows [][]string) {
|
||||
@@ -149,27 +147,22 @@ func (c *console) Table(headers []string, rows [][]string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
cols := len(headers)
|
||||
all := make([][]string, 0, 1+len(rows))
|
||||
all = append(all, headers)
|
||||
padded := make([][]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
all = append(all, padRow(row, cols))
|
||||
padded = append(padded, padRow(row, len(headers)))
|
||||
}
|
||||
widths := make([]int, cols)
|
||||
for _, row := range all {
|
||||
for i, cell := range row {
|
||||
if n := utf8.RuneCountInString(cell); n > widths[i] {
|
||||
widths[i] = n
|
||||
header := c.palette.Header
|
||||
t := table.New().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
Headers(headers...).
|
||||
Rows(padded...).
|
||||
StyleFunc(func(row, _ int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
return header.Padding(0, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Println(boxLine(widths, boxTopLeft, boxTeeDown, boxTopRight))
|
||||
c.Println(boxRow(headers, widths, true, c))
|
||||
c.Println(boxLine(widths, boxTeeRight, boxCross, boxTeeLeft))
|
||||
for _, row := range rows {
|
||||
c.Println(boxRow(padRow(row, cols), widths, false, c))
|
||||
}
|
||||
c.Println(boxLine(widths, boxBottomLeft, boxTeeUp, boxBottomRight))
|
||||
return lipgloss.NewStyle().Padding(0, 1)
|
||||
})
|
||||
c.emit(c.out, t.String()+"\n")
|
||||
}
|
||||
|
||||
func padRow(row []string, n int) []string {
|
||||
@@ -181,30 +174,3 @@ func padRow(row []string, n int) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boxLine(widths []int, left, mid, right string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(left)
|
||||
for i, w := range widths {
|
||||
if i > 0 {
|
||||
b.WriteString(mid)
|
||||
}
|
||||
b.WriteString(strings.Repeat(boxHorizontal, w+2))
|
||||
}
|
||||
b.WriteString(right)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func boxRow(cells []string, widths []int, header bool, c *console) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(boxVertical)
|
||||
for i, cell := range cells {
|
||||
pad := widths[i] - utf8.RuneCountInString(cell)
|
||||
text := cell
|
||||
if header {
|
||||
text = c.style("\x1b[1m", cell)
|
||||
}
|
||||
fmt.Fprintf(&b, " %s%s %s", text, strings.Repeat(" ", pad), boxVertical)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
184
modules/bonfire/widgets_smoke_test.go
Normal file
184
modules/bonfire/widgets_smoke_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// frame is a model's view without escape codes.
|
||||
func frame(m tea.Model) string { return ansi.Strip(m.View().Content) }
|
||||
|
||||
// TestWidgetMatrixSmoke: -q keeps data and errors but drops decoration, -n
|
||||
// makes prompts return their defaults without reading stdin (Secret with a
|
||||
// terminal stdin fails with a hint), ConfirmProceed never asks off a
|
||||
// terminal, and the ported live models render frames from Update and View.
|
||||
func TestWidgetMatrixSmoke(t *testing.T) {
|
||||
t.Run("quiet", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
c := newConsole(strings.NewReader(""), &stdout, &stderr, func(string) string { return "" }, boolPtr(true), boolPtr(false))
|
||||
c.setGlobals(Globals{Quiet: true, NoInteraction: true})
|
||||
c.Info("info")
|
||||
c.Success("success")
|
||||
c.Warning("warning")
|
||||
if err := c.Spinner("spinning", func() error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Progress(4, func(p Progress) error {
|
||||
for range 4 {
|
||||
p.Advance(1)
|
||||
}
|
||||
Failed(p, "item-2", errors.New("broken"))
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Println("data line")
|
||||
c.Table([]string{"key"}, [][]string{{"value"}})
|
||||
c.Error("boom")
|
||||
out := stdout.String()
|
||||
for _, gone := range []string{"info", "success", "warning", "spinning", "[4/4]"} {
|
||||
if strings.Contains(out, gone) {
|
||||
t.Fatalf("-q printed %q:\n%s", gone, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "data line\n") || !strings.Contains(out, "value") {
|
||||
t.Fatalf("-q dropped data:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "boom") || !strings.Contains(stderr.String(), "item-2: broken") {
|
||||
t.Fatalf("-q dropped errors: %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-interaction", func(t *testing.T) {
|
||||
in := strings.NewReader("typed\n")
|
||||
var buf bytes.Buffer
|
||||
c := newConsole(in, &buf, &buf, func(string) string { return "" }, boolPtr(true), boolPtr(true))
|
||||
c.setGlobals(Globals{NoInteraction: true})
|
||||
if got, err := c.Ask("name", "bob"); err != nil || got != "bob" {
|
||||
t.Fatalf("Ask = %q, %v", got, err)
|
||||
}
|
||||
if got, err := c.Confirm("sure?", true); err != nil || !got {
|
||||
t.Fatalf("Confirm = %v, %v", got, err)
|
||||
}
|
||||
if got, err := c.Choice("pick", []string{"a", "b", "c"}, 2); err != nil || got != "c" {
|
||||
t.Fatalf("Choice = %q, %v", got, err)
|
||||
}
|
||||
if in.Len() != len("typed\n") || buf.Len() != 0 {
|
||||
t.Fatalf("-n prompts read stdin or printed: left %d, out %q", in.Len(), buf.String())
|
||||
}
|
||||
if ok, err := ConfirmProceed(c, "Drop everything?"); err != nil || !ok {
|
||||
t.Fatalf("ConfirmProceed = %v, %v", ok, err)
|
||||
}
|
||||
if Interactive(c) || !GlobalsOf(c).NoInteraction {
|
||||
t.Fatal("console with -n reports interactive")
|
||||
}
|
||||
|
||||
c.inTTY = true
|
||||
_, err := c.Secret("password")
|
||||
var hint *HintError
|
||||
if !errors.As(err, &hint) || !strings.Contains(hint.Try, "-n") {
|
||||
t.Fatalf("Secret with a terminal stdin under -n: %v", err)
|
||||
}
|
||||
c.inTTY = false
|
||||
if got, err := c.Secret("password"); err != nil || got != "typed" {
|
||||
t.Fatalf("Secret with piped stdin under -n = %q, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not interactive", func(t *testing.T) {
|
||||
in := strings.NewReader("y\n")
|
||||
var buf bytes.Buffer
|
||||
out := NewOutput(in, &buf, &buf)
|
||||
if ok, err := ConfirmProceed(out, "Drop everything?"); err != nil || !ok || in.Len() != 2 {
|
||||
t.Fatalf("ConfirmProceed off a terminal = %v, %v (stdin left %d)", ok, err, in.Len())
|
||||
}
|
||||
if VerbosityOf(out) != 0 || IsQuiet(out) {
|
||||
t.Fatal("zero globals expected")
|
||||
}
|
||||
var lines bytes.Buffer
|
||||
errOut := NewOutput(strings.NewReader(""), &lines, &lines)
|
||||
err := Process(context.Background(), errOut, "Importing", []string{"a", "b"}, func(_ context.Context, item string) error {
|
||||
if item == "b" {
|
||||
return errors.New("bad row")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "b: bad row") {
|
||||
t.Fatalf("Process err = %v", err)
|
||||
}
|
||||
if got := lines.String(); got != "✓ a\n✗ b: bad row\n" {
|
||||
t.Fatalf("Process lines = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
size := tea.WindowSizeMsg{Width: 80, Height: 24}
|
||||
p := DefaultPalette()
|
||||
|
||||
t.Run("spinner model", func(t *testing.T) {
|
||||
var m tea.Model = newSpinnerModel(p, "Importing posts")
|
||||
m, _ = m.Update(size)
|
||||
m, _ = m.Update(itemMsg{done: 2, total: 5, item: "post-2"})
|
||||
if v := frame(m); !strings.Contains(v, "Importing posts 2/5") || !strings.Contains(v, "post-2") {
|
||||
t.Fatalf("spinner frame = %q", v)
|
||||
}
|
||||
m, cmd := m.Update(finishMsg{})
|
||||
if cmd == nil || !strings.Contains(frame(m), symCheck+" Importing posts") {
|
||||
t.Fatalf("spinner final frame = %q", frame(m))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("progress model", func(t *testing.T) {
|
||||
var m tea.Model = newProgressModel(p, 10)
|
||||
m, _ = m.Update(size)
|
||||
m, _ = m.Update(itemMsg{done: 4, total: 10, item: "row 4"})
|
||||
if v := frame(m); !strings.Contains(v, "4/10") || !strings.Contains(v, "row 4") {
|
||||
t.Fatalf("progress frame = %q", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("process model", func(t *testing.T) {
|
||||
var m tea.Model = newProcessModel(p, "Pushing", []string{"blog", "shop", "forum"})
|
||||
m, _ = m.Update(size)
|
||||
m, _ = m.Update(stepMsg{item: "blog", state: itemDone, done: 1})
|
||||
m, _ = m.Update(stepMsg{item: "shop", state: itemFailed, err: errors.New("rejected"), done: 2})
|
||||
m, _ = m.Update(stepMsg{item: "forum", state: itemRunning, done: 2})
|
||||
v := frame(m)
|
||||
for _, want := range []string{symCheck + " blog", symCross + " shop", "rejected", "forum", "Pushing 2/3"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Fatalf("process frame missing %q:\n%s", want, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("selector model", func(t *testing.T) {
|
||||
var m tea.Model = newSelectorModel(p, "Pick a plugin", []string{"blog", "shop", "forum"}, 0)
|
||||
m, _ = m.Update(size)
|
||||
if v := frame(m); !strings.Contains(v, "> blog") {
|
||||
t.Fatalf("selector frame = %q", v)
|
||||
}
|
||||
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if v := frame(m); !strings.Contains(v, "Selected:") || !strings.Contains(v, "shop") {
|
||||
t.Fatalf("confirmation frame = %q", v)
|
||||
}
|
||||
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
||||
m, _ = m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
|
||||
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
sel := m.(selectorModel)
|
||||
if cmd == nil || sel.chosen != 2 || sel.items[sel.chosen] != "forum" {
|
||||
t.Fatalf("selector chose %d (cmd %v)", sel.chosen, cmd != nil)
|
||||
}
|
||||
var c tea.Model = newSelectorModel(p, "Pick", []string{"a"}, 0)
|
||||
c, _ = c.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
||||
if !c.(selectorModel).cancelled {
|
||||
t.Fatal("esc did not cancel")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -29,7 +29,7 @@ Use cases:
|
||||
- Component levels: `log.components` sets a minimum level per component (`river: warn`, `blog: debug`); components without one use `log.level`.
|
||||
- Environment overrides: `SUMMER_LOG__` variables overlay the defaults key by key, and string values parse where YAML gives numbers or lists (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily`).
|
||||
- Validation: an unknown driver, format or level, a stack naming a missing channel or containing itself, and a relative daily path that leaves the application root fail `diary.Setup` with an error naming the key.
|
||||
- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences.
|
||||
- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences. While a bonfire live view (spinner, progress bar, process view) runs, styled lines print above it through `bonfire.PrintAbove`.
|
||||
- Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days (only files of that name pattern). A relative path resolves against the application root. A directory that cannot be written logs one warning from component `diary` on the console and disables the channel; it never fails the start-up.
|
||||
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
|
||||
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.
|
||||
|
||||
@@ -39,7 +39,8 @@ type ConsoleOptions struct {
|
||||
// as dotted key prefixes. Control characters other than tab are removed from
|
||||
// the message and attribute values, so logged data cannot inject terminal
|
||||
// escape sequences. Each line is written with one Write call, serialized
|
||||
// across the handler and its WithAttrs and WithGroup clones.
|
||||
// across the handler and its WithAttrs and WithGroup clones; while a bonfire
|
||||
// live view runs, lines print above it through bonfire.PrintAbove.
|
||||
func NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler {
|
||||
source := new(atomic.Bool)
|
||||
source.Store(opts.AddSource)
|
||||
@@ -154,6 +155,11 @@ func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
b.WriteString(p.Muted.Render(filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line)))
|
||||
}
|
||||
}
|
||||
// While a bonfire live view (spinner, progress, process view) runs,
|
||||
// the line prints above it instead of tearing it.
|
||||
if bonfire.PrintAbove(b.String()) {
|
||||
return nil
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
|
||||
h.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user