diff --git a/cli/command/image/push.go b/cli/command/image/push.go index 508d30fe50..fd4a690f1b 100644 --- a/cli/command/image/push.go +++ b/cli/command/image/push.go @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "io" - "strings" "github.com/containerd/platforms" "github.com/distribution/reference" @@ -17,6 +16,7 @@ import ( "github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/internal/jsonstream" "github.com/docker/cli/cli/streams" + "github.com/docker/cli/internal/tui" "github.com/docker/docker/api/types/auxprogress" "github.com/docker/docker/api/types/image" registrytypes "github.com/docker/docker/api/types/registry" @@ -78,6 +78,7 @@ Image index won't be pushed, meaning that other manifests, including attestation //nolint:gocyclo func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error { var platform *ocispec.Platform + out := tui.NewOutput(dockerCli.Out()) if opts.platform != "" { p, err := platforms.Parse(opts.platform) if err != nil { @@ -86,7 +87,7 @@ func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error } platform = &p - printNote(dockerCli, `Using --platform pushes only the specified platform manifest of a multi-platform image index. + out.PrintNote(`Using --platform pushes only the specified platform manifest of a multi-platform image index. Other components, like attestations, will not be included. To push the complete multi-platform image, remove the --platform flag. `) @@ -132,8 +133,7 @@ To push the complete multi-platform image, remove the --platform flag. defer func() { for _, note := range notes { - fmt.Fprintln(dockerCli.Err(), "") - printNote(dockerCli, note) + out.PrintNote(note) } }() @@ -183,25 +183,3 @@ func handleAux() func(jm jsonstream.JSONMessage) { } } } - -func printNote(dockerCli command.Cli, format string, args ...any) { - if dockerCli.Err().IsTerminal() { - format = strings.ReplaceAll(format, "--platform", aec.Bold.Apply("--platform")) - } - - header := " Info -> " - padding := len(header) - if dockerCli.Err().IsTerminal() { - padding = len("i Info > ") - header = aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → ")) - } - - _, _ = fmt.Fprint(dockerCli.Err(), header) - s := fmt.Sprintf(format, args...) - for idx, line := range strings.Split(s, "\n") { - if idx > 0 { - _, _ = fmt.Fprint(dockerCli.Err(), strings.Repeat(" ", padding)) - } - _, _ = fmt.Fprintln(dockerCli.Err(), aec.Italic.Apply(line)) - } -} diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index 895a0625b1..a095173bd7 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -5,16 +5,16 @@ import ( "fmt" "sort" "strings" - "unicode/utf8" "github.com/containerd/platforms" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/streams" + "github.com/docker/cli/internal/tui" "github.com/docker/docker/api/types/filters" imagetypes "github.com/docker/docker/api/types/image" "github.com/docker/docker/pkg/stringid" "github.com/docker/go-units" "github.com/morikuni/aec" + "github.com/opencontainers/go-digest" ) type treeOptions struct { @@ -42,6 +42,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error view := treeView{ images: make([]topImage, 0, len(images)), } + attested := make(map[digest.Digest]bool) + for _, img := range images { details := imageDetails{ ID: img.ID, @@ -52,6 +54,10 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error var totalContent int64 children := make([]subImage, 0, len(img.Manifests)) for _, im := range img.Manifests { + if im.Kind == imagetypes.ManifestKindAttestation { + attested[im.AttestationData.For] = true + continue + } if im.Kind != imagetypes.ManifestKindImage { continue } @@ -119,8 +125,59 @@ type subImage struct { const columnSpacing = 3 +var chipInUse = imageChip{ + letter: "U", + desc: "In Use", + fg: 0, + bg: 14, + check: func(d *imageDetails) bool { return d.InUse }, +} + +var chipPlaceholder = tui.Str{ + Plain: " ", + Fancy: " ", +} + +type imageChip struct { + desc string + fg, bg int + letter string + check func(*imageDetails) bool +} + +func (c imageChip) String(isTerm bool) string { + return tui.Str{ + Plain: c.letter, + Fancy: tui.Chip(c.fg, c.bg, " "+c.letter+" "), + }.String(isTerm) +} + +var allChips = []imageChip{ + chipInUse, +} + +func getPossibleChips(view treeView) (chips []imageChip) { + remaining := make([]imageChip, len(allChips)) + copy(remaining, allChips) + + var possible []imageChip + for _, img := range view.images { + for _, c := range img.Children { + for idx := len(remaining) - 1; idx >= 0; idx-- { + chip := remaining[idx] + if chip.check(&c.Details) { + possible = append(possible, chip) + remaining = append(remaining[:idx], remaining[idx+1:]...) + } + } + } + } + + return possible +} + func printImageTree(dockerCLI command.Cli, view treeView) error { - out := dockerCLI.Out() + out := tui.NewOutput(dockerCLI.Out()) _, width := out.GetTtySize() if width == 0 { width = 80 @@ -129,24 +186,17 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { width = 20 } - warningColor := aec.LightYellowF - headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI - topNameColor := aec.NewBuilder(aec.BlueF, aec.Bold).ANSI - normalColor := aec.NewBuilder(aec.DefaultF).ANSI - greenColor := aec.NewBuilder(aec.GreenF).ANSI - untaggedColor := aec.NewBuilder(aec.Faint).ANSI - if !out.IsTerminal() { - headerColor = noColor{} - topNameColor = noColor{} - normalColor = noColor{} - greenColor = noColor{} - warningColor = noColor{} - untaggedColor = noColor{} - } + topNameColor := out.Color(aec.NewBuilder(aec.BlueF, aec.Bold).ANSI) + normalColor := out.Color(tui.ColorSecondary) + untaggedColor := out.Color(tui.ColorTertiary) + isTerm := out.IsTerminal() - _, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")) - _, _ = fmt.Fprintln(out, "") + out.PrintlnWithColor(tui.ColorWarning, "WARNING: This is an experimental feature. The output may change and shouldn't be depended on.") + out.Println(generateLegend(out, width)) + out.Println() + + possibleChips := getPossibleChips(view) columns := []imgColumn{ { Title: "Image", @@ -178,19 +228,68 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { }, }, { - Title: "In Use", - Align: alignCenter, - Width: 6, - Color: &greenColor, - DetailsValue: func(d *imageDetails) string { - if d.InUse { - return "✔" + Title: "Extra", + Align: alignLeft, + Width: func() int { + maxChipsWidth := 0 + for _, chip := range possibleChips { + s := chip.String(isTerm) + l := tui.Width(s) + maxChipsWidth += l } - return " " + + le := len("Extra") + if le > maxChipsWidth { + return le + } + return maxChipsWidth + }(), + Color: &tui.ColorNone, + DetailsValue: func(d *imageDetails) string { + var out string + for _, chip := range possibleChips { + if chip.check(d) { + out += chip.String(isTerm) + } else { + out += chipPlaceholder.String(isTerm) + } + } + return out }, }, } + columns = adjustColumns(width, columns, view.images) + + // Print columns + for i, h := range columns { + if i > 0 { + _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) + } + + _, _ = fmt.Fprint(out, h.Print(tui.ColorTitle, strings.ToUpper(h.Title))) + } + _, _ = fmt.Fprintln(out) + + // Print images + for _, img := range view.images { + printNames(out, columns, img, topNameColor, untaggedColor) + printDetails(out, columns, normalColor, img.Details) + + if len(img.Children) > 0 || view.imageSpacing { + _, _ = fmt.Fprintln(out) + } + printChildren(out, columns, img, normalColor) + _, _ = fmt.Fprintln(out) + } + + return nil +} + +// adjustColumns adjusts the width of the first column to maximize the space +// available for image names and removes any columns that would be too narrow +// to display their content. +func adjustColumns(width uint, columns []imgColumn, images []topImage) []imgColumn { nameWidth := int(width) for idx, h := range columns { if h.Width == 0 { @@ -208,41 +307,35 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { nameWidth -= d } - images := view.images // Try to make the first column as narrow as possible widest := widestFirstColumnValue(columns, images) if nameWidth > widest { nameWidth = widest } columns[0].Width = nameWidth - - // Print columns - for i, h := range columns { - if i > 0 { - _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) - } - - _, _ = fmt.Fprint(out, h.Print(headerColor, strings.ToUpper(h.Title))) - } - - _, _ = fmt.Fprintln(out) - - // Print images - for _, img := range images { - printNames(out, columns, img, topNameColor, untaggedColor) - printDetails(out, columns, normalColor, img.Details) - - if len(img.Children) > 0 || view.imageSpacing { - _, _ = fmt.Fprintln(out) - } - printChildren(out, columns, img, normalColor) - _, _ = fmt.Fprintln(out) - } - - return nil + return columns } -func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) { +func generateLegend(out tui.Output, width uint) string { + var legend string + legend += out.Sprint(tui.InfoHeader) + for idx, chip := range allChips { + legend += " " + out.Sprint(chip) + " " + chip.desc + if idx < len(allChips)-1 { + legend += " |" + } + } + legend += " " + + r := int(width) - tui.Width(legend) + if r < 0 { + r = 0 + } + legend = strings.Repeat(" ", r) + legend + return legend +} + +func printDetails(out tui.Output, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) { for _, h := range headers { if h.DetailsValue == nil { continue @@ -258,17 +351,18 @@ func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI, } } -func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalColor aec.ANSI) { +func printChildren(out tui.Output, headers []imgColumn, img topImage, normalColor aec.ANSI) { for idx, sub := range img.Children { clr := normalColor if !sub.Available { clr = normalColor.With(aec.Faint) } + text := sub.Platform if idx != len(img.Children)-1 { - _, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform)) + _, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+text)) } else { - _, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform)) + _, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+text)) } printDetails(out, headers, clr, sub.Details) @@ -276,7 +370,7 @@ func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalCo } } -func printNames(out *streams.Out, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) { +func printNames(out tui.Output, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) { if len(img.Names) == 0 { _, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "")) } @@ -294,7 +388,7 @@ func printNames(out *streams.Out, headers []imgColumn, img topImage, color, unta // name will be printed alongside other columns. if nameIdx < len(img.Names)-1 { _, fullWidth := out.GetTtySize() - _, _ = fmt.Fprintln(out, color.Apply(truncateRunes(name, int(fullWidth)))) + _, _ = fmt.Fprintln(out, color.Apply(tui.Ellipsis(name, int(fullWidth)))) } else { _, _ = fmt.Fprint(out, headers[0].Print(color, name)) } @@ -318,14 +412,6 @@ type imgColumn struct { Color *aec.ANSI } -func truncateRunes(s string, length int) string { - runes := []rune(s) - if len(runes) > length { - return string(runes[:length-1]) + "…" - } - return s -} - func (h imgColumn) Print(clr aec.ANSI, s string) string { switch h.Align { case alignCenter: @@ -338,10 +424,10 @@ func (h imgColumn) Print(clr aec.ANSI, s string) string { } func (h imgColumn) PrintC(clr aec.ANSI, s string) string { - ln := utf8.RuneCountInString(s) + ln := tui.Width(s) if ln > h.Width { - return clr.Apply(truncateRunes(s, h.Width)) + return clr.Apply(tui.Ellipsis(s, h.Width)) } fill := h.Width - ln @@ -353,37 +439,23 @@ func (h imgColumn) PrintC(clr aec.ANSI, s string) string { } func (h imgColumn) PrintL(clr aec.ANSI, s string) string { - ln := utf8.RuneCountInString(s) + ln := tui.Width(s) if ln > h.Width { - return clr.Apply(truncateRunes(s, h.Width)) + return clr.Apply(tui.Ellipsis(s, h.Width)) } return clr.Apply(s) + strings.Repeat(" ", h.Width-ln) } func (h imgColumn) PrintR(clr aec.ANSI, s string) string { - ln := utf8.RuneCountInString(s) + ln := tui.Width(s) if ln > h.Width { - return clr.Apply(truncateRunes(s, h.Width)) + return clr.Apply(tui.Ellipsis(s, h.Width)) } return strings.Repeat(" ", h.Width-ln) + clr.Apply(s) } -type noColor struct{} - -func (a noColor) With(_ ...aec.ANSI) aec.ANSI { - return a -} - -func (a noColor) Apply(s string) string { - return s -} - -func (a noColor) String() string { - return "" -} - // widestFirstColumnValue calculates the width needed to fully display the image names and platforms. func widestFirstColumnValue(headers []imgColumn, images []topImage) int { width := len(headers[0].Title) diff --git a/internal/tui/chip.go b/internal/tui/chip.go new file mode 100644 index 0000000000..416f96e664 --- /dev/null +++ b/internal/tui/chip.go @@ -0,0 +1,9 @@ +package tui + +import "strconv" + +func Chip(fg, bg int, content string) string { + fgAnsi := "\x1b[38;5;" + strconv.Itoa(fg) + "m" + bgAnsi := "\x1b[48;5;" + strconv.Itoa(bg) + "m" + return fgAnsi + bgAnsi + content + "\x1b[0m" +} diff --git a/internal/tui/colors.go b/internal/tui/colors.go new file mode 100644 index 0000000000..cb1493775e --- /dev/null +++ b/internal/tui/colors.go @@ -0,0 +1,30 @@ +package tui + +import ( + "github.com/morikuni/aec" +) + +var ( + ColorTitle = aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI + ColorPrimary = aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI + ColorSecondary = aec.DefaultF + ColorTertiary = aec.NewBuilder(aec.DefaultF, aec.Faint).ANSI + ColorLink = aec.NewBuilder(aec.LightCyanF, aec.Underline).ANSI + ColorWarning = aec.LightYellowF + ColorFlag = aec.NewBuilder(aec.Bold).ANSI + ColorNone = aec.ANSI(noColor{}) +) + +type noColor struct{} + +func (a noColor) With(_ ...aec.ANSI) aec.ANSI { + return a +} + +func (a noColor) Apply(s string) string { + return s +} + +func (a noColor) String() string { + return "" +} diff --git a/internal/tui/count.go b/internal/tui/count.go new file mode 100644 index 0000000000..a015ce5cf1 --- /dev/null +++ b/internal/tui/count.go @@ -0,0 +1,67 @@ +package tui + +import ( + "strings" + + "github.com/mattn/go-runewidth" +) + +func cleanANSI(s string) string { + for { + start := strings.Index(s, "\x1b") + if start == -1 { + return s + } + end := strings.Index(s[start:], "m") + if end == -1 { + return s + } + s = s[:start] + s[start+end+1:] + } +} + +// Width returns the width of the string, ignoring ANSI escape codes. +// Not all ANSI escape codes are supported yet. +func Width(s string) int { + return runewidth.StringWidth(cleanANSI(s)) +} + +// Ellipsis truncates a string to a given number of runes with an ellipsis at the end. +// It tries to persist the ANSI escape sequences. +func Ellipsis(s string, length int) string { + out := make([]rune, 0, length) + ln := 0 + inEscape := false + tooLong := false + + for _, r := range s { + if r == '\x1b' { + out = append(out, r) + inEscape = true + continue + } + if inEscape { + out = append(out, r) + if r == 'm' { + inEscape = false + if tooLong { + break + } + } + continue + } + + ln += 1 + if ln == length { + tooLong = true + } + if !tooLong { + out = append(out, r) + } + } + + if tooLong { + return string(out) + "…" + } + return string(out) +} diff --git a/internal/tui/note.go b/internal/tui/note.go new file mode 100644 index 0000000000..582b279d67 --- /dev/null +++ b/internal/tui/note.go @@ -0,0 +1,36 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/morikuni/aec" +) + +var InfoHeader = Str{ + Plain: " Info -> ", + Fancy: aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → ")), +} + +func (o Output) PrintNote(format string, args ...any) { + if o.isTerminal { + // TODO: Handle all flags + format = strings.ReplaceAll(format, "--platform", ColorFlag.Apply("--platform")) + } + + header := o.Sprint(InfoHeader) + + _, _ = fmt.Fprint(o, "\n", header) + s := fmt.Sprintf(format, args...) + for idx, line := range strings.Split(s, "\n") { + if idx > 0 { + _, _ = fmt.Fprint(o, strings.Repeat(" ", Width(header))) + } + + l := line + if o.isTerminal { + l = aec.Italic.Apply(l) + } + _, _ = fmt.Fprintln(o, l) + } +} diff --git a/internal/tui/output.go b/internal/tui/output.go new file mode 100644 index 0000000000..a0785fcdf7 --- /dev/null +++ b/internal/tui/output.go @@ -0,0 +1,59 @@ +package tui + +import ( + "fmt" + + "github.com/docker/cli/cli/streams" + "github.com/morikuni/aec" +) + +type Output struct { + *streams.Out + isTerminal bool +} + +type terminalPrintable interface { + String(isTerminal bool) string +} + +func NewOutput(out *streams.Out) Output { + return Output{ + Out: out, + isTerminal: out.IsTerminal(), + } +} + +func (o Output) Color(clr aec.ANSI) aec.ANSI { + if o.isTerminal { + return clr + } + return ColorNone +} + +func (o Output) Sprint(all ...any) string { + var out []any + for _, p := range all { + if s, ok := p.(terminalPrintable); ok { + out = append(out, s.String(o.isTerminal)) + } else { + out = append(out, p) + } + } + return fmt.Sprint(out...) +} + +func (o Output) PrintlnWithColor(clr aec.ANSI, args ...any) { + msg := o.Sprint(args...) + if o.isTerminal { + msg = clr.Apply(msg) + } + _, _ = fmt.Fprintln(o.Out, msg) +} + +func (o Output) Println(p ...any) { + _, _ = fmt.Fprintln(o.Out, o.Sprint(p...)) +} + +func (o Output) Print(p ...any) { + _, _ = fmt.Print(o.Out, o.Sprint(p...)) +} diff --git a/internal/tui/str.go b/internal/tui/str.go new file mode 100644 index 0000000000..2cc9a3d787 --- /dev/null +++ b/internal/tui/str.go @@ -0,0 +1,16 @@ +package tui + +type Str struct { + // Fancy is the fancy string representation of the string. + Fancy string + + // Plain is the plain string representation of the string. + Plain string +} + +func (p Str) String(isTerminal bool) string { + if isTerminal { + return p.Fancy + } + return p.Plain +}