Build, format, optimize, and review interactive Power BI reports, with a report pane that follows the pbir CLI and shows what Claude reads and changes.

<img src="media/bg42-11-4.png" alt="" width="800">
<h1 align="center">power-bi-agentic-development</h1>
The best source for Power BI AI skills and agentic development resources in one marketplace <br></br> <i> Teach AI agents like Claude Code or GitHub Copilot Power BI and Microsoft Fabric </i>
<img src="https://img.shields.io/badge/version-26.40.3-blue" alt="Version"> <img src="https://img.shields.io/badge/Power_BI-F2C811?logo=powerbi&logoColor=000" alt="Power BI"> <img src="https://img.shields.io/badge/Microsoft_Fabric-008272" alt="Microsoft Fabric"> <img src="https://img.shields.io/badge/Tabular_Editor-2E7D32" alt="Tabular Editor"> <img src="https://img.shields.io/badge/license-GPL--3.0-green" alt="License">
[!WARNING] These skills are under active development with a weekly release cadence, so expect regular renaming and restructuring.
Versions 26.26 through 26.38 are a deliberate breaking transition. Skills may be consolidated, renamed, removed, or made less automatic between weekly releases in this range. Pin 26.25 or earlier if you need the pre-transition skill structure and behavior; do not assume compatibility from one transition release to the next.
Here's how you get started in Claude Code; run this in the terminal to get the marketplace:
claude plugin marketplace add data-goblin/power-bi-agentic-development
Click here for a YouTube walkthrough
Add the marketplace, then install plugins via /plugin and navigating to the installed marketplace.
<table> <tr> <td align="center"><img src="media/marketplace-ui.png" alt="Marketplace UI" width="400"></td> <td align="center"><img src="media/marketplace-auto-update.png" alt="Marketplace auto-update" width="400"></td> </tr> <tr> <td align="center"><em>Install plugins from the marketplace</em></td> <td align="center"><em>Enable marketplace auto-update</em></td> </tr> </table>
Alternative; add plugins via command line:
claude plugin install goblin-mode@power-bi-agentic-development
claude plugin install tabular-editor@power-bi-agentic-development
claude plugin install pbi-desktop@power-bi-agentic-development
claude plugin install pbip@power-bi-agentic-development
claude plugin install semantic-models@power-bi-agentic-development
claude plugin install reports@power-bi-agentic-development
claude plugin install paginated-reports@power-bi-agentic-development
claude plugin install custom-visuals@power-bi-agentic-development
claude plugin install fabric-cli@power-bi-agentic-development
claude plugin install fabric-admin@power-bi-agentic-development
claude plugin install databricks-cli@power-bi-agentic-development
claude plugin install fabric-data-app@power-bi-agentic-development
claude plugin install etl@power-bi-agentic-development
The standalone Copilot CLI supports plugin installation from GitHub repos. Copilot CLI reads the same .claude-plugin/marketplace.json manifest this repo uses, so the marketplace and child-plugin layout works without modification.
TMDL files have a problem with repository-relative paths over 260 characters. Windows' legacy MAX_PATH blocks git clone from writing them unless long path support is enabled at both the OS and git level. Without this, copilot plugin install aborts with Filename too long.
Check useful-stuff/agent-scripts/enable-windows-longpaths.ps1 as an example of a script you can run from an elevated ps environment to enable long paths; there are other routes to do this that you can find online, too... just ask Copilot. A reboot is recommended after the registry change. This is a Windows OS limitation, documented at Maximum Path Length Limitation.
See also the below git config command:
git config --system core.longpaths true
This repository is an Anthropic-format plugin marketplace (a set of plugins), not a single distributable plugin, so the root .claude-plugin/ contains only marketplace.json. Two documented install paths work:
1. Register the marketplace once, then install named child plugins. Example:
copilot plugin marketplace add data-goblin/power-bi-agentic-development
copilot plugin install tabular-editor@power-bi-agentic-development
2. Or install a single plugin directly from its subdirectory, no marketplace registration needed. Example:
copilot plugin install data-goblin/power-bi-agentic-development:plugins/pbip
Both forms are documented in the Copilot CLI plugin reference and the plugins how-to. Inside an interactive Copilot session, /plugin install PLUGIN-NAME@MARKETPLACE-NAME is the equivalent of (1). The bare copilot plugin install data-goblin/power-bi-agentic-development (no qualifier) will not install anything useful, because the root is a marketplace catalog, not a plugin.
Inside Copilot CLI:
/env # Loaded instructions, MCP servers, skills, agents, plugins, LSPs, extensions
/plugin list # Installed plugins
/skills list # Available skills
/skills info pbip # Details for a specific skill
/agent # Browse installed agents
skills/<name>/SKILL.md.*.agent.md extension required by Copilot CLI's documented convention. Claude Code matches any *.md file in agents/, so the dual extension works in both tools..mcp.json (plugin root) or .github/mcp.json. The plugins in this repo do not currently ship MCP servers.hooks/hooks.json (with per-hook if path filters). Copilot CLI reads .github/plugin/plugin.json, which points it at hooks/copilot-hooks.json: the same scripts, wired with explicit bash and powershell commands so a Windows host without Git Bash on PATH gets a clean no-op instead of a failed launch. Copilot has no if filter and denies a tool call on any non-zero PreToolUse exit, so every script also re-applies its own path and command filters and exits 0 on anything unrelated. Requires Copilot CLI >= 1.0.26 (2026-04-14) for CLAUDE_PLUGIN_ROOT (changelog); older builds skip the hooks rather than fail. Native Windows bash users may also hit a separate path-format bug tracked upstream at claude-code#11984.~/.copilot/installed-plugins/ and their hooks run in every session on the machine. There is no per-project install, so the "prefer project scope" advice below applies to Claude Code only; in Copilot, install a plugin only while you need it./plugin rm or copilot plugin uninstall can fail with Access is denied. (os error 5). This is an open Copilot CLI bug, not a plugin problem: the installer swaps the plugin folder by renaming it, and Windows refuses the rename while another process holds a handle on it. VS Code's Copilot extension keeps directory watchers on every installed plugin's hooks/, agents/ and skills/ folders, and a second Copilot CLI session does the same. Tracked at github/copilot-cli#4095 and #4151.
%USERPROFILE%\.copilot\installed-plugins\ (or %COPILOT_HOME%\installed-plugins\) and restart Copilot CLI.hooks folder from that same location.The repo contains skills, agents, and hooks.
/skill-name. In Claude Code, skills and commands have coalesced; commands are simply more prescriptive skill workflows.Hook checks can be individually toggled via config files. Set any check to false to disable it:
plugins/pbip/hooks/config.yaml -- PBIR, TMDL, and report binding validationplugins/pbi-desktop/hooks/config.yaml -- DAX references, measure metadata, referential integrity, metadata cache[!WARNING] Don't install every plugin. Each skill competes for the agent's attention and context window, so install a plugin only when you need it and remove it when you don't. Prefer installing plugins scoped to a project rather than to your user, so each project carries only the skills it actually uses.
| Type | Name | Description |
|---|---|---|
| Skill | help-me-get-started | A slow, friendly, jargon-free guide to agentic development for people new to agents; talks through what you want to do, feels out your role and access, teaches the five pillars (model, context, prompt, tools, environment) with local interactive explainers, and checks and installs what you need (Windows/macOS commands). Adapts pace: full tutorial or a fast install run |
| Skill | improve-my-agent-setup | Setup-wide health check with modes (shallow/deep/ultra/yolo): skills, memory, tools, STT, model independence, other clients, harness config, git, permission/isolation, network and autonomous-agent exposure, safety enforcement, secrets and PII hygiene, and workflow habits; then offers to fix what's weak. Absorbs the old /audit-context |
| Type | Name | Description |
|---|---|---|
| Skill | bpa-rules | Create and improve Best Practice Analyzer rules for models |
| Skill | c-sharp-scripting | C# scripting and macros for TE |
| Skill | te-cli | Cross-platform Tabular Editor CLI (te, preview) for semantic models from the terminal |
| Skill | te2-cli | Tabular Editor 2 CLI usage and automation (not TE3) |
| Skill | te-docs | Tabular Editor documentation search, TE3 config files. Uses pbi-search CLI |
| Command | /suggest-rule | Generate BPA rules from descriptions |
| Agent | bpa-expression-helper | Debug and improve BPA rule expressions |
| Type | Name | Description |
|---|---|---|
| Skill | connect-pbid | Explore, query, and modify a model in Power BI Desktop, and reload/screenshot the report canvas via the Desktop Bridge |
| Agent | query-listener | Capture DAX queries from Power BI Desktop visuals in real time |
| Hook | DAX reference validation | Validates table, column, and measure references against the connected model; suggests corrections |
| Hook | Measure metadata enforcement | Blocks adding measures without DisplayFolder, Description, and FormatString |
| Hook | Referential integrity check | Reports unmatched many-side keys after relationship or column changes |
| Hook | Metadata cache refresh | Auto-snapshots model metadata on TOM connect or model modification |
| Hook | Compatibility level check | Reports features available by upgrading; optional auto-upgrade |
| Type | Name | Description |
|---|---|---|
| Skill | pbip | Power BI Project (PBIP) format, structure, and file types |
| Skill | tmdl | Author and edit TMDL files directly |
| Skill | pbir-format | Author and edit PBIR metadata files directly (visual.json, report.json, themes, filters, report extensions, visual calculations) |
| Agent | pbip-validator | Validate PBIP project structure, TMDL syntax, and PBIR schemas |
| Hook | PBIR validation | Validates PBIR structure, required fields, naming conventions, and schema URLs |
| Hook | Report binding validation | Validates semantic model binding (byPath directory exists; byConnection model exists via fab exists) |
| Hook | TMDL validation | Validates TMDL structural syntax |
| Type | Name | Description |
|---|---|---|
| Skill | create-pbi-report | Step-by-step workflow for building a complete report from scratch via the pbir CLI: model discovery, pages, theme, visuals, field binding, filtering, formatting, validation, publishing |
| Skill | pbi-report-design | The Power BI report design canon: design identity, visual hierarchy, layout, color discipline, chart selection, KPI/table design, accessibility. Shared reference used by the other report skills |
| Skill | modifying-theme-json | Design, enforce, audit, and validate report themes through the pbir CLI |
| Skill | review-report | Actionable feedback on report quality, usage, and effectiveness; usage analysis, health checks |
| Skill | pbir-cli | Programmatic report manipulation via the pbir CLI, including live Power BI Desktop refresh and page screenshots |
| Mod | /report-pane | Report pane: pages, visuals, bookmarks, filters and report measures of the .Report folder as a tree that follows pbir, with live highlights of what Claude reads or changes. Optional arg: a path to a .Report folder. Icons: FabricSymbols NF |
| Agent | deneb-reviewer | Review Deneb visual specs for Vega/Vega-Lite syntax and conventions |
| Agent | svg-reviewer | Review SVG DAX measures for syntax and design quality |
| Agent | r-reviewer | Review R visual scripts (ggplot2) for Power BI conventions |
| Agent | python-reviewer | Review Python visual scripts (matplotlib/seaborn) for Power BI conventions |
| Type | Name | Description |
|---|---|---|
| Skill | paginated-report | Author, validate, publish, and test Power BI paginated reports in RDL format (Report Builder, PBIRS/SSRS-compatible); connect to a semantic model, render to PDF/Excel |
| Hook | RDL validation | Validates .rdl structure after Write/Edit and on Bash commands touching .rdl files; blocks on structural errors only |
| Type | Name | Description |
|---|---|---|
| Skill | deneb-visuals | Deneb visual creation, Vega/Vega-Lite spec authoring, Deneb best practices for PBIR reports |
| Skill | r-visuals | Custom R visuals (ggplot2) for Power BI reports |
| Skill | python-visuals | Custom Python visuals (matplotlib/seaborn) for Power BI reports |
| Skill | svg-visuals | SVG generation via DAX measures and extension measures for inline chart visualizations (sparklines, bullet charts, KPI indicators, and more) |
| Skill | powerbi-custom-visuals | Power BI developer custom visual (.pbiviz) development with the pbiviz toolchain and its MCP server: scaffold, build, debug, package, certify, publish to AppSource |
Reviewer agents for these visual types (deneb-reviewer, svg-reviewer, r-reviewer, python-reviewer) ship in the reports plugin above.
| Type | Name | Description |
|---|---|---|
| Skill | semantic-model | Design, build, refresh, and review semantic models through a te-first tool cascade |
| Skill | standardize-naming-conventions | Audit and standardize naming conventions in semantic models |
| Skill | refresh-semantic-model | Trigger or troubleshoot refreshes |
| Skill | lineage-analysis | Trace downstream reports from a semantic model across workspaces |
| Skill | power-query | Write M expressions, debug query folding, execute M locally or via Fabric API |
| Skill | dax | Write, debug, and optimize DAX in semantic models. Contributed by Justin Martin |
| Agent | semantic-model-auditor | Audit semantic models for quality, memory, DAX, and design issues |
| Type | Name | Description |
|---|---|---|
| Skill | fabric-cli | Fabric CLI (fab) for any remote operation in Power BI or Fabric (works fully on Pro, PPU; Fabric not required) |
| Command | /migrating-fabric-trial-capacities | Migrate workspaces from trial to production capacity |
| Mod | /fabric-pane | Fabric pane: workspaces and items as a tree that follows fab, with live highlights, copy of fab paths, open in Fabric, and open semantic models in te. Optional arg: a workspace to reveal. Icons: FabricSymbols NF |
| Type | Name | Description |
|---|---|---|
| Skill | audit-tenant-settings | Audit Fabric and Power BI tenant settings, delegated overrides, and Entra security group membership |
| Type | Name | Description |
|---|---|---|
| Mod | /databricks-pane | Databricks pane: workspace, Unity Catalog, compute, jobs, pipelines, apps and dashboards as a tree that follows databricks, with live highlights, profile switching (-p or DATABRICKS_CONFIG_PROFILE), copy of CLI arguments, and open in Databricks. Optional arg: a profile. Icons: DatabricksSymbols NF |
| Type | Name | Description |
|---|---|---|
| Mod | /fabric-app-pane | Fabric app pane: every Fabric App (rayfin/rayfin.yml) under the working directory with its data sources, deploy state and files, as a tree that follows rayfin, with live highlights of what Claude deploys or changes. Optional arg: a folder. Icons: FabricSymbols NF |
| Type | Name | Description |
|---|---|---|
| Skill |
hooks/register.tsx 884 lines1import { type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { LINUX_LAUNCH as LAUNCH, openCommand } from './open'
3
4import type { Explorer, Target, TreeNode } from '../types'
5import { glyph, type Tier, visualLabel } from './icons'
6import type { RowSpec, RowsProps, Seg } from './rows'
7import { ancestors, diff, empty, visible } from './tree'
8import { idOf, reportUrl, workspaceUrl } from './fabric'
9import { binOf, type Invocation, invocations, join, pbirReport, posix, targetLabel, tokenize, useDrives, useHome } from './parse'
10import { loadReport, reportDetail } from './report'
11import { pbirTouched } from './touch'
12
13const STATE = { plugin: 'reports', key: 'explorer' } as const
14const TREE = { plugin: 'reports', key: 'tree' } as const
15const PANE = 'report-pane'
16const TITLE = 'Report'
17const TITLE_GLYPH = { fabric: 0xf202a, nerd: '\u{f0127}', plain: '▣' }
18const TITLE_COLOR = '#fbbf24'
19const HINT = 'waiting for a pbir command, or /report-pane <path.Report>'
20const WINDOW = 400
21const DETAIL_ROWS = 12
22const BUSY_MAX_MS = 600_000
23const FLASH_MS = 2700
24const DOUBLE_MS = 450
25const SHIMMER = ['#f97316', '#fb923c', '#fdba74', '#ffedd5']
26const TONES: Record<string, { bright: string[]; dim: string[] }> = {
27 orange: { bright: SHIMMER, dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'] },
28 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'] },
29 pink: { bright: ['#ec4899', '#f472b6', '#f9a8d4', '#fce7f3'], dim: ['#831843', '#9d174d', '#b8406f', '#cf6f93'] },
30 purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'] },
31}
32const PLAIN_SPINNER = ['|', '/', '-', '\\']
33const FONT_SCRIPT =
34 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
35 'else f=$(ls "$HOME"/Library/Fonts/*"$2"* /Library/Fonts/*"$2"* 2>/dev/null | head -n1); fi; ' +
36 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
37 '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 ' +
38 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
39 '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; ' +
40 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
41
42let lastPress = { key: '', at: 0 }
43const views: Record<string, { from: number; max: number; rows: number; room: number }> = {}
44let detected: Tier = 'nerd'
45let glyphSetting = 'auto'
46let fontHint = true
47let hinted = false
48let fellBack = false
49let follow = true
50let pbirBin = 'pbir'
51let blink: Timer | null = null
52let runs = 0
53let closed = false
54let noDock = false
55let inflight: Promise<void> | null = null
56let queued: Promise<void> | null = null
57let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
58
59function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
60 platform ??= (async () => {
61 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
62 try {
63 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
64 } catch {
65 return 'linux'
66 }
67 })()
68 return platform
69}
70
71async function openPane($: EngineInterface, args: { id: string; title: string; focus?: true }): Promise<void> {
72 if (noDock && !args.focus) return
73 await $.ui.open(args)
74}
75
76async function cwdOf($: EngineInterface): Promise<string> {
77 return posix(await $.session.cwd())
78}
79
80function quiet(p: Promise<unknown>): void {
81 void p.catch(() => undefined)
82}
83
84function jumpTo(ex: Explorer, nodes: TreeNode[], query: string): Partial<Explorer> {
85 const q = query.trim().toLowerCase()
86 const hits = q ? nodes.filter(n => n.name.toLowerCase().includes(q) || n.note.toLowerCase().includes(q)).slice(0, 10) : []
87 const open = new Set(ex.expanded)
88 for (const n of hits) for (const a of ancestors(nodes, n.id)) open.add(a)
89 return { query: '', expanded: [...open], cursor: hits[0]?.id ?? ex.cursor, scroll: {} }
90}
91
92async function copyOf($: EngineInterface, text: string, surface?: string): Promise<void> {
93 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
94 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy')
95}
96
97function clean(segs: Seg[]): Seg[] {
98 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
99 return segs
100}
101
102async function launch($: EngineInterface, argv: string[]): Promise<void> {
103 try {
104 const run = await $.process.run(['sh', '-c', LAUNCH, 'sh', ...argv], { timeoutMs: 10_000 })
105 if (run.exitCode !== 0) $.ui.toast(`could not start ${argv[0] ?? ''} (exit ${run.exitCode})`)
106 } catch {
107 $.ui.toast(`could not start ${argv[0] ?? ''}`)
108 }
109}
110
111async function openUrl($: EngineInterface, url: string): Promise<void> {
112 const { argv, init } = openCommand(await osName($), url)
113 try {
114 const run = await $.process.run(argv, init)
115 if (run.exitCode !== 0) $.ui.toast(`could not open ${url} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
116 } catch {
117 $.ui.toast(`could not open ${url} with ${argv[0]}`)
118 }
119}
120
121function cmdq(arg: string): string {
122 return /^[\w@%+=:,./\\-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '""')}"`
123}
124
125function shq(arg: string): string {
126 return `'${arg.replace(/'/g, `'\\''`)}'`
127}
128
129async function terminal($: EngineInterface, linux: string[], cmd: string[], cwd = ''): Promise<void> {
130 const line = cmd.map(shq).join(' ')
131 const os = await osName($)
132 if (os === 'linux') return launch($, ['xdg-terminal-exec', ...linux])
133 const argv =
134 os === 'darwin'
135 ? ['osascript', '-e', `tell application "Terminal" to do script "${(cwd ? `cd ${shq(cwd)} && ${line}` : line).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`, '-e', 'tell application "Terminal" to activate']
136 : ['cmd', '/c', 'start', '', 'cmd', '/k', cwd ? `cd /d "${cwd.replace(/\//g, '\\')}" && ${cmd.map(cmdq).join(' ')}` : cmd.map(cmdq).join(' ')]
137 try {
138 const run = await $.process.run(argv, { timeoutMs: 10_000 })
139 if (run.exitCode !== 0) $.ui.toast(`could not start ${argv[0] ?? ''} (exit ${run.exitCode})`)
140 } catch {
141 $.ui.toast(`could not start ${argv[0] ?? ''}`)
142 }
143}
144
145async function fontState($: EngineInterface, charset: string, name: string): Promise<'ok' | 'stale' | 'missing'> {
146 try {
147 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset, name], { timeoutMs: 5_000 })).stdout.trim()
148 return out === 'stale' || out === 'missing' ? out : 'ok'
149 } catch {
150 return 'missing'
151 }
152}
153
154async function detectGlyphs($: EngineInterface): Promise<void> {
155 if ((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY'))) {
156 detected = 'nerd'
157 return
158 }
159 const nerd = (await fontState($, 'f04eb', 'Nerd')) === 'ok'
160 detected = nerd && (await fontState($, 'f2621', 'FabricSymbols')) === 'ok' ? 'fabric' : nerd ? 'nerd' : 'plain'
161}
162
163function fontNote(): string {
164 if (!fontHint || hinted || !fellBack) return ''
165 hinted = true
166 return 'Tell user once: Report pane icons need github.com/data-goblin/fabric-nf plus a Nerd Font. Plugin option fontHint=off disables this.'
167}
168
169function tierFor(surface: string): Tier {
170 if (glyphSetting === 'fabric' || glyphSetting === 'nerd' || glyphSetting === 'plain') return glyphSetting
171 return surface === 'desktop' ? 'plain' : detected
172}
173
174function titleGlyph(tier: Tier): string {
175 return tier === 'fabric' ? String.fromCodePoint(TITLE_GLYPH.fabric) : tier === 'nerd' ? TITLE_GLYPH.nerd : TITLE_GLYPH.plain
176}
177
178function titleFor(target: Target | null): string {
179 return `${titleGlyph(tierFor('terminal'))} ${target ? targetLabel(target) : TITLE}`
180}
181
182async function get($: EngineInterface): Promise<Explorer> {
183 return { ...empty(), ...(await $.state.get(STATE)).value }
184}
185
186async function nodesOf($: EngineInterface, target: Target | null): Promise<TreeNode[]> {
187 const tree = (await $.state.get(TREE)).value
188 return tree && sameTarget(tree.target, target) ? tree.nodes : []
189}
190
191async function setTree($: EngineInterface, target: Target, nodes: TreeNode[]): Promise<TreeNode[] | null> {
192 for (let i = 0; i < 20; i++) {
193 if (!sameTarget((await get($)).target, target)) return null
194 const cur = await $.state.get(TREE)
195 const before = cur.value && sameTarget(cur.value.target, target) ? cur.value.nodes : []
196 const done = await $.state.set(TREE, { target, nodes }, { ifVersion: cur.version })
197 if (done.isSet) return before
198 }
199 return null
200}
201
202async function put($: EngineInterface, fn: (ex: Explorer) => Explorer): Promise<void> {
203 await update($, STATE, cur => fn({ ...empty(), ...cur }))
204}
205
206function patch($: EngineInterface, fn: (ex: Explorer) => Partial<Explorer>) {
207 return put($, ex => ({ ...ex, ...fn(ex) }))
208}
209
210const sameTarget = (a: Target | null, b: Target | null) => JSON.stringify(a) === JSON.stringify(b)
211
212function refresh($: EngineInterface): Promise<void> {
213 if (inflight) {
214 const rerun = () => {
215 queued = null
216 return refresh($)
217 }
218 queued ??= inflight.then(rerun, rerun)
219 return queued
220 }
221 inflight = doRefresh($).finally(() => {
222 inflight = null
223 })
224 return inflight
225}
226
227async function light($: EngineInterface, ids: string[], tone: string, running: boolean, run = ''): Promise<string> {
228 const unique = [...new Set(ids)]
229 const ex = await get($)
230 if (run && ex.work && ex.work.run !== run) return ''
231 if (!running && unique.length === 0) {
232 if (run) await settle($, run)
233 return ''
234 }
235 const mine = run || `${++runs}`
236 blink?.cancel()
237 blink = null
238 const at = await $.clock.now()
239 const nodes = await nodesOf($, ex.target)
240 let kept = false
241 await patch($, cur => {
242 if (run && cur.work && cur.work.run !== run) return {}
243 kept = true
244 const byId = new Map(nodes.map(n => [n.id, n]))
245 const open = new Set(cur.expanded)
246 const dim = new Set<string>()
247 for (const id of unique) {
248 const chain = ancestors(nodes, id, byId)
249 if (!chain.some(a => !open.has(a))) continue
250 for (const a of chain) {
251 if (!open.has(a)) dim.add(a)
252 if (follow) open.add(a)
253 }
254 }
255 for (const id of unique) dim.delete(id)
256 const root = rootOf(cur, nodes)
257 const away = follow && root !== '' && unique.some(id => id !== root && !ancestors(nodes, id, byId).includes(root))
258 const next: Explorer = { ...cur, expanded: [...open], root: away ? '' : cur.root }
259 const scroll = follow ? heldInView(next, nodes, [...unique, ...dim], unique[unique.length - 1] ?? '') : cur.scroll
260 return { work: { run: mine, tone, running, at }, flash: unique, flashDim: [...dim], expanded: next.expanded, scroll, ...(away ? { root: '' } : {}) }
261 })
262 if (!kept) return ''
263 if (!running) {
264 blink = $.clock.after(FLASH_MS, () => {
265 blink = null
266 quiet(settle($, mine))
267 })
268 if (ex.target && !closed) await openPane($, { id: PANE, title: titleFor(ex.target) })
269 }
270 return mine
271}
272
273async function settle($: EngineInterface, run: string): Promise<void> {
274 await patch($, cur => (cur.work?.run === run ? { work: null, flash: [], flashDim: [] } : {}))
275}
276
277function rootOf(ex: Explorer, nodes: TreeNode[]): string {
278 return ex.root && nodes.some(n => n.id === ex.root) ? ex.root : ''
279}
280
281function heldInView(ex: Explorer, nodes: TreeNode[], lit: string[], focus: string): Record<string, number> {
282 const rows = visible(ex, nodes, rootOf(ex, nodes))
283 const where = (id: string) => rows.findIndex(r => r.node.id === id)
284 const first = Math.min(...lit.map(where).filter(i => i >= 0))
285 const at = where(focus)
286 const out: Record<string, number> = {}
287 for (const [surface, from] of Object.entries(ex.scroll)) if (at >= 0 && first >= from && at < from + (views[surface]?.room ?? 0)) out[surface] = from
288 return out
289}
290
291function anchored(ex: Explorer, before: TreeNode[], after: TreeNode[], expanded: string[]): Record<string, number> {
292 const was = visible(ex, before, rootOf(ex, before))
293 const now = visible({ ...ex, expanded }, after, rootOf(ex, after))
294 const out: Record<string, number> = {}
295 for (const [surface, from] of Object.entries(ex.scroll)) {
296 const top = was[from]?.node.id
297 const at = top ? now.findIndex(r => r.node.id === top) : -1
298 out[surface] = at < 0 ? from : at
299 }
300 return out
301}
302
303async function point($: EngineInterface, target: Target | null, opened: 'asked' | 'unasked', fresh = false): Promise<boolean> {
304 const moved = !sameTarget((await get($)).target, target)
305 if (moved) {
306 await put($, cur => ({ ...empty(), target, ...(follow || opened === 'asked' ? {} : { scroll: cur.scroll }) }))
307 await $.state.set(TREE, { target, nodes: [] })
308 } else if (fresh) await patch($, () => ({ expanded: [], query: '', selected: '', detail: [], cursor: '', changed: [] }))
309 if (opened === 'asked' || (moved && target)) {
310 const title = titleFor(target)
311 if (opened === 'asked') {
312 closed = false
313 await openPane($, { id: PANE, title, focus: true })
314 } else if (!closed) await openPane($, { id: PANE, title })
315 }
316 return moved
317}
318
319async function navigate($: EngineInterface, target: Target | null, n: TreeNode): Promise<void> {
320 const nodes = await nodesOf($, target)
321 await patch($, cur => (sameTarget(cur.target, target) ? { root: n.id, cursor: n.id, scroll: {}, expanded: [...new Set([...cur.expanded, n.id, ...ancestors(nodes, n.id)])] } : {}))
322}
323
324async function goUp($: EngineInterface): Promise<void> {
325 const nodes = await nodesOf($, (await get($)).target)
326 await patch($, cur => {
327 const n = nodes.find(x => x.id === cur.root)
328 return { root: n?.parent ?? '', cursor: n?.id ?? cur.cursor, scroll: {} }
329 })
330}
331
332async function goHome($: EngineInterface): Promise<void> {
333 await patch($, () => ({ root: '', scroll: {} }))
334}
335
336async function press($: EngineInterface, ex: Explorer, n: TreeNode): Promise<void> {
337 const now = await $.clock.now()
338 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
339 lastPress = { key: isDouble ? '' : n.id, at: now }
340 if (isDouble && (await nodesOf($, ex.target)).some(c => c.parent === n.id)) await navigate($, ex.target, n)
341 else if (isDouble) await openLocal($, await get($), n)
342 else await select($, ex.target, n)
343}
344
345async function openWeb($: EngineInterface, ex: Explorer, n: TreeNode): Promise<void> {
346 const url = webUrl(ex, n)
347 if (url) await openUrl($, url)
348 else $.ui.toast('no Fabric link for this object')
349}
350
351async function fabId($: EngineInterface, path: string): Promise<string> {
352 try {
353 const run = await $.process.run(['fab', 'get', path, '-q', 'id'], { timeoutMs: 30_000 })
354 return run.exitCode === 0 ? idOf(run.stdout) : ''
355 } catch {
356 return ''
357 }
358}
359
360async function targetUrl($: EngineInterface, ws: string, report: string): Promise<string> {
361 const wsId = await fabId($, `${ws}.Workspace`)
362 if (!wsId) return ''
363 const reportId = await fabId($, `${ws}.Workspace/${report}.Report`)
364 return reportId ? reportUrl(wsId, reportId) : workspaceUrl(wsId)
365}
366
367function webUrl(ex: Explorer, n: TreeNode): string {
368 return n.path ? ex.targetUrl : ''
369}
370
371type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
372
373let markId = 0
374
375async function sinceNow($: EngineInterface, watch: boolean): Promise<Since> {
376 const ms = await $.clock.now()
377 if (!watch) return { ms, mark: '', os: 'linux' }
378 const os = await osName($)
379 if (os !== 'darwin') return { ms, mark: '', os }
380 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/${PANE}-${await $.session.id()}-${++markId}.mark`
381 try {
382 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
383 return { ms, mark, os }
384 } catch {
385 return { ms, mark: '', os }
386 }
387}
388
389async function dropMark($: EngineInterface, since: Since): Promise<void> {
390 if (since.mark) await $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
391}
392
393async function changedUnder($: EngineInterface, dirs: string[], since: Since): Promise<string[]> {
394 if (since.os === 'win32') return []
395 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
396 try {
397 const run = await $.process.run(['find', '-H', ...dirs, '-name', '.git', '-prune', '-o', '(', '-type', 'f', '-o', '-type', 'd', ')', ...test, '-print'], {
398 timeoutMs: 8_000,
399 })
400 return run.stdout.split('\n').filter(Boolean).slice(0, 500)
401 } catch {
402 return []
403 }
404}
405
406async function directEdit($: EngineInterface, files: string[], run = ''): Promise<void> {
407 const t = (await get($)).target
408 const root = t?.kind === 'local' ? t.path.replace(/\/$/, '') : ''
409 if (!root || !files.some(f => f === root || f.startsWith(root + '/'))) {
410 if (run) await settle($, run)
411 return
412 }
413 await refresh($)
414 await light($, (await get($)).changed, 'orange', false, run)
415}
416
417function toggled(ex: Explorer, nodes: TreeNode[], n: TreeNode): Partial<Explorer> {
418 const open = new Set(ex.expanded)
419 if (nodes.some(c => c.parent === n.id)) open.has(n.id) ? open.delete(n.id) : open.add(n.id)
420 return { expanded: [...open], cursor: n.id, selected: n.path ? n.id : ex.selected }
421}
422
423const tasks = new Map<string, ((ok: boolean) => Promise<void>)[]>()
424
425function backgroundOf(result: unknown): string {
426 const id = (result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
427 return typeof id === 'string' ? id : ''
428}
429
430function afterTask(id: string, done: (ok: boolean) => Promise<void>): void {
431 tasks.set(id, [...(tasks.get(id) ?? []), done])
432}
433
434async function taskEnded(text: string): Promise<void> {
435 for (const block of text.split('<task-notification>').slice(1)) {
436 const id = block.match(/<task-id>([^<]+)<\/task-id>/)?.[1]
437 const status = block.match(/<status>([^<]+)<\/status>/)?.[1]
438 const waiting = id && status ? tasks.get(id) : undefined
439 if (!id || !waiting) continue
440 tasks.delete(id)
441 for (const done of waiting) await done(status === 'completed')
442 }
443}
444
445const PBIR_READONLY = new Set(['ls', 'list', 'get', 'schema', 'auth', 'validate', 'diff', 'help', 'tree', 'find', 'search', 'info', 'show', 'dump', 'cat', '--help', '-h'])
446
447function readOnlyPbir(inv: Invocation): boolean {
448 return inv.args.filter(a => !a.startsWith('-')).slice(0, 2).some(a => PBIR_READONLY.has(a))
449}
450
451async function doRefresh($: EngineInterface): Promise<void> {
452 const target = (await get($)).target
453 if (!target || target.kind !== 'local') return
454 await patch($, () => ({ status: 'loading' }))
455 try {
456 const nodes = await loadReport(
457 {
458 text: async path => String(await $.fs.read(path)),
459 list: async path => (await $.fs.list(path)).map(e => ({ name: e.name, kind: e.kind })),
460 exists: path => $.fs.exists(path),
461 },
462 target.path,
463 )
464 const before = await setTree($, target, nodes)
465 if (!before) return
466 await patch($, cur => {
467 if (!sameTarget(cur.target, target)) return {}
468 const sel = cur.selected ? nodes.find(n => n.id === cur.selected) : undefined
469 const picked = !cur.selected ? {} : sel ? { detail: reportDetail(sel) } : { selected: '', detail: [] }
470 const expanded = before.length ? cur.expanded : nodes.filter(n => !n.parent).map(n => n.id)
471 return { changed: diff(before, nodes), expanded, status: '', ...picked, ...(follow ? {} : { scroll: anchored(cur, before, nodes, expanded) }) }
472 })
473 const m = target.path.match(/([^/]+)\.Workspace\/([^/]+)\.Report$/)
474 if (m?.[1] && m[2] && !(await get($)).targetUrl) {
475 const url = await targetUrl($, m[1], m[2])
476 await patch($, c => (sameTarget(c.target, target) ? { targetUrl: url } : {}))
477 }
478 } catch (err) {
479 await patch($, cur => (sameTarget(cur.target, target) ? { status: `error: ${err instanceof Error ? err.message : String(err)}` } : {}))
480 }
481}
482
483function toneOf(calls: Invocation[]): string {
484 return calls.some(inv => !readOnlyPbir(inv)) ? 'orange' : 'purple'
485}
486
487async function followPbir($: EngineInterface, calls: Invocation[], run: string): Promise<void> {
488 const ran = calls.filter(inv => !inv.maybe)
489 for (const inv of ran) pbirBin = binOf(inv.bin, inv.cwd, 'pbir')
490 let dirty = false
491 for (const inv of calls) {
492 const path = inv.maybe ? null : pbirReport(inv)
493 if (path && (await point($, { kind: 'local', path }, 'unasked'))) dirty = true
494 if (!readOnlyPbir(inv) && !inv.args.includes('--dry-run')) dirty = true
495 }
496 quiet(
497 (async () => {
498 if (dirty) await refresh($)
499 const cur = await get($)
500 const nodes = await nodesOf($, cur.target)
501 await light($, [...(dirty ? cur.changed : []), ...ran.flatMap(inv => pbirTouched(inv, nodes))], toneOf(calls), false, run)
502 })(),
503 )
504}
505
506async function openLocal($: EngineInterface, ex: Explorer, n: TreeNode): Promise<void> {
507 const t = ex.target
508 if (t?.kind === 'local') {
509 const parent = t.path.replace(/\/[^/]+$/, '') || '/'
510 await terminal($, ['bash', '-lc', 'cd "$1" && "$2" -i; exec bash', '_', parent, pbirBin], [pbirBin, '-i'], parent)
511 } else if (webUrl(ex, n)) await openUrl($, webUrl(ex, n))
512}
513
514async function select($: EngineInterface, target: Target | null, n: TreeNode): Promise<void> {
515 const nodes = await nodesOf($, target)
516 const node = nodes.find(x => x.id === n.id)
517 if (!node) return
518 await patch($, cur => (sameTarget(cur.target, target) ? { ...toggled(cur, nodes, node), ...(node.path ? { detail: reportDetail(node) } : {}) } : {}))
519}
520
521function contextFor(ex: Explorer, n: TreeNode): string {
522 const t = ex.target
523 return [
524 'The user has this report object selected in the Report pane; "this", "it" or "the selected" in the prompt likely refers to it.',
525 `${n.kind}: ${n.name}`,
526 `pbir report folder: ${t?.kind === 'local' ? t.path : ''}`,
527 `pbir path: ${n.path}`,
528 ...ex.detail.slice(1),
529 ].join('\n')
530}
531
532function wholeArg(args: string | undefined): string {
533 const raw = (args ?? '').trim()
534 return /^["']/.test(raw) ? (tokenize(raw)[0] ?? '') : raw
535}
536
537export const register: Register = (on, options) => {
538 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
539 follow = options?.follow !== 'off'
540 fontHint = options?.fontHint !== 'off'
541
542 on('session.start', async ($, e, next) => {
543 const windows = (await $.env.get('OS')) === 'Windows_NT'
544 useDrives(windows)
545 useHome(posix((await $.env.get('HOME')) || (windows ? await $.env.get('USERPROFILE') : '') || ''))
546 hinted = false
547 fellBack = false
548 pbirBin = 'pbir'
549 lastPress = { key: '', at: 0 }
550 quiet(detectGlyphs($).then(() => $.ui.invalidate('ui.render')))
551 await patch($, () => ({ flash: [], flashDim: [], work: null }))
552 await $.command.register({ name: PANE, description: 'Open the Report pane; args: <path to .Report>' })
553 const ex = await get($)
554 if (ex.target) quiet(openPane($, { id: PANE, title: titleFor(ex.target) }))
555 return next(e)
556 })
557
558 on('command.run', { command: PANE }, async ($, e) => {
559 const terminalOnly = !(await $.session.surfaces()).includes('desktop')
560 if (terminalOnly && e.presentation && !e.presentation.isFullscreen) return { text: 'The Report pane shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /report-pane.' }
561 if (terminalOnly && e.presentation && e.presentation.columns < 110) return { text: 'The Report pane shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /report-pane.' }
562 noDock = false
563 await detectGlyphs($)
564 const cwd = await cwdOf($)
565 let target = (await get($)).target
566 const first = wholeArg(e.args)
567 if (first) target = { kind: 'local', path: join(cwd, first.replace(/\/$/, '')) }
568 await point($, target, 'asked', Boolean(first))
569 quiet(refresh($))
570 return { text: target ? `Report pane on ${targetLabel(target)}.` : 'Report pane open; no report yet.' }
571 })
572
573 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
574 const command = e.tool === 'Bash' ? e.command : ''
575 if (!/\bpbir\b/.test(command)) return next(e)
576 const calls = invocations(command, await cwdOf($))
577 if (calls.length === 0) return next(e)
578 const ex = await get($)
579 const here = calls.filter(inv => {
580 const p = pbirReport(inv)
581 return ex.target && (!p || (ex.target.kind === 'local' && ex.target.path === p))
582 })
583 const nodes = here.length ? await nodesOf($, ex.target) : []
584 const run = here.length ? await light($, here.flatMap(inv => (inv.maybe ? [] : pbirTouched(inv, nodes))), toneOf(calls), true) : ''
585 let result: Awaited<ReturnType<typeof next>>
586 try {
587 result = await next(e)
588 } catch (err) {
589 if (run) await settle($, run)
590 throw err
591 }
592 const task = result.deny || result.isError ? '' : backgroundOf(result.result)
593 if (task) {
594 afterTask(task, async ok => {
595 if (ok) await followPbir($, calls, run)
596 else if (run) await settle($, run)
597 })
598 return result
599 }
600 if (result.deny || result.isError) {
601 if (run) await settle($, run)
602 return result
603 }
604 await followPbir($, calls, run)
605 return result
606 })
607
608 on('tool.call', async ($, e, next) => {
609 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'Bash') return next(e)
610 const command = e.tool === 'Bash' ? e.command : ''
611 const viaCli = e.tool === 'Bash' && invocations(command, await cwdOf($)).length > 0
612 const t = (await get($)).target
613 const watched = e.tool === 'Bash' && !viaCli && t?.kind === 'local' ? [t.path] : []
614 const file = e.tool === 'Bash' ? '' : join(await cwdOf($), e.file_path)
615 const inside = file !== '' && t?.kind === 'local' && file.startsWith(t.path.replace(/\/$/, '') + '/')
616 const run = inside ? await light($, [], 'orange', true) : ''
617 const since = await sinceNow($, watched.length > 0)
618 let result: Awaited<ReturnType<typeof next>>
619 try {
620 result = await next(e)
621 } catch (err) {
622 quiet(dropMark($, since))
623 if (run) await settle($, run)
624 throw err
625 }
626 if (result.deny || result.isError) {
627 quiet(dropMark($, since))
628 if (run) await settle($, run)
629 return result
630 }
631 if (file) {
632 const root = file.match(/^(.*?\.Report)\//)?.[1]
633 if (root && !inside) await point($, { kind: 'local', path: root }, 'unasked')
634 quiet(directEdit($, [file], run))
635 } else if (watched.length) {
636 const scan = async () => {
637 try {
638 await directEdit($, await changedUnder($, watched, since))
639 } finally {
640 await dropMark($, since)
641 }
642 }
643 const task = backgroundOf(result.result)
644 if (task) afterTask(task, ok => (ok ? scan() : dropMark($, since)))
645 else quiet(scan())
646 }
647 return result
648 })
649
650 on('session.append', async ($, e, next) => {
651 if (tasks.size && e.door !== 'tool-result' && e.message.type !== 'assistant') quiet(taskEnded(JSON.stringify(e.message.content)))
652 return next(e)
653 })
654
655 on('ui.close', async ($, e, next) => {
656 const result = await next(e)
657 if (e.id === PANE && e.origin.kind === 'person') closed = true
658 return result
659 })
660
661 on('ui.message', async ($, e, next) => {
662 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
663 const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
664 const ex = await get($)
665 if (typeof data.scrollTo === 'number') {
666 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * (views[e.surface]?.max ?? 0))
667 if (to !== ex.scroll[e.surface]) await patch($, cur => ({ scroll: { ...cur.scroll, [e.surface]: to } }))
668 return {}
669 }
670 const nodes = await nodesOf($, ex.target)
671 if (typeof data.copy === 'string') {
672 const n = nodes.find(x => x.id === data.copy)
673 if (n) await copyOf($, n.path || n.name, e.surface)
674 return {}
675 }
676 if (typeof data.press === 'string') {
677 const n = nodes.find(x => x.id === data.press)
678 if (!n) return {}
679 if (ex.scroll[e.surface] === undefined) await patch($, cur => ({ scroll: { ...cur.scroll, [e.surface]: views[e.surface]?.from ?? 0 } }))
680 if (data.ctrl) await openWeb($, ex, n)
681 else if (data.shift) await openLocal($, ex, n)
682 else await press($, ex, n)
683 return {}
684 }
685 if (typeof data.key !== 'string') return {}
686 const rows = visible(ex, nodes, rootOf(ex, nodes))
687 const at = rows.findIndex(r => r.node.id === ex.cursor)
688 const cur = rows[at]
689 const move = (d: number) => {
690 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
691 return target ? patch($, () => ({ cursor: target.node.id, scroll: {} })) : Promise.resolve()
692 }
693 if (data.key === 'up' || data.key === 'k') await move(-1)
694 else if (data.key === 'down' || data.key === 'j') await move(1)
695 else if (data.key === 'pageup') await move(-10)
696 else if (data.key === 'pagedown') await move(10)
697 else if (data.key === 'home') await move(-rows.length)
698 else if (data.key === 'end') await move(rows.length)
699 else if (cur && (data.key === 'y' || data.key === 'Y')) await copyOf($, cur.node.path || cur.node.name, e.surface)
700 else if (cur && (data.key === 'right' || data.key === 'l') && !cur.leaf && !cur.open) await select($, ex.target, cur.node)
701 else if (cur && (data.key === 'left' || data.key === 'h')) {
702 if (!cur.leaf && cur.open) await patch($, x => ({ expanded: x.expanded.filter(id => id !== cur.node.id) }))
703 else if (cur.node.parent) await patch($, () => ({ cursor: cur.node.parent }))
704 } else if (cur && (data.key === ' ' || data.key === 'return')) await (data.key === 'return' && cur.leaf ? openLocal($, ex, cur.node) : select($, ex.target, cur.node))
705 return {}
706 })
707
708 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
709 const ex = await get($)
710 const known = Object.keys(views)
711 const sized = known.filter(s => views[s]?.rows === e.bodyRows)
712 const step = Math.sign(e.by) * Math.max(3, Math.abs(e.by))
713 const moved: Record<string, number> = {}
714 for (const s of sized.length ? sized : known) {
715 const v = views[s]
716 if (v) moved[s] = Math.max(0, Math.min(v.max, (ex.scroll[s] ?? v.from) + step))
717 }
718 if (Object.entries(moved).some(([s, to]) => to !== ex.scroll[s])) await patch($, cur => ({ scroll: { ...cur.scroll, ...moved } }))
719 return {}
720 })
721
722 on('prompt.submit', async ($, e, next) => {
723 const ex = await get($)
724 const n = ex.selected && ex.target ? (await nodesOf($, ex.target)).find(x => x.id === ex.selected) : undefined
725 const hint = fontNote()
726 const context = [...(e.context ?? []), ...(n ? [contextFor(ex, n)] : []), ...(hint ? [hint] : [])]
727 return context.length === (e.context ?? []).length ? next(e) : next({ ...e, context })
728 })
729
730 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
731 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
732 if (e.surface === 'terminal' && e.props.placement === 'inline') {
733 noDock = true
734 quiet($.ui.close({ id: PANE }))
735 const { Box: Empty } = $.ui.resolve(e)
736 return <Empty />
737 }
738 const tier = tierFor(e.surface)
739 if (e.surface === 'terminal' && tier === 'plain' && glyphSetting === 'auto') fellBack = true
740 const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
741 const ex = await get($)
742 const nodes = await nodesOf($, ex.target)
743 const now = await $.clock.now()
744 const work = ex.work && (!ex.work.running || now - ex.work.at < BUSY_MAX_MS) ? ex.work : null
745 const tone = work?.tone ?? ''
746 const brightSet = new Set(work ? ex.flash : [])
747 const dimSet = new Set(work ? ex.flashDim : [])
748 const width = Math.max(20, e.props.bodyColumns)
749 const rootId = rootOf(ex, nodes)
750 const rows = visible(ex, nodes, rootId)
751 const changed = new Set(ex.changed)
752 const detailRows = ex.detail.length ? Math.min(ex.detail.length, DETAIL_ROWS) + 2 : 0
753 const room = Math.max(5, Math.min(WINDOW, (e.props.scroll?.bodyRows ?? 40) - 4 - detailRows))
754 const focusId = follow && work && ex.flash.length ? (ex.flash[ex.flash.length - 1] ?? ex.cursor) : ex.cursor
755 const at = Math.max(0, rows.findIndex(r => r.node.id === focusId))
756 const isLit = (id: string) => brightSet.has(id) || dimSet.has(id)
757 const lit = follow && work ? rows.findIndex(r => isLit(r.node.id)) : -1
758 const fits = lit >= 0 && at - lit < room - 2
759 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))
760 const inView = lit >= base && at < base + room
761 const flashing = lit >= 0 && !inView
762 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))
763 let from = flashing ? nearBottom(room) : base
764 const cap = Math.max(1, Math.floor(room / 3))
765 const chain = new Set(flashing ? ancestors(nodes, focusId) : [])
766 const keep = (id: string) => chain.has(id) || isLit(id)
767 let pinned = flashing ? rows.slice(0, from).filter(r => keep(r.node.id)).slice(-cap) : []
768 if (pinned.length) {
769 from = nearBottom(Math.max(3, room - pinned.length - 1))
770 pinned = rows.slice(0, from).filter(r => keep(r.node.id)).slice(-cap)
771 }
772 const max = Math.max(0, rows.length - room)
773 const held = ex.scroll[e.surface]
774 if (held !== undefined) {
775 from = Math.max(0, Math.min(held, max))
776 pinned = []
777 }
778 views[e.surface] = { from, max, rows: e.props.scroll?.bodyRows ?? 40, room }
779 const shown = rows.slice(from, from + room - pinned.length)
780 const sel = nodes.find(n => n.id === ex.selected)
781 const clip = (s: string, max = width) => (s.length > max ? s.slice(0, max - 1) + '…' : s)
782 const spin = (tone: string): Seg => ({ t: ' ', spin: true, b: true, c: TONES[tone]?.bright[1] ?? SHIMMER[1] })
783 const rowSpec = (r: (typeof rows)[number]): RowSpec => {
784 const n = r.node
785 const g = glyph(n, tier)
786 const arrow = r.leaf ? ' ' : tier === 'plain' ? (r.open ? '▾ ' : '▸ ') : r.open ? '\u{f47c} ' : '\u{f460} '
787 const note = n.note ? ` ${n.kind === 'visual' ? visualLabel(n.note) : n.note}` : ''
788 const cols = Math.max(4, width - r.depth * 2 - 6 - note.length)
789 const isBright = brightSet.has(n.id)
790 const isDim = !isBright && dimSet.has(n.id)
791 const name = clip(n.name, cols)
792 const faded = n.hidden
793 const left: Seg[] = [
794 { t: changed.has(n.id) ? '*' : ' ', c: '#e5c07b' },
795 { t: ' '.repeat(r.depth) },
796 { t: arrow, c: '#7a7a86' },
797 isBright || isDim ? { t: g.char + ' ', sh: tone, dim: isDim, one: true } : { t: g.char + ' ', c: faded ? '#6e6e7a' : g.color },
798 isBright || isDim ? { t: name, sh: tone, dim: isDim, b: isBright } : { t: name, c: faded ? '#6e6e7a' : g.label, b: n.id === ex.selected },
799 ]
800 return { id: n.id, left: clean(left), right: note ? [{ t: note, c: '#6e6e7a' }] : [] }
801 }
802 const note = (text: string): RowSpec => ({ id: '', left: [{ t: text, c: '#6e6e7a' }], right: [] })
803 const specs: RowSpec[] = [...pinned.map(rowSpec), ...(pinned.length > 0 ? [note(' ⋮')] : []), ...shown.map(rowSpec)]
804 const barSize = Math.max(1, Math.round((specs.length * shown.length) / Math.max(1, rows.length)))
805 const bar =
806 rows.length > shown.length + pinned.length
807 ? { pos: max ? Math.round((from / max) * (specs.length - barSize)) : 0, size: barSize, thumb: '#5b9bd5', track: '#4a4a56' }
808 : undefined
809 const icon = (nerd: string, fallback: string) => (tier === 'plain' ? fallback : nerd)
810 const rootNode = nodes.find(n => n.id === rootId)
811 const head: RowSpec = {
812 id: '',
813 left: clean([
814 { t: `${titleGlyph(tier)} `, c: TITLE_COLOR },
815 { t: rootNode ? rootNode.name : ex.target ? targetLabel(ex.target) : TITLE, b: true, ...(work ? { sh: tone } : {}) },
816 ...(ex.status ? [{ t: ` ${ex.status}`, c: '#6e6e7a' }] : []),
817 ...(ex.changed.length ? [{ t: ` ${ex.changed.length} changed`, c: '#e5c07b' }] : []),
818 ...(work?.running ? [spin(tone)] : []),
819 ]),
820 right: [],
821 }
822 const spinner = tier === 'plain' ? PLAIN_SPINNER : []
823 return (
824 <Box flexDirection="column" minHeight={Math.max(1, e.props.scroll?.bodyRows ?? 1)}>
825 <Box flexDirection="row">
826 <Box flexGrow={1} flexShrink={1}>
827 <Client key="head" module="./rows.tsx" props={{ rows: [head], active: '', activeBg: '', hoverBg: '', tones: TONES, spinner } satisfies RowsProps} />
828 </Box>
829 <Box flexDirection="row" gap={2}>
830 {rootNode && <Button key="up" plain dimColor label={icon('\u{f005d}', '↑')} onPress={() => quiet(goUp($))} />}
831 {rootNode && <Button key="home" plain dimColor label={icon('\u{f02dc}', '⌂')} onPress={() => quiet(goHome($))} />}
832 <Button key="refresh" plain dimColor label={icon('\u{f0450}', '↻')} onPress={() => quiet(refresh($))} />
833 <Button key="collapse" plain dimColor label={icon('\u{eac5}', '⊟')} onPress={() => quiet(patch($, () => ({ expanded: [] })))} />
834 {sel && <Button key="clear" plain label={icon('\u{f0156}', '✕')} onPress={() => quiet(patch($, () => ({ selected: '', detail: [] })))} />}
835 <Text> </Text>
836 </Box>
837 </Box>
838 <Box flexDirection="row">
839 <Box flexGrow={1}>
840 {work && !ex.query ? (
841 <Client key="working" module="./rows.tsx" props={{ rows: [{ id: '', left: [{ t: 'Claude is working in Power BI...', sh: tone }], right: [] }], active: '', activeBg: '', hoverBg: '', tones: TONES, spinner } satisfies RowsProps} />
842 ) : (
843 <Input
844 key="q"
845 label="/ "
846 placeholder="search"
847 submitLabel="jump"
848 autoFocus
849 value={ex.query}
850 onInput={(v: string) => quiet(patch($, () => ({ query: v })))}
851 onSubmit={(v: string) => quiet(patch($, cur => jumpTo(cur, nodes, v)))}
852 />
853 )}
854 </Box>
855 {ex.query ? <Button key="clearq" plain dimColor label={tier === 'plain' ? '×' : '\u{f0156}'} onPress={() => quiet(patch($, () => ({ query: '' })))} /> : null}
856 </Box>
857 {nodes.length === 0 && <Text dimColor>{ex.target ? 'nothing loaded yet' : HINT}</Text>}
858 <Client key="rows" module="./rows.tsx" props={{ rows: specs, active: ex.cursor, activeBg: '#3e4451', hoverBg: '#2d2f33', tones: TONES, spinner, ...(bar ? { bar } : {}) } satisfies RowsProps} />
859 {ex.detail.length > 0 && (
860 <Box flexDirection="column" marginTop={1}>
861 {ex.detail.slice(0, DETAIL_ROWS).map((l, i) => (
862 <Text key={String(i)} dimColor={i > 0} bold={i === 0} wrap="truncate-end">
863 {l.replace(/\s+/g, ' ')}
864 </Text>
865 ))}
866 {sel && (
867 <Box flexDirection="row" gap={2}>
868 <Button key="open" plain label={`${icon('\u{f0379}', '↗')} open`} onPress={() => quiet(openLocal($, ex, sel))} />
869 {webUrl(ex, sel) && <Button key="web" plain label={`${icon('\u{f059f}', '◎')} open in Fabric`} onPress={() => quiet(openWeb($, ex, sel))} />}
870 </Box>
871 )}
872 </Box>
873 )}
874 <Box flexGrow={1} />
875 {sel && (
876 <Text dimColor wrap="truncate-start">
877 selected: {sel.path || sel.name}
878 </Text>
879 )}
880 </Box>
881 )
882 })
883}
884hooks/open.ts 41 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
41hooks/icons.ts 181 lines1import type { TreeNode } from '../types'
2import { FAB_ITEMS, PBIR_CHILDREN, PBIR_VISUALS } 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 CONTAINER: Icon = { fabric: 0xf2609, nerd: '\u{f024b}', rgb: [197, 197, 197] }
10
11const REPORT_NERD: Record<string, string> = {
12 page: '\u{f0214}',
13 visual: '\u{f154e}',
14 reportfilter: '\u{f0232}',
15 pagefilter: '\u{f0232}',
16 visualfilter: '\u{f0232}',
17 bookmark: '\u{f00c0}',
18 theme: '\u{f03d8}',
19 'data role': '\u{f0d7e}',
20 field: '\u{f0835}',
21 'ext measure': '\u{f00ec}',
22}
23
24const VISUAL_NERD: Record<string, string> = {
25 accessibleTable: '\u{f1094}',
26 actionButton: '\u{f12a8}',
27 advancedSlicerVisual: '\u{f0570}',
28 aiNarratives: '\u{f0674}',
29 animatedNumber: '\u{f0199}',
30 areaChart: '\u{f0e91}',
31 azureMap: '\u{f0352}',
32 barChart: '\u{f066c}',
33 basicShape: '\u{f14fb}',
34 bookmarkNavigator: '\u{f0e15}',
35 card: '\u{f03a0}',
36 cardVisual: '\u{f0b77}',
37 clusteredBarChart: '\u{f066e}',
38 clusteredColumnChart: '\u{f0128}',
39 columnChart: '\u{f076a}',
40 dataQueryVisual: '\u{f0866}',
41 debugVisual: '\u{f00e4}',
42 decompositionTreeVisual: '\u{f04aa}',
43 donutChart: '\u{f07af}',
44 filledMap: '\u{f01e7}',
45 filterSlicer: '\u{f1aa3}',
46 funnel: '\u{f0236}',
47 gauge: '\u{f029a}',
48 group: '\u{f02c3}',
49 groupVisual: '\u{f02c3}',
50 heatMap: '\u{f066d}',
51 hundredPercentStackedAreaChart: '\u{f076b}',
52 hundredPercentStackedBarChart: '\u{f0729}',
53 hundredPercentStackedColumnChart: '\u{f056d}',
54 image: '\u{f02e9}',
55 keyDriversVisual: '\u{f06e8}',
56 keyInfluencers: '\u{f06e8}',
57 kpi: '\u{f0535}',
58 lineChart: '\u{f012a}',
59 lineClusteredColumnComboChart: '\u{f081f}',
60 lineStackedColumnComboChart: '\u{f1213}',
61 listSlicer: '\u{f096a}',
62 map: '\u{f034d}',
63 matrix: '\u{f121d}',
64 multiRowCard: '\u{f0ab7}',
65 pageNavigator: '\u{f0bb0}',
66 pieChart: '\u{f012b}',
67 pivotTable: '\u{f183c}',
68 pythonVisual: '\u{f0320}',
69 qnaVisual: '\u{f0817}',
70 rdlVisual: '\u{f0219}',
71 ribbonChart: '\u{f11df}',
72 scatterChart: '\u{f0e92}',
73 scorecard: '\u{f08c9}',
74 scriptVisual: '\u{f07d4}',
75 shape: '\u{f0831}',
76 shapeMap: '\u{f0560}',
77 slicer: '\u{f10e5}',
78 stackedAreaChart: '\u{f0127}',
79 stackedBarChart: '\u{f066c}',
80 stackedColumnChart: '\u{f076a}',
81 table: '\u{f04f1}',
82 tableEx: '\u{f04eb}',
83 textSlicer: '\u{f0eae}',
84 textbox: '\u{f0d11}',
85 treemap: '\u{f0e94}',
86 waterfallChart: '\u{f1918}',
87}
88
89const VISUAL_PLAIN: Record<string, string> = {
90 accessibleTable: '▧', actionButton: '►', advancedSlicerVisual: '⧉', aiNarratives: '✦', animatedNumber: '№', areaChart: '◢', azureMap: '♁',
91 barChart: '▤', basicShape: '○', bookmarkNavigator: '⚐', card: '#', cardVisual: '▭', clusteredBarChart: '≡', clusteredColumnChart: '▆',
92 columnChart: '▥', dataQueryVisual: '⊜', debugVisual: '✗', decompositionTreeVisual: '⊢', donutChart: '◎', filledMap: '◍', filterSlicer: '⧨',
93 funnel: '▽', gauge: '◠', group: '❐', groupVisual: '❐', heatMap: '▒', hundredPercentStackedAreaChart: '◩', hundredPercentStackedBarChart: '▬',
94 hundredPercentStackedColumnChart: '█', image: '⊡', keyDriversVisual: '☼', keyInfluencers: '☼', kpi: '⬈', lineChart: '∿',
95 lineClusteredColumnComboChart: '╫', lineStackedColumnComboChart: '╪', listSlicer: '⁝', map: '⌖', matrix: '⧆', multiRowCard: '⊟', pageNavigator: '⇥',
96 pieChart: '◔', pivotTable: '⊠', pythonVisual: 'π', qnaVisual: '?', rdlVisual: '⎙', ribbonChart: '≋', scatterChart: '∴', scorecard: '◉',
97 scriptVisual: 'ℛ', shape: '◭', shapeMap: '⬠', slicer: '☐', stackedAreaChart: '◿', stackedBarChart: '▤', stackedColumnChart: '▥', table: '▩',
98 tableEx: '▦', textSlicer: '⌕', textbox: '¶', treemap: '⊞', waterfallChart: '▟',
99}
100
101const ITEM_NERD: Record<string, string> = {
102 SemanticModel: '\u{f01bc}',
103 Report: '\u{f0219}',
104}
105
106const PLAIN: Record<string, string> = {
107 report: '▣', 'semantic model': '◆', group: '■', page: '□', visual: '▢', reportfilter: '▿', pagefilter: '▿',
108 visualfilter: '▿', bookmark: '⚑', theme: '◐', 'data role': '◫', field: '│', 'ext measure': 'Σ',
109}
110
111export type Glyph = { char: string; color: string | undefined; label?: string }
112
113function hex(rgb: Rgb | null): string | undefined {
114 return rgb ? '#' + rgb.map(v => v.toString(16).padStart(2, '0')).join('') : undefined
115}
116
117function pick(icon: Icon, tier: Tier, plain: string): Glyph {
118 if (tier === 'plain') return { char: plain, color: hex(icon.rgb) }
119 return { char: tier === 'fabric' && icon.fabric ? String.fromCodePoint(icon.fabric) : icon.nerd, color: hex(icon.rgb) }
120}
121
122function hls(rgb: Rgb): [number, number, number] {
123 const [r, g, b] = rgb.map(v => v / 255) as Rgb
124 const max = Math.max(r, g, b)
125 const min = Math.min(r, g, b)
126 const l = (max + min) / 2
127 const range = max - min
128 if (range < 1e-6) return [0, l, 0]
129 const s = l <= 0.5 ? range / (max + min) : range / (2 - max - min)
130 const rc = (max - r) / range
131 const gc = (max - g) / range
132 const bc = (max - b) / range
133 const h = r === max ? bc - gc : g === max ? 2 + rc - bc : 4 + gc - rc
134 return [(((h / 6) % 1) + 1) % 1, l, s]
135}
136
137function component(m1: number, m2: number, hue: number): number {
138 const h = ((hue % 1) + 1) % 1
139 if (h < 1 / 6) return m1 + (m2 - m1) * h * 6
140 if (h < 0.5) return m2
141 if (h < 2 / 3) return m1 + (m2 - m1) * (2 / 3 - h) * 6
142 return m1
143}
144
145function lift(rgb: Rgb): Rgb {
146 const [h, l0, s] = hls(rgb)
147 const l = Math.max(l0, 0.62)
148 if (s < 1e-6) return [Math.round(l * 255), Math.round(l * 255), Math.round(l * 255)]
149 const m2 = l <= 0.5 ? l * (1 + s) : l + s - l * s
150 const m1 = 2 * l - m2
151 return [h + 1 / 3, h, h - 1 / 3].map(x => Math.round(component(m1, m2, x) * 255)) as Rgb
152}
153
154function item(type: string): Icon {
155 const hit = FAB_ITEMS[type]
156 return { fabric: hit?.[0] ?? 0, nerd: ITEM_NERD[type] ?? '\u{f0214}', rgb: hit?.[1] ? lift(hit[1]) : null }
157}
158
159const CUSTOM_VISUAL = /^(.+?)[0-9A-F]{32}$/i
160
161export function visualLabel(type: string): string {
162 const custom = type.match(CUSTOM_VISUAL)?.[1]
163 return custom ? `${custom[0]?.toUpperCase()}${custom.slice(1)}, custom visual` : type
164}
165
166export function glyph(n: TreeNode, tier: Tier): Glyph {
167 const type = n.kind === 'visual' ? n.note || n.name : ''
168 if (n.kind === 'visual' && CUSTOM_VISUAL.test(type)) return pick({ fabric: 0, nerd: '\u{f0431}', rgb: GLYPH_DIM }, tier, '⧈')
169 const plain = VISUAL_PLAIN[type] ?? PLAIN[n.kind] ?? '■'
170 if (n.kind === 'report') return pick(item('Report'), tier, plain)
171 if (n.kind === 'semantic model') {
172 const g = pick(item('SemanticModel'), tier, plain)
173 return { ...g, label: g.color }
174 }
175 if (REPORT_NERD[n.kind]) {
176 const fabric = n.kind === 'visual' ? (PBIR_VISUALS[type] ?? PBIR_CHILDREN.visual) : PBIR_CHILDREN[n.kind]
177 return pick({ fabric: fabric ?? 0, nerd: VISUAL_NERD[type] ?? REPORT_NERD[n.kind] ?? CONTAINER.nerd, rgb: GLYPH_DIM }, tier, plain)
178 }
179 return pick(CONTAINER, tier, plain)
180}
181hooks/rows.tsx 144 lines1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6 rows: RowSpec[]
7 active: string
8 activeBg: string
9 hoverBg: string
10 tones: Record<string, { bright: string[]; dim: string[] }>
11 spinner?: string[]
12 pointer?: boolean
13 bar?: { pos: number; size: number; thumb: string; track: string }
14}
15type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
16
17const TICK_MS = 90
18const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20function shimmer(i: number, phase: number, len: number, palette: string[]): string {
21 const band = ((phase * 1.6) % (len + 8)) - 4
22 const d = Math.abs(i - band)
23 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
24}
25
26const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
27 const { Box, Text } = surface.elements
28 let state = surface.state
29 if (state === undefined) {
30 state = { hover: -1, phase: 0, drag: false, ref: {} }
31 surface.setState(state)
32 }
33 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
34 if (lit && !state.ref.stop) {
35 state.ref.stop = surface.every(TICK_MS, () => {
36 const cur = surface.state
37 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
38 })
39 } else if (!lit && state.ref.stop) {
40 state.ref.stop()
41 state.ref.stop = undefined
42 }
43 if (props.pointer === false) {
44 state.ref.unpoint?.()
45 state.ref.unpoint = undefined
46 } else state.ref.unpoint = surface.onPointer(e => {
47 const cur = surface.state ?? state
48 const span = Math.max(1, props.rows.length - 1)
49 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
50 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
51 if (e.type === 'up' || e.type === 'leave') {
52 surface.setState({ ...cur, drag: false })
53 return
54 }
55 if (e.type === 'down' || e.type === 'move') {
56 if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
57 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
58 return
59 }
60 }
61 if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
62 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
63 return
64 }
65 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
66 const row = props.rows[e.y]
67 if (e.type === 'down' && e.button === 'right' && row?.id) {
68 surface.post({ copy: row.id, shift: Boolean(e.shift) })
69 return
70 }
71 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
72 let x = 0
73 for (const seg of row.left) {
74 const w = [...seg.t].length
75 if (seg.tab && e.x >= x && e.x < x + w) {
76 surface.post({ tab: seg.tab })
77 return
78 }
79 x += w
80 }
81 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
82 })
83 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
84 const frames = props.spinner?.length ? props.spinner : FRAMES
85 const draw = (s: Seg, k: number) => {
86 if (s.spin) {
87 return (
88 <Text key={String(k)} color={s.c} bold={s.b}>
89 {s.t + (frames[state.phase % frames.length] ?? '')}
90 </Text>
91 )
92 }
93 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
94 if (!palette) {
95 return (
96 <Text key={String(k)} color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
97 {s.t}
98 </Text>
99 )
100 }
101 if (s.one) {
102 return (
103 <Text key={String(k)} color={shimmer(-1, state.phase, s.t.length, palette)}>
104 {s.t}
105 </Text>
106 )
107 }
108 const chars = [...s.t]
109 return (
110 <Text key={String(k)} bold={s.b}>
111 {chars.map((ch, i) => (
112 <Text key={String(i)} color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
113 ))}
114 </Text>
115 )
116 }
117 return (
118 <Box flexDirection="column">
119 {props.rows.map((r, i) => (
120 <Box
121 key={r.id || `row-${i}`}
122 flexDirection="row"
123 height={1}
124 overflow="hidden"
125 backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
126 >
127 <Box flexShrink={1} overflow="hidden">
128 {r.left.map(draw)}
129 </Box>
130 <Box flexGrow={1} />
131 {r.right.map(draw)}
132 {props.bar && (
133 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
134 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
135 </Text>
136 )}
137 </Box>
138 ))}
139 </Box>
140 )
141}
142
143export default Rows
144hooks/tree.ts 90 lines1import type { Explorer, TreeNode } from '../types'
2
3export type Row = { node: TreeNode; depth: number; open: boolean; leaf: boolean }
4
5export function empty(): Explorer {
6 return {
7 target: null,
8 changed: [],
9 expanded: [],
10 query: '',
11 cursor: '',
12 selected: '',
13 detail: [],
14 status: '',
15 flash: [],
16 flashDim: [],
17 work: null,
18 targetUrl: '',
19 scroll: {},
20 root: '',
21 }
22}
23
24export function diff(before: TreeNode[], after: TreeNode[]): string[] {
25 if (before.length === 0) return []
26 const old = new Map(before.map(n => [n.id, n.sig + '|' + n.hidden]))
27 const out = new Set(after.filter(n => old.get(n.id) !== n.sig + '|' + n.hidden).map(n => n.id))
28 const was = new Map(before.map(n => [n.id, n]))
29 const now = new Map(after.map(n => [n.id, n]))
30 for (const n of before) {
31 if (now.has(n.id)) continue
32 let up = was.get(n.parent)
33 while (up && !now.has(up.id)) up = was.get(up.parent)
34 if (up) out.add(up.id)
35 }
36 return [...out]
37}
38
39function childrenOf(nodes: TreeNode[]): Map<string, TreeNode[]> {
40 const kids = new Map<string, TreeNode[]>()
41 for (const n of nodes) {
42 const list = kids.get(n.parent)
43 if (list) list.push(n)
44 else kids.set(n.parent, [n])
45 }
46 return kids
47}
48
49export function ancestors(nodes: TreeNode[], id: string, byId?: Map<string, TreeNode>): string[] {
50 const map = byId ?? new Map(nodes.map(n => [n.id, n]))
51 const out: string[] = []
52 let cur = map.get(id)
53 while (cur && cur.parent) {
54 out.push(cur.parent)
55 cur = map.get(cur.parent)
56 }
57 return out
58}
59
60export function visible(ex: Explorer, nodes: TreeNode[], root = ''): Row[] {
61 const kids = childrenOf(nodes)
62 const q = ex.query.trim().toLowerCase()
63 let keep: Set<string> | null = null
64 if (q) {
65 keep = new Set()
66 const byId = new Map(nodes.map(n => [n.id, n]))
67 for (const n of nodes) {
68 if (!n.name.toLowerCase().includes(q) && !n.note.toLowerCase().includes(q)) continue
69 let cur: TreeNode | undefined = n
70 while (cur && !keep.has(cur.id)) {
71 keep.add(cur.id)
72 cur = byId.get(cur.parent)
73 }
74 }
75 }
76 const open = new Set(ex.expanded)
77 const rows: Row[] = []
78 const walk = (parent: string, depth: number) => {
79 for (const n of kids.get(parent) ?? []) {
80 if (keep && !keep.has(n.id)) continue
81 const leaf = !kids.has(n.id)
82 const isOpen = !leaf && (keep ? true : open.has(n.id))
83 rows.push({ node: n, depth, open: isOpen, leaf })
84 if (isOpen) walk(n.id, depth + 1)
85 }
86 }
87 walk(root, 0)
88 return rows
89}
90hooks/fabric.ts 17 lines1const PORTAL = 'https://app.powerbi.com'
2const GUID = /^[0-9a-f-]{36}$/i
3
4export function workspaceUrl(wsId: string): string {
5 return GUID.test(wsId) ? `${PORTAL}/groups/${wsId}` : ''
6}
7
8export function reportUrl(wsId: string, reportId: string): string {
9 const ws = workspaceUrl(wsId)
10 return ws && GUID.test(reportId) ? `${ws}/reports/${reportId}` : ws
11}
12
13export function idOf(stdout: string): string {
14 const m = stdout.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)
15 return m?.[0] ?? ''
16}
17hooks/parse.ts 214 lines1import type { Target } from '../types'
2
3export type Invocation = { bin: string; args: string[]; cwd: string; maybe: boolean }
4
5const SEPARATORS = new Set(['&&', '||', ';', '|', '&', '\n'])
6const REDIRECT = /^(\d*>>?|\d*<|\d*>&\d*|&>>?)$/
7
8let drives = false
9let home = ''
10
11export function useDrives(on: boolean): void {
12 drives = on
13}
14
15export function useHome(path: string): void {
16 home = path.replace(/\/+$/, '')
17}
18
19export function posix(path: string): string {
20 if (!drives) return path
21 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
22}
23
24export function isAbsolute(path: string): boolean {
25 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
26}
27
28export function tokenize(command: string): string[] {
29 const out: string[] = []
30 let cur = ''
31 let quote = ''
32 let has = false
33 for (let i = 0; i < command.length; i++) {
34 const ch = command[i] ?? ''
35 if (!quote && ch === '#' && !has && !cur) {
36 while (i + 1 < command.length && command[i + 1] !== '\n') i++
37 continue
38 }
39 if (quote) {
40 if (ch === quote) quote = ''
41 else if (ch === '\\' && quote === '"' && /[$`"\\\n]/.test(command[i + 1] ?? '')) cur += (command[++i] ?? '').replace('\n', '')
42 else cur += ch
43 continue
44 }
45 if (ch === '"' || ch === "'") {
46 quote = ch
47 has = true
48 } else if (ch === '\\' && i + 1 < command.length) {
49 cur += command[++i] ?? ''
50 has = true
51 } else if (ch === '\n') {
52 if (has || cur) out.push(cur)
53 cur = ''
54 has = false
55 out.push('\n')
56 } else if (/\s/.test(ch)) {
57 if (has || cur) out.push(cur)
58 cur = ''
59 has = false
60 } else if (ch === '>' || ch === '<') {
61 let op = /^\d+$/.test(cur) || (cur === '&' && !has) ? cur : ''
62 if (!op && (has || cur)) out.push(cur)
63 op += ch
64 while (command[i + 1] === '>' || command[i + 1] === '&' || /\d/.test(command[i + 1] ?? '')) {
65 if (command[i + 1] === '&' && op.endsWith('&')) break
66 op += command[++i]
67 }
68 out.push(op)
69 cur = ''
70 has = false
71 } else if (ch === ';' || ch === '|' || ch === '&') {
72 if (ch === '&' && command[i + 1] === '>') {
73 if (has || cur) out.push(cur)
74 cur = '&'
75 has = false
76 continue
77 }
78 if (has || cur) out.push(cur)
79 cur = ''
80 has = false
81 const two = command.slice(i, i + 2)
82 if (two === '&&' || two === '||') {
83 out.push(two)
84 i++
85 } else out.push(ch)
86 } else {
87 cur += ch
88 has = true
89 }
90 }
91 if (has || cur) out.push(cur)
92 if (!quote) return out
93 return command
94 .split(/(&&|\|\||[;|\n])/)
95 .flatMap(part => (/^(&&|\|\||[;|\n])$/.test(part) ? [part] : part.trim().split(/\s+/).filter(Boolean)))
96}
97
98export function join(base: string, raw: string): string {
99 let path = posix(raw)
100 if (path === '~' || path.startsWith('~/')) {
101 if (!home) return path
102 path = home + path.slice(1)
103 }
104 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
105 const parts = isAbsolute(path) ? [drive] : base.split('/')
106 for (const seg of path.slice(drive.length).split('/')) {
107 if (seg === '' || seg === '.') continue
108 if (seg === '..') {
109 if (parts.length > 1) parts.pop()
110 } else parts.push(seg)
111 }
112 return parts.length > 1 ? parts.join('/') : `${parts[0] ?? ''}/`
113}
114
115export function binOf(bin: string, cwd: string, name: string): string {
116 const path = posix(bin)
117 if (!path.includes('/') || /[~$`]/.test(path)) return name
118 if (isAbsolute(path)) return join('/', path)
119 return isAbsolute(cwd) && !/[~$`]/.test(cwd) ? join(cwd, path) : name
120}
121
122export function invocations(command: string, sessionCwd: string): Invocation[] {
123 const toks = tokenize(command)
124 const found: Invocation[] = []
125 let cwd = sessionCwd
126 let start = true
127 let maybe = false
128 let gate = -1
129 for (let i = 0; i < toks.length; i++) {
130 const t = toks[i] ?? ''
131 if (SEPARATORS.has(t)) {
132 if (t === '&&' && gate < 0) gate = found.length
133 if (t === '||' && gate >= 0) for (const inv of found.slice(gate)) inv.maybe = true
134 if (t !== '|' && t !== '&&' && t !== '||') gate = -1
135 if (t !== '|') maybe = t === '||'
136 start = true
137 continue
138 }
139 if (!start) continue
140 start = false
141 let j = i
142 while (j < toks.length && /^[A-Z_][A-Z0-9_]*=/.test(toks[j] ?? '')) j++
143 const bin = toks[j] ?? ''
144 const head = bin.split('/').pop()
145 const dir = toks[j + 1]
146 if (head === 'cd' && dir && !SEPARATORS.has(dir)) {
147 cwd = join(cwd, dir)
148 continue
149 }
150 if (head !== 'pbir') continue
151 const args: string[] = []
152 let k = j + 1
153 while (k < toks.length && !SEPARATORS.has(toks[k] ?? '')) {
154 const tok = toks[k++] ?? ''
155 if (REDIRECT.test(tok)) {
156 if (!/&\d*$/.test(tok) || tok.startsWith('&')) k++
157 continue
158 }
159 args.push(tok)
160 }
161 found.push({ bin, args, cwd, maybe })
162 i = k - 1
163 }
164 return found
165}
166
167function flag(args: string[], names: string[]): string | undefined {
168 for (let i = 0; i < args.length; i++) {
169 const a = args[i] ?? ''
170 for (const n of names) {
171 if (a === n) return args[i + 1]
172 if (a.startsWith(n + '=')) return a.slice(n.length + 1)
173 }
174 }
175 return undefined
176}
177
178function positionals(args: string[], valued: string[]): string[] {
179 const out: string[] = []
180 for (let i = 1; i < args.length; i++) {
181 const a = args[i] ?? ''
182 if (a.startsWith('-')) {
183 if (valued.includes(a)) i++
184 continue
185 }
186 out.push(a)
187 }
188 return out
189}
190
191export function pbirReport(inv: Invocation): string | null {
192 const { args, cwd } = inv
193 if (args[0] === 'connect') {
194 const pos = positionals(args, ['-o', '--output', '-p', '--profile'])
195 const [first, second] = pos
196 if (first && !second) return join(cwd, first.replace(/\/$/, ''))
197 if (first && second) {
198 const ws = first.replace(/\.Workspace$/, '')
199 const rpt = second.replace(/\.Report$/, '')
200 const out = flag(args, ['-o', '--output'])
201 return join(out ? join(cwd, out) : cwd, `${ws}.Workspace/${rpt}.Report`)
202 }
203 }
204 for (const a of args.slice(1)) {
205 const m = a.match(/^(.*?\.Report)(\/|$)/)
206 if (m?.[1]) return join(cwd, m[1])
207 }
208 return null
209}
210
211export function targetLabel(t: Target | null): string {
212 return t ? t.path.split('/').filter(Boolean).slice(-2).join('/') : 'no target'
213}
214hooks/report.ts 193 lines1import type { TreeNode } from '../types'
2
3type Json = Record<string, any>
4
5const READS = 16
6
7export type Io = {
8 text: (path: string) => Promise<string>
9 list: (path: string) => Promise<{ name: string; kind: string }[]>
10 exists: (path: string) => Promise<boolean>
11}
12
13function node(id: string, parent: string, kind: string, name: string, path: string, sig = '', note = ''): TreeNode {
14 return { id, parent, kind, name, path, hidden: false, sig, note }
15}
16
17function reason(err: unknown): string {
18 return err instanceof Error ? err.message : String(err)
19}
20
21function rel(base: string, path: string): string {
22 return path.startsWith(base + '/') ? path.slice(base.length + 1) : path
23}
24
25async function json(io: Io, base: string, path: string): Promise<Json | null> {
26 let text: string
27 try {
28 text = await io.text(path)
29 } catch (err) {
30 if (!(await io.exists(path))) return null
31 throw new Error(`cannot read ${rel(base, path)}: ${reason(err)}`)
32 }
33 try {
34 return JSON.parse(text) as Json
35 } catch (err) {
36 throw new Error(`invalid JSON in ${rel(base, path)}: ${reason(err)}`)
37 }
38}
39
40async function entries(io: Io, base: string, path: string): Promise<{ name: string; kind: string }[]> {
41 try {
42 return await io.list(path)
43 } catch (err) {
44 if (!(await io.exists(path))) return []
45 throw new Error(`cannot list ${rel(base, path)}: ${reason(err)}`)
46 }
47}
48
49async function dirs(io: Io, base: string, path: string): Promise<string[]> {
50 return (await entries(io, base, path)).filter(e => e.kind === 'dir').map(e => e.name)
51}
52
53async function each<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
54 const out: R[] = []
55 let next = 0
56 const work = async () => {
57 while (next < items.length) {
58 const i = next++
59 out[i] = await fn(items[i] as T)
60 }
61 }
62 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, work))
63 return out
64}
65
66function hash(s: string): string {
67 let x = 5381
68 for (let i = 0; i < s.length; i++) x = (x * 33 + s.charCodeAt(i)) >>> 0
69 return x.toString(16)
70}
71
72function fieldName(field: Json | undefined): string {
73 if (!field) return '?'
74 const inner = field.Column ?? field.Measure ?? field.Aggregation?.Expression?.Column ?? field.HierarchyLevel ?? {}
75 const entity = inner.Expression?.SourceRef?.Entity ?? inner.Expression?.Hierarchy?.Expression?.SourceRef?.Entity ?? ''
76 const prop = inner.Property ?? inner.Level ?? ''
77 return entity ? `${entity}.${prop}` : prop || '?'
78}
79
80function filters(list: Json[] | undefined, parent: string, base: string, kind: string): TreeNode[] {
81 if (!list?.length) return []
82 const gid = `${parent}/filters`
83 const out = [node(gid, parent, 'group', `Filters (${list.length})`, '')]
84 for (const f of list) {
85 const name = fieldName(f.field)
86 out.push(node(`${gid}/${f.name}`, gid, kind, name, `${base}/${name}.Filter`, hash(JSON.stringify(f)), f.type ?? ''))
87 }
88 return out
89}
90
91function literal(v: Json | undefined): string {
92 const s = v?.expr?.Literal?.Value
93 return typeof s === 'string' ? s.replace(/^'|'$/g, '') : ''
94}
95
96export async function loadReport(io: Io, dir: string): Promise<TreeNode[]> {
97 const def = `${dir}/definition`
98 const report = await json(io, dir, `${def}/report.json`)
99 if (!report) throw new Error(`not a PBIR report: ${dir}`)
100 const rname = dir.split('/').pop() ?? 'Report'
101 const root = 'R'
102 const out: TreeNode[] = [node(root, '', 'report', rname, rname, hash(JSON.stringify(report)))]
103
104 const pbir = await json(io, dir, `${dir}/definition.pbir`)
105 const ref = pbir?.datasetReference ?? {}
106 const byPath: string = ref.byPath?.path ?? ''
107 const conn: string = ref.byConnection?.connectionString ?? ''
108 const modelName = byPath
109 ? (byPath.split(/[\\/]/).pop() ?? byPath).replace(/\.SemanticModel$/i, '')
110 : (conn.match(/initial catalog=([^;"]+)/i)?.[1] ?? conn.match(/semanticmodelid=([^;"]+)/i)?.[1] ?? ref.byConnection?.pbiModelDatabaseName ?? '')
111 if (modelName) {
112 out.push(node(`${root}/model`, root, 'semantic model', modelName, '', hash(JSON.stringify(ref)), byPath ? 'local model' : 'live connection'))
113 }
114 const theme = report.themeCollection?.customTheme?.name ?? report.themeCollection?.baseTheme?.name
115 if (theme) out.push(node(`${root}/theme`, root, 'theme', theme, `${rname}/${theme}.Theme`, hash(JSON.stringify(report.themeCollection))))
116 out.push(...filters(report.filterConfig?.filters, root, rname, 'reportfilter'))
117
118 const meta = await json(io, dir, `${def}/pages/pages.json`)
119 const folders = await dirs(io, dir, `${def}/pages`)
120 const order: string[] = meta?.pageOrder ?? folders
121 const pagesId = `${root}/pages`
122 out.push(node(pagesId, root, 'group', `Pages (${order.length})`, ''))
123 for (const pf of order) {
124 const page = await json(io, dir, `${def}/pages/${pf}/page.json`)
125 if (!page) continue
126 const pid = `${pagesId}/${pf}`
127 const ppath = `${rname}/${page.displayName ?? pf}.Page`
128 const active = meta?.activePageName === pf ? 'active' : ''
129 const hidden = page.visibility === 'HiddenInViewMode'
130 out.push({ ...node(pid, pagesId, 'page', page.displayName ?? pf, ppath, hash(JSON.stringify(page)), active), hidden })
131 out.push(...filters(page.filterConfig?.filters, pid, ppath, 'pagefilter'))
132 const vdir = `${def}/pages/${pf}/visuals`
133 const vfolders = await dirs(io, dir, vdir)
134 const visuals = await each(vfolders, READS, async vf => ({ vf, v: await json(io, dir, `${vdir}/${vf}/visual.json`) }))
135 const vis: { z: number; n: TreeNode[] }[] = []
136 for (const { vf, v } of visuals) {
137 if (!v) continue
138 const vid = `${pid}/${vf}`
139 const type = v.visual?.visualType ?? (v.visualGroup ? 'group' : 'visual')
140 const title = literal(v.visual?.visualContainerObjects?.title?.[0]?.properties?.text) || v.visualGroup?.displayName || ''
141 const vpath = `${ppath}/${v.name ?? vf}.Visual`
142 const nodes: TreeNode[] = [
143 {
144 ...node(vid, pid, 'visual', title ? `${title}` : type, vpath, hash(JSON.stringify(v)), title ? type : ''),
145 hidden: v.isHidden === true,
146 },
147 ]
148 for (const [role, st] of Object.entries((v.visual?.query?.queryState ?? {}) as Json)) {
149 const rid = `${vid}/${role}`
150 nodes.push(node(rid, vid, 'data role', role, ''))
151 for (const p of (st as Json).projections ?? []) {
152 const ref = p.queryRef ?? fieldName(p.field)
153 nodes.push(node(`${rid}/${ref}`, rid, 'field', p.displayName ?? ref, vpath, '', p.displayName ? ref : ''))
154 }
155 }
156 nodes.push(...filters(v.filterConfig?.filters, vid, vpath, 'visualfilter'))
157 vis.push({ z: v.position?.z ?? 0, n: nodes })
158 }
159 vis.sort((a, b) => b.z - a.z)
160 for (const one of vis) out.push(...one.n)
161 }
162
163 const bdir = `${def}/bookmarks`
164 const files = (await entries(io, dir, bdir)).filter(e => e.name.endsWith('.bookmark.json'))
165 if (files.length) {
166 const bid = `${root}/bookmarks`
167 out.push(node(bid, root, 'group', `Bookmarks (${files.length})`, ''))
168 for (const f of files) {
169 const b = await json(io, dir, `${bdir}/${f.name}`)
170 out.push(node(`${bid}/${f.name}`, bid, 'bookmark', b?.displayName ?? f.name, `${rname}/bookmark:${b?.name ?? f.name.replace(/\.bookmark\.json$/, '')}`, hash(JSON.stringify(b))))
171 }
172 }
173
174 const ext = await json(io, dir, `${def}/reportExtensions.json`)
175 const measures = (ext?.entities ?? []).flatMap((e: Json) =>
176 (e.measures ?? []).map((m: Json) => ({ table: e.name, m })),
177 )
178 if (measures.length) {
179 const xid = `${root}/ext`
180 out.push(node(xid, root, 'group', `Report measures (${measures.length})`, ''))
181 for (const { table, m } of measures)
182 out.push(node(`${xid}/${table}.${m.name}`, xid, 'ext measure', m.name, rname, hash(m.expression ?? ''), table))
183 }
184 return out
185}
186
187export function reportDetail(n: TreeNode): string[] {
188 const lines = [`${n.kind} ${n.name}`]
189 if (n.path) lines.push(`pbir path: ${n.path}`)
190 if (n.note) lines.push(`note: ${n.note}`)
191 return lines
192}
193hooks/touch.ts 54 lines1import type { TreeNode } from '../types'
2import type { Invocation } from './parse'
3
4function glob(pattern: string): RegExp {
5 const esc = pattern
6 .split(/(\*\*|\*|\?)/)
7 .map(p => (p === '**' ? '.*' : p === '*' ? '[^/]*' : p === '?' ? '.' : p.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
8 .join('')
9 return new RegExp(`(^|/)${esc}$`, 'i')
10}
11
12function pbirParts(arg: string): { page?: string; visual?: string; glob: boolean } | null {
13 const trimmed = arg.replace(/(\.(Page|Visual))\.[A-Za-z].*$/, '$1')
14 const segs = trimmed.split('/')
15 const page = segs.find(s => s.endsWith('.Page'))?.replace(/\.Page$/, '')
16 const visual = segs.find(s => s.endsWith('.Visual'))?.replace(/\.Visual$/, '')
17 if (!page && !visual) return null
18 return { page, visual, glob: /[*?]/.test(trimmed) }
19}
20
21function matches(name: string, pattern: string, isGlob: boolean): boolean {
22 if (!isGlob) return name.toLowerCase() === pattern.toLowerCase()
23 return glob(pattern.replace(/^\*\*\/?/, '')).test(name)
24}
25
26export function pbirTouched(inv: Invocation, nodes: TreeNode[]): string[] {
27 const out = new Set<string>()
28 const pages = nodes.filter(n => n.kind === 'page')
29 const visuals = nodes.filter(n => n.kind === 'visual')
30 for (const arg of inv.args.slice(1)) {
31 const p = pbirParts(arg)
32 if (!p) continue
33 const hitPages = p.page
34 ? pages.filter(n => {
35 const folder = n.id.split('/').pop() ?? ''
36 return matches(n.name, p.page ?? '', p.glob) || matches(folder, p.page ?? '', p.glob)
37 })
38 : []
39 if (!p.visual) {
40 for (const n of hitPages) out.add(n.id)
41 continue
42 }
43 for (const v of visuals) {
44 const vname = v.path.split('/').pop()?.replace(/\.Visual$/, '') ?? ''
45 const folder = (v.id.split('/').pop() ?? '').replace(/-Visual$/, '')
46 const type = v.note || v.name
47 const inPage = !p.page || p.page === '**' || hitPages.some(pg => v.parent === pg.id)
48 const named = matches(vname, p.visual, p.glob) || matches(folder, p.visual, p.glob)
49 if (inPage && (named || (p.glob && matches(type, p.visual, true)))) out.add(v.id)
50 }
51 }
52 return [...out]
53}
54hooks/icon-data.ts 6 lines1export const FAB_ITEMS: Record<string, [number, [number, number, number] | null]> = {"SemanticModel": [991234, [90, 64, 156]], "Report": [991274, [194, 132, 0]]}
2
3export const PBIR_VISUALS: Record<string, number> = {"accessibleTable": 993311, "actionButton": 993316, "advancedSlicerVisual": 993309, "aiNarratives": 991875, "animatedNumber": 993305, "areaChart": 993287, "azureMap": 993301, "barChart": 993280, "basicShape": 991978, "bookmarkNavigator": 993318, "card": 992044, "cardVisual": 993303, "clusteredBarChart": 993281, "clusteredColumnChart": 993284, "columnChart": 993283, "dataQueryVisual": 992065, "debugVisual": 993322, "decompositionTreeVisual": 991825, "donutChart": 993297, "filledMap": 993300, "filterSlicer": 991891, "funnel": 993294, "gauge": 993302, "group": 993323, "groupVisual": 993323, "heatMap": 993313, "hundredPercentStackedAreaChart": 993289, "hundredPercentStackedBarChart": 993282, "hundredPercentStackedColumnChart": 993285, "image": 993315, "keyDriversVisual": 993319, "keyInfluencers": 993319, "kpi": 993306, "lineChart": 993286, "lineClusteredColumnComboChart": 993291, "lineStackedColumnComboChart": 993290, "listSlicer": 993307, "map": 993299, "matrix": 992035, "multiRowCard": 993304, "pageNavigator": 993317, "pieChart": 993296, "pivotTable": 993312, "pythonVisual": 992051, "qnaVisual": 993320, "rdlVisual": 991878, "ribbonChart": 993292, "scatterChart": 993295, "scorecard": 992054, "scriptVisual": 993321, "shape": 991979, "shapeMap": 991948, "slicer": 992062, "stackedAreaChart": 993288, "stackedBarChart": 993280, "stackedColumnChart": 993283, "table": 993310, "tableEx": 992023, "textSlicer": 993308, "textbox": 993314, "treemap": 993298, "waterfallChart": 993293}
4
5export const PBIR_CHILDREN: Record<string, number> = {"page": 991989, "visual": 991983, "field": 991803, "reportfilter": 991891, "pagefilter": 992062, "visualfilter": 991898, "bookmark": 991764, "ext measure": 992019, "data role": 991982}
6types/index.d.ts 44 lines1export type Target = { kind: 'local'; path: 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}
13
14export type Explorer = {
15 target: Target | null
16 changed: string[]
17 expanded: string[]
18 query: string
19 cursor: string
20 selected: string
21 detail: string[]
22 status: string
23 flash: string[]
24 flashDim: string[]
25 work: { run: string; tone: string; running: boolean; at: number } | null
26 targetUrl: string
27 scroll: Record<string, number>
28 root: string
29}
30
31export type Tree = {
32 target: Target | null
33 nodes: TreeNode[]
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'reports': {
39 explorer: Explorer
40 tree: Tree
41 }
42 }
43}
44