commit 9c6ea0f25c125d88586f684206b741db4e275ae3
parent 4c56b80bafe0999f2f14de8a78667ff0d3cdd825
Author: Erik Loualiche <[email protected]>
Date: Sun, 1 Mar 2026 16:35:11 -0600
feat: stream rsync progress to TUI with real-time percentage
Replace CombinedOutput() with os.Pipe + bufio.Scanner in new
RunWithProgress(ctx, callback) method. Add --info=progress2 to rsync
flags for overall transfer percentage. Stream each output line to the
log view and parse progress for the header display ("Syncing 45%").
Also: prefer Homebrew rsync over macOS openrsync, validate rsync >= 3.1
on startup, and use context.Context to kill hanging rsync on TUI quit.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Diffstat:
5 files changed, 183 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
@@ -513,5 +513,9 @@ esync sync --dry-run
## System Requirements
- **Go** 1.22+ (for building from source)
-- **rsync** 3.x
+- **rsync** 3.1+ (required for `--info=progress2` real-time transfer progress)
- **macOS** or **Linux** (uses fsnotify for filesystem events)
+
+> **macOS note:** The built-in `/usr/bin/rsync` is Apple's `openrsync` which is too old.
+> Install a modern rsync via Homebrew: `brew install rsync`. esync will automatically
+> prefer the Homebrew version when available.
diff --git a/cmd/sync.go b/cmd/sync.go
@@ -1,10 +1,12 @@
package cmd
import (
+ "context"
"fmt"
"os"
"os/signal"
"path/filepath"
+ "regexp"
"strings"
"syscall"
"time"
@@ -54,6 +56,9 @@ func init() {
rootCmd.AddCommand(syncCmd)
}
+// reProgress2 matches the percentage in rsync --info=progress2 output lines.
+var reProgress2 = regexp.MustCompile(`(\d+)%`)
+
// ---------------------------------------------------------------------------
// Config loading
// ---------------------------------------------------------------------------
@@ -156,14 +161,43 @@ func runSync(cmd *cobra.Command, args []string) error {
// ---------------------------------------------------------------------------
func runTUI(cfg *config.Config, s *syncer.Syncer) error {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
app := tui.NewApp(cfg.Sync.Local, cfg.Sync.Remote)
syncCh := app.SyncEventChan()
+ logCh := app.LogEntryChan()
+
handler := func() {
// Update header status to syncing
syncCh <- tui.SyncEvent{Status: "status:syncing"}
- result, err := s.Run()
+ var lastPct string
+ onLine := func(line string) {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ return
+ }
+ // Stream to log view
+ select {
+ case logCh <- tui.LogEntry{Time: time.Now(), Level: "INF", Message: trimmed}:
+ default:
+ }
+ // Parse progress2 percentage and update header
+ if m := reProgress2.FindStringSubmatch(trimmed); len(m) > 1 {
+ pct := m[1]
+ if pct != lastPct {
+ lastPct = pct
+ select {
+ case syncCh <- tui.SyncEvent{Status: "status:syncing " + pct + "%"}:
+ default:
+ }
+ }
+ }
+ }
+
+ result, err := s.RunWithProgress(ctx, onLine)
now := time.Now()
if err != nil {
diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go
@@ -3,7 +3,10 @@
package syncer
import (
+ "bufio"
+ "context"
"fmt"
+ "os"
"os/exec"
"regexp"
"strconv"
@@ -17,6 +20,9 @@ import (
// Types
// ---------------------------------------------------------------------------
+// ProgressFunc is called for each line of rsync output during RunWithProgress.
+type ProgressFunc func(line string)
+
// FileEntry records a transferred file and its size in bytes.
type FileEntry struct {
Name string
@@ -52,22 +58,70 @@ func New(cfg *config.Config) *Syncer {
// Public methods
// ---------------------------------------------------------------------------
+// rsyncBin returns the path to the rsync binary, preferring a homebrew
+// install over the macOS system openrsync (which lacks --info=progress2).
+func rsyncBin() string {
+ candidates := []string{
+ "/opt/homebrew/bin/rsync",
+ "/usr/local/bin/rsync",
+ }
+ for _, c := range candidates {
+ if _, err := os.Stat(c); err == nil {
+ return c
+ }
+ }
+ return "rsync" // fallback to PATH
+}
+
+// minRsyncVersion is the minimum rsync version required for --info=progress2.
+const minRsyncVersion = "3.1.0"
+
// CheckRsync verifies that rsync is installed and returns its version string.
-// Returns an error if rsync is not found on PATH.
+// Returns an error if rsync is not found on PATH or if the version is too old
+// (--info=progress2 requires rsync >= 3.1.0).
func CheckRsync() (string, error) {
- out, err := exec.Command("rsync", "--version").Output()
+ out, err := exec.Command(rsyncBin(), "--version").Output()
if err != nil {
- return "", fmt.Errorf("rsync not found: %w\nInstall rsync (e.g. brew install rsync, apt install rsync) and try again", err)
+ return "", fmt.Errorf("rsync not found: %w\nInstall rsync 3.1+ (e.g. brew install rsync, apt install rsync) and try again", err)
}
// First line is "rsync version X.Y.Z protocol version N"
- firstLine := strings.SplitN(string(out), "\n", 2)[0]
- return strings.TrimSpace(firstLine), nil
+ firstLine := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
+
+ // Extract version number
+ if m := reRsyncVersion.FindStringSubmatch(firstLine); len(m) > 1 {
+ if compareVersions(m[1], minRsyncVersion) < 0 {
+ return firstLine, fmt.Errorf("rsync %s is too old (need %s+); install a newer rsync (e.g. brew install rsync)", m[1], minRsyncVersion)
+ }
+ }
+
+ return firstLine, nil
+}
+
+// reRsyncVersion extracts the version number from rsync --version output.
+var reRsyncVersion = regexp.MustCompile(`version\s+(\d+\.\d+\.\d+)`)
+
+// compareVersions compares two dotted version strings (e.g. "3.1.0" vs "2.6.9").
+// Returns -1, 0, or 1.
+func compareVersions(a, b string) int {
+ pa := strings.Split(a, ".")
+ pb := strings.Split(b, ".")
+ for i := 0; i < len(pa) && i < len(pb); i++ {
+ na, _ := strconv.Atoi(pa[i])
+ nb, _ := strconv.Atoi(pb[i])
+ if na < nb {
+ return -1
+ }
+ if na > nb {
+ return 1
+ }
+ }
+ return len(pa) - len(pb)
}
// BuildCommand constructs the rsync argument list with all flags, excludes,
// SSH options, extra_args, source (trailing /), and destination.
func (s *Syncer) BuildCommand() []string {
- args := []string{"rsync", "--recursive", "--times", "--progress", "--stats"}
+ args := []string{rsyncBin(), "--recursive", "--times", "--progress", "--stats", "--info=progress2"}
rsync := s.cfg.Settings.Rsync
@@ -128,12 +182,16 @@ func (s *Syncer) BuildCommand() []string {
// Run executes the rsync command, captures output, and parses stats.
func (s *Syncer) Run() (*Result, error) {
+ return s.RunContext(context.Background())
+}
+
+// RunContext executes the rsync command with a context for cancellation.
+func (s *Syncer) RunContext(ctx context.Context) (*Result, error) {
args := s.BuildCommand()
start := time.Now()
- // args[0] is "rsync", the rest are arguments
- cmd := exec.Command(args[0], args[1:]...)
+ cmd := exec.CommandContext(ctx, args[0], args[1:]...)
output, err := cmd.CombinedOutput()
duration := time.Since(start)
@@ -158,6 +216,64 @@ func (s *Syncer) Run() (*Result, error) {
return result, nil
}
+// RunWithProgress executes rsync while streaming each output line to onLine.
+// The context allows cancellation (e.g. when the TUI exits).
+// If onLine is nil it falls through to RunContext().
+func (s *Syncer) RunWithProgress(ctx context.Context, onLine ProgressFunc) (*Result, error) {
+ if onLine == nil {
+ return s.RunContext(ctx)
+ }
+
+ args := s.BuildCommand()
+ start := time.Now()
+
+ cmd := exec.CommandContext(ctx, args[0], args[1:]...)
+
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ return nil, fmt.Errorf("creating pipe: %w", err)
+ }
+ cmd.Stdout = pw
+ cmd.Stderr = pw
+
+ if err := cmd.Start(); err != nil {
+ pw.Close()
+ pr.Close()
+ return nil, fmt.Errorf("starting rsync: %w", err)
+ }
+ pw.Close() // parent closes write end so scanner sees EOF
+
+ var buf strings.Builder
+ scanner := bufio.NewScanner(pr)
+ for scanner.Scan() {
+ line := scanner.Text()
+ buf.WriteString(line + "\n")
+ onLine(line)
+ }
+ pr.Close()
+
+ waitErr := cmd.Wait()
+ duration := time.Since(start)
+ outStr := buf.String()
+
+ result := &Result{
+ Duration: duration,
+ Files: s.extractFiles(outStr),
+ }
+ count, bytes := s.extractStats(outStr)
+ result.FilesCount = count
+ result.BytesTotal = bytes
+
+ if waitErr != nil {
+ result.Success = false
+ result.ErrorMessage = fmt.Sprintf("rsync error: %v\n%s", waitErr, outStr)
+ return result, waitErr
+ }
+
+ result.Success = true
+ return result, nil
+}
+
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
@@ -226,6 +342,11 @@ func (s *Syncer) extractFiles(output string) []FileEntry {
continue
}
+ // Skip --info=progress2 summary lines (e.g. " 1,234 56% 1.23MB/s 0:00:01 (xfr#1, to-chk=2/4)")
+ if strings.Contains(trimmed, "xfr#") || strings.Contains(trimmed, "to-chk=") {
+ continue
+ }
+
// Stop at stats section
if strings.HasPrefix(trimmed, "Number of") ||
strings.HasPrefix(trimmed, "sent ") ||
diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go
@@ -35,13 +35,13 @@ func TestBuildCommand_Local(t *testing.T) {
s := New(cfg)
cmd := s.BuildCommand()
- // Should start with rsync
- if cmd[0] != "rsync" {
- t.Errorf("cmd[0] = %q, want %q", cmd[0], "rsync")
+ // Should start with rsync (possibly absolute path)
+ if !strings.HasSuffix(cmd[0], "rsync") {
+ t.Errorf("cmd[0] = %q, want rsync binary", cmd[0])
}
// Must contain base flags
- for _, flag := range []string{"--recursive", "--times", "--progress", "--copy-unsafe-links"} {
+ for _, flag := range []string{"--recursive", "--times", "--progress", "--info=progress2", "--copy-unsafe-links"} {
if !containsArg(cmd, flag) {
t.Errorf("missing flag %q in %v", flag, cmd)
}
diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go
@@ -233,14 +233,18 @@ func (m DashboardModel) View() string {
// statusDisplay returns the icon and styled text for the current status.
func (m DashboardModel) statusDisplay() (string, string) {
- switch m.status {
- case "watching":
+ switch {
+ case m.status == "watching":
return statusSynced.Render("●"), statusSynced.Render("Watching")
- case "syncing":
- return statusSyncing.Render("⟳"), statusSyncing.Render("Syncing")
- case "paused":
+ case strings.HasPrefix(m.status, "syncing"):
+ label := "Syncing"
+ if pct := strings.TrimPrefix(m.status, "syncing "); pct != m.status {
+ label = "Syncing " + pct
+ }
+ return statusSyncing.Render("⟳"), statusSyncing.Render(label)
+ case m.status == "paused":
return dimStyle.Render("⏸"), dimStyle.Render("Paused")
- case "error":
+ case m.status == "error":
return statusError.Render("✗"), statusError.Render("Error")
default:
return "?", m.status