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 @@ -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 <resource> 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

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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
7 changes: 7 additions & 0 deletions libs/cmdio/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
203 changes: 203 additions & 0 deletions libs/cmdio/paged_template.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading