Skip to content
Closed
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 @@ -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 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.

### 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 @@ -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
)
Expand Down
10 changes: 10 additions & 0 deletions libs/cmdio/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
151 changes: 151 additions & 0 deletions libs/cmdio/paged_json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package cmdio

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"

"github.com/databricks/databricks-sdk-go/listing"
)

// renderIteratorPagedJSON streams the iterator as a JSON array, pausing
// for user input every pagerPageSize items. The output is always a
// syntactically valid JSON array — even when the user quits early, we
// still write the closing ']' before returning so a reader that pipes
// the output to a file gets parseable JSON.
//
// The caller is expected to have already verified that the terminal
// supports paging (Capabilities.SupportsPager).
func renderIteratorPagedJSON[T any](
ctx context.Context,
iter listing.Iterator[T],
out io.Writer,
) error {
keys, restore, err := startRawStdinKeyReader(ctx)
if err != nil {
return err
}
defer restore()
return renderIteratorPagedJSONCore(
ctx,
iter,
crlfWriter{w: out},
crlfWriter{w: os.Stderr},
keys,
pagerPageSize,
)
}

// renderIteratorPagedJSONCore is the testable core of
// renderIteratorPagedJSON: it takes the output streams and key channel
// as dependencies and never touches os.Stdin directly.
//
// The rendering mirrors iteratorRenderer.renderJson — a pretty-printed
// JSON array with 2-space indentation — but Flush() is followed by a
// user prompt once every pageSize items. If the user says to quit, the
// closing bracket is still written so the accumulated output remains a
// valid JSON document.
func renderIteratorPagedJSONCore[T any](
ctx context.Context,
iter listing.Iterator[T],
out io.Writer,
prompts io.Writer,
keys <-chan byte,
pageSize int,
) error {
if pageSize <= 0 {
pageSize = pagerPageSize
}

// We render into an intermediate buffer and flush at page
// boundaries. This lets us show the user N items, prompt, then
// continue — without juggling partial writes to the underlying
// writer mid-item.
var buf bytes.Buffer
flush := func() error {
if buf.Len() == 0 {
return nil
}
if _, err := out.Write(buf.Bytes()); err != nil {
return err
}
buf.Reset()
return nil
}

// We defer writing the opening bracket until we actually have an
// item to write. That way an empty iterator (or one that errors
// before yielding anything) produces "[]\n" — valid JSON — rather
// than a half-open "[\n " that the caller can't parse.
totalWritten := 0
finalize := func() error {
if totalWritten == 0 {
buf.WriteString("[]\n")
} else {
buf.WriteString("\n]\n")
}
return flush()
}

limit := limitFromContext(ctx)
drainAll := false
inPage := 0

for iter.HasNext(ctx) {
if limit > 0 && totalWritten >= limit {
break
}
item, err := iter.Next(ctx)
if err != nil {
_ = finalize()
return err
}
if totalWritten == 0 {
buf.WriteString("[\n ")
} else {
buf.WriteString(",\n ")
}
encoded, err := json.MarshalIndent(item, " ", " ")
if err != nil {
_ = finalize()
return err
}
buf.Write(encoded)
totalWritten++
inPage++

if inPage < pageSize {
continue
}
// End of a page. Flush what we have and either prompt or
// continue the drain.
if err := flush(); err != nil {
return err
}
inPage = 0
if drainAll {
if pagerShouldQuit(keys) {
return finalize()
}
continue
}
fmt.Fprint(prompts, pagerPromptText)
k, ok := pagerNextKey(ctx, keys)
fmt.Fprint(prompts, pagerClearLine)
if !ok {
return finalize()
}
switch k {
case ' ':
// continue with the next page
case '\r', '\n':
drainAll = true
case 'q', 'Q', pagerKeyEscape, pagerKeyCtrlC:
return finalize()
}
}
return finalize()
}
159 changes: 159 additions & 0 deletions libs/cmdio/paged_json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package cmdio

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"testing"

"github.com/databricks/databricks-sdk-go/listing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type pagedJSONRow struct {
ID int `json:"id"`
Name string `json:"name"`
}

type pagedJSONIterator struct {
data []pagedJSONRow
pos int
err error
}

func (it *pagedJSONIterator) HasNext(_ context.Context) bool {
return it.pos < len(it.data)
}

func (it *pagedJSONIterator) Next(_ context.Context) (pagedJSONRow, error) {
if it.err != nil {
return pagedJSONRow{}, it.err
}
v := it.data[it.pos]
it.pos++
return v, nil
}

func makePagedJSONRows(n int) []pagedJSONRow {
rows := make([]pagedJSONRow, n)
for i := range rows {
rows[i] = pagedJSONRow{ID: i + 1, Name: fmt.Sprintf("row-%d", i+1)}
}
return rows
}

func makeKeyChan(keys ...byte) <-chan byte {
ch := make(chan byte, len(keys))
for _, k := range keys {
ch <- k
}
close(ch)
return ch
}

