From e67290687e59539f865382cdef6965d0e5ea990c Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 00:35:39 +0100 Subject: [PATCH 1/5] 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 d7a71045b136f90d27f44f178cb4f813bef5c724 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 13:36:12 +0100 Subject: [PATCH 2/5] 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 b7ca3d8de4dfc016d17d827452b3bc57cf51a37b Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 15:45:15 +0100 Subject: [PATCH 3/5] 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 aa4cdb060e0d121bf3eba72fce8f7ea6b53b3f00 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 16:03:29 +0100 Subject: [PATCH 4/5] 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 5/5] 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())