Add progress bars and spinners for long-running CLI operations#149
Add progress bars and spinners for long-running CLI operations#149Copilot wants to merge 13 commits into
Conversation
- New cmd/progress.go: progressBar struct (in-place fill bar with \r), countdown() for deterministic sleeps, spinWhile() for indeterminate waits (braille spinner). Constants progressLineWidth/progressBarWidth ensure consistent rendering across all callers. - cmd/helpers.go: waitForReady, waitForReadyAny, waitForMigration now show an animated [████░░░░] progress bar instead of printing a new line per attempt. - cmd/deploy_local.go: 30s MySQL init sleep replaced with countdown bar. - cmd/deploy_azure.go: DeployBicep wrapped with spinWhile(); MySQL 30s sleep replaced with countdown(). - cmd/configure_projects.go: triggerAndPoll uses in-place \r updates with renderBar for task progress. Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/5882daaa-5962-4400-851a-c131c3cb23f9 Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a small, zero-dependency progress UI helper for the gh-devlake Cobra CLI and wires it into several long-running operations (container readiness polling, fixed sleeps, Azure Bicep deployment, and pipeline monitoring) to provide continuous terminal feedback instead of static messages.
Changes:
- Added
cmd/progress.gowith an in-place progress bar, deterministic countdown bar, and a spinner wrapper for indeterminate waits. - Updated DevLake readiness + migration polling to render a single in-place progress bar instead of printing a new line per attempt.
- Wrapped long sleeps and Azure deployment / pipeline monitoring with countdown/spinner/in-place progress output.
Show a summary per file
| File | Description |
|---|---|
cmd/progress.go |
Adds progress bar, countdown, and spinner primitives for in-place terminal feedback. |
cmd/helpers.go |
Uses the new progress bar for /ping readiness and migration polling loops. |
cmd/deploy_local.go |
Replaces a fixed MySQL initialization sleep with a countdown bar during local deploy. |
cmd/deploy_azure.go |
Adds countdown for MySQL start wait and wraps Bicep deployment in a spinner. |
cmd/configure_projects.go |
Switches pipeline polling output to an in-place progress bar/status line. |
Copilot's findings
- Files reviewed: 5/5 changed files
- Comments generated: 3
| const progressLineWidth = 72 | ||
|
|
||
| // progressBarWidth is the number of block characters in every progress bar. | ||
| const progressBarWidth = 24 | ||
|
|
||
| // progressBar renders an in-place terminal progress bar using \r. | ||
| // Create with newProgressBar, call update to redraw, and done to finish. | ||
| type progressBar struct { | ||
| total int | ||
| width int | ||
| start time.Time | ||
| } | ||
|
|
||
| func newProgressBar(total int) *progressBar { | ||
| return &progressBar{total: total, width: progressBarWidth, start: time.Now()} | ||
| } | ||
|
|
||
| // renderBar returns a [████░░░░] string representing current/total progress. | ||
| // Rounding is applied so early progress is visible even at low percentages. | ||
| func renderBar(current, total, width int) string { | ||
| if total <= 0 { | ||
| return "[" + strings.Repeat("░", width) + "]" | ||
| } | ||
| // Use rounding division to avoid premature completion or invisible early progress. | ||
| filled := (width*current + total/2) / total | ||
| if filled > width { | ||
| filled = width | ||
| } | ||
| return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]" | ||
| } | ||
|
|
||
| // update redraws the progress bar at position current. | ||
| // It uses \r to overwrite the current line in the terminal. | ||
| func (p *progressBar) update(current int, label string) { | ||
| bar := renderBar(current, p.total, p.width) | ||
| elapsed := time.Since(p.start).Truncate(time.Second) | ||
| fmt.Printf("\r %s %2d/%-2d %s (%s elapsed) ", bar, current, p.total, label, elapsed) | ||
| } | ||
|
|
||
| // clear erases the progress bar line and returns the cursor to column 0. | ||
| func (p *progressBar) clear() { | ||
| fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) | ||
| } |
| var deployment *azure.DeploymentOutput | ||
| err = spinWhile("Deploying Azure resources via Bicep (this takes several minutes)", func() error { | ||
| var innerErr error | ||
| deployment, innerErr = azure.DeployBicep(azureRG, templatePath, params) | ||
| return innerErr | ||
| }) |
| if err != nil { | ||
| elapsed := time.Since(deadline.Add(-timeout)).Truncate(time.Second) | ||
| fmt.Printf(" [%s] Could not check status...\n", elapsed) | ||
| fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed)) | ||
| } else { | ||
| elapsed := time.Since(deadline.Add(-timeout)).Truncate(time.Second) | ||
| fmt.Printf(" [%s] Status: %s | Tasks: %d/%d\n", elapsed, p.Status, p.FinishedTasks, p.TotalTasks) | ||
| bar := renderBar(p.FinishedTasks, p.TotalTasks, progressBarWidth) | ||
| fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed)) | ||
|
|
|
@copilot Can you address the comments in this PR |
…width spaces
Replace strings.Repeat(" ", progressLineWidth) clears with \033[2K (ANSI
erase entire line) in progress.go and configure_projects.go. This correctly
erases lines of any length regardless of label width, fixing garbled output
for long labels like the Azure Bicep spinner. Also removes the now-unneeded
progressLineWidth constant and trailing-space padding from all update/spinner
print calls.
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Addressed all three review comments in commit The root cause was the same across all three: relying on overwriting with a fixed number of spaces (72) to "clear" a line, which fails for longer content. Fix: replaced every
The now-unneeded |
|
@copilot Please address the Code Review Agent findings from the latest review cycle:
Validation: |
…s to progressLineWidth Re-add progressLineWidth = 72 constant and add a rune-safe truncatePad helper that pads short lines with spaces and truncates long lines with "…". Apply it to update(), spinWhile ticker, and configure_projects.go status lines so every in-place write occupies exactly the target column width. Replace ANSI \033[2K clears with matching space-overwrite clears (reliable since every update now writes exactly progressLineWidth chars). Truncate configure_projects pipeline status to progressLineWidth-3 runes (accounting for the 3-space prefix) to prevent overflow on terminals of width 80. Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
…onfigure_projects.go Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
All three findings addressed in commits
Note: I kept the |
| // progressLineWidth is the number of terminal columns each in-place status | ||
| // line occupies. Every update() and spinner tick writes exactly this many | ||
| // visible characters so that clear() (which overwrites the same width with | ||
| // spaces) always fully erases the previous line. | ||
| const progressLineWidth = 72 |
| func (p *progressBar) update(current int, label string) { | ||
| bar := renderBar(current, p.total, p.width) | ||
| elapsed := time.Since(p.start).Truncate(time.Second) | ||
| line := fmt.Sprintf(" %s %2d/%-2d %s (%s elapsed)", bar, current, p.total, label, elapsed) | ||
| fmt.Printf("\r%s", truncatePad(line, progressLineWidth)) | ||
| } |
| // truncatePad returns s padded with spaces to exactly width runes, | ||
| // or truncated to (width-1) runes followed by "…" if s is longer. | ||
| // Width is measured in runes to handle multi-byte characters correctly. | ||
| func truncatePad(s string, width int) string { | ||
| runes := []rune(s) | ||
| n := len(runes) | ||
| if n > width { | ||
| return string(runes[:width-1]) + "…" | ||
| } | ||
| return s + strings.Repeat(" ", width-n) | ||
| } | ||
|
|
||
| // progressBar renders an in-place terminal progress bar using \r. | ||
| // Create with newProgressBar, call update to redraw, and done to finish. | ||
| type progressBar struct { | ||
| total int | ||
| width int | ||
| start time.Time | ||
| } | ||
|
|
||
| func newProgressBar(total int) *progressBar { | ||
| return &progressBar{total: total, width: progressBarWidth, start: time.Now()} | ||
| } | ||
|
|
||
| // renderBar returns a [████░░░░] string representing current/total progress. | ||
| // Rounding is applied so early progress is visible even at low percentages. | ||
| func renderBar(current, total, width int) string { | ||
| if total <= 0 { | ||
| return "[" + strings.Repeat("░", width) + "]" | ||
| } | ||
| // Use rounding division to avoid premature completion or invisible early progress. | ||
| filled := (width*current + total/2) / total | ||
| if filled > width { | ||
| filled = width | ||
| } | ||
| return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]" | ||
| } |
All three findings addressed in commit
|
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
| // Returns fn's error. | ||
| func spinWhile(label string, fn func() error) error { | ||
| done := make(chan error, 1) | ||
| go func() { done <- fn() }() |
| } | ||
| // Use rounding division to avoid premature completion or invisible early progress. | ||
| filled := (width*current + total/2) / total | ||
| if filled > width { |
| require ( | ||
| github.com/mattn/go-runewidth v0.0.23 | ||
| github.com/spf13/cobra v1.8.1 | ||
| github.com/spf13/pflag v1.0.5 | ||
| golang.org/x/term v0.25.0 | ||
| ) | ||
|
|
||
| require ( | ||
| github.com/clipperhouse/uax29/v2 v2.2.0 // indirect | ||
| github.com/inconshreveable/mousetrap v1.1.0 // indirect |
|
@copilot Round 2 — three findings:
Validation: |
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Addressed in
Validation passed: |
| func waitForReady(baseURL string, maxAttempts int, interval time.Duration) error { | ||
| httpClient := &http.Client{Timeout: 5 * time.Second} | ||
| bar := newProgressBar(maxAttempts) | ||
| for attempt := 1; attempt <= maxAttempts; attempt++ { | ||
| bar.update(attempt, "waiting for DevLake") | ||
| resp, err := httpClient.Get(baseURL + "/ping") | ||
| if err == nil { | ||
| resp.Body.Close() | ||
| if resp.StatusCode == http.StatusOK { | ||
| fmt.Println(" ✅ DevLake is responding!") | ||
| bar.done("✅ DevLake is responding!") | ||
| return nil | ||
| } | ||
| } | ||
| fmt.Printf(" Attempt %d/%d — waiting...\n", attempt, maxAttempts) | ||
| time.Sleep(interval) | ||
| } | ||
| bar.clear() | ||
| return fmt.Errorf("DevLake not ready after %d attempts — check logs", maxAttempts) | ||
| } |
| func (p *progressBar) update(current int, label string) { | ||
| bar := renderBar(current, p.total, p.width) | ||
| elapsed := time.Since(p.start).Truncate(time.Second) | ||
| line := fmt.Sprintf("%s%s %2d/%-2d %s (%s elapsed)", statusLinePrefix, bar, current, p.total, label, elapsed) | ||
| fmt.Printf("\r%s", truncatePad(line, progressLineWidth)) | ||
| } | ||
|
|
||
| // clear erases the progress bar line and returns the cursor to column 0. | ||
| func (p *progressBar) clear() { | ||
| fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) | ||
| } | ||
|
|
||
| // done clears the bar and prints a completion message. | ||
| func (p *progressBar) done(msg string) { | ||
| p.clear() | ||
| fmt.Println(statusLinePrefix + msg) |
| require ( | ||
| github.com/mattn/go-runewidth v0.0.23 | ||
| github.com/spf13/cobra v1.8.1 | ||
| github.com/spf13/pflag v1.0.5 | ||
| golang.org/x/term v0.25.0 | ||
| ) | ||
|
|
||
| require ( | ||
| github.com/clipperhouse/uax29/v2 v2.2.0 // indirect | ||
| github.com/inconshreveable/mousetrap v1.1.0 // indirect |
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Any operation taking >30s (container startup, Azure Bicep deployment, DB migration, pipeline sync) gave no visual feedback beyond a static message. Users had no way to gauge progress or distinguish a slow operation from a hang.
New:
cmd/progress.goZero-dependency terminal progress UI with three primitives:
progressBar— in-place[████████░░░░░░░░] 8/36 waiting for DevLake (1m20s elapsed)via\rcountdown(n, label)— deterministic per-second fill bar for fixed-duration sleepsspinWhile(label, fn)— runsfnin a goroutine, shows a braille spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) until it returnsConstants
progressLineWidth = 72andprogressBarWidth = 24keep rendering consistent across all callers.Applied to long-running operations
waitForReady/waitForReadyAny/waitForMigrationstartLocalContainers— MySQL init sleeptime.Sleep(30s)countdown(30, "MySQL initializing")DeployBicepindeploy_azure.gospinWhile("Deploying Azure resources via Bicep", ...)time.Sleep(30s)countdown(30, "waiting for MySQL")triggerAndPollinconfigure_projects.go[████░░░░] 3/8 tasks — TASK_RUNNING (45s elapsed)Example — spinner wrapping Bicep deployment
On failure,
spinWhileclears the spinner line before returning so the caller's error message lands on a clean line.Original prompt
Created from VS Code.