SLOPSHOPPER

fabric-cli

Fabric and the Power BI service via the fab CLI, with a Fabric pane that follows fab.

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

Draws

Pane · 󲊒 Fabric tenant
▣ client module ./rows.tsx 󰑐 / : search ⏎ jump nothing loaded yet ▣ client module ./rows.tsx
README

<h1 align="center">Power BI Agentic Dev</h1>

A personalized fork of Kurt Buhler's Power BI &amp; Microsoft Fabric skills marketplace <br></br> <i>Teach AI agents like Claude Code, GitHub Copilot, or OpenAI Codex to work with Power BI and Microsoft Fabric</i>

<img src="https://img.shields.io/badge/version-26.25-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">

[!NOTE] This is Tim's personalized fork of data-goblin/power-bi-agentic-development, renamed to power-bi-agentic-dev so it can coexist with the original. It carries the upstream plugins plus additional personal skills, and tracks Kurt's releases selectively rather than following his weekly cadence. Full credit for the original work goes to Kurt Buhler — see Attribution.


What is agentic development?

  • Agentic development is when you use AI agents to help build, manage, and optimize artifacts and software — semantic models, reports, and everything around them (workspaces, deployment pipelines, and processes).
  • A marketplace hosts plugins you can install. A plugin is a bundle of resources that help coding agents perform better: skills, subagents, hooks, and MCP servers focused on a topic or task.
  • This marketplace is focused on making your agent work well with Power BI and Fabric — Power BI skills, Fabric skills, subagents, and hooks for coding agents.

Installation

Add the marketplace in Claude Code — run this in the terminal:

claude plugin marketplace add InsightfulAnalytics/PBI_Agentic_Dev

