detached 857a4f3

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 (
55 "fmt"
66 "io"
77 "strings"
8- "text/tabwriter"
98 "time"
109
10+ "github.com/charmbracelet/lipgloss"
1111 "github.com/spf13/cobra"
1212
1313 "github.com/bnema/gordon/internal/adapters/dto"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1415 )
1516
1617 type imagesClient interface {
@@ -24,6 +25,24 @@ type imagesPruneOptions struct {
2425 RuntimeOnly bool
2526 }
2627
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+
2746 func newImagesCmd() *cobra.Command {
2847 cmd := &cobra.Command{
2948 Use: "images",
@@ -95,12 +114,8 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
95114 return err
96115 }
97116
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-
103117 danglingCount := 0
118+ rows := make([][]string, 0, len(images))
104119 for _, img := range images {
105120 dangling := "no"
106121 if img.Dangling {
@@ -108,12 +123,24 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
108123 danglingCount++
109124 }
110125
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+ })
114134 }
115135
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 {
117144 return err
118145 }
119146
@@ -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 bool25 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 err114 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 := 0117 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 err127+ 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 err144 return err
118 }145 }
119 146
M internal/adapters/in/cli/images_test.go +176 -0
@@ -4,6 +4,8 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "regexp"
8+ "strings"
79 "testing"
810 "time"
911
@@ -62,6 +64,110 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
6264 assert.Equal(t, 1, client.listImagesCalls)
6365 }
6466
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+
65171 func TestRunImagesList_EmptyOutput(t *testing.T) {
66172 client := &fakeImagesClient{listImagesResp: []dto.Image{}}
67173
@@ -156,3 +262,73 @@ func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
156262 require.Error(t, err)
157263 assert.Contains(t, err.Error(), "empty response")
158264 }
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 @@
11 package components
22
33 import (
4+ "strings"
5+
46 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
57
68 "github.com/charmbracelet/lipgloss"
79 "github.com/charmbracelet/lipgloss/table"
10+ "github.com/mattn/go-runewidth"
11+ "github.com/rivo/uniseg"
812 )
913
1014 // TableColumn defines a table column.
@@ -137,7 +141,19 @@ func (t *TableModel) Render() string {
137141 // Extract headers
138142 headers := make([]string, len(t.columns))
139143 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+ }
141157 }
142158
143159 // Create table
@@ -145,15 +161,27 @@ func (t *TableModel) Render() string {
145161 Border(t.border).
146162 BorderStyle(t.borderStyle).
147163 Headers(headers...).
148- Rows(t.rows...).
164+ Rows(rows...).
149165 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+
150178 if row == table.HeaderRow {
151- return t.headerStyle
179+ return applyWidth(t.headerStyle)
152180 }
153181 if row%2 == 0 {
154- return t.evenStyle
182+ return applyWidth(t.evenStyle)
155183 }
156- return t.oddStyle
184+ return applyWidth(t.oddStyle)
157185 })
158186
159187 // Apply width if set
@@ -164,6 +192,40 @@ func (t *TableModel) Render() string {
164192 return tbl.String()
165193 }
166194
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+
167229 // View returns the table view (alias for Render).
168230 func (t *TableModel) View() string {
169231 return t.Render()
@@ -1,10 +1,14 @@
1 package components1 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 headers141 // 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.Title144+ 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 table159 // 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.headerStyle179+ return applyWidth(t.headerStyle)
152 }180 }
153 if row%2 == 0 {181 if row%2 == 0 {
154- return t.evenStyle182+ return applyWidth(t.evenStyle)
155 }183 }
156- return t.oddStyle184+ return applyWidth(t.oddStyle)
157 })185 })
158 186
159 // Apply width if set187 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close