From 6c86dcc21918da52e42b1ae3fbf426010580e28e Mon Sep 17 00:00:00 2001 From: Laura Brehm Date: Thu, 24 Oct 2024 13:12:25 +0100 Subject: [PATCH] cmd/image/tree: refactor Signed-off-by: Laura Brehm --- cli/command/image/tree.go | 338 ++++++-------------------------- cli/command/image/tree_table.go | 265 +++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 275 deletions(-) create mode 100644 cli/command/image/tree_table.go diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index ec4ef7b4e4..8fab20e09e 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -4,12 +4,9 @@ import ( "context" "fmt" "sort" - "strings" - "unicode/utf8" "github.com/containerd/platforms" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/streams" "github.com/docker/docker/api/types/filters" imagetypes "github.com/docker/docker/api/types/image" "github.com/docker/docker/pkg/stringid" @@ -22,13 +19,6 @@ type treeOptions struct { filters filters.Args } -type treeView struct { - images []topImage - - // imageSpacing indicates whether there should be extra spacing between images. - imageSpacing bool -} - func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error { images, err := dockerCLI.Client().ImageList(ctx, imagetypes.ListOptions{ All: opts.all, @@ -39,28 +29,73 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error return err } - view := treeView{ - images: make([]topImage, 0, len(images)), + warningColor := aec.LightYellowF + if !dockerCLI.Out().IsTerminal() { + warningColor = noColor{} } + _, _ = fmt.Fprintln(dockerCLI.Out(), warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")) + _, _ = fmt.Fprintln(dockerCLI.Out(), "") + + out := dockerCLI.Out() + _, width := out.GetTtySize() + if width == 0 { + width = 80 + } + if width < 20 { + width = 20 + } + + 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{} + untaggedColor = noColor{} + } + + columns := buildTableColumns(int(width), greenColor) + imageRows, anyImageHasChildren := buildTableRows(images) + columns = formatColumnsForOutput(int(width), columns, imageRows) + table := imageTreeTable{ + columns: columns, + headerColor: headerColor, + indexNameColor: topNameColor, + untaggedColor: untaggedColor, + normalColor: normalColor, + spacing: anyImageHasChildren, + } + + table.printTable(out, imageRows) + return nil +} + +func buildTableRows(images []imagetypes.Summary) ([]ImageIndexRow, bool) { + imageRows := make([]ImageIndexRow, 0, len(images)) + var hasChildren bool for _, img := range images { - details := imageDetails{ + details := rowDetails{ ID: img.ID, DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3), InUse: img.Containers > 0, } var totalContent int64 - children := make([]subImage, 0, len(img.Manifests)) + children := make([]ImageManifestRow, 0, len(img.Manifests)) for _, im := range img.Manifests { if im.Kind != imagetypes.ManifestKindImage { continue } im := im - sub := subImage{ + sub := ImageManifestRow{ Platform: platforms.Format(im.ImageData.Platform), Available: im.Available, - Details: imageDetails{ + Details: rowDetails{ ID: im.ID, DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3), InUse: len(im.ImageData.Containers) > 0, @@ -77,12 +112,12 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error children = append(children, sub) // Add extra spacing between images if there's at least one entry with children. - view.imageSpacing = true + hasChildren = true } details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) - view.images = append(view.images, topImage{ + imageRows = append(imageRows, ImageIndexRow{ Names: img.RepoTags, Details: details, Children: children, @@ -90,64 +125,14 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error }) } - sort.Slice(view.images, func(i, j int) bool { - return view.images[i].created > view.images[j].created + sort.Slice(imageRows, func(i, j int) bool { + return imageRows[i].created > imageRows[j].created }) - return printImageTree(dockerCLI, view) + return imageRows, hasChildren } -type imageDetails struct { - ID string - DiskUsage string - InUse bool - ContentSize string -} - -type topImage struct { - Names []string - Details imageDetails - Children []subImage - - created int64 -} - -type subImage struct { - Platform string - Available bool - Details imageDetails -} - -const columnSpacing = 3 - -func printImageTree(dockerCLI command.Cli, view treeView) error { - out := dockerCLI.Out() - _, width := out.GetTtySize() - if width == 0 { - width = 80 - } - if width < 20 { - 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{} - } - - _, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")) - _, _ = fmt.Fprintln(out, "") - +func buildTableColumns(ttyWidth int, usedColumnColor aec.ANSI) []imgColumn { columns := []imgColumn{ { Title: "Image", @@ -158,7 +143,7 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { Title: "ID", Align: alignLeft, Width: 12, - DetailsValue: func(d *imageDetails) string { + DetailsValue: func(d *rowDetails) string { return stringid.TruncateID(d.ID) }, }, @@ -166,7 +151,7 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { Title: "Disk usage", Align: alignRight, Width: 10, - DetailsValue: func(d *imageDetails) string { + DetailsValue: func(d *rowDetails) string { return d.DiskUsage }, }, @@ -174,7 +159,7 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { Title: "Content size", Align: alignRight, Width: 12, - DetailsValue: func(d *imageDetails) string { + DetailsValue: func(d *rowDetails) string { return d.ContentSize }, }, @@ -182,8 +167,8 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { Title: "In Use", Align: alignCenter, Width: 6, - Color: &greenColor, - DetailsValue: func(d *imageDetails) string { + Color: &usedColumnColor, + DetailsValue: func(d *rowDetails) string { if d.InUse { return "✔" } @@ -192,202 +177,5 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { }, } - nameWidth := int(width) - for idx, h := range columns { - if h.Width == 0 { - continue - } - d := h.Width - if idx > 0 { - d += columnSpacing - } - // If the first column gets too short, remove remaining columns - if nameWidth-d < 12 { - columns = columns[:idx] - break - } - 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 -} - -func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) { - for _, h := range headers { - if h.DetailsValue == nil { - continue - } - - _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) - clr := defaultColor - if h.Color != nil { - clr = *h.Color - } - val := h.DetailsValue(&details) - _, _ = fmt.Fprint(out, h.Print(clr, val)) - } -} - -func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalColor aec.ANSI) { - for idx, sub := range img.Children { - clr := normalColor - if !sub.Available { - clr = normalColor.With(aec.Faint) - } - - if idx != len(img.Children)-1 { - _, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform)) - } else { - _, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform)) - } - - printDetails(out, headers, clr, sub.Details) - _, _ = fmt.Fprintln(out, "") - } -} - -func printNames(out *streams.Out, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) { - if len(img.Names) == 0 { - _, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "")) - } - - for nameIdx, name := range img.Names { - if nameIdx != 0 { - _, _ = fmt.Fprintln(out, "") - } - _, _ = fmt.Fprint(out, headers[0].Print(color, name)) - } -} - -type alignment int - -const ( - alignLeft alignment = iota - alignCenter - alignRight -) - -type imgColumn struct { - Title string - Width int - Align alignment - - DetailsValue func(*imageDetails) string - Color *aec.ANSI -} - -func truncateRunes(s string, length int) string { - runes := []rune(s) - if len(runes) > length { - return string(runes[:length-3]) + "..." - } - return s -} - -func (h imgColumn) Print(clr aec.ANSI, s string) string { - switch h.Align { - case alignCenter: - return h.PrintC(clr, s) - case alignRight: - return h.PrintR(clr, s) - case alignLeft: - } - return h.PrintL(clr, s) -} - -func (h imgColumn) PrintC(clr aec.ANSI, s string) string { - ln := utf8.RuneCountInString(s) - - if ln > h.Width { - return clr.Apply(truncateRunes(s, h.Width)) - } - - fill := h.Width - ln - - l := fill / 2 - r := fill - l - - return strings.Repeat(" ", l) + clr.Apply(s) + strings.Repeat(" ", r) -} - -func (h imgColumn) PrintL(clr aec.ANSI, s string) string { - ln := utf8.RuneCountInString(s) - if ln > h.Width { - return clr.Apply(truncateRunes(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) - if ln > h.Width { - return clr.Apply(truncateRunes(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) - for _, img := range images { - for _, name := range img.Names { - if len(name) > width { - width = len(name) - } - } - for _, sub := range img.Children { - pl := len(sub.Platform) + len("└─ ") - if pl > width { - width = pl - } - } - } - return width + return columns } diff --git a/cli/command/image/tree_table.go b/cli/command/image/tree_table.go new file mode 100644 index 0000000000..d71513717c --- /dev/null +++ b/cli/command/image/tree_table.go @@ -0,0 +1,265 @@ +package image + +import ( + "fmt" + "io" + "strings" + "unicode/utf8" + + "github.com/morikuni/aec" +) + +type imageTreeTable struct { + columns []imgColumn + + headerColor aec.ANSI + indexNameColor aec.ANSI + untaggedColor aec.ANSI + normalColor aec.ANSI + highlightColor aec.ANSI + + spacing bool +} + +type ImageIndexRow struct { + Names []string + Details rowDetails + Children []ImageManifestRow + + created int64 +} + +type ImageManifestRow struct { + Platform string + Available bool + Highlight bool + Details rowDetails +} + +// rowDetails is used by both ImageIndexRow and ImageManifestRow +type rowDetails struct { + ID string + DiskUsage string + InUse bool + ContentSize string +} + +func (t *imageTreeTable) printTable(out io.Writer, imgs []ImageIndexRow) { + t.printHeaders(out) + for _, img := range imgs { + t.printIndex(out, img) + _, _ = fmt.Fprintln(out) + } +} + +func (t *imageTreeTable) printHeaders(out io.Writer) { + for i, h := range t.columns { + if i > 0 { + _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) + } + + _, _ = fmt.Fprint(out, h.Print(t.headerColor, strings.ToUpper(h.Title))) + } + + _, _ = fmt.Fprintln(out) +} + +func (t *imageTreeTable) printIndex(out io.Writer, img ImageIndexRow) { + // print the names for the index + printNames(out, t.columns, img, t.indexNameColor, t.untaggedColor) + // print the rest of the columns/details for the header + printDetails(out, t.columns, t.normalColor, img.Details) + + // print the manifest rows, with their details + if len(img.Children) > 0 || t.spacing { + _, _ = fmt.Fprintln(out) + } + + printChildren(out, t.columns, img, t.normalColor) +} + +func printDetails(out io.Writer, headers []imgColumn, defaultColor aec.ANSI, details rowDetails) { + for _, h := range headers { + if h.DetailsValue == nil { + continue + } + + _, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing)) + clr := defaultColor + if h.Color != nil { + clr = *h.Color + } + val := h.DetailsValue(&details) + _, _ = fmt.Fprint(out, h.Print(clr, val)) + } +} + +func printChildren(out io.Writer, headers []imgColumn, img ImageIndexRow, normalColor aec.ANSI) { + for idx, sub := range img.Children { + clr := normalColor + if !sub.Available { + clr = normalColor.With(aec.Faint) + } + if sub.Highlight { + clr = normalColor.With(aec.Bold) + } + + if idx != len(img.Children)-1 { + _, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform)) + } else { + _, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform)) + } + + printDetails(out, headers, clr, sub.Details) + _, _ = fmt.Fprintln(out) + } +} + +func printNames(out io.Writer, headers []imgColumn, img ImageIndexRow, color, untaggedColor aec.ANSI) { + if len(img.Names) == 0 { + _, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "")) + } + + for nameIdx, name := range img.Names { + if nameIdx != 0 { + _, _ = fmt.Fprintln(out) + } + _, _ = fmt.Fprint(out, headers[0].Print(color, name)) + } +} + +type alignment int + +const ( + alignLeft alignment = iota + alignCenter + alignRight +) + +type imgColumn struct { + Title string + Width int + Align alignment + + DetailsValue func(*rowDetails) string + Color *aec.ANSI +} + +// formatColumnsForOutput resizes the table columns for the provided tty +// size. The first column is made as narrow as possible, and columns are +// removed from the table output if the tty is not wide enough to +// accomodate the entire table. +func formatColumnsForOutput(ttyWidth int, columns []imgColumn, images []ImageIndexRow) []imgColumn { + nameWidth := ttyWidth + for idx, h := range columns { + if h.Width == 0 { + continue + } + d := h.Width + if idx > 0 { + d += columnSpacing + } + // If the first column gets too short, remove remaining columns + if nameWidth-d < 12 { + columns = columns[:idx] + break + } + nameWidth -= d + } + + // Try to make the first column as narrow as possible + widest := widestFirstColumnValue(columns, images) + if nameWidth > widest { + nameWidth = widest + } + columns[0].Width = nameWidth + + return columns +} + +const columnSpacing = 3 + +func truncateRunes(s string, length int) string { + runes := []rune(s) + if len(runes) > length { + return string(runes[:length-3]) + "..." + } + return s +} + +func (h imgColumn) Print(clr aec.ANSI, s string) string { + switch h.Align { + case alignCenter: + return h.PrintC(clr, s) + case alignRight: + return h.PrintR(clr, s) + case alignLeft: + } + return h.PrintL(clr, s) +} + +func (h imgColumn) PrintC(clr aec.ANSI, s string) string { + ln := utf8.RuneCountInString(s) + + if ln > h.Width { + return clr.Apply(truncateRunes(s, h.Width)) + } + + fill := h.Width - ln + + l := fill / 2 + r := fill - l + + return strings.Repeat(" ", l) + clr.Apply(s) + strings.Repeat(" ", r) +} + +func (h imgColumn) PrintL(clr aec.ANSI, s string) string { + ln := utf8.RuneCountInString(s) + if ln > h.Width { + return clr.Apply(truncateRunes(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) + if ln > h.Width { + return clr.Apply(truncateRunes(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 []ImageIndexRow) int { + width := len(headers[0].Title) + for _, img := range images { + for _, name := range img.Names { + if len(name) > width { + width = len(name) + } + } + for _, sub := range img.Children { + pl := len(sub.Platform) + len("└─ ") + if pl > width { + width = pl + } + } + } + return width +}