Connect to, read, and modify anything in Fabric via the CLI, with a Fabric pane that follows what fab does.

<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 1133 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { LINUX_LAUNCH as LAUNCH, openCommand } from './open'
3
4import type { Explorer, Target, TreeNode } from '../types'
5import { glyph, type Tier } from './icons'
6import type { RowSpec, RowsProps, Seg } from './rows'
7import { ancestors, EMPTY, empty, emptyMark, isLoaded, merge, visible } from './tree'
8import { inOneLake, modelOf, parseChildren, parseOneLake, parseWorkspaces, PLACEHOLDER } from './fabric'
9import { FAB_TONE, type FabKind, fabCalls, fabGuids, fabKind, fabPositionals, fabWorkspaces, type Invocation, invocations, join, modelFromConnection, posix, queryOf, targetLabel, tokenize, useDrives } from './parse'
10import { fabTouched } from './touch'
11import { COPY, REFRESH, runFailure, setupOf, setupRows, spawnFailure } from './setup'
12
13const STATE = { plugin: 'fabric-cli', key: 'explorer' } as const
14const NODES = { plugin: 'fabric-cli', key: 'nodes' } as const
15const PANE = 'fabric-pane'
16const TITLE = 'Fabric'
17const TITLE_GLYPH = { fabric: 0xf2292, nerd: 'F', plain: 'F' }
18const TITLE_COLOR = '#2dd4bf'
19const HINT = 'waiting for a fab command, or /fabric-pane'
20const SORT = false
21const WINDOW = 400
22const DETAIL_ROWS = 12
23const HEADER = '#header'
24const WHOLE_VERBS = new Set(['mkdir', 'create', 'rm', 'del', 'mv', 'move', 'rename'])
25const BUSY_MAX_MS = 600_000
26const FLASH_MS = 2700
27const DOUBLE_MS = 450
28const SHIMMER = ['#f97316', '#fb923c', '#fdba74', '#ffedd5']
29const TONES: Record<string, { bright: string[]; dim: string[] }> = {
30 orange: { bright: SHIMMER, dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'] },
31 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'] },
32 pink: { bright: ['#ec4899', '#f472b6', '#f9a8d4', '#fce7f3'], dim: ['#831843', '#9d174d', '#b8406f', '#cf6f93'] },
33 purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'] },
34}
35const PLAIN_SPINNER = ['|', '/', '-', '\\']
36const FONT_SCRIPT =
37 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
38 'else f=$(ls "$HOME"/Library/Fonts/*"$2"* /Library/Fonts/*"$2"* 2>/dev/null | head -n1); fi; ' +
39 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
40 '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 ' +
41 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
42 '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; ' +
43 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
44
45let lastPress = { key: '', at: 0 }
46const views = new Map<string, { from: number; max: number }>()
47let detected: Tier = 'nerd'
48let glyphSetting = 'auto'
49let fontHint = true
50let hinted = false
51let fellBack = false
52let follow = true
53let blink: Timer | null = null
54let generation = 0
55let running = 0
56let closed = false
57let noDock = false
58let inflight: Promise<void> | null = null
59let queued: Promise<void> | null = null
60let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
61
62function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
63 platform ??= (async () => {
64 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
65 try {
66 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
67 } catch {
68 return 'linux'
69 }
70 })()
71 return platform
72}
73
74async function openPane($: EngineInterface, args: { id: string; title: string; focus?: true }): Promise<void> {
75 if (noDock && !args.focus) return
76 await $.ui.open(args)
77}
78
79async function cwdOf($: EngineInterface): Promise<string> {
80 return posix(await $.session.cwd())
81}
82
83function jumpTo(ex: Explorer, query: string): Partial<Explorer> {
84 const q = query.trim().toLowerCase()
85 const hits = q ? ex.nodes.filter(n => n.name.toLowerCase().includes(q) || n.note.toLowerCase().includes(q)).slice(0, 10) : []
86 const open = new Set(ex.expanded)
87 for (const n of hits) for (const a of ancestors(ex.nodes, n.id)) open.add(a)
88 return { query: '', expanded: [...open], cursor: hits[0]?.id ?? ex.cursor, scroll: null }
89}
90
91async function copyOf($: EngineInterface, text: string, surface?: string): Promise<void> {
92 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
93 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy')
94}
95
96function clean(segs: Seg[]): Seg[] {
97 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
98 return segs
99}
100
101async function installed($: EngineInterface, cmd: string): Promise<boolean> {
102 try {
103 return (await $.process.run(['sh', '-c', 'command -v "$1" >/dev/null 2>&1', 'sh', cmd], { timeoutMs: 5_000 })).exitCode === 0
104 } catch {
105 return false
106 }
107}
108
109async function launch($: EngineInterface, ...choices: string[][]): Promise<void> {
110 for (const argv of choices) {
111 if (!(await installed($, argv[0] ?? ''))) continue
112 try {
113 if ((await $.process.run(['sh', '-c', LAUNCH, 'sh', ...argv], { timeoutMs: 15_000 })).exitCode === 0) return
114 } catch {
115 continue
116 }
117 }
118 $.ui.toast(`could not start ${choices.map(c => c[0] ?? '').join(' or ')}`)
119}
120
121async function started($: EngineInterface, argv: string[]): Promise<void> {
122 try {
123 const run = await $.process.run(argv, { timeoutMs: 10_000 })
124 if (run.exitCode !== 0) $.ui.toast(`could not start ${argv[0] ?? ''}: ${(run.stderr || run.stdout).trim().split('\n')[0] ?? ''}`)
125 } catch {
126 $.ui.toast(`could not start ${argv[0] ?? ''}`)
127 }
128}
129
130const slots = { busy: 0, queue: [] as (() => void)[] }
131
132async function limited<T>(fn: () => Promise<T>): Promise<T> {
133 if (slots.busy < 4) slots.busy++
134 else await new Promise<void>(resolve => slots.queue.push(resolve))
135 try {
136 return await fn()
137 } finally {
138 const next = slots.queue.shift()
139 if (next) next()
140 else slots.busy--
141 }
142}
143
144async function openUrl($: EngineInterface, url: string): Promise<void> {
145 const { argv, init } = openCommand(await osName($), url)
146 try {
147 const run = await $.process.run(argv, init)
148 if (run.exitCode !== 0) $.ui.toast(`could not open ${url} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
149 } catch {
150 $.ui.toast(`could not open ${url} with ${argv[0]}`)
151 }
152}
153
154function psq(arg: string): string {
155 return `'${arg.replace(/'/g, "''")}'`
156}
157
158function shq(arg: string): string {
159 return `'${arg.replace(/'/g, `'\\''`)}'`
160}
161
162async function terminal($: EngineInterface, linux: string[], cmd: string[], cwd = ''): Promise<void> {
163 const line = cmd.map(shq).join(' ')
164 const os = await osName($)
165 if (os === 'linux') {
166 if (!(await installed($, linux[0] ?? ''))) return $.ui.toast(`${linux[0] ?? ''} is not installed`)
167 return launch($, ['xdg-terminal-exec', ...linux], ['x-terminal-emulator', '-e', ...linux])
168 }
169 if (os === 'darwin') {
170 return started($, ['osascript', '-e', `tell application "Terminal" to do script "${(cwd ? `cd ${shq(cwd)} && ${line}` : line).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`, '-e', 'tell application "Terminal" to activate'])
171 }
172 const inner = `${cwd ? `Set-Location -LiteralPath ${psq(cwd.replace(/\//g, '\\'))}; ` : ''}& ${cmd.map(psq).join(' ')}`
173 return started($, ['powershell', '-NoProfile', '-Command', `$ErrorActionPreference = 'Stop'; Start-Process powershell -ArgumentList @('-NoExit', '-Command', ${psq(inner)})`])
174}
175
176async function fontState($: EngineInterface, charset: string, name: string): Promise<'ok' | 'stale' | 'missing'> {
177 try {
178 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset, name], { timeoutMs: 5_000 })).stdout.trim()
179 return out === 'stale' || out === 'missing' ? out : 'ok'
180 } catch {
181 return 'missing'
182 }
183}
184
185async function detectGlyphs($: EngineInterface): Promise<void> {
186 if ((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY'))) {
187 detected = 'nerd'
188 return
189 }
190 const nerd = (await fontState($, 'f04eb', 'Nerd')) === 'ok'
191 detected = nerd && (await fontState($, 'f2621', 'FabricSymbols')) === 'ok' ? 'fabric' : nerd ? 'nerd' : 'plain'
192}
193
194function fontNote(): string {
195 if (!fontHint || hinted || !fellBack) return ''
196 hinted = true
197 return 'Tell user once: Fabric pane icons need github.com/data-goblin/fabric-nf plus a Nerd Font. Plugin option fontHint=off disables this.'
198}
199
200function tierFor(surface: string): Tier {
201 if (glyphSetting === 'fabric' || glyphSetting === 'nerd' || glyphSetting === 'plain') return glyphSetting
202 return surface === 'desktop' ? 'plain' : detected
203}
204
205function titleGlyph(tier: Tier): string {
206 return tier === 'fabric' ? String.fromCodePoint(TITLE_GLYPH.fabric) : tier === 'nerd' ? TITLE_GLYPH.nerd : TITLE_GLYPH.plain
207}
208
209function titleFor(target: Target | null): string {
210 return `${titleGlyph(tierFor('terminal'))} ${target ? targetLabel(target) : TITLE}`
211}
212
213async function get($: EngineInterface): Promise<Explorer> {
214 const [view, tree] = await Promise.all([$.state.get(STATE), $.state.get(NODES)])
215 return { ...empty(), ...view.value, nodes: tree.value ?? [] }
216}
217
218async function put($: EngineInterface, fn: (ex: Explorer) => Explorer): Promise<void> {
219 for (let i = 0; ; i++) {
220 if (i >= 50) throw new Error('pane state is busy; try again')
221 const [view, tree] = await Promise.all([$.state.get(STATE), $.state.get(NODES)])
222 const cur: Explorer = { ...empty(), ...view.value, nodes: tree.value ?? [] }
223 const next = anchored(cur, fn(cur))
224 if (next.nodes !== cur.nodes) {
225 const done = await $.state.set(NODES, next.nodes, { ifVersion: tree.version })
226 if (!done.isSet) continue
227 }
228 const keys = Object.keys(next) as (keyof Explorer)[]
229 if (!keys.some(k => k !== 'nodes' && next[k] !== cur[k])) return
230 const { nodes: _nodes, ...rest } = next
231 const done = await $.state.set(STATE, { ...rest, nodes: [] }, { ifVersion: view.version })
232 if (done.isSet) return
233 }
234}
235
236function patch($: EngineInterface, fn: (ex: Explorer) => Partial<Explorer>) {
237 return put($, ex => ({ ...ex, ...fn(ex) }))
238}
239
240async function patchView($: EngineInterface, fn: (ex: Explorer) => Partial<Explorer>): Promise<void> {
241 for (let i = 0; ; i++) {
242 if (i >= 50) throw new Error('pane state is busy; try again')
243 const view = await $.state.get(STATE)
244 const cur: Explorer = { ...empty(), ...view.value, nodes: [] }
245 const change = fn(cur)
246 if (Object.keys(change).length === 0) return
247 const done = await $.state.set(STATE, { ...cur, ...change, nodes: [] }, { ifVersion: view.version })
248 if (done.isSet) return
249 }
250}
251
252const sameTarget = (a: Target | null, b: Target | null) => JSON.stringify(a) === JSON.stringify(b)
253
254function refresh($: EngineInterface): Promise<void> {
255 if (inflight) {
256 const rerun = () => {
257 queued = null
258 return refresh($)
259 }
260 queued ??= inflight.then(rerun, rerun)
261 return queued
262 }
263 inflight = doRefresh($).finally(() => {
264 inflight = null
265 })
266 return inflight
267}
268
269async function flash($: EngineInterface, ids: string[], alsoLit: string[] = []): Promise<void> {
270 const unique = [...new Set(ids)]
271 if (unique.length === 0) return
272 const ex = await get($)
273 await patch($, cur => {
274 const byId = new Map(cur.nodes.map(n => [n.id, n]))
275 const open = new Set(cur.expanded)
276 const bright = new Set([...cur.flash.filter(id => !unique.includes(id)), ...unique])
277 const dim = new Set([...cur.flashDim, ...alsoLit])
278 for (const id of unique) {
279 const chain = ancestors(cur.nodes, id, byId)
280 if (!chain.some(a => !open.has(a))) continue
281 for (const a of chain) {
282 if (!open.has(a)) dim.add(a)
283 open.add(a)
284 }
285 }
286 for (const id of bright) dim.delete(id)
287 const root = rootOf(cur)
288 const away = follow && root !== '' && unique.some(id => id !== root && !ancestors(cur.nodes, id, byId).includes(root))
289 return { flash: [...bright], flashDim: [...dim], expanded: [...open], ...(follow ? { scroll: null } : {}), ...(away ? { root: '' } : {}) }
290 })
291 if (ex.target && !closed) await openPane($, { id: PANE, title: titleFor(ex.target) })
292}
293
294type Mark = { ids: string[]; tone: string }
295
296const STRENGTH: FabKind[] = ['read', 'download', 'upload', 'modify']
297
298function toneOf(kinds: FabKind[]): string {
299 return FAB_TONE[STRENGTH.reduce<FabKind>((best, k) => (kinds.includes(k) ? k : best), 'read')]
300}
301
302function rootOf(ex: Explorer): string {
303 return ex.root && ex.nodes.some(n => n.id === ex.root) ? ex.root : ''
304}
305
306function anchored(cur: Explorer, next: Explorer): Explorer {
307 if (follow || cur.scroll === null || next.scroll !== cur.scroll || (next.nodes === cur.nodes && next.expanded === cur.expanded)) return next
308 const top = visible(cur, SORT, rootOf(cur))[cur.scroll]?.node.id
309 const at = top ? visible(next, SORT, rootOf(next)).findIndex(row => row.node.id === top) : -1
310 return at < 0 || at === cur.scroll ? next : { ...next, scroll: at }
311}
312
313
314async function begin($: EngineInterface, mark: Mark): Promise<void> {
315 running++
316 generation++
317 blink?.cancel()
318 blink = null
319 const at = await $.clock.now()
320 await patchView($, cur => {
321 const map = { ...cur.busy }
322 for (const id of mark.ids) map[id] = { tone: mark.tone, n: (map[id]?.n ?? 0) + 1, at }
323 return { busy: map, work: { tone: mark.tone, at }, flash: [], flashDim: [] }
324 })
325}
326
327async function finish($: EngineInterface, mark: Mark): Promise<void> {
328 running = Math.max(0, running - 1)
329 const mine = generation
330 const idle = running === 0
331 let lit = false
332 await patchView($, cur => {
333 const map = { ...cur.busy }
334 for (const id of mark.ids) {
335 const left = (map[id]?.n ?? 1) - 1
336 const entry = map[id]
337 if (left > 0 && entry) map[id] = { ...entry, n: left }
338 else delete map[id]
339 }
340 lit = cur.flash.length > 0 || cur.flashDim.length > 0
341 return { busy: map, ...(idle && generation === mine && !lit ? { work: null } : {}) }
342 })
343 if (!idle || !lit || generation !== mine) return
344 blink?.cancel()
345 blink = $.clock.after(FLASH_MS, () => {
346 if (generation !== mine || running > 0) return
347 blink = null
348 quiet(patchView($, () => (generation === mine && running === 0 ? { work: null, flash: [], flashDim: [] } : {})))
349 })
350}
351
352async function point($: EngineInterface, target: Target | null, opened: 'asked' | 'unasked', fresh = false): Promise<boolean> {
353 const moved = !sameTarget((await get($)).target, target)
354 if (moved) {
355 treeGen++
356 await put($, () => ({ ...empty(), target }))
357 }
358 else if (fresh) await patchView($, () => ({ expanded: [], query: '', selected: '', detail: [], cursor: '' }))
359 if (opened === 'asked' || (moved && target)) {
360 const title = titleFor(target)
361 if (opened === 'asked') {
362 closed = false
363 await openPane($, { id: PANE, title, focus: true })
364 } else if (!closed) await openPane($, { id: PANE, title })
365 }
366 return moved
367}
368
369async function navigate($: EngineInterface, n: TreeNode): Promise<void> {
370 await patch($, cur => ({ root: n.id, cursor: n.id, scroll: null, expanded: [...new Set([...cur.expanded, n.id, ...ancestors(cur.nodes, n.id)])] }))
371 if (await expandFabric($, n)) await expandOpen($, n.id)
372}
373
374async function goUp($: EngineInterface): Promise<void> {
375 await patch($, cur => {
376 const n = cur.nodes.find(x => x.id === cur.root)
377 return { root: n?.parent ?? '', cursor: n?.id ?? cur.cursor, scroll: null }
378 })
379}
380
381async function goHome($: EngineInterface): Promise<void> {
382 await patchView($, () => ({ root: '', scroll: null }))
383}
384
385async function press($: EngineInterface, n: TreeNode): Promise<void> {
386 const now = await $.clock.now()
387 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
388 lastPress = { key: isDouble ? '' : n.id, at: now }
389 const ex = await get($)
390 if (isDouble && ex.nodes.some(c => c.parent === n.id)) await navigate($, n)
391 else if (isDouble) await openLocal($, ex, n)
392 else await select($, n)
393}
394
395async function openWeb($: EngineInterface, ex: Explorer, n: TreeNode): Promise<void> {
396 const url = webUrl(ex, n)
397 if (url) await openUrl($, url)
398 else $.ui.toast('no Fabric link for this object')
399}
400
401let fabContext: { bin: string; env: Record<string, string> } = { bin: 'fab', env: {} }
402let treeGen = 0
403
404function contextOf(inv: { bin: string; env: Record<string, string>; cwd: string }): typeof fabContext {
405 const env = Object.fromEntries(Object.entries(inv.env).filter(([k]) => /^(FAB_|AZURE_|IDENTITY_|REQUESTS_CA_BUNDLE$|HTTPS?_PROXY$|NO_PROXY$)/i.test(k)))
406 return { bin: binOf(inv.bin, inv.cwd), env }
407}
408
409function binOf(bin: string, cwd: string): string {
410 if (!bin.includes('/') || /[~$`]/.test(bin)) return 'fab'
411 if (bin.startsWith('/') || /^[A-Za-z]:\//.test(bin)) return bin
412 return cwd.startsWith('/') && !/[~$`]/.test(cwd) ? join(cwd, bin) : 'fab'
413}
414
415function targetOf(env: Record<string, string>): Target {
416 const identity = [env.FAB_TENANT_ID, env.FAB_SPN_CLIENT_ID].filter(Boolean).join('|')
417 return identity ? { kind: 'fabric', identity } : { kind: 'fabric' }
418}
419
420function listing(stdout: string, n: TreeNode, nodes: TreeNode[]): { kids: TreeNode[]; become: string } {
421 return inOneLake(n) ? parseOneLake(stdout, n) : { kids: parseChildren(stdout, n, nodes), become: '' }
422}
423
424async function fabLs($: EngineInterface, path?: string): Promise<string> {
425 const ctx = fabContext
426 const where = path ? `/${path.replace(/^\/+/, '')}` : '/'
427 const run = await limited(() =>
428 $.process.run([ctx.bin, 'ls', where, '-l', '--output_format', 'json'], {
429 timeoutMs: 60_000,
430 ...(Object.keys(ctx.env).length ? { env: ctx.env } : {}),
431 }),
432 ).catch((err: unknown) => {
433 throw spawnFailure(err, ctx.bin)
434 })
435 if (run.exitCode !== 0) throw runFailure(run, 'fab ls failed')
436 if (run.isStdoutTruncated) throw new Error(`fab ls ${where}: too much output to show`)
437 return run.stdout
438}
439
440const loading = new Map<string, Promise<boolean>>()
441let statusOwner = ''
442
443function errorText(err: unknown): string {
444 return `error: ${err instanceof Error ? err.message : String(err)}`
445}
446
447function wsNode(nodes: TreeNode[], workspace: string): TreeNode | undefined {
448 const want = `W:${workspace}`.toLowerCase()
449 return nodes.find(n => n.id.toLowerCase() === want)
450}
451
452function wsId(nodes: TreeNode[], workspace: string): string {
453 return wsNode(nodes, workspace)?.id ?? `W:${workspace}`
454}
455
456async function pool<T>(items: T[], fn: (item: T) => Promise<unknown>, size = 4): Promise<void> {
457 const queue = [...items]
458 await Promise.all(
459 Array.from({ length: Math.min(size, queue.length) }, async () => {
460 for (let item = queue.shift(); item !== undefined; item = queue.shift()) await fn(item)
461 }),
462 )
463}
464
465async function expandOpen($: EngineInterface, root = ''): Promise<void> {
466 const failed = new Set<string>()
467 for (let pass = 0; pass < 8; pass++) {
468 const ex = await get($)
469 const open = new Set(ex.expanded)
470 const pending = new Set(ex.nodes.filter(c => c.kind === PLACEHOLDER).map(c => c.parent))
471 const byId = new Map(ex.nodes.map(n => [n.id, n]))
472 const todo = ex.nodes.filter(n => {
473 if (!open.has(n.id) || !pending.has(n.id) || failed.has(n.id)) return false
474 const chain = ancestors(ex.nodes, n.id, byId)
475 return chain.every(a => open.has(a)) && (!root || chain.includes(root))
476 })
477 if (todo.length === 0) return
478 await pool(todo, async n => {
479 if (!(await expandFabric($, n))) failed.add(n.id)
480 })
481 }
482}
483
484async function fabRaw($: EngineInterface, args: string[]): Promise<string> {
485 const ctx = fabContext
486 const run = await limited(() =>
487 $.process.run([ctx.bin, ...args], { timeoutMs: 60_000, ...(Object.keys(ctx.env).length ? { env: ctx.env } : {}) }),
488 )
489 if (run.exitCode !== 0 || run.isStdoutTruncated) throw new Error((run.stderr || run.stdout).trim().split('\n')[0] || `fab ${args[0]} failed`)
490 return run.stdout
491}
492
493function quiet(p: Promise<unknown>): void {
494 void p.catch(() => undefined)
495}
496
497const domainOf = new Map<string, string>()
498const domainName = new Map<string, string>()
499
500function jsonOf<T>(text: string): T | null {
501 try {
502 return JSON.parse(text.slice(Math.max(0, text.indexOf('{')))) as T
503 } catch {
504 return null
505 }
506}
507
508async function loadDomains($: EngineInterface): Promise<void> {
509 const names = jsonOf<{ result?: { data?: { name?: string; id?: string }[] } }>(await fabRaw($, ['ls', '/.domains', '-l', '--output_format', 'json']).catch(() => ''))
510 domainName.clear()
511 for (const d of names?.result?.data ?? []) if (d.id && d.name) domainName.set(d.id.toLowerCase(), d.name.replace(/\.Domain$/i, ''))
512 const found = new Map<string, string>()
513 let token = ''
514 for (let page = 0; domainName.size > 0 && page < 50; page++) {
515 const body = jsonOf<{ status_code?: number; text?: { value?: { id?: string; domainId?: string }[]; continuationToken?: string } }>(
516 await fabRaw($, ['api', token ? `workspaces?continuationToken=${encodeURIComponent(token)}` : 'workspaces']).catch(() => ''),
517 )
518 if (!body || (body.status_code && body.status_code >= 400)) break
519 for (const w of body.text?.value ?? []) if (w.id && w.domainId && domainName.has(w.domainId.toLowerCase())) found.set(w.id.toLowerCase(), w.domainId.toLowerCase())
520 token = body.text?.continuationToken ?? ''
521 if (!token) break
522 }
523 domainOf.clear()
524 for (const [w, d] of found) domainOf.set(w, d)
525}
526
527function grouped(nodes: TreeNode[], on: boolean): TreeNode[] {
528 const flat = nodes.filter(n => n.kind !== 'domain').map(n => (n.kind === 'workspace' && n.parent !== '' ? { ...n, parent: '' } : n))
529 if (!on || domainOf.size === 0) return flat
530 const keyOf = (n: TreeNode) => domainOf.get(n.sig.toLowerCase()) ?? 'none'
531 const counts = new Map<string, number>()
532 for (const n of flat) if (n.kind === 'workspace') counts.set(keyOf(n), (counts.get(keyOf(n)) ?? 0) + 1)
533 if (![...counts.keys()].some(k => k !== 'none')) return flat
534 const label = (k: string) => (k === 'none' ? 'No domain' : (domainName.get(k) ?? `Domain ${k.slice(0, 8)}`))
535 const order = [...counts.keys()].sort((a, b) => (a === 'none' ? 1 : b === 'none' ? -1 : label(a).localeCompare(label(b))))
536 const domains = order.map(k => {
537 const count = counts.get(k) ?? 0
538 return { id: `D:${k}`, parent: '', kind: 'domain', name: label(k), path: '', hidden: false, sig: k, note: `${count} workspace${count === 1 ? '' : 's'}` }
539 })
540 return [...domains, ...flat.map(n => (n.kind === 'workspace' ? { ...n, parent: `D:${keyOf(n)}` } : n))]
541}
542
543async function toggleDomains($: EngineInterface): Promise<void> {
544 const on = (await get($)).byDomain === false
545 if (on && domainOf.size === 0) await loadDomains($)
546 await patch($, cur => ({ byDomain: on, nodes: grouped(cur.nodes, on), root: cur.root.startsWith('D:') ? '' : cur.root }))
547}
548
549async function relistRoot($: EngineInterface): Promise<void> {
550 const target = (await get($)).target
551 if (!target) return
552 try {
553 const kids = parseWorkspaces(await fabLs($))
554 await patch($, cur => (sameTarget(cur.target, target) ? { nodes: grouped(merge(grouped(cur.nodes, false), '', kids), cur.byDomain !== false), setup: null } : {}))
555 } catch (err) {
556 statusOwner = ''
557 const setup = setupOf(err)
558 await patchView($, cur => (sameTarget(cur.target, target) ? { status: setup ? '' : errorText(err), setup } : {}))
559 }
560}
561
562async function doRefresh($: EngineInterface): Promise<void> {
563 const target = (await get($)).target
564 if (!target) return
565 await patchView($, () => ({ status: 'loading' }))
566 treeGen++
567 try {
568 const byDomain = (await get($)).byDomain !== false
569 const [listing] = await Promise.all([fabLs($), byDomain ? loadDomains($) : Promise.resolve()])
570 const nodes = grouped(parseWorkspaces(listing), byDomain)
571 await patch($, cur => (sameTarget(cur.target, target) ? { nodes, expanded: cur.nodes.length ? cur.expanded : [], status: '', setup: null } : {}))
572 await expandOpen($)
573 } catch (err) {
574 const setup = setupOf(err)
575 await patch($, cur => (sameTarget(cur.target, target) ? { status: setup ? '' : errorText(err), setup } : {}))
576 }
577}
578
579const rerun = new Map<string, Promise<boolean>>()
580
581function reloadNode($: EngineInterface, id: string): Promise<boolean> {
582 const key = `reload:${id.toLowerCase()}`
583 const running = loading.get(key)
584 if (running) {
585 const again = () => {
586 rerun.delete(key)
587 return reloadNode($, id)
588 }
589 const next = rerun.get(key) ?? running.then(again, again)
590 rerun.set(key, next)
591 return next
592 }
593 const job = (async () => {
594 const pending = loading.get(id)
595 if (pending) await pending.catch(() => false)
596 const ex = await get($)
597 const n = ex.nodes.find(x => x.id === id)
598 if (!n || !isLoaded(ex.nodes, n.id)) return true
599 const gen = treeGen
600 try {
601 const { kids } = listing(await fabLs($, n.path), n, ex.nodes)
602 const owned = statusOwner === n.id
603 if (owned) statusOwner = ''
604 await patch($, cur => (gen === treeGen && cur.nodes.some(x => x.id === n.id) ? { nodes: merge(cur.nodes, n.id, kids), ...(owned ? { status: '' } : {}) } : {}))
605 } catch (err) {
606 statusOwner = n.id
607 await patchView($, () => ({ status: errorText(err) }))
608 return false
609 }
610 await expandOpen($, n.id)
611 return true
612 })().finally(() => loading.delete(key))
613 loading.set(key, job)
614 return job
615}
616
617async function reloadWorkspace($: EngineInterface, workspace: string): Promise<boolean> {
618 const ws = wsNode((await get($)).nodes, workspace)
619 return ws ? reloadNode($, ws.id) : true
620}
621
622function expandFabric($: EngineInterface, n: TreeNode): Promise<boolean> {
623 const running = loading.get(n.id)
624 if (running) return running
625 const job = (async () => {
626 const ex = await get($)
627 if (!ex.nodes.some(c => c.parent === n.id && c.kind === PLACEHOLDER)) return true
628 const gen = treeGen
629 try {
630 const { kids, become } = listing(await fabLs($, n.path), n, ex.nodes)
631 const owned = statusOwner === n.id
632 if (owned) statusOwner = ''
633 const fill = kids.length ? kids : become === 'lakehouse table' ? [] : [emptyMark(n.id)]
634 await patch($, cur =>
635 gen === treeGen && cur.nodes.some(x => x.parent === n.id && x.kind === PLACEHOLDER)
636 ? { nodes: cur.nodes.flatMap(x => (x.parent === n.id && x.kind === PLACEHOLDER ? fill : x.id === n.id && become ? [{ ...x, kind: become, note: become === 'lakehouse table' ? 'Delta table' : x.note }] : [x])), ...(owned ? { status: '' } : {}) }
637 : owned
638 ? { status: '' }
639 : {},
640 )
641 return true
642 } catch (err) {
643 statusOwner = n.id
644 await patchView($, () => ({ status: errorText(err) }))
645 return false
646 }
647 })().finally(() => loading.delete(n.id))
648 loading.set(n.id, job)
649 return job
650}
651
652async function reveal($: EngineInterface, workspace: string, focus = false): Promise<void> {
653 const n = wsNode((await get($)).nodes, workspace)
654 if (!n) return
655 await patch($, cur => ({ expanded: [...new Set([...cur.expanded, ...ancestors(cur.nodes, n.id), n.id])], ...(focus ? { cursor: n.id } : {}) }))
656 await expandFabric($, n)
657}
658
659async function revealPath($: EngineInterface, path: string): Promise<void> {
660 const segs = path.replace(/^\//, '').replace(/\/$/, '').split('/')
661 for (let i = 2; i < segs.length; i++) {
662 const want = segs.slice(0, i).join('/').toLowerCase()
663 const n = (await get($)).nodes.find(x => x.path.toLowerCase() === want)
664 if (!n) return
665 await expandFabric($, n)
666 }
667}
668
669function webUrl(_ex: Explorer, n: TreeNode): string {
670 return n.url || ''
671}
672
673async function openLocal($: EngineInterface, ex: Explorer, n: TreeNode): Promise<void> {
674 const model = modelOf(n)
675 if (model) await terminal($, ['te', 'interactive', '-s', model.server, '-d', model.database], ['te', 'interactive', '-s', model.server, '-d', model.database])
676 else if (webUrl(ex, n)) await openUrl($, webUrl(ex, n))
677}
678
679async function select($: EngineInterface, n: TreeNode): Promise<void> {
680 if (n.kind === PLACEHOLDER || n.kind === EMPTY) return
681 const ex = await get($)
682 const isLeaf = !ex.nodes.some(c => c.parent === n.id)
683 await patch($, cur => {
684 const open = new Set(cur.expanded)
685 if (!isLeaf) open.has(n.id) ? open.delete(n.id) : open.add(n.id)
686 return { expanded: [...open], cursor: n.id, selected: n.path ? n.id : cur.selected }
687 })
688 if (!isLeaf && (await expandFabric($, n))) await expandOpen($, n.id)
689 const detail: string[] = [`${n.kind} ${n.name}`, ...(n.path ? [`fab path: ${n.path}`] : n.note ? [n.note] : [])]
690 await patchView($, cur => (cur.cursor === n.id ? { detail } : {}))
691}
692
693function contextFor(ex: Explorer, n: TreeNode): string {
694 return [
695 'The user has this Fabric item selected in the Fabric pane; "this", "it" or "the selected" in the prompt likely refers to it.',
696 `${n.kind}: ${n.name}`,
697 `fab path: ${n.path}`,
698 ...ex.detail.slice(2),
699 ].join('\n')
700}
701
702const waiting = new Map<string, () => Promise<void>>()
703
704function backgroundOf(result: unknown): string {
705 if (!result || typeof result !== 'object') return ''
706 const id = (result as { backgroundTaskId?: unknown }).backgroundTaskId
707 return typeof id === 'string' ? id : ''
708}
709
710async function afterFab($: EngineInterface, calls: Invocation[], stale: Set<string>, succeeded: boolean): Promise<void> {
711 const head = calls[0]
712 if (!head) return
713 fabContext = contextOf(head)
714 const moved = await point($, targetOf(fabContext.env), 'unasked')
715 const start = await get($)
716 const fresh0 = moved || start.nodes.length === 0 || start.setup !== null
717 if (fresh0) await refresh($)
718 const prior = await get($)
719 const before = new Set(prior.expanded)
720 const lower = (xs: string[]) => [...new Map(xs.map(x => [x.toLowerCase(), x])).values()]
721 const changes = calls.filter(inv => fabKind(inv) === 'modify' || fabKind(inv) === 'upload')
722 const whole = new Set(
723 changes
724 .filter(inv => WHOLE_VERBS.has(inv.args[0] ?? '') || (inv.args[0] === 'set' && inv.args.some(a => /displayname/i.test(a))))
725 .flatMap(inv => fabPositionals(inv.args.slice(1)).filter(a => /^\/?[^/]+\.Workspace\/?$/i.test(a)).flatMap(a => fabWorkspaces({ ...inv, args: ['', a] })))
726 .map(w => w.toLowerCase()),
727 )
728 if (whole.size && !fresh0) await relistRoot($)
729 await pool(lower(calls.flatMap(fabWorkspaces).filter(w => !whole.has(w.toLowerCase()))), w => reveal($, w))
730 await pool(
731 lower(
732 changes.flatMap(fabWorkspaces).filter(w => {
733 if (whole.has(w.toLowerCase())) return false
734 const id = wsId(prior.nodes, w)
735 return isLoaded(prior.nodes, id) || stale.has(id)
736 }),
737 ),
738 w => reloadWorkspace($, w),
739 )
740 const folders = new Set<string>()
741 for (const inv of changes) {
742 for (const a of fabPositionals(inv.args.slice(1))) {
743 const segs = a.replace(/^\/+|\/+$/g, '').split('/')
744 for (let i = 2; i < segs.length; i++) {
745 const want = segs.slice(0, i).join('/').toLowerCase()
746 const n = prior.nodes.find(x => x.path.toLowerCase() === want)
747 if (n && isLoaded(prior.nodes, n.id)) folders.add(n.id)
748 }
749 }
750 }
751 await pool([...folders], id => reloadNode($, id))
752 for (const a of lower(calls.flatMap(inv => inv.args.slice(1).filter(x => /\.Workspace\//i.test(x))))) await revealPath($, a)
753 if (!succeeded) return
754 const fresh = await get($)
755 const lit = { touched: [] as string[], opened: [] as string[] }
756 for (const inv of calls) {
757 const touched = fabTouched(inv, fresh.nodes)
758 const opened = touched.length ? fabWorkspaces(inv).map(w => wsId(fresh.nodes, w)).filter(id => !before.has(id)) : []
759 lit.touched.push(...touched)
760 lit.opened.push(...opened)
761 }
762 await flash($, lit.touched, lit.opened)
763}
764
765function byGuid(guids: string[], nodes: TreeNode[]): string[] {
766 if (guids.length === 0) return []
767 const want = new Set(guids)
768 return nodes.filter(n => (n.sig && want.has(n.sig.toLowerCase())) || [...want].some(g => (n.url ?? '').toLowerCase().includes(g))).map(n => n.id)
769}
770
771const SQL_KINDS = new Set(['Lakehouse', 'Warehouse', 'SQLEndpoint', 'SQLDatabase', 'MirroredDatabase'])
772
773async function queryNodes($: EngineInterface, queries: { inv: Invocation; q: ReturnType<typeof queryOf> }[], nodes: TreeNode[]): Promise<{ ids: string[]; workspaces: string[]; models: string[] }> {
774 const ids: string[] = []
775 const workspaces: string[] = []
776 const models: string[] = []
777 for (const { inv, q } of queries) {
778 if (!q) continue
779 let workspace = q.workspace
780 let model = q.model
781 if (q.report) {
782 try {
783 const base = posix(q.report).startsWith('/') ? posix(q.report) : `${posix(inv.cwd).replace(/\/+$/, '')}/${posix(q.report)}`
784 const found = modelFromConnection(String(await $.fs.read(`${base.replace(/\/+$/, '')}/definition.pbir`)))
785 if (found) ({ workspace, model } = found)
786 } catch {
787 continue
788 }
789 }
790 if (workspace && model) {
791 workspaces.push(workspace)
792 const path = `${workspace}.workspace/${model}.semanticmodel`.toLowerCase()
793 models.push(path)
794 const n = nodes.find(x => x.path.toLowerCase() === path)
795 if (n) ids.push(n.id)
796 }
797 if (q.database) ids.push(...nodes.filter(n => SQL_KINDS.has(n.kind) && n.name.toLowerCase() === q.database.toLowerCase()).map(n => n.id))
798 }
799 return { ids: [...new Set(ids)], workspaces: [...new Set(workspaces)], models }
800}
801
802async function afterQueries($: EngineInterface, asked: { ids: string[]; workspaces: string[]; models: string[] }): Promise<void> {
803 if (closed || noDock) return
804 await pool(asked.workspaces, w => reveal($, w))
805 const fresh = await get($)
806 const ids = [...new Set([...asked.ids, ...fresh.nodes.filter(n => asked.models.includes(n.path.toLowerCase())).map(n => n.id)])]
807 await flash($, ids)
808}
809
810function wholeArg(args: string | undefined): string {
811 const raw = (args ?? '').trim()
812 return /^["']/.test(raw) ? (tokenize(raw)[0] ?? '') : raw
813}
814
815export const register: Register = (on, options) => {
816 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
817 follow = options?.follow !== 'off'
818 fontHint = options?.fontHint !== 'off'
819
820 on('session.start', async ($, e, next) => {
821 useDrives((await $.env.get('OS')) === 'Windows_NT')
822 hinted = false
823 fellBack = false
824 quiet(detectGlyphs($).then(() => $.ui.invalidate('ui.render')))
825 running = 0
826 generation++
827 blink?.cancel()
828 blink = null
829 await patchView($, () => ({ flash: [], flashDim: [], work: null, busy: {} }))
830 await $.command.register({ name: PANE, description: 'Open the Fabric pane; args: [workspace]' })
831 const ex = await get($)
832 if (ex.target) quiet(openPane($, { id: PANE, title: titleFor(ex.target) }))
833 return next(e)
834 })
835
836 on('command.run', { command: PANE }, async ($, e) => {
837 const terminalOnly = !(await $.session.surfaces().catch(() => ['terminal'])).some(x => x !== 'terminal')
838 if (terminalOnly && e.presentation && !e.presentation.isFullscreen) return { text: 'The Fabric pane shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /fabric-pane.' }
839 if (terminalOnly && e.presentation && e.presentation.columns < 110) return { text: 'The Fabric pane shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /fabric-pane.' }
840 noDock = false
841 const workspace = wholeArg(e.args)
842 await point($, targetOf(fabContext.env), 'asked', true)
843 await refresh($)
844 if (workspace) await reveal($, workspace.replace(/\.Workspace$/i, ''), true)
845 return { text: workspace ? `Fabric pane on ${workspace}.` : 'Fabric pane open.' }
846 })
847
848 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
849 const command = e.tool === 'Bash' ? e.command : ''
850 if (!/\b(fab|te|pbir|sqlcmd)\b/.test(command)) return next(e)
851 const found = invocations(command, await cwdOf($), {}, ['fab', 'te', 'pbir', 'sqlcmd'])
852 const calls = fabCalls(found.filter(i => i.tool === 'fab'))
853 const queries = found.filter(i => i.tool !== 'fab').flatMap(i => {
854 const q = queryOf(i)
855 return q ? [{ inv: i, q }] : []
856 })
857 if (calls.length === 0 && queries.length === 0) return next(e)
858 if (closed || noDock) return next(e)
859 const ex = await get($)
860 const asked = await queryNodes($, queries, ex.nodes)
861 const mark: Mark = {
862 ids: [HEADER, ...calls.flatMap(inv => [...fabWorkspaces(inv).map(w => wsId(ex.nodes, w)), ...fabTouched(inv, ex.nodes), ...byGuid(fabGuids(inv), ex.nodes)]), ...asked.ids],
863 tone: toneOf([...calls.map(fabKind), ...(queries.length ? (['read'] as FabKind[]) : [])]),
864 }
865 await begin($, mark)
866 let result: Awaited<ReturnType<typeof next>>
867 try {
868 result = await next(e)
869 } catch (err) {
870 await finish($, mark)
871 throw err
872 }
873 const task = result.deny ? '' : backgroundOf(result.result)
874 const stale = new Set(loading.keys())
875 const settle = async () => {
876 try {
877 if (result.deny || closed || noDock) return
878 if (calls.length) await afterFab($, calls, stale, !result.isError)
879 if (queries.length && !result.isError) await afterQueries($, asked)
880 } finally {
881 await finish($, mark)
882 }
883 }
884 if (task) waiting.set(task, settle)
885 else void settle().catch(() => undefined)
886 return result
887 })
888
889 on('ui.close', async ($, e, next) => {
890 const result = await next(e)
891 if (e.id === PANE && e.origin.kind === 'person') closed = true
892 return result
893 })
894
895 on('ui.message', async ($, e, next) => {
896 if (e.requestId === PANE && e.element === 'setup' && e.data && typeof e.data === 'object') {
897 const data = e.data as { press?: unknown; tab?: unknown; copy?: unknown }
898 const pick = [data.tab, data.press, data.copy].find((v): v is string => typeof v === 'string') ?? ''
899 if (pick === REFRESH) quiet(refresh($))
900 else if (pick.startsWith(COPY)) await copyOf($, pick.slice(COPY.length), e.surface)
901 return {}
902 }
903 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
904 const view = views.get(e.surface) ?? { from: 0, max: 0 }
905 const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
906 const ex = await get($)
907 if (typeof data.scrollTo === 'number') {
908 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
909 if (to !== ex.scroll) await patchView($, () => ({ scroll: to }))
910 return {}
911 }
912 if (typeof data.copy === 'string') {
913 const n = ex.nodes.find(x => x.id === data.copy)
914 if (n) await copyOf($, n.path || n.name, e.surface)
915 return {}
916 }
917 if (typeof data.press === 'string') {
918 const n = ex.nodes.find(x => x.id === data.press)
919 if (!n) return {}
920 if (ex.scroll === null) await patchView($, () => ({ scroll: view.from }))
921 if (data.ctrl) await openWeb($, ex, n)
922 else if (data.shift) await openLocal($, ex, n)
923 else await press($, n)
924 return {}
925 }
926 if (typeof data.key !== 'string') return {}
927 const rows = visible(ex, SORT, rootOf(ex))
928 const at = rows.findIndex(r => r.node.id === ex.cursor)
929 const cur = rows[at]
930 const move = (d: number) => {
931 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
932 return target ? patchView($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
933 }
934 if (data.key === 'up' || data.key === 'k') await move(-1)
935 else if (data.key === 'down' || data.key === 'j') await move(1)
936 else if (data.key === 'pageup') await move(-10)
937 else if (data.key === 'pagedown') await move(10)
938 else if (data.key === 'home') await move(-rows.length)
939 else if (data.key === 'end') await move(rows.length)
940 else if (cur && (data.key === 'y' || data.key === 'Y')) await copyOf($, cur.node.path || cur.node.name, e.surface)
941 else if (cur && (data.key === 'right' || data.key === 'l') && !cur.leaf && !cur.open) await select($, cur.node)
942 else if (cur && (data.key === 'left' || data.key === 'h')) {
943 if (!cur.leaf && cur.open) await patchView($, x => ({ expanded: x.expanded.filter(id => id !== cur.node.id) }))
944 else if (cur.node.parent) await patchView($, () => ({ cursor: cur.node.parent }))
945 } else if (cur && (data.key === ' ' || data.key === 'return')) await (data.key === 'return' && cur.leaf ? openLocal($, ex, cur.node) : select($, cur.node))
946 return {}
947 })
948
949 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
950 const view = views.get('terminal') ?? views.get('desktop') ?? { from: 0, max: 0 }
951 const ex = await get($)
952 const to = Math.max(0, Math.min(view.max, (ex.scroll ?? view.from) + Math.sign(e.by) * Math.max(3, Math.abs(e.by))))
953 if (to !== ex.scroll) await patchView($, () => ({ scroll: to }))
954 return {}
955 })
956
957 on('prompt.submit', async ($, e, next) => {
958 if (e.origin?.kind === 'task-notification') {
959 for (const [task, settle] of waiting) {
960 if (!e.text.includes(task)) continue
961 waiting.delete(task)
962 void settle().catch(() => undefined)
963 }
964 }
965 const ex = await get($)
966 const n = ex.nodes.find(x => x.id === ex.selected)
967 if (!ex.target || closed || noDock) return next(e)
968 const hint = fontNote()
969 const context = [...(e.context ?? []), ...(n ? [contextFor(ex, n)] : []), ...(hint ? [hint] : [])]
970 return context.length === (e.context ?? []).length ? next(e) : next({ ...e, context })
971 })
972
973 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
974 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
975 if (e.surface === 'terminal' && e.props.placement === 'inline') {
976 noDock = true
977 void $.ui.close({ id: PANE }).catch(() => undefined)
978 const { Box: Empty } = $.ui.resolve(e)
979 return <Empty />
980 }
981 const tier = tierFor(e.surface)
982 if (e.surface === 'terminal' && tier === 'plain' && glyphSetting === 'auto') fellBack = true
983 const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
984 const ex = await get($)
985 const now = await $.clock.now()
986 const busyTone = (id: string) => {
987 const b = ex.busy[id]
988 return b && now - b.at < BUSY_MAX_MS ? b.tone : ''
989 }
990 const work = ex.work && now - ex.work.at < BUSY_MAX_MS ? ex.work : null
991 const brightSet = new Set(work ? ex.flash : [])
992 const dimSet = new Set(work ? ex.flashDim : [])
993 const width = Math.max(20, e.props.bodyColumns)
994 const rows = visible(ex, SORT, rootOf(ex))
995 const detailRows = ex.detail.length ? Math.min(ex.detail.length, DETAIL_ROWS) + 2 : 0
996 const room = Math.max(5, Math.min(WINDOW, (e.props.scroll?.bodyRows ?? 40) - 4 - detailRows))
997 const focusId = follow && work && ex.flash.length ? (ex.flash[ex.flash.length - 1] ?? ex.cursor) : ex.cursor
998 const at = Math.max(0, rows.findIndex(r => r.node.id === focusId))
999 const isLit = (id: string) => brightSet.has(id) || dimSet.has(id)
1000 const lit = follow && work ? rows.findIndex(r => isLit(r.node.id)) : -1
1001 const fits = lit >= 0 && at - lit < room - 2
1002 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))
1003 const inView = lit >= base && at < base + room
1004 const flashing = lit >= 0 && !inView
1005 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))
1006 let from = flashing ? nearBottom(room) : base
1007 const cap = Math.max(1, Math.floor(room / 3))
1008 const chain = new Set(flashing ? ancestors(ex.nodes, focusId) : [])
1009 const keep = (id: string) => chain.has(id) || isLit(id)
1010 let pinned = flashing ? rows.slice(0, from).filter(r => keep(r.node.id)).slice(-cap) : []
1011 if (pinned.length) {
1012 from = nearBottom(Math.max(3, room - pinned.length - 1))
1013 pinned = rows.slice(0, from).filter(r => keep(r.node.id)).slice(-cap)
1014 }
1015 const max = Math.max(0, rows.length - room)
1016 if (ex.scroll !== null) {
1017 from = Math.max(0, Math.min(ex.scroll, max))
1018 pinned = []
1019 }
1020 views.set(e.surface, { from, max })
1021 const shown = rows.slice(from, from + room - pinned.length)
1022 const sel = ex.nodes.find(n => n.id === ex.selected)
1023 const clip = (s: string, max = width) => (s.length > max ? s.slice(0, max - 1) + '…' : s)
1024 const spin = (tone: string): Seg => ({ t: ' ', spin: true, b: true, c: TONES[tone]?.bright[1] ?? SHIMMER[1] })
1025 const rowSpec = (r: (typeof rows)[number]): RowSpec => {
1026 const n = r.node
1027 const g = glyph(n, tier)
1028 const arrow = r.leaf ? ' ' : tier === 'plain' ? (r.open ? '▾ ' : '▸ ') : r.open ? '\u{f47c} ' : '\u{f460} '
1029 const note = n.note ? ` ${n.note}` : ''
1030 const cols = Math.max(4, width - r.depth * 2 - 6 - note.length)
1031 const busy = busyTone(n.id)
1032 const isBright = brightSet.has(n.id) || (work !== null && busy !== '')
1033 const isDim = !isBright && dimSet.has(n.id)
1034 const name = clip(n.name, cols)
1035 const faded = n.hidden || n.kind === 'placeholder' || n.kind === EMPTY
1036 const tone = work?.tone ?? 'orange'
1037 const left: Seg[] = [
1038 { t: ' '.repeat(r.depth) },
1039 { t: arrow, c: '#7a7a86' },
1040 isBright || isDim ? { t: g.char + ' ', sh: tone, dim: isDim, one: true } : { t: g.char + ' ', c: faded ? '#6e6e7a' : g.color },
1041 isBright || isDim ? { t: name, sh: tone, dim: isDim, b: isBright } : { t: name, c: faded ? '#6e6e7a' : g.label, b: n.id === ex.selected },
1042 ]
1043 if (busy) left.push(spin(busy))
1044 return { id: n.kind === 'placeholder' || n.kind === EMPTY ? '' : n.id, left: clean(left), right: note ? [{ t: note, c: '#6e6e7a' }] : [] }
1045 }
1046 const note = (text: string): RowSpec => ({ id: '', left: [{ t: text, c: '#6e6e7a' }], right: [] })
1047 const specs: RowSpec[] = [...pinned.map(rowSpec), ...(pinned.length > 0 ? [note(' ⋮')] : []), ...shown.map(rowSpec)]
1048 const barSize = Math.max(1, Math.round((specs.length * shown.length) / Math.max(1, rows.length)))
1049 const bar =
1050 rows.length > shown.length + pinned.length
1051 ? { pos: max ? Math.round((from / max) * (specs.length - barSize)) : 0, size: barSize, thumb: '#5b9bd5', track: '#4a4a56' }
1052 : undefined
1053 const icon = (nerd: string, fallback: string) => (tier === 'plain' ? fallback : nerd)
1054 const rootNode = ex.nodes.find(n => n.id === rootOf(ex))
1055 const rootName = rootNode ? rootNode.name : ''
1056 const head: RowSpec = {
1057 id: '',
1058 left: clean([
1059 { t: `${titleGlyph(tier)} `, c: TITLE_COLOR },
1060 { t: rootName || (ex.target ? targetLabel(ex.target) : TITLE), b: true },
1061 ...(ex.status ? [{ t: ` ${ex.status}`, c: '#6e6e7a' }] : []),
1062 ...(busyTone(HEADER) ? [spin(busyTone(HEADER))] : []),
1063 ]),
1064 right: [],
1065 }
1066 const spinner = tier === 'plain' ? PLAIN_SPINNER : []
1067 return (
1068 <Box flexDirection="column" minHeight={Math.max(1, e.props.scroll?.bodyRows ?? 1)}>
1069 <Box flexDirection="row">
1070 {domainOf.size > 0 && <Button key="domains" plain dimColor={ex.byDomain === false} label={`${icon('\u{f0ac}', '◇')} `} onPress={() => quiet(toggleDomains($))} />}
1071 <Box flexGrow={1} flexShrink={1}>
1072 <Client key="head" module="./rows.tsx" props={{ rows: [head], active: '', activeBg: '', hoverBg: '', tones: TONES, spinner } satisfies RowsProps} />
1073 </Box>
1074 <Box flexDirection="row" gap={2}>
1075 {rootNode && <Button key="up" plain dimColor label={icon('\u{f005d}', '↑')} onPress={() => quiet(goUp($))} />}
1076 {rootNode && <Button key="home" plain dimColor label={icon('\u{f02dc}', '⌂')} onPress={() => quiet(goHome($))} />}
1077 <Button key="refresh" plain dimColor label={icon('\u{f0450}', '↻')} onPress={() => quiet(refresh($))} />
1078 <Button key="collapse" plain dimColor label={icon('\u{eac5}', '⊟')} onPress={() => quiet(patchView($, () => ({ expanded: [] })))} />
1079 {sel && <Button key="clear" plain label={icon('\u{f0156}', '✕')} onPress={() => quiet(patchView($, () => ({ selected: '', detail: [] })))} />}
1080 <Text> </Text>
1081 </Box>
1082 </Box>
1083 <Box flexDirection="row">
1084 <Box flexGrow={1}>
1085 {work && !ex.query ? (
1086 <Client key="working" module="./rows.tsx" props={{ rows: [{ id: '', left: [{ t: 'Claude is working in Fabric...', sh: work.tone }], right: [] }], active: '', activeBg: '', hoverBg: '', tones: TONES, spinner } satisfies RowsProps} />
1087 ) : (
1088 <Input
1089 key="q"
1090 label="/ "
1091 placeholder="search"
1092 submitLabel="jump"
1093 autoFocus
1094 value={ex.query}
1095 onInput={(v: string) => quiet(patchView($, () => ({ query: v })))}
1096 onSubmit={(v: string) => quiet(patch($, cur => jumpTo(cur, v)))}
1097 />
1098 )}
1099 </Box>
1100 {ex.query ? <Button key="clearq" plain dimColor label={tier === 'plain' ? '×' : '\u{f0156}'} onPress={() => quiet(patchView($, () => ({ query: '' })))} /> : null}
1101 </Box>
1102 {!ex.setup && ex.nodes.length === 0 && <Text dimColor>{ex.target ? 'nothing loaded yet' : HINT}</Text>}
1103 {ex.setup ? (
1104 <Client key="setup" module="./rows.tsx" props={{ rows: setupRows(ex.setup, tier, width), active: '', activeBg: '', hoverBg: '#2d2f33', tones: TONES, spinner } satisfies RowsProps} />
1105 ) : (
1106 <Client key="rows" module="./rows.tsx" props={{ rows: specs, active: ex.cursor, activeBg: '#3e4451', hoverBg: '#2d2f33', tones: TONES, spinner, ...(bar ? { bar } : {}) } satisfies RowsProps} />
1107 )}
1108 {ex.detail.length > 0 && (
1109 <Box flexDirection="column" marginTop={1}>
1110 {ex.detail.slice(0, DETAIL_ROWS).map((l, i) => (
1111 <Text key={String(i)} dimColor={i > 0} bold={i === 0} wrap="truncate-end">
1112 {l.replace(/\s+/g, ' ')}
1113 </Text>
1114 ))}
1115 {sel && (
1116 <Box flexDirection="row" gap={2}>
1117 {modelOf(sel) && <Button key="open" plain label={`${icon('\u{f0379}', '↗')} open in te`} onPress={() => quiet(openLocal($, ex, sel))} />}
1118 {webUrl(ex, sel) && <Button key="web" plain label={`${icon('\u{f059f}', '◎')} open in Fabric`} onPress={() => quiet(openWeb($, ex, sel))} />}
1119 </Box>
1120 )}
1121 </Box>
1122 )}
1123 <Box flexGrow={1} />
1124 {sel && (
1125 <Text dimColor wrap="truncate-start">
1126 selected: {sel.path || sel.name}
1127 </Text>
1128 )}
1129 </Box>
1130 )
1131 })
1132}
1133hooks/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 97 lines1import type { TreeNode } from '../types'
2import { FAB_ITEMS } from './icon-data'
3
4type Rgb = [number, number, number]
5type Icon = { fabric: number; nerd: string; rgb: Rgb | null }
6export type Tier = 'fabric' | 'nerd' | 'plain'
7
8const GLYPH_DIM: Rgb = [0x6e, 0x6e, 0x7a]
9const WORKSPACE: Rgb = [0xcf, 0xe9, 0xe7]
10const CONTAINER: Icon = { fabric: 0xf2609, nerd: '\u{f024b}', rgb: [197, 197, 197] }
11
12const ITEM_NERD: Record<string, string> = {
13 SemanticModel: '\u{f01bc}',
14 Report: '\u{f0219}',
15 Notebook: '\u{f082e}',
16 Lakehouse: '\u{f0a0b}',
17 Warehouse: '\u{f0a0b}',
18}
19
20export type Glyph = { char: string; color: string | undefined; label?: string }
21
22function hex(rgb: Rgb | null): string | undefined {
23 return rgb ? '#' + rgb.map(v => v.toString(16).padStart(2, '0')).join('') : undefined
24}
25
26const PLAIN_ITEMS: Record<string, string> = {
27 SemanticModel: '◆', Report: '▣', PaginatedReport: '▤', Dashboard: '▦', KQLDashboard: '▦', Lakehouse: '≋', Warehouse: '▥',
28 SQLEndpoint: '▥', SQLDatabase: '▥', Datamart: '▥', Notebook: '≡', SparkJobDefinition: '∗', DataPipeline: '⇉', CopyJob: '⇉',
29 MountedDataFactory: '⇉', Dataflow: '⇶', Eventhouse: '◎', KQLDatabase: '◎', KQLQueryset: '⌕', Eventstream: '≈',
30 Environment: '◌', MLExperiment: '∴', MLModel: '∷', MirroredCatalog: '◑', MirroredDatabase: '◑', MirroredWarehouse: '◑',
31 DataAgent: '◈', OperationsAgent: '◈', Ontology: '⋔', UserDataFunction: 'ƒ', VariableLibrary: '⊞', AppBackend: '▢', Apps: '▢', OrgApp: '▢',
32}
33
34function pick(icon: Icon, tier: Tier, plain: string): Glyph {
35 if (tier === 'plain') return { char: plain, color: hex(icon.rgb) }
36 return { char: tier === 'fabric' && icon.fabric ? String.fromCodePoint(icon.fabric) : icon.nerd, color: hex(icon.rgb) }
37}
38
39function hls(rgb: Rgb): [number, number, number] {
40 const [r, g, b] = rgb.map(v => v / 255) as Rgb
41 const max = Math.max(r, g, b)
42 const min = Math.min(r, g, b)
43 const l = (max + min) / 2
44 const range = max - min
45 if (range < 1e-6) return [0, l, 0]
46 const s = l <= 0.5 ? range / (max + min) : range / (2 - max - min)
47 const rc = (max - r) / range
48 const gc = (max - g) / range
49 const bc = (max - b) / range
50 const h = r === max ? bc - gc : g === max ? 2 + rc - bc : 4 + gc - rc
51 return [(((h / 6) % 1) + 1) % 1, l, s]
52}
53
54function component(m1: number, m2: number, hue: number): number {
55 const h = ((hue % 1) + 1) % 1
56 if (h < 1 / 6) return m1 + (m2 - m1) * h * 6
57 if (h < 0.5) return m2
58 if (h < 2 / 3) return m1 + (m2 - m1) * (2 / 3 - h) * 6
59 return m1
60}
61
62function lift(rgb: Rgb): Rgb {
63 const [h, l0, s] = hls(rgb)
64 const l = Math.max(l0, 0.62)
65 if (s < 1e-6) return [Math.round(l * 255), Math.round(l * 255), Math.round(l * 255)]
66 const m2 = l <= 0.5 ? l * (1 + s) : l + s - l * s
67 const m1 = 2 * l - m2
68 return [h + 1 / 3, h, h - 1 / 3].map(x => Math.round(component(m1, m2, x) * 255)) as Rgb
69}
70
71const items = new Map<string, Icon>()
72
73function item(type: string): Icon {
74 const known = items.get(type)
75 if (known) return known
76 const hit = FAB_ITEMS[type]
77 const made = { fabric: hit?.[0] ?? 0, nerd: ITEM_NERD[type] ?? '\u{f0214}', rgb: hit?.[1] ? lift(hit[1]) : null }
78 items.set(type, made)
79 return made
80}
81
82export function glyph(n: TreeNode, tier: Tier): Glyph {
83 if (n.kind === 'placeholder' || n.kind === 'empty') return { char: n.kind === 'empty' ? '∅' : '…', color: hex(GLYPH_DIM) }
84 if (n.kind === 'workspace') return { ...pick({ fabric: 0xf203e, nerd: '\u{f0253}', rgb: GLYPH_DIM }, tier, '◫'), label: hex(WORKSPACE) }
85 if (n.kind === 'fabric folder') return pick({ fabric: 0, nerd: '\u{f024b}', rgb: GLYPH_DIM }, tier, '■')
86 if (n.kind === 'domain') return { ...pick({ fabric: 0, nerd: '\u{f0ac}', rgb: [196, 181, 253] }, tier, '◇'), label: hex([221, 214, 254]) }
87 if (n.kind === 'lakehouse folder' || n.kind === 'onelake folder' || n.kind === 'onelake dir' || n.kind === 'onelake schema') return pick({ fabric: 0, nerd: '\u{f024b}', rgb: GLYPH_DIM }, tier, '■')
88 if (n.kind === 'lakehouse table') return pick({ fabric: 0xf2621, nerd: '\u{f04eb}', rgb: [69, 137, 224] }, tier, '▦')
89 if (n.kind === 'onelake shortcut') return pick({ fabric: 0, nerd: '\u{f0337}', rgb: GLYPH_DIM }, tier, '↪')
90 if (n.kind === 'onelake file') return pick({ fabric: 0, nerd: '\u{f0214}', rgb: GLYPH_DIM }, tier, '▫')
91 if (FAB_ITEMS[n.kind]) {
92 const g = pick(item(n.kind), tier, PLAIN_ITEMS[n.kind] ?? '▪')
93 return { ...g, label: g.color }
94 }
95 return pick(CONTAINER, tier, '■')
96}
97hooks/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 132 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 flash: [],
16 work: null,
17 busy: {},
18 flashDim: [],
19 scroll: null,
20 root: '',
21 setup: null,
22 }
23}
24
25function childrenOf(nodes: TreeNode[]): Map<string, TreeNode[]> {
26 const kids = new Map<string, TreeNode[]>()
27 for (const n of nodes) {
28 const list = kids.get(n.parent)
29 if (list) list.push(n)
30 else kids.set(n.parent, [n])
31 }
32 return kids
33}
34
35export const EMPTY = 'empty'
36
37export function emptyMark(parent: string): TreeNode {
38 return { id: `${parent}/∅`, parent, kind: EMPTY, name: 'empty', path: '', hidden: false, sig: '', note: '' }
39}
40
41export function isLoaded(nodes: TreeNode[], id: string): boolean {
42 let any = false
43 for (const n of nodes) {
44 if (n.parent !== id) continue
45 if (n.kind === 'placeholder') return false
46 any = true
47 }
48 return any
49}
50
51export function merge(nodes: TreeNode[], parent: string, kids: TreeNode[]): TreeNode[] {
52 const under = childrenOf(nodes)
53 const subtree = (id: string, out: TreeNode[] = []): TreeNode[] => {
54 for (const c of under.get(id) ?? []) {
55 out.push(c)
56 subtree(c.id, out)
57 }
58 return out
59 }
60 const known = new Map(nodes.map(n => [n.id, n]))
61 const loaded = (id: string) => {
62 const cs = under.get(id) ?? []
63 return cs.length > 0 && !cs.some(c => c.kind === 'placeholder')
64 }
65 const same = (k: TreeNode) => {
66 const old = known.get(k.id)
67 return Boolean(old) && old?.kind === k.kind && old?.sig === k.sig
68 }
69 const keep = new Set(kids.filter(k => k.kind !== 'placeholder' && same(k) && loaded(k.id)).map(k => k.id))
70 const gone = new Set(subtree(parent).map(n => n.id))
71 const out = nodes.filter(n => !gone.has(n.id))
72 for (const k of kids) {
73 if (k.kind === 'placeholder' && keep.has(k.parent)) continue
74 out.push(k)
75 if (keep.has(k.id)) subtree(k.id, out)
76 }
77 if (kids.length === 0) out.push(emptyMark(parent))
78 return out
79}
80
81export function ancestors(nodes: TreeNode[], id: string, byId?: Map<string, TreeNode>): string[] {
82 const map = byId ?? new Map(nodes.map(n => [n.id, n]))
83 const out: string[] = []
84 let cur = map.get(id)
85 while (cur && cur.parent) {
86 out.push(cur.parent)
87 cur = map.get(cur.parent)
88 }
89 return out
90}
91
92const RANK: Record<string, number> = { folder: 0, group: 3 }
93const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
94
95function sorted(list: TreeNode[]): TreeNode[] {
96 return [...list].sort(
97 (a, b) => (RANK[a.kind] ?? 2) - (RANK[b.kind] ?? 2) || collator.compare(a.name, b.name),
98 )
99}
100
101export function visible(ex: Explorer, sort = false, root = ''): Row[] {
102 const kids = childrenOf(ex.nodes)
103 const q = ex.query.trim().toLowerCase()
104 let keep: Set<string> | null = null
105 if (q) {
106 keep = new Set()
107 const byId = new Map(ex.nodes.map(n => [n.id, n]))
108 for (const n of ex.nodes) {
109 if (!n.name.toLowerCase().includes(q) && !n.note.toLowerCase().includes(q)) continue
110 let cur: TreeNode | undefined = n
111 while (cur && !keep.has(cur.id)) {
112 keep.add(cur.id)
113 cur = byId.get(cur.parent)
114 }
115 }
116 }
117 const open = new Set(ex.expanded)
118 const rows: Row[] = []
119 const walk = (parent: string, depth: number) => {
120 const list = kids.get(parent) ?? []
121 for (const n of sort ? sorted(list) : list) {
122 if (keep && !keep.has(n.id)) continue
123 const leaf = !kids.has(n.id)
124 const isOpen = !leaf && (keep ? true : open.has(n.id))
125 rows.push({ node: n, depth, open: isOpen, leaf })
126 if (isOpen) walk(n.id, depth + 1)
127 }
128 }
129 walk(root, 0)
130 return rows
131}
132hooks/fabric.ts 142 lines1import type { TreeNode } from '../types'
2
3export const PLACEHOLDER = 'placeholder'
4const PORTAL = 'https://app.powerbi.com'
5
6const ITEM_ROUTE: Record<string, string> = {
7 Report: 'reports',
8 SemanticModel: 'datasets',
9 Dashboard: 'dashboards',
10 PaginatedReport: 'rdlreports',
11 Lakehouse: 'lakehouses',
12 Warehouse: 'datawarehouses',
13 Notebook: 'synapsenotebooks',
14 DataPipeline: 'pipelines',
15 Dataflow: 'dataflows-gen2',
16 Eventhouse: 'eventhouses',
17 KQLDatabase: 'databases',
18 SQLDatabase: 'sqldatabases',
19 Environment: 'sparkenvironments',
20}
21
22type Entry = { name: string; id: string; type?: string }
23
24function node(id: string, parent: string, kind: string, name: string, path: string, url = ''): TreeNode {
25 return { id, parent, kind, name, path, hidden: false, sig: '', note: '', url }
26}
27
28function stub(parent: string): TreeNode {
29 return node(`${parent}/…`, parent, PLACEHOLDER, 'loading…', '')
30}
31
32function entries(stdout: string): Entry[] {
33 const start = stdout.indexOf('{')
34 if (start < 0) throw new Error('fab ls returned no JSON')
35 const parsed = JSON.parse(stdout.slice(start)) as { result?: { data?: Entry[] } }
36 if (!parsed.result || !Array.isArray(parsed.result.data ?? [])) throw new Error('fab ls returned an unexpected shape')
37 return (parsed.result.data ?? []).filter(e => typeof e?.name === 'string')
38}
39
40function split(entry: string): { name: string; type: string } {
41 const dot = entry.lastIndexOf('.')
42 return dot > 0 ? { name: entry.slice(0, dot), type: entry.slice(dot + 1) } : { name: entry, type: '' }
43}
44
45export function workspaceUrl(wsId: string): string {
46 return /^[0-9a-f-]{36}$/i.test(wsId) ? `${PORTAL}/groups/${wsId}` : ''
47}
48
49export function itemUrl(wsId: string, type: string, itemId: string): string {
50 const ws = workspaceUrl(wsId)
51 if (!ws || !/^[0-9a-f-]{36}$/i.test(itemId)) return ws
52 const route = ITEM_ROUTE[type]
53 if (!route) return ws
54 return type === 'SemanticModel' ? `${ws}/${route}/${itemId}/details` : `${ws}/${route}/${itemId}`
55}
56
57export function parseWorkspaces(stdout: string): TreeNode[] {
58 const out: TreeNode[] = []
59 for (const e of entries(stdout)) {
60 const { name, type } = split(e.name)
61 if (type !== 'Workspace') continue
62 const id = `W:${name}`
63 out.push({ ...node(id, '', 'workspace', name, e.name, workspaceUrl(e.id)), sig: e.id }, stub(id))
64 }
65 return out
66}
67
68function wsIdOf(parent: TreeNode, nodes: TreeNode[]): string {
69 let cur: TreeNode | undefined = parent
70 while (cur && cur.kind !== 'workspace') cur = nodes.find(n => n.id === cur?.parent)
71 return cur?.sig ?? ''
72}
73
74export function parseChildren(stdout: string, parent: TreeNode, nodes: TreeNode[]): TreeNode[] {
75 const wsId = wsIdOf(parent, nodes)
76 const out: TreeNode[] = []
77 for (const e of entries(stdout)) {
78 const { name, type } = split(e.name)
79 if (!type) continue
80 const id = `${parent.id}/${e.name}`
81 const path = `${parent.path}/${e.name}`
82 if (type === 'Folder') out.push({ ...node(id, parent.id, 'fabric folder', name, path, workspaceUrl(wsId)), sig: wsId }, stub(id))
83 else if (ONELAKE_ITEMS.has(type)) out.push({ ...node(id, parent.id, type, name, path, itemUrl(wsId, type, e.id ?? '')), note: typeLabel(type) }, stub(id))
84 else out.push({ ...node(id, parent.id, type, name, path, itemUrl(wsId, type, e.id ?? '')), note: typeLabel(type) })
85 }
86 return out
87}
88
89const ONELAKE_ITEMS = new Set(['Lakehouse'])
90const ONELAKE_DIRS = new Set(['lakehouse folder', 'onelake folder', 'onelake dir', 'onelake schema'])
91
92export function inOneLake(n: TreeNode): boolean {
93 return ONELAKE_ITEMS.has(n.kind) || ONELAKE_DIRS.has(n.kind)
94}
95
96export function parseOneLake(stdout: string, parent: TreeNode): { kids: TreeNode[]; become: string } {
97 const list = entries(stdout)
98 const tables = /\.Lakehouse\/Tables(\/|$)/i.test(parent.path)
99 if (tables && parent.kind === 'onelake dir' && list.some(e => e.name === '_delta_log')) return { kids: [], become: 'lakehouse table' }
100 const schema = tables && parent.kind === 'onelake dir'
101 const out: TreeNode[] = []
102 for (const e of list) {
103 const name = e.type === 'Shortcut' ? e.name.replace(/\.Shortcut$/, '') : e.name
104 const id = `${parent.id}/${name}`
105 const path = `${parent.path}/${name}`
106 if (ONELAKE_ITEMS.has(parent.kind)) out.push(node(id, parent.id, 'lakehouse folder', name, path), stub(id))
107 else if (e.type === 'Directory' && schema) out.push({ ...node(id, parent.id, 'lakehouse table', name, path), note: 'Delta table' })
108 else if (e.type === 'Directory' && tables) out.push(node(id, parent.id, 'onelake dir', name, path), stub(id))
109 else if (e.type === 'Directory') out.push(node(id, parent.id, 'onelake folder', name, path), stub(id))
110 else out.push({ ...node(id, parent.id, e.type === 'Shortcut' ? 'onelake shortcut' : 'onelake file', name, path), note: e.type === 'Shortcut' ? 'Shortcut' : '' })
111 }
112 return { kids: out, become: schema ? 'onelake schema' : '' }
113}
114
115export function workspaceOf(path: string): string | null {
116 const m = path.match(/^\/?([^/]+)\.Workspace(\/|$)/i)
117 return m?.[1] ?? null
118}
119
120export function modelOf(n: TreeNode): { server: string; database: string } | null {
121 if (n.kind !== 'SemanticModel') return null
122 const server = workspaceOf(n.path)
123 return server ? { server, database: n.name } : null
124}
125
126const TYPE_LABEL: Record<string, string> = {
127 AppBackend: 'Fabric App',
128 KQLDatabase: 'KQL Database',
129 KQLQueryset: 'KQL Queryset',
130 KQLDashboard: 'Real-Time Dashboard',
131 MLExperiment: 'ML Experiment',
132 MLModel: 'ML Model',
133 SQLEndpoint: 'SQL Endpoint',
134 SQLDatabase: 'SQL Database',
135 RDLReport: 'Paginated Report',
136 PaginatedReport: 'Paginated Report',
137}
138
139export function typeLabel(type: string): string {
140 return TYPE_LABEL[type] ?? type.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
141}
142hooks/parse.ts 491 lines1import type { Target } from '../types'
2
3export type Invocation = { tool: string; bin: string; args: string[]; cwd: string; env: Record<string, string> }
4
5const SEPARATORS = new Set(['&&', '||', ';', '|', '&', '\n', '(', ')', '`'])
6const PREFIXES = new Set(['do', 'then', 'else', 'elif', 'if', '!', 'time', '{', 'env', 'command', 'builtin', 'exec', 'nohup', 'sudo', 'nice', 'xargs', 'uvx', 'pipx', 'timeout', 'while', 'until'])
7const PREFIX_VALUED: Record<string, Set<string>> = {
8 sudo: new Set(['-u', '-g', '-C', '-h', '-p', '-U', '-r', '-t', '-D']),
9 timeout: new Set(['-k', '-s', '--kill-after', '--signal']),
10 xargs: new Set(['-n', '-I', '-P', '-L', '-s', '-d', '-E', '-a']),
11 env: new Set(['-u', '-C', '-S', '--unset', '--chdir']),
12 nice: new Set(['-n']),
13 exec: new Set(['-a']),
14 uvx: new Set(['--from', '--with', '-p', '--python']),
15 uv: new Set(['--from', '--with', '-p', '--python', '--project', '--directory']),
16 pipx: new Set(['--spec', '--python']),
17}
18const MAX_CALLS = 50
19const REDIRECT = /^(\d*>>?|\d*<|\d*>&\d*|&>>?)$/
20
21let drives = false
22
23export function useDrives(on: boolean): void {
24 drives = on
25}
26
27export function posix(path: string): string {
28 if (!drives) return path
29 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
30}
31
32export function isAbsolute(path: string): boolean {
33 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
34}
35
36export function tokenize(command: string): string[] {
37 const out: string[] = []
38 let cur = ''
39 let quote = ''
40 let has = false
41 for (let i = 0; i < command.length; i++) {
42 const ch = command[i] ?? ''
43 if (!quote && ch === '#' && !has && !cur) {
44 while (i + 1 < command.length && command[i + 1] !== '\n') i++
45 continue
46 }
47 if (quote) {
48 if (ch === quote) quote = ''
49 else if (ch === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i] ?? ''
50 else cur += ch
51 continue
52 }
53 if (ch === '"' || ch === "'") {
54 quote = ch
55 has = true
56 } else if (ch === '\\' && i + 1 < command.length) {
57 cur += command[++i] ?? ''
58 has = true
59 } else if (ch === '\n') {
60 if (has || cur) out.push(cur)
61 cur = ''
62 has = false
63 out.push('\n')
64 } else if (/\s/.test(ch)) {
65 if (has || cur) out.push(cur)
66 cur = ''
67 has = false
68 } else if (ch === '>' || ch === '<') {
69 let op = /^\d+$/.test(cur) || (cur === '&' && !has) ? cur : ''
70 if (!op && (has || cur)) out.push(cur)
71 op += ch
72 while (command[i + 1] === '>' || command[i + 1] === '&' || /\d/.test(command[i + 1] ?? '')) {
73 if (command[i + 1] === '&' && op.endsWith('&')) break
74 op += command[++i]
75 }
76 out.push(op)
77 cur = ''
78 has = false
79 } else if (ch === '(' || ch === ')' || ch === '`') {
80 if (has || cur) out.push(cur)
81 cur = ''
82 has = false
83 out.push(ch)
84 } else if (ch === ';' || ch === '|' || ch === '&') {
85 if (ch === '&' && command[i + 1] === '>') {
86 if (has || cur) out.push(cur)
87 cur = '&'
88 has = false
89 continue
90 }
91 if (has || cur) out.push(cur)
92 cur = ''
93 has = false
94 const two = command.slice(i, i + 2)
95 if (two === '&&' || two === '||') {
96 out.push(two)
97 i++
98 } else out.push(ch)
99 } else {
100 cur += ch
101 has = true
102 }
103 }
104 if (has || cur) out.push(cur)
105 if (!quote) return out
106 return command
107 .split(/(&&|\|\||[;|\n])/)
108 .flatMap(part => (/^(&&|\|\||[;|\n])$/.test(part) ? [part] : part.trim().split(/\s+/).filter(Boolean)))
109}
110
111export function join(base: string, raw: string): string {
112 const path = posix(raw)
113 if (isAbsolute(path)) return path
114 if (path.startsWith('~/')) return path
115 const parts = base.split('/')
116 for (const seg of path.split('/')) {
117 if (seg === '' || seg === '.') continue
118 if (seg === '..') parts.pop()
119 else parts.push(seg)
120 }
121 return parts.join('/') || '/'
122}
123
124function operators(line: string): { word: string; tabs: boolean; expand: boolean }[] {
125 const found: { word: string; tabs: boolean; expand: boolean }[] = []
126 const outer: boolean[] = []
127 let single = false
128 let double = false
129 for (let i = 0; i < line.length; i++) {
130 const ch = line[i]
131 if (single) {
132 if (ch === "'") single = false
133 continue
134 }
135 if (ch === '\\') {
136 i++
137 continue
138 }
139 if (ch === '$' && line[i + 1] === '(') {
140 outer.push(double)
141 double = false
142 i++
143 continue
144 }
145 if (ch === ')' && outer.length && !double) {
146 double = outer.pop() ?? false
147 continue
148 }
149 if (ch === '"') {
150 double = !double
151 continue
152 }
153 if (double) continue
154 if (ch === "'") {
155 single = true
156 continue
157 }
158 if (ch === '#' && (i === 0 || /\s/.test(line[i - 1] ?? ''))) break
159 if (ch === '<' && line[i + 1] === '<' && line[i + 2] !== '<') {
160 const m = line.slice(i).match(/^<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/)
161 if (m) {
162 found.push({ word: m[3] ?? '', tabs: m[1] === '-', expand: !m[2] })
163 i += m[0].length - 1
164 }
165 }
166 }
167 return found
168}
169
170function heredocless(command: string): string {
171 const out: string[] = []
172 const pending: { word: string; tabs: boolean; expand: boolean }[] = []
173 for (const line of command.split('\n')) {
174 const head = pending[0]
175 if (head) {
176 if ((head.tabs ? line.replace(/^\t+/, '') : line) === head.word) pending.shift()
177 else if (head.expand) out.push(...substitutions(line))
178 continue
179 }
180 out.push(line)
181 pending.push(...operators(line))
182 }
183 return out.join('\n')
184}
185
186function substitutions(command: string): string[] {
187 const out: string[] = []
188 let single = false
189 let double = false
190 for (let i = 0; i < command.length; i++) {
191 const ch = command[i]
192 if (single) {
193 if (ch === "'") single = false
194 continue
195 }
196 if (ch === '\\') {
197 i++
198 continue
199 }
200 if (ch === "'" && !double) {
201 single = true
202 continue
203 }
204 if (ch === '"') {
205 double = !double
206 continue
207 }
208 if (ch === '#' && !double && (i === 0 || /\s/.test(command[i - 1] ?? ''))) {
209 while (i + 1 < command.length && command[i + 1] !== '\n') i++
210 continue
211 }
212 if (ch === '$' && command[i + 1] === '(') {
213 let depth = 0
214 for (let j = i + 1; j < command.length; j++) {
215 if (command[j] === '(') depth++
216 else if (command[j] === ')' && --depth === 0) {
217 out.push(command.slice(i + 2, j))
218 i = j
219 break
220 }
221 }
222 continue
223 }
224 if (ch === '`') {
225 let j = i + 1
226 while (j < command.length && command[j] !== '`') j += command[j] === '\\' ? 2 : 1
227 if (j < command.length) out.push(command.slice(i + 1, j))
228 i = j
229 }
230 }
231 return out
232}
233
234function expand(args: string[], loops: { name: string; words: string[] }[]): string[][] {
235 let out = [args]
236 for (const { name, words } of loops) {
237 if (!name) continue
238 const source = `\\$\\{${name}\\}|\\$${name}(?![A-Za-z0-9_])`
239 const used = new RegExp(source)
240 const ref = new RegExp(source, 'g')
241 if (!out.some(a => a.some(x => used.test(x)))) continue
242 out = out.flatMap(a => words.map(w => a.map(x => x.replace(ref, w)))).slice(0, MAX_CALLS)
243 }
244 return out
245}
246
247function skipFlags(toks: string[], j: number, word: string): number {
248 const valued = PREFIX_VALUED[word]
249 while (j < toks.length && (toks[j] ?? '').startsWith('-') && !SEPARATORS.has(toks[j] ?? '')) j += valued?.has(toks[j] ?? '') ? 2 : 1
250 return j
251}
252
253export function invocations(command: string, sessionCwd: string, base: Record<string, string> = {}, tools: readonly string[] = ['fab']): Invocation[] {
254 const text = heredocless(command)
255 const toks = tokenize(text)
256 const found: Invocation[] = []
257 const loops: { name: string; words: string[] }[] = []
258 const exported: Record<string, string> = { ...base }
259 let cwd = sessionCwd
260 let start = true
261 for (let i = 0; i < toks.length && found.length < MAX_CALLS; i++) {
262 const t = toks[i] ?? ''
263 if (SEPARATORS.has(t)) {
264 start = true
265 continue
266 }
267 if (!start) continue
268 start = false
269 const env: Record<string, string> = {}
270 let j = i
271 for (;;) {
272 const tok = toks[j] ?? ''
273 const assign = tok.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s)
274 const word = tok.split('/').pop() ?? ''
275 if (assign) {
276 env[assign[1] ?? ''] = assign[2] ?? ''
277 j++
278 } else if (word === 'uv' && toks[j + 1] === 'run') j = skipFlags(toks, j + 2, 'uv')
279 else if (word === 'timeout') {
280 j = skipFlags(toks, j + 1, word)
281 if (/^\d/.test(toks[j] ?? '')) j++
282 } else if (word === 'while' || word === 'until') {
283 loops.push({ name: '', words: [] })
284 j++
285 } else if (word === 'command' && /^-[A-Za-z]*[vV]/.test(toks[j + 1] ?? '')) break
286 else if (PREFIXES.has(word)) j = skipFlags(toks, j + 1, word)
287 else break
288 }
289 const head = toks[j]?.split('/').pop()
290 const dir = toks[j + 1]
291 if (head === 'export') {
292 for (let k = j + 1; k < toks.length && !SEPARATORS.has(toks[k] ?? ''); k++) {
293 const m = (toks[k] ?? '').match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s)
294 if (m) exported[m[1] ?? ''] = m[2] ?? ''
295 }
296 continue
297 }
298 if (head === 'for' && toks[j + 2] === 'in' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(dir ?? '')) {
299 const words: string[] = []
300 for (let k = j + 3; k < toks.length && !SEPARATORS.has(toks[k] ?? '') && toks[k] !== 'do'; k++) words.push(toks[k] ?? '')
301 loops.push({ name: dir ?? '', words: words.slice(0, 20) })
302 continue
303 }
304 if (head === 'for' || head === 'select') {
305 loops.push({ name: '', words: [] })
306 continue
307 }
308 if (head === 'done') {
309 loops.pop()
310 continue
311 }
312 if (head === 'cd' && dir && !SEPARATORS.has(dir)) {
313 cwd = join(cwd, dir)
314 continue
315 }
316 if (!head || !tools.includes(head)) continue
317 const args: string[] = []
318 let k = j + 1
319 while (k < toks.length && !SEPARATORS.has(toks[k] ?? '')) {
320 const tok = toks[k++] ?? ''
321 if (REDIRECT.test(tok)) {
322 if (!/&\d*$/.test(tok) || tok.startsWith('&')) k++
323 continue
324 }
325 args.push(tok)
326 }
327 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 } })
328 i = k - 1
329 }
330 const keyOf = (inv: Invocation) => `${inv.args.join('\0')}\0${JSON.stringify(inv.env)}`
331 const seen = new Set(found.map(keyOf))
332 for (const body of substitutions(text)) {
333 for (const inv of invocations(body, cwd, exported, tools)) {
334 const key = keyOf(inv)
335 if (seen.has(key) || found.length >= MAX_CALLS) continue
336 seen.add(key)
337 found.push(inv)
338 }
339 }
340 return found
341}
342
343export function targetLabel(t: Target | null): string {
344 return t ? 'Fabric tenant' : 'no target'
345}
346
347export function fabWorkspaces(inv: Invocation): string[] {
348 const out: string[] = []
349 for (const a of inv.args.slice(1)) {
350 const m = a.match(/^\/?([^/]+)\.Workspace(\/|$)/i)
351 if (m?.[1]) out.push(m[1])
352 }
353 return out
354}
355
356export type FabKind = 'read' | 'download' | 'upload' | 'modify'
357
358const FAB_READ = new Set(['ls', 'dir', 'get', 'exists', 'find', 'open', 'pwd', 'cd'])
359const FAB_IGNORE = new Set(['auth', 'config', 'help', 'version', 'desc', '--help', '--version', '-h', '-v'])
360const FAB_SUB_READ = new Set(['ls', 'list', 'get', 'run-list', 'run-status', 'schema', 'status'])
361const FAB_VALUE_FLAGS = new Set(['-X', '--method', '-H', '--headers', '-i', '--input', '-A', '--audience', '-q', '--query', '-P', '--params', '-o', '--output', '--format'])
362const ITEM_SUFFIX = /\.(Workspace|Capacity|Connection|Gateway|Domain|Lakehouse|Warehouse|SemanticModel|Report|Notebook|DataPipeline|Dataflow|Environment|KQLDatabase|Eventhouse|SQLDatabase|MirroredDatabase|SparkJobDefinition|PaginatedReport|Dashboard|AppBackend|UserDataFunction|Folder)(\/|$)/i
363
364export function fabPositionals(args: string[]): string[] {
365 const out: string[] = []
366 for (let i = 0; i < args.length; i++) {
367 const a = args[i] ?? ''
368 if (a.startsWith('-')) {
369 if (FAB_VALUE_FLAGS.has(a)) i++
370 continue
371 }
372 out.push(a)
373 }
374 return out
375}
376
377function isLocal(arg: string): boolean {
378 return /^(\.{1,2}\/|~|\/(?!.*\.Workspace))/.test(arg) || !ITEM_SUFFIX.test(arg)
379}
380
381export function fabKind(inv: Invocation): FabKind {
382 const verb = inv.args[0] ?? ''
383 const rest = inv.args.slice(1)
384 const pos = fabPositionals(rest)
385 if (verb === 'export' || verb === 'bulk-export') return 'download'
386 if (verb === 'import' || verb === 'deploy' || verb === 'publish') return 'upload'
387 if (verb === 'cp' || verb === 'copy') {
388 const src = pos[0] ?? ''
389 const dst = pos[pos.length - 1] ?? ''
390 if (!isLocal(src) && isLocal(dst)) return 'download'
391 if (isLocal(src) && !isLocal(dst)) return 'upload'
392 return 'modify'
393 }
394 if (verb === 'api') {
395 const eq = rest.find(a => a.startsWith('--method='))?.split('=')[1]
396 const at = rest.findIndex(a => a === '-X' || a === '--method')
397 const method = (eq ?? (at >= 0 ? rest[at + 1] : undefined) ?? 'get').toLowerCase()
398 const url = pos[0] ?? ''
399 if (/executeQueries|executeDaxQueries/i.test(url)) return 'read'
400 if (/getdefinition/i.test(url)) return 'download'
401 if (/updatedefinition/i.test(url)) return 'upload'
402 return method === 'get' ? 'read' : 'modify'
403 }
404 if (verb === 'get' && rest.some(a => a === '-o' || a === '--output')) return 'download'
405 if (verb === 'acl' || verb === 'label' || verb === 'job' || verb === 'table') return FAB_SUB_READ.has(pos[0] ?? '') ? 'read' : 'modify'
406 return FAB_READ.has(verb) ? 'read' : 'modify'
407}
408
409export const FAB_TONE: Record<FabKind, string> = { read: 'purple', download: 'teal', upload: 'pink', modify: 'orange' }
410
411function fabCd(cwd: string, target: string | undefined): string {
412 if (!target || target === '~' || target === '/') return ''
413 const parts = target.startsWith('/') ? [] : cwd.split('/').filter(Boolean)
414 for (const seg of target.split('/')) {
415 if (!seg || seg === '.') continue
416 if (seg === '..') parts.pop()
417 else parts.push(seg)
418 }
419 return parts.join('/')
420}
421
422export function fabCalls(calls: Invocation[]): Invocation[] {
423 let cwd = ''
424 const out: Invocation[] = []
425 for (const inv of calls) {
426 const verb = inv.args[0] ?? ''
427 if (!verb || FAB_IGNORE.has(verb)) continue
428 const args = cwd
429 ? inv.args.map((a, i) => (i > 0 && !a.startsWith('-') && !a.startsWith('/') && ITEM_SUFFIX.test(a) && !/^[^/]+\.Workspace(\/|$)/i.test(a) && !isLocalPath(a) ? `${cwd}/${a}` : a))
430 : inv.args
431 if (verb === 'cd') cwd = fabCd(cwd, fabPositionals(inv.args.slice(1))[0])
432 out.push({ ...inv, args })
433 }
434 return out
435}
436
437function isLocalPath(arg: string): boolean {
438 return /^(\.{1,2}\/|~)/.test(arg)
439}
440
441const GUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
442
443export function fabGuids(inv: Invocation): string[] {
444 return inv.args[0] === 'api' ? inv.args.slice(1).flatMap(a => a.match(GUID) ?? []).map(g => g.toLowerCase()) : []
445}
446
447function flagOf(args: string[], names: string[]): string {
448 for (let i = 0; i < args.length; i++) {
449 const a = args[i] ?? ''
450 for (const n of names) {
451 if (a === n) return args[i + 1] ?? ''
452 if (a.startsWith(`${n}=`)) return a.slice(n.length + 1)
453 }
454 }
455 return ''
456}
457
458export function decoded(text: string): string {
459 try {
460 return decodeURIComponent(text)
461 } catch {
462 return text
463 }
464}
465
466export type Query = { workspace: string; model: string; database: string; report: string }
467
468export function queryOf(inv: Invocation): Query | null {
469 const none = { workspace: '', model: '', database: '', report: '' }
470 if (inv.tool === 'te' && inv.args[0] === 'query') {
471 const server = flagOf(inv.args, ['-s', '--server'])
472 const ws = server.match(/^powerbi:\/\/[^/]+\/v1\.0\/[^/]+\/(.+)$/i)?.[1]
473 const model = flagOf(inv.args, ['-d', '--database'])
474 return ws && model ? { ...none, workspace: decoded(ws), model } : null
475 }
476 if (inv.tool === 'pbir' && inv.args[0] === 'model' && inv.args.some(a => a === '-q' || a === '--query' || a.startsWith('--query='))) {
477 const report = inv.args[1] ?? ''
478 return report && !report.startsWith('-') ? { ...none, report } : null
479 }
480 if (inv.tool === 'sqlcmd') {
481 const database = flagOf(inv.args, ['-d', '--database-name'])
482 return database ? { ...none, database } : null
483 }
484 return null
485}
486
487export function modelFromConnection(text: string): { workspace: string; model: string } | null {
488 const m = text.match(/Data Source=\\?"?powerbi:\/\/[^/]+\/v1\.0\/[^/]+\/([^";\\]+)\\?"?;\s*initial catalog=\\?"?([^";\\]+)/i)
489 return m?.[1] && m[2] ? { workspace: decoded(m[1].trim()), model: m[2].trim() } : null
490}
491hooks/touch.ts 19 lines1import type { TreeNode } from '../types'
2import type { Invocation } from './parse'
3
4export function fabTouched(inv: Invocation, nodes: TreeNode[]): string[] {
5 const out = new Set<string>()
6 for (const arg of inv.args.slice(1)) {
7 if (!arg.includes('.') || /^(\.{1,2}\/|~)/.test(arg)) continue
8 const want = arg.replace(/^\//, '').replace(/\/$/, '').toLowerCase()
9 let best: TreeNode | undefined
10 for (const n of nodes) {
11 const have = n.path.toLowerCase()
12 if (!have || (want !== have && !want.startsWith(`${have}/`))) continue
13 if (!best || have.length > best.path.length) best = n
14 }
15 if (best) out.add(best.id)
16 }
17 return [...out]
18}
19hooks/setup.ts 165 lines1import type { Setup, SetupKind } from '../types'
2import type { Tier } from './icons'
3import type { RowSpec, Seg } from './rows'
4
5const ICON_COLOR = '#2dd4bf'
6const COMMAND_COLOR = '#7dd3fc'
7const TEXT_COLOR = '#c5c5cf'
8const QUIET_COLOR = '#8a8a96'
9
10export const COPY = 'copy:'
11export const REFRESH = 'refresh'
12
13const ICONS: Record<'missing' | 'install' | 'signin' | 'signedout' | 'unreachable', { nerd: string; plain: string }> = {
14 missing: { nerd: '\u{f071}', plain: '✗' },
15 install: { nerd: '\u{f019}', plain: '↓' },
16 signin: { nerd: '\u{f090}', plain: '→' },
17 signedout: { nerd: '\u{f023}', plain: '⊘' },
18 unreachable: { nerd: '\u{f05aa}', plain: '≠' },
19}
20
21const NETWORK =
22 /timed? ?out|timeout|name or service not known|nodename nor servname|getaddrinfo|temporary failure in name resolution|NameResolutionError|failed to resolve|could not resolve host|ENOTFOUND|EAI_AGAIN|connection (was )?(refused|reset|aborted)|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|network is unreachable|no route to host|NewConnectionError|ConnectTimeoutError|ReadTimeoutError|failed to establish a new connection|max retries exceeded with url|ProxyError|proxy error|unable to connect to proxy|cannot connect to proxy|tunnel connection failed|407 proxy|SSLError|CERTIFICATE_VERIFY_FAILED|failed to connect/i
23const SIGNED_OUT =
24 /\b401\b|unauthori[sz]ed|not (logged|signed) in|not authenticated|no credentials|credentials? (is |are )?missing|authentication credential is missing|token (has )?expired|expired token|invalid (jwt )?token|token is invalid|failed to (get|obtain|acquire) (an )?access token|failed to decode jwt|(could not|cannot|unable to) configure default credentials|default credentials? (were )?not found|no (configuration|profile) (was )?found|auth(entication)? (is )?required|fab auth login|re-?login|(tenant|client) id is required|FAB_TENANT_ID must be set|interaction_required|invalid_grant|AADSTS\d+/i
25const AUTH_CODES = /^(Unauthorized|AuthenticationFailed|ServicePrincipalAuthMissing|TokenExpired|InvalidToken|NotAuthenticated)$/i
26const EXIT_NOT_FOUND = 127
27const EXIT_AUTHORIZATION_REQUIRED = 4
28
29export class CliError extends Error {
30 readonly kind: SetupKind | ''
31
32 constructor(message: string, kind: SetupKind | '') {
33 super(message)
34 this.kind = kind
35 }
36}
37
38function firstLine(text: string): string {
39 for (const raw of text.replace(/\x1b\[[0-9;]*m/g, '').split('\n')) {
40 const line = raw.trim().replace(/^x\s+/, '')
41 if (line) return line
42 }
43 return ''
44}
45
46function reported(stdout: string): { message: string; code: string } {
47 const at = stdout.indexOf('{')
48 if (at < 0) return { message: '', code: '' }
49 try {
50 const body = JSON.parse(stdout.slice(at)) as { result?: { message?: unknown; error_code?: unknown } }
51 const message = typeof body.result?.message === 'string' ? body.result.message : ''
52 const code = typeof body.result?.error_code === 'string' ? body.result.error_code : ''
53 return { message: firstLine(message), code }
54 } catch {
55 return { message: '', code: '' }
56 }
57}
58
59export function spawnFailure(err: unknown, bin: string): CliError {
60 const text = err instanceof Error ? err.message : String(err)
61 return NETWORK.test(text) ? new CliError(firstLine(text) || `${bin} timed out`, 'unreachable') : new CliError(firstLine(text) || `${bin} could not start`, 'missing')
62}
63
64export function runFailure(run: { exitCode: number; stdout: string; stderr: string }, fallback: string): CliError {
65 const json = reported(run.stdout)
66 const line = json.message || firstLine(run.stderr) || firstLine(run.stdout) || fallback
67 const all = `${json.message}\n${run.stderr}\n${run.stdout}`
68 if (run.exitCode === EXIT_NOT_FOUND) return new CliError(line, 'missing')
69 if (NETWORK.test(all)) return new CliError(line, 'unreachable')
70 if (run.exitCode === EXIT_AUTHORIZATION_REQUIRED || AUTH_CODES.test(json.code) || SIGNED_OUT.test(all)) return new CliError(line, 'signed-out')
71 return new CliError(line, '')
72}
73
74export function setupOf(err: unknown): Setup | null {
75 return err instanceof CliError && err.kind ? { kind: err.kind, line: err.message } : null
76}
77
78type Part = { t: string; cmd?: string; refresh?: true; keep?: true; b?: true; c?: string }
79const INDENT = ' '
80
81type Line = { parts: Part[]; command?: string; head?: boolean }
82
83const say = (t: string, c = TEXT_COLOR): Line => ({ parts: [{ t, c }] })
84const command = (cmd: string): Line => ({ parts: [{ t: cmd, c: COMMAND_COLOR, keep: true }], command: cmd })
85const gap: Line = { parts: [] }
86
87function copyFor(kind: SetupKind, line: string, icon: (k: keyof typeof ICONS) => string): Line[] {
88 const title = (k: keyof typeof ICONS, t: string): Line => ({ parts: [{ t: `${icon(k)} `, c: ICON_COLOR }, { t, b: true }], head: true })
89 const press = (t: string): Part => ({ t, refresh: true, c: COMMAND_COLOR })
90 const inline = (cmd: string): Part => ({ t: cmd, cmd, c: COMMAND_COLOR })
91 if (kind === 'missing') {
92 return [
93 title('missing', 'Fabric CLI not found'),
94 say('This pane needs the Fabric CLI (fab).'),
95 gap,
96 title('install', 'Install it'),
97 command('uv tool install ms-fabric-cli'),
98 { parts: [{ t: 'No uv yet? ', c: TEXT_COLOR }, inline('brew install uv'), { t: ' or ', c: TEXT_COLOR }, inline('winget install uv')] },
99 gap,
100 title('signin', 'Then sign in'),
101 command('fab auth login'),
102 gap,
103 { parts: [{ t: 'Press ', c: QUIET_COLOR }, press('↻'), { t: " when you're done. Or ask Claude to set it up for you.", c: QUIET_COLOR }] },
104 ]
105 }
106 if (kind === 'signed-out') {
107 return [
108 title('signedout', 'Not signed in to Fabric'),
109 command('fab auth login'),
110 gap,
111 { parts: [{ t: 'Press ', c: QUIET_COLOR }, press('↻'), { t: " when you're done. To check which account you use: ", c: QUIET_COLOR }, inline('fab auth status')] },
112 ]
113 }
114 return [
115 title('unreachable', "Can't reach Fabric"),
116 say(line, QUIET_COLOR),
117 { parts: [{ t: 'Check your network or proxy, then press ', c: QUIET_COLOR }, press('↻'), { t: '.', c: QUIET_COLOR }] },
118 ]
119}
120
121function seg(p: Part, t: string): Seg {
122 return { t, ...(p.c ? { c: p.c } : {}), ...(p.b ? { b: true } : {}), ...(p.cmd ? { tab: COPY + p.cmd } : p.refresh ? { tab: REFRESH } : {}) }
123}
124
125function chunks(word: string, width: number): string[] {
126 const chars = [...word]
127 if (chars.length <= width) return [word]
128 const out: string[] = []
129 for (let i = 0; i < chars.length; i += width) out.push(chars.slice(i, i + width).join(''))
130 return out
131}
132
133function wrapped(parts: Part[], width: number): Seg[][] {
134 const lines: Seg[][] = [[]]
135 let used = 0
136 for (const p of parts) {
137 const words = p.cmd || p.refresh || p.keep ? [p.t] : (p.t.match(/\S+\s*|\s+/g) ?? []).flatMap(w => chunks(w, width))
138 for (const word of words) {
139 const size = [...word.trimEnd()].length
140 if (used > 0 && used + size > width) {
141 lines.push([])
142 used = 0
143 }
144 const t = used === 0 ? word.trimStart() : word
145 if (!t) continue
146 lines[lines.length - 1]?.push(seg(p, t))
147 used += [...t].length
148 }
149 }
150 return lines
151}
152
153export function setupRows(setup: Setup, tier: Tier, width: number): RowSpec[] {
154 const icon = (k: keyof typeof ICONS) => (tier === 'plain' ? ICONS[k].plain : ICONS[k].nerd)
155 const blank: RowSpec = { id: '', left: [{ t: ' ' }], right: [] }
156 return [
157 blank,
158 ...copyFor(setup.kind, setup.line, icon).flatMap(line => {
159 if (line.parts.length === 0) return [blank]
160 const indent = line.head ? '' : INDENT
161 return wrapped(line.parts, Math.max(8, width - indent.length)).map(left => ({ id: line.command ? COPY + line.command : '', left: indent ? [{ t: indent }, ...left] : left, right: [] }))
162 }),
163 ]
164}
165hooks/icon-data.ts 2 lines1export const FAB_ITEMS: Record<string, [number, [number, number, number] | null]> = {"SemanticModel": [991234, [90, 64, 156]], "Report": [991274, [194, 132, 0]], "PaginatedReport": [991272, [194, 132, 0]], "Dashboard": [991239, [194, 132, 0]], "Lakehouse": [991258, [29, 83, 164]], "Warehouse": [991243, [29, 83, 164]], "Notebook": [991266, [63, 125, 53]], "DataPipeline": [991270, [63, 125, 53]], "Dataflow": [991245, [63, 125, 53]], "KQLDatabase": [991255, [0, 142, 230]], "KQLQueryset": [991256, [63, 125, 53]], "KQLDashboard": [991273, [189, 30, 123]], "Eventstream": [991249, [189, 30, 123]], "Eventhouse": [991248, [0, 142, 230]], "MLExperiment": [991250, [63, 125, 53]], "MLModel": [991265, [63, 125, 53]], "SQLDatabase": [991280, [0, 142, 230]], "SQLEndpoint": [991243, [29, 83, 164]], "Environment": [991247, [63, 125, 53]], "SparkJobDefinition": [991279, [63, 125, 53]], "Datamart": [991246, [90, 64, 156]], "CopyJob": [991237, [63, 125, 53]], "VariableLibrary": [991285, [63, 125, 53]], "UserDataFunction": [991284, [63, 125, 53]], "MirroredDatabase": [991262, [0, 142, 230]], "MirroredWarehouse": [991262, [0, 142, 230]], "MirroredCatalog": [991261, [0, 142, 230]], "OrgApp": [991235, [184, 85, 0]], "MountedDataFactory": [991241, [253, 225, 0]], "Ontology": [991267, [90, 64, 156]], "DataAgent": [991240, [63, 125, 53]], "OperationsAgent": [991268, [189, 30, 123]], "Apps": [991235, [184, 85, 0]], "AppBackend": [991302, [29, 83, 164]]}
2types/index.d.ts 48 lines1export type Target = { kind: 'fabric'; identity?: 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 SetupKind = 'missing' | 'signed-out' | 'unreachable'
16
17export type Setup = { kind: SetupKind; line: string }
18
19export type Work = { tone: string; at: number }
20
21export type Explorer = {
22 target: Target | null
23 nodes: TreeNode[]
24 expanded: string[]
25 query: string
26 cursor: string
27 selected: string
28 detail: string[]
29 status: string
30 flash: string[]
31 work: Work | null
32 busy: Record<string, { tone: string; n: number; at: number }>
33 flashDim: string[]
34 scroll: number | null
35 root: string
36 setup: Setup | null
37 byDomain?: boolean
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'fabric-cli': {
43 explorer: Explorer
44 nodes: TreeNode[]
45 }
46 }
47}
48