Skip to content

Commit 73aade1

Browse files
authored
Merge pull request #6566 from vvoland/img-list
image/list: Show collapsed tree by default
2 parents 0f589b3 + 6fa5900 commit 73aade1

File tree

9 files changed

+112
-72
lines changed

9 files changed

+112
-72
lines changed

cli/command/image/list.go

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ type imagesOptions struct {
2929
showDigests bool
3030
format string
3131
filter opts.FilterOpt
32-
calledAs string
3332
tree bool
3433
}
3534

@@ -45,11 +44,14 @@ func newImagesCommand(dockerCLI command.Cli) *cobra.Command {
4544
if len(args) > 0 {
4645
options.matchName = args[0]
4746
}
48-
// Pass through how the command was invoked. We use this to print
49-
// warnings when an ambiguous argument was passed when using the
50-
// legacy (top-level) "docker images" subcommand.
51-
options.calledAs = cmd.CalledAs()
52-
return runImages(cmd.Context(), dockerCLI, options)
47+
numImages, err := runImages(cmd.Context(), dockerCLI, options)
48+
if err != nil {
49+
return err
50+
}
51+
if numImages == 0 && options.matchName != "" && cmd.CalledAs() == "images" {
52+
printAmbiguousHint(dockerCLI.Err(), options.matchName)
53+
}
54+
return nil
5355
},
5456
Annotations: map[string]string{
5557
"category-top": "7",
@@ -82,50 +84,41 @@ func newListCommand(dockerCLI command.Cli) *cobra.Command {
8284
return &cmd
8385
}
8486

85-
//nolint:gocyclo
86-
func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) error {
87+
func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) (int, error) {
8788
filters := options.filter.Value()
8889
if options.matchName != "" {
8990
filters.Add("reference", options.matchName)
9091
}
9192

92-
if options.tree {
93-
if options.quiet {
94-
return errors.New("--quiet is not yet supported with --tree")
95-
}
96-
if options.noTrunc {
97-
return errors.New("--no-trunc is not yet supported with --tree")
98-
}
99-
if options.showDigests {
100-
return errors.New("--show-digest is not yet supported with --tree")
101-
}
102-
if options.format != "" {
103-
return errors.New("--format is not yet supported with --tree")
104-
}
93+
useTree, err := shouldUseTree(options)
94+
if err != nil {
95+
return 0, err
10596
}
10697

10798
listOpts := client.ImageListOptions{
10899
All: options.all,
109100
Filters: filters,
110-
Manifests: options.tree,
101+
Manifests: useTree,
111102
}
112103

113104
res, err := dockerCLI.Client().ImageList(ctx, listOpts)
114105
if err != nil {
115-
return err
106+
return 0, err
116107
}
108+
117109
images := res.Items
118110
if !options.all {
119111
if _, ok := filters["dangling"]; !ok {
120112
images = slices.DeleteFunc(images, isDangling)
121113
}
122114
}
123115

124-
if options.tree {
116+
if useTree {
125117
return runTree(ctx, dockerCLI, treeOptions{
126-
images: images,
127-
all: options.all,
128-
filters: filters,
118+
images: images,
119+
all: options.all,
120+
filters: filters,
121+
expanded: options.tree,
129122
})
130123
}
131124

@@ -147,12 +140,37 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions
147140
Digest: options.showDigests,
148141
}
149142
if err := formatter.ImageWrite(imageCtx, images); err != nil {
150-
return err
143+
return 0, err
151144
}
152-
if options.matchName != "" && len(images) == 0 && options.calledAs == "images" {
153-
printAmbiguousHint(dockerCLI.Err(), options.matchName)
145+
return len(images), nil
146+
}
147+
148+
func shouldUseTree(options imagesOptions) (bool, error) {
149+
if options.quiet {
150+
if options.tree {
151+
return false, errors.New("--quiet is not yet supported with --tree")
152+
}
153+
return false, nil
154+
}
155+
if options.noTrunc {
156+
if options.tree {
157+
return false, errors.New("--no-trunc is not yet supported with --tree")
158+
}
159+
return false, nil
160+
}
161+
if options.showDigests {
162+
if options.tree {
163+
return false, errors.New("--show-digest is not yet supported with --tree")
164+
}
165+
return false, nil
166+
}
167+
if options.format != "" {
168+
if options.tree {
169+
return false, errors.New("--format is not yet supported with --tree")
170+
}
171+
return false, nil
154172
}
155-
return nil
173+
return true, nil
156174
}
157175

158176
// isDangling is a copy of [formatter.isDangling].

cli/command/image/list_test.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestNewImagesCommandErrors(t *testing.T) {
3838
cmd := newImagesCommand(test.NewFakeCli(&fakeClient{imageListFunc: tc.imageListFunc}))
3939
cmd.SetOut(io.Discard)
4040
cmd.SetErr(io.Discard)
41-
cmd.SetArgs(tc.args)
41+
cmd.SetArgs(nilToEmptySlice(tc.args))
4242
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
4343
})
4444
}
@@ -87,7 +87,7 @@ func TestNewImagesCommandSuccess(t *testing.T) {
8787
cmd := newImagesCommand(cli)
8888
cmd.SetOut(io.Discard)
8989
cmd.SetErr(io.Discard)
90-
cmd.SetArgs(tc.args)
90+
cmd.SetArgs(nilToEmptySlice(tc.args))
9191
err := cmd.Execute()
9292
assert.NilError(t, err)
9393
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("list-command-success.%s.golden", tc.name))
@@ -97,6 +97,7 @@ func TestNewImagesCommandSuccess(t *testing.T) {
9797

9898
func TestNewListCommandAlias(t *testing.T) {
9999
cmd := newListCommand(test.NewFakeCli(&fakeClient{}))
100+
cmd.SetArgs([]string{""})
100101
assert.Check(t, cmd.HasAlias("list"))
101102
assert.Check(t, !cmd.HasAlias("other"))
102103
}
@@ -114,3 +115,10 @@ func TestNewListCommandAmbiguous(t *testing.T) {
114115
assert.NilError(t, err)
115116
golden.Assert(t, cli.ErrBuffer().String(), "list-command-ambiguous.golden")
116117
}
118+
119+
func nilToEmptySlice[T any](s []T) []T {
120+
if s == nil {
121+
return []T{}
122+
}
123+
return s
124+
}

cli/command/image/remove_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func (notFound) NotFound() {}
2626

2727
func TestNewRemoveCommandAlias(t *testing.T) {
2828
cmd := newImageRemoveCommand(test.NewFakeCli(&fakeClient{}))
29+
cmd.SetArgs([]string{""})
2930
assert.Check(t, cmd.HasAlias("rmi"))
3031
assert.Check(t, cmd.HasAlias("remove"))
3132
assert.Check(t, !cmd.HasAlias("other"))
@@ -69,7 +70,7 @@ func TestNewRemoveCommandErrors(t *testing.T) {
6970
}))
7071
cmd.SetOut(io.Discard)
7172
cmd.SetErr(io.Discard)
72-
cmd.SetArgs(tc.args)
73+
cmd.SetArgs(nilToEmptySlice(tc.args))
7374
assert.ErrorContains(t, cmd.Execute(), tc.expectedError)
7475
})
7576
}
@@ -134,7 +135,7 @@ func TestNewRemoveCommandSuccess(t *testing.T) {
134135
cmd := newRemoveCommand(cli)
135136
cmd.SetOut(io.Discard)
136137
cmd.SetErr(io.Discard)
137-
cmd.SetArgs(tc.args)
138+
cmd.SetArgs(nilToEmptySlice(tc.args))
138139
assert.NilError(t, cmd.Execute())
139140
assert.Check(t, is.Equal(tc.expectedStderr, cli.ErrBuffer().String()))
140141
golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("remove-command-success.%s.golden", tc.name))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
WARNING: This output is designed for human readability. For machine-readable output, please use --format.
12

