From 43e26382652f764038ec6e24f8d5b00441a02ce1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 12:31:07 +0000 Subject: [PATCH] feat(ui): add Progress interface with Quiet and Interactive implementations Co-Authored-By: Claude Opus 4.6 --- go.mod | 4 +- go.sum | 4 ++ pkg/ui/progress.go | 93 +++++++++++++++++++++++++++++++++++++++++ pkg/ui/progress_test.go | 40 ++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 pkg/ui/progress.go create mode 100644 pkg/ui/progress_test.go diff --git a/go.mod b/go.mod index 0264f1a..2ded154 100644 --- a/go.mod +++ b/go.mod @@ -61,8 +61,8 @@ require ( github.com/wailsapp/mimetype v1.4.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.45.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.31.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index bf4e1a3..6eaa438 100644 --- a/go.sum +++ b/go.sum @@ -183,9 +183,13 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/pkg/ui/progress.go b/pkg/ui/progress.go new file mode 100644 index 0000000..08a01ce --- /dev/null +++ b/pkg/ui/progress.go @@ -0,0 +1,93 @@ +package ui + +import ( + "fmt" + "io" + "os" + + "golang.org/x/term" +) + +// Progress abstracts output for both interactive and scripted use. +type Progress interface { + Start(label string) + Update(current, total int64) + Finish(label string) + Log(level, msg string, args ...any) +} + +// QuietProgress writes structured log lines. For cron, pipes, --quiet. +type QuietProgress struct { + w io.Writer +} + +func NewQuietProgress(w io.Writer) *QuietProgress { + return &QuietProgress{w: w} +} + +func (q *QuietProgress) Start(label string) { + fmt.Fprintf(q.w, "[START] %s\n", label) +} + +func (q *QuietProgress) Update(current, total int64) { + if total > 0 { + fmt.Fprintf(q.w, "[PROGRESS] %d/%d\n", current, total) + } +} + +func (q *QuietProgress) Finish(label string) { + fmt.Fprintf(q.w, "[DONE] %s\n", label) +} + +func (q *QuietProgress) Log(level, msg string, args ...any) { + fmt.Fprintf(q.w, "[%s] %s", level, msg) + for i := 0; i+1 < len(args); i += 2 { + fmt.Fprintf(q.w, " %v=%v", args[i], args[i+1]) + } + fmt.Fprintln(q.w) +} + +// InteractiveProgress uses simple terminal output for TTY sessions. +type InteractiveProgress struct { + w io.Writer +} + +func NewInteractiveProgress(w io.Writer) *InteractiveProgress { + return &InteractiveProgress{w: w} +} + +func (p *InteractiveProgress) Start(label string) { + fmt.Fprintf(p.w, "→ %s\n", label) +} + +func (p *InteractiveProgress) Update(current, total int64) { + if total > 0 { + pct := current * 100 / total + fmt.Fprintf(p.w, "\r %d%%", pct) + } +} + +func (p *InteractiveProgress) Finish(label string) { + fmt.Fprintf(p.w, "\r✓ %s\n", label) +} + +func (p *InteractiveProgress) Log(level, msg string, args ...any) { + fmt.Fprintf(p.w, " %s", msg) + for i := 0; i+1 < len(args); i += 2 { + fmt.Fprintf(p.w, " %v=%v", args[i], args[i+1]) + } + fmt.Fprintln(p.w) +} + +// IsTTY returns true if the given file descriptor is a terminal. +func IsTTY(fd int) bool { + return term.IsTerminal(fd) +} + +// DefaultProgress returns InteractiveProgress for TTYs, QuietProgress otherwise. +func DefaultProgress() Progress { + if IsTTY(int(os.Stdout.Fd())) { + return NewInteractiveProgress(os.Stdout) + } + return NewQuietProgress(os.Stdout) +} diff --git a/pkg/ui/progress_test.go b/pkg/ui/progress_test.go new file mode 100644 index 0000000..3e84f67 --- /dev/null +++ b/pkg/ui/progress_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "bytes" + "strings" + "testing" +) + +func TestQuietProgress_Log_Good(t *testing.T) { + var buf bytes.Buffer + p := NewQuietProgress(&buf) + p.Log("info", "test message", "key", "val") + out := buf.String() + if !strings.Contains(out, "test message") { + t.Fatalf("expected log output to contain 'test message', got: %s", out) + } +} + +func TestQuietProgress_StartFinish_Good(t *testing.T) { + var buf bytes.Buffer + p := NewQuietProgress(&buf) + p.Start("collecting") + p.Update(50, 100) + p.Finish("done") + out := buf.String() + if !strings.Contains(out, "collecting") { + t.Fatalf("expected 'collecting' in output, got: %s", out) + } + if !strings.Contains(out, "done") { + t.Fatalf("expected 'done' in output, got: %s", out) + } +} + +func TestQuietProgress_Update_Ugly(t *testing.T) { + var buf bytes.Buffer + p := NewQuietProgress(&buf) + // Should not panic with zero total + p.Update(0, 0) + p.Update(5, 0) +}