Skip to content
Open
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
48 changes: 33 additions & 15 deletions libs/tableview/tableview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"))
}

Expand Down
36 changes: 36 additions & 0 deletions libs/tableview/tableview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tableview
import (
"strings"
"testing"
"unicode/utf8"

"github.com/charmbracelet/bubbles/viewport"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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")
Expand Down
Loading