SLOPSHOPPER

databricks-cli

A Databricks pane that follows the databricks CLI: browse workspace, Unity Catalog, compute, jobs, pipelines, apps and dashboards, and see what Claude reads or…

newpaneguardcommandtoastprompt
★ 1,028v26.40.4GPL-3.0updated 2026-10-07data-goblin/power-bi-agentic-development/plugins/databricks-cli
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · databricks-cli
│ ┃ 󳁀 Databricks ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./rows.tsx 󰑐 │ ┃ / : search ⏎ jump ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./rows.tsx ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /databricks-pane │ ┃ ⎿ databricks-cli: Databricks pane open. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 󳁀 Databricks
▣ client module ./rows.tsx 󰑐 / : search ⏎ jump ▣ client module ./rows.tsx
README

<img src="media/bg42-11-4.png" alt="" width="800">

<h1 align="center">power-bi-agentic-development</h1>

The best source for Power BI AI skills and agentic development resources in one marketplace <br></br> <i> Teach AI agents like Claude Code or GitHub Copilot Power BI and Microsoft Fabric </i>

<img src="https://img.shields.io/badge/version-26.40.3-blue" alt="Version"> <img src="https://img.shields.io/badge/Power_BI-F2C811?logo=powerbi&logoColor=000" alt="Power BI"> <img src="https://img.shields.io/badge/Microsoft_Fabric-008272" alt="Microsoft Fabric"> <img src="https://img.shields.io/badge/Tabular_Editor-2E7D32" alt="Tabular Editor"> <img src="https://img.shields.io/badge/license-GPL--3.0-green" alt="License">

[!WARNING] These skills are under active development with a weekly release cadence, so expect regular renaming and restructuring.

Versions 26.26 through 26.38 are a deliberate breaking transition. Skills may be consolidated, renamed, removed, or made less automatic between weekly releases in this range. Pin 26.25 or earlier if you need the pre-transition skill structure and behavior; do not assume compatibility from one transition release to the next.


What is agentic development?

  • Agentic development is when you use agents to help you build, manage, and optimize artifacts and software. This includes semantic models, reports, and the things around them, like workspaces, deployment pipelines, and also processes.
  • A marketplace hosts plugins that you can install. Plugins are a collection of resources that help coding agents perform better. They are typically special instruction files and scripts. Plugins can contain skills, subagents, hooks, and MCP servers focused on special topics or tasks.
  • This marketplace is focused on everything to help your agent work well with Power BI and Fabric: Power BI skills, Fabric skills, subagents, and hooks for coding agents. Read further for more information.

Installation

Here's how you get started in Claude Code; run this in the terminal to get the marketplace:

claude plugin marketplace add data-goblin/power-bi-agentic-development

Example: Using the pbir-cli with the skills

Click here for a YouTube walkthrough

pbir-cli demo

Claude Code

Add the marketplace, then install plugins via /plugin and navigating to the installed marketplace.

<table> <tr> <td align="center"><img src="media/marketplace-ui.png" alt="Marketplace UI" width="400"></td> <td align="center"><img src="media/marketplace-auto-update.png" alt="Marketplace auto-update" width="400"></td> </tr> <tr> <td align="center"><em>Install plugins from the marketplace</em></td> <td align="center"><em>Enable marketplace auto-update</em></td> </tr> </table>

Alternative; add plugins via command line:

claude plugin install goblin-mode@power-bi-agentic-development
claude plugin install tabular-editor@power-bi-agentic-development
claude plugin install pbi-desktop@power-bi-agentic-development
claude plugin install pbip@power-bi-agentic-development
claude plugin install semantic-models@power-bi-agentic-development
claude plugin install reports@power-bi-agentic-development
claude plugin install paginated-reports@power-bi-agentic-development
claude plugin install custom-visuals@power-bi-agentic-development
claude plugin install fabric-cli@power-bi-agentic-development
claude plugin install fabric-admin@power-bi-agentic-development
claude plugin install databricks-cli@power-bi-agentic-development
claude plugin install fabric-data-app@power-bi-agentic-development
claude plugin install etl@power-bi-agentic-development

Copilot CLI

The standalone Copilot CLI supports plugin installation from GitHub repos. Copilot CLI reads the same .claude-plugin/marketplace.json manifest this repo uses, so the marketplace and child-plugin layout works without modification.

TMDL files have a problem with repository-relative paths over 260 characters. Windows' legacy MAX_PATH blocks git clone from writing them unless long path support is enabled at both the OS and git level. Without this, copilot plugin install aborts with Filename too long.

Check useful-stuff/agent-scripts/enable-windows-longpaths.ps1 as an example of a script you can run from an elevated ps environment to enable long paths; there are other routes to do this that you can find online, too... just ask Copilot. A reboot is recommended after the registry change. This is a Windows OS limitation, documented at Maximum Path Length Limitation.

See also the below git config command:

git config --system core.longpaths true

This repository is an Anthropic-format plugin marketplace (a set of plugins), not a single distributable plugin, so the root .claude-plugin/ contains only marketplace.json. Two documented install paths work:

1. Register the marketplace once, then install named child plugins. Example:

copilot plugin marketplace add data-goblin/power-bi-agentic-development
copilot plugin install tabular-editor@power-bi-agentic-development

2. Or install a single plugin directly from its subdirectory, no marketplace registration needed. Example:

copilot plugin install data-goblin/power-bi-agentic-development:plugins/pbip

Both forms are documented in the Copilot CLI plugin reference and the plugins how-to. Inside an interactive Copilot session, /plugin install PLUGIN-NAME@MARKETPLACE-NAME is the equivalent of (1). The bare copilot plugin install data-goblin/power-bi-agentic-development (no qualifier) will not install anything useful, because the root is a marketplace catalog, not a plugin.

Inside Copilot CLI:

/env                    # Loaded instructions, MCP servers, skills, agents, plugins, LSPs, extensions
/plugin list            # Installed plugins
/skills list            # Available skills
/skills info pbip       # Details for a specific skill
/agent                  # Browse installed agents
  • Skills load identically; Copilot CLI reads skills/<name>/SKILL.md.
  • Agents use the *.agent.md extension required by Copilot CLI's documented convention. Claude Code matches any *.md file in agents/, so the dual extension works in both tools.
  • MCP servers load from .mcp.json (plugin root) or .github/mcp.json. The plugins in this repo do not currently ship MCP servers.
  • Hooks ship twice. Claude Code reads hooks/hooks.json (with per-hook if path filters). Copilot CLI reads .github/plugin/plugin.json, which points it at hooks/copilot-hooks.json: the same scripts, wired with explicit bash and powershell commands so a Windows host without Git Bash on PATH gets a clean no-op instead of a failed launch. Copilot has no if filter and denies a tool call on any non-zero PreToolUse exit, so every script also re-applies its own path and command filters and exits 0 on anything unrelated. Requires Copilot CLI >= 1.0.26 (2026-04-14) for CLAUDE_PLUGIN_ROOT (changelog); older builds skip the hooks rather than fail. Native Windows bash users may also hit a separate path-format bug tracked upstream at claude-code#11984.
  • Scope is always user-wide in Copilot CLI: plugins install to ~/.copilot/installed-plugins/ and their hooks run in every session on the machine. There is no per-project install, so the "prefer project scope" advice below applies to Claude Code only; in Copilot, install a plugin only while you need it.

/plugin rm or copilot plugin uninstall can fail with Access is denied. (os error 5). This is an open Copilot CLI bug, not a plugin problem: the installer swaps the plugin folder by renaming it, and Windows refuses the rename while another process holds a handle on it. VS Code's Copilot extension keeps directory watchers on every installed plugin's hooks/, agents/ and skills/ folders, and a second Copilot CLI session does the same. Tracked at github/copilot-cli#4095 and #4151.

  1. Close every VS Code window and every other Copilot CLI session, then retry the uninstall.
  2. If it still fails, delete the plugin by hand: remove its folder under %USERPROFILE%\.copilot\installed-plugins\ (or %COPILOT_HOME%\installed-plugins\) and restart Copilot CLI.
  3. To stop the hooks without uninstalling, delete the plugin's hooks folder from that same location.

Overview

The repo contains skills, agents, and hooks.

  • Skills teach agents domain knowledge and workflows. They activate automatically based on task context, or can be invoked manually with /skill-name. In Claude Code, skills and commands have coalesced; commands are simply more prescriptive skill workflows.
  • Agents are autonomous subprocesses that handle complex, multi-step tasks independently; typically used for review and validation.
  • Hooks run automatically after tool use to validate files and catch errors early. They are deterministic; they fire when a specific pattern is matched, not by LLM judgment.
  • Mods add a pane to Claude Code itself; see Claude Code mods.

Hook checks can be individually toggled via config files. Set any check to false to disable it:

  • plugins/pbip/hooks/config.yaml -- PBIR, TMDL, and report binding validation
  • plugins/pbi-desktop/hooks/config.yaml -- DAX references, measure metadata, referential integrity, metadata cache

Power BI and Fabric skills, agents, and hooks: available plugins

[!WARNING] Don't install every plugin. Each skill competes for the agent's attention and context window, so install a plugin only when you need it and remove it when you don't. Prefer installing plugins scoped to a project rather than to your user, so each project carries only the skills it actually uses.

TypeNameDescription
Skillhelp-me-get-startedA slow, friendly, jargon-free guide to agentic development for people new to agents; talks through what you want to do, feels out your role and access, teaches the five pillars (model, context, prompt, tools, environment) with local interactive explainers, and checks and installs what you need (Windows/macOS commands). Adapts pace: full tutorial or a fast install run
Skillimprove-my-agent-setupSetup-wide health check with modes (shallow/deep/ultra/yolo): skills, memory, tools, STT, model independence, other clients, harness config, git, permission/isolation, network and autonomous-agent exposure, safety enforcement, secrets and PII hygiene, and workflow habits; then offers to fix what's weak. Absorbs the old /audit-context
TypeNameDescription
Skillbpa-rulesCreate and improve Best Practice Analyzer rules for models
Skillc-sharp-scriptingC# scripting and macros for TE
Skillte-cliCross-platform Tabular Editor CLI (te, preview) for semantic models from the terminal
Skillte2-cliTabular Editor 2 CLI usage and automation (not TE3)
Skillte-docsTabular Editor documentation search, TE3 config files. Uses pbi-search CLI
Command/suggest-ruleGenerate BPA rules from descriptions
Agentbpa-expression-helperDebug and improve BPA rule expressions
TypeNameDescription
Skillconnect-pbidExplore, query, and modify a model in Power BI Desktop, and reload/screenshot the report canvas via the Desktop Bridge
Agentquery-listenerCapture DAX queries from Power BI Desktop visuals in real time
HookDAX reference validationValidates table, column, and measure references against the connected model; suggests corrections
HookMeasure metadata enforcementBlocks adding measures without DisplayFolder, Description, and FormatString
HookReferential integrity checkReports unmatched many-side keys after relationship or column changes
HookMetadata cache refreshAuto-snapshots model metadata on TOM connect or model modification
HookCompatibility level checkReports features available by upgrading; optional auto-upgrade
TypeNameDescription
SkillpbipPower BI Project (PBIP) format, structure, and file types
SkilltmdlAuthor and edit TMDL files directly
Skillpbir-formatAuthor and edit PBIR metadata files directly (visual.json, report.json, themes, filters, report extensions, visual calculations)
Agentpbip-validatorValidate PBIP project structure, TMDL syntax, and PBIR schemas
HookPBIR validationValidates PBIR structure, required fields, naming conventions, and schema URLs
HookReport binding validationValidates semantic model binding (byPath directory exists; byConnection model exists via fab exists)
HookTMDL validationValidates TMDL structural syntax
TypeNameDescription
Skillcreate-pbi-reportStep-by-step workflow for building a complete report from scratch via the pbir CLI: model discovery, pages, theme, visuals, field binding, filtering, formatting, validation, publishing
Skillpbi-report-designThe Power BI report design canon: design identity, visual hierarchy, layout, color discipline, chart selection, KPI/table design, accessibility. Shared reference used by the other report skills
Skillmodifying-theme-jsonDesign, enforce, audit, and validate report themes through the pbir CLI
Skillreview-reportActionable feedback on report quality, usage, and effectiveness; usage analysis, health checks
Skillpbir-cliProgrammatic report manipulation via the pbir CLI, including live Power BI Desktop refresh and page screenshots
Mod/report-paneReport pane: pages, visuals, bookmarks, filters and report measures of the .Report folder as a tree that follows pbir, with live highlights of what Claude reads or changes. Optional arg: a path to a .Report folder. Icons: FabricSymbols NF
Agentdeneb-reviewerReview Deneb visual specs for Vega/Vega-Lite syntax and conventions
Agentsvg-reviewerReview SVG DAX measures for syntax and design quality
Agentr-reviewerReview R visual scripts (ggplot2) for Power BI conventions
Agentpython-reviewerReview Python visual scripts (matplotlib/seaborn) for Power BI conventions
TypeNameDescription
Skillpaginated-reportAuthor, validate, publish, and test Power BI paginated reports in RDL format (Report Builder, PBIRS/SSRS-compatible); connect to a semantic model, render to PDF/Excel
HookRDL validationValidates .rdl structure after Write/Edit and on Bash commands touching .rdl files; blocks on structural errors only
TypeNameDescription
Skilldeneb-visualsDeneb visual creation, Vega/Vega-Lite spec authoring, Deneb best practices for PBIR reports
Skillr-visualsCustom R visuals (ggplot2) for Power BI reports
Skillpython-visualsCustom Python visuals (matplotlib/seaborn) for Power BI reports
Skillsvg-visualsSVG generation via DAX measures and extension measures for inline chart visualizations (sparklines, bullet charts, KPI indicators, and more)
Skillpowerbi-custom-visualsPower BI developer custom visual (.pbiviz) development with the pbiviz toolchain and its MCP server: scaffold, build, debug, package, certify, publish to AppSource

Reviewer agents for these visual types (deneb-reviewer, svg-reviewer, r-reviewer, python-reviewer) ship in the reports plugin above.

