From e67290687e59539f865382cdef6965d0e5ea990c Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 00:35:39 +0100 Subject: [PATCH 01/32] Add endless-scrolling TUI table for interactive list commands Co-authored-by: Isaac --- cmd/root/io.go | 1 + experimental/aitools/cmd/render.go | 44 +-- libs/cmdio/capabilities.go | 6 + libs/cmdio/context.go | 33 +++ libs/cmdio/render.go | 19 ++ libs/tableview/autodetect.go | 116 ++++++++ libs/tableview/autodetect_test.go | 132 +++++++++ libs/tableview/common.go | 160 ++++++++++ libs/tableview/config.go | 30 ++ libs/tableview/paginated.go | 451 +++++++++++++++++++++++++++++ libs/tableview/paginated_test.go | 439 ++++++++++++++++++++++++++++ libs/tableview/registry.go | 26 ++ libs/tableview/tableview.go | 122 +------- libs/tableview/wrap.go | 33 +++ libs/tableview/wrap_test.go | 84 ++++++ 15 files changed, 1535 insertions(+), 161 deletions(-) create mode 100644 libs/cmdio/context.go create mode 100644 libs/tableview/autodetect.go create mode 100644 libs/tableview/autodetect_test.go create mode 100644 libs/tableview/common.go create mode 100644 libs/tableview/config.go create mode 100644 libs/tableview/paginated.go create mode 100644 libs/tableview/paginated_test.go create mode 100644 libs/tableview/registry.go create mode 100644 libs/tableview/wrap.go create mode 100644 libs/tableview/wrap_test.go diff --git a/cmd/root/io.go b/cmd/root/io.go index 6393c62d66a..f798579d87e 100644 --- a/cmd/root/io.go +++ b/cmd/root/io.go @@ -49,6 +49,7 @@ func (f *outputFlag) initializeIO(ctx context.Context, cmd *cobra.Command) (cont cmdIO := cmdio.NewIO(ctx, f.output, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), headerTemplate, template) ctx = cmdio.InContext(ctx, cmdIO) + ctx = cmdio.WithCommand(ctx, cmd) cmd.SetContext(ctx) return ctx, nil } diff --git a/experimental/aitools/cmd/render.go b/experimental/aitools/cmd/render.go index b7eadb401cb..11efd9dca4f 100644 --- a/experimental/aitools/cmd/render.go +++ b/experimental/aitools/cmd/render.go @@ -4,18 +4,11 @@ import ( "encoding/json" "fmt" "io" - "strings" - "text/tabwriter" "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/sql" ) -const ( - // maxColumnWidth is the maximum display width for any single column in static table output. - maxColumnWidth = 40 -) - // extractColumns returns column names from the query result manifest. func extractColumns(manifest *sql.ResultManifest) []string { if manifest == nil || manifest.Schema == nil { @@ -53,42 +46,7 @@ func renderJSON(w io.Writer, columns []string, rows [][]string) error { // renderStaticTable writes query results as a formatted text table. func renderStaticTable(w io.Writer, columns []string, rows [][]string) error { - tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) - - // Header row. - fmt.Fprintln(tw, strings.Join(columns, "\t")) - - // Separator. - seps := make([]string, len(columns)) - for i, col := range columns { - width := len(col) - for _, row := range rows { - if i < len(row) { - width = max(width, len(row[i])) - } - } - width = min(width, maxColumnWidth) - seps[i] = strings.Repeat("-", width) - } - fmt.Fprintln(tw, strings.Join(seps, "\t")) - - // Data rows. - for _, row := range rows { - vals := make([]string, len(columns)) - for i := range columns { - if i < len(row) { - vals[i] = row[i] - } - } - fmt.Fprintln(tw, strings.Join(vals, "\t")) - } - - if err := tw.Flush(); err != nil { - return err - } - - fmt.Fprintf(w, "\n%d rows\n", len(rows)) - return nil + return tableview.RenderStaticTable(w, columns, rows) } // renderInteractiveTable displays query results in the interactive table browser. diff --git a/libs/cmdio/capabilities.go b/libs/cmdio/capabilities.go index 455acebc772..4af338183e8 100644 --- a/libs/cmdio/capabilities.go +++ b/libs/cmdio/capabilities.go @@ -42,6 +42,12 @@ func (c Capabilities) SupportsPrompt() bool { return c.SupportsInteractive() && c.stdinIsTTY && !c.isGitBash } +// SupportsTUI returns true when the terminal supports a full interactive TUI. +// Requires stdin (keyboard), stderr (prompts), and stdout (TUI output) all be TTYs with color. +func (c Capabilities) SupportsTUI() bool { + return c.stdinIsTTY && c.stdoutIsTTY && c.stderrIsTTY && c.color && !c.isGitBash +} + // SupportsColor returns true if the given writer supports colored output. // This checks both TTY status and environment variables (NO_COLOR, TERM=dumb). func (c Capabilities) SupportsColor(w io.Writer) bool { diff --git a/libs/cmdio/context.go b/libs/cmdio/context.go new file mode 100644 index 00000000000..c057be6a3a7 --- /dev/null +++ b/libs/cmdio/context.go @@ -0,0 +1,33 @@ +package cmdio + +import ( + "context" + + "github.com/spf13/cobra" +) + +type cmdKeyType struct{} + +// WithCommand stores the cobra.Command in context. +func WithCommand(ctx context.Context, cmd *cobra.Command) context.Context { + return context.WithValue(ctx, cmdKeyType{}, cmd) +} + +// CommandFromContext retrieves the cobra.Command from context. +func CommandFromContext(ctx context.Context) *cobra.Command { + cmd, _ := ctx.Value(cmdKeyType{}).(*cobra.Command) + return cmd +} + +type maxItemsKeyType struct{} + +// WithMaxItems stores a max items limit in context. +func WithMaxItems(ctx context.Context, n int) context.Context { + return context.WithValue(ctx, maxItemsKeyType{}, n) +} + +// GetMaxItems retrieves the max items limit from context (0 = unlimited). +func GetMaxItems(ctx context.Context) int { + n, _ := ctx.Value(maxItemsKeyType{}).(int) + return n +} diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index c344c3d0286..e3b87fa146d 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/listing" "github.com/fatih/color" "github.com/nwidger/jsoncolor" @@ -265,6 +266,24 @@ func Render(ctx context.Context, v any) error { func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error { c := fromContext(ctx) + + // Only launch TUI when an explicit TableConfig is registered. + // AutoDetect is available but opt-in from the override layer. + if c.outputFormat == flags.OutputText && c.capabilities.SupportsTUI() { + cmd := CommandFromContext(ctx) + if cmd != nil { + if cfg := tableview.GetConfig(cmd); cfg != nil { + iter := tableview.WrapIterator(i, cfg.Columns) + maxItems := GetMaxItems(ctx) + p := tableview.NewPaginatedProgram(ctx, c.out, cfg, iter, maxItems) + c.acquireTeaProgram(p) + defer c.releaseTeaProgram() + _, err := p.Run() + return err + } + } + } + return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template) } diff --git a/libs/tableview/autodetect.go b/libs/tableview/autodetect.go new file mode 100644 index 00000000000..3921a5efc7f --- /dev/null +++ b/libs/tableview/autodetect.go @@ -0,0 +1,116 @@ +package tableview + +import ( + "fmt" + "reflect" + "strings" + "sync" + "unicode" + + "github.com/databricks/databricks-sdk-go/listing" +) + +const maxAutoColumns = 8 + +var autoCache sync.Map // reflect.Type -> *TableConfig + +// AutoDetect creates a TableConfig by reflecting on the element type of the iterator. +// It picks up to maxAutoColumns top-level scalar fields. +// Returns nil if no suitable columns are found. +func AutoDetect[T any](iter listing.Iterator[T]) *TableConfig { + var zero T + t := reflect.TypeOf(zero) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + if cached, ok := autoCache.Load(t); ok { + return cached.(*TableConfig) + } + + cfg := autoDetectFromType(t) + if cfg != nil { + autoCache.Store(t, cfg) + } + return cfg +} + +func autoDetectFromType(t reflect.Type) *TableConfig { + if t.Kind() != reflect.Struct { + return nil + } + + var columns []ColumnDef + for i := range t.NumField() { + if len(columns) >= maxAutoColumns { + break + } + field := t.Field(i) + if !field.IsExported() || field.Anonymous { + continue + } + if !isScalarKind(field.Type.Kind()) { + continue + } + + header := fieldHeader(field) + columns = append(columns, ColumnDef{ + Header: header, + Extract: func(v any) string { + val := reflect.ValueOf(v) + if val.Kind() == reflect.Ptr { + if val.IsNil() { + return "" + } + val = val.Elem() + } + f := val.Field(i) + return fmt.Sprintf("%v", f.Interface()) + }, + }) + } + + if len(columns) == 0 { + return nil + } + return &TableConfig{Columns: columns} +} + +func isScalarKind(k reflect.Kind) bool { + switch k { + case reflect.String, reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + default: + return false + } +} + +// fieldHeader converts a struct field to a display header. +// Uses the json tag if available, otherwise the field name. +func fieldHeader(f reflect.StructField) string { + tag := f.Tag.Get("json") + if tag != "" { + name, _, _ := strings.Cut(tag, ",") + if name != "" && name != "-" { + return snakeToTitle(name) + } + } + return f.Name +} + +func snakeToTitle(s string) string { + words := strings.Split(s, "_") + for i, w := range words { + if w == "id" { + words[i] = "ID" + } else if len(w) > 0 { + runes := []rune(w) + runes[0] = unicode.ToUpper(runes[0]) + words[i] = string(runes) + } + } + return strings.Join(words, " ") +} diff --git a/libs/tableview/autodetect_test.go b/libs/tableview/autodetect_test.go new file mode 100644 index 00000000000..90ab1019fb2 --- /dev/null +++ b/libs/tableview/autodetect_test.go @@ -0,0 +1,132 @@ +package tableview + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type scalarStruct struct { + Name string `json:"name"` + Age int `json:"age"` + Active bool `json:"is_active"` + Score float64 `json:"score"` +} + +type nestedStruct struct { + ID string `json:"id"` + Config struct { + Key string + } + Label string `json:"label"` +} + +type manyFieldsStruct struct { + F1 string `json:"f1"` + F2 string `json:"f2"` + F3 string `json:"f3"` + F4 string `json:"f4"` + F5 string `json:"f5"` + F6 string `json:"f6"` + F7 string `json:"f7"` + F8 string `json:"f8"` + F9 string `json:"f9"` + F10 string `json:"f10"` +} + +type noExportedFields struct { + hidden string //nolint:unused +} + +type jsonTagStruct struct { + WorkspaceID string `json:"workspace_id"` + DisplayName string `json:"display_name"` + NoTag string +} + +func TestAutoDetectScalarFields(t *testing.T) { + iter := &fakeIterator[scalarStruct]{items: []scalarStruct{{Name: "alice", Age: 30, Active: true, Score: 9.5}}} + cfg := AutoDetect[scalarStruct](iter) + require.NotNil(t, cfg) + assert.Len(t, cfg.Columns, 4) + assert.Equal(t, "Name", cfg.Columns[0].Header) + assert.Equal(t, "Age", cfg.Columns[1].Header) + assert.Equal(t, "Is Active", cfg.Columns[2].Header) + assert.Equal(t, "Score", cfg.Columns[3].Header) + + val := scalarStruct{Name: "bob", Age: 25, Active: false, Score: 7.2} + assert.Equal(t, "bob", cfg.Columns[0].Extract(val)) + assert.Equal(t, "25", cfg.Columns[1].Extract(val)) + assert.Equal(t, "false", cfg.Columns[2].Extract(val)) + assert.Equal(t, "7.2", cfg.Columns[3].Extract(val)) +} + +func TestAutoDetectSkipsNestedFields(t *testing.T) { + iter := &fakeIterator[nestedStruct]{items: []nestedStruct{{ID: "123", Label: "test"}}} + cfg := AutoDetect[nestedStruct](iter) + require.NotNil(t, cfg) + assert.Len(t, cfg.Columns, 2) + assert.Equal(t, "ID", cfg.Columns[0].Header) + assert.Equal(t, "Label", cfg.Columns[1].Header) +} + +func TestAutoDetectPointerType(t *testing.T) { + iter := &fakeIterator[*scalarStruct]{items: []*scalarStruct{{Name: "ptr", Age: 1}}} + cfg := AutoDetect[*scalarStruct](iter) + require.NotNil(t, cfg) + assert.Len(t, cfg.Columns, 4) + + val := &scalarStruct{Name: "ptr", Age: 1} + assert.Equal(t, "ptr", cfg.Columns[0].Extract(val)) + assert.Equal(t, "1", cfg.Columns[1].Extract(val)) +} + +func TestAutoDetectCappedAtMaxColumns(t *testing.T) { + iter := &fakeIterator[manyFieldsStruct]{items: []manyFieldsStruct{{}}} + cfg := AutoDetect[manyFieldsStruct](iter) + require.NotNil(t, cfg) + assert.Len(t, cfg.Columns, maxAutoColumns) +} + +func TestAutoDetectNoExportedFields(t *testing.T) { + iter := &fakeIterator[noExportedFields]{items: []noExportedFields{{}}} + cfg := AutoDetect[noExportedFields](iter) + assert.Nil(t, cfg) +} + +func TestAutoDetectJsonTags(t *testing.T) { + iter := &fakeIterator[jsonTagStruct]{items: []jsonTagStruct{{}}} + cfg := AutoDetect[jsonTagStruct](iter) + require.NotNil(t, cfg) + assert.Equal(t, "Workspace ID", cfg.Columns[0].Header) + assert.Equal(t, "Display Name", cfg.Columns[1].Header) + assert.Equal(t, "NoTag", cfg.Columns[2].Header) +} + +func TestAutoDetectCaching(t *testing.T) { + iter1 := &fakeIterator[scalarStruct]{items: []scalarStruct{{}}} + cfg1 := AutoDetect[scalarStruct](iter1) + + iter2 := &fakeIterator[scalarStruct]{items: []scalarStruct{{}}} + cfg2 := AutoDetect[scalarStruct](iter2) + + // Should return the same cached pointer. + assert.Same(t, cfg1, cfg2) +} + +func TestSnakeToTitle(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"workspace_id", "Workspace ID"}, + {"display_name", "Display Name"}, + {"id", "ID"}, + {"simple", "Simple"}, + {"a_b_c", "A B C"}, + } + for _, tt := range tests { + assert.Equal(t, tt.expected, snakeToTitle(tt.input)) + } +} diff --git a/libs/tableview/common.go b/libs/tableview/common.go new file mode 100644 index 00000000000..58372408a17 --- /dev/null +++ b/libs/tableview/common.go @@ -0,0 +1,160 @@ +package tableview + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" +) + +const ( + horizontalScrollStep = 4 + footerHeight = 1 + searchFooterHeight = 2 + // headerLines is the number of non-data lines at the top (header + separator). + headerLines = 2 +) + +var ( + searchHighlightStyle = lipgloss.NewStyle().Background(lipgloss.Color("228")).Foreground(lipgloss.Color("0")) + cursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("229")) + footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + searchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) +) + +// renderTableLines produces aligned table text as individual lines. +func renderTableLines(columns []string, rows [][]string) []string { + var buf strings.Builder + tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + + // Header. + fmt.Fprintln(tw, strings.Join(columns, "\t")) + + // Separator: compute widths from header + data for dash line. + widths := make([]int, len(columns)) + for i, col := range columns { + widths[i] = len(col) + } + for _, row := range rows { + for i := range columns { + if i < len(row) { + widths[i] = max(widths[i], len(row[i])) + } + } + } + seps := make([]string, len(columns)) + for i, w := range widths { + seps[i] = strings.Repeat("─", w) + } + fmt.Fprintln(tw, strings.Join(seps, "\t")) + + // Data rows. + for _, row := range rows { + vals := make([]string, len(columns)) + for i := range columns { + if i < len(row) { + vals[i] = row[i] + } + } + fmt.Fprintln(tw, strings.Join(vals, "\t")) + } + + tw.Flush() + + // Split into lines, drop trailing empty. + lines := strings.Split(buf.String(), "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +// findMatches returns line indices containing the query (case-insensitive). +func findMatches(lines []string, query string) []int { + if query == "" { + return nil + } + lower := strings.ToLower(query) + var matches []int + for i, line := range lines { + if strings.Contains(strings.ToLower(line), lower) { + matches = append(matches, i) + } + } + return matches +} + +// highlightSearch applies search match highlighting to a single line. +func highlightSearch(line, query string) string { + if query == "" { + return line + } + lower := strings.ToLower(query) + qLen := len(query) + lineLower := strings.ToLower(line) + + var b strings.Builder + pos := 0 + for { + idx := strings.Index(lineLower[pos:], lower) + if idx < 0 { + b.WriteString(line[pos:]) + break + } + b.WriteString(line[pos : pos+idx]) + b.WriteString(searchHighlightStyle.Render(line[pos+idx : pos+idx+qLen])) + pos += idx + qLen + } + return b.String() +} + +// scrollViewportToCursor ensures the cursor line is visible in the viewport. +func scrollViewportToCursor(vp *viewport.Model, cursor int) { + top := vp.YOffset + bottom := top + vp.Height - 1 + if cursor < top { + vp.SetYOffset(cursor) + } else if cursor > bottom { + vp.SetYOffset(cursor - vp.Height + 1) + } +} + +// RenderStaticTable renders a non-interactive table to the writer. +// This is used as fallback when the terminal doesn't support full interactivity. +func RenderStaticTable(w io.Writer, columns []string, rows [][]string) error { + const maxColumnWidth = 40 + + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + // Header + fmt.Fprintln(tw, strings.Join(columns, "\t")) + // Separator + seps := make([]string, len(columns)) + for i, col := range columns { + width := len(col) + for _, row := range rows { + if i < len(row) { + width = max(width, min(len(row[i]), maxColumnWidth)) + } + } + seps[i] = strings.Repeat("-", width) + } + fmt.Fprintln(tw, strings.Join(seps, "\t")) + // Data rows (no cell truncation; truncation is a TUI display concern) + for _, row := range rows { + vals := make([]string, len(columns)) + for i := range columns { + if i < len(row) { + vals[i] = row[i] + } + } + fmt.Fprintln(tw, strings.Join(vals, "\t")) + } + if err := tw.Flush(); err != nil { + return err + } + _, err := fmt.Fprintf(w, "\n%d rows\n", len(rows)) + return err +} diff --git a/libs/tableview/config.go b/libs/tableview/config.go new file mode 100644 index 00000000000..c933f08e87c --- /dev/null +++ b/libs/tableview/config.go @@ -0,0 +1,30 @@ +package tableview + +import "context" + +// ColumnDef defines a column in the TUI table. +type ColumnDef struct { + Header string // Display name in header row. + MaxWidth int // Max cell width; 0 = default (50). + Extract func(v any) string // Extracts cell value from typed SDK struct. +} + +// SearchConfig configures server-side search for a list command. +type SearchConfig struct { + Placeholder string // Shown in search bar. + // NewIterator creates a fresh RowIterator with the search applied. + // Called when user submits a search query. + NewIterator func(ctx context.Context, query string) RowIterator +} + +// TableConfig configures the TUI table for a list command. +type TableConfig struct { + Columns []ColumnDef + Search *SearchConfig // nil = search disabled. +} + +// RowIterator provides type-erased rows to the TUI. +type RowIterator interface { + HasNext(ctx context.Context) bool + Next(ctx context.Context) ([]string, error) +} diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go new file mode 100644 index 00000000000..086c366efa3 --- /dev/null +++ b/libs/tableview/paginated.go @@ -0,0 +1,451 @@ +package tableview + +import ( + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + fetchBatchSize = 50 + fetchThresholdFromBottom = 10 + defaultMaxColumnWidth = 50 +) + +// rowsFetchedMsg carries newly fetched rows from the iterator. +type rowsFetchedMsg struct { + rows [][]string + exhausted bool + err error +} + +type paginatedModel struct { + cfg *TableConfig + headers []string + + viewport viewport.Model + ready bool + + // Data + rows [][]string + loading bool + exhausted bool + err error + + // Fetch state + rowIter RowIterator + makeFetchCmd func(m paginatedModel) tea.Cmd // closure capturing ctx + makeSearchIter func(query string) RowIterator // closure capturing ctx + + // Display + cursor int + widths []int + + // Search + searching bool + searchInput string + savedRows [][]string + savedIter RowIterator + savedExhaust bool + + // Limits + maxItems int + limitReached bool +} + +// newFetchCmdFunc returns a closure that creates fetch commands, capturing ctx. +func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd { + return func(m paginatedModel) tea.Cmd { + iter := m.rowIter + currentLen := len(m.rows) + maxItems := m.maxItems + + return func() tea.Msg { + var rows [][]string + exhausted := false + + limit := fetchBatchSize + if maxItems > 0 { + remaining := maxItems - currentLen + if remaining <= 0 { + return rowsFetchedMsg{exhausted: true} + } + limit = min(limit, remaining) + } + + for range limit { + if !iter.HasNext(ctx) { + exhausted = true + break + } + row, err := iter.Next(ctx) + if err != nil { + return rowsFetchedMsg{err: err} + } + rows = append(rows, row) + } + + if maxItems > 0 && currentLen+len(rows) >= maxItems { + exhausted = true + } + + return rowsFetchedMsg{rows: rows, exhausted: exhausted} + } + } +} + +// newSearchIterFunc returns a closure that creates search iterators, capturing ctx. +func newSearchIterFunc(ctx context.Context, search *SearchConfig) func(string) RowIterator { + return func(query string) RowIterator { + return search.NewIterator(ctx, query) + } +} + +// NewPaginatedProgram creates but does not run the paginated TUI program. +func NewPaginatedProgram(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIterator, maxItems int) *tea.Program { + headers := make([]string, len(cfg.Columns)) + for i, col := range cfg.Columns { + headers[i] = col.Header + } + + m := paginatedModel{ + cfg: cfg, + headers: headers, + rowIter: iter, + makeFetchCmd: newFetchCmdFunc(ctx), + maxItems: maxItems, + } + + if cfg.Search != nil { + m.makeSearchIter = newSearchIterFunc(ctx, cfg.Search) + } + + return tea.NewProgram(m, tea.WithOutput(w)) +} + +// RunPaginated launches the paginated TUI table. +func RunPaginated(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIterator, maxItems int) error { + p := NewPaginatedProgram(ctx, w, cfg, iter, maxItems) + _, err := p.Run() + return err +} + +func (m paginatedModel) Init() tea.Cmd { + return m.makeFetchCmd(m) +} + +func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + fh := footerHeight + if m.searching { + fh = searchFooterHeight + } + if !m.ready { + m.viewport = viewport.New(msg.Width, msg.Height-fh) + m.viewport.SetHorizontalStep(horizontalScrollStep) + m.ready = true + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - fh + } + if len(m.rows) > 0 { + m.viewport.SetContent(m.renderContent()) + } + return m, nil + + case rowsFetchedMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + return m, nil + } + + isFirstBatch := len(m.rows) == 0 + m.rows = append(m.rows, msg.rows...) + m.exhausted = msg.exhausted + + if m.maxItems > 0 && len(m.rows) >= m.maxItems { + m.limitReached = true + m.exhausted = true + } + + if isFirstBatch && len(m.rows) > 0 { + m.computeWidths() + m.cursor = 0 + } + + if m.ready { + m.viewport.SetContent(m.renderContent()) + } + return m, nil + + case tea.KeyMsg: + if m.searching { + return m.updateSearch(msg) + } + return m.updateNormal(msg) + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *paginatedModel) computeWidths() { + m.widths = make([]int, len(m.headers)) + for i, h := range m.headers { + m.widths[i] = len(h) + } + for _, row := range m.rows { + for i := range m.widths { + if i < len(row) { + maxW := defaultMaxColumnWidth + if i < len(m.cfg.Columns) && m.cfg.Columns[i].MaxWidth > 0 { + maxW = m.cfg.Columns[i].MaxWidth + } + m.widths[i] = min(max(m.widths[i], len(row[i])), maxW) + } + } + } +} + +func (m paginatedModel) renderContent() string { + var buf strings.Builder + tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + + // Header + fmt.Fprintln(tw, strings.Join(m.headers, "\t")) + + // Separator + seps := make([]string, len(m.headers)) + for i, w := range m.widths { + seps[i] = strings.Repeat("─", w) + } + fmt.Fprintln(tw, strings.Join(seps, "\t")) + + // Data rows + for _, row := range m.rows { + vals := make([]string, len(m.headers)) + for i := range m.headers { + if i < len(row) { + v := row[i] + maxW := defaultMaxColumnWidth + if i < len(m.cfg.Columns) && m.cfg.Columns[i].MaxWidth > 0 { + maxW = m.cfg.Columns[i].MaxWidth + } + if len(v) > maxW { + if maxW <= 3 { + v = v[:maxW] + } else { + v = v[:maxW-3] + "..." + } + } + vals[i] = v + } + } + fmt.Fprintln(tw, strings.Join(vals, "\t")) + } + tw.Flush() + + lines := strings.Split(buf.String(), "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + + // Apply cursor highlighting + result := make([]string, len(lines)) + for i, line := range lines { + if i == m.cursor+headerLines { + result[i] = cursorStyle.Render(line) + } else { + result[i] = line + } + } + + return strings.Join(result, "\n") +} + +func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc", "ctrl+c": + return m, tea.Quit + case "/": + if m.cfg.Search != nil { + m.searching = true + m.searchInput = "" + m.viewport.Height-- + return m, nil + } + return m, nil + case "up", "k": + m.moveCursor(-1) + m, cmd := maybeFetch(m) + return m, cmd + case "down", "j": + m.moveCursor(1) + m, cmd := maybeFetch(m) + return m, cmd + case "pgup", "b": + m.moveCursor(-m.viewport.Height) + return m, nil + case "pgdown", "f", " ": + m.moveCursor(m.viewport.Height) + m, cmd := maybeFetch(m) + return m, cmd + case "g": + m.cursor = 0 + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoTop() + return m, nil + case "G": + m.cursor = max(len(m.rows)-1, 0) + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoBottom() + m, cmd := maybeFetch(m) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *paginatedModel) moveCursor(delta int) { + m.cursor += delta + m.cursor = max(m.cursor, 0) + m.cursor = min(m.cursor, max(len(m.rows)-1, 0)) + m.viewport.SetContent(m.renderContent()) + + displayLine := m.cursor + headerLines + scrollViewportToCursor(&m.viewport, displayLine) +} + +func maybeFetch(m paginatedModel) (paginatedModel, tea.Cmd) { + if m.loading || m.exhausted { + return m, nil + } + if len(m.rows)-m.cursor <= fetchThresholdFromBottom { + m.loading = true + return m, m.makeFetchCmd(m) + } + return m, nil +} + +func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + m.searching = false + m.viewport.Height++ + query := m.searchInput + if query == "" { + // Restore original state + if m.savedRows != nil { + m.rows = m.savedRows + m.rowIter = m.savedIter + m.exhausted = m.savedExhaust + m.savedRows = nil + m.savedIter = nil + m.cursor = 0 + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoTop() + } + return m, nil + } + // Save current state + if m.savedRows == nil { + m.savedRows = m.rows + m.savedIter = m.rowIter + m.savedExhaust = m.exhausted + } + // Create new iterator with search + m.rows = nil + m.exhausted = false + m.loading = false + m.cursor = 0 + m.rowIter = m.makeSearchIter(query) + return m, m.makeFetchCmd(m) + case "esc", "ctrl+c": + m.searching = false + m.searchInput = "" + m.viewport.Height++ + return m, nil + case "backspace": + if len(m.searchInput) > 0 { + m.searchInput = m.searchInput[:len(m.searchInput)-1] + } + return m, nil + default: + if len(msg.String()) == 1 || msg.Type == tea.KeyRunes { + m.searchInput += msg.String() + } + return m, nil + } +} + +func (m paginatedModel) View() string { + if !m.ready { + return "Loading..." + } + if len(m.rows) == 0 && m.loading { + return "Fetching results..." + } + if len(m.rows) == 0 && m.exhausted { + return "No results found." + } + if m.err != nil { + return fmt.Sprintf("Error: %v", m.err) + } + + footer := m.renderFooter() + return m.viewport.View() + "\n" + footer +} + +func (m paginatedModel) renderFooter() string { + if m.searching { + placeholder := "" + if m.cfg.Search != nil { + placeholder = m.cfg.Search.Placeholder + } + input := m.searchInput + if input == "" && placeholder != "" { + input = footerStyle.Render(placeholder) + } + prompt := searchStyle.Render("/ " + input + "█") + return footerStyle.Render(fmt.Sprintf("%d rows loaded", len(m.rows))) + "\n" + prompt + } + + var parts []string + + if m.limitReached { + parts = append(parts, fmt.Sprintf("%d rows (limit: %d)", len(m.rows), m.maxItems)) + } else if m.exhausted { + parts = append(parts, fmt.Sprintf("%d rows", len(m.rows))) + } else { + parts = append(parts, fmt.Sprintf("%d rows loaded (more available)", len(m.rows))) + } + + if m.loading { + parts = append(parts, "loading...") + } + + parts = append(parts, "←→↑↓ scroll", "g/G top/bottom") + + if m.cfg.Search != nil { + parts = append(parts, "/ search") + } + + parts = append(parts, "q quit") + + if m.exhausted && len(m.rows) > 0 { + pct := int(m.viewport.ScrollPercent() * 100) + parts = append(parts, fmt.Sprintf("%d%%", pct)) + } + + return footerStyle.Render(strings.Join(parts, " | ")) +} diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go new file mode 100644 index 00000000000..6fc44ce8f49 --- /dev/null +++ b/libs/tableview/paginated_test.go @@ -0,0 +1,439 @@ +package tableview + +import ( + "context" + "errors" + "fmt" + "strconv" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stringRowIterator struct { + rows [][]string + pos int +} + +func (s *stringRowIterator) HasNext(_ context.Context) bool { + return s.pos < len(s.rows) +} + +func (s *stringRowIterator) Next(_ context.Context) ([]string, error) { + if s.pos >= len(s.rows) { + return nil, errors.New("no more rows") + } + row := s.rows[s.pos] + s.pos++ + return row, nil +} + +func newTestConfig() *TableConfig { + return &TableConfig{ + Columns: []ColumnDef{ + {Header: "Name"}, + {Header: "Age"}, + }, + } +} + +func newTestModel(t *testing.T, rows [][]string, maxItems int) paginatedModel { + iter := &stringRowIterator{rows: rows} + cfg := newTestConfig() + return paginatedModel{ + cfg: cfg, + headers: []string{"Name", "Age"}, + rowIter: iter, + makeFetchCmd: newFetchCmdFunc(t.Context()), + maxItems: maxItems, + } +} + +func TestPaginatedModelInit(t *testing.T) { + m := newTestModel(t, [][]string{{"alice", "30"}}, 0) + cmd := m.Init() + require.NotNil(t, cmd) +} + +func TestPaginatedFetchFirstBatch(t *testing.T) { + rows := [][]string{{"alice", "30"}, {"bob", "25"}} + m := newTestModel(t, rows, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + + msg := rowsFetchedMsg{rows: rows, exhausted: true} + result, _ := m.Update(msg) + pm := result.(paginatedModel) + + assert.Len(t, pm.rows, 2) + assert.True(t, pm.exhausted) + assert.Equal(t, 0, pm.cursor) + assert.NotNil(t, pm.widths) +} + +func TestPaginatedFetchSubsequentBatch(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + m.rows = [][]string{{"alice", "30"}} + m.widths = []int{5, 3} + + msg := rowsFetchedMsg{rows: [][]string{{"bob", "25"}}, exhausted: false} + result, _ := m.Update(msg) + pm := result.(paginatedModel) + + assert.Len(t, pm.rows, 2) + assert.False(t, pm.exhausted) +} + +func TestPaginatedFetchExhaustion(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + + msg := rowsFetchedMsg{rows: nil, exhausted: true} + result, _ := m.Update(msg) + pm := result.(paginatedModel) + + assert.True(t, pm.exhausted) + assert.Empty(t, pm.rows) +} + +func TestPaginatedFetchError(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + + msg := rowsFetchedMsg{err: errors.New("network error")} + result, _ := m.Update(msg) + pm := result.(paginatedModel) + + require.Error(t, pm.err) + assert.Equal(t, "network error", pm.err.Error()) +} + +func TestPaginatedCursorMovement(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + m.rows = [][]string{{"alice", "30"}, {"bob", "25"}, {"charlie", "35"}} + m.widths = []int{7, 3} + m.cursor = 0 + + // Move down + m.moveCursor(1) + assert.Equal(t, 1, m.cursor) + + // Move down again + m.moveCursor(1) + assert.Equal(t, 2, m.cursor) + + // Can't go past end + m.moveCursor(1) + assert.Equal(t, 2, m.cursor) + + // Move up + m.moveCursor(-1) + assert.Equal(t, 1, m.cursor) + + // Can't go above 0 + m.moveCursor(-5) + assert.Equal(t, 0, m.cursor) +} + +func TestPaginatedMaxItemsLimit(t *testing.T) { + m := newTestModel(t, nil, 3) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + + rows := [][]string{{"a", "1"}, {"b", "2"}, {"c", "3"}} + msg := rowsFetchedMsg{rows: rows, exhausted: false} + result, _ := m.Update(msg) + pm := result.(paginatedModel) + + assert.True(t, pm.limitReached) + assert.True(t, pm.exhausted) + assert.Len(t, pm.rows, 3) +} + +func TestPaginatedViewLoading(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.loading = true + view := m.View() + assert.Equal(t, "Fetching results...", view) +} + +func TestPaginatedViewNoResults(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.exhausted = true + view := m.View() + assert.Equal(t, "No results found.", view) +} + +func TestPaginatedViewError(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.err = errors.New("something broke") + view := m.View() + assert.Contains(t, view, "Error: something broke") +} + +func TestPaginatedViewNotReady(t *testing.T) { + m := newTestModel(t, nil, 0) + view := m.View() + assert.Equal(t, "Loading...", view) +} + +func TestPaginatedMaybeFetchTriggered(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = make([][]string, 15) + m.cursor = 10 + m.loading = false + m.exhausted = false + + m, cmd := maybeFetch(m) + assert.NotNil(t, cmd) + assert.True(t, m.loading, "loading should be true after fetch triggered") +} + +func TestPaginatedMaybeFetchNotTriggeredWhenExhausted(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = make([][]string, 15) + m.cursor = 10 + m.exhausted = true + + _, cmd := maybeFetch(m) + assert.Nil(t, cmd) +} + +func TestPaginatedMaybeFetchNotTriggeredWhenLoading(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = make([][]string, 15) + m.cursor = 10 + m.loading = true + + _, cmd := maybeFetch(m) + assert.Nil(t, cmd) +} + +func TestPaginatedMaybeFetchNotTriggeredWhenFarFromBottom(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = make([][]string, 50) + m.cursor = 0 + + _, cmd := maybeFetch(m) + assert.Nil(t, cmd) +} + +func TestPaginatedSearchEnterAndRestore(t *testing.T) { + searchCalled := false + cfg := &TableConfig{ + Columns: []ColumnDef{ + {Header: "Name"}, + }, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, query string) RowIterator { + searchCalled = true + return &stringRowIterator{rows: [][]string{{"found:" + query}}} + }, + }, + } + + ctx := t.Context() + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: [][]string{{"original"}}}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + rows: [][]string{{"original"}}, + widths: []int{8}, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Enter search mode + m.searching = true + m.searchInput = "test" + + // Submit search + result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) + pm := result.(paginatedModel) + + assert.False(t, pm.searching) + assert.True(t, searchCalled) + assert.NotNil(t, cmd) + assert.NotNil(t, pm.savedRows) + + // Restore by submitting empty search + pm.searching = true + pm.searchInput = "" + pm.rows = [][]string{{"found:test"}} + result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) + pm = result.(paginatedModel) + + assert.Equal(t, [][]string{{"original"}}, pm.rows) + assert.Nil(t, pm.savedRows) +} + +func TestPaginatedSearchEscCancels(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "partial" + m.viewport.Height = 20 + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm := result.(paginatedModel) + + assert.False(t, pm.searching) + assert.Equal(t, "", pm.searchInput) + assert.Equal(t, 21, pm.viewport.Height) +} + +func TestPaginatedSearchBackspace(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "abc" + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyBackspace}) + pm := result.(paginatedModel) + + assert.Equal(t, "ab", pm.searchInput) +} + +func TestPaginatedSearchTyping(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "" + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + pm := result.(paginatedModel) + + assert.Equal(t, "a", pm.searchInput) +} + +func TestPaginatedRenderFooterExhausted(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = [][]string{{"a", "1"}, {"b", "2"}} + m.exhausted = true + m.cfg = newTestConfig() + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + + footer := m.renderFooter() + assert.Contains(t, footer, "2 rows") + assert.Contains(t, footer, "q quit") +} + +func TestPaginatedRenderFooterMoreAvailable(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = [][]string{{"a", "1"}} + m.exhausted = false + m.cfg = newTestConfig() + + footer := m.renderFooter() + assert.Contains(t, footer, "more available") +} + +func TestPaginatedRenderFooterLimitReached(t *testing.T) { + m := newTestModel(t, nil, 10) + m.rows = make([][]string, 10) + m.limitReached = true + m.exhausted = true + m.cfg = newTestConfig() + + footer := m.renderFooter() + assert.Contains(t, footer, "limit: 10") +} + +func TestMaybeFetchSetsLoadingAndPreventsDoubleFetch(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Simulate a first batch loaded with more available. + rows := make([][]string, 15) + for i := range rows { + rows[i] = []string{fmt.Sprintf("name%d", i), strconv.Itoa(i)} + } + msg := rowsFetchedMsg{rows: rows, exhausted: false} + result, _ := m.Update(msg) + m = result.(paginatedModel) + + // Move cursor near bottom to trigger fetch threshold. + m.cursor = len(m.rows) - 5 + m.viewport.SetContent(m.renderContent()) + + // Trigger update with down key. + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + um := updated.(paginatedModel) + + require.NotNil(t, cmd, "fetch should be triggered when near bottom") + assert.True(t, um.loading, "model should be in loading state when fetch triggered") + + // Second down key should NOT trigger another fetch while loading. + updated2, cmd2 := um.Update(tea.KeyMsg{Type: tea.KeyDown}) + _ = updated2 + assert.Nil(t, cmd2, "should not trigger second fetch while loading") +} + +func TestFetchCmdWithIterator(t *testing.T) { + rows := make([][]string, 60) + for i := range rows { + rows[i] = []string{fmt.Sprintf("name%d", i), strconv.Itoa(i)} + } + m := newTestModel(t, rows, 0) + + // Init returns the first fetch command. + cmd := m.Init() + require.NotNil(t, cmd) + + // Execute the command to get the message. + msg := cmd() + fetched, ok := msg.(rowsFetchedMsg) + require.True(t, ok) + + assert.NoError(t, fetched.err) + assert.Len(t, fetched.rows, fetchBatchSize) + assert.False(t, fetched.exhausted, "iterator should have more rows") +} + +func TestFetchCmdExhaustsSmallIterator(t *testing.T) { + rows := [][]string{{"alice", "30"}, {"bob", "25"}} + m := newTestModel(t, rows, 0) + + cmd := m.Init() + require.NotNil(t, cmd) + + msg := cmd() + fetched, ok := msg.(rowsFetchedMsg) + require.True(t, ok) + + assert.NoError(t, fetched.err) + assert.Len(t, fetched.rows, 2) + assert.True(t, fetched.exhausted, "small iterator should be exhausted") +} + +func TestPaginatedRenderFooterWithSearch(t *testing.T) { + m := newTestModel(t, nil, 0) + m.cfg = &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{Placeholder: "type here"}, + } + m.rows = [][]string{{"a"}} + + footer := m.renderFooter() + assert.Contains(t, footer, "/ search") +} diff --git a/libs/tableview/registry.go b/libs/tableview/registry.go new file mode 100644 index 00000000000..6bcb10f87c9 --- /dev/null +++ b/libs/tableview/registry.go @@ -0,0 +1,26 @@ +package tableview + +import ( + "sync" + + "github.com/spf13/cobra" +) + +var ( + configMu sync.RWMutex + configs = map[*cobra.Command]*TableConfig{} +) + +// RegisterConfig associates a TableConfig with a command. +func RegisterConfig(cmd *cobra.Command, cfg TableConfig) { + configMu.Lock() + defer configMu.Unlock() + configs[cmd] = &cfg +} + +// GetConfig retrieves the TableConfig for a command, if registered. +func GetConfig(cmd *cobra.Command) *TableConfig { + configMu.RLock() + defer configMu.RUnlock() + return configs[cmd] +} diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 18eca554ce8..e6a40685e29 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -5,26 +5,9 @@ import ( "fmt" "io" "strings" - "text/tabwriter" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -const ( - horizontalScrollStep = 4 - footerHeight = 1 - searchFooterHeight = 2 - // headerLines is the number of non-data lines at the top (header + separator). - headerLines = 2 -) - -var ( - searchHighlightStyle = lipgloss.NewStyle().Background(lipgloss.Color("228")).Foreground(lipgloss.Color("0")) - cursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("229")) - footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - searchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) ) // Run displays tabular data in an interactive browser. @@ -42,92 +25,6 @@ func Run(w io.Writer, columns []string, rows [][]string) error { return err } -// renderTableLines produces aligned table text as individual lines. -func renderTableLines(columns []string, rows [][]string) []string { - var buf strings.Builder - tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) - - // Header. - fmt.Fprintln(tw, strings.Join(columns, "\t")) - - // Separator: compute widths from header + data for dash line. - widths := make([]int, len(columns)) - for i, col := range columns { - widths[i] = len(col) - } - for _, row := range rows { - for i := range columns { - if i < len(row) { - widths[i] = max(widths[i], len(row[i])) - } - } - } - seps := make([]string, len(columns)) - for i, w := range widths { - seps[i] = strings.Repeat("─", w) - } - fmt.Fprintln(tw, strings.Join(seps, "\t")) - - // Data rows. - for _, row := range rows { - vals := make([]string, len(columns)) - for i := range columns { - if i < len(row) { - vals[i] = row[i] - } - } - fmt.Fprintln(tw, strings.Join(vals, "\t")) - } - - tw.Flush() - - // Split into lines, drop trailing empty. - lines := strings.Split(buf.String(), "\n") - if len(lines) > 0 && lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } - return lines -} - -// findMatches returns line indices containing the query (case-insensitive). -func findMatches(lines []string, query string) []int { - if query == "" { - return nil - } - lower := strings.ToLower(query) - var matches []int - for i, line := range lines { - if strings.Contains(strings.ToLower(line), lower) { - matches = append(matches, i) - } - } - return matches -} - -// highlightSearch applies search match highlighting to a single line. -func highlightSearch(line, query string) string { - if query == "" { - return line - } - lower := strings.ToLower(query) - qLen := len(query) - lineLower := strings.ToLower(line) - - var b strings.Builder - pos := 0 - for { - idx := strings.Index(lineLower[pos:], lower) - if idx < 0 { - b.WriteString(line[pos:]) - break - } - b.WriteString(line[pos : pos+idx]) - b.WriteString(searchHighlightStyle.Render(line[pos+idx : pos+idx+qLen])) - pos += idx + qLen - } - return b.String() -} - // renderContent builds the viewport content with cursor and search highlighting. // Search highlighting is applied first on clean text, then cursor style wraps the result. func (m model) renderContent() string { @@ -208,7 +105,7 @@ func (m model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.matchIdx = (m.matchIdx + 1) % len(m.matchLines) m.cursor = m.matchLines[m.matchIdx] m.viewport.SetContent(m.renderContent()) - m.scrollToCursor() + scrollViewportToCursor(&m.viewport, m.cursor) } return m, nil case "N": @@ -216,7 +113,7 @@ func (m model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.matchIdx = (m.matchIdx - 1 + len(m.matchLines)) % len(m.matchLines) m.cursor = m.matchLines[m.matchIdx] m.viewport.SetContent(m.renderContent()) - m.scrollToCursor() + scrollViewportToCursor(&m.viewport, m.cursor) } return m, nil case "up", "k": @@ -255,18 +152,7 @@ func (m *model) moveCursor(delta int) { m.cursor = max(m.cursor, headerLines) m.cursor = min(m.cursor, len(m.lines)-1) m.viewport.SetContent(m.renderContent()) - m.scrollToCursor() -} - -// scrollToCursor ensures the cursor line is visible in the viewport. -func (m *model) scrollToCursor() { - top := m.viewport.YOffset - bottom := top + m.viewport.Height - 1 - if m.cursor < top { - m.viewport.SetYOffset(m.cursor) - } else if m.cursor > bottom { - m.viewport.SetYOffset(m.cursor - m.viewport.Height + 1) - } + scrollViewportToCursor(&m.viewport, m.cursor) } func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -283,7 +169,7 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.viewport.SetContent(m.renderContent()) if len(m.matchLines) > 0 { - m.scrollToCursor() + scrollViewportToCursor(&m.viewport, m.cursor) } return m, nil case "esc", "ctrl+c": diff --git a/libs/tableview/wrap.go b/libs/tableview/wrap.go new file mode 100644 index 00000000000..96b012f468d --- /dev/null +++ b/libs/tableview/wrap.go @@ -0,0 +1,33 @@ +package tableview + +import ( + "context" + + "github.com/databricks/databricks-sdk-go/listing" +) + +// WrapIterator wraps a typed listing.Iterator into a type-erased RowIterator. +func WrapIterator[T any](iter listing.Iterator[T], columns []ColumnDef) RowIterator { + return &typedRowIterator[T]{inner: iter, columns: columns} +} + +type typedRowIterator[T any] struct { + inner listing.Iterator[T] + columns []ColumnDef +} + +func (r *typedRowIterator[T]) HasNext(ctx context.Context) bool { + return r.inner.HasNext(ctx) +} + +func (r *typedRowIterator[T]) Next(ctx context.Context) ([]string, error) { + item, err := r.inner.Next(ctx) + if err != nil { + return nil, err + } + row := make([]string, len(r.columns)) + for i, col := range r.columns { + row[i] = col.Extract(item) + } + return row, nil +} diff --git a/libs/tableview/wrap_test.go b/libs/tableview/wrap_test.go new file mode 100644 index 00000000000..316bc9e993a --- /dev/null +++ b/libs/tableview/wrap_test.go @@ -0,0 +1,84 @@ +package tableview + +import ( + "context" + "errors" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeItem struct { + Name string + Age int +} + +type fakeIterator[T any] struct { + items []T + pos int +} + +func (f *fakeIterator[T]) HasNext(_ context.Context) bool { + return f.pos < len(f.items) +} + +func (f *fakeIterator[T]) Next(_ context.Context) (T, error) { + if f.pos >= len(f.items) { + var zero T + return zero, errors.New("no more items") + } + item := f.items[f.pos] + f.pos++ + return item, nil +} + +func TestWrapIteratorNormalIteration(t *testing.T) { + items := []fakeItem{{Name: "alice", Age: 30}, {Name: "bob", Age: 25}} + iter := &fakeIterator[fakeItem]{items: items} + columns := []ColumnDef{ + {Header: "Name", Extract: func(v any) string { return v.(fakeItem).Name }}, + {Header: "Age", Extract: func(v any) string { return strconv.Itoa(v.(fakeItem).Age) }}, + } + + ctx := t.Context() + ri := WrapIterator[fakeItem](iter, columns) + + require.True(t, ri.HasNext(ctx)) + row, err := ri.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"alice", "30"}, row) + + require.True(t, ri.HasNext(ctx)) + row, err = ri.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"bob", "25"}, row) + + assert.False(t, ri.HasNext(ctx)) +} + +func TestWrapIteratorEmpty(t *testing.T) { + iter := &fakeIterator[fakeItem]{} + columns := []ColumnDef{ + {Header: "Name", Extract: func(v any) string { return v.(fakeItem).Name }}, + } + + ctx := t.Context() + ri := WrapIterator[fakeItem](iter, columns) + assert.False(t, ri.HasNext(ctx)) +} + +func TestWrapIteratorExtractFunctions(t *testing.T) { + items := []fakeItem{{Name: "charlie", Age: 42}} + iter := &fakeIterator[fakeItem]{items: items} + columns := []ColumnDef{ + {Header: "Upper", Extract: func(v any) string { return "PREFIX_" + v.(fakeItem).Name }}, + } + + ctx := t.Context() + ri := WrapIterator[fakeItem](iter, columns) + row, err := ri.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"PREFIX_charlie"}, row) +} From d7a71045b136f90d27f44f178cb4f813bef5c724 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 13:36:12 +0100 Subject: [PATCH 02/32] Fix stale fetch race condition and empty-table search restore --- libs/tableview/paginated.go | 45 +++++++++++------- libs/tableview/paginated_test.go | 80 +++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 17 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 086c366efa3..444f87e6646 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -19,9 +19,10 @@ const ( // rowsFetchedMsg carries newly fetched rows from the iterator. type rowsFetchedMsg struct { - rows [][]string - exhausted bool - err error + rows [][]string + exhausted bool + err error + generation int } type paginatedModel struct { @@ -38,20 +39,22 @@ type paginatedModel struct { err error // Fetch state - rowIter RowIterator - makeFetchCmd func(m paginatedModel) tea.Cmd // closure capturing ctx - makeSearchIter func(query string) RowIterator // closure capturing ctx + rowIter RowIterator + makeFetchCmd func(m paginatedModel) tea.Cmd // closure capturing ctx + makeSearchIter func(query string) RowIterator // closure capturing ctx + fetchGeneration int // Display cursor int widths []int // Search - searching bool - searchInput string - savedRows [][]string - savedIter RowIterator - savedExhaust bool + searching bool + searchInput string + hasSearchState bool + savedRows [][]string + savedIter RowIterator + savedExhaust bool // Limits maxItems int @@ -64,6 +67,7 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd { iter := m.rowIter currentLen := len(m.rows) maxItems := m.maxItems + generation := m.fetchGeneration return func() tea.Msg { var rows [][]string @@ -73,7 +77,7 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd { if maxItems > 0 { remaining := maxItems - currentLen if remaining <= 0 { - return rowsFetchedMsg{exhausted: true} + return rowsFetchedMsg{exhausted: true, generation: generation} } limit = min(limit, remaining) } @@ -85,7 +89,7 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd { } row, err := iter.Next(ctx) if err != nil { - return rowsFetchedMsg{err: err} + return rowsFetchedMsg{err: err, generation: generation} } rows = append(rows, row) } @@ -94,7 +98,7 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd { exhausted = true } - return rowsFetchedMsg{rows: rows, exhausted: exhausted} + return rowsFetchedMsg{rows: rows, exhausted: exhausted, generation: generation} } } } @@ -160,6 +164,9 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case rowsFetchedMsg: + if msg.generation != m.fetchGeneration { + return m, nil + } m.loading = false if msg.err != nil { m.err = msg.err @@ -345,12 +352,16 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { query := m.searchInput if query == "" { // Restore original state - if m.savedRows != nil { + if m.hasSearchState { + m.fetchGeneration++ m.rows = m.savedRows m.rowIter = m.savedIter m.exhausted = m.savedExhaust + m.loading = false + m.hasSearchState = false m.savedRows = nil m.savedIter = nil + m.savedExhaust = false m.cursor = 0 m.viewport.SetContent(m.renderContent()) m.viewport.GotoTop() @@ -358,12 +369,14 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } // Save current state - if m.savedRows == nil { + if !m.hasSearchState { + m.hasSearchState = true m.savedRows = m.rows m.savedIter = m.rowIter m.savedExhaust = m.exhausted } // Create new iterator with search + m.fetchGeneration++ m.rows = nil m.exhausted = false m.loading = false diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 6fc44ce8f49..bc46c382b51 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -273,7 +273,8 @@ func TestPaginatedSearchEnterAndRestore(t *testing.T) { assert.False(t, pm.searching) assert.True(t, searchCalled) assert.NotNil(t, cmd) - assert.NotNil(t, pm.savedRows) + assert.True(t, pm.hasSearchState) + assert.Equal(t, 1, pm.fetchGeneration) // Restore by submitting empty search pm.searching = true @@ -283,7 +284,60 @@ func TestPaginatedSearchEnterAndRestore(t *testing.T) { pm = result.(paginatedModel) assert.Equal(t, [][]string{{"original"}}, pm.rows) + assert.False(t, pm.hasSearchState) assert.Nil(t, pm.savedRows) + assert.Equal(t, 2, pm.fetchGeneration) +} + +func TestPaginatedSearchRestoreEmptyOriginalTable(t *testing.T) { + cfg := &TableConfig{ + Columns: []ColumnDef{ + {Header: "Name"}, + }, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, query string) RowIterator { + return &stringRowIterator{rows: [][]string{{"found:" + query}}} + }, + }, + } + + ctx := t.Context() + originalIter := &stringRowIterator{} + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: originalIter, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + exhausted: true, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + m.searching = true + m.searchInput = "test" + + result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) + pm := result.(paginatedModel) + + assert.NotNil(t, cmd) + assert.True(t, pm.hasSearchState) + assert.Nil(t, pm.savedRows) + assert.Equal(t, 1, pm.fetchGeneration) + + pm.searching = true + pm.searchInput = "" + pm.rows = [][]string{{"found:test"}} + result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) + pm = result.(paginatedModel) + + assert.Nil(t, pm.rows) + assert.Equal(t, originalIter, pm.rowIter) + assert.True(t, pm.exhausted) + assert.False(t, pm.hasSearchState) + assert.Equal(t, 2, pm.fetchGeneration) } func TestPaginatedSearchEscCancels(t *testing.T) { @@ -389,6 +443,28 @@ func TestMaybeFetchSetsLoadingAndPreventsDoubleFetch(t *testing.T) { assert.Nil(t, cmd2, "should not trigger second fetch while loading") } +func TestPaginatedIgnoresStaleFetchMessages(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + m.rows = [][]string{{"search", "1"}} + m.widths = []int{6, 1} + m.loading = true + m.fetchGeneration = 1 + + result, _ := m.Update(rowsFetchedMsg{ + rows: [][]string{{"stale", "2"}}, + exhausted: true, + generation: 0, + }) + pm := result.(paginatedModel) + + assert.Equal(t, [][]string{{"search", "1"}}, pm.rows) + assert.False(t, pm.exhausted) + assert.True(t, pm.loading) +} + func TestFetchCmdWithIterator(t *testing.T) { rows := make([][]string, 60) for i := range rows { @@ -406,6 +482,7 @@ func TestFetchCmdWithIterator(t *testing.T) { require.True(t, ok) assert.NoError(t, fetched.err) + assert.Equal(t, 0, fetched.generation) assert.Len(t, fetched.rows, fetchBatchSize) assert.False(t, fetched.exhausted, "iterator should have more rows") } @@ -422,6 +499,7 @@ func TestFetchCmdExhaustsSmallIterator(t *testing.T) { require.True(t, ok) assert.NoError(t, fetched.err) + assert.Equal(t, 0, fetched.generation) assert.Len(t, fetched.rows, 2) assert.True(t, fetched.exhausted, "small iterator should be exhausted") } From b7ca3d8de4dfc016d17d827452b3bc57cf51a37b Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 15:45:15 +0100 Subject: [PATCH 03/32] Add debounced live search to paginated TUI table Search input now triggers server-side filtering automatically after the user stops typing for 200ms, instead of waiting for Enter. This prevents redundant API calls on each keystroke while keeping the text input responsive. Enter still executes search immediately, bypassing the debounce. Uses Bubble Tea's tick-based message pattern with a sequence counter to discard stale debounce ticks when the user types additional characters before the delay expires. --- libs/tableview/paginated.go | 103 +++++++++++------ libs/tableview/paginated_test.go | 189 ++++++++++++++++++++++++++++++- 2 files changed, 254 insertions(+), 38 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 444f87e6646..7f89b2472bf 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -6,6 +6,7 @@ import ( "io" "strings" "text/tabwriter" + "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -15,6 +16,7 @@ const ( fetchBatchSize = 50 fetchThresholdFromBottom = 10 defaultMaxColumnWidth = 50 + searchDebounceDelay = 200 * time.Millisecond ) // rowsFetchedMsg carries newly fetched rows from the iterator. @@ -25,6 +27,12 @@ type rowsFetchedMsg struct { generation int } +// searchDebounceMsg fires after the debounce delay to trigger a search. +// The seq field is compared against the model's debounceSeq to discard stale ticks. +type searchDebounceMsg struct { + seq int +} + type paginatedModel struct { cfg *TableConfig headers []string @@ -51,6 +59,7 @@ type paginatedModel struct { // Search searching bool searchInput string + debounceSeq int hasSearchState bool savedRows [][]string savedIter RowIterator @@ -192,6 +201,12 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case searchDebounceMsg: + if msg.seq != m.debounceSeq || !m.searching { + return m, nil + } + return m.executeSearch(m.searchInput) + case tea.KeyMsg: if m.searching { return m.updateSearch(msg) @@ -344,45 +359,61 @@ func maybeFetch(m paginatedModel) (paginatedModel, tea.Cmd) { return m, nil } +// scheduleSearchDebounce returns a command that sends a searchDebounceMsg after the delay. +func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { + m.debounceSeq++ + seq := m.debounceSeq + return tea.Tick(searchDebounceDelay, func(_ time.Time) tea.Msg { + return searchDebounceMsg{seq: seq} + }) +} + +// executeSearch triggers a server-side search for the given query. +// If query is empty, it restores the original (pre-search) state. +func (m paginatedModel) executeSearch(query string) (tea.Model, tea.Cmd) { + if query == "" { + if m.hasSearchState { + m.fetchGeneration++ + m.rows = m.savedRows + m.rowIter = m.savedIter + m.exhausted = m.savedExhaust + m.loading = false + m.hasSearchState = false + m.savedRows = nil + m.savedIter = nil + m.savedExhaust = false + m.cursor = 0 + if m.ready { + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoTop() + } + } + return m, nil + } + + if !m.hasSearchState { + m.hasSearchState = true + m.savedRows = m.rows + m.savedIter = m.rowIter + m.savedExhaust = m.exhausted + } + + m.fetchGeneration++ + m.rows = nil + m.exhausted = false + m.loading = false + m.cursor = 0 + m.rowIter = m.makeSearchIter(query) + return m, m.makeFetchCmd(m) +} + func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": m.searching = false m.viewport.Height++ - query := m.searchInput - if query == "" { - // Restore original state - if m.hasSearchState { - m.fetchGeneration++ - m.rows = m.savedRows - m.rowIter = m.savedIter - m.exhausted = m.savedExhaust - m.loading = false - m.hasSearchState = false - m.savedRows = nil - m.savedIter = nil - m.savedExhaust = false - m.cursor = 0 - m.viewport.SetContent(m.renderContent()) - m.viewport.GotoTop() - } - return m, nil - } - // Save current state - if !m.hasSearchState { - m.hasSearchState = true - m.savedRows = m.rows - m.savedIter = m.rowIter - m.savedExhaust = m.exhausted - } - // Create new iterator with search - m.fetchGeneration++ - m.rows = nil - m.exhausted = false - m.loading = false - m.cursor = 0 - m.rowIter = m.makeSearchIter(query) - return m, m.makeFetchCmd(m) + // Execute final search immediately (bypass debounce). + return m.executeSearch(m.searchInput) case "esc", "ctrl+c": m.searching = false m.searchInput = "" @@ -392,12 +423,12 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.searchInput) > 0 { m.searchInput = m.searchInput[:len(m.searchInput)-1] } - return m, nil + return m, m.scheduleSearchDebounce() default: if len(msg.String()) == 1 || msg.Type == tea.KeyRunes { m.searchInput += msg.String() } - return m, nil + return m, m.scheduleSearchDebounce() } } diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index bc46c382b51..d956062376f 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -359,10 +359,11 @@ func TestPaginatedSearchBackspace(t *testing.T) { m.searching = true m.searchInput = "abc" - result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyBackspace}) + result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyBackspace}) pm := result.(paginatedModel) assert.Equal(t, "ab", pm.searchInput) + assert.NotNil(t, cmd, "backspace should schedule a debounce tick") } func TestPaginatedSearchTyping(t *testing.T) { @@ -370,10 +371,11 @@ func TestPaginatedSearchTyping(t *testing.T) { m.searching = true m.searchInput = "" - result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) pm := result.(paginatedModel) assert.Equal(t, "a", pm.searchInput) + assert.NotNil(t, cmd, "typing should schedule a debounce tick") } func TestPaginatedRenderFooterExhausted(t *testing.T) { @@ -515,3 +517,186 @@ func TestPaginatedRenderFooterWithSearch(t *testing.T) { footer := m.renderFooter() assert.Contains(t, footer, "/ search") } + +func TestPaginatedSearchDebounceIncrementsSeq(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "" + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + pm := result.(paginatedModel) + assert.Equal(t, 1, pm.debounceSeq) + + result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) + pm = result.(paginatedModel) + assert.Equal(t, 2, pm.debounceSeq) +} + +func TestPaginatedSearchDebounceStaleTickIgnored(t *testing.T) { + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + t.Error("search should not be called for stale debounce") + return &stringRowIterator{} + }, + }, + } + + ctx := t.Context() + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + searching: true, + searchInput: "test", + debounceSeq: 5, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Send a stale debounce message (seq=3, current=5). + result, cmd := m.Update(searchDebounceMsg{seq: 3}) + pm := result.(paginatedModel) + + assert.Nil(t, cmd) + assert.Nil(t, pm.rows, "rows should not change for stale debounce") +} + +func TestPaginatedSearchDebounceCurrentSeqTriggers(t *testing.T) { + searchCalled := false + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, query string) RowIterator { + searchCalled = true + assert.Equal(t, "hello", query) + return &stringRowIterator{rows: [][]string{{"found"}}} + }, + }, + } + + ctx := t.Context() + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: [][]string{{"original"}}}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + searching: true, + searchInput: "hello", + debounceSeq: 3, + rows: [][]string{{"original"}}, + widths: []int{8}, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Send a matching debounce message (seq=3). + result, cmd := m.Update(searchDebounceMsg{seq: 3}) + pm := result.(paginatedModel) + + assert.True(t, searchCalled) + assert.NotNil(t, cmd, "should return fetch command") + assert.True(t, pm.hasSearchState) + assert.Equal(t, [][]string{{"original"}}, pm.savedRows) +} + +func TestPaginatedSearchDebounceIgnoredWhenNotSearching(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = false + m.debounceSeq = 1 + + result, cmd := m.Update(searchDebounceMsg{seq: 1}) + pm := result.(paginatedModel) + + assert.Nil(t, cmd) + assert.False(t, pm.searching) +} + +func TestPaginatedSearchEnterBypassesDebounce(t *testing.T) { + searchCalled := false + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, query string) RowIterator { + searchCalled = true + return &stringRowIterator{rows: [][]string{{"found:" + query}}} + }, + }, + } + + ctx := t.Context() + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: [][]string{{"original"}}}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + rows: [][]string{{"original"}}, + widths: []int{8}, + searching: true, + searchInput: "test", + debounceSeq: 5, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) + pm := result.(paginatedModel) + + assert.True(t, searchCalled, "enter should trigger search immediately") + assert.NotNil(t, cmd) + assert.False(t, pm.searching, "search mode should be exited") +} + +func TestPaginatedSearchDebounceEmptyQueryRestores(t *testing.T) { + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + return &stringRowIterator{} + }, + }, + } + + ctx := t.Context() + originalIter := &stringRowIterator{rows: [][]string{{"original"}}} + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: [][]string{{"search-result"}}}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + searching: true, + searchInput: "", + debounceSeq: 2, + hasSearchState: true, + savedRows: [][]string{{"original"}}, + savedIter: originalIter, + savedExhaust: true, + rows: [][]string{{"search-result"}}, + widths: []int{13}, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Debounce fires with empty search input, should restore. + result, cmd := m.Update(searchDebounceMsg{seq: 2}) + pm := result.(paginatedModel) + + assert.Nil(t, cmd) + assert.Equal(t, [][]string{{"original"}}, pm.rows) + assert.Equal(t, originalIter, pm.rowIter) + assert.False(t, pm.hasSearchState) +} From aa4cdb060e0d121bf3eba72fce8f7ea6b53b3f00 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 16:03:29 +0100 Subject: [PATCH 04/32] Fix search/fetch race conditions, esc restore, and error propagation Fix four issues in the paginated TUI: 1. Entering search mode now sets loading=true to prevent maybeFetch from starting new fetches against the shared iterator while in search mode. In-flight fetches are discarded via the generation check. 2. executeSearch sets loading=true (was false) to prevent overlapping fetch commands when a quick scroll triggers maybeFetch before the first search fetch returns. 3. Pressing esc to close search now restores savedRows, savedIter, and savedExhaust (same as clearing the query via enter with empty input). 4. RenderIterator now checks the final model for application-level errors via the new FinalModel interface, since tea.Program.Run() only returns framework errors. --- libs/cmdio/render.go | 12 ++- libs/tableview/paginated.go | 34 +++++++- libs/tableview/paginated_test.go | 141 +++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index e3b87fa146d..f4289dbfd72 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -278,8 +278,16 @@ func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error { p := tableview.NewPaginatedProgram(ctx, c.out, cfg, iter, maxItems) c.acquireTeaProgram(p) defer c.releaseTeaProgram() - _, err := p.Run() - return err + finalModel, err := p.Run() + if err != nil { + return err + } + if pm, ok := finalModel.(tableview.FinalModel); ok { + if modelErr := pm.Err(); modelErr != nil { + return modelErr + } + } + return nil } } } diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 7f89b2472bf..21872ad059c 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -19,6 +19,13 @@ const ( searchDebounceDelay = 200 * time.Millisecond ) +// FinalModel is implemented by the paginated TUI model to expose errors +// that occurred during data fetching. tea.Program.Run() only returns +// framework errors, not application-level errors stored in the model. +type FinalModel interface { + Err() error +} + // rowsFetchedMsg carries newly fetched rows from the iterator. type rowsFetchedMsg struct { rows [][]string @@ -148,6 +155,11 @@ func RunPaginated(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIt return err } +// Err returns any error that occurred during data fetching. +func (m paginatedModel) Err() error { + return m.err +} + func (m paginatedModel) Init() tea.Cmd { return m.makeFetchCmd(m) } @@ -301,6 +313,10 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cfg.Search != nil { m.searching = true m.searchInput = "" + // Prevent maybeFetch from starting new fetches against the old iterator + // while we're in search mode. Any in-flight fetch will be discarded + // via generation check when it returns. + m.loading = true m.viewport.Height-- return m, nil } @@ -401,7 +417,7 @@ func (m paginatedModel) executeSearch(query string) (tea.Model, tea.Cmd) { m.fetchGeneration++ m.rows = nil m.exhausted = false - m.loading = false + m.loading = true m.cursor = 0 m.rowIter = m.makeSearchIter(query) return m, m.makeFetchCmd(m) @@ -418,6 +434,22 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searching = false m.searchInput = "" m.viewport.Height++ + if m.hasSearchState { + m.fetchGeneration++ + m.rows = m.savedRows + m.rowIter = m.savedIter + m.exhausted = m.savedExhaust + m.loading = false + m.hasSearchState = false + m.savedRows = nil + m.savedIter = nil + m.savedExhaust = false + m.cursor = 0 + if m.ready { + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoTop() + } + } return m, nil case "backspace": if len(m.searchInput) > 0 { diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index d956062376f..0e2e6fb6ea0 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -658,6 +658,147 @@ func TestPaginatedSearchEnterBypassesDebounce(t *testing.T) { assert.False(t, pm.searching, "search mode should be exited") } +func TestPaginatedSearchModeBlocksFetch(t *testing.T) { + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + return &stringRowIterator{} + }, + }, + } + + ctx := t.Context() + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: make([][]string, 20)}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + rows: make([][]string, 15), + widths: []int{4}, + ready: true, + loading: false, + exhausted: false, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Enter search mode via "/" key. + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + pm := result.(paginatedModel) + + assert.True(t, pm.searching) + assert.True(t, pm.loading, "entering search mode should set loading=true to block maybeFetch") + + // Verify maybeFetch is blocked. + pm.cursor = len(pm.rows) - 1 + pm, cmd := maybeFetch(pm) + assert.Nil(t, cmd, "maybeFetch should not trigger while loading is true") +} + +func TestPaginatedSearchExecuteSetsLoading(t *testing.T) { + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + return &stringRowIterator{rows: [][]string{{"result"}}} + }, + }, + } + + ctx := t.Context() + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: [][]string{{"original"}}}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + rows: [][]string{{"original"}}, + widths: []int{8}, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + result, cmd := m.executeSearch("test") + pm := result.(paginatedModel) + + assert.NotNil(t, cmd) + assert.True(t, pm.loading, "executeSearch should set loading=true to prevent overlapping fetches") +} + +func TestPaginatedSearchEscRestoresData(t *testing.T) { + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + return &stringRowIterator{rows: [][]string{{"search-result"}}} + }, + }, + } + + ctx := t.Context() + originalIter := &stringRowIterator{rows: [][]string{{"original"}}} + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: [][]string{{"search-result"}}}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + searching: true, + searchInput: "test", + hasSearchState: true, + savedRows: [][]string{{"original"}}, + savedIter: originalIter, + savedExhaust: true, + rows: [][]string{{"search-result"}}, + widths: []int{13}, + ready: true, + fetchGeneration: 2, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm := result.(paginatedModel) + + assert.False(t, pm.searching) + assert.Equal(t, "", pm.searchInput) + assert.Equal(t, [][]string{{"original"}}, pm.rows) + assert.Equal(t, originalIter, pm.rowIter) + assert.True(t, pm.exhausted) + assert.False(t, pm.hasSearchState) + assert.Nil(t, pm.savedRows) + assert.Equal(t, 3, pm.fetchGeneration) + assert.Equal(t, 0, pm.cursor) +} + +func TestPaginatedSearchEscWithNoSearchStateDoesNothing(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "partial" + m.rows = [][]string{{"data"}} + m.viewport.Height = 20 + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm := result.(paginatedModel) + + assert.False(t, pm.searching) + assert.Equal(t, [][]string{{"data"}}, pm.rows, "rows should not change when there is no saved search state") +} + +func TestPaginatedModelErr(t *testing.T) { + m := newTestModel(t, nil, 0) + assert.Nil(t, m.Err()) + + m.err = errors.New("test error") + assert.Equal(t, "test error", m.Err().Error()) +} + func TestPaginatedSearchDebounceEmptyQueryRestores(t *testing.T) { cfg := &TableConfig{ Columns: []ColumnDef{{Header: "Name"}}, From dbd224106946bc331972e346cc93a05369e1e35a Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 17:11:04 +0100 Subject: [PATCH 05/32] Fix lint: use assert.NoError per testifylint rule --- libs/tableview/paginated_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 0e2e6fb6ea0..c7add1aacd3 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -793,7 +793,7 @@ func TestPaginatedSearchEscWithNoSearchStateDoesNothing(t *testing.T) { func TestPaginatedModelErr(t *testing.T) { m := newTestModel(t, nil, 0) - assert.Nil(t, m.Err()) + assert.NoError(t, m.Err()) m.err = errors.New("test error") assert.Equal(t, "test error", m.Err().Error()) From 229baa9086c2864ecfbd2659a525cb7ee9865a43 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:14:45 +0100 Subject: [PATCH 06/32] Fix UTF-8 backspace corruption and fragile typing check in search Backspace in search used byte truncation which corrupts multi-byte UTF-8 characters (accented chars, CJK). Use utf8.DecodeLastRuneInString to remove the last rune correctly. Also replace the fragile `len(msg.String()) == 1` byte-length check with `msg.Type == tea.KeyRunes` for detecting printable input. Co-authored-by: Isaac --- libs/tableview/paginated.go | 9 ++++++--- libs/tableview/tableview.go | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 21872ad059c..69cc5369c32 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -7,6 +7,7 @@ import ( "strings" "text/tabwriter" "time" + "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -453,14 +454,16 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "backspace": if len(m.searchInput) > 0 { - m.searchInput = m.searchInput[:len(m.searchInput)-1] + _, size := utf8.DecodeLastRuneInString(m.searchInput) + m.searchInput = m.searchInput[:len(m.searchInput)-size] } return m, m.scheduleSearchDebounce() default: - if len(msg.String()) == 1 || msg.Type == tea.KeyRunes { + if msg.Type == tea.KeyRunes { m.searchInput += msg.String() + return m, m.scheduleSearchDebounce() } - return m, m.scheduleSearchDebounce() + return m, nil } } diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index e6a40685e29..32088323564 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "strings" + "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -179,12 +180,12 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "backspace": if len(m.searchInput) > 0 { - m.searchInput = m.searchInput[:len(m.searchInput)-1] + _, size := utf8.DecodeLastRuneInString(m.searchInput) + m.searchInput = m.searchInput[:len(m.searchInput)-size] } return m, nil default: - // Only accept printable characters. - if len(msg.String()) == 1 || msg.Type == tea.KeyRunes { + if msg.Type == tea.KeyRunes { m.searchInput += msg.String() } return m, nil From 78e672071429388307ac41427d8c4cbe95bcaa83 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:15:08 +0100 Subject: [PATCH 07/32] Fix RunPaginated silently dropping model-level fetch errors RunPaginated only returned tea.Program.Run() errors but ignored FinalModel.Err(). This means application-level errors stored in the model (e.g. network errors during fetch) were silently swallowed. Now checks FinalModel.Err() after Run(), matching how render.go handles it. Co-authored-by: Isaac --- libs/tableview/paginated.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 69cc5369c32..eede19a6027 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -152,8 +152,16 @@ func NewPaginatedProgram(ctx context.Context, w io.Writer, cfg *TableConfig, ite // RunPaginated launches the paginated TUI table. func RunPaginated(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIterator, maxItems int) error { p := NewPaginatedProgram(ctx, w, cfg, iter, maxItems) - _, err := p.Run() - return err + finalModel, err := p.Run() + if err != nil { + return err + } + if pm, ok := finalModel.(FinalModel); ok { + if modelErr := pm.Err(); modelErr != nil { + return modelErr + } + } + return nil } // Err returns any error that occurred during data fetching. From 78b56b75c06d9c63fd8e92eeeeca9879da650d56 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:15:27 +0100 Subject: [PATCH 08/32] Recompute column widths on every batch, not just the first Column widths were only computed when isFirstBatch was true. Subsequent batches with wider values would get silently truncated. Now computeWidths() runs whenever new rows arrive. Co-authored-by: Isaac --- libs/tableview/paginated.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index eede19a6027..7fb652dcf95 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -212,9 +212,11 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.exhausted = true } - if isFirstBatch && len(m.rows) > 0 { + if len(m.rows) > 0 { m.computeWidths() - m.cursor = 0 + if isFirstBatch { + m.cursor = 0 + } } if m.ready { From 96e614a5c7abb17ae812cfaaace654ab1a34ed8d Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:16:03 +0100 Subject: [PATCH 09/32] Extract restorePreSearchState to fix DRY violation and stuck loading The esc-cancel path duplicated the restore logic from executeSearch(""), creating a maintenance risk. Extracted into restorePreSearchState(). This also fixes a bug where entering search mode (which sets loading=true to block maybeFetch) then immediately pressing Esc/Enter with an empty query would leave loading=true permanently. restorePreSearchState now unconditionally resets loading=false, even when no search was executed. Co-authored-by: Isaac --- libs/tableview/paginated.go | 56 ++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 7fb652dcf95..def2c4607e9 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -395,26 +395,33 @@ func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { }) } +// restorePreSearchState restores the original (pre-search) data and resets +// loading so that maybeFetch is unblocked. Safe to call even when there is +// no saved search state. +func (m *paginatedModel) restorePreSearchState() { + if m.hasSearchState { + m.fetchGeneration++ + m.rows = m.savedRows + m.rowIter = m.savedIter + m.exhausted = m.savedExhaust + m.hasSearchState = false + m.savedRows = nil + m.savedIter = nil + m.savedExhaust = false + } + m.loading = false + m.cursor = 0 + if m.ready { + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoTop() + } +} + // executeSearch triggers a server-side search for the given query. // If query is empty, it restores the original (pre-search) state. func (m paginatedModel) executeSearch(query string) (tea.Model, tea.Cmd) { if query == "" { - if m.hasSearchState { - m.fetchGeneration++ - m.rows = m.savedRows - m.rowIter = m.savedIter - m.exhausted = m.savedExhaust - m.loading = false - m.hasSearchState = false - m.savedRows = nil - m.savedIter = nil - m.savedExhaust = false - m.cursor = 0 - if m.ready { - m.viewport.SetContent(m.renderContent()) - m.viewport.GotoTop() - } - } + m.restorePreSearchState() return m, nil } @@ -445,22 +452,7 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searching = false m.searchInput = "" m.viewport.Height++ - if m.hasSearchState { - m.fetchGeneration++ - m.rows = m.savedRows - m.rowIter = m.savedIter - m.exhausted = m.savedExhaust - m.loading = false - m.hasSearchState = false - m.savedRows = nil - m.savedIter = nil - m.savedExhaust = false - m.cursor = 0 - if m.ready { - m.viewport.SetContent(m.renderContent()) - m.viewport.GotoTop() - } - } + m.restorePreSearchState() return m, nil case "backspace": if len(m.searchInput) > 0 { From 3143a722e696d984390b1a83ec750e82e9e0995a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:16:24 +0100 Subject: [PATCH 10/32] Show fetch errors in footer instead of replacing loaded data When a fetch error occurred after rows were already loaded, View() replaced the entire table with just "Error: ...". Now the error-only view is shown only when there are zero rows. When rows exist, the error is displayed in the footer while keeping the table visible. Co-authored-by: Isaac --- libs/tableview/paginated.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index def2c4607e9..843b3823cf7 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -479,11 +479,14 @@ func (m paginatedModel) View() string { if len(m.rows) == 0 && m.exhausted { return "No results found." } - if m.err != nil { + if m.err != nil && len(m.rows) == 0 { return fmt.Sprintf("Error: %v", m.err) } footer := m.renderFooter() + if m.err != nil { + footer = footerStyle.Render(fmt.Sprintf("Error: %v", m.err)) + } return m.viewport.View() + "\n" + footer } From a548b7951d5d1e0f1a432dc517c4b8fde59ceb5a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:42:30 +0100 Subject: [PATCH 11/32] Fix sticky errors, missing space input, and search/fetch race in TUI table Clear m.err on successful fetch so transient errors don't persist for the session. Handle tea.KeySpace in both search handlers so queries with spaces work. Bump fetchGeneration unconditionally in restorePreSearchState so canceling search before any query executes still discards in-flight fetches. Co-authored-by: Isaac --- libs/tableview/paginated.go | 7 ++++- libs/tableview/paginated_test.go | 48 ++++++++++++++++++++++++++++++++ libs/tableview/tableview.go | 2 ++ libs/tableview/tableview_test.go | 13 +++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 843b3823cf7..92c83a76eb2 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -202,6 +202,7 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err return m, nil } + m.err = nil isFirstBatch := len(m.rows) == 0 m.rows = append(m.rows, msg.rows...) @@ -399,8 +400,8 @@ func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { // loading so that maybeFetch is unblocked. Safe to call even when there is // no saved search state. func (m *paginatedModel) restorePreSearchState() { + m.fetchGeneration++ if m.hasSearchState { - m.fetchGeneration++ m.rows = m.savedRows m.rowIter = m.savedIter m.exhausted = m.savedExhaust @@ -465,6 +466,10 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searchInput += msg.String() return m, m.scheduleSearchDebounce() } + if msg.Type == tea.KeySpace { + m.searchInput += " " + return m, m.scheduleSearchDebounce() + } return m, nil } } diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index c7add1aacd3..ffff274e2a5 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -799,6 +799,54 @@ func TestPaginatedModelErr(t *testing.T) { assert.Equal(t, "test error", m.Err().Error()) } +func TestPaginatedSearchSpaceCharacterInput(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "my" + + result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeySpace}) + pm := result.(paginatedModel) + + assert.Equal(t, "my ", pm.searchInput) + assert.NotNil(t, cmd, "space should schedule a debounce tick") +} + +func TestPaginatedFetchErrorClearedOnSuccess(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Simulate a fetch error. + errMsg := rowsFetchedMsg{err: errors.New("transient network error")} + result, _ := m.Update(errMsg) + pm := result.(paginatedModel) + require.Error(t, pm.err) + + // Simulate a successful fetch afterward. + successMsg := rowsFetchedMsg{rows: [][]string{{"alice", "30"}}, exhausted: true} + result, _ = pm.Update(successMsg) + pm = result.(paginatedModel) + + assert.NoError(t, pm.err, "error should be cleared after successful fetch") + assert.Len(t, pm.rows, 1) +} + +func TestPaginatedSearchEscWithoutSearchStateBumpsFetchGeneration(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "" + m.loading = true + m.viewport.Height = 20 + m.fetchGeneration = 5 + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm := result.(paginatedModel) + + assert.Equal(t, 6, pm.fetchGeneration, "fetchGeneration should be bumped even without search state") + assert.False(t, pm.loading) +} + func TestPaginatedSearchDebounceEmptyQueryRestores(t *testing.T) { cfg := &TableConfig{ Columns: []ColumnDef{{Header: "Name"}}, diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 32088323564..039835fd2a2 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -187,6 +187,8 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { default: if msg.Type == tea.KeyRunes { m.searchInput += msg.String() + } else if msg.Type == tea.KeySpace { + m.searchInput += " " } return m, nil } diff --git a/libs/tableview/tableview_test.go b/libs/tableview/tableview_test.go index c761a9cf007..d0ad651953e 100644 --- a/libs/tableview/tableview_test.go +++ b/libs/tableview/tableview_test.go @@ -3,6 +3,7 @@ package tableview import ( "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -55,6 +56,18 @@ func TestFindMatchesEmptyQuery(t *testing.T) { assert.Nil(t, matches) } +func TestSearchSpaceCharacterInput(t *testing.T) { + m := model{ + searching: true, + searchInput: "my", + } + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeySpace}) + rm := result.(model) + + assert.Equal(t, "my ", rm.searchInput) +} + func TestHighlightSearchEmptyQuery(t *testing.T) { result := highlightSearch("hello alice", "") assert.Equal(t, "hello alice", result) From 43c526d545b5f453baecae98b406ff1bbc7514b3 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:56:58 +0100 Subject: [PATCH 12/32] Fix search cancel silently dropping in-flight fetch rows Only bump fetchGeneration when hasSearchState is true (switching from search iterator back to original). When hasSearchState is false (user opened search UI but never executed a query), the original iterator and generation are still valid, so in-flight fetches must be accepted. Co-authored-by: Isaac --- libs/tableview/paginated.go | 9 ++--- libs/tableview/paginated_test.go | 59 ++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 92c83a76eb2..3358420002a 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -325,9 +325,8 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cfg.Search != nil { m.searching = true m.searchInput = "" - // Prevent maybeFetch from starting new fetches against the old iterator - // while we're in search mode. Any in-flight fetch will be discarded - // via generation check when it returns. + // Block maybeFetch while search UI is visible. In-flight fetches from + // the original iterator will still be accepted (same generation). m.loading = true m.viewport.Height-- return m, nil @@ -400,8 +399,10 @@ func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { // loading so that maybeFetch is unblocked. Safe to call even when there is // no saved search state. func (m *paginatedModel) restorePreSearchState() { - m.fetchGeneration++ if m.hasSearchState { + // Bump generation to discard any in-flight search fetch, since we're + // switching back to the original iterator. + m.fetchGeneration++ m.rows = m.savedRows m.rowIter = m.savedIter m.exhausted = m.savedExhaust diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index ffff274e2a5..a994b4561ea 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -832,7 +832,7 @@ func TestPaginatedFetchErrorClearedOnSuccess(t *testing.T) { assert.Len(t, pm.rows, 1) } -func TestPaginatedSearchEscWithoutSearchStateBumpsFetchGeneration(t *testing.T) { +func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { m := newTestModel(t, nil, 0) m.searching = true m.searchInput = "" @@ -843,10 +843,65 @@ func TestPaginatedSearchEscWithoutSearchStateBumpsFetchGeneration(t *testing.T) result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) pm := result.(paginatedModel) - assert.Equal(t, 6, pm.fetchGeneration, "fetchGeneration should be bumped even without search state") + assert.Equal(t, 5, pm.fetchGeneration, "fetchGeneration should NOT be bumped without search state") assert.False(t, pm.loading) } +func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { + ctx := t.Context() + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + return &stringRowIterator{} + }, + }, + } + + iter := &stringRowIterator{rows: [][]string{{"row1"}, {"row2"}}} + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: iter, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + widths: []int{4}, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Simulate: a fetch is in-flight at generation 0. + m.loading = true + startGen := m.fetchGeneration + + // User enters search mode (pressing "/"). + m.searching = true + m.searchInput = "" + + // User immediately cancels with esc (no search executed). + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm := result.(paginatedModel) + + // Generation must be unchanged so the in-flight fetch is accepted. + assert.Equal(t, startGen, pm.fetchGeneration) + assert.False(t, pm.hasSearchState) + + // Simulate the in-flight fetch completing with the original generation. + fetched := rowsFetchedMsg{ + rows: [][]string{{"fetched-row"}}, + exhausted: true, + generation: startGen, + } + result2, _ := pm.Update(fetched) + pm2 := result2.(paginatedModel) + + // The rows must be accepted, not silently dropped. + assert.Equal(t, [][]string{{"fetched-row"}}, pm2.rows) + assert.True(t, pm2.exhausted) +} + func TestPaginatedSearchDebounceEmptyQueryRestores(t *testing.T) { cfg := &TableConfig{ Columns: []ColumnDef{{Header: "Name"}}, From 428195334e51f153727b35d12376ee925dcb89ed Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 21:08:39 +0100 Subject: [PATCH 13/32] Fix search/fetch race: preserve loading state when no search was active In restorePreSearchState(), clearing loading=false unconditionally allowed maybeFetch() to queue a second concurrent fetch while the original was still in-flight. Move m.loading=false inside the hasSearchState branch so it only resets when switching back from a search iterator. When no search was active, the original fetch's loading flag is preserved until it returns naturally. Co-authored-by: Isaac --- libs/tableview/paginated.go | 2 +- libs/tableview/paginated_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 3358420002a..72ae8d6b3fd 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -410,8 +410,8 @@ func (m *paginatedModel) restorePreSearchState() { m.savedRows = nil m.savedIter = nil m.savedExhaust = false + m.loading = false } - m.loading = false m.cursor = 0 if m.ready { m.viewport.SetContent(m.renderContent()) diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index a994b4561ea..56b78d2731c 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -844,7 +844,7 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { pm := result.(paginatedModel) assert.Equal(t, 5, pm.fetchGeneration, "fetchGeneration should NOT be bumped without search state") - assert.False(t, pm.loading) + assert.True(t, pm.loading, "loading should be preserved when hasSearchState is false and a fetch was in-flight") } func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { From b3a6a5398d85bd75297284bbeb490a41142622ef Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 21:21:13 +0100 Subject: [PATCH 14/32] Fix loading state stuck after canceling search without executing Save the pre-search loading state when entering search mode via "/", and restore it when the user cancels with "esc" before executing a search. Previously, loading was set to true on search entry but never cleared on exit when no search had been performed, permanently blocking maybeFetch and preventing further pagination. Co-authored-by: Isaac --- libs/tableview/paginated.go | 6 ++++ libs/tableview/paginated_test.go | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 72ae8d6b3fd..99e68af0470 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -72,6 +72,7 @@ type paginatedModel struct { savedRows [][]string savedIter RowIterator savedExhaust bool + savedLoading bool // Limits maxItems int @@ -327,6 +328,7 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searchInput = "" // Block maybeFetch while search UI is visible. In-flight fetches from // the original iterator will still be accepted (same generation). + m.savedLoading = m.loading m.loading = true m.viewport.Height-- return m, nil @@ -411,6 +413,10 @@ func (m *paginatedModel) restorePreSearchState() { m.savedIter = nil m.savedExhaust = false m.loading = false + } else { + // No search was executed; restore the loading state from before + // entering search mode so maybeFetch isn't permanently blocked. + m.loading = m.savedLoading } m.cursor = 0 if m.ready { diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 56b78d2731c..77a411a4c40 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -836,6 +836,7 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { m := newTestModel(t, nil, 0) m.searching = true m.searchInput = "" + m.savedLoading = true // fetch was in-flight before entering search m.loading = true m.viewport.Height = 20 m.fetchGeneration = 5 @@ -847,6 +848,52 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { assert.True(t, pm.loading, "loading should be preserved when hasSearchState is false and a fetch was in-flight") } +func TestPaginatedSearchEscWithoutExecutingRestoresLoading(t *testing.T) { + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + return &stringRowIterator{} + }, + }, + } + + ctx := t.Context() + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: make([][]string, 20)}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + rows: make([][]string, 15), + widths: []int{4}, + ready: true, + loading: false, + exhausted: false, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Enter search mode via "/". + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + pm := result.(paginatedModel) + assert.True(t, pm.loading, "loading should be true while in search mode") + assert.False(t, pm.savedLoading, "savedLoading should capture the pre-search value (false)") + + // Cancel immediately with esc (no search executed). + result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm = result.(paginatedModel) + + assert.False(t, pm.searching) + assert.False(t, pm.loading, "loading should be restored to false after esc without search") + + // Verify maybeFetch can fire again. + pm.cursor = len(pm.rows) - 1 + pm, cmd := maybeFetch(pm) + assert.NotNil(t, cmd, "maybeFetch should trigger after loading is restored") +} + func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { ctx := t.Context() cfg := &TableConfig{ From 7b4c0eed01a73d49f37ebfd33ef4b45800934df2 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 21:30:33 +0100 Subject: [PATCH 15/32] Separate search and loading concerns in paginated model The loading flag was overloaded to both indicate "fetch in-flight" and "block fetches during search UI". This caused edge cases with save/restore. Instead, maybeFetch now checks the searching flag directly, and loading is managed purely by fetch start/complete. Co-authored-by: Isaac --- libs/tableview/paginated.go | 12 ++---------- libs/tableview/paginated_test.go | 31 ++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 99e68af0470..2ae62d846ac 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -72,7 +72,7 @@ type paginatedModel struct { savedRows [][]string savedIter RowIterator savedExhaust bool - savedLoading bool + // Limits maxItems int @@ -326,10 +326,6 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cfg.Search != nil { m.searching = true m.searchInput = "" - // Block maybeFetch while search UI is visible. In-flight fetches from - // the original iterator will still be accepted (same generation). - m.savedLoading = m.loading - m.loading = true m.viewport.Height-- return m, nil } @@ -378,7 +374,7 @@ func (m *paginatedModel) moveCursor(delta int) { } func maybeFetch(m paginatedModel) (paginatedModel, tea.Cmd) { - if m.loading || m.exhausted { + if m.loading || m.exhausted || m.searching { return m, nil } if len(m.rows)-m.cursor <= fetchThresholdFromBottom { @@ -413,10 +409,6 @@ func (m *paginatedModel) restorePreSearchState() { m.savedIter = nil m.savedExhaust = false m.loading = false - } else { - // No search was executed; restore the loading state from before - // entering search mode so maybeFetch isn't permanently blocked. - m.loading = m.savedLoading } m.cursor = 0 if m.ready { diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 77a411a4c40..0b683aef1b5 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -224,6 +224,16 @@ func TestPaginatedMaybeFetchNotTriggeredWhenLoading(t *testing.T) { assert.Nil(t, cmd) } +func TestPaginatedMaybeFetchNotTriggeredWhenSearching(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = make([][]string, 15) + m.cursor = 10 + m.searching = true + + _, cmd := maybeFetch(m) + assert.Nil(t, cmd) +} + func TestPaginatedMaybeFetchNotTriggeredWhenFarFromBottom(t *testing.T) { m := newTestModel(t, nil, 0) m.rows = make([][]string, 50) @@ -690,12 +700,12 @@ func TestPaginatedSearchModeBlocksFetch(t *testing.T) { pm := result.(paginatedModel) assert.True(t, pm.searching) - assert.True(t, pm.loading, "entering search mode should set loading=true to block maybeFetch") + assert.False(t, pm.loading, "entering search mode should not overload loading flag") - // Verify maybeFetch is blocked. + // Verify maybeFetch is blocked by the searching flag. pm.cursor = len(pm.rows) - 1 pm, cmd := maybeFetch(pm) - assert.Nil(t, cmd, "maybeFetch should not trigger while loading is true") + assert.Nil(t, cmd, "maybeFetch should not trigger while searching is true") } func TestPaginatedSearchExecuteSetsLoading(t *testing.T) { @@ -836,8 +846,7 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { m := newTestModel(t, nil, 0) m.searching = true m.searchInput = "" - m.savedLoading = true // fetch was in-flight before entering search - m.loading = true + m.loading = true // fetch was in-flight before entering search m.viewport.Height = 20 m.fetchGeneration = 5 @@ -848,7 +857,7 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { assert.True(t, pm.loading, "loading should be preserved when hasSearchState is false and a fetch was in-flight") } -func TestPaginatedSearchEscWithoutExecutingRestoresLoading(t *testing.T) { +func TestPaginatedSearchEscWithoutExecutingUnblocksFetch(t *testing.T) { cfg := &TableConfig{ Columns: []ColumnDef{{Header: "Name"}}, Search: &SearchConfig{ @@ -878,20 +887,20 @@ func TestPaginatedSearchEscWithoutExecutingRestoresLoading(t *testing.T) { // Enter search mode via "/". result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) pm := result.(paginatedModel) - assert.True(t, pm.loading, "loading should be true while in search mode") - assert.False(t, pm.savedLoading, "savedLoading should capture the pre-search value (false)") + assert.True(t, pm.searching) + assert.False(t, pm.loading, "loading should not be overloaded by search mode") // Cancel immediately with esc (no search executed). result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) pm = result.(paginatedModel) assert.False(t, pm.searching) - assert.False(t, pm.loading, "loading should be restored to false after esc without search") + assert.False(t, pm.loading, "loading should remain false after esc") - // Verify maybeFetch can fire again. + // Verify maybeFetch can fire again (searching=false, loading=false). pm.cursor = len(pm.rows) - 1 pm, cmd := maybeFetch(pm) - assert.NotNil(t, cmd, "maybeFetch should trigger after loading is restored") + assert.NotNil(t, cmd, "maybeFetch should trigger after search mode is exited") } func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { From aaf150e70e8540a95af0408880a97f0a78e9171a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 22:27:15 +0100 Subject: [PATCH 16/32] Fix lint: remove extra blank line, convert if/else to switch Co-authored-by: Isaac --- libs/tableview/paginated.go | 1 - libs/tableview/tableview.go | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 2ae62d846ac..b72ac1ce01d 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -73,7 +73,6 @@ type paginatedModel struct { savedIter RowIterator savedExhaust bool - // Limits maxItems int limitReached bool diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 039835fd2a2..5dcd28d7e5a 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -185,9 +185,10 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil default: - if msg.Type == tea.KeyRunes { + switch msg.Type { + case tea.KeyRunes: m.searchInput += msg.String() - } else if msg.Type == tea.KeySpace { + case tea.KeySpace: m.searchInput += " " } return m, nil From 5709b104fffdf5e6f264c09c6cec491848baf6e4 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 17 Mar 2026 08:12:53 +0100 Subject: [PATCH 17/32] Fix exhaustive lint: replace switch on tea.KeyType with if-else The exhaustive linter requires all cases of tea.KeyType to be handled in a switch statement. Since we only care about KeyRunes and KeySpace, an if-else is simpler and avoids the issue entirely. Co-authored-by: Isaac --- libs/tableview/tableview.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 5dcd28d7e5a..973f4f257da 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -185,11 +185,8 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil default: - switch msg.Type { - case tea.KeyRunes: + if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { m.searchInput += msg.String() - case tea.KeySpace: - m.searchInput += " " } return m, nil } From c7fb162544b4251d35d0eb68a332854c7267119b Mon Sep 17 00:00:00 2001 From: simon Date: Thu, 19 Mar 2026 07:43:34 +0100 Subject: [PATCH 18/32] Add curated TUI table overrides for list commands (#4731) ## Why PR #4729 adds an interactive table for list commands, but its default behavior is to fall through to template rendering when no table configuration is registered. For the commands people use most, the table should start with the fields they actually care about, and it should enable search where the backend supports it. These 15 commands were selected based on CLI usage as some of the most frequently used list commands by customer count. ## Changes Before: interactive list commands used template rendering with no curated table configuration. Now: 15 common list commands register curated columns, and jobs list plus pipelines list-pipelines also enable / search in the TUI. This PR builds on #4729. It does not change the table engine itself. It adds per-command overrides that: - Choose the columns and column order for each command - Preserve the existing non-interactive template output - Enable server-side search where the API supports filtering Commands covered: jobs list, clusters list, pipelines list-pipelines, warehouses list, catalogs list, schemas list, tables list, apps list, repos list, instance-pools list, serving-endpoints list, volumes list, external-locations list, alerts list, workspace list ### Post-review fixes - Disable TUI search when `--filter` is already set on pipelines (composite filters are unsupported) - Clear stale pagination state (`PageToken`, `Offset`) before constructing search iterators - Add backslash escaping to pipelines LIKE filter - Extract repeated type assertions in apps Extract closures to local variables ## Test plan - `go build ./...` - Manual smoke test: verify curated columns for jobs list, clusters list, catalogs list, warehouses list, serving-endpoints list - Manual smoke test: verify / search for jobs list and pipelines list-pipelines - `make checks` passes - `make lintfull` passes (0 issues) --- cmd/workspace/alerts/overrides.go | 32 +++++++++ cmd/workspace/apps/overrides.go | 28 ++++++++ cmd/workspace/apps/overrides_test.go | 68 ++++++++++++++++++ cmd/workspace/catalogs/overrides.go | 15 ++++ cmd/workspace/clusters/overrides.go | 15 ++++ cmd/workspace/external-locations/overrides.go | 15 ++++ cmd/workspace/instance-pools/overrides.go | 19 +++++ cmd/workspace/jobs/overrides.go | 32 +++++++++ cmd/workspace/jobs/overrides_test.go | 50 +++++++++++++ cmd/workspace/pipelines/overrides.go | 64 +++++++++++++++++ cmd/workspace/pipelines/overrides_test.go | 70 +++++++++++++++++++ cmd/workspace/repos/overrides.go | 18 +++++ cmd/workspace/schemas/overrides.go | 15 ++++ cmd/workspace/serving-endpoints/overrides.go | 35 ++++++++++ .../serving-endpoints/overrides_test.go | 58 +++++++++++++++ cmd/workspace/tables/overrides.go | 12 ++++ cmd/workspace/volumes/overrides.go | 32 +++++++++ cmd/workspace/warehouses/overrides.go | 18 +++++ cmd/workspace/workspace/overrides.go | 19 +++++ 19 files changed, 615 insertions(+) create mode 100644 cmd/workspace/alerts/overrides.go create mode 100644 cmd/workspace/apps/overrides_test.go create mode 100644 cmd/workspace/jobs/overrides_test.go create mode 100644 cmd/workspace/serving-endpoints/overrides.go create mode 100644 cmd/workspace/serving-endpoints/overrides_test.go create mode 100644 cmd/workspace/volumes/overrides.go diff --git a/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go new file mode 100644 index 00000000000..ed5fdaa8dbf --- /dev/null +++ b/cmd/workspace/alerts/overrides.go @@ -0,0 +1,32 @@ +package alerts + +import ( + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/sql" + "github.com/spf13/cobra" +) + +func listOverride(listCmd *cobra.Command, listReq *sql.ListAlertsRequest) { + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .Id}} {{.DisplayName}} {{.State}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "ID", Extract: func(v any) string { + return v.(sql.ListAlertsResponseAlert).Id + }}, + {Header: "Name", Extract: func(v any) string { + return v.(sql.ListAlertsResponseAlert).DisplayName + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(sql.ListAlertsResponseAlert).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) +} + +func init() { + listOverrides = append(listOverrides, listOverride) +} diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index 6a909a943e7..ec6a25b803a 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -5,6 +5,7 @@ import ( appsCli "github.com/databricks/cli/cmd/apps" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/apps" "github.com/spf13/cobra" ) @@ -15,6 +16,33 @@ func listOverride(listCmd *cobra.Command, listReq *apps.ListAppsRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Name | green}} {{.Url}} {{if .ComputeStatus}}{{if eq .ComputeStatus.State "ACTIVE"}}{{green "%s" .ComputeStatus.State }}{{else}}{{blue "%s" .ComputeStatus.State}}{{end}}{{end}} {{if .ActiveDeployment}}{{if eq .ActiveDeployment.Status.State "SUCCEEDED"}}{{green "%s" .ActiveDeployment.Status.State }}{{else}}{{blue "%s" .ActiveDeployment.Status.State}}{{end}}{{end}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + a := v.(apps.App) + return a.Name + }}, + {Header: "URL", Extract: func(v any) string { + a := v.(apps.App) + return a.Url + }}, + {Header: "Compute Status", Extract: func(v any) string { + a := v.(apps.App) + if a.ComputeStatus != nil { + return string(a.ComputeStatus.State) + } + return "" + }}, + {Header: "Deploy Status", Extract: func(v any) string { + a := v.(apps.App) + if a.ActiveDeployment != nil && a.ActiveDeployment.Status != nil { + return string(a.ActiveDeployment.Status.State) + } + return "" + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, listDeploymentsReq *apps.ListAppDeploymentsRequest) { diff --git a/cmd/workspace/apps/overrides_test.go b/cmd/workspace/apps/overrides_test.go new file mode 100644 index 00000000000..c2d374f38b3 --- /dev/null +++ b/cmd/workspace/apps/overrides_test.go @@ -0,0 +1,68 @@ +package apps + +import ( + "testing" + + "github.com/databricks/cli/libs/tableview" + sdkapps "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListTableConfig(t *testing.T) { + cmd := newList() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.Len(t, cfg.Columns, 4) + + tests := []struct { + name string + app sdkapps.App + wantName string + wantURL string + wantCompute string + wantDeploy string + }{ + { + name: "with nested fields", + app: sdkapps.App{ + Name: "test-app", + Url: "https://example.com", + ComputeStatus: &sdkapps.ComputeStatus{ + State: sdkapps.ComputeStateActive, + }, + ActiveDeployment: &sdkapps.AppDeployment{ + Status: &sdkapps.AppDeploymentStatus{ + State: sdkapps.AppDeploymentStateSucceeded, + }, + }, + }, + wantName: "test-app", + wantURL: "https://example.com", + wantCompute: "ACTIVE", + wantDeploy: "SUCCEEDED", + }, + { + name: "nil nested fields", + app: sdkapps.App{ + Name: "test-app", + Url: "https://example.com", + ActiveDeployment: &sdkapps.AppDeployment{}, + }, + wantName: "test-app", + wantURL: "https://example.com", + wantCompute: "", + wantDeploy: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, cfg.Columns[0].Extract(tt.app)) + assert.Equal(t, tt.wantURL, cfg.Columns[1].Extract(tt.app)) + assert.Equal(t, tt.wantCompute, cfg.Columns[2].Extract(tt.app)) + assert.Equal(t, tt.wantDeploy, cfg.Columns[3].Extract(tt.app)) + }) + } +} diff --git a/cmd/workspace/catalogs/overrides.go b/cmd/workspace/catalogs/overrides.go index e2201dc152e..46d66a08b24 100644 --- a/cmd/workspace/catalogs/overrides.go +++ b/cmd/workspace/catalogs/overrides.go @@ -2,6 +2,7 @@ package catalogs import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/spf13/cobra" ) @@ -12,6 +13,20 @@ func listOverride(listCmd *cobra.Command, listReq *catalog.ListCatalogsRequest) listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Name|green}} {{blue "%s" .CatalogType}} {{.Comment}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + return v.(catalog.CatalogInfo).Name + }}, + {Header: "Type", Extract: func(v any) string { + return string(v.(catalog.CatalogInfo).CatalogType) + }}, + {Header: "Comment", MaxWidth: 40, Extract: func(v any) string { + return v.(catalog.CatalogInfo).Comment + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/clusters/overrides.go b/cmd/workspace/clusters/overrides.go index 6038978ae44..910918b0178 100644 --- a/cmd/workspace/clusters/overrides.go +++ b/cmd/workspace/clusters/overrides.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/spf13/cobra" ) @@ -17,6 +18,20 @@ func listOverride(listCmd *cobra.Command, listReq *compute.ListClustersRequest) {{range .}}{{.ClusterId | green}} {{.ClusterName | cyan}} {{if eq .State "RUNNING"}}{{green "%s" .State}}{{else if eq .State "TERMINATED"}}{{red "%s" .State}}{{else}}{{blue "%s" .State}}{{end}} {{end}}`) + columns := []tableview.ColumnDef{ + {Header: "Cluster ID", Extract: func(v any) string { + return v.(compute.ClusterDetails).ClusterId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(compute.ClusterDetails).ClusterName + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(compute.ClusterDetails).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listReq.FilterBy = &compute.ListClustersFilterBy{} listCmd.Flags().BoolVar(&listReq.FilterBy.IsPinned, "is-pinned", false, "Filter clusters by pinned status") listCmd.Flags().StringVar(&listReq.FilterBy.PolicyId, "policy-id", "", "Filter clusters by policy id") diff --git a/cmd/workspace/external-locations/overrides.go b/cmd/workspace/external-locations/overrides.go index 00b4921d4dc..9d9108f5be9 100644 --- a/cmd/workspace/external-locations/overrides.go +++ b/cmd/workspace/external-locations/overrides.go @@ -2,6 +2,7 @@ package external_locations import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/spf13/cobra" ) @@ -12,6 +13,20 @@ func listOverride(listCmd *cobra.Command, listReq *catalog.ListExternalLocations listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Name|green}} {{.CredentialName|cyan}} {{.Url}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + return v.(catalog.ExternalLocationInfo).Name + }}, + {Header: "Credential", Extract: func(v any) string { + return v.(catalog.ExternalLocationInfo).CredentialName + }}, + {Header: "URL", Extract: func(v any) string { + return v.(catalog.ExternalLocationInfo).Url + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/instance-pools/overrides.go b/cmd/workspace/instance-pools/overrides.go index f62f8c53670..ddf6181f15c 100644 --- a/cmd/workspace/instance-pools/overrides.go +++ b/cmd/workspace/instance-pools/overrides.go @@ -2,6 +2,8 @@ package instance_pools import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/compute" "github.com/spf13/cobra" ) @@ -9,6 +11,23 @@ func listOverride(listCmd *cobra.Command) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.InstancePoolId|green}} {{.InstancePoolName}} {{.NodeTypeId}} {{.State}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Pool ID", Extract: func(v any) string { + return v.(compute.InstancePoolAndStats).InstancePoolId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(compute.InstancePoolAndStats).InstancePoolName + }}, + {Header: "Node Type", Extract: func(v any) string { + return v.(compute.InstancePoolAndStats).NodeTypeId + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(compute.InstancePoolAndStats).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/jobs/overrides.go b/cmd/workspace/jobs/overrides.go index ee7d205517c..d9786601ba4 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -1,7 +1,12 @@ package jobs import ( + "context" + "strconv" + + "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/spf13/cobra" ) @@ -10,6 +15,33 @@ func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .JobId}} {{.Settings.Name}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Job ID", Extract: func(v any) string { + return strconv.FormatInt(v.(jobs.BaseJob).JobId, 10) + }}, + {Header: "Name", Extract: func(v any) string { + if v.(jobs.BaseJob).Settings != nil { + return v.(jobs.BaseJob).Settings.Name + } + return "" + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{ + Columns: columns, + Search: &tableview.SearchConfig{ + Placeholder: "Search by exact name...", + NewIterator: func(ctx context.Context, query string) tableview.RowIterator { + req := *listReq + req.Name = query + req.PageToken = "" + req.Offset = 0 + w := cmdctx.WorkspaceClient(ctx) + return tableview.WrapIterator(w.Jobs.List(ctx, req), columns) + }, + }, + }) } func listRunsOverride(listRunsCmd *cobra.Command, listRunsReq *jobs.ListRunsRequest) { diff --git a/cmd/workspace/jobs/overrides_test.go b/cmd/workspace/jobs/overrides_test.go new file mode 100644 index 00000000000..66bcb5da276 --- /dev/null +++ b/cmd/workspace/jobs/overrides_test.go @@ -0,0 +1,50 @@ +package jobs + +import ( + "testing" + + "github.com/databricks/cli/libs/tableview" + sdkjobs "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListTableConfig(t *testing.T) { + cmd := newList() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.Len(t, cfg.Columns, 2) + + tests := []struct { + name string + job sdkjobs.BaseJob + wantID string + wantName string + }{ + { + name: "with settings", + job: sdkjobs.BaseJob{ + JobId: 123, + Settings: &sdkjobs.JobSettings{Name: "test-job"}, + }, + wantID: "123", + wantName: "test-job", + }, + { + name: "nil settings", + job: sdkjobs.BaseJob{ + JobId: 456, + }, + wantID: "456", + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantID, cfg.Columns[0].Extract(tt.job)) + assert.Equal(t, tt.wantName, cfg.Columns[1].Extract(tt.job)) + }) + } +} diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 08c36deabe2..361c834bfae 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -1,15 +1,70 @@ package pipelines import ( + "context" + "fmt" "regexp" "slices" + "strings" pipelinesCli "github.com/databricks/cli/cmd/pipelines" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/pipelines" "github.com/spf13/cobra" ) +func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipelinesRequest) { + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .PipelineId}} {{.Name}} {{blue "%s" .State}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Pipeline ID", Extract: func(v any) string { + return v.(pipelines.PipelineStateInfo).PipelineId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(pipelines.PipelineStateInfo).Name + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(pipelines.PipelineStateInfo).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{ + Columns: columns, + Search: &tableview.SearchConfig{ + Placeholder: "Filter by name...", + NewIterator: func(ctx context.Context, query string) tableview.RowIterator { + req := *listReq + req.PageToken = "" + escaped := strings.ReplaceAll(query, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, "'", "''") + escaped = strings.ReplaceAll(escaped, "%", `\%`) + escaped = strings.ReplaceAll(escaped, "_", `\_`) + req.Filter = fmt.Sprintf("name LIKE '%%%s%%'", escaped) + w := cmdctx.WorkspaceClient(ctx) + return tableview.WrapIterator(w.Pipelines.ListPipelines(ctx, req), columns) + }, + }, + }) + + // The pipelines API does not support composite filters, so disable + // TUI search when the user passes --filter on the command line. + origPreRunE := listCmd.PreRunE + listCmd.PreRunE = func(cmd *cobra.Command, args []string) error { + disableSearchIfFilterSet(cmd) + if origPreRunE != nil { + return origPreRunE(cmd, args) + } + return nil + } +} + func init() { + listPipelinesOverrides = append(listPipelinesOverrides, listPipelinesOverride) + cmdOverrides = append(cmdOverrides, func(cli *cobra.Command) { // all auto-generated commands apart from nonManagementCommands go into 'management' group nonManagementCommands := []string{ @@ -71,6 +126,15 @@ With a PIPELINE_ID: Stops the pipeline identified by the UUID using the API.` }) } +// disableSearchIfFilterSet clears the TUI search config when --filter is active. +func disableSearchIfFilterSet(cmd *cobra.Command) { + if cmd.Flags().Changed("filter") { + if cfg := tableview.GetConfig(cmd); cfg != nil { + cfg.Search = nil + } + } +} + var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // looksLikeUUID checks if a string matches the UUID format with lowercase hex digits diff --git a/cmd/workspace/pipelines/overrides_test.go b/cmd/workspace/pipelines/overrides_test.go index 2e70cf4845b..27f3c265887 100644 --- a/cmd/workspace/pipelines/overrides_test.go +++ b/cmd/workspace/pipelines/overrides_test.go @@ -3,7 +3,13 @@ package pipelines import ( "testing" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/experimental/mocks" + sdkpipelines "github.com/databricks/databricks-sdk-go/service/pipelines" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) func TestLooksLikeUUID(t *testing.T) { @@ -13,3 +19,67 @@ func TestLooksLikeUUID(t *testing.T) { func TestLooksLikeUUID_resourceName(t *testing.T) { assert.False(t, looksLikeUUID("my-pipeline-key")) } + +func TestListPipelinesTableConfig(t *testing.T) { + cmd := newListPipelines() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.Len(t, cfg.Columns, 3) + require.NotNil(t, cfg.Search) + + pipeline := sdkpipelines.PipelineStateInfo{ + PipelineId: "pipeline-id", + Name: "pipeline-name", + State: sdkpipelines.PipelineStateIdle, + } + + assert.Equal(t, "pipeline-id", cfg.Columns[0].Extract(pipeline)) + assert.Equal(t, "pipeline-name", cfg.Columns[1].Extract(pipeline)) + assert.Equal(t, "IDLE", cfg.Columns[2].Extract(pipeline)) +} + +func TestListPipelinesSearchEscapesLikeWildcards(t *testing.T) { + cmd := newListPipelines() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Search) + + m := mocks.NewMockWorkspaceClient(t) + m.GetMockPipelinesAPI().EXPECT(). + ListPipelines(mock.Anything, sdkpipelines.ListPipelinesRequest{ + Filter: "name LIKE '%foo''\\%\\_bar%'", + }). + Return(nil) + + ctx := cmdctx.SetWorkspaceClient(t.Context(), m.WorkspaceClient) + assert.NotNil(t, cfg.Search.NewIterator(ctx, "foo'%_bar")) +} + +func TestListPipelinesSearchDisabledWhenFilterSet(t *testing.T) { + cmd := newListPipelines() + + err := cmd.Flags().Set("filter", "state = 'RUNNING'") + require.NoError(t, err) + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Search) + + // The pipelines API does not support composite filters, so the + // PreRunE hook calls disableSearchIfFilterSet to nil out search. + disableSearchIfFilterSet(cmd) + assert.Nil(t, cfg.Search) +} + +func TestListPipelinesSearchNotDisabledWithoutFilter(t *testing.T) { + cmd := newListPipelines() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Search) + + disableSearchIfFilterSet(cmd) + assert.NotNil(t, cfg.Search) +} diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 72f58bb5010..85bdb1a6d6e 100644 --- a/cmd/workspace/repos/overrides.go +++ b/cmd/workspace/repos/overrides.go @@ -11,6 +11,7 @@ import ( "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/spf13/cobra" @@ -20,6 +21,23 @@ func listOverride(listCmd *cobra.Command, listReq *workspace.ListReposRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .Id}} {{.Path}} {{.Branch|blue}} {{.Url|cyan}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "ID", Extract: func(v any) string { + return strconv.FormatInt(v.(workspace.RepoInfo).Id, 10) + }}, + {Header: "Path", Extract: func(v any) string { + return v.(workspace.RepoInfo).Path + }}, + {Header: "Branch", Extract: func(v any) string { + return v.(workspace.RepoInfo).Branch + }}, + {Header: "URL", Extract: func(v any) string { + return v.(workspace.RepoInfo).Url + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func createOverride(createCmd *cobra.Command, createReq *workspace.CreateRepoRequest) { diff --git a/cmd/workspace/schemas/overrides.go b/cmd/workspace/schemas/overrides.go index ba4c65ce735..625c92f3d79 100644 --- a/cmd/workspace/schemas/overrides.go +++ b/cmd/workspace/schemas/overrides.go @@ -2,6 +2,7 @@ package schemas import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/spf13/cobra" ) @@ -12,6 +13,20 @@ func listOverride(listCmd *cobra.Command, listReq *catalog.ListSchemasRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.FullName|green}} {{.Owner|cyan}} {{.Comment}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Full Name", Extract: func(v any) string { + return v.(catalog.SchemaInfo).FullName + }}, + {Header: "Owner", Extract: func(v any) string { + return v.(catalog.SchemaInfo).Owner + }}, + {Header: "Comment", MaxWidth: 40, Extract: func(v any) string { + return v.(catalog.SchemaInfo).Comment + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/serving-endpoints/overrides.go b/cmd/workspace/serving-endpoints/overrides.go new file mode 100644 index 00000000000..611428f18d9 --- /dev/null +++ b/cmd/workspace/serving-endpoints/overrides.go @@ -0,0 +1,35 @@ +package serving_endpoints + +import ( + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/serving" + "github.com/spf13/cobra" +) + +func listOverride(listCmd *cobra.Command) { + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .Name}} {{if .State}}{{.State.Ready}}{{end}} {{.Creator}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + return v.(serving.ServingEndpoint).Name + }}, + {Header: "State", Extract: func(v any) string { + if v.(serving.ServingEndpoint).State != nil { + return string(v.(serving.ServingEndpoint).State.Ready) + } + return "" + }}, + {Header: "Creator", Extract: func(v any) string { + return v.(serving.ServingEndpoint).Creator + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) +} + +func init() { + listOverrides = append(listOverrides, listOverride) +} diff --git a/cmd/workspace/serving-endpoints/overrides_test.go b/cmd/workspace/serving-endpoints/overrides_test.go new file mode 100644 index 00000000000..1ab6f39dad3 --- /dev/null +++ b/cmd/workspace/serving-endpoints/overrides_test.go @@ -0,0 +1,58 @@ +package serving_endpoints + +import ( + "testing" + + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/serving" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListTableConfig(t *testing.T) { + cmd := newList() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.Len(t, cfg.Columns, 3) + + tests := []struct { + name string + endpoint serving.ServingEndpoint + wantName string + wantState string + wantCreator string + }{ + { + name: "with state", + endpoint: serving.ServingEndpoint{ + Name: "endpoint", + Creator: "user@example.com", + State: &serving.EndpointState{ + Ready: serving.EndpointStateReadyReady, + }, + }, + wantName: "endpoint", + wantState: "READY", + wantCreator: "user@example.com", + }, + { + name: "nil state", + endpoint: serving.ServingEndpoint{ + Name: "endpoint", + Creator: "user@example.com", + }, + wantName: "endpoint", + wantState: "", + wantCreator: "user@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, cfg.Columns[0].Extract(tt.endpoint)) + assert.Equal(t, tt.wantState, cfg.Columns[1].Extract(tt.endpoint)) + assert.Equal(t, tt.wantCreator, cfg.Columns[2].Extract(tt.endpoint)) + }) + } +} diff --git a/cmd/workspace/tables/overrides.go b/cmd/workspace/tables/overrides.go index a0849ada7f4..157d62daf9c 100644 --- a/cmd/workspace/tables/overrides.go +++ b/cmd/workspace/tables/overrides.go @@ -2,6 +2,7 @@ package tables import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/spf13/cobra" ) @@ -12,6 +13,17 @@ func listOverride(listCmd *cobra.Command, listReq *catalog.ListTablesRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.FullName|green}} {{blue "%s" .TableType}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Full Name", Extract: func(v any) string { + return v.(catalog.TableInfo).FullName + }}, + {Header: "Table Type", Extract: func(v any) string { + return string(v.(catalog.TableInfo).TableType) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/volumes/overrides.go b/cmd/workspace/volumes/overrides.go new file mode 100644 index 00000000000..0a4f645de33 --- /dev/null +++ b/cmd/workspace/volumes/overrides.go @@ -0,0 +1,32 @@ +package volumes + +import ( + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/catalog" + "github.com/spf13/cobra" +) + +func listOverride(listCmd *cobra.Command, listReq *catalog.ListVolumesRequest) { + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .Name}} {{.VolumeType}} {{.FullName}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + return v.(catalog.VolumeInfo).Name + }}, + {Header: "Volume Type", Extract: func(v any) string { + return string(v.(catalog.VolumeInfo).VolumeType) + }}, + {Header: "Full Name", Extract: func(v any) string { + return v.(catalog.VolumeInfo).FullName + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) +} + +func init() { + listOverrides = append(listOverrides, listOverride) +} diff --git a/cmd/workspace/warehouses/overrides.go b/cmd/workspace/warehouses/overrides.go index 9457557d00b..edc58ad6811 100644 --- a/cmd/workspace/warehouses/overrides.go +++ b/cmd/workspace/warehouses/overrides.go @@ -2,6 +2,7 @@ package warehouses import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/sql" "github.com/spf13/cobra" ) @@ -12,6 +13,23 @@ func listOverride(listCmd *cobra.Command, listReq *sql.ListWarehousesRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Id|green}} {{.Name|cyan}} {{.ClusterSize|cyan}} {{if eq .State "RUNNING"}}{{"RUNNING"|green}}{{else if eq .State "STOPPED"}}{{"STOPPED"|red}}{{else}}{{blue "%s" .State}}{{end}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "ID", Extract: func(v any) string { + return v.(sql.EndpointInfo).Id + }}, + {Header: "Name", Extract: func(v any) string { + return v.(sql.EndpointInfo).Name + }}, + {Header: "Size", Extract: func(v any) string { + return v.(sql.EndpointInfo).ClusterSize + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(sql.EndpointInfo).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/workspace/overrides.go b/cmd/workspace/workspace/overrides.go index c57209b554b..56e2e74f71b 100644 --- a/cmd/workspace/workspace/overrides.go +++ b/cmd/workspace/workspace/overrides.go @@ -6,10 +6,12 @@ import ( "fmt" "net/http" "os" + "strconv" "strings" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/spf13/cobra" @@ -22,6 +24,23 @@ func listOverride(listCmd *cobra.Command, listReq *workspace.ListWorkspaceReques listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .ObjectId}} {{blue "%s" .ObjectType}} {{cyan "%s" .Language}} {{.Path|cyan}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "ID", Extract: func(v any) string { + return strconv.FormatInt(v.(workspace.ObjectInfo).ObjectId, 10) + }}, + {Header: "Type", Extract: func(v any) string { + return string(v.(workspace.ObjectInfo).ObjectType) + }}, + {Header: "Language", Extract: func(v any) string { + return string(v.(workspace.ObjectInfo).Language) + }}, + {Header: "Path", Extract: func(v any) string { + return v.(workspace.ObjectInfo).Path + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func exportOverride(exportCmd *cobra.Command, exportReq *workspace.ExportRequest) { From 8d63e1a452edf019e6f05287449e892d330b168a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 23 Mar 2026 21:47:40 +0100 Subject: [PATCH 19/32] Add TUI table overrides for high-traffic list commands (#4732) ## Why PR #4731 added curated TUI table overrides for 15 list commands. This follow-up covers 5 additional commands that are among the most frequently used in the CLI, but were missing curated columns. ## Changes Before: these 5 commands used either generic text templates (secrets, cluster-policies) or raw JSON output (lakeview, pipeline events) with no curated TUI table columns. Now: all 5 register curated TableConfig overrides so they show useful columns in the interactive TUI. Commands that had no text template override (lakeview list, pipelines list-pipeline-events) also get template annotations for the non-interactive fallback. This PR stacks on #4731. It only adds per-command overrides, no engine changes. ### Post-review fixes - Sanitize control whitespace (`\n`, `\r`, `\t`) in pipeline event messages to prevent table row corruption - Increase MaxWidth for pipeline event Message column from 60 to 200 (pragmatic cap until non-destructive clipping is implemented) - Remove redundant `PaginatedModel` type alias, use `FinalModel` interface instead - Remove duplicate `TestPaginatedErrAccessor` test - Trim verbose MaxWidth truncation comment ## Test plan - `go build ./...` - `make checks` passes - `make lintfull` passes (0 issues) - Manual smoke test: verify curated columns for `secrets list-scopes`, `lakeview list`, `pipelines list-pipeline-events` --- cmd/workspace/alerts/overrides.go | 2 +- cmd/workspace/apps/overrides.go | 4 +-- cmd/workspace/catalogs/overrides.go | 2 +- cmd/workspace/cluster-policies/overrides.go | 18 ++++++++++ cmd/workspace/external-locations/overrides.go | 2 +- cmd/workspace/jobs/overrides.go | 2 +- cmd/workspace/lakeview/overrides.go | 25 ++++++++++++++ cmd/workspace/pipelines/overrides.go | 34 +++++++++++++++++++ cmd/workspace/repos/overrides.go | 2 +- cmd/workspace/schemas/overrides.go | 2 +- cmd/workspace/secrets/overrides.go | 29 ++++++++++++++++ cmd/workspace/tables/overrides.go | 2 +- cmd/workspace/volumes/overrides.go | 2 +- cmd/workspace/warehouses/overrides.go | 2 +- libs/cmdio/render.go | 13 +++++-- libs/tableview/config.go | 7 ++-- libs/tableview/paginated.go | 19 ++++++----- 17 files changed, 143 insertions(+), 24 deletions(-) diff --git a/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go index ed5fdaa8dbf..b7d3a3e3f45 100644 --- a/cmd/workspace/alerts/overrides.go +++ b/cmd/workspace/alerts/overrides.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *sql.ListAlertsRequest) { +func listOverride(listCmd *cobra.Command, _ *sql.ListAlertsRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%s" .Id}} {{.DisplayName}} {{.State}} {{end}}`) diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index ec6a25b803a..480f271a94e 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *apps.ListAppsRequest) { +func listOverride(listCmd *cobra.Command, _ *apps.ListAppsRequest) { listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Url"}} {{header "ComputeStatus"}} {{header "DeploymentStatus"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` @@ -45,7 +45,7 @@ func listOverride(listCmd *cobra.Command, listReq *apps.ListAppsRequest) { tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } -func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, listDeploymentsReq *apps.ListAppDeploymentsRequest) { +func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, _ *apps.ListAppDeploymentsRequest) { listDeploymentsCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "DeploymentId"}} {{header "State"}} {{header "CreatedAt"}}`) listDeploymentsCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/catalogs/overrides.go b/cmd/workspace/catalogs/overrides.go index 46d66a08b24..d86af1eeea4 100644 --- a/cmd/workspace/catalogs/overrides.go +++ b/cmd/workspace/catalogs/overrides.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListCatalogsRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListCatalogsRequest) { listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Type"}} {{header "Comment"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/cluster-policies/overrides.go b/cmd/workspace/cluster-policies/overrides.go index 9278b29c391..8bc320aa5fd 100644 --- a/cmd/workspace/cluster-policies/overrides.go +++ b/cmd/workspace/cluster-policies/overrides.go @@ -2,6 +2,7 @@ package cluster_policies import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/spf13/cobra" ) @@ -10,6 +11,23 @@ func listOverride(listCmd *cobra.Command, _ *compute.ListClusterPoliciesRequest) listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.PolicyId | green}} {{.Name}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Policy ID", Extract: func(v any) string { + return v.(compute.Policy).PolicyId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(compute.Policy).Name + }}, + {Header: "Default", Extract: func(v any) string { + if v.(compute.Policy).IsDefault { + return "yes" + } + return "" + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func getOverride(getCmd *cobra.Command, _ *compute.GetClusterPolicyRequest) { diff --git a/cmd/workspace/external-locations/overrides.go b/cmd/workspace/external-locations/overrides.go index 9d9108f5be9..607550da3a3 100644 --- a/cmd/workspace/external-locations/overrides.go +++ b/cmd/workspace/external-locations/overrides.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListExternalLocationsRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListExternalLocationsRequest) { listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Credential"}} {{header "URL"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/jobs/overrides.go b/cmd/workspace/jobs/overrides.go index d9786601ba4..d20efd6195a 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -44,7 +44,7 @@ func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { }) } -func listRunsOverride(listRunsCmd *cobra.Command, listRunsReq *jobs.ListRunsRequest) { +func listRunsOverride(listRunsCmd *cobra.Command, _ *jobs.ListRunsRequest) { listRunsCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Job ID"}} {{header "Run ID"}} {{header "Result State"}} URL`) listRunsCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/lakeview/overrides.go b/cmd/workspace/lakeview/overrides.go index 6ffb641aa97..55357f703d6 100644 --- a/cmd/workspace/lakeview/overrides.go +++ b/cmd/workspace/lakeview/overrides.go @@ -1,10 +1,34 @@ package lakeview import ( + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/dashboards" "github.com/spf13/cobra" ) +func listOverride(listCmd *cobra.Command, _ *dashboards.ListDashboardsRequest) { + listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` + {{header "Dashboard ID"}} {{header "Name"}} {{header "State"}}`) + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .DashboardId}} {{.DisplayName}} {{blue "%s" .LifecycleState}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Dashboard ID", Extract: func(v any) string { + return v.(dashboards.Dashboard).DashboardId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(dashboards.Dashboard).DisplayName + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(dashboards.Dashboard).LifecycleState) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) +} + func publishOverride(cmd *cobra.Command, req *dashboards.PublishRequest) { originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { @@ -15,5 +39,6 @@ func publishOverride(cmd *cobra.Command, req *dashboards.PublishRequest) { } func init() { + listOverrides = append(listOverrides, listOverride) publishOverrides = append(publishOverrides, publishOverride) } diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 361c834bfae..083c77c5919 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -62,8 +62,34 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli } } +func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelineEventsRequest) { + listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` + {{header "Timestamp"}} {{header "Level"}} {{header "Event Type"}} {{header "Message"}}`) + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{.Timestamp}} {{.Level}} {{.EventType}} {{.Message | sanitize}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Timestamp", Extract: func(v any) string { + return v.(pipelines.PipelineEvent).Timestamp + }}, + {Header: "Level", Extract: func(v any) string { + return string(v.(pipelines.PipelineEvent).Level) + }}, + {Header: "Event Type", Extract: func(v any) string { + return v.(pipelines.PipelineEvent).EventType + }}, + {Header: "Message", MaxWidth: 200, Extract: func(v any) string { + return sanitizeWhitespace(v.(pipelines.PipelineEvent).Message) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) +} + func init() { listPipelinesOverrides = append(listPipelinesOverrides, listPipelinesOverride) + listPipelineEventsOverrides = append(listPipelineEventsOverrides, listPipelineEventsOverride) cmdOverrides = append(cmdOverrides, func(cli *cobra.Command) { // all auto-generated commands apart from nonManagementCommands go into 'management' group @@ -135,6 +161,14 @@ func disableSearchIfFilterSet(cmd *cobra.Command) { } } +var controlWhitespaceReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") + +// sanitizeWhitespace replaces control whitespace (newlines, tabs) with spaces +// to prevent corrupting tab-delimited or TUI table output. +func sanitizeWhitespace(s string) string { + return controlWhitespaceReplacer.Replace(s) +} + var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // looksLikeUUID checks if a string matches the UUID format with lowercase hex digits diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 85bdb1a6d6e..65cc353dd76 100644 --- a/cmd/workspace/repos/overrides.go +++ b/cmd/workspace/repos/overrides.go @@ -17,7 +17,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *workspace.ListReposRequest) { +func listOverride(listCmd *cobra.Command, _ *workspace.ListReposRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .Id}} {{.Path}} {{.Branch|blue}} {{.Url|cyan}} {{end}}`) diff --git a/cmd/workspace/schemas/overrides.go b/cmd/workspace/schemas/overrides.go index 625c92f3d79..0e9b1b03b93 100644 --- a/cmd/workspace/schemas/overrides.go +++ b/cmd/workspace/schemas/overrides.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListSchemasRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListSchemasRequest) { listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Full Name"}} {{header "Owner"}} {{header "Comment"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index b215f17a7f7..5de7268905e 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -1,7 +1,10 @@ package secrets import ( + "time" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/spf13/cobra" ) @@ -16,6 +19,17 @@ func listScopesOverride(listScopesCmd *cobra.Command) { listScopesCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Name|green}} {{.BackendType}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Scope", Extract: func(v any) string { + return v.(workspace.SecretScope).Name + }}, + {Header: "Backend Type", Extract: func(v any) string { + return string(v.(workspace.SecretScope).BackendType) + }}, + } + + tableview.RegisterConfig(listScopesCmd, tableview.TableConfig{Columns: columns}) } func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSecretsRequest) { @@ -24,6 +38,21 @@ func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSec listSecretsCommand.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Key|green}} {{.LastUpdatedTimestamp}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Key", Extract: func(v any) string { + return v.(workspace.SecretMetadata).Key + }}, + {Header: "Last Updated", Extract: func(v any) string { + ts := v.(workspace.SecretMetadata).LastUpdatedTimestamp + if ts == 0 { + return "" + } + return time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05") + }}, + } + + tableview.RegisterConfig(listSecretsCommand, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/tables/overrides.go b/cmd/workspace/tables/overrides.go index 157d62daf9c..8e0987d4697 100644 --- a/cmd/workspace/tables/overrides.go +++ b/cmd/workspace/tables/overrides.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListTablesRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListTablesRequest) { listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Full Name"}} {{header "Table Type"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/volumes/overrides.go b/cmd/workspace/volumes/overrides.go index 0a4f645de33..66b946f2ea2 100644 --- a/cmd/workspace/volumes/overrides.go +++ b/cmd/workspace/volumes/overrides.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListVolumesRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListVolumesRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%s" .Name}} {{.VolumeType}} {{.FullName}} {{end}}`) diff --git a/cmd/workspace/warehouses/overrides.go b/cmd/workspace/warehouses/overrides.go index edc58ad6811..14b2635a040 100644 --- a/cmd/workspace/warehouses/overrides.go +++ b/cmd/workspace/warehouses/overrides.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *sql.ListWarehousesRequest) { +func listOverride(listCmd *cobra.Command, _ *sql.ListWarehousesRequest) { listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "ID"}} {{header "Name"}} {{header "Size"}} {{header "State"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index f4289dbfd72..6b6d1b9121f 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -313,6 +313,14 @@ func RenderIteratorJson[T any](ctx context.Context, i listing.Iterator[T]) error return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template) } +var controlWhitespaceReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") + +// sanitizeControlWhitespace replaces newlines and tabs with spaces to prevent +// corrupting tab-delimited text output. +func sanitizeControlWhitespace(s string) string { + return controlWhitespaceReplacer.Replace(s) +} + var renderFuncMap = template.FuncMap{ // we render colored output if stdout is TTY, otherwise we render text. // in the future we'll check if we can explicitly check for stderr being @@ -330,8 +338,9 @@ var renderFuncMap = template.FuncMap{ "italic": func(format string, a ...any) string { return color.New(color.Italic).Sprintf(format, a...) }, - "replace": strings.ReplaceAll, - "join": strings.Join, + "replace": strings.ReplaceAll, + "sanitize": sanitizeControlWhitespace, + "join": strings.Join, "sub": func(a, b int) int { return a - b }, diff --git a/libs/tableview/config.go b/libs/tableview/config.go index c933f08e87c..f6ba32a41a2 100644 --- a/libs/tableview/config.go +++ b/libs/tableview/config.go @@ -4,8 +4,11 @@ import "context" // ColumnDef defines a column in the TUI table. type ColumnDef struct { - Header string // Display name in header row. - MaxWidth int // Max cell width; 0 = default (50). + Header string // Display name in header row. + // MaxWidth caps cell display width; 0 = default (50). Values exceeding + // this limit are destructively truncated with "..." in the rendered + // output. Horizontal scrolling does not recover the hidden portion. + MaxWidth int Extract func(v any) string // Extracts cell value from typed SDK struct. } diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index b72ac1ce01d..b9d3be3f076 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -78,6 +78,11 @@ type paginatedModel struct { limitReached bool } +// Err returns the error recorded during data fetching, if any. +func (m paginatedModel) Err() error { + return m.err +} + // newFetchCmdFunc returns a closure that creates fetch commands, capturing ctx. func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd { return func(m paginatedModel) tea.Cmd { @@ -156,19 +161,14 @@ func RunPaginated(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIt if err != nil { return err } - if pm, ok := finalModel.(FinalModel); ok { - if modelErr := pm.Err(); modelErr != nil { - return modelErr + if m, ok := finalModel.(FinalModel); ok { + if fetchErr := m.Err(); fetchErr != nil { + return fetchErr } } return nil } -// Err returns any error that occurred during data fetching. -func (m paginatedModel) Err() error { - return m.err -} - func (m paginatedModel) Init() tea.Cmd { return m.makeFetchCmd(m) } @@ -275,7 +275,8 @@ func (m paginatedModel) renderContent() string { } fmt.Fprintln(tw, strings.Join(seps, "\t")) - // Data rows + // Data rows. + // MaxWidth truncation is destructive; horizontal scroll won't recover hidden text. for _, row := range m.rows { vals := make([]string, len(m.headers)) for i := range m.headers { From ff16f2bcea54c787b07062d78391ef275d2e5ce7 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 17:11:11 +0100 Subject: [PATCH 20/32] Fix UTF-8 truncation, context cancellation masking, and dead code - Use rune-aware length checks and slicing in renderContent and computeWidths to prevent corrupting multi-byte characters - Check ctx.Err() after HasNext returns false so context cancellation surfaces as an error instead of masquerading as normal exhaustion - Guard AutoDetect extractors against non-struct values to prevent panic - Remove unused RunPaginated (RenderIterator calls NewPaginatedProgram directly) and unused WithMaxItems - Add comment explaining the retry-by-scrolling behavior on fetch errors Co-authored-by: Isaac --- libs/cmdio/context.go | 5 ----- libs/tableview/autodetect.go | 3 +++ libs/tableview/common.go | 9 +++++---- libs/tableview/paginated.go | 32 ++++++++++++-------------------- 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/libs/cmdio/context.go b/libs/cmdio/context.go index c057be6a3a7..f44609e1007 100644 --- a/libs/cmdio/context.go +++ b/libs/cmdio/context.go @@ -21,11 +21,6 @@ func CommandFromContext(ctx context.Context) *cobra.Command { type maxItemsKeyType struct{} -// WithMaxItems stores a max items limit in context. -func WithMaxItems(ctx context.Context, n int) context.Context { - return context.WithValue(ctx, maxItemsKeyType{}, n) -} - // GetMaxItems retrieves the max items limit from context (0 = unlimited). func GetMaxItems(ctx context.Context) int { n, _ := ctx.Value(maxItemsKeyType{}).(int) diff --git a/libs/tableview/autodetect.go b/libs/tableview/autodetect.go index 3921a5efc7f..6226e5cc3b4 100644 --- a/libs/tableview/autodetect.go +++ b/libs/tableview/autodetect.go @@ -64,6 +64,9 @@ func autoDetectFromType(t reflect.Type) *TableConfig { } val = val.Elem() } + if val.Kind() != reflect.Struct { + return "" + } f := val.Field(i) return fmt.Sprintf("%v", f.Interface()) }, diff --git a/libs/tableview/common.go b/libs/tableview/common.go index 58372408a17..e24792234bf 100644 --- a/libs/tableview/common.go +++ b/libs/tableview/common.go @@ -5,6 +5,7 @@ import ( "io" "strings" "text/tabwriter" + "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss" @@ -36,12 +37,12 @@ func renderTableLines(columns []string, rows [][]string) []string { // Separator: compute widths from header + data for dash line. widths := make([]int, len(columns)) for i, col := range columns { - widths[i] = len(col) + widths[i] = utf8.RuneCountInString(col) } for _, row := range rows { for i := range columns { if i < len(row) { - widths[i] = max(widths[i], len(row[i])) + widths[i] = max(widths[i], utf8.RuneCountInString(row[i])) } } } @@ -133,10 +134,10 @@ func RenderStaticTable(w io.Writer, columns []string, rows [][]string) error { // Separator seps := make([]string, len(columns)) for i, col := range columns { - width := len(col) + width := utf8.RuneCountInString(col) for _, row := range rows { if i < len(row) { - width = max(width, min(len(row[i]), maxColumnWidth)) + width = max(width, min(utf8.RuneCountInString(row[i]), maxColumnWidth)) } } seps[i] = strings.Repeat("-", width) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index b9d3be3f076..0da6237da94 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -106,6 +106,9 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd { for range limit { if !iter.HasNext(ctx) { + if ctx.Err() != nil { + return rowsFetchedMsg{err: ctx.Err(), generation: generation} + } exhausted = true break } @@ -154,21 +157,6 @@ func NewPaginatedProgram(ctx context.Context, w io.Writer, cfg *TableConfig, ite return tea.NewProgram(m, tea.WithOutput(w)) } -// RunPaginated launches the paginated TUI table. -func RunPaginated(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIterator, maxItems int) error { - p := NewPaginatedProgram(ctx, w, cfg, iter, maxItems) - finalModel, err := p.Run() - if err != nil { - return err - } - if m, ok := finalModel.(FinalModel); ok { - if fetchErr := m.Err(); fetchErr != nil { - return fetchErr - } - } - return nil -} - func (m paginatedModel) Init() tea.Cmd { return m.makeFetchCmd(m) } @@ -200,6 +188,9 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false if msg.err != nil { m.err = msg.err + // fetchGeneration is intentionally not bumped: scrolling past the + // threshold retries the failed fetch, and the error is shown in + // the footer until the retry succeeds. return m, nil } m.err = nil @@ -246,7 +237,7 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *paginatedModel) computeWidths() { m.widths = make([]int, len(m.headers)) for i, h := range m.headers { - m.widths[i] = len(h) + m.widths[i] = utf8.RuneCountInString(h) } for _, row := range m.rows { for i := range m.widths { @@ -255,7 +246,7 @@ func (m *paginatedModel) computeWidths() { if i < len(m.cfg.Columns) && m.cfg.Columns[i].MaxWidth > 0 { maxW = m.cfg.Columns[i].MaxWidth } - m.widths[i] = min(max(m.widths[i], len(row[i])), maxW) + m.widths[i] = min(max(m.widths[i], utf8.RuneCountInString(row[i])), maxW) } } } @@ -286,11 +277,12 @@ func (m paginatedModel) renderContent() string { if i < len(m.cfg.Columns) && m.cfg.Columns[i].MaxWidth > 0 { maxW = m.cfg.Columns[i].MaxWidth } - if len(v) > maxW { + if utf8.RuneCountInString(v) > maxW { + runes := []rune(v) if maxW <= 3 { - v = v[:maxW] + v = string(runes[:maxW]) } else { - v = v[:maxW-3] + "..." + v = string(runes[:maxW-3]) + "..." } } vals[i] = v From f755a0e311136a11a0c90a4e1c6c7b546eea2465 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 19:08:33 +0100 Subject: [PATCH 21/32] Fix search highlighting misalignment, stale limitReached, and missing recompute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite highlightSearch to work in rune-space so case-folding length changes (e.g. "ß" to "ss") do not misalign highlighted spans - Reset limitReached in executeSearch and restorePreSearchState so the footer does not incorrectly show limit info after search transitions - Call computeWidths() in restorePreSearchState before rendering so separator widths are recalculated for the restored data Co-authored-by: Isaac --- libs/tableview/common.go | 35 ++++++++++++++++++++++++----------- libs/tableview/paginated.go | 3 +++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/libs/tableview/common.go b/libs/tableview/common.go index e24792234bf..cd33efec0b4 100644 --- a/libs/tableview/common.go +++ b/libs/tableview/common.go @@ -89,26 +89,39 @@ func findMatches(lines []string, query string) []int { } // highlightSearch applies search match highlighting to a single line. +// It works in rune-space so that case-folding length changes (e.g. "ß"→"ss") +// do not misalign the highlighted spans in the original string. func highlightSearch(line, query string) string { if query == "" { return line } - lower := strings.ToLower(query) - qLen := len(query) - lineLower := strings.ToLower(line) + lineRunes := []rune(line) + queryRunes := []rune(strings.ToLower(query)) + lineLower := []rune(strings.ToLower(line)) + qLen := len(queryRunes) var b strings.Builder pos := 0 - for { - idx := strings.Index(lineLower[pos:], lower) - if idx < 0 { - b.WriteString(line[pos:]) - break + for pos <= len(lineLower)-qLen { + match := false + for i := range qLen { + if lineLower[pos+i] != queryRunes[i] { + break + } + if i == qLen-1 { + match = true + } + } + if !match { + b.WriteRune(lineRunes[pos]) + pos++ + continue } - b.WriteString(line[pos : pos+idx]) - b.WriteString(searchHighlightStyle.Render(line[pos+idx : pos+idx+qLen])) - pos += idx + qLen + b.WriteString(searchHighlightStyle.Render(string(lineRunes[pos : pos+qLen]))) + pos += qLen } + // Write remaining runes after last possible match position. + b.WriteString(string(lineRunes[pos:])) return b.String() } diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 0da6237da94..d358dd6cc9a 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -400,10 +400,12 @@ func (m *paginatedModel) restorePreSearchState() { m.savedRows = nil m.savedIter = nil m.savedExhaust = false + m.limitReached = false m.loading = false } m.cursor = 0 if m.ready { + m.computeWidths() m.viewport.SetContent(m.renderContent()) m.viewport.GotoTop() } @@ -427,6 +429,7 @@ func (m paginatedModel) executeSearch(query string) (tea.Model, tea.Cmd) { m.fetchGeneration++ m.rows = nil m.exhausted = false + m.limitReached = false m.loading = true m.cursor = 0 m.rowIter = m.makeSearchIter(query) From d4fe4fb3ce5a94cc51022a99965a27e2bd0cb4d9 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 14 Apr 2026 09:34:31 +0200 Subject: [PATCH 22/32] Fix iterator data race: set loading=true before Init() fires first fetch Init() returns a tea.Cmd that fetches rows in a goroutine but cannot modify the model (Bubbletea's Init() only returns a Cmd, not a Model). Without loading=true in the constructor, a keypress arriving before the first rowsFetchedMsg would pass maybeFetch's guard and fire a second concurrent fetch on the same non-thread-safe iterator. Co-authored-by: Isaac --- libs/tableview/paginated.go | 1 + libs/tableview/paginated_test.go | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index d358dd6cc9a..646abc787ab 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -148,6 +148,7 @@ func NewPaginatedProgram(ctx context.Context, w io.Writer, cfg *TableConfig, ite rowIter: iter, makeFetchCmd: newFetchCmdFunc(ctx), maxItems: maxItems, + loading: true, // Init() fires the first fetch } if cfg.Search != nil { diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 0b683aef1b5..deb6b6f8d0b 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "strconv" "testing" @@ -57,6 +58,54 @@ func TestPaginatedModelInit(t *testing.T) { require.NotNil(t, cmd) } +func TestNewPaginatedProgramSetsLoadingTrue(t *testing.T) { + cfg := newTestConfig() + iter := &stringRowIterator{rows: [][]string{{"alice", "30"}}} + p := NewPaginatedProgram(t.Context(), io.Discard, cfg, iter, 0) + require.NotNil(t, p) + + // The model inside the program isn't exported, so verify the invariant + // indirectly: construct the same model and confirm loading is set. + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name", "Age"}, + rowIter: iter, + makeFetchCmd: newFetchCmdFunc(t.Context()), + loading: true, + } + assert.True(t, m.loading) +} + +// TestKeyBeforeFirstFetchDoesNotDoubleFetch verifies that a keypress arriving +// before the first fetch completes does not trigger a second concurrent fetch. +// This guards against a data race on the shared iterator. +func TestKeyBeforeFirstFetchDoesNotDoubleFetch(t *testing.T) { + rows := [][]string{{"alice", "30"}, {"bob", "25"}} + iter := &stringRowIterator{rows: rows} + cfg := newTestConfig() + + // Construct the model the same way NewPaginatedProgram does, + // with loading=true since Init() fires the first fetch. + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name", "Age"}, + rowIter: iter, + makeFetchCmd: newFetchCmdFunc(t.Context()), + loading: true, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Simulate a keypress arriving before the first rowsFetchedMsg. + // With loading=true, maybeFetch must bail out. + result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + pm := result.(paginatedModel) + + assert.Nil(t, cmd, "must not trigger a second fetch while the initial fetch is in-flight") + assert.True(t, pm.loading, "loading must remain true") +} + func TestPaginatedFetchFirstBatch(t *testing.T) { rows := [][]string{{"alice", "30"}, {"bob", "25"}} m := newTestModel(t, rows, 0) From bcf339dd1f46d699b77c94650fce6a0439264470 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 09:43:58 +0200 Subject: [PATCH 23/32] Address review feedback: fix linter errors, unify command plumbing, improve consistency - Remove useless TestNewPaginatedProgramSetsLoadingTrue (linter unusedwrite) - Make ctrl+c quit from search mode instead of canceling search - Add comments explaining viewport height adjustments for search bar - Remove unused AutoDetect function and its tests - Add comments explaining template+tableview coexistence in all overrides - Thread cobra.Command through RenderIterator parameter instead of context, removing WithCommand/CommandFromContext context mechanism - Unify separator character to box-drawing (U+2500) in RenderStaticTable Co-authored-by: Isaac --- cmd/account/budget-policy/budget-policy.go | 2 +- cmd/account/budgets/budgets.go | 2 +- .../custom-app-integration.go | 2 +- cmd/account/endpoints/endpoints.go | 2 +- .../federation-policy/federation-policy.go | 2 +- cmd/account/groups-v2/groups-v2.go | 2 +- cmd/account/groups/groups.go | 2 +- .../ip-access-lists/ip-access-lists.go | 2 +- cmd/account/log-delivery/log-delivery.go | 2 +- .../metastore-assignments.go | 2 +- cmd/account/metastores/metastores.go | 2 +- .../network-connectivity.go | 4 +- .../network-policies/network-policies.go | 2 +- .../o-auth-published-apps.go | 2 +- .../published-app-integration.go | 2 +- .../service-principal-federation-policy.go | 2 +- .../service-principal-secrets.go | 2 +- .../service-principals-v2.go | 2 +- .../service-principals/service-principals.go | 2 +- cmd/account/settings-v2/settings-v2.go | 4 +- .../storage-credentials.go | 2 +- cmd/account/users-v2/users-v2.go | 2 +- cmd/account/users/users.go | 2 +- .../workspace-assignment.go | 2 +- cmd/root/io.go | 1 - cmd/workspace/alerts-v2/alerts-v2.go | 2 +- cmd/workspace/alerts/alerts.go | 2 +- cmd/workspace/alerts/overrides.go | 2 + cmd/workspace/apps-settings/apps-settings.go | 2 +- cmd/workspace/apps/apps.go | 6 +- cmd/workspace/apps/overrides.go | 2 + cmd/workspace/catalogs/catalogs.go | 2 +- cmd/workspace/catalogs/overrides.go | 2 + .../clean-room-asset-revisions.go | 2 +- .../clean-room-assets/clean-room-assets.go | 2 +- .../clean-room-auto-approval-rules.go | 2 +- .../clean-room-task-runs.go | 2 +- cmd/workspace/clean-rooms/clean-rooms.go | 2 +- .../cluster-policies/cluster-policies.go | 2 +- cmd/workspace/cluster-policies/overrides.go | 2 + cmd/workspace/clusters/clusters.go | 4 +- cmd/workspace/clusters/overrides.go | 2 + cmd/workspace/connections/connections.go | 2 +- .../consumer-fulfillments.go | 4 +- .../consumer-installations.go | 4 +- .../consumer-listings/consumer-listings.go | 4 +- .../consumer-personalization-requests.go | 2 +- .../consumer-providers/consumer-providers.go | 2 +- cmd/workspace/credentials/credentials.go | 2 +- cmd/workspace/dashboards/dashboards.go | 2 +- cmd/workspace/data-quality/data-quality.go | 4 +- cmd/workspace/database/database.go | 8 +- .../entity-tag-assignments.go | 2 +- cmd/workspace/environments/environments.go | 2 +- cmd/workspace/experiments/experiments.go | 10 +- .../external-lineage/external-lineage.go | 2 +- .../external-locations/external-locations.go | 2 +- cmd/workspace/external-locations/overrides.go | 2 + .../external-metadata/external-metadata.go | 2 +- .../feature-engineering.go | 6 +- cmd/workspace/feature-store/feature-store.go | 2 +- cmd/workspace/functions/functions.go | 2 +- .../git-credentials/git-credentials.go | 2 +- .../global-init-scripts.go | 2 +- cmd/workspace/groups-v2/groups-v2.go | 2 +- cmd/workspace/groups/groups.go | 2 +- .../instance-pools/instance-pools.go | 2 +- cmd/workspace/instance-pools/overrides.go | 2 + .../instance-profiles/instance-profiles.go | 2 +- .../ip-access-lists/ip-access-lists.go | 2 +- cmd/workspace/jobs/jobs.go | 4 +- cmd/workspace/jobs/overrides.go | 2 + .../knowledge-assistants.go | 4 +- cmd/workspace/lakeview/lakeview.go | 6 +- cmd/workspace/lakeview/overrides.go | 2 + cmd/workspace/libraries/libraries.go | 4 +- .../materialized-features.go | 2 +- cmd/workspace/metastores/metastores.go | 2 +- .../model-registry/model-registry.go | 12 +- .../model-versions/model-versions.go | 2 +- .../notification-destinations.go | 2 +- cmd/workspace/pipelines/overrides.go | 2 + cmd/workspace/pipelines/pipelines.go | 4 +- cmd/workspace/policies/policies.go | 2 +- .../policy-compliance-for-clusters.go | 2 +- .../policy-compliance-for-jobs.go | 2 +- .../policy-families/policy-families.go | 2 +- cmd/workspace/postgres/postgres.go | 10 +- .../provider-exchange-filters.go | 2 +- .../provider-exchanges/provider-exchanges.go | 6 +- .../provider-files/provider-files.go | 2 +- .../provider-listings/provider-listings.go | 2 +- .../provider-personalization-requests.go | 2 +- .../provider-providers/provider-providers.go | 2 +- cmd/workspace/providers/providers.go | 4 +- .../quality-monitor-v2/quality-monitor-v2.go | 2 +- .../queries-legacy/queries-legacy.go | 2 +- cmd/workspace/queries/queries.go | 4 +- .../recipient-federation-policies.go | 2 +- cmd/workspace/recipients/recipients.go | 2 +- .../registered-models/registered-models.go | 2 +- cmd/workspace/repos/overrides.go | 2 + cmd/workspace/repos/repos.go | 2 +- .../resource-quotas/resource-quotas.go | 2 +- cmd/workspace/schemas/overrides.go | 2 + cmd/workspace/schemas/schemas.go | 2 +- cmd/workspace/secrets/overrides.go | 2 + cmd/workspace/secrets/secrets.go | 6 +- .../service-principal-secrets-proxy.go | 2 +- .../service-principals-v2.go | 2 +- .../service-principals/service-principals.go | 2 +- cmd/workspace/serving-endpoints/overrides.go | 2 + .../serving-endpoints/serving-endpoints.go | 2 +- cmd/workspace/shares/overrides.go | 2 +- cmd/workspace/shares/shares.go | 2 +- .../storage-credentials.go | 2 +- .../system-schemas/system-schemas.go | 2 +- cmd/workspace/tables/overrides.go | 2 + cmd/workspace/tables/tables.go | 4 +- cmd/workspace/tag-policies/tag-policies.go | 2 +- .../token-management/token-management.go | 2 +- cmd/workspace/tokens/tokens.go | 2 +- cmd/workspace/users-v2/users-v2.go | 2 +- cmd/workspace/users/users.go | 2 +- .../vector-search-endpoints.go | 2 +- .../vector-search-indexes.go | 2 +- cmd/workspace/volumes/overrides.go | 2 + cmd/workspace/volumes/volumes.go | 2 +- cmd/workspace/warehouses/overrides.go | 2 + cmd/workspace/warehouses/warehouses.go | 4 +- .../workspace-bindings/workspace-bindings.go | 2 +- .../workspace-entity-tag-assignments.go | 2 +- .../workspace-settings-v2.go | 2 +- cmd/workspace/workspace/overrides.go | 2 + cmd/workspace/workspace/workspace.go | 2 +- experimental/aitools/cmd/render_test.go | 2 +- libs/cmdio/context.go | 28 ---- libs/cmdio/render.go | 15 +- libs/cmdio/render_test.go | 2 +- libs/tableview/autodetect.go | 119 ---------------- libs/tableview/autodetect_test.go | 132 ------------------ libs/tableview/common.go | 2 +- libs/tableview/paginated.go | 7 +- libs/tableview/paginated_test.go | 19 --- libs/tableview/tableview.go | 4 +- 145 files changed, 216 insertions(+), 465 deletions(-) delete mode 100644 libs/cmdio/context.go delete mode 100644 libs/tableview/autodetect.go delete mode 100644 libs/tableview/autodetect_test.go diff --git a/cmd/account/budget-policy/budget-policy.go b/cmd/account/budget-policy/budget-policy.go index 37adb68b13b..8a4075dcba1 100755 --- a/cmd/account/budget-policy/budget-policy.go +++ b/cmd/account/budget-policy/budget-policy.go @@ -260,7 +260,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.BudgetPolicy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/budgets/budgets.go b/cmd/account/budgets/budgets.go index 9fb34e4664d..8e37628b5ae 100755 --- a/cmd/account/budgets/budgets.go +++ b/cmd/account/budgets/budgets.go @@ -258,7 +258,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.Budgets.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/custom-app-integration/custom-app-integration.go b/cmd/account/custom-app-integration/custom-app-integration.go index 3a6d9d151d9..1019f655323 100755 --- a/cmd/account/custom-app-integration/custom-app-integration.go +++ b/cmd/account/custom-app-integration/custom-app-integration.go @@ -266,7 +266,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.CustomAppIntegration.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/endpoints/endpoints.go b/cmd/account/endpoints/endpoints.go index 9e1d3e7bac1..8b3584984ab 100755 --- a/cmd/account/endpoints/endpoints.go +++ b/cmd/account/endpoints/endpoints.go @@ -290,7 +290,7 @@ func newListEndpoints() *cobra.Command { listEndpointsReq.Parent = args[0] response := a.Endpoints.ListEndpoints(ctx, listEndpointsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/federation-policy/federation-policy.go b/cmd/account/federation-policy/federation-policy.go index e8aba32696b..de2621835ad 100755 --- a/cmd/account/federation-policy/federation-policy.go +++ b/cmd/account/federation-policy/federation-policy.go @@ -299,7 +299,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.FederationPolicy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/groups-v2/groups-v2.go b/cmd/account/groups-v2/groups-v2.go index 669b21f84a9..e03255479af 100755 --- a/cmd/account/groups-v2/groups-v2.go +++ b/cmd/account/groups-v2/groups-v2.go @@ -278,7 +278,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.GroupsV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/groups/groups.go b/cmd/account/groups/groups.go index 9f2a4e89dfd..9ad32b67d28 100755 --- a/cmd/account/groups/groups.go +++ b/cmd/account/groups/groups.go @@ -311,7 +311,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.Groups.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/ip-access-lists/ip-access-lists.go b/cmd/account/ip-access-lists/ip-access-lists.go index 32bf120db51..04b787cd39f 100755 --- a/cmd/account/ip-access-lists/ip-access-lists.go +++ b/cmd/account/ip-access-lists/ip-access-lists.go @@ -327,7 +327,7 @@ func newList() *cobra.Command { ctx := cmd.Context() a := cmdctx.AccountClient(ctx) response := a.IpAccessLists.List(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/log-delivery/log-delivery.go b/cmd/account/log-delivery/log-delivery.go index e94e4194353..721f4a9ffb5 100755 --- a/cmd/account/log-delivery/log-delivery.go +++ b/cmd/account/log-delivery/log-delivery.go @@ -301,7 +301,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.LogDelivery.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/metastore-assignments/metastore-assignments.go b/cmd/account/metastore-assignments/metastore-assignments.go index b1e574596f0..9fab7da7b5e 100755 --- a/cmd/account/metastore-assignments/metastore-assignments.go +++ b/cmd/account/metastore-assignments/metastore-assignments.go @@ -284,7 +284,7 @@ func newList() *cobra.Command { listReq.MetastoreId = args[0] response := a.MetastoreAssignments.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/metastores/metastores.go b/cmd/account/metastores/metastores.go index f2bfebacb18..023c12d0155 100755 --- a/cmd/account/metastores/metastores.go +++ b/cmd/account/metastores/metastores.go @@ -247,7 +247,7 @@ func newList() *cobra.Command { ctx := cmd.Context() a := cmdctx.AccountClient(ctx) response := a.Metastores.List(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/network-connectivity/network-connectivity.go b/cmd/account/network-connectivity/network-connectivity.go index 23c4e85dded..6e62b752021 100755 --- a/cmd/account/network-connectivity/network-connectivity.go +++ b/cmd/account/network-connectivity/network-connectivity.go @@ -510,7 +510,7 @@ func newListNetworkConnectivityConfigurations() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.NetworkConnectivity.ListNetworkConnectivityConfigurations(ctx, listNetworkConnectivityConfigurationsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -565,7 +565,7 @@ func newListPrivateEndpointRules() *cobra.Command { listPrivateEndpointRulesReq.NetworkConnectivityConfigId = args[0] response := a.NetworkConnectivity.ListPrivateEndpointRules(ctx, listPrivateEndpointRulesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/network-policies/network-policies.go b/cmd/account/network-policies/network-policies.go index 4165773e646..8ea14b28b87 100755 --- a/cmd/account/network-policies/network-policies.go +++ b/cmd/account/network-policies/network-policies.go @@ -265,7 +265,7 @@ func newListNetworkPoliciesRpc() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.NetworkPolicies.ListNetworkPoliciesRpc(ctx, listNetworkPoliciesRpcReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/o-auth-published-apps/o-auth-published-apps.go b/cmd/account/o-auth-published-apps/o-auth-published-apps.go index 254ef5324c1..f95e421c5c3 100755 --- a/cmd/account/o-auth-published-apps/o-auth-published-apps.go +++ b/cmd/account/o-auth-published-apps/o-auth-published-apps.go @@ -73,7 +73,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.OAuthPublishedApps.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/published-app-integration/published-app-integration.go b/cmd/account/published-app-integration/published-app-integration.go index 58e3fc923dc..d4c8f71c63f 100755 --- a/cmd/account/published-app-integration/published-app-integration.go +++ b/cmd/account/published-app-integration/published-app-integration.go @@ -258,7 +258,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.PublishedAppIntegration.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/service-principal-federation-policy/service-principal-federation-policy.go b/cmd/account/service-principal-federation-policy/service-principal-federation-policy.go index 13edacfb9ac..77d10226eef 100755 --- a/cmd/account/service-principal-federation-policy/service-principal-federation-policy.go +++ b/cmd/account/service-principal-federation-policy/service-principal-federation-policy.go @@ -341,7 +341,7 @@ func newList() *cobra.Command { } response := a.ServicePrincipalFederationPolicy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/service-principal-secrets/service-principal-secrets.go b/cmd/account/service-principal-secrets/service-principal-secrets.go index 022e8090fc4..d8b2261fa3f 100755 --- a/cmd/account/service-principal-secrets/service-principal-secrets.go +++ b/cmd/account/service-principal-secrets/service-principal-secrets.go @@ -223,7 +223,7 @@ func newList() *cobra.Command { listReq.ServicePrincipalId = args[0] response := a.ServicePrincipalSecrets.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/service-principals-v2/service-principals-v2.go b/cmd/account/service-principals-v2/service-principals-v2.go index dfdd40c36cb..edfb73130a6 100755 --- a/cmd/account/service-principals-v2/service-principals-v2.go +++ b/cmd/account/service-principals-v2/service-principals-v2.go @@ -273,7 +273,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.ServicePrincipalsV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/service-principals/service-principals.go b/cmd/account/service-principals/service-principals.go index 06d410fc33d..3f1cc353965 100755 --- a/cmd/account/service-principals/service-principals.go +++ b/cmd/account/service-principals/service-principals.go @@ -308,7 +308,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.ServicePrincipals.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/settings-v2/settings-v2.go b/cmd/account/settings-v2/settings-v2.go index 11883f11f67..ab06b1554a2 100755 --- a/cmd/account/settings-v2/settings-v2.go +++ b/cmd/account/settings-v2/settings-v2.go @@ -197,7 +197,7 @@ func newListAccountSettingsMetadata() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.SettingsV2.ListAccountSettingsMetadata(ctx, listAccountSettingsMetadataReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -260,7 +260,7 @@ func newListAccountUserPreferencesMetadata() *cobra.Command { listAccountUserPreferencesMetadataReq.UserId = args[0] response := a.SettingsV2.ListAccountUserPreferencesMetadata(ctx, listAccountUserPreferencesMetadataReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/storage-credentials/storage-credentials.go b/cmd/account/storage-credentials/storage-credentials.go index c3fce95ea29..854a91f3389 100755 --- a/cmd/account/storage-credentials/storage-credentials.go +++ b/cmd/account/storage-credentials/storage-credentials.go @@ -278,7 +278,7 @@ func newList() *cobra.Command { listReq.MetastoreId = args[0] response := a.StorageCredentials.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/users-v2/users-v2.go b/cmd/account/users-v2/users-v2.go index b7ddd391b4e..ed23cc18374 100755 --- a/cmd/account/users-v2/users-v2.go +++ b/cmd/account/users-v2/users-v2.go @@ -289,7 +289,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.UsersV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/users/users.go b/cmd/account/users/users.go index 57218bafc65..e8e88a7aa65 100755 --- a/cmd/account/users/users.go +++ b/cmd/account/users/users.go @@ -324,7 +324,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.Users.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/workspace-assignment/workspace-assignment.go b/cmd/account/workspace-assignment/workspace-assignment.go index 16c163cd6bf..f38873baae7 100755 --- a/cmd/account/workspace-assignment/workspace-assignment.go +++ b/cmd/account/workspace-assignment/workspace-assignment.go @@ -208,7 +208,7 @@ func newList() *cobra.Command { } response := a.WorkspaceAssignment.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/root/io.go b/cmd/root/io.go index f798579d87e..6393c62d66a 100644 --- a/cmd/root/io.go +++ b/cmd/root/io.go @@ -49,7 +49,6 @@ func (f *outputFlag) initializeIO(ctx context.Context, cmd *cobra.Command) (cont cmdIO := cmdio.NewIO(ctx, f.output, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), headerTemplate, template) ctx = cmdio.InContext(ctx, cmdIO) - ctx = cmdio.WithCommand(ctx, cmd) cmd.SetContext(ctx) return ctx, nil } diff --git a/cmd/workspace/alerts-v2/alerts-v2.go b/cmd/workspace/alerts-v2/alerts-v2.go index cffe9760401..5931f25c123 100755 --- a/cmd/workspace/alerts-v2/alerts-v2.go +++ b/cmd/workspace/alerts-v2/alerts-v2.go @@ -254,7 +254,7 @@ func newListAlerts() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.AlertsV2.ListAlerts(ctx, listAlertsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/alerts/alerts.go b/cmd/workspace/alerts/alerts.go index 38c012d2603..36a489fa3e0 100755 --- a/cmd/workspace/alerts/alerts.go +++ b/cmd/workspace/alerts/alerts.go @@ -285,7 +285,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Alerts.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go index b7d3a3e3f45..564c6632b6b 100644 --- a/cmd/workspace/alerts/overrides.go +++ b/cmd/workspace/alerts/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *sql.ListAlertsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%s" .Id}} {{.DisplayName}} {{.State}} {{end}}`) diff --git a/cmd/workspace/apps-settings/apps-settings.go b/cmd/workspace/apps-settings/apps-settings.go index a3306c04686..09cfc23961f 100755 --- a/cmd/workspace/apps-settings/apps-settings.go +++ b/cmd/workspace/apps-settings/apps-settings.go @@ -299,7 +299,7 @@ func newListCustomTemplates() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.AppsSettings.ListCustomTemplates(ctx, listCustomTemplatesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/apps/apps.go b/cmd/workspace/apps/apps.go index dd6f6b5dfa6..8bd3ef4961d 100755 --- a/cmd/workspace/apps/apps.go +++ b/cmd/workspace/apps/apps.go @@ -1146,7 +1146,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Apps.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1202,7 +1202,7 @@ func newListDeployments() *cobra.Command { listDeploymentsReq.AppName = args[0] response := w.Apps.ListDeployments(ctx, listDeploymentsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1256,7 +1256,7 @@ func newListSpaces() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Apps.ListSpaces(ctx, listSpacesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index 480f271a94e..bb0d097d564 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -11,6 +11,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *apps.ListAppsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Url"}} {{header "ComputeStatus"}} {{header "DeploymentStatus"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/catalogs/catalogs.go b/cmd/workspace/catalogs/catalogs.go index 43ff7642078..5daa758f1f7 100755 --- a/cmd/workspace/catalogs/catalogs.go +++ b/cmd/workspace/catalogs/catalogs.go @@ -306,7 +306,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Catalogs.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/catalogs/overrides.go b/cmd/workspace/catalogs/overrides.go index d86af1eeea4..80a7b21de85 100644 --- a/cmd/workspace/catalogs/overrides.go +++ b/cmd/workspace/catalogs/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *catalog.ListCatalogsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Type"}} {{header "Comment"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/clean-room-asset-revisions/clean-room-asset-revisions.go b/cmd/workspace/clean-room-asset-revisions/clean-room-asset-revisions.go index b836f0be266..858c4d483d7 100755 --- a/cmd/workspace/clean-room-asset-revisions/clean-room-asset-revisions.go +++ b/cmd/workspace/clean-room-asset-revisions/clean-room-asset-revisions.go @@ -156,7 +156,7 @@ func newList() *cobra.Command { listReq.Name = args[2] response := w.CleanRoomAssetRevisions.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-room-assets/clean-room-assets.go b/cmd/workspace/clean-room-assets/clean-room-assets.go index 4654d9b3572..5025f379d3f 100755 --- a/cmd/workspace/clean-room-assets/clean-room-assets.go +++ b/cmd/workspace/clean-room-assets/clean-room-assets.go @@ -409,7 +409,7 @@ func newList() *cobra.Command { listReq.CleanRoomName = args[0] response := w.CleanRoomAssets.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-room-auto-approval-rules/clean-room-auto-approval-rules.go b/cmd/workspace/clean-room-auto-approval-rules/clean-room-auto-approval-rules.go index b45d0b8745d..984feb39404 100755 --- a/cmd/workspace/clean-room-auto-approval-rules/clean-room-auto-approval-rules.go +++ b/cmd/workspace/clean-room-auto-approval-rules/clean-room-auto-approval-rules.go @@ -262,7 +262,7 @@ func newList() *cobra.Command { listReq.CleanRoomName = args[0] response := w.CleanRoomAutoApprovalRules.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-room-task-runs/clean-room-task-runs.go b/cmd/workspace/clean-room-task-runs/clean-room-task-runs.go index a889675a60b..f14f8d61143 100755 --- a/cmd/workspace/clean-room-task-runs/clean-room-task-runs.go +++ b/cmd/workspace/clean-room-task-runs/clean-room-task-runs.go @@ -76,7 +76,7 @@ func newList() *cobra.Command { listReq.CleanRoomName = args[0] response := w.CleanRoomTaskRuns.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-rooms/clean-rooms.go b/cmd/workspace/clean-rooms/clean-rooms.go index 3aea991abff..2abcc224f21 100755 --- a/cmd/workspace/clean-rooms/clean-rooms.go +++ b/cmd/workspace/clean-rooms/clean-rooms.go @@ -369,7 +369,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.CleanRooms.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/cluster-policies/cluster-policies.go b/cmd/workspace/cluster-policies/cluster-policies.go index 83df4ae3117..803e67343ba 100755 --- a/cmd/workspace/cluster-policies/cluster-policies.go +++ b/cmd/workspace/cluster-policies/cluster-policies.go @@ -584,7 +584,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ClusterPolicies.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/cluster-policies/overrides.go b/cmd/workspace/cluster-policies/overrides.go index 8bc320aa5fd..1c85b030ad9 100644 --- a/cmd/workspace/cluster-policies/overrides.go +++ b/cmd/workspace/cluster-policies/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *compute.ListClusterPoliciesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.PolicyId | green}} {{.Name}} {{end}}`) diff --git a/cmd/workspace/clusters/clusters.go b/cmd/workspace/clusters/clusters.go index 64f2d810971..dcf912d76fb 100755 --- a/cmd/workspace/clusters/clusters.go +++ b/cmd/workspace/clusters/clusters.go @@ -696,7 +696,7 @@ func newEvents() *cobra.Command { } response := w.Clusters.Events(ctx, eventsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -957,7 +957,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Clusters.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clusters/overrides.go b/cmd/workspace/clusters/overrides.go index 910918b0178..057caabdca8 100644 --- a/cmd/workspace/clusters/overrides.go +++ b/cmd/workspace/clusters/overrides.go @@ -12,6 +12,8 @@ import ( // Below we add overrides for filter flags for cluster list command to allow for custom filtering // Auto generating such flags is not yet supported by the CLI generator func listOverride(listCmd *cobra.Command, listReq *compute.ListClustersRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "ID"}} {{header "Name"}} {{header "State"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/connections/connections.go b/cmd/workspace/connections/connections.go index f7a291e51c4..a2b66155fb5 100755 --- a/cmd/workspace/connections/connections.go +++ b/cmd/workspace/connections/connections.go @@ -303,7 +303,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Connections.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-fulfillments/consumer-fulfillments.go b/cmd/workspace/consumer-fulfillments/consumer-fulfillments.go index d08d1165454..2fd1a315af9 100755 --- a/cmd/workspace/consumer-fulfillments/consumer-fulfillments.go +++ b/cmd/workspace/consumer-fulfillments/consumer-fulfillments.go @@ -73,7 +73,7 @@ func newGet() *cobra.Command { getReq.ListingId = args[0] response := w.ConsumerFulfillments.Get(ctx, getReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -130,7 +130,7 @@ func newList() *cobra.Command { listReq.ListingId = args[0] response := w.ConsumerFulfillments.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-installations/consumer-installations.go b/cmd/workspace/consumer-installations/consumer-installations.go index 09ecadd8a23..7e5819f6a3b 100755 --- a/cmd/workspace/consumer-installations/consumer-installations.go +++ b/cmd/workspace/consumer-installations/consumer-installations.go @@ -206,7 +206,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ConsumerInstallations.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -259,7 +259,7 @@ func newListListingInstallations() *cobra.Command { listListingInstallationsReq.ListingId = args[0] response := w.ConsumerInstallations.ListListingInstallations(ctx, listListingInstallationsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-listings/consumer-listings.go b/cmd/workspace/consumer-listings/consumer-listings.go index 43e7ea74c03..31c9693fee2 100755 --- a/cmd/workspace/consumer-listings/consumer-listings.go +++ b/cmd/workspace/consumer-listings/consumer-listings.go @@ -204,7 +204,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ConsumerListings.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -279,7 +279,7 @@ func newSearch() *cobra.Command { searchReq.Query = args[0] response := w.ConsumerListings.Search(ctx, searchReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-personalization-requests/consumer-personalization-requests.go b/cmd/workspace/consumer-personalization-requests/consumer-personalization-requests.go index e1ed812c4c4..487cd623ebe 100755 --- a/cmd/workspace/consumer-personalization-requests/consumer-personalization-requests.go +++ b/cmd/workspace/consumer-personalization-requests/consumer-personalization-requests.go @@ -207,7 +207,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ConsumerPersonalizationRequests.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-providers/consumer-providers.go b/cmd/workspace/consumer-providers/consumer-providers.go index 058accfa1ff..3c61dda7780 100755 --- a/cmd/workspace/consumer-providers/consumer-providers.go +++ b/cmd/workspace/consumer-providers/consumer-providers.go @@ -196,7 +196,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ConsumerProviders.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/credentials/credentials.go b/cmd/workspace/credentials/credentials.go index f0e37742688..f97b70787ae 100755 --- a/cmd/workspace/credentials/credentials.go +++ b/cmd/workspace/credentials/credentials.go @@ -395,7 +395,7 @@ func newListCredentials() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Credentials.ListCredentials(ctx, listCredentialsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/dashboards/dashboards.go b/cmd/workspace/dashboards/dashboards.go index 95ee2111bef..775f79a7ebc 100755 --- a/cmd/workspace/dashboards/dashboards.go +++ b/cmd/workspace/dashboards/dashboards.go @@ -213,7 +213,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Dashboards.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/data-quality/data-quality.go b/cmd/workspace/data-quality/data-quality.go index 59dfb28a99e..4338b42a946 100755 --- a/cmd/workspace/data-quality/data-quality.go +++ b/cmd/workspace/data-quality/data-quality.go @@ -729,7 +729,7 @@ func newListMonitor() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.DataQuality.ListMonitor(ctx, listMonitorReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -814,7 +814,7 @@ func newListRefresh() *cobra.Command { listRefreshReq.ObjectId = args[1] response := w.DataQuality.ListRefresh(ctx, listRefreshReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/database/database.go b/cmd/workspace/database/database.go index a84f9a09713..c5e85f62ee4 100755 --- a/cmd/workspace/database/database.go +++ b/cmd/workspace/database/database.go @@ -1222,7 +1222,7 @@ func newListDatabaseCatalogs() *cobra.Command { listDatabaseCatalogsReq.InstanceName = args[0] response := w.Database.ListDatabaseCatalogs(ctx, listDatabaseCatalogsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1282,7 +1282,7 @@ func newListDatabaseInstanceRoles() *cobra.Command { listDatabaseInstanceRolesReq.InstanceName = args[0] response := w.Database.ListDatabaseInstanceRoles(ctx, listDatabaseInstanceRolesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1331,7 +1331,7 @@ func newListDatabaseInstances() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Database.ListDatabaseInstances(ctx, listDatabaseInstancesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1390,7 +1390,7 @@ func newListSyncedDatabaseTables() *cobra.Command { listSyncedDatabaseTablesReq.InstanceName = args[0] response := w.Database.ListSyncedDatabaseTables(ctx, listSyncedDatabaseTablesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/entity-tag-assignments/entity-tag-assignments.go b/cmd/workspace/entity-tag-assignments/entity-tag-assignments.go index b8893a9c806..c1ced90c004 100755 --- a/cmd/workspace/entity-tag-assignments/entity-tag-assignments.go +++ b/cmd/workspace/entity-tag-assignments/entity-tag-assignments.go @@ -328,7 +328,7 @@ func newList() *cobra.Command { listReq.EntityName = args[1] response := w.EntityTagAssignments.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/environments/environments.go b/cmd/workspace/environments/environments.go index 6afb38437bc..05001129d8d 100755 --- a/cmd/workspace/environments/environments.go +++ b/cmd/workspace/environments/environments.go @@ -454,7 +454,7 @@ func newListWorkspaceBaseEnvironments() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Environments.ListWorkspaceBaseEnvironments(ctx, listWorkspaceBaseEnvironmentsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/experiments/experiments.go b/cmd/workspace/experiments/experiments.go index 5527006c238..c525c48f4f0 100755 --- a/cmd/workspace/experiments/experiments.go +++ b/cmd/workspace/experiments/experiments.go @@ -1032,7 +1032,7 @@ func newGetHistory() *cobra.Command { getHistoryReq.MetricKey = args[0] response := w.Experiments.GetHistory(ctx, getHistoryReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1320,7 +1320,7 @@ func newListArtifacts() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Experiments.ListArtifacts(ctx, listArtifactsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1372,7 +1372,7 @@ func newListExperiments() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Experiments.ListExperiments(ctx, listExperimentsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -2329,7 +2329,7 @@ func newSearchExperiments() *cobra.Command { } response := w.Experiments.SearchExperiments(ctx, searchExperimentsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -2476,7 +2476,7 @@ func newSearchRuns() *cobra.Command { } response := w.Experiments.SearchRuns(ctx, searchRunsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/external-lineage/external-lineage.go b/cmd/workspace/external-lineage/external-lineage.go index be80f67c90a..b3e272ba202 100755 --- a/cmd/workspace/external-lineage/external-lineage.go +++ b/cmd/workspace/external-lineage/external-lineage.go @@ -258,7 +258,7 @@ func newListExternalLineageRelationships() *cobra.Command { } response := w.ExternalLineage.ListExternalLineageRelationships(ctx, listExternalLineageRelationshipsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/external-locations/external-locations.go b/cmd/workspace/external-locations/external-locations.go index ea3a855921e..e59e5c7b07c 100755 --- a/cmd/workspace/external-locations/external-locations.go +++ b/cmd/workspace/external-locations/external-locations.go @@ -319,7 +319,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ExternalLocations.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/external-locations/overrides.go b/cmd/workspace/external-locations/overrides.go index 607550da3a3..1554265fce1 100644 --- a/cmd/workspace/external-locations/overrides.go +++ b/cmd/workspace/external-locations/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *catalog.ListExternalLocationsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Credential"}} {{header "URL"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/external-metadata/external-metadata.go b/cmd/workspace/external-metadata/external-metadata.go index 7768c5e99bd..619ffaf6c8d 100755 --- a/cmd/workspace/external-metadata/external-metadata.go +++ b/cmd/workspace/external-metadata/external-metadata.go @@ -323,7 +323,7 @@ func newListExternalMetadata() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ExternalMetadata.ListExternalMetadata(ctx, listExternalMetadataReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/feature-engineering/feature-engineering.go b/cmd/workspace/feature-engineering/feature-engineering.go index 5d1e5375079..097a37817d2 100755 --- a/cmd/workspace/feature-engineering/feature-engineering.go +++ b/cmd/workspace/feature-engineering/feature-engineering.go @@ -728,7 +728,7 @@ func newListFeatures() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.FeatureEngineering.ListFeatures(ctx, listFeaturesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -781,7 +781,7 @@ func newListKafkaConfigs() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.FeatureEngineering.ListKafkaConfigs(ctx, listKafkaConfigsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -831,7 +831,7 @@ func newListMaterializedFeatures() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.FeatureEngineering.ListMaterializedFeatures(ctx, listMaterializedFeaturesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/feature-store/feature-store.go b/cmd/workspace/feature-store/feature-store.go index d9bb4633292..3cdd48541cc 100755 --- a/cmd/workspace/feature-store/feature-store.go +++ b/cmd/workspace/feature-store/feature-store.go @@ -338,7 +338,7 @@ func newListOnlineStores() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.FeatureStore.ListOnlineStores(ctx, listOnlineStoresReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/functions/functions.go b/cmd/workspace/functions/functions.go index 9ecf0948b39..7f9eb1968b3 100755 --- a/cmd/workspace/functions/functions.go +++ b/cmd/workspace/functions/functions.go @@ -327,7 +327,7 @@ func newList() *cobra.Command { listReq.SchemaName = args[1] response := w.Functions.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/git-credentials/git-credentials.go b/cmd/workspace/git-credentials/git-credentials.go index 9d3cae1b4f1..15248b72138 100755 --- a/cmd/workspace/git-credentials/git-credentials.go +++ b/cmd/workspace/git-credentials/git-credentials.go @@ -318,7 +318,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.GitCredentials.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/global-init-scripts/global-init-scripts.go b/cmd/workspace/global-init-scripts/global-init-scripts.go index 610ba872690..535bee013fe 100755 --- a/cmd/workspace/global-init-scripts/global-init-scripts.go +++ b/cmd/workspace/global-init-scripts/global-init-scripts.go @@ -299,7 +299,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.GlobalInitScripts.List(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/groups-v2/groups-v2.go b/cmd/workspace/groups-v2/groups-v2.go index cd3247c526b..e02fb4547c8 100755 --- a/cmd/workspace/groups-v2/groups-v2.go +++ b/cmd/workspace/groups-v2/groups-v2.go @@ -277,7 +277,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.GroupsV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/groups/groups.go b/cmd/workspace/groups/groups.go index 595c03218bb..9d02a13e812 100755 --- a/cmd/workspace/groups/groups.go +++ b/cmd/workspace/groups/groups.go @@ -309,7 +309,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Groups.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/instance-pools/instance-pools.go b/cmd/workspace/instance-pools/instance-pools.go index 640058629d1..1f31a5a0792 100755 --- a/cmd/workspace/instance-pools/instance-pools.go +++ b/cmd/workspace/instance-pools/instance-pools.go @@ -591,7 +591,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.InstancePools.List(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/instance-pools/overrides.go b/cmd/workspace/instance-pools/overrides.go index ddf6181f15c..ac9709afd97 100644 --- a/cmd/workspace/instance-pools/overrides.go +++ b/cmd/workspace/instance-pools/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.InstancePoolId|green}} {{.InstancePoolName}} {{.NodeTypeId}} {{.State}} {{end}}`) diff --git a/cmd/workspace/instance-profiles/instance-profiles.go b/cmd/workspace/instance-profiles/instance-profiles.go index 5acc3c30bf7..d833e427536 100755 --- a/cmd/workspace/instance-profiles/instance-profiles.go +++ b/cmd/workspace/instance-profiles/instance-profiles.go @@ -257,7 +257,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.InstanceProfiles.List(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/ip-access-lists/ip-access-lists.go b/cmd/workspace/ip-access-lists/ip-access-lists.go index b1a8e61ffa6..32d7026e1b5 100755 --- a/cmd/workspace/ip-access-lists/ip-access-lists.go +++ b/cmd/workspace/ip-access-lists/ip-access-lists.go @@ -328,7 +328,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.IpAccessLists.List(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/jobs/jobs.go b/cmd/workspace/jobs/jobs.go index 93e8f29bb4a..e12895226f9 100755 --- a/cmd/workspace/jobs/jobs.go +++ b/cmd/workspace/jobs/jobs.go @@ -1027,7 +1027,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Jobs.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1086,7 +1086,7 @@ func newListRuns() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Jobs.ListRuns(ctx, listRunsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/jobs/overrides.go b/cmd/workspace/jobs/overrides.go index d20efd6195a..34dd9b392de 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -12,6 +12,8 @@ import ( ) func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .JobId}} {{.Settings.Name}} {{end}}`) diff --git a/cmd/workspace/knowledge-assistants/knowledge-assistants.go b/cmd/workspace/knowledge-assistants/knowledge-assistants.go index a23c0fa88e9..a31ce630403 100755 --- a/cmd/workspace/knowledge-assistants/knowledge-assistants.go +++ b/cmd/workspace/knowledge-assistants/knowledge-assistants.go @@ -508,7 +508,7 @@ func newListKnowledgeAssistants() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.KnowledgeAssistants.ListKnowledgeAssistants(ctx, listKnowledgeAssistantsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -565,7 +565,7 @@ func newListKnowledgeSources() *cobra.Command { listKnowledgeSourcesReq.Parent = args[0] response := w.KnowledgeAssistants.ListKnowledgeSources(ctx, listKnowledgeSourcesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/lakeview/lakeview.go b/cmd/workspace/lakeview/lakeview.go index 4dd071a903c..29d2caa0e48 100755 --- a/cmd/workspace/lakeview/lakeview.go +++ b/cmd/workspace/lakeview/lakeview.go @@ -690,7 +690,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Lakeview.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -744,7 +744,7 @@ func newListSchedules() *cobra.Command { listSchedulesReq.DashboardId = args[0] response := w.Lakeview.ListSchedules(ctx, listSchedulesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -800,7 +800,7 @@ func newListSubscriptions() *cobra.Command { listSubscriptionsReq.ScheduleId = args[1] response := w.Lakeview.ListSubscriptions(ctx, listSubscriptionsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/lakeview/overrides.go b/cmd/workspace/lakeview/overrides.go index 55357f703d6..d57b2278a1c 100644 --- a/cmd/workspace/lakeview/overrides.go +++ b/cmd/workspace/lakeview/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *dashboards.ListDashboardsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Dashboard ID"}} {{header "Name"}} {{header "State"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/libraries/libraries.go b/cmd/workspace/libraries/libraries.go index db2225d9e1c..7618bd6be44 100755 --- a/cmd/workspace/libraries/libraries.go +++ b/cmd/workspace/libraries/libraries.go @@ -80,7 +80,7 @@ func newAllClusterStatuses() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.Libraries.AllClusterStatuses(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -139,7 +139,7 @@ func newClusterStatus() *cobra.Command { clusterStatusReq.ClusterId = args[0] response := w.Libraries.ClusterStatus(ctx, clusterStatusReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/materialized-features/materialized-features.go b/cmd/workspace/materialized-features/materialized-features.go index 12194b38fee..3cb3d9ce94c 100755 --- a/cmd/workspace/materialized-features/materialized-features.go +++ b/cmd/workspace/materialized-features/materialized-features.go @@ -338,7 +338,7 @@ func newListFeatureTags() *cobra.Command { listFeatureTagsReq.FeatureName = args[1] response := w.MaterializedFeatures.ListFeatureTags(ctx, listFeatureTagsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/metastores/metastores.go b/cmd/workspace/metastores/metastores.go index 9f00ed1bfd9..3ed3893d329 100755 --- a/cmd/workspace/metastores/metastores.go +++ b/cmd/workspace/metastores/metastores.go @@ -443,7 +443,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Metastores.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/model-registry/model-registry.go b/cmd/workspace/model-registry/model-registry.go index 047040ce46e..fceff6a5cf4 100755 --- a/cmd/workspace/model-registry/model-registry.go +++ b/cmd/workspace/model-registry/model-registry.go @@ -1102,7 +1102,7 @@ func newGetLatestVersions() *cobra.Command { } response := w.ModelRegistry.GetLatestVersions(ctx, getLatestVersionsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1441,7 +1441,7 @@ func newListModels() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ModelRegistry.ListModels(ctx, listModelsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1496,7 +1496,7 @@ func newListTransitionRequests() *cobra.Command { listTransitionRequestsReq.Version = args[1] response := w.ModelRegistry.ListTransitionRequests(ctx, listTransitionRequestsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1549,7 +1549,7 @@ func newListWebhooks() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ModelRegistry.ListWebhooks(ctx, listWebhooksReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1782,7 +1782,7 @@ func newSearchModelVersions() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ModelRegistry.SearchModelVersions(ctx, searchModelVersionsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1835,7 +1835,7 @@ func newSearchModels() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ModelRegistry.SearchModels(ctx, searchModelsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/model-versions/model-versions.go b/cmd/workspace/model-versions/model-versions.go index b2ceca74321..a0c20b50437 100755 --- a/cmd/workspace/model-versions/model-versions.go +++ b/cmd/workspace/model-versions/model-versions.go @@ -308,7 +308,7 @@ func newList() *cobra.Command { listReq.FullName = args[0] response := w.ModelVersions.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/notification-destinations/notification-destinations.go b/cmd/workspace/notification-destinations/notification-destinations.go index cc365c9e7e7..4eebe458085 100755 --- a/cmd/workspace/notification-destinations/notification-destinations.go +++ b/cmd/workspace/notification-destinations/notification-destinations.go @@ -255,7 +255,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.NotificationDestinations.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 083c77c5919..bbef66bdfc1 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -16,6 +16,8 @@ import ( ) func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipelinesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%s" .PipelineId}} {{.Name}} {{blue "%s" .State}} {{end}}`) diff --git a/cmd/workspace/pipelines/pipelines.go b/cmd/workspace/pipelines/pipelines.go index 56138c45a19..ac34f639127 100755 --- a/cmd/workspace/pipelines/pipelines.go +++ b/cmd/workspace/pipelines/pipelines.go @@ -588,7 +588,7 @@ func newListPipelineEvents() *cobra.Command { listPipelineEventsReq.PipelineId = args[0] response := w.Pipelines.ListPipelineEvents(ctx, listPipelineEventsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -641,7 +641,7 @@ func newListPipelines() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Pipelines.ListPipelines(ctx, listPipelinesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/policies/policies.go b/cmd/workspace/policies/policies.go index 35e8d0cfab7..0d1e81fbdc1 100755 --- a/cmd/workspace/policies/policies.go +++ b/cmd/workspace/policies/policies.go @@ -371,7 +371,7 @@ func newListPolicies() *cobra.Command { listPoliciesReq.OnSecurableFullname = args[1] response := w.Policies.ListPolicies(ctx, listPoliciesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/policy-compliance-for-clusters/policy-compliance-for-clusters.go b/cmd/workspace/policy-compliance-for-clusters/policy-compliance-for-clusters.go index 8d715bd6d72..fe36251d262 100755 --- a/cmd/workspace/policy-compliance-for-clusters/policy-compliance-for-clusters.go +++ b/cmd/workspace/policy-compliance-for-clusters/policy-compliance-for-clusters.go @@ -242,7 +242,7 @@ func newListCompliance() *cobra.Command { listComplianceReq.PolicyId = args[0] response := w.PolicyComplianceForClusters.ListCompliance(ctx, listComplianceReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/policy-compliance-for-jobs/policy-compliance-for-jobs.go b/cmd/workspace/policy-compliance-for-jobs/policy-compliance-for-jobs.go index 4a025f4ba1a..cfe17ab8113 100755 --- a/cmd/workspace/policy-compliance-for-jobs/policy-compliance-for-jobs.go +++ b/cmd/workspace/policy-compliance-for-jobs/policy-compliance-for-jobs.go @@ -245,7 +245,7 @@ func newListCompliance() *cobra.Command { listComplianceReq.PolicyId = args[0] response := w.PolicyComplianceForJobs.ListCompliance(ctx, listComplianceReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/policy-families/policy-families.go b/cmd/workspace/policy-families/policy-families.go index f5b65a6a3a7..88b75ddcbf0 100755 --- a/cmd/workspace/policy-families/policy-families.go +++ b/cmd/workspace/policy-families/policy-families.go @@ -139,7 +139,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.PolicyFamilies.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/postgres/postgres.go b/cmd/workspace/postgres/postgres.go index f098764d032..741224092a0 100755 --- a/cmd/workspace/postgres/postgres.go +++ b/cmd/workspace/postgres/postgres.go @@ -1668,7 +1668,7 @@ func newListBranches() *cobra.Command { listBranchesReq.Parent = args[0] response := w.Postgres.ListBranches(ctx, listBranchesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1728,7 +1728,7 @@ func newListDatabases() *cobra.Command { listDatabasesReq.Parent = args[0] response := w.Postgres.ListDatabases(ctx, listDatabasesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1785,7 +1785,7 @@ func newListEndpoints() *cobra.Command { listEndpointsReq.Parent = args[0] response := w.Postgres.ListEndpoints(ctx, listEndpointsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1837,7 +1837,7 @@ func newListProjects() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Postgres.ListProjects(ctx, listProjectsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -1894,7 +1894,7 @@ func newListRoles() *cobra.Command { listRolesReq.Parent = args[0] response := w.Postgres.ListRoles(ctx, listRolesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-exchange-filters/provider-exchange-filters.go b/cmd/workspace/provider-exchange-filters/provider-exchange-filters.go index 4c62276bf6b..f26310806f4 100755 --- a/cmd/workspace/provider-exchange-filters/provider-exchange-filters.go +++ b/cmd/workspace/provider-exchange-filters/provider-exchange-filters.go @@ -207,7 +207,7 @@ func newList() *cobra.Command { listReq.ExchangeId = args[0] response := w.ProviderExchangeFilters.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-exchanges/provider-exchanges.go b/cmd/workspace/provider-exchanges/provider-exchanges.go index da333288ca1..8d22e546946 100755 --- a/cmd/workspace/provider-exchanges/provider-exchanges.go +++ b/cmd/workspace/provider-exchanges/provider-exchanges.go @@ -383,7 +383,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ProviderExchanges.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -436,7 +436,7 @@ func newListExchangesForListing() *cobra.Command { listExchangesForListingReq.ListingId = args[0] response := w.ProviderExchanges.ListExchangesForListing(ctx, listExchangesForListingReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -489,7 +489,7 @@ func newListListingsForExchange() *cobra.Command { listListingsForExchangeReq.ExchangeId = args[0] response := w.ProviderExchanges.ListListingsForExchange(ctx, listListingsForExchangeReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-files/provider-files.go b/cmd/workspace/provider-files/provider-files.go index 69de0a23ec2..485f76cfde4 100755 --- a/cmd/workspace/provider-files/provider-files.go +++ b/cmd/workspace/provider-files/provider-files.go @@ -287,7 +287,7 @@ func newList() *cobra.Command { } response := w.ProviderFiles.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-listings/provider-listings.go b/cmd/workspace/provider-listings/provider-listings.go index 9686737d2e7..03e2f64668a 100755 --- a/cmd/workspace/provider-listings/provider-listings.go +++ b/cmd/workspace/provider-listings/provider-listings.go @@ -272,7 +272,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ProviderListings.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-personalization-requests/provider-personalization-requests.go b/cmd/workspace/provider-personalization-requests/provider-personalization-requests.go index 57eccbdbd5d..9da88ec06a3 100755 --- a/cmd/workspace/provider-personalization-requests/provider-personalization-requests.go +++ b/cmd/workspace/provider-personalization-requests/provider-personalization-requests.go @@ -76,7 +76,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ProviderPersonalizationRequests.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-providers/provider-providers.go b/cmd/workspace/provider-providers/provider-providers.go index 68f0198ac9d..a68663b001b 100755 --- a/cmd/workspace/provider-providers/provider-providers.go +++ b/cmd/workspace/provider-providers/provider-providers.go @@ -271,7 +271,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ProviderProviders.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/providers/providers.go b/cmd/workspace/providers/providers.go index a7fa13a2351..6f120d8dc92 100755 --- a/cmd/workspace/providers/providers.go +++ b/cmd/workspace/providers/providers.go @@ -319,7 +319,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Providers.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -453,7 +453,7 @@ func newListShares() *cobra.Command { listSharesReq.Name = args[0] response := w.Providers.ListShares(ctx, listSharesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/quality-monitor-v2/quality-monitor-v2.go b/cmd/workspace/quality-monitor-v2/quality-monitor-v2.go index 8c5e3547bdb..54b97681fc2 100755 --- a/cmd/workspace/quality-monitor-v2/quality-monitor-v2.go +++ b/cmd/workspace/quality-monitor-v2/quality-monitor-v2.go @@ -287,7 +287,7 @@ func newListQualityMonitor() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.QualityMonitorV2.ListQualityMonitor(ctx, listQualityMonitorReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/queries-legacy/queries-legacy.go b/cmd/workspace/queries-legacy/queries-legacy.go index 8537645e24e..3411520b07b 100755 --- a/cmd/workspace/queries-legacy/queries-legacy.go +++ b/cmd/workspace/queries-legacy/queries-legacy.go @@ -303,7 +303,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.QueriesLegacy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/queries/queries.go b/cmd/workspace/queries/queries.go index 64c94f0df05..eebeac17557 100755 --- a/cmd/workspace/queries/queries.go +++ b/cmd/workspace/queries/queries.go @@ -286,7 +286,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Queries.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -354,7 +354,7 @@ func newListVisualizations() *cobra.Command { listVisualizationsReq.Id = args[0] response := w.Queries.ListVisualizations(ctx, listVisualizationsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/recipient-federation-policies/recipient-federation-policies.go b/cmd/workspace/recipient-federation-policies/recipient-federation-policies.go index 5ff07221f92..7f4f11fa840 100755 --- a/cmd/workspace/recipient-federation-policies/recipient-federation-policies.go +++ b/cmd/workspace/recipient-federation-policies/recipient-federation-policies.go @@ -329,7 +329,7 @@ func newList() *cobra.Command { listReq.RecipientName = args[0] response := w.RecipientFederationPolicies.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/recipients/recipients.go b/cmd/workspace/recipients/recipients.go index 9722aab9827..d824f3ad7fa 100755 --- a/cmd/workspace/recipients/recipients.go +++ b/cmd/workspace/recipients/recipients.go @@ -313,7 +313,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Recipients.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/registered-models/registered-models.go b/cmd/workspace/registered-models/registered-models.go index f60b1d10a17..1fc3db342f0 100755 --- a/cmd/workspace/registered-models/registered-models.go +++ b/cmd/workspace/registered-models/registered-models.go @@ -432,7 +432,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.RegisteredModels.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 65cc353dd76..48f78f64e1f 100644 --- a/cmd/workspace/repos/overrides.go +++ b/cmd/workspace/repos/overrides.go @@ -18,6 +18,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *workspace.ListReposRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .Id}} {{.Path}} {{.Branch|blue}} {{.Url|cyan}} {{end}}`) diff --git a/cmd/workspace/repos/repos.go b/cmd/workspace/repos/repos.go index 24075353f08..e511a73f602 100755 --- a/cmd/workspace/repos/repos.go +++ b/cmd/workspace/repos/repos.go @@ -462,7 +462,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Repos.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/resource-quotas/resource-quotas.go b/cmd/workspace/resource-quotas/resource-quotas.go index d639c5fb4ea..da31aab2f18 100755 --- a/cmd/workspace/resource-quotas/resource-quotas.go +++ b/cmd/workspace/resource-quotas/resource-quotas.go @@ -150,7 +150,7 @@ func newListQuotas() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ResourceQuotas.ListQuotas(ctx, listQuotasReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/schemas/overrides.go b/cmd/workspace/schemas/overrides.go index 0e9b1b03b93..cc16d11c2e7 100644 --- a/cmd/workspace/schemas/overrides.go +++ b/cmd/workspace/schemas/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *catalog.ListSchemasRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Full Name"}} {{header "Owner"}} {{header "Comment"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/schemas/schemas.go b/cmd/workspace/schemas/schemas.go index e51261aae1c..d56e168ff13 100755 --- a/cmd/workspace/schemas/schemas.go +++ b/cmd/workspace/schemas/schemas.go @@ -308,7 +308,7 @@ func newList() *cobra.Command { listReq.CatalogName = args[0] response := w.Schemas.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index 5de7268905e..009bbfe84cf 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -14,6 +14,8 @@ func cmdOverride(cmd *cobra.Command) { } func listScopesOverride(listScopesCmd *cobra.Command) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listScopesCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Scope"}} {{header "Backend Type"}}`) listScopesCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/secrets/secrets.go b/cmd/workspace/secrets/secrets.go index 34f356d841c..8023dcd674a 100755 --- a/cmd/workspace/secrets/secrets.go +++ b/cmd/workspace/secrets/secrets.go @@ -667,7 +667,7 @@ func newListAcls() *cobra.Command { listAclsReq.Scope = args[0] response := w.Secrets.ListAcls(ctx, listAclsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -716,7 +716,7 @@ func newListScopes() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.Secrets.ListScopes(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -785,7 +785,7 @@ func newListSecrets() *cobra.Command { listSecretsReq.Scope = args[0] response := w.Secrets.ListSecrets(ctx, listSecretsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/service-principal-secrets-proxy/service-principal-secrets-proxy.go b/cmd/workspace/service-principal-secrets-proxy/service-principal-secrets-proxy.go index c213797bbe1..fc7bbe0bedf 100755 --- a/cmd/workspace/service-principal-secrets-proxy/service-principal-secrets-proxy.go +++ b/cmd/workspace/service-principal-secrets-proxy/service-principal-secrets-proxy.go @@ -225,7 +225,7 @@ func newList() *cobra.Command { listReq.ServicePrincipalId = args[0] response := w.ServicePrincipalSecretsProxy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/service-principals-v2/service-principals-v2.go b/cmd/workspace/service-principals-v2/service-principals-v2.go index 1620df923db..01bbf6d7af6 100755 --- a/cmd/workspace/service-principals-v2/service-principals-v2.go +++ b/cmd/workspace/service-principals-v2/service-principals-v2.go @@ -276,7 +276,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ServicePrincipalsV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/service-principals/service-principals.go b/cmd/workspace/service-principals/service-principals.go index 76737b45176..b24dbf66692 100755 --- a/cmd/workspace/service-principals/service-principals.go +++ b/cmd/workspace/service-principals/service-principals.go @@ -308,7 +308,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ServicePrincipals.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/serving-endpoints/overrides.go b/cmd/workspace/serving-endpoints/overrides.go index 611428f18d9..f0f5eb60fb5 100644 --- a/cmd/workspace/serving-endpoints/overrides.go +++ b/cmd/workspace/serving-endpoints/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%s" .Name}} {{if .State}}{{.State.Ready}}{{end}} {{.Creator}} {{end}}`) diff --git a/cmd/workspace/serving-endpoints/serving-endpoints.go b/cmd/workspace/serving-endpoints/serving-endpoints.go index 84313eed21b..fa6e4181d06 100755 --- a/cmd/workspace/serving-endpoints/serving-endpoints.go +++ b/cmd/workspace/serving-endpoints/serving-endpoints.go @@ -758,7 +758,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.ServingEndpoints.List(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/shares/overrides.go b/cmd/workspace/shares/overrides.go index a959f45cdb4..85b8854b7ff 100644 --- a/cmd/workspace/shares/overrides.go +++ b/cmd/workspace/shares/overrides.go @@ -38,7 +38,7 @@ func newList() *cobra.Command { //nolint:staticcheck // this API is deprecated but we still need to expose this in the CLI. response := w.Shares.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/shares/shares.go b/cmd/workspace/shares/shares.go index 1902cc8c73b..132d92f96bb 100755 --- a/cmd/workspace/shares/shares.go +++ b/cmd/workspace/shares/shares.go @@ -287,7 +287,7 @@ func newListShares() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Shares.ListShares(ctx, listSharesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/storage-credentials/storage-credentials.go b/cmd/workspace/storage-credentials/storage-credentials.go index 196a57f047e..bf19c4d55a0 100755 --- a/cmd/workspace/storage-credentials/storage-credentials.go +++ b/cmd/workspace/storage-credentials/storage-credentials.go @@ -313,7 +313,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.StorageCredentials.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/system-schemas/system-schemas.go b/cmd/workspace/system-schemas/system-schemas.go index 813715c454e..959033af58e 100755 --- a/cmd/workspace/system-schemas/system-schemas.go +++ b/cmd/workspace/system-schemas/system-schemas.go @@ -224,7 +224,7 @@ func newList() *cobra.Command { listReq.MetastoreId = args[0] response := w.SystemSchemas.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/tables/overrides.go b/cmd/workspace/tables/overrides.go index 8e0987d4697..d716c323455 100644 --- a/cmd/workspace/tables/overrides.go +++ b/cmd/workspace/tables/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *catalog.ListTablesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Full Name"}} {{header "Table Type"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/tables/tables.go b/cmd/workspace/tables/tables.go index 30892e44b75..54f6340979d 100755 --- a/cmd/workspace/tables/tables.go +++ b/cmd/workspace/tables/tables.go @@ -481,7 +481,7 @@ func newList() *cobra.Command { listReq.SchemaName = args[1] response := w.Tables.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -555,7 +555,7 @@ func newListSummaries() *cobra.Command { listSummariesReq.CatalogName = args[0] response := w.Tables.ListSummaries(ctx, listSummariesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/tag-policies/tag-policies.go b/cmd/workspace/tag-policies/tag-policies.go index 97c64a74c44..498f92f60ef 100755 --- a/cmd/workspace/tag-policies/tag-policies.go +++ b/cmd/workspace/tag-policies/tag-policies.go @@ -289,7 +289,7 @@ func newListTagPolicies() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.TagPolicies.ListTagPolicies(ctx, listTagPoliciesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/token-management/token-management.go b/cmd/workspace/token-management/token-management.go index 83ef0879247..f75df13b042 100755 --- a/cmd/workspace/token-management/token-management.go +++ b/cmd/workspace/token-management/token-management.go @@ -402,7 +402,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.TokenManagement.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/tokens/tokens.go b/cmd/workspace/tokens/tokens.go index 715db5485de..c1998f37649 100755 --- a/cmd/workspace/tokens/tokens.go +++ b/cmd/workspace/tokens/tokens.go @@ -235,7 +235,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.Tokens.List(ctx) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/users-v2/users-v2.go b/cmd/workspace/users-v2/users-v2.go index c104c9a861f..32a7282f0b3 100755 --- a/cmd/workspace/users-v2/users-v2.go +++ b/cmd/workspace/users-v2/users-v2.go @@ -389,7 +389,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.UsersV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/users/users.go b/cmd/workspace/users/users.go index c9ea8059428..bc29cc2f814 100755 --- a/cmd/workspace/users/users.go +++ b/cmd/workspace/users/users.go @@ -413,7 +413,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Users.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/vector-search-endpoints/vector-search-endpoints.go b/cmd/workspace/vector-search-endpoints/vector-search-endpoints.go index d11b5f22151..eda88b261f0 100755 --- a/cmd/workspace/vector-search-endpoints/vector-search-endpoints.go +++ b/cmd/workspace/vector-search-endpoints/vector-search-endpoints.go @@ -309,7 +309,7 @@ func newListEndpoints() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.VectorSearchEndpoints.ListEndpoints(ctx, listEndpointsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/vector-search-indexes/vector-search-indexes.go b/cmd/workspace/vector-search-indexes/vector-search-indexes.go index c7ecade2bb4..8c1350289c5 100755 --- a/cmd/workspace/vector-search-indexes/vector-search-indexes.go +++ b/cmd/workspace/vector-search-indexes/vector-search-indexes.go @@ -378,7 +378,7 @@ func newListIndexes() *cobra.Command { listIndexesReq.EndpointName = args[0] response := w.VectorSearchIndexes.ListIndexes(ctx, listIndexesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/volumes/overrides.go b/cmd/workspace/volumes/overrides.go index 66b946f2ea2..d7d53023f18 100644 --- a/cmd/workspace/volumes/overrides.go +++ b/cmd/workspace/volumes/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *catalog.ListVolumesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%s" .Name}} {{.VolumeType}} {{.FullName}} {{end}}`) diff --git a/cmd/workspace/volumes/volumes.go b/cmd/workspace/volumes/volumes.go index 15988f01c28..00a54a3cf62 100755 --- a/cmd/workspace/volumes/volumes.go +++ b/cmd/workspace/volumes/volumes.go @@ -301,7 +301,7 @@ func newList() *cobra.Command { listReq.SchemaName = args[1] response := w.Volumes.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/warehouses/overrides.go b/cmd/workspace/warehouses/overrides.go index 14b2635a040..53a8207d4be 100644 --- a/cmd/workspace/warehouses/overrides.go +++ b/cmd/workspace/warehouses/overrides.go @@ -8,6 +8,8 @@ import ( ) func listOverride(listCmd *cobra.Command, _ *sql.ListWarehousesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "ID"}} {{header "Name"}} {{header "Size"}} {{header "State"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/warehouses/warehouses.go b/cmd/workspace/warehouses/warehouses.go index 8003dfd2cf2..5e1aaf50c5a 100755 --- a/cmd/workspace/warehouses/warehouses.go +++ b/cmd/workspace/warehouses/warehouses.go @@ -880,7 +880,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Warehouses.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. @@ -932,7 +932,7 @@ func newListDefaultWarehouseOverrides() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Warehouses.ListDefaultWarehouseOverrides(ctx, listDefaultWarehouseOverridesReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/workspace-bindings/workspace-bindings.go b/cmd/workspace/workspace-bindings/workspace-bindings.go index 04090db4ae5..70e61bd333d 100755 --- a/cmd/workspace/workspace-bindings/workspace-bindings.go +++ b/cmd/workspace/workspace-bindings/workspace-bindings.go @@ -166,7 +166,7 @@ func newGetBindings() *cobra.Command { getBindingsReq.SecurableName = args[1] response := w.WorkspaceBindings.GetBindings(ctx, getBindingsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/workspace-entity-tag-assignments/workspace-entity-tag-assignments.go b/cmd/workspace/workspace-entity-tag-assignments/workspace-entity-tag-assignments.go index e15733be8b1..353f7281155 100755 --- a/cmd/workspace/workspace-entity-tag-assignments/workspace-entity-tag-assignments.go +++ b/cmd/workspace/workspace-entity-tag-assignments/workspace-entity-tag-assignments.go @@ -306,7 +306,7 @@ func newListTagAssignments() *cobra.Command { listTagAssignmentsReq.EntityId = args[1] response := w.WorkspaceEntityTagAssignments.ListTagAssignments(ctx, listTagAssignmentsReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/workspace-settings-v2/workspace-settings-v2.go b/cmd/workspace/workspace-settings-v2/workspace-settings-v2.go index 02429a6bc1f..2c3beaeaac8 100755 --- a/cmd/workspace/workspace-settings-v2/workspace-settings-v2.go +++ b/cmd/workspace/workspace-settings-v2/workspace-settings-v2.go @@ -133,7 +133,7 @@ func newListWorkspaceSettingsMetadata() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.WorkspaceSettingsV2.ListWorkspaceSettingsMetadata(ctx, listWorkspaceSettingsMetadataReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/workspace/overrides.go b/cmd/workspace/workspace/overrides.go index 56e2e74f71b..47dd002c57e 100644 --- a/cmd/workspace/workspace/overrides.go +++ b/cmd/workspace/workspace/overrides.go @@ -19,6 +19,8 @@ import ( func listOverride(listCmd *cobra.Command, listReq *workspace.ListWorkspaceRequest) { listReq.Path = "/" + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "ID"}} {{header "Type"}} {{header "Language"}} {{header "Path"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/workspace/workspace.go b/cmd/workspace/workspace/workspace.go index fa77cef4aed..bb62616fe5c 100755 --- a/cmd/workspace/workspace/workspace.go +++ b/cmd/workspace/workspace/workspace.go @@ -560,7 +560,7 @@ func newList() *cobra.Command { listReq.Path = args[0] response := w.Workspace.List(ctx, listReq) - return cmdio.RenderIterator(ctx, response) + return cmdio.RenderIterator(ctx, cmd, response) } // Disable completions since they are not applicable. diff --git a/experimental/aitools/cmd/render_test.go b/experimental/aitools/cmd/render_test.go index f3e07a8d95d..5ea75b87028 100644 --- a/experimental/aitools/cmd/render_test.go +++ b/experimental/aitools/cmd/render_test.go @@ -77,7 +77,7 @@ func TestRenderStaticTable(t *testing.T) { assert.Contains(t, output, "name") assert.Contains(t, output, "alice") assert.Contains(t, output, "bob") - assert.Contains(t, output, "---") + assert.Contains(t, output, "───") assert.Contains(t, output, "2 rows") } diff --git a/libs/cmdio/context.go b/libs/cmdio/context.go deleted file mode 100644 index f44609e1007..00000000000 --- a/libs/cmdio/context.go +++ /dev/null @@ -1,28 +0,0 @@ -package cmdio - -import ( - "context" - - "github.com/spf13/cobra" -) - -type cmdKeyType struct{} - -// WithCommand stores the cobra.Command in context. -func WithCommand(ctx context.Context, cmd *cobra.Command) context.Context { - return context.WithValue(ctx, cmdKeyType{}, cmd) -} - -// CommandFromContext retrieves the cobra.Command from context. -func CommandFromContext(ctx context.Context) *cobra.Command { - cmd, _ := ctx.Value(cmdKeyType{}).(*cobra.Command) - return cmd -} - -type maxItemsKeyType struct{} - -// GetMaxItems retrieves the max items limit from context (0 = unlimited). -func GetMaxItems(ctx context.Context) int { - n, _ := ctx.Value(maxItemsKeyType{}).(int) - return n -} diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 6b6d1b9121f..73ea8c4f754 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -19,6 +19,7 @@ import ( "github.com/databricks/databricks-sdk-go/listing" "github.com/fatih/color" "github.com/nwidger/jsoncolor" + "github.com/spf13/cobra" ) // Heredoc is the equivalent of compute.TrimLeadingWhitespace @@ -252,6 +253,14 @@ func renderWithTemplate(ctx context.Context, r any, outputFormat flags.Output, w } } +type maxItemsKeyType struct{} + +// GetMaxItems retrieves the max items limit from context (0 = unlimited). +func GetMaxItems(ctx context.Context) int { + n, _ := ctx.Value(maxItemsKeyType{}).(int) + return n +} + type listingInterface interface { HasNext(context.Context) bool } @@ -264,13 +273,11 @@ func Render(ctx context.Context, v any) error { return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template) } -func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error { +func RenderIterator[T any](ctx context.Context, cmd *cobra.Command, i listing.Iterator[T]) error { c := fromContext(ctx) - // Only launch TUI when an explicit TableConfig is registered. - // AutoDetect is available but opt-in from the override layer. + // Only launch TUI when an explicit TableConfig is registered via overrides. if c.outputFormat == flags.OutputText && c.capabilities.SupportsTUI() { - cmd := CommandFromContext(ctx) if cmd != nil { if cfg := tableview.GetConfig(cmd); cfg != nil { iter := tableview.WrapIterator(i, cfg.Columns) diff --git a/libs/cmdio/render_test.go b/libs/cmdio/render_test.go index be41f80c384..dce4041212d 100644 --- a/libs/cmdio/render_test.go +++ b/libs/cmdio/render_test.go @@ -176,7 +176,7 @@ func TestRender(t *testing.T) { ctx = InContext(ctx, cmdIO) var err error if vv, ok := c.v.(listing.Iterator[*provisioning.Workspace]); ok { - err = RenderIterator(ctx, vv) + err = RenderIterator(ctx, nil, vv) } else { err = Render(ctx, c.v) } diff --git a/libs/tableview/autodetect.go b/libs/tableview/autodetect.go deleted file mode 100644 index 6226e5cc3b4..00000000000 --- a/libs/tableview/autodetect.go +++ /dev/null @@ -1,119 +0,0 @@ -package tableview - -import ( - "fmt" - "reflect" - "strings" - "sync" - "unicode" - - "github.com/databricks/databricks-sdk-go/listing" -) - -const maxAutoColumns = 8 - -var autoCache sync.Map // reflect.Type -> *TableConfig - -// AutoDetect creates a TableConfig by reflecting on the element type of the iterator. -// It picks up to maxAutoColumns top-level scalar fields. -// Returns nil if no suitable columns are found. -func AutoDetect[T any](iter listing.Iterator[T]) *TableConfig { - var zero T - t := reflect.TypeOf(zero) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - if cached, ok := autoCache.Load(t); ok { - return cached.(*TableConfig) - } - - cfg := autoDetectFromType(t) - if cfg != nil { - autoCache.Store(t, cfg) - } - return cfg -} - -func autoDetectFromType(t reflect.Type) *TableConfig { - if t.Kind() != reflect.Struct { - return nil - } - - var columns []ColumnDef - for i := range t.NumField() { - if len(columns) >= maxAutoColumns { - break - } - field := t.Field(i) - if !field.IsExported() || field.Anonymous { - continue - } - if !isScalarKind(field.Type.Kind()) { - continue - } - - header := fieldHeader(field) - columns = append(columns, ColumnDef{ - Header: header, - Extract: func(v any) string { - val := reflect.ValueOf(v) - if val.Kind() == reflect.Ptr { - if val.IsNil() { - return "" - } - val = val.Elem() - } - if val.Kind() != reflect.Struct { - return "" - } - f := val.Field(i) - return fmt.Sprintf("%v", f.Interface()) - }, - }) - } - - if len(columns) == 0 { - return nil - } - return &TableConfig{Columns: columns} -} - -func isScalarKind(k reflect.Kind) bool { - switch k { - case reflect.String, reflect.Bool, - reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, - reflect.Float32, reflect.Float64: - return true - default: - return false - } -} - -// fieldHeader converts a struct field to a display header. -// Uses the json tag if available, otherwise the field name. -func fieldHeader(f reflect.StructField) string { - tag := f.Tag.Get("json") - if tag != "" { - name, _, _ := strings.Cut(tag, ",") - if name != "" && name != "-" { - return snakeToTitle(name) - } - } - return f.Name -} - -func snakeToTitle(s string) string { - words := strings.Split(s, "_") - for i, w := range words { - if w == "id" { - words[i] = "ID" - } else if len(w) > 0 { - runes := []rune(w) - runes[0] = unicode.ToUpper(runes[0]) - words[i] = string(runes) - } - } - return strings.Join(words, " ") -} diff --git a/libs/tableview/autodetect_test.go b/libs/tableview/autodetect_test.go deleted file mode 100644 index 90ab1019fb2..00000000000 --- a/libs/tableview/autodetect_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package tableview - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type scalarStruct struct { - Name string `json:"name"` - Age int `json:"age"` - Active bool `json:"is_active"` - Score float64 `json:"score"` -} - -type nestedStruct struct { - ID string `json:"id"` - Config struct { - Key string - } - Label string `json:"label"` -} - -type manyFieldsStruct struct { - F1 string `json:"f1"` - F2 string `json:"f2"` - F3 string `json:"f3"` - F4 string `json:"f4"` - F5 string `json:"f5"` - F6 string `json:"f6"` - F7 string `json:"f7"` - F8 string `json:"f8"` - F9 string `json:"f9"` - F10 string `json:"f10"` -} - -type noExportedFields struct { - hidden string //nolint:unused -} - -type jsonTagStruct struct { - WorkspaceID string `json:"workspace_id"` - DisplayName string `json:"display_name"` - NoTag string -} - -func TestAutoDetectScalarFields(t *testing.T) { - iter := &fakeIterator[scalarStruct]{items: []scalarStruct{{Name: "alice", Age: 30, Active: true, Score: 9.5}}} - cfg := AutoDetect[scalarStruct](iter) - require.NotNil(t, cfg) - assert.Len(t, cfg.Columns, 4) - assert.Equal(t, "Name", cfg.Columns[0].Header) - assert.Equal(t, "Age", cfg.Columns[1].Header) - assert.Equal(t, "Is Active", cfg.Columns[2].Header) - assert.Equal(t, "Score", cfg.Columns[3].Header) - - val := scalarStruct{Name: "bob", Age: 25, Active: false, Score: 7.2} - assert.Equal(t, "bob", cfg.Columns[0].Extract(val)) - assert.Equal(t, "25", cfg.Columns[1].Extract(val)) - assert.Equal(t, "false", cfg.Columns[2].Extract(val)) - assert.Equal(t, "7.2", cfg.Columns[3].Extract(val)) -} - -func TestAutoDetectSkipsNestedFields(t *testing.T) { - iter := &fakeIterator[nestedStruct]{items: []nestedStruct{{ID: "123", Label: "test"}}} - cfg := AutoDetect[nestedStruct](iter) - require.NotNil(t, cfg) - assert.Len(t, cfg.Columns, 2) - assert.Equal(t, "ID", cfg.Columns[0].Header) - assert.Equal(t, "Label", cfg.Columns[1].Header) -} - -func TestAutoDetectPointerType(t *testing.T) { - iter := &fakeIterator[*scalarStruct]{items: []*scalarStruct{{Name: "ptr", Age: 1}}} - cfg := AutoDetect[*scalarStruct](iter) - require.NotNil(t, cfg) - assert.Len(t, cfg.Columns, 4) - - val := &scalarStruct{Name: "ptr", Age: 1} - assert.Equal(t, "ptr", cfg.Columns[0].Extract(val)) - assert.Equal(t, "1", cfg.Columns[1].Extract(val)) -} - -func TestAutoDetectCappedAtMaxColumns(t *testing.T) { - iter := &fakeIterator[manyFieldsStruct]{items: []manyFieldsStruct{{}}} - cfg := AutoDetect[manyFieldsStruct](iter) - require.NotNil(t, cfg) - assert.Len(t, cfg.Columns, maxAutoColumns) -} - -func TestAutoDetectNoExportedFields(t *testing.T) { - iter := &fakeIterator[noExportedFields]{items: []noExportedFields{{}}} - cfg := AutoDetect[noExportedFields](iter) - assert.Nil(t, cfg) -} - -func TestAutoDetectJsonTags(t *testing.T) { - iter := &fakeIterator[jsonTagStruct]{items: []jsonTagStruct{{}}} - cfg := AutoDetect[jsonTagStruct](iter) - require.NotNil(t, cfg) - assert.Equal(t, "Workspace ID", cfg.Columns[0].Header) - assert.Equal(t, "Display Name", cfg.Columns[1].Header) - assert.Equal(t, "NoTag", cfg.Columns[2].Header) -} - -func TestAutoDetectCaching(t *testing.T) { - iter1 := &fakeIterator[scalarStruct]{items: []scalarStruct{{}}} - cfg1 := AutoDetect[scalarStruct](iter1) - - iter2 := &fakeIterator[scalarStruct]{items: []scalarStruct{{}}} - cfg2 := AutoDetect[scalarStruct](iter2) - - // Should return the same cached pointer. - assert.Same(t, cfg1, cfg2) -} - -func TestSnakeToTitle(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"workspace_id", "Workspace ID"}, - {"display_name", "Display Name"}, - {"id", "ID"}, - {"simple", "Simple"}, - {"a_b_c", "A B C"}, - } - for _, tt := range tests { - assert.Equal(t, tt.expected, snakeToTitle(tt.input)) - } -} diff --git a/libs/tableview/common.go b/libs/tableview/common.go index cd33efec0b4..f389f2dcf7f 100644 --- a/libs/tableview/common.go +++ b/libs/tableview/common.go @@ -153,7 +153,7 @@ func RenderStaticTable(w io.Writer, columns []string, rows [][]string) error { width = max(width, min(utf8.RuneCountInString(row[i]), maxColumnWidth)) } } - seps[i] = strings.Repeat("-", width) + seps[i] = strings.Repeat("─", width) } fmt.Fprintln(tw, strings.Join(seps, "\t")) // Data rows (no cell truncation; truncation is a TUI display concern) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 646abc787ab..ee6d22ce5c8 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -319,6 +319,7 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cfg.Search != nil { m.searching = true m.searchInput = "" + // Shrink viewport by one row to make room for the search input bar. m.viewport.Height-- return m, nil } @@ -441,12 +442,16 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": m.searching = false + // Restore viewport height now that search bar is hidden. m.viewport.Height++ // Execute final search immediately (bypass debounce). return m.executeSearch(m.searchInput) - case "esc", "ctrl+c": + case "ctrl+c": + return m, tea.Quit + case "esc": m.searching = false m.searchInput = "" + // Restore viewport height now that search bar is hidden. m.viewport.Height++ m.restorePreSearchState() return m, nil diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index deb6b6f8d0b..7ce1a922ae8 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "strconv" "testing" @@ -58,24 +57,6 @@ func TestPaginatedModelInit(t *testing.T) { require.NotNil(t, cmd) } -func TestNewPaginatedProgramSetsLoadingTrue(t *testing.T) { - cfg := newTestConfig() - iter := &stringRowIterator{rows: [][]string{{"alice", "30"}}} - p := NewPaginatedProgram(t.Context(), io.Discard, cfg, iter, 0) - require.NotNil(t, p) - - // The model inside the program isn't exported, so verify the invariant - // indirectly: construct the same model and confirm loading is set. - m := paginatedModel{ - cfg: cfg, - headers: []string{"Name", "Age"}, - rowIter: iter, - makeFetchCmd: newFetchCmdFunc(t.Context()), - loading: true, - } - assert.True(t, m.loading) -} - // TestKeyBeforeFirstFetchDoesNotDoubleFetch verifies that a keypress arriving // before the first fetch completes does not trigger a second concurrent fetch. // This guards against a data race on the shared iterator. diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 973f4f257da..c6276cfafec 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -173,7 +173,9 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { scrollViewportToCursor(&m.viewport, m.cursor) } return m, nil - case "esc", "ctrl+c": + case "ctrl+c": + return m, tea.Quit + case "esc": m.searching = false m.searchInput = "" m.viewport.Height++ From d967b2868ad40f31d9692f43f36be69fa9e177b8 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 09:53:27 +0200 Subject: [PATCH 24/32] Address review nits: add missing override comments, document RenderIterator cmd param --- cmd/workspace/pipelines/overrides.go | 2 ++ cmd/workspace/secrets/overrides.go | 2 ++ libs/cmdio/render.go | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index bbef66bdfc1..5c6f4fb087e 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -65,6 +65,8 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli } func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelineEventsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Timestamp"}} {{header "Level"}} {{header "Event Type"}} {{header "Message"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index 009bbfe84cf..ec5de4a23e5 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -35,6 +35,8 @@ func listScopesOverride(listScopesCmd *cobra.Command) { } func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSecretsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listSecretsCommand.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Key"}} {{header "Last Updated Timestamp"}}`) listSecretsCommand.Annotations["template"] = cmdio.Heredoc(` diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 73ea8c4f754..425a26eabc7 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -273,6 +273,10 @@ func Render(ctx context.Context, v any) error { return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template) } +// RenderIterator renders the items from i. When cmd is non-nil and has a +// registered TableConfig, a paginated TUI table is shown in interactive +// terminals. Pass nil for cmd to skip TUI lookup and always use template +// rendering. func RenderIterator[T any](ctx context.Context, cmd *cobra.Command, i listing.Iterator[T]) error { c := fromContext(ctx) From 046f656af460b65c1ded42045356f8a6f63a6ec0 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 10:42:57 +0200 Subject: [PATCH 25/32] Replace global tableview registry with context-based config Co-authored-by: Isaac --- cmd/workspace/alerts/overrides.go | 2 +- cmd/workspace/apps/overrides.go | 2 +- cmd/workspace/apps/overrides_test.go | 2 +- cmd/workspace/catalogs/overrides.go | 2 +- cmd/workspace/cluster-policies/overrides.go | 2 +- cmd/workspace/clusters/overrides.go | 2 +- cmd/workspace/external-locations/overrides.go | 2 +- cmd/workspace/instance-pools/overrides.go | 2 +- cmd/workspace/jobs/overrides.go | 4 +-- cmd/workspace/jobs/overrides_test.go | 2 +- cmd/workspace/lakeview/overrides.go | 2 +- cmd/workspace/pipelines/overrides.go | 8 ++--- cmd/workspace/pipelines/overrides_test.go | 8 ++--- cmd/workspace/repos/overrides.go | 2 +- cmd/workspace/schemas/overrides.go | 2 +- cmd/workspace/secrets/overrides.go | 4 +-- cmd/workspace/serving-endpoints/overrides.go | 2 +- .../serving-endpoints/overrides_test.go | 2 +- cmd/workspace/tables/overrides.go | 2 +- cmd/workspace/volumes/overrides.go | 2 +- cmd/workspace/warehouses/overrides.go | 2 +- cmd/workspace/workspace/overrides.go | 2 +- libs/cmdio/render.go | 30 +++++++++---------- libs/tableview/context.go | 22 ++++++++++++++ libs/tableview/registry.go | 26 ---------------- 25 files changed, 66 insertions(+), 72 deletions(-) create mode 100644 libs/tableview/context.go delete mode 100644 libs/tableview/registry.go diff --git a/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go index 564c6632b6b..760a74345df 100644 --- a/cmd/workspace/alerts/overrides.go +++ b/cmd/workspace/alerts/overrides.go @@ -26,7 +26,7 @@ func listOverride(listCmd *cobra.Command, _ *sql.ListAlertsRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index bb0d097d564..f1669221e64 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -44,7 +44,7 @@ func listOverride(listCmd *cobra.Command, _ *apps.ListAppsRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, _ *apps.ListAppDeploymentsRequest) { diff --git a/cmd/workspace/apps/overrides_test.go b/cmd/workspace/apps/overrides_test.go index c2d374f38b3..a2620d98df3 100644 --- a/cmd/workspace/apps/overrides_test.go +++ b/cmd/workspace/apps/overrides_test.go @@ -12,7 +12,7 @@ import ( func TestListTableConfig(t *testing.T) { cmd := newList() - cfg := tableview.GetConfig(cmd) + cfg := tableview.GetTableConfig(cmd.Context()) require.NotNil(t, cfg) require.Len(t, cfg.Columns, 4) diff --git a/cmd/workspace/catalogs/overrides.go b/cmd/workspace/catalogs/overrides.go index 80a7b21de85..00aa4def459 100644 --- a/cmd/workspace/catalogs/overrides.go +++ b/cmd/workspace/catalogs/overrides.go @@ -28,7 +28,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListCatalogsRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/cluster-policies/overrides.go b/cmd/workspace/cluster-policies/overrides.go index 1c85b030ad9..2f4b9f330f5 100644 --- a/cmd/workspace/cluster-policies/overrides.go +++ b/cmd/workspace/cluster-policies/overrides.go @@ -29,7 +29,7 @@ func listOverride(listCmd *cobra.Command, _ *compute.ListClusterPoliciesRequest) }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func getOverride(getCmd *cobra.Command, _ *compute.GetClusterPolicyRequest) { diff --git a/cmd/workspace/clusters/overrides.go b/cmd/workspace/clusters/overrides.go index 057caabdca8..ac9f1e88c01 100644 --- a/cmd/workspace/clusters/overrides.go +++ b/cmd/workspace/clusters/overrides.go @@ -32,7 +32,7 @@ func listOverride(listCmd *cobra.Command, listReq *compute.ListClustersRequest) }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) listReq.FilterBy = &compute.ListClustersFilterBy{} listCmd.Flags().BoolVar(&listReq.FilterBy.IsPinned, "is-pinned", false, "Filter clusters by pinned status") diff --git a/cmd/workspace/external-locations/overrides.go b/cmd/workspace/external-locations/overrides.go index 1554265fce1..77f643e6f49 100644 --- a/cmd/workspace/external-locations/overrides.go +++ b/cmd/workspace/external-locations/overrides.go @@ -28,7 +28,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListExternalLocationsReques }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/instance-pools/overrides.go b/cmd/workspace/instance-pools/overrides.go index ac9709afd97..d014b420e46 100644 --- a/cmd/workspace/instance-pools/overrides.go +++ b/cmd/workspace/instance-pools/overrides.go @@ -29,7 +29,7 @@ func listOverride(listCmd *cobra.Command) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/jobs/overrides.go b/cmd/workspace/jobs/overrides.go index 34dd9b392de..9af1eb25e55 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -30,7 +30,7 @@ func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{ + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{ Columns: columns, Search: &tableview.SearchConfig{ Placeholder: "Search by exact name...", @@ -43,7 +43,7 @@ func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { return tableview.WrapIterator(w.Jobs.List(ctx, req), columns) }, }, - }) + })) } func listRunsOverride(listRunsCmd *cobra.Command, _ *jobs.ListRunsRequest) { diff --git a/cmd/workspace/jobs/overrides_test.go b/cmd/workspace/jobs/overrides_test.go index 66bcb5da276..4794188016f 100644 --- a/cmd/workspace/jobs/overrides_test.go +++ b/cmd/workspace/jobs/overrides_test.go @@ -12,7 +12,7 @@ import ( func TestListTableConfig(t *testing.T) { cmd := newList() - cfg := tableview.GetConfig(cmd) + cfg := tableview.GetTableConfig(cmd.Context()) require.NotNil(t, cfg) require.Len(t, cfg.Columns, 2) diff --git a/cmd/workspace/lakeview/overrides.go b/cmd/workspace/lakeview/overrides.go index d57b2278a1c..8533f270b65 100644 --- a/cmd/workspace/lakeview/overrides.go +++ b/cmd/workspace/lakeview/overrides.go @@ -28,7 +28,7 @@ func listOverride(listCmd *cobra.Command, _ *dashboards.ListDashboardsRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func publishOverride(cmd *cobra.Command, req *dashboards.PublishRequest) { diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 5c6f4fb087e..ee11366b19b 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -34,7 +34,7 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{ + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{ Columns: columns, Search: &tableview.SearchConfig{ Placeholder: "Filter by name...", @@ -50,7 +50,7 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli return tableview.WrapIterator(w.Pipelines.ListPipelines(ctx, req), columns) }, }, - }) + })) // The pipelines API does not support composite filters, so disable // TUI search when the user passes --filter on the command line. @@ -88,7 +88,7 @@ func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelin }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { @@ -159,7 +159,7 @@ With a PIPELINE_ID: Stops the pipeline identified by the UUID using the API.` // disableSearchIfFilterSet clears the TUI search config when --filter is active. func disableSearchIfFilterSet(cmd *cobra.Command) { if cmd.Flags().Changed("filter") { - if cfg := tableview.GetConfig(cmd); cfg != nil { + if cfg := tableview.GetTableConfig(cmd.Context()); cfg != nil { cfg.Search = nil } } diff --git a/cmd/workspace/pipelines/overrides_test.go b/cmd/workspace/pipelines/overrides_test.go index 27f3c265887..7c082bfae52 100644 --- a/cmd/workspace/pipelines/overrides_test.go +++ b/cmd/workspace/pipelines/overrides_test.go @@ -23,7 +23,7 @@ func TestLooksLikeUUID_resourceName(t *testing.T) { func TestListPipelinesTableConfig(t *testing.T) { cmd := newListPipelines() - cfg := tableview.GetConfig(cmd) + cfg := tableview.GetTableConfig(cmd.Context()) require.NotNil(t, cfg) require.Len(t, cfg.Columns, 3) require.NotNil(t, cfg.Search) @@ -42,7 +42,7 @@ func TestListPipelinesTableConfig(t *testing.T) { func TestListPipelinesSearchEscapesLikeWildcards(t *testing.T) { cmd := newListPipelines() - cfg := tableview.GetConfig(cmd) + cfg := tableview.GetTableConfig(cmd.Context()) require.NotNil(t, cfg) require.NotNil(t, cfg.Search) @@ -63,7 +63,7 @@ func TestListPipelinesSearchDisabledWhenFilterSet(t *testing.T) { err := cmd.Flags().Set("filter", "state = 'RUNNING'") require.NoError(t, err) - cfg := tableview.GetConfig(cmd) + cfg := tableview.GetTableConfig(cmd.Context()) require.NotNil(t, cfg) require.NotNil(t, cfg.Search) @@ -76,7 +76,7 @@ func TestListPipelinesSearchDisabledWhenFilterSet(t *testing.T) { func TestListPipelinesSearchNotDisabledWithoutFilter(t *testing.T) { cmd := newListPipelines() - cfg := tableview.GetConfig(cmd) + cfg := tableview.GetTableConfig(cmd.Context()) require.NotNil(t, cfg) require.NotNil(t, cfg.Search) diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 48f78f64e1f..36d52fe716b 100644 --- a/cmd/workspace/repos/overrides.go +++ b/cmd/workspace/repos/overrides.go @@ -39,7 +39,7 @@ func listOverride(listCmd *cobra.Command, _ *workspace.ListReposRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func createOverride(createCmd *cobra.Command, createReq *workspace.CreateRepoRequest) { diff --git a/cmd/workspace/schemas/overrides.go b/cmd/workspace/schemas/overrides.go index cc16d11c2e7..758c55a023d 100644 --- a/cmd/workspace/schemas/overrides.go +++ b/cmd/workspace/schemas/overrides.go @@ -28,7 +28,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListSchemasRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index ec5de4a23e5..93204cdf3f1 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -31,7 +31,7 @@ func listScopesOverride(listScopesCmd *cobra.Command) { }}, } - tableview.RegisterConfig(listScopesCmd, tableview.TableConfig{Columns: columns}) + listScopesCmd.SetContext(tableview.SetTableConfig(listScopesCmd.Context(), &tableview.TableConfig{Columns: columns})) } func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSecretsRequest) { @@ -56,7 +56,7 @@ func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSec }}, } - tableview.RegisterConfig(listSecretsCommand, tableview.TableConfig{Columns: columns}) + listSecretsCommand.SetContext(tableview.SetTableConfig(listSecretsCommand.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/serving-endpoints/overrides.go b/cmd/workspace/serving-endpoints/overrides.go index f0f5eb60fb5..2c9027b5b69 100644 --- a/cmd/workspace/serving-endpoints/overrides.go +++ b/cmd/workspace/serving-endpoints/overrides.go @@ -29,7 +29,7 @@ func listOverride(listCmd *cobra.Command) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/serving-endpoints/overrides_test.go b/cmd/workspace/serving-endpoints/overrides_test.go index 1ab6f39dad3..c696ac19e11 100644 --- a/cmd/workspace/serving-endpoints/overrides_test.go +++ b/cmd/workspace/serving-endpoints/overrides_test.go @@ -12,7 +12,7 @@ import ( func TestListTableConfig(t *testing.T) { cmd := newList() - cfg := tableview.GetConfig(cmd) + cfg := tableview.GetTableConfig(cmd.Context()) require.NotNil(t, cfg) require.Len(t, cfg.Columns, 3) diff --git a/cmd/workspace/tables/overrides.go b/cmd/workspace/tables/overrides.go index d716c323455..c68b051fed8 100644 --- a/cmd/workspace/tables/overrides.go +++ b/cmd/workspace/tables/overrides.go @@ -25,7 +25,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListTablesRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/volumes/overrides.go b/cmd/workspace/volumes/overrides.go index d7d53023f18..a31aae281d6 100644 --- a/cmd/workspace/volumes/overrides.go +++ b/cmd/workspace/volumes/overrides.go @@ -26,7 +26,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListVolumesRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/warehouses/overrides.go b/cmd/workspace/warehouses/overrides.go index 53a8207d4be..0a8f9f9d3b8 100644 --- a/cmd/workspace/warehouses/overrides.go +++ b/cmd/workspace/warehouses/overrides.go @@ -31,7 +31,7 @@ func listOverride(listCmd *cobra.Command, _ *sql.ListWarehousesRequest) { }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func init() { diff --git a/cmd/workspace/workspace/overrides.go b/cmd/workspace/workspace/overrides.go index 47dd002c57e..18b556e2cb6 100644 --- a/cmd/workspace/workspace/overrides.go +++ b/cmd/workspace/workspace/overrides.go @@ -42,7 +42,7 @@ func listOverride(listCmd *cobra.Command, listReq *workspace.ListWorkspaceReques }}, } - tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) } func exportOverride(exportCmd *cobra.Command, exportReq *workspace.ExportRequest) { diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 425a26eabc7..656ab334de4 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -282,24 +282,22 @@ func RenderIterator[T any](ctx context.Context, cmd *cobra.Command, i listing.It // Only launch TUI when an explicit TableConfig is registered via overrides. if c.outputFormat == flags.OutputText && c.capabilities.SupportsTUI() { - if cmd != nil { - if cfg := tableview.GetConfig(cmd); cfg != nil { - iter := tableview.WrapIterator(i, cfg.Columns) - maxItems := GetMaxItems(ctx) - p := tableview.NewPaginatedProgram(ctx, c.out, cfg, iter, maxItems) - c.acquireTeaProgram(p) - defer c.releaseTeaProgram() - finalModel, err := p.Run() - if err != nil { - return err - } - if pm, ok := finalModel.(tableview.FinalModel); ok { - if modelErr := pm.Err(); modelErr != nil { - return modelErr - } + if cfg := tableview.GetTableConfig(ctx); cfg != nil { + iter := tableview.WrapIterator(i, cfg.Columns) + maxItems := GetMaxItems(ctx) + p := tableview.NewPaginatedProgram(ctx, c.out, cfg, iter, maxItems) + c.acquireTeaProgram(p) + defer c.releaseTeaProgram() + finalModel, err := p.Run() + if err != nil { + return err + } + if pm, ok := finalModel.(tableview.FinalModel); ok { + if modelErr := pm.Err(); modelErr != nil { + return modelErr } - return nil } + return nil } } diff --git a/libs/tableview/context.go b/libs/tableview/context.go new file mode 100644 index 00000000000..ec50955f5a6 --- /dev/null +++ b/libs/tableview/context.go @@ -0,0 +1,22 @@ +package tableview + +import "context" + +type tableConfigKeyType struct{} + +// SetTableConfig stores a *TableConfig in context. +// If ctx is nil (e.g. during command construction before Execute), context.Background() is used. +func SetTableConfig(ctx context.Context, cfg *TableConfig) context.Context { + if ctx == nil { + // Commands have no context during construction (before Execute), so + // context.Background is the only available root. + ctx = context.Background() //nolint:gocritic + } + return context.WithValue(ctx, tableConfigKeyType{}, cfg) +} + +// GetTableConfig retrieves the *TableConfig from context, or nil. +func GetTableConfig(ctx context.Context) *TableConfig { + cfg, _ := ctx.Value(tableConfigKeyType{}).(*TableConfig) + return cfg +} diff --git a/libs/tableview/registry.go b/libs/tableview/registry.go deleted file mode 100644 index 6bcb10f87c9..00000000000 --- a/libs/tableview/registry.go +++ /dev/null @@ -1,26 +0,0 @@ -package tableview - -import ( - "sync" - - "github.com/spf13/cobra" -) - -var ( - configMu sync.RWMutex - configs = map[*cobra.Command]*TableConfig{} -) - -// RegisterConfig associates a TableConfig with a command. -func RegisterConfig(cmd *cobra.Command, cfg TableConfig) { - configMu.Lock() - defer configMu.Unlock() - configs[cmd] = &cfg -} - -// GetConfig retrieves the TableConfig for a command, if registered. -func GetConfig(cmd *cobra.Command) *TableConfig { - configMu.RLock() - defer configMu.RUnlock() - return configs[cmd] -} From da84d97d421e85aa2f85d24e85ab93e54792aba4 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 11:00:49 +0200 Subject: [PATCH 26/32] Fix context propagation: inject table config at execution time via PreRunE Co-authored-by: Isaac --- cmd/workspace/alerts/overrides.go | 2 +- cmd/workspace/apps/overrides.go | 2 +- cmd/workspace/apps/overrides_test.go | 2 +- cmd/workspace/catalogs/overrides.go | 2 +- cmd/workspace/cluster-policies/overrides.go | 2 +- cmd/workspace/clusters/overrides.go | 2 +- cmd/workspace/external-locations/overrides.go | 2 +- cmd/workspace/instance-pools/overrides.go | 2 +- cmd/workspace/jobs/overrides.go | 4 +- cmd/workspace/jobs/overrides_test.go | 2 +- cmd/workspace/lakeview/overrides.go | 2 +- cmd/workspace/pipelines/overrides.go | 14 ++++--- cmd/workspace/pipelines/overrides_test.go | 15 +++---- cmd/workspace/repos/overrides.go | 2 +- cmd/workspace/schemas/overrides.go | 2 +- cmd/workspace/secrets/overrides.go | 4 +- cmd/workspace/serving-endpoints/overrides.go | 2 +- .../serving-endpoints/overrides_test.go | 2 +- cmd/workspace/tables/overrides.go | 2 +- cmd/workspace/volumes/overrides.go | 2 +- cmd/workspace/warehouses/overrides.go | 2 +- cmd/workspace/workspace/overrides.go | 2 +- libs/tableview/context.go | 42 +++++++++++++++---- 23 files changed, 74 insertions(+), 41 deletions(-) diff --git a/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go index 760a74345df..0616d56be6f 100644 --- a/cmd/workspace/alerts/overrides.go +++ b/cmd/workspace/alerts/overrides.go @@ -26,7 +26,7 @@ func listOverride(listCmd *cobra.Command, _ *sql.ListAlertsRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index f1669221e64..bd4610c3cc5 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -44,7 +44,7 @@ func listOverride(listCmd *cobra.Command, _ *apps.ListAppsRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, _ *apps.ListAppDeploymentsRequest) { diff --git a/cmd/workspace/apps/overrides_test.go b/cmd/workspace/apps/overrides_test.go index a2620d98df3..7c7491f66b5 100644 --- a/cmd/workspace/apps/overrides_test.go +++ b/cmd/workspace/apps/overrides_test.go @@ -12,7 +12,7 @@ import ( func TestListTableConfig(t *testing.T) { cmd := newList() - cfg := tableview.GetTableConfig(cmd.Context()) + cfg := tableview.GetTableConfigForCmd(cmd) require.NotNil(t, cfg) require.Len(t, cfg.Columns, 4) diff --git a/cmd/workspace/catalogs/overrides.go b/cmd/workspace/catalogs/overrides.go index 00aa4def459..c9dbcefdffa 100644 --- a/cmd/workspace/catalogs/overrides.go +++ b/cmd/workspace/catalogs/overrides.go @@ -28,7 +28,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListCatalogsRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/cluster-policies/overrides.go b/cmd/workspace/cluster-policies/overrides.go index 2f4b9f330f5..63af01c8071 100644 --- a/cmd/workspace/cluster-policies/overrides.go +++ b/cmd/workspace/cluster-policies/overrides.go @@ -29,7 +29,7 @@ func listOverride(listCmd *cobra.Command, _ *compute.ListClusterPoliciesRequest) }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func getOverride(getCmd *cobra.Command, _ *compute.GetClusterPolicyRequest) { diff --git a/cmd/workspace/clusters/overrides.go b/cmd/workspace/clusters/overrides.go index ac9f1e88c01..9ae463119c8 100644 --- a/cmd/workspace/clusters/overrides.go +++ b/cmd/workspace/clusters/overrides.go @@ -32,7 +32,7 @@ func listOverride(listCmd *cobra.Command, listReq *compute.ListClustersRequest) }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) listReq.FilterBy = &compute.ListClustersFilterBy{} listCmd.Flags().BoolVar(&listReq.FilterBy.IsPinned, "is-pinned", false, "Filter clusters by pinned status") diff --git a/cmd/workspace/external-locations/overrides.go b/cmd/workspace/external-locations/overrides.go index 77f643e6f49..c976134beaf 100644 --- a/cmd/workspace/external-locations/overrides.go +++ b/cmd/workspace/external-locations/overrides.go @@ -28,7 +28,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListExternalLocationsReques }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/instance-pools/overrides.go b/cmd/workspace/instance-pools/overrides.go index d014b420e46..9b0aeed1e2c 100644 --- a/cmd/workspace/instance-pools/overrides.go +++ b/cmd/workspace/instance-pools/overrides.go @@ -29,7 +29,7 @@ func listOverride(listCmd *cobra.Command) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/jobs/overrides.go b/cmd/workspace/jobs/overrides.go index 9af1eb25e55..f3ecb9efd3e 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -30,7 +30,7 @@ func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{ + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{ Columns: columns, Search: &tableview.SearchConfig{ Placeholder: "Search by exact name...", @@ -43,7 +43,7 @@ func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { return tableview.WrapIterator(w.Jobs.List(ctx, req), columns) }, }, - })) + }) } func listRunsOverride(listRunsCmd *cobra.Command, _ *jobs.ListRunsRequest) { diff --git a/cmd/workspace/jobs/overrides_test.go b/cmd/workspace/jobs/overrides_test.go index 4794188016f..623ea108187 100644 --- a/cmd/workspace/jobs/overrides_test.go +++ b/cmd/workspace/jobs/overrides_test.go @@ -12,7 +12,7 @@ import ( func TestListTableConfig(t *testing.T) { cmd := newList() - cfg := tableview.GetTableConfig(cmd.Context()) + cfg := tableview.GetTableConfigForCmd(cmd) require.NotNil(t, cfg) require.Len(t, cfg.Columns, 2) diff --git a/cmd/workspace/lakeview/overrides.go b/cmd/workspace/lakeview/overrides.go index 8533f270b65..0c2dda80970 100644 --- a/cmd/workspace/lakeview/overrides.go +++ b/cmd/workspace/lakeview/overrides.go @@ -28,7 +28,7 @@ func listOverride(listCmd *cobra.Command, _ *dashboards.ListDashboardsRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func publishOverride(cmd *cobra.Command, req *dashboards.PublishRequest) { diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index ee11366b19b..76bcd77ba25 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -34,7 +34,7 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{ + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{ Columns: columns, Search: &tableview.SearchConfig{ Placeholder: "Filter by name...", @@ -50,16 +50,20 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli return tableview.WrapIterator(w.Pipelines.ListPipelines(ctx, req), columns) }, }, - })) + }) // The pipelines API does not support composite filters, so disable // TUI search when the user passes --filter on the command line. + // This wrapper runs after the table config PreRunE so the config + // is already in the context when disableSearchIfFilterSet reads it. origPreRunE := listCmd.PreRunE listCmd.PreRunE = func(cmd *cobra.Command, args []string) error { - disableSearchIfFilterSet(cmd) if origPreRunE != nil { - return origPreRunE(cmd, args) + if err := origPreRunE(cmd, args); err != nil { + return err + } } + disableSearchIfFilterSet(cmd) return nil } } @@ -88,7 +92,7 @@ func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelin }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/pipelines/overrides_test.go b/cmd/workspace/pipelines/overrides_test.go index 7c082bfae52..d92646f68e2 100644 --- a/cmd/workspace/pipelines/overrides_test.go +++ b/cmd/workspace/pipelines/overrides_test.go @@ -23,7 +23,7 @@ func TestLooksLikeUUID_resourceName(t *testing.T) { func TestListPipelinesTableConfig(t *testing.T) { cmd := newListPipelines() - cfg := tableview.GetTableConfig(cmd.Context()) + cfg := tableview.GetTableConfigForCmd(cmd) require.NotNil(t, cfg) require.Len(t, cfg.Columns, 3) require.NotNil(t, cfg.Search) @@ -42,7 +42,7 @@ func TestListPipelinesTableConfig(t *testing.T) { func TestListPipelinesSearchEscapesLikeWildcards(t *testing.T) { cmd := newListPipelines() - cfg := tableview.GetTableConfig(cmd.Context()) + cfg := tableview.GetTableConfigForCmd(cmd) require.NotNil(t, cfg) require.NotNil(t, cfg.Search) @@ -63,12 +63,12 @@ func TestListPipelinesSearchDisabledWhenFilterSet(t *testing.T) { err := cmd.Flags().Set("filter", "state = 'RUNNING'") require.NoError(t, err) - cfg := tableview.GetTableConfig(cmd.Context()) + cfg := tableview.GetTableConfigForCmd(cmd) require.NotNil(t, cfg) require.NotNil(t, cfg.Search) - // The pipelines API does not support composite filters, so the - // PreRunE hook calls disableSearchIfFilterSet to nil out search. + // Simulate context setup so disableSearchIfFilterSet can read the config. + cmd.SetContext(tableview.SetTableConfig(t.Context(), cfg)) disableSearchIfFilterSet(cmd) assert.Nil(t, cfg.Search) } @@ -76,10 +76,11 @@ func TestListPipelinesSearchDisabledWhenFilterSet(t *testing.T) { func TestListPipelinesSearchNotDisabledWithoutFilter(t *testing.T) { cmd := newListPipelines() - cfg := tableview.GetTableConfig(cmd.Context()) + cfg := tableview.GetTableConfigForCmd(cmd) require.NotNil(t, cfg) - require.NotNil(t, cfg.Search) + // Simulate context setup so disableSearchIfFilterSet can read the config. + cmd.SetContext(tableview.SetTableConfig(t.Context(), cfg)) disableSearchIfFilterSet(cmd) assert.NotNil(t, cfg.Search) } diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 36d52fe716b..415bb0b494a 100644 --- a/cmd/workspace/repos/overrides.go +++ b/cmd/workspace/repos/overrides.go @@ -39,7 +39,7 @@ func listOverride(listCmd *cobra.Command, _ *workspace.ListReposRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func createOverride(createCmd *cobra.Command, createReq *workspace.CreateRepoRequest) { diff --git a/cmd/workspace/schemas/overrides.go b/cmd/workspace/schemas/overrides.go index 758c55a023d..1d2c16475f4 100644 --- a/cmd/workspace/schemas/overrides.go +++ b/cmd/workspace/schemas/overrides.go @@ -28,7 +28,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListSchemasRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index 93204cdf3f1..db550756202 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -31,7 +31,7 @@ func listScopesOverride(listScopesCmd *cobra.Command) { }}, } - listScopesCmd.SetContext(tableview.SetTableConfig(listScopesCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listScopesCmd, &tableview.TableConfig{Columns: columns}) } func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSecretsRequest) { @@ -56,7 +56,7 @@ func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSec }}, } - listSecretsCommand.SetContext(tableview.SetTableConfig(listSecretsCommand.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listSecretsCommand, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/serving-endpoints/overrides.go b/cmd/workspace/serving-endpoints/overrides.go index 2c9027b5b69..bc9850fb718 100644 --- a/cmd/workspace/serving-endpoints/overrides.go +++ b/cmd/workspace/serving-endpoints/overrides.go @@ -29,7 +29,7 @@ func listOverride(listCmd *cobra.Command) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/serving-endpoints/overrides_test.go b/cmd/workspace/serving-endpoints/overrides_test.go index c696ac19e11..e1a562b212a 100644 --- a/cmd/workspace/serving-endpoints/overrides_test.go +++ b/cmd/workspace/serving-endpoints/overrides_test.go @@ -12,7 +12,7 @@ import ( func TestListTableConfig(t *testing.T) { cmd := newList() - cfg := tableview.GetTableConfig(cmd.Context()) + cfg := tableview.GetTableConfigForCmd(cmd) require.NotNil(t, cfg) require.Len(t, cfg.Columns, 3) diff --git a/cmd/workspace/tables/overrides.go b/cmd/workspace/tables/overrides.go index c68b051fed8..0204eb57b4c 100644 --- a/cmd/workspace/tables/overrides.go +++ b/cmd/workspace/tables/overrides.go @@ -25,7 +25,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListTablesRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/volumes/overrides.go b/cmd/workspace/volumes/overrides.go index a31aae281d6..d0729b88a22 100644 --- a/cmd/workspace/volumes/overrides.go +++ b/cmd/workspace/volumes/overrides.go @@ -26,7 +26,7 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListVolumesRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/warehouses/overrides.go b/cmd/workspace/warehouses/overrides.go index 0a8f9f9d3b8..258e0f4076c 100644 --- a/cmd/workspace/warehouses/overrides.go +++ b/cmd/workspace/warehouses/overrides.go @@ -31,7 +31,7 @@ func listOverride(listCmd *cobra.Command, _ *sql.ListWarehousesRequest) { }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/workspace/overrides.go b/cmd/workspace/workspace/overrides.go index 18b556e2cb6..d1eb6f4016b 100644 --- a/cmd/workspace/workspace/overrides.go +++ b/cmd/workspace/workspace/overrides.go @@ -42,7 +42,7 @@ func listOverride(listCmd *cobra.Command, listReq *workspace.ListWorkspaceReques }}, } - listCmd.SetContext(tableview.SetTableConfig(listCmd.Context(), &tableview.TableConfig{Columns: columns})) + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func exportOverride(exportCmd *cobra.Command, exportReq *workspace.ExportRequest) { diff --git a/libs/tableview/context.go b/libs/tableview/context.go index ec50955f5a6..981299a8168 100644 --- a/libs/tableview/context.go +++ b/libs/tableview/context.go @@ -1,17 +1,16 @@ package tableview -import "context" +import ( + "context" + "sync" + + "github.com/spf13/cobra" +) type tableConfigKeyType struct{} // SetTableConfig stores a *TableConfig in context. -// If ctx is nil (e.g. during command construction before Execute), context.Background() is used. func SetTableConfig(ctx context.Context, cfg *TableConfig) context.Context { - if ctx == nil { - // Commands have no context during construction (before Execute), so - // context.Background is the only available root. - ctx = context.Background() //nolint:gocritic - } return context.WithValue(ctx, tableConfigKeyType{}, cfg) } @@ -20,3 +19,32 @@ func GetTableConfig(ctx context.Context) *TableConfig { cfg, _ := ctx.Value(tableConfigKeyType{}).(*TableConfig) return cfg } + +// configByCmd holds configs registered via SetTableConfigOnCmd so they can +// be looked up before the command's PreRunE has executed (useful in tests). +var configByCmd sync.Map + +// SetTableConfigOnCmd arranges for cfg to be stored in the command's context +// at execution time via PreRunE, preserving Cobra's context propagation. +func SetTableConfigOnCmd(cmd *cobra.Command, cfg *TableConfig) { + configByCmd.Store(cmd, cfg) + prev := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + cmd.SetContext(SetTableConfig(cmd.Context(), cfg)) + if prev != nil { + return prev(cmd, args) + } + return nil + } +} + +// GetTableConfigForCmd returns the config registered on cmd via +// SetTableConfigOnCmd. Unlike GetTableConfig (which reads from context), +// this works before the command's PreRunE has executed. +func GetTableConfigForCmd(cmd *cobra.Command) *TableConfig { + v, ok := configByCmd.Load(cmd) + if !ok { + return nil + } + return v.(*TableConfig) +} From baaf1a88f34478eceab7fdb9d441a06716a69c85 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 11:15:11 +0200 Subject: [PATCH 27/32] Extract shared computeColumnWidths to deduplicate width computation Co-authored-by: Isaac --- libs/tableview/common.go | 56 ++++++++++++++++++----------- libs/tableview/paginated.go | 20 ++++------- libs/tableview/tableview_test.go | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 33 deletions(-) diff --git a/libs/tableview/common.go b/libs/tableview/common.go index f389f2dcf7f..20c05101dba 100644 --- a/libs/tableview/common.go +++ b/libs/tableview/common.go @@ -26,6 +26,33 @@ var ( searchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) ) +// computeColumnWidths returns the display width for each column. +// It scans headers and all rows, taking the max rune count per column. +// If maxWidths[i] > 0, the width for column i is capped at that value. +// Pass nil for maxWidths to use no caps. +func computeColumnWidths(headers []string, rows [][]string, maxWidths []int) []int { + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = utf8.RuneCountInString(h) + } + for _, row := range rows { + for i := range widths { + if i < len(row) { + w := utf8.RuneCountInString(row[i]) + if w > widths[i] { + widths[i] = w + } + } + } + } + for i := range widths { + if maxWidths != nil && i < len(maxWidths) && maxWidths[i] > 0 { + widths[i] = min(widths[i], maxWidths[i]) + } + } + return widths +} + // renderTableLines produces aligned table text as individual lines. func renderTableLines(columns []string, rows [][]string) []string { var buf strings.Builder @@ -34,18 +61,8 @@ func renderTableLines(columns []string, rows [][]string) []string { // Header. fmt.Fprintln(tw, strings.Join(columns, "\t")) - // Separator: compute widths from header + data for dash line. - widths := make([]int, len(columns)) - for i, col := range columns { - widths[i] = utf8.RuneCountInString(col) - } - for _, row := range rows { - for i := range columns { - if i < len(row) { - widths[i] = max(widths[i], utf8.RuneCountInString(row[i])) - } - } - } + // Separator. + widths := computeColumnWidths(columns, rows, nil) seps := make([]string, len(columns)) for i, w := range widths { seps[i] = strings.Repeat("─", w) @@ -145,15 +162,14 @@ func RenderStaticTable(w io.Writer, columns []string, rows [][]string) error { // Header fmt.Fprintln(tw, strings.Join(columns, "\t")) // Separator + caps := make([]int, len(columns)) + for i := range caps { + caps[i] = maxColumnWidth + } + widths := computeColumnWidths(columns, rows, caps) seps := make([]string, len(columns)) - for i, col := range columns { - width := utf8.RuneCountInString(col) - for _, row := range rows { - if i < len(row) { - width = max(width, min(utf8.RuneCountInString(row[i]), maxColumnWidth)) - } - } - seps[i] = strings.Repeat("─", width) + for i, w := range widths { + seps[i] = strings.Repeat("─", w) } fmt.Fprintln(tw, strings.Join(seps, "\t")) // Data rows (no cell truncation; truncation is a TUI display concern) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index ee6d22ce5c8..ed4badcc79a 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -236,21 +236,15 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *paginatedModel) computeWidths() { - m.widths = make([]int, len(m.headers)) - for i, h := range m.headers { - m.widths[i] = utf8.RuneCountInString(h) - } - for _, row := range m.rows { - for i := range m.widths { - if i < len(row) { - maxW := defaultMaxColumnWidth - if i < len(m.cfg.Columns) && m.cfg.Columns[i].MaxWidth > 0 { - maxW = m.cfg.Columns[i].MaxWidth - } - m.widths[i] = min(max(m.widths[i], utf8.RuneCountInString(row[i])), maxW) - } + caps := make([]int, len(m.headers)) + for i := range caps { + maxW := defaultMaxColumnWidth + if i < len(m.cfg.Columns) && m.cfg.Columns[i].MaxWidth > 0 { + maxW = m.cfg.Columns[i].MaxWidth } + caps[i] = maxW } + m.widths = computeColumnWidths(m.headers, m.rows, caps) } func (m paginatedModel) renderContent() string { diff --git a/libs/tableview/tableview_test.go b/libs/tableview/tableview_test.go index d0ad651953e..55dd815c9cd 100644 --- a/libs/tableview/tableview_test.go +++ b/libs/tableview/tableview_test.go @@ -83,3 +83,65 @@ func TestHighlightSearchNoMatch(t *testing.T) { result := highlightSearch("hello bob", "alice") assert.Equal(t, "hello bob", result) } + +func TestComputeColumnWidths(t *testing.T) { + tests := []struct { + name string + headers []string + rows [][]string + maxWidths []int + want []int + }{ + { + name: "header wider than data", + headers: []string{"username", "id"}, + rows: [][]string{{"al", "1"}, {"bo", "2"}}, + want: []int{8, 2}, + }, + { + name: "data wider than header", + headers: []string{"id", "name"}, + rows: [][]string{{"1", "alexander"}, {"2", "bob"}}, + want: []int{2, 9}, + }, + { + name: "cap limits wide data", + headers: []string{"a", "b"}, + rows: [][]string{{"short", "this-is-a-very-long-value"}}, + maxWidths: []int{10, 10}, + want: []int{5, 10}, + }, + { + name: "nil maxWidths applies no caps", + headers: []string{"x"}, + rows: [][]string{{"a-really-long-string-with-no-cap"}}, + want: []int{32}, + }, + { + name: "empty rows returns header widths", + headers: []string{"name", "age"}, + rows: nil, + want: []int{4, 3}, + }, + { + name: "uneven row lengths", + headers: []string{"a", "b", "c"}, + rows: [][]string{{"longvalue"}, {"x", "y"}}, + want: []int{9, 1, 1}, + }, + { + name: "zero cap value means no cap for that column", + headers: []string{"a", "b"}, + rows: [][]string{{"longvalue", "longvalue"}}, + maxWidths: []int{0, 5}, + want: []int{9, 5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeColumnWidths(tt.headers, tt.rows, tt.maxWidths) + assert.Equal(t, tt.want, got) + }) + } +} From 44046116c77e2a7d63b6d19aea05416ff68f2a07 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 11:28:45 +0200 Subject: [PATCH 28/32] Extract shared renderTableToLines to deduplicate table rendering Co-authored-by: Isaac --- libs/tableview/common.go | 45 +++++++++++++++---------------------- libs/tableview/paginated.go | 31 ++++++------------------- 2 files changed, 25 insertions(+), 51 deletions(-) diff --git a/libs/tableview/common.go b/libs/tableview/common.go index 20c05101dba..aa9f3da9908 100644 --- a/libs/tableview/common.go +++ b/libs/tableview/common.go @@ -53,17 +53,17 @@ func computeColumnWidths(headers []string, rows [][]string, maxWidths []int) []i return widths } -// renderTableLines produces aligned table text as individual lines. -func renderTableLines(columns []string, rows [][]string) []string { +// renderTableToLines renders headers, a separator, and data rows through tabwriter, +// returning the output split into lines. Widths are used for the separator only. +func renderTableToLines(headers []string, widths []int, rows [][]string) []string { var buf strings.Builder tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) // Header. - fmt.Fprintln(tw, strings.Join(columns, "\t")) + fmt.Fprintln(tw, strings.Join(headers, "\t")) // Separator. - widths := computeColumnWidths(columns, rows, nil) - seps := make([]string, len(columns)) + seps := make([]string, len(headers)) for i, w := range widths { seps[i] = strings.Repeat("─", w) } @@ -71,8 +71,8 @@ func renderTableLines(columns []string, rows [][]string) []string { // Data rows. for _, row := range rows { - vals := make([]string, len(columns)) - for i := range columns { + vals := make([]string, len(headers)) + for i := range headers { if i < len(row) { vals[i] = row[i] } @@ -90,6 +90,12 @@ func renderTableLines(columns []string, rows [][]string) []string { return lines } +// renderTableLines produces aligned table text as individual lines. +func renderTableLines(columns []string, rows [][]string) []string { + widths := computeColumnWidths(columns, rows, nil) + return renderTableToLines(columns, widths, rows) +} + // findMatches returns line indices containing the query (case-insensitive). func findMatches(lines []string, query string) []int { if query == "" { @@ -158,32 +164,17 @@ func scrollViewportToCursor(vp *viewport.Model, cursor int) { func RenderStaticTable(w io.Writer, columns []string, rows [][]string) error { const maxColumnWidth = 40 - tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) - // Header - fmt.Fprintln(tw, strings.Join(columns, "\t")) - // Separator caps := make([]int, len(columns)) for i := range caps { caps[i] = maxColumnWidth } widths := computeColumnWidths(columns, rows, caps) - seps := make([]string, len(columns)) - for i, w := range widths { - seps[i] = strings.Repeat("─", w) - } - fmt.Fprintln(tw, strings.Join(seps, "\t")) - // Data rows (no cell truncation; truncation is a TUI display concern) - for _, row := range rows { - vals := make([]string, len(columns)) - for i := range columns { - if i < len(row) { - vals[i] = row[i] - } + lines := renderTableToLines(columns, widths, rows) + + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { + return err } - fmt.Fprintln(tw, strings.Join(vals, "\t")) - } - if err := tw.Flush(); err != nil { - return err } _, err := fmt.Fprintf(w, "\n%d rows\n", len(rows)) return err diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index ed4badcc79a..b800edb0dd1 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "strings" - "text/tabwriter" "time" "unicode/utf8" @@ -248,22 +247,10 @@ func (m *paginatedModel) computeWidths() { } func (m paginatedModel) renderContent() string { - var buf strings.Builder - tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) - - // Header - fmt.Fprintln(tw, strings.Join(m.headers, "\t")) - - // Separator - seps := make([]string, len(m.headers)) - for i, w := range m.widths { - seps[i] = strings.Repeat("─", w) - } - fmt.Fprintln(tw, strings.Join(seps, "\t")) - - // Data rows. - // MaxWidth truncation is destructive; horizontal scroll won't recover hidden text. - for _, row := range m.rows { + // Pre-truncate rows for display. MaxWidth truncation is destructive; + // horizontal scroll won't recover hidden text. + truncated := make([][]string, len(m.rows)) + for ri, row := range m.rows { vals := make([]string, len(m.headers)) for i := range m.headers { if i < len(row) { @@ -283,16 +270,12 @@ func (m paginatedModel) renderContent() string { vals[i] = v } } - fmt.Fprintln(tw, strings.Join(vals, "\t")) + truncated[ri] = vals } - tw.Flush() - lines := strings.Split(buf.String(), "\n") - if len(lines) > 0 && lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } + lines := renderTableToLines(m.headers, m.widths, truncated) - // Apply cursor highlighting + // Apply cursor highlighting. result := make([]string, len(lines)) for i, line := range lines { if i == m.cursor+headerLines { From 5767ea3e7b3e52155ece201052611c2d96920444 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 11:36:37 +0200 Subject: [PATCH 29/32] Consolidate maybeFetch tests, replace iterator panics with errors Co-authored-by: Isaac --- libs/cmdio/render.go | 4 +- libs/tableview/paginated_test.go | 114 ++++++++++++++++++------------- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 656ab334de4..3839d75b4dc 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -268,7 +268,7 @@ type listingInterface interface { func Render(ctx context.Context, v any) error { c := fromContext(ctx) if _, ok := v.(listingInterface); ok { - panic("use RenderIterator instead") + return errors.New("cannot render listing.Iterator directly; use RenderIterator instead") } return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template) } @@ -307,7 +307,7 @@ func RenderIterator[T any](ctx context.Context, cmd *cobra.Command, i listing.It func RenderWithTemplate(ctx context.Context, v any, headerTemplate, template string) error { c := fromContext(ctx) if _, ok := v.(listingInterface); ok { - panic("use RenderIteratorWithTemplate instead") + return errors.New("cannot render listing.Iterator directly; use RenderIteratorWithTemplate instead") } return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, headerTemplate, template) } diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 7ce1a922ae8..ac1428f3040 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -222,55 +222,71 @@ func TestPaginatedViewNotReady(t *testing.T) { assert.Equal(t, "Loading...", view) } -func TestPaginatedMaybeFetchTriggered(t *testing.T) { - m := newTestModel(t, nil, 0) - m.rows = make([][]string, 15) - m.cursor = 10 - m.loading = false - m.exhausted = false - - m, cmd := maybeFetch(m) - assert.NotNil(t, cmd) - assert.True(t, m.loading, "loading should be true after fetch triggered") -} - -func TestPaginatedMaybeFetchNotTriggeredWhenExhausted(t *testing.T) { - m := newTestModel(t, nil, 0) - m.rows = make([][]string, 15) - m.cursor = 10 - m.exhausted = true - - _, cmd := maybeFetch(m) - assert.Nil(t, cmd) -} - -func TestPaginatedMaybeFetchNotTriggeredWhenLoading(t *testing.T) { - m := newTestModel(t, nil, 0) - m.rows = make([][]string, 15) - m.cursor = 10 - m.loading = true - - _, cmd := maybeFetch(m) - assert.Nil(t, cmd) -} - -func TestPaginatedMaybeFetchNotTriggeredWhenSearching(t *testing.T) { - m := newTestModel(t, nil, 0) - m.rows = make([][]string, 15) - m.cursor = 10 - m.searching = true - - _, cmd := maybeFetch(m) - assert.Nil(t, cmd) -} - -func TestPaginatedMaybeFetchNotTriggeredWhenFarFromBottom(t *testing.T) { - m := newTestModel(t, nil, 0) - m.rows = make([][]string, 50) - m.cursor = 0 - - _, cmd := maybeFetch(m) - assert.Nil(t, cmd) +func TestMaybeFetch(t *testing.T) { + tests := []struct { + name string + setup func(*paginatedModel) + wantFetch bool + }{ + { + name: "triggers when near bottom and not loading", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 15) + m.cursor = 10 + m.loading = false + m.exhausted = false + }, + wantFetch: true, + }, + { + name: "does not trigger when exhausted", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 15) + m.cursor = 10 + m.exhausted = true + }, + wantFetch: false, + }, + { + name: "does not trigger when loading", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 15) + m.cursor = 10 + m.loading = true + }, + wantFetch: false, + }, + { + name: "does not trigger when searching", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 15) + m.cursor = 10 + m.searching = true + }, + wantFetch: false, + }, + { + name: "does not trigger when far from bottom", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 50) + m.cursor = 0 + }, + wantFetch: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newTestModel(t, nil, 0) + tt.setup(&m) + m, cmd := maybeFetch(m) + if tt.wantFetch { + assert.NotNil(t, cmd) + assert.True(t, m.loading, "loading should be true after fetch triggered") + } else { + assert.Nil(t, cmd) + } + }) + } } func TestPaginatedSearchEnterAndRestore(t *testing.T) { From c0f314ed850f4b513c71df0fd8e7eb6583d46a81 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 12:49:48 +0200 Subject: [PATCH 30/32] Address review findings: search UX, unused param, sanitize dedup - Keep previous rows visible during search (show "Searching..." footer instead of clearing rows immediately) - Clone TableConfig before mutation in disableSearchIfFilterSet - Remove unused cmd parameter from RenderIterator and update all callers - Export and deduplicate SanitizeControlWhitespace - Fix inaccurate highlightSearch comment about case folding Co-authored-by: Isaac --- cmd/account/budget-policy/budget-policy.go | 2 +- cmd/account/budgets/budgets.go | 2 +- .../custom-app-integration.go | 2 +- cmd/account/endpoints/endpoints.go | 2 +- .../federation-policy/federation-policy.go | 2 +- cmd/account/groups-v2/groups-v2.go | 2 +- cmd/account/groups/groups.go | 2 +- .../ip-access-lists/ip-access-lists.go | 2 +- cmd/account/log-delivery/log-delivery.go | 2 +- .../metastore-assignments.go | 2 +- cmd/account/metastores/metastores.go | 2 +- .../network-connectivity.go | 4 ++-- .../network-policies/network-policies.go | 2 +- .../o-auth-published-apps.go | 2 +- .../published-app-integration.go | 2 +- .../service-principal-federation-policy.go | 2 +- .../service-principal-secrets.go | 2 +- .../service-principals-v2.go | 2 +- .../service-principals/service-principals.go | 2 +- cmd/account/settings-v2/settings-v2.go | 4 ++-- .../storage-credentials.go | 2 +- cmd/account/users-v2/users-v2.go | 2 +- cmd/account/users/users.go | 2 +- .../workspace-assignment.go | 2 +- cmd/workspace/alerts-v2/alerts-v2.go | 2 +- cmd/workspace/alerts/alerts.go | 2 +- cmd/workspace/apps-settings/apps-settings.go | 2 +- cmd/workspace/apps/apps.go | 6 +++--- cmd/workspace/catalogs/catalogs.go | 2 +- .../clean-room-asset-revisions.go | 2 +- .../clean-room-assets/clean-room-assets.go | 2 +- .../clean-room-auto-approval-rules.go | 2 +- .../clean-room-task-runs.go | 2 +- cmd/workspace/clean-rooms/clean-rooms.go | 2 +- .../cluster-policies/cluster-policies.go | 2 +- cmd/workspace/clusters/clusters.go | 4 ++-- cmd/workspace/connections/connections.go | 2 +- .../consumer-fulfillments.go | 4 ++-- .../consumer-installations.go | 4 ++-- .../consumer-listings/consumer-listings.go | 4 ++-- .../consumer-personalization-requests.go | 2 +- .../consumer-providers/consumer-providers.go | 2 +- cmd/workspace/credentials/credentials.go | 2 +- cmd/workspace/dashboards/dashboards.go | 2 +- cmd/workspace/data-quality/data-quality.go | 4 ++-- cmd/workspace/database/database.go | 8 +++---- .../entity-tag-assignments.go | 2 +- cmd/workspace/environments/environments.go | 2 +- cmd/workspace/experiments/experiments.go | 10 ++++----- .../external-lineage/external-lineage.go | 2 +- .../external-locations/external-locations.go | 2 +- .../external-metadata/external-metadata.go | 2 +- .../feature-engineering.go | 6 +++--- cmd/workspace/feature-store/feature-store.go | 2 +- cmd/workspace/functions/functions.go | 2 +- .../git-credentials/git-credentials.go | 2 +- .../global-init-scripts.go | 2 +- cmd/workspace/groups-v2/groups-v2.go | 2 +- cmd/workspace/groups/groups.go | 2 +- .../instance-pools/instance-pools.go | 2 +- .../instance-profiles/instance-profiles.go | 2 +- .../ip-access-lists/ip-access-lists.go | 2 +- cmd/workspace/jobs/jobs.go | 4 ++-- .../knowledge-assistants.go | 4 ++-- cmd/workspace/lakeview/lakeview.go | 6 +++--- cmd/workspace/libraries/libraries.go | 4 ++-- .../materialized-features.go | 2 +- cmd/workspace/metastores/metastores.go | 2 +- .../model-registry/model-registry.go | 12 +++++------ .../model-versions/model-versions.go | 2 +- .../notification-destinations.go | 2 +- cmd/workspace/pipelines/overrides.go | 15 +++++-------- cmd/workspace/pipelines/overrides_test.go | 8 ++++++- cmd/workspace/pipelines/pipelines.go | 4 ++-- cmd/workspace/policies/policies.go | 2 +- .../policy-compliance-for-clusters.go | 2 +- .../policy-compliance-for-jobs.go | 2 +- .../policy-families/policy-families.go | 2 +- cmd/workspace/postgres/postgres.go | 10 ++++----- .../provider-exchange-filters.go | 2 +- .../provider-exchanges/provider-exchanges.go | 6 +++--- .../provider-files/provider-files.go | 2 +- .../provider-listings/provider-listings.go | 2 +- .../provider-personalization-requests.go | 2 +- .../provider-providers/provider-providers.go | 2 +- cmd/workspace/providers/providers.go | 4 ++-- .../quality-monitor-v2/quality-monitor-v2.go | 2 +- .../queries-legacy/queries-legacy.go | 2 +- cmd/workspace/queries/queries.go | 4 ++-- .../recipient-federation-policies.go | 2 +- cmd/workspace/recipients/recipients.go | 2 +- .../registered-models/registered-models.go | 2 +- cmd/workspace/repos/repos.go | 2 +- .../resource-quotas/resource-quotas.go | 2 +- cmd/workspace/schemas/schemas.go | 2 +- cmd/workspace/secrets/secrets.go | 6 +++--- .../service-principal-secrets-proxy.go | 2 +- .../service-principals-v2.go | 2 +- .../service-principals/service-principals.go | 2 +- .../serving-endpoints/serving-endpoints.go | 2 +- cmd/workspace/shares/overrides.go | 2 +- cmd/workspace/shares/shares.go | 2 +- .../storage-credentials.go | 2 +- .../system-schemas/system-schemas.go | 2 +- cmd/workspace/tables/tables.go | 4 ++-- cmd/workspace/tag-policies/tag-policies.go | 2 +- .../token-management/token-management.go | 2 +- cmd/workspace/tokens/tokens.go | 2 +- cmd/workspace/users-v2/users-v2.go | 2 +- cmd/workspace/users/users.go | 2 +- .../vector-search-endpoints.go | 2 +- .../vector-search-indexes.go | 2 +- cmd/workspace/volumes/volumes.go | 2 +- cmd/workspace/warehouses/warehouses.go | 4 ++-- .../workspace-bindings/workspace-bindings.go | 2 +- .../workspace-entity-tag-assignments.go | 2 +- .../workspace-settings-v2.go | 2 +- cmd/workspace/workspace/workspace.go | 2 +- libs/cmdio/render.go | 16 +++++++------- libs/cmdio/render_test.go | 2 +- libs/tableview/common.go | 5 +++-- libs/tableview/paginated.go | 21 ++++++++++++++++--- 122 files changed, 198 insertions(+), 183 deletions(-) diff --git a/cmd/account/budget-policy/budget-policy.go b/cmd/account/budget-policy/budget-policy.go index 8a4075dcba1..37adb68b13b 100755 --- a/cmd/account/budget-policy/budget-policy.go +++ b/cmd/account/budget-policy/budget-policy.go @@ -260,7 +260,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.BudgetPolicy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/budgets/budgets.go b/cmd/account/budgets/budgets.go index 8e37628b5ae..9fb34e4664d 100755 --- a/cmd/account/budgets/budgets.go +++ b/cmd/account/budgets/budgets.go @@ -258,7 +258,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.Budgets.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/custom-app-integration/custom-app-integration.go b/cmd/account/custom-app-integration/custom-app-integration.go index 1019f655323..3a6d9d151d9 100755 --- a/cmd/account/custom-app-integration/custom-app-integration.go +++ b/cmd/account/custom-app-integration/custom-app-integration.go @@ -266,7 +266,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.CustomAppIntegration.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/endpoints/endpoints.go b/cmd/account/endpoints/endpoints.go index 8b3584984ab..9e1d3e7bac1 100755 --- a/cmd/account/endpoints/endpoints.go +++ b/cmd/account/endpoints/endpoints.go @@ -290,7 +290,7 @@ func newListEndpoints() *cobra.Command { listEndpointsReq.Parent = args[0] response := a.Endpoints.ListEndpoints(ctx, listEndpointsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/federation-policy/federation-policy.go b/cmd/account/federation-policy/federation-policy.go index de2621835ad..e8aba32696b 100755 --- a/cmd/account/federation-policy/federation-policy.go +++ b/cmd/account/federation-policy/federation-policy.go @@ -299,7 +299,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.FederationPolicy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/groups-v2/groups-v2.go b/cmd/account/groups-v2/groups-v2.go index e03255479af..669b21f84a9 100755 --- a/cmd/account/groups-v2/groups-v2.go +++ b/cmd/account/groups-v2/groups-v2.go @@ -278,7 +278,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.GroupsV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/groups/groups.go b/cmd/account/groups/groups.go index 9ad32b67d28..9f2a4e89dfd 100755 --- a/cmd/account/groups/groups.go +++ b/cmd/account/groups/groups.go @@ -311,7 +311,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.Groups.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/ip-access-lists/ip-access-lists.go b/cmd/account/ip-access-lists/ip-access-lists.go index 04b787cd39f..32bf120db51 100755 --- a/cmd/account/ip-access-lists/ip-access-lists.go +++ b/cmd/account/ip-access-lists/ip-access-lists.go @@ -327,7 +327,7 @@ func newList() *cobra.Command { ctx := cmd.Context() a := cmdctx.AccountClient(ctx) response := a.IpAccessLists.List(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/log-delivery/log-delivery.go b/cmd/account/log-delivery/log-delivery.go index 721f4a9ffb5..e94e4194353 100755 --- a/cmd/account/log-delivery/log-delivery.go +++ b/cmd/account/log-delivery/log-delivery.go @@ -301,7 +301,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.LogDelivery.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/metastore-assignments/metastore-assignments.go b/cmd/account/metastore-assignments/metastore-assignments.go index 9fab7da7b5e..b1e574596f0 100755 --- a/cmd/account/metastore-assignments/metastore-assignments.go +++ b/cmd/account/metastore-assignments/metastore-assignments.go @@ -284,7 +284,7 @@ func newList() *cobra.Command { listReq.MetastoreId = args[0] response := a.MetastoreAssignments.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/metastores/metastores.go b/cmd/account/metastores/metastores.go index 023c12d0155..f2bfebacb18 100755 --- a/cmd/account/metastores/metastores.go +++ b/cmd/account/metastores/metastores.go @@ -247,7 +247,7 @@ func newList() *cobra.Command { ctx := cmd.Context() a := cmdctx.AccountClient(ctx) response := a.Metastores.List(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/network-connectivity/network-connectivity.go b/cmd/account/network-connectivity/network-connectivity.go index 6e62b752021..23c4e85dded 100755 --- a/cmd/account/network-connectivity/network-connectivity.go +++ b/cmd/account/network-connectivity/network-connectivity.go @@ -510,7 +510,7 @@ func newListNetworkConnectivityConfigurations() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.NetworkConnectivity.ListNetworkConnectivityConfigurations(ctx, listNetworkConnectivityConfigurationsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -565,7 +565,7 @@ func newListPrivateEndpointRules() *cobra.Command { listPrivateEndpointRulesReq.NetworkConnectivityConfigId = args[0] response := a.NetworkConnectivity.ListPrivateEndpointRules(ctx, listPrivateEndpointRulesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/network-policies/network-policies.go b/cmd/account/network-policies/network-policies.go index 8ea14b28b87..4165773e646 100755 --- a/cmd/account/network-policies/network-policies.go +++ b/cmd/account/network-policies/network-policies.go @@ -265,7 +265,7 @@ func newListNetworkPoliciesRpc() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.NetworkPolicies.ListNetworkPoliciesRpc(ctx, listNetworkPoliciesRpcReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/o-auth-published-apps/o-auth-published-apps.go b/cmd/account/o-auth-published-apps/o-auth-published-apps.go index f95e421c5c3..254ef5324c1 100755 --- a/cmd/account/o-auth-published-apps/o-auth-published-apps.go +++ b/cmd/account/o-auth-published-apps/o-auth-published-apps.go @@ -73,7 +73,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.OAuthPublishedApps.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/published-app-integration/published-app-integration.go b/cmd/account/published-app-integration/published-app-integration.go index d4c8f71c63f..58e3fc923dc 100755 --- a/cmd/account/published-app-integration/published-app-integration.go +++ b/cmd/account/published-app-integration/published-app-integration.go @@ -258,7 +258,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.PublishedAppIntegration.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/service-principal-federation-policy/service-principal-federation-policy.go b/cmd/account/service-principal-federation-policy/service-principal-federation-policy.go index 77d10226eef..13edacfb9ac 100755 --- a/cmd/account/service-principal-federation-policy/service-principal-federation-policy.go +++ b/cmd/account/service-principal-federation-policy/service-principal-federation-policy.go @@ -341,7 +341,7 @@ func newList() *cobra.Command { } response := a.ServicePrincipalFederationPolicy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/service-principal-secrets/service-principal-secrets.go b/cmd/account/service-principal-secrets/service-principal-secrets.go index d8b2261fa3f..022e8090fc4 100755 --- a/cmd/account/service-principal-secrets/service-principal-secrets.go +++ b/cmd/account/service-principal-secrets/service-principal-secrets.go @@ -223,7 +223,7 @@ func newList() *cobra.Command { listReq.ServicePrincipalId = args[0] response := a.ServicePrincipalSecrets.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/service-principals-v2/service-principals-v2.go b/cmd/account/service-principals-v2/service-principals-v2.go index edfb73130a6..dfdd40c36cb 100755 --- a/cmd/account/service-principals-v2/service-principals-v2.go +++ b/cmd/account/service-principals-v2/service-principals-v2.go @@ -273,7 +273,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.ServicePrincipalsV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/service-principals/service-principals.go b/cmd/account/service-principals/service-principals.go index 3f1cc353965..06d410fc33d 100755 --- a/cmd/account/service-principals/service-principals.go +++ b/cmd/account/service-principals/service-principals.go @@ -308,7 +308,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.ServicePrincipals.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/settings-v2/settings-v2.go b/cmd/account/settings-v2/settings-v2.go index ab06b1554a2..11883f11f67 100755 --- a/cmd/account/settings-v2/settings-v2.go +++ b/cmd/account/settings-v2/settings-v2.go @@ -197,7 +197,7 @@ func newListAccountSettingsMetadata() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.SettingsV2.ListAccountSettingsMetadata(ctx, listAccountSettingsMetadataReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -260,7 +260,7 @@ func newListAccountUserPreferencesMetadata() *cobra.Command { listAccountUserPreferencesMetadataReq.UserId = args[0] response := a.SettingsV2.ListAccountUserPreferencesMetadata(ctx, listAccountUserPreferencesMetadataReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/storage-credentials/storage-credentials.go b/cmd/account/storage-credentials/storage-credentials.go index 854a91f3389..c3fce95ea29 100755 --- a/cmd/account/storage-credentials/storage-credentials.go +++ b/cmd/account/storage-credentials/storage-credentials.go @@ -278,7 +278,7 @@ func newList() *cobra.Command { listReq.MetastoreId = args[0] response := a.StorageCredentials.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/users-v2/users-v2.go b/cmd/account/users-v2/users-v2.go index ed23cc18374..b7ddd391b4e 100755 --- a/cmd/account/users-v2/users-v2.go +++ b/cmd/account/users-v2/users-v2.go @@ -289,7 +289,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.UsersV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/users/users.go b/cmd/account/users/users.go index e8e88a7aa65..57218bafc65 100755 --- a/cmd/account/users/users.go +++ b/cmd/account/users/users.go @@ -324,7 +324,7 @@ func newList() *cobra.Command { a := cmdctx.AccountClient(ctx) response := a.Users.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/account/workspace-assignment/workspace-assignment.go b/cmd/account/workspace-assignment/workspace-assignment.go index f38873baae7..16c163cd6bf 100755 --- a/cmd/account/workspace-assignment/workspace-assignment.go +++ b/cmd/account/workspace-assignment/workspace-assignment.go @@ -208,7 +208,7 @@ func newList() *cobra.Command { } response := a.WorkspaceAssignment.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/alerts-v2/alerts-v2.go b/cmd/workspace/alerts-v2/alerts-v2.go index 5931f25c123..cffe9760401 100755 --- a/cmd/workspace/alerts-v2/alerts-v2.go +++ b/cmd/workspace/alerts-v2/alerts-v2.go @@ -254,7 +254,7 @@ func newListAlerts() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.AlertsV2.ListAlerts(ctx, listAlertsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/alerts/alerts.go b/cmd/workspace/alerts/alerts.go index 36a489fa3e0..38c012d2603 100755 --- a/cmd/workspace/alerts/alerts.go +++ b/cmd/workspace/alerts/alerts.go @@ -285,7 +285,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Alerts.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/apps-settings/apps-settings.go b/cmd/workspace/apps-settings/apps-settings.go index 09cfc23961f..a3306c04686 100755 --- a/cmd/workspace/apps-settings/apps-settings.go +++ b/cmd/workspace/apps-settings/apps-settings.go @@ -299,7 +299,7 @@ func newListCustomTemplates() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.AppsSettings.ListCustomTemplates(ctx, listCustomTemplatesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/apps/apps.go b/cmd/workspace/apps/apps.go index 8bd3ef4961d..dd6f6b5dfa6 100755 --- a/cmd/workspace/apps/apps.go +++ b/cmd/workspace/apps/apps.go @@ -1146,7 +1146,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Apps.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1202,7 +1202,7 @@ func newListDeployments() *cobra.Command { listDeploymentsReq.AppName = args[0] response := w.Apps.ListDeployments(ctx, listDeploymentsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1256,7 +1256,7 @@ func newListSpaces() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Apps.ListSpaces(ctx, listSpacesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/catalogs/catalogs.go b/cmd/workspace/catalogs/catalogs.go index 5daa758f1f7..43ff7642078 100755 --- a/cmd/workspace/catalogs/catalogs.go +++ b/cmd/workspace/catalogs/catalogs.go @@ -306,7 +306,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Catalogs.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-room-asset-revisions/clean-room-asset-revisions.go b/cmd/workspace/clean-room-asset-revisions/clean-room-asset-revisions.go index 858c4d483d7..b836f0be266 100755 --- a/cmd/workspace/clean-room-asset-revisions/clean-room-asset-revisions.go +++ b/cmd/workspace/clean-room-asset-revisions/clean-room-asset-revisions.go @@ -156,7 +156,7 @@ func newList() *cobra.Command { listReq.Name = args[2] response := w.CleanRoomAssetRevisions.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-room-assets/clean-room-assets.go b/cmd/workspace/clean-room-assets/clean-room-assets.go index 5025f379d3f..4654d9b3572 100755 --- a/cmd/workspace/clean-room-assets/clean-room-assets.go +++ b/cmd/workspace/clean-room-assets/clean-room-assets.go @@ -409,7 +409,7 @@ func newList() *cobra.Command { listReq.CleanRoomName = args[0] response := w.CleanRoomAssets.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-room-auto-approval-rules/clean-room-auto-approval-rules.go b/cmd/workspace/clean-room-auto-approval-rules/clean-room-auto-approval-rules.go index 984feb39404..b45d0b8745d 100755 --- a/cmd/workspace/clean-room-auto-approval-rules/clean-room-auto-approval-rules.go +++ b/cmd/workspace/clean-room-auto-approval-rules/clean-room-auto-approval-rules.go @@ -262,7 +262,7 @@ func newList() *cobra.Command { listReq.CleanRoomName = args[0] response := w.CleanRoomAutoApprovalRules.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-room-task-runs/clean-room-task-runs.go b/cmd/workspace/clean-room-task-runs/clean-room-task-runs.go index f14f8d61143..a889675a60b 100755 --- a/cmd/workspace/clean-room-task-runs/clean-room-task-runs.go +++ b/cmd/workspace/clean-room-task-runs/clean-room-task-runs.go @@ -76,7 +76,7 @@ func newList() *cobra.Command { listReq.CleanRoomName = args[0] response := w.CleanRoomTaskRuns.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clean-rooms/clean-rooms.go b/cmd/workspace/clean-rooms/clean-rooms.go index 2abcc224f21..3aea991abff 100755 --- a/cmd/workspace/clean-rooms/clean-rooms.go +++ b/cmd/workspace/clean-rooms/clean-rooms.go @@ -369,7 +369,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.CleanRooms.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/cluster-policies/cluster-policies.go b/cmd/workspace/cluster-policies/cluster-policies.go index 803e67343ba..83df4ae3117 100755 --- a/cmd/workspace/cluster-policies/cluster-policies.go +++ b/cmd/workspace/cluster-policies/cluster-policies.go @@ -584,7 +584,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ClusterPolicies.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/clusters/clusters.go b/cmd/workspace/clusters/clusters.go index dcf912d76fb..64f2d810971 100755 --- a/cmd/workspace/clusters/clusters.go +++ b/cmd/workspace/clusters/clusters.go @@ -696,7 +696,7 @@ func newEvents() *cobra.Command { } response := w.Clusters.Events(ctx, eventsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -957,7 +957,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Clusters.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/connections/connections.go b/cmd/workspace/connections/connections.go index a2b66155fb5..f7a291e51c4 100755 --- a/cmd/workspace/connections/connections.go +++ b/cmd/workspace/connections/connections.go @@ -303,7 +303,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Connections.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-fulfillments/consumer-fulfillments.go b/cmd/workspace/consumer-fulfillments/consumer-fulfillments.go index 2fd1a315af9..d08d1165454 100755 --- a/cmd/workspace/consumer-fulfillments/consumer-fulfillments.go +++ b/cmd/workspace/consumer-fulfillments/consumer-fulfillments.go @@ -73,7 +73,7 @@ func newGet() *cobra.Command { getReq.ListingId = args[0] response := w.ConsumerFulfillments.Get(ctx, getReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -130,7 +130,7 @@ func newList() *cobra.Command { listReq.ListingId = args[0] response := w.ConsumerFulfillments.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-installations/consumer-installations.go b/cmd/workspace/consumer-installations/consumer-installations.go index 7e5819f6a3b..09ecadd8a23 100755 --- a/cmd/workspace/consumer-installations/consumer-installations.go +++ b/cmd/workspace/consumer-installations/consumer-installations.go @@ -206,7 +206,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ConsumerInstallations.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -259,7 +259,7 @@ func newListListingInstallations() *cobra.Command { listListingInstallationsReq.ListingId = args[0] response := w.ConsumerInstallations.ListListingInstallations(ctx, listListingInstallationsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-listings/consumer-listings.go b/cmd/workspace/consumer-listings/consumer-listings.go index 31c9693fee2..43e7ea74c03 100755 --- a/cmd/workspace/consumer-listings/consumer-listings.go +++ b/cmd/workspace/consumer-listings/consumer-listings.go @@ -204,7 +204,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ConsumerListings.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -279,7 +279,7 @@ func newSearch() *cobra.Command { searchReq.Query = args[0] response := w.ConsumerListings.Search(ctx, searchReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-personalization-requests/consumer-personalization-requests.go b/cmd/workspace/consumer-personalization-requests/consumer-personalization-requests.go index 487cd623ebe..e1ed812c4c4 100755 --- a/cmd/workspace/consumer-personalization-requests/consumer-personalization-requests.go +++ b/cmd/workspace/consumer-personalization-requests/consumer-personalization-requests.go @@ -207,7 +207,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ConsumerPersonalizationRequests.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/consumer-providers/consumer-providers.go b/cmd/workspace/consumer-providers/consumer-providers.go index 3c61dda7780..058accfa1ff 100755 --- a/cmd/workspace/consumer-providers/consumer-providers.go +++ b/cmd/workspace/consumer-providers/consumer-providers.go @@ -196,7 +196,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ConsumerProviders.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/credentials/credentials.go b/cmd/workspace/credentials/credentials.go index f97b70787ae..f0e37742688 100755 --- a/cmd/workspace/credentials/credentials.go +++ b/cmd/workspace/credentials/credentials.go @@ -395,7 +395,7 @@ func newListCredentials() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Credentials.ListCredentials(ctx, listCredentialsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/dashboards/dashboards.go b/cmd/workspace/dashboards/dashboards.go index 775f79a7ebc..95ee2111bef 100755 --- a/cmd/workspace/dashboards/dashboards.go +++ b/cmd/workspace/dashboards/dashboards.go @@ -213,7 +213,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Dashboards.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/data-quality/data-quality.go b/cmd/workspace/data-quality/data-quality.go index 4338b42a946..59dfb28a99e 100755 --- a/cmd/workspace/data-quality/data-quality.go +++ b/cmd/workspace/data-quality/data-quality.go @@ -729,7 +729,7 @@ func newListMonitor() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.DataQuality.ListMonitor(ctx, listMonitorReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -814,7 +814,7 @@ func newListRefresh() *cobra.Command { listRefreshReq.ObjectId = args[1] response := w.DataQuality.ListRefresh(ctx, listRefreshReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/database/database.go b/cmd/workspace/database/database.go index c5e85f62ee4..a84f9a09713 100755 --- a/cmd/workspace/database/database.go +++ b/cmd/workspace/database/database.go @@ -1222,7 +1222,7 @@ func newListDatabaseCatalogs() *cobra.Command { listDatabaseCatalogsReq.InstanceName = args[0] response := w.Database.ListDatabaseCatalogs(ctx, listDatabaseCatalogsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1282,7 +1282,7 @@ func newListDatabaseInstanceRoles() *cobra.Command { listDatabaseInstanceRolesReq.InstanceName = args[0] response := w.Database.ListDatabaseInstanceRoles(ctx, listDatabaseInstanceRolesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1331,7 +1331,7 @@ func newListDatabaseInstances() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Database.ListDatabaseInstances(ctx, listDatabaseInstancesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1390,7 +1390,7 @@ func newListSyncedDatabaseTables() *cobra.Command { listSyncedDatabaseTablesReq.InstanceName = args[0] response := w.Database.ListSyncedDatabaseTables(ctx, listSyncedDatabaseTablesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/entity-tag-assignments/entity-tag-assignments.go b/cmd/workspace/entity-tag-assignments/entity-tag-assignments.go index c1ced90c004..b8893a9c806 100755 --- a/cmd/workspace/entity-tag-assignments/entity-tag-assignments.go +++ b/cmd/workspace/entity-tag-assignments/entity-tag-assignments.go @@ -328,7 +328,7 @@ func newList() *cobra.Command { listReq.EntityName = args[1] response := w.EntityTagAssignments.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/environments/environments.go b/cmd/workspace/environments/environments.go index 05001129d8d..6afb38437bc 100755 --- a/cmd/workspace/environments/environments.go +++ b/cmd/workspace/environments/environments.go @@ -454,7 +454,7 @@ func newListWorkspaceBaseEnvironments() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Environments.ListWorkspaceBaseEnvironments(ctx, listWorkspaceBaseEnvironmentsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/experiments/experiments.go b/cmd/workspace/experiments/experiments.go index c525c48f4f0..5527006c238 100755 --- a/cmd/workspace/experiments/experiments.go +++ b/cmd/workspace/experiments/experiments.go @@ -1032,7 +1032,7 @@ func newGetHistory() *cobra.Command { getHistoryReq.MetricKey = args[0] response := w.Experiments.GetHistory(ctx, getHistoryReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1320,7 +1320,7 @@ func newListArtifacts() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Experiments.ListArtifacts(ctx, listArtifactsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1372,7 +1372,7 @@ func newListExperiments() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Experiments.ListExperiments(ctx, listExperimentsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -2329,7 +2329,7 @@ func newSearchExperiments() *cobra.Command { } response := w.Experiments.SearchExperiments(ctx, searchExperimentsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -2476,7 +2476,7 @@ func newSearchRuns() *cobra.Command { } response := w.Experiments.SearchRuns(ctx, searchRunsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/external-lineage/external-lineage.go b/cmd/workspace/external-lineage/external-lineage.go index b3e272ba202..be80f67c90a 100755 --- a/cmd/workspace/external-lineage/external-lineage.go +++ b/cmd/workspace/external-lineage/external-lineage.go @@ -258,7 +258,7 @@ func newListExternalLineageRelationships() *cobra.Command { } response := w.ExternalLineage.ListExternalLineageRelationships(ctx, listExternalLineageRelationshipsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/external-locations/external-locations.go b/cmd/workspace/external-locations/external-locations.go index e59e5c7b07c..ea3a855921e 100755 --- a/cmd/workspace/external-locations/external-locations.go +++ b/cmd/workspace/external-locations/external-locations.go @@ -319,7 +319,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ExternalLocations.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/external-metadata/external-metadata.go b/cmd/workspace/external-metadata/external-metadata.go index 619ffaf6c8d..7768c5e99bd 100755 --- a/cmd/workspace/external-metadata/external-metadata.go +++ b/cmd/workspace/external-metadata/external-metadata.go @@ -323,7 +323,7 @@ func newListExternalMetadata() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ExternalMetadata.ListExternalMetadata(ctx, listExternalMetadataReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/feature-engineering/feature-engineering.go b/cmd/workspace/feature-engineering/feature-engineering.go index 097a37817d2..5d1e5375079 100755 --- a/cmd/workspace/feature-engineering/feature-engineering.go +++ b/cmd/workspace/feature-engineering/feature-engineering.go @@ -728,7 +728,7 @@ func newListFeatures() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.FeatureEngineering.ListFeatures(ctx, listFeaturesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -781,7 +781,7 @@ func newListKafkaConfigs() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.FeatureEngineering.ListKafkaConfigs(ctx, listKafkaConfigsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -831,7 +831,7 @@ func newListMaterializedFeatures() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.FeatureEngineering.ListMaterializedFeatures(ctx, listMaterializedFeaturesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/feature-store/feature-store.go b/cmd/workspace/feature-store/feature-store.go index 3cdd48541cc..d9bb4633292 100755 --- a/cmd/workspace/feature-store/feature-store.go +++ b/cmd/workspace/feature-store/feature-store.go @@ -338,7 +338,7 @@ func newListOnlineStores() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.FeatureStore.ListOnlineStores(ctx, listOnlineStoresReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/functions/functions.go b/cmd/workspace/functions/functions.go index 7f9eb1968b3..9ecf0948b39 100755 --- a/cmd/workspace/functions/functions.go +++ b/cmd/workspace/functions/functions.go @@ -327,7 +327,7 @@ func newList() *cobra.Command { listReq.SchemaName = args[1] response := w.Functions.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/git-credentials/git-credentials.go b/cmd/workspace/git-credentials/git-credentials.go index 15248b72138..9d3cae1b4f1 100755 --- a/cmd/workspace/git-credentials/git-credentials.go +++ b/cmd/workspace/git-credentials/git-credentials.go @@ -318,7 +318,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.GitCredentials.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/global-init-scripts/global-init-scripts.go b/cmd/workspace/global-init-scripts/global-init-scripts.go index 535bee013fe..610ba872690 100755 --- a/cmd/workspace/global-init-scripts/global-init-scripts.go +++ b/cmd/workspace/global-init-scripts/global-init-scripts.go @@ -299,7 +299,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.GlobalInitScripts.List(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/groups-v2/groups-v2.go b/cmd/workspace/groups-v2/groups-v2.go index e02fb4547c8..cd3247c526b 100755 --- a/cmd/workspace/groups-v2/groups-v2.go +++ b/cmd/workspace/groups-v2/groups-v2.go @@ -277,7 +277,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.GroupsV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/groups/groups.go b/cmd/workspace/groups/groups.go index 9d02a13e812..595c03218bb 100755 --- a/cmd/workspace/groups/groups.go +++ b/cmd/workspace/groups/groups.go @@ -309,7 +309,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Groups.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/instance-pools/instance-pools.go b/cmd/workspace/instance-pools/instance-pools.go index 1f31a5a0792..640058629d1 100755 --- a/cmd/workspace/instance-pools/instance-pools.go +++ b/cmd/workspace/instance-pools/instance-pools.go @@ -591,7 +591,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.InstancePools.List(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/instance-profiles/instance-profiles.go b/cmd/workspace/instance-profiles/instance-profiles.go index d833e427536..5acc3c30bf7 100755 --- a/cmd/workspace/instance-profiles/instance-profiles.go +++ b/cmd/workspace/instance-profiles/instance-profiles.go @@ -257,7 +257,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.InstanceProfiles.List(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/ip-access-lists/ip-access-lists.go b/cmd/workspace/ip-access-lists/ip-access-lists.go index 32d7026e1b5..b1a8e61ffa6 100755 --- a/cmd/workspace/ip-access-lists/ip-access-lists.go +++ b/cmd/workspace/ip-access-lists/ip-access-lists.go @@ -328,7 +328,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.IpAccessLists.List(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/jobs/jobs.go b/cmd/workspace/jobs/jobs.go index e12895226f9..93e8f29bb4a 100755 --- a/cmd/workspace/jobs/jobs.go +++ b/cmd/workspace/jobs/jobs.go @@ -1027,7 +1027,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Jobs.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1086,7 +1086,7 @@ func newListRuns() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Jobs.ListRuns(ctx, listRunsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/knowledge-assistants/knowledge-assistants.go b/cmd/workspace/knowledge-assistants/knowledge-assistants.go index a31ce630403..a23c0fa88e9 100755 --- a/cmd/workspace/knowledge-assistants/knowledge-assistants.go +++ b/cmd/workspace/knowledge-assistants/knowledge-assistants.go @@ -508,7 +508,7 @@ func newListKnowledgeAssistants() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.KnowledgeAssistants.ListKnowledgeAssistants(ctx, listKnowledgeAssistantsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -565,7 +565,7 @@ func newListKnowledgeSources() *cobra.Command { listKnowledgeSourcesReq.Parent = args[0] response := w.KnowledgeAssistants.ListKnowledgeSources(ctx, listKnowledgeSourcesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/lakeview/lakeview.go b/cmd/workspace/lakeview/lakeview.go index 29d2caa0e48..4dd071a903c 100755 --- a/cmd/workspace/lakeview/lakeview.go +++ b/cmd/workspace/lakeview/lakeview.go @@ -690,7 +690,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Lakeview.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -744,7 +744,7 @@ func newListSchedules() *cobra.Command { listSchedulesReq.DashboardId = args[0] response := w.Lakeview.ListSchedules(ctx, listSchedulesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -800,7 +800,7 @@ func newListSubscriptions() *cobra.Command { listSubscriptionsReq.ScheduleId = args[1] response := w.Lakeview.ListSubscriptions(ctx, listSubscriptionsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/libraries/libraries.go b/cmd/workspace/libraries/libraries.go index 7618bd6be44..db2225d9e1c 100755 --- a/cmd/workspace/libraries/libraries.go +++ b/cmd/workspace/libraries/libraries.go @@ -80,7 +80,7 @@ func newAllClusterStatuses() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.Libraries.AllClusterStatuses(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -139,7 +139,7 @@ func newClusterStatus() *cobra.Command { clusterStatusReq.ClusterId = args[0] response := w.Libraries.ClusterStatus(ctx, clusterStatusReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/materialized-features/materialized-features.go b/cmd/workspace/materialized-features/materialized-features.go index 3cb3d9ce94c..12194b38fee 100755 --- a/cmd/workspace/materialized-features/materialized-features.go +++ b/cmd/workspace/materialized-features/materialized-features.go @@ -338,7 +338,7 @@ func newListFeatureTags() *cobra.Command { listFeatureTagsReq.FeatureName = args[1] response := w.MaterializedFeatures.ListFeatureTags(ctx, listFeatureTagsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/metastores/metastores.go b/cmd/workspace/metastores/metastores.go index 3ed3893d329..9f00ed1bfd9 100755 --- a/cmd/workspace/metastores/metastores.go +++ b/cmd/workspace/metastores/metastores.go @@ -443,7 +443,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Metastores.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/model-registry/model-registry.go b/cmd/workspace/model-registry/model-registry.go index fceff6a5cf4..047040ce46e 100755 --- a/cmd/workspace/model-registry/model-registry.go +++ b/cmd/workspace/model-registry/model-registry.go @@ -1102,7 +1102,7 @@ func newGetLatestVersions() *cobra.Command { } response := w.ModelRegistry.GetLatestVersions(ctx, getLatestVersionsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1441,7 +1441,7 @@ func newListModels() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ModelRegistry.ListModels(ctx, listModelsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1496,7 +1496,7 @@ func newListTransitionRequests() *cobra.Command { listTransitionRequestsReq.Version = args[1] response := w.ModelRegistry.ListTransitionRequests(ctx, listTransitionRequestsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1549,7 +1549,7 @@ func newListWebhooks() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ModelRegistry.ListWebhooks(ctx, listWebhooksReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1782,7 +1782,7 @@ func newSearchModelVersions() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ModelRegistry.SearchModelVersions(ctx, searchModelVersionsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1835,7 +1835,7 @@ func newSearchModels() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ModelRegistry.SearchModels(ctx, searchModelsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/model-versions/model-versions.go b/cmd/workspace/model-versions/model-versions.go index a0c20b50437..b2ceca74321 100755 --- a/cmd/workspace/model-versions/model-versions.go +++ b/cmd/workspace/model-versions/model-versions.go @@ -308,7 +308,7 @@ func newList() *cobra.Command { listReq.FullName = args[0] response := w.ModelVersions.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/notification-destinations/notification-destinations.go b/cmd/workspace/notification-destinations/notification-destinations.go index 4eebe458085..cc365c9e7e7 100755 --- a/cmd/workspace/notification-destinations/notification-destinations.go +++ b/cmd/workspace/notification-destinations/notification-destinations.go @@ -255,7 +255,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.NotificationDestinations.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 76bcd77ba25..16fd844a186 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -88,7 +88,7 @@ func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelin return v.(pipelines.PipelineEvent).EventType }}, {Header: "Message", MaxWidth: 200, Extract: func(v any) string { - return sanitizeWhitespace(v.(pipelines.PipelineEvent).Message) + return cmdio.SanitizeControlWhitespace(v.(pipelines.PipelineEvent).Message) }}, } @@ -161,22 +161,17 @@ With a PIPELINE_ID: Stops the pipeline identified by the UUID using the API.` } // disableSearchIfFilterSet clears the TUI search config when --filter is active. +// It creates a shallow copy to avoid mutating the shared config registered on the command. func disableSearchIfFilterSet(cmd *cobra.Command) { if cmd.Flags().Changed("filter") { if cfg := tableview.GetTableConfig(cmd.Context()); cfg != nil { - cfg.Search = nil + copied := *cfg + copied.Search = nil + cmd.SetContext(tableview.SetTableConfig(cmd.Context(), &copied)) } } } -var controlWhitespaceReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") - -// sanitizeWhitespace replaces control whitespace (newlines, tabs) with spaces -// to prevent corrupting tab-delimited or TUI table output. -func sanitizeWhitespace(s string) string { - return controlWhitespaceReplacer.Replace(s) -} - var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // looksLikeUUID checks if a string matches the UUID format with lowercase hex digits diff --git a/cmd/workspace/pipelines/overrides_test.go b/cmd/workspace/pipelines/overrides_test.go index d92646f68e2..dcf25ab5eaa 100644 --- a/cmd/workspace/pipelines/overrides_test.go +++ b/cmd/workspace/pipelines/overrides_test.go @@ -70,7 +70,13 @@ func TestListPipelinesSearchDisabledWhenFilterSet(t *testing.T) { // Simulate context setup so disableSearchIfFilterSet can read the config. cmd.SetContext(tableview.SetTableConfig(t.Context(), cfg)) disableSearchIfFilterSet(cmd) - assert.Nil(t, cfg.Search) + + // The original config must not be mutated. + assert.NotNil(t, cfg.Search, "original config must not be mutated") + // The config stored in context should have Search disabled. + ctxCfg := tableview.GetTableConfig(cmd.Context()) + require.NotNil(t, ctxCfg) + assert.Nil(t, ctxCfg.Search) } func TestListPipelinesSearchNotDisabledWithoutFilter(t *testing.T) { diff --git a/cmd/workspace/pipelines/pipelines.go b/cmd/workspace/pipelines/pipelines.go index ac34f639127..56138c45a19 100755 --- a/cmd/workspace/pipelines/pipelines.go +++ b/cmd/workspace/pipelines/pipelines.go @@ -588,7 +588,7 @@ func newListPipelineEvents() *cobra.Command { listPipelineEventsReq.PipelineId = args[0] response := w.Pipelines.ListPipelineEvents(ctx, listPipelineEventsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -641,7 +641,7 @@ func newListPipelines() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Pipelines.ListPipelines(ctx, listPipelinesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/policies/policies.go b/cmd/workspace/policies/policies.go index 0d1e81fbdc1..35e8d0cfab7 100755 --- a/cmd/workspace/policies/policies.go +++ b/cmd/workspace/policies/policies.go @@ -371,7 +371,7 @@ func newListPolicies() *cobra.Command { listPoliciesReq.OnSecurableFullname = args[1] response := w.Policies.ListPolicies(ctx, listPoliciesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/policy-compliance-for-clusters/policy-compliance-for-clusters.go b/cmd/workspace/policy-compliance-for-clusters/policy-compliance-for-clusters.go index fe36251d262..8d715bd6d72 100755 --- a/cmd/workspace/policy-compliance-for-clusters/policy-compliance-for-clusters.go +++ b/cmd/workspace/policy-compliance-for-clusters/policy-compliance-for-clusters.go @@ -242,7 +242,7 @@ func newListCompliance() *cobra.Command { listComplianceReq.PolicyId = args[0] response := w.PolicyComplianceForClusters.ListCompliance(ctx, listComplianceReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/policy-compliance-for-jobs/policy-compliance-for-jobs.go b/cmd/workspace/policy-compliance-for-jobs/policy-compliance-for-jobs.go index cfe17ab8113..4a025f4ba1a 100755 --- a/cmd/workspace/policy-compliance-for-jobs/policy-compliance-for-jobs.go +++ b/cmd/workspace/policy-compliance-for-jobs/policy-compliance-for-jobs.go @@ -245,7 +245,7 @@ func newListCompliance() *cobra.Command { listComplianceReq.PolicyId = args[0] response := w.PolicyComplianceForJobs.ListCompliance(ctx, listComplianceReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/policy-families/policy-families.go b/cmd/workspace/policy-families/policy-families.go index 88b75ddcbf0..f5b65a6a3a7 100755 --- a/cmd/workspace/policy-families/policy-families.go +++ b/cmd/workspace/policy-families/policy-families.go @@ -139,7 +139,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.PolicyFamilies.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/postgres/postgres.go b/cmd/workspace/postgres/postgres.go index 741224092a0..f098764d032 100755 --- a/cmd/workspace/postgres/postgres.go +++ b/cmd/workspace/postgres/postgres.go @@ -1668,7 +1668,7 @@ func newListBranches() *cobra.Command { listBranchesReq.Parent = args[0] response := w.Postgres.ListBranches(ctx, listBranchesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1728,7 +1728,7 @@ func newListDatabases() *cobra.Command { listDatabasesReq.Parent = args[0] response := w.Postgres.ListDatabases(ctx, listDatabasesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1785,7 +1785,7 @@ func newListEndpoints() *cobra.Command { listEndpointsReq.Parent = args[0] response := w.Postgres.ListEndpoints(ctx, listEndpointsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1837,7 +1837,7 @@ func newListProjects() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Postgres.ListProjects(ctx, listProjectsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -1894,7 +1894,7 @@ func newListRoles() *cobra.Command { listRolesReq.Parent = args[0] response := w.Postgres.ListRoles(ctx, listRolesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-exchange-filters/provider-exchange-filters.go b/cmd/workspace/provider-exchange-filters/provider-exchange-filters.go index f26310806f4..4c62276bf6b 100755 --- a/cmd/workspace/provider-exchange-filters/provider-exchange-filters.go +++ b/cmd/workspace/provider-exchange-filters/provider-exchange-filters.go @@ -207,7 +207,7 @@ func newList() *cobra.Command { listReq.ExchangeId = args[0] response := w.ProviderExchangeFilters.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-exchanges/provider-exchanges.go b/cmd/workspace/provider-exchanges/provider-exchanges.go index 8d22e546946..da333288ca1 100755 --- a/cmd/workspace/provider-exchanges/provider-exchanges.go +++ b/cmd/workspace/provider-exchanges/provider-exchanges.go @@ -383,7 +383,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ProviderExchanges.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -436,7 +436,7 @@ func newListExchangesForListing() *cobra.Command { listExchangesForListingReq.ListingId = args[0] response := w.ProviderExchanges.ListExchangesForListing(ctx, listExchangesForListingReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -489,7 +489,7 @@ func newListListingsForExchange() *cobra.Command { listListingsForExchangeReq.ExchangeId = args[0] response := w.ProviderExchanges.ListListingsForExchange(ctx, listListingsForExchangeReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-files/provider-files.go b/cmd/workspace/provider-files/provider-files.go index 485f76cfde4..69de0a23ec2 100755 --- a/cmd/workspace/provider-files/provider-files.go +++ b/cmd/workspace/provider-files/provider-files.go @@ -287,7 +287,7 @@ func newList() *cobra.Command { } response := w.ProviderFiles.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-listings/provider-listings.go b/cmd/workspace/provider-listings/provider-listings.go index 03e2f64668a..9686737d2e7 100755 --- a/cmd/workspace/provider-listings/provider-listings.go +++ b/cmd/workspace/provider-listings/provider-listings.go @@ -272,7 +272,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ProviderListings.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-personalization-requests/provider-personalization-requests.go b/cmd/workspace/provider-personalization-requests/provider-personalization-requests.go index 9da88ec06a3..57eccbdbd5d 100755 --- a/cmd/workspace/provider-personalization-requests/provider-personalization-requests.go +++ b/cmd/workspace/provider-personalization-requests/provider-personalization-requests.go @@ -76,7 +76,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ProviderPersonalizationRequests.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/provider-providers/provider-providers.go b/cmd/workspace/provider-providers/provider-providers.go index a68663b001b..68f0198ac9d 100755 --- a/cmd/workspace/provider-providers/provider-providers.go +++ b/cmd/workspace/provider-providers/provider-providers.go @@ -271,7 +271,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ProviderProviders.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/providers/providers.go b/cmd/workspace/providers/providers.go index 6f120d8dc92..a7fa13a2351 100755 --- a/cmd/workspace/providers/providers.go +++ b/cmd/workspace/providers/providers.go @@ -319,7 +319,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Providers.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -453,7 +453,7 @@ func newListShares() *cobra.Command { listSharesReq.Name = args[0] response := w.Providers.ListShares(ctx, listSharesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/quality-monitor-v2/quality-monitor-v2.go b/cmd/workspace/quality-monitor-v2/quality-monitor-v2.go index 54b97681fc2..8c5e3547bdb 100755 --- a/cmd/workspace/quality-monitor-v2/quality-monitor-v2.go +++ b/cmd/workspace/quality-monitor-v2/quality-monitor-v2.go @@ -287,7 +287,7 @@ func newListQualityMonitor() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.QualityMonitorV2.ListQualityMonitor(ctx, listQualityMonitorReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/queries-legacy/queries-legacy.go b/cmd/workspace/queries-legacy/queries-legacy.go index 3411520b07b..8537645e24e 100755 --- a/cmd/workspace/queries-legacy/queries-legacy.go +++ b/cmd/workspace/queries-legacy/queries-legacy.go @@ -303,7 +303,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.QueriesLegacy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/queries/queries.go b/cmd/workspace/queries/queries.go index eebeac17557..64c94f0df05 100755 --- a/cmd/workspace/queries/queries.go +++ b/cmd/workspace/queries/queries.go @@ -286,7 +286,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Queries.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -354,7 +354,7 @@ func newListVisualizations() *cobra.Command { listVisualizationsReq.Id = args[0] response := w.Queries.ListVisualizations(ctx, listVisualizationsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/recipient-federation-policies/recipient-federation-policies.go b/cmd/workspace/recipient-federation-policies/recipient-federation-policies.go index 7f4f11fa840..5ff07221f92 100755 --- a/cmd/workspace/recipient-federation-policies/recipient-federation-policies.go +++ b/cmd/workspace/recipient-federation-policies/recipient-federation-policies.go @@ -329,7 +329,7 @@ func newList() *cobra.Command { listReq.RecipientName = args[0] response := w.RecipientFederationPolicies.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/recipients/recipients.go b/cmd/workspace/recipients/recipients.go index d824f3ad7fa..9722aab9827 100755 --- a/cmd/workspace/recipients/recipients.go +++ b/cmd/workspace/recipients/recipients.go @@ -313,7 +313,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Recipients.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/registered-models/registered-models.go b/cmd/workspace/registered-models/registered-models.go index 1fc3db342f0..f60b1d10a17 100755 --- a/cmd/workspace/registered-models/registered-models.go +++ b/cmd/workspace/registered-models/registered-models.go @@ -432,7 +432,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.RegisteredModels.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/repos/repos.go b/cmd/workspace/repos/repos.go index e511a73f602..24075353f08 100755 --- a/cmd/workspace/repos/repos.go +++ b/cmd/workspace/repos/repos.go @@ -462,7 +462,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Repos.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/resource-quotas/resource-quotas.go b/cmd/workspace/resource-quotas/resource-quotas.go index da31aab2f18..d639c5fb4ea 100755 --- a/cmd/workspace/resource-quotas/resource-quotas.go +++ b/cmd/workspace/resource-quotas/resource-quotas.go @@ -150,7 +150,7 @@ func newListQuotas() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ResourceQuotas.ListQuotas(ctx, listQuotasReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/schemas/schemas.go b/cmd/workspace/schemas/schemas.go index d56e168ff13..e51261aae1c 100755 --- a/cmd/workspace/schemas/schemas.go +++ b/cmd/workspace/schemas/schemas.go @@ -308,7 +308,7 @@ func newList() *cobra.Command { listReq.CatalogName = args[0] response := w.Schemas.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/secrets/secrets.go b/cmd/workspace/secrets/secrets.go index 8023dcd674a..34f356d841c 100755 --- a/cmd/workspace/secrets/secrets.go +++ b/cmd/workspace/secrets/secrets.go @@ -667,7 +667,7 @@ func newListAcls() *cobra.Command { listAclsReq.Scope = args[0] response := w.Secrets.ListAcls(ctx, listAclsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -716,7 +716,7 @@ func newListScopes() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.Secrets.ListScopes(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -785,7 +785,7 @@ func newListSecrets() *cobra.Command { listSecretsReq.Scope = args[0] response := w.Secrets.ListSecrets(ctx, listSecretsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/service-principal-secrets-proxy/service-principal-secrets-proxy.go b/cmd/workspace/service-principal-secrets-proxy/service-principal-secrets-proxy.go index fc7bbe0bedf..c213797bbe1 100755 --- a/cmd/workspace/service-principal-secrets-proxy/service-principal-secrets-proxy.go +++ b/cmd/workspace/service-principal-secrets-proxy/service-principal-secrets-proxy.go @@ -225,7 +225,7 @@ func newList() *cobra.Command { listReq.ServicePrincipalId = args[0] response := w.ServicePrincipalSecretsProxy.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/service-principals-v2/service-principals-v2.go b/cmd/workspace/service-principals-v2/service-principals-v2.go index 01bbf6d7af6..1620df923db 100755 --- a/cmd/workspace/service-principals-v2/service-principals-v2.go +++ b/cmd/workspace/service-principals-v2/service-principals-v2.go @@ -276,7 +276,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ServicePrincipalsV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/service-principals/service-principals.go b/cmd/workspace/service-principals/service-principals.go index b24dbf66692..76737b45176 100755 --- a/cmd/workspace/service-principals/service-principals.go +++ b/cmd/workspace/service-principals/service-principals.go @@ -308,7 +308,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.ServicePrincipals.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/serving-endpoints/serving-endpoints.go b/cmd/workspace/serving-endpoints/serving-endpoints.go index fa6e4181d06..84313eed21b 100755 --- a/cmd/workspace/serving-endpoints/serving-endpoints.go +++ b/cmd/workspace/serving-endpoints/serving-endpoints.go @@ -758,7 +758,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.ServingEndpoints.List(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/shares/overrides.go b/cmd/workspace/shares/overrides.go index 85b8854b7ff..a959f45cdb4 100644 --- a/cmd/workspace/shares/overrides.go +++ b/cmd/workspace/shares/overrides.go @@ -38,7 +38,7 @@ func newList() *cobra.Command { //nolint:staticcheck // this API is deprecated but we still need to expose this in the CLI. response := w.Shares.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/shares/shares.go b/cmd/workspace/shares/shares.go index 132d92f96bb..1902cc8c73b 100755 --- a/cmd/workspace/shares/shares.go +++ b/cmd/workspace/shares/shares.go @@ -287,7 +287,7 @@ func newListShares() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Shares.ListShares(ctx, listSharesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/storage-credentials/storage-credentials.go b/cmd/workspace/storage-credentials/storage-credentials.go index bf19c4d55a0..196a57f047e 100755 --- a/cmd/workspace/storage-credentials/storage-credentials.go +++ b/cmd/workspace/storage-credentials/storage-credentials.go @@ -313,7 +313,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.StorageCredentials.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/system-schemas/system-schemas.go b/cmd/workspace/system-schemas/system-schemas.go index 959033af58e..813715c454e 100755 --- a/cmd/workspace/system-schemas/system-schemas.go +++ b/cmd/workspace/system-schemas/system-schemas.go @@ -224,7 +224,7 @@ func newList() *cobra.Command { listReq.MetastoreId = args[0] response := w.SystemSchemas.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/tables/tables.go b/cmd/workspace/tables/tables.go index 54f6340979d..30892e44b75 100755 --- a/cmd/workspace/tables/tables.go +++ b/cmd/workspace/tables/tables.go @@ -481,7 +481,7 @@ func newList() *cobra.Command { listReq.SchemaName = args[1] response := w.Tables.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -555,7 +555,7 @@ func newListSummaries() *cobra.Command { listSummariesReq.CatalogName = args[0] response := w.Tables.ListSummaries(ctx, listSummariesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/tag-policies/tag-policies.go b/cmd/workspace/tag-policies/tag-policies.go index 498f92f60ef..97c64a74c44 100755 --- a/cmd/workspace/tag-policies/tag-policies.go +++ b/cmd/workspace/tag-policies/tag-policies.go @@ -289,7 +289,7 @@ func newListTagPolicies() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.TagPolicies.ListTagPolicies(ctx, listTagPoliciesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/token-management/token-management.go b/cmd/workspace/token-management/token-management.go index f75df13b042..83ef0879247 100755 --- a/cmd/workspace/token-management/token-management.go +++ b/cmd/workspace/token-management/token-management.go @@ -402,7 +402,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.TokenManagement.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/tokens/tokens.go b/cmd/workspace/tokens/tokens.go index c1998f37649..715db5485de 100755 --- a/cmd/workspace/tokens/tokens.go +++ b/cmd/workspace/tokens/tokens.go @@ -235,7 +235,7 @@ func newList() *cobra.Command { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) response := w.Tokens.List(ctx) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/users-v2/users-v2.go b/cmd/workspace/users-v2/users-v2.go index 32a7282f0b3..c104c9a861f 100755 --- a/cmd/workspace/users-v2/users-v2.go +++ b/cmd/workspace/users-v2/users-v2.go @@ -389,7 +389,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.UsersV2.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/users/users.go b/cmd/workspace/users/users.go index bc29cc2f814..c9ea8059428 100755 --- a/cmd/workspace/users/users.go +++ b/cmd/workspace/users/users.go @@ -413,7 +413,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Users.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/vector-search-endpoints/vector-search-endpoints.go b/cmd/workspace/vector-search-endpoints/vector-search-endpoints.go index eda88b261f0..d11b5f22151 100755 --- a/cmd/workspace/vector-search-endpoints/vector-search-endpoints.go +++ b/cmd/workspace/vector-search-endpoints/vector-search-endpoints.go @@ -309,7 +309,7 @@ func newListEndpoints() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.VectorSearchEndpoints.ListEndpoints(ctx, listEndpointsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/vector-search-indexes/vector-search-indexes.go b/cmd/workspace/vector-search-indexes/vector-search-indexes.go index 8c1350289c5..c7ecade2bb4 100755 --- a/cmd/workspace/vector-search-indexes/vector-search-indexes.go +++ b/cmd/workspace/vector-search-indexes/vector-search-indexes.go @@ -378,7 +378,7 @@ func newListIndexes() *cobra.Command { listIndexesReq.EndpointName = args[0] response := w.VectorSearchIndexes.ListIndexes(ctx, listIndexesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/volumes/volumes.go b/cmd/workspace/volumes/volumes.go index 00a54a3cf62..15988f01c28 100755 --- a/cmd/workspace/volumes/volumes.go +++ b/cmd/workspace/volumes/volumes.go @@ -301,7 +301,7 @@ func newList() *cobra.Command { listReq.SchemaName = args[1] response := w.Volumes.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/warehouses/warehouses.go b/cmd/workspace/warehouses/warehouses.go index 5e1aaf50c5a..8003dfd2cf2 100755 --- a/cmd/workspace/warehouses/warehouses.go +++ b/cmd/workspace/warehouses/warehouses.go @@ -880,7 +880,7 @@ func newList() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Warehouses.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. @@ -932,7 +932,7 @@ func newListDefaultWarehouseOverrides() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.Warehouses.ListDefaultWarehouseOverrides(ctx, listDefaultWarehouseOverridesReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/workspace-bindings/workspace-bindings.go b/cmd/workspace/workspace-bindings/workspace-bindings.go index 70e61bd333d..04090db4ae5 100755 --- a/cmd/workspace/workspace-bindings/workspace-bindings.go +++ b/cmd/workspace/workspace-bindings/workspace-bindings.go @@ -166,7 +166,7 @@ func newGetBindings() *cobra.Command { getBindingsReq.SecurableName = args[1] response := w.WorkspaceBindings.GetBindings(ctx, getBindingsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/workspace-entity-tag-assignments/workspace-entity-tag-assignments.go b/cmd/workspace/workspace-entity-tag-assignments/workspace-entity-tag-assignments.go index 353f7281155..e15733be8b1 100755 --- a/cmd/workspace/workspace-entity-tag-assignments/workspace-entity-tag-assignments.go +++ b/cmd/workspace/workspace-entity-tag-assignments/workspace-entity-tag-assignments.go @@ -306,7 +306,7 @@ func newListTagAssignments() *cobra.Command { listTagAssignmentsReq.EntityId = args[1] response := w.WorkspaceEntityTagAssignments.ListTagAssignments(ctx, listTagAssignmentsReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/workspace-settings-v2/workspace-settings-v2.go b/cmd/workspace/workspace-settings-v2/workspace-settings-v2.go index 2c3beaeaac8..02429a6bc1f 100755 --- a/cmd/workspace/workspace-settings-v2/workspace-settings-v2.go +++ b/cmd/workspace/workspace-settings-v2/workspace-settings-v2.go @@ -133,7 +133,7 @@ func newListWorkspaceSettingsMetadata() *cobra.Command { w := cmdctx.WorkspaceClient(ctx) response := w.WorkspaceSettingsV2.ListWorkspaceSettingsMetadata(ctx, listWorkspaceSettingsMetadataReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/cmd/workspace/workspace/workspace.go b/cmd/workspace/workspace/workspace.go index bb62616fe5c..fa77cef4aed 100755 --- a/cmd/workspace/workspace/workspace.go +++ b/cmd/workspace/workspace/workspace.go @@ -560,7 +560,7 @@ func newList() *cobra.Command { listReq.Path = args[0] response := w.Workspace.List(ctx, listReq) - return cmdio.RenderIterator(ctx, cmd, response) + return cmdio.RenderIterator(ctx, response) } // Disable completions since they are not applicable. diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 3839d75b4dc..f736b92bf4b 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -19,7 +19,6 @@ import ( "github.com/databricks/databricks-sdk-go/listing" "github.com/fatih/color" "github.com/nwidger/jsoncolor" - "github.com/spf13/cobra" ) // Heredoc is the equivalent of compute.TrimLeadingWhitespace @@ -273,11 +272,10 @@ func Render(ctx context.Context, v any) error { return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template) } -// RenderIterator renders the items from i. When cmd is non-nil and has a +// RenderIterator renders the items from i. When the context carries a // registered TableConfig, a paginated TUI table is shown in interactive -// terminals. Pass nil for cmd to skip TUI lookup and always use template -// rendering. -func RenderIterator[T any](ctx context.Context, cmd *cobra.Command, i listing.Iterator[T]) error { +// terminals. +func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error { c := fromContext(ctx) // Only launch TUI when an explicit TableConfig is registered via overrides. @@ -324,9 +322,9 @@ func RenderIteratorJson[T any](ctx context.Context, i listing.Iterator[T]) error var controlWhitespaceReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") -// sanitizeControlWhitespace replaces newlines and tabs with spaces to prevent -// corrupting tab-delimited text output. -func sanitizeControlWhitespace(s string) string { +// SanitizeControlWhitespace replaces newlines and tabs with spaces to prevent +// corrupting tab-delimited or TUI table output. +func SanitizeControlWhitespace(s string) string { return controlWhitespaceReplacer.Replace(s) } @@ -348,7 +346,7 @@ var renderFuncMap = template.FuncMap{ return color.New(color.Italic).Sprintf(format, a...) }, "replace": strings.ReplaceAll, - "sanitize": sanitizeControlWhitespace, + "sanitize": SanitizeControlWhitespace, "join": strings.Join, "sub": func(a, b int) int { return a - b diff --git a/libs/cmdio/render_test.go b/libs/cmdio/render_test.go index dce4041212d..be41f80c384 100644 --- a/libs/cmdio/render_test.go +++ b/libs/cmdio/render_test.go @@ -176,7 +176,7 @@ func TestRender(t *testing.T) { ctx = InContext(ctx, cmdIO) var err error if vv, ok := c.v.(listing.Iterator[*provisioning.Workspace]); ok { - err = RenderIterator(ctx, nil, vv) + err = RenderIterator(ctx, vv) } else { err = Render(ctx, c.v) } diff --git a/libs/tableview/common.go b/libs/tableview/common.go index aa9f3da9908..2c553764bcc 100644 --- a/libs/tableview/common.go +++ b/libs/tableview/common.go @@ -112,8 +112,9 @@ func findMatches(lines []string, query string) []int { } // highlightSearch applies search match highlighting to a single line. -// It works in rune-space so that case-folding length changes (e.g. "ß"→"ss") -// do not misalign the highlighted spans in the original string. +// It works in rune-space so that ANSI escape sequences (which have varying +// byte lengths) and multi-byte UTF-8 characters do not misalign the +// highlighted spans when mapping positions back to the original string. func highlightSearch(line, query string) string { if query == "" { return line diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index b800edb0dd1..4c8cf111308 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -65,6 +65,7 @@ type paginatedModel struct { // Search searching bool + searchLoading bool searchInput string debounceSeq int hasSearchState bool @@ -196,7 +197,13 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = nil isFirstBatch := len(m.rows) == 0 - m.rows = append(m.rows, msg.rows...) + if m.searchLoading { + m.rows = msg.rows + m.searchLoading = false + isFirstBatch = true + } else { + m.rows = append(m.rows, msg.rows...) + } m.exhausted = msg.exhausted if m.maxItems > 0 && len(m.rows) >= m.maxItems { @@ -381,6 +388,7 @@ func (m *paginatedModel) restorePreSearchState() { m.savedExhaust = false m.limitReached = false m.loading = false + m.searchLoading = false } m.cursor = 0 if m.ready { @@ -406,10 +414,10 @@ func (m paginatedModel) executeSearch(query string) (tea.Model, tea.Cmd) { } m.fetchGeneration++ - m.rows = nil m.exhausted = false m.limitReached = false m.loading = true + m.searchLoading = true m.cursor = 0 m.rowIter = m.makeSearchIter(query) return m, m.makeFetchCmd(m) @@ -456,6 +464,9 @@ func (m paginatedModel) View() string { return "Loading..." } if len(m.rows) == 0 && m.loading { + if m.searchLoading { + return "Searching..." + } return "Fetching results..." } if len(m.rows) == 0 && m.exhausted { @@ -483,7 +494,11 @@ func (m paginatedModel) renderFooter() string { input = footerStyle.Render(placeholder) } prompt := searchStyle.Render("/ " + input + "█") - return footerStyle.Render(fmt.Sprintf("%d rows loaded", len(m.rows))) + "\n" + prompt + status := fmt.Sprintf("%d rows loaded", len(m.rows)) + if m.searchLoading { + status = "Searching..." + } + return footerStyle.Render(status) + "\n" + prompt } var parts []string From 4981680d574ce5031d9a3c550198b159c866d8fb Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 17 Apr 2026 15:18:23 +0200 Subject: [PATCH 31/32] Add generic Col helper to reduce TableConfig boilerplate Each cmd/workspace/*/overrides.go had repeated type assertions like `v.(jobs.BaseJob).Field` in every column Extract closure. Introduce a generic `tableview.Col[T]` helper (and `ColMax[T]` for width-capped columns) that hides the assertion, then convert all 18 override files to use it. Each column definition drops from a 3-line func literal with an explicit type assertion to a single-line generic call, making the shape of each table at a glance. Co-authored-by: Isaac --- cmd/workspace/alerts/overrides.go | 12 ++------ cmd/workspace/apps/overrides.go | 20 ++++--------- cmd/workspace/catalogs/overrides.go | 12 ++------ cmd/workspace/cluster-policies/overrides.go | 14 ++++----- cmd/workspace/clusters/overrides.go | 12 ++------ cmd/workspace/external-locations/overrides.go | 12 ++------ cmd/workspace/instance-pools/overrides.go | 16 +++------- cmd/workspace/jobs/overrides.go | 12 ++++---- cmd/workspace/lakeview/overrides.go | 12 ++------ cmd/workspace/pipelines/overrides.go | 30 ++++++------------- cmd/workspace/repos/overrides.go | 16 +++------- cmd/workspace/schemas/overrides.go | 12 ++------ cmd/workspace/secrets/overrides.go | 21 +++++-------- cmd/workspace/serving-endpoints/overrides.go | 16 ++++------ cmd/workspace/tables/overrides.go | 8 ++--- cmd/workspace/volumes/overrides.go | 12 ++------ cmd/workspace/warehouses/overrides.go | 16 +++------- cmd/workspace/workspace/overrides.go | 16 +++------- libs/tableview/wrap.go | 17 +++++++++++ 19 files changed, 94 insertions(+), 192 deletions(-) diff --git a/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go index 0616d56be6f..b272e75cbf7 100644 --- a/cmd/workspace/alerts/overrides.go +++ b/cmd/workspace/alerts/overrides.go @@ -15,15 +15,9 @@ func listOverride(listCmd *cobra.Command, _ *sql.ListAlertsRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "ID", Extract: func(v any) string { - return v.(sql.ListAlertsResponseAlert).Id - }}, - {Header: "Name", Extract: func(v any) string { - return v.(sql.ListAlertsResponseAlert).DisplayName - }}, - {Header: "State", Extract: func(v any) string { - return string(v.(sql.ListAlertsResponseAlert).State) - }}, + tableview.Col("ID", func(a sql.ListAlertsResponseAlert) string { return a.Id }), + tableview.Col("Name", func(a sql.ListAlertsResponseAlert) string { return a.DisplayName }), + tableview.Col("State", func(a sql.ListAlertsResponseAlert) string { return string(a.State) }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index bd4610c3cc5..5e5c154c82c 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -20,28 +20,20 @@ func listOverride(listCmd *cobra.Command, _ *apps.ListAppsRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Name", Extract: func(v any) string { - a := v.(apps.App) - return a.Name - }}, - {Header: "URL", Extract: func(v any) string { - a := v.(apps.App) - return a.Url - }}, - {Header: "Compute Status", Extract: func(v any) string { - a := v.(apps.App) + tableview.Col("Name", func(a apps.App) string { return a.Name }), + tableview.Col("URL", func(a apps.App) string { return a.Url }), + tableview.Col("Compute Status", func(a apps.App) string { if a.ComputeStatus != nil { return string(a.ComputeStatus.State) } return "" - }}, - {Header: "Deploy Status", Extract: func(v any) string { - a := v.(apps.App) + }), + tableview.Col("Deploy Status", func(a apps.App) string { if a.ActiveDeployment != nil && a.ActiveDeployment.Status != nil { return string(a.ActiveDeployment.Status.State) } return "" - }}, + }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/catalogs/overrides.go b/cmd/workspace/catalogs/overrides.go index c9dbcefdffa..fbca09b2677 100644 --- a/cmd/workspace/catalogs/overrides.go +++ b/cmd/workspace/catalogs/overrides.go @@ -17,15 +17,9 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListCatalogsRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Name", Extract: func(v any) string { - return v.(catalog.CatalogInfo).Name - }}, - {Header: "Type", Extract: func(v any) string { - return string(v.(catalog.CatalogInfo).CatalogType) - }}, - {Header: "Comment", MaxWidth: 40, Extract: func(v any) string { - return v.(catalog.CatalogInfo).Comment - }}, + tableview.Col("Name", func(c catalog.CatalogInfo) string { return c.Name }), + tableview.Col("Type", func(c catalog.CatalogInfo) string { return string(c.CatalogType) }), + tableview.ColMax("Comment", 40, func(c catalog.CatalogInfo) string { return c.Comment }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/cluster-policies/overrides.go b/cmd/workspace/cluster-policies/overrides.go index 63af01c8071..0c87a4c1782 100644 --- a/cmd/workspace/cluster-policies/overrides.go +++ b/cmd/workspace/cluster-policies/overrides.go @@ -15,18 +15,14 @@ func listOverride(listCmd *cobra.Command, _ *compute.ListClusterPoliciesRequest) {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Policy ID", Extract: func(v any) string { - return v.(compute.Policy).PolicyId - }}, - {Header: "Name", Extract: func(v any) string { - return v.(compute.Policy).Name - }}, - {Header: "Default", Extract: func(v any) string { - if v.(compute.Policy).IsDefault { + tableview.Col("Policy ID", func(p compute.Policy) string { return p.PolicyId }), + tableview.Col("Name", func(p compute.Policy) string { return p.Name }), + tableview.Col("Default", func(p compute.Policy) string { + if p.IsDefault { return "yes" } return "" - }}, + }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/clusters/overrides.go b/cmd/workspace/clusters/overrides.go index 9ae463119c8..2b47393ba6d 100644 --- a/cmd/workspace/clusters/overrides.go +++ b/cmd/workspace/clusters/overrides.go @@ -21,15 +21,9 @@ func listOverride(listCmd *cobra.Command, listReq *compute.ListClustersRequest) {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Cluster ID", Extract: func(v any) string { - return v.(compute.ClusterDetails).ClusterId - }}, - {Header: "Name", Extract: func(v any) string { - return v.(compute.ClusterDetails).ClusterName - }}, - {Header: "State", Extract: func(v any) string { - return string(v.(compute.ClusterDetails).State) - }}, + tableview.Col("Cluster ID", func(c compute.ClusterDetails) string { return c.ClusterId }), + tableview.Col("Name", func(c compute.ClusterDetails) string { return c.ClusterName }), + tableview.Col("State", func(c compute.ClusterDetails) string { return string(c.State) }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/external-locations/overrides.go b/cmd/workspace/external-locations/overrides.go index c976134beaf..6a22089537a 100644 --- a/cmd/workspace/external-locations/overrides.go +++ b/cmd/workspace/external-locations/overrides.go @@ -17,15 +17,9 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListExternalLocationsReques {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Name", Extract: func(v any) string { - return v.(catalog.ExternalLocationInfo).Name - }}, - {Header: "Credential", Extract: func(v any) string { - return v.(catalog.ExternalLocationInfo).CredentialName - }}, - {Header: "URL", Extract: func(v any) string { - return v.(catalog.ExternalLocationInfo).Url - }}, + tableview.Col("Name", func(l catalog.ExternalLocationInfo) string { return l.Name }), + tableview.Col("Credential", func(l catalog.ExternalLocationInfo) string { return l.CredentialName }), + tableview.Col("URL", func(l catalog.ExternalLocationInfo) string { return l.Url }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/instance-pools/overrides.go b/cmd/workspace/instance-pools/overrides.go index 9b0aeed1e2c..dd097f5bac1 100644 --- a/cmd/workspace/instance-pools/overrides.go +++ b/cmd/workspace/instance-pools/overrides.go @@ -15,18 +15,10 @@ func listOverride(listCmd *cobra.Command) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Pool ID", Extract: func(v any) string { - return v.(compute.InstancePoolAndStats).InstancePoolId - }}, - {Header: "Name", Extract: func(v any) string { - return v.(compute.InstancePoolAndStats).InstancePoolName - }}, - {Header: "Node Type", Extract: func(v any) string { - return v.(compute.InstancePoolAndStats).NodeTypeId - }}, - {Header: "State", Extract: func(v any) string { - return string(v.(compute.InstancePoolAndStats).State) - }}, + tableview.Col("Pool ID", func(p compute.InstancePoolAndStats) string { return p.InstancePoolId }), + tableview.Col("Name", func(p compute.InstancePoolAndStats) string { return p.InstancePoolName }), + tableview.Col("Node Type", func(p compute.InstancePoolAndStats) string { return p.NodeTypeId }), + tableview.Col("State", func(p compute.InstancePoolAndStats) string { return string(p.State) }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/jobs/overrides.go b/cmd/workspace/jobs/overrides.go index f3ecb9efd3e..9828accc0d0 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -19,15 +19,13 @@ func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Job ID", Extract: func(v any) string { - return strconv.FormatInt(v.(jobs.BaseJob).JobId, 10) - }}, - {Header: "Name", Extract: func(v any) string { - if v.(jobs.BaseJob).Settings != nil { - return v.(jobs.BaseJob).Settings.Name + tableview.Col("Job ID", func(j jobs.BaseJob) string { return strconv.FormatInt(j.JobId, 10) }), + tableview.Col("Name", func(j jobs.BaseJob) string { + if j.Settings != nil { + return j.Settings.Name } return "" - }}, + }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{ diff --git a/cmd/workspace/lakeview/overrides.go b/cmd/workspace/lakeview/overrides.go index 0c2dda80970..a3559541fe6 100644 --- a/cmd/workspace/lakeview/overrides.go +++ b/cmd/workspace/lakeview/overrides.go @@ -17,15 +17,9 @@ func listOverride(listCmd *cobra.Command, _ *dashboards.ListDashboardsRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Dashboard ID", Extract: func(v any) string { - return v.(dashboards.Dashboard).DashboardId - }}, - {Header: "Name", Extract: func(v any) string { - return v.(dashboards.Dashboard).DisplayName - }}, - {Header: "State", Extract: func(v any) string { - return string(v.(dashboards.Dashboard).LifecycleState) - }}, + tableview.Col("Dashboard ID", func(d dashboards.Dashboard) string { return d.DashboardId }), + tableview.Col("Name", func(d dashboards.Dashboard) string { return d.DisplayName }), + tableview.Col("State", func(d dashboards.Dashboard) string { return string(d.LifecycleState) }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 16fd844a186..53c8aa97f54 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -23,15 +23,9 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Pipeline ID", Extract: func(v any) string { - return v.(pipelines.PipelineStateInfo).PipelineId - }}, - {Header: "Name", Extract: func(v any) string { - return v.(pipelines.PipelineStateInfo).Name - }}, - {Header: "State", Extract: func(v any) string { - return string(v.(pipelines.PipelineStateInfo).State) - }}, + tableview.Col("Pipeline ID", func(p pipelines.PipelineStateInfo) string { return p.PipelineId }), + tableview.Col("Name", func(p pipelines.PipelineStateInfo) string { return p.Name }), + tableview.Col("State", func(p pipelines.PipelineStateInfo) string { return string(p.State) }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{ @@ -78,18 +72,12 @@ func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelin {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Timestamp", Extract: func(v any) string { - return v.(pipelines.PipelineEvent).Timestamp - }}, - {Header: "Level", Extract: func(v any) string { - return string(v.(pipelines.PipelineEvent).Level) - }}, - {Header: "Event Type", Extract: func(v any) string { - return v.(pipelines.PipelineEvent).EventType - }}, - {Header: "Message", MaxWidth: 200, Extract: func(v any) string { - return cmdio.SanitizeControlWhitespace(v.(pipelines.PipelineEvent).Message) - }}, + tableview.Col("Timestamp", func(e pipelines.PipelineEvent) string { return e.Timestamp }), + tableview.Col("Level", func(e pipelines.PipelineEvent) string { return string(e.Level) }), + tableview.Col("Event Type", func(e pipelines.PipelineEvent) string { return e.EventType }), + tableview.ColMax("Message", 200, func(e pipelines.PipelineEvent) string { + return cmdio.SanitizeControlWhitespace(e.Message) + }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 14f6698b9e1..dca9ac77945 100644 --- a/cmd/workspace/repos/overrides.go +++ b/cmd/workspace/repos/overrides.go @@ -25,18 +25,10 @@ func listOverride(listCmd *cobra.Command, _ *workspace.ListReposRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "ID", Extract: func(v any) string { - return strconv.FormatInt(v.(workspace.RepoInfo).Id, 10) - }}, - {Header: "Path", Extract: func(v any) string { - return v.(workspace.RepoInfo).Path - }}, - {Header: "Branch", Extract: func(v any) string { - return v.(workspace.RepoInfo).Branch - }}, - {Header: "URL", Extract: func(v any) string { - return v.(workspace.RepoInfo).Url - }}, + tableview.Col("ID", func(r workspace.RepoInfo) string { return strconv.FormatInt(r.Id, 10) }), + tableview.Col("Path", func(r workspace.RepoInfo) string { return r.Path }), + tableview.Col("Branch", func(r workspace.RepoInfo) string { return r.Branch }), + tableview.Col("URL", func(r workspace.RepoInfo) string { return r.Url }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/schemas/overrides.go b/cmd/workspace/schemas/overrides.go index 1d2c16475f4..1d65a17e57f 100644 --- a/cmd/workspace/schemas/overrides.go +++ b/cmd/workspace/schemas/overrides.go @@ -17,15 +17,9 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListSchemasRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Full Name", Extract: func(v any) string { - return v.(catalog.SchemaInfo).FullName - }}, - {Header: "Owner", Extract: func(v any) string { - return v.(catalog.SchemaInfo).Owner - }}, - {Header: "Comment", MaxWidth: 40, Extract: func(v any) string { - return v.(catalog.SchemaInfo).Comment - }}, + tableview.Col("Full Name", func(s catalog.SchemaInfo) string { return s.FullName }), + tableview.Col("Owner", func(s catalog.SchemaInfo) string { return s.Owner }), + tableview.ColMax("Comment", 40, func(s catalog.SchemaInfo) string { return s.Comment }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index db550756202..dd38c85c7b3 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -23,12 +23,8 @@ func listScopesOverride(listScopesCmd *cobra.Command) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Scope", Extract: func(v any) string { - return v.(workspace.SecretScope).Name - }}, - {Header: "Backend Type", Extract: func(v any) string { - return string(v.(workspace.SecretScope).BackendType) - }}, + tableview.Col("Scope", func(s workspace.SecretScope) string { return s.Name }), + tableview.Col("Backend Type", func(s workspace.SecretScope) string { return string(s.BackendType) }), } tableview.SetTableConfigOnCmd(listScopesCmd, &tableview.TableConfig{Columns: columns}) @@ -44,16 +40,13 @@ func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSec {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Key", Extract: func(v any) string { - return v.(workspace.SecretMetadata).Key - }}, - {Header: "Last Updated", Extract: func(v any) string { - ts := v.(workspace.SecretMetadata).LastUpdatedTimestamp - if ts == 0 { + tableview.Col("Key", func(s workspace.SecretMetadata) string { return s.Key }), + tableview.Col("Last Updated", func(s workspace.SecretMetadata) string { + if s.LastUpdatedTimestamp == 0 { return "" } - return time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05") - }}, + return time.UnixMilli(s.LastUpdatedTimestamp).UTC().Format("2006-01-02 15:04:05") + }), } tableview.SetTableConfigOnCmd(listSecretsCommand, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/serving-endpoints/overrides.go b/cmd/workspace/serving-endpoints/overrides.go index bc9850fb718..6f00e2ba2db 100644 --- a/cmd/workspace/serving-endpoints/overrides.go +++ b/cmd/workspace/serving-endpoints/overrides.go @@ -15,18 +15,14 @@ func listOverride(listCmd *cobra.Command) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Name", Extract: func(v any) string { - return v.(serving.ServingEndpoint).Name - }}, - {Header: "State", Extract: func(v any) string { - if v.(serving.ServingEndpoint).State != nil { - return string(v.(serving.ServingEndpoint).State.Ready) + tableview.Col("Name", func(e serving.ServingEndpoint) string { return e.Name }), + tableview.Col("State", func(e serving.ServingEndpoint) string { + if e.State != nil { + return string(e.State.Ready) } return "" - }}, - {Header: "Creator", Extract: func(v any) string { - return v.(serving.ServingEndpoint).Creator - }}, + }), + tableview.Col("Creator", func(e serving.ServingEndpoint) string { return e.Creator }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/tables/overrides.go b/cmd/workspace/tables/overrides.go index 0204eb57b4c..5e33319cf6a 100644 --- a/cmd/workspace/tables/overrides.go +++ b/cmd/workspace/tables/overrides.go @@ -17,12 +17,8 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListTablesRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Full Name", Extract: func(v any) string { - return v.(catalog.TableInfo).FullName - }}, - {Header: "Table Type", Extract: func(v any) string { - return string(v.(catalog.TableInfo).TableType) - }}, + tableview.Col("Full Name", func(t catalog.TableInfo) string { return t.FullName }), + tableview.Col("Table Type", func(t catalog.TableInfo) string { return string(t.TableType) }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/volumes/overrides.go b/cmd/workspace/volumes/overrides.go index d0729b88a22..dc912189d20 100644 --- a/cmd/workspace/volumes/overrides.go +++ b/cmd/workspace/volumes/overrides.go @@ -15,15 +15,9 @@ func listOverride(listCmd *cobra.Command, _ *catalog.ListVolumesRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "Name", Extract: func(v any) string { - return v.(catalog.VolumeInfo).Name - }}, - {Header: "Volume Type", Extract: func(v any) string { - return string(v.(catalog.VolumeInfo).VolumeType) - }}, - {Header: "Full Name", Extract: func(v any) string { - return v.(catalog.VolumeInfo).FullName - }}, + tableview.Col("Name", func(vol catalog.VolumeInfo) string { return vol.Name }), + tableview.Col("Volume Type", func(vol catalog.VolumeInfo) string { return string(vol.VolumeType) }), + tableview.Col("Full Name", func(vol catalog.VolumeInfo) string { return vol.FullName }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/warehouses/overrides.go b/cmd/workspace/warehouses/overrides.go index 258e0f4076c..1c846f1a875 100644 --- a/cmd/workspace/warehouses/overrides.go +++ b/cmd/workspace/warehouses/overrides.go @@ -17,18 +17,10 @@ func listOverride(listCmd *cobra.Command, _ *sql.ListWarehousesRequest) { {{end}}`) columns := []tableview.ColumnDef{ - {Header: "ID", Extract: func(v any) string { - return v.(sql.EndpointInfo).Id - }}, - {Header: "Name", Extract: func(v any) string { - return v.(sql.EndpointInfo).Name - }}, - {Header: "Size", Extract: func(v any) string { - return v.(sql.EndpointInfo).ClusterSize - }}, - {Header: "State", Extract: func(v any) string { - return string(v.(sql.EndpointInfo).State) - }}, + tableview.Col("ID", func(e sql.EndpointInfo) string { return e.Id }), + tableview.Col("Name", func(e sql.EndpointInfo) string { return e.Name }), + tableview.Col("Size", func(e sql.EndpointInfo) string { return e.ClusterSize }), + tableview.Col("State", func(e sql.EndpointInfo) string { return string(e.State) }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/cmd/workspace/workspace/overrides.go b/cmd/workspace/workspace/overrides.go index d1eb6f4016b..bb730b7d5fa 100644 --- a/cmd/workspace/workspace/overrides.go +++ b/cmd/workspace/workspace/overrides.go @@ -28,18 +28,10 @@ func listOverride(listCmd *cobra.Command, listReq *workspace.ListWorkspaceReques {{end}}`) columns := []tableview.ColumnDef{ - {Header: "ID", Extract: func(v any) string { - return strconv.FormatInt(v.(workspace.ObjectInfo).ObjectId, 10) - }}, - {Header: "Type", Extract: func(v any) string { - return string(v.(workspace.ObjectInfo).ObjectType) - }}, - {Header: "Language", Extract: func(v any) string { - return string(v.(workspace.ObjectInfo).Language) - }}, - {Header: "Path", Extract: func(v any) string { - return v.(workspace.ObjectInfo).Path - }}, + tableview.Col("ID", func(o workspace.ObjectInfo) string { return strconv.FormatInt(o.ObjectId, 10) }), + tableview.Col("Type", func(o workspace.ObjectInfo) string { return string(o.ObjectType) }), + tableview.Col("Language", func(o workspace.ObjectInfo) string { return string(o.Language) }), + tableview.Col("Path", func(o workspace.ObjectInfo) string { return o.Path }), } tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) diff --git a/libs/tableview/wrap.go b/libs/tableview/wrap.go index 96b012f468d..361365c2bfc 100644 --- a/libs/tableview/wrap.go +++ b/libs/tableview/wrap.go @@ -11,6 +11,23 @@ func WrapIterator[T any](iter listing.Iterator[T], columns []ColumnDef) RowItera return &typedRowIterator[T]{inner: iter, columns: columns} } +// Col builds a ColumnDef for a typed SDK struct, hiding the type assertion. +func Col[T any](header string, extract func(T) string) ColumnDef { + return ColumnDef{ + Header: header, + Extract: func(v any) string { return extract(v.(T)) }, + } +} + +// ColMax is like Col but with a display-width cap. +func ColMax[T any](header string, maxWidth int, extract func(T) string) ColumnDef { + return ColumnDef{ + Header: header, + MaxWidth: maxWidth, + Extract: func(v any) string { return extract(v.(T)) }, + } +} + type typedRowIterator[T any] struct { inner listing.Iterator[T] columns []ColumnDef From e269ea3afae911429208f906df375ebbd1629cbc Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 17 Apr 2026 15:18:33 +0200 Subject: [PATCH 32/32] Consolidate paginated search state into searchState struct paginatedModel had 9 scattered fields handling server-side search (searching, searchLoading, searchInput, debounceSeq, hasSearchState, savedRows, savedIter, savedExhaust, limitReached was left as its own thing). Many of the recent fixes in this PR's history were races between these fields getting out of sync. Group them into a searchState struct with an optional *savedSearch sub-struct for the pre-search snapshot. restorePreSearchState and executeSearch become markedly simpler because "there is no saved state" is one nil check instead of four zeroed fields. Also consolidate a few trivial table tests (View, renderFooter, search input keys, fetch) into table-driven form. Co-authored-by: Isaac --- libs/tableview/paginated.go | 102 ++++--- libs/tableview/paginated_test.go | 486 +++++++++++++++---------------- 2 files changed, 296 insertions(+), 292 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 4c8cf111308..2c3c042039f 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -64,20 +64,30 @@ type paginatedModel struct { widths []int // Search - searching bool - searchLoading bool - searchInput string - debounceSeq int - hasSearchState bool - savedRows [][]string - savedIter RowIterator - savedExhaust bool + search searchState // Limits maxItems int limitReached bool } +// searchState groups the server-side search / debounce state. +// When a search replaces the original iterator, saved holds the +// pre-search snapshot so it can be restored on cancel/clear. +type searchState struct { + active bool + loading bool + input string + debounceSeq int + saved *savedSearch +} + +type savedSearch struct { + rows [][]string + iter RowIterator + exhausted bool +} + // Err returns the error recorded during data fetching, if any. func (m paginatedModel) Err() error { return m.err @@ -166,7 +176,7 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: fh := footerHeight - if m.searching { + if m.search.active { fh = searchFooterHeight } if !m.ready { @@ -197,9 +207,9 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = nil isFirstBatch := len(m.rows) == 0 - if m.searchLoading { + if m.search.loading { m.rows = msg.rows - m.searchLoading = false + m.search.loading = false isFirstBatch = true } else { m.rows = append(m.rows, msg.rows...) @@ -224,13 +234,13 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case searchDebounceMsg: - if msg.seq != m.debounceSeq || !m.searching { + if msg.seq != m.search.debounceSeq || !m.search.active { return m, nil } - return m.executeSearch(m.searchInput) + return m.executeSearch(m.search.input) case tea.KeyMsg: - if m.searching { + if m.search.active { return m.updateSearch(msg) } return m.updateNormal(msg) @@ -301,8 +311,8 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case "/": if m.cfg.Search != nil { - m.searching = true - m.searchInput = "" + m.search.active = true + m.search.input = "" // Shrink viewport by one row to make room for the search input bar. m.viewport.Height-- return m, nil @@ -352,7 +362,7 @@ func (m *paginatedModel) moveCursor(delta int) { } func maybeFetch(m paginatedModel) (paginatedModel, tea.Cmd) { - if m.loading || m.exhausted || m.searching { + if m.loading || m.exhausted || m.search.active { return m, nil } if len(m.rows)-m.cursor <= fetchThresholdFromBottom { @@ -364,8 +374,8 @@ func maybeFetch(m paginatedModel) (paginatedModel, tea.Cmd) { // scheduleSearchDebounce returns a command that sends a searchDebounceMsg after the delay. func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { - m.debounceSeq++ - seq := m.debounceSeq + m.search.debounceSeq++ + seq := m.search.debounceSeq return tea.Tick(searchDebounceDelay, func(_ time.Time) tea.Msg { return searchDebounceMsg{seq: seq} }) @@ -375,20 +385,17 @@ func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { // loading so that maybeFetch is unblocked. Safe to call even when there is // no saved search state. func (m *paginatedModel) restorePreSearchState() { - if m.hasSearchState { + if m.search.saved != nil { // Bump generation to discard any in-flight search fetch, since we're // switching back to the original iterator. m.fetchGeneration++ - m.rows = m.savedRows - m.rowIter = m.savedIter - m.exhausted = m.savedExhaust - m.hasSearchState = false - m.savedRows = nil - m.savedIter = nil - m.savedExhaust = false + m.rows = m.search.saved.rows + m.rowIter = m.search.saved.iter + m.exhausted = m.search.saved.exhausted + m.search.saved = nil m.limitReached = false m.loading = false - m.searchLoading = false + m.search.loading = false } m.cursor = 0 if m.ready { @@ -406,18 +413,19 @@ func (m paginatedModel) executeSearch(query string) (tea.Model, tea.Cmd) { return m, nil } - if !m.hasSearchState { - m.hasSearchState = true - m.savedRows = m.rows - m.savedIter = m.rowIter - m.savedExhaust = m.exhausted + if m.search.saved == nil { + m.search.saved = &savedSearch{ + rows: m.rows, + iter: m.rowIter, + exhausted: m.exhausted, + } } m.fetchGeneration++ m.exhausted = false m.limitReached = false m.loading = true - m.searchLoading = true + m.search.loading = true m.cursor = 0 m.rowIter = m.makeSearchIter(query) return m, m.makeFetchCmd(m) @@ -426,33 +434,33 @@ func (m paginatedModel) executeSearch(query string) (tea.Model, tea.Cmd) { func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": - m.searching = false + m.search.active = false // Restore viewport height now that search bar is hidden. m.viewport.Height++ // Execute final search immediately (bypass debounce). - return m.executeSearch(m.searchInput) + return m.executeSearch(m.search.input) case "ctrl+c": return m, tea.Quit case "esc": - m.searching = false - m.searchInput = "" + m.search.active = false + m.search.input = "" // Restore viewport height now that search bar is hidden. m.viewport.Height++ m.restorePreSearchState() return m, nil case "backspace": - if len(m.searchInput) > 0 { - _, size := utf8.DecodeLastRuneInString(m.searchInput) - m.searchInput = m.searchInput[:len(m.searchInput)-size] + if len(m.search.input) > 0 { + _, size := utf8.DecodeLastRuneInString(m.search.input) + m.search.input = m.search.input[:len(m.search.input)-size] } return m, m.scheduleSearchDebounce() default: if msg.Type == tea.KeyRunes { - m.searchInput += msg.String() + m.search.input += msg.String() return m, m.scheduleSearchDebounce() } if msg.Type == tea.KeySpace { - m.searchInput += " " + m.search.input += " " return m, m.scheduleSearchDebounce() } return m, nil @@ -464,7 +472,7 @@ func (m paginatedModel) View() string { return "Loading..." } if len(m.rows) == 0 && m.loading { - if m.searchLoading { + if m.search.loading { return "Searching..." } return "Fetching results..." @@ -484,18 +492,18 @@ func (m paginatedModel) View() string { } func (m paginatedModel) renderFooter() string { - if m.searching { + if m.search.active { placeholder := "" if m.cfg.Search != nil { placeholder = m.cfg.Search.Placeholder } - input := m.searchInput + input := m.search.input if input == "" && placeholder != "" { input = footerStyle.Render(placeholder) } prompt := searchStyle.Render("/ " + input + "█") status := fmt.Sprintf("%d rows loaded", len(m.rows)) - if m.searchLoading { + if m.search.loading { status = "Searching..." } return footerStyle.Render(status) + "\n" + prompt diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index ac1428f3040..d9c7d518847 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -87,63 +87,66 @@ func TestKeyBeforeFirstFetchDoesNotDoubleFetch(t *testing.T) { assert.True(t, pm.loading, "loading must remain true") } -func TestPaginatedFetchFirstBatch(t *testing.T) { - rows := [][]string{{"alice", "30"}, {"bob", "25"}} - m := newTestModel(t, rows, 0) - m.ready = true - m.viewport.Width = 80 - m.viewport.Height = 20 - - msg := rowsFetchedMsg{rows: rows, exhausted: true} - result, _ := m.Update(msg) - pm := result.(paginatedModel) - - assert.Len(t, pm.rows, 2) - assert.True(t, pm.exhausted) - assert.Equal(t, 0, pm.cursor) - assert.NotNil(t, pm.widths) -} - -func TestPaginatedFetchSubsequentBatch(t *testing.T) { - m := newTestModel(t, nil, 0) - m.ready = true - m.viewport.Width = 80 - m.viewport.Height = 20 - m.rows = [][]string{{"alice", "30"}} - m.widths = []int{5, 3} - - msg := rowsFetchedMsg{rows: [][]string{{"bob", "25"}}, exhausted: false} - result, _ := m.Update(msg) - pm := result.(paginatedModel) - - assert.Len(t, pm.rows, 2) - assert.False(t, pm.exhausted) -} - -func TestPaginatedFetchExhaustion(t *testing.T) { - m := newTestModel(t, nil, 0) - m.ready = true - m.viewport.Width = 80 - m.viewport.Height = 20 - - msg := rowsFetchedMsg{rows: nil, exhausted: true} - result, _ := m.Update(msg) - pm := result.(paginatedModel) - - assert.True(t, pm.exhausted) - assert.Empty(t, pm.rows) -} - -func TestPaginatedFetchError(t *testing.T) { - m := newTestModel(t, nil, 0) - m.ready = true - - msg := rowsFetchedMsg{err: errors.New("network error")} - result, _ := m.Update(msg) - pm := result.(paginatedModel) - - require.Error(t, pm.err) - assert.Equal(t, "network error", pm.err.Error()) +func TestPaginatedFetch(t *testing.T) { + tests := []struct { + name string + setup func(*paginatedModel) + msg rowsFetchedMsg + verify func(*testing.T, paginatedModel) + }{ + { + name: "first batch", + setup: func(*paginatedModel) {}, + msg: rowsFetchedMsg{rows: [][]string{{"alice", "30"}, {"bob", "25"}}, exhausted: true}, + verify: func(t *testing.T, pm paginatedModel) { + assert.Len(t, pm.rows, 2) + assert.True(t, pm.exhausted) + assert.Equal(t, 0, pm.cursor) + assert.NotNil(t, pm.widths) + }, + }, + { + name: "subsequent batch appends", + setup: func(m *paginatedModel) { + m.rows = [][]string{{"alice", "30"}} + m.widths = []int{5, 3} + }, + msg: rowsFetchedMsg{rows: [][]string{{"bob", "25"}}, exhausted: false}, + verify: func(t *testing.T, pm paginatedModel) { + assert.Len(t, pm.rows, 2) + assert.False(t, pm.exhausted) + }, + }, + { + name: "exhaustion with no rows", + setup: func(*paginatedModel) {}, + msg: rowsFetchedMsg{rows: nil, exhausted: true}, + verify: func(t *testing.T, pm paginatedModel) { + assert.True(t, pm.exhausted) + assert.Empty(t, pm.rows) + }, + }, + { + name: "error", + setup: func(*paginatedModel) {}, + msg: rowsFetchedMsg{err: errors.New("network error")}, + verify: func(t *testing.T, pm paginatedModel) { + require.Error(t, pm.err) + assert.Equal(t, "network error", pm.err.Error()) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + tt.setup(&m) + result, _ := m.Update(tt.msg) + tt.verify(t, result.(paginatedModel)) + }) + } } func TestPaginatedCursorMovement(t *testing.T) { @@ -192,34 +195,24 @@ func TestPaginatedMaxItemsLimit(t *testing.T) { assert.Len(t, pm.rows, 3) } -func TestPaginatedViewLoading(t *testing.T) { - m := newTestModel(t, nil, 0) - m.ready = true - m.loading = true - view := m.View() - assert.Equal(t, "Fetching results...", view) -} - -func TestPaginatedViewNoResults(t *testing.T) { - m := newTestModel(t, nil, 0) - m.ready = true - m.exhausted = true - view := m.View() - assert.Equal(t, "No results found.", view) -} - -func TestPaginatedViewError(t *testing.T) { - m := newTestModel(t, nil, 0) - m.ready = true - m.err = errors.New("something broke") - view := m.View() - assert.Contains(t, view, "Error: something broke") -} - -func TestPaginatedViewNotReady(t *testing.T) { - m := newTestModel(t, nil, 0) - view := m.View() - assert.Equal(t, "Loading...", view) +func TestPaginatedView(t *testing.T) { + tests := []struct { + name string + setup func(*paginatedModel) + want string + }{ + {"not ready", func(*paginatedModel) {}, "Loading..."}, + {"loading", func(m *paginatedModel) { m.ready = true; m.loading = true }, "Fetching results..."}, + {"no results", func(m *paginatedModel) { m.ready = true; m.exhausted = true }, "No results found."}, + {"error", func(m *paginatedModel) { m.ready = true; m.err = errors.New("something broke") }, "Error: something broke"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newTestModel(t, nil, 0) + tt.setup(&m) + assert.Contains(t, m.View(), tt.want) + }) + } } func TestMaybeFetch(t *testing.T) { @@ -261,7 +254,7 @@ func TestMaybeFetch(t *testing.T) { setup: func(m *paginatedModel) { m.rows = make([][]string, 15) m.cursor = 10 - m.searching = true + m.search.active = true }, wantFetch: false, }, @@ -319,29 +312,28 @@ func TestPaginatedSearchEnterAndRestore(t *testing.T) { m.viewport.Height = 20 // Enter search mode - m.searching = true - m.searchInput = "test" + m.search.active = true + m.search.input = "test" // Submit search result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) pm := result.(paginatedModel) - assert.False(t, pm.searching) + assert.False(t, pm.search.active) assert.True(t, searchCalled) assert.NotNil(t, cmd) - assert.True(t, pm.hasSearchState) + assert.NotNil(t, pm.search.saved) assert.Equal(t, 1, pm.fetchGeneration) // Restore by submitting empty search - pm.searching = true - pm.searchInput = "" + pm.search.active = true + pm.search.input = "" pm.rows = [][]string{{"found:test"}} result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) pm = result.(paginatedModel) assert.Equal(t, [][]string{{"original"}}, pm.rows) - assert.False(t, pm.hasSearchState) - assert.Nil(t, pm.savedRows) + assert.Nil(t, pm.search.saved) assert.Equal(t, 2, pm.fetchGeneration) } @@ -372,19 +364,19 @@ func TestPaginatedSearchRestoreEmptyOriginalTable(t *testing.T) { m.viewport.Width = 80 m.viewport.Height = 20 - m.searching = true - m.searchInput = "test" + m.search.active = true + m.search.input = "test" result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) pm := result.(paginatedModel) assert.NotNil(t, cmd) - assert.True(t, pm.hasSearchState) - assert.Nil(t, pm.savedRows) + assert.NotNil(t, pm.search.saved) + assert.Nil(t, pm.search.saved.rows) assert.Equal(t, 1, pm.fetchGeneration) - pm.searching = true - pm.searchInput = "" + pm.search.active = true + pm.search.input = "" pm.rows = [][]string{{"found:test"}} result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEnter}) pm = result.(paginatedModel) @@ -392,81 +384,98 @@ func TestPaginatedSearchRestoreEmptyOriginalTable(t *testing.T) { assert.Nil(t, pm.rows) assert.Equal(t, originalIter, pm.rowIter) assert.True(t, pm.exhausted) - assert.False(t, pm.hasSearchState) + assert.Nil(t, pm.search.saved) assert.Equal(t, 2, pm.fetchGeneration) } func TestPaginatedSearchEscCancels(t *testing.T) { m := newTestModel(t, nil, 0) - m.searching = true - m.searchInput = "partial" + m.search.active = true + m.search.input = "partial" m.viewport.Height = 20 result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) pm := result.(paginatedModel) - assert.False(t, pm.searching) - assert.Equal(t, "", pm.searchInput) + assert.False(t, pm.search.active) + assert.Equal(t, "", pm.search.input) assert.Equal(t, 21, pm.viewport.Height) } -func TestPaginatedSearchBackspace(t *testing.T) { - m := newTestModel(t, nil, 0) - m.searching = true - m.searchInput = "abc" - - result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyBackspace}) - pm := result.(paginatedModel) - - assert.Equal(t, "ab", pm.searchInput) - assert.NotNil(t, cmd, "backspace should schedule a debounce tick") -} - -func TestPaginatedSearchTyping(t *testing.T) { - m := newTestModel(t, nil, 0) - m.searching = true - m.searchInput = "" - - result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) - pm := result.(paginatedModel) - - assert.Equal(t, "a", pm.searchInput) - assert.NotNil(t, cmd, "typing should schedule a debounce tick") -} - -func TestPaginatedRenderFooterExhausted(t *testing.T) { - m := newTestModel(t, nil, 0) - m.rows = [][]string{{"a", "1"}, {"b", "2"}} - m.exhausted = true - m.cfg = newTestConfig() - m.ready = true - m.viewport.Width = 80 - m.viewport.Height = 20 - - footer := m.renderFooter() - assert.Contains(t, footer, "2 rows") - assert.Contains(t, footer, "q quit") -} - -func TestPaginatedRenderFooterMoreAvailable(t *testing.T) { - m := newTestModel(t, nil, 0) - m.rows = [][]string{{"a", "1"}} - m.exhausted = false - m.cfg = newTestConfig() - - footer := m.renderFooter() - assert.Contains(t, footer, "more available") +func TestPaginatedSearchInputKeys(t *testing.T) { + tests := []struct { + name string + initial string + key tea.KeyMsg + wantInput string + }{ + {"backspace", "abc", tea.KeyMsg{Type: tea.KeyBackspace}, "ab"}, + {"typing", "", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}, "a"}, + {"space", "my", tea.KeyMsg{Type: tea.KeySpace}, "my "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newTestModel(t, nil, 0) + m.search.active = true + m.search.input = tt.initial + result, cmd := m.updateSearch(tt.key) + pm := result.(paginatedModel) + assert.Equal(t, tt.wantInput, pm.search.input) + assert.NotNil(t, cmd, "input change should schedule a debounce tick") + }) + } } -func TestPaginatedRenderFooterLimitReached(t *testing.T) { - m := newTestModel(t, nil, 10) - m.rows = make([][]string, 10) - m.limitReached = true - m.exhausted = true - m.cfg = newTestConfig() - - footer := m.renderFooter() - assert.Contains(t, footer, "limit: 10") +func TestPaginatedRenderFooter(t *testing.T) { + searchCfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{Placeholder: "type here"}, + } + tests := []struct { + name string + maxItems int + cfg *TableConfig + setup func(*paginatedModel) + contains []string + }{ + { + name: "exhausted", + setup: func(m *paginatedModel) { m.rows = [][]string{{"a", "1"}, {"b", "2"}}; m.exhausted = true }, + contains: []string{"2 rows", "q quit"}, + }, + { + name: "more available", + setup: func(m *paginatedModel) { m.rows = [][]string{{"a", "1"}}; m.exhausted = false }, + contains: []string{"more available"}, + }, + { + name: "limit reached", + maxItems: 10, + setup: func(m *paginatedModel) { m.rows = make([][]string, 10); m.limitReached = true; m.exhausted = true }, + contains: []string{"limit: 10"}, + }, + { + name: "with search configured", + cfg: searchCfg, + setup: func(m *paginatedModel) { m.rows = [][]string{{"a"}} }, + contains: []string{"/ search"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newTestModel(t, nil, tt.maxItems) + if tt.cfg != nil { + m.cfg = tt.cfg + } else { + m.cfg = newTestConfig() + } + tt.setup(&m) + footer := m.renderFooter() + for _, want := range tt.contains { + assert.Contains(t, footer, want) + } + }) + } } func TestMaybeFetchSetsLoadingAndPreventsDoubleFetch(t *testing.T) { @@ -562,30 +571,18 @@ func TestFetchCmdExhaustsSmallIterator(t *testing.T) { assert.True(t, fetched.exhausted, "small iterator should be exhausted") } -func TestPaginatedRenderFooterWithSearch(t *testing.T) { - m := newTestModel(t, nil, 0) - m.cfg = &TableConfig{ - Columns: []ColumnDef{{Header: "Name"}}, - Search: &SearchConfig{Placeholder: "type here"}, - } - m.rows = [][]string{{"a"}} - - footer := m.renderFooter() - assert.Contains(t, footer, "/ search") -} - func TestPaginatedSearchDebounceIncrementsSeq(t *testing.T) { m := newTestModel(t, nil, 0) - m.searching = true - m.searchInput = "" + m.search.active = true + m.search.input = "" result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) pm := result.(paginatedModel) - assert.Equal(t, 1, pm.debounceSeq) + assert.Equal(t, 1, pm.search.debounceSeq) result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) pm = result.(paginatedModel) - assert.Equal(t, 2, pm.debounceSeq) + assert.Equal(t, 2, pm.search.debounceSeq) } func TestPaginatedSearchDebounceStaleTickIgnored(t *testing.T) { @@ -607,10 +604,12 @@ func TestPaginatedSearchDebounceStaleTickIgnored(t *testing.T) { rowIter: &stringRowIterator{}, makeFetchCmd: newFetchCmdFunc(ctx), makeSearchIter: newSearchIterFunc(ctx, cfg.Search), - searching: true, - searchInput: "test", - debounceSeq: 5, - ready: true, + search: searchState{ + active: true, + input: "test", + debounceSeq: 5, + }, + ready: true, } m.viewport.Width = 80 m.viewport.Height = 20 @@ -644,12 +643,14 @@ func TestPaginatedSearchDebounceCurrentSeqTriggers(t *testing.T) { rowIter: &stringRowIterator{rows: [][]string{{"original"}}}, makeFetchCmd: newFetchCmdFunc(ctx), makeSearchIter: newSearchIterFunc(ctx, cfg.Search), - searching: true, - searchInput: "hello", - debounceSeq: 3, - rows: [][]string{{"original"}}, - widths: []int{8}, - ready: true, + search: searchState{ + active: true, + input: "hello", + debounceSeq: 3, + }, + rows: [][]string{{"original"}}, + widths: []int{8}, + ready: true, } m.viewport.Width = 80 m.viewport.Height = 20 @@ -660,20 +661,20 @@ func TestPaginatedSearchDebounceCurrentSeqTriggers(t *testing.T) { assert.True(t, searchCalled) assert.NotNil(t, cmd, "should return fetch command") - assert.True(t, pm.hasSearchState) - assert.Equal(t, [][]string{{"original"}}, pm.savedRows) + assert.NotNil(t, pm.search.saved) + assert.Equal(t, [][]string{{"original"}}, pm.search.saved.rows) } func TestPaginatedSearchDebounceIgnoredWhenNotSearching(t *testing.T) { m := newTestModel(t, nil, 0) - m.searching = false - m.debounceSeq = 1 + m.search.active = false + m.search.debounceSeq = 1 result, cmd := m.Update(searchDebounceMsg{seq: 1}) pm := result.(paginatedModel) assert.Nil(t, cmd) - assert.False(t, pm.searching) + assert.False(t, pm.search.active) } func TestPaginatedSearchEnterBypassesDebounce(t *testing.T) { @@ -698,10 +699,12 @@ func TestPaginatedSearchEnterBypassesDebounce(t *testing.T) { makeSearchIter: newSearchIterFunc(ctx, cfg.Search), rows: [][]string{{"original"}}, widths: []int{8}, - searching: true, - searchInput: "test", - debounceSeq: 5, - ready: true, + search: searchState{ + active: true, + input: "test", + debounceSeq: 5, + }, + ready: true, } m.viewport.Width = 80 m.viewport.Height = 20 @@ -711,7 +714,7 @@ func TestPaginatedSearchEnterBypassesDebounce(t *testing.T) { assert.True(t, searchCalled, "enter should trigger search immediately") assert.NotNil(t, cmd) - assert.False(t, pm.searching, "search mode should be exited") + assert.False(t, pm.search.active, "search mode should be exited") } func TestPaginatedSearchModeBlocksFetch(t *testing.T) { @@ -745,7 +748,7 @@ func TestPaginatedSearchModeBlocksFetch(t *testing.T) { result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) pm := result.(paginatedModel) - assert.True(t, pm.searching) + assert.True(t, pm.search.active) assert.False(t, pm.loading, "entering search mode should not overload loading flag") // Verify maybeFetch is blocked by the searching flag. @@ -800,17 +803,20 @@ func TestPaginatedSearchEscRestoresData(t *testing.T) { ctx := t.Context() originalIter := &stringRowIterator{rows: [][]string{{"original"}}} m := paginatedModel{ - cfg: cfg, - headers: []string{"Name"}, - rowIter: &stringRowIterator{rows: [][]string{{"search-result"}}}, - makeFetchCmd: newFetchCmdFunc(ctx), - makeSearchIter: newSearchIterFunc(ctx, cfg.Search), - searching: true, - searchInput: "test", - hasSearchState: true, - savedRows: [][]string{{"original"}}, - savedIter: originalIter, - savedExhaust: true, + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: [][]string{{"search-result"}}}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + search: searchState{ + active: true, + input: "test", + saved: &savedSearch{ + rows: [][]string{{"original"}}, + iter: originalIter, + exhausted: true, + }, + }, rows: [][]string{{"search-result"}}, widths: []int{13}, ready: true, @@ -822,28 +828,27 @@ func TestPaginatedSearchEscRestoresData(t *testing.T) { result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) pm := result.(paginatedModel) - assert.False(t, pm.searching) - assert.Equal(t, "", pm.searchInput) + assert.False(t, pm.search.active) + assert.Equal(t, "", pm.search.input) assert.Equal(t, [][]string{{"original"}}, pm.rows) assert.Equal(t, originalIter, pm.rowIter) assert.True(t, pm.exhausted) - assert.False(t, pm.hasSearchState) - assert.Nil(t, pm.savedRows) + assert.Nil(t, pm.search.saved) assert.Equal(t, 3, pm.fetchGeneration) assert.Equal(t, 0, pm.cursor) } func TestPaginatedSearchEscWithNoSearchStateDoesNothing(t *testing.T) { m := newTestModel(t, nil, 0) - m.searching = true - m.searchInput = "partial" + m.search.active = true + m.search.input = "partial" m.rows = [][]string{{"data"}} m.viewport.Height = 20 result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) pm := result.(paginatedModel) - assert.False(t, pm.searching) + assert.False(t, pm.search.active) assert.Equal(t, [][]string{{"data"}}, pm.rows, "rows should not change when there is no saved search state") } @@ -855,18 +860,6 @@ func TestPaginatedModelErr(t *testing.T) { assert.Equal(t, "test error", m.Err().Error()) } -func TestPaginatedSearchSpaceCharacterInput(t *testing.T) { - m := newTestModel(t, nil, 0) - m.searching = true - m.searchInput = "my" - - result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeySpace}) - pm := result.(paginatedModel) - - assert.Equal(t, "my ", pm.searchInput) - assert.NotNil(t, cmd, "space should schedule a debounce tick") -} - func TestPaginatedFetchErrorClearedOnSuccess(t *testing.T) { m := newTestModel(t, nil, 0) m.ready = true @@ -890,8 +883,8 @@ func TestPaginatedFetchErrorClearedOnSuccess(t *testing.T) { func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { m := newTestModel(t, nil, 0) - m.searching = true - m.searchInput = "" + m.search.active = true + m.search.input = "" m.loading = true // fetch was in-flight before entering search m.viewport.Height = 20 m.fetchGeneration = 5 @@ -933,14 +926,14 @@ func TestPaginatedSearchEscWithoutExecutingUnblocksFetch(t *testing.T) { // Enter search mode via "/". result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) pm := result.(paginatedModel) - assert.True(t, pm.searching) + assert.True(t, pm.search.active) assert.False(t, pm.loading, "loading should not be overloaded by search mode") // Cancel immediately with esc (no search executed). result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) pm = result.(paginatedModel) - assert.False(t, pm.searching) + assert.False(t, pm.search.active) assert.False(t, pm.loading, "loading should remain false after esc") // Verify maybeFetch can fire again (searching=false, loading=false). @@ -979,8 +972,8 @@ func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { startGen := m.fetchGeneration // User enters search mode (pressing "/"). - m.searching = true - m.searchInput = "" + m.search.active = true + m.search.input = "" // User immediately cancels with esc (no search executed). result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) @@ -988,7 +981,7 @@ func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { // Generation must be unchanged so the in-flight fetch is accepted. assert.Equal(t, startGen, pm.fetchGeneration) - assert.False(t, pm.hasSearchState) + assert.Nil(t, pm.search.saved) // Simulate the in-flight fetch completing with the original generation. fetched := rowsFetchedMsg{ @@ -1023,16 +1016,19 @@ func TestPaginatedSearchDebounceEmptyQueryRestores(t *testing.T) { rowIter: &stringRowIterator{rows: [][]string{{"search-result"}}}, makeFetchCmd: newFetchCmdFunc(ctx), makeSearchIter: newSearchIterFunc(ctx, cfg.Search), - searching: true, - searchInput: "", - debounceSeq: 2, - hasSearchState: true, - savedRows: [][]string{{"original"}}, - savedIter: originalIter, - savedExhaust: true, - rows: [][]string{{"search-result"}}, - widths: []int{13}, - ready: true, + search: searchState{ + active: true, + input: "", + debounceSeq: 2, + saved: &savedSearch{ + rows: [][]string{{"original"}}, + iter: originalIter, + exhausted: true, + }, + }, + rows: [][]string{{"search-result"}}, + widths: []int{13}, + ready: true, } m.viewport.Width = 80 m.viewport.Height = 20 @@ -1044,5 +1040,5 @@ func TestPaginatedSearchDebounceEmptyQueryRestores(t *testing.T) { assert.Nil(t, cmd) assert.Equal(t, [][]string{{"original"}}, pm.rows) assert.Equal(t, originalIter, pm.rowIter) - assert.False(t, pm.hasSearchState) + assert.Nil(t, pm.search.saved) }