diff --git a/cmd/src/campaign_progress_printer.go b/cmd/src/campaign_progress_printer.go index b780dbe942..88229a5d3c 100644 --- a/cmd/src/campaign_progress_printer.go +++ b/cmd/src/campaign_progress_printer.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "sort" "strings" "github.com/sourcegraph/go-diff/diff" @@ -29,6 +30,9 @@ func newCampaignProgressPrinter(out *output.Output, verbose bool, numParallelism } type campaignProgressPrinter struct { + // Used in tests only + forceNoSpinner bool + out *output.Output sem *semaphore.Weighted @@ -59,10 +63,15 @@ func (p *campaignProgressPrinter) initProgressBar(statuses []*campaigns.TaskStat statusBars = append(statusBars, output.NewStatusBar()) } - p.progress = p.out.ProgressWithStatusBars([]output.ProgressBar{{ - Label: fmt.Sprintf("Executing ... (0/%d, 0 errored)", len(statuses)), - Max: float64(len(statuses)), - }}, statusBars, nil) + progressBars := []output.ProgressBar{ + { + Label: fmt.Sprintf("Executing ... (0/%d, 0 errored)", len(statuses)), + Max: float64(len(statuses)), + }, + } + + opts := output.DefaultProgressTTYOpts.WithoutSpinner() + p.progress = p.out.ProgressWithStatusBars(progressBars, statusBars, opts) return numStatusBars } @@ -298,12 +307,12 @@ func verboseDiffSummary(fileDiffs []*diff.FileDiff) ([]string, error) { ) fileStats := make(map[string]string, len(fileDiffs)) + fileNames := make([]string, len(fileDiffs)) - for _, f := range fileDiffs { - name := f.NewName - if name == "/dev/null" { - name = f.OrigName - } + for i, f := range fileDiffs { + name := diffDisplayName(f) + + fileNames[i] = name if len(name) > maxFilenameLen { maxFilenameLen = len(name) @@ -319,8 +328,11 @@ func verboseDiffSummary(fileDiffs []*diff.FileDiff) ([]string, error) { fileStats[name] = fmt.Sprintf("%d %s", num, diffStatDiagram(stat)) } - for file, stats := range fileStats { - lines = append(lines, fmt.Sprintf("\t%-*s | %s", maxFilenameLen, file, stats)) + sort.Slice(fileNames, func(i, j int) bool { return fileNames[i] < fileNames[j] }) + + for _, name := range fileNames { + stats := fileStats[name] + lines = append(lines, fmt.Sprintf("\t%-*s | %s", maxFilenameLen, name, stats)) } var insertionsPlural string @@ -341,3 +353,11 @@ func verboseDiffSummary(fileDiffs []*diff.FileDiff) ([]string, error) { return lines, nil } + +func diffDisplayName(f *diff.FileDiff) string { + name := f.NewName + if name == "/dev/null" { + name = f.OrigName + } + return name +} diff --git a/cmd/src/campaign_progress_printer_test.go b/cmd/src/campaign_progress_printer_test.go new file mode 100644 index 0000000000..eda4cad1d9 --- /dev/null +++ b/cmd/src/campaign_progress_printer_test.go @@ -0,0 +1,263 @@ +package main + +import ( + "runtime" + "strconv" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/sourcegraph/src-cli/internal/campaigns" + "github.com/sourcegraph/src-cli/internal/output" +) + +const progressPrinterDiff = `diff --git README.md README.md +new file mode 100644 +index 0000000..3363c39 +--- /dev/null ++++ README.md +@@ -0,0 +1,3 @@ ++# README ++ ++This is the readme +diff --git a/b/c/c.txt a/b/c/c.txt +deleted file mode 100644 +index 5da75cf..0000000 +--- a/b/c/c.txt ++++ /dev/null +@@ -1 +0,0 @@ +-this is c +diff --git x/x.txt x/x.txt +index 627c2ae..88f1836 100644 +--- x/x.txt ++++ x/x.txt +@@ -1 +1 @@ +-this is x ++this is x (or is it?) +` + +func TestCampaignProgressPrinterIntegration(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Something emits different escape codes on windows.") + } + + buf := &ttyBuf{} + + out := output.NewOutput(buf, output.OutputOpts{ + ForceTTY: true, + ForceColor: true, + ForceHeight: 25, + ForceWidth: 80, + Verbose: true, + }) + + now := time.Now() + statuses := []*campaigns.TaskStatus{ + { + RepoName: "github.com/sourcegraph/sourcegraph", + StartedAt: now, + CurrentlyExecuting: "echo Hello World > README.md", + }, + { + RepoName: "github.com/sourcegraph/src-cli", + StartedAt: now.Add(time.Duration(-5) * time.Second), + CurrentlyExecuting: "Downloading archive", + }, + { + RepoName: "github.com/sourcegraph/automation-testing", + StartedAt: now.Add(time.Duration(-5) * time.Second), + CurrentlyExecuting: "echo Hello World > README.md", + }, + } + + printer := newCampaignProgressPrinter(out, true, 4) + printer.forceNoSpinner = true + + // Print with all three tasks running + printer.PrintStatuses(statuses) + have := buf.Lines() + want := []string{ + "⠋ Executing... (0/3, 0 errored) ", + "│ ", + "├── github.com/sourcegraph/sourcegraph echo Hello World > README.md 0s", + "├── github.com/sourcegraph/src-cli Downloading archive 0s", + "└── github.com/sourcegraph/automati... echo Hello World > README.md 0s", + "", + } + if !cmp.Equal(want, have) { + t.Fatalf("wrong output:\n%s", cmp.Diff(want, have)) + } + + // Now mark the last task as completed + statuses[len(statuses)-1] = &campaigns.TaskStatus{ + RepoName: "github.com/sourcegraph/automation-testing", + StartedAt: now.Add(time.Duration(-5) * time.Second), + FinishedAt: now.Add(time.Duration(5) * time.Second), + CurrentlyExecuting: "", + Err: nil, + ChangesetSpec: &campaigns.ChangesetSpec{ + BaseRepository: "graphql-id", + CreatedChangeset: &campaigns.CreatedChangeset{ + BaseRef: "refs/heads/main", + BaseRev: "d34db33f", + HeadRepository: "graphql-id", + HeadRef: "refs/heads/my-campaign", + Title: "This is my campaign", + Body: "This is my campaign", + Commits: []campaigns.GitCommitDescription{ + { + Message: "This is my campaign", + Diff: progressPrinterDiff, + }, + }, + Published: false, + }, + }, + } + + printer.PrintStatuses(statuses) + have = buf.Lines() + want = []string{ + "github.com/sourcegraph/automation-testing", + "\tREADME.md | 3 +++", + "\ta/b/c/c.txt | 1 -", + "\tx/x.txt | 2 +-", + " 3 files changed, 4 insertions, 2 deletions", + " Execution took 10s", + "", + "⠋ Executing... (1/3, 0 errored) ███████████████▍", + "│ ", + "├── github.com/sourcegraph/sourcegraph echo Hello World > README.md 0s", + "├── github.com/sourcegraph/src-cli Downloading archive 0s", + "└── github.com/sourcegraph/automati... 3 files changed ++++ 0s", + "", + } + if !cmp.Equal(want, have) { + t.Fatalf("wrong output:\n%s", cmp.Diff(want, have)) + } + + // Print again to make sure we get the same result + printer.PrintStatuses(statuses) + have = buf.Lines() + if !cmp.Equal(want, have) { + t.Fatalf("wrong output:\n%s", cmp.Diff(want, have)) + } +} + +type ttyBuf struct { + lines [][]byte + + line int + column int +} + +func (t *ttyBuf) Write(b []byte) (int, error) { + var cur int + + for cur < len(b) { + switch b[cur] { + case '\n': + t.line++ + t.column = 0 + + if len(t.lines) == t.line { + t.lines = append(t.lines, []byte{}) + } + + case '\x1b': + // Check if we're looking at a VT100 escape code. + if len(b) <= cur || b[cur+1] != '[' { + t.writeToCurrentLine(b[cur]) + cur++ + continue + } + + // First of all: forgive me. + // + // Now. Looks like we ran into a VT100 escape code. + // They follow this structure: + // + // \x1b [ + // + // So we jump over the \x1b[ and try to parse the digit. + + cur = cur + 2 // cur == '\x1b', cur + 1 == '[' + + digitStart := cur + for isDigit(b[cur]) { + cur++ + } + + rawDigit := string(b[digitStart:cur]) + digit, err := strconv.ParseInt(rawDigit, 0, 64) + if err != nil { + return 0, err + } + + command := b[cur] + + // Debug helper: + // fmt.Printf("command=%q, digit=%d (t.line=%d, t.column=%d)\n", command, digit, t.line, t.column) + + switch command { + case 'K': + // reset current line + if len(t.lines) > t.line { + t.lines[t.line] = []byte{} + t.column = 0 + } + case 'A': + // move line up by + t.line = t.line - int(digit) + + case 'D': + // *d*elete cursor by amount + t.column = t.column - int(digit) + if t.column < 0 { + t.column = 0 + } + + case 'm': + // noop + + case ';': + // color, skip over until end of color command + for b[cur] != 'm' { + cur++ + } + } + + default: + t.writeToCurrentLine(b[cur]) + } + + cur++ + } + + return len(b), nil +} + +func (t *ttyBuf) writeToCurrentLine(b byte) { + if len(t.lines) == t.line { + t.lines = append(t.lines, []byte{}) + } + + if len(t.lines[t.line]) <= t.column { + t.lines[t.line] = append(t.lines[t.line], b) + } else { + t.lines[t.line][t.column] = b + } + t.column++ +} + +func (t *ttyBuf) Lines() []string { + var lines []string + for _, l := range t.lines { + lines = append(lines, string(l)) + } + return lines +} + +func isDigit(ch byte) bool { + return '0' <= ch && ch <= '9' +} diff --git a/internal/output/output.go b/internal/output/output.go index ffb4c415be..f7326a3c25 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -50,7 +50,15 @@ type Output struct { type OutputOpts struct { // ForceColor ignores all terminal detection and enabled coloured output. ForceColor bool - Verbose bool + // ForceTTY ignores all terminal detection and enables TTY output. + ForceTTY bool + + // ForceHeight ignores all terminal detection and sets the height to this value. + ForceHeight int + // ForceWidth ignores all terminal detection and sets the width to this value. + ForceWidth int + + Verbose bool } // newOutputPlatformQuirks provides a way for conditionally compiled code to @@ -62,6 +70,15 @@ func NewOutput(w io.Writer, opts OutputOpts) *Output { if opts.ForceColor { caps.Color = true } + if opts.ForceTTY { + caps.Isatty = true + } + if opts.ForceHeight != 0 { + caps.Height = opts.ForceHeight + } + if opts.ForceWidth != 0 { + caps.Width = opts.ForceWidth + } o := &Output{caps: caps, opts: opts, w: w} if newOutputPlatformQuirks != nil { diff --git a/internal/output/progress.go b/internal/output/progress.go index 96b70dc20a..566949c4bb 100644 --- a/internal/output/progress.go +++ b/internal/output/progress.go @@ -33,6 +33,17 @@ type ProgressOpts struct { PendingStyle Style SuccessEmoji string SuccessStyle Style + + // NoSpinner turns of the automatic updating of the progress bar and + // spinner in a background goroutine. + // Used for testing only! + NoSpinner bool +} + +func (opt *ProgressOpts) WithoutSpinner() *ProgressOpts { + c := *opt + c.NoSpinner = true + return &c } func newProgress(bars []ProgressBar, o *Output, opts *ProgressOpts) Progress { @@ -42,7 +53,7 @@ func newProgress(bars []ProgressBar, o *Output, opts *ProgressOpts) Progress { } if !o.caps.Isatty { - return newProgressSimple(barPtrs, o) + return newProgressSimple(barPtrs, o, opts) } return newProgressTTY(barPtrs, o, opts) diff --git a/internal/output/progress_simple.go b/internal/output/progress_simple.go index 48fb8f26f1..e68377903b 100644 --- a/internal/output/progress_simple.go +++ b/internal/output/progress_simple.go @@ -38,13 +38,20 @@ func (p *progressSimple) stop() { <-c } -func newProgressSimple(bars []*ProgressBar, o *Output) *progressSimple { +func newProgressSimple(bars []*ProgressBar, o *Output, opts *ProgressOpts) *progressSimple { p := &progressSimple{ Output: o, bars: bars, done: make(chan chan struct{}), } + if opts.NoSpinner { + if p.Output.opts.Verbose { + writeBars(p.Output, p.bars) + } + return p + } + go func() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() diff --git a/internal/output/progress_tty.go b/internal/output/progress_tty.go index cc09e2dd4b..c8069b307b 100644 --- a/internal/output/progress_tty.go +++ b/internal/output/progress_tty.go @@ -9,7 +9,7 @@ import ( "github.com/mattn/go-runewidth" ) -var defaultProgressTTYOpts = ProgressOpts{ +var DefaultProgressTTYOpts = &ProgressOpts{ SuccessEmoji: "\u2705", SuccessStyle: StyleSuccess, PendingStyle: StylePending, @@ -145,7 +145,7 @@ func newProgressTTY(bars []*ProgressBar, o *Output, opts *ProgressOpts) *progres if opts != nil { p.opts = *opts } else { - p.opts = defaultProgressTTYOpts + p.opts = *DefaultProgressTTYOpts } p.determineEmojiWidth() @@ -156,6 +156,10 @@ func newProgressTTY(bars []*ProgressBar, o *Output, opts *ProgressOpts) *progres p.draw() + if opts.NoSpinner { + return p + } + go func() { for s := range p.spinner.C { func() { diff --git a/internal/output/progress_with_status_bars.go b/internal/output/progress_with_status_bars.go index 43ba899cb1..969b695347 100644 --- a/internal/output/progress_with_status_bars.go +++ b/internal/output/progress_with_status_bars.go @@ -16,7 +16,7 @@ func newProgressWithStatusBars(bars []ProgressBar, statusBars []*StatusBar, o *O } if !o.caps.Isatty { - return newProgressWithStatusBarsSimple(barPtrs, statusBars, o) + return newProgressWithStatusBarsSimple(barPtrs, statusBars, o, opts) } 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 index 5a2c7df278..8552859e9b 100644 --- a/internal/output/progress_with_status_bars_simple.go +++ b/internal/output/progress_with_status_bars_simple.go @@ -48,7 +48,7 @@ func (p *progressWithStatusBarsSimple) StatusBarResetf(i int, label, format stri } } -func newProgressWithStatusBarsSimple(bars []*ProgressBar, statusBars []*StatusBar, o *Output) *progressWithStatusBarsSimple { +func newProgressWithStatusBarsSimple(bars []*ProgressBar, statusBars []*StatusBar, o *Output, opts *ProgressOpts) *progressWithStatusBarsSimple { p := &progressWithStatusBarsSimple{ progressSimple: &progressSimple{ Output: o, @@ -58,6 +58,14 @@ func newProgressWithStatusBarsSimple(bars []*ProgressBar, statusBars []*StatusBa statusBars: statusBars, } + if opts.NoSpinner { + if p.Output.opts.Verbose { + writeBars(p.Output, p.bars) + writeStatusBars(p.Output, p.statusBars) + } + return p + } + go func() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() diff --git a/internal/output/progress_with_status_bars_tty.go b/internal/output/progress_with_status_bars_tty.go index dbbeba8b56..012f5de48d 100644 --- a/internal/output/progress_with_status_bars_tty.go +++ b/internal/output/progress_with_status_bars_tty.go @@ -22,7 +22,7 @@ func newProgressWithStatusBarsTTY(bars []*ProgressBar, statusBars []*StatusBar, if opts != nil { p.opts = *opts } else { - p.opts = defaultProgressTTYOpts + p.opts = *DefaultProgressTTYOpts } p.determineEmojiWidth() @@ -34,6 +34,10 @@ func newProgressWithStatusBarsTTY(bars []*ProgressBar, statusBars []*StatusBar, p.draw() + if opts.NoSpinner { + return p + } + go func() { for s := range p.spinner.C { func() { @@ -170,6 +174,7 @@ func (p *progressWithStatusBarsTTY) draw() { } if len(p.statusBars) > 0 { + p.o.clearCurrentLine() fmt.Fprint(p.o.w, StylePending, "│", runewidth.FillLeft("\n", p.o.caps.Width-1)) }