This registers the marketplace under the name power-bi-agentic-dev (from the repo's .claude-plugin/marketplace.json). Note that the GitHub repo slug (PBI_Agentic_Dev) differs from the marketplace name (power-bi-agentic-dev) — the slug is used only in the marketplace add command above; the install commands below reference the marketplace name.

Claude Code

After adding the marketplace, install plugins interactively via /plugin → Marketplaces → select power-bi-agentic-dev → install the plugins you want. You can also enable auto-update per marketplace there.

Or install from the command line:

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

Verify what's installed with claude plugin list, and inspect a plugin's contents and token cost with claude plugin details <plugin>.

Updates. Every release is listed in CHANGELOG.md and published as a GitHub Release, so to hear about new ones, choose Watch → Custom → Releases on this repository. To update, run claude plugin marketplace update power-bi-agentic-dev, then claude plugin update <plugin>@power-bi-agentic-dev for each plugin you use, or turn on auto-update for the marketplace in /plugin.

Copilot CLI

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

TMDL files can produce 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.

See useful-stuff/agent-scripts/enable-windows-longpaths.ps1 for an example script you can run from an elevated PowerShell session to enable long paths (a reboot is recommended after the registry change). This is a Windows OS limitation, documented at Maximum Path Length Limitation.

Also set the git-level flag:

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:

copilot plugin marketplace add InsightfulAnalytics/PBI_Agentic_Dev
copilot plugin install tabular-editor@power-bi-agentic-dev

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

copilot plugin install InsightfulAnalytics/PBI_Agentic_Dev: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@power-bi-agentic-dev is the equivalent of (1). The bare copilot plugin install InsightfulAnalytics/PBI_Agentic_Dev (no qualifier) will not install anything useful, because the root is a marketplace catalog, not a plugin.

Inside Copilot CLI:

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

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

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

GitHub Codespaces and other Linux hosts

Most of what these skills know is about TMDL, the PBIR JSON schemas, and the Fabric and Power BI REST APIs, none of which care about the operating system. A Codespace on this repository picks the skills up with no setup: .devcontainer/devcontainer.json installs Claude Code and runs the bootstrap.

For a Codespace on a Power BI project repository, which is the usual case, copy the devcontainer.json block from .devcontainer/README.md. To set an existing Linux or macOS machine up by hand:

git clone --depth 1 https://github.com/InsightfulAnalytics/PBI_Agentic_Dev.git ~/.pbi-agentic-dev
bash ~/.pbi-agentic-dev/scripts/bootstrap-agent-env.sh

The bootstrap registers the marketplace, installs the toolchain that exists on the platform, and prints a capability report naming what does not, so an agent learns the limits before its first turn rather than by failing:

LinuxWindows with Desktop
fab, te, az, the REST APIsyesyes
TMDL validation (bundled tmdl-validate binary)yesyes
Server-side report render (ExportTo to PDF, then pymupdf to PNG)yesyes
ADOMD or TOM over XMLA against a published modelyesyes
pbir CLIno, and there is no wheel or sdist to installyes
Power BI Desktop, local msmdsrv, DAX Studio, Tabular Editor 2 and 3noyes

On Linux, author PBIR by hand from the pbip:pbir-format examples, publish with fab import, and verify by rendering server-side. .devcontainer/README.md has the detail.

OpenAI Codex

Codex supports the same open Agent Skills standard these plugins use, so the skills work there too — installed with a small script instead of a plugin marketplace:

git clone https://github.com/InsightfulAnalytics/PBI_Agentic_Dev.git
cd PBI_Agentic_Dev
python codex/install.py --check    # dry run: preview what will be installed
python codex/install.py            # skills -> ~/.agents/skills, adapter -> ~/.codex/AGENTS.md
python codex/install.py --mcp      # optional: also add the MCP servers to ~/.codex/config.toml

Keep the clone — it's the runtime store for bundled scripts and validators that the copies in ~/.agents/skills reference. After a git pull, re-run the installer (idempotent) to refresh. The full guide — project-scoped installs, junction mode, uninstall, and troubleshooting — is in codex/README.md.

Inside a Codex session:

/skills                 # list available skills (the Power BI skills should appear)
$tmdl                   # invoke a skill explicitly by name
/mcp                    # MCP servers (if installed with --mcp)
/status                 # loaded AGENTS.md, model, config
  • Skills load identically — Codex reads ~/.agents/skills/<name>/SKILL.md and auto-activates from the description, or on $name.
  • Hooks don't fire on file edits in Codex, so the installer converts the TMDL/PBIR/RDL validation hooks into rules in ~/.codex/AGENTS.md for the agent to run after edits.
  • Subagents aren't spawned; the eight reviewer/validator agents become inline checklists the agent reads from plugins/*/agents/*.agent.md.
  • Slash commands are ported as skills ($audit-context, $suggest-rule, $migrating-fabric-trial-capacities) since Codex deprecated custom prompts.
  • MCP servers are added to ~/.codex/config.toml with --mcp (microsoft-learn over HTTP, pbiviz over stdio).
  • Skills kept outside this repo (e.g. personal skills Claude loads from ~/.claude/skills) are not touched by the installer — copy any you want Codex to have into ~/.agents/skills yourself.
  • The codex/ layer only reads from plugins/; Claude Code behavior is unchanged.

Overview

The repo contains skills, agents, and hooks.

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

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

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

Available plugins

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

TypeNameDescription
Skillsemantic-modelDesign, build, refresh, and review semantic models through a te-first tool cascade
Skilldate-tableDrop a ready-made 58-column DimDate (calendar, ISO week, fiscal, to-date flags) plus the Dates Selected title measure into any PBIP model
Skilldax-standardThe house style for authoring measures: table-first DAX, formatting via dynamic format strings, worked recipes
Skilldax-optimisationDebug and optimize DAX performance (server timings, anti-patterns, tuning)
Skillpower-queryWrite M expressions, debug query folding, execute M locally or via Fabric API
Skillstandardize-naming-conventionsAudit and standardize naming conventions in semantic models
Skillrefresh-semantic-modelTrigger or troubleshoot refreshes
Skilllineage-analysisTrace downstream reports from a semantic model across workspaces
Agentsemantic-model-auditorAudit semantic models for quality, memory, DAX, and design issues
TypeNameDescription
Skillpbir-cliProgrammatic report manipulation via the pbir CLI, including live Power BI Desktop refresh and page screenshots
Skillcreate-pbi-reportEnd-to-end workflow for building a report from scratch with the pbir CLI
Skillpbi-verify-loopRefresh → settle → screenshot loop to visually verify report edits (optional mockup diff)
Skillpower-bi-themeBrowse a preset-theme gallery and apply a chosen theme to a report
Skillmodifying-theme-jsonEdit, enforce, audit, and re-theme report theme JSON
Skillworkout-wednesdayTurn a Workout Wednesday challenge link into a built, verified, published report — see Try it end-to-end
Skillpbi-report-design (WIP)Power BI report best practices, design, and style
Skillreview-report (WIP)Review Power BI reports for usage metrics and best practices
Agentdeneb-reviewerReview Deneb specs for Vega/Vega-Lite syntax and conventions
Agentsvg-reviewerReview SVG DAX measures for syntax and design quality
Agentr-reviewerReview R visual scripts (ggplot2) for Power BI conventions
Agentpython-reviewerReview Python visual scripts (matplotlib/seaborn) for Power BI conventions
TypeNameDescription
Skilldeneb-visualsAuthor Deneb visuals with Vega and Vega-Lite specs
Skilldeneb-pbirRound-trip tooling: extract/embed a Deneb spec in visual.json and offline-render it
Skillperformant-matrixDiagnose a slow financial matrix, then choose between a bridge model fix, a native restructure, or a Deneb grid
Skillpython-visualsCustom Python visuals (matplotlib/seaborn) in Power BI reports
Skillr-visualsCustom R visuals (ggplot2) in Power BI reports
Skillsvg-visualsSVG visuals via DAX measures in Power BI reports
Skillpowerbi-custom-visualsDevelop, build, and package .pbiviz custom visuals with the pbiviz toolchain
TypeNameDescription
SkillpbipPower BI Project (PBIP) format, structure, and file types
SkilltmdlAuthor and edit TMDL files directly
Skillpbir-formatAuthor and edit PBIR metadata files directly (visual.json, report.json, themes, filters, report extensions, visual calculations)
Agentpbip-validatorValidate PBIP project structure, TMDL syntax, and PBIR schemas
HookPBIR validationValidates PBIR structure, required fields, naming conventions, and schema URLs
HookReport binding validationValidates semantic model binding (byPath directory exists; byConnection model exists via fab exists)
HookTMDL validationValidates TMDL structural syntax
TypeNameDescription
Skillbpa-rulesCreate and improve Best Practice Analyzer rules for models
Skillc-sharp-scriptingC# scripting and macros for TE
Skillte-cliCross-platform Tabular Editor CLI (te, preview) for semantic models from the terminal
Skillte2-cliTabular Editor 2 CLI usage and automation (not TE3)
Skillte-docsTabular Editor documentation search, TE3 config files. Uses pbi-search CLI
Command/suggest-ruleGenerate BPA rules from descriptions
Agentbpa-expression-helperDebug and improve BPA rule expressions
TypeNameDescription
Skillconnect-pbidExplore, query, and modify a model in Power BI Desktop, and reload/screenshot the report canvas via the Desktop Bridge
Agentquery-listenerCapture DAX queries from Power BI Desktop visuals in real time
HookDAX reference validationValidates table, column, and measure references against the connected model; suggests corrections
HookMeasure metadata enforcementBlocks adding measures without DisplayFolder, Description, and FormatString
HookReferential integrity checkReports unmatched many-side keys after relationship or column changes
HookMetadata cache refreshAuto-snapshots model metadata on TOM connect or model modification
HookCompatibility level checkReports features available by upgrading; optional auto-upgrade
TypeNameDescription
Skillfabric-cliFabric CLI (fab) for any remote operation in Power BI or Fabric (works fully on Pro/PPU; Fabric not required)
Command/audit-contextReview project context files (CLAUDE.md, agents.md, memory files)

| Command | /migrating-fabric-trial-capacities | Migrate workspac

Source 11 files
hooks/register.tsx 1133 lines
1import 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}
1133
hooks/open.ts 41 lines
1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4  'set -f',
5  'f=$1',
6  'if command -v gio >/dev/null; then',
7  '  if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8  '    case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9  '      video/* | audio/*)',
10  '        app=$(xdg-mime query default text/plain)',
11  '        IFS=:',
12  '        for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13  '          [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14  '        done',
15  '        echo "No text/plain desktop application found for $f" >&2',
16  '        exit 1',
17  '        ;;',
18  '    esac',
19  '  fi',
20  '  if (exec gio open "$f"); then exit 0; fi',
21  'fi',
22  'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28  const argv = os === 'linux'
29    ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30    : os === 'darwin'
31      ? ['open', '--', target]
32      : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33  return {
34    argv,
35    init: {
36      timeoutMs: 10_000,
37      ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38    },
39  }
40}
41
hooks/icons.ts 97 lines
1import 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}
97
hooks/rows.tsx 144 lines
1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6  rows: RowSpec[]
7  active: string
8  activeBg: string
9  hoverBg: string
10  tones: Record<string, { bright: string[]; dim: string[] }>
11  spinner?: string[]
12  pointer?: boolean
13  bar?: { pos: number; size: number; thumb: string; track: string }
14}
15type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
16
17const TICK_MS = 90
18const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20function shimmer(i: number, phase: number, len: number, palette: string[]): string {
21  const band = ((phase * 1.6) % (len + 8)) - 4
22  const d = Math.abs(i - band)
23  return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
24}
25
26const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
27  const { Box, Text } = surface.elements
28  let state = surface.state
29  if (state === undefined) {
30    state = { hover: -1, phase: 0, drag: false, ref: {} }
31    surface.setState(state)
32  }
33  const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
34  if (lit && !state.ref.stop) {
35    state.ref.stop = surface.every(TICK_MS, () => {
36      const cur = surface.state
37      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
38    })
39  } else if (!lit && state.ref.stop) {
40    state.ref.stop()
41    state.ref.stop = undefined
42  }
43  if (props.pointer === false) {
44    state.ref.unpoint?.()
45    state.ref.unpoint = undefined
46  } else state.ref.unpoint = surface.onPointer(e => {
47    const cur = surface.state ?? state
48    const span = Math.max(1, props.rows.length - 1)
49    const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
50    if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
51      if (e.type === 'up' || e.type === 'leave') {
52        surface.setState({ ...cur, drag: false })
53        return
54      }
55      if (e.type === 'down' || e.type === 'move') {
56        if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
57        surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
58        return
59      }
60    }
61    if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
62      if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
63      return
64    }
65    if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
66    const row = props.rows[e.y]
67    if (e.type === 'down' && e.button === 'right' && row?.id) {
68      surface.post({ copy: row.id, shift: Boolean(e.shift) })
69      return
70    }
71    if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
72    let x = 0
73    for (const seg of row.left) {
74      const w = [...seg.t].length
75      if (seg.tab && e.x >= x && e.x < x + w) {
76        surface.post({ tab: seg.tab })
77        return
78      }
79      x += w
80    }
81    if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
82  })
83  surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
84  const frames = props.spinner?.length ? props.spinner : FRAMES
85  const draw = (s: Seg, k: number) => {
86    if (s.spin) {
87      return (
88        <Text key={String(k)} color={s.c} bold={s.b}>
89          {s.t + (frames[state.phase % frames.length] ?? '')}
90        </Text>
91      )
92    }
93    const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
94    if (!palette) {
95      return (
96        <Text key={String(k)} color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
97          {s.t}
98        </Text>
99      )
100    }
101    if (s.one) {
102      return (
103        <Text key={String(k)} color={shimmer(-1, state.phase, s.t.length, palette)}>
104          {s.t}
105        </Text>
106      )
107    }
108    const chars = [...s.t]
109    return (
110      <Text key={String(k)} bold={s.b}>
111        {chars.map((ch, i) => (
112          <Text key={String(i)} color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
113        ))}
114      </Text>
115    )
116  }
117  return (
118    <Box flexDirection="column">
119      {props.rows.map((r, i) => (
120        <Box
121          key={r.id || `row-${i}`}
122          flexDirection="row"
123          height={1}
124          overflow="hidden"
125          backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
126        >
127          <Box flexShrink={1} overflow="hidden">
128            {r.left.map(draw)}
129          </Box>
130          <Box flexGrow={1} />
131          {r.right.map(draw)}
132          {props.bar && (
133            <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
134              {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
135            </Text>
136          )}
137        </Box>
138      ))}
139    </Box>
140  )
141}
142
143export default Rows
144
hooks/tree.ts 132 lines
1import type { Explorer, TreeNode } from '../types'
2
3export type Row = { node: TreeNode; depth: number; open: boolean; leaf: boolean }
4
5export function empty(): Explorer {
6  return {
7    target: null,
8    nodes: [],
9    expanded: [],
10    query: '',
11    cursor: '',
12    selected: '',
13    detail: [],
14    status: '',
15    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}
132
hooks/fabric.ts 142 lines
1import 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}
142
hooks/parse.ts 491 lines
1import 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}
491
hooks/touch.ts 19 lines
1import 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}
19
hooks/setup.ts 165 lines
1import 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}
165
hooks/icon-data.ts 2 lines
1export 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]]}
2
types/index.d.ts 48 lines
1export 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