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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

* 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 render as JSON. When stdin, stdout, and stderr are all TTYs and the command has no row template, `databricks <resource> list` now streams 50 JSON items 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. Piped output still receives the full JSON array; early quit still produces a syntactically valid array.
* Added interactive pagination for list commands that have a row template (jobs, clusters, apps, pipelines, etc.). Same prompt, same keys as the JSON pager; colors and alignment match the existing non-paged output and column widths stay stable across pages.

### Bundles

Expand Down
4 changes: 4 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,11 @@ 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
)

require golang.org/x/term v0.41.0

require (
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
Expand Down
228 changes: 228 additions & 0 deletions libs/cmdio/paged_template.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading