image/tree: Chips to represent "in use"

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Paweł Gronowski
2025-01-14 13:20:03 +01:00
parent 17c5fe601b
commit c950d48f72
8 changed files with 380 additions and 113 deletions

View File

@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/containerd/platforms"
"github.com/distribution/reference"
@ -17,6 +16,7 @@ import (
"github.com/docker/cli/cli/command/completion"
"github.com/docker/cli/cli/internal/jsonstream"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/tui"
"github.com/docker/docker/api/types/auxprogress"
"github.com/docker/docker/api/types/image"
registrytypes "github.com/docker/docker/api/types/registry"
@ -78,6 +78,7 @@ Image index won't be pushed, meaning that other manifests, including attestation
//nolint:gocyclo
func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error {
var platform *ocispec.Platform
out := tui.NewOutput(dockerCli.Out())
if opts.platform != "" {
p, err := platforms.Parse(opts.platform)
if err != nil {
@ -86,7 +87,7 @@ func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error
}
platform = &p
printNote(dockerCli, `Using --platform pushes only the specified platform manifest of a multi-platform image index.
out.PrintNote(`Using --platform pushes only the specified platform manifest of a multi-platform image index.
Other components, like attestations, will not be included.
To push the complete multi-platform image, remove the --platform flag.
`)
@ -132,8 +133,7 @@ To push the complete multi-platform image, remove the --platform flag.
defer func() {
for _, note := range notes {
fmt.Fprintln(dockerCli.Err(), "")
printNote(dockerCli, note)
out.PrintNote(note)
}
}()
@ -183,25 +183,3 @@ func handleAux() func(jm jsonstream.JSONMessage) {
}
}
}
func printNote(dockerCli command.Cli, format string, args ...any) {
if dockerCli.Err().IsTerminal() {
format = strings.ReplaceAll(format, "--platform", aec.Bold.Apply("--platform"))
}
header := " Info -> "
padding := len(header)
if dockerCli.Err().IsTerminal() {
padding = len("i Info > ")
header = aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → "))
}
_, _ = fmt.Fprint(dockerCli.Err(), header)
s := fmt.Sprintf(format, args...)
for idx, line := range strings.Split(s, "\n") {
if idx > 0 {
_, _ = fmt.Fprint(dockerCli.Err(), strings.Repeat(" ", padding))
}
_, _ = fmt.Fprintln(dockerCli.Err(), aec.Italic.Apply(line))
}
}

View File

@ -5,16 +5,16 @@ import (
"fmt"
"sort"
"strings"
"unicode/utf8"
"github.com/containerd/platforms"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/tui"
"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"
"github.com/opencontainers/go-digest"
)
type treeOptions struct {
@ -42,6 +42,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
view := treeView{
images: make([]topImage, 0, len(images)),
}
attested := make(map[digest.Digest]bool)
for _, img := range images {
details := imageDetails{
ID: img.ID,
@ -52,6 +54,10 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
var totalContent int64
children := make([]subImage, 0, len(img.Manifests))
for _, im := range img.Manifests {
if im.Kind == imagetypes.ManifestKindAttestation {
attested[im.AttestationData.For] = true
continue
}
if im.Kind != imagetypes.ManifestKindImage {
continue
}
@ -119,8 +125,59 @@ type subImage struct {
const columnSpacing = 3
var chipInUse = imageChip{
letter: "U",
desc: "In Use",
fg: 0,
bg: 14,
check: func(d *imageDetails) bool { return d.InUse },
}
var chipPlaceholder = tui.Str{
Plain: " ",
Fancy: " ",
}
type imageChip struct {
desc string
fg, bg int
letter string
check func(*imageDetails) bool
}
func (c imageChip) String(isTerm bool) string {
return tui.Str{
Plain: c.letter,
Fancy: tui.Chip(c.fg, c.bg, " "+c.letter+" "),
}.String(isTerm)
}
var allChips = []imageChip{
chipInUse,
}
func getPossibleChips(view treeView) (chips []imageChip) {
remaining := make([]imageChip, len(allChips))
copy(remaining, allChips)
var possible []imageChip
for _, img := range view.images {
for _, c := range img.Children {
for idx := len(remaining) - 1; idx >= 0; idx-- {
chip := remaining[idx]
if chip.check(&c.Details) {
possible = append(possible, chip)
remaining = append(remaining[:idx], remaining[idx+1:]...)
}
}
}
}
return possible
}
func printImageTree(dockerCLI command.Cli, view treeView) error {
out := dockerCLI.Out()
out := tui.NewOutput(dockerCLI.Out())
_, width := out.GetTtySize()
if width == 0 {
width = 80
@ -129,24 +186,17 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
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{}
}
topNameColor := out.Color(aec.NewBuilder(aec.BlueF, aec.Bold).ANSI)
normalColor := out.Color(tui.ColorSecondary)
untaggedColor := out.Color(tui.ColorTertiary)
isTerm := out.IsTerminal()
_, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on."))
_, _ = fmt.Fprintln(out, "")
out.PrintlnWithColor(tui.ColorWarning, "WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")
out.Println(generateLegend(out, width))
out.Println()
possibleChips := getPossibleChips(view)
columns := []imgColumn{
{
Title: "Image",
@ -178,19 +228,68 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
},
},
{
Title: "In Use",
Align: alignCenter,
Width: 6,
Color: &greenColor,
DetailsValue: func(d *imageDetails) string {
if d.InUse {
return "✔"
Title: "Extra",
Align: alignLeft,
Width: func() int {
maxChipsWidth := 0
for _, chip := range possibleChips {
s := chip.String(isTerm)
l := tui.Width(s)
maxChipsWidth += l
}
return " "
le := len("Extra")
if le > maxChipsWidth {
return le
}
return maxChipsWidth
}(),
Color: &tui.ColorNone,
DetailsValue: func(d *imageDetails) string {
var out string
for _, chip := range possibleChips {
if chip.check(d) {
out += chip.String(isTerm)
} else {
out += chipPlaceholder.String(isTerm)
}
}
return out
},
},
}
columns = adjustColumns(width, columns, view.images)
// Print columns
for i, h := range columns {
if i > 0 {
_, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing))
}
_, _ = fmt.Fprint(out, h.Print(tui.ColorTitle, strings.ToUpper(h.Title)))
}
_, _ = fmt.Fprintln(out)
// Print images
for _, img := range view.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
}
// adjustColumns adjusts the width of the first column to maximize the space
// available for image names and removes any columns that would be too narrow
// to display their content.
func adjustColumns(width uint, columns []imgColumn, images []topImage) []imgColumn {
nameWidth := int(width)
for idx, h := range columns {
if h.Width == 0 {
@ -208,41 +307,35 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
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
return columns
}
func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) {
func generateLegend(out tui.Output, width uint) string {
var legend string
legend += out.Sprint(tui.InfoHeader)
for idx, chip := range allChips {
legend += " " + out.Sprint(chip) + " " + chip.desc
if idx < len(allChips)-1 {
legend += " |"
}
}
legend += " "
r := int(width) - tui.Width(legend)
if r < 0 {
r = 0
}
legend = strings.Repeat(" ", r) + legend
return legend
}
func printDetails(out tui.Output, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) {
for _, h := range headers {
if h.DetailsValue == nil {
continue
@ -258,17 +351,18 @@ func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI,
}
}
func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalColor aec.ANSI) {
func printChildren(out tui.Output, headers []imgColumn, img topImage, normalColor aec.ANSI) {
for idx, sub := range img.Children {
clr := normalColor
if !sub.Available {
clr = normalColor.With(aec.Faint)
}
text := sub.Platform
if idx != len(img.Children)-1 {
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform))
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+text))
} else {
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform))
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+text))
}
printDetails(out, headers, clr, sub.Details)
@ -276,7 +370,7 @@ func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalCo
}
}
func printNames(out *streams.Out, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) {
func printNames(out tui.Output, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) {
if len(img.Names) == 0 {
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
}
@ -294,7 +388,7 @@ func printNames(out *streams.Out, headers []imgColumn, img topImage, color, unta
// name will be printed alongside other columns.
if nameIdx < len(img.Names)-1 {
_, fullWidth := out.GetTtySize()
_, _ = fmt.Fprintln(out, color.Apply(truncateRunes(name, int(fullWidth))))
_, _ = fmt.Fprintln(out, color.Apply(tui.Ellipsis(name, int(fullWidth))))
} else {
_, _ = fmt.Fprint(out, headers[0].Print(color, name))
}
@ -318,14 +412,6 @@ type imgColumn struct {
Color *aec.ANSI
}
func truncateRunes(s string, length int) string {
runes := []rune(s)
if len(runes) > length {
return string(runes[:length-1]) + "…"
}
return s
}
func (h imgColumn) Print(clr aec.ANSI, s string) string {
switch h.Align {
case alignCenter:
@ -338,10 +424,10 @@ func (h imgColumn) Print(clr aec.ANSI, s string) string {
}
func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
ln := utf8.RuneCountInString(s)
ln := tui.Width(s)
if ln > h.Width {
return clr.Apply(truncateRunes(s, h.Width))
return clr.Apply(tui.Ellipsis(s, h.Width))
}
fill := h.Width - ln
@ -353,37 +439,23 @@ func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
}
func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
ln := utf8.RuneCountInString(s)
ln := tui.Width(s)
if ln > h.Width {
return clr.Apply(truncateRunes(s, h.Width))
return clr.Apply(tui.Ellipsis(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)
ln := tui.Width(s)
if ln > h.Width {
return clr.Apply(truncateRunes(s, h.Width))
return clr.Apply(tui.Ellipsis(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)