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/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go new file mode 100644 index 0000000000..ed5fdaa8db --- /dev/null +++ b/cmd/workspace/alerts/overrides.go @@ -0,0 +1,32 @@ +package alerts + +import ( + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/sql" + "github.com/spf13/cobra" +) + +func listOverride(listCmd *cobra.Command, listReq *sql.ListAlertsRequest) { + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .Id}} {{.DisplayName}} {{.State}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "ID", Extract: func(v any) string { + return v.(sql.ListAlertsResponseAlert).Id + }}, + {Header: "Name", Extract: func(v any) string { + return v.(sql.ListAlertsResponseAlert).DisplayName + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(sql.ListAlertsResponseAlert).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) +} + +func init() { + listOverrides = append(listOverrides, listOverride) +} diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index 6a909a943e..ec6a25b803 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -5,6 +5,7 @@ import ( appsCli "github.com/databricks/cli/cmd/apps" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/apps" "github.com/spf13/cobra" ) @@ -15,6 +16,33 @@ func listOverride(listCmd *cobra.Command, listReq *apps.ListAppsRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Name | green}} {{.Url}} {{if .ComputeStatus}}{{if eq .ComputeStatus.State "ACTIVE"}}{{green "%s" .ComputeStatus.State }}{{else}}{{blue "%s" .ComputeStatus.State}}{{end}}{{end}} {{if .ActiveDeployment}}{{if eq .ActiveDeployment.Status.State "SUCCEEDED"}}{{green "%s" .ActiveDeployment.Status.State }}{{else}}{{blue "%s" .ActiveDeployment.Status.State}}{{end}}{{end}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + a := v.(apps.App) + return a.Name + }}, + {Header: "URL", Extract: func(v any) string { + a := v.(apps.App) + return a.Url + }}, + {Header: "Compute Status", Extract: func(v any) string { + a := v.(apps.App) + if a.ComputeStatus != nil { + return string(a.ComputeStatus.State) + } + return "" + }}, + {Header: "Deploy Status", Extract: func(v any) string { + a := v.(apps.App) + if a.ActiveDeployment != nil && a.ActiveDeployment.Status != nil { + return string(a.ActiveDeployment.Status.State) + } + return "" + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, listDeploymentsReq *apps.ListAppDeploymentsRequest) { diff --git a/cmd/workspace/apps/overrides_test.go b/cmd/workspace/apps/overrides_test.go new file mode 100644 index 0000000000..c2d374f38b --- /dev/null +++ b/cmd/workspace/apps/overrides_test.go @@ -0,0 +1,68 @@ +package apps + +import ( + "testing" + + "github.com/databricks/cli/libs/tableview" + sdkapps "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListTableConfig(t *testing.T) { + cmd := newList() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.Len(t, cfg.Columns, 4) + + tests := []struct { + name string + app sdkapps.App + wantName string + wantURL string + wantCompute string + wantDeploy string + }{ + { + name: "with nested fields", + app: sdkapps.App{ + Name: "test-app", + Url: "https://example.com", + ComputeStatus: &sdkapps.ComputeStatus{ + State: sdkapps.ComputeStateActive, + }, + ActiveDeployment: &sdkapps.AppDeployment{ + Status: &sdkapps.AppDeploymentStatus{ + State: sdkapps.AppDeploymentStateSucceeded, + }, + }, + }, + wantName: "test-app", + wantURL: "https://example.com", + wantCompute: "ACTIVE", + wantDeploy: "SUCCEEDED", + }, + { + name: "nil nested fields", + app: sdkapps.App{ + Name: "test-app", + Url: "https://example.com", + ActiveDeployment: &sdkapps.AppDeployment{}, + }, + wantName: "test-app", + wantURL: "https://example.com", + wantCompute: "", + wantDeploy: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, cfg.Columns[0].Extract(tt.app)) + assert.Equal(t, tt.wantURL, cfg.Columns[1].Extract(tt.app)) + assert.Equal(t, tt.wantCompute, cfg.Columns[2].Extract(tt.app)) + assert.Equal(t, tt.wantDeploy, cfg.Columns[3].Extract(tt.app)) + }) + } +} diff --git a/cmd/workspace/catalogs/overrides.go b/cmd/workspace/catalogs/overrides.go index e2201dc152..46d66a08b2 100644 --- a/cmd/workspace/catalogs/overrides.go +++ b/cmd/workspace/catalogs/overrides.go @@ -2,6 +2,7 @@ package catalogs import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/spf13/cobra" ) @@ -12,6 +13,20 @@ func listOverride(listCmd *cobra.Command, listReq *catalog.ListCatalogsRequest) listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Name|green}} {{blue "%s" .CatalogType}} {{.Comment}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + return v.(catalog.CatalogInfo).Name + }}, + {Header: "Type", Extract: func(v any) string { + return string(v.(catalog.CatalogInfo).CatalogType) + }}, + {Header: "Comment", MaxWidth: 40, Extract: func(v any) string { + return v.(catalog.CatalogInfo).Comment + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/clusters/overrides.go b/cmd/workspace/clusters/overrides.go index 6038978ae4..910918b017 100644 --- a/cmd/workspace/clusters/overrides.go +++ b/cmd/workspace/clusters/overrides.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/spf13/cobra" ) @@ -17,6 +18,20 @@ func listOverride(listCmd *cobra.Command, listReq *compute.ListClustersRequest) {{range .}}{{.ClusterId | green}} {{.ClusterName | cyan}} {{if eq .State "RUNNING"}}{{green "%s" .State}}{{else if eq .State "TERMINATED"}}{{red "%s" .State}}{{else}}{{blue "%s" .State}}{{end}} {{end}}`) + columns := []tableview.ColumnDef{ + {Header: "Cluster ID", Extract: func(v any) string { + return v.(compute.ClusterDetails).ClusterId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(compute.ClusterDetails).ClusterName + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(compute.ClusterDetails).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) + listReq.FilterBy = &compute.ListClustersFilterBy{} listCmd.Flags().BoolVar(&listReq.FilterBy.IsPinned, "is-pinned", false, "Filter clusters by pinned status") listCmd.Flags().StringVar(&listReq.FilterBy.PolicyId, "policy-id", "", "Filter clusters by policy id") diff --git a/cmd/workspace/external-locations/overrides.go b/cmd/workspace/external-locations/overrides.go index 00b4921d4d..9d9108f5be 100644 --- a/cmd/workspace/external-locations/overrides.go +++ b/cmd/workspace/external-locations/overrides.go @@ -2,6 +2,7 @@ package external_locations import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/spf13/cobra" ) @@ -12,6 +13,20 @@ func listOverride(listCmd *cobra.Command, listReq *catalog.ListExternalLocations listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Name|green}} {{.CredentialName|cyan}} {{.Url}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + return v.(catalog.ExternalLocationInfo).Name + }}, + {Header: "Credential", Extract: func(v any) string { + return v.(catalog.ExternalLocationInfo).CredentialName + }}, + {Header: "URL", Extract: func(v any) string { + return v.(catalog.ExternalLocationInfo).Url + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/instance-pools/overrides.go b/cmd/workspace/instance-pools/overrides.go index f62f8c5367..ddf6181f15 100644 --- a/cmd/workspace/instance-pools/overrides.go +++ b/cmd/workspace/instance-pools/overrides.go @@ -2,6 +2,8 @@ package instance_pools import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/compute" "github.com/spf13/cobra" ) @@ -9,6 +11,23 @@ func listOverride(listCmd *cobra.Command) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.InstancePoolId|green}} {{.InstancePoolName}} {{.NodeTypeId}} {{.State}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Pool ID", Extract: func(v any) string { + return v.(compute.InstancePoolAndStats).InstancePoolId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(compute.InstancePoolAndStats).InstancePoolName + }}, + {Header: "Node Type", Extract: func(v any) string { + return v.(compute.InstancePoolAndStats).NodeTypeId + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(compute.InstancePoolAndStats).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/jobs/overrides.go b/cmd/workspace/jobs/overrides.go index ee7d205517..d9786601ba 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -1,7 +1,12 @@ package jobs import ( + "context" + "strconv" + + "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/spf13/cobra" ) @@ -10,6 +15,33 @@ func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .JobId}} {{.Settings.Name}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Job ID", Extract: func(v any) string { + return strconv.FormatInt(v.(jobs.BaseJob).JobId, 10) + }}, + {Header: "Name", Extract: func(v any) string { + if v.(jobs.BaseJob).Settings != nil { + return v.(jobs.BaseJob).Settings.Name + } + return "" + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{ + Columns: columns, + Search: &tableview.SearchConfig{ + Placeholder: "Search by exact name...", + NewIterator: func(ctx context.Context, query string) tableview.RowIterator { + req := *listReq + req.Name = query + req.PageToken = "" + req.Offset = 0 + w := cmdctx.WorkspaceClient(ctx) + return tableview.WrapIterator(w.Jobs.List(ctx, req), columns) + }, + }, + }) } func listRunsOverride(listRunsCmd *cobra.Command, listRunsReq *jobs.ListRunsRequest) { diff --git a/cmd/workspace/jobs/overrides_test.go b/cmd/workspace/jobs/overrides_test.go new file mode 100644 index 0000000000..66bcb5da27 --- /dev/null +++ b/cmd/workspace/jobs/overrides_test.go @@ -0,0 +1,50 @@ +package jobs + +import ( + "testing" + + "github.com/databricks/cli/libs/tableview" + sdkjobs "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListTableConfig(t *testing.T) { + cmd := newList() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.Len(t, cfg.Columns, 2) + + tests := []struct { + name string + job sdkjobs.BaseJob + wantID string + wantName string + }{ + { + name: "with settings", + job: sdkjobs.BaseJob{ + JobId: 123, + Settings: &sdkjobs.JobSettings{Name: "test-job"}, + }, + wantID: "123", + wantName: "test-job", + }, + { + name: "nil settings", + job: sdkjobs.BaseJob{ + JobId: 456, + }, + wantID: "456", + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantID, cfg.Columns[0].Extract(tt.job)) + assert.Equal(t, tt.wantName, cfg.Columns[1].Extract(tt.job)) + }) + } +} diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 08c36deabe..361c834bfa 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -1,15 +1,70 @@ package pipelines import ( + "context" + "fmt" "regexp" "slices" + "strings" pipelinesCli "github.com/databricks/cli/cmd/pipelines" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/pipelines" "github.com/spf13/cobra" ) +func listPipelinesOverride(listCmd *cobra.Command, listReq *pipelines.ListPipelinesRequest) { + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .PipelineId}} {{.Name}} {{blue "%s" .State}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Pipeline ID", Extract: func(v any) string { + return v.(pipelines.PipelineStateInfo).PipelineId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(pipelines.PipelineStateInfo).Name + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(pipelines.PipelineStateInfo).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{ + Columns: columns, + Search: &tableview.SearchConfig{ + Placeholder: "Filter by name...", + NewIterator: func(ctx context.Context, query string) tableview.RowIterator { + req := *listReq + req.PageToken = "" + escaped := strings.ReplaceAll(query, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, "'", "''") + escaped = strings.ReplaceAll(escaped, "%", `\%`) + escaped = strings.ReplaceAll(escaped, "_", `\_`) + req.Filter = fmt.Sprintf("name LIKE '%%%s%%'", escaped) + w := cmdctx.WorkspaceClient(ctx) + return tableview.WrapIterator(w.Pipelines.ListPipelines(ctx, req), columns) + }, + }, + }) + + // The pipelines API does not support composite filters, so disable + // TUI search when the user passes --filter on the command line. + origPreRunE := listCmd.PreRunE + listCmd.PreRunE = func(cmd *cobra.Command, args []string) error { + disableSearchIfFilterSet(cmd) + if origPreRunE != nil { + return origPreRunE(cmd, args) + } + return nil + } +} + func init() { + listPipelinesOverrides = append(listPipelinesOverrides, listPipelinesOverride) + cmdOverrides = append(cmdOverrides, func(cli *cobra.Command) { // all auto-generated commands apart from nonManagementCommands go into 'management' group nonManagementCommands := []string{ @@ -71,6 +126,15 @@ With a PIPELINE_ID: Stops the pipeline identified by the UUID using the API.` }) } +// disableSearchIfFilterSet clears the TUI search config when --filter is active. +func disableSearchIfFilterSet(cmd *cobra.Command) { + if cmd.Flags().Changed("filter") { + if cfg := tableview.GetConfig(cmd); cfg != nil { + cfg.Search = nil + } + } +} + var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // looksLikeUUID checks if a string matches the UUID format with lowercase hex digits diff --git a/cmd/workspace/pipelines/overrides_test.go b/cmd/workspace/pipelines/overrides_test.go index 2e70cf4845..27f3c26588 100644 --- a/cmd/workspace/pipelines/overrides_test.go +++ b/cmd/workspace/pipelines/overrides_test.go @@ -3,7 +3,13 @@ package pipelines import ( "testing" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/experimental/mocks" + sdkpipelines "github.com/databricks/databricks-sdk-go/service/pipelines" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) func TestLooksLikeUUID(t *testing.T) { @@ -13,3 +19,67 @@ func TestLooksLikeUUID(t *testing.T) { func TestLooksLikeUUID_resourceName(t *testing.T) { assert.False(t, looksLikeUUID("my-pipeline-key")) } + +func TestListPipelinesTableConfig(t *testing.T) { + cmd := newListPipelines() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.Len(t, cfg.Columns, 3) + require.NotNil(t, cfg.Search) + + pipeline := sdkpipelines.PipelineStateInfo{ + PipelineId: "pipeline-id", + Name: "pipeline-name", + State: sdkpipelines.PipelineStateIdle, + } + + assert.Equal(t, "pipeline-id", cfg.Columns[0].Extract(pipeline)) + assert.Equal(t, "pipeline-name", cfg.Columns[1].Extract(pipeline)) + assert.Equal(t, "IDLE", cfg.Columns[2].Extract(pipeline)) +} + +func TestListPipelinesSearchEscapesLikeWildcards(t *testing.T) { + cmd := newListPipelines() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Search) + + m := mocks.NewMockWorkspaceClient(t) + m.GetMockPipelinesAPI().EXPECT(). + ListPipelines(mock.Anything, sdkpipelines.ListPipelinesRequest{ + Filter: "name LIKE '%foo''\\%\\_bar%'", + }). + Return(nil) + + ctx := cmdctx.SetWorkspaceClient(t.Context(), m.WorkspaceClient) + assert.NotNil(t, cfg.Search.NewIterator(ctx, "foo'%_bar")) +} + +func TestListPipelinesSearchDisabledWhenFilterSet(t *testing.T) { + cmd := newListPipelines() + + err := cmd.Flags().Set("filter", "state = 'RUNNING'") + require.NoError(t, err) + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Search) + + // The pipelines API does not support composite filters, so the + // PreRunE hook calls disableSearchIfFilterSet to nil out search. + disableSearchIfFilterSet(cmd) + assert.Nil(t, cfg.Search) +} + +func TestListPipelinesSearchNotDisabledWithoutFilter(t *testing.T) { + cmd := newListPipelines() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Search) + + disableSearchIfFilterSet(cmd) + assert.NotNil(t, cfg.Search) +} diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 72f58bb501..85bdb1a6d6 100644 --- a/cmd/workspace/repos/overrides.go +++ b/cmd/workspace/repos/overrides.go @@ -11,6 +11,7 @@ import ( "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/spf13/cobra" @@ -20,6 +21,23 @@ func listOverride(listCmd *cobra.Command, listReq *workspace.ListReposRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .Id}} {{.Path}} {{.Branch|blue}} {{.Url|cyan}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "ID", Extract: func(v any) string { + return strconv.FormatInt(v.(workspace.RepoInfo).Id, 10) + }}, + {Header: "Path", Extract: func(v any) string { + return v.(workspace.RepoInfo).Path + }}, + {Header: "Branch", Extract: func(v any) string { + return v.(workspace.RepoInfo).Branch + }}, + {Header: "URL", Extract: func(v any) string { + return v.(workspace.RepoInfo).Url + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func createOverride(createCmd *cobra.Command, createReq *workspace.CreateRepoRequest) { diff --git a/cmd/workspace/schemas/overrides.go b/cmd/workspace/schemas/overrides.go index ba4c65ce73..625c92f3d7 100644 --- a/cmd/workspace/schemas/overrides.go +++ b/cmd/workspace/schemas/overrides.go @@ -2,6 +2,7 @@ package schemas import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/spf13/cobra" ) @@ -12,6 +13,20 @@ func listOverride(listCmd *cobra.Command, listReq *catalog.ListSchemasRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.FullName|green}} {{.Owner|cyan}} {{.Comment}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Full Name", Extract: func(v any) string { + return v.(catalog.SchemaInfo).FullName + }}, + {Header: "Owner", Extract: func(v any) string { + return v.(catalog.SchemaInfo).Owner + }}, + {Header: "Comment", MaxWidth: 40, Extract: func(v any) string { + return v.(catalog.SchemaInfo).Comment + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/serving-endpoints/overrides.go b/cmd/workspace/serving-endpoints/overrides.go new file mode 100644 index 0000000000..611428f18d --- /dev/null +++ b/cmd/workspace/serving-endpoints/overrides.go @@ -0,0 +1,35 @@ +package serving_endpoints + +import ( + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/serving" + "github.com/spf13/cobra" +) + +func listOverride(listCmd *cobra.Command) { + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .Name}} {{if .State}}{{.State.Ready}}{{end}} {{.Creator}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + return v.(serving.ServingEndpoint).Name + }}, + {Header: "State", Extract: func(v any) string { + if v.(serving.ServingEndpoint).State != nil { + return string(v.(serving.ServingEndpoint).State.Ready) + } + return "" + }}, + {Header: "Creator", Extract: func(v any) string { + return v.(serving.ServingEndpoint).Creator + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) +} + +func init() { + listOverrides = append(listOverrides, listOverride) +} diff --git a/cmd/workspace/serving-endpoints/overrides_test.go b/cmd/workspace/serving-endpoints/overrides_test.go new file mode 100644 index 0000000000..1ab6f39dad --- /dev/null +++ b/cmd/workspace/serving-endpoints/overrides_test.go @@ -0,0 +1,58 @@ +package serving_endpoints + +import ( + "testing" + + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/serving" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListTableConfig(t *testing.T) { + cmd := newList() + + cfg := tableview.GetConfig(cmd) + require.NotNil(t, cfg) + require.Len(t, cfg.Columns, 3) + + tests := []struct { + name string + endpoint serving.ServingEndpoint + wantName string + wantState string + wantCreator string + }{ + { + name: "with state", + endpoint: serving.ServingEndpoint{ + Name: "endpoint", + Creator: "user@example.com", + State: &serving.EndpointState{ + Ready: serving.EndpointStateReadyReady, + }, + }, + wantName: "endpoint", + wantState: "READY", + wantCreator: "user@example.com", + }, + { + name: "nil state", + endpoint: serving.ServingEndpoint{ + Name: "endpoint", + Creator: "user@example.com", + }, + wantName: "endpoint", + wantState: "", + wantCreator: "user@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, cfg.Columns[0].Extract(tt.endpoint)) + assert.Equal(t, tt.wantState, cfg.Columns[1].Extract(tt.endpoint)) + assert.Equal(t, tt.wantCreator, cfg.Columns[2].Extract(tt.endpoint)) + }) + } +} diff --git a/cmd/workspace/tables/overrides.go b/cmd/workspace/tables/overrides.go index a0849ada7f..157d62daf9 100644 --- a/cmd/workspace/tables/overrides.go +++ b/cmd/workspace/tables/overrides.go @@ -2,6 +2,7 @@ package tables import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/spf13/cobra" ) @@ -12,6 +13,17 @@ func listOverride(listCmd *cobra.Command, listReq *catalog.ListTablesRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.FullName|green}} {{blue "%s" .TableType}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Full Name", Extract: func(v any) string { + return v.(catalog.TableInfo).FullName + }}, + {Header: "Table Type", Extract: func(v any) string { + return string(v.(catalog.TableInfo).TableType) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/volumes/overrides.go b/cmd/workspace/volumes/overrides.go new file mode 100644 index 0000000000..0a4f645de3 --- /dev/null +++ b/cmd/workspace/volumes/overrides.go @@ -0,0 +1,32 @@ +package volumes + +import ( + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" + "github.com/databricks/databricks-sdk-go/service/catalog" + "github.com/spf13/cobra" +) + +func listOverride(listCmd *cobra.Command, listReq *catalog.ListVolumesRequest) { + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .Name}} {{.VolumeType}} {{.FullName}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Name", Extract: func(v any) string { + return v.(catalog.VolumeInfo).Name + }}, + {Header: "Volume Type", Extract: func(v any) string { + return string(v.(catalog.VolumeInfo).VolumeType) + }}, + {Header: "Full Name", Extract: func(v any) string { + return v.(catalog.VolumeInfo).FullName + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) +} + +func init() { + listOverrides = append(listOverrides, listOverride) +} diff --git a/cmd/workspace/warehouses/overrides.go b/cmd/workspace/warehouses/overrides.go index 9457557d00..edc58ad681 100644 --- a/cmd/workspace/warehouses/overrides.go +++ b/cmd/workspace/warehouses/overrides.go @@ -2,6 +2,7 @@ package warehouses import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/sql" "github.com/spf13/cobra" ) @@ -12,6 +13,23 @@ func listOverride(listCmd *cobra.Command, listReq *sql.ListWarehousesRequest) { listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Id|green}} {{.Name|cyan}} {{.ClusterSize|cyan}} {{if eq .State "RUNNING"}}{{"RUNNING"|green}}{{else if eq .State "STOPPED"}}{{"STOPPED"|red}}{{else}}{{blue "%s" .State}}{{end}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "ID", Extract: func(v any) string { + return v.(sql.EndpointInfo).Id + }}, + {Header: "Name", Extract: func(v any) string { + return v.(sql.EndpointInfo).Name + }}, + {Header: "Size", Extract: func(v any) string { + return v.(sql.EndpointInfo).ClusterSize + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(sql.EndpointInfo).State) + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/workspace/overrides.go b/cmd/workspace/workspace/overrides.go index c57209b554..56e2e74f71 100644 --- a/cmd/workspace/workspace/overrides.go +++ b/cmd/workspace/workspace/overrides.go @@ -6,10 +6,12 @@ import ( "fmt" "net/http" "os" + "strconv" "strings" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/spf13/cobra" @@ -22,6 +24,23 @@ func listOverride(listCmd *cobra.Command, listReq *workspace.ListWorkspaceReques listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{green "%d" .ObjectId}} {{blue "%s" .ObjectType}} {{cyan "%s" .Language}} {{.Path|cyan}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "ID", Extract: func(v any) string { + return strconv.FormatInt(v.(workspace.ObjectInfo).ObjectId, 10) + }}, + {Header: "Type", Extract: func(v any) string { + return string(v.(workspace.ObjectInfo).ObjectType) + }}, + {Header: "Language", Extract: func(v any) string { + return string(v.(workspace.ObjectInfo).Language) + }}, + {Header: "Path", Extract: func(v any) string { + return v.(workspace.ObjectInfo).Path + }}, + } + + tableview.RegisterConfig(listCmd, tableview.TableConfig{Columns: columns}) } func exportOverride(exportCmd *cobra.Command, exportReq *workspace.ExportRequest) { diff --git a/experimental/aitools/cmd/render.go b/experimental/aitools/cmd/render.go index 4c49a86029..507d7b5fb0 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..f4289dbfd7 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,32 @@ 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() + finalModel, err := p.Run() + if err != nil { + return err + } + if pm, ok := finalModel.(tableview.FinalModel); ok { + if modelErr := pm.Err(); modelErr != nil { + return modelErr + } + } + return nil + } + } + } + return 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..b72ac1ce01 --- /dev/null +++ b/libs/tableview/paginated.go @@ -0,0 +1,538 @@ +package tableview + +import ( + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + "unicode/utf8" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + fetchBatchSize = 50 + fetchThresholdFromBottom = 10 + defaultMaxColumnWidth = 50 + 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 + exhausted bool + err error + 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 + + 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 + fetchGeneration int + + // Display + cursor int + widths []int + + // Search + searching bool + searchInput string + debounceSeq int + hasSearchState bool + 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 + generation := m.fetchGeneration + + return func() tea.Msg { + var rows [][]string + exhausted := false + + limit := fetchBatchSize + if maxItems > 0 { + remaining := maxItems - currentLen + if remaining <= 0 { + return rowsFetchedMsg{exhausted: true, generation: generation} + } + 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, generation: generation} + } + rows = append(rows, row) + } + + if maxItems > 0 && currentLen+len(rows) >= maxItems { + exhausted = true + } + + return rowsFetchedMsg{rows: rows, exhausted: exhausted, generation: generation} + } + } +} + +// 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) + 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. +func (m paginatedModel) Err() error { + return m.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: + if msg.generation != m.fetchGeneration { + return m, nil + } + m.loading = false + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.err = 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 len(m.rows) > 0 { + m.computeWidths() + if isFirstBatch { + m.cursor = 0 + } + } + + if m.ready { + m.viewport.SetContent(m.renderContent()) + } + 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) + } + 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 || m.searching { + return m, nil + } + if len(m.rows)-m.cursor <= fetchThresholdFromBottom { + m.loading = true + return m, m.makeFetchCmd(m) + } + 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} + }) +} + +// 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 { + // Bump generation to discard any in-flight search fetch, since we're + // switching back to the original iterator. + m.fetchGeneration++ + m.rows = m.savedRows + m.rowIter = m.savedIter + m.exhausted = m.savedExhaust + m.hasSearchState = false + m.savedRows = nil + m.savedIter = nil + m.savedExhaust = false + m.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 == "" { + m.restorePreSearchState() + 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 = true + 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++ + // Execute final search immediately (bypass debounce). + return m.executeSearch(m.searchInput) + case "esc", "ctrl+c": + m.searching = false + m.searchInput = "" + m.viewport.Height++ + m.restorePreSearchState() + return m, nil + case "backspace": + if len(m.searchInput) > 0 { + _, size := utf8.DecodeLastRuneInString(m.searchInput) + m.searchInput = m.searchInput[:len(m.searchInput)-size] + } + return m, m.scheduleSearchDebounce() + default: + if msg.Type == tea.KeyRunes { + m.searchInput += msg.String() + return m, m.scheduleSearchDebounce() + } + if msg.Type == tea.KeySpace { + m.searchInput += " " + return m, m.scheduleSearchDebounce() + } + 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 && 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 +} + +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..0b683aef1b --- /dev/null +++ b/libs/tableview/paginated_test.go @@ -0,0 +1,1002 @@ +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 TestPaginatedMaybeFetchNotTriggeredWhenSearching(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = make([][]string, 15) + m.cursor = 10 + m.searching = true + + _, cmd := maybeFetch(m) + assert.Nil(t, cmd) +} + +func TestPaginatedMaybeFetchNotTriggeredWhenFarFromBottom(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = make([][]string, 50) + m.cursor = 0 + + _, cmd := maybeFetch(m) + assert.Nil(t, cmd) +} + +func 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.True(t, pm.hasSearchState) + assert.Equal(t, 1, pm.fetchGeneration) + + // 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.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) { + 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, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyBackspace}) + pm := result.(paginatedModel) + + assert.Equal(t, "ab", pm.searchInput) + assert.NotNil(t, cmd, "backspace should schedule a debounce tick") +} + +func TestPaginatedSearchTyping(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "" + + result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + pm := result.(paginatedModel) + + assert.Equal(t, "a", pm.searchInput) + assert.NotNil(t, cmd, "typing should schedule a debounce tick") +} + +func TestPaginatedRenderFooterExhausted(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = [][]string{{"a", "1"}, {"b", "2"}} + m.exhausted = true + m.cfg = newTestConfig() + m.ready = true + m.viewport.Width = 80 + m.viewport.Height = 20 + + footer := m.renderFooter() + assert.Contains(t, footer, "2 rows") + assert.Contains(t, footer, "q quit") +} + +func TestPaginatedRenderFooterMoreAvailable(t *testing.T) { + m := newTestModel(t, nil, 0) + m.rows = [][]string{{"a", "1"}} + m.exhausted = false + m.cfg = newTestConfig() + + footer := m.renderFooter() + assert.Contains(t, footer, "more available") +} + +func 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 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 { + 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.Equal(t, 0, fetched.generation) + 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.Equal(t, 0, fetched.generation) + 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") +} + +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 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.False(t, pm.loading, "entering search mode should not overload loading flag") + + // 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 searching 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.NoError(t, m.Err()) + + m.err = errors.New("test error") + 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 TestPaginatedSearchEscWithoutSearchStateKeepsGeneration(t *testing.T) { + m := newTestModel(t, nil, 0) + m.searching = true + m.searchInput = "" + m.loading = true // fetch was in-flight before entering search + m.viewport.Height = 20 + m.fetchGeneration = 5 + + result, _ := m.updateSearch(tea.KeyMsg{Type: tea.KeyEscape}) + pm := result.(paginatedModel) + + assert.Equal(t, 5, pm.fetchGeneration, "fetchGeneration should NOT be bumped without search state") + assert.True(t, pm.loading, "loading should be preserved when hasSearchState is false and a fetch was in-flight") +} + +func TestPaginatedSearchEscWithoutExecutingUnblocksFetch(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.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 remain false after esc") + + // 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 search mode is exited") +} + +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"}}, + 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) +} 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..973f4f257d 100644 --- a/libs/tableview/tableview.go +++ b/libs/tableview/tableview.go @@ -5,26 +5,10 @@ import ( "fmt" "io" "strings" - "text/tabwriter" + "unicode/utf8" "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 +26,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 +106,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 +114,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 +153,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 +170,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": @@ -293,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 || msg.Type == tea.KeySpace { m.searchInput += msg.String() } 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) 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) +}