diff --git a/cmd/apps/deploy_bundle.go b/cmd/apps/deploy_bundle.go index 38e312e388..806eceb1da 100644 --- a/cmd/apps/deploy_bundle.go +++ b/cmd/apps/deploy_bundle.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/bundle/run" "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/apps/prompt" "github.com/databricks/cli/libs/apps/validation" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" @@ -153,11 +154,11 @@ func runBundleDeploy(cmd *cobra.Command, force, skipValidation, skipTests bool) log.Infof(ctx, "Running app: %s", appKey) if err := runBundleApp(ctx, b, appKey); err != nil { - cmdio.LogString(ctx, "✔ Deployment succeeded, but failed to start app") + prompt.PrintDone(ctx, "Deployment succeeded, but failed to start app") return fmt.Errorf("failed to run app: %w. Run `databricks apps logs` to view logs", err) } - cmdio.LogString(ctx, "✔ Deployment complete!") + prompt.PrintDone(ctx, "Deployment complete") return nil } diff --git a/cmd/apps/import.go b/cmd/apps/import.go index b508335231..d32112f5c5 100644 --- a/cmd/apps/import.go +++ b/cmd/apps/import.go @@ -20,6 +20,7 @@ import ( "github.com/databricks/cli/bundle/run" bundleutils "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/apps/prompt" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/dyn" @@ -202,9 +203,10 @@ Examples: } if !quiet { - cmdio.LogString(ctx, fmt.Sprintf("\n✓ App '%s' has been successfully imported to %s", name, outputDir)) + cmdio.LogString(ctx, "") + prompt.PrintDone(ctx, fmt.Sprintf("App '%s' imported to %s", name, outputDir)) if cleanup && oldSourceCodePath != "" { - cmdio.LogString(ctx, "✓ Previous app folder has been cleaned up") + prompt.PrintDone(ctx, "Previous app folder cleaned up") } cmdio.LogString(ctx, "\nYou can now deploy changes with: databricks bundle deploy") } diff --git a/cmd/apps/init.go b/cmd/apps/init.go index e3db5acef6..c142b0b9d6 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -329,6 +329,14 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec } theme := prompt.AppkitTheme() + // Eagerly start fetching resources for ALL plugins in the background. + // This runs while the user is selecting plugins, so by the time resource + // pickers appear the data is likely already cached. + allPluginNames := m.GetPluginNames() + allPossibleResources := m.CollectResources(allPluginNames) + allPossibleResources = append(allPossibleResources, m.CollectOptionalResources(allPluginNames)...) + ctx = prompt.PrefetchResources(ctx, allPossibleResources) + // Step 1: Plugin selection (skip if plugins already provided via flag) selectablePlugins := m.GetSelectablePlugins() if len(config.Features) == 0 && len(selectablePlugins) > 0 { @@ -361,8 +369,11 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec // Always include mandatory plugins. config.Features = appendUnique(config.Features, m.GetMandatoryPluginNames()...) - // Step 2: Prompt for required plugin resource dependencies + // Collect resources for the user's actual selection. resources := m.CollectResources(config.Features) + optionalResources := m.CollectOptionalResources(config.Features) + + // Step 2: Prompt for required plugin resource dependencies for _, r := range resources { values, err := promptForResource(ctx, r, theme, true) if err != nil { @@ -374,7 +385,6 @@ func promptForPluginsAndDeps(ctx context.Context, m *manifest.Manifest, preSelec } // Step 3: Prompt for optional plugin resource dependencies - optionalResources := m.CollectOptionalResources(config.Features) for _, r := range optionalResources { values, err := promptForResource(ctx, r, theme, false) if err != nil { @@ -490,43 +500,178 @@ func cloneRepo(ctx context.Context, repoURL, branch string) (string, error) { return tempDir, nil } -// resolveTemplate resolves a template path, handling both local paths and GitHub URLs. -// branch is used for cloning (can contain "/" for feature branches). -// subdir is an optional subdirectory within the repo to use (for default appkit template). -// Returns the local path to use, a cleanup function (for temp dirs), and any error. -func resolveTemplate(ctx context.Context, templatePath, branch, subdir string) (localPath string, cleanup func(), err error) { - // Case 1: Local path - return as-is +// resolveTemplate resolves a template synchronously with a spinner. +// Used by commands that don't benefit from background cloning (e.g., manifest). +func resolveTemplate(ctx context.Context, templatePath, branch, subdir string) (string, func(), error) { + ch := resolveTemplateAsync(ctx, templatePath, branch, subdir) + return awaitTemplate(ctx, ch) +} + +// templateResult holds the outcome of a background template resolution. +type templateResult struct { + path string + cleanup func() + err error +} + +// resolveTemplateAsync starts resolving the template in a background goroutine. +// For local paths this completes immediately; for GitHub URLs it clones the repo. +// The caller reads the result from the returned channel, optionally showing a +// spinner if the clone hasn't finished by the time it's needed. +func resolveTemplateAsync(ctx context.Context, templatePath, branch, subdir string) <-chan templateResult { + ch := make(chan templateResult, 1) + + // Local path — instant. if !strings.HasPrefix(templatePath, "https://") { - return templatePath, nil, nil + ch <- templateResult{path: templatePath} + return ch } - // Case 2: GitHub URL - parse and clone repoURL, urlSubdir, urlBranch := git.ParseGitHubURL(templatePath) if branch == "" { - branch = urlBranch // Use branch from URL if not overridden by flag + branch = urlBranch } if subdir == "" { - subdir = urlSubdir // Use subdir from URL if not overridden + subdir = urlSubdir } - // Clone to temp dir with spinner - var tempDir string - err = prompt.RunWithSpinnerCtx(ctx, "Cloning template...", func() error { - var cloneErr error - tempDir, cloneErr = cloneRepo(ctx, repoURL, branch) - return cloneErr - }) + go func() { + tempDir, err := cloneRepo(ctx, repoURL, branch) + if err != nil { + ch <- templateResult{err: err} + return + } + cleanup := func() { os.RemoveAll(tempDir) } + localPath := tempDir + if subdir != "" { + localPath = filepath.Join(tempDir, subdir) + } + ch <- templateResult{path: localPath, cleanup: cleanup} + }() + + return ch +} + +// awaitTemplate waits for the background clone to finish. +// If the result is already available it returns immediately with a +// checkmark; otherwise it shows a spinner while waiting. +func awaitTemplate(ctx context.Context, ch <-chan templateResult) (string, func(), error) { + select { + case res := <-ch: + // Clone finished while the user was typing — print completion. + if res.err == nil && res.cleanup != nil { + prompt.PrintDone(ctx, "Template cloned") + } + return res.path, res.cleanup, res.err + default: + // Still cloning — show a spinner for the remaining wait. + var res templateResult + err := prompt.RunWithSpinnerCtx(ctx, "Cloning template...", func() error { + res = <-ch + return res.err + }) + return res.path, res.cleanup, err + } +} + +// findProjectSrcDir locates the actual source directory inside a template. +// Templates may nest their content inside a {{.project_name}} directory. +func findProjectSrcDir(templateDir string) string { + entries, err := os.ReadDir(templateDir) if err != nil { - return "", nil, err + return templateDir + } + for _, e := range entries { + if e.IsDir() && strings.Contains(e.Name(), "{{.project_name}}") { + return filepath.Join(templateDir, e.Name()) + } } + return templateDir +} - cleanup = func() { os.RemoveAll(tempDir) } +// startBackgroundNpmInstall copies the package files from the template into +// destDir and launches `npm ci` in the background. The caller should read the +// returned channel after copyTemplate to get the result. Returns nil if the +// template is not a Node.js project or npm is not available. +func startBackgroundNpmInstall(ctx context.Context, srcProjectDir, destDir, projectName string) <-chan error { + // Check that the template has a package-lock.json (needed by npm ci). + lockFile := filepath.Join(srcProjectDir, "package-lock.json") + if _, err := os.Stat(lockFile); err != nil { + return nil + } - // Return path to subdirectory if specified - if subdir != "" { - return filepath.Join(tempDir, subdir), cleanup, nil + if _, err := exec.LookPath("npm"); err != nil { + return nil + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return nil + } + + // Copy package.json (apply template substitution so the file is valid JSON) + // and package-lock.json (no template vars — copy raw). + for _, name := range []string{"package.json", "package.json.tmpl"} { + src := filepath.Join(srcProjectDir, name) + content, err := os.ReadFile(src) + if err != nil { + continue + } + // Minimal template vars so package.json renders to valid JSON. + minVars := templateData(templateVars{ + ProjectName: projectName, + AppDescription: prompt.DefaultAppDescription, + Plugins: make(map[string]*pluginVar), + }) + tmpl, err := template.New(name).Option("missingkey=zero").Parse(string(content)) + if err != nil { + // Not a Go template — copy raw. + _ = os.WriteFile(filepath.Join(destDir, "package.json"), content, 0o644) + break + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, minVars); err != nil { + _ = os.WriteFile(filepath.Join(destDir, "package.json"), content, 0o644) + break + } + _ = os.WriteFile(filepath.Join(destDir, "package.json"), buf.Bytes(), 0o644) + break + } + + // Copy package-lock.json raw (never has template vars). + if data, err := os.ReadFile(lockFile); err == nil { + _ = os.WriteFile(filepath.Join(destDir, "package-lock.json"), data, 0o644) + } + + ch := make(chan error, 1) + go func() { + cmd := exec.CommandContext(ctx, "npm", "ci", "--no-audit", "--no-fund", "--prefer-offline") + cmd.Dir = destDir + cmd.Stdout = nil + cmd.Stderr = nil + ch <- cmd.Run() + }() + + log.Debugf(ctx, "Started background npm install in %s", destDir) + return ch +} + +// awaitBackgroundNpmInstall waits for the background npm install to complete. +// Shows an instant checkmark if already done, or a spinner for the remainder. +func awaitBackgroundNpmInstall(ctx context.Context, ch <-chan error) error { + select { + case err := <-ch: + if err == nil { + prompt.PrintDone(ctx, "Dependencies installed") + } + return err + default: + var installErr error + err := prompt.RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { + installErr = <-ch + return installErr + }) + return err } - return tempDir, cleanup, nil } func runCreate(ctx context.Context, opts createOptions) error { @@ -564,8 +709,16 @@ func runCreate(ctx context.Context, opts createOptions) error { templateSrc = appkitRepoURL } - // Step 1: Get project name first (needed before we can check destination) - // Determine output directory for validation + // Start cloning in the background so it runs while the user types the name. + branchForClone := opts.branch + subdirForClone := "" + if usingDefaultTemplate { + branchForClone = gitRef + subdirForClone = appkitTemplateDir + } + templateCh := resolveTemplateAsync(ctx, templateSrc, branchForClone, subdirForClone) + + // Step 1: Get project name (clone runs in parallel for remote templates) destDir := opts.name if opts.outputDir != "" { destDir = filepath.Join(opts.outputDir, opts.name) @@ -575,19 +728,16 @@ func runCreate(ctx context.Context, opts createOptions) error { if !isInteractive { return errors.New("--name is required in non-interactive mode") } - // Prompt includes validation for name format AND directory existence name, err := prompt.PromptForProjectName(ctx, opts.outputDir) if err != nil { return err } opts.name = name - // Update destDir with the actual name destDir = opts.name if opts.outputDir != "" { destDir = filepath.Join(opts.outputDir, opts.name) } } else { - // Non-interactive mode: validate name and directory existence if err := prompt.ValidateProjectName(opts.name); err != nil { return err } @@ -596,16 +746,8 @@ func runCreate(ctx context.Context, opts createOptions) error { } } - // Step 2: Resolve template (handles GitHub URLs by cloning) - // For custom templates, --branch can override the URL's branch - // For default appkit template, pass gitRef directly (supports branches with "/" in name) - branchForClone := opts.branch - subdirForClone := "" - if usingDefaultTemplate { - branchForClone = gitRef - subdirForClone = appkitTemplateDir - } - resolvedPath, cleanup, err := resolveTemplate(ctx, templateSrc, branchForClone, subdirForClone) + // Step 2: Wait for template (may already be done if the user took time typing the name) + resolvedPath, cleanup, err := awaitTemplate(ctx, templateCh) if err != nil { return err } @@ -623,6 +765,11 @@ func runCreate(ctx context.Context, opts createOptions) error { } } + // Start npm install in the background so it runs while the user answers prompts. + // This is a Node.js-only optimisation — non-Node templates skip this. + srcProjectDir := findProjectSrcDir(templateDir) + npmInstallCh := startBackgroundNpmInstall(ctx, srcProjectDir, destDir, opts.name) + // Step 3: Load manifest from template (optional — templates without it skip plugin/resource logic) var m *manifest.Manifest if manifest.HasManifest(templateDir) { @@ -737,12 +884,12 @@ func runCreate(ctx context.Context, opts createOptions) error { } } - // Track whether we started creating the project for cleanup on failure + // Track whether we started creating the project for cleanup on failure. + // The background npm install may have created destDir early. var projectCreated bool var runErr error defer func() { - if runErr != nil && projectCreated { - // Clean up partially created project on failure + if runErr != nil && (projectCreated || npmInstallCh != nil) { os.RemoveAll(destDir) } }() @@ -826,7 +973,17 @@ func runCreate(ctx context.Context, opts createOptions) error { absOutputDir = destDir } - // Initialize project based on type (Node.js, Python, etc.) + // Await background npm install (started before prompts to overlap with user interaction). + // If it finishes before this point, the checkmark appears instantly. + if npmInstallCh != nil { + if err := awaitBackgroundNpmInstall(ctx, npmInstallCh); err != nil { + log.Warnf(ctx, "Background npm install failed: %v, will retry during project initialization", err) + } + } + + // Initialize project based on type (Node.js, Python, etc.). + // For Node.js, if the background install succeeded node_modules exists + // and the initializer skips the redundant install step. var nextStepsCmd string projectInitializer := initializer.GetProjectInitializer(absOutputDir) if projectInitializer != nil { @@ -892,10 +1049,11 @@ func runCreate(ctx context.Context, opts createOptions) error { if shouldDeploy { cmdio.LogString(ctx, "") - cmdio.LogString(ctx, "Deploying app...") if err := runPostCreateDeploy(ctx, profile); err != nil { cmdio.LogString(ctx, fmt.Sprintf("⚠ Deploy failed: %v", err)) cmdio.LogString(ctx, " You can deploy manually with: databricks apps deploy") + } else { + prompt.PrintDone(ctx, "Deploy complete") } } diff --git a/libs/apps/initializer/nodejs.go b/libs/apps/initializer/nodejs.go index 1e96f43f72..958f533558 100644 --- a/libs/apps/initializer/nodejs.go +++ b/libs/apps/initializer/nodejs.go @@ -20,12 +20,14 @@ type InitializerNodeJs struct { func (i *InitializerNodeJs) Initialize(ctx context.Context, workDir string) *InitResult { i.workDir = workDir - // Step 1: Run npm install - if err := i.runNpmInstall(ctx, workDir); err != nil { - return &InitResult{ - Success: false, - Message: "Failed to install dependencies", - Error: err, + // Step 1: Run npm install (skip if node_modules already exists from a background install) + if !fileExists(filepath.Join(workDir, "node_modules")) { + if err := i.runNpmInstall(ctx, workDir); err != nil { + return &InitResult{ + Success: false, + Message: "Failed to install dependencies", + Error: err, + } } } diff --git a/libs/apps/manifest/manifest.go b/libs/apps/manifest/manifest.go index 299cfa43f4..19328377e2 100644 --- a/libs/apps/manifest/manifest.go +++ b/libs/apps/manifest/manifest.go @@ -26,6 +26,10 @@ type Resource struct { Description string `json:"description"` // e.g., "SQL Warehouse for executing analytics queries" Permission string `json:"permission"` // e.g., "CAN_USE" Fields map[string]ResourceField `json:"fields"` // field definitions with env var mappings + + // PluginDisplayName is set during resource collection to identify which + // plugin requires this resource. Not part of the JSON manifest. + PluginDisplayName string `json:"-"` } // Key returns the resource key for machine use (config keys, variable naming). @@ -181,6 +185,7 @@ func (m *Manifest) ValidatePluginNames(names []string) error { } // CollectResources returns all required resources for the given plugin names. +// Each returned resource is annotated with PluginDisplayName for UI context. func (m *Manifest) CollectResources(pluginNames []string) []Resource { seen := make(map[string]bool) var resources []Resource @@ -198,6 +203,7 @@ func (m *Manifest) CollectResources(pluginNames []string) []Resource { key := r.Type + ":" + r.Key() if !seen[key] { seen[key] = true + r.PluginDisplayName = plugin.DisplayName resources = append(resources, r) } } @@ -207,6 +213,7 @@ func (m *Manifest) CollectResources(pluginNames []string) []Resource { } // CollectOptionalResources returns all optional resources for the given plugin names. +// Each returned resource is annotated with PluginDisplayName for UI context. func (m *Manifest) CollectOptionalResources(pluginNames []string) []Resource { seen := make(map[string]bool) var resources []Resource @@ -224,6 +231,7 @@ func (m *Manifest) CollectOptionalResources(pluginNames []string) []Resource { key := r.Type + ":" + r.Key() if !seen[key] { seen[key] = true + r.PluginDisplayName = plugin.DisplayName resources = append(resources, r) } } diff --git a/libs/apps/prompt/cache.go b/libs/apps/prompt/cache.go new file mode 100644 index 0000000000..a81e5b88c6 --- /dev/null +++ b/libs/apps/prompt/cache.go @@ -0,0 +1,47 @@ +package prompt + +import ( + "context" + "sync" +) + +type resourceCacheKey struct{} + +// ResourceCache stores pre-fetched PagedFetcher instances so prompt functions +// can skip the initial API fetch when data has already been loaded in parallel. +type ResourceCache struct { + mu sync.RWMutex + fetchers map[string]*PagedFetcher +} + +// NewResourceCache creates an empty cache. +func NewResourceCache() *ResourceCache { + return &ResourceCache{fetchers: make(map[string]*PagedFetcher)} +} + +// SetFetcher stores a pre-created PagedFetcher for a resource type. +func (c *ResourceCache) SetFetcher(resourceType string, f *PagedFetcher) { + c.mu.Lock() + defer c.mu.Unlock() + c.fetchers[resourceType] = f +} + +// GetFetcher returns the cached PagedFetcher for a resource type, or nil. +func (c *ResourceCache) GetFetcher(resourceType string) *PagedFetcher { + c.mu.RLock() + defer c.mu.RUnlock() + return c.fetchers[resourceType] +} + +// ContextWithCache returns a child context carrying the given resource cache. +func ContextWithCache(ctx context.Context, cache *ResourceCache) context.Context { + return context.WithValue(ctx, resourceCacheKey{}, cache) +} + +// CacheFromContext retrieves the resource cache from the context, or nil. +func CacheFromContext(ctx context.Context) *ResourceCache { + if cache, ok := ctx.Value(resourceCacheKey{}).(*ResourceCache); ok { + return cache + } + return nil +} diff --git a/libs/apps/prompt/listers.go b/libs/apps/prompt/listers.go index 2a4c992ad9..e76bac5b0d 100644 --- a/libs/apps/prompt/listers.go +++ b/libs/apps/prompt/listers.go @@ -20,6 +20,7 @@ import ( "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/ml" "github.com/databricks/databricks-sdk-go/service/postgres" + "github.com/databricks/databricks-sdk-go/service/serving" "github.com/databricks/databricks-sdk-go/service/sql" "github.com/databricks/databricks-sdk-go/service/vectorsearch" "github.com/databricks/databricks-sdk-go/service/workspace" @@ -98,7 +99,7 @@ func ListJobs(ctx context.Context) ([]ListItem, error) { if err != nil { return nil, err } - out := make([]ListItem, 0, len(jobList)) + out := make([]ListItem, 0, min(len(jobList), maxListResults)) for _, j := range jobList { label := j.Settings.Name id := strconv.FormatInt(j.JobId, 10) @@ -107,7 +108,7 @@ func ListJobs(ctx context.Context) ([]ListItem, error) { } out = append(out, ListItem{ID: id, Label: label}) } - return out, nil + return capResults(out), nil } // ListSQLWarehousesItems returns SQL warehouses as ListItems (reuses same API as ListSQLWarehouses). @@ -121,7 +122,7 @@ func ListSQLWarehousesItems(ctx context.Context) ([]ListItem, error) { if err != nil { return nil, err } - out := make([]ListItem, 0, len(whs)) + out := make([]ListItem, 0, min(len(whs), maxListResults)) for _, wh := range whs { label := wh.Name if wh.State != "" { @@ -129,7 +130,7 @@ func ListSQLWarehousesItems(ctx context.Context) ([]ListItem, error) { } out = append(out, ListItem{ID: wh.Id, Label: label}) } - return out, nil + return capResults(out), nil } // ListServingEndpoints returns serving endpoints as selectable items. @@ -143,7 +144,7 @@ func ListServingEndpoints(ctx context.Context) ([]ListItem, error) { if err != nil { return nil, err } - out := make([]ListItem, 0, len(endpoints)) + out := make([]ListItem, 0, min(len(endpoints), maxListResults)) for _, e := range endpoints { name := e.Name if name == "" { @@ -151,7 +152,7 @@ func ListServingEndpoints(ctx context.Context) ([]ListItem, error) { } out = append(out, ListItem{ID: e.Id, Label: name}) } - return out, nil + return capResults(out), nil } // ListCatalogs returns UC catalogs as selectable items. @@ -398,6 +399,7 @@ func ListPostgresBranches(ctx context.Context, projectName string) ([]ListItem, } // ListGenieSpaces returns Genie spaces as selectable items. +// Pagination stops early once maxListResults items have been collected. func ListGenieSpaces(ctx context.Context) ([]ListItem, error) { w, err := workspaceClient(ctx) if err != nil { @@ -421,12 +423,12 @@ func ListGenieSpaces(ctx context.Context) ([]ListItem, error) { } out = append(out, ListItem{ID: id, Label: label}) } - if resp.NextPageToken == "" { + if resp.NextPageToken == "" || len(out) >= maxListResults { break } req.PageToken = resp.NextPageToken } - return out, nil + return capResults(out), nil } // ListExperiments returns MLflow experiments as selectable items. @@ -440,7 +442,7 @@ func ListExperiments(ctx context.Context) ([]ListItem, error) { if err != nil { return nil, err } - out := make([]ListItem, 0, len(exps)) + out := make([]ListItem, 0, min(len(exps), maxListResults)) for _, e := range exps { label := e.Name if label == "" { @@ -448,7 +450,7 @@ func ListExperiments(ctx context.Context) ([]ListItem, error) { } out = append(out, ListItem{ID: e.ExperimentId, Label: label}) } - return out, nil + return capResults(out), nil } // TODO: uncomment when bundles support app as an app resource type. @@ -470,3 +472,245 @@ func ListExperiments(ctx context.Context) ([]ListItem, error) { // } // return out, nil // } + +// --------------------------------------------------------------------------- +// Paged lister constructors — return a PagedFetcher that loads pageSize items +// at a time, keeping the SDK iterator alive for incremental "Load more". +// --------------------------------------------------------------------------- + +// NewPagedSQLWarehouses creates a PagedFetcher for SQL warehouses. +func NewPagedSQLWarehouses(ctx context.Context) (*PagedFetcher, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Warehouses.List(ctx, sql.ListWarehousesRequest{PageSize: pageSize}) + mapFn := func(wh sql.EndpointInfo) ListItem { + label := wh.Name + if wh.State != "" { + label = fmt.Sprintf("%s (%s)", wh.Name, wh.State) + } + return ListItem{ID: wh.Id, Label: label} + } + items, hasMore, err := collectN(ctx, iter, pageSize, mapFn) + if err != nil { + return nil, err + } + return &PagedFetcher{ + Items: items, + HasMore: hasMore, + loadMore: func(ctx context.Context) ([]ListItem, bool, error) { + return collectN(ctx, iter, pageSize, mapFn) + }, + }, nil +} + +// NewPagedJobs creates a PagedFetcher for jobs. +func NewPagedJobs(ctx context.Context) (*PagedFetcher, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Jobs.List(ctx, jobs.ListJobsRequest{Limit: pageSize}) + mapFn := func(j jobs.BaseJob) ListItem { + label := j.Settings.Name + id := strconv.FormatInt(j.JobId, 10) + if label == "" { + label = id + } + return ListItem{ID: id, Label: label} + } + items, hasMore, err := collectN(ctx, iter, pageSize, mapFn) + if err != nil { + return nil, err + } + return &PagedFetcher{ + Items: items, + HasMore: hasMore, + loadMore: func(ctx context.Context) ([]ListItem, bool, error) { + return collectN(ctx, iter, pageSize, mapFn) + }, + }, nil +} + +// SearchJobs performs a server-side search for jobs by name (exact, case-insensitive). +func SearchJobs(ctx context.Context, name string) ([]ListItem, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Jobs.List(ctx, jobs.ListJobsRequest{Name: name}) + jobList, err := listing.ToSlice(ctx, iter) + if err != nil { + return nil, err + } + out := make([]ListItem, 0, len(jobList)) + for _, j := range jobList { + label := j.Settings.Name + id := strconv.FormatInt(j.JobId, 10) + if label == "" { + label = id + } + out = append(out, ListItem{ID: id, Label: label}) + } + return out, nil +} + +// NewPagedServingEndpoints creates a PagedFetcher for serving endpoints. +func NewPagedServingEndpoints(ctx context.Context) (*PagedFetcher, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.ServingEndpoints.List(ctx) + mapFn := func(e serving.ServingEndpoint) ListItem { + name := e.Name + if name == "" { + name = e.Id + } + return ListItem{ID: e.Id, Label: name} + } + items, hasMore, err := collectN(ctx, iter, pageSize, mapFn) + if err != nil { + return nil, err + } + return &PagedFetcher{ + Items: items, + HasMore: hasMore, + loadMore: func(ctx context.Context) ([]ListItem, bool, error) { + return collectN(ctx, iter, pageSize, mapFn) + }, + }, nil +} + +// NewPagedExperiments creates a PagedFetcher for MLflow experiments. +func NewPagedExperiments(ctx context.Context) (*PagedFetcher, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Experiments.ListExperiments(ctx, ml.ListExperimentsRequest{MaxResults: int64(pageSize)}) + mapFn := func(e ml.Experiment) ListItem { + label := e.Name + if label == "" { + label = e.ExperimentId + } + return ListItem{ID: e.ExperimentId, Label: label} + } + items, hasMore, err := collectN(ctx, iter, pageSize, mapFn) + if err != nil { + return nil, err + } + return &PagedFetcher{ + Items: items, + HasMore: hasMore, + loadMore: func(ctx context.Context) ([]ListItem, bool, error) { + return collectN(ctx, iter, pageSize, mapFn) + }, + }, nil +} + +// NewPagedGenieSpaces creates a PagedFetcher for Genie spaces (manual pagination). +func NewPagedGenieSpaces(ctx context.Context) (*PagedFetcher, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + var nextToken string + fetchPage := func(ctx context.Context) ([]ListItem, bool, error) { + req := dashboards.GenieListSpacesRequest{PageToken: nextToken, PageSize: pageSize} + resp, err := w.Genie.ListSpaces(ctx, req) + if err != nil { + return nil, false, err + } + items := make([]ListItem, 0, len(resp.Spaces)) + for _, s := range resp.Spaces { + id := s.SpaceId + label := s.Title + if label == "" { + label = s.Description + } + if label == "" { + label = id + } + items = append(items, ListItem{ID: id, Label: label}) + } + nextToken = resp.NextPageToken + return items, nextToken != "", nil + } + items, hasMore, err := fetchPage(ctx) + if err != nil { + return nil, err + } + return &PagedFetcher{ + Items: items, + HasMore: hasMore, + loadMore: fetchPage, + }, nil +} + +// NewPagedConnections creates a PagedFetcher for UC connections. +func NewPagedConnections(ctx context.Context) (*PagedFetcher, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + iter := w.Connections.List(ctx, catalog.ListConnectionsRequest{MaxResults: pageSize}) + mapFn := func(c catalog.ConnectionInfo) ListItem { + name := c.Name + if name == "" { + name = c.FullName + } + return ListItem{ID: c.FullName, Label: name} + } + items, hasMore, err := collectN(ctx, iter, pageSize, mapFn) + if err != nil { + return nil, err + } + return &PagedFetcher{ + Items: items, + HasMore: hasMore, + loadMore: func(ctx context.Context) ([]ListItem, bool, error) { + return collectN(ctx, iter, pageSize, mapFn) + }, + }, nil +} + +// NewPagedVectorSearchIndexes creates a PagedFetcher for vector search indexes. +func NewPagedVectorSearchIndexes(ctx context.Context) (*PagedFetcher, error) { + w, err := workspaceClient(ctx) + if err != nil { + return nil, err + } + var out []ListItem + epIter := w.VectorSearchEndpoints.ListEndpoints(ctx, vectorsearch.ListEndpointsRequest{}) + endpoints, err := listing.ToSlice(ctx, epIter) + if err != nil { + return nil, err + } + capped := false + for _, ep := range endpoints { + indexIter := w.VectorSearchIndexes.ListIndexes(ctx, vectorsearch.ListIndexesRequest{EndpointName: ep.Name}) + indexes, err := listing.ToSlice(ctx, indexIter) + if err != nil { + log.Warnf(ctx, "Failed to list indexes for endpoint %q: %v", ep.Name, err) + continue + } + for _, idx := range indexes { + label := idx.Name + if label == "" { + label = ep.Name + "/ (unnamed)" + } + id := ep.Name + "/" + idx.Name + out = append(out, ListItem{ID: id, Label: fmt.Sprintf("%s / %s", ep.Name, label)}) + if len(out) >= maxTotalResults { + capped = true + break + } + } + if capped { + break + } + } + return &PagedFetcher{Items: out, Capped: capped}, nil +} diff --git a/libs/apps/prompt/paged.go b/libs/apps/prompt/paged.go new file mode 100644 index 0000000000..61fb1ef29e --- /dev/null +++ b/libs/apps/prompt/paged.go @@ -0,0 +1,91 @@ +package prompt + +import ( + "context" + + "github.com/databricks/databricks-sdk-go/listing" +) + +const ( + pageSize = 50 + maxTotalResults = 500 + moreID = "__more__" + manualID = "__manual__" +) + +// PagedFetcher provides incremental access to a resource list. The first page +// is loaded in a background goroutine (signaled via the done channel). +// Subsequent pages are loaded on demand via LoadMore. Once maxTotalResults +// items have been accumulated, Capped is set to true and no more pages are +// offered — only the manual input fallback. +type PagedFetcher struct { + Items []ListItem + HasMore bool + Capped bool + Err error + + done chan struct{} // closed when the first page is ready + loadMore func(ctx context.Context) ([]ListItem, bool, error) +} + +// WaitForFirstPage blocks until the first page is ready or the context is cancelled. +func (p *PagedFetcher) WaitForFirstPage(ctx context.Context) error { + if p.done == nil { + return p.Err + } + select { + case <-p.done: + return p.Err + case <-ctx.Done(): + return ctx.Err() + } +} + +// IsDone returns true if the first page has already been loaded. +func (p *PagedFetcher) IsDone() bool { + if p.done == nil { + return true + } + select { + case <-p.done: + return true + default: + return false + } +} + +// LoadMore fetches the next page and appends it to Items. If the total reaches +// maxTotalResults, HasMore is cleared and Capped is set. +func (p *PagedFetcher) LoadMore(ctx context.Context) error { + if !p.HasMore || p.loadMore == nil { + return nil + } + items, hasMore, err := p.loadMore(ctx) + if err != nil { + return err + } + p.Items = append(p.Items, items...) + p.HasMore = hasMore + if len(p.Items) >= maxTotalResults { + p.HasMore = false + p.Capped = true + } + return nil +} + +// collectN consumes up to n items from an SDK iterator, mapping each to a +// ListItem. Returns the items, whether more exist, and any error. +func collectN[T any](ctx context.Context, iter listing.Iterator[T], n int, mapFn func(T) ListItem) ([]ListItem, bool, error) { + var items []ListItem + for len(items) < n { + if !iter.HasNext(ctx) { + return items, false, nil + } + item, err := iter.Next(ctx) + if err != nil { + return items, false, err + } + items = append(items, mapFn(item)) + } + return items, iter.HasNext(ctx), nil +} diff --git a/libs/apps/prompt/prefetch.go b/libs/apps/prompt/prefetch.go new file mode 100644 index 0000000000..961957eb05 --- /dev/null +++ b/libs/apps/prompt/prefetch.go @@ -0,0 +1,59 @@ +package prompt + +import ( + "context" + + "github.com/databricks/cli/libs/apps/manifest" +) + +// pagedConstructor creates a PagedFetcher with its first page loaded. +type pagedConstructor func(ctx context.Context) (*PagedFetcher, error) + +// pagedConstructors maps resource types to their paged lister constructor. +var pagedConstructors = map[string]pagedConstructor{ + ResourceTypeSQLWarehouse: NewPagedSQLWarehouses, + ResourceTypeJob: NewPagedJobs, + ResourceTypeServingEndpoint: NewPagedServingEndpoints, + ResourceTypeGenieSpace: NewPagedGenieSpaces, + ResourceTypeExperiment: NewPagedExperiments, + ResourceTypeUCConnection: NewPagedConnections, + ResourceTypeVectorSearchIndex: NewPagedVectorSearchIndexes, +} + +// PrefetchResources kicks off a background goroutine for every resource type +// found in resources. Each goroutine fetches the first page (pageSize items) +// and stores a PagedFetcher with the iterator alive for subsequent LoadMore +// calls. The function returns immediately with a context carrying the cache. +// Resource types without a registered constructor (e.g., secrets or UC volumes +// that require multi-step prompts) are silently skipped. +func PrefetchResources(ctx context.Context, resources []manifest.Resource) context.Context { + cache := CacheFromContext(ctx) + if cache == nil { + cache = NewResourceCache() + } + + for _, r := range resources { + ctor, ok := pagedConstructors[r.Type] + if !ok { + continue + } + if cache.GetFetcher(r.Type) != nil { + continue + } + f := &PagedFetcher{done: make(chan struct{})} + cache.SetFetcher(r.Type, f) + go func(create pagedConstructor) { + defer close(f.done) + fetcher, err := create(ctx) + if err != nil { + f.Err = err + return + } + f.Items = fetcher.Items + f.HasMore = fetcher.HasMore + f.loadMore = fetcher.loadMore + }(ctor) + } + + return ContextWithCache(ctx, cache) +} diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 3ebbcadc69..676220d2fb 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -23,19 +23,22 @@ import ( // DefaultAppDescription is the default description for new apps. const DefaultAppDescription = "A Databricks App powered by AppKit" +// Brand palette — tuned for legibility on both light and dark terminals. +var ( + colorRed = lipgloss.Color("#E84040") // Bright Databricks red + colorGray = lipgloss.Color("#A1A1AA") // Light gray, legible on dark backgrounds + colorYellow = lipgloss.Color("#FFAB00") // Databricks yellow / amber + colorOrange = lipgloss.Color("#FF5F40") // Databricks orange (code blocks) +) + // AppkitTheme returns a custom theme for appkit prompts. func AppkitTheme() *huh.Theme { t := huh.ThemeBase() - // Databricks brand colors - red := lipgloss.Color("#BD2B26") - gray := lipgloss.Color("#71717A") // Mid-tone gray, readable on light and dark - yellow := lipgloss.Color("#FFAB00") - - t.Focused.Title = t.Focused.Title.Foreground(red).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(gray) - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(yellow) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(gray) + t.Focused.Title = t.Focused.Title.Foreground(colorRed).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(colorGray) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(colorYellow) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(colorGray) return t } @@ -43,9 +46,9 @@ func AppkitTheme() *huh.Theme { // Styles for printing answered prompts. var ( answeredTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#71717A")) + Foreground(colorGray) answeredValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFAB00")). + Foreground(colorYellow). Bold(true) ) @@ -115,11 +118,11 @@ func ValidateProjectName(s string) error { // PrintHeader prints the AppKit header banner. func PrintHeader(ctx context.Context) { headerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#BD2B26")). + Foreground(colorRed). Bold(true) subtitleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#71717A")) + Foreground(colorGray) cmdio.LogString(ctx, "") cmdio.LogString(ctx, headerStyle.Render("◆ Create a new Databricks AppKit project")) @@ -251,10 +254,9 @@ func promptFromListWithLabel(ctx context.Context, title, emptyMessage string, it var selected string err := huh.NewSelect[string](). Title(title). - Description(fmt.Sprintf("%d available — type to filter", len(items))). + Description(fmt.Sprintf("%d available — / to filter", len(items))). Options(options...). Value(&selected). - Filtering(true). Height(8). WithTheme(theme). Run() @@ -265,6 +267,190 @@ func promptFromListWithLabel(ctx context.Context, title, emptyMessage string, it return selected, labels[selected], nil } +// awaitFetcher waits for a background PagedFetcher's first page. If the data +// is already available it returns immediately; otherwise a spinner is shown. +func awaitFetcher(ctx context.Context, f *PagedFetcher, spinnerMsg string) error { + if f.IsDone() { + return f.Err + } + return RunWithSpinnerCtx(ctx, spinnerMsg, func() error { + return f.WaitForFirstPage(ctx) + }) +} + +// getFetcher returns a PagedFetcher from the cache, waiting for its first page. +// If the cache has no entry, it creates one synchronously using the paged +// constructor registered in pagedConstructors. +func getFetcher(ctx context.Context, resourceType, spinnerMsg string) (*PagedFetcher, error) { + if cache := CacheFromContext(ctx); cache != nil { + if f := cache.GetFetcher(resourceType); f != nil { + if err := awaitFetcher(ctx, f, spinnerMsg); err != nil { + return nil, err + } + return f, nil + } + } + ctor, ok := pagedConstructors[resourceType] + if !ok { + return nil, fmt.Errorf("no lister registered for resource type %q", resourceType) + } + var f *PagedFetcher + err := RunWithSpinnerCtx(ctx, spinnerMsg, func() error { + var fetchErr error + f, fetchErr = ctor(ctx) + return fetchErr + }) + if err != nil { + return nil, err + } + return f, nil +} + +// promptManualInput shows a text input for the user to type a resource name/ID +// manually. prefetchedLabels provides tab-complete suggestions. +func promptManualInput(ctx context.Context, title string, prefetchedLabels []string) (string, error) { + theme := AppkitTheme() + var value string + err := huh.NewInput(). + Title(title). + Placeholder("Type a name or ID"). + Suggestions(prefetchedLabels). + Value(&value). + WithTheme(theme). + Run() + if err != nil { + return "", err + } + return strings.TrimSpace(value), nil +} + +// promptFromPagedFetcher shows a picker backed by a PagedFetcher. When more +// pages are available and the total is under maxTotalResults, a "Load more..." +// option is appended. Once capped (>= maxTotalResults), an "Enter name/ID +// manually..." option replaces it. +// SearchFunc performs a server-side search by name/query. When non-nil, the +// manual input fallback triggers a search instead of accepting raw input. +// This is currently supported by Jobs (name filter). Other resource types can +// pass nil until their APIs add server-side filtering support. +type SearchFunc func(ctx context.Context, query string) ([]ListItem, error) + +func promptFromPagedFetcher(ctx context.Context, title, emptyMessage string, fetcher *PagedFetcher, required bool, searchFn SearchFunc) (string, string, error) { + if len(fetcher.Items) == 0 && !fetcher.HasMore { + if required { + return "", "", errors.New(emptyMessage) + } + return "", "", nil + } + theme := AppkitTheme() + + for { + options := make([]huh.Option[string], 0, len(fetcher.Items)+1) + labels := make(map[string]string, len(fetcher.Items)) + for _, it := range fetcher.Items { + options = append(options, huh.NewOption(it.Label, it.ID)) + labels[it.ID] = it.Label + } + + desc := fmt.Sprintf("%d available — / to filter", len(fetcher.Items)) + if fetcher.HasMore && !fetcher.Capped { + options = append(options, huh.NewOption("↓ Load more...", moreID)) + } else if fetcher.Capped { + manualLabel := "Can't find it? Enter name/ID manually..." + if searchFn != nil { + manualLabel = "Can't find it? Search by name..." + } + options = append(options, huh.NewOption(manualLabel, manualID)) + desc += " (showing first 500)" + } + + var selected string + err := huh.NewSelect[string](). + Title(title). + Description(desc). + Options(options...). + Value(&selected). + Height(8). + WithTheme(theme). + Run() + if err != nil { + return "", "", err + } + + switch selected { + case moreID: + if err := RunWithSpinnerCtx(ctx, "Fetching more results...", func() error { + return fetcher.LoadMore(ctx) + }); err != nil { + return "", "", err + } + continue + + case manualID: + suggestions := make([]string, 0, len(fetcher.Items)) + for _, it := range fetcher.Items { + suggestions = append(suggestions, it.Label) + } + query, inputErr := promptManualInput(ctx, title, suggestions) + if inputErr != nil { + return "", "", inputErr + } + if query == "" { + if required { + continue + } + return "", "", nil + } + + if searchFn == nil { + printAnswered(ctx, title, query) + return query, query, nil + } + + var results []ListItem + if searchErr := RunWithSpinnerCtx(ctx, fmt.Sprintf("Searching for %q...", query), func() error { + var fetchErr error + results, fetchErr = searchFn(ctx, query) + return fetchErr + }); searchErr != nil { + return "", "", searchErr + } + if len(results) == 0 { + printAnswered(ctx, title, query) + return query, query, nil + } + if len(results) == 1 { + printAnswered(ctx, title, results[0].Label) + return results[0].ID, results[0].Label, nil + } + id, pickErr := PromptFromList(ctx, title+" — search results", "no matches", results, required) + if pickErr != nil { + return "", "", pickErr + } + return id, id, nil + + default: + printAnswered(ctx, title, labels[selected]) + return selected, labels[selected], nil + } + } +} + +// promptForPagedResource gets a PagedFetcher (from cache or on-demand), then +// shows the paged picker with Load more / Enter manually support. +// Pass a non-nil searchFn to enable server-side search in the manual input +// fallback (currently only Jobs supports this). +func promptForPagedResource(ctx context.Context, r manifest.Resource, required bool, title, emptyMsg, spinnerMsg string, searchFn SearchFunc) (map[string]string, error) { + f, err := getFetcher(ctx, r.Type, spinnerMsg) + if err != nil { + return nil, err + } + value, _, promptErr := promptFromPagedFetcher(ctx, title, emptyMsg, f, required, searchFn) + if promptErr != nil { + return nil, promptErr + } + return singleValueResult(r, value), nil +} + // PromptForWarehouse shows a picker to select a SQL warehouse. func PromptForWarehouse(ctx context.Context) (string, error) { var items []ListItem @@ -279,6 +465,19 @@ func PromptForWarehouse(ctx context.Context) (string, error) { return PromptFromList(ctx, "Select SQL Warehouse", "no SQL warehouses found. Create one in your workspace first", items, true) } +// resourceTitle returns a prompt title for a resource, including the plugin name +// for context when available (e.g. "Select SQL Warehouse for Analytics"). +func resourceTitle(fallback string, r manifest.Resource) string { + title := r.Alias + if title == "" { + title = fallback + } + if r.PluginDisplayName != "" { + title = fmt.Sprintf("%s for %s", title, r.PluginDisplayName) + } + return title +} + // singleValueResult wraps a single value into the resource values map. // Uses the first field name from Fields for the composite key (resource_key.field), // or falls back to the resource key if no Fields are defined. @@ -293,24 +492,6 @@ func singleValueResult(r manifest.Resource, value string) map[string]string { return map[string]string{r.Key(): value} } -// promptForResourceFromLister runs a spinner, fetches items via fn, then shows PromptFromList. -func promptForResourceFromLister(ctx context.Context, r manifest.Resource, required bool, title, emptyMsg, spinnerMsg string, fn func(context.Context) ([]ListItem, error)) (map[string]string, error) { - var items []ListItem - err := RunWithSpinnerCtx(ctx, spinnerMsg, func() error { - var fetchErr error - items, fetchErr = fn(ctx) - return fetchErr - }) - if err != nil { - return nil, err - } - value, err := PromptFromList(ctx, title, emptyMsg, items, required) - if err != nil { - return nil, err - } - return singleValueResult(r, value), nil -} - // PromptForSecret shows a two-step picker for secret scope and key. func PromptForSecret(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { // Step 1: pick scope @@ -355,14 +536,18 @@ func PromptForSecret(ctx context.Context, r manifest.Resource, required bool) (m }, nil } -// PromptForJob shows a picker for jobs. +// PromptForJob shows a picker for jobs. When the user selects "Enter manually" +// (after the 500-item cap), the input triggers a server-side name search via +// the Jobs API's Name filter before accepting the value. func PromptForJob(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { - return promptForResourceFromLister(ctx, r, required, "Select Job", "no jobs found", "Fetching jobs...", ListJobs) + title := resourceTitle("Select Job", r) + return promptForPagedResource(ctx, r, required, title, "no jobs found", "Fetching jobs...", SearchJobs) } // PromptForSQLWarehouseResource shows a picker for SQL warehouses (manifest.Resource version). func PromptForSQLWarehouseResource(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { - return promptForResourceFromLister(ctx, r, required, "Select SQL Warehouse", "no SQL warehouses found. Create one in your workspace first", "Fetching SQL warehouses...", ListSQLWarehousesItems) + title := resourceTitle("Select SQL Warehouse", r) + return promptForPagedResource(ctx, r, required, title, "no SQL warehouses found. Create one in your workspace first", "Fetching SQL warehouses...", nil) } const backID = "__back__" @@ -457,7 +642,8 @@ func promptUCResource(ctx context.Context, r manifest.Resource, required bool, r // PromptForServingEndpoint shows a picker for serving endpoints. func PromptForServingEndpoint(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { - return promptForResourceFromLister(ctx, r, required, "Select Serving Endpoint", "no serving endpoints found", "Fetching serving endpoints...", ListServingEndpoints) + title := resourceTitle("Select Serving Endpoint", r) + return promptForPagedResource(ctx, r, required, title, "no serving endpoints found", "Fetching serving endpoints...", nil) } // PromptForVolume shows a three-step picker for UC volumes: catalog -> schema -> volume. @@ -467,7 +653,8 @@ func PromptForVolume(ctx context.Context, r manifest.Resource, required bool) (m // PromptForVectorSearchIndex shows a picker for vector search indexes. func PromptForVectorSearchIndex(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { - return promptForResourceFromLister(ctx, r, required, "Select Vector Search Index", "no vector search indexes found", "Fetching vector search indexes...", ListVectorSearchIndexes) + title := resourceTitle("Select Vector Search Index", r) + return promptForPagedResource(ctx, r, required, title, "no vector search indexes found", "Fetching vector search indexes...", nil) } // PromptForUCFunction shows a three-step picker for UC functions: catalog -> schema -> function. @@ -477,7 +664,8 @@ func PromptForUCFunction(ctx context.Context, r manifest.Resource, required bool // PromptForUCConnection shows a picker for UC connections. func PromptForUCConnection(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { - return promptForResourceFromLister(ctx, r, required, "Select UC Connection", "no connections found", "Fetching connections...", ListConnections) + title := resourceTitle("Select UC Connection", r) + return promptForPagedResource(ctx, r, required, title, "no connections found", "Fetching connections...", nil) } // PromptForDatabase shows a two-step picker for database instance and database name. @@ -588,16 +776,13 @@ func PromptForPostgres(ctx context.Context, r manifest.Resource, required bool) // PromptForGenieSpace shows a picker for Genie spaces. // Captures both the space ID and name since the DABs schema requires both fields. func PromptForGenieSpace(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { - var items []ListItem - err := RunWithSpinnerCtx(ctx, "Fetching Genie spaces...", func() error { - var fetchErr error - items, fetchErr = ListGenieSpaces(ctx) - return fetchErr - }) + f, err := getFetcher(ctx, r.Type, "Fetching Genie spaces...") if err != nil { return nil, err } - id, name, err := promptFromListWithLabel(ctx, "Select Genie Space", "no Genie spaces found", items, required) + + title := resourceTitle("Select Genie Space", r) + id, name, err := promptFromPagedFetcher(ctx, title, "no Genie spaces found", f, required, nil) if err != nil { return nil, err } @@ -612,7 +797,8 @@ func PromptForGenieSpace(ctx context.Context, r manifest.Resource, required bool // PromptForExperiment shows a picker for MLflow experiments. func PromptForExperiment(ctx context.Context, r manifest.Resource, required bool) (map[string]string, error) { - return promptForResourceFromLister(ctx, r, required, "Select Experiment", "no experiments found", "Fetching experiments...", ListExperiments) + title := resourceTitle("Select Experiment", r) + return promptForPagedResource(ctx, r, required, title, "no experiments found", "Fetching experiments...", nil) } // TODO: uncomment when bundles support app as an app resource type. @@ -621,7 +807,27 @@ func PromptForExperiment(ctx context.Context, r manifest.Resource, required bool // return promptForResourceFromLister(ctx, r, required, "Select App", "no apps found. Create one first with 'databricks apps create '", "Fetching apps...", ListAppsItems) // } +// Styles for consistent status output. +var ( + doneStyle = lipgloss.NewStyle(). + Foreground(colorYellow). + Bold(true) + doneTextStyle = lipgloss.NewStyle(). + Foreground(colorGray) +) + +// PrintDone prints a styled "✔ message" completion line. +func PrintDone(ctx context.Context, msg string) { + cmdio.LogString(ctx, fmt.Sprintf("%s %s", doneStyle.Render("✔"), doneTextStyle.Render(msg))) +} + +// stripEllipsis removes a trailing "..." from a string for use in completion messages. +func stripEllipsis(s string) string { + return strings.TrimSuffix(s, "...") +} + // RunWithSpinnerCtx runs a function while showing a spinner with the given title. +// On success, prints a styled checkmark completion line. // The spinner stops and the function returns early if the context is cancelled. // Panics in the action are recovered and returned as errors. func RunWithSpinnerCtx(ctx context.Context, title string, action func() error) error { @@ -641,6 +847,9 @@ func RunWithSpinnerCtx(ctx context.Context, title string, action func() error) e select { case err := <-done: spinner.Close() + if err == nil { + PrintDone(ctx, stripEllipsis(title)) + } return err case <-ctx.Done(): spinner.Close() @@ -703,10 +912,9 @@ func PromptForAppSelection(ctx context.Context, title string) (string, error) { var selected string err = huh.NewSelect[string](). Title(title). - Description(fmt.Sprintf("%d apps found — type to filter", len(existingApps))). + Description(fmt.Sprintf("%d apps found — / to filter", len(existingApps))). Options(options...). Value(&selected). - Filtering(true). Height(8). WithTheme(theme). Run() @@ -722,14 +930,14 @@ func PromptForAppSelection(ctx context.Context, title string) (string, error) { // If nextStepsCmd is non-empty, also prints the "Next steps" section with the given command. func PrintSuccess(ctx context.Context, projectName, outputDir string, fileCount int, nextStepsCmd string) { successStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFAB00")). // Databricks yellow + Foreground(colorYellow). Bold(true) dimStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#71717A")) // Mid-tone gray + Foreground(colorGray) codeStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF3621")) // Databricks orange + Foreground(colorOrange) cmdio.LogString(ctx, "") cmdio.LogString(ctx, successStyle.Render("✔ Project created successfully!")) @@ -756,15 +964,15 @@ type SetupNote struct { // PrintSetupNotes renders a styled "Setup Notes" section for selected plugins. func PrintSetupNotes(ctx context.Context, notes []SetupNote) { headerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFAB00")). // Databricks yellow + Foreground(colorYellow). Bold(true) nameStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#71717A")). // Mid-tone gray + Foreground(colorGray). Bold(true) msgStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#71717A")) // Mid-tone gray + Foreground(colorGray) cmdio.LogString(ctx, headerStyle.Render(" Setup Notes")) cmdio.LogString(ctx, "")