23
No images found matching "ls": did you mean "docker image ls"?
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
REPOSITORY TAG IMAGE ID CREATED SIZE
1+
Info -> U In Use
2+
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Info -> U In Use
2+
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
REPOSITORY TAG IMAGE ID CREATED SIZE
1+
Info -> U In Use
2+
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
REPOSITORY TAG IMAGE ID CREATED SIZE
1+
Info -> U In Use
2+
IMAGE ID DISK USAGE CONTENT SIZE EXTRA

cli/command/image/tree.go

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"fmt"
99
"os"
1010
"slices"
11-
"sort"
1211
"strings"
1312

1413
"github.com/containerd/platforms"
@@ -24,9 +23,10 @@ import (
2423
)
2524

2625
type treeOptions struct {
27-
images []imagetypes.Summary
28-
all bool
29-
filters client.Filters
26+
images []imagetypes.Summary
27+
all bool
28+
filters client.Filters
29+
expanded bool
3030
}
3131

3232
type treeView struct {
@@ -36,7 +36,7 @@ type treeView struct {
3636
imageSpacing bool
3737
}
3838

39-
func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error {
39+
func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) (int, error) {
4040
images := opts.images
4141

4242
view := treeView{
@@ -46,9 +46,9 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
4646

4747
for _, img := range images {
4848
if ctx.Err() != nil {
49-
return ctx.Err()
49+
return 0, ctx.Err()
5050
}
51-
details := imageDetails{
51+
topDetails := imageDetails{
5252
ID: img.ID,
5353
DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3),
5454
InUse: img.Containers > 0,
@@ -67,41 +67,58 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
6767
continue
6868
}
6969

70+
inUse := len(im.ImageData.Containers) > 0
71+
if inUse {
72+
// Mark top-level parent image as used if any of its subimages are used.
73+
topDetails.InUse = true
74+
}
75+
76+
if !opts.expanded {
77+
continue
78+
}
79+
7080
sub := subImage{
7181
Platform: platforms.Format(im.ImageData.Platform),
7282
Available: im.Available,
7383
Details: imageDetails{
7484
ID: im.ID,
7585
DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3),
76-
InUse: len(im.ImageData.Containers) > 0,
86+
InUse: inUse,
7787
ContentSize: units.HumanSizeWithPrecision(float64(im.Size.Content), 3),
7888
},
7989
}
8090

81-
if sub.Details.InUse {
82-
// Mark top-level parent image as used if any of its subimages are used.
83-
details.InUse = true
84-
}
85-
8691
children = append(children, sub)
8792

8893
// Add extra spacing between images if there's at least one entry with children.
8994
view.imageSpacing = true
9095
}
9196

92-
details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3)
97+
topDetails.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3)
9398

