Skip to content

Commit dc2b0ce

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

7 files changed

Lines changed: 207 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: 100 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 {
@@ -119,6 +137,7 @@ type subImage struct {
119137

120138
const columnSpacing = 3
121139

140+
//nolint:gocyclo
122141
func printImageTree(dockerCLI command.Cli, view treeView) error {
123142
out := dockerCLI.Out()
124143
_, width := out.GetTtySize()
@@ -133,18 +152,45 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
133152
headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
134153
topNameColor := aec.NewBuilder(aec.BlueF, aec.Bold).ANSI
135154
normalColor := aec.NewBuilder(aec.DefaultF).ANSI
136-
greenColor := aec.NewBuilder(aec.GreenF).ANSI
137155
untaggedColor := aec.NewBuilder(aec.Faint).ANSI
156+
var noneColor aec.ANSI = noColor{}
138157
if !out.IsTerminal() {
139158
headerColor = noColor{}
140159
topNameColor = noColor{}
141160
normalColor = noColor{}
142-
greenColor = noColor{}
143161
warningColor = noColor{}
144162
untaggedColor = noColor{}
145163
}
164+
isTerm := out.IsTerminal()
146165

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

150196
columns := []imgColumn{
@@ -178,15 +224,19 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
178224
},
179225
},
180226
{
181-
Title: "In Use",
182-
Align: alignCenter,
183-
Width: 6,
184-
Color: &greenColor,
227+
Title: "Additional",
228+
Align: alignLeft,
229+
Width: len("Additional"),
230+
Color: &noneColor,
185231
DetailsValue: func(d *imageDetails) string {
232+
var out string
233+
if d.Attested {
234+
out += chipAttested.String(isTerm)
235+
}
186236
if d.InUse {
187-
return "✔"
237+
out += chipInUse.String(isTerm)
188238
}
189-
return " "
239+
return out
190240
},
191241
},
192242
}
@@ -307,11 +357,28 @@ type imgColumn struct {
307357
}
308358

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

317384
func (h imgColumn) Print(clr aec.ANSI, s string) string {
@@ -325,8 +392,26 @@ func (h imgColumn) Print(clr aec.ANSI, s string) string {
325392
return h.PrintL(clr, s)
326393
}
327394

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

331416
if ln > h.Width {
332417
return clr.Apply(truncateRunes(s, h.Width))
@@ -341,7 +426,7 @@ func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
341426
}
342427

343428
func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
344-
ln := utf8.RuneCountInString(s)
429+
ln := countChars(s)
345430
if ln > h.Width {
346431
return clr.Apply(truncateRunes(s, h.Width))
347432
}
@@ -350,7 +435,7 @@ func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
350435
}
351436

352437
func (h imgColumn) PrintR(clr aec.ANSI, s string) string {
353-
ln := utf8.RuneCountInString(s)
438+
ln := countChars(s)
354439
if ln > h.Width {
355440
return clr.Apply(truncateRunes(s, h.Width))
356441
}

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)