Skip to content
Merged
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
5 changes: 3 additions & 2 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
### 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 experimental OS-native secure token storage behind the `--secure-storage` flag on `databricks auth login` and the `DATABRICKS_AUTH_STORAGE=secure` environment variable. Hidden from help during MS1. Legacy file-backed token storage remains the default.
* Added experimental OS-native secure token storage opt-in via `DATABRICKS_AUTH_STORAGE=secure` or `[__settings__].auth_storage = secure` in `.databrickscfg`. Legacy file-backed token storage remains the default.
* 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`. 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.
* Added experimental OS-native secure token storage opt-in via `DATABRICKS_AUTH_STORAGE=secure`. Legacy file-backed token storage remains the default.


### Bundles

Expand Down
8 changes: 8 additions & 0 deletions libs/cmdio/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ func (c Capabilities) SupportsColor(w io.Writer) bool {
return isTTY(w) && c.color
}

// SupportsPager returns true when we can drive an interactive pager.
// It builds on SupportsPrompt (stderr+stdin TTY, not Git Bash) and
// additionally requires stdout to be a TTY so rendered rows land on
// the terminal rather than a redirected file.
func (c Capabilities) SupportsPager() bool {
return c.SupportsPrompt() && c.stdoutIsTTY
}

// 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
160 changes: 160 additions & 0 deletions libs/cmdio/paged_template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package cmdio

import (
"bytes"
"context"
"io"
"regexp"
"strings"
"text/template"
"unicode/utf8"

tea "github.com/charmbracelet/bubbletea"
"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],
in io.Reader,
out io.Writer,
headerTemplate, tmpl string,
) error {
return renderIteratorPagedTemplateCore(ctx, iter, in, out, headerTemplate, tmpl, pagerFallbackPageSize)
}

// templatePager renders accumulated rows, 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 {
headerT *template.Template
rowT *template.Template
headerStr string
widths []int
headerDone bool
}

// flushLines renders the header (on the first call) plus any buffered
// rows, then pads each cell to the widths recorded on the first page so
// columns line up across batches.
func (p *templatePager) flushLines(buf []any) ([]string, error) {
if p.headerDone && len(buf) == 0 {
return nil, nil
}
var rendered bytes.Buffer
if !p.headerDone && p.headerStr != "" {
if err := p.headerT.Execute(&rendered, nil); err != nil {
return nil, err
}
rendered.WriteByte('\n')
}
if len(buf) > 0 {
if err := p.rowT.Execute(&rendered, buf); err != nil {
return nil, err
}
}
p.headerDone = true

text := strings.TrimRight(rendered.String(), "\n")
if text == "" {
return nil, nil
}
rows := strings.Split(text, "\n")
if p.widths == nil {
p.widths = computeWidths(rows)
}
lines := make([]string, len(rows))
for i, row := range rows {
lines[i] = padRow(strings.Split(row, "\t"), p.widths)
}
return lines, nil
}

func renderIteratorPagedTemplateCore[T any](
ctx context.Context,
iter listing.Iterator[T],
in io.Reader,
out io.Writer,
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{
headerT: headerT,
rowT: rowT,
headerStr: headerTemplate,
}
m := newPagerModel(ctx, iter, pager, pageSize, limitFromContext(ctx))
p := tea.NewProgram(
m,
tea.WithInput(in),
tea.WithOutput(out),
// Match spinner: let SIGINT reach the process rather than the TUI
// so Ctrl+C also interrupts a stalled iterator fetch.
tea.WithoutSignalHandler(),
)
// Unlike cmdio.NewSpinner, the pager doesn't need to acquire/release
// through cmdIO: p.Run is blocking and tea restores the terminal on
// its own before returning, so there's no other tea.Program that could
// race with ours.
if _, err := p.Run(); err != nil {
return err
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spinner does acquire/release because it returns the control flow to the caller and cleanup needs to happen when the CLI exits (restore terminal control characters).

This is not needed here because it is blocking.

Could be helpful context in a comment for future refactors.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a comment near p.Run explaining why we dont need the dance here (blocking, tea restores the terminal on its own). thanks for the context

return m.err
}

// 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