diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 8d25c2a4a8..66c4f97eff 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,6 +5,7 @@ ### CLI * Moved file-based OAuth token cache management from the SDK to the CLI. No user-visible change; part of a three-PR sequence that makes the CLI the sole owner of its token cache. +* 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. ### Bundles diff --git a/NOTICE b/NOTICE index 1e286df6f9..3145529c70 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 f376aa0a98..71e9086037 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,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..56a13562d9 100644 --- a/libs/cmdio/capabilities.go +++ b/libs/cmdio/capabilities.go @@ -48,6 +48,13 @@ func (c Capabilities) SupportsColor(w io.Writer) bool { return isTTY(w) && c.color } +// 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 +} + // 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..06e5384afe --- /dev/null +++ b/libs/cmdio/paged_template.go @@ -0,0 +1,203 @@ +package cmdio + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "regexp" + "strings" + "text/template" + "unicode/utf8" + + "github.com/databricks/databricks-sdk-go/listing" +) + +// 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 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], + 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, + ) +} + +// 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], + out io.Writer, + prompts io.Writer, + keys <-chan byte, + headerTemplate, tmpl string, + pageSize int, +) error { + // 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 + } + rowT, err := template.New("row").Funcs(renderFuncMap).Parse(tmpl) + 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 + + 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 := pager.flush(buf); err != nil { + return err + } + buf = buf[:0] + if drainAll { + if pagerShouldQuit(keys) { + return nil + } + continue + } + fmt.Fprint(prompts, pagerPromptText) + k, ok := pagerNextKey(ctx, keys) + fmt.Fprint(prompts, pagerClearLine) + if !ok { + return nil + } + switch k { + case ' ': + case '\r', '\n': + drainAll = true + case 'q', 'Q', pagerKeyEscape, pagerKeyCtrlC: + return nil + } + } + return pager.flush(buf) +} + +// visualWidth counts runes ignoring ANSI SGR escape sequences. +func visualWidth(s string) int { + return utf8.RuneCountInString(ansiCSIPattern.ReplaceAllString(s, "")) +} + +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(" ") + } + b.WriteString(cell) + if i < len(cells)-1 && i < len(widths) { + if pad := widths[i] - visualWidth(cell); 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..b143a00565 --- /dev/null +++ b/libs/cmdio/paged_template_test.go @@ -0,0 +1,266 @@ +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 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, tt.wantLines) + }) + } +} + +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) { + 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") +} + +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. +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..8dbd3bbb0e --- /dev/null +++ b/libs/cmdio/pager.go @@ -0,0 +1,120 @@ +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 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) + 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 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 { + 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 d6018b4e8a..eb2459d2df 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -273,8 +273,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) }