diff --git a/cmd/workspace/alerts/overrides.go b/cmd/workspace/alerts/overrides.go new file mode 100644 index 0000000000..0616d56be6 --- /dev/null +++ b/cmd/workspace/alerts/overrides.go @@ -0,0 +1,34 @@ +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, _ *sql.ListAlertsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. + 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.SetTableConfigOnCmd(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..bd4610c3cc 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -5,19 +5,49 @@ 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" ) -func listOverride(listCmd *cobra.Command, listReq *apps.ListAppsRequest) { +func listOverride(listCmd *cobra.Command, _ *apps.ListAppsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Url"}} {{header "ComputeStatus"}} {{header "DeploymentStatus"}}`) 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.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } -func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, listDeploymentsReq *apps.ListAppDeploymentsRequest) { +func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, _ *apps.ListAppDeploymentsRequest) { listDeploymentsCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "DeploymentId"}} {{header "State"}} {{header "CreatedAt"}}`) listDeploymentsCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/apps/overrides_test.go b/cmd/workspace/apps/overrides_test.go new file mode 100644 index 0000000000..7c7491f66b --- /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.GetTableConfigForCmd(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..c9dbcefdff 100644 --- a/cmd/workspace/catalogs/overrides.go +++ b/cmd/workspace/catalogs/overrides.go @@ -2,16 +2,33 @@ 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" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListCatalogsRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListCatalogsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Type"}} {{header "Comment"}}`) 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.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/cluster-policies/overrides.go b/cmd/workspace/cluster-policies/overrides.go index 9278b29c39..63af01c807 100644 --- a/cmd/workspace/cluster-policies/overrides.go +++ b/cmd/workspace/cluster-policies/overrides.go @@ -2,14 +2,34 @@ package cluster_policies import ( "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/spf13/cobra" ) func listOverride(listCmd *cobra.Command, _ *compute.ListClusterPoliciesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.PolicyId | green}} {{.Name}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Policy ID", Extract: func(v any) string { + return v.(compute.Policy).PolicyId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(compute.Policy).Name + }}, + {Header: "Default", Extract: func(v any) string { + if v.(compute.Policy).IsDefault { + return "yes" + } + return "" + }}, + } + + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func getOverride(getCmd *cobra.Command, _ *compute.GetClusterPolicyRequest) { diff --git a/cmd/workspace/clusters/overrides.go b/cmd/workspace/clusters/overrides.go index 6038978ae4..9ae463119c 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" ) @@ -11,12 +12,28 @@ import ( // Below we add overrides for filter flags for cluster list command to allow for custom filtering // Auto generating such flags is not yet supported by the CLI generator func listOverride(listCmd *cobra.Command, listReq *compute.ListClustersRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "ID"}} {{header "Name"}} {{header "State"}}`) listCmd.Annotations["template"] = cmdio.Heredoc(` {{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.SetTableConfigOnCmd(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..c976134bea 100644 --- a/cmd/workspace/external-locations/overrides.go +++ b/cmd/workspace/external-locations/overrides.go @@ -2,16 +2,33 @@ 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" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListExternalLocationsRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListExternalLocationsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Name"}} {{header "Credential"}} {{header "URL"}}`) 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.SetTableConfigOnCmd(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..9b0aeed1e2 100644 --- a/cmd/workspace/instance-pools/overrides.go +++ b/cmd/workspace/instance-pools/overrides.go @@ -2,13 +2,34 @@ 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" ) func listOverride(listCmd *cobra.Command) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. 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.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/jobs/overrides.go b/cmd/workspace/jobs/overrides.go index ee7d205517..f3ecb9efd3 100644 --- a/cmd/workspace/jobs/overrides.go +++ b/cmd/workspace/jobs/overrides.go @@ -1,18 +1,52 @@ 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" ) func listOverride(listCmd *cobra.Command, listReq *jobs.ListJobsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. 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.SetTableConfigOnCmd(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) { +func listRunsOverride(listRunsCmd *cobra.Command, _ *jobs.ListRunsRequest) { listRunsCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Job ID"}} {{header "Run ID"}} {{header "Result State"}} URL`) listRunsCmd.Annotations["template"] = cmdio.Heredoc(` diff --git a/cmd/workspace/jobs/overrides_test.go b/cmd/workspace/jobs/overrides_test.go new file mode 100644 index 0000000000..623ea10818 --- /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.GetTableConfigForCmd(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/lakeview/overrides.go b/cmd/workspace/lakeview/overrides.go index 6ffb641aa9..0c2dda8097 100644 --- a/cmd/workspace/lakeview/overrides.go +++ b/cmd/workspace/lakeview/overrides.go @@ -1,10 +1,36 @@ package lakeview import ( + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/dashboards" "github.com/spf13/cobra" ) +func listOverride(listCmd *cobra.Command, _ *dashboards.ListDashboardsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. + listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` + {{header "Dashboard ID"}} {{header "Name"}} {{header "State"}}`) + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{green "%s" .DashboardId}} {{.DisplayName}} {{blue "%s" .LifecycleState}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Dashboard ID", Extract: func(v any) string { + return v.(dashboards.Dashboard).DashboardId + }}, + {Header: "Name", Extract: func(v any) string { + return v.(dashboards.Dashboard).DisplayName + }}, + {Header: "State", Extract: func(v any) string { + return string(v.(dashboards.Dashboard).LifecycleState) + }}, + } + + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) +} + func publishOverride(cmd *cobra.Command, req *dashboards.PublishRequest) { originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { @@ -15,5 +41,6 @@ func publishOverride(cmd *cobra.Command, req *dashboards.PublishRequest) { } func init() { + listOverrides = append(listOverrides, listOverride) publishOverrides = append(publishOverrides, publishOverride) } diff --git a/cmd/workspace/pipelines/overrides.go b/cmd/workspace/pipelines/overrides.go index 08c36deabe..16fd844a18 100644 --- a/cmd/workspace/pipelines/overrides.go +++ b/cmd/workspace/pipelines/overrides.go @@ -1,15 +1,104 @@ 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) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. + 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.SetTableConfigOnCmd(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. + // This wrapper runs after the table config PreRunE so the config + // is already in the context when disableSearchIfFilterSet reads it. + origPreRunE := listCmd.PreRunE + listCmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if origPreRunE != nil { + if err := origPreRunE(cmd, args); err != nil { + return err + } + } + disableSearchIfFilterSet(cmd) + return nil + } +} + +func listPipelineEventsOverride(listCmd *cobra.Command, _ *pipelines.ListPipelineEventsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. + listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` + {{header "Timestamp"}} {{header "Level"}} {{header "Event Type"}} {{header "Message"}}`) + listCmd.Annotations["template"] = cmdio.Heredoc(` + {{range .}}{{.Timestamp}} {{.Level}} {{.EventType}} {{.Message | sanitize}} + {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Timestamp", Extract: func(v any) string { + return v.(pipelines.PipelineEvent).Timestamp + }}, + {Header: "Level", Extract: func(v any) string { + return string(v.(pipelines.PipelineEvent).Level) + }}, + {Header: "Event Type", Extract: func(v any) string { + return v.(pipelines.PipelineEvent).EventType + }}, + {Header: "Message", MaxWidth: 200, Extract: func(v any) string { + return cmdio.SanitizeControlWhitespace(v.(pipelines.PipelineEvent).Message) + }}, + } + + tableview.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) +} + func init() { + listPipelinesOverrides = append(listPipelinesOverrides, listPipelinesOverride) + listPipelineEventsOverrides = append(listPipelineEventsOverrides, listPipelineEventsOverride) + cmdOverrides = append(cmdOverrides, func(cli *cobra.Command) { // all auto-generated commands apart from nonManagementCommands go into 'management' group nonManagementCommands := []string{ @@ -71,6 +160,18 @@ With a PIPELINE_ID: Stops the pipeline identified by the UUID using the API.` }) } +// disableSearchIfFilterSet clears the TUI search config when --filter is active. +// It creates a shallow copy to avoid mutating the shared config registered on the command. +func disableSearchIfFilterSet(cmd *cobra.Command) { + if cmd.Flags().Changed("filter") { + if cfg := tableview.GetTableConfig(cmd.Context()); cfg != nil { + copied := *cfg + copied.Search = nil + cmd.SetContext(tableview.SetTableConfig(cmd.Context(), &copied)) + } + } +} + 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..dcf25ab5ea 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,74 @@ 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.GetTableConfigForCmd(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.GetTableConfigForCmd(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.GetTableConfigForCmd(cmd) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Search) + + // Simulate context setup so disableSearchIfFilterSet can read the config. + cmd.SetContext(tableview.SetTableConfig(t.Context(), cfg)) + disableSearchIfFilterSet(cmd) + + // The original config must not be mutated. + assert.NotNil(t, cfg.Search, "original config must not be mutated") + // The config stored in context should have Search disabled. + ctxCfg := tableview.GetTableConfig(cmd.Context()) + require.NotNil(t, ctxCfg) + assert.Nil(t, ctxCfg.Search) +} + +func TestListPipelinesSearchNotDisabledWithoutFilter(t *testing.T) { + cmd := newListPipelines() + + cfg := tableview.GetTableConfigForCmd(cmd) + require.NotNil(t, cfg) + + // Simulate context setup so disableSearchIfFilterSet can read the config. + cmd.SetContext(tableview.SetTableConfig(t.Context(), cfg)) + disableSearchIfFilterSet(cmd) + assert.NotNil(t, cfg.Search) +} diff --git a/cmd/workspace/repos/overrides.go b/cmd/workspace/repos/overrides.go index 72f58bb501..415bb0b494 100644 --- a/cmd/workspace/repos/overrides.go +++ b/cmd/workspace/repos/overrides.go @@ -11,15 +11,35 @@ 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" ) -func listOverride(listCmd *cobra.Command, listReq *workspace.ListReposRequest) { +func listOverride(listCmd *cobra.Command, _ *workspace.ListReposRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. 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.SetTableConfigOnCmd(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..1d2c16475f 100644 --- a/cmd/workspace/schemas/overrides.go +++ b/cmd/workspace/schemas/overrides.go @@ -2,16 +2,33 @@ 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" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListSchemasRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListSchemasRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Full Name"}} {{header "Owner"}} {{header "Comment"}}`) 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.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/secrets/overrides.go b/cmd/workspace/secrets/overrides.go index b215f17a7f..db55075620 100644 --- a/cmd/workspace/secrets/overrides.go +++ b/cmd/workspace/secrets/overrides.go @@ -1,7 +1,10 @@ package secrets import ( + "time" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/tableview" "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/spf13/cobra" ) @@ -11,19 +14,49 @@ func cmdOverride(cmd *cobra.Command) { } func listScopesOverride(listScopesCmd *cobra.Command) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listScopesCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Scope"}} {{header "Backend Type"}}`) listScopesCmd.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Name|green}} {{.BackendType}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Scope", Extract: func(v any) string { + return v.(workspace.SecretScope).Name + }}, + {Header: "Backend Type", Extract: func(v any) string { + return string(v.(workspace.SecretScope).BackendType) + }}, + } + + tableview.SetTableConfigOnCmd(listScopesCmd, &tableview.TableConfig{Columns: columns}) } func listSecretsOverride(listSecretsCommand *cobra.Command, _ *workspace.ListSecretsRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listSecretsCommand.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Key"}} {{header "Last Updated Timestamp"}}`) listSecretsCommand.Annotations["template"] = cmdio.Heredoc(` {{range .}}{{.Key|green}} {{.LastUpdatedTimestamp}} {{end}}`) + + columns := []tableview.ColumnDef{ + {Header: "Key", Extract: func(v any) string { + return v.(workspace.SecretMetadata).Key + }}, + {Header: "Last Updated", Extract: func(v any) string { + ts := v.(workspace.SecretMetadata).LastUpdatedTimestamp + if ts == 0 { + return "" + } + return time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05") + }}, + } + + tableview.SetTableConfigOnCmd(listSecretsCommand, &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..bc9850fb71 --- /dev/null +++ b/cmd/workspace/serving-endpoints/overrides.go @@ -0,0 +1,37 @@ +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) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. + 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.SetTableConfigOnCmd(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..e1a562b212 --- /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.GetTableConfigForCmd(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..0204eb57b4 100644 --- a/cmd/workspace/tables/overrides.go +++ b/cmd/workspace/tables/overrides.go @@ -2,16 +2,30 @@ 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" ) -func listOverride(listCmd *cobra.Command, listReq *catalog.ListTablesRequest) { +func listOverride(listCmd *cobra.Command, _ *catalog.ListTablesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "Full Name"}} {{header "Table Type"}}`) 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.SetTableConfigOnCmd(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..d0729b88a2 --- /dev/null +++ b/cmd/workspace/volumes/overrides.go @@ -0,0 +1,34 @@ +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, _ *catalog.ListVolumesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. + 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.SetTableConfigOnCmd(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..258e0f4076 100644 --- a/cmd/workspace/warehouses/overrides.go +++ b/cmd/workspace/warehouses/overrides.go @@ -2,16 +2,36 @@ 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" ) -func listOverride(listCmd *cobra.Command, listReq *sql.ListWarehousesRequest) { +func listOverride(listCmd *cobra.Command, _ *sql.ListWarehousesRequest) { + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "ID"}} {{header "Name"}} {{header "Size"}} {{header "State"}}`) 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.SetTableConfigOnCmd(listCmd, &tableview.TableConfig{Columns: columns}) } func init() { diff --git a/cmd/workspace/workspace/overrides.go b/cmd/workspace/workspace/overrides.go index c57209b554..d1eb6f4016 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" @@ -17,11 +19,30 @@ import ( func listOverride(listCmd *cobra.Command, listReq *workspace.ListWorkspaceRequest) { listReq.Path = "/" + // Template is the text-mode fallback for non-interactive/piped output. + // TableConfig drives the interactive TUI when the terminal supports it. listCmd.Annotations["headerTemplate"] = cmdio.Heredoc(` {{header "ID"}} {{header "Type"}} {{header "Language"}} {{header "Path"}}`) 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.SetTableConfigOnCmd(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/experimental/aitools/cmd/render_test.go b/experimental/aitools/cmd/render_test.go index f3e07a8d95..5ea75b8702 100644 --- a/experimental/aitools/cmd/render_test.go +++ b/experimental/aitools/cmd/render_test.go @@ -77,7 +77,7 @@ func TestRenderStaticTable(t *testing.T) { assert.Contains(t, output, "name") assert.Contains(t, output, "alice") assert.Contains(t, output, "bob") - assert.Contains(t, output, "---") + assert.Contains(t, output, "───") assert.Contains(t, output, "2 rows") } 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/render.go b/libs/cmdio/render.go index c344c3d028..f736b92bf4 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" @@ -251,6 +252,14 @@ func renderWithTemplate(ctx context.Context, r any, outputFormat flags.Output, w } } +type maxItemsKeyType struct{} + +// GetMaxItems retrieves the max items limit from context (0 = unlimited). +func GetMaxItems(ctx context.Context) int { + n, _ := ctx.Value(maxItemsKeyType{}).(int) + return n +} + type listingInterface interface { HasNext(context.Context) bool } @@ -258,20 +267,45 @@ type listingInterface interface { func Render(ctx context.Context, v any) error { c := fromContext(ctx) if _, ok := v.(listingInterface); ok { - panic("use RenderIterator instead") + return errors.New("cannot render listing.Iterator directly; use RenderIterator instead") } return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template) } +// RenderIterator renders the items from i. When the context carries a +// registered TableConfig, a paginated TUI table is shown in interactive +// terminals. func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error { c := fromContext(ctx) + + // Only launch TUI when an explicit TableConfig is registered via overrides. + if c.outputFormat == flags.OutputText && c.capabilities.SupportsTUI() { + if cfg := tableview.GetTableConfig(ctx); 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) } func RenderWithTemplate(ctx context.Context, v any, headerTemplate, template string) error { c := fromContext(ctx) if _, ok := v.(listingInterface); ok { - panic("use RenderIteratorWithTemplate instead") + return errors.New("cannot render listing.Iterator directly; use RenderIteratorWithTemplate instead") } return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, headerTemplate, template) } @@ -286,6 +320,14 @@ func RenderIteratorJson[T any](ctx context.Context, i listing.Iterator[T]) error return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template) } +var controlWhitespaceReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") + +// SanitizeControlWhitespace replaces newlines and tabs with spaces to prevent +// corrupting tab-delimited or TUI table output. +func SanitizeControlWhitespace(s string) string { + return controlWhitespaceReplacer.Replace(s) +} + var renderFuncMap = template.FuncMap{ // we render colored output if stdout is TTY, otherwise we render text. // in the future we'll check if we can explicitly check for stderr being @@ -303,8 +345,9 @@ var renderFuncMap = template.FuncMap{ "italic": func(format string, a ...any) string { return color.New(color.Italic).Sprintf(format, a...) }, - "replace": strings.ReplaceAll, - "join": strings.Join, + "replace": strings.ReplaceAll, + "sanitize": SanitizeControlWhitespace, + "join": strings.Join, "sub": func(a, b int) int { return a - b }, diff --git a/libs/tableview/common.go b/libs/tableview/common.go new file mode 100644 index 0000000000..2c553764bc --- /dev/null +++ b/libs/tableview/common.go @@ -0,0 +1,182 @@ +package tableview + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + "unicode/utf8" + + "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")) +) + +// computeColumnWidths returns the display width for each column. +// It scans headers and all rows, taking the max rune count per column. +// If maxWidths[i] > 0, the width for column i is capped at that value. +// Pass nil for maxWidths to use no caps. +func computeColumnWidths(headers []string, rows [][]string, maxWidths []int) []int { + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = utf8.RuneCountInString(h) + } + for _, row := range rows { + for i := range widths { + if i < len(row) { + w := utf8.RuneCountInString(row[i]) + if w > widths[i] { + widths[i] = w + } + } + } + } + for i := range widths { + if maxWidths != nil && i < len(maxWidths) && maxWidths[i] > 0 { + widths[i] = min(widths[i], maxWidths[i]) + } + } + return widths +} + +// renderTableToLines renders headers, a separator, and data rows through tabwriter, +// returning the output split into lines. Widths are used for the separator only. +func renderTableToLines(headers []string, widths []int, rows [][]string) []string { + var buf strings.Builder + tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + + // Header. + fmt.Fprintln(tw, strings.Join(headers, "\t")) + + // Separator. + seps := make([]string, len(headers)) + 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(headers)) + for i := range headers { + 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 +} + +// renderTableLines produces aligned table text as individual lines. +func renderTableLines(columns []string, rows [][]string) []string { + widths := computeColumnWidths(columns, rows, nil) + return renderTableToLines(columns, widths, rows) +} + +// 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. +// It works in rune-space so that ANSI escape sequences (which have varying +// byte lengths) and multi-byte UTF-8 characters do not misalign the +// highlighted spans when mapping positions back to the original string. +func highlightSearch(line, query string) string { + if query == "" { + return line + } + lineRunes := []rune(line) + queryRunes := []rune(strings.ToLower(query)) + lineLower := []rune(strings.ToLower(line)) + qLen := len(queryRunes) + + var b strings.Builder + pos := 0 + for pos <= len(lineLower)-qLen { + match := false + for i := range qLen { + if lineLower[pos+i] != queryRunes[i] { + break + } + if i == qLen-1 { + match = true + } + } + if !match { + b.WriteRune(lineRunes[pos]) + pos++ + continue + } + b.WriteString(searchHighlightStyle.Render(string(lineRunes[pos : pos+qLen]))) + pos += qLen + } + // Write remaining runes after last possible match position. + b.WriteString(string(lineRunes[pos:])) + 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 + + caps := make([]int, len(columns)) + for i := range caps { + caps[i] = maxColumnWidth + } + widths := computeColumnWidths(columns, rows, caps) + lines := renderTableToLines(columns, widths, rows) + + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); 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..f6ba32a41a --- /dev/null +++ b/libs/tableview/config.go @@ -0,0 +1,33 @@ +package tableview + +import "context" + +// ColumnDef defines a column in the TUI table. +type ColumnDef struct { + Header string // Display name in header row. + // MaxWidth caps cell display width; 0 = default (50). Values exceeding + // this limit are destructively truncated with "..." in the rendered + // output. Horizontal scrolling does not recover the hidden portion. + MaxWidth int + Extract func(v any) string // Extracts cell value from typed SDK struct. +} + +// 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/context.go b/libs/tableview/context.go new file mode 100644 index 0000000000..981299a816 --- /dev/null +++ b/libs/tableview/context.go @@ -0,0 +1,50 @@ +package tableview + +import ( + "context" + "sync" + + "github.com/spf13/cobra" +) + +type tableConfigKeyType struct{} + +// SetTableConfig stores a *TableConfig in context. +func SetTableConfig(ctx context.Context, cfg *TableConfig) context.Context { + return context.WithValue(ctx, tableConfigKeyType{}, cfg) +} + +// GetTableConfig retrieves the *TableConfig from context, or nil. +func GetTableConfig(ctx context.Context) *TableConfig { + cfg, _ := ctx.Value(tableConfigKeyType{}).(*TableConfig) + return cfg +} + +// configByCmd holds configs registered via SetTableConfigOnCmd so they can +// be looked up before the command's PreRunE has executed (useful in tests). +var configByCmd sync.Map + +// SetTableConfigOnCmd arranges for cfg to be stored in the command's context +// at execution time via PreRunE, preserving Cobra's context propagation. +func SetTableConfigOnCmd(cmd *cobra.Command, cfg *TableConfig) { + configByCmd.Store(cmd, cfg) + prev := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + cmd.SetContext(SetTableConfig(cmd.Context(), cfg)) + if prev != nil { + return prev(cmd, args) + } + return nil + } +} + +// GetTableConfigForCmd returns the config registered on cmd via +// SetTableConfigOnCmd. Unlike GetTableConfig (which reads from context), +// this works before the command's PreRunE has executed. +func GetTableConfigForCmd(cmd *cobra.Command) *TableConfig { + v, ok := configByCmd.Load(cmd) + if !ok { + return nil + } + return v.(*TableConfig) +} diff --git a/libs/tableview/paginated.go b/libs/tableview/paginated.go new file mode 100644 index 0000000000..4c8cf11130 --- /dev/null +++ b/libs/tableview/paginated.go @@ -0,0 +1,532 @@ +package tableview + +import ( + "context" + "fmt" + "io" + "strings" + "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 + searchLoading bool + searchInput string + debounceSeq int + hasSearchState bool + savedRows [][]string + savedIter RowIterator + savedExhaust bool + + // Limits + maxItems int + limitReached bool +} + +// Err returns the error recorded during data fetching, if any. +func (m paginatedModel) Err() error { + return m.err +} + +// newFetchCmdFunc returns a closure that creates fetch commands, capturing ctx. +func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd { + return func(m paginatedModel) tea.Cmd { + 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) { + if ctx.Err() != nil { + return rowsFetchedMsg{err: ctx.Err(), generation: generation} + } + 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, + loading: true, // Init() fires the first fetch + } + + if cfg.Search != nil { + m.makeSearchIter = newSearchIterFunc(ctx, cfg.Search) + } + + return tea.NewProgram(m, tea.WithOutput(w)) +} + +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 + // fetchGeneration is intentionally not bumped: scrolling past the + // threshold retries the failed fetch, and the error is shown in + // the footer until the retry succeeds. + return m, nil + } + m.err = nil + + isFirstBatch := len(m.rows) == 0 + if m.searchLoading { + m.rows = msg.rows + m.searchLoading = false + isFirstBatch = true + } else { + 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() { + caps := make([]int, len(m.headers)) + for i := range caps { + maxW := defaultMaxColumnWidth + if i < len(m.cfg.Columns) && m.cfg.Columns[i].MaxWidth > 0 { + maxW = m.cfg.Columns[i].MaxWidth + } + caps[i] = maxW + } + m.widths = computeColumnWidths(m.headers, m.rows, caps) +} + +func (m paginatedModel) renderContent() string { + // Pre-truncate rows for display. MaxWidth truncation is destructive; + // horizontal scroll won't recover hidden text. + truncated := make([][]string, len(m.rows)) + for ri, 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 utf8.RuneCountInString(v) > maxW { + runes := []rune(v) + if maxW <= 3 { + v = string(runes[:maxW]) + } else { + v = string(runes[:maxW-3]) + "..." + } + } + vals[i] = v + } + } + truncated[ri] = vals + } + + lines := renderTableToLines(m.headers, m.widths, truncated) + + // 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 = "" + // Shrink viewport by one row to make room for the search input bar. + 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.limitReached = false + m.loading = false + m.searchLoading = false + } + m.cursor = 0 + if m.ready { + m.computeWidths() + 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.exhausted = false + m.limitReached = false + m.loading = true + m.searchLoading = 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 + // Restore viewport height now that search bar is hidden. + m.viewport.Height++ + // Execute final search immediately (bypass debounce). + return m.executeSearch(m.searchInput) + case "ctrl+c": + return m, tea.Quit + case "esc": + m.searching = false + m.searchInput = "" + // Restore viewport height now that search bar is hidden. + 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 { + if m.searchLoading { + return "Searching..." + } + 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 + "█") + status := fmt.Sprintf("%d rows loaded", len(m.rows)) + if m.searchLoading { + status = "Searching..." + } + return footerStyle.Render(status) + "\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..ac1428f304 --- /dev/null +++ b/libs/tableview/paginated_test.go @@ -0,0 +1,1048 @@ +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) +} + +// TestKeyBeforeFirstFetchDoesNotDoubleFetch verifies that a keypress arriving +// before the first fetch completes does not trigger a second concurrent fetch. +// This guards against a data race on the shared iterator. +func TestKeyBeforeFirstFetchDoesNotDoubleFetch(t *testing.T) { + rows := [][]string{{"alice", "30"}, {"bob", "25"}} + iter := &stringRowIterator{rows: rows} + cfg := newTestConfig() + + // Construct the model the same way NewPaginatedProgram does, + // with loading=true since Init() fires the first fetch. + m := paginatedModel{ + cfg: cfg, + headers: []string{"Name", "Age"}, + rowIter: iter, + makeFetchCmd: newFetchCmdFunc(t.Context()), + loading: true, + ready: true, + } + m.viewport.Width = 80 + m.viewport.Height = 20 + + // Simulate a keypress arriving before the first rowsFetchedMsg. + // With loading=true, maybeFetch must bail out. + result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + pm := result.(paginatedModel) + + assert.Nil(t, cmd, "must not trigger a second fetch while the initial fetch is in-flight") + assert.True(t, pm.loading, "loading must remain true") +} + +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 TestMaybeFetch(t *testing.T) { + tests := []struct { + name string + setup func(*paginatedModel) + wantFetch bool + }{ + { + name: "triggers when near bottom and not loading", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 15) + m.cursor = 10 + m.loading = false + m.exhausted = false + }, + wantFetch: true, + }, + { + name: "does not trigger when exhausted", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 15) + m.cursor = 10 + m.exhausted = true + }, + wantFetch: false, + }, + { + name: "does not trigger when loading", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 15) + m.cursor = 10 + m.loading = true + }, + wantFetch: false, + }, + { + name: "does not trigger when searching", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 15) + m.cursor = 10 + m.searching = true + }, + wantFetch: false, + }, + { + name: "does not trigger when far from bottom", + setup: func(m *paginatedModel) { + m.rows = make([][]string, 50) + m.cursor = 0 + }, + wantFetch: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newTestModel(t, nil, 0) + tt.setup(&m) + m, cmd := maybeFetch(m) + if tt.wantFetch { + assert.NotNil(t, cmd) + assert.True(t, m.loading, "loading should be true after fetch triggered") + } else { + 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/tableview.go b/libs/tableview/tableview.go index 18eca554ce..c6276cfafe 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,22 +170,24 @@ 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": + case "ctrl+c": + return m, tea.Quit + case "esc": m.searching = false m.searchInput = "" m.viewport.Height++ return m, nil case "backspace": if len(m.searchInput) > 0 { - m.searchInput = m.searchInput[:len(m.searchInput)-1] + _, 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..55dd815c9c 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) @@ -70,3 +83,65 @@ func TestHighlightSearchNoMatch(t *testing.T) { result := highlightSearch("hello bob", "alice") assert.Equal(t, "hello bob", result) } + +func TestComputeColumnWidths(t *testing.T) { + tests := []struct { + name string + headers []string + rows [][]string + maxWidths []int + want []int + }{ + { + name: "header wider than data", + headers: []string{"username", "id"}, + rows: [][]string{{"al", "1"}, {"bo", "2"}}, + want: []int{8, 2}, + }, + { + name: "data wider than header", + headers: []string{"id", "name"}, + rows: [][]string{{"1", "alexander"}, {"2", "bob"}}, + want: []int{2, 9}, + }, + { + name: "cap limits wide data", + headers: []string{"a", "b"}, + rows: [][]string{{"short", "this-is-a-very-long-value"}}, + maxWidths: []int{10, 10}, + want: []int{5, 10}, + }, + { + name: "nil maxWidths applies no caps", + headers: []string{"x"}, + rows: [][]string{{"a-really-long-string-with-no-cap"}}, + want: []int{32}, + }, + { + name: "empty rows returns header widths", + headers: []string{"name", "age"}, + rows: nil, + want: []int{4, 3}, + }, + { + name: "uneven row lengths", + headers: []string{"a", "b", "c"}, + rows: [][]string{{"longvalue"}, {"x", "y"}}, + want: []int{9, 1, 1}, + }, + { + name: "zero cap value means no cap for that column", + headers: []string{"a", "b"}, + rows: [][]string{{"longvalue", "longvalue"}}, + maxWidths: []int{0, 5}, + want: []int{9, 5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeColumnWidths(tt.headers, tt.rows, tt.maxWidths) + assert.Equal(t, tt.want, got) + }) + } +} 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) +}