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. A bar whose pass stopped short // of its total, as an interrupted one does, is left as last drawn; the // library's Finish would fill it up. func (p *progress) finish() { if p == nil { return } if p.bar != nil { if p.total >= 0 && p.count < p.total { _ = p.bar.Exit() } else { _ = 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) }