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

<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.
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
Click here for a YouTube walkthrough
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
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/<name>/SKILL.md.*.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.json (plugin root) or .github/mcp.json. The plugins in this repo do not currently ship MCP servers.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.~/.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.
%USERPROFILE%\.copilot\installed-plugins\ (or %COPILOT_HOME%\installed-plugins\) and restart Copilot CLI.hooks folder from that same location.The repo contains skills, agents, and hooks.
/skill-name. In Claude Code, skills and commands have coalesced; commands are simply more prescriptive skill workflows.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 validationplugins/pbi-desktop/hooks/config.yaml -- DAX references, measure metadata, referential integrity, metadata cache[!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.
| Type | Name | Description |
|---|---|---|
| Skill | help-me-get-started | A 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 |
| Skill | improve-my-agent-setup | Setup-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 |
| Type | Name | Description |
|---|---|---|
| Skill | bpa-rules | Create and improve Best Practice Analyzer rules for models |
| Skill | c-sharp-scripting | C# scripting and macros for TE |
| Skill | te-cli | Cross-platform Tabular Editor CLI (te, preview) for semantic models from the terminal |
| Skill | te2-cli | Tabular Editor 2 CLI usage and automation (not TE3) |
| Skill | te-docs | Tabular Editor documentation search, TE3 config files. Uses pbi-search CLI |
| Command | /suggest-rule | Generate BPA rules from descriptions |
| Agent | bpa-expression-helper | Debug and improve BPA rule expressions |
| Type | Name | Description |
|---|---|---|
| Skill | connect-pbid | Explore, query, and modify a model in Power BI Desktop, and reload/screenshot the report canvas via the Desktop Bridge |
| Agent | query-listener | Capture DAX queries from Power BI Desktop visuals in real time |
| Hook | DAX reference validation | Validates table, column, and measure references against the connected model; suggests corrections |
| Hook | Measure metadata enforcement | Blocks adding measures without DisplayFolder, Description, and FormatString |
| Hook | Referential integrity check | Reports unmatched many-side keys after relationship or column changes |
| Hook | Metadata cache refresh | Auto-snapshots model metadata on TOM connect or model modification |
| Hook | Compatibility level check | Reports features available by upgrading; optional auto-upgrade |
| Type | Name | Description |
|---|---|---|
| Skill | pbip | Power BI Project (PBIP) format, structure, and file types |
| Skill | tmdl | Author and edit TMDL files directly |
| Skill | pbir-format | Author and edit PBIR metadata files directly (visual.json, report.json, themes, filters, report extensions, visual calculations) |
| Agent | pbip-validator | Validate PBIP project structure, TMDL syntax, and PBIR schemas |
| Hook | PBIR validation | Validates PBIR structure, required fields, naming conventions, and schema URLs |
| Hook | Report binding validation | Validates semantic model binding (byPath directory exists; byConnection model exists via fab exists) |
| Hook | TMDL validation | Validates TMDL structural syntax |
| Type | Name | Description |
|---|---|---|
| Skill | create-pbi-report | Step-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 |
| Skill | pbi-report-design | The 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 |
| Skill | modifying-theme-json | Design, enforce, audit, and validate report themes through the pbir CLI |
| Skill | review-report | Actionable feedback on report quality, usage, and effectiveness; usage analysis, health checks |
| Skill | pbir-cli | Programmatic report manipulation via the pbir CLI, including live Power BI Desktop refresh and page screenshots |
| Mod | /report-pane | Report 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 |
| Agent | deneb-reviewer | Review Deneb visual specs for Vega/Vega-Lite syntax and conventions |
| Agent | svg-reviewer | Review SVG DAX measures for syntax and design quality |
| Agent | r-reviewer | Review R visual scripts (ggplot2) for Power BI conventions |
| Agent | python-reviewer | Review Python visual scripts (matplotlib/seaborn) for Power BI conventions |
| Type | Name | Description |
|---|---|---|
| Skill | paginated-report | Author, 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 |
| Hook | RDL validation | Validates .rdl structure after Write/Edit and on Bash commands touching .rdl files; blocks on structural errors only |
| Type | Name | Description |
|---|---|---|
| Skill | deneb-visuals | Deneb visual creation, Vega/Vega-Lite spec authoring, Deneb best practices for PBIR reports |
| Skill | r-visuals | Custom R visuals (ggplot2) for Power BI reports |
| Skill | python-visuals | Custom Python visuals (matplotlib/seaborn) for Power BI reports |
| Skill | svg-visuals | SVG generation via DAX measures and extension measures for inline chart visualizations (sparklines, bullet charts, KPI indicators, and more) |
| Skill | powerbi-custom-visuals | Power 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.
| Type | Name | Description |
|---|---|---|
| Skill | semantic-model | Design, build, refresh, and review semantic models through a te-first tool cascade |
| Skill | standardize-naming-conventions | Audit and standardize naming conventions in semantic models |
| Skill | refresh-semantic-model | Trigger or troubleshoot refreshes |
| Skill | lineage-analysis | Trace downstream reports from a semantic model across workspaces |
| Skill | power-query | Write M expressions, debug query folding, execute M locally or via Fabric API |
| Skill | dax | Write, debug, and optimize DAX in semantic models. Contributed by Justin Martin |
| Agent | semantic-model-auditor | Audit semantic models for quality, memory, DAX, and design issues |
| Type | Name | Description |
|---|---|---|
| Skill | fabric-cli | Fabric CLI (fab) for any remote operation in Power BI or Fabric (works fully on Pro, PPU; Fabric not required) |
| Command | /migrating-fabric-trial-capacities | Migrate workspaces from trial to production capacity |
| Mod | /fabric-pane | Fabric 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 |
| Type | Name | Description |
|---|---|---|
| Skill | audit-tenant-settings | Audit Fabric and Power BI tenant settings, delegated overrides, and Entra security group membership |
| Type | Name | Description |
|---|---|---|
| Mod | /databricks-pane | Databricks 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 |
| Type | Name | Description |
|---|---|---|
| Mod | /fabric-app-pane | Fabric 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 |
| Type | Name | Description |
|---|---|---|
| Skill |
hooks/register.tsx 1045 lines1import 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}
1045hooks/open.ts 41 lines1export 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}
41hooks/icons.ts 68 lines1import 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}
68hooks/rows.tsx 144 lines1import 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
144hooks/tree.ts 130 lines1import 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}
130hooks/databricks.ts 241 lines1import 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}
241hooks/parse.ts 502 lines1export 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' }
502hooks/icon-data.ts 90 lines1export 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}
90types/index.d.ts 43 lines1export 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