From b0a68c2be4f463941cf9bf0732803545b6342435 Mon Sep 17 00:00:00 2001 From: Tanish Gupta Date: Thu, 23 Jul 2026 06:52:46 +0000 Subject: [PATCH] Truncate wide cells in the interactive table browser --- libs/tableview/tableview.go | 48 ++++++++++++++++++++++---------- libs/tableview/tableview_test.go | 36 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 7d8e7b292d..f59f79b69d 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -22,8 +22,24 @@ const ( // headerLines is the number of non-data lines at the top (header + separator). // These are rendered above the viewport so they stay visible while data scrolls. headerLines = 2 + // maxCellWidth bounds each cell so a single very large value cannot inflate a + // column to the point of degrading rendering and scrolling. + maxCellWidth = 256 ) +// truncateCell shortens s to maxCellWidth, marking truncation with an ellipsis. +func truncateCell(s string) string { + const ellipsis = "..." + if len(s) <= maxCellWidth { + return s + } + r := []rune(s) + if len(r) <= maxCellWidth { + return s + } + return string(r[:maxCellWidth-len(ellipsis)]) + ellipsis +} + // Run displays tabular data in an interactive browser. // Writes to w (typically stdout). Blocks until user quits. func Run(ctx context.Context, w io.Writer, columns []string, rows [][]string) error { @@ -52,35 +68,37 @@ func renderTableLines(columns []string, rows [][]string) []string { var buf strings.Builder tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) - // Header. - fmt.Fprintln(tw, strings.Join(columns, "\t")) + // Header and data cells are truncated once so widths and emitted values agree. + header := make([]string, len(columns)) + for i, col := range columns { + header[i] = truncateCell(col) + } + fmt.Fprintln(tw, strings.Join(header, "\t")) - // Separator: compute widths from header + data for dash line. + cells := make([][]string, len(rows)) widths := make([]int, len(columns)) - for i, col := range columns { - widths[i] = len(col) + for i := range columns { + widths[i] = len(header[i]) } - for _, row := range rows { + for r, row := range rows { + vals := make([]string, len(columns)) for i := range columns { if i < len(row) { - widths[i] = max(widths[i], len(row[i])) + vals[i] = truncateCell(row[i]) + widths[i] = max(widths[i], len(vals[i])) } } + cells[r] = vals } + + // Separator dash line, sized to the truncated column widths. seps := make([]string, len(columns)) for i, w := range widths { seps[i] = strings.Repeat("─", w) } fmt.Fprintln(tw, strings.Join(seps, "\t")) - // Data rows. - for _, row := range rows { - vals := make([]string, len(columns)) - for i := range columns { - if i < len(row) { - vals[i] = row[i] - } - } + for _, vals := range cells { fmt.Fprintln(tw, strings.Join(vals, "\t")) } diff --git a/libs/tableview/tableview_test.go b/libs/tableview/tableview_test.go index 7d542a365b..81ec7645c2 100644 --- a/libs/tableview/tableview_test.go +++ b/libs/tableview/tableview_test.go @@ -3,6 +3,7 @@ package tableview import ( "strings" "testing" + "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" "github.com/stretchr/testify/assert" @@ -33,6 +34,41 @@ func TestRenderTableLinesEmpty(t *testing.T) { assert.Contains(t, lines[1], "──") } +func TestTruncateCell(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"short", "abc", "abc"}, + {"exactly max", strings.Repeat("x", maxCellWidth), strings.Repeat("x", maxCellWidth)}, + {"over max", strings.Repeat("x", maxCellWidth+10), strings.Repeat("x", maxCellWidth-3) + "..."}, + {"many bytes few runes", strings.Repeat("é", 200), strings.Repeat("é", 200)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, truncateCell(tc.in)) + }) + } +} + +func TestTruncateCellMultibyteBoundary(t *testing.T) { + got := truncateCell(strings.Repeat("é", maxCellWidth+10)) + assert.True(t, utf8.ValidString(got)) + assert.True(t, strings.HasSuffix(got, "...")) +} + +func TestRenderTableLinesTruncatesLongCell(t *testing.T) { + long := strings.Repeat("x", 10_000) + lines := renderTableLines([]string{"blob"}, [][]string{{long}}) + require.GreaterOrEqual(t, len(lines), 3) + dataRow := lines[2] + assert.LessOrEqual(t, len(dataRow), maxCellWidth) + assert.Contains(t, dataRow, "...") + assert.NotContains(t, dataRow, long) +} + func TestFindMatches(t *testing.T) { lines := []string{"header", "---", "alice", "bob", "alice again"} matches := findMatches(lines, "alice")