refactor(images): render list with bounded table layout
Use the shared table component with fixed column widths and grapheme-safe truncation so long repositories, tags, and image IDs stay readable without breaking row shape.
- commit
- 857a4f35d5970760475725b7df0864dcc956f768
- author
- brice · · 2026-02-13 05:18 UTC
- parent
- 25e2a20
- signature
- unsigned
4 files · +370 -15
this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.
M internal/adapters/in/cli/images.go +37 -10
| @@ -5,12 +5,13 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | 7 | "strings" | |
| 8 | - | "text/tabwriter" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| 10 | + | "github.com/charmbracelet/lipgloss" | |
| 11 | 11 | "github.com/spf13/cobra" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | type imagesClient interface { | |
| @@ -24,6 +25,24 @@ type imagesPruneOptions struct { | |||
| 24 | 25 | RuntimeOnly bool | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 28 | + | const ( | |
| 29 | + | imagesListRepositoryColumnWidth = 44 | |
| 30 | + | imagesListTagColumnWidth = 14 | |
| 31 | + | imagesListSizeColumnWidth = 10 | |
| 32 | + | imagesListCreatedColumnWidth = 22 | |
| 33 | + | imagesListImageIDColumnWidth = 22 | |
| 34 | + | imagesListDanglingColumnWidth = 10 | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | var imagesListTableColumns = []components.TableColumn{ | |
| 38 | + | {Title: "REPOSITORY", Width: imagesListRepositoryColumnWidth}, | |
| 39 | + | {Title: "TAG", Width: imagesListTagColumnWidth}, | |
| 40 | + | {Title: "SIZE", Width: imagesListSizeColumnWidth}, | |
| 41 | + | {Title: "CREATED", Width: imagesListCreatedColumnWidth}, | |
| 42 | + | {Title: "IMAGE_ID", Width: imagesListImageIDColumnWidth}, | |
| 43 | + | {Title: "DANGLING", Width: imagesListDanglingColumnWidth}, | |
| 44 | + | } | |
| 45 | + | ||
| 27 | 46 | func newImagesCmd() *cobra.Command { | |
| 28 | 47 | cmd := &cobra.Command{ | |
| 29 | 48 | Use: "images", | |
| @@ -95,12 +114,8 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||
| 95 | 114 | return err | |
| 96 | 115 | } | |
| 97 | 116 | ||
| 98 | - | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |
| 99 | - | if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil { | |
| 100 | - | return err | |
| 101 | - | } | |
| 102 | - | ||
| 103 | 117 | danglingCount := 0 | |
| 118 | + | rows := make([][]string, 0, len(images)) | |
| 104 | 119 | for _, img := range images { | |
| 105 | 120 | dangling := "no" | |
| 106 | 121 | if img.Dangling { | |
| @@ -108,12 +123,24 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||
| 108 | 123 | danglingCount++ | |
| 109 | 124 | } | |
| 110 | 125 | ||
| 111 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil { | |
| 112 | - | return err | |
| 113 | - | } | |
| 126 | + | rows = append(rows, []string{ | |
| 127 | + | img.Repository, | |
| 128 | + | img.Tag, | |
| 129 | + | formatImageSize(img.Size), | |
| 130 | + | formatImageCreatedAt(img.Created), | |
| 131 | + | formatImageID(img.ID), | |
| 132 | + | dangling, | |
| 133 | + | }) | |
| 114 | 134 | } | |
| 115 | 135 | ||
| 116 | - | if err := w.Flush(); err != nil { | |
| 136 | + | table := components.NewTable( | |
| 137 | + | components.WithColumns(imagesListTableColumns), | |
| 138 | + | components.WithRows(rows), | |
| 139 | + | components.WithHeaderStyle(lipgloss.NewStyle().Bold(true)), | |
| 140 | + | components.WithCellStyle(lipgloss.NewStyle()), | |
| 141 | + | ) | |
| 142 | + | ||
| 143 | + | if err := cliWriteLine(out, table.View()); err != nil { | |
| 117 | 144 | return err | |
| 118 | 145 | } | |
| 119 | 146 | ||
| @@ -5,12 +5,13 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | - | "text/tabwriter" | |||
| 9 | "time" | 8 | "time" | ||
| 10 | 9 | ||||
| 10 | + | "github.com/charmbracelet/lipgloss" | |||
| 11 | "github.com/spf13/cobra" | 11 | "github.com/spf13/cobra" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/gordon/internal/adapters/dto" | 13 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | type imagesClient interface { | 17 | type imagesClient interface { | ||
| @@ -24,6 +25,24 @@ type imagesPruneOptions struct { | |||||
| 24 | RuntimeOnly bool | 25 | RuntimeOnly bool | ||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 28 | + | const ( | |||
| 29 | + | imagesListRepositoryColumnWidth = 44 | |||
| 30 | + | imagesListTagColumnWidth = 14 | |||
| 31 | + | imagesListSizeColumnWidth = 10 | |||
| 32 | + | imagesListCreatedColumnWidth = 22 | |||
| 33 | + | imagesListImageIDColumnWidth = 22 | |||
| 34 | + | imagesListDanglingColumnWidth = 10 | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | var imagesListTableColumns = []components.TableColumn{ | |||
| 38 | + | {Title: "REPOSITORY", Width: imagesListRepositoryColumnWidth}, | |||
| 39 | + | {Title: "TAG", Width: imagesListTagColumnWidth}, | |||
| 40 | + | {Title: "SIZE", Width: imagesListSizeColumnWidth}, | |||
| 41 | + | {Title: "CREATED", Width: imagesListCreatedColumnWidth}, | |||
| 42 | + | {Title: "IMAGE_ID", Width: imagesListImageIDColumnWidth}, | |||
| 43 | + | {Title: "DANGLING", Width: imagesListDanglingColumnWidth}, | |||
| 44 | + | } | |||
| 45 | + | ||||
| 27 | func newImagesCmd() *cobra.Command { | 46 | func newImagesCmd() *cobra.Command { | ||
| 28 | cmd := &cobra.Command{ | 47 | cmd := &cobra.Command{ | ||
| 29 | Use: "images", | 48 | Use: "images", | ||
| @@ -95,12 +114,8 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||||
| 95 | return err | 114 | return err | ||
| 96 | } | 115 | } | ||
| 97 | 116 | ||||
| 98 | - | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |||
| 99 | - | if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil { | |||
| 100 | - | return err | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | danglingCount := 0 | 117 | danglingCount := 0 | ||
| 118 | + | rows := make([][]string, 0, len(images)) | |||
| 104 | for _, img := range images { | 119 | for _, img := range images { | ||
| 105 | dangling := "no" | 120 | dangling := "no" | ||
| 106 | if img.Dangling { | 121 | if img.Dangling { | ||
| @@ -108,12 +123,24 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||||
| 108 | danglingCount++ | 123 | danglingCount++ | ||
| 109 | } | 124 | } | ||
| 110 | 125 | ||||
| 111 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil { | 126 | + | rows = append(rows, []string{ |
| 112 | - | return err | 127 | + | img.Repository, |
| 113 | - | } | 128 | + | img.Tag, |
| 129 | + | formatImageSize(img.Size), | |||
| 130 | + | formatImageCreatedAt(img.Created), | |||
| 131 | + | formatImageID(img.ID), | |||
| 132 | + | dangling, | |||
| 133 | + | }) | |||
| 114 | } | 134 | } | ||
| 115 | 135 | ||||
| 116 | - | if err := w.Flush(); err != nil { | 136 | + | table := components.NewTable( |
| 137 | + | components.WithColumns(imagesListTableColumns), | |||
| 138 | + | components.WithRows(rows), | |||
| 139 | + | components.WithHeaderStyle(lipgloss.NewStyle().Bold(true)), | |||
| 140 | + | components.WithCellStyle(lipgloss.NewStyle()), | |||
| 141 | + | ) | |||
| 142 | + | ||||
| 143 | + | if err := cliWriteLine(out, table.View()); err != nil { | |||
| 117 | return err | 144 | return err | ||
| 118 | } | 145 | } | ||
| 119 | 146 | ||||
M internal/adapters/in/cli/images_test.go +176 -0
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "regexp" | |
| 8 | + | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| @@ -62,6 +64,110 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||
| 62 | 64 | assert.Equal(t, 1, client.listImagesCalls) | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 67 | + | func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) { | |
| 68 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 69 | + | client := &fakeImagesClient{ | |
| 70 | + | listImagesResp: []dto.Image{ | |
| 71 | + | {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}, | |
| 72 | + | }, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | var out bytes.Buffer | |
| 76 | + | err := runImagesList(context.Background(), client, &out) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | ||
| 79 | + | text := stripANSI(out.String()) | |
| 80 | + | header := findHeaderLine(text) | |
| 81 | + | assert.NotEmpty(t, header) | |
| 82 | + | assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG")) | |
| 83 | + | assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE")) | |
| 84 | + | assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED")) | |
| 85 | + | assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID")) | |
| 86 | + | assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING")) | |
| 87 | + | assert.Contains(t, text, "Total images: 1 (dangling: 0)") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) { | |
| 91 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 92 | + | longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use" | |
| 93 | + | longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow" | |
| 94 | + | client := &fakeImagesClient{ | |
| 95 | + | listImagesResp: []dto.Image{ | |
| 96 | + | {Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false}, | |
| 97 | + | }, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | var out bytes.Buffer | |
| 101 | + | err := runImagesList(context.Background(), client, &out) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | ||
| 104 | + | text := stripANSI(out.String()) | |
| 105 | + | header := findHeaderLine(text) | |
| 106 | + | assert.NotEmpty(t, header) | |
| 107 | + | assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG")) | |
| 108 | + | assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE")) | |
| 109 | + | assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED")) | |
| 110 | + | assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID")) | |
| 111 | + | assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING")) | |
| 112 | + | ||
| 113 | + | row := findImageListRowLine(text, "latest") | |
| 114 | + | assert.NotEmpty(t, row) | |
| 115 | + | ||
| 116 | + | cols := splitTableColumns(row) | |
| 117 | + | require.Len(t, cols, 6) | |
| 118 | + | ||
| 119 | + | repositoryField := cols[0] | |
| 120 | + | imageIDField := cols[4] | |
| 121 | + | ||
| 122 | + | assert.NotEqual(t, longRepository, repositoryField) | |
| 123 | + | assert.NotEqual(t, longImageID, imageIDField) | |
| 124 | + | assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis") | |
| 125 | + | assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis") | |
| 126 | + | assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "..."))) | |
| 127 | + | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |
| 128 | + | ||
| 129 | + | assert.Contains(t, text, "Total images: 1 (dangling: 0)") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) { | |
| 133 | + | longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra" | |
| 134 | + | longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name" | |
| 135 | + | longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" | |
| 136 | + | client := &fakeImagesClient{ | |
| 137 | + | listImagesResp: []dto.Image{ | |
| 138 | + | {Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true}, | |
| 139 | + | }, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | var out bytes.Buffer | |
| 143 | + | err := runImagesList(context.Background(), client, &out) | |
| 144 | + | require.NoError(t, err) | |
| 145 | + | ||
| 146 | + | text := stripANSI(out.String()) | |
| 147 | + | rows := findImageListRows(text) | |
| 148 | + | require.Len(t, rows, 1, "expected a single, no-wrap data row") | |
| 149 | + | ||
| 150 | + | cols := rows[0] | |
| 151 | + | require.Len(t, cols, 6) | |
| 152 | + | ||
| 153 | + | repositoryField := cols[0] | |
| 154 | + | tagField := cols[1] | |
| 155 | + | sizeField := cols[2] | |
| 156 | + | createdField := cols[3] | |
| 157 | + | imageIDField := cols[4] | |
| 158 | + | ||
| 159 | + | assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis") | |
| 160 | + | assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis") | |
| 161 | + | assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis") | |
| 162 | + | assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "..."))) | |
| 163 | + | assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "..."))) | |
| 164 | + | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |
| 165 | + | ||
| 166 | + | assert.Equal(t, "-", sizeField) | |
| 167 | + | assert.Equal(t, "-", createdField) | |
| 168 | + | assert.Contains(t, text, "Total images: 1 (dangling: 1)") | |
| 169 | + | } | |
| 170 | + | ||
| 65 | 171 | func TestRunImagesList_EmptyOutput(t *testing.T) { | |
| 66 | 172 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | |
| 67 | 173 | ||
| @@ -156,3 +262,73 @@ func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) { | |||
| 156 | 262 | require.Error(t, err) | |
| 157 | 263 | assert.Contains(t, err.Error(), "empty response") | |
| 158 | 264 | } | |
| 265 | + | ||
| 266 | + | func stripANSI(value string) string { | |
| 267 | + | ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) | |
| 268 | + | return ansiRegex.ReplaceAllString(value, "") | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func findHeaderLine(text string) string { | |
| 272 | + | for _, line := range strings.Split(text, "\n") { | |
| 273 | + | if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") { | |
| 274 | + | return line | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | return "" | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func findImageListRowLine(text string, tag string) string { | |
| 282 | + | for _, line := range strings.Split(text, "\n") { | |
| 283 | + | trimmed := strings.TrimSpace(line) | |
| 284 | + | if trimmed == "" { | |
| 285 | + | continue | |
| 286 | + | } | |
| 287 | + | if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") { | |
| 288 | + | continue | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | cols := splitTableColumns(line) | |
| 292 | + | if len(cols) == 6 && cols[1] == tag { | |
| 293 | + | return line | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | return "" | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func findImageListRows(text string) [][]string { | |
| 301 | + | rows := make([][]string, 0) | |
| 302 | + | for _, line := range strings.Split(text, "\n") { | |
| 303 | + | trimmed := strings.TrimSpace(line) | |
| 304 | + | if trimmed == "" { | |
| 305 | + | continue | |
| 306 | + | } | |
| 307 | + | if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") { | |
| 308 | + | continue | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | cols := splitTableColumns(line) | |
| 312 | + | if len(cols) == 6 && cols[0] != "REPOSITORY" { | |
| 313 | + | rows = append(rows, cols) | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | return rows | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func splitTableColumns(line string) []string { | |
| 321 | + | if strings.Contains(line, "│") { | |
| 322 | + | parts := strings.Split(line, "│") | |
| 323 | + | cols := make([]string, 0, len(parts)) | |
| 324 | + | for _, part := range parts { | |
| 325 | + | cell := strings.TrimSpace(part) | |
| 326 | + | if cell != "" { | |
| 327 | + | cols = append(cols, cell) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | return cols | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1) | |
| 334 | + | } | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "regexp" | |||
| 8 | + | "strings" | |||
| 7 | "testing" | 9 | "testing" | ||
| 8 | "time" | 10 | "time" | ||
| 9 | 11 | ||||
| @@ -62,6 +64,110 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||||
| 62 | assert.Equal(t, 1, client.listImagesCalls) | 64 | assert.Equal(t, 1, client.listImagesCalls) | ||
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 67 | + | func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) { | |||
| 68 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 69 | + | client := &fakeImagesClient{ | |||
| 70 | + | listImagesResp: []dto.Image{ | |||
| 71 | + | {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}, | |||
| 72 | + | }, | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | var out bytes.Buffer | |||
| 76 | + | err := runImagesList(context.Background(), client, &out) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | ||||
| 79 | + | text := stripANSI(out.String()) | |||
| 80 | + | header := findHeaderLine(text) | |||
| 81 | + | assert.NotEmpty(t, header) | |||
| 82 | + | assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG")) | |||
| 83 | + | assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE")) | |||
| 84 | + | assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED")) | |||
| 85 | + | assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID")) | |||
| 86 | + | assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING")) | |||
| 87 | + | assert.Contains(t, text, "Total images: 1 (dangling: 0)") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) { | |||
| 91 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 92 | + | longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use" | |||
| 93 | + | longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow" | |||
| 94 | + | client := &fakeImagesClient{ | |||
| 95 | + | listImagesResp: []dto.Image{ | |||
| 96 | + | {Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false}, | |||
| 97 | + | }, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | var out bytes.Buffer | |||
| 101 | + | err := runImagesList(context.Background(), client, &out) | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | ||||
| 104 | + | text := stripANSI(out.String()) | |||
| 105 | + | header := findHeaderLine(text) | |||
| 106 | + | assert.NotEmpty(t, header) | |||
| 107 | + | assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG")) | |||
| 108 | + | assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE")) | |||
| 109 | + | assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED")) | |||
| 110 | + | assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID")) | |||
| 111 | + | assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING")) | |||
| 112 | + | ||||
| 113 | + | row := findImageListRowLine(text, "latest") | |||
| 114 | + | assert.NotEmpty(t, row) | |||
| 115 | + | ||||
| 116 | + | cols := splitTableColumns(row) | |||
| 117 | + | require.Len(t, cols, 6) | |||
| 118 | + | ||||
| 119 | + | repositoryField := cols[0] | |||
| 120 | + | imageIDField := cols[4] | |||
| 121 | + | ||||
| 122 | + | assert.NotEqual(t, longRepository, repositoryField) | |||
| 123 | + | assert.NotEqual(t, longImageID, imageIDField) | |||
| 124 | + | assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis") | |||
| 125 | + | assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis") | |||
| 126 | + | assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "..."))) | |||
| 127 | + | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |||
| 128 | + | ||||
| 129 | + | assert.Contains(t, text, "Total images: 1 (dangling: 0)") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) { | |||
| 133 | + | longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra" | |||
| 134 | + | longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name" | |||
| 135 | + | longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" | |||
| 136 | + | client := &fakeImagesClient{ | |||
| 137 | + | listImagesResp: []dto.Image{ | |||
| 138 | + | {Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true}, | |||
| 139 | + | }, | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | var out bytes.Buffer | |||
| 143 | + | err := runImagesList(context.Background(), client, &out) | |||
| 144 | + | require.NoError(t, err) | |||
| 145 | + | ||||
| 146 | + | text := stripANSI(out.String()) | |||
| 147 | + | rows := findImageListRows(text) | |||
| 148 | + | require.Len(t, rows, 1, "expected a single, no-wrap data row") | |||
| 149 | + | ||||
| 150 | + | cols := rows[0] | |||
| 151 | + | require.Len(t, cols, 6) | |||
| 152 | + | ||||
| 153 | + | repositoryField := cols[0] | |||
| 154 | + | tagField := cols[1] | |||
| 155 | + | sizeField := cols[2] | |||
| 156 | + | createdField := cols[3] | |||
| 157 | + | imageIDField := cols[4] | |||
| 158 | + | ||||
| 159 | + | assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis") | |||
| 160 | + | assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis") | |||
| 161 | + | assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis") | |||
| 162 | + | assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "..."))) | |||
| 163 | + | assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "..."))) | |||
| 164 | + | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |||
| 165 | + | ||||
| 166 | + | assert.Equal(t, "-", sizeField) | |||
| 167 | + | assert.Equal(t, "-", createdField) | |||
| 168 | + | assert.Contains(t, text, "Total images: 1 (dangling: 1)") | |||
| 169 | + | } | |||
| 170 | + | ||||
| 65 | func TestRunImagesList_EmptyOutput(t *testing.T) { | 171 | func TestRunImagesList_EmptyOutput(t *testing.T) { | ||
| 66 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | 172 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | ||
| 67 | 173 | ||||
| @@ -156,3 +262,73 @@ func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) { | |||||
| 156 | require.Error(t, err) | 262 | require.Error(t, err) | ||
| 157 | assert.Contains(t, err.Error(), "empty response") | 263 | assert.Contains(t, err.Error(), "empty response") | ||
| 158 | } | 264 | } | ||
| 265 | + | ||||
| 266 | + | func stripANSI(value string) string { | |||
| 267 | + | ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) | |||
| 268 | + | return ansiRegex.ReplaceAllString(value, "") | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func findHeaderLine(text string) string { | |||
| 272 | + | for _, line := range strings.Split(text, "\n") { | |||
| 273 | + | if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") { | |||
| 274 | + | return line | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | return "" | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func findImageListRowLine(text string, tag string) string { | |||
| 282 | + | for _, line := range strings.Split(text, "\n") { | |||
| 283 | + | trimmed := strings.TrimSpace(line) | |||
| 284 | + | if trimmed == "" { | |||
| 285 | + | continue | |||
| 286 | + | } | |||
| 287 | + | if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") { | |||
| 288 | + | continue | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | cols := splitTableColumns(line) | |||
| 292 | + | if len(cols) == 6 && cols[1] == tag { | |||
| 293 | + | return line | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | return "" | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func findImageListRows(text string) [][]string { | |||
| 301 | + | rows := make([][]string, 0) | |||
| 302 | + | for _, line := range strings.Split(text, "\n") { | |||
| 303 | + | trimmed := strings.TrimSpace(line) | |||
| 304 | + | if trimmed == "" { | |||
| 305 | + | continue | |||
| 306 | + | } | |||
| 307 | + | if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") { | |||
| 308 | + | continue | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | cols := splitTableColumns(line) | |||
| 312 | + | if len(cols) == 6 && cols[0] != "REPOSITORY" { | |||
| 313 | + | rows = append(rows, cols) | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | return rows | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func splitTableColumns(line string) []string { | |||
| 321 | + | if strings.Contains(line, "│") { | |||
| 322 | + | parts := strings.Split(line, "│") | |||
| 323 | + | cols := make([]string, 0, len(parts)) | |||
| 324 | + | for _, part := range parts { | |||
| 325 | + | cell := strings.TrimSpace(part) | |||
| 326 | + | if cell != "" { | |||
| 327 | + | cols = append(cols, cell) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | return cols | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1) | |||
| 334 | + | } | |||
M internal/adapters/in/cli/ui/components/table.go +67 -5
| @@ -1,10 +1,14 @@ | |||
| 1 | 1 | package components | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/charmbracelet/lipgloss" | |
| 7 | 9 | "github.com/charmbracelet/lipgloss/table" | |
| 10 | + | "github.com/mattn/go-runewidth" | |
| 11 | + | "github.com/rivo/uniseg" | |
| 8 | 12 | ) | |
| 9 | 13 | ||
| 10 | 14 | // TableColumn defines a table column. | |
| @@ -137,7 +141,19 @@ func (t *TableModel) Render() string { | |||
| 137 | 141 | // Extract headers | |
| 138 | 142 | headers := make([]string, len(t.columns)) | |
| 139 | 143 | for i, col := range t.columns { | |
| 140 | - | headers[i] = col.Title | |
| 144 | + | headers[i] = truncateCell(col.Title, col.Width) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | rows := make([][]string, len(t.rows)) | |
| 148 | + | for rowIdx, row := range t.rows { | |
| 149 | + | rows[rowIdx] = make([]string, len(row)) | |
| 150 | + | for colIdx, cell := range row { | |
| 151 | + | width := 0 | |
| 152 | + | if colIdx < len(t.columns) { | |
| 153 | + | width = t.columns[colIdx].Width | |
| 154 | + | } | |
| 155 | + | rows[rowIdx][colIdx] = truncateCell(cell, width) | |
| 156 | + | } | |
| 141 | 157 | } | |
| 142 | 158 | ||
| 143 | 159 | // Create table | |
| @@ -145,15 +161,27 @@ func (t *TableModel) Render() string { | |||
| 145 | 161 | Border(t.border). | |
| 146 | 162 | BorderStyle(t.borderStyle). | |
| 147 | 163 | Headers(headers...). | |
| 148 | - | Rows(t.rows...). | |
| 164 | + | Rows(rows...). | |
| 149 | 165 | StyleFunc(func(row, col int) lipgloss.Style { | |
| 166 | + | width := 0 | |
| 167 | + | if col >= 0 && col < len(t.columns) { | |
| 168 | + | width = t.columns[col].Width | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | applyWidth := func(s lipgloss.Style) lipgloss.Style { | |
| 172 | + | if width > 0 { | |
| 173 | + | return s.Width(width).MaxWidth(width) | |
| 174 | + | } | |
| 175 | + | return s | |
| 176 | + | } | |
| 177 | + | ||
| 150 | 178 | if row == table.HeaderRow { | |
| 151 | - | return t.headerStyle | |
| 179 | + | return applyWidth(t.headerStyle) | |
| 152 | 180 | } | |
| 153 | 181 | if row%2 == 0 { | |
| 154 | - | return t.evenStyle | |
| 182 | + | return applyWidth(t.evenStyle) | |
| 155 | 183 | } | |
| 156 | - | return t.oddStyle | |
| 184 | + | return applyWidth(t.oddStyle) | |
| 157 | 185 | }) | |
| 158 | 186 | ||
| 159 | 187 | // Apply width if set | |
| @@ -164,6 +192,40 @@ func (t *TableModel) Render() string { | |||
| 164 | 192 | return tbl.String() | |
| 165 | 193 | } | |
| 166 | 194 | ||
| 195 | + | func truncateCell(value string, maxWidth int) string { | |
| 196 | + | if strings.Contains(value, "\x1b[") { | |
| 197 | + | return value | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | if maxWidth <= 0 || runewidth.StringWidth(value) <= maxWidth { | |
| 201 | + | return value | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | if maxWidth <= 3 { | |
| 205 | + | return strings.Repeat(".", maxWidth) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | targetWidth := maxWidth - 3 | |
| 209 | + | b := strings.Builder{} | |
| 210 | + | currentWidth := 0 | |
| 211 | + | g := uniseg.NewGraphemes(value) | |
| 212 | + | for g.Next() { | |
| 213 | + | grapheme := g.Str() | |
| 214 | + | graphemeWidth := runewidth.StringWidth(grapheme) | |
| 215 | + | if currentWidth+graphemeWidth > targetWidth { | |
| 216 | + | break | |
| 217 | + | } | |
| 218 | + | b.WriteString(grapheme) | |
| 219 | + | currentWidth += graphemeWidth | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | if b.Len() == 0 { | |
| 223 | + | return strings.Repeat(".", maxWidth) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | return b.String() + "..." | |
| 227 | + | } | |
| 228 | + | ||
| 167 | 229 | // View returns the table view (alias for Render). | |
| 168 | 230 | func (t *TableModel) View() string { | |
| 169 | 231 | return t.Render() | |
| @@ -1,10 +1,14 @@ | |||||
| 1 | package components | 1 | package components | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 6 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 5 | 7 | ||||
| 6 | "github.com/charmbracelet/lipgloss" | 8 | "github.com/charmbracelet/lipgloss" | ||
| 7 | "github.com/charmbracelet/lipgloss/table" | 9 | "github.com/charmbracelet/lipgloss/table" | ||
| 10 | + | "github.com/mattn/go-runewidth" | |||
| 11 | + | "github.com/rivo/uniseg" | |||
| 8 | ) | 12 | ) | ||
| 9 | 13 | ||||
| 10 | // TableColumn defines a table column. | 14 | // TableColumn defines a table column. | ||
| @@ -137,7 +141,19 @@ func (t *TableModel) Render() string { | |||||
| 137 | // Extract headers | 141 | // Extract headers | ||
| 138 | headers := make([]string, len(t.columns)) | 142 | headers := make([]string, len(t.columns)) | ||
| 139 | for i, col := range t.columns { | 143 | for i, col := range t.columns { | ||
| 140 | - | headers[i] = col.Title | 144 | + | headers[i] = truncateCell(col.Title, col.Width) |
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | rows := make([][]string, len(t.rows)) | |||
| 148 | + | for rowIdx, row := range t.rows { | |||
| 149 | + | rows[rowIdx] = make([]string, len(row)) | |||
| 150 | + | for colIdx, cell := range row { | |||
| 151 | + | width := 0 | |||
| 152 | + | if colIdx < len(t.columns) { | |||
| 153 | + | width = t.columns[colIdx].Width | |||
| 154 | + | } | |||
| 155 | + | rows[rowIdx][colIdx] = truncateCell(cell, width) | |||
| 156 | + | } | |||
| 141 | } | 157 | } | ||
| 142 | 158 | ||||
| 143 | // Create table | 159 | // Create table | ||
| @@ -145,15 +161,27 @@ func (t *TableModel) Render() string { | |||||
| 145 | Border(t.border). | 161 | Border(t.border). | ||
| 146 | BorderStyle(t.borderStyle). | 162 | BorderStyle(t.borderStyle). | ||
| 147 | Headers(headers...). | 163 | Headers(headers...). | ||
| 148 | - | Rows(t.rows...). | 164 | + | Rows(rows...). |
| 149 | StyleFunc(func(row, col int) lipgloss.Style { | 165 | StyleFunc(func(row, col int) lipgloss.Style { | ||
| 166 | + | width := 0 | |||
| 167 | + | if col >= 0 && col < len(t.columns) { | |||
| 168 | + | width = t.columns[col].Width | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | applyWidth := func(s lipgloss.Style) lipgloss.Style { | |||
| 172 | + | if width > 0 { | |||
| 173 | + | return s.Width(width).MaxWidth(width) | |||
| 174 | + | } | |||
| 175 | + | return s | |||
| 176 | + | } | |||
| 177 | + | ||||
| 150 | if row == table.HeaderRow { | 178 | if row == table.HeaderRow { | ||
| 151 | - | return t.headerStyle | 179 | + | return applyWidth(t.headerStyle) |
| 152 | } | 180 | } | ||
| 153 | if row%2 == 0 { | 181 | if row%2 == 0 { | ||
| 154 | - | return t.evenStyle | 182 | + | return applyWidth(t.evenStyle) |
| 155 | } | 183 | } | ||
| 156 | - | return t.oddStyle | 184 | + | return applyWidth(t.oddStyle) |
| 157 | }) | 185 | }) | ||
| 158 | 186 | ||||
| 159 | // Apply width if set | 187 | // Apply width if set | ||
| @@ -164,6 +192,40 @@ func (t *TableModel) Render() string { | |||||
| 164 | return tbl.String() | 192 | return tbl.String() | ||
| 165 | } | 193 | } | ||
| 166 | 194 | ||||
| 195 | + | func truncateCell(value string, maxWidth int) string { | |||
| 196 | + | if strings.Contains(value, "\x1b[") { | |||
| 197 | + | return value | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | if maxWidth <= 0 || runewidth.StringWidth(value) <= maxWidth { | |||
| 201 | + | return value | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | if maxWidth <= 3 { | |||
| 205 | + | return strings.Repeat(".", maxWidth) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | targetWidth := maxWidth - 3 | |||
| 209 | + | b := strings.Builder{} | |||
| 210 | + | currentWidth := 0 | |||
| 211 | + | g := uniseg.NewGraphemes(value) | |||
| 212 | + | for g.Next() { | |||
| 213 | + | grapheme := g.Str() | |||
| 214 | + | graphemeWidth := runewidth.StringWidth(grapheme) | |||
| 215 | + | if currentWidth+graphemeWidth > targetWidth { | |||
| 216 | + | break | |||
| 217 | + | } | |||
| 218 | + | b.WriteString(grapheme) | |||
| 219 | + | currentWidth += graphemeWidth | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | if b.Len() == 0 { | |||
| 223 | + | return strings.Repeat(".", maxWidth) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | return b.String() + "..." | |||
| 227 | + | } | |||
| 228 | + | ||||
| 167 | // View returns the table view (alias for Render). | 229 | // View returns the table view (alias for Render). | ||
| 168 | func (t *TableModel) View() string { | 230 | func (t *TableModel) View() string { | ||
| 169 | return t.Render() | 231 | return t.Render() | ||
A internal/adapters/in/cli/ui/components/table_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package components | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/charmbracelet/lipgloss" | |
| 9 | + | "github.com/mattn/go-runewidth" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestTableRender_AppliesConfiguredColumnWidth(t *testing.T) { | |
| 15 | + | tableModel := NewTable( | |
| 16 | + | WithColumns([]TableColumn{{Title: "ID", Width: 5}}), | |
| 17 | + | WithRows([][]string{{"abc"}}), | |
| 18 | + | WithHeaderStyle(lipgloss.NewStyle()), | |
| 19 | + | WithCellStyle(lipgloss.NewStyle()), | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | rendered := stripANSI(tableModel.Render()) | |
| 23 | + | lines := strings.Split(rendered, "\n") | |
| 24 | + | ||
| 25 | + | var rowLine string | |
| 26 | + | for _, line := range lines { | |
| 27 | + | if strings.Contains(line, "abc") { | |
| 28 | + | rowLine = line | |
| 29 | + | break | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | require.NotEmpty(t, rowLine) | |
| 34 | + | assert.Contains(t, rowLine, "abc ") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestTableRender_TruncatesLongCellTextWithEllipsis(t *testing.T) { | |
| 38 | + | tableModel := NewTable( | |
| 39 | + | WithColumns([]TableColumn{{Title: "ID", Width: 5}}), | |
| 40 | + | WithRows([][]string{{"abcdef"}}), | |
| 41 | + | WithHeaderStyle(lipgloss.NewStyle()), | |
| 42 | + | WithCellStyle(lipgloss.NewStyle()), | |
| 43 | + | ) | |
| 44 | + | ||
| 45 | + | rendered := stripANSI(tableModel.Render()) | |
| 46 | + | assert.Contains(t, rendered, "ab...") | |
| 47 | + | assert.NotContains(t, rendered, "abcdef") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestTruncateCell_LeavesShortTextUnchanged(t *testing.T) { | |
| 51 | + | assert.Equal(t, "abc", truncateCell("abc", 5)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestTruncateCell_EdgeCases(t *testing.T) { | |
| 55 | + | tests := []struct { | |
| 56 | + | name string | |
| 57 | + | value string | |
| 58 | + | maxWidth int | |
| 59 | + | expected string | |
| 60 | + | }{ | |
| 61 | + | {name: "zero width passthrough", value: "abcdef", maxWidth: 0, expected: "abcdef"}, | |
| 62 | + | {name: "width three all dots", value: "abcdef", maxWidth: 3, expected: "..."}, | |
| 63 | + | {name: "ascii truncates", value: "abcdef", maxWidth: 5, expected: "ab..."}, | |
| 64 | + | {name: "cjk truncates by display width", value: "你好世界", maxWidth: 5, expected: "你..."}, | |
| 65 | + | {name: "grapheme-safe emoji truncation", value: "👨<U+200D>👩<U+200D>👧<U+200D>👦abcd", maxWidth: 5, expected: "👨<U+200D>👩<U+200D>👧<U+200D>👦..."}, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | for _, tt := range tests { | |
| 69 | + | tt := tt | |
| 70 | + | t.Run(tt.name, func(t *testing.T) { | |
| 71 | + | got := truncateCell(tt.value, tt.maxWidth) | |
| 72 | + | assert.Equal(t, tt.expected, got) | |
| 73 | + | if tt.maxWidth > 0 { | |
| 74 | + | assert.LessOrEqual(t, runewidth.StringWidth(got), tt.maxWidth) | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestTruncateCell_AnsiInputPassthrough(t *testing.T) { | |
| 81 | + | styled := "\x1b[32mactive\x1b[0m" | |
| 82 | + | got := truncateCell(styled, 3) | |
| 83 | + | assert.Equal(t, styled, got) | |
| 84 | + | assert.Contains(t, got, "\x1b[") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func stripANSI(input string) string { | |
| 88 | + | ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) | |
| 89 | + | return ansiPattern.ReplaceAllString(input, "") | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/charmbracelet/lipgloss" | |||
| 9 | + | "github.com/mattn/go-runewidth" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestTableRender_AppliesConfiguredColumnWidth(t *testing.T) { | |||
| 15 | + | tableModel := NewTable( | |||
| 16 | + | WithColumns([]TableColumn{{Title: "ID", Width: 5}}), | |||
| 17 | + | WithRows([][]string{{"abc"}}), | |||
| 18 | + | WithHeaderStyle(lipgloss.NewStyle()), | |||
| 19 | + | WithCellStyle(lipgloss.NewStyle()), | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | rendered := stripANSI(tableModel.Render()) | |||
| 23 | + | lines := strings.Split(rendered, "\n") | |||
| 24 | + | ||||
| 25 | + | var rowLine string | |||
| 26 | + | for _, line := range lines { | |||
| 27 | + | if strings.Contains(line, "abc") { | |||
| 28 | + | rowLine = line | |||
| 29 | + | break | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | require.NotEmpty(t, rowLine) | |||
| 34 | + | assert.Contains(t, rowLine, "abc ") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestTableRender_TruncatesLongCellTextWithEllipsis(t *testing.T) { | |||
| 38 | + | tableModel := NewTable( | |||
| 39 | + | WithColumns([]TableColumn{{Title: "ID", Width: 5}}), | |||
| 40 | + | WithRows([][]string{{"abcdef"}}), | |||
| 41 | + | WithHeaderStyle(lipgloss.NewStyle()), | |||
| 42 | + | WithCellStyle(lipgloss.NewStyle()), | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | rendered := stripANSI(tableModel.Render()) | |||
| 46 | + | assert.Contains(t, rendered, "ab...") | |||
| 47 | + | assert.NotContains(t, rendered, "abcdef") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestTruncateCell_LeavesShortTextUnchanged(t *testing.T) { | |||
| 51 | + | assert.Equal(t, "abc", truncateCell("abc", 5)) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestTruncateCell_EdgeCases(t *testing.T) { | |||
| 55 | + | tests := []struct { | |||
| 56 | + | name string | |||
| 57 | + | value string | |||
| 58 | + | maxWidth int | |||
| 59 | + | expected string | |||
| 60 | + | }{ | |||
| 61 | + | {name: "zero width passthrough", value: "abcdef", maxWidth: 0, expected: "abcdef"}, | |||
| 62 | + | {name: "width three all dots", value: "abcdef", maxWidth: 3, expected: "..."}, | |||
| 63 | + | {name: "ascii truncates", value: "abcdef", maxWidth: 5, expected: "ab..."}, | |||
| 64 | + | {name: "cjk truncates by display width", value: "你好世界", maxWidth: 5, expected: "你..."}, | |||
| 65 | + | {name: "grapheme-safe emoji truncation", value: "👨<U+200D>👩<U+200D>👧<U+200D>👦abcd", maxWidth: 5, expected: "👨<U+200D>👩<U+200D>👧<U+200D>👦..."}, | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | for _, tt := range tests { | |||
| 69 | + | tt := tt | |||
| 70 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 71 | + | got := truncateCell(tt.value, tt.maxWidth) | |||
| 72 | + | assert.Equal(t, tt.expected, got) | |||
| 73 | + | if tt.maxWidth > 0 { | |||
| 74 | + | assert.LessOrEqual(t, runewidth.StringWidth(got), tt.maxWidth) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestTruncateCell_AnsiInputPassthrough(t *testing.T) { | |||
| 81 | + | styled := "\x1b[32mactive\x1b[0m" | |||
| 82 | + | got := truncateCell(styled, 3) | |||
| 83 | + | assert.Equal(t, styled, got) | |||
| 84 | + | assert.Contains(t, got, "\x1b[") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func stripANSI(input string) string { | |||
| 88 | + | ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) | |||
| 89 | + | return ansiPattern.ReplaceAllString(input, "") | |||
| 90 | + | } | |||