From cbd549c3df78e7b251bdf074b9f13bd91da37cf7 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 21 Apr 2026 11:56:36 +0200 Subject: [PATCH 1/3] Add interactive pager for list commands with a row template When stdin, stdout, and stderr are all TTYs and the command has a row template (jobs list, clusters list, apps list, pipelines list, etc.), the CLI streams 50 rows at a time and prompts on stderr: [space] more [enter] all [q|esc] quit SPACE fetches the next page. ENTER drains the rest (interruptible by q/esc/Ctrl+C between pages). q/esc/Ctrl+C quit immediately. Piped output and --output json keep the existing non-paged behavior. Rendering reuses the existing template + headerTemplate annotations (same colors, same alignment as today). Column widths are locked from the first page so they stay stable across batches. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + NOTICE | 4 + go.mod | 1 + libs/cmdio/capabilities.go | 10 ++ libs/cmdio/paged_template.go | 228 +++++++++++++++++++++++++ libs/cmdio/paged_template_test.go | 274 ++++++++++++++++++++++++++++++ libs/cmdio/pager.go | 133 +++++++++++++++ libs/cmdio/pager_test.go | 72 ++++++++ libs/cmdio/render.go | 9 + 9 files changed, 732 insertions(+) create mode 100644 libs/cmdio/paged_template.go create mode 100644 libs/cmdio/paged_template_test.go create mode 100644 libs/cmdio/pager.go create mode 100644 libs/cmdio/pager_test.go diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index ede82f7779..b7acb6e644 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -7,6 +7,7 @@ ### CLI * Added `--limit` flag to all paginated list commands for client-side result capping ([#4984](https://github.com/databricks/cli/pull/4984)). +* Added interactive pagination for list commands that have a row template (jobs, clusters, apps, pipelines, etc.). When stdin, stdout, and stderr are all TTYs, `databricks list` now streams 50 rows at a time and prompts `[space] more [enter] all [q|esc] quit` on stderr. ENTER can be interrupted by `q`/`esc`/`Ctrl+C` between pages. Colors and alignment match the existing non-paged output; column widths stay stable across pages. Piped output and `--output json` are unchanged. * Accept `yes` in addition to `y` for confirmation prompts, and show `[y/N]` to indicate that no is the default. ### Bundles diff --git a/NOTICE b/NOTICE index 883c24ab78..baad4b0525 100644 --- a/NOTICE +++ b/NOTICE @@ -115,6 +115,10 @@ golang.org/x/sys - https://github.com/golang/sys Copyright 2009 The Go Authors. License - https://github.com/golang/sys/blob/master/LICENSE +golang.org/x/term - https://github.com/golang/term +Copyright 2009 The Go Authors. +License - https://github.com/golang/term/blob/master/LICENSE + golang.org/x/text - https://github.com/golang/text Copyright 2009 The Go Authors. License - https://github.com/golang/text/blob/master/LICENSE diff --git a/go.mod b/go.mod index bdcacec405..4cd20d588c 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( golang.org/x/oauth2 v0.36.0 // BSD-3-Clause golang.org/x/sync v0.20.0 // BSD-3-Clause golang.org/x/sys v0.43.0 // BSD-3-Clause + golang.org/x/term v0.41.0 // BSD-3-Clause golang.org/x/text v0.35.0 // BSD-3-Clause gopkg.in/ini.v1 v1.67.1 // Apache-2.0 ) diff --git a/libs/cmdio/capabilities.go b/libs/cmdio/capabilities.go index 455acebc77..12b46f6150 100644 --- a/libs/cmdio/capabilities.go +++ b/libs/cmdio/capabilities.go @@ -48,6 +48,16 @@ func (c Capabilities) SupportsColor(w io.Writer) bool { return isTTY(w) && c.color } +// SupportsPager returns true when we can run an interactive pager between +// batches of output: stdin, stdout, and stderr must all be TTYs. Stdin +// carries the user's keystrokes, stdout receives the paged content, and +// stderr carries the "[space] more / [enter] all" prompt — all three +// must be visible for the interaction to make sense. Git Bash is +// excluded because raw-mode stdin reads are unreliable there. +func (c Capabilities) SupportsPager() bool { + return c.stdinIsTTY && c.stdoutIsTTY && c.stderrIsTTY && !c.isGitBash +} + // detectGitBash returns true if running in Git Bash on Windows (has broken promptui support). // We do not allow prompting in Git Bash on Windows. // Likely due to fact that Git Bash does not correctly support ANSI escape sequences, diff --git a/libs/cmdio/paged_template.go b/libs/cmdio/paged_template.go new file mode 100644 index 0000000000..15ff1f9311 --- /dev/null +++ b/libs/cmdio/paged_template.go @@ -0,0 +1,228 @@ +package cmdio + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "regexp" + "strings" + "text/template" + "unicode/utf8" + + "github.com/databricks/databricks-sdk-go/listing" +) + +// pagerColumnSeparator is the inter-column spacing used when emitting paged +// template output. Matches text/tabwriter.NewWriter(..., 2, ' ', ...) in +// the non-paged path, so single-batch output is visually indistinguishable +// from what renderUsingTemplate produces. +const pagerColumnSeparator = " " + +// ansiCSIPattern matches ANSI SGR escape sequences so we can compute the +// on-screen width of cells that contain colored output. We do not attempt +// to cover every ANSI escape — just the SGR color/style ones (CSI ... m) +// emitted by github.com/fatih/color, which is all our templates use today. +var ansiCSIPattern = regexp.MustCompile("\x1b\\[[0-9;]*m") + +// renderIteratorPagedTemplate streams an iterator through the existing +// template-based renderer one page at a time, prompting the user between +// pages on stderr. The rendering is intentionally identical to the non- +// paged path — same templates, same colors — only the flush cadence, +// the user-facing prompt, and column-width stability across batches +// differ. +// +// SPACE advances by one page; ENTER drains the remaining iterator (still +// interruptible by q/esc/Ctrl+C); q/esc/Ctrl+C stop immediately. +func renderIteratorPagedTemplate[T any]( + ctx context.Context, + iter listing.Iterator[T], + out io.Writer, + headerTemplate, tmpl string, +) error { + keys, restore, err := startRawStdinKeyReader(ctx) + if err != nil { + return err + } + defer restore() + return renderIteratorPagedTemplateCore( + ctx, + iter, + crlfWriter{w: out}, + crlfWriter{w: os.Stderr}, + keys, + headerTemplate, + tmpl, + pagerPageSize, + ) +} + +// renderIteratorPagedTemplateCore is the testable core of +// renderIteratorPagedTemplate: it assumes the caller has already set up +// raw stdin (or any key source) and delivered a channel of keystrokes. +// It never touches os.Stdin directly. +// +// Unlike renderUsingTemplate (the non-paged path) we do not rely on +// text/tabwriter to align columns. Tabwriter computes column widths +// per-flush and resets on Flush(), which produces a jarring +// width-shift when a short final batch follows a wider first batch. +// Here we render each page's template output into an intermediate +// buffer, split it into cells by tab, lock visual column widths from +// the first page, and pad every subsequent page to the same widths. +// The output is visually indistinguishable from tabwriter for single- +// batch lists and stays aligned across batches for longer ones. +func renderIteratorPagedTemplateCore[T any]( + ctx context.Context, + iter listing.Iterator[T], + out io.Writer, + prompts io.Writer, + keys <-chan byte, + headerTemplate, tmpl string, + pageSize int, +) error { + // Use two independent templates so parsing the row template doesn't + // overwrite the header template's parsed body (they would if they + // shared the same *template.Template instance — Parse replaces the + // body in place and returns the receiver). + headerT, err := template.New("header").Funcs(renderFuncMap).Parse(headerTemplate) + if err != nil { + return err + } + rowT, err := template.New("row").Funcs(renderFuncMap).Parse(tmpl) + if err != nil { + return err + } + + limit := limitFromContext(ctx) + drainAll := false + buf := make([]any, 0, pageSize) + total := 0 + + var lockedWidths []int + firstBatchDone := false + + flushPage := func() error { + // Nothing to emit after the first batch is out the door and the + // buffer is empty — we've already written the header. + if firstBatchDone && len(buf) == 0 { + return nil + } + + var rendered bytes.Buffer + if !firstBatchDone && headerTemplate != "" { + if err := headerT.Execute(&rendered, nil); err != nil { + return err + } + rendered.WriteByte('\n') + } + if len(buf) > 0 { + if err := rowT.Execute(&rendered, buf); err != nil { + return err + } + buf = buf[:0] + } + firstBatchDone = true + + text := strings.TrimRight(rendered.String(), "\n") + if text == "" { + return nil + } + rows := strings.Split(text, "\n") + + // Lock column widths from the first batch (header + first page). + // Every subsequent batch pads to these widths so columns do not + // shift between pages. + if lockedWidths == nil { + for _, row := range rows { + for i, cell := range strings.Split(row, "\t") { + if i >= len(lockedWidths) { + lockedWidths = append(lockedWidths, 0) + } + if w := visualWidth(cell); w > lockedWidths[i] { + lockedWidths[i] = w + } + } + } + } + + for _, row := range rows { + line := padRow(strings.Split(row, "\t"), lockedWidths) + if _, err := io.WriteString(out, line+"\n"); err != nil { + return err + } + } + return nil + } + + for iter.HasNext(ctx) { + if limit > 0 && total >= limit { + break + } + n, err := iter.Next(ctx) + if err != nil { + return err + } + buf = append(buf, n) + total++ + + if len(buf) < pageSize { + continue + } + if err := flushPage(); err != nil { + return err + } + if drainAll { + if pagerShouldQuit(keys) { + return nil + } + continue + } + // Show the prompt and wait for a key. + fmt.Fprint(prompts, pagerPromptText) + k, ok := pagerNextKey(ctx, keys) + fmt.Fprint(prompts, pagerClearLine) + if !ok { + return nil + } + switch k { + case ' ': + // Continue to the next page. + case '\r', '\n': + drainAll = true + case 'q', 'Q', pagerKeyEscape, pagerKeyCtrlC: + return nil + } + } + return flushPage() +} + +// visualWidth returns the number of runes a string would occupy on-screen +// if ANSI SGR escape sequences are respected as zero-width (which the +// terminal does). This matches what the user sees and lets us pad colored +// cells to consistent visual widths. +func visualWidth(s string) int { + return utf8.RuneCountInString(ansiCSIPattern.ReplaceAllString(s, "")) +} + +// padRow joins the given cells with pagerColumnSeparator, padding every +// cell except the last to widths[i] visual runes. Cells wider than +// widths[i] are emitted as-is — the extra content pushes subsequent +// columns right for that row only, which is the same behavior tabwriter +// would give and the same behavior the non-paged renderer has today. +func padRow(cells []string, widths []int) string { + var b strings.Builder + for i, cell := range cells { + if i > 0 { + b.WriteString(pagerColumnSeparator) + } + b.WriteString(cell) + if i < len(cells)-1 && i < len(widths) { + pad := widths[i] - visualWidth(cell) + if pad > 0 { + b.WriteString(strings.Repeat(" ", pad)) + } + } + } + return b.String() +} diff --git a/libs/cmdio/paged_template_test.go b/libs/cmdio/paged_template_test.go new file mode 100644 index 0000000000..1b61135108 --- /dev/null +++ b/libs/cmdio/paged_template_test.go @@ -0,0 +1,274 @@ +package cmdio + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/listing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type numberIterator struct { + n int + pos int + err error +} + +func (it *numberIterator) HasNext(_ context.Context) bool { + return it.pos < it.n +} + +func (it *numberIterator) Next(_ context.Context) (int, error) { + if it.err != nil { + return 0, it.err + } + it.pos++ + return it.pos, nil +} + +func makeTemplateKeys(bytes ...byte) <-chan byte { + ch := make(chan byte, len(bytes)) + for _, b := range bytes { + ch <- b + } + close(ch) + return ch +} + +func runPagedTemplate(t *testing.T, n, pageSize int, keys []byte) string { + t.Helper() + var out, prompts bytes.Buffer + iter := listing.Iterator[int](&numberIterator{n: n}) + err := renderIteratorPagedTemplateCore( + t.Context(), + iter, + &out, + &prompts, + makeTemplateKeys(keys...), + "", + "{{range .}}{{.}}\n{{end}}", + pageSize, + ) + require.NoError(t, err) + return out.String() +} + +func TestPagedTemplateDrainsWhenFirstPageExhausts(t *testing.T) { + out := runPagedTemplate(t, 3, 10, nil) + require.Equal(t, "1\n2\n3\n", out) +} + +func TestPagedTemplateSpaceFetchesOneMorePage(t *testing.T) { + out := runPagedTemplate(t, 7, 3, []byte{' '}) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + assert.Len(t, lines, 6) +} + +func TestPagedTemplateEnterDrainsIterator(t *testing.T) { + out := runPagedTemplate(t, 25, 5, []byte{'\r'}) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + assert.Len(t, lines, 25) +} + +func TestPagedTemplateQuitKeyExits(t *testing.T) { + out := runPagedTemplate(t, 100, 5, []byte{'q'}) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + assert.Len(t, lines, 5) +} + +func TestPagedTemplateEscExits(t *testing.T) { + out := runPagedTemplate(t, 100, 5, []byte{pagerKeyEscape}) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + assert.Len(t, lines, 5) +} + +func TestPagedTemplateCtrlCExits(t *testing.T) { + out := runPagedTemplate(t, 100, 5, []byte{pagerKeyCtrlC}) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + assert.Len(t, lines, 5) +} + +func TestPagedTemplateEnterInterruptibleByCtrlC(t *testing.T) { + out := runPagedTemplate(t, 20, 5, []byte{'\r', pagerKeyCtrlC}) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + assert.Len(t, lines, 10) +} + +func TestPagedTemplateRespectsLimit(t *testing.T) { + var out, prompts bytes.Buffer + iter := listing.Iterator[int](&numberIterator{n: 200}) + ctx := WithLimit(t.Context(), 7) + err := renderIteratorPagedTemplateCore( + ctx, + iter, + &out, + &prompts, + makeTemplateKeys('\r'), + "", + "{{range .}}{{.}}\n{{end}}", + 5, + ) + require.NoError(t, err) + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + assert.Len(t, lines, 7) +} + +func TestPagedTemplatePrintsHeaderOnce(t *testing.T) { + var out, prompts bytes.Buffer + iter := listing.Iterator[int](&numberIterator{n: 8}) + err := renderIteratorPagedTemplateCore( + t.Context(), + iter, + &out, + &prompts, + makeTemplateKeys(' '), + `ID`, + "{{range .}}{{.}}\n{{end}}", + 3, + ) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(out.String(), "ID\n")) + assert.True(t, strings.HasPrefix(out.String(), "ID\n")) +} + +func TestPagedTemplatePropagatesFetchError(t *testing.T) { + var out, prompts bytes.Buffer + iter := listing.Iterator[int](&numberIterator{n: 100, err: errors.New("boom")}) + err := renderIteratorPagedTemplateCore( + t.Context(), + iter, + &out, + &prompts, + makeTemplateKeys(), + "", + "{{range .}}{{.}}\n{{end}}", + 5, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +func TestPagedTemplateRendersHeaderAndRowsCorrectly(t *testing.T) { + // Regression guard against the header/row template cross-pollution + // bug: if the two templates share a *template.Template receiver, + // every flush re-emits the header text where rows should be. + var out, prompts bytes.Buffer + iter := listing.Iterator[int](&numberIterator{n: 6}) + err := renderIteratorPagedTemplateCore( + t.Context(), + iter, + &out, + &prompts, + makeTemplateKeys(), + `ID Name`, + "{{range .}}{{.}} item-{{.}}\n{{end}}", + 100, + ) + require.NoError(t, err) + got := out.String() + assert.Contains(t, got, "ID") + assert.Contains(t, got, "Name") + for i := 1; i <= 6; i++ { + assert.Contains(t, got, fmt.Sprintf("item-%d", i)) + } + assert.Equal(t, 1, strings.Count(got, "ID")) +} + +func TestPagedTemplateEmptyIteratorStillFlushesHeader(t *testing.T) { + var out, prompts bytes.Buffer + iter := listing.Iterator[int](&numberIterator{n: 0}) + err := renderIteratorPagedTemplateCore( + t.Context(), + iter, + &out, + &prompts, + makeTemplateKeys(), + `ID Name`, + "{{range .}}{{.}}\n{{end}}", + 10, + ) + require.NoError(t, err) + assert.Contains(t, out.String(), "ID") + assert.Contains(t, out.String(), "Name") +} + +// slicedIterator is a tiny iterator implementation for tests that prefer +// to hand over strongly-typed row structs. +type slicedIterator[T any] struct { + data []T + pos int +} + +func (it *slicedIterator[T]) HasNext(_ context.Context) bool { return it.pos < len(it.data) } +func (it *slicedIterator[T]) Next(_ context.Context) (T, error) { + v := it.data[it.pos] + it.pos++ + return v, nil +} + +func TestPagedTemplateColumnWidthsStableAcrossBatches(t *testing.T) { + type row struct { + Name string + Tag string + } + rows := []row{ + {"wide-name-that-sets-the-width", "a"}, + {"short", "b"}, + } + iter := &slicedIterator[row]{data: rows} + var out, prompts bytes.Buffer + err := renderIteratorPagedTemplateCore( + t.Context(), + iter, + &out, + &prompts, + makeTemplateKeys(' '), + "Name Tag", + "{{range .}}{{.Name}} {{.Tag}}\n{{end}}", + 1, + ) + require.NoError(t, err) + got := out.String() + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + require.Len(t, lines, 3) + + // Column 1 must start at the same visible offset on every line. + const wantColTwoOffset = 31 + for i, line := range lines { + idx := strings.LastIndex(line, " ") + 1 + assert.Equal(t, wantColTwoOffset, idx, "line %d: col 1 expected at offset %d, got %d (line=%q)", i, wantColTwoOffset, idx, line) + } +} + +// TestPagedTemplateMatchesNonPagedForSmallList asserts that single-batch +// output is byte-identical to the non-paged template renderer, so users +// who never hit a second page see the exact same thing they used to. +func TestPagedTemplateMatchesNonPagedForSmallList(t *testing.T) { + const rows = 5 + tmpl := `{{range .}}{{green "%d" .}} {{.}} +{{end}}` + var expected bytes.Buffer + refIter := listing.Iterator[int](&numberIterator{n: rows}) + require.NoError(t, renderWithTemplate(t.Context(), newIteratorRenderer(refIter), flags.OutputText, &expected, "", tmpl)) + + var actual, prompts bytes.Buffer + pagedIter := listing.Iterator[int](&numberIterator{n: rows}) + require.NoError(t, renderIteratorPagedTemplateCore( + t.Context(), + pagedIter, + &actual, + &prompts, + makeTemplateKeys(), + "", + tmpl, + 100, + )) + assert.Equal(t, expected.String(), actual.String()) + assert.NotEmpty(t, expected.String()) +} diff --git a/libs/cmdio/pager.go b/libs/cmdio/pager.go new file mode 100644 index 0000000000..400aee8c6a --- /dev/null +++ b/libs/cmdio/pager.go @@ -0,0 +1,133 @@ +package cmdio + +import ( + "context" + "fmt" + "io" + "os" + + "golang.org/x/term" +) + +// pagerPageSize is the number of items rendered between prompts. +const pagerPageSize = 50 + +// pagerPromptText is shown on stderr between pages. +const pagerPromptText = "[space] more [enter] all [q|esc] quit" + +// pagerClearLine is the ANSI sequence to return to column 0 and erase the +// current line. Used to remove the prompt before writing the next page. +const pagerClearLine = "\r\x1b[K" + +// Key codes we care about when reading single bytes from stdin in raw mode. +const ( + pagerKeyEscape = 0x1b + pagerKeyCtrlC = 0x03 +) + +// startRawStdinKeyReader puts stdin into raw mode and spawns a goroutine +// that publishes each keystroke as a byte on the returned channel. The +// returned restore function must be called (typically via defer) to put +// the terminal back in its original mode; it is safe to call even if +// MakeRaw failed (it's a no-op). +// +// The goroutine exits when stdin returns an error (e.g. EOF on process +// shutdown) or when ctx is cancelled, at which point the channel is +// closed. Leaking the goroutine before that is acceptable because the +// pager is only invoked by short-lived CLI commands: the process exits +// shortly after the caller returns. +// +// Note: term.MakeRaw also clears the TTY's OPOST flag on most Unixes. +// With OPOST off, outbound '\n' is not translated to '\r\n', so callers +// that write newlines while raw mode is active should wrap their output +// stream in crlfWriter to avoid staircase output. +func startRawStdinKeyReader(ctx context.Context) (<-chan byte, func(), error) { + fd := int(os.Stdin.Fd()) + oldState, err := term.MakeRaw(fd) + if err != nil { + return nil, func() {}, fmt.Errorf("failed to enter raw mode on stdin: %w", err) + } + restore := func() { _ = term.Restore(fd, oldState) } + + ch := make(chan byte, 16) + go func() { + defer close(ch) + buf := make([]byte, 1) + for { + n, err := os.Stdin.Read(buf) + if err != nil || n == 0 { + return + } + select { + case ch <- buf[0]: + case <-ctx.Done(): + return + } + } + }() + return ch, restore, nil +} + +// pagerNextKey blocks until a key arrives, the key channel closes, or the +// context is cancelled. Returns ok=false on close or cancellation. +func pagerNextKey(ctx context.Context, keys <-chan byte) (byte, bool) { + select { + case k, ok := <-keys: + return k, ok + case <-ctx.Done(): + return 0, false + } +} + +// pagerShouldQuit drains any buffered keys non-blockingly and returns true +// if one of q/Q/esc/Ctrl+C was pressed. Other keys are consumed and +// dropped. A closed channel means stdin ran out (EOF) — that's not a +// quit signal; the caller should keep draining. +func pagerShouldQuit(keys <-chan byte) bool { + for { + select { + case k, ok := <-keys: + if !ok { + return false + } + if k == 'q' || k == 'Q' || k == pagerKeyEscape || k == pagerKeyCtrlC { + return true + } + default: + return false + } + } +} + +// crlfWriter translates outbound '\n' bytes into '\r\n' so output written +// while the TTY is in raw mode (OPOST cleared) still starts at column 0. +// io.Writer semantics are preserved: the returned byte count is the +// number of bytes from p that were consumed, not the (possibly larger) +// number of bytes written to the underlying writer. +type crlfWriter struct { + w io.Writer +} + +func (c crlfWriter) Write(p []byte) (int, error) { + start := 0 + for i, b := range p { + if b != '\n' { + continue + } + if i > start { + if _, err := c.w.Write(p[start:i]); err != nil { + return start, err + } + } + if _, err := c.w.Write([]byte{'\r', '\n'}); err != nil { + return i, err + } + start = i + 1 + } + if start < len(p) { + if _, err := c.w.Write(p[start:]); err != nil { + return start, err + } + } + return len(p), nil +} diff --git a/libs/cmdio/pager_test.go b/libs/cmdio/pager_test.go new file mode 100644 index 0000000000..d2e6f00463 --- /dev/null +++ b/libs/cmdio/pager_test.go @@ -0,0 +1,72 @@ +package cmdio + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCrlfWriterTranslatesNewlines(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"no newlines", "abc", "abc"}, + {"single newline mid", "a\nb", "a\r\nb"}, + {"newline at end", "abc\n", "abc\r\n"}, + {"newline at start", "\nabc", "\r\nabc"}, + {"consecutive newlines", "\n\n", "\r\n\r\n"}, + {"multiple lines", "one\ntwo\nthree\n", "one\r\ntwo\r\nthree\r\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + w := crlfWriter{w: &buf} + n, err := w.Write([]byte(tt.in)) + require.NoError(t, err) + assert.Equal(t, len(tt.in), n, "Write must return the input byte count") + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestPagerShouldQuitDrainsNonQuitKeys(t *testing.T) { + ch := make(chan byte, 4) + ch <- ' ' + ch <- 'x' + ch <- 'y' + assert.False(t, pagerShouldQuit(ch), "non-quit keys must return false") + assert.Empty(t, ch, "non-quit keys must be drained from the channel") +} + +func TestPagerShouldQuitReturnsTrueForQuitKeys(t *testing.T) { + for _, k := range []byte{'q', 'Q', pagerKeyEscape, pagerKeyCtrlC} { + ch := make(chan byte, 1) + ch <- k + assert.Truef(t, pagerShouldQuit(ch), "key %q must trigger quit", k) + } +} + +func TestPagerShouldQuitClosedChannelKeepsDraining(t *testing.T) { + ch := make(chan byte) + close(ch) + assert.False(t, pagerShouldQuit(ch), "closed channel (stdin EOF) must not force quit") +} + +func TestPagerNextKeyReturnsFalseOnClosedChannel(t *testing.T) { + ch := make(chan byte) + close(ch) + _, ok := pagerNextKey(t.Context(), ch) + assert.False(t, ok) +} + +func TestPagerNextKeyReturnsKey(t *testing.T) { + ch := make(chan byte, 1) + ch <- ' ' + k, ok := pagerNextKey(t.Context(), ch) + assert.True(t, ok) + assert.Equal(t, byte(' '), k) +} diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 733dd53fa7..662046531c 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -271,8 +271,17 @@ func Render(ctx context.Context, v any) error { return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template) } +// RenderIterator renders the items produced by i. When the terminal is +// fully interactive (stdin + stdout + stderr all TTYs) and the command +// has a row template, we page through the existing template + tabwriter +// pipeline (same colors, same alignment as the non-paged path; widths are +// locked from the first batch so columns stay aligned across pages). +// Piped output and JSON output keep the existing non-paged behavior. func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error { c := fromContext(ctx) + if c.capabilities.SupportsPager() && c.outputFormat == flags.OutputText && c.template != "" { + return renderIteratorPagedTemplate(ctx, i, c.out, c.headerTemplate, c.template) + } return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template) } From 75189212f785e12b1d16e1751bf398ee8a3002a7 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 21 Apr 2026 12:09:48 +0200 Subject: [PATCH 2/3] Simplify pager: trim docs, extract flushPage, collapse tests Cuts ~100 lines without behavior changes: - Trim over-long doc blocks on SupportsPager, startRawStdinKeyReader, and renderIteratorPagedTemplateCore. - Drop comments that restate the code. - Extract the flushPage closure into a templatePager struct. - Collapse q/Q/esc/Ctrl+C exit tests into a table-driven test. - Drop the brittle hard-coded-offset column test; single-page equivalence and the header-once test already cover the behavior. Co-authored-by: Isaac --- libs/cmdio/capabilities.go | 9 +- libs/cmdio/paged_template.go | 193 +++++++++++++----------------- libs/cmdio/paged_template_test.go | 75 ++---------- libs/cmdio/pager.go | 25 +--- 4 files changed, 101 insertions(+), 201 deletions(-) diff --git a/libs/cmdio/capabilities.go b/libs/cmdio/capabilities.go index 12b46f6150..56a13562d9 100644 --- a/libs/cmdio/capabilities.go +++ b/libs/cmdio/capabilities.go @@ -48,12 +48,9 @@ func (c Capabilities) SupportsColor(w io.Writer) bool { return isTTY(w) && c.color } -// SupportsPager returns true when we can run an interactive pager between -// batches of output: stdin, stdout, and stderr must all be TTYs. Stdin -// carries the user's keystrokes, stdout receives the paged content, and -// stderr carries the "[space] more / [enter] all" prompt — all three -// must be visible for the interaction to make sense. Git Bash is -// excluded because raw-mode stdin reads are unreliable there. +// SupportsPager returns true when all three std streams are TTYs (stdin +// for keystrokes, stdout for content, stderr for the prompt). Git Bash +// is excluded because raw-mode stdin reads are unreliable there. func (c Capabilities) SupportsPager() bool { return c.stdinIsTTY && c.stdoutIsTTY && c.stderrIsTTY && !c.isGitBash } diff --git a/libs/cmdio/paged_template.go b/libs/cmdio/paged_template.go index 15ff1f9311..06e5384afe 100644 --- a/libs/cmdio/paged_template.go +++ b/libs/cmdio/paged_template.go @@ -14,27 +14,14 @@ import ( "github.com/databricks/databricks-sdk-go/listing" ) -// pagerColumnSeparator is the inter-column spacing used when emitting paged -// template output. Matches text/tabwriter.NewWriter(..., 2, ' ', ...) in -// the non-paged path, so single-batch output is visually indistinguishable -// from what renderUsingTemplate produces. -const pagerColumnSeparator = " " - -// ansiCSIPattern matches ANSI SGR escape sequences so we can compute the -// on-screen width of cells that contain colored output. We do not attempt -// to cover every ANSI escape — just the SGR color/style ones (CSI ... m) -// emitted by github.com/fatih/color, which is all our templates use today. +// ansiCSIPattern matches ANSI SGR escape sequences so colored cells +// aren't counted toward column widths. github.com/fatih/color emits CSI +// ... m, which is all our templates use. var ansiCSIPattern = regexp.MustCompile("\x1b\\[[0-9;]*m") -// renderIteratorPagedTemplate streams an iterator through the existing -// template-based renderer one page at a time, prompting the user between -// pages on stderr. The rendering is intentionally identical to the non- -// paged path — same templates, same colors — only the flush cadence, -// the user-facing prompt, and column-width stability across batches -// differ. -// -// SPACE advances by one page; ENTER drains the remaining iterator (still -// interruptible by q/esc/Ctrl+C); q/esc/Ctrl+C stop immediately. +// renderIteratorPagedTemplate pages an iterator through the template +// renderer, prompting between batches. SPACE advances one page, ENTER +// drains the rest, q/esc/Ctrl+C quit. func renderIteratorPagedTemplate[T any]( ctx context.Context, iter listing.Iterator[T], @@ -58,20 +45,52 @@ func renderIteratorPagedTemplate[T any]( ) } -// renderIteratorPagedTemplateCore is the testable core of -// renderIteratorPagedTemplate: it assumes the caller has already set up -// raw stdin (or any key source) and delivered a channel of keystrokes. -// It never touches os.Stdin directly. -// -// Unlike renderUsingTemplate (the non-paged path) we do not rely on -// text/tabwriter to align columns. Tabwriter computes column widths -// per-flush and resets on Flush(), which produces a jarring -// width-shift when a short final batch follows a wider first batch. -// Here we render each page's template output into an intermediate -// buffer, split it into cells by tab, lock visual column widths from -// the first page, and pad every subsequent page to the same widths. -// The output is visually indistinguishable from tabwriter for single- -// batch lists and stays aligned across batches for longer ones. +// templatePager renders accumulated rows to out, locking column widths +// from the first page so layout stays stable across batches. We do not +// use text/tabwriter because it recomputes widths on every Flush. +type templatePager struct { + out io.Writer + headerT *template.Template + rowT *template.Template + headerStr string + widths []int + headerDone bool +} + +func (p *templatePager) flush(buf []any) error { + if p.headerDone && len(buf) == 0 { + return nil + } + var rendered bytes.Buffer + if !p.headerDone && p.headerStr != "" { + if err := p.headerT.Execute(&rendered, nil); err != nil { + return err + } + rendered.WriteByte('\n') + } + if len(buf) > 0 { + if err := p.rowT.Execute(&rendered, buf); err != nil { + return err + } + } + p.headerDone = true + + text := strings.TrimRight(rendered.String(), "\n") + if text == "" { + return nil + } + rows := strings.Split(text, "\n") + if p.widths == nil { + p.widths = computeWidths(rows) + } + for _, row := range rows { + if _, err := io.WriteString(p.out, padRow(strings.Split(row, "\t"), p.widths)+"\n"); err != nil { + return err + } + } + return nil +} + func renderIteratorPagedTemplateCore[T any]( ctx context.Context, iter listing.Iterator[T], @@ -81,10 +100,9 @@ func renderIteratorPagedTemplateCore[T any]( headerTemplate, tmpl string, pageSize int, ) error { - // Use two independent templates so parsing the row template doesn't - // overwrite the header template's parsed body (they would if they - // shared the same *template.Template instance — Parse replaces the - // body in place and returns the receiver). + // Header and row templates must be separate *template.Template + // instances: Parse replaces the receiver's body in place, so sharing + // one makes the second Parse stomp the first. headerT, err := template.New("header").Funcs(renderFuncMap).Parse(headerTemplate) if err != nil { return err @@ -93,68 +111,18 @@ func renderIteratorPagedTemplateCore[T any]( if err != nil { return err } + pager := &templatePager{ + out: out, + headerT: headerT, + rowT: rowT, + headerStr: headerTemplate, + } limit := limitFromContext(ctx) drainAll := false buf := make([]any, 0, pageSize) total := 0 - var lockedWidths []int - firstBatchDone := false - - flushPage := func() error { - // Nothing to emit after the first batch is out the door and the - // buffer is empty — we've already written the header. - if firstBatchDone && len(buf) == 0 { - return nil - } - - var rendered bytes.Buffer - if !firstBatchDone && headerTemplate != "" { - if err := headerT.Execute(&rendered, nil); err != nil { - return err - } - rendered.WriteByte('\n') - } - if len(buf) > 0 { - if err := rowT.Execute(&rendered, buf); err != nil { - return err - } - buf = buf[:0] - } - firstBatchDone = true - - text := strings.TrimRight(rendered.String(), "\n") - if text == "" { - return nil - } - rows := strings.Split(text, "\n") - - // Lock column widths from the first batch (header + first page). - // Every subsequent batch pads to these widths so columns do not - // shift between pages. - if lockedWidths == nil { - for _, row := range rows { - for i, cell := range strings.Split(row, "\t") { - if i >= len(lockedWidths) { - lockedWidths = append(lockedWidths, 0) - } - if w := visualWidth(cell); w > lockedWidths[i] { - lockedWidths[i] = w - } - } - } - } - - for _, row := range rows { - line := padRow(strings.Split(row, "\t"), lockedWidths) - if _, err := io.WriteString(out, line+"\n"); err != nil { - return err - } - } - return nil - } - for iter.HasNext(ctx) { if limit > 0 && total >= limit { break @@ -169,16 +137,16 @@ func renderIteratorPagedTemplateCore[T any]( if len(buf) < pageSize { continue } - if err := flushPage(); err != nil { + if err := pager.flush(buf); err != nil { return err } + buf = buf[:0] if drainAll { if pagerShouldQuit(keys) { return nil } continue } - // Show the prompt and wait for a key. fmt.Fprint(prompts, pagerPromptText) k, ok := pagerNextKey(ctx, keys) fmt.Fprint(prompts, pagerClearLine) @@ -187,39 +155,46 @@ func renderIteratorPagedTemplateCore[T any]( } switch k { case ' ': - // Continue to the next page. case '\r', '\n': drainAll = true case 'q', 'Q', pagerKeyEscape, pagerKeyCtrlC: return nil } } - return flushPage() + return pager.flush(buf) } -// visualWidth returns the number of runes a string would occupy on-screen -// if ANSI SGR escape sequences are respected as zero-width (which the -// terminal does). This matches what the user sees and lets us pad colored -// cells to consistent visual widths. +// visualWidth counts runes ignoring ANSI SGR escape sequences. func visualWidth(s string) int { return utf8.RuneCountInString(ansiCSIPattern.ReplaceAllString(s, "")) } -// padRow joins the given cells with pagerColumnSeparator, padding every -// cell except the last to widths[i] visual runes. Cells wider than -// widths[i] are emitted as-is — the extra content pushes subsequent -// columns right for that row only, which is the same behavior tabwriter -// would give and the same behavior the non-paged renderer has today. +func computeWidths(rows []string) []int { + var widths []int + for _, row := range rows { + for i, cell := range strings.Split(row, "\t") { + if i >= len(widths) { + widths = append(widths, 0) + } + if w := visualWidth(cell); w > widths[i] { + widths[i] = w + } + } + } + return widths +} + +// padRow joins cells with two-space separators matching tabwriter's +// minpad, padding every cell except the last to widths[i] visual runes. func padRow(cells []string, widths []int) string { var b strings.Builder for i, cell := range cells { if i > 0 { - b.WriteString(pagerColumnSeparator) + b.WriteString(" ") } b.WriteString(cell) if i < len(cells)-1 && i < len(widths) { - pad := widths[i] - visualWidth(cell) - if pad > 0 { + if pad := widths[i] - visualWidth(cell); pad > 0 { b.WriteString(strings.Repeat(" ", pad)) } } diff --git a/libs/cmdio/paged_template_test.go b/libs/cmdio/paged_template_test.go index 1b61135108..82cd72fc14 100644 --- a/libs/cmdio/paged_template_test.go +++ b/libs/cmdio/paged_template_test.go @@ -76,22 +76,14 @@ func TestPagedTemplateEnterDrainsIterator(t *testing.T) { assert.Len(t, lines, 25) } -func TestPagedTemplateQuitKeyExits(t *testing.T) { - out := runPagedTemplate(t, 100, 5, []byte{'q'}) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - assert.Len(t, lines, 5) -} - -func TestPagedTemplateEscExits(t *testing.T) { - out := runPagedTemplate(t, 100, 5, []byte{pagerKeyEscape}) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - assert.Len(t, lines, 5) -} - -func TestPagedTemplateCtrlCExits(t *testing.T) { - out := runPagedTemplate(t, 100, 5, []byte{pagerKeyCtrlC}) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - assert.Len(t, lines, 5) +func TestPagedTemplateQuitKeysExit(t *testing.T) { + for _, k := range []byte{'q', 'Q', pagerKeyEscape, pagerKeyCtrlC} { + t.Run(fmt.Sprintf("key=%d", k), func(t *testing.T) { + out := runPagedTemplate(t, 100, 5, []byte{k}) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + assert.Len(t, lines, 5) + }) + } } func TestPagedTemplateEnterInterruptibleByCtrlC(t *testing.T) { @@ -155,9 +147,6 @@ func TestPagedTemplatePropagatesFetchError(t *testing.T) { } func TestPagedTemplateRendersHeaderAndRowsCorrectly(t *testing.T) { - // Regression guard against the header/row template cross-pollution - // bug: if the two templates share a *template.Template receiver, - // every flush re-emits the header text where rows should be. var out, prompts bytes.Buffer iter := listing.Iterator[int](&numberIterator{n: 6}) err := renderIteratorPagedTemplateCore( @@ -198,54 +187,6 @@ func TestPagedTemplateEmptyIteratorStillFlushesHeader(t *testing.T) { assert.Contains(t, out.String(), "Name") } -// slicedIterator is a tiny iterator implementation for tests that prefer -// to hand over strongly-typed row structs. -type slicedIterator[T any] struct { - data []T - pos int -} - -func (it *slicedIterator[T]) HasNext(_ context.Context) bool { return it.pos < len(it.data) } -func (it *slicedIterator[T]) Next(_ context.Context) (T, error) { - v := it.data[it.pos] - it.pos++ - return v, nil -} - -func TestPagedTemplateColumnWidthsStableAcrossBatches(t *testing.T) { - type row struct { - Name string - Tag string - } - rows := []row{ - {"wide-name-that-sets-the-width", "a"}, - {"short", "b"}, - } - iter := &slicedIterator[row]{data: rows} - var out, prompts bytes.Buffer - err := renderIteratorPagedTemplateCore( - t.Context(), - iter, - &out, - &prompts, - makeTemplateKeys(' '), - "Name Tag", - "{{range .}}{{.Name}} {{.Tag}}\n{{end}}", - 1, - ) - require.NoError(t, err) - got := out.String() - lines := strings.Split(strings.TrimRight(got, "\n"), "\n") - require.Len(t, lines, 3) - - // Column 1 must start at the same visible offset on every line. - const wantColTwoOffset = 31 - for i, line := range lines { - idx := strings.LastIndex(line, " ") + 1 - assert.Equal(t, wantColTwoOffset, idx, "line %d: col 1 expected at offset %d, got %d (line=%q)", i, wantColTwoOffset, idx, line) - } -} - // TestPagedTemplateMatchesNonPagedForSmallList asserts that single-batch // output is byte-identical to the non-paged template renderer, so users // who never hit a second page see the exact same thing they used to. diff --git a/libs/cmdio/pager.go b/libs/cmdio/pager.go index 400aee8c6a..8dbd3bbb0e 100644 --- a/libs/cmdio/pager.go +++ b/libs/cmdio/pager.go @@ -25,22 +25,10 @@ const ( pagerKeyCtrlC = 0x03 ) -// startRawStdinKeyReader puts stdin into raw mode and spawns a goroutine -// that publishes each keystroke as a byte on the returned channel. The -// returned restore function must be called (typically via defer) to put -// the terminal back in its original mode; it is safe to call even if -// MakeRaw failed (it's a no-op). -// -// The goroutine exits when stdin returns an error (e.g. EOF on process -// shutdown) or when ctx is cancelled, at which point the channel is -// closed. Leaking the goroutine before that is acceptable because the -// pager is only invoked by short-lived CLI commands: the process exits -// shortly after the caller returns. -// -// Note: term.MakeRaw also clears the TTY's OPOST flag on most Unixes. -// With OPOST off, outbound '\n' is not translated to '\r\n', so callers -// that write newlines while raw mode is active should wrap their output -// stream in crlfWriter to avoid staircase output. +// startRawStdinKeyReader puts stdin into raw mode and streams keystrokes +// onto the returned channel. Callers must defer restore. Raw mode also +// clears OPOST on Unix, so output written while active needs crlfWriter +// to avoid staircase newlines. func startRawStdinKeyReader(ctx context.Context) (<-chan byte, func(), error) { fd := int(os.Stdin.Fd()) oldState, err := term.MakeRaw(fd) @@ -80,9 +68,8 @@ func pagerNextKey(ctx context.Context, keys <-chan byte) (byte, bool) { } // pagerShouldQuit drains any buffered keys non-blockingly and returns true -// if one of q/Q/esc/Ctrl+C was pressed. Other keys are consumed and -// dropped. A closed channel means stdin ran out (EOF) — that's not a -// quit signal; the caller should keep draining. +// if q/Q/esc/Ctrl+C was pressed. A closed channel (stdin EOF) is not a +// quit signal. func pagerShouldQuit(keys <-chan byte) bool { for { select { From 81cb4dce0583e2f681f6f0b4bf476c20fbb90920 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 21 Apr 2026 12:21:08 +0200 Subject: [PATCH 3/3] Apply go-code-structure patterns to tests Two principles from docs/go-code-structure: - Table-driven tests: collapse four pagination behavior tests into one. - Test the pure logic directly: add unit tests for visualWidth, computeWidths, and padRow instead of exercising them only via the integration path. Failures now point directly at the broken helper. Co-authored-by: Isaac --- libs/cmdio/paged_template_test.go | 107 ++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 28 deletions(-) diff --git a/libs/cmdio/paged_template_test.go b/libs/cmdio/paged_template_test.go index 82cd72fc14..b143a00565 100644 --- a/libs/cmdio/paged_template_test.go +++ b/libs/cmdio/paged_template_test.go @@ -59,39 +59,32 @@ func runPagedTemplate(t *testing.T, n, pageSize int, keys []byte) string { return out.String() } -func TestPagedTemplateDrainsWhenFirstPageExhausts(t *testing.T) { - out := runPagedTemplate(t, 3, 10, nil) - require.Equal(t, "1\n2\n3\n", out) -} - -func TestPagedTemplateSpaceFetchesOneMorePage(t *testing.T) { - out := runPagedTemplate(t, 7, 3, []byte{' '}) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - assert.Len(t, lines, 6) -} - -func TestPagedTemplateEnterDrainsIterator(t *testing.T) { - out := runPagedTemplate(t, 25, 5, []byte{'\r'}) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - assert.Len(t, lines, 25) -} - -func TestPagedTemplateQuitKeysExit(t *testing.T) { - for _, k := range []byte{'q', 'Q', pagerKeyEscape, pagerKeyCtrlC} { - t.Run(fmt.Sprintf("key=%d", k), func(t *testing.T) { - out := runPagedTemplate(t, 100, 5, []byte{k}) +func TestPagedTemplateBehavior(t *testing.T) { + tests := []struct { + name string + items int + pageSize int + keys []byte + wantLines int + }{ + {"drains when first page exhausts iterator", 3, 10, nil, 3}, + {"space fetches one more page", 7, 3, []byte{' '}, 6}, + {"enter drains remaining iterator", 25, 5, []byte{'\r'}, 25}, + {"enter interruptible by ctrl+c", 20, 5, []byte{'\r', pagerKeyCtrlC}, 10}, + {"q exits after first page", 100, 5, []byte{'q'}, 5}, + {"Q exits after first page", 100, 5, []byte{'Q'}, 5}, + {"esc exits after first page", 100, 5, []byte{pagerKeyEscape}, 5}, + {"ctrl+c exits after first page", 100, 5, []byte{pagerKeyCtrlC}, 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := runPagedTemplate(t, tt.items, tt.pageSize, tt.keys) lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - assert.Len(t, lines, 5) + assert.Len(t, lines, tt.wantLines) }) } } -func TestPagedTemplateEnterInterruptibleByCtrlC(t *testing.T) { - out := runPagedTemplate(t, 20, 5, []byte{'\r', pagerKeyCtrlC}) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - assert.Len(t, lines, 10) -} - func TestPagedTemplateRespectsLimit(t *testing.T) { var out, prompts bytes.Buffer iter := listing.Iterator[int](&numberIterator{n: 200}) @@ -187,6 +180,64 @@ func TestPagedTemplateEmptyIteratorStillFlushesHeader(t *testing.T) { assert.Contains(t, out.String(), "Name") } +func TestVisualWidth(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {"plain ascii", "hello", 5}, + {"empty", "", 0}, + {"green SGR wraps text", "\x1b[32mhello\x1b[0m", 5}, + {"multiple SGR escapes", "\x1b[1;31mfoo\x1b[0m bar", 7}, + {"multibyte runes count as one each", "héllo", 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, visualWidth(tt.in)) + }) + } +} + +func TestComputeWidths(t *testing.T) { + tests := []struct { + name string + rows []string + want []int + }{ + {"empty input", nil, nil}, + {"single row", []string{"a\tbb\tccc"}, []int{1, 2, 3}}, + {"widest wins per column", []string{"a\tbb", "aaa\tb"}, []int{3, 2}}, + {"ragged rows extend column count", []string{"a", "b\tcc"}, []int{1, 2}}, + {"SGR escapes don't inflate widths", []string{"\x1b[31mred\x1b[0m\tplain"}, []int{3, 5}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, computeWidths(tt.rows)) + }) + } +} + +func TestPadRow(t *testing.T) { + tests := []struct { + name string + cells []string + widths []int + want string + }{ + {"single cell is emitted as-is", []string{"only"}, []int{10}, "only"}, + {"pads every cell except the last", []string{"a", "bb", "c"}, []int{3, 3, 3}, "a bb c"}, + {"overflowing cell pushes next column right", []string{"toolong", "b"}, []int{3, 3}, "toolong b"}, + {"no widths means no padding", []string{"a", "b"}, nil, "a b"}, + {"SGR escape doesn't count toward pad", []string{"\x1b[31mred\x1b[0m", "b"}, []int{5, 1}, "\x1b[31mred\x1b[0m b"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, padRow(tt.cells, tt.widths)) + }) + } +} + // TestPagedTemplateMatchesNonPagedForSmallList asserts that single-batch // output is byte-identical to the non-paged template renderer, so users // who never hit a second page see the exact same thing they used to.