func runPagedJSON(t *testing.T, rows []pagedJSONRow, pageSize int, keys []byte) (string, string, error) {
t.Helper()
var out, prompts bytes.Buffer
iter := listing.Iterator[pagedJSONRow](&pagedJSONIterator{data: rows})
err := renderIteratorPagedJSONCore(t.Context(), iter, &out, &prompts, makeKeyChan(keys...), pageSize)
return out.String(), prompts.String(), err
}

func TestPagedJSONRendersFullArrayWhenFitsInOnePage(t *testing.T) {
got, _, err := runPagedJSON(t, makePagedJSONRows(3), 10, nil)
require.NoError(t, err)
assert.JSONEq(t, `[{"id":1,"name":"row-1"},{"id":2,"name":"row-2"},{"id":3,"name":"row-3"}]`, got)
}

func TestPagedJSONSpaceAdvancesOneMorePage(t *testing.T) {
// 7 items, page=3: first page (3), SPACE → second page (3), then the
// key channel closes (stdin EOF) and the pager finalizes, writing
// 6 items total. Fetching item 7 would require a second keypress.
got, _, err := runPagedJSON(t, makePagedJSONRows(7), 3, []byte{' '})
require.NoError(t, err)
var items []pagedJSONRow
require.NoError(t, json.Unmarshal([]byte(got), &items))
assert.Len(t, items, 6)
}

func TestPagedJSONTwoSpacesAdvanceTwoMorePages(t *testing.T) {
// 7 items, page=3, SPACE + SPACE: after the second SPACE the loop
// fetches item 7, HasNext is false, loop exits and the finalizer
// closes the array.
got, _, err := runPagedJSON(t, makePagedJSONRows(7), 3, []byte{' ', ' '})
require.NoError(t, err)
var items []pagedJSONRow
require.NoError(t, json.Unmarshal([]byte(got), &items))
assert.Len(t, items, 7)
}

func TestPagedJSONEnterDrainsIterator(t *testing.T) {
got, _, err := runPagedJSON(t, makePagedJSONRows(20), 5, []byte{'\r'})
require.NoError(t, err)
var items []pagedJSONRow
require.NoError(t, json.Unmarshal([]byte(got), &items))
assert.Len(t, items, 20)
}

func TestPagedJSONQuitEndsEarlyButKeepsValidArray(t *testing.T) {
got, _, err := runPagedJSON(t, makePagedJSONRows(20), 5, []byte{'q'})
require.NoError(t, err)
var items []pagedJSONRow
require.NoError(t, json.Unmarshal([]byte(got), &items), "output must still be valid JSON after early quit: %q", got)
assert.Len(t, items, 5, "only the first page should have rendered")
}

func TestPagedJSONEscQuitsWithValidJSON(t *testing.T) {
got, _, err := runPagedJSON(t, makePagedJSONRows(20), 5, []byte{pagerKeyEscape})
require.NoError(t, err)
var items []pagedJSONRow
require.NoError(t, json.Unmarshal([]byte(got), &items))
assert.Len(t, items, 5)
}

func TestPagedJSONCtrlCInterruptsDrain(t *testing.T) {
// ENTER drains, then a buffered Ctrl+C interrupts after the next
// flush: first page (5) + second page (5) = 10 items; third page
// skipped due to quit signal.
got, _, err := runPagedJSON(t, makePagedJSONRows(20), 5, []byte{'\r', pagerKeyCtrlC})
require.NoError(t, err)
var items []pagedJSONRow
require.NoError(t, json.Unmarshal([]byte(got), &items))
assert.Len(t, items, 10)
}

func TestPagedJSONEmptyIteratorProducesValidEmptyArray(t *testing.T) {
got, _, err := runPagedJSON(t, nil, 10, nil)
require.NoError(t, err)
assert.JSONEq(t, `[]`, got)
}

func TestPagedJSONRespectsLimit(t *testing.T) {
var out, prompts bytes.Buffer
iter := listing.Iterator[pagedJSONRow](&pagedJSONIterator{data: makePagedJSONRows(100)})
ctx := WithLimit(t.Context(), 7)
err := renderIteratorPagedJSONCore(ctx, iter, &out, &prompts, makeKeyChan('\r'), 5)
require.NoError(t, err)
var items []pagedJSONRow
require.NoError(t, json.Unmarshal(out.Bytes(), &items))
assert.Len(t, items, 7)
}

func TestPagedJSONFetchErrorPropagatesButStillValidJSON(t *testing.T) {
var out, prompts bytes.Buffer
iter := listing.Iterator[pagedJSONRow](&pagedJSONIterator{data: makePagedJSONRows(10), err: errors.New("boom")})
err := renderIteratorPagedJSONCore(t.Context(), iter, &out, &prompts, makeKeyChan(), 5)
require.Error(t, err)
assert.Contains(t, err.Error(), "boom")
// Partial buffer should still produce valid JSON (empty array).
assert.JSONEq(t, `[]`, out.String())
}

func TestPagedJSONWritesPromptToPromptsStream(t *testing.T) {
_, promptsOut, err := runPagedJSON(t, makePagedJSONRows(20), 5, []byte{'q'})
require.NoError(t, err)
assert.Contains(t, promptsOut, pagerPromptText)
}
Loading
Loading