shithub: furgit

Download patch

ref: c75a034d25ca87f3d209a8e82c743b8a7e96573b
parent: 043449fe7dc8e5608920889a83d35444f9b2bffb
author: Runxi Yu <me@runxiyu.org>
date: Sun Mar 8 10:15:38 EDT 2026

internal/progress: Add progress meter

--- /dev/null
+++ b/internal/progress/constants.go
@@ -1,0 +1,11 @@
+package progress
+
+import "time"
+
+const (
+	// DefaultDelay is the default delayed-progress interval.
+	DefaultDelay = time.Second
+
+	updateInterval     = time.Second
+	throughputInterval = 500 * time.Millisecond
+)
--- /dev/null
+++ b/internal/progress/consume.go
@@ -1,0 +1,15 @@
+package progress
+
+import "time"
+
+func (meter *Meter) consumeUpdateTick(now time.Time) bool {
+	if now.Before(meter.nextUpdateAt) {
+		return false
+	}
+
+	for !now.Before(meter.nextUpdateAt) {
+		meter.nextUpdateAt = meter.nextUpdateAt.Add(updateInterval)
+	}
+
+	return true
+}
--- /dev/null
+++ b/internal/progress/counters.go
@@ -1,0 +1,14 @@
+package progress
+
+import "fmt"
+
+func (meter *Meter) renderCounters() string {
+	if meter.total > 0 {
+		percent := int(meter.lastDone * 100 / meter.total)
+		meter.lastPercent = percent
+
+		return fmt.Sprintf("%3d%% (%d/%d)%s", percent, meter.lastDone, meter.total, meter.throughputSuffix)
+	}
+
+	return fmt.Sprintf("%d%s", meter.lastDone, meter.throughputSuffix)
+}
--- /dev/null
+++ b/internal/progress/humanize.go
@@ -1,0 +1,21 @@
+package progress
+
+import "fmt"
+
+func humanizeBytes(n uint64) string {
+	const unit = 1024
+	if n < unit {
+		return fmt.Sprintf("%d B", n)
+	}
+
+	value := float64(n)
+	units := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
+	for i := 0; i < len(units); i++ {
+		value /= unit
+		if value < unit || i == len(units)-1 {
+			return fmt.Sprintf("%.2f %s", value, units[i])
+		}
+	}
+
+	return fmt.Sprintf("%d B", n)
+}
--- /dev/null
+++ b/internal/progress/meter.go
@@ -1,0 +1,30 @@
+package progress
+
+import (
+	"io"
+	"time"
+)
+
+// Meter renders one in-place progress line.
+type Meter struct {
+	writer io.Writer
+	flush  func() error
+
+	title      string
+	total      uint64
+	delay      time.Duration
+	sparse     bool
+	throughput bool
+
+	startedAt      time.Time
+	nextUpdateAt   time.Time
+	nextThroughput time.Time
+
+	lastDone     uint64
+	lastBytes    uint64
+	lastPercent  int
+	lastCounterW int
+	sawValue     bool
+
+	throughputSuffix string
+}
--- /dev/null
+++ b/internal/progress/new.go
@@ -1,0 +1,22 @@
+package progress
+
+import "time"
+
+// New creates one progress meter.
+func New(opts Options) *Meter {
+	now := time.Now()
+
+	return &Meter{
+		writer:         opts.Writer,
+		flush:          opts.Flush,
+		title:          opts.Title,
+		total:          opts.Total,
+		delay:          max(opts.Delay, time.Duration(0)),
+		sparse:         opts.Sparse,
+		throughput:     opts.Throughput,
+		startedAt:      now,
+		nextUpdateAt:   now.Add(updateInterval),
+		nextThroughput: now.Add(throughputInterval),
+		lastPercent:    -1,
+	}
+}
--- /dev/null
+++ b/internal/progress/options.go
@@ -1,0 +1,22 @@
+package progress
+
+import (
+	"io"
+	"time"
+)
+
+// Options configures one progress meter.
+type Options struct {
+	Writer io.Writer
+	Flush  func() error
+
+	Title string
+	Total uint64
+
+	// Delay suppresses progress output until Delay has elapsed since Start.
+	Delay time.Duration
+	// Sparse forces one final 100% line at Stop when the caller sampled updates.
+	Sparse bool
+	// Throughput appends ", <total> | <rate>/s" and refreshes rate every 500ms.
+	Throughput bool
+}
--- /dev/null
+++ b/internal/progress/refresh.go
@@ -1,0 +1,25 @@
+package progress
+
+import "time"
+
+func (meter *Meter) refreshThroughput(now time.Time) {
+	if !meter.throughput {
+		return
+	}
+
+	if meter.nextThroughput.After(now) && meter.throughputSuffix != "" {
+		return
+	}
+
+	for !now.Before(meter.nextThroughput) {
+		meter.nextThroughput = meter.nextThroughput.Add(throughputInterval)
+	}
+
+	elapsed := now.Sub(meter.startedAt)
+	if elapsed <= 0 {
+		return
+	}
+
+	rate := uint64(float64(meter.lastBytes) / elapsed.Seconds())
+	meter.throughputSuffix = ", " + humanizeBytes(meter.lastBytes) + " | " + humanizeBytes(rate) + "/s"
+}
--- /dev/null
+++ b/internal/progress/render.go
@@ -1,0 +1,34 @@
+package progress
+
+import (
+	"strings"
+	"time"
+
+	"codeberg.org/lindenii/furgit/internal/utils"
+)
+
+func (meter *Meter) render(now time.Time, eol string) {
+	if meter.delay > 0 && now.Sub(meter.startedAt) < meter.delay && eol == "\r" {
+		return
+	}
+
+	meter.refreshThroughput(now)
+
+	counters := meter.renderCounters()
+	clear := 0
+	if len(counters) < meter.lastCounterW {
+		clear = meter.lastCounterW - len(counters) + 1
+	}
+	meter.lastCounterW = len(counters)
+
+	line := meter.title + ": " + counters
+	if clear > 0 {
+		line += strings.Repeat(" ", clear)
+	}
+	line += eol
+
+	utils.BestEffortFprintf(meter.writer, "%s", line)
+	if meter.flush != nil {
+		_ = meter.flush()
+	}
+}
--- /dev/null
+++ b/internal/progress/set.go
@@ -1,0 +1,30 @@
+package progress
+
+import "time"
+
+// Set records current progress and renders when percent changed or the 1s tick
+// elapsed.
+func (meter *Meter) Set(done uint64, bytes uint64) {
+	meter.lastDone = done
+	meter.lastBytes = bytes
+	meter.sawValue = true
+
+	if meter.writer == nil {
+		return
+	}
+
+	now := time.Now()
+	forced := meter.consumeUpdateTick(now)
+
+	percentChanged := false
+	if meter.total > 0 {
+		percent := int(done * 100 / meter.total)
+		percentChanged = percent != meter.lastPercent
+	}
+
+	if !percentChanged && !forced {
+		return
+	}
+
+	meter.render(now, "\r")
+}
--- /dev/null
+++ b/internal/progress/stop.go
@@ -1,0 +1,20 @@
+package progress
+
+import "time"
+
+// Stop forces the final progress line and appends ", <msg>.".
+func (meter *Meter) Stop(msg string) {
+	if !meter.sawValue || meter.writer == nil {
+		return
+	}
+
+	if msg == "" {
+		msg = "done"
+	}
+
+	if meter.sparse && meter.total > 0 && meter.lastDone != meter.total {
+		meter.lastDone = meter.total
+	}
+
+	meter.render(time.Now(), ", "+msg+".\n")
+}
--