feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
format "codeberg.org/petrbalvin/goget/internal/format"
|
||||
)
|
||||
|
||||
// ProgressBarConfig configures the progress bar
|
||||
type ProgressBarConfig struct {
|
||||
Total int64
|
||||
Width int
|
||||
Colors bool
|
||||
OnComplete func()
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
// DefaultProgressBarConfig returns the default configuration
|
||||
func DefaultProgressBarConfig() *ProgressBarConfig {
|
||||
return &ProgressBarConfig{
|
||||
Total: -1,
|
||||
Width: 0,
|
||||
Colors: true,
|
||||
Writer: os.Stderr,
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressBar represents a visual progress bar
|
||||
type ProgressBar struct {
|
||||
config *ProgressBarConfig
|
||||
current int64
|
||||
startTime time.Time
|
||||
lastUpdate time.Time
|
||||
barWidth int
|
||||
mu sync.Mutex
|
||||
speedHistory []float64
|
||||
speedMax float64
|
||||
}
|
||||
|
||||
// NewProgressBar creates a new progress bar
|
||||
func NewProgressBar(cfg *ProgressBarConfig) *ProgressBar {
|
||||
if cfg == nil {
|
||||
cfg = DefaultProgressBarConfig()
|
||||
}
|
||||
|
||||
pb := &ProgressBar{
|
||||
config: cfg,
|
||||
current: 0,
|
||||
startTime: time.Now(),
|
||||
lastUpdate: time.Now(),
|
||||
barWidth: 40,
|
||||
}
|
||||
|
||||
if cfg.Width == 0 {
|
||||
tw := getTerminalWidth()
|
||||
pb.barWidth = max(20, tw-45)
|
||||
} else {
|
||||
pb.barWidth = cfg.Width
|
||||
}
|
||||
|
||||
return pb
|
||||
}
|
||||
|
||||
// SetTotal sets the total value in a thread-safe way
|
||||
func (pb *ProgressBar) SetTotal(total int64) {
|
||||
pb.mu.Lock()
|
||||
pb.config.Total = total
|
||||
pb.mu.Unlock()
|
||||
}
|
||||
|
||||
// Update updates progress
|
||||
func (pb *ProgressBar) Update(current int64) {
|
||||
pb.mu.Lock()
|
||||
pb.current = current
|
||||
pb.renderLocked()
|
||||
pb.mu.Unlock()
|
||||
}
|
||||
|
||||
// Add adds to the current progress
|
||||
func (pb *ProgressBar) Add(n int64) {
|
||||
pb.mu.Lock()
|
||||
pb.current += n
|
||||
pb.renderLocked()
|
||||
pb.mu.Unlock()
|
||||
}
|
||||
|
||||
// Finish completes the progress bar
|
||||
func (pb *ProgressBar) Finish() {
|
||||
pb.mu.Lock()
|
||||
if pb.config.Total > 0 {
|
||||
pb.current = pb.config.Total
|
||||
}
|
||||
pb.renderFinalLocked()
|
||||
pb.mu.Unlock()
|
||||
if pb.config.OnComplete != nil {
|
||||
pb.config.OnComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel cancels the progress bar with error state
|
||||
func (pb *ProgressBar) Cancel() {
|
||||
pb.mu.Lock()
|
||||
pb.renderCancelledLocked()
|
||||
pb.mu.Unlock()
|
||||
}
|
||||
|
||||
// renderLocked renders the current state; must be called with pb.mu locked
|
||||
func (pb *ProgressBar) renderLocked() {
|
||||
now := time.Now()
|
||||
if now.Sub(pb.lastUpdate) < 50*time.Millisecond {
|
||||
return
|
||||
}
|
||||
pb.lastUpdate = now
|
||||
|
||||
w := pb.config.Writer
|
||||
total := pb.config.Total
|
||||
current := pb.current
|
||||
elapsed := now.Sub(pb.startTime)
|
||||
|
||||
// Calculate speed
|
||||
speed := float64(0)
|
||||
if elapsed.Seconds() > 0 {
|
||||
speed = float64(current) / elapsed.Seconds()
|
||||
}
|
||||
|
||||
// Record speed history (keep last 15 samples)
|
||||
pb.speedHistory = append(pb.speedHistory, speed)
|
||||
if len(pb.speedHistory) > 15 {
|
||||
pb.speedHistory = pb.speedHistory[len(pb.speedHistory)-15:]
|
||||
}
|
||||
// Update max speed
|
||||
if speed > pb.speedMax {
|
||||
pb.speedMax = speed
|
||||
}
|
||||
|
||||
percent := float64(0)
|
||||
showPercent := total > 0
|
||||
if showPercent {
|
||||
percent = float64(current) / float64(total) * 100
|
||||
}
|
||||
|
||||
var percentStr string
|
||||
if showPercent {
|
||||
percentStr = fmt.Sprintf(" %5.1f%%", percent)
|
||||
} else {
|
||||
percentStr = " ??%"
|
||||
}
|
||||
|
||||
filled := 0
|
||||
empty := pb.barWidth
|
||||
if showPercent {
|
||||
filled = int(float64(pb.barWidth) * percent / 100)
|
||||
empty = pb.barWidth - filled
|
||||
}
|
||||
|
||||
barStart := ""
|
||||
barEnd := ""
|
||||
if pb.config.Colors {
|
||||
barStart = ColorGreen
|
||||
barEnd = ColorReset
|
||||
}
|
||||
|
||||
bar := barStart + strings.Repeat("█", filled) + barEnd + strings.Repeat("░", empty)
|
||||
|
||||
currentStr := format.Bytes(current)
|
||||
totalStr := "?"
|
||||
if total > 0 {
|
||||
totalStr = format.Bytes(total)
|
||||
}
|
||||
|
||||
speedStr := format.Speed(speed)
|
||||
|
||||
etaStr := ""
|
||||
if showPercent && current < total && speed > 0 {
|
||||
remaining := float64(total-current) / speed
|
||||
etaStr = formatDuration(time.Duration(remaining * float64(time.Second)))
|
||||
}
|
||||
|
||||
etaPrefix := ""
|
||||
etaSuffix := ""
|
||||
if etaStr != "" && pb.config.Colors {
|
||||
etaPrefix = ColorCyan
|
||||
etaSuffix = ColorReset
|
||||
}
|
||||
|
||||
// Build speed sparkline
|
||||
sparkline := pb.renderSparkline()
|
||||
|
||||
var line strings.Builder
|
||||
if pb.config.Colors {
|
||||
line.WriteString(ColorBold)
|
||||
}
|
||||
line.WriteString("[")
|
||||
line.WriteString(bar)
|
||||
line.WriteString("]")
|
||||
if pb.config.Colors {
|
||||
line.WriteString(ColorReset)
|
||||
}
|
||||
line.WriteString(percentStr)
|
||||
line.WriteString(fmt.Sprintf(" %s/%s", currentStr, totalStr))
|
||||
line.WriteString(fmt.Sprintf(" %s", speedStr))
|
||||
if sparkline != "" {
|
||||
line.WriteString(fmt.Sprintf(" %s", sparkline))
|
||||
}
|
||||
if etaStr != "" {
|
||||
line.WriteString(fmt.Sprintf(" %sETA: %s%s", etaPrefix, etaStr, etaSuffix))
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\r%s\033[K", line.String())
|
||||
}
|
||||
|
||||
// renderFinalLocked renders the completed state; must be called with pb.mu locked
|
||||
func (pb *ProgressBar) renderFinalLocked() {
|
||||
w := pb.config.Writer
|
||||
elapsed := time.Since(pb.startTime)
|
||||
|
||||
prefix := ""
|
||||
suffix := ""
|
||||
if pb.config.Colors {
|
||||
prefix = ColorGreen + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s✓ Download complete%s | %s | %s elapsed",
|
||||
prefix, suffix, format.Bytes(pb.current), formatDuration(elapsed))
|
||||
|
||||
fmt.Fprintf(w, "\r%s\n", line)
|
||||
}
|
||||
|
||||
// renderCancelledLocked renders the cancelled state; must be called with pb.mu locked
|
||||
func (pb *ProgressBar) renderCancelledLocked() {
|
||||
w := pb.config.Writer
|
||||
|
||||
prefix := ""
|
||||
suffix := ""
|
||||
if pb.config.Colors {
|
||||
prefix = ColorRed + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s✗ Download cancelled%s", prefix, suffix)
|
||||
fmt.Fprintf(w, "\r%s\n", line)
|
||||
}
|
||||
|
||||
// renderSparkline returns a mini ASCII sparkline of recent speed history.
|
||||
// Uses Unicode block elements: ▁▂▃▄▅▆▇█
|
||||
func (pb *ProgressBar) renderSparkline() string {
|
||||
if len(pb.speedHistory) < 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
chars := []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
|
||||
maxSpeed := pb.speedMax
|
||||
if maxSpeed == 0 {
|
||||
for _, s := range pb.speedHistory {
|
||||
if s > maxSpeed {
|
||||
maxSpeed = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxSpeed == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Take up to 10 most recent samples
|
||||
history := pb.speedHistory
|
||||
if len(history) > 10 {
|
||||
history = history[len(history)-10:]
|
||||
}
|
||||
|
||||
var sparkline strings.Builder
|
||||
for _, speed := range history {
|
||||
ratio := speed / maxSpeed
|
||||
if ratio > 1.0 {
|
||||
ratio = 1.0
|
||||
}
|
||||
idx := int(ratio * float64(len(chars)-1))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(chars) {
|
||||
idx = len(chars) - 1
|
||||
}
|
||||
sparkline.WriteString(chars[idx])
|
||||
}
|
||||
|
||||
return sparkline.String()
|
||||
}
|
||||
|
||||
// getTerminalWidth gets the terminal width
|
||||
func getTerminalWidth() int {
|
||||
if ws := os.Getenv("COLUMNS"); ws != "" {
|
||||
if w := parseInt(ws); w > 0 {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return 80
|
||||
}
|
||||
|
||||
// parseInt helper
|
||||
func parseInt(s string) int {
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// formatDuration formats a duration
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
m := d / time.Minute
|
||||
d -= m * time.Minute
|
||||
s := d / time.Second
|
||||
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
}
|
||||
if m > 0 {
|
||||
return fmt.Sprintf("%dm%ds", m, s)
|
||||
}
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
|
||||
// PrintProgressInfo prints an info message with progress styling
|
||||
func PrintProgressInfo(w io.Writer, msg string) {
|
||||
prefix, suffix := "", ""
|
||||
if isColorTerminal(w) {
|
||||
prefix = ColorCyan + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
fmt.Fprintf(w, "%s• %s%s\n", prefix, msg, suffix)
|
||||
}
|
||||
|
||||
// PrintProgressSuccess prints a success message with progress styling
|
||||
func PrintProgressSuccess(w io.Writer, msg string) {
|
||||
prefix, suffix := "", ""
|
||||
if isColorTerminal(w) {
|
||||
prefix = ColorGreen + ColorBold
|
||||
suffix = ColorReset
|
||||
}
|
||||
fmt.Fprintf(w, "%s✓ %s%s\n", prefix, msg, suffix)
|
||||
}
|
||||
Reference in New Issue
Block a user