Merge pull request #6566 from vvoland/img-list

image/list: Show collapsed tree by default
This commit is contained in:
Sebastiaan van Stijn
2025-10-31 18:22:07 +01:00
committed by GitHub
9 changed files with 112 additions and 72 deletions
+49 -31
View File
@@ -29,7 +29,6 @@ type imagesOptions struct {
showDigests bool
format string
filter opts.FilterOpt
calledAs string
tree bool
}
@@ -45,11 +44,14 @@ func newImagesCommand(dockerCLI command.Cli) *cobra.Command {
if len(args) > 0 {
options.matchName = args[0]
}
// Pass through how the command was invoked. We use this to print
// warnings when an ambiguous argument was passed when using the
// legacy (top-level) "docker images" subcommand.
options.calledAs = cmd.CalledAs()
return runImages(cmd.Context(), dockerCLI, options)
numImages, err := runImages(cmd.Context(), dockerCLI, options)
if err != nil {
return err
}
if numImages == 0 && options.matchName != "" && cmd.CalledAs() == "images" {
printAmbiguousHint(dockerCLI.Err(), options.matchName)
}
return nil
},
Annotations: map[string]string{
"category-top": "7",
@@ -82,38 +84,28 @@ func newListCommand(dockerCLI command.Cli) *cobra.Command {
return &cmd
}
//nolint:gocyclo
func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) error {
func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) (int, error) {
filters := options.filter.Value()
if options.matchName != "" {
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")
}
useTree, err := shouldUseTree(options)
if err != nil {
return 0, err
}
listOpts := client.ImageListOptions{
All: options.all,
Filters: filters,
Manifests: options.tree,
Manifests: useTree,
}
res, err := dockerCLI.Client().ImageList(ctx, listOpts)
if err != nil {
return err
return 0, err
}
images := res.Items
if !options.all {
if _, ok := filters["dangling"]; !ok {
@@ -121,11 +113,12 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions
}
}
if options.tree {
if useTree {
return runTree(ctx, dockerCLI, treeOptions{
images: images,
all: options.all,
filters: filters,
images: images,
all: options.all,
filters: filters,
expanded: options.tree,
})
}
@@ -147,12 +140,37 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions
Digest: options.showDigests,
}
if err := formatter.ImageWrite(imageCtx, images); err != nil {
return err
return 0, err
}
if options.matchName != "" && len(images) == 0 && options.calledAs == "images" {
printAmbiguousHint(dockerCLI.Err(), options.matchName)
return len(images), nil
}
func shouldUseTree(options imagesOptions) (bool, error) {
if options.quiet {
if options.tree {
return false, errors.New("--quiet is not yet supported with --tree")
}
return false, nil
}
return nil
if options.noTrunc {
if options.tree {
return false, errors.New("--no-trunc is not yet supported with --tree")
}
return false, nil
}
if options.showDigests {
if options.tree {
return false, errors.New("--show-digest is not yet supported with --tree")
}
return false, nil
}
if options.format != "" {
if options.tree {
return false, errors.New("--format is not yet supported with --tree")
}
return false, nil
}
return true, nil
}
// isDangling is a copy of [formatter.isDangling].
+10 -2
View File
@@ -38,7 +38,7 @@ func TestNewImagesCommandErrors(t *testing.T) {
cmd := newImagesCommand(test.NewFakeCli(&fakeClient{imageListFunc: tc.imageListFunc}))
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(tc.args)
cmd.SetArgs(nilToEmptySlice(tc.args))
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
})
}
@@ -87,7 +87,7 @@ func TestNewImagesCommandSuccess(t *testing.T) {
cmd := newImagesCommand(cli)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(tc.args)
cmd.SetArgs(nilToEmptySlice(tc.args))
err := cmd.Execute()
assert.NilError(t, err)
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("list-command-success.%s.golden", tc.name))
@@ -97,6 +97,7 @@ func TestNewImagesCommandSuccess(t *testing.T) {
func TestNewListCommandAlias(t *testing.T) {
cmd := newListCommand(test.NewFakeCli(&fakeClient{}))
cmd.SetArgs([]string{""})
assert.Check(t, cmd.HasAlias("list"))
assert.Check(t, !cmd.HasAlias("other"))
}
@@ -114,3 +115,10 @@ func TestNewListCommandAmbiguous(t *testing.T) {
assert.NilError(t, err)
golden.Assert(t, cli.ErrBuffer().String(), "list-command-ambiguous.golden")
}
func nilToEmptySlice[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}
+3 -2
View File
@@ -26,6 +26,7 @@ func (notFound) NotFound() {}
func TestNewRemoveCommandAlias(t *testing.T) {
cmd := newImageRemoveCommand(test.NewFakeCli(&fakeClient{}))
cmd.SetArgs([]string{""})
assert.Check(t, cmd.HasAlias("rmi"))
assert.Check(t, cmd.HasAlias("remove"))
assert.Check(t, !cmd.HasAlias("other"))
@@ -69,7 +70,7 @@ func TestNewRemoveCommandErrors(t *testing.T) {
}))
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(tc.args)
cmd.SetArgs(nilToEmptySlice(tc.args))
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
})
}
@@ -134,7 +135,7 @@ func TestNewRemoveCommandSuccess(t *testing.T) {
cmd := newRemoveCommand(cli)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(tc.args)
cmd.SetArgs(nilToEmptySlice(tc.args))
assert.NilError(t, cmd.Execute())
assert.Check(t, is.Equal(tc.expectedStderr, cli.ErrBuffer().String()))
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("remove-command-success.%s.golden", tc.name))
@@ -1,2 +1,3 @@
WARNING: This output is designed for human readability. For machine-readable output, please use --format.
No images found matching "ls": did you mean "docker image ls"?
@@ -1 +1,2 @@
REPOSITORY TAG IMAGE ID CREATED SIZE
Info -> U In Use
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
@@ -0,0 +1,2 @@
Info -> U In Use
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
@@ -1 +1,2 @@
REPOSITORY TAG IMAGE ID CREATED SIZE
Info -> U In Use
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
@@ -1 +1,2 @@
REPOSITORY TAG IMAGE ID CREATED SIZE
Info -> U In Use
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
+41 -34
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"os"
"slices"
"sort"
"strings"
"github.com/containerd/platforms"
@@ -24,9 +23,10 @@ import (
)
type treeOptions struct {
images []imagetypes.Summary
all bool
filters client.Filters
images []imagetypes.Summary
all bool
filters client.Filters
expanded bool
}
type treeView struct {
@@ -36,7 +36,7 @@ type treeView struct {
imageSpacing bool
}
func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error {
func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) (int, error) {
images := opts.images
view := treeView{
@@ -46,9 +46,9 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
for _, img := range images {
if ctx.Err() != nil {
return ctx.Err()
return 0, ctx.Err()
}
details := imageDetails{
topDetails := imageDetails{
ID: img.ID,
DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3),
InUse: img.Containers > 0,
@@ -67,41 +67,58 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
continue
}
inUse := len(im.ImageData.Containers) > 0
if inUse {
// Mark top-level parent image as used if any of its subimages are used.
topDetails.InUse = true
}
if !opts.expanded {
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),
InUse: len(im.ImageData.Containers) > 0,
InUse: inUse,
ContentSize: units.HumanSizeWithPrecision(float64(im.Size.Content), 3),
},
}
if sub.Details.InUse {
// Mark top-level parent image as used if any of its subimages are used.
details.InUse = true
}
children = append(children, sub)
// Add extra spacing between images if there's at least one entry with children.
view.imageSpacing = true
}
details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3)
topDetails.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3)
// Sort tags for this image
sortedTags := make([]string, len(img.RepoTags))
copy(sortedTags, img.RepoTags)
slices.Sort(sortedTags)
view.images = append(view.images, topImage{
Names: sortedTags,
Details: details,
Children: children,
created: img.Created,
})
if opts.expanded {
view.images = append(view.images, topImage{
Names: sortedTags,
Details: topDetails,
Children: children,
created: img.Created,
})
continue
}
for _, tag := range sortedTags {
view.images = append(view.images, topImage{
Names: []string{tag},
Details: topDetails,
Children: children,
created: img.Created,
})
}
}
slices.SortFunc(view.images, func(a, b topImage) int {
@@ -123,7 +140,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
return strings.Compare(nameA, nameB)
})
return printImageTree(dockerCLI, view)
printImageTree(dockerCLI, view)
return len(view.images), nil
}
type imageDetails struct {
@@ -206,7 +224,7 @@ func getPossibleChips(view treeView) (chips []imageChip) {
return possible
}
func printImageTree(dockerCLI command.Cli, view treeView) error {
func printImageTree(dockerCLI command.Cli, view treeView) {
if streamRedirected(dockerCLI.Out()) {
_, _ = fmt.Fprintln(dockerCLI.Err(), "WARNING: This output is designed for human readability. For machine-readable output, please use --format.")
}
@@ -313,8 +331,6 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
printChildren(out, columns, img, normalColor)
_, _ = fmt.Fprintln(out)
}
return nil
}
// adjustColumns adjusts the width of the first column to maximize the space
@@ -356,7 +372,6 @@ func generateLegend(out tui.Output, width uint) string {
legend += " |"
}
}
legend += " "
r := int(width) - tui.Width(legend)
if r < 0 {
@@ -406,15 +421,7 @@ func printNames(out tui.Output, headers []imgColumn, img topImage, color, untagg
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
}
// TODO: Replace with namesLongestToShortest := slices.SortedFunc(slices.Values(img.Names))
// once we move to Go 1.23.
namesLongestToShortest := make([]string, len(img.Names))
copy(namesLongestToShortest, img.Names)
sort.Slice(namesLongestToShortest, func(i, j int) bool {
return len(namesLongestToShortest[i]) > len(namesLongestToShortest[j])
})
for nameIdx, name := range namesLongestToShortest {
for nameIdx, name := range img.Names {
// Don't limit first names to the column width because only the last
// name will be printed alongside other columns.
if nameIdx < len(img.Names)-1 {