Skip to content

Commit 0e3e9d2

Browse files
committed
image/tree: Chips to represent "in use"/"attested"
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
1 parent dbc5cf6 commit 0e3e9d2

7 files changed

Lines changed: 206 additions & 36 deletions

File tree

cli/command/image/push.go

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import (
88
"encoding/json"
99
"fmt"
1010
"io"
11-
"strings"
1211

1312
"github.com/containerd/platforms"
1413
"github.com/distribution/reference"
1514
"github.com/docker/cli/cli"
1615
"github.com/docker/cli/cli/command"
1716
"github.com/docker/cli/cli/command/completion"
1817
"github.com/docker/cli/cli/streams"
18+
"github.com/docker/cli/internal/tui"
1919
"github.com/docker/docker/api/types/auxprogress"
2020
"github.com/docker/docker/api/types/image"
2121
registrytypes "github.com/docker/docker/api/types/registry"
@@ -132,7 +132,6 @@ To push the complete multi-platform image, remove the --platform flag.
132132

133133
defer func() {
134134
for _, note := range notes {
135-
fmt.Fprintln(dockerCli.Err(), "")
136135
printNote(dockerCli, note)
137136
}
138137
}()
@@ -185,23 +184,5 @@ func handleAux(dockerCli command.Cli) func(jm jsonmessage.JSONMessage) {
185184
}
186185

187186
func printNote(dockerCli command.Cli, format string, args ...any) {
188-
if dockerCli.Err().IsTerminal() {
189-
format = strings.ReplaceAll(format, "--platform", aec.Bold.Apply("--platform"))
190-
}
191-
192-
header := " Info -> "
193-
padding := len(header)
194-
if dockerCli.Err().IsTerminal() {
195-
padding = len("i Info > ")
196-
header = aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → "))
197-
}
198-
199-
_, _ = fmt.Fprint(dockerCli.Err(), header)
200-
s := fmt.Sprintf(format, args...)
201-
for idx, line := range strings.Split(s, "\n") {
202-
if idx > 0 {
203-
_, _ = fmt.Fprint(dockerCli.Err(), strings.Repeat(" ", padding))
204-
}
205-
_, _ = fmt.Fprintln(dockerCli.Err(), aec.Italic.Apply(line))
206-
}
187+
tui.PrintNote(dockerCli.Err(), format, args...)
207188
}

cli/command/image/tree.go

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import (
1010
"github.com/containerd/platforms"
1111
"github.com/docker/cli/cli/command"
1212
"github.com/docker/cli/cli/streams"
13+
"github.com/docker/cli/internal/tui"
1314
"github.com/docker/docker/api/types/filters"
1415
imagetypes "github.com/docker/docker/api/types/image"
1516
"github.com/docker/docker/pkg/stringid"
1617
"github.com/docker/go-units"
1718
"github.com/morikuni/aec"
19+
"github.com/opencontainers/go-digest"
1820
)
1921

2022
type treeOptions struct {
@@ -42,6 +44,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
4244
view := treeView{
4345
images: make([]topImage, 0, len(images)),
4446
}
47+
attested := make(map[digest.Digest]bool)
48+
4549
for _, img := range images {
4650
details := imageDetails{
4751
ID: img.ID,
@@ -52,6 +56,10 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
5256
var totalContent int64
5357
children := make([]subImage, 0, len(img.Manifests))
5458
for _, im := range img.Manifests {
59+
if im.Kind == imagetypes.ManifestKindAttestation {
60+
attested[im.AttestationData.For] = true
61+
continue
62+
}
5563
if im.Kind != imagetypes.ManifestKindImage {
5664
continue
5765
}
@@ -89,6 +97,15 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
8997
})
9098
}
9199

100+
for _, img := range view.images {
101+
for idx, sub := range img.Children {
102+
if attested[digest.Digest(sub.Details.ID)] {
103+
sub.Details.Attested = true
104+
img.Children[idx] = sub
105+
}
106+
}
107+
}
108+
92109
sort.Slice(view.images, func(i, j int) bool {
93110
return view.images[i].created > view.images[j].created
94111
})
@@ -101,6 +118,7 @@ type imageDetails struct {
101118
DiskUsage string
102119
InUse bool
103120
ContentSize string
121+
Attested bool
104122
}
105123

106124
type topImage struct {
@@ -133,18 +151,45 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
133151
headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
134152
topNameColor := aec.NewBuilder(aec.BlueF, aec.Bold).ANSI
135153
normalColor := aec.NewBuilder(aec.DefaultF).ANSI
136-
greenColor := aec.NewBuilder(aec.GreenF).ANSI
137154
untaggedColor := aec.NewBuilder(aec.Faint).ANSI
155+
var noneColor aec.ANSI = noColor{}
138156
if !out.IsTerminal() {
139157
headerColor = noColor{}
140158
topNameColor = noColor{}
141159
normalColor = noColor{}
142-
greenColor = noColor{}
143160
warningColor = noColor{}
144161
untaggedColor = noColor{}
145162
}
163+
isTerm := out.IsTerminal()
146164

147165
_, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on."))
166+
167+
chipAttested := tui.Printable{
168+
Plain: "A",
169+
Fancy: tui.Chip(233, 49, " A "),
170+
}
171+
chipInUse := tui.Printable{
172+
Plain: "U",
173+
Fancy: tui.Chip(255, 177, " U "),
174+
}
175+
176+
var legend string
177+
// Print legend
178+
legend += tui.Printable{
179+
Plain: "ADDITIONAL Legend: ",
180+
Fancy: aec.Bold.Apply("Legend → "),
181+
}.String(isTerm)
182+
legend += chipAttested.String(isTerm)
183+
legend += " Attested | "
184+
legend += chipInUse.String(isTerm)
185+
legend += " In Use"
186+
187+
r := int(width) - countChars(legend)
188+
if r < 0 {
189+
r = 0
190+
}
191+
_, _ = fmt.Fprintln(out, strings.Repeat(" ", r)+legend)
192+
148193
_, _ = fmt.Fprintln(out, "")
149194

150195
columns := []imgColumn{
@@ -178,15 +223,19 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
178223
},
179224
},
180225
{
181-
Title: "In Use",
182-
Align: alignCenter,
183-
Width: 6,
184-
Color: &greenColor,
226+
Title: "Additional",
227+
Align: alignLeft,
228+
Width: len("Additional"),
229+
Color: &noneColor,
185230
DetailsValue: func(d *imageDetails) string {
231+
var out string
232+
if d.Attested {
233+
out += chipAttested.String(isTerm)
234+
}
186235
if d.InUse {
187-
return "✔"
236+
out += chipInUse.String(isTerm)
188237
}
189-
return " "
238+
return out
190239
},
191240
},
192241
}
@@ -307,11 +356,28 @@ type imgColumn struct {
307356
}
308357

309358
func truncateRunes(s string, length int) string {
310-
runes := []rune(s)
311-
if len(runes) > length {
312-
return string(runes[:length-3]) + "..."
359+
var out []rune
360+
ln := 0
361+
inEscape := false
362+
for _, r := range s {
363+
if r == '\x1b' {
364+
inEscape = true
365+
continue
366+
}
367+
if inEscape {
368+
if r == 'm' {
369+
inEscape = false
370+
}
371+
continue
372+
}
373+
374+
ln += 1
375+
if ln == length-3 {
376+
return string(out) + "..."
377+
}
378+
out = append(out, r)
313379
}
314-
return s
380+
return string(out)
315381
}
316382

317383
func (h imgColumn) Print(clr aec.ANSI, s string) string {
@@ -325,8 +391,26 @@ func (h imgColumn) Print(clr aec.ANSI, s string) string {
325391
return h.PrintL(clr, s)
326392
}
327393

394+
func cleanANSI(s string) string {
395+
for {
396+
start := strings.Index(s, "\x1b")
397+
if start == -1 {
398+
return s
399+
}
400+
end := strings.Index(s[start:], "m")
401+
if end == -1 {
402+
return s
403+
}
404+
s = s[:start] + s[start+end+1:]
405+
}
406+
}
407+
408+
func countChars(s string) int {
409+
return utf8.RuneCountInString(cleanANSI(s))
410+
}
411+
328412
func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
329-
ln := utf8.RuneCountInString(s)
413+
ln := countChars(s)
330414

331415
if ln > h.Width {
332416
return clr.Apply(truncateRunes(s, h.Width))
@@ -341,7 +425,7 @@ func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
341425
}
342426

343427
func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
344-
ln := utf8.RuneCountInString(s)
428+
ln := countChars(s)
345429
if ln > h.Width {
346430
return clr.Apply(truncateRunes(s, h.Width))
347431
}
@@ -350,7 +434,7 @@ func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
350434
}
351435

352436
func (h imgColumn) PrintR(clr aec.ANSI, s string) string {
353-
ln := utf8.RuneCountInString(s)
437+
ln := countChars(s)
354438
if ln > h.Width {
355439
return clr.Apply(truncateRunes(s, h.Width))
356440
}

cli/command/image/tree_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package image
2+
3+
import (
4+
"testing"
5+
6+
"github.com/docker/cli/internal/tui"
7+
"gotest.tools/v3/assert"
8+
is "gotest.tools/v3/assert/cmp"
9+
)
10+
11+
func TestTruncateRunes(t *testing.T) {
12+
a := tui.Chip(0, 0, " A ")
13+
14+
assert.Check(t, is.Equal(countChars(a), 3))
15+
16+
b := tui.Chip(0, 0, " B ")
17+
18+
assert.Check(t, is.Equal(countChars(a+b), 6))
19+
}

internal/tui/chip.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package tui
2+
3+
import "strconv"
4+
5+
func Chip(fg, bg int, content string) string {
6+
fgAnsi := "\x1b[38;5;" + strconv.Itoa(fg) + "m"
7+
bgAnsi := "\x1b[48;5;" + strconv.Itoa(bg) + "m"
8+
return fgAnsi + bgAnsi + content + "\x1b[0m"
9+
}

internal/tui/colors.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package tui
2+
3+
import (
4+
"github.com/morikuni/aec"
5+
)
6+
7+
var (
8+
ColorTitle = aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
9+
ColorPrimary = aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
10+
ColorSecondary = aec.NewBuilder(aec.DefaultF).ANSI
11+
ColorTertiary = aec.NewBuilder(aec.DefaultF, aec.Faint).ANSI
12+
ColorLink = aec.NewBuilder(aec.LightCyanF, aec.Underline).ANSI
13+
ColorFlag = aec.NewBuilder(aec.Bold).ANSI
14+
)

internal/tui/note.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/docker/cli/cli/streams"
8+
"github.com/morikuni/aec"
9+
)
10+
11+
func PrintNote(out *streams.Out, format string, args ...any) {
12+
if out.IsTerminal() {
13+
format = strings.ReplaceAll(format, "--platform", ColorFlag.Apply("--platform"))
14+
}
15+
16+
header := " Info -> "
17+
padding := len(header)
18+
if out.IsTerminal() {
19+
padding = len("i Info > ")
20+
header = aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → "))
21+
}
22+
23+
_, _ = fmt.Fprint(out, "\n", header)
24+
s := fmt.Sprintf(format, args...)
25+
for idx, line := range strings.Split(s, "\n") {
26+
if idx > 0 {
27+
_, _ = fmt.Fprint(out, strings.Repeat(" ", padding))
28+
}
29+
30+
l := line
31+
if out.IsTerminal() {
32+
l = aec.Italic.Apply(l)
33+
}
34+
_, _ = fmt.Fprintln(out, l)
35+
}
36+
}

internal/tui/printable.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/docker/cli/cli/streams"
7+
)
8+
9+
type Printable struct {
10+
Plain string
11+
Fancy string
12+
}
13+
14+
func (p Printable) String(isTerminal bool) string {
15+
if isTerminal {
16+
return p.Fancy
17+
}
18+
return p.Plain
19+
}
20+
21+
func (p Printable) Print(out *streams.Out) {
22+
if out.IsTerminal() {
23+
_, _ = fmt.Fprint(out, p.Fancy)
24+
} else {
25+
_, _ = fmt.Fprint(out, p.Plain)
26+
}
27+
}

0 commit comments

Comments
 (0)