mirror of
https://github.com/docker/compose.git
synced 2026-02-09 01:59:22 +08:00
Fixed progress UI to adapt to terminal width
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
committed by
Nicolas De loof
parent
2f108ffaa8
commit
c8d687599a
@@ -21,9 +21,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/buger/goterm"
|
||||
"github.com/docker/go-units"
|
||||
@@ -258,13 +260,39 @@ func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 size info
|
||||
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
|
||||
}
|
||||
terminalWidth := goterm.Width()
|
||||
|
||||
up := w.numLines + 1
|
||||
if !w.repeated {
|
||||
up--
|
||||
@@ -283,39 +311,208 @@ func (w *ttyWriter) print() {
|
||||
firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
|
||||
_, _ = fmt.Fprintln(w.out, firstLine)
|
||||
|
||||
var statusPadding int
|
||||
for _, t := range w.tasks {
|
||||
l := len(t.ID)
|
||||
if len(t.parents) == 0 && statusPadding < l {
|
||||
statusPadding = l
|
||||
// Collect parent tasks in original order
|
||||
allTasks := slices.Collect(w.parentTasks())
|
||||
|
||||
// Available lines: terminal height - 2 (header line + potential "more" line)
|
||||
maxLines := terminalHeight - 2
|
||||
if maxLines < 1 {
|
||||
maxLines = 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)
|
||||
}
|
||||
}
|
||||
|
||||
skipChildEvents := len(w.tasks) > goterm.Height()-2
|
||||
// shorten details/taskID to fit terminal width
|
||||
w.adjustLineWidth(lines, timerLen, terminalWidth)
|
||||
|
||||
// compute padding
|
||||
w.applyPadding(lines, terminalWidth, timerLen)
|
||||
|
||||
// Render lines
|
||||
numLines := 0
|
||||
for t := range w.parentTasks() {
|
||||
line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
|
||||
_, _ = fmt.Fprint(w.out, line)
|
||||
for _, l := range lines {
|
||||
_, _ = fmt.Fprint(w.out, lineText(l))
|
||||
numLines++
|
||||
if skipChildEvents {
|
||||
continue
|
||||
}
|
||||
for child := range w.childrenTasks(t.ID) {
|
||||
line := w.lineText(child, " ", terminalWidth, statusPadding-2, w.dryRun)
|
||||
_, _ = fmt.Fprint(w.out, line)
|
||||
numLines++
|
||||
}
|
||||
}
|
||||
for i := numLines; i < w.numLines; i++ {
|
||||
if numLines < goterm.Height()-2 {
|
||||
_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
|
||||
numLines++
|
||||
|
||||
if showMore {
|
||||
moreCount := len(allTasks) - len(tasksToShow)
|
||||
moreText := fmt.Sprintf(" ... %d more", moreCount)
|
||||
pad := terminalWidth - len(moreText)
|
||||
if pad < 0 {
|
||||
pad = 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) lineText(t *task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
|
||||
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 = terminalWidth - lineLen - timerLen
|
||||
if l.timerPad < 1 {
|
||||
l.timerPad = 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
|
||||
}
|
||||
|
||||
// First try to truncate details, then taskID
|
||||
if !truncateDetails(lines, overflow) && !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) + len(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
|
||||
}
|
||||
|
||||
// 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 := overflow
|
||||
if reduction > len(l.details)-3 {
|
||||
reduction = 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.
|
||||
func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool {
|
||||
longestIdx := -1
|
||||
longestLen := minIDLen
|
||||
for i := range lines {
|
||||
if len(lines[i].taskID) > longestLen {
|
||||
longestLen = len(lines[i].taskID)
|
||||
longestIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if longestIdx < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
l := &lines[longestIdx]
|
||||
reduction := overflow + 3 // account for "..."
|
||||
newLen := len(l.taskID) - reduction
|
||||
if newLen < minIDLen-3 {
|
||||
newLen = minIDLen - 3
|
||||
}
|
||||
if newLen > 0 {
|
||||
l.taskID = l.taskID[:newLen] + "..."
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *ttyWriter) prepareLineData(t *task) lineData {
|
||||
endTime := time.Now()
|
||||
if t.status != api.Working {
|
||||
endTime = t.startTime
|
||||
@@ -323,8 +520,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i
|
||||
endTime = t.endTime
|
||||
}
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
if dryRun {
|
||||
if w.dryRun {
|
||||
prefix = PrefixColor(DRYRUN_PREFIX)
|
||||
}
|
||||
|
||||
@@ -338,11 +536,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i
|
||||
)
|
||||
|
||||
// only show the aggregated progress while the root operation is in-progress
|
||||
if parent := t; parent.status == api.Working {
|
||||
for child := range w.childrenTasks(parent.ID) {
|
||||
if t.status == api.Working {
|
||||
for child := range w.childrenTasks(t.ID) {
|
||||
if child.status == api.Working && child.total == 0 {
|
||||
// we don't have totals available for all the child events
|
||||
// so don't show the total progress yet
|
||||
hideDetails = true
|
||||
}
|
||||
total += child.total
|
||||
@@ -356,49 +552,49 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i
|
||||
}
|
||||
}
|
||||
|
||||
// don't try to show detailed progress if we don't have any idea
|
||||
if total == 0 {
|
||||
hideDetails = true
|
||||
}
|
||||
|
||||
txt := t.ID
|
||||
var progress string
|
||||
if len(completion) > 0 {
|
||||
var progress string
|
||||
progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
|
||||
if !hideDetails {
|
||||
progress = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
|
||||
progress += fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
|
||||
}
|
||||
txt = fmt.Sprintf("%s [%s]%s",
|
||||
t.ID,
|
||||
SuccessColor(strings.Join(completion, "")),
|
||||
progress,
|
||||
)
|
||||
}
|
||||
textLen := len(txt)
|
||||
padding := statusPadding - textLen
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
// calculate the max length for the status text, on errors it
|
||||
// is 2-3 lines long and breaks the line formatting
|
||||
maxDetailsLen := terminalWidth - textLen - statusPadding - 15
|
||||
details := t.details
|
||||
// in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index
|
||||
if maxDetailsLen > 0 && len(details) > maxDetailsLen {
|
||||
details = details[:maxDetailsLen] + "..."
|
||||
}
|
||||
text := fmt.Sprintf("%s %s%s %s %s%s %s",
|
||||
pad,
|
||||
spinner(t),
|
||||
prefix,
|
||||
txt,
|
||||
strings.Repeat(" ", padding),
|
||||
colorFn(t.status)(t.text),
|
||||
details,
|
||||
)
|
||||
timer := fmt.Sprintf("%.1fs ", elapsed)
|
||||
o := align(text, TimerColor(timer), terminalWidth)
|
||||
|
||||
return o
|
||||
return lineData{
|
||||
spinner: spinner(t),
|
||||
prefix: prefix,
|
||||
taskID: t.ID,
|
||||
progress: progress,
|
||||
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 (
|
||||
@@ -443,17 +639,6 @@ func numDone(tasks map[string]*task) int {
|
||||
return i
|
||||
}
|
||||
|
||||
func align(l, r string, w int) string {
|
||||
ll := lenAnsi(l)
|
||||
lr := lenAnsi(r)
|
||||
pad := ""
|
||||
count := w - ll - lr
|
||||
if count > 0 {
|
||||
pad = strings.Repeat(" ", count)
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s\n", l, pad, r)
|
||||
}
|
||||
|
||||
// lenAnsi count of user-perceived characters in ANSI string.
|
||||
func lenAnsi(s string) int {
|
||||
length := 0
|
||||
|
||||
424
cmd/display/tty_test.go
Normal file
424
cmd/display/tty_test.go
Normal file
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
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 (
|
||||
"bytes"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"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: make(chan bool),
|
||||
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 := 0; i < 3; i++ {
|
||||
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 ...")
|
||||
}
|
||||
|
||||
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
|
||||
inAnsi := false
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
inAnsi = true
|
||||
continue
|
||||
}
|
||||
if inAnsi {
|
||||
// ANSI sequences end with a letter (m, h, l, G, etc.)
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
||||
inAnsi = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func TestPrintWithDimensions_PulledAndPullingWithLongIDs(t *testing.T) {
|
||||
w, buf := newTestWriter()
|
||||
|
||||
// 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(),
|
||||
}
|
||||
completedTask.spinner.Stop()
|
||||
w.tasks[completedTask.ID] = completedTask
|
||||
w.ids = append(w.ids, completedTask.ID)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := lenAnsi(tc.input)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user