mirror of
https://github.com/hauler-dev/hauler.git
synced 2026-08-19 20:36:30 +00:00
323 lines
9.2 KiB
Go
323 lines
9.2 KiB
Go
package log
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"hauler.dev/go/hauler/v2/pkg/consts"
|
|
)
|
|
|
|
// spinnerFrames is the braille-dot spinner animation, advanced roughly every
|
|
// spinnerInterval by the Renderer's ticker goroutine.
|
|
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
|
|
const spinnerInterval = 120 * time.Millisecond
|
|
|
|
// fallbackWidth is used when out is not an *os.File (e.g. tests writing to a
|
|
// *bytes.Buffer) or when the terminal width can't be determined.
|
|
const fallbackWidth = 80
|
|
|
|
// heightMargin is reserved off the raw detected terminal height before it is
|
|
// used as a row cap. Writing to a terminal's last row triggers auto-scroll,
|
|
// which invalidates eraseLocked's "move cursor up N-1 rows" math and
|
|
// corrupts scrollback -- load-bearing, not a tunable optimization. It does
|
|
// not help if the cursor was already near the bottom when hauler started.
|
|
const heightMargin = 1
|
|
|
|
// Renderer renders one live row per in-flight job to a terminal, erasing and
|
|
// redrawing the whole live region as jobs begin, finish, and log lines are
|
|
// emitted through it. A job's row disappears the moment it finishes, but
|
|
// whatever it already logged (e.g. a "✓ added ..." line) stays behind in
|
|
// permanent scrollback.
|
|
//
|
|
// Locking: mu guards both the terminal (out) and all status state
|
|
// (in-flight names, spinner frame, cached width/height, drawn row count).
|
|
// Compose as log.NewLogger(renderer) so NewLogger's zerolog.SyncWriter wraps
|
|
// the Renderer, giving strict nesting SyncWriter.mu -> Renderer.mu on the
|
|
// log-write path. The spinner ticker writes to the terminal via Renderer.mu
|
|
// directly, bypassing SyncWriter entirely, so Renderer.mu is what actually
|
|
// prevents a tick-driven redraw from interleaving with an in-progress
|
|
// log-line write. Wrapping a second SyncWriter around the real os.Stdout
|
|
// inside the Renderer would create an unordered second lock on the same fd.
|
|
type Renderer struct {
|
|
out io.Writer
|
|
|
|
mu sync.Mutex
|
|
started bool
|
|
stopped bool
|
|
inFlight []string
|
|
spinnerFrame int
|
|
width int
|
|
height int // cached effective terminal height; 0 == no cap
|
|
drawnRows int // rows currently occupying screen space from the last draw
|
|
|
|
done chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewRenderer returns a Renderer that writes to out.
|
|
func NewRenderer(out io.Writer) *Renderer {
|
|
return &Renderer{out: out}
|
|
}
|
|
|
|
// Start begins a new progress session and launches the spinner ticker
|
|
// goroutine. It caches the terminal width and height for the lifetime of the
|
|
// session (mid-run resize is out of scope). A call while a session is
|
|
// already live (started and not yet stopped) is a no-op: reassigning r.done
|
|
// here would orphan the still-running spinner goroutine on the old channel,
|
|
// which it holds in its own stack frame. Stop closes only the newest r.done,
|
|
// so that goroutine would never observe a close and r.wg.Wait() in Stop
|
|
// would block forever.
|
|
func (r *Renderer) Start() {
|
|
r.mu.Lock()
|
|
if r.started && !r.stopped {
|
|
r.mu.Unlock()
|
|
return
|
|
}
|
|
r.inFlight = nil
|
|
r.spinnerFrame = 0
|
|
r.width, r.height = r.detectSize()
|
|
r.drawnRows = 0
|
|
r.started = true
|
|
r.stopped = false
|
|
done := make(chan struct{})
|
|
r.done = done
|
|
r.mu.Unlock()
|
|
|
|
r.wg.Add(1)
|
|
go r.run(done)
|
|
}
|
|
|
|
// Began records name as in-flight and redraws the live region.
|
|
func (r *Renderer) Began(name string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.inFlight = append(r.inFlight, name)
|
|
r.redrawLocked()
|
|
}
|
|
|
|
// Finished removes name from the in-flight set and redraws the live region.
|
|
// Call it regardless of the job's success or failure so failed jobs are
|
|
// removed from the display too.
|
|
func (r *Renderer) Finished(name string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for i, n := range r.inFlight {
|
|
if n == name {
|
|
r.inFlight = append(r.inFlight[:i], r.inFlight[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
r.redrawLocked()
|
|
}
|
|
|
|
// Write implements io.Writer and is the sink log.NewLogger writes through.
|
|
// It erases the current live region, writes the log payload untouched, then
|
|
// redraws the region on the fresh line(s) left by the payload's trailing
|
|
// newline.
|
|
func (r *Renderer) Write(p []byte) (int, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.eraseLocked()
|
|
if _, err := r.out.Write(p); err != nil {
|
|
return 0, err
|
|
}
|
|
r.drawRowsLocked()
|
|
|
|
return len(p), nil
|
|
}
|
|
|
|
// Stop synchronously halts and joins the spinner goroutine, then erases the
|
|
// live region. Safe to call even if Start was never called; a single call is
|
|
// sufficient (double-Stop is not required to be safe).
|
|
func (r *Renderer) Stop() {
|
|
r.mu.Lock()
|
|
if !r.started || r.stopped {
|
|
r.mu.Unlock()
|
|
return
|
|
}
|
|
r.stopped = true
|
|
close(r.done)
|
|
r.mu.Unlock()
|
|
|
|
r.wg.Wait()
|
|
|
|
r.mu.Lock()
|
|
r.eraseLocked()
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// run advances the spinner frame and redraws roughly every spinnerInterval
|
|
// until done closes. done is passed in rather than read from r.done because
|
|
// r.done is only ever written under r.mu (by Start), and this goroutine
|
|
// otherwise has no synchronized way to observe it.
|
|
func (r *Renderer) run(done chan struct{}) {
|
|
defer r.wg.Done()
|
|
|
|
ticker := time.NewTicker(spinnerInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
r.mu.Lock()
|
|
r.spinnerFrame = (r.spinnerFrame + 1) % len(spinnerFrames)
|
|
r.redrawLocked()
|
|
r.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// eraseLocked erases the currently-drawn live region. Callers must hold mu.
|
|
func (r *Renderer) eraseLocked() {
|
|
if r.drawnRows == 0 {
|
|
return
|
|
}
|
|
if r.drawnRows > 1 {
|
|
fmt.Fprintf(r.out, "\x1b[%dA", r.drawnRows-1)
|
|
}
|
|
fmt.Fprint(r.out, "\r\x1b[0J")
|
|
r.drawnRows = 0
|
|
}
|
|
|
|
// drawRowsLocked writes the current set of rows (see buildRowsLocked).
|
|
// Callers must hold mu.
|
|
func (r *Renderer) drawRowsLocked() {
|
|
lines := r.buildRowsLocked()
|
|
if len(lines) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprint(r.out, strings.Join(lines, "\n"))
|
|
r.drawnRows = len(lines)
|
|
}
|
|
|
|
// redrawLocked is eraseLocked + drawRowsLocked, used by Began/Finished/the
|
|
// spinner tick; Write uses the two halves separately. Callers must hold mu.
|
|
func (r *Renderer) redrawLocked() {
|
|
r.eraseLocked()
|
|
r.drawRowsLocked()
|
|
}
|
|
|
|
// buildRowsLocked returns this frame's rows, truncated to width and capped
|
|
// to height (with a "+K more" summary row when capped). Callers must hold
|
|
// mu.
|
|
func (r *Renderer) buildRowsLocked() []string {
|
|
if len(r.inFlight) == 0 {
|
|
return nil
|
|
}
|
|
|
|
frame := spinnerFrames[r.spinnerFrame]
|
|
|
|
if r.height <= 0 || len(r.inFlight) <= r.height {
|
|
rows := make([]string, len(r.inFlight))
|
|
for i, ref := range r.inFlight {
|
|
rows[i] = r.formatRowLocked(frame, ref)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
shown := r.height - 1
|
|
rows := make([]string, 0, r.height)
|
|
for _, ref := range r.inFlight[:shown] {
|
|
rows = append(rows, r.formatRowLocked(frame, ref))
|
|
}
|
|
remaining := len(r.inFlight) - shown
|
|
rows = append(rows, fmt.Sprintf(" +%d more", remaining))
|
|
return rows
|
|
}
|
|
|
|
// ellipsis marks a truncated ref. It is a 3-byte UTF-8 character, so
|
|
// truncation math below sizes against len(ellipsis) rather than assuming
|
|
// one byte of budget.
|
|
const ellipsis = "…"
|
|
|
|
// levelWidth is the fixed width of zerolog.ConsoleWriter's rendered level
|
|
// field ("DBG", "INF", "WRN", "ERR", ...) -- always 3 characters.
|
|
const levelWidth = 3
|
|
|
|
// alignmentWidth pads a progress row to line up under the log *message*
|
|
// column rather than the timestamp; it mirrors one rendered log prefix
|
|
// (CustomTimeFormat + space + 3-char level + space) and must move if
|
|
// CustomTimeFormat's width changes.
|
|
var alignmentWidth = len(consts.CustomTimeFormat) + 1 + levelWidth + 1
|
|
|
|
// alignmentPrefix is alignmentWidth worth of an alternating dot-space guide
|
|
// pattern, precomputed once at package init to avoid per-frame allocation.
|
|
var alignmentPrefix = buildAlignmentPrefix()
|
|
|
|
func buildAlignmentPrefix() string {
|
|
var b strings.Builder
|
|
b.Grow(alignmentWidth)
|
|
for i := 0; i < alignmentWidth; i++ {
|
|
if i%2 == 0 {
|
|
b.WriteByte('.')
|
|
} else {
|
|
b.WriteByte(' ')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// formatRowLocked renders "<alignmentPrefix><frame> adding <ref>",
|
|
// truncating ref (never the fixed prefix) with a trailing ellipsis so the
|
|
// row fits within r.width. Callers must hold mu.
|
|
func (r *Renderer) formatRowLocked(frame, ref string) string {
|
|
prefix := alignmentPrefix + frame + " adding "
|
|
|
|
budget := r.width - len(prefix)
|
|
if budget < 1 {
|
|
budget = 1
|
|
}
|
|
if len(ref) > budget {
|
|
keep := budget - len(ellipsis)
|
|
if keep < 0 {
|
|
keep = 0
|
|
}
|
|
ref = ref[:keep] + ellipsis
|
|
}
|
|
|
|
return prefix + ref
|
|
}
|
|
|
|
// detectSize queries width/height via golang.org/x/term when out is an
|
|
// *os.File, else falls back to fallbackWidth/0 (e.g. *bytes.Buffer in
|
|
// tests, or a GetSize error). Height's fallback is deliberately 0 (no cap)
|
|
// rather than a guessed size, so an unknown height never artificially
|
|
// limits rows; see heightMargin for the one-row reduction applied to a
|
|
// detected height.
|
|
func (r *Renderer) detectSize() (width, height int) {
|
|
width = fallbackWidth
|
|
height = 0
|
|
|
|
f, ok := r.out.(*os.File)
|
|
if !ok {
|
|
return width, height
|
|
}
|
|
|
|
w, h, err := term.GetSize(int(f.Fd()))
|
|
if err != nil {
|
|
return width, height
|
|
}
|
|
if w > 0 {
|
|
width = w
|
|
}
|
|
if h > 0 {
|
|
if effective := h - heightMargin; effective >= 1 {
|
|
height = effective
|
|
}
|
|
// else: terminal too small after the margin; leave height at 0 (no
|
|
// cap) rather than a degenerate zero/negative-row region.
|
|
}
|
|
|
|
return width, height
|
|
}
|