Skip to content

Commit e6df8ca

Browse files
authored
Merge pull request #196 from SAY-5/fix/ecr-image-list-columns-195
fix(ecr): align image list columns to fixed widths (#195)
2 parents 3725a2f + 349d1dc commit e6df8ca

2 files changed

Lines changed: 124 additions & 1 deletion

File tree

internal/services/aws/ecr_model.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ type ECRImage struct {
4747
SizeBytes int64
4848
}
4949

50+
// Column widths for the ECR image list. Tuned so a typical row
51+
// (`v1.2.3`, sha256-19, `2026-04-28 12:34`, ` 12.3 MB`, ` [stale]`) fits
52+
// comfortably while a long tag truncates with an ellipsis instead of
53+
// pushing the later columns out of alignment (#195).
54+
const (
55+
ecrTagColWidth = 30
56+
ecrDigestColWidth = 19 // shortDigest already pads/truncates to <= 19
57+
ecrPushedColWidth = 16 // "2006-01-02 15:04"
58+
ecrSizeColWidth = 9
59+
)
60+
5061
func (i ECRImage) DisplayTitle() string {
5162
pushed := "-"
5263
if !i.PushedAt.IsZero() {
@@ -58,7 +69,34 @@ func (i ECRImage) DisplayTitle() string {
5869
} else if i.IsStale(time.Now()) {
5970
status = " [stale]"
6071
}
61-
return fmt.Sprintf("%s %s %s %s%s", i.PrimaryLabel(), shortDigest(i.Digest), pushed, FormatBytes(i.SizeBytes), status)
72+
return fmt.Sprintf("%s %-*s %-*s %*s%s",
73+
fitColumn(i.PrimaryLabel(), ecrTagColWidth),
74+
ecrDigestColWidth, shortDigest(i.Digest),
75+
ecrPushedColWidth, pushed,
76+
ecrSizeColWidth, FormatBytes(i.SizeBytes),
77+
status,
78+
)
79+
}
80+
81+
// fitColumn right-pads `s` to exactly `width` runes so subsequent columns
82+
// align in the rendered list. Long values are truncated with a single
83+
// trailing ellipsis. Width is measured in runes (not bytes) so multi-byte
84+
// tag characters don't break the layout.
85+
func fitColumn(s string, width int) string {
86+
if width <= 0 {
87+
return s
88+
}
89+
runes := []rune(s)
90+
if len(runes) == width {
91+
return s
92+
}
93+
if len(runes) < width {
94+
return s + strings.Repeat(" ", width-len(runes))
95+
}
96+
if width <= 1 {
97+
return string(runes[:width])
98+
}
99+
return string(runes[:width-1]) + "…"
62100
}
63101

64102
func (i ECRImage) FilterText() string {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package aws
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
)
8+
9+
// Regression test for #195. The ECR image list rendered tag/digest/etc.
10+
// as space-padded but variable-width columns, so the digest column
11+
// started at a different position for each tag length and the list was
12+
// hard to scan. After the fix the digest substring lands at the same
13+
// position in every rendered row, regardless of tag length.
14+
func TestECRImageDisplayTitleColumnsAlign(t *testing.T) {
15+
now := time.Now()
16+
mk := func(tag string) ECRImage {
17+
return ECRImage{
18+
Tags: []string{tag},
19+
Digest: "sha256:abcdef0123456789",
20+
PushedAt: now,
21+
SizeBytes: 1234567,
22+
}
23+
}
24+
25+
short := mk("v1").DisplayTitle()
26+
medium := mk("v1.2.3-rc.1").DisplayTitle()
27+
long := mk("very-long-feature-branch-tag-that-overflows-the-column").DisplayTitle()
28+
29+
// shortDigest output for our digest. Reused as a column-position probe.
30+
const digest = "sha256:abcdef0123"
31+
32+
// Compare rune positions, not bytes — the truncated row contains a
33+
// multi-byte ellipsis ('…' is 3 bytes / 1 rune) so byte indexes
34+
// disagree even when the rendered column lines up correctly in a
35+
// terminal. Rune count matches what the user actually sees.
36+
runePos := func(s, needle string) int {
37+
idx := strings.Index(s, needle)
38+
if idx < 0 {
39+
return -1
40+
}
41+
return len([]rune(s[:idx]))
42+
}
43+
pShort := runePos(short, digest)
44+
pMedium := runePos(medium, digest)
45+
pLong := runePos(long, digest)
46+
47+
if pShort < 0 || pMedium < 0 || pLong < 0 {
48+
t.Fatalf("digest substring missing from rendered row:\n short=%q\n medium=%q\n long=%q",
49+
short, medium, long)
50+
}
51+
if pShort != pMedium || pMedium != pLong {
52+
t.Fatalf("digest column not aligned (short=%d medium=%d long=%d):\n %s\n %s\n %s",
53+
pShort, pMedium, pLong, short, medium, long)
54+
}
55+
56+
// Overflow must truncate via ellipsis, not push later columns right.
57+
if !strings.Contains(long, "…") {
58+
t.Fatalf("long tag should truncate with ellipsis, got %q", long)
59+
}
60+
}
61+
62+
// fitColumn is the helper that does the truncate-or-pad work; pin its
63+
// contract directly so any future width tweak surfaces here.
64+
func TestFitColumn(t *testing.T) {
65+
cases := []struct {
66+
name string
67+
in string
68+
width int
69+
want string
70+
}{
71+
{"shorter pads with spaces", "abc", 6, "abc "},
72+
{"exact length unchanged", "abcdef", 6, "abcdef"},
73+
{"longer truncates with ellipsis", "abcdefghij", 6, "abcde…"},
74+
{"unicode tag still respects rune width", "v1.2-α", 6, "v1.2-α"},
75+
{"width 0 returns input", "abc", 0, "abc"},
76+
{"width 1 with overflow keeps single rune", "abcde", 1, "a"},
77+
}
78+
for _, tc := range cases {
79+
t.Run(tc.name, func(t *testing.T) {
80+
if got := fitColumn(tc.in, tc.width); got != tc.want {
81+
t.Fatalf("fitColumn(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want)
82+
}
83+
})
84+
}
85+
}

0 commit comments

Comments
 (0)