Merge pull request #5744 from vvoland/image-tree-chips

image/tree: Chips to represent "in use"
This commit is contained in:
Paweł Gronowski 2025-02-04 09:23:24 +00:00 committed by GitHub
commit 5b90e0e4e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 380 additions and 113 deletions

View File

@ -8,7 +8,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"strings"
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/distribution/reference" "github.com/distribution/reference"
@ -17,6 +16,7 @@ import (
"github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/internal/jsonstream" "github.com/docker/cli/cli/internal/jsonstream"
"github.com/docker/cli/cli/streams" "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/auxprogress"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
registrytypes "github.com/docker/docker/api/types/registry" 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 //nolint:gocyclo
func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error { func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error {
var platform *ocispec.Platform var platform *ocispec.Platform
out := tui.NewOutput(dockerCli.Out())
if opts.platform != "" { if opts.platform != "" {
p, err := platforms.Parse(opts.platform) p, err := platforms.Parse(opts.platform)
if err != nil { if err != nil {
@ -86,7 +87,7 @@ func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error
} }
platform = &p 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. Other components, like attestations, will not be included.
To push the complete multi-platform image, remove the --platform flag. 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() { defer func() {
for _, note := range notes { for _, note := range notes {
fmt.Fprintln(dockerCli.Err(), "") out.PrintNote(note)
printNote(dockerCli, 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))
}
}

View File

@ -5,16 +5,16 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"unicode/utf8"
"github.com/containerd/platforms" "github.com/containerd/platforms"
"github.com/docker/cli/cli/command" "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" "github.com/docker/docker/api/types/filters"
imagetypes "github.com/docker/docker/api/types/image" imagetypes "github.com/docker/docker/api/types/image"
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/morikuni/aec" "github.com/morikuni/aec"
"github.com/opencontainers/go-digest"
) )
type treeOptions struct { type treeOptions struct {
@ -42,6 +42,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
view := treeView{ view := treeView{
images: make([]topImage, 0, len(images)), images: make([]topImage, 0, len(images)),
} }
attested := make(map[digest.Digest]bool)
for _, img := range images { for _, img := range images {
details := imageDetails{ details := imageDetails{
ID: img.ID, ID: img.ID,
@ -52,6 +54,10 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
var totalContent int64 var totalContent int64
children := make([]subImage, 0, len(img.Manifests)) children := make([]subImage, 0, len(img.Manifests))
for _, im := range img.Manifests { for _, im := range img.Manifests {
if im.Kind == imagetypes.ManifestKindAttestation {
attested[im.AttestationData.For] = true
continue
}
if im.Kind != imagetypes.ManifestKindImage { if im.Kind != imagetypes.ManifestKindImage {
continue continue
} }
@ -119,8 +125,59 @@ type subImage struct {
const columnSpacing = 3 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 { func printImageTree(dockerCLI command.Cli, view treeView) error {
out := dockerCLI.Out() out := tui.NewOutput(dockerCLI.Out())
_, width := out.GetTtySize() _, width := out.GetTtySize()
if width == 0 { if width == 0 {
width = 80 width = 80
@ -129,24 +186,17 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
width = 20 width = 20
} }
warningColor := aec.LightYellowF topNameColor := out.Color(aec.NewBuilder(aec.BlueF, aec.Bold).ANSI)
headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI normalColor := out.Color(tui.ColorSecondary)
topNameColor := aec.NewBuilder(aec.BlueF, aec.Bold).ANSI untaggedColor := out.Color(tui.ColorTertiary)
normalColor := aec.NewBuilder(aec.DefaultF).ANSI isTerm := out.IsTerminal()
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{}
}
_, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")) out.PrintlnWithColor(tui.ColorWarning, "WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")
_, _ = fmt.Fprintln(out, "")
out.Println(generateLegend(out, width))
out.Println()
possibleChips := getPossibleChips(view)
columns := []imgColumn{ columns := []imgColumn{
{ {
Title: "Image", Title: "Image",
@ -178,19 +228,68 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
}, },
}, },
{ {
Title: "In Use", Title: "Extra",
Align: alignCenter, Align: alignLeft,
Width: 6, Width: func() int {
Color: &greenColor, maxChipsWidth := 0
DetailsValue: func(d *imageDetails) string { for _, chip := range possibleChips {
if d.InUse { s := chip.String(isTerm)
return "✔" 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) nameWidth := int(width)
for idx, h := range columns { for idx, h := range columns {
if h.Width == 0 { if h.Width == 0 {
@ -208,41 +307,35 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
nameWidth -= d nameWidth -= d
} }
images := view.images
// Try to make the first column as narrow as possible // Try to make the first column as narrow as possible
widest := widestFirstColumnValue(columns, images) widest := widestFirstColumnValue(columns, images)
if nameWidth > widest { if nameWidth > widest {
nameWidth = widest nameWidth = widest
} }
columns[0].Width = nameWidth columns[0].Width = nameWidth
return columns
// 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))) 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
} }
_, _ = fmt.Fprintln(out) func printDetails(out tui.Output, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) {
// 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
}
func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) {
for _, h := range headers { for _, h := range headers {
if h.DetailsValue == nil { if h.DetailsValue == nil {
continue 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 { for idx, sub := range img.Children {
clr := normalColor clr := normalColor
if !sub.Available { if !sub.Available {
clr = normalColor.With(aec.Faint) clr = normalColor.With(aec.Faint)
} }
text := sub.Platform
if idx != len(img.Children)-1 { if idx != len(img.Children)-1 {
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform)) _, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+text))
} else { } else {
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform)) _, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+text))
} }
printDetails(out, headers, clr, sub.Details) 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 { if len(img.Names) == 0 {
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>")) _, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
} }
@ -294,7 +388,7 @@ func printNames(out *streams.Out, headers []imgColumn, img topImage, color, unta
// name will be printed alongside other columns. // name will be printed alongside other columns.
if nameIdx < len(img.Names)-1 { if nameIdx < len(img.Names)-1 {
_, fullWidth := out.GetTtySize() _, fullWidth := out.GetTtySize()
_, _ = fmt.Fprintln(out, color.Apply(truncateRunes(name, int(fullWidth)))) _, _ = fmt.Fprintln(out, color.Apply(tui.Ellipsis(name, int(fullWidth))))
} else { } else {
_, _ = fmt.Fprint(out, headers[0].Print(color, name)) _, _ = fmt.Fprint(out, headers[0].Print(color, name))
} }
@ -318,14 +412,6 @@ type imgColumn struct {
Color *aec.ANSI 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 { func (h imgColumn) Print(clr aec.ANSI, s string) string {
switch h.Align { switch h.Align {
case alignCenter: 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 { func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
ln := utf8.RuneCountInString(s) ln := tui.Width(s)
if ln > h.Width { if ln > h.Width {
return clr.Apply(truncateRunes(s, h.Width)) return clr.Apply(tui.Ellipsis(s, h.Width))
} }
fill := h.Width - ln 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 { func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
ln := utf8.RuneCountInString(s) ln := tui.Width(s)
if ln > h.Width { 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) return clr.Apply(s) + strings.Repeat(" ", h.Width-ln)
} }
func (h imgColumn) PrintR(clr aec.ANSI, s string) string { func (h imgColumn) PrintR(clr aec.ANSI, s string) string {
ln := utf8.RuneCountInString(s) ln := tui.Width(s)
if ln > h.Width { 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) 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. // widestFirstColumnValue calculates the width needed to fully display the image names and platforms.
func widestFirstColumnValue(headers []imgColumn, images []topImage) int { func widestFirstColumnValue(headers []imgColumn, images []topImage) int {
width := len(headers[0].Title) width := len(headers[0].Title)

9
internal/tui/chip.go Normal file
View File

@ -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"
}

30
internal/tui/colors.go Normal file
View File

@ -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 ""
}

67
internal/tui/count.go Normal file
View File

@ -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)
}

36
internal/tui/note.go Normal file
View File

@ -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)
}
}

59
internal/tui/output.go Normal file
View File

@ -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...))
}

16
internal/tui/str.go Normal file
View File

@ -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
}