From e67290687e59539f865382cdef6965d0e5ea990c Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 00:35:39 +0100 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 229baa9086c2864ecfbd2659a525cb7ee9865a43 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:14:45 +0100 Subject: [PATCH 06/17] Fix UTF-8 backspace corruption and fragile typing check in search Backspace in search used byte truncation which corrupts multi-byte UTF-8 characters (accented chars, CJK). Use utf8.DecodeLastRuneInString to remove the last rune correctly. Also replace the fragile `len(msg.String()) == 1` byte-length check with `msg.Type == tea.KeyRunes` for detecting printable input. Co-authored-by: Isaac --- libs/tableview/paginated.go | 9 ++++++--- libs/tableview/tableview.go | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 21872ad059..69cc5369c3 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -7,6 +7,7 @@ import ( "strings" "text/tabwriter" "time" + "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -453,14 +454,16 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "backspace": if len(m.searchInput) > 0 { - m.searchInput = m.searchInput[:len(m.searchInput)-1] + _, size := utf8.DecodeLastRuneInString(m.searchInput) + m.searchInput = m.searchInput[:len(m.searchInput)-size] } return m, m.scheduleSearchDebounce() default: - if len(msg.String()) == 1 || msg.Type == tea.KeyRunes { + if msg.Type == tea.KeyRunes { m.searchInput += msg.String() + return m, m.scheduleSearchDebounce() } - return m, m.scheduleSearchDebounce() + return m, nil } } diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index e6a40685e2..3208832356 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "strings" + "unicode/utf8" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -179,12 +180,12 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "backspace": if len(m.searchInput) > 0 { - m.searchInput = m.searchInput[:len(m.searchInput)-1] + _, size := utf8.DecodeLastRuneInString(m.searchInput) + m.searchInput = m.searchInput[:len(m.searchInput)-size] } return m, nil default: - // Only accept printable characters. - if len(msg.String()) == 1 || msg.Type == tea.KeyRunes { + if msg.Type == tea.KeyRunes { m.searchInput += msg.String() } return m, nil From 78e672071429388307ac41427d8c4cbe95bcaa83 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:15:08 +0100 Subject: [PATCH 07/17] Fix RunPaginated silently dropping model-level fetch errors RunPaginated only returned tea.Program.Run() errors but ignored FinalModel.Err(). This means application-level errors stored in the model (e.g. network errors during fetch) were silently swallowed. Now checks FinalModel.Err() after Run(), matching how render.go handles it. Co-authored-by: Isaac --- libs/tableview/paginated.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 69cc5369c3..eede19a602 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -152,8 +152,16 @@ func NewPaginatedProgram(ctx context.Context, w io.Writer, cfg *TableConfig, ite // RunPaginated launches the paginated TUI table. func RunPaginated(ctx context.Context, w io.Writer, cfg *TableConfig, iter RowIterator, maxItems int) error { p := NewPaginatedProgram(ctx, w, cfg, iter, maxItems) - _, err := p.Run() - return err + finalModel, err := p.Run() + if err != nil { + return err + } + if pm, ok := finalModel.(FinalModel); ok { + if modelErr := pm.Err(); modelErr != nil { + return modelErr + } + } + return nil } // Err returns any error that occurred during data fetching. From 78b56b75c06d9c63fd8e92eeeeca9879da650d56 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:15:27 +0100 Subject: [PATCH 08/17] Recompute column widths on every batch, not just the first Column widths were only computed when isFirstBatch was true. Subsequent batches with wider values would get silently truncated. Now computeWidths() runs whenever new rows arrive. Co-authored-by: Isaac --- libs/tableview/paginated.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index eede19a602..7fb652dcf9 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -212,9 +212,11 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.exhausted = true } - if isFirstBatch && len(m.rows) > 0 { + if len(m.rows) > 0 { m.computeWidths() - m.cursor = 0 + if isFirstBatch { + m.cursor = 0 + } } if m.ready { From 96e614a5c7abb17ae812cfaaace654ab1a34ed8d Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:16:03 +0100 Subject: [PATCH 09/17] Extract restorePreSearchState to fix DRY violation and stuck loading The esc-cancel path duplicated the restore logic from executeSearch(""), creating a maintenance risk. Extracted into restorePreSearchState(). This also fixes a bug where entering search mode (which sets loading=true to block maybeFetch) then immediately pressing Esc/Enter with an empty query would leave loading=true permanently. restorePreSearchState now unconditionally resets loading=false, even when no search was executed. Co-authored-by: Isaac --- libs/tableview/paginated.go | 56 ++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 7fb652dcf9..def2c4607e 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -395,26 +395,33 @@ func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { }) } +// restorePreSearchState restores the original (pre-search) data and resets +// loading so that maybeFetch is unblocked. Safe to call even when there is +// no saved search state. +func (m *paginatedModel) restorePreSearchState() { + if m.hasSearchState { + m.fetchGeneration++ + m.rows = m.savedRows + m.rowIter = m.savedIter + m.exhausted = m.savedExhaust + m.hasSearchState = false + m.savedRows = nil + m.savedIter = nil + m.savedExhaust = false + } + m.loading = false + m.cursor = 0 + if m.ready { + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoTop() + } +} + // executeSearch triggers a server-side search for the given query. // If query is empty, it restores the original (pre-search) state. func (m paginatedModel) executeSearch(query string) (tea.Model, tea.Cmd) { if query == "" { - if m.hasSearchState { - m.fetchGeneration++ - m.rows = m.savedRows - m.rowIter = m.savedIter - m.exhausted = m.savedExhaust - m.loading = false - m.hasSearchState = false - m.savedRows = nil - m.savedIter = nil - m.savedExhaust = false - m.cursor = 0 - if m.ready { - m.viewport.SetContent(m.renderContent()) - m.viewport.GotoTop() - } - } + m.restorePreSearchState() return m, nil } @@ -445,22 +452,7 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searching = false m.searchInput = "" m.viewport.Height++ - if m.hasSearchState { - m.fetchGeneration++ - m.rows = m.savedRows - m.rowIter = m.savedIter - m.exhausted = m.savedExhaust - m.loading = false - m.hasSearchState = false - m.savedRows = nil - m.savedIter = nil - m.savedExhaust = false - m.cursor = 0 - if m.ready { - m.viewport.SetContent(m.renderContent()) - m.viewport.GotoTop() - } - } + m.restorePreSearchState() return m, nil case "backspace": if len(m.searchInput) > 0 { From 3143a722e696d984390b1a83ec750e82e9e0995a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:16:24 +0100 Subject: [PATCH 10/17] Show fetch errors in footer instead of replacing loaded data When a fetch error occurred after rows were already loaded, View() replaced the entire table with just "Error: ...". Now the error-only view is shown only when there are zero rows. When rows exist, the error is displayed in the footer while keeping the table visible. Co-authored-by: Isaac --- libs/tableview/paginated.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index def2c4607e..843b3823cf 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -479,11 +479,14 @@ func (m paginatedModel) View() string { if len(m.rows) == 0 && m.exhausted { return "No results found." } - if m.err != nil { + if m.err != nil && len(m.rows) == 0 { return fmt.Sprintf("Error: %v", m.err) } footer := m.renderFooter() + if m.err != nil { + footer = footerStyle.Render(fmt.Sprintf("Error: %v", m.err)) + } return m.viewport.View() + "\n" + footer } From a548b7951d5d1e0f1a432dc517c4b8fde59ceb5a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:42:30 +0100 Subject: [PATCH 11/17] Fix sticky errors, missing space input, and search/fetch race in TUI table Clear m.err on successful fetch so transient errors don't persist for the session. Handle tea.KeySpace in both search handlers so queries with spaces work. Bump fetchGeneration unconditionally in restorePreSearchState so canceling search before any query executes still discards in-flight fetches. Co-authored-by: Isaac --- libs/tableview/paginated.go | 7 ++++- libs/tableview/paginated_test.go | 48 ++++++++++++++++++++++++++++++++ libs/tableview/tableview.go | 2 ++ libs/tableview/tableview_test.go | 13 +++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 843b3823cf..92c83a76eb 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -202,6 +202,7 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err return m, nil } + m.err = nil isFirstBatch := len(m.rows) == 0 m.rows = append(m.rows, msg.rows...) @@ -399,8 +400,8 @@ func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { // loading so that maybeFetch is unblocked. Safe to call even when there is // no saved search state. func (m *paginatedModel) restorePreSearchState() { + m.fetchGeneration++ if m.hasSearchState { - m.fetchGeneration++ m.rows = m.savedRows m.rowIter = m.savedIter m.exhausted = m.savedExhaust @@ -465,6 +466,10 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searchInput += msg.String() return m, m.scheduleSearchDebounce() } + if msg.Type == tea.KeySpace { + m.searchInput += " " + return m, m.scheduleSearchDebounce() + } return m, nil } } diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index c7add1aacd..ffff274e2a 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -799,6 +799,54 @@ func TestPaginatedModelErr(t *testing.T) { assert.Equal(t, "test error", m.Err().Error()) } +func TestPaginatedSearchSpaceCharacterInput(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "my" + + result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeySpace}) + pm := result.(paginatedModel) + + assert.Equal(t, "my ", pm.searchInput) + assert.NotNil(t, cmd, "space should schedule a debounce tick") +} + +func TestPaginatedFetchErrorClearedOnSuccess(t *testing.T) { + m := newTestModel(t, nil, 0) + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Simulate a fetch error. + errMsg := rowsFetchedMsg{err: errors.New("transient network error")} + result, _ := m.Update(errMsg) + pm := result.(paginatedModel) + require.Error(t, pm.err) + + // Simulate a successful fetch afterward. + successMsg := rowsFetchedMsg{rows: [][]string{{"alice", "30"}}, exhausted: true} + result, _ = pm.Update(successMsg) + pm = result.(paginatedModel) + + assert.NoError(t, pm.err, "error should be cleared after successful fetch") + assert.Len(t, pm.rows, 1) +} + +func TestPaginatedSearchEscWithoutSearchStateBumpsFetchGeneration(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "" + m.loading = true + m.viewport.Height = 20 + m.fetchGeneration = 5 + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm := result.(paginatedModel) + + assert.Equal(t, 6, pm.fetchGeneration, "fetchGeneration should be bumped even without search state") + assert.False(t, pm.loading) +} + func TestPaginatedSearchDebounceEmptyQueryRestores(t *testing.T) { cfg := &TableConfig{ Columns: []ColumnDef{{Header: "Name"}}, diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 3208832356..039835fd2a 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -187,6 +187,8 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { default: if msg.Type == tea.KeyRunes { m.searchInput += msg.String() + } else if msg.Type == tea.KeySpace { + m.searchInput += " " } return m, nil } diff --git a/libs/tableview/tableview_test.go b/libs/tableview/tableview_test.go index c761a9cf00..d0ad651953 100644 --- a/libs/tableview/tableview_test.go +++ b/libs/tableview/tableview_test.go @@ -3,6 +3,7 @@ package tableview import ( "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -55,6 +56,18 @@ func TestFindMatchesEmptyQuery(t *testing.T) { assert.Nil(t, matches) } +func TestSearchSpaceCharacterInput(t *testing.T) { + m := model{ + searching: true, + searchInput: "my", + } + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeySpace}) + rm := result.(model) + + assert.Equal(t, "my ", rm.searchInput) +} + func TestHighlightSearchEmptyQuery(t *testing.T) { result := highlightSearch("hello alice", "") assert.Equal(t, "hello alice", result) From 43c526d545b5f453baecae98b406ff1bbc7514b3 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 20:56:58 +0100 Subject: [PATCH 12/17] Fix search cancel silently dropping in-flight fetch rows Only bump fetchGeneration when hasSearchState is true (switching from search iterator back to original). When hasSearchState is false (user opened search UI but never executed a query), the original iterator and generation are still valid, so in-flight fetches must be accepted. Co-authored-by: Isaac --- libs/tableview/paginated.go | 9 ++--- libs/tableview/paginated_test.go | 59 ++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 92c83a76eb..3358420002 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -325,9 +325,8 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cfg.Search != nil { m.searching = true m.searchInput = "" - // Prevent maybeFetch from starting new fetches against the old iterator - // while we're in search mode. Any in-flight fetch will be discarded - // via generation check when it returns. + // Block maybeFetch while search UI is visible. In-flight fetches from + // the original iterator will still be accepted (same generation). m.loading = true m.viewport.Height-- return m, nil @@ -400,8 +399,10 @@ func (m *paginatedModel) scheduleSearchDebounce() tea.Cmd { // loading so that maybeFetch is unblocked. Safe to call even when there is // no saved search state. func (m *paginatedModel) restorePreSearchState() { - m.fetchGeneration++ if m.hasSearchState { + // Bump generation to discard any in-flight search fetch, since we're + // switching back to the original iterator. + m.fetchGeneration++ m.rows = m.savedRows m.rowIter = m.savedIter m.exhausted = m.savedExhaust diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index ffff274e2a..a994b4561e 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -832,7 +832,7 @@ func TestPaginatedFetchErrorClearedOnSuccess(t *testing.T) { assert.Len(t, pm.rows, 1) } -func TestPaginatedSearchEscWithoutSearchStateBumpsFetchGeneration(t *testing.T) { +func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { m := newTestModel(t, nil, 0) m.searching = true m.searchInput = "" @@ -843,10 +843,65 @@ func TestPaginatedSearchEscWithoutSearchStateBumpsFetchGeneration(t *testing.T) result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) pm := result.(paginatedModel) - assert.Equal(t, 6, pm.fetchGeneration, "fetchGeneration should be bumped even without search state") + assert.Equal(t, 5, pm.fetchGeneration, "fetchGeneration should NOT be bumped without search state") assert.False(t, pm.loading) } +func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { + ctx := t.Context() + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + return &stringRowIterator{} + }, + }, + } + + iter := &stringRowIterator{rows: [][]string{{"row1"}, {"row2"}}} + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: iter, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + widths: []int{4}, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Simulate: a fetch is in-flight at generation 0. + m.loading = true + startGen := m.fetchGeneration + + // User enters search mode (pressing "/"). + m.searching = true + m.searchInput = "" + + // User immediately cancels with esc (no search executed). + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm := result.(paginatedModel) + + // Generation must be unchanged so the in-flight fetch is accepted. + assert.Equal(t, startGen, pm.fetchGeneration) + assert.False(t, pm.hasSearchState) + + // Simulate the in-flight fetch completing with the original generation. + fetched := rowsFetchedMsg{ + rows: [][]string{{"fetched-row"}}, + exhausted: true, + generation: startGen, + } + result2, _ := pm.Update(fetched) + pm2 := result2.(paginatedModel) + + // The rows must be accepted, not silently dropped. + assert.Equal(t, [][]string{{"fetched-row"}}, pm2.rows) + assert.True(t, pm2.exhausted) +} + func TestPaginatedSearchDebounceEmptyQueryRestores(t *testing.T) { cfg := &TableConfig{ Columns: []ColumnDef{{Header: "Name"}}, From 428195334e51f153727b35d12376ee925dcb89ed Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 21:08:39 +0100 Subject: [PATCH 13/17] Fix search/fetch race: preserve loading state when no search was active In restorePreSearchState(), clearing loading=false unconditionally allowed maybeFetch() to queue a second concurrent fetch while the original was still in-flight. Move m.loading=false inside the hasSearchState branch so it only resets when switching back from a search iterator. When no search was active, the original fetch's loading flag is preserved until it returns naturally. Co-authored-by: Isaac --- libs/tableview/paginated.go | 2 +- libs/tableview/paginated_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 3358420002..72ae8d6b3f 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -410,8 +410,8 @@ func (m *paginatedModel) restorePreSearchState() { m.savedRows = nil m.savedIter = nil m.savedExhaust = false + m.loading = false } - m.loading = false m.cursor = 0 if m.ready { m.viewport.SetContent(m.renderContent()) diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index a994b4561e..56b78d2731 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -844,7 +844,7 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { pm := result.(paginatedModel) assert.Equal(t, 5, pm.fetchGeneration, "fetchGeneration should NOT be bumped without search state") - assert.False(t, pm.loading) + assert.True(t, pm.loading, "loading should be preserved when hasSearchState is false and a fetch was in-flight") } func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { From b3a6a5398d85bd75297284bbeb490a41142622ef Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 21:21:13 +0100 Subject: [PATCH 14/17] Fix loading state stuck after canceling search without executing Save the pre-search loading state when entering search mode via "/", and restore it when the user cancels with "esc" before executing a search. Previously, loading was set to true on search entry but never cleared on exit when no search had been performed, permanently blocking maybeFetch and preventing further pagination. Co-authored-by: Isaac --- libs/tableview/paginated.go | 6 ++++ libs/tableview/paginated_test.go | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 72ae8d6b3f..99e68af047 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -72,6 +72,7 @@ type paginatedModel struct { savedRows [][]string savedIter RowIterator savedExhaust bool + savedLoading bool // Limits maxItems int @@ -327,6 +328,7 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searchInput = "" // Block maybeFetch while search UI is visible. In-flight fetches from // the original iterator will still be accepted (same generation). + m.savedLoading = m.loading m.loading = true m.viewport.Height-- return m, nil @@ -411,6 +413,10 @@ func (m *paginatedModel) restorePreSearchState() { m.savedIter = nil m.savedExhaust = false m.loading = false + } else { + // No search was executed; restore the loading state from before + // entering search mode so maybeFetch isn't permanently blocked. + m.loading = m.savedLoading } m.cursor = 0 if m.ready { diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 56b78d2731..77a411a4c4 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -836,6 +836,7 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { m := newTestModel(t, nil, 0) m.searching = true m.searchInput = "" + m.savedLoading = true // fetch was in-flight before entering search m.loading = true m.viewport.Height = 20 m.fetchGeneration = 5 @@ -847,6 +848,52 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { assert.True(t, pm.loading, "loading should be preserved when hasSearchState is false and a fetch was in-flight") } +func TestPaginatedSearchEscWithoutExecutingRestoresLoading(t *testing.T) { + cfg := &TableConfig{ + Columns: []ColumnDef{{Header: "Name"}}, + Search: &SearchConfig{ + Placeholder: "search...", + NewIterator: func(_ context.Context, _ string) RowIterator { + return &stringRowIterator{} + }, + }, + } + + ctx := t.Context() + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name"}, + rowIter: &stringRowIterator{rows: make([][]string, 20)}, + makeFetchCmd: newFetchCmdFunc(ctx), + makeSearchIter: newSearchIterFunc(ctx, cfg.Search), + rows: make([][]string, 15), + widths: []int{4}, + ready: true, + loading: false, + exhausted: false, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Enter search mode via "/". + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + pm := result.(paginatedModel) + assert.True(t, pm.loading, "loading should be true while in search mode") + assert.False(t, pm.savedLoading, "savedLoading should capture the pre-search value (false)") + + // Cancel immediately with esc (no search executed). + result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm = result.(paginatedModel) + + assert.False(t, pm.searching) + assert.False(t, pm.loading, "loading should be restored to false after esc without search") + + // Verify maybeFetch can fire again. + pm.cursor = len(pm.rows) - 1 + pm, cmd := maybeFetch(pm) + assert.NotNil(t, cmd, "maybeFetch should trigger after loading is restored") +} + func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { ctx := t.Context() cfg := &TableConfig{ From 7b4c0eed01a73d49f37ebfd33ef4b45800934df2 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 21:30:33 +0100 Subject: [PATCH 15/17] Separate search and loading concerns in paginated model The loading flag was overloaded to both indicate "fetch in-flight" and "block fetches during search UI". This caused edge cases with save/restore. Instead, maybeFetch now checks the searching flag directly, and loading is managed purely by fetch start/complete. Co-authored-by: Isaac --- libs/tableview/paginated.go | 12 ++---------- libs/tableview/paginated_test.go | 31 ++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 99e68af047..2ae62d846a 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -72,7 +72,7 @@ type paginatedModel struct { savedRows [][]string savedIter RowIterator savedExhaust bool - savedLoading bool + // Limits maxItems int @@ -326,10 +326,6 @@ func (m paginatedModel) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cfg.Search != nil { m.searching = true m.searchInput = "" - // Block maybeFetch while search UI is visible. In-flight fetches from - // the original iterator will still be accepted (same generation). - m.savedLoading = m.loading - m.loading = true m.viewport.Height-- return m, nil } @@ -378,7 +374,7 @@ func (m *paginatedModel) moveCursor(delta int) { } func maybeFetch(m paginatedModel) (paginatedModel, tea.Cmd) { - if m.loading || m.exhausted { + if m.loading || m.exhausted || m.searching { return m, nil } if len(m.rows)-m.cursor <= fetchThresholdFromBottom { @@ -413,10 +409,6 @@ func (m *paginatedModel) restorePreSearchState() { m.savedIter = nil m.savedExhaust = false m.loading = false - } else { - // No search was executed; restore the loading state from before - // entering search mode so maybeFetch isn't permanently blocked. - m.loading = m.savedLoading } m.cursor = 0 if m.ready { diff --git a/libs/tableview/paginated_test.go b/libs/tableview/paginated_test.go index 77a411a4c4..0b683aef1b 100644 --- a/libs/tableview/paginated_test.go +++ b/libs/tableview/paginated_test.go @@ -224,6 +224,16 @@ func TestPaginatedMaybeFetchNotTriggeredWhenLoading(t *testing.T) { assert.Nil(t, cmd) } +func TestPaginatedMaybeFetchNotTriggeredWhenSearching(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = make([][]string, 15) + m.cursor = 10 + m.searching = true + + _, cmd := maybeFetch(m) + assert.Nil(t, cmd) +} + func TestPaginatedMaybeFetchNotTriggeredWhenFarFromBottom(t *testing.T) { m := newTestModel(t, nil, 0) m.rows = make([][]string, 50) @@ -690,12 +700,12 @@ func TestPaginatedSearchModeBlocksFetch(t *testing.T) { pm := result.(paginatedModel) assert.True(t, pm.searching) - assert.True(t, pm.loading, "entering search mode should set loading=true to block maybeFetch") + assert.False(t, pm.loading, "entering search mode should not overload loading flag") - // Verify maybeFetch is blocked. + // Verify maybeFetch is blocked by the searching flag. pm.cursor = len(pm.rows) - 1 pm, cmd := maybeFetch(pm) - assert.Nil(t, cmd, "maybeFetch should not trigger while loading is true") + assert.Nil(t, cmd, "maybeFetch should not trigger while searching is true") } func TestPaginatedSearchExecuteSetsLoading(t *testing.T) { @@ -836,8 +846,7 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { m := newTestModel(t, nil, 0) m.searching = true m.searchInput = "" - m.savedLoading = true // fetch was in-flight before entering search - m.loading = true + m.loading = true // fetch was in-flight before entering search m.viewport.Height = 20 m.fetchGeneration = 5 @@ -848,7 +857,7 @@ func TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { assert.True(t, pm.loading, "loading should be preserved when hasSearchState is false and a fetch was in-flight") } -func TestPaginatedSearchEscWithoutExecutingRestoresLoading(t *testing.T) { +func TestPaginatedSearchEscWithoutExecutingUnblocksFetch(t *testing.T) { cfg := &TableConfig{ Columns: []ColumnDef{{Header: "Name"}}, Search: &SearchConfig{ @@ -878,20 +887,20 @@ func TestPaginatedSearchEscWithoutExecutingRestoresLoading(t *testing.T) { // Enter search mode via "/". result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) pm := result.(paginatedModel) - assert.True(t, pm.loading, "loading should be true while in search mode") - assert.False(t, pm.savedLoading, "savedLoading should capture the pre-search value (false)") + assert.True(t, pm.searching) + assert.False(t, pm.loading, "loading should not be overloaded by search mode") // Cancel immediately with esc (no search executed). result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) pm = result.(paginatedModel) assert.False(t, pm.searching) - assert.False(t, pm.loading, "loading should be restored to false after esc without search") + assert.False(t, pm.loading, "loading should remain false after esc") - // Verify maybeFetch can fire again. + // Verify maybeFetch can fire again (searching=false, loading=false). pm.cursor = len(pm.rows) - 1 pm, cmd := maybeFetch(pm) - assert.NotNil(t, cmd, "maybeFetch should trigger after loading is restored") + assert.NotNil(t, cmd, "maybeFetch should trigger after search mode is exited") } func TestPaginatedSearchEscBeforeFetchCompletesKeepsRows(t *testing.T) { From aaf150e70e8540a95af0408880a97f0a78e9171a Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 16 Mar 2026 22:27:15 +0100 Subject: [PATCH 16/17] Fix lint: remove extra blank line, convert if/else to switch Co-authored-by: Isaac --- libs/tableview/paginated.go | 1 - libs/tableview/tableview.go | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go index 2ae62d846a..b72ac1ce01 100644 --- a/libs/tableview/paginated.go +++ b/libs/tableview/paginated.go @@ -73,7 +73,6 @@ type paginatedModel struct { savedIter RowIterator savedExhaust bool - // Limits maxItems int limitReached bool diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 039835fd2a..5dcd28d7e5 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -185,9 +185,10 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil default: - if msg.Type == tea.KeyRunes { + switch msg.Type { + case tea.KeyRunes: m.searchInput += msg.String() - } else if msg.Type == tea.KeySpace { + case tea.KeySpace: m.searchInput += " " } return m, nil From 5709b104fffdf5e6f264c09c6cec491848baf6e4 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 17 Mar 2026 08:12:53 +0100 Subject: [PATCH 17/17] Fix exhaustive lint: replace switch on tea.KeyType with if-else The exhaustive linter requires all cases of tea.KeyType to be handled in a switch statement. Since we only care about KeyRunes and KeySpace, an if-else is simpler and avoids the issue entirely. Co-authored-by: Isaac --- libs/tableview/tableview.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libs/tableview/tableview.go b/libs/tableview/tableview.go index 5dcd28d7e5..973f4f257d 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -185,11 +185,8 @@ func (m model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil default: - switch msg.Type { - case tea.KeyRunes: + if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { m.searchInput += msg.String() - case tea.KeySpace: - m.searchInput += " " } return m, nil }