SLOPSHOPPER

reports

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.

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

Draws

Pane · 󲀪 Report
▣ client module ./rows.tsx 󰑐 / : search ⏎ jump waiting for a pbir command, or /report-pane <path.Report> ▣ client module ./rows.tsx
README

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

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

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

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

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

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


What is agentic development?

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

Installation

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

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

Example: Using the pbir-cli with the skills

Click here for a YouTube walkthrough

pbir-cli demo

Claude Code

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

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

Alternative; add plugins via command line:

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

Copilot CLI

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

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

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

See also the below git config command:

git config --system core.longpaths true

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

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

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

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

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

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

Inside Copilot CLI:

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

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

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

Overview

The repo contains skills, agents, and hooks.

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

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

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

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

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

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

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

TypeNameDescription
Skillsemantic-modelDesign, build, refresh, and review semantic models through a te-first tool cascade
Skillstandardize-naming-conventionsAudit and standardize naming conventions in semantic models
Skillrefresh-semantic-modelTrigger or troubleshoot refreshes
Skilllineage-analysisTrace downstream reports from a semantic model across workspaces
Skillpower-queryWrite M expressions, debug query folding, execute M locally or via Fabric API
SkilldaxWrite, debug, and optimize DAX in semantic models. Contributed by Justin Martin
Agentsemantic-model-auditorAudit semantic models for quality, memory, DAX, and design issues
TypeNameDescription
Skillfabric-cliFabric CLI (fab) for any remote operation in Power BI or Fabric (works fully on Pro, PPU; Fabric not required)
Command/migrating-fabric-trial-capacitiesMigrate workspaces from trial to production capacity
Mod/fabric-paneFabric pane: workspaces and items as a tree that follows fab, with live highlights, copy of fab paths, open in Fabric, and open semantic models in te. Optional arg: a workspace to reveal. Icons: FabricSymbols NF
TypeNameDescription
Skillaudit-tenant-settingsAudit Fabric and Power BI tenant settings, delegated overrides, and Entra security group membership
TypeNameDescription
Mod/databricks-paneDatabricks pane: workspace, Unity Catalog, compute, jobs, pipelines, apps and dashboards as a tree that follows databricks, with live highlights, profile switching (-p or DATABRICKS_CONFIG_PROFILE), copy of CLI arguments, and open in Databricks. Optional arg: a profile. Icons: DatabricksSymbols NF
TypeNameDescription
Mod/fabric-app-paneFabric app pane: every Fabric App (rayfin/rayfin.yml) under the working directory with its data sources, deploy state and files, as a tree that follows rayfin, with live highlights of what Claude deploys or changes. Optional arg: a folder. Icons: FabricSymbols NF
TypeNameDescription
Skill
Source 11 files
hooks/register.tsx 884 lines
1import { 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}
884
hooks/open.ts 41 lines
1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4  'set -f',
5  'f=$1',
6  'if command -v gio >/dev/null; then',
7  '  if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8  '    case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9  '      video/* | audio/*)',
10  '        app=$(xdg-mime query default text/plain)',
11  '        IFS=:',
12  '        for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13  '          [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14  '        done',
15  '        echo "No text/plain desktop application found for $f" >&2',
16  '        exit 1',
17  '        ;;',
18  '    esac',
19  '  fi',
20  '  if (exec gio open "$f"); then exit 0; fi',
21  'fi',
22  'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28  const argv = os === 'linux'
29    ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30    : os === 'darwin'
31      ? ['open', '--', target]
32      : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33  return {
34    argv,
35    init: {
36      timeoutMs: 10_000,
37      ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38    },
39  }
40}
41
hooks/icons.ts 181 lines
1import 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}
181
hooks/rows.tsx 144 lines
1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6  rows: RowSpec[]
7  active: string
8  activeBg: string
9  hoverBg: string
10  tones: Record<string, { bright: string[]; dim: string[] }>
11  spinner?: string[]
12  pointer?: boolean
13  bar?: { pos: number; size: number; thumb: string; track: string }
14}
15type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
16
17const TICK_MS = 90
18const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20function shimmer(i: number, phase: number, len: number, palette: string[]): string {
21  const band = ((phase * 1.6) % (len + 8)) - 4
22  const d = Math.abs(i - band)
23  return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
24}
25
26const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
27  const { Box, Text } = surface.elements
28  let state = surface.state
29  if (state === undefined) {
30    state = { hover: -1, phase: 0, drag: false, ref: {} }
31    surface.setState(state)
32  }
33  const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
34  if (lit && !state.ref.stop) {
35    state.ref.stop = surface.every(TICK_MS, () => {
36      const cur = surface.state
37      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
38    })
39  } else if (!lit && state.ref.stop) {
40    state.ref.stop()
41    state.ref.stop = undefined
42  }
43  if (props.pointer === false) {
44    state.ref.unpoint?.()
45    state.ref.unpoint = undefined
46  } else state.ref.unpoint = surface.onPointer(e => {
47    const cur = surface.state ?? state
48    const span = Math.max(1, props.rows.length - 1)
49    const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
50    if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
51      if (e.type === 'up' || e.type === 'leave') {
52        surface.setState({ ...cur, drag: false })
53        return
54      }
55      if (e.type === 'down' || e.type === 'move') {
56        if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
57        surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
58        return
59      }
60    }
61    if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
62      if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
63      return
64    }
65    if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
66    const row = props.rows[e.y]
67    if (e.type === 'down' && e.button === 'right' && row?.id) {
68      surface.post({ copy: row.id, shift: Boolean(e.shift) })
69      return
70    }
71    if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
72    let x = 0
73    for (const seg of row.left) {
74      const w = [...seg.t].length
75      if (seg.tab && e.x >= x && e.x < x + w) {
76        surface.post({ tab: seg.tab })
77        return
78      }
79      x += w
80    }
81    if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
82  })
83  surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
84  const frames = props.spinner?.length ? props.spinner : FRAMES
85  const draw = (s: Seg, k: number) => {
86    if (s.spin) {
87      return (
88        <Text key={String(k)} color={s.c} bold={s.b}>
89          {s.t + (frames[state.phase % frames.length] ?? '')}
90        </Text>
91      )
92    }
93    const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
94    if (!palette) {
95      return (
96        <Text key={String(k)} color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
97          {s.t}
98        </Text>
99      )
100    }
101    if (s.one) {
102      return (
103        <Text key={String(k)} color={shimmer(-1, state.phase, s.t.length, palette)}>
104          {s.t}
105        </Text>
106      )
107    }
108    const chars = [...s.t]
109    return (
110      <Text key={String(k)} bold={s.b}>
111        {chars.map((ch, i) => (
112          <Text key={String(i)} color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
113        ))}
114      </Text>
115    )
116  }
117  return (
118    <Box flexDirection="column">
119      {props.rows.map((r, i) => (
120        <Box
121          key={r.id || `row-${i}`}
122          flexDirection="row"
123          height={1}
124          overflow="hidden"
125          backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
126        >
127          <Box flexShrink={1} overflow="hidden">
128            {r.left.map(draw)}
129          </Box>
130          <Box flexGrow={1} />
131          {r.right.map(draw)}
132          {props.bar && (
133            <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
134              {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
135            </Text>
136          )}
137        </Box>
138      ))}
139    </Box>
140  )
141}
142
143export default Rows
144
hooks/tree.ts 90 lines
1import type { Explorer, TreeNode } from '../types'
2
3export type Row = { node: TreeNode; depth: number; open: boolean; leaf: boolean }
4
5export function empty(): Explorer {
6  return {
7    target: null,
8    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}
90
hooks/fabric.ts 17 lines
1const 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}
17
hooks/parse.ts 214 lines
1import 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}
214
hooks/report.ts 193 lines
1import 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}
193
hooks/touch.ts 54 lines
1import 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}
54
hooks/icon-data.ts 6 lines
1export 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}
6
types/index.d.ts 44 lines
1export 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