diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index c87c2627c9..2b45922fd7 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,8 +5,9 @@ ### CLI * Moved file-based OAuth token cache management from the SDK to the CLI. No user-visible change; part of a three-PR sequence that makes the CLI the sole owner of its token cache. -* Added experimental OS-native secure token storage behind the `--secure-storage` flag on `databricks auth login` and the `DATABRICKS_AUTH_STORAGE=secure` environment variable. Hidden from help during MS1. Legacy file-backed token storage remains the default. -* Added experimental OS-native secure token storage opt-in via `DATABRICKS_AUTH_STORAGE=secure` or `[__settings__].auth_storage = secure` in `.databrickscfg`. Legacy file-backed token storage remains the default. +* Added interactive pagination for list commands that have a row template (jobs, clusters, apps, pipelines, etc.). When stdin, stdout, and stderr are all TTYs, `databricks list` now streams 50 rows at a time and prompts `[space] more [enter] all [q|esc] quit`. ENTER can be interrupted by `q`/`esc`/`Ctrl+C` between pages. Colors and alignment match the existing non-paged output; column widths stay stable across pages. Piped output and `--output json` are unchanged. +* Added experimental OS-native secure token storage opt-in via `DATABRICKS_AUTH_STORAGE=secure`. Legacy file-backed token storage remains the default. + ### Bundles diff --git a/libs/cmdio/capabilities.go b/libs/cmdio/capabilities.go index 455acebc77..62ac4b6ae9 100644 --- a/libs/cmdio/capabilities.go +++ b/libs/cmdio/capabilities.go @@ -48,6 +48,14 @@ func (c Capabilities) SupportsColor(w io.Writer) bool { return isTTY(w) && c.color } +// SupportsPager returns true when we can drive an interactive pager. +// It builds on SupportsPrompt (stderr+stdin TTY, not Git Bash) and +// additionally requires stdout to be a TTY so rendered rows land on +// the terminal rather than a redirected file. +func (c Capabilities) SupportsPager() bool { + return c.SupportsPrompt() && c.stdoutIsTTY +} + // detectGitBash returns true if running in Git Bash on Windows (has broken promptui support). // We do not allow prompting in Git Bash on Windows. // Likely due to fact that Git Bash does not correctly support ANSI escape sequences, diff --git a/libs/cmdio/paged_template.go b/libs/cmdio/paged_template.go new file mode 100644 index 0000000000..a579fa48af --- /dev/null +++ b/libs/cmdio/paged_template.go @@ -0,0 +1,160 @@ +package cmdio + +import ( + "bytes" + "context" + "io" + "regexp" + "strings" + "text/template" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + "github.com/databricks/databricks-sdk-go/listing" +) + +// ansiCSIPattern matches ANSI SGR escape sequences so colored cells +// aren't counted toward column widths. github.com/fatih/color emits CSI +// ... m, which is all our templates use. +var ansiCSIPattern = regexp.MustCompile("\x1b\\[[0-9;]*m") + +// renderIteratorPagedTemplate pages an iterator through the template +// renderer, prompting between batches. SPACE advances one page, ENTER +// drains the rest, q/esc/Ctrl+C quit. +func renderIteratorPagedTemplate[T any]( + ctx context.Context, + iter listing.Iterator[T], + in io.Reader, + out io.Writer, + headerTemplate, tmpl string, +) error { + return renderIteratorPagedTemplateCore(ctx, iter, in, out, headerTemplate, tmpl, pagerFallbackPageSize) +} + +// templatePager renders accumulated rows, locking column widths from the +// first page so layout stays stable across batches. We do not use +// text/tabwriter because it recomputes widths on every Flush. +type templatePager struct { + headerT *template.Template + rowT *template.Template + headerStr string + widths []int + headerDone bool +} + +// flushLines renders the header (on the first call) plus any buffered +// rows, then pads each cell to the widths recorded on the first page so +// columns line up across batches. +func (p *templatePager) flushLines(buf []any) ([]string, error) { + if p.headerDone && len(buf) == 0 { + return nil, nil + } + var rendered bytes.Buffer + if !p.headerDone && p.headerStr != "" { + if err := p.headerT.Execute(&rendered, nil); err != nil { + return nil, err + } + rendered.WriteByte('\n') + } + if len(buf) > 0 { + if err := p.rowT.Execute(&rendered, buf); err != nil { + return nil, err + } + } + p.headerDone = true + + text := strings.TrimRight(rendered.String(), "\n") + if text == "" { + return nil, nil + } + rows := strings.Split(text, "\n") + if p.widths == nil { + p.widths = computeWidths(rows) + } + lines := make([]string, len(rows)) + for i, row := range rows { + lines[i] = padRow(strings.Split(row, "\t"), p.widths) + } + return lines, nil +} + +func renderIteratorPagedTemplateCore[T any]( + ctx context.Context, + iter listing.Iterator[T], + in io.Reader, + out io.Writer, + headerTemplate, tmpl string, + pageSize int, +) error { + // Header and row templates must be separate *template.Template + // instances: Parse replaces the receiver's body in place, so sharing + // one makes the second Parse stomp the first. + headerT, err := template.New("header").Funcs(renderFuncMap).Parse(headerTemplate) + if err != nil { + return err + } + rowT, err := template.New("row").Funcs(renderFuncMap).Parse(tmpl) + if err != nil { + return err + } + pager := &templatePager{ + headerT: headerT, + rowT: rowT, + headerStr: headerTemplate, + } + m := newPagerModel(ctx, iter, pager, pageSize, limitFromContext(ctx)) + p := tea.NewProgram( + m, + tea.WithInput(in), + tea.WithOutput(out), + // Match spinner: let SIGINT reach the process rather than the TUI + // so Ctrl+C also interrupts a stalled iterator fetch. + tea.WithoutSignalHandler(), + ) + // Unlike cmdio.NewSpinner, the pager doesn't need to acquire/release + // through cmdIO: p.Run is blocking and tea restores the terminal on + // its own before returning, so there's no other tea.Program that could + // race with ours. + if _, err := p.Run(); err != nil { + return err + } + return m.err +} + +// visualWidth counts runes ignoring ANSI SGR escape sequences. +func visualWidth(s string) int { + return utf8.RuneCountInString(ansiCSIPattern.ReplaceAllString(s, "")) +} + +func computeWidths(rows []string) []int { + var widths []int + for _, row := range rows { + for i, cell := range strings.Split(row, "\t") { + if i >= len(widths) { + widths = append(widths, 0) + } + if w := visualWidth(cell); w > widths[i] { + widths[i] = w + } + } + } + return widths +} + +// padRow joins cells with two-space separators matching tabwriter's +// minpad, padding every cell except the last to widths[i] visual runes. +func padRow(cells []string, widths []int) string { + var b strings.Builder + for i, cell := range cells { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(cell) + if i < len(cells)-1 && i < len(widths) { + if pad := widths[i] - visualWidth(cell); pad > 0 { + b.WriteString(strings.Repeat(" ", pad)) + } + } + } + return b.String() +} diff --git a/libs/cmdio/paged_template_test.go b/libs/cmdio/paged_template_test.go new file mode 100644 index 0000000000..24daeb4598 --- /dev/null +++ b/libs/cmdio/paged_template_test.go @@ -0,0 +1,267 @@ +package cmdio + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/listing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type numberIterator struct { + n int + pos int + err error +} + +func (it *numberIterator) HasNext(_ context.Context) bool { + return it.pos < it.n +} + +func (it *numberIterator) Next(_ context.Context) (int, error) { + if it.err != nil { + return 0, it.err + } + it.pos++ + return it.pos, nil +} + +// ansiStripPattern is broader than ansiCSIPattern: tea emits non-SGR +// sequences (cursor moves, erase-line, bracketed-paste toggles) that +// the production width calculation doesn't need to strip. +var ansiStripPattern = regexp.MustCompile("\x1b\\[[?]?[0-9;]*[A-Za-z]") + +func stripANSI(s string) string { + return ansiStripPattern.ReplaceAllString(s, "") +} + +// pagedOutput runs a full paged render, feeding ENTER to auto-drain, +// and returns the ANSI-stripped output. +func pagedOutput( + t *testing.T, + ctx context.Context, + iter listing.Iterator[int], + headerTemplate, tmpl string, + pageSize int, +) string { + t.Helper() + var out bytes.Buffer + require.NoError(t, renderIteratorPagedTemplateCore( + ctx, iter, + strings.NewReader("\r"), + &out, + headerTemplate, tmpl, pageSize, + )) + return stripANSI(out.String()) +} + +func countContentLines(s string) int { + count := 0 + for line := range strings.SplitSeq(s, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.Contains(trimmed, pagerPromptText) { + continue + } + count++ + } + return count +} + +func TestPagedTemplateDrainsFullIterator(t *testing.T) { + out := pagedOutput(t, t.Context(), &numberIterator{n: 23}, "", "{{range .}}{{.}}\n{{end}}", 5) + assert.Equal(t, 23, countContentLines(out)) + for i := 1; i <= 23; i++ { + assert.Contains(t, out, strconv.Itoa(i)) + } +} + +func TestPagedTemplateRespectsLimit(t *testing.T) { + ctx := WithLimit(t.Context(), 7) + out := pagedOutput(t, ctx, &numberIterator{n: 200}, "", "{{range .}}{{.}}\n{{end}}", 5) + assert.Equal(t, 7, countContentLines(out)) +} + +func TestPagedTemplatePrintsHeaderOnce(t *testing.T) { + out := pagedOutput(t, t.Context(), &numberIterator{n: 8}, "ID", "{{range .}}{{.}}\n{{end}}", 3) + assert.Equal(t, 1, strings.Count(out, "ID")) +} + +func TestPagedTemplatePropagatesFetchError(t *testing.T) { + var buf bytes.Buffer + err := renderIteratorPagedTemplateCore( + t.Context(), + &numberIterator{n: 100, err: errors.New("boom")}, + strings.NewReader(""), + &buf, + "", + "{{range .}}{{.}}\n{{end}}", + 5, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +func TestPagedTemplateRendersHeaderAndRows(t *testing.T) { + out := pagedOutput(t, t.Context(), &numberIterator{n: 6}, "ID\tName", "{{range .}}{{.}}\titem-{{.}}\n{{end}}", 100) + assert.Contains(t, out, "ID") + assert.Contains(t, out, "Name") + for i := 1; i <= 6; i++ { + assert.Contains(t, out, fmt.Sprintf("item-%d", i)) + } + assert.Equal(t, 1, strings.Count(out, "ID")) +} + +func TestPagedTemplateEmptyIteratorStillFlushesHeader(t *testing.T) { + pr, pw := io.Pipe() + defer pw.Close() + var out bytes.Buffer + require.NoError(t, renderIteratorPagedTemplateCore( + t.Context(), + &numberIterator{n: 0}, + pr, + &out, + "ID\tName", + "{{range .}}{{.}}\n{{end}}", + 10, + )) + stripped := stripANSI(out.String()) + assert.Contains(t, stripped, "ID") + assert.Contains(t, stripped, "Name") +} + +func TestPagedTemplateColumnsStableAcrossBatches(t *testing.T) { + it := &numberIterator{n: 6} + tmpl := "{{range .}}col-{{.}}\tval\n{{end}}" + out := pagedOutput(t, t.Context(), it, "", tmpl, 3) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + var dataRows []string + for _, l := range lines { + if strings.Contains(l, "col-") { + dataRows = append(dataRows, l) + } + } + require.Len(t, dataRows, 6) + // Gap before "val" is the locked column width plus tabwriter minpad. + for _, row := range dataRows { + idx := strings.Index(row, "val") + require.Positive(t, idx) + assert.GreaterOrEqual(t, idx, len("col-N")+2, "row %q should keep minpad gap", row) + } +} + +// TestPagedTemplateMatchesNonPagedForSmallList pins parity with the +// non-paged path so users who never see a second page see the same +// content they used to. +func TestPagedTemplateMatchesNonPagedForSmallList(t *testing.T) { + const rows = 5 + tmpl := "{{range .}}{{green \"%d\" .}}\t{{.}}\n{{end}}" + + var expected bytes.Buffer + refIter := listing.Iterator[int](&numberIterator{n: rows}) + require.NoError(t, renderWithTemplate(t.Context(), newIteratorRenderer(refIter), flags.OutputText, &expected, "", tmpl)) + + pagedIter := listing.Iterator[int](&numberIterator{n: rows}) + var actual bytes.Buffer + pr, pw := io.Pipe() + defer pw.Close() + require.NoError(t, renderIteratorPagedTemplateCore( + t.Context(), + pagedIter, + pr, + &actual, + "", + tmpl, + 100, + )) + + assertSameContentLines(t, expected.String(), stripANSI(actual.String())) +} + +func assertSameContentLines(t *testing.T, want, got string) { + t.Helper() + wantLines := nonEmptyLines(want) + gotLines := nonEmptyLines(got) + require.Equal(t, len(wantLines), len(gotLines), "line count mismatch\nwant:\n%s\ngot:\n%s", want, got) + for i := range wantLines { + assert.Equal(t, wantLines[i], gotLines[i], "line %d", i) + } +} + +func nonEmptyLines(s string) []string { + var out []string + for l := range strings.SplitSeq(s, "\n") { + t := strings.TrimRight(l, " \r\t") + if t == "" { + continue + } + out = append(out, t) + } + return out +} + +func TestVisualWidth(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {"plain ascii", "hello", 5}, + {"empty", "", 0}, + {"green SGR wraps text", "\x1b[32mhello\x1b[0m", 5}, + {"multiple SGR escapes", "\x1b[1;31mfoo\x1b[0m bar", 7}, + {"multibyte runes count as one each", "héllo", 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, visualWidth(tt.in)) + }) + } +} + +func TestComputeWidths(t *testing.T) { + tests := []struct { + name string + rows []string + want []int + }{ + {"empty input", nil, nil}, + {"single row", []string{"a\tbb\tccc"}, []int{1, 2, 3}}, + {"widest wins per column", []string{"a\tbb", "aaa\tb"}, []int{3, 2}}, + {"ragged rows extend column count", []string{"a", "b\tcc"}, []int{1, 2}}, + {"SGR escapes don't inflate widths", []string{"\x1b[31mred\x1b[0m\tplain"}, []int{3, 5}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, computeWidths(tt.rows)) + }) + } +} + +func TestPadRow(t *testing.T) { + tests := []struct { + name string + cells []string + widths []int + want string + }{ + {"single cell is emitted as-is", []string{"only"}, []int{10}, "only"}, + {"pads every cell except the last", []string{"a", "bb", "c"}, []int{3, 3, 3}, "a bb c"}, + {"overflowing cell pushes next column right", []string{"toolong", "b"}, []int{3, 3}, "toolong b"}, + {"no widths means no padding", []string{"a", "b"}, nil, "a b"}, + {"SGR escape doesn't count toward pad", []string{"\x1b[31mred\x1b[0m", "b"}, []int{5, 1}, "\x1b[31mred\x1b[0m b"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, padRow(tt.cells, tt.widths)) + }) + } +} diff --git a/libs/cmdio/pager.go b/libs/cmdio/pager.go new file mode 100644 index 0000000000..6070e2f2ff --- /dev/null +++ b/libs/cmdio/pager.go @@ -0,0 +1,227 @@ +package cmdio + +import ( + "context" + "strings" + "time" + + bubblespinner "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/databricks/databricks-sdk-go/listing" +) + +// pagerFallbackPageSize is used before the first WindowSizeMsg arrives, +// and when the terminal height is too small to size a page by itself. +const pagerFallbackPageSize = 50 + +// pagerMinPageSize is the floor: one line of header plus a few rows so +// the prompt still has something to sit under. +const pagerMinPageSize = 5 + +// pagerViewOverhead is the number of lines we keep below the printed +// rows for the prompt (or spinner). +const pagerViewOverhead = 1 + +// pagerPromptText is shown between pages. +const pagerPromptText = "[space] more [enter] all [q|esc] quit" + +// pagerLoadingText is appended to the spinner while a fetch is in flight. +const pagerLoadingText = "loading..." + +// pagerModel is the tea.Model that drives the paged render loop: one +// fetch produces a batchMsg, Update prints it via tea.Println, and +// View shows the prompt between pages. +type pagerModel[T any] struct { + iter listing.Iterator[T] + pager *templatePager + spinner bubblespinner.Model + // fetch is bound at construction with the caller's context captured + // so we don't have to stash ctx on the struct (tea.Cmd has no ctx + // parameter of its own). + fetch func() tea.Msg + pageSize int + limit int + total int + + // Keep only one fetch in flight at a time: the iterator is not safe + // to read from two goroutines. If SPACE or ENTER arrives while + // fetching, drainAll is recorded and the pending batchMsg chains + // the next fetch. + fetching bool + drainAll bool + hasPrinted bool + iterDone bool + err error +} + +// newPagerModel wires ctx into the fetch closure so nothing on the +// struct has to hold onto a context. +func newPagerModel[T any]( + ctx context.Context, + iter listing.Iterator[T], + pager *templatePager, + pageSize, limit int, +) *pagerModel[T] { + m := &pagerModel[T]{ + iter: iter, + pager: pager, + spinner: newPagerSpinner(), + pageSize: pageSize, + limit: limit, + } + m.fetch = func() tea.Msg { return m.doFetch(ctx) } + return m +} + +// newPagerSpinner builds a spinner matching the one the cmdio package's +// NewSpinner uses, so interactive feedback looks the same everywhere. +func newPagerSpinner() bubblespinner.Model { + s := bubblespinner.New() + s.Spinner = bubblespinner.Spinner{ + Frames: []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}, + FPS: time.Second / 5, + } + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + return s +} + +// batchMsg carries the rendered lines from one fetch. done is true when +// the iterator is exhausted or the limit is reached. +type batchMsg struct { + lines []string + done bool + err error +} + +func (m *pagerModel[T]) Init() tea.Cmd { + m.fetching = true + return tea.Batch(m.fetch, m.spinner.Tick) +} + +// doFetch reads one page from the iterator and renders it into lines. +// It runs off the update loop so a slow network fetch doesn't stall +// key handling. +func (m *pagerModel[T]) doFetch(ctx context.Context) tea.Msg { + buf := make([]any, 0, m.pageSize) + done := false + for len(buf) < m.pageSize { + if m.limit > 0 && m.total+len(buf) >= m.limit { + done = true + break + } + if !m.iter.HasNext(ctx) { + done = true + break + } + n, err := m.iter.Next(ctx) + if err != nil { + return batchMsg{err: err} + } + buf = append(buf, n) + } + lines, err := m.pager.flushLines(buf) + if err != nil { + return batchMsg{err: err} + } + m.total += len(buf) + return batchMsg{lines: lines, done: done} +} + +func (m *pagerModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.pageSize = max(msg.Height-pagerViewOverhead, pagerMinPageSize) + return m, nil + + case bubblespinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case batchMsg: + m.fetching = false + if msg.err != nil { + m.err = msg.err + return m, tea.Quit + } + m.hasPrinted = true + // One Println cmd (not N) keeps the batch ordered even though + // tea.Sequence dispatches each cmd on its own goroutine. + var printCmd tea.Cmd + if len(msg.lines) > 0 { + printCmd = tea.Println(strings.Join(msg.lines, "\n")) + } + switch { + case msg.done: + m.iterDone = true + return m, tea.Sequence(printCmd, tea.Quit) + case m.drainAll: + m.fetching = true + return m, tea.Sequence(printCmd, m.fetch) + default: + return m, printCmd + } + + case tea.KeyMsg: + if m.iterDone { + return m, nil + } + return m.handleKey(msg) + } + return m, nil +} + +func (m *pagerModel[T]) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { //nolint:exhaustive // the pager only cares about a few keys + case tea.KeyEnter: + return m, m.startDrain() + case tea.KeyEsc, tea.KeyCtrlC: + return m, tea.Quit + case tea.KeySpace: + return m, m.startAdvance() + case tea.KeyRunes: + switch msg.String() { + case " ": + return m, m.startAdvance() + case "q", "Q": + return m, tea.Quit + } + } + return m, nil +} + +func (m *pagerModel[T]) startAdvance() tea.Cmd { + if m.drainAll || m.fetching { + return nil + } + m.fetching = true + return m.fetch +} + +func (m *pagerModel[T]) startDrain() tea.Cmd { + if m.drainAll { + return nil + } + m.drainAll = true + // If a fetch is already in flight, its batchMsg will see drainAll + // and chain the next fetch. Otherwise kick one off here. + if m.fetching { + return nil + } + m.fetching = true + return m.fetch +} + +func (m *pagerModel[T]) View() string { + switch { + case m.iterDone || m.err != nil: + return "" + case m.fetching: + return m.spinner.View() + " " + pagerLoadingText + case m.drainAll || !m.hasPrinted: + return "" + default: + return pagerPromptText + } +} diff --git a/libs/cmdio/pager_test.go b/libs/cmdio/pager_test.go new file mode 100644 index 0000000000..0153d5eac0 --- /dev/null +++ b/libs/cmdio/pager_test.go @@ -0,0 +1,253 @@ +package cmdio + +import ( + "errors" + "reflect" + "testing" + "text/template" + + tea "github.com/charmbracelet/bubbletea" + "github.com/databricks/databricks-sdk-go/listing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestPager(t *testing.T, iter listing.Iterator[int], pageSize int) *pagerModel[int] { + t.Helper() + rowT, err := template.New("row").Funcs(renderFuncMap).Parse("{{range .}}{{.}}\n{{end}}") + require.NoError(t, err) + headerT, err := template.New("header").Funcs(renderFuncMap).Parse("") + require.NoError(t, err) + return newPagerModel(t.Context(), iter, &templatePager{ + headerT: headerT, + rowT: rowT, + }, pageSize, 0) +} + +func runCmd(t *testing.T, cmd tea.Cmd) tea.Msg { + t.Helper() + require.NotNil(t, cmd) + return cmd() +} + +// unwrapCmds pulls the cmds out of a tea.Batch/tea.Sequence result. +// sequenceMsg is unexported, so we fall back to reflect on the []tea.Cmd +// underlying type — update if bubbletea renames it. +func unwrapCmds(t *testing.T, msg tea.Msg) []tea.Cmd { + t.Helper() + if bm, ok := msg.(tea.BatchMsg); ok { + return []tea.Cmd(bm) + } + rv := reflect.ValueOf(msg) + require.Equal(t, reflect.Slice, rv.Kind(), "expected a slice-of-cmds msg, got %T", msg) + cmds := make([]tea.Cmd, rv.Len()) + for i := range cmds { + c, ok := rv.Index(i).Interface().(tea.Cmd) + require.True(t, ok, "slice element %d is not a tea.Cmd", i) + cmds[i] = c + } + return cmds +} + +// printedText pulls the body out of a tea.Println result. +// printLineMessage is unexported, so reflect on its only string field. +func printedText(t *testing.T, msg tea.Msg) string { + t.Helper() + rv := reflect.ValueOf(msg) + require.Equal(t, reflect.Struct, rv.Kind(), "expected a struct msg, got %T", msg) + for i := range rv.NumField() { + if rv.Field(i).Kind() == reflect.String { + return rv.Field(i).String() + } + } + t.Fatalf("no string field in %T", msg) + return "" +} + +func TestPagerModelInitFetchesFirstBatch(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 3}, 10) + // Init returns a tea.Batch(m.fetch, spinner.Tick); find the fetch. + var b batchMsg + for _, c := range unwrapCmds(t, runCmd(t, m.Init())) { + if msg, ok := c().(batchMsg); ok { + b = msg + break + } + } + assert.True(t, b.done, "small iterator is drained in one batch") + assert.Len(t, b.lines, 3) + assert.True(t, m.fetching, "Init must mark the model as fetching") +} + +func TestPagerModelBatchPrintsAndQuitsWhenDone(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 3}, 10) + _, cmd := m.Update(batchMsg{lines: []string{"1", "2", "3"}, done: true}) + assert.True(t, m.iterDone) + assert.True(t, m.hasPrinted) + cmds := unwrapCmds(t, runCmd(t, cmd)) + require.Len(t, cmds, 2) + assert.Contains(t, printedText(t, runCmd(t, cmds[0])), "1\n2\n3") + _, isQuit := runCmd(t, cmds[1]).(tea.QuitMsg) + assert.True(t, isQuit, "final cmd must quit once the iterator is drained") +} + +func TestPagerModelBatchDonePrintsHeaderOnlyEmptyIter(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 0}, 10) + _, cmd := m.Update(batchMsg{lines: []string{"HEADER"}, done: true}) + cmds := unwrapCmds(t, runCmd(t, cmd)) + require.Len(t, cmds, 2) + assert.Equal(t, "HEADER", printedText(t, runCmd(t, cmds[0]))) +} + +func TestPagerModelBatchPromptsWhenMore(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 5) + _, cmd := m.Update(batchMsg{lines: []string{"1", "2"}, done: false}) + assert.False(t, m.iterDone) + assert.True(t, m.hasPrinted) + assert.False(t, m.drainAll) + assert.Equal(t, pagerPromptText, m.View()) + assert.Contains(t, printedText(t, runCmd(t, cmd)), "1\n2") +} + +func TestPagerModelBatchDrainingChainsFetch(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.drainAll = true + _, cmd := m.Update(batchMsg{lines: []string{"1", "2"}, done: false}) + cmds := unwrapCmds(t, runCmd(t, cmd)) + require.Len(t, cmds, 2) + assert.Contains(t, printedText(t, runCmd(t, cmds[0])), "1\n2") + _, isFetch := runCmd(t, cmds[1]).(batchMsg) + assert.True(t, isFetch, "draining must auto-fetch the next batch") +} + +func TestPagerModelBatchErrorTerminates(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 0}, 5) + _, cmd := m.Update(batchMsg{err: errors.New("boom")}) + assert.EqualError(t, m.err, "boom") + _, isQuit := runCmd(t, cmd).(tea.QuitMsg) + assert.True(t, isQuit) +} + +func TestPagerModelSpaceFetchesNext(t *testing.T) { + cases := []struct { + name string + key tea.KeyMsg + }{ + {"KeySpace", tea.KeyMsg{Type: tea.KeySpace}}, + {"KeyRunes space", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.hasPrinted = true + _, cmd := m.Update(tc.key) + _, ok := runCmd(t, cmd).(batchMsg) + assert.True(t, ok, "space should fire a fetch") + }) + } +} + +func TestPagerModelEnterSetsDrainAll(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.hasPrinted = true + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + assert.True(t, m.drainAll) + assert.NotContains(t, m.View(), pagerPromptText, "no prompt while draining") + _, ok := runCmd(t, cmd).(batchMsg) + assert.True(t, ok, "enter should fire a fetch") +} + +func TestPagerModelEnterIsNoOpWhileAlreadyDraining(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.hasPrinted = true + m.drainAll = true + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + assert.Nil(t, cmd, "re-entering drain shouldn't stack another fetch") +} + +func TestPagerModelSpaceIgnoredDuringFetch(t *testing.T) { + // Between Init and the first batchMsg, SPACE must not schedule a second + // fetch: doing so would run the iterator from two goroutines at once. + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.fetching = true + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace}) + assert.Nil(t, cmd, "SPACE while fetching must not dispatch another fetch") +} + +func TestPagerModelEnterDuringFetchDefersFetch(t *testing.T) { + // ENTER during an in-flight fetch flips drainAll but can't issue a new + // fetch; the pending batchMsg will chain one when it arrives. + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.fetching = true + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + assert.True(t, m.drainAll) + assert.Nil(t, cmd, "ENTER during fetch must defer to batchMsg chaining") +} + +func TestPagerModelQuitKeys(t *testing.T) { + cases := []struct { + name string + key tea.KeyMsg + }{ + {"q", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}}, + {"Q", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Q'}}}, + {"esc", tea.KeyMsg{Type: tea.KeyEsc}}, + {"ctrl+c", tea.KeyMsg{Type: tea.KeyCtrlC}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.hasPrinted = true + _, cmd := m.Update(tc.key) + _, ok := runCmd(t, cmd).(tea.QuitMsg) + assert.True(t, ok) + }) + } +} + +func TestPagerModelQuitKeysInterruptDrain(t *testing.T) { + for _, key := range []tea.KeyMsg{ + {Type: tea.KeyRunes, Runes: []rune{'q'}}, + {Type: tea.KeyEsc}, + {Type: tea.KeyCtrlC}, + } { + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.hasPrinted = true + m.drainAll = true + _, cmd := m.Update(key) + _, ok := runCmd(t, cmd).(tea.QuitMsg) + assert.True(t, ok, "quit keys must interrupt a drain") + } +} + +func TestPagerModelIgnoresKeysAfterDone(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 0}, 5) + m.iterDone = true + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + assert.Nil(t, cmd, "keys after completion should be no-ops") +} + +func TestPagerModelViewHiddenUntilFirstBatch(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 10}, 5) + assert.Empty(t, m.View(), "prompt must not flash before any output is rendered") +} + +func TestPagerModelViewShowsSpinnerWhileFetching(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 5) + m.fetching = true + assert.Contains(t, m.View(), pagerLoadingText) + assert.NotContains(t, m.View(), pagerPromptText) +} + +func TestPagerModelWindowSizeResizesPage(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 50) + _, cmd := m.Update(tea.WindowSizeMsg{Height: 30, Width: 120}) + assert.Nil(t, cmd, "resize should not itself dispatch a command") + assert.Equal(t, 30-pagerViewOverhead, m.pageSize) +} + +func TestPagerModelWindowSizeFloorsAtMin(t *testing.T) { + m := newTestPager(t, &numberIterator{n: 100}, 50) + _, _ = m.Update(tea.WindowSizeMsg{Height: 3, Width: 80}) + assert.Equal(t, pagerMinPageSize, m.pageSize) +} diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 83dae00f39..8fb006191a 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -273,8 +273,17 @@ func Render(ctx context.Context, v any) error { return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template) } +// RenderIterator renders the items produced by i. When the terminal is +// fully interactive (stdin + stdout + stderr all TTYs) and the command +// has a row template, we page through the existing template + tabwriter +// pipeline (same colors, same alignment as the non-paged path; widths are +// locked from the first batch so columns stay aligned across pages). +// Piped output and JSON output keep the existing non-paged behavior. func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error { c := fromContext(ctx) + if c.capabilities.SupportsPager() && c.outputFormat == flags.OutputText && c.template != "" { + return renderIteratorPagedTemplate(ctx, i, c.in, c.out, c.headerTemplate, c.template) + } return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template) }