diff --git a/cli/command/image/list.go b/cli/command/image/list.go
index a691efed45..cfe73c5ade 100644
--- a/cli/command/image/list.go
+++ b/cli/command/image/list.go
@@ -2,6 +2,7 @@ package image
import (
"context"
+ "errors"
"fmt"
"io"
@@ -24,6 +25,7 @@ type imagesOptions struct {
format string
filter opts.FilterOpt
calledAs string
+ tree bool
}
// NewImagesCommand creates a new `docker images` command
@@ -59,6 +61,10 @@ func NewImagesCommand(dockerCLI command.Cli) *cobra.Command {
flags.StringVar(&options.format, "format", "", flagsHelper.FormatHelp)
flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided")
+ flags.BoolVar(&options.tree, "tree", false, "List multi-platform images as a tree (EXPERIMENTAL)")
+ flags.SetAnnotation("tree", "version", []string{"1.47"})
+ flags.SetAnnotation("tree", "experimentalCLI", nil)
+
return cmd
}
@@ -75,6 +81,26 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions
filters.Add("reference", options.matchName)
}
+ if options.tree {
+ if options.quiet {
+ return errors.New("--quiet is not yet supported with --tree")
+ }
+ if options.noTrunc {
+ return errors.New("--no-trunc is not yet supported with --tree")
+ }
+ if options.showDigests {
+ return errors.New("--show-digest is not yet supported with --tree")
+ }
+ if options.format != "" {
+ return errors.New("--format is not yet supported with --tree")
+ }
+
+ return runTree(ctx, dockerCLI, treeOptions{
+ all: options.all,
+ filters: filters,
+ })
+ }
+
images, err := dockerCLI.Client().ImageList(ctx, image.ListOptions{
All: options.all,
Filters: filters,
diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go
new file mode 100644
index 0000000000..a4f0761e92
--- /dev/null
+++ b/cli/command/image/tree.go
@@ -0,0 +1,312 @@
+package image
+
+import (
+ "context"
+ "fmt"
+ "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"
+ "github.com/docker/go-units"
+ "github.com/morikuni/aec"
+)
+
+type treeOptions struct {
+ all bool
+ filters filters.Args
+}
+
+func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error {
+ images, err := dockerCLI.Client().ImageList(ctx, imagetypes.ListOptions{
+ All: opts.all,
+ Filters: opts.filters,
+ Manifests: true,
+ })
+ if err != nil {
+ return err
+ }
+
+ view := make([]topImage, 0, len(images))
+ for _, img := range images {
+ details := imageDetails{
+ ID: img.ID,
+ DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3),
+ Used: img.Containers > 0,
+ }
+
+ children := make([]subImage, 0, len(img.Manifests))
+ for _, im := range img.Manifests {
+ if im.Kind != imagetypes.ManifestKindImage {
+ continue
+ }
+
+ sub := subImage{
+ Platform: platforms.Format(im.ImageData.Platform),
+ Available: im.Available,
+ Details: imageDetails{
+ ID: im.ID,
+ DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3),
+ Used: len(im.ImageData.Containers) > 0,
+ },
+ }
+
+ children = append(children, sub)
+ }
+
+ view = append(view, topImage{
+ Names: img.RepoTags,
+ Details: details,
+ Children: children,
+ })
+ }
+
+ return printImageTree(dockerCLI, view)
+}
+
+type imageDetails struct {
+ ID string
+ DiskUsage string
+ Used bool
+}
+
+type topImage struct {
+ Names []string
+ Details imageDetails
+ Children []subImage
+}
+
+type subImage struct {
+ Platform string
+ Available bool
+ Details imageDetails
+}
+
+const columnSpacing = 3
+
+func printImageTree(dockerCLI command.Cli, images []topImage) error {
+ 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.Underline, aec.Bold).ANSI
+ normalColor := aec.NewBuilder(aec.DefaultF).ANSI
+ greenColor := aec.NewBuilder(aec.GreenF).ANSI
+ if !out.IsTerminal() {
+ headerColor = noColor{}
+ topNameColor = noColor{}
+ normalColor = noColor{}
+ greenColor = noColor{}
+ }
+
+ columns := []imgColumn{
+ {Title: "Image", Width: 0, Left: true},
+ {
+ Title: "ID",
+ Width: 12,
+ DetailsValue: func(d *imageDetails) string {
+ return stringid.TruncateID(d.ID)
+ },
+ },
+ {
+ Title: "Disk usage",
+ Width: 10,
+ DetailsValue: func(d *imageDetails) string {
+ return d.DiskUsage
+ },
+ },
+ {
+ Title: "Used",
+ Width: 4,
+ Color: &greenColor,
+ DetailsValue: func(d *imageDetails) string {
+ if d.Used {
+ return "✔"
+ }
+ return " "
+ },
+ },
+ }
+
+ 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
+ }
+
+ // 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, h.Title))
+ }
+
+ _, _ = fmt.Fprintln(out)
+
+ // Print images
+ for idx, img := range images {
+ if idx != 0 {
+ _, _ = fmt.Fprintln(out, "")
+ }
+
+ printNames(out, columns, img, topNameColor)
+ printDetails(out, columns, normalColor, img.Details)
+ printChildren(out, columns, img, normalColor)
+ }
+
+ 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))
+ }
+ fmt.Printf("\n")
+}
+
+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)
+ }
+}
+
+func printNames(out *streams.Out, headers []imgColumn, img topImage, color aec.ANSI) {
+ for nameIdx, name := range img.Names {
+ if nameIdx != 0 {
+ _, _ = fmt.Fprintln(out, "")
+ }
+ _, _ = fmt.Fprint(out, headers[0].Print(color, name))
+ }
+}
+
+type imgColumn struct {
+ Title string
+ Width int
+ Left bool
+
+ 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) (out string) {
+ if h.Left {
+ return h.PrintL(clr, s)
+ }
+ return h.PrintC(clr, s)
+}
+
+func (h imgColumn) PrintC(clr aec.ANSI, s string) (out 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)
+}
+
+type noColor struct{}
+
+func (a noColor) With(ansi ...aec.ANSI) aec.ANSI {
+ return aec.NewBuilder(ansi...).ANSI
+}
+
+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
+}
diff --git a/docs/reference/commandline/image_ls.md b/docs/reference/commandline/image_ls.md
index 3365c29c6b..29174f171b 100644
--- a/docs/reference/commandline/image_ls.md
+++ b/docs/reference/commandline/image_ls.md
@@ -17,6 +17,7 @@ List images
| [`--format`](#format) | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates |
| [`--no-trunc`](#no-trunc) | `bool` | | Don't truncate output |
| `-q`, `--quiet` | `bool` | | Only show image IDs |
+| `--tree` | `bool` | | List multi-platform images as a tree (EXPERIMENTAL) |
diff --git a/docs/reference/commandline/images.md b/docs/reference/commandline/images.md
index 1f7b3b5a4d..8d615ec21b 100644
--- a/docs/reference/commandline/images.md
+++ b/docs/reference/commandline/images.md
@@ -17,6 +17,7 @@ List images
| `--format` | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates |
| `--no-trunc` | `bool` | | Don't truncate output |
| `-q`, `--quiet` | `bool` | | Only show image IDs |
+| `--tree` | `bool` | | List multi-platform images as a tree (EXPERIMENTAL) |