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:
+36
-3
@@ -12,6 +12,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
quietFlag bool
|
||||
)
|
||||
|
||||
var syncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync tasks with remote server",
|
||||
@@ -91,7 +95,16 @@ Examples:
|
||||
|
||||
if response == "y" || response == "yes" {
|
||||
strategy := sync.ParseStrategy(cfg.SyncStrategy)
|
||||
result, err := client.Sync(strategy)
|
||||
|
||||
// Create progress reporter
|
||||
var reporter sync.ProgressReporter
|
||||
if sync.ShouldShowProgress(quietFlag) {
|
||||
reporter = sync.NewInteractiveProgress(os.Stdout)
|
||||
} else {
|
||||
reporter = &sync.NoOpProgress{}
|
||||
}
|
||||
|
||||
result, err := client.Sync(strategy, reporter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error syncing: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -172,7 +185,15 @@ var syncNowCmd = &cobra.Command{
|
||||
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
|
||||
strategy := sync.ParseStrategy(cfg.SyncStrategy)
|
||||
|
||||
result, err := client.Sync(strategy)
|
||||
// Create progress reporter
|
||||
var reporter sync.ProgressReporter
|
||||
if sync.ShouldShowProgress(quietFlag) {
|
||||
reporter = sync.NewInteractiveProgress(os.Stdout)
|
||||
} else {
|
||||
reporter = &sync.NoOpProgress{}
|
||||
}
|
||||
|
||||
result, err := client.Sync(strategy, reporter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -351,7 +372,16 @@ Examples:
|
||||
|
||||
// Parse server tasks
|
||||
// For initial merge, we'll do a full sync
|
||||
result, err := client.Sync(strategy)
|
||||
|
||||
// Create progress reporter
|
||||
var reporter sync.ProgressReporter
|
||||
if sync.ShouldShowProgress(quietFlag) {
|
||||
reporter = sync.NewInteractiveProgress(os.Stdout)
|
||||
} else {
|
||||
reporter = &sync.NoOpProgress{}
|
||||
}
|
||||
|
||||
result, err := client.Sync(strategy, reporter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error during merge: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -379,6 +409,9 @@ func init() {
|
||||
|
||||
syncMergeCmd.Flags().Bool("prefer-local", false, "Prefer local database")
|
||||
syncMergeCmd.Flags().Bool("prefer-server", false, "Prefer server database")
|
||||
|
||||
// Global sync flags
|
||||
syncCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress progress output")
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
@@ -30,5 +30,6 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
)
|
||||
|
||||
@@ -69,6 +69,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -152,17 +152,24 @@ type ChangeLogEntry struct {
|
||||
}
|
||||
|
||||
// Sync performs a full bidirectional sync
|
||||
func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) {
|
||||
func (c *Client) Sync(strategy ConflictResolution, reporter ProgressReporter) (*SyncResult, error) {
|
||||
result := &SyncResult{}
|
||||
|
||||
// Use NoOp progress if none provided
|
||||
if reporter == nil {
|
||||
reporter = &NoOpProgress{}
|
||||
}
|
||||
|
||||
// Check if we have a queue with pending changes
|
||||
queue, err := NewQueue()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load queue: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
// Phase 1: Test connection
|
||||
reporter.StartPhase("Testing connection", 1)
|
||||
if !c.testConnection() {
|
||||
reporter.CompletePhase()
|
||||
// Server offline - queue changes if any
|
||||
cfg, _ := engine.GetConfig()
|
||||
if cfg != nil && cfg.SyncQueueOffline {
|
||||
@@ -177,28 +184,46 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("server unreachable")
|
||||
}
|
||||
reporter.UpdatePhase(1, "Connected")
|
||||
reporter.CompletePhase()
|
||||
|
||||
// Server is online - process queue first if it has items
|
||||
// Phase 2: Process queued changes if any
|
||||
if queue.Size() > 0 {
|
||||
reporter.StartPhase("Pushing queued changes", int64(queue.Size()))
|
||||
if err := c.pushQueuedChanges(queue.GetPending()); err != nil {
|
||||
reporter.CompletePhase()
|
||||
return nil, fmt.Errorf("failed to push queued changes: %w", err)
|
||||
}
|
||||
result.Pushed = queue.Size()
|
||||
reporter.UpdatePhase(int64(queue.Size()), fmt.Sprintf("%d changes pushed", queue.Size()))
|
||||
reporter.CompletePhase()
|
||||
queue.Clear()
|
||||
}
|
||||
|
||||
// Get last sync time
|
||||
lastSync := c.getLastSyncTime()
|
||||
|
||||
// Pull changes from server
|
||||
// Phase 3: Pull changes from server
|
||||
reporter.StartPhase("Pulling from server", 1)
|
||||
changes, err := c.PullChanges(lastSync)
|
||||
if err != nil {
|
||||
reporter.CompletePhase()
|
||||
return nil, fmt.Errorf("failed to pull changes: %w", err)
|
||||
}
|
||||
reporter.UpdatePhase(1, fmt.Sprintf("Received %d changes", len(changes)))
|
||||
reporter.CompletePhase()
|
||||
|
||||
// Phase 4: Parse and sort changes
|
||||
if len(changes) > 0 {
|
||||
reporter.StartPhase("Processing changes", int64(len(changes)))
|
||||
}
|
||||
|
||||
// Convert changes to tasks
|
||||
remoteTasks, err := c.parseChanges(changes)
|
||||
if err != nil {
|
||||
if len(changes) > 0 {
|
||||
reporter.CompletePhase()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse changes: %w", err)
|
||||
}
|
||||
|
||||
@@ -217,6 +242,11 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) {
|
||||
return false // maintain original order
|
||||
})
|
||||
|
||||
if len(changes) > 0 {
|
||||
reporter.UpdatePhase(int64(len(changes)), fmt.Sprintf("Parsed %d tasks", len(remoteTasks)))
|
||||
reporter.CompletePhase()
|
||||
}
|
||||
|
||||
result.Pulled = len(remoteTasks)
|
||||
|
||||
// Get local changes since last sync
|
||||
@@ -233,8 +263,21 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) {
|
||||
|
||||
result.ConflictsResolved = conflicts
|
||||
|
||||
// Apply merged tasks locally
|
||||
for _, task := range merged {
|
||||
// Phase 5: Apply merged tasks locally
|
||||
if len(merged) > 0 {
|
||||
reporter.StartPhase("Applying changes locally", int64(len(merged)))
|
||||
}
|
||||
|
||||
for i, task := range merged {
|
||||
// Update progress every task or every 10% for large syncs
|
||||
if len(merged) < 20 || i%(len(merged)/10+1) == 0 {
|
||||
desc := task.Description
|
||||
if len(desc) > 40 {
|
||||
desc = desc[:37] + "..."
|
||||
}
|
||||
reporter.UpdatePhase(int64(i+1), desc)
|
||||
}
|
||||
|
||||
if err := task.Save(); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("failed to save task %s: %v", task.UUID, err))
|
||||
continue
|
||||
@@ -276,18 +319,29 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Push local changes to server
|
||||
if len(merged) > 0 {
|
||||
reporter.CompletePhase()
|
||||
}
|
||||
|
||||
// Phase 6: Push local changes to server
|
||||
if len(localChanges) > 0 {
|
||||
reporter.StartPhase("Pushing to server", int64(len(localChanges)))
|
||||
if err := c.PushChanges(localChanges); err != nil {
|
||||
reporter.CompletePhase()
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("failed to push changes: %v", err))
|
||||
} else {
|
||||
result.Pushed += len(localChanges)
|
||||
reporter.UpdatePhase(int64(len(localChanges)), fmt.Sprintf("%d changes pushed", len(localChanges)))
|
||||
reporter.CompletePhase()
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
c.updateLastSyncTime(time.Now().Unix())
|
||||
|
||||
// Complete progress
|
||||
reporter.Done()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user