Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion internal/services/aws/ecr_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ type ECRImage struct {
SizeBytes int64
}

// Column widths for the ECR image list. Tuned so a typical row
// (`v1.2.3`, sha256-19, `2026-04-28 12:34`, ` 12.3 MB`, ` [stale]`) fits
// comfortably while a long tag truncates with an ellipsis instead of
// pushing the later columns out of alignment (#195).
const (
ecrTagColWidth = 30
ecrDigestColWidth = 19 // shortDigest already pads/truncates to <= 19
ecrPushedColWidth = 16 // "2006-01-02 15:04"
ecrSizeColWidth = 9
)

func (i ECRImage) DisplayTitle() string {
pushed := "-"
if !i.PushedAt.IsZero() {
Expand All @@ -58,7 +69,34 @@ func (i ECRImage) DisplayTitle() string {
} else if i.IsStale(time.Now()) {
status = " [stale]"
}
return fmt.Sprintf("%s %s %s %s%s", i.PrimaryLabel(), shortDigest(i.Digest), pushed, FormatBytes(i.SizeBytes), status)
return fmt.Sprintf("%s %-*s %-*s %*s%s",
fitColumn(i.PrimaryLabel(), ecrTagColWidth),
ecrDigestColWidth, shortDigest(i.Digest),
ecrPushedColWidth, pushed,
ecrSizeColWidth, FormatBytes(i.SizeBytes),
status,
)
}

// fitColumn right-pads `s` to exactly `width` runes so subsequent columns
// align in the rendered list. Long values are truncated with a single
// trailing ellipsis. Width is measured in runes (not bytes) so multi-byte
// tag characters don't break the layout.
func fitColumn(s string, width int) string {
if width <= 0 {
return s
}
runes := []rune(s)
if len(runes) == width {
return s
}
if len(runes) < width {
return s + strings.Repeat(" ", width-len(runes))
}
if width <= 1 {
return string(runes[:width])
}
return string(runes[:width-1]) + "…"
}

func (i ECRImage) FilterText() string {
Expand Down
85 changes: 85 additions & 0 deletions internal/services/aws/ecr_model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package aws

import (
"strings"
"testing"
"time"
)

// Regression test for #195. The ECR image list rendered tag/digest/etc.
// as space-padded but variable-width columns, so the digest column
// started at a different position for each tag length and the list was
// hard to scan. After the fix the digest substring lands at the same
// position in every rendered row, regardless of tag length.
func TestECRImageDisplayTitleColumnsAlign(t *testing.T) {
now := time.Now()
mk := func(tag string) ECRImage {
return ECRImage{
Tags: []string{tag},
Digest: "sha256:abcdef0123456789",
PushedAt: now,
SizeBytes: 1234567,
}
}

short := mk("v1").DisplayTitle()
medium := mk("v1.2.3-rc.1").DisplayTitle()
long := mk("very-long-feature-branch-tag-that-overflows-the-column").DisplayTitle()

// shortDigest output for our digest. Reused as a column-position probe.
const digest = "sha256:abcdef0123"

// Compare rune positions, not bytes — the truncated row contains a
// multi-byte ellipsis ('…' is 3 bytes / 1 rune) so byte indexes
// disagree even when the rendered column lines up correctly in a
// terminal. Rune count matches what the user actually sees.
runePos := func(s, needle string) int {
idx := strings.Index(s, needle)
if idx < 0 {
return -1
}
return len([]rune(s[:idx]))
}
pShort := runePos(short, digest)
pMedium := runePos(medium, digest)
pLong := runePos(long, digest)

if pShort < 0 || pMedium < 0 || pLong < 0 {
t.Fatalf("digest substring missing from rendered row:\n short=%q\n medium=%q\n long=%q",
short, medium, long)
}
if pShort != pMedium || pMedium != pLong {
t.Fatalf("digest column not aligned (short=%d medium=%d long=%d):\n %s\n %s\n %s",
pShort, pMedium, pLong, short, medium, long)
}

// Overflow must truncate via ellipsis, not push later columns right.
if !strings.Contains(long, "…") {
t.Fatalf("long tag should truncate with ellipsis, got %q", long)
}
}

// fitColumn is the helper that does the truncate-or-pad work; pin its
// contract directly so any future width tweak surfaces here.
func TestFitColumn(t *testing.T) {
cases := []struct {
name string
in string
width int
want string
}{
{"shorter pads with spaces", "abc", 6, "abc "},
{"exact length unchanged", "abcdef", 6, "abcdef"},
{"longer truncates with ellipsis", "abcdefghij", 6, "abcde…"},
{"unicode tag still respects rune width", "v1.2-α", 6, "v1.2-α"},
{"width 0 returns input", "abc", 0, "abc"},
{"width 1 with overflow keeps single rune", "abcde", 1, "a"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := fitColumn(tc.in, tc.width); got != tc.want {
t.Fatalf("fitColumn(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want)
}
})
}
}
Loading