diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f6485081..6dd2986728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to `src-cli` are documented in this file. - Error reporting by `src campaign [preview|apply]` has been improved and now includes more information about which step failed in which repository. [#325](https://github.com/sourcegraph/src-cli/pull/325) - The default behaviour of `src campaigns [preview|apply]` has been changed to retain downloaded archives of repositories for better performance across re-runs of the command. To use the old behaviour and delete the archives use the `-clean-archives` flag. Repository archives are also not stored in the directory for temp data (see `-tmp` flag) anymore but in the cache directory, which can be configured with the `-cache` flag. To manually delete archives between runs, delete the `*.zip` files in the `-cache` directory (see `src campaigns -help` for its default location). - `src campaign [preview|apply]` now check whether `git` and `docker` are available before trying to execute a campaign spec's steps. [#326](https://github.com/sourcegraph/src-cli/pull/326) +- The progress bar displayed by `src campaign [preview|apply]` has been extended by status bars that show which steps are currently being executed for each repository. [#338](https://github.com/sourcegraph/src-cli/pull/338) ### Fixed diff --git a/cmd/src/campaign_progress_printer.go b/cmd/src/campaign_progress_printer.go new file mode 100644 index 0000000000..0b68dadc5c --- /dev/null +++ b/cmd/src/campaign_progress_printer.go @@ -0,0 +1,188 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/sourcegraph/go-diff/diff" + "github.com/sourcegraph/src-cli/internal/campaigns" + "github.com/sourcegraph/src-cli/internal/output" +) + +func newCampaignProgressPrinter(out *output.Output, numParallelism int) *campaignProgressPrinter { + return &campaignProgressPrinter{ + out: out, + + numParallelism: numParallelism, + + completedTasks: map[string]bool{}, + runningTasks: map[string]*campaigns.TaskStatus{}, + + repoStatusBar: map[string]int{}, + statusBarRepo: map[int]string{}, + } +} + +type campaignProgressPrinter struct { + out *output.Output + progress output.ProgressWithStatusBars + + maxRepoName int + numParallelism int + + completedTasks map[string]bool + runningTasks map[string]*campaigns.TaskStatus + + repoStatusBar map[string]int + statusBarRepo map[int]string +} + +func (p *campaignProgressPrinter) initProgressBar(statuses []*campaigns.TaskStatus) { + statusBars := []*output.StatusBar{} + for i := 0; i < p.numParallelism; i++ { + statusBars = append(statusBars, output.NewStatusBarWithLabel("Starting worker...")) + } + + p.progress = p.out.ProgressWithStatusBars([]output.ProgressBar{{ + Label: fmt.Sprintf("Executing steps in %d repositories", len(statuses)), + Max: float64(len(statuses)), + }}, statusBars, nil) +} + +func (p *campaignProgressPrinter) Complete() { + if p.progress != nil { + p.progress.Complete() + } +} + +func (p *campaignProgressPrinter) PrintStatuses(statuses []*campaigns.TaskStatus) { + if p.progress == nil { + p.initProgressBar(statuses) + } + + newlyCompleted := []*campaigns.TaskStatus{} + currentlyRunning := []*campaigns.TaskStatus{} + + for _, ts := range statuses { + if len(ts.RepoName) > p.maxRepoName { + p.maxRepoName = len(ts.RepoName) + } + + if ts.IsCompleted() { + if !p.completedTasks[ts.RepoName] { + p.completedTasks[ts.RepoName] = true + newlyCompleted = append(newlyCompleted, ts) + } + + if _, ok := p.runningTasks[ts.RepoName]; ok { + delete(p.runningTasks, ts.RepoName) + + // Free slot + idx := p.repoStatusBar[ts.RepoName] + delete(p.statusBarRepo, idx) + } + } + + if ts.IsRunning() { + currentlyRunning = append(currentlyRunning, ts) + } + + } + + p.progress.SetValue(0, float64(len(p.completedTasks))) + + newlyStarted := map[string]*campaigns.TaskStatus{} + statusBarIndex := 0 + for _, ts := range currentlyRunning { + if _, ok := p.runningTasks[ts.RepoName]; ok { + continue + } + + newlyStarted[ts.RepoName] = ts + p.runningTasks[ts.RepoName] = ts + + // Find free slot + _, ok := p.statusBarRepo[statusBarIndex] + for ok { + statusBarIndex += 1 + _, ok = p.statusBarRepo[statusBarIndex] + } + + p.statusBarRepo[statusBarIndex] = ts.RepoName + p.repoStatusBar[ts.RepoName] = statusBarIndex + } + + for _, ts := range newlyCompleted { + statusText, err := taskStatusText(ts) + if err != nil { + p.progress.Verbosef("%-*s failed to display status: %s", p.maxRepoName, ts.RepoName, err) + continue + } + + p.progress.Verbosef("%-*s %s", p.maxRepoName, ts.RepoName, statusText) + + if idx, ok := p.repoStatusBar[ts.RepoName]; ok { + // Log that this task completed, but only if there is no + // currently executing one in this bar, to avoid flicker. + if _, ok := p.statusBarRepo[idx]; !ok { + p.progress.StatusBarCompletef(idx, statusText) + } + delete(p.repoStatusBar, ts.RepoName) + } + } + + for statusBar, repo := range p.statusBarRepo { + ts, ok := p.runningTasks[repo] + if !ok { + // This should not happen + continue + } + + statusText, err := taskStatusText(ts) + if err != nil { + p.progress.Verbosef("%-*s failed to display status: %s", p.maxRepoName, ts.RepoName, err) + continue + } + + if _, ok := newlyStarted[repo]; ok { + p.progress.StatusBarResetf(statusBar, ts.RepoName, statusText) + } else { + p.progress.StatusBarUpdatef(statusBar, statusText) + } + } +} + +func taskStatusText(ts *campaigns.TaskStatus) (string, error) { + var statusText string + + if ts.IsCompleted() { + if ts.ChangesetSpec == nil { + statusText = "No changes" + } else { + fileDiffs, err := diff.ParseMultiFileDiff([]byte(ts.ChangesetSpec.Commits[0].Diff)) + if err != nil { + return "", err + } + + statusText = diffStatDescription(fileDiffs) + " " + diffStatDiagram(sumDiffStats(fileDiffs)) + } + + if ts.Cached { + statusText += " (cached)" + } + } else if ts.IsRunning() { + if ts.CurrentlyExecuting != "" { + lines := strings.Split(ts.CurrentlyExecuting, "\n") + escapedLine := strings.ReplaceAll(lines[0], "%", "%%") + if len(lines) > 1 { + statusText = fmt.Sprintf("%s ...", escapedLine) + } else { + statusText = fmt.Sprintf("%s", escapedLine) + } + } else { + statusText = fmt.Sprintf("...") + } + } + + return statusText, nil +} diff --git a/cmd/src/campaigns_common.go b/cmd/src/campaigns_common.go index 423baa2948..9072ac4dbe 100644 --- a/cmd/src/campaigns_common.go +++ b/cmd/src/campaigns_common.go @@ -240,12 +240,12 @@ func campaignsExecute(ctx context.Context, out *output.Output, svc *campaigns.Se campaignsCompletePending(pending, "Resolved repositories") } - execProgress, execProgressComplete := executeCampaignSpecProgress(out) - specs, err := svc.ExecuteCampaignSpec(ctx, repos, executor, campaignSpec, execProgress) + p := newCampaignProgressPrinter(out, opts.Parallelism) + specs, err := svc.ExecuteCampaignSpec(ctx, repos, executor, campaignSpec, p.PrintStatuses) if err != nil { return "", "", err } - execProgressComplete() + p.Complete() if logFiles := executor.LogFiles(); len(logFiles) > 0 && flags.keepLogs { func() { @@ -372,9 +372,10 @@ func diffStatDiagram(stat diff.Stat) string { added *= x deleted *= x } - return fmt.Sprintf("%s%s%s%s", + return fmt.Sprintf("%s%s%s%s%s", output.StyleLinesAdded, strings.Repeat("+", int(added)), output.StyleLinesDeleted, strings.Repeat("-", int(deleted)), + output.StyleReset, ) } @@ -409,78 +410,3 @@ func contextCancelOnInterrupt(parent context.Context) (context.Context, func()) ctxCancel() } } - -// executeCampaignSpecProgress returns a function that can be passed to -// (*Service).ExecuteCampaignSpec as the "progress" function. -// -// It prints a progress bar and, if verbose mode is activated, diff stats of -// the produced diffs. -// -// The second return value is the "complete" function that completes the -// progress bar and should be called after ExecuteCampaignSpec returns -// successfully. -func executeCampaignSpecProgress(out *output.Output) (func(statuses []*campaigns.TaskStatus), func()) { - var ( - progress output.Progress - maxRepoName int - completedTasks = map[string]bool{} - ) - - complete := func() { - if progress != nil { - progress.Complete() - } - } - - progressFunc := func(statuses []*campaigns.TaskStatus) { - if progress == nil { - progress = out.Progress([]output.ProgressBar{{ - Label: fmt.Sprintf("Executing steps in %d repositories", len(statuses)), - Max: float64(len(statuses)), - }}, nil) - } - - unloggedCompleted := []*campaigns.TaskStatus{} - - for _, ts := range statuses { - if len(ts.RepoName) > maxRepoName { - maxRepoName = len(ts.RepoName) - } - - if ts.FinishedAt.IsZero() { - continue - } - - if !completedTasks[ts.RepoName] { - completedTasks[ts.RepoName] = true - unloggedCompleted = append(unloggedCompleted, ts) - } - - } - - progress.SetValue(0, float64(len(completedTasks))) - - for _, ts := range unloggedCompleted { - var statusText string - - if ts.ChangesetSpec == nil { - statusText = "No changes" - } else { - fileDiffs, err := diff.ParseMultiFileDiff([]byte(ts.ChangesetSpec.Commits[0].Diff)) - if err != nil { - panic(err) - } - - statusText = diffStatDescription(fileDiffs) + " " + diffStatDiagram(sumDiffStats(fileDiffs)) - } - - if ts.Cached { - statusText += " (cached)" - } - - progress.Verbosef("%-*s %s", maxRepoName, ts.RepoName, statusText) - } - } - - return progressFunc, complete -} diff --git a/internal/campaigns/executor.go b/internal/campaigns/executor.go index ff3effc94b..413995e13c 100644 --- a/internal/campaigns/executor.go +++ b/internal/campaigns/executor.go @@ -60,12 +60,21 @@ type TaskStatus struct { FinishedAt time.Time // TODO: add current step and progress fields. + CurrentlyExecuting string // Result fields. ChangesetSpec *ChangesetSpec Err error } +func (ts *TaskStatus) IsRunning() bool { + return !ts.StartedAt.IsZero() && ts.FinishedAt.IsZero() +} + +func (ts *TaskStatus) IsCompleted() bool { + return !ts.StartedAt.IsZero() && !ts.FinishedAt.IsZero() +} + type executor struct { ExecutorOpts @@ -156,6 +165,7 @@ func (x *executor) do(ctx context.Context, task *Task) (err error) { // Ensure that the status is updated when we're done. defer func() { status.FinishedAt = time.Now() + status.CurrentlyExecuting = "" status.Err = err x.updateTaskStatus(task, status) }() @@ -231,7 +241,10 @@ func (x *executor) do(ctx context.Context, task *Task) (err error) { defer cancel() // Actually execute the steps. - diff, err := runSteps(runCtx, x.creator, task.Repository, task.Steps, log, x.tempDir) + diff, err := runSteps(runCtx, x.creator, task.Repository, task.Steps, log, x.tempDir, func(currentlyExecuting string) { + status.CurrentlyExecuting = currentlyExecuting + x.updateTaskStatus(task, status) + }) if err != nil { if reachedTimeout(runCtx, err) { err = &errTimeoutReached{timeout: x.Timeout} diff --git a/internal/campaigns/run_steps.go b/internal/campaigns/run_steps.go index 351c0bcb97..e692fc3453 100644 --- a/internal/campaigns/run_steps.go +++ b/internal/campaigns/run_steps.go @@ -17,7 +17,9 @@ import ( "github.com/sourcegraph/src-cli/internal/campaigns/graphql" ) -func runSteps(ctx context.Context, wc *WorkspaceCreator, repo *graphql.Repository, steps []Step, logger *TaskLogger, tempDir string) ([]byte, error) { +func runSteps(ctx context.Context, wc *WorkspaceCreator, repo *graphql.Repository, steps []Step, logger *TaskLogger, tempDir string, reportProgress func(string)) ([]byte, error) { + reportProgress("Downloading archive") + volumeDir, err := wc.Create(ctx, repo) if err != nil { return nil, errors.Wrap(err, "creating workspace") @@ -34,6 +36,7 @@ func runSteps(ctx context.Context, wc *WorkspaceCreator, repo *graphql.Repositor return out, nil } + reportProgress("Initializing workspace") if _, err := runGitCmd("init"); err != nil { return nil, errors.Wrap(err, "git init failed") } @@ -59,6 +62,7 @@ func runSteps(ctx context.Context, wc *WorkspaceCreator, repo *graphql.Repositor for i, step := range steps { logger.Logf("[Step %d] docker run %s %q", i+1, step.Container, step.Run) + reportProgress(step.Run) cidFile, err := ioutil.TempFile(tempDir, repo.Slug()+"-container-id") if err != nil { @@ -143,6 +147,7 @@ func runSteps(ctx context.Context, wc *WorkspaceCreator, repo *graphql.Repositor return nil, errors.Wrap(err, "git add failed") } + reportProgress("Calculating diff") // As of Sourcegraph 3.14 we only support unified diff format. // That means we need to strip away the `a/` and `/b` prefixes with `--no-prefix`. // See: https://github.com/sourcegraph/sourcegraph/blob/82d5e7e1562fef6be5c0b17f18631040fd330835/enterprise/internal/campaigns/service.go#L324-L329 diff --git a/internal/output/_examples/main.go b/internal/output/_examples/main.go index c08f1bca64..640b4bf39b 100644 --- a/internal/output/_examples/main.go +++ b/internal/output/_examples/main.go @@ -12,11 +12,13 @@ import ( var ( duration time.Duration verbose bool + withBars bool ) func init() { flag.DurationVar(&duration, "progress", 5*time.Second, "time to take in the progress bar and pending samples") flag.BoolVar(&verbose, "verbose", false, "enable verbose mode") + flag.BoolVar(&withBars, "with-bars", false, "show status bars on top of progress bar") } func main() { @@ -26,6 +28,14 @@ func main() { Verbose: verbose, }) + if withBars { + demoProgressWithBars(out, duration) + } else { + demo(out, duration) + } +} + +func demo(out *output.Output, duration time.Duration) { var wg sync.WaitGroup progress := out.Progress([]output.ProgressBar{ {Label: "A", Max: 1.0}, @@ -96,3 +106,94 @@ func main() { block.Write("Here is some additional information.\nIt even line wraps.") block.Close() } + +func demoProgressWithBars(out *output.Output, duration time.Duration) { + var wg sync.WaitGroup + progress := out.ProgressWithStatusBars([]output.ProgressBar{ + {Label: "Running steps", Max: 1.0}, + }, []*output.StatusBar{ + output.NewStatusBarWithLabel("github.com/sourcegraph/src-cli"), + output.NewStatusBarWithLabel("github.com/sourcegraph/sourcegraph"), + }, nil) + + wg.Add(1) + go func() { + ticker := time.NewTicker(duration / 10) + defer ticker.Stop() + defer wg.Done() + + i := 0 + for _ = range ticker.C { + i += 1 + if i > 10 { + return + } + + progress.Verbosef("%slog line %d", output.StyleWarning, i) + } + }() + + wg.Add(1) + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + defer wg.Done() + + start := time.Now() + until := start.Add(duration) + for _ = range ticker.C { + now := time.Now() + if now.After(until) { + return + } + + elapsed := time.Since(start) + + if elapsed < 5*time.Second { + if elapsed < 1*time.Second { + progress.StatusBarUpdatef(0, "Downloading archive...") + progress.StatusBarUpdatef(1, "Downloading archive...") + + } else if elapsed > 1*time.Second && elapsed < 2*time.Second { + progress.StatusBarUpdatef(0, `comby -in-place 'fmt.Sprintf("%%d", :[v])' 'strconv.Itoa(:[v])' main.go`) + progress.StatusBarUpdatef(1, `comby -in-place 'fmt.Sprintf("%%d", :[v])' 'strconv.Itoa(:[v])' pkg/main.go pkg/utils.go`) + + } else if elapsed > 2*time.Second && elapsed < 4*time.Second { + progress.StatusBarUpdatef(0, `goimports -w main.go`) + if elapsed > (2*time.Second + 500*time.Millisecond) { + progress.StatusBarUpdatef(1, `goimports -w pkg/main.go pkg/utils.go`) + } + + } else if elapsed > 4*time.Second && elapsed < 5*time.Second { + progress.StatusBarCompletef(1, `Done!`) + if elapsed > (4*time.Second + 500*time.Millisecond) { + progress.StatusBarCompletef(0, `Done!`) + } + } + } + + if elapsed > 5*time.Second && elapsed < 6*time.Second { + progress.StatusBarResetf(0, "github.com/sourcegraph/code-intel", `Downloading archive...`) + if elapsed > (5*time.Second + 200*time.Millisecond) { + progress.StatusBarResetf(1, "github.com/sourcegraph/srcx86", `Downloading archive...`) + } + } else if elapsed > 6*time.Second && elapsed < 7*time.Second { + progress.StatusBarUpdatef(1, `comby -in-place 'fmt.Sprintf("%%d", :[v])' 'strconv.Itoa(:[v])' main.go (%s)`) + if elapsed > (6*time.Second + 100*time.Millisecond) { + progress.StatusBarUpdatef(0, `comby -in-place 'fmt.Sprintf("%%d", :[v])' 'strconv.Itoa(:[v])' main.go`) + } + } else if elapsed > 7*time.Second && elapsed < 8*time.Second { + progress.StatusBarCompletef(0, "Done!") + if elapsed > (7*time.Second + 320*time.Millisecond) { + progress.StatusBarCompletef(1, "Done!") + } + } + + progress.SetValue(0, float64(now.Sub(start))/float64(duration)) + } + }() + + wg.Wait() + + progress.Complete() +} diff --git a/internal/output/output.go b/internal/output/output.go index 7acfa94e6d..ffb4c415be 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -134,6 +134,15 @@ func (o *Output) Progress(bars []ProgressBar, opts *ProgressOpts) Progress { return newProgress(bars, o, opts) } +// ProgressWithStatusBars sets up a new progress bar context with StatusBar +// contexts. This should not be invoked if there is an active Block or Pending +// context. +// +// A Progress instance must be disposed of via the Complete or Destroy methods. +func (o *Output) ProgressWithStatusBars(bars []ProgressBar, statusBars []*StatusBar, opts *ProgressOpts) ProgressWithStatusBars { + return newProgressWithStatusBars(bars, statusBars, o, opts) +} + // The utility functions below do not make checks for whether the terminal is a // TTY, and should only be invoked from behind appropriate guards. diff --git a/internal/output/progress_tty.go b/internal/output/progress_tty.go index 76985e020d..1c2664c61d 100644 --- a/internal/output/progress_tty.go +++ b/internal/output/progress_tty.go @@ -9,8 +9,15 @@ import ( "github.com/mattn/go-runewidth" ) +var defaultProgressTTYOpts = ProgressOpts{ + SuccessEmoji: "\u2705", + SuccessStyle: StyleSuccess, + PendingStyle: StylePending, +} + type progressTTY struct { bars []*ProgressBar + o *Output opts ProgressOpts @@ -127,28 +134,11 @@ func newProgressTTY(bars []*ProgressBar, o *Output, opts *ProgressOpts) *progres if opts != nil { p.opts = *opts } else { - p.opts = ProgressOpts{ - SuccessEmoji: "\u2705", - SuccessStyle: StyleSuccess, - PendingStyle: StylePending, - } - } - - if w := runewidth.StringWidth(p.opts.SuccessEmoji); w > p.emojiWidth { - p.emojiWidth = w + 1 - } - - p.labelWidth = 0 - for _, bar := range bars { - bar.labelWidth = runewidth.StringWidth(bar.Label) - if bar.labelWidth > p.labelWidth { - p.labelWidth = bar.labelWidth - } + p.opts = defaultProgressTTYOpts } - if maxWidth := p.o.caps.Width/2 - p.emojiWidth; (p.labelWidth + 2) > maxWidth { - p.labelWidth = maxWidth - 2 - } + p.determineEmojiWidth() + p.determineLabelWidth() p.o.lock.Lock() defer p.o.lock.Unlock() @@ -172,9 +162,29 @@ func newProgressTTY(bars []*ProgressBar, o *Output, opts *ProgressOpts) *progres return p } +func (p *progressTTY) determineEmojiWidth() { + if w := runewidth.StringWidth(p.opts.SuccessEmoji); w > p.emojiWidth { + p.emojiWidth = w + 1 + } +} + +func (p *progressTTY) determineLabelWidth() { + p.labelWidth = 0 + for _, bar := range p.bars { + bar.labelWidth = runewidth.StringWidth(bar.Label) + if bar.labelWidth > p.labelWidth { + p.labelWidth = bar.labelWidth + } + } + + if maxWidth := p.o.caps.Width/2 - p.emojiWidth; (p.labelWidth + 2) > maxWidth { + p.labelWidth = maxWidth - 2 + } +} + func (p *progressTTY) draw() { for _, bar := range p.bars { - p.writeLine(bar) + p.writeBar(bar) } } @@ -187,7 +197,7 @@ func (p *progressTTY) moveToOrigin() { p.o.moveUp(len(p.bars)) } -func (p *progressTTY) writeLine(bar *ProgressBar) { +func (p *progressTTY) writeBar(bar *ProgressBar) { p.o.clearCurrentLine() value := bar.Value diff --git a/internal/output/progress_with_status_bars.go b/internal/output/progress_with_status_bars.go new file mode 100644 index 0000000000..a266954911 --- /dev/null +++ b/internal/output/progress_with_status_bars.go @@ -0,0 +1,22 @@ +package output + +type ProgressWithStatusBars interface { + Progress + + StatusBarUpdatef(i int, format string, args ...interface{}) + StatusBarCompletef(i int, format string, args ...interface{}) + StatusBarResetf(i int, label, format string, args ...interface{}) +} + +func newProgressWithStatusBars(bars []ProgressBar, statusBars []*StatusBar, o *Output, opts *ProgressOpts) ProgressWithStatusBars { + barPtrs := make([]*ProgressBar, len(bars)) + for i := range bars { + barPtrs[i] = &bars[i] + } + + if !o.caps.Isatty { + return newProgressWithStatusBarsSimple(barPtrs, statusBars, o) + } + + return newProgressWithStatusBarsTTY(barPtrs, statusBars, o, opts) +} diff --git a/internal/output/progress_with_status_bars_simple.go b/internal/output/progress_with_status_bars_simple.go new file mode 100644 index 0000000000..c32eae9a5f --- /dev/null +++ b/internal/output/progress_with_status_bars_simple.go @@ -0,0 +1,88 @@ +package output + +import ( + "time" +) + +type progressWithStatusBarsSimple struct { + *progressSimple + + statusBars []*StatusBar +} + +func (p *progressWithStatusBarsSimple) Complete() { + p.stop() + writeBars(p.Output, p.bars) + writeStatusBars(p.Output, p.statusBars) +} + +func (p *progressWithStatusBarsSimple) StatusBarUpdatef(i int, format string, args ...interface{}) { + if p.statusBars[i] != nil { + p.statusBars[i].Updatef(format, args...) + } +} + +func (p *progressWithStatusBarsSimple) StatusBarCompletef(i int, format string, args ...interface{}) { + if p.statusBars[i] != nil { + wasComplete := p.statusBars[i].completed + p.statusBars[i].Completef(format, args...) + if !wasComplete { + writeStatusBar(p.Output, p.statusBars[i]) + } + } +} + +func (p *progressWithStatusBarsSimple) StatusBarResetf(i int, label, format string, args ...interface{}) { + if p.statusBars[i] != nil { + p.statusBars[i].Resetf(label, format, args...) + } +} + +func newProgressWithStatusBarsSimple(bars []*ProgressBar, statusBars []*StatusBar, o *Output) *progressWithStatusBarsSimple { + p := &progressWithStatusBarsSimple{ + progressSimple: &progressSimple{ + Output: o, + bars: bars, + done: make(chan chan struct{}), + }, + statusBars: statusBars, + } + + go func() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if p.Output.opts.Verbose { + writeBars(p.Output, p.bars) + writeStatusBars(p.Output, p.statusBars) + } + + case c := <-p.done: + c <- struct{}{} + return + } + } + }() + + return p +} + +func writeStatusBar(w Writer, bar *StatusBar) { + w.Writef("%s: "+bar.format, append([]interface{}{bar.label}, bar.args...)...) +} + +func writeStatusBars(o *Output, bars []*StatusBar) { + if len(bars) > 1 { + block := o.Block(Line("", StyleReset, "Status:")) + defer block.Close() + + for _, bar := range bars { + writeStatusBar(block, bar) + } + } else if len(bars) == 1 { + writeStatusBar(o, bars[0]) + } +} diff --git a/internal/output/progress_with_status_bars_tty.go b/internal/output/progress_with_status_bars_tty.go new file mode 100644 index 0000000000..f831dfdd0f --- /dev/null +++ b/internal/output/progress_with_status_bars_tty.go @@ -0,0 +1,276 @@ +package output + +import ( + "fmt" + "time" + + "github.com/mattn/go-runewidth" +) + +func newProgressWithStatusBarsTTY(bars []*ProgressBar, statusBars []*StatusBar, o *Output, opts *ProgressOpts) *progressWithStatusBarsTTY { + p := &progressWithStatusBarsTTY{ + progressTTY: &progressTTY{ + bars: bars, + o: o, + emojiWidth: 3, + pendingEmoji: spinnerStrings[0], + spinner: newSpinner(100 * time.Millisecond), + }, + statusBars: statusBars, + } + + if opts != nil { + p.opts = *opts + } else { + p.opts = defaultProgressTTYOpts + } + + p.determineEmojiWidth() + p.determineLabelWidth() + p.determineStatusBarLabelWidth() + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.draw() + + go func() { + for s := range p.spinner.C { + func() { + p.pendingEmoji = s + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + p.draw() + }() + } + }() + + return p +} + +type progressWithStatusBarsTTY struct { + *progressTTY + + statusBars []*StatusBar + statusBarLabelWidth int +} + +func (p *progressWithStatusBarsTTY) Close() { p.Destroy() } + +func (p *progressWithStatusBarsTTY) Destroy() { + p.spinner.stop() + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + + for i := 0; i < p.lines(); i += 1 { + p.o.clearCurrentLine() + p.o.moveDown(1) + } + + p.moveToOrigin() +} + +func (p *progressWithStatusBarsTTY) Complete() { + p.spinner.stop() + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + // +1 because of the line between progress and status bars + for i := 0; i < len(p.statusBars)+1; i += 1 { + p.o.moveUp(1) + p.o.clearCurrentLine() + } + p.statusBars = p.statusBars[0:0] + + for _, bar := range p.bars { + bar.Value = bar.Max + } + + p.o.moveUp(len(p.bars)) + p.draw() +} + +func (p *progressWithStatusBarsTTY) lines() int { + return len(p.bars) + len(p.statusBars) + 1 +} + +func (p *progressWithStatusBarsTTY) SetLabel(i int, label string) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.bars[i].Label = label + p.bars[i].labelWidth = runewidth.StringWidth(label) + p.drawInSitu() +} + +func (p *progressWithStatusBarsTTY) SetValue(i int, v float64) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.bars[i].Value = v + p.drawInSitu() +} + +func (p *progressWithStatusBarsTTY) StatusBarResetf(i int, label, format string, args ...interface{}) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + if p.statusBars[i] != nil { + p.statusBars[i].Resetf(label, format, args...) + } + + p.determineStatusBarLabelWidth() + p.drawInSitu() +} + +func (p *progressWithStatusBarsTTY) StatusBarUpdatef(i int, format string, args ...interface{}) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + if p.statusBars[i] != nil { + p.statusBars[i].Updatef(format, args...) + } + + p.drawInSitu() +} + +func (p *progressWithStatusBarsTTY) StatusBarCompletef(i int, format string, args ...interface{}) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + if p.statusBars[i] != nil { + p.statusBars[i].Completef(format, args...) + } + + p.drawInSitu() +} + +func (p *progressWithStatusBarsTTY) draw() { + for _, bar := range p.bars { + p.writeBar(bar) + } + + if len(p.statusBars) > 0 { + fmt.Fprint(p.o.w, StylePending, "│", runewidth.FillLeft("\n", p.o.caps.Width-1)) + + } + + for i, statusBar := range p.statusBars { + if statusBar == nil { + continue + } + last := i == len(p.statusBars)-1 + p.writeStatusBar(last, statusBar) + } +} + +func (p *progressWithStatusBarsTTY) moveToOrigin() { + p.o.moveUp(p.lines()) +} + +func (p *progressWithStatusBarsTTY) drawInSitu() { + p.moveToOrigin() + p.draw() +} + +func (p *progressWithStatusBarsTTY) determineStatusBarLabelWidth() { + p.statusBarLabelWidth = 0 + for _, bar := range p.statusBars { + labelWidth := runewidth.StringWidth(bar.label) + if labelWidth > p.statusBarLabelWidth { + p.statusBarLabelWidth = labelWidth + } + } + + statusBarPrefixWidth := 4 // statusBars have box char and space + if maxWidth := p.o.caps.Width/2 - statusBarPrefixWidth; (p.statusBarLabelWidth + 2) > maxWidth { + p.statusBarLabelWidth = maxWidth - 2 + } +} + +func (p *progressWithStatusBarsTTY) writeStatusBar(last bool, statusBar *StatusBar) { + style := StylePending + if statusBar.completed { + style = StyleSuccess + } + box := "├── " + if last { + box = "└── " + } + const boxWidth = 4 + + labelFillWidth := p.statusBarLabelWidth + 2 + label := runewidth.FillRight(runewidth.Truncate(statusBar.label, p.statusBarLabelWidth, "..."), labelFillWidth) + + duration := statusBar.runtime().String() + durationLength := runewidth.StringWidth(duration) + + textMaxLength := p.o.caps.Width - boxWidth - labelFillWidth - (durationLength + 2) + text := runewidth.Truncate(fmt.Sprintf(statusBar.format, p.o.caps.formatArgs(statusBar.args)...), textMaxLength, "...") + + // The text might contain invisible control characters, so we need to + // exclude them when counting length + textLength := visibleStringWidth(text) + + durationMaxWidth := textMaxLength - textLength + (durationLength + 2) + durationText := runewidth.FillLeft(duration, durationMaxWidth) + + p.o.clearCurrentLine() + fmt.Fprint(p.o.w, style, box, label, StyleReset, text, StyleBold, durationText, StyleReset, "\n") +} + +func (p *progressWithStatusBarsTTY) Verbose(s string) { + if p.o.opts.Verbose { + p.Write(s) + } +} + +func (p *progressWithStatusBarsTTY) Verbosef(format string, args ...interface{}) { + if p.o.opts.Verbose { + p.Writef(format, args...) + } +} + +func (p *progressWithStatusBarsTTY) VerboseLine(line FancyLine) { + if p.o.opts.Verbose { + p.WriteLine(line) + } +} + +func (p *progressWithStatusBarsTTY) Write(s string) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + p.o.clearCurrentLine() + fmt.Fprintln(p.o.w, s) + p.draw() +} + +func (p *progressWithStatusBarsTTY) Writef(format string, args ...interface{}) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + p.o.clearCurrentLine() + fmt.Fprintf(p.o.w, format, p.o.caps.formatArgs(args)...) + fmt.Fprint(p.o.w, "\n") + p.draw() +} + +func (p *progressWithStatusBarsTTY) WriteLine(line FancyLine) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + p.o.clearCurrentLine() + line.write(p.o.w, p.o.caps) + p.draw() +} diff --git a/internal/output/status_bar.go b/internal/output/status_bar.go new file mode 100644 index 0000000000..aa4ce6c7f1 --- /dev/null +++ b/internal/output/status_bar.go @@ -0,0 +1,54 @@ +package output + +import "time" + +// StatusBar is a sub-element of a progress bar that displays the current status +// of a process. +type StatusBar struct { + completed bool + + label string + format string + args []interface{} + + startedAt time.Time + finishedAt time.Time +} + +// Completef sets the StatusBar to completed and updates its text. +func (sb *StatusBar) Completef(format string, args ...interface{}) { + sb.completed = true + sb.format = format + sb.args = args + sb.finishedAt = time.Now() +} + +// Resetf sets the status of the StatusBar to incomplete and updates its label and text. +func (sb *StatusBar) Resetf(label, format string, args ...interface{}) { + sb.completed = false + sb.label = label + sb.format = format + sb.args = args + sb.startedAt = time.Now() +} + +// Updatef updates the StatusBar's text. +func (sb *StatusBar) Updatef(format string, args ...interface{}) { + sb.format = format + sb.args = args +} + +func (sb *StatusBar) runtime() time.Duration { + if sb.startedAt.IsZero() { + return 0 + } + if sb.finishedAt.IsZero() { + return time.Since(sb.startedAt).Truncate(time.Second) + } + + return sb.finishedAt.Sub(sb.startedAt).Truncate(time.Second) +} + +func NewStatusBarWithLabel(label string) *StatusBar { + return &StatusBar{label: label, startedAt: time.Now()} +} diff --git a/internal/output/visible_string_width.go b/internal/output/visible_string_width.go new file mode 100644 index 0000000000..32ca28abe0 --- /dev/null +++ b/internal/output/visible_string_width.go @@ -0,0 +1,17 @@ +package output + +import ( + "regexp" + + "github.com/mattn/go-runewidth" +) + +// This regex is taken from here: +// https://github.com/acarl005/stripansi/blob/5a71ef0e047df0427e87a79f27009029921f1f9b/stripansi.go +const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" + +var ansiRegex = regexp.MustCompile(ansi) + +func visibleStringWidth(str string) int { + return runewidth.StringWidth(ansiRegex.ReplaceAllString(str, "")) +}