From e67290687e59539f865382cdef6965d0e5ea990c Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 00:35:39 +0100 Subject: [PATCH 01/16] 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 6393c62d66..f798579d87 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 b7eadb401c..11efd9dca4 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 455acebc77..4af338183e 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 0000000000..c057be6a3a --- /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 c344c3d028..e3b87fa146 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 0000000000..3921a5efc7 --- /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 0000000000..90ab1019fb --- /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 0000000000..58372408a1 --- /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 0000000000..c933f08e87 --- /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 0000000000..086c366efa --- /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 0000000000..6fc44ce8f4 --- /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 0000000000..6bcb10f87c --- /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 18eca554ce..e6a40685e2 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 0000000000..96b012f468 --- /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 0000000000..316bc9e993 --- /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 78f348997e4709e53b08b3670cdfe3fc573b1848 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 00:50:03 +0100 Subject: [PATCH 02/16] Add curated TUI table overrides for 15 list commands Co-authored-by: Isaac --- cmd/workspace/alerts/overrides.go | 32 +++++++++++++++ cmd/workspace/apps/overrides.go | 24 +++++++++++ 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 | 30 ++++++++++++++ cmd/workspace/pipelines/overrides.go | 40 +++++++++++++++++++ cmd/workspace/repos/overrides.go | 18 +++++++++ cmd/workspace/schemas/overrides.go | 15 +++++++ cmd/workspace/serving-endpoints/overrides.go | 35 ++++++++++++++++ cmd/workspace/tables/overrides.go | 12 ++++++ cmd/workspace/volumes/overrides.go | 32 +++++++++++++++ cmd/workspace/warehouses/overrides.go | 18 +++++++++ cmd/workspace/workspace/overrides.go | 19 +++++++++ 15 files changed, 339 insertions(+) create mode 100644 cmd/workspace/alerts/overrides.go create mode 100644 cmd/workspace/serving-endpoints/overrides.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 0000000000..ed5fdaa8db --- /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 6a909a943e..5ccc6024f4 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,29 @@ 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 { + return v.(apps.App).Name + }}, + {Header: "URL", Extract: func(v any) string { + return v.(apps.App).Url + }}, + {Header: "Compute Status", Extract: func(v any) string { + if v.(apps.App).ComputeStatus != nil { + return string(v.(apps.App).ComputeStatus.State) + } + return "" + }}, + {Header: "Deploy Status", Extract: func(v any) string { + if v.(apps.App).ActiveDeployment != nil && v.(apps.App).ActiveDeployment.Status != nil { + return string(v.(apps.App).ActiveDeployment.Status.State) + } + return "" + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, listDeploymentsReq *apps.ListAppDeploymentsRequest) { diff --git a/cmd/workspace/catalogs/overrides.go b/cmd/workspace/catalogs/overrides.go index e2201dc152..46d66a08b2 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 6038978ae4..910918b017 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 00b4921d4d..9d9108f5be 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 f62f8c5367..ddf6181f15 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 ee7d205517..101226e27c 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,31 @@ 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 + 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/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 08c36deabe..3a887f3a95 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -1,15 +1,55 @@ 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 + escaped := strings.ReplaceAll(query, "'", "''") + req.Filter = fmt.Sprintf("name LIKE '%%%s%%'", escaped) + w := cmdctx.WorkspaceClient(ctx) + return tableview.WrapIterator(w.Pipelines.ListPipelines(ctx, req), columns) + }, + }, + }) +} + 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{ diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 8110c2aa48..0b3852d0ff 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 ba4c65ce73..625c92f3d7 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 0000000000..611428f18d --- /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/tables/overrides.go b/cmd/workspace/tables/overrides.go index a0849ada7f..157d62daf9 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 0000000000..0a4f645de3 --- /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 9457557d00..edc58ad681 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 c57209b554..56e2e74f71 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 2183eded59abd888bfb8cb26146f3bf5aab289bb Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 07:36:17 +0100 Subject: [PATCH 03/16] Add TUI table overrides for 5 high-traffic list commands --- cmd/workspace/cluster-policies/overrides.go | 18 +++++++++++++ cmd/workspace/lakeview/overrides.go | 25 ++++++++++++++++++ cmd/workspace/pipelines/overrides.go | 26 ++++++++++++++++++ cmd/workspace/secrets/overrides.go | 29 +++++++++++++++++++++ 4 files changed, 98 insertions(+) diff --git a/cmd/workspace/cluster-policies/overrides.go b/cmd/workspace/cluster-policies/overrides.go index 9278b29c39..8bc320aa5f 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/lakeview/overrides.go b/cmd/workspace/lakeview/overrides.go index 6ffb641aa9..17953123a4 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, listReq *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 3a887f3a95..fcb51c3dae 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -47,8 +47,34 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli }) } +func listPipelineEventsOverride(listCmd *cobra.Command, listReq *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}} + {{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: 60, Extract: func(v any) string { + return 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 diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index b215f17a7f..8403ac4699 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -1,7 +1,10 @@ package secrets import ( + "strconv" + "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 strconv.FormatInt(ts, 10) + }}, + } + + tableview.RegisterConfig(listSecretsCommand, tableview.TableConfig{Columns: columns}) } func init() { From 5217d6ce3d6b06033dfcb43e9f5404bbd219f605 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 13:34:17 +0100 Subject: [PATCH 04/16] Format secret timestamps as human-readable dates --- cmd/workspace/secrets/overrides.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index 8403ac4699..5de7268905 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -1,7 +1,7 @@ package secrets import ( - "strconv" + "time" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/tableview" @@ -48,7 +48,7 @@ func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSec if ts == 0 { return "" } - return strconv.FormatInt(ts, 10) + return time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05") }}, } From d7a71045b136f90d27f44f178cb4f813bef5c724 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 13:36:12 +0100 Subject: [PATCH 05/16] 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 086c366efa..444f87e664 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 6fc44ce8f4..bc46c382b5 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 17b9e114c708745b31ab6af861c2e9c028ed1572 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 13:37:44 +0100 Subject: [PATCH 06/16] Escape pipeline search wildcards and add override tests Treat pipeline search input literally when users type SQL LIKE wildcards, and add package-level override tests so nested SDK field access is exercised before runtime. --- cmd/workspace/apps/overrides_test.go | 68 +++++++++++++++++++ cmd/workspace/jobs/overrides_test.go | 50 ++++++++++++++ cmd/workspace/pipelines/overrides.go | 2 + cmd/workspace/pipelines/overrides_test.go | 43 ++++++++++++ .../serving-endpoints/overrides_test.go | 58 ++++++++++++++++ 5 files changed, 221 insertions(+) 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_test.go diff --git a/cmd/workspace/apps/overrides_test.go b/cmd/workspace/apps/overrides_test.go new file mode 100644 index 0000000000..c2d374f38b --- /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/jobs/overrides_test.go b/cmd/workspace/jobs/overrides_test.go new file mode 100644 index 0000000000..66bcb5da27 --- /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 3a887f3a95..e93fb86047 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -39,6 +39,8 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli NewIterator: func(ctx context.Context, query string) tableview.RowIterator { req := *listReq escaped := strings.ReplaceAll(query, "'", "''") + 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) diff --git a/cmd/workspace/pipelines/overrides_test.go b/cmd/workspace/pipelines/overrides_test.go index 2e70cf4845..8c205d6430 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,40 @@ 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")) +} diff --git a/cmd/workspace/serving-endpoints/overrides_test.go b/cmd/workspace/serving-endpoints/overrides_test.go new file mode 100644 index 0000000000..1ab6f39dad --- /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)) + }) + } +} From 56ebde49db2866ae371d59fa2bbca66997743e9c Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 15:40:08 +0100 Subject: [PATCH 07/16] Preserve user --filter when TUI search is used for pipelines list Previously, the TUI search callback overwrote req.Filter entirely with a name LIKE expression, discarding any filter the user passed via --filter. Now the name LIKE clause is combined with the existing filter using AND, so both constraints apply together. --- cmd/workspace/pipelines/overrides.go | 7 ++++++- cmd/workspace/pipelines/overrides_test.go | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index e93fb86047..993b6f27c3 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -41,7 +41,12 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli escaped := strings.ReplaceAll(query, "'", "''") escaped = strings.ReplaceAll(escaped, "%", "\\%") escaped = strings.ReplaceAll(escaped, "_", "\\_") - req.Filter = fmt.Sprintf("name LIKE '%%%s%%'", escaped) + nameFilter := fmt.Sprintf("name LIKE '%%%s%%'", escaped) + if req.Filter != "" { + req.Filter = req.Filter + " AND " + nameFilter + } else { + req.Filter = nameFilter + } w := cmdctx.WorkspaceClient(ctx) return tableview.WrapIterator(w.Pipelines.ListPipelines(ctx, req), columns) }, diff --git a/cmd/workspace/pipelines/overrides_test.go b/cmd/workspace/pipelines/overrides_test.go index 8c205d6430..7a320789e6 100644 --- a/cmd/workspace/pipelines/overrides_test.go +++ b/cmd/workspace/pipelines/overrides_test.go @@ -56,3 +56,25 @@ func TestListPipelinesSearchEscapesLikeWildcards(t *testing.T) { ctx := cmdctx.SetWorkspaceClient(t.Context(), m.WorkspaceClient) assert.NotNil(t, cfg.Search.NewIterator(ctx, "foo'%_bar")) } + +func TestListPipelinesSearchPreservesExistingFilter(t *testing.T) { + cmd := newListPipelines() + + // Simulate the user passing --filter on the command line. + err := cmd.Flags().Set("filter", "state = 'RUNNING'") + require.NoError(t, err) + + 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: "state = 'RUNNING' AND name LIKE '%myquery%'", + }). + Return(nil) + + ctx := cmdctx.SetWorkspaceClient(t.Context(), m.WorkspaceClient) + assert.NotNil(t, cfg.Search.NewIterator(ctx, "myquery")) +} From 8d942f8b5ac049e9a7947b8b5cd4eab12ca133a4 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 15:40:26 +0100 Subject: [PATCH 08/16] Replace unused listReq parameters with _ in list override functions Several list override functions declared a named request parameter (e.g. listReq) that was never used after flag binding. Replace these with blank identifiers to satisfy go vet and make intent clearer. The parameter is kept named in clusters, jobs, pipelines, and workspace overrides where it is actively used for flag binding or search closures. --- cmd/workspace/alerts/overrides.go | 2 +- cmd/workspace/apps/overrides.go | 4 ++-- cmd/workspace/catalogs/overrides.go | 2 +- cmd/workspace/external-locations/overrides.go | 2 +- cmd/workspace/jobs/overrides.go | 2 +- cmd/workspace/lakeview/overrides.go | 2 +- cmd/workspace/pipelines/overrides.go | 2 +- cmd/workspace/repos/overrides.go | 2 +- cmd/workspace/schemas/overrides.go | 2 +- cmd/workspace/tables/overrides.go | 2 +- cmd/workspace/volumes/overrides.go | 2 +- cmd/workspace/warehouses/overrides.go | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go index ed5fdaa8db..b7d3a3e3f4 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 5ccc6024f4..1d1808be0f 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(` @@ -41,7 +41,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 46d66a08b2..d86af1eeea 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/external-locations/overrides.go b/cmd/workspace/external-locations/overrides.go index 9d9108f5be..607550da3a 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 101226e27c..49ea37a1ce 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -42,7 +42,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 17953123a4..55357f703d 100644 --- a/cmd/workspace/lakeview/overrides.go +++ b/cmd/workspace/lakeview/overrides.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -func listOverride(listCmd *cobra.Command, listReq *dashboards.ListDashboardsRequest) { +func listOverride(listCmd *cobra.Command, _ *dashboards.ListDashboardsRequest) { listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Dashboard ID"}} {{header "Name"}} {{header "State"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index fcb51c3dae..00027101f2 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -47,7 +47,7 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli }) } -func listPipelineEventsOverride(listCmd *cobra.Command, listReq *pipelines.ListPipelineEventsRequest) { +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(` diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 0b3852d0ff..b5bf428284 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 625c92f3d7..0e9b1b03b9 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/tables/overrides.go b/cmd/workspace/tables/overrides.go index 157d62daf9..8e0987d469 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 0a4f645de3..66b946f2ea 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 edc58ad681..14b2635a04 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(` From b7ca3d8de4dfc016d17d827452b3bc57cf51a37b Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 15:45:15 +0100 Subject: [PATCH 09/16] 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 444f87e664..7f89b2472b 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 bc46c382b5..d956062376 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 c3ff7347505fded95344c3e3fe8362e778328181 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 16:00:07 +0100 Subject: [PATCH 10/16] Fix operator precedence bug when combining user filter with name search Wrap the user-provided filter in parentheses before appending the AND clause. Without this, a filter like 'a OR b' combined with a name search would parse as 'a OR (b AND name LIKE ...)' instead of the intended '(a OR b) AND name LIKE ...'. Add a test case with an OR filter to verify correct parenthesization. --- cmd/workspace/pipelines/overrides.go | 2 +- cmd/workspace/pipelines/overrides_test.go | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 993b6f27c3..89f00dd64c 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -43,7 +43,7 @@ func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipeli escaped = strings.ReplaceAll(escaped, "_", "\\_") nameFilter := fmt.Sprintf("name LIKE '%%%s%%'", escaped) if req.Filter != "" { - req.Filter = req.Filter + " AND " + nameFilter + req.Filter = "(" + req.Filter + ") AND " + nameFilter } else { req.Filter = nameFilter } diff --git a/cmd/workspace/pipelines/overrides_test.go b/cmd/workspace/pipelines/overrides_test.go index 7a320789e6..edb84f7a31 100644 --- a/cmd/workspace/pipelines/overrides_test.go +++ b/cmd/workspace/pipelines/overrides_test.go @@ -71,7 +71,28 @@ func TestListPipelinesSearchPreservesExistingFilter(t *testing.T) { m := mocks.NewMockWorkspaceClient(t) m.GetMockPipelinesAPI().EXPECT(). ListPipelines(mock.Anything, sdkpipelines.ListPipelinesRequest{ - Filter: "state = 'RUNNING' AND name LIKE '%myquery%'", + Filter: "(state = 'RUNNING') AND name LIKE '%myquery%'", + }). + Return(nil) + + ctx := cmdctx.SetWorkspaceClient(t.Context(), m.WorkspaceClient) + assert.NotNil(t, cfg.Search.NewIterator(ctx, "myquery")) +} + +func TestListPipelinesSearchWrapsORFilterInParens(t *testing.T) { + cmd := newListPipelines() + + err := cmd.Flags().Set("filter", "state = 'RUNNING' OR state = 'IDLE'") + require.NoError(t, err) + + 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: "(state = 'RUNNING' OR state = 'IDLE') AND name LIKE '%myquery%'", }). Return(nil) From c35aefbf6e99f423c6f9c223caf19901a1c36185 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 16:01:41 +0100 Subject: [PATCH 11/16] Propagate iterator fetch errors from TUI model to command exit code Previously, RenderIterator and RunPaginated only returned the error from tea.Program.Run(), ignoring any fetch error stored in the model. An API error mid-stream would display an error screen in the TUI but the command would still exit 0. Now both functions inspect the final model via the new Err() accessor and return the fetch error if set. Also documents the destructive MaxWidth truncation behavior on ColumnDef and renderContent. --- libs/cmdio/render.go | 15 +++++++++++++-- libs/tableview/config.go | 7 +++++-- libs/tableview/paginated.go | 28 +++++++++++++++++++++++++--- libs/tableview/paginated_test.go | 8 ++++++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index e3b87fa146..a4bce2e1d2 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -278,8 +278,19 @@ 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 + } + // p.Run() may succeed even if the model recorded an iterator + // fetch error (shown on screen but not propagated). Check the + // final model so the command exits non-zero on API errors. + if m, ok := finalModel.(tableview.PaginatedModel); ok { + if fetchErr := m.Err(); fetchErr != nil { + return fetchErr + } + } + return nil } } } diff --git a/libs/tableview/config.go b/libs/tableview/config.go index c933f08e87..f6ba32a41a 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 086c366efa..c914738bfd 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -24,6 +24,10 @@ type rowsFetchedMsg struct { err error } +// PaginatedModel is the exported alias used by callers (e.g. RenderIterator) +// to inspect the final model returned by tea.Program.Run(). +type PaginatedModel = paginatedModel + type paginatedModel struct { cfg *TableConfig headers []string @@ -58,6 +62,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 { @@ -131,8 +140,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 m, ok := finalModel.(PaginatedModel); ok { + if fetchErr := m.Err(); fetchErr != nil { + return fetchErr + } + } + return nil } func (m paginatedModel) Init() tea.Cmd { @@ -229,7 +246,12 @@ func (m paginatedModel) renderContent() string { } fmt.Fprintln(tw, strings.Join(seps, "\t")) - // Data rows + // Data rows. + // NOTE: MaxWidth truncation here is destructive, not display wrapping. + // Values exceeding MaxWidth are cut and suffixed with "..." in the + // rendered output. Horizontal scrolling cannot recover the hidden tail. + // A future improvement could store full values and only truncate the + // visible slice, but that requires per-cell width tracking. for _, row := range m.rows { vals := make([]string, len(m.headers)) for i := range m.headers { diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 6fc44ce8f4..2df8f8f53f 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -116,6 +116,14 @@ func TestPaginatedFetchError(t *testing.T) { assert.Equal(t, "network error", pm.err.Error()) } +func TestPaginatedErrAccessor(t *testing.T) { + m := newTestModel(t, nil, 0) + assert.NoError(t, m.Err()) + + m.err = errors.New("api timeout") + assert.EqualError(t, m.Err(), "api timeout") +} + func TestPaginatedCursorMovement(t *testing.T) { m := newTestModel(t, nil, 0) m.ready = true From aa4cdb060e0d121bf3eba72fce8f7ea6b53b3f00 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 16:03:29 +0100 Subject: [PATCH 12/16] 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 e3b87fa146..f4289dbfd7 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 7f89b2472b..21872ad059 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 d956062376..0e2e6fb6ea 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 13/16] 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 0e2e6fb6ea..c7add1aacd 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 c1cdd19e425176ae2c79ad1f10f8f57d94dc13d4 Mon Sep 17 00:00:00 2001 From: simon Date: Sat, 14 Mar 2026 22:53:28 +0100 Subject: [PATCH 14/16] Remove duplicate Err() method on paginatedModel --- libs/tableview/paginated.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 3bdbcf4097..788ca07437 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -172,11 +172,6 @@ func RunPaginated(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIt 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) } From 2ddfdf0f870cb682b2703665d13dd2c5195cecf8 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:24:34 +0100 Subject: [PATCH 15/16] Sanitize pipeline event messages and increase MaxWidth Pipeline event messages can contain embedded newlines, carriage returns, and tabs that corrupt tab-delimited text output and TUI table rows. Add a `sanitize` template function to cmdio's renderFuncMap and use it in the text template. Also sanitize in the TUI Extract function. Increase MaxWidth from 60 to 200 so diagnostic payloads are not truncated destructively before the actionable part of the error. Co-authored-by: Isaac --- cmd/workspace/pipelines/overrides.go | 14 +++++++++++--- libs/cmdio/render.go | 11 ++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 5187e64fbd..292a7b17d3 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -58,7 +58,7 @@ func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelin listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Timestamp"}} {{header "Level"}} {{header "Event Type"}} {{header "Message"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` - {{range .}}{{.Timestamp}} {{.Level}} {{.EventType}} {{.Message}} + {{range .}}{{.Timestamp}} {{.Level}} {{.EventType}} {{.Message | sanitize}} {{end}}`) columns := []tableview.ColumnDef{ @@ -71,8 +71,8 @@ func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelin {Header: "Event Type", Extract: func(v any) string { return v.(pipelines.PipelineEvent).EventType }}, - {Header: "Message", MaxWidth: 60, Extract: func(v any) string { - return v.(pipelines.PipelineEvent).Message + {Header: "Message", MaxWidth: 200, Extract: func(v any) string { + return sanitizeWhitespace(v.(pipelines.PipelineEvent).Message) }}, } @@ -144,6 +144,14 @@ With a PIPELINE_ID: Stops the pipeline identified by the UUID using the API.` }) } +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/libs/cmdio/render.go b/libs/cmdio/render.go index f4289dbfd7..7a91f8ef8f 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,7 +338,8 @@ var renderFuncMap = template.FuncMap{ "italic": func(format string, a ...any) string { return color.New(color.Italic).Sprintf(format, a...) }, - "replace": strings.ReplaceAll, + "replace": strings.ReplaceAll, + "sanitize": sanitizeControlWhitespace, "join": strings.Join, "sub": func(a, b int) int { return a - b From c0475fc9ce8bd5424da1c8f4dbd742ae476f24a5 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:24:43 +0100 Subject: [PATCH 16/16] Clean up PaginatedModel alias, duplicate test, and verbose comment Remove the PaginatedModel type alias (FinalModel interface suffices). Remove the duplicate TestPaginatedErrAccessor test that overlaps with TestPaginatedModelErr. Reduce the 5-line MaxWidth truncation comment to a single line. Co-authored-by: Isaac --- libs/tableview/paginated.go | 12 ++---------- libs/tableview/paginated_test.go | 8 -------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 788ca07437..cb093a9769 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -40,10 +40,6 @@ type searchDebounceMsg struct { seq int } -// PaginatedModel is the exported alias used by callers (e.g. RenderIterator) -// to inspect the final model returned by tea.Program.Run(). -type PaginatedModel = paginatedModel - type paginatedModel struct { cfg *TableConfig headers []string @@ -164,7 +160,7 @@ func RunPaginated(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIt if err != nil { return err } - if m, ok := finalModel.(PaginatedModel); ok { + if m, ok := finalModel.(FinalModel); ok { if fetchErr := m.Err(); fetchErr != nil { return fetchErr } @@ -276,11 +272,7 @@ func (m paginatedModel) renderContent() string { fmt.Fprintln(tw, strings.Join(seps, "\t")) // Data rows. - // NOTE: MaxWidth truncation here is destructive, not display wrapping. - // Values exceeding MaxWidth are cut and suffixed with "..." in the - // rendered output. Horizontal scrolling cannot recover the hidden tail. - // A future improvement could store full values and only truncate the - // visible slice, but that requires per-cell width tracking. + // 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 { diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index d839e5f9a1..c7add1aacd 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -116,14 +116,6 @@ func TestPaginatedFetchError(t *testing.T) { assert.Equal(t, "network error", pm.err.Error()) } -func TestPaginatedErrAccessor(t *testing.T) { - m := newTestModel(t, nil, 0) - assert.NoError(t, m.Err()) - - m.err = errors.New("api timeout") - assert.EqualError(t, m.Err(), "api timeout") -} - func TestPaginatedCursorMovement(t *testing.T) { m := newTestModel(t, nil, 0) m.ready = true