9499
// Sort tags for this image
95100
sortedTags := make([]string, len(img.RepoTags))
96101
copy(sortedTags, img.RepoTags)
97102
slices.Sort(sortedTags)
98103

99-
view.images = append(view.images, topImage{
100-
Names: sortedTags,
101-
Details: details,
102-
Children: children,
103-
created: img.Created,
104-
})
104+
if opts.expanded {
105+
view.images = append(view.images, topImage{
106+
Names: sortedTags,
107+
Details: topDetails,
108+
Children: children,
109+
created: img.Created,
110+
})
111+
continue
112+
}
113+
114+
for _, tag := range sortedTags {
115+
view.images = append(view.images, topImage{
116+
Names: []string{tag},
117+
Details: topDetails,
118+
Children: children,
119+
created: img.Created,
120+
})
121+
}
105122
}
106123

107124
slices.SortFunc(view.images, func(a, b topImage) int {
@@ -123,7 +140,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
123140
return strings.Compare(nameA, nameB)
124141
})
125142

126-
return printImageTree(dockerCLI, view)
143+
printImageTree(dockerCLI, view)
144+
return len(view.images), nil
127145
}
128146

129147
type imageDetails struct {
@@ -206,7 +224,7 @@ func getPossibleChips(view treeView) (chips []imageChip) {
206224
return possible
207225
}
208226

209-
func printImageTree(dockerCLI command.Cli, view treeView) error {
227+
func printImageTree(dockerCLI command.Cli, view treeView) {
210228
if streamRedirected(dockerCLI.Out()) {
211229
_, _ = fmt.Fprintln(dockerCLI.Err(), "WARNING: This output is designed for human readability. For machine-readable output, please use --format.")
212230
}
@@ -313,8 +331,6 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
313331
printChildren(out, columns, img, normalColor)
314332
_, _ = fmt.Fprintln(out)
315333
}
316-
317-
return nil
318334
}
319335

320336
// adjustColumns adjusts the width of the first column to maximize the space
@@ -356,7 +372,6 @@ func generateLegend(out tui.Output, width uint) string {
356372
legend += " |"
357373
}
358374
}
359-
legend += " "
360375

361376
r := int(width) - tui.Width(legend)
362377
if r < 0 {
@@ -406,15 +421,7 @@ func printNames(out tui.Output, headers []imgColumn, img topImage, color, untagg
406421
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
407422
}
408423

409-
// TODO: Replace with namesLongestToShortest := slices.SortedFunc(slices.Values(img.Names))
410-
// once we move to Go 1.23.
411-
namesLongestToShortest := make([]string, len(img.Names))
412-
copy(namesLongestToShortest, img.Names)
413-
sort.Slice(namesLongestToShortest, func(i, j int) bool {
414-
return len(namesLongestToShortest[i]) > len(namesLongestToShortest[j])
415-
})
416-
417-
for nameIdx, name := range namesLongestToShortest {
424+
for nameIdx, name := range img.Names {
418425
// Don't limit first names to the column width because only the last
419426
// name will be printed alongside other columns.
420427
if nameIdx < len(img.Names)-1 {

0 commit comments

Comments
 (0)