Merge pull request #6566 from vvoland/img-list
image/list: Show collapsed tree by default
This commit is contained in:
+49
-31
@@ -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].
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
[39m[1mIMAGE[0m [39m[1mID[0m [39m[1mDISK USAGE[0m [39m[1mCONTENT SIZE[0m [39m[1mEXTRA[0m
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Info -> U In Use
|
||||
[39m[1mIMAGE[0m [39m[1mID[0m [39m[1mDISK USAGE[0m [39m[1mCONTENT SIZE[0m [39m[1mEXTRA[0m
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
REPOSITORY TAG IMAGE ID CREATED SIZE
|
||||
Info -> U In Use
|
||||
[39m[1mIMAGE[0m [39m[1mID[0m [39m[1mDISK USAGE[0m [39m[1mCONTENT SIZE[0m [39m[1mEXTRA[0m
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
REPOSITORY TAG IMAGE ID CREATED SIZE
|
||||
Info -> U In Use
|
||||
[39m[1mIMAGE[0m [39m[1mID[0m [39m[1mDISK USAGE[0m [39m[1mCONTENT SIZE[0m [39m[1mEXTRA[0m
|
||||
|
||||
+41
-34
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user