TypeNameDescription
Skillsemantic-modelDesign, build, refresh, and review semantic models through a te-first tool cascade
Skillstandardize-naming-conventionsAudit and standardize naming conventions in semantic models
Skillrefresh-semantic-modelTrigger or troubleshoot refreshes
Skilllineage-analysisTrace downstream reports from a semantic model across workspaces
Skillpower-queryWrite M expressions, debug query folding, execute M locally or via Fabric API
SkilldaxWrite, debug, and optimize DAX in semantic models. Contributed by Justin Martin
Agentsemantic-model-auditorAudit semantic models for quality, memory, DAX, and design issues
TypeNameDescription
Skillfabric-cliFabric CLI (fab) for any remote operation in Power BI or Fabric (works fully on Pro, PPU; Fabric not required)
Command/migrating-fabric-trial-capacitiesMigrate workspaces from trial to production capacity
Mod/fabric-paneFabric pane: workspaces and items as a tree that follows fab, with live highlights, copy of fab paths, open in Fabric, and open semantic models in te. Optional arg: a workspace to reveal. Icons: FabricSymbols NF
TypeNameDescription
Skillaudit-tenant-settingsAudit Fabric and Power BI tenant settings, delegated overrides, and Entra security group membership
TypeNameDescription
Mod/databricks-paneDatabricks pane: workspace, Unity Catalog, compute, jobs, pipelines, apps and dashboards as a tree that follows databricks, with live highlights, profile switching (-p or DATABRICKS_CONFIG_PROFILE), copy of CLI arguments, and open in Databricks. Optional arg: a profile. Icons: DatabricksSymbols NF
TypeNameDescription
Mod/fabric-app-paneFabric app pane: every Fabric App (rayfin/rayfin.yml) under the working directory with its data sources, deploy state and files, as a tree that follows rayfin, with live highlights of what Claude deploys or changes. Optional arg: a folder. Icons: FabricSymbols NF
TypeNameDescription
Skill
Source 9 files
hooks/register.tsx 1045 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { openCommand } from './open'
3
4import type { Explorer, Onboard, Target, TreeNode, Work } from '../types'
5import { glyph, titleGlyph as dbTitle, type Tier } from './icons'
6import type { RowSpec, RowsProps, Seg } from './rows'
7import { ancestors, EMPTY, empty, emptyMark, isLoaded, merge, visible } from './tree'
8import { chainOf, children, hostFrom, listCalls, PLACEHOLDER, roots } from './databricks'
9import { DB_TONE, dbKind, dbProfile, dbTargets, type Invocation, invocations, positionals, posix, tokenize, useDrives } from './parse'
10
11const STATE = { plugin: 'databricks-cli', key: 'explorer' } as const
12const NODES = { plugin: 'databricks-cli', key: 'nodes' } as const
13const PANE = 'databricks-pane'
14const TITLE = 'Databricks'
15const TITLE_COLOR = '#ff3621'
16const HINT = 'waiting for a databricks command, or /databricks-pane'
17const SORT = false
18const WINDOW = 400
19const DETAIL_ROWS = 12
20const HEADER = '#header'
21const MEMBER_VERBS = /^(create|delete|import|import-dir|mkdirs|rm|mv|rename|restore|deploy|destroy)/
22const BUSY_MAX_MS = 600_000
23const FLASH_MS = 2700
24const DOUBLE_MS = 450
25const SHIMMER = ['#f97316', '#fb923c', '#fdba74', '#ffedd5']
26const TONES: Record<string, { bright: string[]; dim: string[] }> = {
27  orange: { bright: SHIMMER, dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'] },
28  teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'] },
29  pink: { bright: ['#ec4899', '#f472b6', '#f9a8d4', '#fce7f3'], dim: ['#831843', '#9d174d', '#b8406f', '#cf6f93'] },
30  purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'] },
31}
32const PLAIN_SPINNER = ['|', '/', '-', '\\']
33const TONE_RANK = ['orange', 'pink', 'teal', 'purple']
34const ACCENT = '#5b9bd5'
35const LOGIN = 'databricks auth login --host <your workspace URL>'
36const UNREACHABLE =
37  /dial tcp|no such host|connection refused|connection reset|network is unreachable|no route to host|i\/o timeout|tls handshake timeout|context deadline exceeded|client\.timeout|proxyconnect|proxy error|timed? ?out|temporary failure in name resolution|could not resolve host|getaddrinfo|econnrefused|econnreset|etimedout|enotfound/i
38const SIGNED_OUT =
39  /(?:status|code|http)\W{0,3}401\b|\b401 unauthorized|\bunauthorized\b|unauthenticated|not authenticated|not logged in|no credentials|cannot configure default credentials|cannot get access token|(?:invalid|expired) (?:access |refresh |oauth )?token|token (?:is |has )?(?:invalid|expired|not set)|credential was not sent|has no \S+ profile configured|no configuration file found|no profiles configured|cannot load databricks config|databricks auth login/i
40const FONT_SCRIPT =
41  'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
42  'else f=$(ls "$HOME"/Library/Fonts/*"$2"* /Library/Fonts/*"$2"* 2>/dev/null | head -n1); fi; ' +
43  '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
44  'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
45  'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
46  'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
47  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
48
49let lastPress = { key: '', at: 0 }
50const views = new Map<string, { from: number; max: number }>()
51let detected: Tier = 'nerd'
52let glyphSetting = 'auto'
53let fontHint = true
54let hinted = false
55let fellBack = false
56let follow = true
57let blink: Timer | null = null
58let generation = 0
59let closed = false
60let noDock = false
61let inflight: Promise<void> | null = null
62let queued: Promise<void> | null = null
63let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
64const hosts = new Map<string, string>()
65
66function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
67  platform ??= (async () => {
68    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
69    try {
70      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
71    } catch {
72      return 'linux'
73    }
74  })()
75  return platform
76}
77
78async function openPane($: EngineInterface, args: { id: string; title: string; focus?: true }): Promise<void> {
79  if (noDock && !args.focus) return
80  await $.ui.open(args)
81}
82
83async function cwdOf($: EngineInterface): Promise<string> {
84  return posix(await $.session.cwd())
85}
86
87function jumpTo(ex: Explorer, query: string): Partial<Explorer> {
88  const q = query.trim().toLowerCase()
89  const hits = q ? ex.nodes.filter(n => n.name.toLowerCase().includes(q) || n.note.toLowerCase().includes(q)).slice(0, 10) : []
90  const open = new Set(ex.expanded)
91  for (const n of hits) for (const a of ancestors(ex.nodes, n.id)) open.add(a)
92  return { query: '', expanded: [...open], cursor: hits[0]?.id ?? ex.cursor, scroll: null }
93}
94
95async function copyOf($: EngineInterface, text: string, surface?: string): Promise<void> {
96  const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
97  $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy')
98}
99
100function clean(segs: Seg[]): Seg[] {
101  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
102  return segs
103}
104
105const slots = { busy: 0, queue: [] as (() => void)[] }
106
107async function limited<T>(fn: () => Promise<T>): Promise<T> {
108  if (slots.busy < 4) slots.busy++
109  else await new Promise<void>(resolve => slots.queue.push(resolve))
110  try {
111    return await fn()
112  } finally {
113    const next = slots.queue.shift()
114    if (next) next()
115    else slots.busy--
116  }
117}
118
119async function openUrl($: EngineInterface, url: string): Promise<void> {
120  const { argv, init } = openCommand(await osName($), url)
121  try {
122    const run = await $.process.run(argv, init)
123    if (run.exitCode !== 0) $.ui.toast(`could not open ${url} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
124  } catch {
125    $.ui.toast(`could not open ${url} with ${argv[0]}`)
126  }
127}
128
129async function fontState($: EngineInterface, charset: string, name: string): Promise<'ok' | 'stale' | 'missing'> {
130  try {
131    const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset, name], { timeoutMs: 5_000 })).stdout.trim()
132    return out === 'stale' || out === 'missing' ? out : 'ok'
133  } catch {
134    return 'missing'
135  }
136}
137
138function quiet(p: Promise<unknown>): void {
139  void p.catch(() => undefined)
140}
141
142async function detectGlyphs($: EngineInterface): Promise<void> {
143  if ((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY'))) {
144    detected = 'nerd'
145    return
146  }
147  const nerd = (await fontState($, 'f04eb', 'Nerd')) === 'ok'
148  detected = nerd && (await fontState($, 'f3000', 'DatabricksSymbols')) === 'ok' ? 'databricks' : nerd ? 'nerd' : 'plain'
149}
150
151function fontNote(): string {
152  if (!fontHint || hinted || !fellBack) return ''
153  hinted = true
154  return 'Tell user once: Databricks pane icons need github.com/data-goblin/databricks-nf plus a Nerd Font. Plugin option fontHint=off disables this.'
155}
156
157function tierFor(surface: string): Tier {
158  if (glyphSetting === 'databricks' || glyphSetting === 'nerd' || glyphSetting === 'plain') return glyphSetting
159  return surface === 'desktop' ? 'plain' : detected
160}
161
162function titleGlyph(tier: Tier): string {
163  return dbTitle(tier)
164}
165
166function label(target: Target | null): string {
167  return target?.profile ? `${TITLE} · ${target.profile}` : TITLE
168}
169function titleFor(target: Target | null): string {
170  return `${titleGlyph(tierFor('terminal'))} ${label(target)}`
171}
172async function get($: EngineInterface): Promise<Explorer> {
173  const [view, tree] = await Promise.all([$.state.get(STATE), $.state.get(NODES)])
174  return { ...empty(), ...view.value, nodes: tree.value ?? [] }
175}
176
177async function put($: EngineInterface, fn: (ex: Explorer) => Explorer): Promise<void> {
178  for (let i = 0; ; i++) {
179    if (i >= 50) throw new Error('pane state is busy; try again')
180    const [view, tree] = await Promise.all([$.state.get(STATE), $.state.get(NODES)])
181    const cur: Explorer = { ...empty(), ...view.value, nodes: tree.value ?? [] }
182    const next = anchored(cur, fn(cur))
183    if (next.nodes !== cur.nodes) {
184      const done = await $.state.set(NODES, next.nodes, { ifVersion: tree.version })
185      if (!done.isSet) continue
186    }
187    const keys = Object.keys(next) as (keyof Explorer)[]
188    if (!keys.some(k => k !== 'nodes' && next[k] !== cur[k])) return
189    const { nodes: _nodes, ...rest } = next
190    const done = await $.state.set(STATE, { ...rest, nodes: [] }, { ifVersion: view.version })
191    if (done.isSet) return
192  }
193}
194
195function patch($: EngineInterface, fn: (ex: Explorer) => Partial<Explorer>) {
196  return put($, ex => ({ ...ex, ...fn(ex) }))
197}
198
199async function patchView($: EngineInterface, fn: (ex: Explorer) => Partial<Explorer>): Promise<void> {
200  for (let i = 0; ; i++) {
201    if (i >= 50) throw new Error('pane state is busy; try again')
202    const view = await $.state.get(STATE)
203    const cur: Explorer = { ...empty(), ...view.value, nodes: [] }
204    const change = fn(cur)
205    if (Object.keys(change).length === 0) return
206    const done = await $.state.set(STATE, { ...cur, ...change, nodes: [] }, { ifVersion: view.version })
207    if (done.isSet) return
208  }
209}
210
211const sameTarget = (a: Target | null, b: Target | null) => JSON.stringify(a) === JSON.stringify(b)
212
213function refresh($: EngineInterface): Promise<void> {
214  if (inflight) {
215    const rerun = () => {
216      queued = null
217      return refresh($)
218    }
219    queued ??= inflight.then(rerun, rerun)
220    return queued
221  }
222  inflight = doRefresh($).finally(() => {
223    inflight = null
224  })
225  return inflight
226}
227
228function live(work: Work | null, now: number): work is Work {
229  if (!work) return false
230  return work.until === null ? now - work.since < BUSY_MAX_MS : now < work.until
231}
232
233async function startWork($: EngineInterface, ids: string[], tone: string): Promise<number> {
234  const mine = ++generation
235  blink?.cancel()
236  blink = null
237  const at = await $.clock.now()
238  await patchView($, cur => {
239    const prior = live(cur.work, at) ? cur.work : null
240    const lit = [...new Set([...(prior?.lit ?? []), ...ids])]
241    return { work: { tone, lit, dim: (prior?.dim ?? []).filter(id => !lit.includes(id)), since: at, until: null } }
242  })
243  return mine
244}
245
246async function finishWork($: EngineInterface, mine: number, tone: string, ids: string[] = [], alsoLit: string[] = []): Promise<void> {
247  const unique = [...new Set(ids)]
248  const own = () => generation === mine
249  const ex = await get($)
250  const at = await $.clock.now()
251  await patch($, cur => {
252    const byId = new Map(cur.nodes.map(n => [n.id, n]))
253    const open = new Set(cur.expanded)
254    const dim = new Set(alsoLit)
255    for (const id of unique) {
256      const chain = ancestors(cur.nodes, id, byId)
257      if (!chain.some(a => !open.has(a))) continue
258      for (const a of chain) {
259        if (!open.has(a)) dim.add(a)
260        open.add(a)
261      }
262    }
263    for (const id of unique) dim.delete(id)
264    const prior = live(cur.work, at) ? cur.work : null
265    const work: Work | null = own()
266      ? unique.length
267        ? { tone, lit: unique, dim: [...dim], since: prior?.since ?? at, until: at + FLASH_MS }
268        : null
269      : prior && unique.length
270        ? { ...prior, lit: [...new Set([...prior.lit, ...unique])], dim: [...new Set([...prior.dim, ...dim])].filter(id => !unique.includes(id) && !prior.lit.includes(id)) }
271        : cur.work
272    if (unique.length === 0) return { work }
273    const root = rootOf(cur)
274    const away = follow && root !== '' && unique.some(id => id !== root && !ancestors(cur.nodes, id, byId).includes(root))
275    return { work, expanded: [...open], ...(follow ? { scroll: null } : {}), ...(away ? { root: '' } : {}) }
276  })
277  if (!own() || unique.length === 0) return
278  blink = $.clock.after(FLASH_MS, () => {
279    if (!own()) return
280    blink = null
281    quiet(patchView($, () => (own() ? { work: null } : {})))
282  })
283  if (ex.target && !closed) await openPane($, { id: PANE, title: titleFor(ex.target) })
284}
285
286type Mark = { ids: string[]; tone: string }
287
288function rootOf(ex: Explorer): string {
289  return ex.root && ex.nodes.some(n => n.id === ex.root) ? ex.root : ''
290}
291
292function anchored(cur: Explorer, next: Explorer): Explorer {
293  if (follow || cur.scroll === null || next.scroll !== cur.scroll || (next.nodes === cur.nodes && next.expanded === cur.expanded)) return next
294  const top = visible(cur, SORT, rootOf(cur))[cur.scroll]?.node.id
295  const at = top ? visible(next, SORT, rootOf(next)).findIndex(row => row.node.id === top) : -1
296  return at < 0 || at === cur.scroll ? next : { ...next, scroll: at }
297}
298
299
300async function markBusy($: EngineInterface, marks: Mark[]): Promise<void> {
301  if (marks.every(m => m.ids.length === 0)) return
302  const at = await $.clock.now()
303  await patchView($, cur => {
304    const map = { ...cur.busy }
305    for (const { ids, tone } of marks) for (const id of ids) map[id] = { tone, n: (map[id]?.n ?? 0) + 1, at }
306    return { busy: map }
307  })
308}
309
310async function clearBusy($: EngineInterface, marks: Mark[]): Promise<void> {
311  if (marks.every(m => m.ids.length === 0)) return
312  await patchView($, cur => {
313    const map = { ...cur.busy }
314    for (const { ids } of marks) {
315      for (const id of ids) {
316        const left = (map[id]?.n ?? 1) - 1
317        const entry = map[id]
318        if (left > 0 && entry) map[id] = { ...entry, n: left }
319        else delete map[id]
320      }
321    }
322    return { busy: map }
323  })
324}
325
326async function point($: EngineInterface, target: Target | null, opened: 'asked' | 'unasked', fresh = false): Promise<boolean> {
327  const moved = !sameTarget((await get($)).target, target)
328  activeProfile = target?.profile ?? ''
329  if (moved) {
330    treeGen++
331    hosts.clear()
332    await put($, () => ({ ...empty(), target }))
333  }
334  else if (fresh) await patchView($, () => ({ expanded: [], query: '', selected: '', detail: [], cursor: '' }))
335  if (opened === 'asked' || (moved && target)) {
336    const title = titleFor(target)
337    if (opened === 'asked') {
338      closed = false
339      await openPane($, { id: PANE, title, focus: true })
340    } else if (!closed) await openPane($, { id: PANE, title })
341  }
342  return moved
343}
344
345async function navigate($: EngineInterface, n: TreeNode): Promise<void> {
346  await patch($, cur => ({ root: n.id, cursor: n.id, scroll: null, expanded: [...new Set([...cur.expanded, n.id, ...ancestors(cur.nodes, n.id)])] }))
347  if (await expandNode($, n)) await expandOpen($, n.id)
348}
349
350async function goUp($: EngineInterface): Promise<void> {
351  await patch($, cur => {
352    const n = cur.nodes.find(x => x.id === cur.root)
353    return { root: n?.parent ?? '', cursor: n?.id ?? cur.cursor, scroll: null }
354  })
355}
356
357async function goHome($: EngineInterface): Promise<void> {
358  await patchView($, () => ({ root: '', scroll: null }))
359}
360
361async function press($: EngineInterface, n: TreeNode): Promise<void> {
362  const now = await $.clock.now()
363  const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
364  lastPress = { key: isDouble ? '' : n.id, at: now }
365  const ex = await get($)
366  if (isDouble && ex.nodes.some(c => c.parent === n.id)) await navigate($, n)
367  else if (isDouble) await openLocal($, ex, n)
368  else await select($, n)
369}
370
371async function openWeb($: EngineInterface, ex: Explorer, n: TreeNode): Promise<void> {
372  const url = webUrl(ex, n)
373  if (url) await openUrl($, url)
374  else $.ui.toast('no Databricks link: set host in ~/.databrickscfg or DATABRICKS_HOST')
375}
376
377async function hostOf($: EngineInterface, profile: string): Promise<string> {
378  const known = hosts.get(profile)
379  if (known !== undefined) return known
380  let host = profile ? '' : ((await $.env.get('DATABRICKS_HOST')) ?? '').trim().replace(/\/+$/, '')
381  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
382  const file = (await $.env.get('DATABRICKS_CONFIG_FILE')) || `${posix(home).replace(/\/+$/, '')}/.databrickscfg`
383  if (!host) {
384    try {
385      host = hostFrom(String(await $.fs.read(posix(file))), profile)
386    } catch {
387      host = ''
388    }
389  }
390  if (host && !/^https?:\/\//.test(host)) host = `https://${host}`
391  hosts.set(profile, host)
392  return host
393}
394type Trouble = Onboard['kind'] | ''
395
396class CliError extends Error {
397  constructor(
398    message: string,
399    readonly trouble: Trouble,
400  ) {
401    super(message)
402  }
403}
404
405function troubleOf(text: string): Trouble {
406  return UNREACHABLE.test(text) ? 'unreachable' : SIGNED_OUT.test(text) ? 'signedOut' : ''
407}
408
409async function dbRun($: EngineInterface, tail: string[], profile: string): Promise<string> {
410  const argv = ['databricks', ...tail, '-o', 'json', ...(profile ? ['-p', profile] : [])]
411  let run: Awaited<ReturnType<EngineInterface['process']['run']>>
412  try {
413    run = await limited(() => $.process.run(argv, { timeoutMs: 60_000 }))
414  } catch (err) {
415    const text = (err instanceof Error ? err.message : String(err)).trim()
416    throw new CliError(text.split('\n')[0] || 'databricks did not start', /timed? ?out/i.test(text) ? 'unreachable' : 'missing')
417  }
418  if (run.exitCode !== 0) {
419    const text = (run.stderr || run.stdout).trim()
420    throw new CliError(text.split('\n')[0] || `databricks ${tail.join(' ')} failed`, run.exitCode === 127 ? 'missing' : troubleOf(text))
421  }
422  if (run.isStdoutTruncated) throw new Error(`databricks ${tail.slice(0, 2).join(' ')}: too much output to show`)
423  return run.stdout
424}
425
426async function implicitProfile($: EngineInterface): Promise<string> {
427  return ((await $.env.get('DATABRICKS_CONFIG_PROFILE')) ?? '').trim()
428}
429
430const loading = new Map<string, Promise<boolean>>()
431let treeGen = 0
432let statusOwner = ''
433let activeProfile = ''
434
435function errorText(err: unknown): string {
436  return `error: ${err instanceof Error ? err.message : String(err)}`
437}
438
439async function pool<T>(items: T[], fn: (item: T) => Promise<unknown>, size = 4): Promise<void> {
440  const queue = [...items]
441  await Promise.all(
442    Array.from({ length: Math.min(size, queue.length) }, async () => {
443      for (let item = queue.shift(); item !== undefined; item = queue.shift()) await fn(item)
444    }),
445  )
446}
447
448async function expandOpen($: EngineInterface, root = ''): Promise<void> {
449  const failed = new Set<string>()
450  for (let pass = 0; pass < 8; pass++) {
451    const ex = await get($)
452    const open = new Set(ex.expanded)
453    const pending = new Set(ex.nodes.filter(c => c.kind === PLACEHOLDER).map(c => c.parent))
454    const byId = new Map(ex.nodes.map(n => [n.id, n]))
455    const todo = ex.nodes.filter(n => {
456      if (!open.has(n.id) || !pending.has(n.id) || failed.has(n.id)) return false
457      const chain = ancestors(ex.nodes, n.id, byId)
458      return chain.every(a => open.has(a)) && (!root || chain.includes(root))
459    })
460    if (todo.length === 0) return
461    await pool(todo, async n => {
462      if (!(await expandNode($, n))) failed.add(n.id)
463    })
464  }
465}
466function onboardOf(err: unknown): Onboard | null {
467  return err instanceof CliError && err.trouble ? { kind: err.trouble, line: err.message } : null
468}
469
470function failed(cur: Explorer, target: Target | null, err: unknown): Partial<Explorer> {
471  if (!sameTarget(cur.target, target)) return {}
472  const onboard = onboardOf(err)
473  return onboard ? { onboard } : { status: errorText(err) }
474}
475
476async function probe($: EngineInterface, profile: string): Promise<Onboard | null> {
477  try {
478    await dbRun($, ['current-user', 'me'], profile)
479    return null
480  } catch (err) {
481    return onboardOf(err)
482  }
483}
484
485async function doRefresh($: EngineInterface): Promise<void> {
486  const target = (await get($)).target
487  if (!target) return
488  await patchView($, () => ({ status: 'loading' }))
489  treeGen++
490  hosts.clear()
491  const onboard = await probe($, target.profile)
492  if (onboard) {
493    await patchView($, cur => (sameTarget(cur.target, target) ? { onboard, status: '' } : {}))
494    return
495  }
496  try {
497    await patch($, cur => (sameTarget(cur.target, target) ? { nodes: roots(), expanded: cur.nodes.length ? cur.expanded : [], status: '', onboard: null } : {}))
498    await expandOpen($)
499  } catch (err) {
500    await patch($, cur => failed(cur, target, err))
501  }
502}
503async function listChildren($: EngineInterface, n: TreeNode, profile: string): Promise<TreeNode[]> {
504  const outputs = await Promise.all(listCalls(n).map(tail => dbRun($, tail, profile)))
505  return children(n, outputs, await hostOf($, profile || (await implicitProfile($))))
506}
507
508const rerun = new Map<string, Promise<boolean>>()
509
510function reloadNode($: EngineInterface, id: string): Promise<boolean> {
511  const key = `${activeProfile}|reload:${id}`
512  const running = loading.get(key)
513  if (running) {
514    const next =
515      rerun.get(key) ??
516      running.then(
517        () => {
518          rerun.delete(key)
519          return reloadNode($, id)
520        },
521        () => {
522          rerun.delete(key)
523          return reloadNode($, id)
524        },
525      )
526    rerun.set(key, next)
527    return next
528  }
529  const job = (async () => {
530    const pending = loading.get(`${activeProfile}|${id}`)
531    if (pending) await pending.catch(() => false)
532    const ex = await get($)
533    const target = ex.target
534    const n = ex.nodes.find(x => x.id === id)
535    if (!n || !isLoaded(ex.nodes, n.id)) return true
536    const gen = treeGen
537    try {
538      const kids = await listChildren($, n, target?.profile ?? '')
539      const owned = statusOwner === n.id
540      if (owned) statusOwner = ''
541      await patch($, cur => (gen === treeGen && sameTarget(cur.target, target) && cur.nodes.some(x => x.id === n.id) ? { nodes: merge(cur.nodes, n.id, kids), ...(owned ? { status: '' } : {}), ...(cur.onboard ? { onboard: null } : {}) } : {}))
542    } catch (err) {
543      statusOwner = n.id
544      await patchView($, cur => failed(cur, target, err))
545      return false
546    }
547    await expandOpen($, n.id)
548    return true
549  })().finally(() => loading.delete(key))
550  loading.set(key, job)
551  return job
552}
553
554function expandNode($: EngineInterface, n: TreeNode): Promise<boolean> {
555  const key = `${activeProfile}|${n.id}`
556  const running = loading.get(key)
557  if (running) return running
558  const job = (async () => {
559    const ex = await get($)
560    const target = ex.target
561    if (!ex.nodes.some(c => c.parent === n.id && c.kind === PLACEHOLDER)) return true
562    const gen = treeGen
563    try {
564      const kids = await listChildren($, n, target?.profile ?? '')
565      const owned = statusOwner === n.id
566      if (owned) statusOwner = ''
567      await patch($, cur => {
568        if (gen !== treeGen || !sameTarget(cur.target, target)) return {}
569        const ok = { ...(owned ? { status: '' } : {}), ...(cur.onboard ? { onboard: null } : {}) }
570        if (!cur.nodes.some(x => x.parent === n.id && x.kind === PLACEHOLDER)) return ok
571        return { nodes: cur.nodes.flatMap(x => (x.parent === n.id && x.kind === PLACEHOLDER ? (kids.length ? kids : [emptyMark(n.id)]) : [x])), ...ok }
572      })
573      return true
574    } catch (err) {
575      statusOwner = n.id
576      await patchView($, cur => failed(cur, target, err))
577      return false
578    }
579  })().finally(() => loading.delete(key))
580  loading.set(key, job)
581  return job
582}
583
584async function reveal($: EngineInterface, chain: string[]): Promise<void> {
585  for (const id of chain) {
586    const n = (await get($)).nodes.find(x => x.id === id)
587    if (!n) return
588    await patch($, cur => ({ expanded: [...new Set([...cur.expanded, n.id])] }))
589    await expandNode($, n)
590  }
591}
592function webUrl(_ex: Explorer, n: TreeNode): string {
593  return n.url || ''
594}
595
596async function openLocal($: EngineInterface, ex: Explorer, n: TreeNode): Promise<void> {
597  if (webUrl(ex, n)) await openUrl($, webUrl(ex, n))
598}
599async function select($: EngineInterface, n: TreeNode): Promise<void> {
600  if (n.kind === PLACEHOLDER || n.kind === EMPTY) return
601  const ex = await get($)
602  const isLeaf = !ex.nodes.some(c => c.parent === n.id)
603  await patch($, cur => {
604    const open = new Set(cur.expanded)
605    if (!isLeaf) open.has(n.id) ? open.delete(n.id) : open.add(n.id)
606    return { expanded: [...open], cursor: n.id, selected: n.path ? n.id : cur.selected }
607  })
608  if (!isLeaf && (await expandNode($, n))) await expandOpen($, n.id)
609  const detail = [`${n.kind.replace(/^section-/, '').replace(/_/g, ' ')} ${n.name}`, ...(n.path ? [`cli: ${n.path}`] : []), ...(n.note ? [n.note] : [])]
610  await patchView($, cur => (cur.cursor === n.id ? { detail } : {}))
611}
612function contextFor(ex: Explorer, n: TreeNode): string {
613  return [
614    'The user has this Databricks object selected in the Databricks pane; "this", "it" or "the selected" in the prompt likely refers to it.',
615    `${n.kind.replace(/_/g, ' ')}: ${n.name}`,
616    ...(n.path ? [`databricks CLI argument: ${n.path}`] : []),
617    ...(ex.target?.profile ? [`profile: ${ex.target.profile}`] : []),
618    ...(n.url ? [`url: ${n.url}`] : []),
619  ].join('\n')
620}
621const STATUS_GROUPS = new Set(['clusters', 'warehouses', 'apps', 'pipelines'])
622const waiting = new Map<string, () => Promise<void>>()
623
624function backgroundOf(result: unknown): string {
625  if (!result || typeof result !== 'object') return ''
626  const id = (result as { backgroundTaskId?: unknown }).backgroundTaskId
627  return typeof id === 'string' ? id : ''
628}
629
630type Call = { inv: Invocation; kind: NonNullable<ReturnType<typeof dbKind>> }
631
632type Lit = { touched: string[]; opened: string[] }
633
634async function afterDb($: EngineInterface, calls: Call[], stale: Set<string>, succeeded: boolean): Promise<Lit> {
635  const none: Lit = { touched: [], opened: [] }
636  const fallback = await implicitProfile($)
637  const canon = (p: string) => p || fallback || 'DEFAULT'
638  const resolved = (inv: Invocation) => dbProfile(inv) || inv.env.DATABRICKS_CONFIG_PROFILE || ''
639  const own = calls.filter(c => !c.inv.env.DATABRICKS_HOST && !c.inv.env.DATABRICKS_TOKEN)
640  const first = own[0]
641  if (!first) return none
642  const current = (await get($)).target
643  const want = resolved(first.inv)
644  const profile = current && canon(current.profile) === canon(want) ? current.profile : want
645  const moved = await point($, { kind: 'databricks', profile }, 'unasked')
646  const shown = await get($)
647  if (moved || shown.nodes.length === 0 || shown.onboard) await refresh($)
648  if ((await get($)).onboard) return none
649  const mine = own.filter(c => canon(resolved(c.inv)) === canon(profile))
650  const prior = await get($)
651  const before = new Set(prior.expanded)
652  const targets = [...new Set(mine.flatMap(c => dbTargets(c.inv)))]
653  await pool(targets, id => reveal($, chainOf(id)))
654  const reloads = new Set<string>()
655  for (const c of mine) {
656    if (c.kind !== 'modify' && c.kind !== 'upload') continue
657    if (!changesMembers(c.inv.args) && !STATUS_GROUPS.has(positionals(c.inv.args)[0] ?? '')) continue
658    for (const id of dbTargets(c.inv)) {
659      const chain = chainOf(id)
660      const parent = chain[chain.length - 1]
661      if (parent) reloads.add(parent)
662      if (id.startsWith('S:')) reloads.add(id)
663    }
664  }
665  await pool([...reloads].filter(id => isLoaded(prior.nodes, id) || stale.has(`${activeProfile}|${id}`)), id => reloadNode($, id))
666  const fresh = await get($)
667  const present = new Set(fresh.nodes.map(n => n.id))
668  if (!succeeded) return none
669  const touched = mine.flatMap(c => dbTargets(c.inv)).filter(id => present.has(id))
670  return { touched, opened: touched.flatMap(id => chainOf(id)).filter(id => !before.has(id) && present.has(id)) }
671}
672
673function toneOf(calls: Call[]): string {
674  const tones = new Set(calls.map(c => DB_TONE[c.kind]))
675  return TONE_RANK.find(t => tones.has(t)) ?? 'orange'
676}
677
678type Line = { icon: [string, string]; title: string } | { text: string; dim?: true } | { label: string; cmd: string }
679
680function onboardBlocks(o: Onboard): Line[][] {
681  const signIn: [string, string] = ['\u{f0342}', '→']
682  if (o.kind === 'missing') {
683    return [
684      [{ icon: ['\u{f05d6}', '⚠'], title: 'Databricks CLI not found' }, { text: 'This pane needs the Databricks CLI (databricks).' }],
685      [
686        { icon: ['\u{f01da}', '↓'], title: 'Install it' },
687        { label: 'macOS or Linux: ', cmd: 'brew tap databricks/tap && brew install databricks' },
688        { label: 'Windows: ', cmd: 'winget install Databricks.DatabricksCLI' },
689      ],
690      [{ icon: signIn, title: 'Then sign in to your workspace' }, { label: '', cmd: LOGIN }],
691      [{ text: "Press ↻ when you're done. Or ask Claude to set it up for you.", dim: true }],
692    ]
693  }
694  if (o.kind === 'signedOut') {
695    return [
696      [{ icon: ['\u{f0306}', '⚿'], title: 'Not signed in to Databricks' }, { label: '', cmd: LOGIN }],
697      [{ text: "Press ↻ when you're done. Several workspaces? Pick one with -p <profile>.", dim: true }],
698    ]
699  }
700  return [[{ icon: ['\u{f0164}', '⊘'], title: "Can't reach Databricks" }, { text: o.line, dim: true }, { text: 'Check your network or proxy, then press ↻.', dim: true }]]
701}
702
703function changesMembers(args: string[]): boolean {
704  const [group = '', verb = ''] = positionals(args)
705  if (group === 'fs') return false
706  if (group === 'sync') return true
707  return MEMBER_VERBS.test(verb) || (verb === 'update' && args.some(a => a.startsWith('--new-name')))
708}
709
710export const register: Register = (on, options) => {
711  glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
712  follow = options?.follow !== 'off'
713  fontHint = options?.fontHint !== 'off'
714
715  on('session.start', async ($, e, next) => {
716    useDrives((await $.env.get('OS')) === 'Windows_NT')
717    hinted = false
718    fellBack = false
719    quiet(detectGlyphs($).then(() => $.ui.invalidate('ui.render')))
720    await patchView($, () => ({ work: null, busy: {} }))
721    await $.command.register({ name: PANE, description: 'Open the Databricks pane; args: [profile]' })
722    const ex = await get($)
723    activeProfile = ex.target?.profile ?? ''
724    if (ex.target) quiet(openPane($, { id: PANE, title: titleFor(ex.target) }))
725    return next(e)
726  })
727
728  on('command.run', { command: PANE }, async ($, e) => {
729    const terminalOnly = !(await $.session.surfaces().catch(() => ['terminal'])).some(x => x !== 'terminal')
730    if (terminalOnly && e.presentation && !e.presentation.isFullscreen) return { text: 'The Databricks pane shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /databricks-pane.' }
731    if (terminalOnly && e.presentation && e.presentation.columns < 110) return { text: 'The Databricks pane shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /databricks-pane.' }
732    noDock = false
733    const [profile = ''] = tokenize(e.args ?? '')
734    await point($, { kind: 'databricks', profile }, 'asked', true)
735    await refresh($)
736    return { text: profile ? `Databricks pane on profile ${profile}.` : 'Databricks pane open.' }
737  })
738
739  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
740    const command = e.tool === 'Bash' ? e.command : ''
741    if (!/\bdatabricks\b/.test(command)) return next(e)
742    const calls = invocations(command, await cwdOf($)).flatMap(inv => {
743      const kind = dbKind(inv)
744      return kind ? [{ inv, kind }] : []
745    })
746    if (calls.length === 0) return next(e)
747    if (closed || noDock) return next(e)
748    const ex = await get($)
749    const have = new Set(ex.nodes.map(n => n.id))
750    const marks: Mark[] = calls.map(c => ({ ids: [HEADER, ...dbTargets(c.inv).filter(id => have.has(id))], tone: DB_TONE[c.kind] }))
751    const tone = toneOf(calls)
752    await markBusy($, marks)
753    const mine = await startWork($, [...new Set(marks.flatMap(m => m.ids.filter(id => id !== HEADER)))], tone)
754    let result: Awaited<ReturnType<typeof next>>
755    try {
756      result = await next(e)
757    } catch (err) {
758      await clearBusy($, marks)
759      await finishWork($, mine, tone)
760      throw err
761    }
762    const task = result.deny ? '' : backgroundOf(result.result)
763    const stale = new Set(loading.keys())
764    const settle = async () => {
765      await clearBusy($, marks)
766      let lit: Lit = { touched: [], opened: [] }
767      try {
768        if (!result.deny && !closed && !noDock) lit = await afterDb($, calls, stale, !result.isError)
769      } finally {
770        await finishWork($, mine, tone, lit.touched, lit.opened)
771      }
772    }
773    if (task) waiting.set(task, settle)
774    else void settle().catch(() => undefined)
775    return result
776  })
777
778  on('ui.close', async ($, e, next) => {
779    const result = await next(e)
780    if (e.id === PANE && e.origin.kind === 'person') closed = true
781    return result
782  })
783
784  on('ui.message', async ($, e, next) => {
785    if (e.requestId === PANE && typeof e.element === 'string' && e.element.startsWith('onboard-') && e.data && typeof e.data === 'object') {
786      const data = e.data as { press?: unknown; copy?: unknown }
787      const text = typeof data.press === 'string' ? data.press : typeof data.copy === 'string' ? data.copy : ''
788      if (text) await copyOf($, text, e.surface)
789      return {}
790    }
791    if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
792    const view = views.get(e.surface) ?? { from: 0, max: 0 }
793    const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
794    const ex = await get($)
795    if (typeof data.scrollTo === 'number') {
796      const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
797      if (to !== ex.scroll) await patchView($, () => ({ scroll: to }))
798      return {}
799    }
800    if (typeof data.copy === 'string') {
801      const n = ex.nodes.find(x => x.id === data.copy)
802      if (n) await copyOf($, n.path || n.name, e.surface)
803      return {}
804    }
805    if (typeof data.press === 'string') {
806      const n = ex.nodes.find(x => x.id === data.press)
807      if (!n) return {}
808      if (ex.scroll === null) await patchView($, () => ({ scroll: view.from }))
809      if (data.ctrl) await openWeb($, ex, n)
810      else if (data.shift) await openLocal($, ex, n)
811      else await press($, n)
812      return {}
813    }
814    if (typeof data.key !== 'string') return {}
815    const rows = visible(ex, SORT, rootOf(ex))
816    const at = rows.findIndex(r => r.node.id === ex.cursor)
817    const cur = rows[at]
818    const move = (d: number) => {
819      const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
820      return target ? patchView($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
821    }
822    if (data.key === 'up' || data.key === 'k') await move(-1)
823    else if (data.key === 'down' || data.key === 'j') await move(1)
824    else if (data.key === 'pageup') await move(-10)
825    else if (data.key === 'pagedown') await move(10)
826    else if (data.key === 'home') await move(-rows.length)
827    else if (data.key === 'end') await move(rows.length)
828    else if (cur && (data.key === 'y' || data.key === 'Y')) await copyOf($, cur.node.path || cur.node.name, e.surface)
829    else if (cur && (data.key === 'right' || data.key === 'l') && !cur.leaf && !cur.open) await select($, cur.node)
830    else if (cur && (data.key === 'left' || data.key === 'h')) {
831      if (!cur.leaf && cur.open) await patchView($, x => ({ expanded: x.expanded.filter(id => id !== cur.node.id) }))
832      else if (cur.node.parent) await patchView($, () => ({ cursor: cur.node.parent }))
833    } else if (cur && (data.key === ' ' || data.key === 'return')) await (data.key === 'return' && cur.leaf ? openLocal($, ex, cur.node) : select($, cur.node))
834    return {}
835  })
836
837  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
838    const view = views.get('terminal') ?? views.get('desktop') ?? { from: 0, max: 0 }
839    const ex = await get($)
840    const to = Math.max(0, Math.min(view.max, (ex.scroll ?? view.from) + Math.sign(e.by) * Math.max(3, Math.abs(e.by))))
841    if (to !== ex.scroll) await patchView($, () => ({ scroll: to }))
842    return {}
843  })
844
845  on('prompt.submit', async ($, e, next) => {
846    if (e.origin?.kind === 'task-notification') {
847      for (const [task, settle] of waiting) {
848        if (!e.text.includes(task)) continue
849        waiting.delete(task)
850        void settle().catch(() => undefined)
851      }
852    }
853    const ex = await get($)
854    const n = ex.nodes.find(x => x.id === ex.selected)
855    if (!ex.target || closed || noDock) return next(e)
856    const hint = fontNote()
857    const context = [...(e.context ?? []), ...(n ? [contextFor(ex, n)] : []), ...(hint ? [hint] : [])]
858    return context.length === (e.context ?? []).length ? next(e) : next({ ...e, context })
859  })
860
861  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
862    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
863    if (e.surface === 'terminal' && e.props.placement === 'inline') {
864      noDock = true
865      void $.ui.close({ id: PANE }).catch(() => undefined)
866      const { Box: Empty } = $.ui.resolve(e)
867      return <Empty />
868    }
869    const tier = tierFor(e.surface)
870    if (e.surface === 'terminal' && tier === 'plain' && glyphSetting === 'auto') fellBack = true
871    const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
872    const ex = await get($)
873    const now = await $.clock.now()
874    const busyTone = (id: string) => {
875      const b = ex.busy[id]
876      return b && now - b.at < BUSY_MAX_MS ? b.tone : ''
877    }
878    const work = live(ex.work, now) ? ex.work : null
879    const brightSet = new Set(work?.lit ?? [])
880    const dimSet = new Set(work?.dim ?? [])
881    const width = Math.max(20, e.props.bodyColumns)
882    const rows = visible(ex, SORT, rootOf(ex))
883    const detailRows = ex.detail.length ? Math.min(ex.detail.length, DETAIL_ROWS) + 2 : 0
884    const room = Math.max(5, Math.min(WINDOW, (e.props.scroll?.bodyRows ?? 40) - 4 - detailRows))
885    const focusId = follow && work && work.lit.length ? (work.lit[work.lit.length - 1] ?? ex.cursor) : ex.cursor
886    const at = Math.max(0, rows.findIndex(r => r.node.id === focusId))
887    const isLit = (id: string) => brightSet.has(id) || dimSet.has(id)
888    const lit = follow && work ? rows.findIndex(r => isLit(r.node.id)) : -1
889    const fits = lit >= 0 && at - lit < room - 2
890    const base = Math.max(0, Math.min(Math.max(0, rows.findIndex(r => r.node.id === ex.cursor)) - Math.floor(room / 2), rows.length - room))
891    const inView = lit >= base && at < base + room
892    const flashing = lit >= 0 && !inView
893    const nearBottom = (span: number) => Math.max(0, Math.min(fits ? Math.min(Math.max(0, lit - 1), at - span + 3) : at - span + 3, rows.length - span))
894    let from = flashing ? nearBottom(room) : base
895    const cap = Math.max(1, Math.floor(room / 3))
896    const chain = new Set(flashing ? ancestors(ex.nodes, focusId) : [])
897    const keep = (id: string) => chain.has(id) || isLit(id)
898    let pinned = flashing ? rows.slice(0, from).filter(r => keep(r.node.id)).slice(-cap) : []
899    if (pinned.length) {
900      from = nearBottom(Math.max(3, room - pinned.length - 1))
901      pinned = rows.slice(0, from).filter(r => keep(r.node.id)).slice(-cap)
902    }
903    const max = Math.max(0, rows.length - room)
904    if (ex.scroll !== null) {
905      from = Math.max(0, Math.min(ex.scroll, max))
906      pinned = []
907    }
908    views.set(e.surface, { from, max })
909    const shown = rows.slice(from, from + room - pinned.length)
910    const sel = ex.nodes.find(n => n.id === ex.selected)
911    const clip = (s: string, max = width) => (s.length > max ? s.slice(0, max - 1) + '…' : s)
912    const spin = (tone: string): Seg => ({ t: ' ', spin: true, b: true, c: TONES[tone]?.bright[1] ?? SHIMMER[1] })
913    const rowSpec = (r: (typeof rows)[number]): RowSpec => {
914      const n = r.node
915      const g = glyph(n, tier)
916      const arrow = r.leaf ? '  ' : tier === 'plain' ? (r.open ? '▾ ' : '▸ ') : r.open ? '\u{f47c} ' : '\u{f460} '
917      const note = n.note ? ` ${n.note}` : ''
918      const cols = Math.max(4, width - r.depth * 2 - 6 - note.length)
919      const isBright = brightSet.has(n.id)
920      const isDim = !isBright && dimSet.has(n.id)
921      const name = clip(n.name, cols)
922      const faded = n.hidden || n.kind === 'placeholder' || n.kind === EMPTY
923      const tone = work?.tone ?? 'orange'
924      const busy = busyTone(n.id)
925      const left: Seg[] = [
926        { t: '  '.repeat(r.depth) },
927        { t: arrow, c: '#7a7a86' },
928        isBright || isDim ? { t: g.char + ' ', sh: tone, dim: isDim, one: true } : { t: g.char + ' ', c: faded ? '#6e6e7a' : g.color },
929        isBright || isDim ? { t: name, sh: tone, dim: isDim, b: isBright } : { t: name, c: faded ? '#6e6e7a' : g.label, b: n.id === ex.selected },
930      ]
931      if (busy) left.push(spin(busy))
932      return { id: n.kind === 'placeholder' || n.kind === EMPTY ? '' : n.id, left: clean(left), right: note ? [{ t: note, c: '#6e6e7a' }] : [] }
933    }
934    const note = (text: string): RowSpec => ({ id: '', left: [{ t: text, c: '#6e6e7a' }], right: [] })
935    const specs: RowSpec[] = [...pinned.map(rowSpec), ...(pinned.length > 0 ? [note('  ⋮')] : []), ...shown.map(rowSpec)]
936    const barSize = Math.max(1, Math.round((specs.length * shown.length) / Math.max(1, rows.length)))
937    const bar =
938      rows.length > shown.length + pinned.length
939        ? { pos: max ? Math.round((from / max) * (specs.length - barSize)) : 0, size: barSize, thumb: '#5b9bd5', track: '#4a4a56' }
940        : undefined
941    const icon = (nerd: string, fallback: string) => (tier === 'plain' ? fallback : nerd)
942    const rootNode = ex.nodes.find(n => n.id === rootOf(ex))
943    const rootName = rootNode ? rootNode.name : ''
944    const head: RowSpec = {
945      id: '',
946      left: clean([
947        { t: `${titleGlyph(tier)} `, c: TITLE_COLOR },
948        { t: rootName || (label(ex.target)), b: true },
949        ...(ex.status ? [{ t: `  ${ex.status}`, c: '#6e6e7a' }] : []),
950        ...(busyTone(HEADER) ? [spin(busyTone(HEADER))] : []),
951      ]),
952      right: [],
953    }
954    const spinner = tier === 'plain' ? PLAIN_SPINNER : []
955    return (
956      <Box flexDirection="column" minHeight={Math.max(1, e.props.scroll?.bodyRows ?? 1)}>
957        <Box flexDirection="row">
958          <Box flexGrow={1} flexShrink={1}>
959            <Client key="head" module="./rows.tsx" props={{ rows: [head], active: '', activeBg: '', hoverBg: '', tones: TONES, spinner } satisfies RowsProps} />
960          </Box>
961          <Box flexDirection="row" gap={2}>
962            {rootNode && <Button key="up" plain dimColor label={icon('\u{f005d}', '↑')} onPress={() => quiet(goUp($))} />}
963            {rootNode && <Button key="home" plain dimColor label={icon('\u{f02dc}', '⌂')} onPress={() => quiet(goHome($))} />}
964            <Button key="refresh" plain dimColor label={icon('\u{f0450}', '↻')} onPress={() => quiet(refresh($))} />
965            <Button key="collapse" plain dimColor label={icon('\u{eac5}', '⊟')} onPress={() => quiet(patchView($, () => ({ expanded: [] })))} />
966            {sel && <Button key="clear" plain label={icon('\u{f0156}', '✕')} onPress={() => quiet(patchView($, () => ({ selected: '', detail: [] })))} />}
967            <Text> </Text>
968          </Box>
969        </Box>
970        <Box flexDirection="row">
971          <Box flexGrow={1}>
972            {work && !ex.query ? (
973              <Client key="working" module="./rows.tsx" props={{ rows: [{ id: '', left: [{ t: 'Claude is working in Databricks...', sh: work.tone }], right: [] }], active: '', activeBg: '', hoverBg: '', tones: TONES, spinner } satisfies RowsProps} />
974            ) : (
975            <Input
976              key="q"
977              label="/ "
978              placeholder="search"
979              submitLabel="jump"
980              autoFocus
981              value={ex.query}
982              onInput={(v: string) => quiet(patchView($, () => ({ query: v })))}
983              onSubmit={(v: string) => quiet(patch($, cur => jumpTo(cur, v)))}
984            />
985            )}
986          </Box>
987          {ex.query ? <Button key="clearq" plain dimColor label={tier === 'plain' ? '×' : '\u{f0156}'} onPress={() => quiet(patchView($, () => ({ query: '' })))} /> : null}
988        </Box>
989        {ex.onboard && (
990          <Box flexDirection="column" marginTop={1}>
991            {onboardBlocks(ex.onboard).map((block, b) => (
992              <Box key={`block-${b}`} flexDirection="column" marginTop={b > 0 ? 1 : 0}>
993                {block.map((line, i) =>
994                  'title' in line ? (
995                    <Text key={`line-${i}`} wrap="wrap">
996                      <Text color={TITLE_COLOR}>{`${icon(line.icon[0], line.icon[1])} `}</Text>
997                      <Text bold>{line.title}</Text>
998                    </Text>
999                  ) : 'cmd' in line ? (
1000                    <Box key={`line-${i}`} marginLeft={2}>
1001                      <Client
1002                        key={`onboard-${b}-${i}`}
1003                        module="./rows.tsx"
1004                        props={{ rows: [{ id: line.cmd, left: [...(line.label ? [{ t: line.label }] : []), { t: line.cmd, c: ACCENT }], right: [] }], active: '', activeBg: '', hoverBg: '#2d2f33', tones: TONES, spinner } satisfies RowsProps}
1005                      />
1006                    </Box>
1007                  ) : (
1008                    <Box key={`line-${i}`} marginLeft={2}>
1009                      <Text wrap="wrap" dimColor={line.dim}>
1010                        {line.text}
1011                      </Text>
1012                    </Box>
1013                  ),
1014                )}
1015              </Box>
1016            ))}
1017          </Box>
1018        )}
1019        {!ex.onboard && ex.nodes.length === 0 && <Text dimColor>{ex.target ? 'nothing loaded yet' : HINT}</Text>}
1020        {!ex.onboard && <Client key="rows" module="./rows.tsx" props={{ rows: specs, active: ex.cursor, activeBg: '#3e4451', hoverBg: '#2d2f33', tones: TONES, spinner, ...(bar ? { bar } : {}) } satisfies RowsProps} />}
1021        {!ex.onboard && ex.detail.length > 0 && (
1022          <Box flexDirection="column" marginTop={1}>
1023            {ex.detail.slice(0, DETAIL_ROWS).map((l, i) => (
1024              <Text key={String(i)} dimColor={i > 0} bold={i === 0} wrap="truncate-end">
1025                {l.replace(/\s+/g, ' ')}
1026              </Text>
1027            ))}
1028            {sel && (
1029              <Box flexDirection="row" gap={2}>
1030                {webUrl(ex, sel) && <Button key="web" plain label={`${icon('\u{f059f}', '◎')} open in Databricks`} onPress={() => quiet(openWeb($, ex, sel))} />}
1031              </Box>
1032            )}
1033          </Box>
1034        )}
1035        <Box flexGrow={1} />
1036        {sel && (
1037          <Text dimColor wrap="truncate-start">
1038            selected: {sel.path || sel.name}
1039          </Text>
1040        )}
1041      </Box>
1042    )
1043  })
1044}
1045
hooks/open.ts 41 lines
1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4  'set -f',
5  'f=$1',
6  'if command -v gio >/dev/null; then',
7  '  if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8  '    case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9  '      video/* | audio/*)',
10  '        app=$(xdg-mime query default text/plain)',
11  '        IFS=:',
12  '        for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13  '          [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14  '        done',
15  '        echo "No text/plain desktop application found for $f" >&2',
16  '        exit 1',
17  '        ;;',
18  '    esac',
19  '  fi',
20  '  if (exec gio open "$f"); then exit 0; fi',
21  'fi',
22  'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28  const argv = os === 'linux'
29    ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30    : os === 'darwin'
31      ? ['open', '--', target]
32      : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33  return {
34    argv,
35    init: {
36      timeoutMs: 10_000,
37      ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38    },
39  }
40}
41
hooks/icons.ts 68 lines
1import type { TreeNode } from '../types'
2import { DB_CODEPOINTS } from './icon-data'
3
4type Rgb = [number, number, number]
5type Icon = { db: string; nerd: string; rgb: Rgb; plain: string }
6export type Tier = 'databricks' | 'nerd' | 'plain'
7export type Glyph = { char: string; color: string | undefined; label?: string }
8
9const RED: Rgb = [255, 54, 33]
10const SLATE: Rgb = [148, 163, 184]
11const ICONS: Record<string, Icon> = {
12  'section-workspace': { db: 'ui_workspace', nerd: '\u{f0b9f}', rgb: RED, plain: '▣' },
13  'section-catalog': { db: 'ui_catalog_section', nerd: '\u{f05da}', rgb: RED, plain: '▣' },
14  'section-compute': { db: 'ui_compute', nerd: '\u{f048b}', rgb: RED, plain: '▣' },
15  'section-jobs': { db: 'ui_jobs', nerd: '\u{f0bb4}', rgb: RED, plain: '▣' },
16  'section-pipelines': { db: 'pipeline', nerd: '\u{f07e5}', rgb: RED, plain: '▣' },
17  'section-apps': { db: 'ui_apps', nerd: '\u{f003b}', rgb: RED, plain: '▣' },
18  'section-dashboards': { db: 'ui_dashboard', nerd: '\u{f056e}', rgb: RED, plain: '▣' },
19  folder: { db: 'ui_folder', nerd: '\u{f024b}', rgb: [184, 175, 137], plain: '■' },
20  repo: { db: 'ui_repo', nerd: '\u{f02a2}', rgb: [240, 80, 50], plain: '⑂' },
21  notebook: { db: 'ui_notebook', nerd: '\u{f082e}', rgb: [96, 165, 250], plain: '▤' },
22  file: { db: 'file', nerd: '\u{f0214}', rgb: SLATE, plain: '·' },
23  library: { db: 'file_code', nerd: '\u{f03d7}', rgb: SLATE, plain: '·' },
24  dashboard: { db: 'ui_dashboard', nerd: '\u{f056e}', rgb: [251, 191, 36], plain: '▦' },
25  catalog: { db: 'ui_catalog', nerd: '\u{f05da}', rgb: [167, 139, 250], plain: '◆' },
26  schema: { db: 'ui_schema', nerd: '\u{f01bc}', rgb: [129, 140, 248], plain: '◇' },
27  table: { db: 'ui_table', nerd: '\u{f04eb}', rgb: [74, 222, 128], plain: '▦' },
28  view: { db: 'view', nerd: '\u{f0208}', rgb: [45, 212, 191], plain: '▧' },
29  materialized_view: { db: 'materialized_view', nerd: '\u{f0208}', rgb: [45, 212, 191], plain: '▧' },
30  streaming_table: { db: 'streaming_table', nerd: '\u{f04eb}', rgb: [52, 211, 153], plain: '▦' },
31  metric_view: { db: 'ui_metric_view', nerd: '\u{f04eb}', rgb: [167, 139, 250], plain: '▦' },
32  catalog_system: { db: 'ui_catalog_system', nerd: '\u{f05da}', rgb: [148, 163, 184], plain: '◆' },
33  catalog_shared: { db: 'ui_catalog_shared', nerd: '\u{f05da}', rgb: [45, 212, 191], plain: '◆' },
34  catalog_workspace: { db: 'ui_catalog_workspace', nerd: '\u{f05da}', rgb: [167, 139, 250], plain: '◆' },
35  folder_users: { db: 'ui_folder_users', nerd: '\u{f0849}', rgb: [184, 175, 137], plain: '■' },
36  folder_shared: { db: 'ui_folder_shared', nerd: '\u{f024b}', rgb: [184, 175, 137], plain: '■' },
37  volume: { db: 'volume', nerd: '\u{f02ca}', rgb: [251, 146, 60], plain: '◎' },
38  cluster: { db: 'ui_compute', nerd: '\u{f048b}', rgb: [56, 189, 248], plain: '◉' },
39  warehouse: { db: 'ui_warehouse', nerd: '\u{f140b}', rgb: [250, 204, 21], plain: '◉' },
40  job: { db: 'ui_jobs', nerd: '\u{f0bb4}', rgb: [244, 114, 182], plain: '▶' },
41  pipeline: { db: 'pipeline', nerd: '\u{f07e5}', rgb: [52, 211, 153], plain: '⇶' },
42  app: { db: 'ui_apps', nerd: '\u{f003b}', rgb: [251, 113, 133], plain: '◈' },
43  placeholder: { db: '', nerd: '\u{f0453}', rgb: [110, 110, 122], plain: '…' },
44  empty: { db: '', nerd: '∅', rgb: [110, 110, 122], plain: '∅' },
45}
46const FALLBACK: Icon = { db: 'file', nerd: '\u{f0214}', rgb: SLATE, plain: '·' }
47
48const hex = (c: Rgb) => `#${c.map(v => v.toString(16).padStart(2, '0')).join('')}`
49
50function variant(n: TreeNode): string {
51  if (n.kind === 'catalog') return n.note === 'system' && n.name === 'system' ? 'catalog_system' : n.note === 'system' || n.note === 'deltasharing' ? 'catalog_shared' : n.name === 'workspace' ? 'catalog_workspace' : 'catalog'
52  if (n.kind === 'folder' && n.path === '/Users') return 'folder_users'
53  if (n.kind === 'folder' && n.path === '/Shared') return 'folder_shared'
54  return n.kind
55}
56
57export function glyph(n: TreeNode, tier: Tier): Glyph {
58  const icon = ICONS[variant(n)] ?? ICONS[n.kind] ?? FALLBACK
59  const cp = DB_CODEPOINTS[icon.db]
60  const char = tier === 'databricks' && cp ? String.fromCodePoint(cp) : tier === 'plain' ? icon.plain : icon.nerd
61  return { char, color: hex(icon.rgb) }
62}
63
64export function titleGlyph(tier: Tier): string {
65  const cp = DB_CODEPOINTS.ui_logo
66  return tier === 'databricks' && cp ? String.fromCodePoint(cp) : tier === 'plain' ? '◈' : '\u{f0b9f}'
67}
68
hooks/rows.tsx 144 lines
1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6  rows: RowSpec[]
7  active: string
8  activeBg: string
9  hoverBg: string
10  tones: Record<string, { bright: string[]; dim: string[] }>
11  spinner?: string[]
12  pointer?: boolean
13  bar?: { pos: number; size: number; thumb: string; track: string }
14}
15type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
16
17const TICK_MS = 90
18const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20function shimmer(i: number, phase: number, len: number, palette: string[]): string {
21  const band = ((phase * 1.6) % (len + 8)) - 4
22  const d = Math.abs(i - band)
23  return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
24}
25
26const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
27  const { Box, Text } = surface.elements
28  let state = surface.state
29  if (state === undefined) {
30    state = { hover: -1, phase: 0, drag: false, ref: {} }
31    surface.setState(state)
32  }
33  const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
34  if (lit && !state.ref.stop) {
35    state.ref.stop = surface.every(TICK_MS, () => {
36      const cur = surface.state
37      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
38    })
39  } else if (!lit && state.ref.stop) {
40    state.ref.stop()
41    state.ref.stop = undefined
42  }
43  if (props.pointer === false) {
44    state.ref.unpoint?.()
45    state.ref.unpoint = undefined
46  } else state.ref.unpoint = surface.onPointer(e => {
47    const cur = surface.state ?? state
48    const span = Math.max(1, props.rows.length - 1)
49    const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
50    if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
51      if (e.type === 'up' || e.type === 'leave') {
52        surface.setState({ ...cur, drag: false })
53        return
54      }
55      if (e.type === 'down' || e.type === 'move') {
56        if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
57        surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
58        return
59      }
60    }
61    if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
62      if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
63      return
64    }
65    if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
66    const row = props.rows[e.y]
67    if (e.type === 'down' && e.button === 'right' && row?.id) {
68      surface.post({ copy: row.id, shift: Boolean(e.shift) })
69      return
70    }
71    if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
72    let x = 0
73    for (const seg of row.left) {
74      const w = [...seg.t].length
75      if (seg.tab && e.x >= x && e.x < x + w) {
76        surface.post({ tab: seg.tab })
77        return
78      }
79      x += w
80    }
81    if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
82  })
83  surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
84  const frames = props.spinner?.length ? props.spinner : FRAMES
85  const draw = (s: Seg, k: number) => {
86    if (s.spin) {
87      return (
88        <Text key={String(k)} color={s.c} bold={s.b}>
89          {s.t + (frames[state.phase % frames.length] ?? '')}
90        </Text>
91      )
92    }
93    const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
94    if (!palette) {
95      return (
96        <Text key={String(k)} color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
97          {s.t}
98        </Text>
99      )
100    }
101    if (s.one) {
102      return (
103        <Text key={String(k)} color={shimmer(-1, state.phase, s.t.length, palette)}>
104          {s.t}
105        </Text>
106      )
107    }
108    const chars = [...s.t]
109    return (
110      <Text key={String(k)} bold={s.b}>
111        {chars.map((ch, i) => (
112          <Text key={String(i)} color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
113        ))}
114      </Text>
115    )
116  }
117  return (
118    <Box flexDirection="column">
119      {props.rows.map((r, i) => (
120        <Box
121          key={r.id || `row-${i}`}
122          flexDirection="row"
123          height={1}
124          overflow="hidden"
125          backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
126        >
127          <Box flexShrink={1} overflow="hidden">
128            {r.left.map(draw)}
129          </Box>
130          <Box flexGrow={1} />
131          {r.right.map(draw)}
132          {props.bar && (
133            <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
134              {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
135            </Text>
136          )}
137        </Box>
138      ))}
139    </Box>
140  )
141}
142
143export default Rows
144
hooks/tree.ts 130 lines
1import type { Explorer, TreeNode } from '../types'
2
3export type Row = { node: TreeNode; depth: number; open: boolean; leaf: boolean }
4
5export function empty(): Explorer {
6  return {
7    target: null,
8    nodes: [],
9    expanded: [],
10    query: '',
11    cursor: '',
12    selected: '',
13    detail: [],
14    status: '',
15    work: null,
16    onboard: null,
17    busy: {},
18    scroll: null,
19    root: '',
20  }
21}
22
23function childrenOf(nodes: TreeNode[]): Map<string, TreeNode[]> {
24  const kids = new Map<string, TreeNode[]>()
25  for (const n of nodes) {
26    const list = kids.get(n.parent)
27    if (list) list.push(n)
28    else kids.set(n.parent, [n])
29  }
30  return kids
31}
32
33export const EMPTY = 'empty'
34
35export function emptyMark(parent: string): TreeNode {
36  return { id: `${parent}/∅`, parent, kind: EMPTY, name: 'empty', path: '', hidden: false, sig: '', note: '' }
37}
38
39export function isLoaded(nodes: TreeNode[], id: string): boolean {
40  let any = false
41  for (const n of nodes) {
42    if (n.parent !== id) continue
43    if (n.kind === 'placeholder') return false
44    any = true
45  }
46  return any
47}
48
49export function merge(nodes: TreeNode[], parent: string, kids: TreeNode[]): TreeNode[] {
50  const under = childrenOf(nodes)
51  const subtree = (id: string, out: TreeNode[] = []): TreeNode[] => {
52    for (const c of under.get(id) ?? []) {
53      out.push(c)
54      subtree(c.id, out)
55    }
56    return out
57  }
58  const known = new Map(nodes.map(n => [n.id, n]))
59  const loaded = (id: string) => {
60    const cs = under.get(id) ?? []
61    return cs.length > 0 && !cs.some(c => c.kind === 'placeholder')
62  }
63  const same = (k: TreeNode) => {
64    const old = known.get(k.id)
65    return Boolean(old) && old?.kind === k.kind && old?.sig === k.sig
66  }
67  const keep = new Set(kids.filter(k => k.kind !== 'placeholder' && same(k) && loaded(k.id)).map(k => k.id))
68  const gone = new Set(subtree(parent).map(n => n.id))
69  const out = nodes.filter(n => !gone.has(n.id))
70  for (const k of kids) {
71    if (k.kind === 'placeholder' && keep.has(k.parent)) continue
72    out.push(k)
73    if (keep.has(k.id)) subtree(k.id, out)
74  }
75  if (kids.length === 0) out.push(emptyMark(parent))
76  return out
77}
78
79export function ancestors(nodes: TreeNode[], id: string, byId?: Map<string, TreeNode>): string[] {
80  const map = byId ?? new Map(nodes.map(n => [n.id, n]))
81  const out: string[] = []
82  let cur = map.get(id)
83  while (cur && cur.parent) {
84    out.push(cur.parent)
85    cur = map.get(cur.parent)
86  }
87  return out
88}
89
90const RANK: Record<string, number> = { folder: 0, group: 3 }
91const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
92
93function sorted(list: TreeNode[]): TreeNode[] {
94  return [...list].sort(
95    (a, b) => (RANK[a.kind] ?? 2) - (RANK[b.kind] ?? 2) || collator.compare(a.name, b.name),
96  )
97}
98
99export function visible(ex: Explorer, sort = false, root = ''): Row[] {
100  const kids = childrenOf(ex.nodes)
101  const q = ex.query.trim().toLowerCase()
102  let keep: Set<string> | null = null
103  if (q) {
104    keep = new Set()
105    const byId = new Map(ex.nodes.map(n => [n.id, n]))
106    for (const n of ex.nodes) {
107      if (!n.name.toLowerCase().includes(q) && !n.note.toLowerCase().includes(q)) continue
108      let cur: TreeNode | undefined = n
109      while (cur && !keep.has(cur.id)) {
110        keep.add(cur.id)
111        cur = byId.get(cur.parent)
112      }
113    }
114  }
115  const open = new Set(ex.expanded)
116  const rows: Row[] = []
117  const walk = (parent: string, depth: number) => {
118    const list = kids.get(parent) ?? []
119    for (const n of sort ? sorted(list) : list) {
120      if (keep && !keep.has(n.id)) continue
121      const leaf = !kids.has(n.id)
122      const isOpen = !leaf && (keep ? true : open.has(n.id))
123      rows.push({ node: n, depth, open: isOpen, leaf })
124      if (isOpen) walk(n.id, depth + 1)
125    }
126  }
127  walk(root, 0)
128  return rows
129}
130
hooks/databricks.ts 241 lines
1import type { TreeNode } from '../types'
2
3export const PLACEHOLDER = 'placeholder'
4
5export const SECTIONS = [
6  { id: 'S:workspace', name: 'Workspace', kind: 'section-workspace' },
7  { id: 'S:catalog', name: 'Catalog', kind: 'section-catalog' },
8  { id: 'S:compute', name: 'Compute', kind: 'section-compute' },
9  { id: 'S:jobs', name: 'Jobs', kind: 'section-jobs' },
10  { id: 'S:pipelines', name: 'Pipelines', kind: 'section-pipelines' },
11  { id: 'S:apps', name: 'Apps', kind: 'section-apps' },
12  { id: 'S:dashboards', name: 'Dashboards', kind: 'section-dashboards' },
13] as const
14
15type Rec = Record<string, unknown>
16
17function node(id: string, parent: string, kind: string, name: string, path: string, extra: Partial<TreeNode> = {}): TreeNode {
18  return { id, parent, kind, name, path, hidden: false, sig: '', note: '', ...extra }
19}
20
21export function stub(parent: string): TreeNode {
22  return node(`${parent}/…`, parent, PLACEHOLDER, 'loading…', '')
23}
24
25export function roots(): TreeNode[] {
26  return SECTIONS.flatMap(s => [node(s.id, '', s.kind, s.name, ''), stub(s.id)])
27}
28
29function str(v: unknown): string {
30  return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : ''
31}
32
33function at(rec: Rec, ...keys: string[]): string {
34  let cur: unknown = rec
35  for (const k of keys) cur = cur && typeof cur === 'object' ? (cur as Rec)[k] : undefined
36  return str(cur)
37}
38
39export function records(stdout: string): Rec[] {
40  const start = stdout.search(/[[{]/)
41  if (start < 0) return []
42  const parsed: unknown = JSON.parse(stdout.slice(start))
43  const list = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' ? Object.values(parsed).find(Array.isArray) : undefined
44  return (list ?? []).filter((r): r is Rec => Boolean(r) && typeof r === 'object')
45}
46
47export function listCalls(n: TreeNode): string[][] {
48  const [cat = '', sch = ''] = n.path.split('.')
49  switch (n.kind) {
50    case 'section-workspace':
51      return [['workspace', 'list', '/']]
52    case 'folder':
53    case 'repo':
54      return [['workspace', 'list', n.path]]
55    case 'section-catalog':
56      return [['catalogs', 'list']]
57    case 'catalog':
58      return [['schemas', 'list', cat]]
59    case 'schema':
60      return [
61        ['tables', 'list', cat, sch, '--omit-columns', '--omit-properties', '--omit-username'],
62        ['volumes', 'list', cat, sch],
63      ]
64    case 'section-compute':
65      return [['clusters', 'list'], ['warehouses', 'list']]
66    case 'section-jobs':
67      return [['jobs', 'list']]
68    case 'section-pipelines':
69      return [['pipelines', 'list-pipelines']]
70    case 'section-apps':
71      return [['apps', 'list']]
72    case 'section-dashboards':
73      return [['lakeview', 'list']]
74    default:
75      return []
76  }
77}
78
79const OBJECT_KIND: Record<string, string> = { DIRECTORY: 'folder', NOTEBOOK: 'notebook', FILE: 'file', REPO: 'repo', DASHBOARD: 'dashboard', LIBRARY: 'library' }
80
81function base(path: string): string {
82  return path.replace(/\/+$/, '').split('/').pop() || path
83}
84
85export function children(n: TreeNode, outputs: string[], host: string): TreeNode[] {
86  const out: TreeNode[] = []
87  const push = (child: TreeNode, open: boolean) => {
88    child.url = urlFor(child, host)
89    out.push(child)
90    if (open) out.push(stub(child.id))
91  }
92  const [o0 = '', o1 = ''] = outputs
93  switch (n.kind) {
94    case 'section-workspace':
95    case 'folder':
96    case 'repo':
97      for (const r of records(o0)) {
98        const path = at(r, 'path')
99        if (!path) continue
100        const kind = OBJECT_KIND[at(r, 'object_type')] ?? 'file'
101        push(node(`WS:${path}`, n.id, kind, base(path), path, { sig: at(r, 'object_id'), note: kind === 'notebook' ? at(r, 'language').toLowerCase() : '' }), kind === 'folder' || kind === 'repo')
102        const dash = kind === 'dashboard' ? at(r, 'resource_id') : ''
103        const made = out[out.length - 1]
104        if (made && host) made.url = dash ? `${host}/dashboardsv3/${encodeURIComponent(dash)}/published` : kind === 'dashboard' ? `${host}/#workspace${path.split('/').map(encodeURIComponent).join('/')}` : made.url
105      }
106      break
107    case 'section-catalog':
108      for (const r of records(o0)) {
109        const name = at(r, 'name')
110        if (name) push(node(`UC:${name}`, n.id, 'catalog', name, name, { sig: at(r, 'created_at'), note: at(r, 'catalog_type').toLowerCase().replace(/_catalog$/, '') }), true)
111      }
112      break
113    case 'catalog':
114      for (const r of records(o0)) {
115        const name = at(r, 'name')
116        if (name) push(node(`UC:${n.path}.${name}`, n.id, 'schema', name, at(r, 'full_name') || `${n.path}.${name}`, { sig: at(r, 'created_at') }), true)
117      }
118      break
119    case 'schema':
120      for (const r of records(o0)) {
121        const name = at(r, 'name')
122        const type = at(r, 'table_type')
123        const kind = type === 'METRIC_VIEW' ? 'metric_view' : type === 'MATERIALIZED_VIEW' ? 'materialized_view' : type === 'STREAMING_TABLE' ? 'streaming_table' : type === 'VIEW' ? 'view' : 'table'
124        if (name) push(node(`UC:${n.path}.${name}`, n.id, kind, name, at(r, 'full_name') || `${n.path}.${name}`, { note: type.toLowerCase().replace(/_/g, ' ') }), false)
125      }
126      for (const r of records(o1)) {
127        const name = at(r, 'name')
128        if (name) push(node(`UV:${n.path}.${name}`, n.id, 'volume', name, at(r, 'full_name') || `${n.path}.${name}`, { note: 'volume' }), false)
129      }
130      break
131    case 'section-compute':
132      for (const r of records(o0)) {
133        const id = at(r, 'cluster_id')
134        if (id) push(node(`CL:${id}`, n.id, 'cluster', at(r, 'cluster_name') || id, id, { note: at(r, 'state').toLowerCase() }), false)
135      }
136      for (const r of records(o1)) {
137        const id = at(r, 'id')
138        if (id) push(node(`SW:${id}`, n.id, 'warehouse', at(r, 'name') || id, id, { note: at(r, 'state').toLowerCase() }), false)
139      }
140      break
141    case 'section-jobs':
142      for (const r of records(o0)) {
143        const id = at(r, 'job_id')
144        if (id) push(node(`J:${id}`, n.id, 'job', at(r, 'settings', 'name') || id, id), false)
145      }
146      break
147    case 'section-pipelines':
148      for (const r of records(o0)) {
149        const id = at(r, 'pipeline_id')
150        if (id) push(node(`P:${id}`, n.id, 'pipeline', at(r, 'name') || id, id, { note: at(r, 'state').toLowerCase() }), false)
151      }
152      break
153    case 'section-apps':
154      for (const r of records(o0)) {
155        const name = at(r, 'name')
156        if (name) push(node(`A:${name}`, n.id, 'app', name, name, { note: (at(r, 'app_status', 'state') || at(r, 'compute_status', 'state')).toLowerCase() }), false)
157      }
158      break
159    case 'section-dashboards':
160      for (const r of records(o0)) {
161        const id = at(r, 'dashboard_id')
162        if (id) push(node(`D:${id}`, n.id, 'dashboard', at(r, 'display_name') || id, id), false)
163      }
164      break
165  }
166  return out
167}
168
169export function urlFor(n: TreeNode, host: string): string {
170  if (!host) return ''
171  const [c = '', s = '', t = ''] = n.path.split('.')
172  const enc = (v: string) => encodeURIComponent(v)
173  switch (n.kind) {
174    case 'folder':
175    case 'notebook':
176    case 'file':
177    case 'repo':
178    case 'library':
179      return `${host}/#workspace${n.path.split('/').map(enc).join('/')}`
180    case 'catalog':
181      return `${host}/explore/data/${enc(c)}`
182    case 'schema':
183      return `${host}/explore/data/${enc(c)}/${enc(s)}`
184    case 'table':
185    case 'view':
186    case 'metric_view':
187    case 'materialized_view':
188    case 'streaming_table':
189      return `${host}/explore/data/${enc(c)}/${enc(s)}/${enc(t)}`
190    case 'volume':
191      return `${host}/explore/data/volumes/${enc(c)}/${enc(s)}/${enc(t)}`
192    case 'cluster':
193      return `${host}/compute/clusters/${enc(n.path)}`
194    case 'warehouse':
195      return `${host}/sql/warehouses/${enc(n.path)}`
196    case 'job':
197      return `${host}/jobs/${enc(n.path)}`
198    case 'pipeline':
199      return `${host}/pipelines/${enc(n.path)}`
200    case 'app':
201      return `${host}/apps/${enc(n.path)}`
202    case 'dashboard':
203      return `${host}/dashboardsv3/${enc(n.path)}/published`
204    default:
205      return host
206  }
207}
208
209export function chainOf(id: string): string[] {
210  if (id.startsWith('WS:')) {
211    const parts = id.slice(3).split('/').filter(Boolean)
212    return ['S:workspace', ...parts.slice(0, -1).map((_, i) => `WS:/${parts.slice(0, i + 1).join('/')}`)]
213  }
214  if (id.startsWith('UC:') || id.startsWith('UV:')) {
215    const parts = id.slice(3).split('.')
216    const out = ['S:catalog']
217    if (parts.length >= 2) out.push(`UC:${parts[0]}`)
218    if (parts.length >= 3) out.push(`UC:${parts[0]}.${parts[1]}`)
219    return out
220  }
221  const prefix = id.split(':')[0]
222  const section: Record<string, string> = { CL: 'S:compute', SW: 'S:compute', J: 'S:jobs', P: 'S:pipelines', A: 'S:apps', D: 'S:dashboards' }
223  return prefix && section[prefix] ? [section[prefix]] : []
224}
225
226export function hostFrom(cfg: string, profile: string): string {
227  const want = profile || 'DEFAULT'
228  let section = ''
229  for (const raw of cfg.split(/\r?\n/)) {
230    const line = raw.trim()
231    const head = /^\[(.+)\]$/.exec(line)
232    if (head) {
233      section = head[1]?.trim() ?? ''
234      continue
235    }
236    const kv = /^host\s*=\s*(.+)$/i.exec(line)
237    if (kv && section === want) return (kv[1] ?? '').trim().replace(/\/+$/, '')
238  }
239  return ''
240}
241
hooks/parse.ts 502 lines
1export type Invocation = { tool: string; bin: string; args: string[]; cwd: string; env: Record<string, string> }
2
3const SEPARATORS = new Set(['&&', '||', ';', '|', '&', '\n', '(', ')', '`'])
4const PREFIXES = new Set(['do', 'then', 'else', 'elif', 'if', '!', 'time', '{', 'env', 'command', 'builtin', 'exec', 'nohup', 'sudo', 'nice', 'xargs', 'uvx', 'pipx', 'timeout', 'while', 'until'])
5const PREFIX_VALUED: Record<string, Set<string>> = {
6  sudo: new Set(['-u', '-g', '-C', '-h', '-p', '-U', '-r', '-t', '-D']),
7  timeout: new Set(['-k', '-s', '--kill-after', '--signal']),
8  xargs: new Set(['-n', '-I', '-P', '-L', '-s', '-d', '-E', '-a']),
9  env: new Set(['-u', '-C', '-S', '--unset', '--chdir']),
10  nice: new Set(['-n']),
11  exec: new Set(['-a']),
12  uvx: new Set(['--from', '--with', '-p', '--python']),
13  uv: new Set(['--from', '--with', '-p', '--python', '--project', '--directory']),
14  pipx: new Set(['--spec', '--python']),
15}
16const MAX_CALLS = 50
17const REDIRECT = /^(\d*>>?|\d*<|\d*>&\d*|&>>?)$/
18
19let drives = false
20
21export function useDrives(on: boolean): void {
22  drives = on
23}
24
25export function posix(path: string): string {
26  if (!drives) return path
27  return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
28}
29
30export function isAbsolute(path: string): boolean {
31  return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
32}
33
34export function tokenize(command: string, subs: { at: number; body: string }[] = []): string[] {
35  const out: string[] = []
36  let cur = ''
37  let quote = ''
38  let has = false
39  for (let i = 0; i < command.length; i++) {
40    const ch = command[i] ?? ''
41    if (!quote && ch === '#' && !has && !cur) {
42      while (i + 1 < command.length && command[i + 1] !== '\n') i++
43      continue
44    }
45    if (quote) {
46      if (ch === quote) quote = ''
47      else if (ch === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i] ?? ''
48      else if (quote === '"' && ((ch === '$' && command[i + 1] === '(') || ch === '`')) {
49        const end = closing(command, i)
50        if (end < 0) cur += ch
51        else {
52          subs.push({ at: out.length, body: command.slice(ch === '`' ? i + 1 : i + 2, end) })
53          cur += command.slice(i, end + 1)
54          i = end
55        }
56      } else cur += ch
57      continue
58    }
59    if (ch === '"' || ch === "'") {
60      quote = ch
61      has = true
62    } else if (ch === '\\' && i + 1 < command.length) {
63      cur += command[++i] ?? ''
64      has = true
65    } else if (ch === '\n') {
66      if (has || cur) out.push(cur)
67      cur = ''
68      has = false
69      out.push('\n')
70    } else if (/\s/.test(ch)) {
71      if (has || cur) out.push(cur)
72      cur = ''
73      has = false
74    } else if (ch === '>' || ch === '<') {
75      let op = /^\d+$/.test(cur) || (cur === '&' && !has) ? cur : ''
76      if (!op && (has || cur)) out.push(cur)
77      op += ch
78      while (command[i + 1] === '>' || command[i + 1] === '&' || /\d/.test(command[i + 1] ?? '')) {
79        if (command[i + 1] === '&' && op.endsWith('&')) break
80        op += command[++i]
81      }
82      out.push(op)
83      cur = ''
84      has = false
85    } else if (ch === '(' || ch === ')' || ch === '`') {
86      if (has || cur) out.push(cur)
87      cur = ''
88      has = false
89      out.push(ch)
90    } else if (ch === ';' || ch === '|' || ch === '&') {
91      if (ch === '&' && command[i + 1] === '>') {
92        if (has || cur) out.push(cur)
93        cur = '&'
94        has = false
95        continue
96      }
97      if (has || cur) out.push(cur)
98      cur = ''
99      has = false
100      const two = command.slice(i, i + 2)
101      if (two === '&&' || two === '||') {
102        out.push(two)
103        i++
104      } else out.push(ch)
105    } else {
106      cur += ch
107      has = true
108    }
109  }
110  if (has || cur) out.push(cur)
111  if (!quote) return out
112  return command
113    .split(/(&&|\|\||[;|\n])/)
114    .flatMap(part => (/^(&&|\|\||[;|\n])$/.test(part) ? [part] : part.trim().split(/\s+/).filter(Boolean)))
115}
116
117export function join(base: string, raw: string): string {
118  const path = posix(raw)
119  if (isAbsolute(path)) return path
120  if (path.startsWith('~/')) return path
121  const parts = base.split('/')
122  for (const seg of path.split('/')) {
123    if (seg === '' || seg === '.') continue
124    if (seg === '..') parts.pop()
125    else parts.push(seg)
126  }
127  return parts.join('/') || '/'
128}
129
130function operators(line: string): { word: string; tabs: boolean; expand: boolean }[] {
131  const found: { word: string; tabs: boolean; expand: boolean }[] = []
132  const outer: boolean[] = []
133  let single = false
134  let double = false
135  for (let i = 0; i < line.length; i++) {
136    const ch = line[i]
137    if (single) {
138      if (ch === "'") single = false
139      continue
140    }
141    if (ch === '\\') {
142      i++
143      continue
144    }
145    if (ch === '$' && line[i + 1] === '(') {
146      outer.push(double)
147      double = false
148      i++
149      continue
150    }
151    if (ch === ')' && outer.length && !double) {
152      double = outer.pop() ?? false
153      continue
154    }
155    if (ch === '"') {
156      double = !double
157      continue
158    }
159    if (double) continue
160    if (ch === "'") {
161      single = true
162      continue
163    }
164    if (ch === '#' && (i === 0 || /\s/.test(line[i - 1] ?? ''))) break
165    if (ch === '<' && line[i + 1] === '<' && line[i + 2] !== '<') {
166      const m = line.slice(i).match(/^<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/)
167      if (m) {
168        found.push({ word: m[3] ?? '', tabs: m[1] === '-', expand: !m[2] })
169        i += m[0].length - 1
170      }
171    }
172  }
173  return found
174}
175
176function heredocless(command: string): string {
177  const out: string[] = []
178  const pending: { word: string; tabs: boolean; expand: boolean }[] = []
179  for (const line of command.split('\n')) {
180    const head = pending[0]
181    if (head) {
182      if ((head.tabs ? line.replace(/^\t+/, '') : line) === head.word) pending.shift()
183      else if (head.expand) out.push(...substitutions(line))
184      continue
185    }
186    out.push(line)
187    pending.push(...operators(line))
188  }
189  return out.join('\n')
190}
191
192function closing(command: string, i: number): number {
193  if (command[i] === '`') {
194    let j = i + 1
195    while (j < command.length && command[j] !== '`') j += command[j] === '\\' ? 2 : 1
196    return j < command.length ? j : -1
197  }
198  let depth = 0
199  for (let j = i + 1; j < command.length; j++) {
200    if (command[j] === '(') depth++
201    else if (command[j] === ')' && --depth === 0) return j
202  }
203  return -1
204}
205
206function substitutions(command: string): string[] {
207  const out: string[] = []
208  let single = false
209  let double = false
210  for (let i = 0; i < command.length; i++) {
211    const ch = command[i]
212    if (single) {
213      if (ch === "'") single = false
214      continue
215    }
216    if (ch === '\\') {
217      i++
218      continue
219    }
220    if (ch === "'" && !double) {
221      single = true
222      continue
223    }
224    if (ch === '"') {
225      double = !double
226      continue
227    }
228    if (ch === '#' && !double && (i === 0 || /\s/.test(command[i - 1] ?? ''))) {
229      while (i + 1 < command.length && command[i + 1] !== '\n') i++
230      continue
231    }
232    if ((ch === '$' && command[i + 1] === '(') || ch === '`') {
233      const end = closing(command, i)
234      if (end < 0) continue
235      out.push(command.slice(ch === '`' ? i + 1 : i + 2, end))
236      i = end
237    }
238  }
239  return out
240}
241
242function expand(args: string[], loops: { name: string; words: string[] }[]): string[][] {
243  let out = [args]
244  for (const { name, words } of loops) {
245    if (!name) continue
246    const source = `\\$\\{${name}\\}|\\$${name}(?![A-Za-z0-9_])`
247    const used = new RegExp(source)
248    const ref = new RegExp(source, 'g')
249    if (!out.some(a => a.some(x => used.test(x)))) continue
250    out = out.flatMap(a => words.map(w => a.map(x => x.replace(ref, w)))).slice(0, MAX_CALLS)
251  }
252  return out
253}
254
255function skipFlags(toks: string[], j: number, word: string): number {
256  const valued = PREFIX_VALUED[word]
257  while (j < toks.length && (toks[j] ?? '').startsWith('-') && !SEPARATORS.has(toks[j] ?? '')) j += valued?.has(toks[j] ?? '') ? 2 : 1
258  return j
259}
260
261export function invocations(command: string, sessionCwd: string, base: Record<string, string> = {}, tools: readonly string[] = ['databricks']): Invocation[] {
262  const subs: { at: number; body: string }[] = []
263  const toks = tokenize(heredocless(command), subs)
264  const found: Invocation[] = []
265  const loops: { name: string; words: string[] }[] = []
266  const exported: Record<string, string> = { ...base }
267  let cwd = sessionCwd
268  let start = true
269  for (let i = 0; i < toks.length && found.length < MAX_CALLS; i++) {
270    const t = toks[i] ?? ''
271    if (SEPARATORS.has(t)) {
272      start = true
273      continue
274    }
275    if (!start) continue
276    start = false
277    let end = i
278    while (end < toks.length && !SEPARATORS.has(toks[end] ?? '')) end++
279    for (let sub = subs[0]; sub && sub.at < end; sub = subs[0]) {
280      subs.shift()
281      found.push(...invocations(sub.body, cwd, exported, tools).slice(0, MAX_CALLS - found.length))
282    }
283    const env: Record<string, string> = {}
284    let j = i
285    for (;;) {
286      const tok = toks[j] ?? ''
287      const assign = tok.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s)
288      const word = tok.split('/').pop() ?? ''
289      if (assign) {
290        env[assign[1] ?? ''] = assign[2] ?? ''
291        j++
292      } else if (word === 'uv' && toks[j + 1] === 'run') j = skipFlags(toks, j + 2, 'uv')
293      else if (word === 'timeout') {
294        j = skipFlags(toks, j + 1, word)
295        if (/^\d/.test(toks[j] ?? '')) j++
296      } else if (word === 'while' || word === 'until') {
297        loops.push({ name: '', words: [] })
298        j++
299      } else if (word === 'command' && /^-[A-Za-z]*[vV]/.test(toks[j + 1] ?? '')) break
300      else if (PREFIXES.has(word)) j = skipFlags(toks, j + 1, word)
301      else break
302    }
303    const head = toks[j]?.split('/').pop()
304    const dir = toks[j + 1]
305    if (head === 'export') {
306      for (let k = j + 1; k < toks.length && !SEPARATORS.has(toks[k] ?? ''); k++) {
307        const m = (toks[k] ?? '').match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s)
308        if (m) exported[m[1] ?? ''] = m[2] ?? ''
309      }
310      continue
311    }
312    if (head === 'for' && toks[j + 2] === 'in' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(dir ?? '')) {
313      const words: string[] = []
314      for (let k = j + 3; k < toks.length && !SEPARATORS.has(toks[k] ?? '') && toks[k] !== 'do'; k++) words.push(toks[k] ?? '')
315      loops.push({ name: dir ?? '', words: words.slice(0, 20) })
316      continue
317    }
318    if (head === 'for' || head === 'select') {
319      loops.push({ name: '', words: [] })
320      continue
321    }
322    if (head === 'done') {
323      loops.pop()
324      continue
325    }
326    if (head === 'cd' && dir && !SEPARATORS.has(dir)) {
327      cwd = join(cwd, dir)
328      continue
329    }
330    if (!head || !tools.includes(head)) continue
331    const args: string[] = []
332    let k = j + 1
333    while (k < toks.length && !SEPARATORS.has(toks[k] ?? '')) {
334      const tok = toks[k++] ?? ''
335      if (REDIRECT.test(tok)) {
336        if (!/&\d*$/.test(tok) || tok.startsWith('&')) k++
337        continue
338      }
339      args.push(tok)
340    }
341    for (const each of expand(args, loops)) if (found.length < MAX_CALLS) found.push({ tool: head, bin: toks[j] ?? head, args: each, cwd, env: { ...exported, ...env } })
342    i = k - 1
343  }
344  for (const sub of subs) found.push(...invocations(sub.body, cwd, exported, tools).slice(0, MAX_CALLS - found.length))
345  return found
346}
347
348export type DbKind = 'read' | 'download' | 'upload' | 'modify'
349
350const IGNORED = new Set(['auth', 'configure', 'completion', 'help', 'version', 'cache', 'labs', 'quickstart', 'psql', 'ssh', 'aitools'])
351const VALUE_FLAGS = new Set(['--limit', '--path', '--page-size', '--max-num-clusters', '--min-num-clusters', '--cluster-size', '--warehouse-type', '--spark-version', '--node-type-id', '--num-workers', '--description', '--owner', '--filter', '--source-dir', '--target-dir', '--key', '--scope', '--string-value', '-p', '--profile', '-o', '--output', '--file', '--format', '--language', '--json', '-t', '--target', '--var', '--cluster-id', '--job-id', '--run-id', '--warehouse-id', '--pipeline-id', '--catalog-name', '--schema-name', '--max-results', '--page-token', '--timeout', '--log-level', '--log-file', '--log-format', '--name', '--comment', '--storage-root', '--source-code-path'])
352const READ_VERBS = new Set(['get', 'list', 'describe', 'summary', 'status', 'exists', 'read', 'search', 'ls', 'cat', 'me', 'validate', 'show'])
353const REMOTE = /^(dbfs:|\/Volumes\/)/i
354const UC_NAME = /^[^\s./:]+\.[^\s./:]+\.[^\s./:]+$/
355const UC_GROUPS = new Set(['tables', 'functions', 'registered-models', 'model-versions', 'volumes', 'grants', 'online-tables', 'quality-monitors', 'lakehouse-monitors', 'table-constraints', 'artifact-allowlists', 'temporary-table-credentials', 'vector-search-indexes'])
356
357export function positionals(args: string[]): string[] {
358  const out: string[] = []
359  for (let i = 0; i < args.length; i++) {
360    const a = args[i] ?? ''
361    if (a.startsWith('-')) {
362      if (VALUE_FLAGS.has(a)) i++
363      continue
364    }
365    out.push(a)
366  }
367  return out
368}
369
370function flag(args: string[], ...names: string[]): string {
371  for (let i = 0; i < args.length; i++) {
372    const a = args[i] ?? ''
373    for (const n of names) {
374      if (a === n) return args[i + 1] ?? ''
375      if (a.startsWith(`${n}=`)) return a.slice(n.length + 1)
376    }
377  }
378  return ''
379}
380
381export function dbProfile(inv: Invocation): string {
382  return flag(inv.args, '-p', '--profile')
383}
384
385export function dbKind(inv: Invocation): DbKind | null {
386  const pos = positionals(inv.args)
387  const [group = '', verb = ''] = pos
388  if (!group || IGNORED.has(group)) return null
389  if (group === 'fs') {
390    if (verb === 'ls' || verb === 'cat') return 'read'
391    if (verb === 'cp') {
392      const rest = pos.slice(2)
393      const src = rest[0] ?? ''
394      const dst = rest[rest.length - 1] ?? ''
395      if (REMOTE.test(src) && !REMOTE.test(dst)) return 'download'
396      if (!REMOTE.test(src) && REMOTE.test(dst)) return 'upload'
397    }
398    return 'modify'
399  }
400  if (group === 'workspace') {
401    if (verb === 'export' || verb === 'export-dir') return 'download'
402    if (verb === 'import' || verb === 'import-dir') return 'upload'
403  }
404  if (group === 'bundle') return verb === 'deploy' ? 'upload' : READ_VERBS.has(verb) || verb === 'generate' ? 'read' : 'modify'
405  if (group === 'sync') return 'upload'
406  if (group === 'api') return verb.toLowerCase() === 'get' || /\/sql\/statements/.test(pos[2] ?? '') ? 'read' : 'modify'
407  if (group === 'current-user') return 'read'
408  if (verb === 'export' || verb === 'export-run') return 'download'
409  if (READ_VERBS.has(verb) || verb.startsWith('list-') || verb.startsWith('get-')) return 'read'
410  return 'modify'
411}
412
413function workspacePath(p: string): string {
414  return p.replace(/^\/Workspace(?=\/)/, '').replace(/\/+$/, '') || '/'
415}
416
417function volumeOf(p: string): string {
418  const m = p.replace(/^dbfs:/i, '').match(/^\/Volumes\/([^/]+)\/([^/]+)\/([^/]+)/)
419  return m ? `UV:${m[1]}.${m[2]}.${m[3]}` : ''
420}
421
422export function dbTargets(inv: Invocation): string[] {
423  const pos = positionals(inv.args)
424  const [group = '', verb = '', ...rest] = pos
425  const a0 = rest[0] ?? ''
426  const a1 = rest[1] ?? ''
427  const out: string[] = []
428  const id = (prefix: string, value: string) => value && out.push(`${prefix}:${value}`)
429  switch (group) {
430    case 'workspace':
431      if (verb === 'import-dir') id('WS', a1 && workspacePath(a1))
432      else if (a0.startsWith('/')) id('WS', workspacePath(a0))
433      else if (verb === 'list') out.push('S:workspace')
434      break
435    case 'fs':
436      for (const p of rest) if (volumeOf(p)) out.push(volumeOf(p))
437      break
438    case 'catalogs':
439      if (verb === 'list') out.push('S:catalog')
440      else id('UC', a0)
441      break
442    case 'schemas':
443      id('UC', verb === 'create' && a1 ? `${a1}.${a0}` : a0)
444      break
445    case 'tables':
446    case 'functions':
447    case 'registered-models':
448      if (verb === 'list' || verb === 'list-summaries') id('UC', a0 && a1 ? `${a0}.${a1}` : flag(inv.args, '--catalog-name') && flag(inv.args, '--schema-name') ? `${flag(inv.args, '--catalog-name')}.${flag(inv.args, '--schema-name')}` : '')
449      else if (group === 'tables' && verb === 'create' && a1 && rest[2]) id('UC', `${a1}.${rest[2]}.${a0}`)
450      else id('UC', a0)
451      break
452    case 'volumes':
453      if (verb === 'list') id('UC', a0 && a1 ? `${a0}.${a1}` : '')
454      else if (verb === 'create') id('UV', a0 && a1 && rest[2] ? `${a0}.${a1}.${rest[2]}` : '')
455      else id('UV', a0)
456      break
457    case 'clusters':
458      if (verb === 'list') out.push('S:compute')
459      else id('CL', flag(inv.args, '--cluster-id') || a0)
460      break
461    case 'warehouses':
462      if (verb === 'list') out.push('S:compute')
463      else id('SW', flag(inv.args, '--warehouse-id') || a0)
464      break
465    case 'jobs':
466      if (verb === 'list') out.push('S:jobs')
467      else id('J', flag(inv.args, '--job-id') || (/run/.test(verb) && verb !== 'run-now' ? '' : /^\d+$/.test(a0) ? a0 : ''))
468      break
469    case 'api': {
470      if (!/\/sql\/statements/.test(a0)) break
471      const body = flag(inv.args, '--json')
472      const wh = body.match(/"warehouse_id"\s*:\s*"([^"]+)"/)?.[1]
473      if (wh) out.push(`SW:${wh}`)
474      break
475    }
476    case 'bundle':
477      if (verb === 'deploy' || verb === 'destroy') out.push('S:jobs', 'S:pipelines', 'S:dashboards', 'S:apps', 'S:workspace')
478      break
479    case 'sync':
480      out.push('S:workspace')
481      break
482    case 'pipelines':
483      if (verb === 'list-pipelines') out.push('S:pipelines')
484      else id('P', flag(inv.args, '--pipeline-id') || a0)
485      break
486    case 'apps':
487      if (verb === 'list') out.push('S:apps')
488      else id('A', a0)
489      break
490    case 'lakeview':
491      if (verb === 'list') out.push('S:dashboards')
492      else id('D', a0)
493      break
494  }
495  const SECTION: Record<string, string> = { jobs: 'S:jobs', pipelines: 'S:pipelines', apps: 'S:apps', lakeview: 'S:dashboards', clusters: 'S:compute', warehouses: 'S:compute', catalogs: 'S:catalog' }
496  if (out.length === 0 && (verb === 'create' || verb.startsWith('create-') || verb === 'import') && SECTION[group]) out.push(SECTION[group] ?? '')
497  if (UC_GROUPS.has(group)) for (const p of rest) if (UC_NAME.test(p) && !out.includes(`UC:${p}`) && !out.includes(`UV:${p}`)) out.push(`UC:${p}`)
498  return out
499}
500
501export const DB_TONE: Record<DbKind, string> = { read: 'purple', download: 'teal', upload: 'pink', modify: 'orange' }
502
hooks/icon-data.ts 90 lines
1export const DB_CODEPOINTS: Record<string, number> = {
2  "workspace": 0xf3000,
3  "folder": 0xf3001,
4  "folder_open": 0xf3002,
5  "folder_home": 0xf3003,
6  "notebook": 0xf3004,
7  "file": 0xf3005,
8  "file_code": 0xf3006,
9  "repo": 0xf3007,
10  "repo_open": 0xf3008,
11  "branch": 0xf3009,
12  "catalog": 0xf300a,
13  "catalog_shared": 0xf300b,
14  "schema": 0xf300c,
15  "database": 0xf300d,
16  "table": 0xf300e,
17  "view": 0xf300f,
18  "materialized_view": 0xf3010,
19  "streaming_table": 0xf3011,
20  "volume": 0xf3012,
21  "volume_open": 0xf3013,
22  "function": 0xf3014,
23  "model": 0xf3015,
24  "serving_endpoint": 0xf3016,
25  "cluster": 0xf3017,
26  "warehouse": 0xf3018,
27  "job": 0xf3019,
28  "pipeline": 0xf301a,
29  "dashboard": 0xf301b,
30  "query": 0xf301c,
31  "sql": 0xf301d,
32  "alert": 0xf301e,
33  "app": 0xf301f,
34  "experiment": 0xf3020,
35  "secret": 0xf3021,
36  "user": 0xf3022,
37  "group": 0xf3023,
38  "dbfs": 0xf3024,
39  "vector_search": 0xf3025,
40  "lakebase": 0xf3026,
41  "genie": 0xf3027,
42  "refresh": 0xf3028,
43  "eye": 0xf3029,
44  "eye_off": 0xf302a,
45  "collapse": 0xf302b,
46  "expand": 0xf302c,
47  "home": 0xf302d,
48  "up": 0xf302e,
49  "search": 0xf302f,
50  "chevron_right": 0xf3030,
51  "chevron_down": 0xf3031,
52  "check": 0xf3032,
53  "warning": 0xf3033,
54  "danger": 0xf3034,
55  "loading": 0xf3035,
56  "play": 0xf3036,
57  "stop": 0xf3037,
58  "trash": 0xf3038,
59  "link": 0xf3039,
60  "lock": 0xf303a,
61  "gear": 0xf303b,
62  "clock": 0xf303c,
63  "sync": 0xf303d,
64  "ui_logo": 0xf3040,
65  "ui_workspace": 0xf3041,
66  "ui_folder": 0xf3042,
67  "ui_folder_users": 0xf3043,
68  "ui_repo": 0xf3044,
69  "ui_folder_shared": 0xf3045,
70  "ui_notebook": 0xf3046,
71  "ui_dashboard": 0xf3047,
72  "ui_catalog_section": 0xf3048,
73  "ui_catalog": 0xf3049,
74  "ui_catalog_workspace": 0xf304a,
75  "ui_catalog_system": 0xf304b,
76  "ui_catalog_shared": 0xf304c,
77  "ui_schema": 0xf304d,
78  "ui_table": 0xf304e,
79  "ui_metric_view": 0xf304f,
80  "ui_jobs": 0xf3050,
81  "ui_compute": 0xf3051,
82  "ui_warehouse": 0xf3052,
83  "ui_apps": 0xf3053,
84  "ui_genie": 0xf3054,
85  "ui_models": 0xf3055,
86  "ui_serving": 0xf3056,
87  "ui_queries": 0xf3057,
88  "ui_alerts": 0xf3058,
89}
90
types/index.d.ts 43 lines
1export type Target = { kind: 'databricks'; profile: string }
2
3export type TreeNode = {
4  id: string
5  parent: string
6  kind: string
7  name: string
8  path: string
9  hidden: boolean
10  sig: string
11  note: string
12  url?: string
13}
14
15export type Work = { tone: string; lit: string[]; dim: string[]; since: number; until: number | null }
16
17export type Onboard = { kind: 'missing' | 'signedOut' | 'unreachable'; line: string }
18
19export type Explorer = {
20  target: Target | null
21  nodes: TreeNode[]
22  expanded: string[]
23  query: string
24  cursor: string
25  selected: string
26  detail: string[]
27  status: string
28  work: Work | null
29  onboard: Onboard | null
30  busy: Record<string, { tone: string; n: number; at: number }>
31  scroll: number | null
32  root: string
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'databricks-cli': {
38      explorer: Explorer
39      nodes: TreeNode[]
40    }
41  }
42}
43