check / check (push) Successful in 1m6s
When stderr is not a terminal, each phase prints its zero-state line as it starts instead of after its first item. stderrIsTTY uses term.IsTerminal from golang.org/x/term, now a direct dependency, so /dev/null is no longer taken for a terminal. A spinner keeps the library's background redraw, so its count and elapsed time stay current while a phase waits for its next item. A warning printed during a spinner phase goes through the bar (progressbar.Bprintln), which prints it before its next redraw instead of racing it. Bars with a total have no background redraw and still print warnings directly. Model: opus-5-5
190 lines
4.8 KiB
Go
190 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/schollz/progressbar/v3"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// plainInterval is the minimum time between progress lines when stderr
|
|
// is not a TTY.
|
|
const plainInterval = 5 * time.Second
|
|
|
|
// barThrottle caps how often the TTY progress bar redraws.
|
|
const barThrottle = 100 * time.Millisecond
|
|
|
|
// walkSpinnerType selects the progressbar library's spinner style used
|
|
// for the walk pass, which has no known total.
|
|
const walkSpinnerType = 14
|
|
|
|
// percentScale converts a fraction to a percentage.
|
|
const percentScale = 100
|
|
|
|
// stderrIsTTY reports whether stderr is attached to a terminal.
|
|
func stderrIsTTY() bool {
|
|
return term.IsTerminal(int(os.Stderr.Fd()))
|
|
}
|
|
|
|
// progress renders one scan pass's progress on stderr. On a TTY it
|
|
// delegates to the progressbar library (spinner style when the total is
|
|
// unknown, full bar with count/percent/rate/elapsed/ETA otherwise). When
|
|
// stderr is not a TTY it emits no ANSI redraws: it prints a plain
|
|
// one-line update as the pass starts, then no more often than every
|
|
// plainInterval.
|
|
//
|
|
// All methods must be called from the main goroutine only. On a TTY
|
|
// the library also redraws a spinner from its own goroutine, several
|
|
// times a second, so its count and elapsed time stay current while a
|
|
// pass waits for its next item. A nil *progress is a valid
|
|
// no-display receiver: every method is a no-op, so batched database
|
|
// flushes during the streaming pass can reuse the update-pass helpers
|
|
// without rendering anything.
|
|
type progress struct {
|
|
label string
|
|
total int64 // -1 when unknown (walk pass)
|
|
bar *progressbar.ProgressBar
|
|
count int64
|
|
start time.Time
|
|
last time.Time
|
|
}
|
|
|
|
func newProgress(label string, total int64) *progress {
|
|
p := &progress{label: label, total: total, start: time.Now()}
|
|
if stderrIsTTY() {
|
|
p.bar = newBar(label, total)
|
|
|
|
return p
|
|
}
|
|
|
|
// Print the zero state at once: the first item may take minutes,
|
|
// and a pass must never look hung.
|
|
p.last = p.start
|
|
fmt.Fprintln(os.Stderr, p.plainLine())
|
|
|
|
return p
|
|
}
|
|
|
|
// newBar builds the TTY display for newProgress.
|
|
func newBar(label string, total int64) *progressbar.ProgressBar {
|
|
opts := []progressbar.Option{
|
|
progressbar.OptionSetWriter(os.Stderr),
|
|
progressbar.OptionSetDescription(label),
|
|
progressbar.OptionShowCount(),
|
|
progressbar.OptionShowIts(),
|
|
progressbar.OptionSetItsString("files"),
|
|
progressbar.OptionSetElapsedTime(true),
|
|
progressbar.OptionThrottle(barThrottle),
|
|
// Render at zero immediately: a phase must be visible the
|
|
// moment it starts, even before its first item completes.
|
|
progressbar.OptionSetRenderBlankState(true),
|
|
}
|
|
if total >= 0 {
|
|
opts = append(opts,
|
|
progressbar.OptionSetPredictTime(true),
|
|
progressbar.OptionShowElapsedTimeOnFinish(),
|
|
)
|
|
} else {
|
|
opts = append(opts,
|
|
progressbar.OptionSetPredictTime(false),
|
|
progressbar.OptionSpinnerType(walkSpinnerType),
|
|
)
|
|
}
|
|
|
|
return progressbar.NewOptions64(total, opts...)
|
|
}
|
|
|
|
// increment records one completed item and refreshes the display.
|
|
func (p *progress) increment() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
p.count++
|
|
if p.bar != nil {
|
|
_ = p.bar.Add(1)
|
|
|
|
return
|
|
}
|
|
|
|
if time.Since(p.last) >= plainInterval {
|
|
p.last = time.Now()
|
|
fmt.Fprintln(os.Stderr, p.plainLine())
|
|
}
|
|
}
|
|
|
|
// warnf prints a one-line warning to stderr without corrupting the bar.
|
|
// The whole message is escaped like a report's path columns, so a path
|
|
// holding a newline cannot split the warning.
|
|
func (p *progress) warnf(format string, args ...any) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
msg := escapePath(fmt.Sprintf(format, args...))
|
|
|
|
if p.bar != nil && p.total < 0 {
|
|
// The library also redraws a spinner from its own goroutine, so
|
|
// a direct write could land inside a redraw. The bar prints the
|
|
// warning itself, just before its next redraw.
|
|
_, _ = progressbar.Bprintln(p.bar, msg)
|
|
|
|
return
|
|
}
|
|
|
|
if p.bar != nil {
|
|
_ = p.bar.Clear()
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, msg)
|
|
}
|
|
|
|
// finish terminates the pass's display.
|
|
func (p *progress) finish() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
if p.bar != nil {
|
|
_ = p.bar.Finish()
|
|
|
|
fmt.Fprintln(os.Stderr)
|
|
|
|
return
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, p.plainLine())
|
|
}
|
|
|
|
// plainLine formats a non-TTY progress line.
|
|
func (p *progress) plainLine() string {
|
|
elapsed := time.Since(p.start)
|
|
if p.total < 0 {
|
|
return fmt.Sprintf("%s: %d files, elapsed %s",
|
|
p.label, p.count, elapsed.Round(time.Second))
|
|
}
|
|
|
|
pct := float64(percentScale)
|
|
if p.total > 0 {
|
|
pct = percentScale * float64(p.count) / float64(p.total)
|
|
}
|
|
|
|
rate := 0.0
|
|
if s := elapsed.Seconds(); s > 0 {
|
|
rate = float64(p.count) / s
|
|
}
|
|
|
|
eta := "?"
|
|
|
|
if rate > 0 && p.count <= p.total {
|
|
remaining := time.Duration(float64(p.total-p.count) / rate * float64(time.Second))
|
|
eta = remaining.Round(time.Second).String()
|
|
}
|
|
|
|
return fmt.Sprintf("%s: [%d/%d] %.0f%% %.0f files/s elapsed %s eta %s",
|
|
p.label, p.count, p.total, pct, rate,
|
|
elapsed.Round(time.Second), eta)
|
|
}
|