mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
refactor(display): rebuild the TTY progress renderer on a model/layout/screen split
The TTY renderer accumulated display-corruption fixes (truncation of details, then progress sizes, then task ids; timer alignment; rune-based measurement) that each patched one symptom of the same structural gap: nothing guaranteed a rendered line fits the terminal, and once a line wraps, cursor arithmetic desyncs and the block corrupts. Replace it with three separable units: - tty_model.go: pure event reducer with an injected clock, preserving first-parent-wins updates, monotonic progress and header counters - tty_layout.go: pure (model, size, now) -> lines function; all widths are measured in terminal cells (go-runewidth, so CJK is correct) on plain text before coloring, and every line is clipped to the terminal width by construction, status text included - tty_screen.go: diff-based repaint; unchanged rows are skipped, identical frames write nothing, a frame is a single Write; a shrinking terminal abandons the block instead of moving the cursor over reflowed rows The writer coordinates them behind a mutex and stops the refresh goroutine through context cancellation, so Done cannot block when the operation context was cancelled first (Ctrl-C during pull). The spinner frame is derived from the clock instead of advancing on every call, and truncation can no longer split multi-byte runes. Visual output is unchanged: the snapshot test reproduces the previous renderer's golden output character for character. Co-Authored-By: Claude Fable 5 <[email protected]> Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
committed by
Guillaume Lours
co-authored by
Claude Fable 5
parent
8018a96fde
commit
9fc38cce0e
@@ -92,7 +92,7 @@ func TestSelectEventProcessor_AutoMode(t *testing.T) {
|
||||
name: "stderr TTY, stdout piped -> Full",
|
||||
errIsTTY: true,
|
||||
ansi: "auto",
|
||||
wantType: "*display.ttyWriter",
|
||||
wantType: "*display.termWriter",
|
||||
wantMode: display.ModeTTY,
|
||||
},
|
||||
{
|
||||
@@ -107,7 +107,7 @@ func TestSelectEventProcessor_AutoMode(t *testing.T) {
|
||||
outIsTTY: true,
|
||||
errIsTTY: true,
|
||||
ansi: "auto",
|
||||
wantType: "*display.ttyWriter",
|
||||
wantType: "*display.termWriter",
|
||||
wantMode: display.ModeTTY,
|
||||
},
|
||||
{
|
||||
@@ -154,7 +154,7 @@ func TestSelectEventProcessor_ExplicitMode(t *testing.T) {
|
||||
progress: display.ModeTTY,
|
||||
ansi: "auto",
|
||||
wantMode: display.ModeTTY,
|
||||
wantType: "*display.ttyWriter",
|
||||
wantType: "*display.termWriter",
|
||||
},
|
||||
{
|
||||
name: "progress=tty with ansi=never is rejected",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package display
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Spinner struct {
|
||||
time time.Time
|
||||
index int
|
||||
chars []string
|
||||
stop bool
|
||||
done string
|
||||
}
|
||||
|
||||
func NewSpinner() *Spinner {
|
||||
chars := []string{
|
||||
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
|
||||
}
|
||||
done := "⠿"
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
chars = []string{"-"}
|
||||
done = "-"
|
||||
}
|
||||
|
||||
return &Spinner{
|
||||
index: 0,
|
||||
time: time.Now(),
|
||||
chars: chars,
|
||||
done: done,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Spinner) String() string {
|
||||
if s.stop {
|
||||
return s.done
|
||||
}
|
||||
|
||||
d := time.Since(s.time)
|
||||
if d.Milliseconds() > 100 {
|
||||
s.index = (s.index + 1) % len(s.chars)
|
||||
}
|
||||
|
||||
return s.chars[s.index]
|
||||
}
|
||||
|
||||
func (s *Spinner) Stop() {
|
||||
s.stop = true
|
||||
}
|
||||
|
||||
func (s *Spinner) Restart() {
|
||||
s.stop = false
|
||||
}
|
||||
+114
-657
@@ -20,716 +20,173 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/buger/goterm"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/morikuni/aec"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
"github.com/docker/compose/v5/pkg/utils"
|
||||
)
|
||||
|
||||
// Full creates an EventProcessor that render advanced UI within a terminal.
|
||||
// On Start, TUI lists task with a progress timer
|
||||
func Full(out io.Writer, info io.Writer, detached bool) api.EventProcessor {
|
||||
return &ttyWriter{
|
||||
// Full creates the terminal EventProcessor, built on a model/layout/screen
|
||||
// split:
|
||||
//
|
||||
// - tty_model.go — event reducer, pure data, injected clock
|
||||
// - tty_layout.go — pure (model, size, now) → lines, every line ≤ width
|
||||
// - tty_screen.go — diff-based repaint of the block, single Write per frame
|
||||
//
|
||||
// The writer itself only coordinates: a mutex guards the model and the
|
||||
// screen, and the refresh goroutine is stopped through context cancellation
|
||||
// so no lifecycle transition can block (Done after Ctrl-C included).
|
||||
func Full(out io.Writer, info io.Writer, detached bool, opts ...TermOption) api.EventProcessor {
|
||||
w := &termWriter{
|
||||
out: out,
|
||||
info: info,
|
||||
tasks: map[string]*task{},
|
||||
done: newDoneSignal(),
|
||||
mtx: &sync.Mutex{},
|
||||
detached: detached,
|
||||
tree: newTaskTree(),
|
||||
scr: screen{out: out},
|
||||
size: termSize,
|
||||
now: time.Now,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// doneSignal is a channel that's safe to close more than once: a shared bus
|
||||
// can go through more than one sequential Start/Done cycle in its lifetime
|
||||
// (e.g. `docker compose rm --stop` runs a "stop" cycle before its own
|
||||
// "remove" cycle), and a bare channel can only ever be closed once.
|
||||
type doneSignal struct {
|
||||
ch chan bool
|
||||
once sync.Once
|
||||
// TermOption customizes the Full EventProcessor.
|
||||
type TermOption func(*termWriter)
|
||||
|
||||
// WithDryRun prefixes every row with the dry-run marker.
|
||||
func WithDryRun() TermOption {
|
||||
return func(w *termWriter) { w.dryRun = true }
|
||||
}
|
||||
|
||||
func newDoneSignal() *doneSignal {
|
||||
return &doneSignal{ch: make(chan bool)}
|
||||
}
|
||||
type termWriter struct {
|
||||
mu sync.Mutex
|
||||
out io.Writer
|
||||
info io.Writer
|
||||
detached bool
|
||||
dryRun bool
|
||||
|
||||
func (d *doneSignal) close() {
|
||||
d.once.Do(func() { close(d.ch) })
|
||||
}
|
||||
|
||||
type ttyWriter struct {
|
||||
out io.Writer
|
||||
ids []string // tasks ids ordered as first event appeared
|
||||
tasks map[string]*task
|
||||
repeated bool
|
||||
numLines int
|
||||
done *doneSignal // (re)created by Start for each Start/Done cycle
|
||||
mtx *sync.Mutex
|
||||
dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
|
||||
tree taskTree
|
||||
scr screen
|
||||
operation string
|
||||
ticker *time.Ticker
|
||||
suspended bool
|
||||
info io.Writer
|
||||
detached bool
|
||||
stopTicks context.CancelFunc
|
||||
|
||||
// injected for tests
|
||||
size func() (width, height int)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type task struct {
|
||||
ID string
|
||||
parent string // the resource this task receives updates from - other parents will be ignored
|
||||
parents utils.Set[string] // all resources to depend on this task
|
||||
startTime time.Time
|
||||
endTime time.Time
|
||||
text string
|
||||
details string
|
||||
status api.EventStatus
|
||||
current int64
|
||||
percent int
|
||||
total int64
|
||||
spinner *Spinner
|
||||
func termSize() (int, int) {
|
||||
width, height := goterm.Width(), goterm.Height()
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
if height <= 0 {
|
||||
height = 24
|
||||
}
|
||||
return width, height
|
||||
}
|
||||
|
||||
func newTask(e api.Resource) task {
|
||||
t := task{
|
||||
ID: e.ID,
|
||||
parents: utils.NewSet[string](),
|
||||
startTime: time.Now(),
|
||||
text: e.Text,
|
||||
details: e.Details,
|
||||
status: e.Status,
|
||||
current: e.Current,
|
||||
percent: e.Percent,
|
||||
total: e.Total,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
if e.ParentID != "" {
|
||||
t.parent = e.ParentID
|
||||
t.parents.Add(e.ParentID)
|
||||
}
|
||||
if e.Status == api.Done || e.Status == api.Error {
|
||||
t.stop()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// update adjusts task state based on last received event
|
||||
func (t *task) update(e api.Resource) {
|
||||
if e.ParentID != "" {
|
||||
t.parents.Add(e.ParentID)
|
||||
// we may receive same event from distinct parents (typically: images sharing layers)
|
||||
// to avoid status to flicker, only accept updates from our first declared parent
|
||||
if t.parent != e.ParentID {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// update task based on received event
|
||||
switch e.Status {
|
||||
case api.Done, api.Error, api.Warning:
|
||||
if t.status != e.Status {
|
||||
t.stop()
|
||||
}
|
||||
case api.Working:
|
||||
t.spinner.Restart()
|
||||
}
|
||||
t.status = e.Status
|
||||
t.text = e.Text
|
||||
t.details = e.Details
|
||||
// progress can only go up
|
||||
if e.Total > t.total {
|
||||
t.total = e.Total
|
||||
}
|
||||
if e.Current > t.current {
|
||||
t.current = e.Current
|
||||
}
|
||||
if e.Percent > t.percent {
|
||||
t.percent = e.Percent
|
||||
}
|
||||
}
|
||||
|
||||
func (t *task) stop() {
|
||||
t.endTime = time.Now()
|
||||
t.spinner.Stop()
|
||||
}
|
||||
|
||||
func (t *task) Completed() bool {
|
||||
switch t.status {
|
||||
case api.Done, api.Error, api.Warning:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ttyWriter) Start(ctx context.Context, operation string) {
|
||||
w.mtx.Lock()
|
||||
// If a previous cycle is still open (Start called again before its
|
||||
// matching Done, e.g. nested Start/Done misuse), close it out first so
|
||||
// its render goroutine exits instead of leaking until ctx is done.
|
||||
if w.done != nil {
|
||||
w.done.close()
|
||||
}
|
||||
if w.ticker != nil {
|
||||
w.ticker.Stop()
|
||||
}
|
||||
done := newDoneSignal()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
w.done = done
|
||||
w.ticker = ticker
|
||||
func (w *termWriter) Start(ctx context.Context, operation string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.operation = operation
|
||||
w.mtx.Unlock()
|
||||
// The refresh goroutine is bound to a derived context: parent
|
||||
// cancellation (Ctrl-C) and Done both stop it by cancelling, which can
|
||||
// never block — there is no channel handshake to miss.
|
||||
tickCtx, cancel := context.WithCancel(ctx)
|
||||
w.stopTicks = cancel
|
||||
go w.refresh(tickCtx)
|
||||
}
|
||||
|
||||
// done and ticker are this cycle's own, captured locally: a later Start
|
||||
// (a new Start/Done cycle on the same writer) reassigns w.done/w.ticker,
|
||||
// but that never affects this goroutine's own wait.
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// interrupted
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-done.ch:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.print()
|
||||
}
|
||||
func (w *termWriter) refresh(ctx context.Context) {
|
||||
ticker := time.NewTicker(tickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.mu.Lock()
|
||||
w.repaint()
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *ttyWriter) Done(operation string, success bool) {
|
||||
w.print()
|
||||
w.mtx.Lock()
|
||||
done := w.done
|
||||
ticker := w.ticker
|
||||
w.mtx.Unlock()
|
||||
|
||||
// close never blocks, unlike a send, so this returns even if the render
|
||||
// goroutine already exited via ctx.Done().
|
||||
done.close()
|
||||
|
||||
w.mtx.Lock()
|
||||
defer w.mtx.Unlock()
|
||||
if ticker != nil {
|
||||
ticker.Stop()
|
||||
}
|
||||
w.operation = ""
|
||||
}
|
||||
|
||||
func (w *ttyWriter) On(events ...api.Resource) {
|
||||
w.mtx.Lock()
|
||||
defer w.mtx.Unlock()
|
||||
func (w *termWriter) Done(string, bool) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.stopTicks != nil {
|
||||
w.stopTicks()
|
||||
w.stopTicks = nil
|
||||
}
|
||||
w.repaint() // leave the final state on screen
|
||||
w.operation = ""
|
||||
// The tree and the screen survive: a follow-up operation on the same
|
||||
// writer (`up` chains create and start) extends the same block.
|
||||
}
|
||||
|
||||
func (w *termWriter) On(events ...api.Resource) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for _, e := range events {
|
||||
if e.ID == "Compose" {
|
||||
if e.ID == api.ResourceCompose {
|
||||
_, _ = fmt.Fprintln(w.info, ErrorColor(e.Details))
|
||||
continue
|
||||
}
|
||||
|
||||
if w.operation != "start" && (e.Text == api.StatusStarted || e.Text == api.StatusStarting) && !w.detached {
|
||||
// skip those events to avoid mix with container logs
|
||||
// skip those events to avoid mixing with container logs
|
||||
continue
|
||||
}
|
||||
w.event(e)
|
||||
w.handle(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ttyWriter) event(e api.Resource) {
|
||||
// Suspend print while a build is in progress, to avoid collision with buildkit Display
|
||||
if w.ticker != nil {
|
||||
if e.Text == api.StatusBuilding {
|
||||
w.ticker.Stop()
|
||||
w.suspended = true
|
||||
} else if w.suspended {
|
||||
w.ticker.Reset(100 * time.Millisecond)
|
||||
w.suspended = false
|
||||
}
|
||||
func (w *termWriter) handle(e api.Resource) {
|
||||
// Buildkit paints its own UI on the same stream: stay silent while a
|
||||
// build is in flight, and once it's over start a fresh block below
|
||||
// whatever buildkit wrote instead of repainting over it.
|
||||
if e.Text == api.StatusBuilding {
|
||||
w.suspended = true
|
||||
} else if w.suspended {
|
||||
w.suspended = false
|
||||
w.scr.reset()
|
||||
}
|
||||
|
||||
if last, ok := w.tasks[e.ID]; ok {
|
||||
last.update(e)
|
||||
} else {
|
||||
t := newTask(e)
|
||||
w.tasks[e.ID] = &t
|
||||
w.ids = append(w.ids, e.ID)
|
||||
w.tree.apply(e, w.now())
|
||||
|
||||
if w.operation == "" {
|
||||
// outside any operation: degrade to one plain line per event
|
||||
_, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, plainEventColor(e.Status)(e.Text), e.Details)
|
||||
}
|
||||
w.printEvent(e)
|
||||
}
|
||||
|
||||
func (w *ttyWriter) printEvent(e api.Resource) {
|
||||
if w.operation != "" {
|
||||
// event will be displayed by progress UI on ticker's ticks
|
||||
func (w *termWriter) repaint() {
|
||||
if w.suspended || w.operation == "" || len(w.tree.nodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var color colorFunc
|
||||
switch e.Status {
|
||||
case api.Working:
|
||||
color = SuccessColor
|
||||
case api.Done:
|
||||
color = SuccessColor
|
||||
case api.Warning:
|
||||
color = WarningColor
|
||||
case api.Error:
|
||||
color = ErrorColor
|
||||
}
|
||||
_, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, color(e.Text), e.Details)
|
||||
width, height := w.size()
|
||||
lines := layoutFrame(&w.tree, w.operation, layoutOpts{
|
||||
width: width,
|
||||
height: height,
|
||||
dryRun: w.dryRun,
|
||||
now: w.now(),
|
||||
})
|
||||
w.scr.paint(lines, width)
|
||||
}
|
||||
|
||||
func (w *ttyWriter) parentTasks() iter.Seq[*task] {
|
||||
return func(yield func(*task) bool) {
|
||||
for _, id := range w.ids { // iterate on ids to enforce a consistent order
|
||||
t := w.tasks[id]
|
||||
if len(t.parents) == 0 {
|
||||
yield(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
|
||||
return func(yield func(*task) bool) {
|
||||
for _, id := range w.ids { // iterate on ids to enforce a consistent order
|
||||
t := w.tasks[id]
|
||||
if t.parents.Has(parent) {
|
||||
yield(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lineData holds pre-computed formatting for a task line
|
||||
type lineData struct {
|
||||
spinner string // rendered spinner with color
|
||||
prefix string // dry-run prefix if any
|
||||
taskID string // possibly abbreviated
|
||||
progress string // progress bar and (optionally) size info appended
|
||||
progressSizeBytes int // byte length of the trailing size suffix in progress, 0 if none
|
||||
status string // rendered status with color
|
||||
details string // possibly abbreviated
|
||||
timer string // rendered timer with color
|
||||
statusPad int // padding before status to align
|
||||
timerPad int // padding before timer to align
|
||||
statusColor colorFunc
|
||||
}
|
||||
|
||||
func (w *ttyWriter) print() {
|
||||
terminalWidth := goterm.Width()
|
||||
terminalHeight := goterm.Height()
|
||||
if terminalWidth <= 0 {
|
||||
terminalWidth = 80
|
||||
}
|
||||
if terminalHeight <= 0 {
|
||||
terminalHeight = 24
|
||||
}
|
||||
w.printWithDimensions(terminalWidth, terminalHeight)
|
||||
}
|
||||
|
||||
func (w *ttyWriter) printWithDimensions(terminalWidth, terminalHeight int) {
|
||||
w.mtx.Lock()
|
||||
defer w.mtx.Unlock()
|
||||
if len(w.tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
up := w.numLines + 1
|
||||
if !w.repeated {
|
||||
up--
|
||||
w.repeated = true
|
||||
}
|
||||
b := aec.NewBuilder(
|
||||
aec.Hide, // Hide the cursor while we are printing
|
||||
aec.Up(uint(up)),
|
||||
aec.Column(0),
|
||||
)
|
||||
_, _ = fmt.Fprint(w.out, b.ANSI)
|
||||
defer func() {
|
||||
_, _ = fmt.Fprint(w.out, aec.Show)
|
||||
}()
|
||||
|
||||
firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
|
||||
_, _ = fmt.Fprintln(w.out, firstLine)
|
||||
|
||||
// Collect parent tasks in original order
|
||||
allTasks := slices.Collect(w.parentTasks())
|
||||
|
||||
// Available lines: terminal height - 2 (header line + potential "more" line)
|
||||
maxLines := max(terminalHeight-2, 1)
|
||||
|
||||
showMore := len(allTasks) > maxLines
|
||||
tasksToShow := allTasks
|
||||
if showMore {
|
||||
tasksToShow = allTasks[:maxLines-1] // Reserve one line for "more" message
|
||||
}
|
||||
|
||||
// collect line data and compute timerLen
|
||||
lines := make([]lineData, len(tasksToShow))
|
||||
var timerLen int
|
||||
for i, t := range tasksToShow {
|
||||
lines[i] = w.prepareLineData(t)
|
||||
if len(lines[i].timer) > timerLen {
|
||||
timerLen = len(lines[i].timer)
|
||||
}
|
||||
}
|
||||
|
||||
// pad timers so they all have the same visible width
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
if l.timer == "" {
|
||||
continue
|
||||
}
|
||||
timerWidth := utf8.RuneCountInString(l.timer)
|
||||
if timerWidth < timerLen {
|
||||
// Left-pad so the timer's right edge stays aligned on the terminal.
|
||||
// This also prevents stale suffix characters from visually “sticking”
|
||||
// when a previously-rendered timer was wider (e.g. "10.6s" -> "0.0s").
|
||||
l.timer = strings.Repeat(" ", timerLen-timerWidth) + l.timer
|
||||
}
|
||||
}
|
||||
|
||||
// shorten details/taskID to fit terminal width
|
||||
w.adjustLineWidth(lines, timerLen, terminalWidth)
|
||||
|
||||
// compute padding
|
||||
w.applyPadding(lines, terminalWidth, timerLen)
|
||||
|
||||
// Render lines
|
||||
numLines := 0
|
||||
for _, l := range lines {
|
||||
_, _ = fmt.Fprint(w.out, lineText(l))
|
||||
numLines++
|
||||
}
|
||||
|
||||
if showMore {
|
||||
moreCount := len(allTasks) - len(tasksToShow)
|
||||
moreText := fmt.Sprintf(" ... %d more", moreCount)
|
||||
pad := max(terminalWidth-len(moreText), 0)
|
||||
_, _ = fmt.Fprintf(w.out, "%s%s\n", moreText, strings.Repeat(" ", pad))
|
||||
numLines++
|
||||
}
|
||||
|
||||
// Clear any remaining lines from previous render
|
||||
for i := numLines; i < w.numLines; i++ {
|
||||
_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
|
||||
numLines++
|
||||
}
|
||||
w.numLines = numLines
|
||||
}
|
||||
|
||||
func (w *ttyWriter) applyPadding(lines []lineData, terminalWidth int, timerLen int) {
|
||||
var maxBeforeStatus int
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
// Width before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
|
||||
beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
|
||||
if beforeStatus > maxBeforeStatus {
|
||||
maxBeforeStatus = beforeStatus
|
||||
}
|
||||
}
|
||||
|
||||
for i, l := range lines {
|
||||
// Position before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
|
||||
beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
|
||||
// statusPad aligns status; lineText adds 1 more space after statusPad
|
||||
l.statusPad = maxBeforeStatus - beforeStatus
|
||||
|
||||
// Format: beforeStatus + statusPad + space(1) + status
|
||||
lineLen := beforeStatus + l.statusPad + 1 + utf8.RuneCountInString(l.status)
|
||||
if l.details != "" {
|
||||
lineLen += 1 + utf8.RuneCountInString(l.details)
|
||||
}
|
||||
l.timerPad = max(terminalWidth-lineLen-timerLen, 1)
|
||||
lines[i] = l
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidth int) {
|
||||
const minIDLen = 10
|
||||
maxStatusLen := maxStatusLength(lines)
|
||||
|
||||
// Iteratively truncate until all lines fit
|
||||
for range 100 { // safety limit
|
||||
maxBeforeStatus := maxBeforeStatusWidth(lines)
|
||||
overflow := computeOverflow(lines, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth)
|
||||
|
||||
if overflow <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Drop ancillary content (details, progress size info) before touching the taskID.
|
||||
if !truncateDetails(lines, overflow) && !truncateProgressSize(lines) && !truncateLongestTaskID(lines, overflow, minIDLen) {
|
||||
break // Can't truncate further
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maxStatusLength returns the maximum status text length across all lines.
|
||||
func maxStatusLength(lines []lineData) int {
|
||||
var maxLen int
|
||||
for i := range lines {
|
||||
if len(lines[i].status) > maxLen {
|
||||
maxLen = len(lines[i].status)
|
||||
}
|
||||
}
|
||||
return maxLen
|
||||
}
|
||||
|
||||
// maxBeforeStatusWidth computes the maximum width before statusPad across all lines.
|
||||
// This is: space(1) + spinner(1) + prefix + space(1) + taskID + progress
|
||||
func maxBeforeStatusWidth(lines []lineData) int {
|
||||
var maxWidth int
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
width := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
|
||||
if width > maxWidth {
|
||||
maxWidth = width
|
||||
}
|
||||
}
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
// computeOverflow calculates how many characters the widest line exceeds the terminal width.
|
||||
// Returns 0 or negative if all lines fit.
|
||||
func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth int) int {
|
||||
var maxOverflow int
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
detailsLen := len(l.details)
|
||||
if detailsLen > 0 {
|
||||
detailsLen++ // space before details
|
||||
}
|
||||
// Line width: maxBeforeStatus + space(1) + status + details + minTimerPad(1) + timer
|
||||
lineWidth := maxBeforeStatus + 1 + maxStatusLen + detailsLen + 1 + timerLen
|
||||
overflow := lineWidth - terminalWidth
|
||||
if overflow > maxOverflow {
|
||||
maxOverflow = overflow
|
||||
}
|
||||
}
|
||||
return maxOverflow
|
||||
}
|
||||
|
||||
// truncateProgressSize drops the trailing "X.XMB / Y.YMB" size info from the
|
||||
// line currently driving maxBeforeStatusWidth — only that line's shrink can
|
||||
// reduce overflow. Returns true if any line was modified.
|
||||
func truncateProgressSize(lines []lineData) bool {
|
||||
maxIdx := -1
|
||||
var maxWidth int
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
if l.progressSizeBytes == 0 {
|
||||
continue
|
||||
}
|
||||
w := lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
|
||||
if maxIdx < 0 || w > maxWidth {
|
||||
maxWidth = w
|
||||
maxIdx = i
|
||||
}
|
||||
}
|
||||
if maxIdx < 0 {
|
||||
return false
|
||||
}
|
||||
l := &lines[maxIdx]
|
||||
l.progress = l.progress[:len(l.progress)-l.progressSizeBytes]
|
||||
l.progressSizeBytes = 0
|
||||
return true
|
||||
}
|
||||
|
||||
// truncateDetails tries to truncate the first line's details to reduce overflow.
|
||||
// Returns true if any truncation was performed.
|
||||
func truncateDetails(lines []lineData, overflow int) bool {
|
||||
for i := range lines {
|
||||
l := &lines[i]
|
||||
if len(l.details) > 3 {
|
||||
reduction := min(overflow, len(l.details)-3)
|
||||
l.details = l.details[:len(l.details)-reduction-3] + "..."
|
||||
return true
|
||||
} else if l.details != "" {
|
||||
l.details = ""
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncateLongestTaskID truncates the longest taskID to reduce overflow.
|
||||
// Returns true if truncation was performed. Lengths and slicing are in runes
|
||||
// to avoid emitting invalid UTF-8 when taskID contains multi-byte chars.
|
||||
func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool {
|
||||
longestIdx := -1
|
||||
longestLen := minIDLen
|
||||
for i := range lines {
|
||||
if utf8.RuneCountInString(lines[i].taskID) > longestLen {
|
||||
longestLen = utf8.RuneCountInString(lines[i].taskID)
|
||||
longestIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if longestIdx < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
l := &lines[longestIdx]
|
||||
reduction := overflow + 3 // account for "..."
|
||||
newLen := max(longestLen-reduction, minIDLen-3)
|
||||
runes := []rune(l.taskID)
|
||||
l.taskID = string(runes[:newLen]) + "..."
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *ttyWriter) prepareLineData(t *task) lineData {
|
||||
endTime := time.Now()
|
||||
if t.status != api.Working {
|
||||
endTime = t.startTime
|
||||
if (t.endTime != time.Time{}) {
|
||||
endTime = t.endTime
|
||||
}
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
if w.dryRun {
|
||||
prefix = PrefixColor(DRYRUN_PREFIX)
|
||||
}
|
||||
|
||||
elapsed := endTime.Sub(t.startTime).Seconds()
|
||||
|
||||
var (
|
||||
hideDetails bool
|
||||
total int64
|
||||
current int64
|
||||
completion []string
|
||||
)
|
||||
|
||||
// only show the aggregated progress while the root operation is in-progress
|
||||
if t.status == api.Working {
|
||||
for child := range w.childrenTasks(t.ID) {
|
||||
if child.status == api.Working && child.total == 0 {
|
||||
hideDetails = true
|
||||
}
|
||||
total += child.total
|
||||
current += child.current
|
||||
r := len(percentChars) - 1
|
||||
p := min(child.percent, 100)
|
||||
completion = append(completion, percentChars[r*p/100])
|
||||
}
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
hideDetails = true
|
||||
}
|
||||
|
||||
var progress string
|
||||
var progressSizeBytes int
|
||||
if len(completion) > 0 {
|
||||
progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
|
||||
if !hideDetails {
|
||||
sizeInfo := fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
|
||||
progress += sizeInfo
|
||||
progressSizeBytes = len(sizeInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return lineData{
|
||||
spinner: spinner(t),
|
||||
prefix: prefix,
|
||||
taskID: t.ID,
|
||||
progress: progress,
|
||||
progressSizeBytes: progressSizeBytes,
|
||||
status: t.text,
|
||||
statusColor: colorFn(t.status),
|
||||
details: t.details,
|
||||
timer: fmt.Sprintf("%.1fs", elapsed),
|
||||
}
|
||||
}
|
||||
|
||||
func lineText(l lineData) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(l.spinner)
|
||||
sb.WriteString(l.prefix)
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(l.taskID)
|
||||
sb.WriteString(l.progress)
|
||||
sb.WriteString(strings.Repeat(" ", l.statusPad))
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(l.statusColor(l.status))
|
||||
if l.details != "" {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(l.details)
|
||||
}
|
||||
sb.WriteString(strings.Repeat(" ", l.timerPad))
|
||||
sb.WriteString(TimerColor(l.timer))
|
||||
sb.WriteString("\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var (
|
||||
spinnerDone = "✔"
|
||||
spinnerWarning = "!"
|
||||
spinnerError = "✘"
|
||||
)
|
||||
|
||||
func spinner(t *task) string {
|
||||
switch t.status {
|
||||
case api.Done:
|
||||
return SuccessColor(spinnerDone)
|
||||
case api.Warning:
|
||||
return WarningColor(spinnerWarning)
|
||||
case api.Error:
|
||||
return ErrorColor(spinnerError)
|
||||
default:
|
||||
return CountColor(t.spinner.String())
|
||||
}
|
||||
}
|
||||
|
||||
func colorFn(s api.EventStatus) colorFunc {
|
||||
func plainEventColor(s api.EventStatus) colorFunc {
|
||||
switch s {
|
||||
case api.Done:
|
||||
return SuccessColor
|
||||
case api.Warning:
|
||||
return WarningColor
|
||||
case api.Error:
|
||||
return ErrorColor
|
||||
default:
|
||||
return nocolor
|
||||
return SuccessColor
|
||||
}
|
||||
}
|
||||
|
||||
func numDone(tasks map[string]*task) int {
|
||||
i := 0
|
||||
for _, t := range tasks {
|
||||
if t.status != api.Working {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// lenAnsi count of user-perceived characters in ANSI string.
|
||||
func lenAnsi(s string) int {
|
||||
length := 0
|
||||
ansiCode := false
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
ansiCode = true
|
||||
continue
|
||||
}
|
||||
if ansiCode && r == 'm' {
|
||||
ansiCode = false
|
||||
continue
|
||||
}
|
||||
if !ansiCode {
|
||||
length++
|
||||
}
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
var percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
)
|
||||
|
||||
// The layout is a pure function of (taskTree, operation, layoutOpts):
|
||||
// no clocks, no terminal, no mutation. Its single hard invariant, enforced
|
||||
// by renderSegs, is that every returned line occupies at most o.width
|
||||
// terminal cells. This is what makes screen cursor arithmetic sound: a line
|
||||
// that never wraps is a line the cursor can reliably move back over.
|
||||
//
|
||||
// All width computations happen on plain text, in terminal cells (via
|
||||
// go-runewidth, so CJK and other wide runes count as 2); colors are applied
|
||||
// only when a segment's final text is known. ANSI sequences therefore never
|
||||
// enter any measurement.
|
||||
|
||||
// seg is a piece of a row: plain text plus the color to apply once its
|
||||
// geometry is final.
|
||||
type seg struct {
|
||||
text string
|
||||
color colorFunc
|
||||
}
|
||||
|
||||
type layoutOpts struct {
|
||||
width, height int
|
||||
dryRun bool
|
||||
now time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
tickInterval = 100 * time.Millisecond
|
||||
minIDCells = 10
|
||||
// left margin: space + spinner + space
|
||||
marginCells = 3
|
||||
// widest status text worth reserving column space for; longer ones are
|
||||
// truncated rather than allowed to squeeze the id column
|
||||
maxStatusReserveCells = 20
|
||||
)
|
||||
|
||||
// layoutFrame renders the whole progress block, one string per terminal row.
|
||||
func layoutFrame(t *taskTree, operation string, o layoutOpts) []string {
|
||||
done, total := t.counts()
|
||||
lines := []string{fmt.Sprintf("[+] %s %d/%d", operation, done, total)}
|
||||
|
||||
rows := buildRows(t, o)
|
||||
maxRows := max(o.height-2, 1)
|
||||
var more int
|
||||
if len(rows) > maxRows {
|
||||
more = len(rows) - (maxRows - 1)
|
||||
rows = rows[:maxRows-1]
|
||||
}
|
||||
|
||||
cols := computeColumns(rows, o.width)
|
||||
for i := range rows {
|
||||
lines = append(lines, renderSegs(rowSegs(&rows[i], cols, o.width), o.width))
|
||||
}
|
||||
if more > 0 {
|
||||
lines = append(lines, renderSegs([]seg{{text: fmt.Sprintf(" ... %d more", more)}}, o.width))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// row is the pre-rendered content of one line, before column fitting.
|
||||
type row struct {
|
||||
spin seg
|
||||
prefix string // dry-run prefix
|
||||
id string
|
||||
barStrip string // aggregated braille strip, one glyph per child task
|
||||
sizes string // droppable progress suffix appended after the bar
|
||||
status string
|
||||
statusColor colorFunc
|
||||
details string
|
||||
timer string
|
||||
}
|
||||
|
||||
func buildRows(t *taskTree, o layoutOpts) []row {
|
||||
var rows []row
|
||||
for _, root := range t.roots() {
|
||||
rows = append(rows, makeRow(t, root, o))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func makeRow(t *taskTree, n *node, o layoutOpts) row {
|
||||
r := row{
|
||||
spin: spinGlyph(n, o.now),
|
||||
id: n.id,
|
||||
status: n.text,
|
||||
statusColor: colorFn(n.status),
|
||||
details: n.details,
|
||||
timer: fmt.Sprintf("%.1fs", nodeElapsed(n, o.now).Seconds()),
|
||||
}
|
||||
if o.dryRun {
|
||||
r.prefix = DRYRUN_PREFIX
|
||||
}
|
||||
r.barStrip, r.sizes = aggregateProgress(t, n)
|
||||
return r
|
||||
}
|
||||
|
||||
func nodeElapsed(n *node, now time.Time) time.Duration {
|
||||
switch {
|
||||
case n.status == api.Working:
|
||||
return now.Sub(n.startedAt)
|
||||
case !n.endedAt.IsZero():
|
||||
return n.endedAt.Sub(n.startedAt)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// aggregateProgress compresses the children of a root task into a braille
|
||||
// strip (one glyph per child) and a "current / total" size suffix.
|
||||
func aggregateProgress(t *taskTree, root *node) (strip, sizes string) {
|
||||
if root.status != api.Working {
|
||||
return "", ""
|
||||
}
|
||||
var (
|
||||
total, current int64
|
||||
hideSizes bool
|
||||
glyphs []string
|
||||
)
|
||||
for _, child := range t.children(root.id) {
|
||||
if child.status == api.Working && child.total == 0 {
|
||||
hideSizes = true
|
||||
}
|
||||
total += child.total
|
||||
current += child.current
|
||||
r := len(percentChars) - 1
|
||||
p := min(child.percent, 100)
|
||||
glyphs = append(glyphs, percentChars[r*p/100])
|
||||
}
|
||||
if len(glyphs) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
if total == 0 {
|
||||
hideSizes = true
|
||||
}
|
||||
if !hideSizes {
|
||||
sizes = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
|
||||
}
|
||||
return strings.Join(glyphs, ""), sizes
|
||||
}
|
||||
|
||||
// columns holds the shared geometry aligning all rows of a frame.
|
||||
type columns struct {
|
||||
left int // indent + prefix + id + bar + sizes
|
||||
timer int
|
||||
}
|
||||
|
||||
func computeColumns(rows []row, width int) columns {
|
||||
var c columns
|
||||
var status int
|
||||
for i := range rows {
|
||||
r := &rows[i]
|
||||
c.timer = max(c.timer, runewidth.StringWidth(r.timer))
|
||||
c.left = max(c.left, leftWidth(r, true))
|
||||
status = max(status, runewidth.StringWidth(r.status))
|
||||
}
|
||||
// keep room for the widest (capped) status and the filler before the timer
|
||||
reserve := min(status, maxStatusReserveCells) + 2
|
||||
c.left = min(c.left, max(width-marginCells-reserve-c.timer, minIDCells))
|
||||
return c
|
||||
}
|
||||
|
||||
// leftWidth measures the left block of a row in cells.
|
||||
func leftWidth(r *row, withSizes bool) int {
|
||||
w := runewidth.StringWidth(r.prefix) + runewidth.StringWidth(r.id)
|
||||
if r.barStrip != "" {
|
||||
w += 3 + runewidth.StringWidth(r.barStrip) // " [" + strip + "]"
|
||||
}
|
||||
if withSizes {
|
||||
w += runewidth.StringWidth(r.sizes)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// rowSegs lays one row out into segments. Content degrades in a fixed order
|
||||
// when space is short: drop the sizes suffix, truncate the id, truncate
|
||||
// status, truncate then drop details. renderSegs is the final hard guarantee.
|
||||
func rowSegs(r *row, c columns, width int) []seg {
|
||||
var segs []seg
|
||||
used := 0
|
||||
add := func(text string, color colorFunc) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
segs = append(segs, seg{text: text, color: color})
|
||||
used += runewidth.StringWidth(text)
|
||||
}
|
||||
|
||||
add(" ", nil)
|
||||
add(r.spin.text, r.spin.color)
|
||||
add(r.prefix, PrefixColor)
|
||||
add(" ", nil)
|
||||
|
||||
// left block: id + bar + sizes, fitted to the shared left column
|
||||
sizes := r.sizes
|
||||
if leftWidth(r, true) > c.left {
|
||||
sizes = "" // drop the size suffix first
|
||||
}
|
||||
if lw := leftWidth(r, false); lw > c.left {
|
||||
idBudget := max(c.left-(lw-runewidth.StringWidth(r.id)), minIDCells-3)
|
||||
r.id = truncateCells(r.id, idBudget)
|
||||
}
|
||||
add(r.id, nil)
|
||||
if r.barStrip != "" {
|
||||
add(" [", nil)
|
||||
add(r.barStrip, SuccessColor)
|
||||
add("]", nil)
|
||||
}
|
||||
add(sizes, nil)
|
||||
if pad := marginCells + c.left - used; pad > 0 {
|
||||
add(strings.Repeat(" ", pad), nil)
|
||||
}
|
||||
|
||||
// status, then details, each within what remains before the timer
|
||||
if budget := width - used - c.timer - 2; budget > 0 {
|
||||
add(" ", nil)
|
||||
add(truncateCells(r.status, budget), r.statusColor)
|
||||
}
|
||||
if budget := width - used - c.timer - 2; r.details != "" && budget >= 5 {
|
||||
add(" ", nil)
|
||||
add(truncateCells(r.details, budget-1), nil)
|
||||
}
|
||||
|
||||
// right-aligned timer
|
||||
if fill := width - used - c.timer; fill > 0 {
|
||||
add(strings.Repeat(" ", fill), nil)
|
||||
}
|
||||
add(strings.Repeat(" ", max(c.timer-runewidth.StringWidth(r.timer), 0)), nil)
|
||||
add(r.timer, TimerColor)
|
||||
return segs
|
||||
}
|
||||
|
||||
// renderSegs assembles segments into the final styled line, enforcing the
|
||||
// package invariant: the rendered line occupies at most maxCells terminal
|
||||
// cells. Truncation happens on plain text before coloring, so the clip is
|
||||
// both ANSI-safe and UTF-8-safe.
|
||||
func renderSegs(segs []seg, maxCells int) string {
|
||||
var sb strings.Builder
|
||||
cells := 0
|
||||
for _, s := range segs {
|
||||
w := runewidth.StringWidth(s.text)
|
||||
if cells+w > maxCells {
|
||||
s.text = runewidth.Truncate(s.text, maxCells-cells, "")
|
||||
w = runewidth.StringWidth(s.text)
|
||||
}
|
||||
if s.text != "" {
|
||||
if s.color != nil {
|
||||
sb.WriteString(s.color(s.text))
|
||||
} else {
|
||||
sb.WriteString(s.text)
|
||||
}
|
||||
cells += w
|
||||
}
|
||||
if cells >= maxCells {
|
||||
break
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// truncateCells shortens s to the given number of terminal cells, appending
|
||||
// "..." when something was actually cut and space allows.
|
||||
func truncateCells(s string, cells int) string {
|
||||
if cells <= 0 {
|
||||
return ""
|
||||
}
|
||||
if runewidth.StringWidth(s) <= cells {
|
||||
return s
|
||||
}
|
||||
if cells <= 3 {
|
||||
return runewidth.Truncate(s, cells, "")
|
||||
}
|
||||
return runewidth.Truncate(s, cells, "...")
|
||||
}
|
||||
|
||||
var (
|
||||
spinnerDone = "✔"
|
||||
spinnerWarning = "!"
|
||||
spinnerError = "✘"
|
||||
|
||||
// percentChars maps a completion ratio to a braille glyph for the
|
||||
// aggregated per-image strip
|
||||
percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")
|
||||
|
||||
termSpinnerFrames = spinnerFrames()
|
||||
)
|
||||
|
||||
func colorFn(s api.EventStatus) colorFunc {
|
||||
switch s {
|
||||
case api.Done:
|
||||
return SuccessColor
|
||||
case api.Warning:
|
||||
return WarningColor
|
||||
case api.Error:
|
||||
return ErrorColor
|
||||
default:
|
||||
return nocolor
|
||||
}
|
||||
}
|
||||
|
||||
func spinnerFrames() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{"-"}
|
||||
}
|
||||
return []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
}
|
||||
|
||||
// spinGlyph derives the per-row glyph from status and time. The animation
|
||||
// frame is a pure function of the clock, so its speed doesn't depend on how
|
||||
// often the layout runs.
|
||||
func spinGlyph(n *node, now time.Time) seg {
|
||||
switch n.status {
|
||||
case api.Done:
|
||||
return seg{text: spinnerDone, color: SuccessColor}
|
||||
case api.Warning:
|
||||
return seg{text: spinnerWarning, color: WarningColor}
|
||||
case api.Error:
|
||||
return seg{text: spinnerError, color: ErrorColor}
|
||||
default:
|
||||
frame := int(now.Sub(n.startedAt)/tickInterval) % len(termSpinnerFrames)
|
||||
if frame < 0 {
|
||||
frame = 0
|
||||
}
|
||||
return seg{text: termSpinnerFrames[frame], color: CountColor}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package display
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
"github.com/docker/compose/v5/pkg/utils"
|
||||
)
|
||||
|
||||
// node is the rendered state of a single task, fed exclusively by
|
||||
// taskTree.apply. It holds no rendering concern: no spinner, no strings
|
||||
// destined for the screen, no clock reads.
|
||||
type node struct {
|
||||
id string
|
||||
anchor string // first parent seen; only events carrying it update the node
|
||||
parents utils.Set[string]
|
||||
text string
|
||||
details string
|
||||
status api.EventStatus
|
||||
current int64
|
||||
total int64
|
||||
percent int
|
||||
startedAt time.Time
|
||||
endedAt time.Time
|
||||
}
|
||||
|
||||
func (n *node) completed() bool {
|
||||
switch n.status {
|
||||
case api.Done, api.Error, api.Warning:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// taskTree stores tasks in arrival order and resolves parent/child links.
|
||||
// It is a plain data structure: all mutation goes through apply, all time is
|
||||
// injected, so its behavior is exhaustively testable without a terminal.
|
||||
type taskTree struct {
|
||||
order []string
|
||||
nodes map[string]*node
|
||||
}
|
||||
|
||||
func newTaskTree() taskTree {
|
||||
return taskTree{nodes: map[string]*node{}}
|
||||
}
|
||||
|
||||
// apply is the single state transition of the model.
|
||||
func (t *taskTree) apply(e api.Resource, now time.Time) {
|
||||
n, ok := t.nodes[e.ID]
|
||||
if !ok {
|
||||
n = &node{
|
||||
id: e.ID,
|
||||
anchor: e.ParentID,
|
||||
parents: utils.NewSet[string](),
|
||||
startedAt: now,
|
||||
}
|
||||
t.nodes[e.ID] = n
|
||||
t.order = append(t.order, e.ID)
|
||||
}
|
||||
if e.ParentID != "" {
|
||||
n.parents.Add(e.ParentID)
|
||||
// Layers shared by several images receive the same events once per
|
||||
// image. Accept updates from the first declared parent only, so the
|
||||
// rendered state doesn't flicker between concurrent pull streams.
|
||||
if n.anchor != e.ParentID {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
wasCompleted := n.completed()
|
||||
n.status = e.Status
|
||||
n.text = e.Text
|
||||
n.details = e.Details
|
||||
// progress is monotonic: out-of-order events must not move bars backwards
|
||||
n.total = max(n.total, e.Total)
|
||||
n.current = max(n.current, e.Current)
|
||||
n.percent = max(n.percent, e.Percent)
|
||||
if n.completed() && !wasCompleted {
|
||||
n.endedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
// roots returns the top-level tasks in arrival order.
|
||||
func (t *taskTree) roots() []*node {
|
||||
var roots []*node
|
||||
for _, id := range t.order {
|
||||
if n := t.nodes[id]; len(n.parents) == 0 {
|
||||
roots = append(roots, n)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// children returns the tasks attached to parent, in arrival order. A layer
|
||||
// shared by several images is listed under each of them.
|
||||
func (t *taskTree) children(parent string) []*node {
|
||||
var children []*node
|
||||
for _, id := range t.order {
|
||||
if n := t.nodes[id]; n.parents.Has(parent) {
|
||||
children = append(children, n)
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// counts returns completed and total task counts, children included, to
|
||||
// preserve the historical "[+] pull 3/15" header semantics.
|
||||
func (t *taskTree) counts() (done, total int) {
|
||||
for _, n := range t.nodes {
|
||||
if n.completed() {
|
||||
done++
|
||||
}
|
||||
}
|
||||
return done, len(t.nodes)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package display
|
||||
|
||||
import (
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/morikuni/aec"
|
||||
)
|
||||
|
||||
// screen owns the terminal region the progress block is painted on.
|
||||
//
|
||||
// It relies on a single contract with the layout: every line fits the
|
||||
// terminal width, so one string is one terminal row and the cursor can move
|
||||
// back over the block with plain relative moves. Repainting diffs against
|
||||
// the previous frame: unchanged rows are skipped (a bare newline), changed
|
||||
// rows are erased and rewritten, leftover rows from a taller previous frame
|
||||
// are blanked. Each frame is flushed as a single Write.
|
||||
type screen struct {
|
||||
out io.Writer
|
||||
prev []string
|
||||
prevWidth int
|
||||
}
|
||||
|
||||
// reset forgets the painted block: the next paint starts fresh at the
|
||||
// current cursor position. Used when foreign output (buildkit, a resize
|
||||
// reflow) may have invalidated our notion of where the block lives.
|
||||
func (s *screen) reset() {
|
||||
s.prev = nil
|
||||
s.prevWidth = 0
|
||||
}
|
||||
|
||||
func (s *screen) paint(rows []string, width int) {
|
||||
if width < s.prevWidth {
|
||||
// The terminal shrank: previously painted rows may have wrapped and
|
||||
// the terminal may have reflowed them, so cursor arithmetic against
|
||||
// the old block is meaningless. Leave it behind and start a new one.
|
||||
s.reset()
|
||||
}
|
||||
redrawAll := width != s.prevWidth
|
||||
if !redrawAll && slices.Equal(rows, s.prev) {
|
||||
return // nothing changed, write nothing
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(aec.Hide.String())
|
||||
if n := len(s.prev); n > 0 {
|
||||
b.WriteString(aec.Up(uint(n)).String())
|
||||
}
|
||||
b.WriteString(aec.Column(0).String())
|
||||
for i, row := range rows {
|
||||
if !redrawAll && i < len(s.prev) && s.prev[i] == row {
|
||||
b.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
b.WriteString(aec.EraseLine(aec.EraseModes.All).String())
|
||||
b.WriteString(row)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if extra := len(s.prev) - len(rows); extra > 0 {
|
||||
for range extra {
|
||||
b.WriteString(aec.EraseLine(aec.EraseModes.All).String())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(aec.Up(uint(extra)).String())
|
||||
}
|
||||
b.WriteString(aec.Show.String())
|
||||
_, _ = io.WriteString(s.out, b.String())
|
||||
|
||||
s.prev = rows
|
||||
s.prevWidth = width
|
||||
}
|
||||
+163
-663
@@ -20,356 +20,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
)
|
||||
|
||||
func newTestWriter() (*ttyWriter, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
w := &ttyWriter{
|
||||
out: &buf,
|
||||
info: &buf,
|
||||
tasks: map[string]*task{},
|
||||
done: newDoneSignal(),
|
||||
mtx: &sync.Mutex{},
|
||||
operation: "pull",
|
||||
}
|
||||
return w, &buf
|
||||
}
|
||||
|
||||
func addTask(w *ttyWriter, id, text, details string, status api.EventStatus) {
|
||||
t := &task{
|
||||
ID: id,
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now(),
|
||||
text: text,
|
||||
details: details,
|
||||
status: status,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks[id] = t
|
||||
w.ids = append(w.ids, id)
|
||||
}
|
||||
|
||||
// extractLines parses the output buffer and returns lines without ANSI control sequences
|
||||
func extractLines(buf *bytes.Buffer) []string {
|
||||
content := buf.String()
|
||||
// Split by newline
|
||||
rawLines := strings.Split(content, "\n")
|
||||
var lines []string
|
||||
for _, line := range rawLines {
|
||||
// Skip empty lines and lines that are just ANSI codes
|
||||
if lenAnsi(line) > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_LinesFitTerminalWidth(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
taskID string
|
||||
status string
|
||||
details string
|
||||
terminalWidth int
|
||||
}{
|
||||
{
|
||||
name: "short task fits wide terminal",
|
||||
taskID: "Image foo",
|
||||
status: "Pulling",
|
||||
details: "layer abc123",
|
||||
terminalWidth: 100,
|
||||
},
|
||||
{
|
||||
name: "long details truncated to fit",
|
||||
taskID: "Image foo",
|
||||
status: "Pulling",
|
||||
details: "downloading layer sha256:abc123def456789xyz0123456789abcdef",
|
||||
terminalWidth: 50,
|
||||
},
|
||||
{
|
||||
name: "long taskID truncated to fit",
|
||||
taskID: "very-long-image-name-that-exceeds-terminal-width",
|
||||
status: "Pulling",
|
||||
details: "",
|
||||
terminalWidth: 40,
|
||||
},
|
||||
{
|
||||
name: "both long taskID and details",
|
||||
taskID: "my-very-long-service-name-here",
|
||||
status: "Downloading",
|
||||
details: "layer sha256:abc123def456789xyz0123456789",
|
||||
terminalWidth: 50,
|
||||
},
|
||||
{
|
||||
name: "narrow terminal",
|
||||
taskID: "service-name",
|
||||
status: "Pulling",
|
||||
details: "some details",
|
||||
terminalWidth: 35,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
addTask(w, tc.taskID, tc.status, tc.details, api.Working)
|
||||
|
||||
w.printWithDimensions(tc.terminalWidth, 24)
|
||||
|
||||
lines := extractLines(buf)
|
||||
for i, line := range lines {
|
||||
lineLen := lenAnsi(line)
|
||||
assert.Assert(t, lineLen <= tc.terminalWidth,
|
||||
"line %d has length %d which exceeds terminal width %d: %q",
|
||||
i, lineLen, tc.terminalWidth, line)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_MultipleTasksFitTerminalWidth(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
|
||||
// Add multiple tasks with varying lengths
|
||||
addTask(w, "Image nginx", "Pulling", "layer sha256:abc123", api.Working)
|
||||
addTask(w, "Image postgres-database", "Pulling", "downloading", api.Working)
|
||||
addTask(w, "Image redis", "Pulled", "", api.Done)
|
||||
|
||||
terminalWidth := 60
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
lines := extractLines(buf)
|
||||
for i, line := range lines {
|
||||
lineLen := lenAnsi(line)
|
||||
assert.Assert(t, lineLen <= terminalWidth,
|
||||
"line %d has length %d which exceeds terminal width %d: %q",
|
||||
i, lineLen, terminalWidth, line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_VeryNarrowTerminal(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
addTask(w, "Image nginx", "Pulling", "details", api.Working)
|
||||
|
||||
terminalWidth := 30
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
lines := extractLines(buf)
|
||||
for i, line := range lines {
|
||||
lineLen := lenAnsi(line)
|
||||
assert.Assert(t, lineLen <= terminalWidth,
|
||||
"line %d has length %d which exceeds terminal width %d: %q",
|
||||
i, lineLen, terminalWidth, line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_TaskWithProgress(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
|
||||
// Create parent task
|
||||
parent := &task{
|
||||
ID: "Image nginx",
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now(),
|
||||
text: "Pulling",
|
||||
status: api.Working,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks["Image nginx"] = parent
|
||||
w.ids = append(w.ids, "Image nginx")
|
||||
|
||||
// Create child tasks to trigger progress display
|
||||
for i := range 3 {
|
||||
child := &task{
|
||||
ID: "layer" + string(rune('a'+i)),
|
||||
parents: map[string]struct{}{"Image nginx": {}},
|
||||
startTime: time.Now(),
|
||||
text: "Downloading",
|
||||
status: api.Working,
|
||||
total: 1000,
|
||||
current: 500,
|
||||
percent: 50,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks[child.ID] = child
|
||||
w.ids = append(w.ids, child.ID)
|
||||
}
|
||||
|
||||
terminalWidth := 80
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
lines := extractLines(buf)
|
||||
for i, line := range lines {
|
||||
lineLen := lenAnsi(line)
|
||||
assert.Assert(t, lineLen <= terminalWidth,
|
||||
"line %d has length %d which exceeds terminal width %d: %q",
|
||||
i, lineLen, terminalWidth, line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustLineWidth_DetailsCorrectlyTruncated(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: "Image foo",
|
||||
status: "Pulling",
|
||||
details: "downloading layer sha256:abc123def456789xyz",
|
||||
},
|
||||
}
|
||||
|
||||
terminalWidth := 50
|
||||
timerLen := 5
|
||||
w.adjustLineWidth(lines, timerLen, terminalWidth)
|
||||
|
||||
// Verify the line fits
|
||||
detailsLen := len(lines[0].details)
|
||||
if detailsLen > 0 {
|
||||
detailsLen++ // space before details
|
||||
}
|
||||
// widthWithoutDetails = 5 + prefix(0) + taskID(9) + progress(0) + status(7) + timer(5) = 26
|
||||
lineWidth := 5 + len(lines[0].taskID) + len(lines[0].status) + detailsLen + timerLen
|
||||
|
||||
assert.Assert(t, lineWidth <= terminalWidth,
|
||||
"line width %d should not exceed terminal width %d (taskID=%q, details=%q)",
|
||||
lineWidth, terminalWidth, lines[0].taskID, lines[0].details)
|
||||
|
||||
// Verify details were truncated (not removed entirely)
|
||||
assert.Assert(t, lines[0].details != "", "details should be truncated, not removed")
|
||||
assert.Assert(t, strings.HasSuffix(lines[0].details, "..."), "truncated details should end with ...")
|
||||
}
|
||||
|
||||
func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: "very-long-image-name-that-exceeds-minimum-length",
|
||||
status: "Pulling",
|
||||
details: "",
|
||||
},
|
||||
}
|
||||
|
||||
terminalWidth := 40
|
||||
timerLen := 5
|
||||
w.adjustLineWidth(lines, timerLen, terminalWidth)
|
||||
|
||||
lineWidth := 5 + len(lines[0].taskID) + 7 + timerLen
|
||||
|
||||
assert.Assert(t, lineWidth <= terminalWidth,
|
||||
"line width %d should not exceed terminal width %d (taskID=%q)",
|
||||
lineWidth, terminalWidth, lines[0].taskID)
|
||||
|
||||
assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...")
|
||||
}
|
||||
|
||||
// TestAdjustLineWidth_MultiByteTaskIDFits guards against drift between
|
||||
// applyPadding (rune-based) and maxBeforeStatusWidth (formerly byte-based):
|
||||
// a byte-based measurement falsely flags overflow for multi-byte taskIDs.
|
||||
func TestAdjustLineWidth_MultiByteTaskIDFits(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
taskID := "Image 测试测试" // 10 runes, 18 bytes
|
||||
lines := []lineData{{
|
||||
taskID: taskID,
|
||||
status: "Pulling",
|
||||
}}
|
||||
|
||||
// terminalWidth=30 fits in runes (3+10+1+7+1+4 = 26) but not in bytes
|
||||
// (3+18+1+7+1+4 = 34), so a byte-based measurement would truncate.
|
||||
w.adjustLineWidth(lines, 4, 30)
|
||||
|
||||
assert.Equal(t, taskID, lines[0].taskID,
|
||||
"taskID should not be modified when it fits terminal width in runes")
|
||||
}
|
||||
|
||||
// TestTruncateLongestTaskID_PreservesValidUTF8 verifies that when truncation
|
||||
// of a multi-byte UTF-8 taskID is genuinely required, the resulting string
|
||||
// remains valid UTF-8. Byte-indexed slicing can land mid-rune and emit
|
||||
// replacement characters (�) into the rendered output.
|
||||
func TestTruncateLongestTaskID_PreservesValidUTF8(t *testing.T) {
|
||||
taskID := "Image 测试测试测试测试" // 14 runes, 30 bytes
|
||||
lines := []lineData{{taskID: taskID}}
|
||||
|
||||
truncateLongestTaskID(lines, 8, 10)
|
||||
|
||||
assert.Assert(t, utf8.ValidString(lines[0].taskID),
|
||||
"truncated taskID must remain valid UTF-8, got %q", lines[0].taskID)
|
||||
assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."),
|
||||
"truncated taskID should end with ..., got %q", lines[0].taskID)
|
||||
}
|
||||
|
||||
// TestTruncateProgressSize_PicksWidestLine verifies that dropping the size
|
||||
// suffix targets the line currently driving maxBeforeStatusWidth (the only
|
||||
// line whose shrink can reduce overflow), preserving size info on narrower
|
||||
// lines that are not the bottleneck.
|
||||
func TestTruncateProgressSize_PicksWidestLine(t *testing.T) {
|
||||
narrowSuffix := " 5MB / 10MB"
|
||||
wideSuffix := " 50MB / 100MB"
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: "Image short",
|
||||
progress: " [⣿⣿]" + narrowSuffix,
|
||||
progressSizeBytes: len(narrowSuffix),
|
||||
},
|
||||
{
|
||||
taskID: "Image very-long-named-task",
|
||||
progress: " [⣿⣿⣿⣿⣿⣿⣿⣿]" + wideSuffix,
|
||||
progressSizeBytes: len(wideSuffix),
|
||||
},
|
||||
}
|
||||
|
||||
truncateProgressSize(lines)
|
||||
|
||||
assert.Equal(t, 0, lines[1].progressSizeBytes,
|
||||
"widest line should lose its size suffix first")
|
||||
assert.Equal(t, len(narrowSuffix), lines[0].progressSizeBytes,
|
||||
"narrower line should retain its size suffix")
|
||||
}
|
||||
|
||||
func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
originalDetails := "short"
|
||||
originalTaskID := "Image foo"
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: originalTaskID,
|
||||
status: "Pulling",
|
||||
details: originalDetails,
|
||||
},
|
||||
}
|
||||
|
||||
// Wide terminal, nothing should be truncated
|
||||
w.adjustLineWidth(lines, 5, 100)
|
||||
|
||||
assert.Equal(t, originalTaskID, lines[0].taskID, "taskID should not be modified")
|
||||
assert.Equal(t, originalDetails, lines[0].details, "details should not be modified")
|
||||
}
|
||||
|
||||
func TestAdjustLineWidth_DetailsRemovedWhenTooShort(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
lines := []lineData{
|
||||
{
|
||||
taskID: "Image foo",
|
||||
status: "Pulling",
|
||||
details: "abc", // Very short, can't be meaningfully truncated
|
||||
},
|
||||
}
|
||||
|
||||
// Terminal so narrow that even minimal details + "..." wouldn't help
|
||||
w.adjustLineWidth(lines, 5, 28)
|
||||
|
||||
assert.Equal(t, "", lines[0].details, "details should be removed entirely when too short to truncate")
|
||||
}
|
||||
|
||||
// stripAnsi removes ANSI escape codes from a string
|
||||
func stripAnsi(s string) string {
|
||||
var result strings.Builder
|
||||
@@ -391,376 +52,215 @@ func stripAnsi(s string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_PulledAndPullingWithLongIDs(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
// testClock returns a controllable clock starting at a fixed instant.
|
||||
func testClock() (func() time.Time, *time.Time) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
return func() time.Time { return now }, &now
|
||||
}
|
||||
|
||||
// Add a completed task with long ID
|
||||
completedTask := &task{
|
||||
ID: "Image docker.io/library/nginx-long-name",
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now().Add(-2 * time.Second),
|
||||
endTime: time.Now(),
|
||||
text: "Pulled",
|
||||
status: api.Done,
|
||||
spinner: NewSpinner(),
|
||||
func newTermWriter(width, height int) (*termWriter, *bytes.Buffer, *time.Time) {
|
||||
var buf bytes.Buffer
|
||||
clock, now := testClock()
|
||||
w := &termWriter{
|
||||
out: &buf,
|
||||
info: &buf,
|
||||
tree: newTaskTree(),
|
||||
scr: screen{out: &buf},
|
||||
size: func() (int, int) { return width, height },
|
||||
now: clock,
|
||||
operation: "pull",
|
||||
}
|
||||
completedTask.spinner.Stop()
|
||||
w.tasks[completedTask.ID] = completedTask
|
||||
w.ids = append(w.ids, completedTask.ID)
|
||||
return w, &buf, now
|
||||
}
|
||||
|
||||
// Add a pending task with long ID
|
||||
pendingTask := &task{
|
||||
ID: "Image docker.io/library/postgres-database",
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now(),
|
||||
text: "Pulling",
|
||||
status: api.Working,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks[pendingTask.ID] = pendingTask
|
||||
w.ids = append(w.ids, pendingTask.ID)
|
||||
|
||||
terminalWidth := 50
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
// Strip all ANSI codes from output and split by newline
|
||||
stripped := stripAnsi(buf.String())
|
||||
lines := strings.Split(stripped, "\n")
|
||||
|
||||
// Filter non-empty lines
|
||||
var nonEmptyLines []string
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
nonEmptyLines = append(nonEmptyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Expected output format (50 runes per task line)
|
||||
expected := `[+] pull 1/2
|
||||
✔ Image docker.io/library/nginx-l... Pulled 2.0s
|
||||
⠋ Image docker.io/library/postgre... Pulling 0.0s`
|
||||
|
||||
expectedLines := strings.Split(expected, "\n")
|
||||
|
||||
// Debug output
|
||||
t.Logf("Actual output:\n")
|
||||
for i, line := range nonEmptyLines {
|
||||
t.Logf(" line %d (%2d runes): %q", i, utf8.RuneCountInString(line), line)
|
||||
}
|
||||
|
||||
// Verify number of lines
|
||||
assert.Equal(t, len(expectedLines), len(nonEmptyLines), "number of lines should match")
|
||||
|
||||
// Verify each line matches expected
|
||||
for i, line := range nonEmptyLines {
|
||||
if i < len(expectedLines) {
|
||||
assert.Equal(t, expectedLines[i], line,
|
||||
"line %d should match expected", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify task lines fit within terminal width (strict - no tolerance)
|
||||
for i, line := range nonEmptyLines {
|
||||
if i > 0 { // Skip header line
|
||||
runeCount := utf8.RuneCountInString(line)
|
||||
assert.Assert(t, runeCount <= terminalWidth,
|
||||
"line %d has %d runes which exceeds terminal width %d: %q",
|
||||
i, runeCount, terminalWidth, line)
|
||||
}
|
||||
// feed applies events at the writer's current clock.
|
||||
func feed(w *termWriter, events ...api.Resource) {
|
||||
for _, e := range events {
|
||||
w.tree.apply(e, w.now())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_TimerIsRightAligned(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
|
||||
base := time.Unix(0, 0)
|
||||
|
||||
// Long timer: "10.6s" (length 5)
|
||||
longTask := &task{
|
||||
ID: "task-long",
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: base,
|
||||
endTime: base.Add(10*time.Second + 600*time.Millisecond),
|
||||
text: "Pulled",
|
||||
status: api.Done,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
longTask.spinner.Stop()
|
||||
w.tasks[longTask.ID] = longTask
|
||||
w.ids = append(w.ids, longTask.ID)
|
||||
|
||||
// Short timer: "0.0s" (length 4)
|
||||
shortTask := &task{
|
||||
ID: "task-short",
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: base,
|
||||
endTime: base,
|
||||
text: "Pulled",
|
||||
status: api.Done,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
shortTask.spinner.Stop()
|
||||
w.tasks[shortTask.ID] = shortTask
|
||||
w.ids = append(w.ids, shortTask.ID)
|
||||
|
||||
terminalWidth := 80
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
|
||||
// Strip ANSI codes from output and split by newline
|
||||
stripped := stripAnsi(buf.String())
|
||||
lines := strings.Split(stripped, "\n")
|
||||
|
||||
var nonEmptyLines []string
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
nonEmptyLines = append(nonEmptyLines, line)
|
||||
// visualLines strips ANSI sequences from the painted frame and returns the
|
||||
// visible rows.
|
||||
func visualLines(buf *bytes.Buffer) []string {
|
||||
var lines []string
|
||||
for _, l := range strings.Split(stripAnsi(buf.String()), "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the line containing the shorter timer.
|
||||
var shortLine string
|
||||
for _, line := range nonEmptyLines {
|
||||
if strings.Contains(line, "0.0s") {
|
||||
shortLine = line
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.Assert(t, shortLine != "", "expected to find a rendered line containing \"0.0s\"")
|
||||
assert.Assert(t, strings.HasSuffix(shortLine, "0.0s"),
|
||||
"short timer should be left-padded (no trailing spaces after the timer); got: %q",
|
||||
shortLine)
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestLenAnsi(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected int
|
||||
}{
|
||||
{"hello", 5},
|
||||
{"\x1b[32mhello\x1b[0m", 5},
|
||||
{"\x1b[1;32mgreen\x1b[0m text", 10},
|
||||
{"", 0},
|
||||
{"\x1b[0m", 0},
|
||||
// adversarialEvents exercises every historical overflow trigger at once:
|
||||
// unbounded status text (skippedEvent), CJK ids, long error details, layers
|
||||
// with and without totals.
|
||||
func adversarialEvents() []api.Resource {
|
||||
events := []api.Resource{
|
||||
{ID: "app", Status: api.Warning, Text: "Skipped: current commandline does not match manifest, and image has no local build metadata"},
|
||||
{ID: "Image 测试测试测试测试-with-a-very-long-tag:v1.2.3-alpha.4", Status: api.Working, Text: "Pulling"},
|
||||
{ID: "db", Status: api.Error, Text: "Error", Details: "réseau « frontend » introuvable — vérifiez la configuration réseau du projet et les alias déclarés"},
|
||||
}
|
||||
for i := range 20 {
|
||||
events = append(events, api.Resource{
|
||||
ID: fmt.Sprintf("layer-%02d", i),
|
||||
ParentID: "Image 测试测试测试测试-with-a-very-long-tag:v1.2.3-alpha.4",
|
||||
Status: api.Working,
|
||||
Text: "Downloading",
|
||||
Current: int64(i) * 1_000_000,
|
||||
Total: 50_000_000,
|
||||
Percent: i * 5,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := lenAnsi(tc.input)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
func TestTerm_NoLineEverExceedsTerminalWidth(t *testing.T) {
|
||||
for _, width := range []int{20, 30, 40, 60, 80, 120} {
|
||||
t.Run(fmt.Sprintf("width=%d", width), func(t *testing.T) {
|
||||
w, buf, _ := newTermWriter(width, 40)
|
||||
feed(w, adversarialEvents()...)
|
||||
w.repaint()
|
||||
|
||||
for i, line := range visualLines(buf) {
|
||||
got := runewidth.StringWidth(strings.TrimRight(line, " "))
|
||||
assert.Assert(t, got <= width,
|
||||
"line %d is %d cells wide (> %d): %q", i, got, width, line)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoneBeforeStartDoesNotPanic guards against a nil-pointer panic if Done
|
||||
// is ever called without a prior Start: Full must initialize done, since
|
||||
// ttyWriter is exposed only through the public api.EventProcessor interface
|
||||
// and a caller isn't guaranteed to call Start first.
|
||||
func TestDoneBeforeStartDoesNotPanic(t *testing.T) {
|
||||
w := Full(io.Discard, io.Discard, false)
|
||||
w.Done("op", false)
|
||||
}
|
||||
func TestTerm_TruncationPreservesUTF8(t *testing.T) {
|
||||
w, buf, _ := newTermWriter(40, 24)
|
||||
feed(w, adversarialEvents()...)
|
||||
w.repaint()
|
||||
|
||||
func TestDoneDeadlockFix(t *testing.T) {
|
||||
w, _ := newTestWriter()
|
||||
addTask(w, "test-task", "Working", "details", api.Working)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
w.Start(ctx, "test")
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
w.Done("test", true)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Deadlock detected: Done() did not complete within 5 seconds")
|
||||
for i, line := range visualLines(buf) {
|
||||
assert.Assert(t, utf8.ValidString(line),
|
||||
"line %d is invalid UTF-8 after truncation: %q", i, line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoneAfterContextCancelDoesNotHang is the regression test for
|
||||
// docker/compose#14114: a single SIGTERM/SIGINT cancels the command context
|
||||
// (AdaptCmd). The render goroutine started by Start then exits through its
|
||||
// ctx.Done() branch and never receives from the done channel. Done must
|
||||
// still return.
|
||||
func TestDoneAfterContextCancelDoesNotHang(t *testing.T) {
|
||||
w, _ := newTestWriter()
|
||||
// Done must never block, even when the operation context was cancelled
|
||||
// (Ctrl-C) before Done runs — the historical unbuffered-channel handshake
|
||||
// deadlocked in that ordering.
|
||||
func TestTerm_DoneReturnsAfterContextCancel(t *testing.T) {
|
||||
w, _, _ := newTermWriter(80, 24)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
w.Start(ctx, "down")
|
||||
w.Start(ctx, "pull")
|
||||
w.On(api.Resource{ID: "Image foo", Text: "Pulling", Status: api.Working})
|
||||
cancel()
|
||||
// Let the render goroutine observe the cancellation and exit.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond) // let the refresh goroutine exit
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
w.Done("down", false)
|
||||
w.Done("pull", true)
|
||||
close(finished)
|
||||
}()
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ttyWriter.Done blocked forever after context cancellation")
|
||||
t.Fatal("Done() blocked after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNestedStartDoneDoesNotPanic guards against a panic if Start/Done are
|
||||
// ever nested (a Start called again before the matching Done): closing a
|
||||
// channel more than once panics unless each cycle's own doneSignal (with its
|
||||
// own sync.Once) is used, instead of a single shared channel. It also
|
||||
// guards against a goroutine leak: the outer cycle's render goroutine must
|
||||
// not be left waiting on a doneSignal nobody closes once Start replaces it.
|
||||
func TestNestedStartDoneDoesNotPanic(t *testing.T) {
|
||||
w, _ := newTestWriter()
|
||||
ctx := t.Context()
|
||||
// The spinner animation must be a function of time, not of how often the
|
||||
// layout runs.
|
||||
func TestTerm_SpinnerIsTimeBased(t *testing.T) {
|
||||
clock, now := testClock()
|
||||
n := &node{status: api.Working, startedAt: clock()}
|
||||
|
||||
w.Start(ctx, "outer")
|
||||
w.Start(ctx, "inner")
|
||||
w.Done("inner", false)
|
||||
w.Done("outer", false)
|
||||
a := spinGlyph(n, *now)
|
||||
b := spinGlyph(n, *now)
|
||||
assert.Equal(t, a.text, b.text, "same instant must yield the same frame")
|
||||
|
||||
// give the outer cycle's render goroutine a chance to observe the
|
||||
// second Start closing its signal and exit before goleak checks.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
goleak.VerifyNone(t)
|
||||
*now = now.Add(tickInterval)
|
||||
c := spinGlyph(n, *now)
|
||||
assert.Assert(t, a.text != c.text, "advancing the clock must advance the frame")
|
||||
}
|
||||
|
||||
// TestSequentialStartDoneEachGetFreshChannel is the regression test for the
|
||||
// bus going through more than one Start/Done cycle in its lifetime, as
|
||||
// happens with `docker compose rm --stop` (a "stop" cycle, then its own
|
||||
// "remove" cycle): each Start must allocate a fresh doneSignal, independent
|
||||
// of any earlier cycle's already-closed one, or the second cycle's render
|
||||
// goroutine would see it as immediately closed and exit without ever
|
||||
// ticking.
|
||||
func TestSequentialStartDoneEachGetFreshChannel(t *testing.T) {
|
||||
w, _ := newTestWriter()
|
||||
ctx := t.Context()
|
||||
func TestTerm_DiffRepaintSkipsUnchangedRows(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := screen{out: &buf}
|
||||
s.paint([]string{"header", "row a", "row b"}, 80)
|
||||
|
||||
w.Start(ctx, "first")
|
||||
first := w.done
|
||||
w.Done("first", false)
|
||||
select {
|
||||
case <-first.ch:
|
||||
default:
|
||||
t.Fatal("expected the first cycle's done channel to be closed after its Done")
|
||||
}
|
||||
buf.Reset()
|
||||
s.paint([]string{"header", "row a CHANGED", "row b"}, 80)
|
||||
out := buf.String()
|
||||
erases := strings.Count(out, "\x1b[2K")
|
||||
assert.Equal(t, 1, erases, "only the changed row should be erased and rewritten, got %d in %q", erases, out)
|
||||
assert.Assert(t, !strings.Contains(out, "header"), "unchanged header must not be rewritten")
|
||||
assert.Assert(t, strings.Contains(out, "row a CHANGED"))
|
||||
|
||||
w.Start(ctx, "second")
|
||||
second := w.done
|
||||
if second == first {
|
||||
t.Fatal("expected Start to allocate a fresh done signal for the new cycle")
|
||||
}
|
||||
select {
|
||||
case <-second.ch:
|
||||
t.Fatal("expected the second cycle's done channel to still be open before its own Done")
|
||||
default:
|
||||
}
|
||||
|
||||
w.Done("second", false)
|
||||
select {
|
||||
case <-second.ch:
|
||||
default:
|
||||
t.Fatal("expected the second cycle's done channel to be closed after its own Done")
|
||||
}
|
||||
buf.Reset()
|
||||
s.paint([]string{"header", "row a CHANGED", "row b"}, 80)
|
||||
assert.Equal(t, "", buf.String(), "identical frame must write nothing")
|
||||
}
|
||||
|
||||
// TestAdjustLineWidth_WideProgressForcesSizeInfoDrop is the unit-level
|
||||
// regression test for docker/compose#13595. When progress contains the
|
||||
// " X.XMB / Y.YMB" size suffix and the bar makes beforeStatus large enough
|
||||
// to overflow terminalWidth, taskID truncation alone cannot make the line
|
||||
// fit: applyPadding's max(timerPad, 1) floor adds one char back, and the
|
||||
// "..."-padding minimum (10 chars) on taskID puts a lower bound on
|
||||
// beforeStatus. The size info portion of progress must therefore be
|
||||
// droppable when overflow can't be eliminated otherwise.
|
||||
func TestAdjustLineWidth_WideProgressForcesSizeInfoDrop(t *testing.T) {
|
||||
w := &ttyWriter{}
|
||||
// Mirror prepareLineData's layout: " [bar]" + " %7s / %-7s".
|
||||
sizeSuffix := " 50MB / 100MB "
|
||||
progress := " [" + strings.Repeat("⣿", 30) + "]" + sizeSuffix
|
||||
lines := []lineData{{
|
||||
taskID: "Image mariadb:11",
|
||||
progress: progress,
|
||||
progressSizeBytes: len(sizeSuffix),
|
||||
status: "Pulling",
|
||||
statusColor: nocolor,
|
||||
spinner: " ",
|
||||
timer: "5.4s",
|
||||
}}
|
||||
func TestTerm_ShrinkingFrameBlanksLeftoverRows(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := screen{out: &buf}
|
||||
s.paint([]string{"header", "row a", "row b"}, 80)
|
||||
|
||||
terminalWidth := 60
|
||||
timerLen := 4
|
||||
w.adjustLineWidth(lines, timerLen, terminalWidth)
|
||||
w.applyPadding(lines, terminalWidth, timerLen)
|
||||
|
||||
rendered := strings.TrimRight(lineText(lines[0]), "\n")
|
||||
assert.Assert(t, lenAnsi(rendered) <= terminalWidth,
|
||||
"line length %d should not exceed terminal width %d: %q",
|
||||
lenAnsi(rendered), terminalWidth, rendered)
|
||||
buf.Reset()
|
||||
s.paint([]string{"header", "row a"}, 80)
|
||||
assert.Equal(t, 1, strings.Count(buf.String(), "\x1b[2K"),
|
||||
"the leftover row must be blanked")
|
||||
}
|
||||
|
||||
// addParentWithDownloadingChildren wires a parent task with N children whose
|
||||
// non-zero totals trigger the " X.XMB / Y.YMB" suffix in prepareLineData's
|
||||
// progress field. Used by the multi-render regression test below.
|
||||
func addParentWithDownloadingChildren(w *ttyWriter, parentID string, children int, totalBytes int64) {
|
||||
parent := &task{
|
||||
ID: parentID,
|
||||
parents: make(map[string]struct{}),
|
||||
startTime: time.Now(),
|
||||
text: "Pulling",
|
||||
status: api.Working,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks[parent.ID] = parent
|
||||
w.ids = append(w.ids, parent.ID)
|
||||
for i := range children {
|
||||
c := &task{
|
||||
ID: fmt.Sprintf("%s/layer%d", parentID, i),
|
||||
parents: map[string]struct{}{parent.ID: {}},
|
||||
startTime: time.Now(),
|
||||
text: "Downloading",
|
||||
status: api.Working,
|
||||
total: totalBytes / int64(children),
|
||||
current: totalBytes / int64(children) / 2,
|
||||
percent: 50,
|
||||
spinner: NewSpinner(),
|
||||
}
|
||||
w.tasks[c.ID] = c
|
||||
w.ids = append(w.ids, c.ID)
|
||||
func TestTerm_HeightCapAddsMoreMarker(t *testing.T) {
|
||||
w, buf, _ := newTermWriter(80, 10)
|
||||
var events []api.Resource
|
||||
for i := range 30 {
|
||||
events = append(events, api.Resource{ID: fmt.Sprintf("service-%02d", i), Text: "Creating", Status: api.Working})
|
||||
}
|
||||
feed(w, events...)
|
||||
w.repaint()
|
||||
|
||||
lines := visualLines(buf)
|
||||
assert.Assert(t, len(lines) <= 10, "must not paint more rows than the terminal height, got %d", len(lines))
|
||||
assert.Assert(t, strings.Contains(lines[len(lines)-1], "more"),
|
||||
"last line should advertise the hidden rows: %q", lines[len(lines)-1])
|
||||
}
|
||||
|
||||
// TestPrintWithDimensions_MultipleRendersFit verifies the cross-render aspect
|
||||
// of docker/compose#13595: even a single overflowing line desyncs the cursor
|
||||
// on the following tick because aec.Up(numLines) counts logical lines while
|
||||
// the terminal wraps visual lines. Use many concurrent parent tasks with
|
||||
// wide progress bars in a narrow terminal so adjustLineWidth's truncation
|
||||
// loop can't bring every line under terminalWidth without dropping size
|
||||
// info from progress.
|
||||
func TestPrintWithDimensions_MultipleRendersFit(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
// Two parents so the truncation loop must walk multiple lines; 30 children
|
||||
// per parent makes each progress bar wide enough that taskID truncation
|
||||
// alone can't bring the line under terminalWidth.
|
||||
for i := range 2 {
|
||||
addParentWithDownloadingChildren(w,
|
||||
"Image very-long-name-image-"+string(rune('a'+i))+":v1.2.3",
|
||||
30, 100_000_000)
|
||||
}
|
||||
// Child tasks (image layers) are not rendered as rows: they only feed the
|
||||
// parent's aggregated strip and size counters.
|
||||
func TestTerm_ChildTasksAggregateIntoParentRow(t *testing.T) {
|
||||
w, buf, _ := newTermWriter(100, 40)
|
||||
feed(w,
|
||||
api.Resource{ID: "Image nginx", Text: "Pulling", Status: api.Working},
|
||||
api.Resource{ID: "sha256:aaaa", ParentID: "Image nginx", Text: "Downloading", Status: api.Working, Current: 25_000_000, Total: 100_000_000, Percent: 25},
|
||||
)
|
||||
w.repaint()
|
||||
|
||||
terminalWidth := 60
|
||||
for tick := range 10 {
|
||||
for _, t := range w.tasks {
|
||||
if t.status == api.Working && t.total > 0 {
|
||||
t.current = min(t.current+t.total/10, t.total)
|
||||
}
|
||||
}
|
||||
buf.Reset()
|
||||
w.printWithDimensions(terminalWidth, 24)
|
||||
for i, line := range extractLines(buf) {
|
||||
assert.Assert(t, lenAnsi(line) <= terminalWidth,
|
||||
"tick %d line %d has length %d > terminalWidth %d: %q",
|
||||
tick, i, lenAnsi(line), terminalWidth, line)
|
||||
}
|
||||
lines := visualLines(buf)
|
||||
assert.Equal(t, 2, len(lines), "header + image only, got %v", lines)
|
||||
image := lines[1]
|
||||
assert.Assert(t, !strings.Contains(buf.String(), "sha256:aaaa"), "layers must not get their own row")
|
||||
assert.Assert(t, strings.Contains(image, "25MB / 100MB"), "image row should aggregate layer sizes: %q", image)
|
||||
}
|
||||
|
||||
// Deterministic visual check with a fixed clock: overall shape, id
|
||||
// truncation and right-aligned timers.
|
||||
func TestTerm_VisualSnapshot(t *testing.T) {
|
||||
w, buf, now := newTermWriter(50, 24)
|
||||
feed(w, api.Resource{ID: "Image docker.io/library/nginx-long-name", Text: "Pulling", Status: api.Working})
|
||||
*now = now.Add(2 * time.Second)
|
||||
feed(w, api.Resource{ID: "Image docker.io/library/nginx-long-name", Text: "Pulled", Status: api.Done})
|
||||
feed(w, api.Resource{ID: "Image docker.io/library/postgres-database", Text: "Pulling", Status: api.Working})
|
||||
w.repaint()
|
||||
|
||||
lines := visualLines(buf)
|
||||
// identical, character for character, to the legacy renderer's golden
|
||||
// output in TestPrintWithDimensions_PulledAndPullingWithLongIDs
|
||||
expected := []string{
|
||||
"[+] pull 1/2",
|
||||
" ✔ Image docker.io/library/nginx-l... Pulled 2.0s",
|
||||
" ⠋ Image docker.io/library/postgre... Pulling 0.0s",
|
||||
}
|
||||
assert.Equal(t, len(expected), len(lines))
|
||||
for i := range expected {
|
||||
assert.Equal(t, expected[i], strings.TrimRight(lines[i], " "), "line %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-version v1.9.0
|
||||
github.com/mattn/go-runewidth v0.0.23
|
||||
github.com/mattn/go-shellwords v1.0.14
|
||||
github.com/mitchellh/go-ps v1.0.0
|
||||
github.com/moby/buildkit v0.33.0
|
||||
@@ -92,7 +93,6 @@ require (
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/locker v1.0.1 // indirect
|
||||
|
||||
Reference in New Issue
Block a user