feat: Add visual progress indicators to sync operations

- Implement ProgressReporter interface with InteractiveProgress and NoOpProgress
- Add real-time progress bars using go-pretty/progress library
- Track 6 sync phases: connection test, queue push, pull, parse, apply, and server push
- Add --quiet flag to suppress progress output
- Auto-detect TTY to disable progress when piped/redirected
- Show task-level progress during apply phase with descriptions
- Display percentage complete and elapsed time for each phase
This commit is contained in:
2026-01-05 23:20:27 +01:00
parent 59861bc3bf
commit 1c3186a342
5 changed files with 264 additions and 10 deletions
+164
View File
@@ -0,0 +1,164 @@
package sync
import (
"fmt"
"io"
"os"
"time"
"github.com/jedib0t/go-pretty/v6/progress"
"github.com/jedib0t/go-pretty/v6/text"
)
// ProgressReporter interface for sync progress reporting
type ProgressReporter interface {
// Phase tracking
StartPhase(name string, total int64)
UpdatePhase(current int64, message string)
CompletePhase()
// Overall status
SetStatus(message string)
Done()
}
// NoOpProgress is a no-op progress reporter for non-interactive scenarios
type NoOpProgress struct{}
func (p *NoOpProgress) StartPhase(name string, total int64) {}
func (p *NoOpProgress) UpdatePhase(current int64, message string) {}
func (p *NoOpProgress) CompletePhase() {}
func (p *NoOpProgress) SetStatus(message string) {}
func (p *NoOpProgress) Done() {}
// InteractiveProgress provides visual progress indication using go-pretty
type InteractiveProgress struct {
writer progress.Writer
currentTracker *progress.Tracker
baseMessage string // Store base message for current phase
startTime time.Time
output io.Writer
}
// NewInteractiveProgress creates a new interactive progress reporter
func NewInteractiveProgress(output io.Writer) *InteractiveProgress {
pw := progress.NewWriter()
pw.SetOutputWriter(output)
// Configure style
pw.SetStyle(progress.StyleDefault)
pw.Style().Colors = progress.StyleColors{
Message: text.Colors{text.FgHiWhite},
Percent: text.Colors{text.FgHiCyan},
Stats: text.Colors{text.FgHiBlack},
Time: text.Colors{text.FgHiBlack},
Tracker: text.Colors{text.FgHiWhite},
Value: text.Colors{text.FgCyan},
}
pw.Style().Visibility.ETA = false
pw.Style().Visibility.ETAOverall = false
pw.Style().Visibility.Speed = false
pw.Style().Visibility.SpeedOverall = false
pw.Style().Visibility.Value = true
pw.Style().Visibility.Percentage = true
pw.Style().Options.PercentFormat = "%4.1f%%"
// Start rendering
go pw.Render()
return &InteractiveProgress{
writer: pw,
startTime: time.Now(),
output: output,
}
}
// StartPhase begins a new progress phase
func (p *InteractiveProgress) StartPhase(name string, total int64) {
// Complete previous phase if exists
if p.currentTracker != nil {
p.currentTracker.MarkAsDone()
}
// Create new tracker
tracker := &progress.Tracker{
Message: name,
Total: total,
Units: progress.Units{},
}
p.writer.AppendTracker(tracker)
p.currentTracker = tracker
p.baseMessage = name // Store base message
}
// UpdatePhase updates the current phase progress
func (p *InteractiveProgress) UpdatePhase(current int64, message string) {
if p.currentTracker != nil {
p.currentTracker.SetValue(current)
if message != "" {
// Use base message + status to avoid appending repeatedly
p.currentTracker.UpdateMessage(fmt.Sprintf("%s - %s", p.baseMessage, message))
}
}
}
// CompletePhase marks the current phase as complete
func (p *InteractiveProgress) CompletePhase() {
if p.currentTracker != nil {
p.currentTracker.MarkAsDone()
p.currentTracker = nil
}
}
// SetStatus updates the overall status message
func (p *InteractiveProgress) SetStatus(message string) {
// For status updates without a phase, we can use UpdateMessage on current tracker
if p.currentTracker != nil {
p.currentTracker.UpdateMessage(message)
}
}
// Done completes all progress and cleans up
func (p *InteractiveProgress) Done() {
// Mark any remaining tracker as done
if p.currentTracker != nil {
p.currentTracker.MarkAsDone()
p.currentTracker = nil
}
// Stop the writer (this waits for all trackers to finish)
p.writer.Stop()
// Add a newline for clean separation from subsequent output
fmt.Fprintln(p.output)
}
// formatDuration formats a duration in a human-readable way
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
minutes := int(d.Minutes())
seconds := int(d.Seconds()) % 60
return fmt.Sprintf("%dm%ds", minutes, seconds)
}
// ShouldShowProgress determines if progress should be shown based on environment
func ShouldShowProgress(quiet bool) bool {
if quiet {
return false
}
// Check if output is a terminal
fileInfo, err := os.Stdout.Stat()
if err != nil {
return false
}
// Check if it's a character device (terminal)
return (fileInfo.Mode() & os.ModeCharDevice) != 0
}