Skip to content

Commit 53b85d2

Browse files
committed
Add endless-scrolling TUI table for interactive list commands
Co-authored-by: Isaac
1 parent 309f527 commit 53b85d2

File tree

14 files changed

+1433
-161
lines changed

14 files changed

+1433
-161
lines changed

cmd/root/io.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func (f *outputFlag) initializeIO(ctx context.Context, cmd *cobra.Command) (cont
4949

5050
cmdIO := cmdio.NewIO(ctx, f.output, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), headerTemplate, template)
5151
ctx = cmdio.InContext(ctx, cmdIO)
52+
ctx = cmdio.WithCommand(ctx, cmd)
5253
cmd.SetContext(ctx)
5354
return ctx, nil
5455
}

experimental/aitools/cmd/render.go

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,11 @@ import (
44
"encoding/json"
55
"fmt"
66
"io"
7-
"strings"
8-
"text/tabwriter"
97

108
"github.com/databricks/cli/libs/tableview"
119
"github.com/databricks/databricks-sdk-go/service/sql"
1210
)
1311

14-
const (
15-
// maxColumnWidth is the maximum display width for any single column in static table output.
16-
maxColumnWidth = 40
17-
)
18-
1912
// extractColumns returns column names from the query result manifest.
2013
func extractColumns(manifest *sql.ResultManifest) []string {
2114
if manifest == nil || manifest.Schema == nil {
@@ -53,42 +46,7 @@ func renderJSON(w io.Writer, columns []string, rows [][]string) error {
5346

5447
// renderStaticTable writes query results as a formatted text table.
5548
func renderStaticTable(w io.Writer, columns []string, rows [][]string) error {
56-
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
57-
58-
// Header row.
59-
fmt.Fprintln(tw, strings.Join(columns, "\t"))
60-
61-
// Separator.
62-
seps := make([]string, len(columns))
63-
for i, col := range columns {
64-
width := len(col)
65-
for _, row := range rows {
66-
if i < len(row) {
67-
width = max(width, len(row[i]))
68-
}
69-
}
70-
width = min(width, maxColumnWidth)
71-
seps[i] = strings.Repeat("-", width)
72-
}
73-
fmt.Fprintln(tw, strings.Join(seps, "\t"))
74-
75-
// Data rows.
76-
for _, row := range rows {
77-
vals := make([]string, len(columns))
78-
for i := range columns {
79-
if i < len(row) {
80-
vals[i] = row[i]
81-
}
82-
}
83-
fmt.Fprintln(tw, strings.Join(vals, "\t"))
84-
}
85-
86-
if err := tw.Flush(); err != nil {
87-
return err
88-
}
89-
90-
fmt.Fprintf(w, "\n%d rows\n", len(rows))
91-
return nil
49+
return tableview.RenderStaticTable(w, columns, rows)
9250
}
9351

9452
// renderInteractiveTable displays query results in the interactive table browser.

libs/cmdio/context.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package cmdio
2+
3+
import (
4+
"context"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
type cmdKeyType struct{}
10+
11+
// WithCommand stores the cobra.Command in context.
12+
func WithCommand(ctx context.Context, cmd *cobra.Command) context.Context {
13+
return context.WithValue(ctx, cmdKeyType{}, cmd)
14+
}
15+
16+
// CommandFromContext retrieves the cobra.Command from context.
17+
func CommandFromContext(ctx context.Context) *cobra.Command {
18+
cmd, _ := ctx.Value(cmdKeyType{}).(*cobra.Command)
19+
return cmd
20+
}
21+
22+
type maxItemsKeyType struct{}
23+
24+
// WithMaxItems stores a max items limit in context.
25+
func WithMaxItems(ctx context.Context, n int) context.Context {
26+
return context.WithValue(ctx, maxItemsKeyType{}, n)
27+
}
28+
29+
// GetMaxItems retrieves the max items limit from context (0 = unlimited).
30+
func GetMaxItems(ctx context.Context) int {
31+
n, _ := ctx.Value(maxItemsKeyType{}).(int)
32+
return n
33+
}

libs/cmdio/render.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/databricks/cli/libs/diag"
1717
"github.com/databricks/cli/libs/flags"
18+
"github.com/databricks/cli/libs/tableview"
1819
"github.com/databricks/databricks-sdk-go/listing"
1920
"github.com/fatih/color"
2021
"github.com/nwidger/jsoncolor"
@@ -265,6 +266,24 @@ func Render(ctx context.Context, v any) error {
265266

266267
func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error {
267268
c := fromContext(ctx)
269+
270+
// Launch paginated TUI when interactive and text output.
271+
if c.outputFormat == flags.OutputText && c.capabilities.SupportsPrompt() {
272+
cmd := CommandFromContext(ctx)
273+
var cfg *tableview.TableConfig
274+
if cmd != nil {
275+
cfg = tableview.GetConfig(cmd)
276+
}
277+
if cfg == nil {
278+
cfg = tableview.AutoDetect(i)
279+
}
280+
if cfg != nil {
281+
iter := tableview.WrapIterator(i, cfg.Columns)
282+
maxItems := GetMaxItems(ctx)
283+
return tableview.RunPaginated(ctx, c.out, cfg, iter, maxItems)
284+
}
285+
}
286+
268287
return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template)
269288
}
270289

libs/tableview/autodetect.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package tableview
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"strings"
7+
"sync"
8+
"unicode"
9+
10+
"github.com/databricks/databricks-sdk-go/listing"
11+
)
12+
13+
const maxAutoColumns = 8
14+
15+
var autoCache sync.Map // reflect.Type -> *TableConfig
16+
17+
// AutoDetect creates a TableConfig by reflecting on the element type of the iterator.
18+
// It picks up to maxAutoColumns top-level scalar fields.
19+
// Returns nil if no suitable columns are found.
20+
func AutoDetect[T any](iter listing.Iterator[T]) *TableConfig {
21+
var zero T
22+
t := reflect.TypeOf(zero)
23+
if t.Kind() == reflect.Ptr {
24+
t = t.Elem()
25+
}
26+
27+
if cached, ok := autoCache.Load(t); ok {
28+
return cached.(*TableConfig)
29+
}
30+
31+
cfg := autoDetectFromType(t)
32+
if cfg != nil {
33+
autoCache.Store(t, cfg)
34+
}
35+
return cfg
36+
}
37+
38+
func autoDetectFromType(t reflect.Type) *TableConfig {
39+
if t.Kind() != reflect.Struct {
40+
return nil
41+
}
42+
43+
var columns []ColumnDef
44+
for i := range t.NumField() {
45+
if len(columns) >= maxAutoColumns {
46+
break
47+
}
48+
field := t.Field(i)
49+
if !field.IsExported() || field.Anonymous {
50+
continue
51+
}
52+
if !isScalarKind(field.Type.Kind()) {
53+
continue
54+
}
55+
56+
header := fieldHeader(field)
57+
fieldIdx := i
58+
columns = append(columns, ColumnDef{
59+
Header: header,
60+
Extract: func(v any) string {
61+
val := reflect.ValueOf(v)
62+
if val.Kind() == reflect.Ptr {
63+
if val.IsNil() {
64+
return ""
65+
}
66+
val = val.Elem()
67+
}
68+
f := val.Field(fieldIdx)
69+
return fmt.Sprintf("%v", f.Interface())
70+
},
71+
})
72+
}
73+
74+
if len(columns) == 0 {
75+
return nil
76+
}
77+
return &TableConfig{Columns: columns}
78+
}
79+
80+
func isScalarKind(k reflect.Kind) bool {
81+
switch k {
82+
case reflect.String, reflect.Bool,
83+
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
84+
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
85+
reflect.Float32, reflect.Float64:
86+
return true
87+
}
88+
return false
89+
}
90+
91+
// fieldHeader converts a struct field to a display header.
92+
// Uses the json tag if available, otherwise the field name.
93+
func fieldHeader(f reflect.StructField) string {
94+
tag := f.Tag.Get("json")
95+
if tag != "" {
96+
name, _, _ := strings.Cut(tag, ",")
97+
if name != "" && name != "-" {
98+
return snakeToTitle(name)
99+
}
100+
}
101+
return f.Name
102+
}
103+
104+
func snakeToTitle(s string) string {
105+
words := strings.Split(s, "_")
106+
for i, w := range words {
107+
if w == "id" {
108+
words[i] = "ID"
109+
} else if len(w) > 0 {
110+
runes := []rune(w)
111+
runes[0] = unicode.ToUpper(runes[0])
112+
words[i] = string(runes)
113+
}
114+
}
115+
return strings.Join(words, " ")
116+
}

libs/tableview/autodetect_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package tableview
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
type scalarStruct struct {
11+
Name string `json:"name"`
12+
Age int `json:"age"`
13+
Active bool `json:"is_active"`
14+
Score float64 `json:"score"`
15+
}
16+
17+
type nestedStruct struct {
18+
ID string `json:"id"`
19+
Config struct {
20+
Key string
21+
}
22+
Label string `json:"label"`
23+
}
24+
25+
type manyFieldsStruct struct {
26+
F1 string `json:"f1"`
27+
F2 string `json:"f2"`
28+
F3 string `json:"f3"`
29+
F4 string `json:"f4"`
30+
F5 string `json:"f5"`
31+
F6 string `json:"f6"`
32+
F7 string `json:"f7"`
33+
F8 string `json:"f8"`
34+
F9 string `json:"f9"`
35+
F10 string `json:"f10"`
36+
}
37+
38+
type noExportedFields struct {
39+
hidden string //nolint:unused
40+
}
41+
42+
type jsonTagStruct struct {
43+
WorkspaceID string `json:"workspace_id"`
44+
DisplayName string `json:"display_name"`
45+
NoTag string
46+
}
47+
48+
func TestAutoDetectScalarFields(t *testing.T) {
49+
iter := &fakeIterator[scalarStruct]{items: []scalarStruct{{Name: "alice", Age: 30, Active: true, Score: 9.5}}}
50+
cfg := AutoDetect[scalarStruct](iter)
51+
require.NotNil(t, cfg)
52+
assert.Len(t, cfg.Columns, 4)
53+
assert.Equal(t, "Name", cfg.Columns[0].Header)
54+
assert.Equal(t, "Age", cfg.Columns[1].Header)
55+
assert.Equal(t, "Is Active", cfg.Columns[2].Header)
56+
assert.Equal(t, "Score", cfg.Columns[3].Header)
57+
58+
val := scalarStruct{Name: "bob", Age: 25, Active: false, Score: 7.2}
59+
assert.Equal(t, "bob", cfg.Columns[0].Extract(val))
60+
assert.Equal(t, "25", cfg.Columns[1].Extract(val))
61+
assert.Equal(t, "false", cfg.Columns[2].Extract(val))
62+
assert.Equal(t, "7.2", cfg.Columns[3].Extract(val))
63+
}
64+
65+
func TestAutoDetectSkipsNestedFields(t *testing.T) {
66+
iter := &fakeIterator[nestedStruct]{items: []nestedStruct{{ID: "123", Label: "test"}}}
67+
cfg := AutoDetect[nestedStruct](iter)
68+
require.NotNil(t, cfg)
69+
assert.Len(t, cfg.Columns, 2)
70+
assert.Equal(t, "ID", cfg.Columns[0].Header)
71+
assert.Equal(t, "Label", cfg.Columns[1].Header)
72+
}
73+
74+
func TestAutoDetectPointerType(t *testing.T) {
75+
iter := &fakeIterator[*scalarStruct]{items: []*scalarStruct{{Name: "ptr", Age: 1}}}
76+
cfg := AutoDetect[*scalarStruct](iter)
77+
require.NotNil(t, cfg)
78+
assert.Len(t, cfg.Columns, 4)
79+
80+
val := &scalarStruct{Name: "ptr", Age: 1}
81+
assert.Equal(t, "ptr", cfg.Columns[0].Extract(val))
82+
assert.Equal(t, "1", cfg.Columns[1].Extract(val))
83+
}
84+
85+
func TestAutoDetectCappedAtMaxColumns(t *testing.T) {
86+
iter := &fakeIterator[manyFieldsStruct]{items: []manyFieldsStruct{{}}}
87+
cfg := AutoDetect[manyFieldsStruct](iter)
88+
require.NotNil(t, cfg)
89+
assert.Len(t, cfg.Columns, maxAutoColumns)
90+
}
91+
92+
func TestAutoDetectNoExportedFields(t *testing.T) {
93+
iter := &fakeIterator[noExportedFields]{items: []noExportedFields{{}}}
94+
cfg := AutoDetect[noExportedFields](iter)
95+
assert.Nil(t, cfg)
96+
}
97+
98+
func TestAutoDetectJsonTags(t *testing.T) {
99+
iter := &fakeIterator[jsonTagStruct]{items: []jsonTagStruct{{}}}
100+
cfg := AutoDetect[jsonTagStruct](iter)
101+
require.NotNil(t, cfg)
102+
assert.Equal(t, "Workspace ID", cfg.Columns[0].Header)
103+
assert.Equal(t, "Display Name", cfg.Columns[1].Header)
104+
assert.Equal(t, "NoTag", cfg.Columns[2].Header)
105+
}
106+
107+
func TestAutoDetectCaching(t *testing.T) {
108+
iter1 := &fakeIterator[scalarStruct]{items: []scalarStruct{{}}}
109+
cfg1 := AutoDetect[scalarStruct](iter1)
110+
111+
iter2 := &fakeIterator[scalarStruct]{items: []scalarStruct{{}}}
112+
cfg2 := AutoDetect[scalarStruct](iter2)
113+
114+
// Should return the same cached pointer.
115+
assert.Same(t, cfg1, cfg2)
116+
}
117+
118+
func TestSnakeToTitle(t *testing.T) {
119+
tests := []struct {
120+
input string
121+
expected string
122+
}{
123+
{"workspace_id", "Workspace ID"},
124+
{"display_name", "Display Name"},
125+
{"id", "ID"},
126+
{"simple", "Simple"},
127+
{"a_b_c", "A B C"},
128+
}
129+
for _, tt := range tests {
130+
assert.Equal(t, tt.expected, snakeToTitle(tt.input))
131+
}
132+
}

0 commit comments

Comments
 (0)