A Fabric data app pane that follows the rayfin CLI: browse every Fabric App under your working directory with its data sources and files, and see what Claude…

<img src="media/bg42-11-4.png" alt="" width="800">
<h1 align="center">power-bi-agentic-development</h1>
The best source for Power BI AI skills and agentic development resources in one marketplace <br></br> <i> Teach AI agents like Claude Code or GitHub Copilot Power BI and Microsoft Fabric </i>
<img src="https://img.shields.io/badge/version-26.40.3-blue" alt="Version"> <img src="https://img.shields.io/badge/Power_BI-F2C811?logo=powerbi&logoColor=000" alt="Power BI"> <img src="https://img.shields.io/badge/Microsoft_Fabric-008272" alt="Microsoft Fabric"> <img src="https://img.shields.io/badge/Tabular_Editor-2E7D32" alt="Tabular Editor"> <img src="https://img.shields.io/badge/license-GPL--3.0-green" alt="License">
[!WARNING] These skills are under active development with a weekly release cadence, so expect regular renaming and restructuring.
Versions 26.26 through 26.38 are a deliberate breaking transition. Skills may be consolidated, renamed, removed, or made less automatic between weekly releases in this range. Pin 26.25 or earlier if you need the pre-transition skill structure and behavior; do not assume compatibility from one transition release to the next.
Here's how you get started in Claude Code; run this in the terminal to get the marketplace:
claude plugin marketplace add data-goblin/power-bi-agentic-development
Click here for a YouTube walkthrough
Add the marketplace, then install plugins via /plugin and navigating to the installed marketplace.
<table> <tr> <td align="center"><img src="media/marketplace-ui.png" alt="Marketplace UI" width="400"></td> <td align="center"><img src="media/marketplace-auto-update.png" alt="Marketplace auto-update" width="400"></td> </tr> <tr> <td align="center"><em>Install plugins from the marketplace</em></td> <td align="center"><em>Enable marketplace auto-update</em></td> </tr> </table>
Alternative; add plugins via command line:
claude plugin install goblin-mode@power-bi-agentic-development
claude plugin install tabular-editor@power-bi-agentic-development
claude plugin install pbi-desktop@power-bi-agentic-development
claude plugin install pbip@power-bi-agentic-development
claude plugin install semantic-models@power-bi-agentic-development
claude plugin install reports@power-bi-agentic-development
claude plugin install paginated-reports@power-bi-agentic-development
claude plugin install custom-visuals@power-bi-agentic-development
claude plugin install fabric-cli@power-bi-agentic-development
claude plugin install fabric-admin@power-bi-agentic-development
claude plugin install databricks-cli@power-bi-agentic-development
claude plugin install fabric-data-app@power-bi-agentic-development
claude plugin install etl@power-bi-agentic-development
The standalone Copilot CLI supports plugin installation from GitHub repos. Copilot CLI reads the same .claude-plugin/marketplace.json manifest this repo uses, so the marketplace and child-plugin layout works without modification.
TMDL files have a problem with repository-relative paths over 260 characters. Windows' legacy MAX_PATH blocks git clone from writing them unless long path support is enabled at both the OS and git level. Without this, copilot plugin install aborts with Filename too long.
Check useful-stuff/agent-scripts/enable-windows-longpaths.ps1 as an example of a script you can run from an elevated ps environment to enable long paths; there are other routes to do this that you can find online, too... just ask Copilot. A reboot is recommended after the registry change. This is a Windows OS limitation, documented at Maximum Path Length Limitation.
See also the below git config command:
git config --system core.longpaths true
This repository is an Anthropic-format plugin marketplace (a set of plugins), not a single distributable plugin, so the root .claude-plugin/ contains only marketplace.json. Two documented install paths work:
1. Register the marketplace once, then install named child plugins. Example:
copilot plugin marketplace add data-goblin/power-bi-agentic-development
copilot plugin install tabular-editor@power-bi-agentic-development
2. Or install a single plugin directly from its subdirectory, no marketplace registration needed. Example:
copilot plugin install data-goblin/power-bi-agentic-development:plugins/pbip
Both forms are documented in the Copilot CLI plugin reference and the plugins how-to. Inside an interactive Copilot session, /plugin install PLUGIN-NAME@MARKETPLACE-NAME is the equivalent of (1). The bare copilot plugin install data-goblin/power-bi-agentic-development (no qualifier) will not install anything useful, because the root is a marketplace catalog, not a plugin.
Inside Copilot CLI:
/env # Loaded instructions, MCP servers, skills, agents, plugins, LSPs, extensions
/plugin list # Installed plugins
/skills list # Available skills
/skills info pbip # Details for a specific skill
/agent # Browse installed agents
skills/<name>/SKILL.md.*.agent.md extension required by Copilot CLI's documented convention. Claude Code matches any *.md file in agents/, so the dual extension works in both tools..mcp.json (plugin root) or .github/mcp.json. The plugins in this repo do not currently ship MCP servers.hooks/hooks.json (with per-hook if path filters). Copilot CLI reads .github/plugin/plugin.json, which points it at hooks/copilot-hooks.json: the same scripts, wired with explicit bash and powershell commands so a Windows host without Git Bash on PATH gets a clean no-op instead of a failed launch. Copilot has no if filter and denies a tool call on any non-zero PreToolUse exit, so every script also re-applies its own path and command filters and exits 0 on anything unrelated. Requires Copilot CLI >= 1.0.26 (2026-04-14) for CLAUDE_PLUGIN_ROOT (changelog); older builds skip the hooks rather than fail. Native Windows bash users may also hit a separate path-format bug tracked upstream at claude-code#11984.~/.copilot/installed-plugins/ and their hooks run in every session on the machine. There is no per-project install, so the "prefer project scope" advice below applies to Claude Code only; in Copilot, install a plugin only while you need it./plugin rm or copilot plugin uninstall can fail with Access is denied. (os error 5). This is an open Copilot CLI bug, not a plugin problem: the installer swaps the plugin folder by renaming it, and Windows refuses the rename while another process holds a handle on it. VS Code's Copilot extension keeps directory watchers on every installed plugin's hooks/, agents/ and skills/ folders, and a second Copilot CLI session does the same. Tracked at github/copilot-cli#4095 and #4151.
%USERPROFILE%\.copilot\installed-plugins\ (or %COPILOT_HOME%\installed-plugins\) and restart Copilot CLI.hooks folder from that same location.The repo contains skills, agents, and hooks.
/skill-name. In Claude Code, skills and commands have coalesced; commands are simply more prescriptive skill workflows.Hook checks can be individually toggled via config files. Set any check to false to disable it:
plugins/pbip/hooks/config.yaml -- PBIR, TMDL, and report binding validationplugins/pbi-desktop/hooks/config.yaml -- DAX references, measure metadata, referential integrity, metadata cache[!WARNING] Don't install every plugin. Each skill competes for the agent's attention and context window, so install a plugin only when you need it and remove it when you don't. Prefer installing plugins scoped to a project rather than to your user, so each project carries only the skills it actually uses.
| Type | Name | Description |
|---|---|---|
| Skill | help-me-get-started | A slow, friendly, jargon-free guide to agentic development for people new to agents; talks through what you want to do, feels out your role and access, teaches the five pillars (model, context, prompt, tools, environment) with local interactive explainers, and checks and installs what you need (Windows/macOS commands). Adapts pace: full tutorial or a fast install run |
| Skill | improve-my-agent-setup | Setup-wide health check with modes (shallow/deep/ultra/yolo): skills, memory, tools, STT, model independence, other clients, harness config, git, permission/isolation, network and autonomous-agent exposure, safety enforcement, secrets and PII hygiene, and workflow habits; then offers to fix what's weak. Absorbs the old /audit-context |
| Type | Name | Description |
|---|---|---|
| Skill | bpa-rules | Create and improve Best Practice Analyzer rules for models |
| Skill | c-sharp-scripting | C# scripting and macros for TE |
| Skill | te-cli | Cross-platform Tabular Editor CLI (te, preview) for semantic models from the terminal |
| Skill | te2-cli | Tabular Editor 2 CLI usage and automation (not TE3) |
| Skill | te-docs | Tabular Editor documentation search, TE3 config files. Uses pbi-search CLI |
| Command | /suggest-rule | Generate BPA rules from descriptions |
| Agent | bpa-expression-helper | Debug and improve BPA rule expressions |
| Type | Name | Description |
|---|---|---|
| Skill | connect-pbid | Explore, query, and modify a model in Power BI Desktop, and reload/screenshot the report canvas via the Desktop Bridge |
| Agent | query-listener | Capture DAX queries from Power BI Desktop visuals in real time |
| Hook | DAX reference validation | Validates table, column, and measure references against the connected model; suggests corrections |
| Hook | Measure metadata enforcement | Blocks adding measures without DisplayFolder, Description, and FormatString |
| Hook | Referential integrity check | Reports unmatched many-side keys after relationship or column changes |
| Hook | Metadata cache refresh | Auto-snapshots model metadata on TOM connect or model modification |
| Hook | Compatibility level check | Reports features available by upgrading; optional auto-upgrade |
| Type | Name | Description |
|---|---|---|
| Skill | pbip | Power BI Project (PBIP) format, structure, and file types |
| Skill | tmdl | Author and edit TMDL files directly |
| Skill | pbir-format | Author and edit PBIR metadata files directly (visual.json, report.json, themes, filters, report extensions, visual calculations) |
| Agent | pbip-validator | Validate PBIP project structure, TMDL syntax, and PBIR schemas |
| Hook | PBIR validation | Validates PBIR structure, required fields, naming conventions, and schema URLs |
| Hook | Report binding validation | Validates semantic model binding (byPath directory exists; byConnection model exists via fab exists) |
| Hook | TMDL validation | Validates TMDL structural syntax |
| Type | Name | Description |
|---|---|---|
| Skill | create-pbi-report | Step-by-step workflow for building a complete report from scratch via the pbir CLI: model discovery, pages, theme, visuals, field binding, filtering, formatting, validation, publishing |
| Skill | pbi-report-design | The Power BI report design canon: design identity, visual hierarchy, layout, color discipline, chart selection, KPI/table design, accessibility. Shared reference used by the other report skills |
| Skill | modifying-theme-json | Design, enforce, audit, and validate report themes through the pbir CLI |
| Skill | review-report | Actionable feedback on report quality, usage, and effectiveness; usage analysis, health checks |
| Skill | pbir-cli | Programmatic report manipulation via the pbir CLI, including live Power BI Desktop refresh and page screenshots |
| Mod | /report-pane | Report pane: pages, visuals, bookmarks, filters and report measures of the .Report folder as a tree that follows pbir, with live highlights of what Claude reads or changes. Optional arg: a path to a .Report folder. Icons: FabricSymbols NF |
| Agent | deneb-reviewer | Review Deneb visual specs for Vega/Vega-Lite syntax and conventions |
| Agent | svg-reviewer | Review SVG DAX measures for syntax and design quality |
| Agent | r-reviewer | Review R visual scripts (ggplot2) for Power BI conventions |
| Agent | python-reviewer | Review Python visual scripts (matplotlib/seaborn) for Power BI conventions |
| Type | Name | Description |
|---|---|---|
| Skill | paginated-report | Author, validate, publish, and test Power BI paginated reports in RDL format (Report Builder, PBIRS/SSRS-compatible); connect to a semantic model, render to PDF/Excel |
| Hook | RDL validation | Validates .rdl structure after Write/Edit and on Bash commands touching .rdl files; blocks on structural errors only |
| Type | Name | Description |
|---|---|---|
| Skill | deneb-visuals | Deneb visual creation, Vega/Vega-Lite spec authoring, Deneb best practices for PBIR reports |
| Skill | r-visuals | Custom R visuals (ggplot2) for Power BI reports |
| Skill | python-visuals | Custom Python visuals (matplotlib/seaborn) for Power BI reports |
| Skill | svg-visuals | SVG generation via DAX measures and extension measures for inline chart visualizations (sparklines, bullet charts, KPI indicators, and more) |
| Skill | powerbi-custom-visuals | Power BI developer custom visual (.pbiviz) development with the pbiviz toolchain and its MCP server: scaffold, build, debug, package, certify, publish to AppSource |
Reviewer agents for these visual types (deneb-reviewer, svg-reviewer, r-reviewer, python-reviewer) ship in the reports plugin above.
| Type | Name | Description |
|---|---|---|
| Skill | semantic-model | Design, build, refresh, and review semantic models through a te-first tool cascade |
| Skill | standardize-naming-conventions | Audit and standardize naming conventions in semantic models |
| Skill | refresh-semantic-model | Trigger or troubleshoot refreshes |
| Skill | lineage-analysis | Trace downstream reports from a semantic model across workspaces |
| Skill | power-query | Write M expressions, debug query folding, execute M locally or via Fabric API |
| Skill | dax | Write, debug, and optimize DAX in semantic models. Contributed by Justin Martin |
| Agent | semantic-model-auditor | Audit semantic models for quality, memory, DAX, and design issues |
| Type | Name | Description |
|---|---|---|
| Skill | fabric-cli | Fabric CLI (fab) for any remote operation in Power BI or Fabric (works fully on Pro, PPU; Fabric not required) |
| Command | /migrating-fabric-trial-capacities | Migrate workspaces from trial to production capacity |
| Mod | /fabric-pane | Fabric pane: workspaces and items as a tree that follows fab, with live highlights, copy of fab paths, open in Fabric, and open semantic models in te. Optional arg: a workspace to reveal. Icons: FabricSymbols NF |
| Type | Name | Description |
|---|---|---|
| Skill | audit-tenant-settings | Audit Fabric and Power BI tenant settings, delegated overrides, and Entra security group membership |
| Type | Name | Description |
|---|---|---|
| Mod | /databricks-pane | Databricks pane: workspace, Unity Catalog, compute, jobs, pipelines, apps and dashboards as a tree that follows databricks, with live highlights, profile switching (-p or DATABRICKS_CONFIG_PROFILE), copy of CLI arguments, and open in Databricks. Optional arg: a profile. Icons: DatabricksSymbols NF |
| Type | Name | Description |
|---|---|---|
| Mod | /fabric-app-pane | Fabric app pane: every Fabric App (rayfin/rayfin.yml) under the working directory with its data sources, deploy state and files, as a tree that follows rayfin, with live highlights of what Claude deploys or changes. Optional arg: a folder. Icons: FabricSymbols NF |
| Type | Name | Description |
|---|---|---|
| Skill |
hooks/register.tsx 1270 lines1import { type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { openCommand } from './open'
3
4import type { AppInfo, FileNode, FileTree, Source, Theme } from '../types'
5import { merge, parseSources } from './apps'
6import { CLIS } from './clis'
7import { glyphFor, type Tier } from './glyphs'
8import type { RowSpec, RowsProps, Seg } from './rows'
9import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR, plainIcon, stronger } from './icons'
10import { type CliCall, cliCalls, deploys, mutates, tokenize } from './parse'
11import {
12 ancestorsOf,
13 type Change,
14 DEFAULT_THEME,
15 dirname,
16 emptyTree,
17 inside,
18 join,
19 mapLimit,
20 parseGit,
21 parseNumstat,
22 rollCounts,
23 rollUp,
24 parseTheme,
25 posix,
26 replaceChildren,
27 resolvePath,
28 stamp,
29 toNodes,
30 underAny,
31 useDrives,
32 visibleRows,
33} from './tree'
34
35const PANE = 'fabric-app-pane'
36const TREE = { plugin: 'fabric-data-app', key: 'tree' } as const
37const APPS = { plugin: 'fabric-data-app', key: 'apps' } as const
38const THEME = { plugin: 'fabric-data-app', key: 'theme' } as const
39const ADD_COLOR = '#98c379'
40const DEL_COLOR = '#e06c75'
41const TITLE = 'Fabric apps'
42const WORKING = 'Claude is working in Fabric...'
43const FONT_HINT = 'Tell user once: Fabric app pane icons need github.com/data-goblin/fabric-nf plus a Nerd Font. Plugin option fontHint=off disables this.'
44const BUSY_MAX_MS = 600_000
45const FLASH_MS = 2700
46const DOUBLE_MS = 450
47const FIND_LIMIT = 200
48const READ_LIMIT = 8
49const SOURCE_INPUTS = ['fabric.yaml', 'rayfin/rayfin.yml', 'rayfin/data/schema.ts']
50const APP_INPUTS = [...new Set([...SOURCE_INPUTS, ...CLIS.flatMap(c => [c.marker, ...c.inputs])])]
51const LIST_LIMIT = 16
52const TONES: Record<string, { bright: string[]; dim: string[] }> = {
53 orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'] },
54 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'] },
55}
56const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
57const APP_COLOR = '#5a91e2'
58const FONT_SCRIPT =
59 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
60 'else f=$(ls "$HOME"/Library/Fonts/*"$2"* /Library/Fonts/*"$2"* 2>/dev/null | head -n1); fi; ' +
61 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
62 '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 ' +
63 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
64 '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; ' +
65 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
66const PRUNE = ['node_modules', '.git', 'dist', 'target', '.venv', '__pycache__', '.playwright']
67const HOME_PRUNE = ['Library', 'AppData', '.Trash']
68
69let blink: Timer | null = null
70let generation = 0
71let workTimer: Timer | null = null
72let workGen = 0
73let epoch = 0
74let lastPress = { key: '', at: 0 }
75let view: { from: number; max: number; ids: string[] } = { from: 0, max: 0, ids: [] }
76let shownApps: AppInfo[] = []
77let gitTops = new Set<string>()
78let gitRun: Promise<void> | null = null
79let gitNext: Promise<void> | null = null
80let closed = false
81let noDock = false
82let detected: Tier = 'nerd'
83let glyphSetting = 'auto'
84let follow = true
85let fontHint = true
86let hinted = false
87let fellBack = false
88let remote = false
89
90function tierFor(surface: string): Tier {
91 if (glyphSetting === 'fabric' || glyphSetting === 'nerd' || glyphSetting === 'plain') return glyphSetting
92 return surface === 'desktop' ? 'plain' : detected
93}
94
95function fontNote(): string {
96 if (!fontHint || hinted || !fellBack) return ''
97 hinted = true
98 return FONT_HINT
99}
100
101function quiet(p: Promise<unknown>): void {
102 void p.catch(() => undefined)
103}
104let scanning: Promise<void> | null = null
105let scanJobs: Job[] = []
106const running = new Map<string, Job>()
107
108type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
109type Job = { since: Since; usedCli: boolean; deploy: boolean; deployDir: string; busy: boolean }
110
111let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
112let markId = 0
113let home = ''
114
115function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
116 platform ??= (async () => {
117 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
118 try {
119 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
120 } catch {
121 return 'linux'
122 }
123 })()
124 return platform
125}
126
127async function openPane($: EngineInterface, args: { id: string; title: string; focus?: true }): Promise<void> {
128 if (noDock && !args.focus) return
129 await $.ui.open(args)
130}
131
132async function cwdOf($: EngineInterface): Promise<string> {
133 return posix(await $.session.cwd())
134}
135
136async function sinceNow($: EngineInterface, watch: boolean): Promise<Since> {
137 const ms = await $.clock.now()
138 if (!watch) return { ms, mark: '', os: 'linux' }
139 const os = await osName($)
140 if (os !== 'darwin') return { ms, mark: '', os }
141 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/${PANE}-${await $.session.id()}-${++markId}.mark`
142 try {
143 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
144 return { ms, mark, os }
145 } catch {
146 return { ms, mark: '', os }
147 }
148}
149
150async function dropMarks($: EngineInterface, marks: string[]): Promise<void> {
151 const live = marks.filter(Boolean)
152 if (live.length) await $.process.run(['rm', '-f', ...live], { timeoutMs: 3_000 }).catch(() => undefined)
153}
154
155async function copyOf($: EngineInterface, id: string, root: string, absolute: boolean, surface?: string): Promise<void> {
156 const text = !absolute && id.startsWith(root + '/') ? id.slice(root.length + 1) : id
157 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
158 $.ui.toast(done.isCopied ? `Copied ${text}` : `Could not copy: ${done.reason}`)
159}
160
161function fit(text: string, cols: number): string {
162 return text.length > cols ? text.slice(0, cols - 1) + '…' : text
163}
164
165function clean(segs: Seg[]): Seg[] {
166 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
167 return segs
168}
169
170async function listNames($: EngineInterface, dir: string): Promise<{ name: string; kind: string }[]> {
171 try {
172 return (await $.fs.list(dir)).map(e => ({ name: e.name, kind: e.kind }))
173 } catch {
174 return []
175 }
176}
177
178async function sourcesOf($: EngineInterface, dir: string): Promise<Source[]> {
179 const fp = join(dir, 'rayfin/functions-python')
180 const udfDefs = await Promise.all(
181 (await listNames($, fp)).filter(e => e.kind === 'dir').map(async e => ({ folder: e.name, text: await readText($, join(fp, `${e.name}/definition.json`)) })),
182 )
183 const tsFunctions = (await listNames($, join(dir, 'rayfin/functions')))
184 .filter(e => e.kind === 'file' && /\.(ts|js)$/.test(e.name) && !/\.d\.ts$/.test(e.name) && e.name !== 'index.ts')
185 .map(e => e.name.replace(/\.(ts|js)$/, ''))
186 return parseSources({
187 fabricYaml: await readText($, join(dir, 'fabric.yaml')),
188 rayfinYml: await readText($, join(dir, 'rayfin/rayfin.yml')),
189 schemaTs: await readText($, join(dir, 'rayfin/data/schema.ts')),
190 udfDefs,
191 tsFunctions,
192 })
193}
194
195async function get($: EngineInterface): Promise<FileTree> {
196 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
197}
198
199async function getApps($: EngineInterface): Promise<AppInfo[]> {
200 return (await $.state.get(APPS)).value ?? []
201}
202
203async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
204 await update($, TREE, value => {
205 const cur = { ...emptyTree(''), ...value }
206 return anchored(cur, fn(cur))
207 })
208}
209
210type Item = { node: FileNode; depth: number; open: boolean; source?: Source; child?: string }
211
212function topOf(t: FileTree): string {
213 return t.top && t.nodes.some(n => n.id === t.top) ? t.top : ''
214}
215
216function itemsOf(t: FileTree, apps: AppInfo[]): Item[] {
217 const byDir = new Map(apps.map(a => [a.dir, a]))
218 const rows: Item[] = []
219 const sources = (app: AppInfo, depth: number) => {
220 const synthetic = (key: string, name: string): FileNode => ({ id: `${app.dir}#${key}`, parent: app.dir, name, kind: 'file', hidden: false, mtime: 0, loaded: false })
221 if (app.error) rows.push({ node: synthetic('error', app.error), depth, open: false, source: { kind: 'error', name: app.error, note: '' } })
222 if (app.sources.length === 0) rows.push({ node: synthetic('none', 'no data sources configured'), depth, open: false, source: { kind: 'none', name: 'no data sources configured', note: '' } })
223 app.sources.forEach((src, i) => {
224 rows.push({ node: synthetic(`s${i}`, src.name), depth, open: false, source: src })
225 for (const child of src.children ?? []) rows.push({ node: synthetic(`s${i}:${child}`, child), depth: depth + 1, open: false, source: src, child })
226 })
227 }
228 const top = topOf(t)
229 const topApp = byDir.get(top)
230 if (topApp) sources(topApp, 0)
231 for (const r of visibleRows(t, top)) {
232 rows.push(r)
233 const app = byDir.get(r.node.id)
234 if (app && r.open) sources(app, r.depth + 1)
235 }
236 return rows
237}
238
239function anchored(cur: FileTree, next: FileTree): FileTree {
240 if (follow || cur.scroll === null || next.scroll !== cur.scroll || (next.nodes === cur.nodes && next.expanded === cur.expanded)) return next
241 const top = itemsOf(cur, shownApps)[cur.scroll]?.node.id
242 const at = top ? itemsOf(next, shownApps).findIndex(r => r.node.id === top) : -1
243 return at < 0 || at === cur.scroll ? next : { ...next, scroll: at }
244}
245
246function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
247 return put($, t => ({ ...t, ...fn(t) }))
248}
249
250function appOf(apps: AppInfo[], path: string): AppInfo | undefined {
251 return apps
252 .filter(a => path === a.dir || path.startsWith(a.dir + '/'))
253 .sort((a, b) => b.dir.length - a.dir.length)[0]
254}
255
256function chainOf(apps: AppInfo[], id: string): string[] {
257 const app = appOf(apps, id)
258 if (!app || id === app.dir) return []
259 const out: string[] = []
260 let dir = dirname(id)
261 while (dir.length >= app.dir.length) {
262 out.push(dir)
263 if (dir === app.dir) break
264 dir = dirname(dir)
265 }
266 return out
267}
268
269async function list($: EngineInterface, dir: string): Promise<FileNode[]> {
270 const entries = await $.fs.list(dir)
271 const resolved = await Promise.all(
272 entries.map(async e => {
273 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, isLink: false }
274 try {
275 const target = await $.fs.stat(join(dir, e.name))
276 return target.kind === 'dir'
277 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, isLink: false }
278 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, isLink: true }
279 } catch {
280 return { name: e.name, kind: 'other' as const, mtimeMs: 0, isLink: true }
281 }
282 }),
283 )
284 return toNodes(dir, resolved)
285}
286
287async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
288 const root = (await get($)).root
289 const listed = new Map<string, FileNode[]>()
290 const failed: string[] = []
291 await mapLimit(dirs, LIST_LIMIT, async dir => {
292 try {
293 listed.set(dir, await list($, dir))
294 } catch (err) {
295 failed.push(`${shortPath(dir)} (${err instanceof Error ? err.message : String(err)})`)
296 }
297 })
298 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed), status: failed.length ? `could not list ${failed.join(', ')}` : '' } : {}))
299 return listed
300}
301
302async function readText($: EngineInterface, path: string): Promise<string> {
303 try {
304 return String(await $.fs.read(path))
305 } catch {
306 return ''
307 }
308}
309
310async function walkMarkers($: EngineInterface, root: string): Promise<string[]> {
311 const hits: string[] = []
312 let level = [root]
313 let seen = 0
314 for (let depth = 0; depth < 6 && level.length && seen < 20_000; depth++) {
315 const next: string[] = []
316 for (const dir of level) {
317 for (const n of await list($, dir).catch(() => [] as FileNode[])) {
318 seen++
319 if (n.kind === 'dir' && !PRUNE.includes(n.name) && !(root === home && HOME_PRUNE.includes(n.name))) next.push(n.id)
320 for (const cli of CLIS) {
321 const [first, ...rest] = cli.marker.split('/')
322 if (n.name !== first) continue
323 if (rest.length === 0 && n.kind !== 'dir') hits.push(n.id)
324 else if (rest.length && n.kind === 'dir' && (await $.fs.stat(join(n.id, rest.join('/'))).then(st => st.kind === 'file', () => false))) hits.push(join(n.id, rest.join('/')))
325 }
326 }
327 }
328 level = next
329 }
330 return hits
331}
332
333function markerOf(path: string): { dir: string; cli: string } | null {
334 for (const cli of CLIS) if (path.endsWith(`/${cli.marker}`)) return { dir: path.slice(0, -(cli.marker.length + 1)) || '/', cli: cli.name }
335 return null
336}
337
338async function discover($: EngineInterface, root: string): Promise<AppInfo[]> {
339 const names = root === home ? [...PRUNE, ...HOME_PRUNE] : PRUNE
340 const prune = names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name]))
341 const markers = CLIS.flatMap((c, i) => [...(i ? ['-o'] : []), ...(c.marker.includes('/') ? ['-path', `*/${c.marker}`] : ['-name', c.marker])])
342 let hits: string[] = []
343 try {
344 if ((await osName($)) === 'win32') hits = await walkMarkers($, root)
345 else {
346 const run = await $.process.run(['find', '-H', root, '-maxdepth', '6', '(', ...prune, ')', '-prune', '-o', '(', ...markers, ')', '-print0'], { timeoutMs: 10_000 })
347 hits = run.stdout.split('\0').filter(Boolean)
348 }
349 } catch {
350 hits = []
351 }
352 const kinds = new Map<string, string[]>()
353 for (const hit of hits) {
354 const found = markerOf(hit)
355 if (!found) continue
356 const names = kinds.get(found.dir) ?? []
357 if (!names.includes(found.cli)) names.push(found.cli)
358 kinds.set(found.dir, names)
359 }
360 const dirs = [...kinds.keys()].sort((a, b) => a.localeCompare(b))
361 return mapLimit(dirs, READ_LIMIT, dir => appAt($, dir, kinds.get(dir) ?? []))
362}
363
364async function appAt($: EngineInterface, dir: string, names: string[]): Promise<AppInfo> {
365 const clis = CLIS.filter(c => names.includes(c.name))
366 const cli = clis.map(c => c.name).join('+')
367 try {
368 const parts = await Promise.all(clis.map(c => c.read(dir, rel => readText($, join(dir, rel)))))
369 const info = parts.reduce((a, b) => merge(a, b))
370 return { ...info, sources: await sourcesOf($, dir) }
371 } catch (err) {
372 const name = dir.split('/').pop() || dir
373 return { dir, name, title: name, cli, workspace: '', item: '', portal: '', hosting: '', sources: [], error: `could not read this app: ${err instanceof Error ? err.message : String(err)}` }
374 }
375}
376
377async function syncApps($: EngineInterface, found?: AppInfo[], at = epoch): Promise<{ added: string[]; changed: string[] }> {
378 const t = await get($)
379 if (!t.root || epoch !== at) return { added: [], changed: [] }
380 const before = await getApps($)
381 const apps = (found ?? (await discover($, t.root))).filter(a => a.dir !== t.root)
382 const now = await get($)
383 if (epoch !== at || now.root !== t.root) return { added: [], changed: [] }
384 await $.state.set(APPS, apps)
385 const old = new Map(before.map(a => [a.dir, JSON.stringify(a)]))
386 const added = apps.filter(a => !old.has(a.dir)).map(a => a.dir)
387 const changed = apps.filter(a => old.has(a.dir) && old.get(a.dir) !== JSON.stringify(a)).map(a => a.dir)
388 await patch($, cur => {
389 if (cur.root !== t.root) return {}
390 const keep = new Set(apps.map(a => a.dir))
391 const appNodes: FileNode[] = apps.map(a => {
392 const prev = cur.nodes.find(n => n.id === a.dir)
393 return { id: a.dir, parent: cur.root, name: a.title, kind: 'dir', hidden: false, mtime: 0, loaded: prev?.loaded ?? false }
394 })
395 const rest = cur.nodes.filter(n => n.parent !== cur.root && !keep.has(n.id) && underAny(n.parent, keep, cur.root))
396 return { nodes: [...appNodes, ...rest] }
397 })
398 return { added, changed }
399}
400
401type GitScan = { dir: string; git: Record<string, string>; ignored: string[]; untrackedDirs: string[]; diff: Record<string, [number, number]>; files: Record<string, Change> }
402
403async function gitAt($: EngineInterface, dir: string): Promise<{ top: string; prefix: string } | null> {
404 const run = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-prefix', '--show-toplevel'], { timeoutMs: 10_000 })
405 if (run.exitCode !== 0) return null
406 const [prefix = '', toplevel = ''] = run.stdout.split('\n')
407 return { top: toplevel.trim(), prefix: prefix.trim() }
408}
409
410async function scanGit($: EngineInterface, dir: string, prefix: string): Promise<GitScan | null> {
411 const git = (args: string[]) => $.process.run(['git', '--no-optional-locks', '-C', dir, ...args], { timeoutMs: 20_000, env: { GIT_OPTIONAL_LOCKS: '0' } })
412 const run = await git(['status', '--porcelain=v1', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
413 if (run.exitCode !== 0) return null
414 const parsed = parseGit(run.stdout, dir, prefix)
415 const diff: Record<string, [number, number]> = {}
416 const head = await git(['diff', 'HEAD', '--numstat', '-z', '--', '.'])
417 if (head.exitCode === 0) parseNumstat(head.stdout, dir, prefix, diff)
418 const files = { ...parsed.files }
419 if (parsed.untrackedDirs.length) {
420 const others = await git(['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(dir.length + 1) || '.')])
421 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(dir, rel)] = 'new'
422 }
423 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
424 return { dir, git: parsed.git, ignored: parsed.ignored, untrackedDirs: parsed.untrackedDirs, diff, files }
425}
426
427async function readGit($: EngineInterface): Promise<void> {
428 const t = await get($)
429 if (!t.root) return
430 const root = t.root
431 try {
432 const apps = await getApps($)
433 const base = await gitAt($, root)
434 const rootScan = base ? await scanGit($, root, base.prefix) : null
435 if (base && !rootScan) return
436 const loose = rootScan ? new Set([...rootScan.untrackedDirs, ...rootScan.ignored]) : null
437 const candidates = apps.filter(a => !loose || loose.has(a.dir)).sort((x, y) => x.dir.length - y.dir.length)
438 const found = await mapLimit(candidates, READ_LIMIT, a => gitAt($, a.dir).catch(() => null))
439 const own: { dir: string; top: string; prefix: string }[] = []
440 candidates.forEach((a, i) => {
441 const g = found[i]
442 if (!g || (base && g.top === base.top) || own.some(o => o.top === g.top && a.dir.startsWith(o.dir + '/'))) return
443 own.push({ dir: a.dir, top: g.top, prefix: g.prefix })
444 })
445 const extra = await mapLimit(own, READ_LIMIT, o => scanGit($, o.dir, o.prefix).catch(() => null))
446 const scans = [...(rootScan ? [rootScan] : []), ...extra.filter((x): x is GitScan => x !== null)]
447 const owned = scans.map(x => x.dir).filter(d => d !== root)
448 gitTops = new Set([...(base ? [base.top] : []), ...own.filter((_, i) => extra[i]).map(o => o.top)])
449 const git: Record<string, string> = {}
450 const ignored: string[] = []
451 const untrackedDirs: string[] = []
452 const diff: Record<string, [number, number]> = {}
453 const files: Record<string, Change> = {}
454 for (const scan of scans) {
455 const deeper = owned.filter(d => d !== scan.dir && d.startsWith(scan.dir === '/' ? '/' : scan.dir + '/'))
456 const keep = (p: string) => !deeper.some(d => p === d || p.startsWith(d + '/'))
457 for (const [p, l] of Object.entries(scan.git)) if (keep(p)) git[p] = stronger(git[p], l)
458 ignored.push(...scan.ignored.filter(keep))
459 untrackedDirs.push(...scan.untrackedDirs.filter(keep))
460 for (const [p, d] of Object.entries(scan.diff)) if (keep(p)) diff[p] = d
461 for (const [p, c] of Object.entries(scan.files)) if (keep(p)) files[p] = c
462 }
463 await patch($, cur => (cur.root === root ? { git, ignored, untrackedDirs, diff: rollUp(diff, root), counts: rollCounts(files, root) } : {}))
464 } catch {
465 gitTops = new Set()
466 }
467}
468
469function refreshGit($: EngineInterface): Promise<void> {
470 if (!gitRun) {
471 gitRun = readGit($).finally(() => {
472 gitRun = null
473 })
474 return gitRun
475 }
476 const rerun = () => {
477 gitNext = null
478 return refreshGit($)
479 }
480 gitNext ??= gitRun.then(rerun, rerun)
481 return gitNext
482}
483
484async function reset($: EngineInterface, start: string, focus = false, found?: AppInfo[]): Promise<void> {
485 const mine = ++epoch
486 const apps = found ?? (await discover($, start))
487 const root = apps.some(a => a.dir === start) ? dirname(start) : start
488 const keepHidden = (await get($)).showHidden
489 if (epoch !== mine) return
490 generation += 1
491 blink?.cancel()
492 blink = null
493 await put($, () => ({ ...emptyTree(root), showHidden: keepHidden }))
494 await $.state.set(APPS, [])
495 const title = titleOf(root)
496 closed = false
497 if (focus) await openPane($, { id: PANE, title, focus: true })
498 else await openPane($, { id: PANE, title })
499 await syncApps($, apps, mine)
500 await refreshGit($)
501}
502
503function titleOf(root: string): string {
504 return `${TITLE}: ${root.split('/').pop() || root}`
505}
506
507function isLoaded(t: FileTree, dir: string): boolean {
508 return Boolean(t.nodes.find(n => n.id === dir)?.loaded)
509}
510
511async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
512 const apps = await getApps($)
513 for (let pass = 0; pass < 32; pass++) {
514 const t = await get($)
515 const need = new Set<string>()
516 for (const p of paths) {
517 for (const dir of chainOf(apps, p).reverse()) {
518 if (!isLoaded(t, dir)) {
519 need.add(dir)
520 break
521 }
522 }
523 }
524 if (need.size === 0) return
525 await loadDirs($, [...need])
526 }
527}
528
529async function changedSince($: EngineInterface, dirs: string[], since: Since): Promise<string[]> {
530 if (dirs.length === 0 || since.os === 'win32') return []
531 const prune = PRUNE.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name]))
532 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
533 try {
534 const run = await $.process.run(['find', '-H', ...dirs, '-xdev', '(', ...prune, ')', '-prune', '-o', '-type', 'f', ...test, '-print0'], {
535 timeoutMs: 8_000,
536 })
537 return run.stdout.split('\0').filter(Boolean)
538 } catch {
539 return []
540 }
541}
542
543async function flash($: EngineInterface, ids: string[], tone = 'orange', ends = 0): Promise<void> {
544 const unique = [...new Set(ids)]
545 if (unique.length === 0 && ends === 0) return
546 const apps = await getApps($)
547 const root = (await get($)).root
548 const now = await $.clock.now()
549 const seen = new Set(view.ids)
550 const inView = unique.every(id => seen.has(id))
551 let mode = 'none' as 'none' | 'flash' | 'work'
552 await patch($, cur => {
553 mode = 'none'
554 if (cur.root !== root) return {}
555 const w = ends > 0 ? cur.work : null
556 if (!w && unique.length === 0) return {}
557 const open = new Set(cur.expanded)
558 const prior = w ? w.lit : cur.flashOn ? cur.flash : []
559 const bright = new Set([...prior.filter(id => !unique.includes(id)), ...unique])
560 const dim = new Set<string>(w ? w.dim : cur.flashOn ? cur.flashDim : [])
561 const tones: Record<string, string> = !w && cur.flashOn ? { ...cur.flashTones } : {}
562 for (const id of unique) {
563 tones[id] = tone
564 const chain = chainOf(apps, id)
565 if (!chain.some(a => !open.has(a))) continue
566 for (const a of chain) {
567 if (!open.has(a)) {
568 dim.add(a)
569 tones[a] ??= tone
570 }
571 if (follow) open.add(a)
572 }
573 }
574 for (const id of bright) dim.delete(id)
575 const top = topOf(cur)
576 const away = follow && top !== '' && unique.some(id => id !== top && !inside(top, id))
577 const reveal: Partial<FileTree> = unique.length
578 ? { expanded: [...open], ...(follow && !inView ? { scroll: null } : {}), ...(away ? { top: '' } : {}) }
579 : {}
580 if (w) {
581 const n = Math.max(0, w.n - ends)
582 if (n === 0 && unique.length === 0) return { work: null }
583 if (n === 0) mode = 'work'
584 return { ...reveal, work: { ...w, n, lit: [...bright], dim: [...dim], until: n === 0 ? now + FLASH_MS : 0 } }
585 }
586 mode = 'flash'
587 return { ...reveal, flash: [...bright], flashDim: [...dim], flashOn: true, flashTones: tones }
588 })
589 if (mode === 'flash') {
590 const mine = ++generation
591 blink?.cancel()
592 blink = $.clock.after(FLASH_MS, () => {
593 if (generation !== mine) return
594 blink = null
595 quiet(patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {})))
596 })
597 } else if (mode === 'work') {
598 const mine = ++workGen
599 workTimer?.cancel()
600 workTimer = $.clock.after(FLASH_MS, () => {
601 if (workGen !== mine) return
602 workTimer = null
603 quiet(patch($, cur => (workGen === mine && cur.work?.n === 0 ? { work: null } : {})))
604 })
605 }
606 if (unique.length === 0) return
607 const t = await get($)
608 if (!closed) await openPane($, { id: PANE, title: titleOf(t.root) })
609}
610
611function touchedBy(apps: AppInfo[], calls: CliCall[]): string[] {
612 return [...new Set(calls.map(c => (c.cwd ? appOf(apps, c.cwd)?.dir : undefined)).filter((d): d is string => Boolean(d)))]
613}
614
615async function startWork($: EngineInterface, tone: string, lit: string[]): Promise<void> {
616 const at = await $.clock.now()
617 workGen += 1
618 workTimer?.cancel()
619 workTimer = null
620 await patch($, cur => {
621 const live = cur.work && cur.work.n > 0 ? cur.work : null
622 return {
623 work: {
624 tone: live?.tone === 'teal' ? 'teal' : tone,
625 n: (live?.n ?? 0) + 1,
626 at,
627 until: 0,
628 lit: [...new Set([...(live?.lit ?? []), ...lit])],
629 dim: live?.dim ?? [],
630 },
631 }
632 })
633}
634
635function endWork($: EngineInterface): Promise<void> {
636 return flash($, [], 'orange', 1)
637}
638
639async function afterBash($: EngineInterface, jobs: Job[]): Promise<{ ids: string[]; tone: string }> {
640 const t = await get($)
641 if (!t.root) return { ids: [], tone: 'orange' }
642 const usedCli = jobs.some(j => j.usedCli)
643 const deploy = jobs.some(j => j.deploy)
644 const deployDir = jobs.map(j => j.deployDir).find(Boolean) ?? ''
645 const since = jobs.reduce((a, j) => (j.since.ms < a.ms ? j.since : a), jobs[0]?.since ?? { ms: 0, mark: '', os: 'linux' as const })
646 const { added, changed } = usedCli ? await syncApps($) : { added: [], changed: [] }
647 const apps = await getApps($)
648 await refreshGit($)
649 const ignored = new Set((await get($)).ignored)
650 const fresh = added.length ? [] : await changedSince($, apps.map(a => a.dir), since)
651 const hits = fresh.filter(p => !underAny(p, ignored, t.root)).slice(0, FIND_LIMIT)
652 const newMarker = hits.some(p => markerOf(p) !== null)
653 const sync = usedCli ? { added: [], changed: [] } : newMarker ? await syncApps($) : { added: [], changed: await refreshApps($, hits) }
654 await revealPaths($, hits)
655 const loaded = await get($)
656 const open = new Set(loaded.expanded)
657 await loadDirs($, [...new Set([...loaded.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id), ...hits.map(dirname)])])
658 const present = new Set((await get($)).nodes.map(n => n.id))
659 const ids = [...added, ...changed, ...sync.added, ...sync.changed, ...hits.filter(p => present.has(p))]
660 if (deploy) {
661 const target = deployDir ? appOf(apps, deployDir) : undefined
662 const deployed = [...changed, ...sync.changed].filter(d => apps.some(a => a.dir === d))
663 return { ids: deployed.length ? deployed : target ? [target.dir] : [], tone: 'teal' }
664 }
665 return { ids, tone: 'orange' }
666}
667
668function scheduleScan($: EngineInterface, job: Job): void {
669 scanJobs.push(job)
670 if (scanning) return
671 scanning = (async () => {
672 try {
673 while (scanJobs.length) {
674 const jobs = scanJobs
675 scanJobs = []
676 let lit = { ids: [] as string[], tone: 'orange' }
677 try {
678 lit = await afterBash($, jobs)
679 } catch (err) {
680 await report($, `refresh failed: ${err instanceof Error ? err.message : String(err)}`)
681 } finally {
682 await dropMarks($, jobs.map(j => j.since.mark))
683 }
684 try {
685 await flash($, lit.ids, lit.tone, jobs.filter(j => j.busy).length)
686 } catch (err) {
687 await report($, `refresh failed: ${err instanceof Error ? err.message : String(err)}`)
688 }
689 }
690 } finally {
691 scanning = null
692 }
693 })()
694 quiet(scanning)
695}
696
697async function report($: EngineInterface, text: string): Promise<void> {
698 await patch($, () => ({ status: text })).catch(() => $.ui.toast(text))
699}
700
701function feedsApp(app: AppInfo, path: string): boolean {
702 if (!path.startsWith(app.dir + '/')) return false
703 const rel = path.slice(app.dir.length + 1)
704 return (
705 APP_INPUTS.includes(rel) ||
706 /^rayfin\/functions\/[^/]+\.(ts|js)$/.test(rel) ||
707 /^rayfin\/functions-python\/[^/]+\/definition\.json$/.test(rel)
708 )
709}
710
711async function refreshApps($: EngineInterface, paths: string[]): Promise<string[]> {
712 const at = epoch
713 const root = (await get($)).root
714 for (let i = 0; i < 5; i++) {
715 const cur = await $.state.get(APPS)
716 const apps = cur.value ?? []
717 const dirs = new Set<string>()
718 for (const p of paths) {
719 const app = appOf(apps, p)
720 if (app && feedsApp(app, p)) dirs.add(app.dir)
721 }
722 if (dirs.size === 0) return []
723 const fresh = await Promise.all(apps.map(a => (dirs.has(a.dir) ? appAt($, a.dir, a.cli.split('+')) : a)))
724 const now = await get($)
725 if (epoch !== at || now.root !== root) return []
726 const changed = fresh.filter((a, j) => JSON.stringify(a) !== JSON.stringify(apps[j])).map(a => a.dir)
727 if (changed.length === 0) return []
728 if ((await $.state.set(APPS, fresh, { ifVersion: cur.version })).isSet) return changed
729 }
730 return []
731}
732
733async function touched($: EngineInterface, paths: string[]): Promise<void> {
734 const markers = paths.filter(p => markerOf(p) !== null)
735 const sync = markers.length ? await syncApps($) : { added: [], changed: await refreshApps($, paths) }
736 const apps = await getApps($)
737 const within = paths.filter(p => appOf(apps, p))
738 if (within.length === 0 && sync.added.length === 0) return
739 await revealPaths($, within)
740 await loadDirs($, [...new Set(within.map(dirname))].filter(d => appOf(apps, d)))
741 await refreshGit($)
742 await flash($, [...sync.added, ...within])
743}
744
745async function openTarget($: EngineInterface, target: string): Promise<void> {
746 const { argv, init } = openCommand(await osName($), target)
747 try {
748 const run = await $.process.run(argv, init)
749 if (run.exitCode !== 0) $.ui.toast(`could not open ${target} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
750 } catch {
751 $.ui.toast(`could not open ${target} with ${argv[0]}`)
752 }
753}
754
755async function toggle($: EngineInterface, n: FileNode): Promise<void> {
756 if (n.kind === 'dir' && !n.loaded) await loadDirs($, [n.id])
757 await patch($, t => {
758 const open = new Set(t.expanded)
759 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
760 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' && n.parent !== t.root ? t.selected : n.id }
761 })
762}
763
764async function press($: EngineInterface, n: FileNode): Promise<void> {
765 const now = await $.clock.now()
766 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
767 lastPress = { key: isDouble ? '' : n.id, at: now }
768 if (!isDouble) await toggle($, n)
769 else if (n.kind === 'dir') await navigate($, n)
770 else await openNode($, n, false)
771}
772
773async function navigate($: EngineInterface, n: FileNode): Promise<void> {
774 if (!n.loaded) await loadDirs($, [n.id])
775 await patch($, cur => ({ top: n.id, cursor: n.id, scroll: null, expanded: [...new Set([...cur.expanded, n.id])] }))
776}
777
778async function goUp($: EngineInterface): Promise<void> {
779 await patch($, cur => {
780 const n = cur.nodes.find(x => x.id === topOf(cur))
781 return { top: n && n.parent !== cur.root ? n.parent : '', cursor: n?.id ?? cur.cursor, scroll: null }
782 })
783}
784
785async function goHome($: EngineInterface): Promise<void> {
786 await patch($, () => ({ top: '', scroll: null }))
787}
788
789async function openNode($: EngineInterface, n: FileNode, web: boolean): Promise<void> {
790 const app = (await getApps($)).find(a => a.dir === n.id)
791 await openTarget($, app ? (web ? app.portal || app.dir : app.dir) : n.id)
792}
793
794function faint(hex: string): string {
795 const v = parseInt(hex.slice(1), 16)
796 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
797 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
798}
799
800async function fontState($: EngineInterface, charset: string, name: string): Promise<'ok' | 'stale' | 'missing'> {
801 try {
802 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset, name], { timeoutMs: 5_000 })).stdout.trim()
803 return out === 'stale' || out === 'missing' ? out : 'ok'
804 } catch {
805 return 'missing'
806 }
807}
808
809function shortPath(path: string): string {
810 return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
811}
812
813function jumpTo(t: FileTree, query: string): Partial<FileTree> {
814 const q = query.trim().toLowerCase()
815 const hits = q ? t.nodes.filter(n => n.name.toLowerCase().includes(q)).slice(0, 10) : []
816 const open = new Set(t.expanded)
817 for (const n of hits) for (const a of ancestorsOf(n.id, t.root)) open.add(a)
818 const top = topOf(t)
819 const first = hits[0]?.id
820 return { query: '', expanded: [...open], cursor: first ?? t.cursor, scroll: null, top: first && top && !inside(top, first) ? '' : top }
821}
822
823let lastDetect = 0
824
825async function detectGlyphs($: EngineInterface): Promise<void> {
826 lastDetect = await $.clock.now()
827 if (remote) {
828 detected = 'nerd'
829 return
830 }
831 const nerd = (await fontState($, 'f04eb', 'Nerd')) === 'ok'
832 detected = nerd && (await fontState($, 'f2002', 'FabricSymbols')) === 'ok' ? 'fabric' : nerd ? 'nerd' : 'plain'
833}
834
835async function startup($: EngineInterface, detect: boolean): Promise<void> {
836 if (detect) {
837 remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
838 await detectGlyphs($)
839 }
840 try {
841 await $.state.set(THEME, parseTheme(String(await $.fs.read(`${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`))))
842 } catch {
843 await $.state.set(THEME, DEFAULT_THEME)
844 }
845 const t = await get($)
846 if (t.flashOn || t.work) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {}, work: null }))
847 const cwd = await cwdOf($)
848 if (!t.root) await put($, () => emptyTree(cwd))
849 const apps = await discover($, t.root || cwd)
850 if (apps.length) {
851 if (!t.root || t.nodes.length === 0) await reset($, t.root || cwd, false, apps)
852 else await openPane($, { id: PANE, title: titleOf(t.root || cwd) })
853 }
854}
855
856export const register: Register = (on, options) => {
857 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
858 follow = options?.follow !== 'off'
859 fontHint = options?.fontHint !== 'off'
860 on('session.start', async ($, e, next) => {
861 useDrives((await $.env.get('OS')) === 'Windows_NT')
862 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
863 hinted = false
864 fellBack = false
865 closed = false
866 noDock = false
867 await $.command.register({ name: PANE, description: 'Show the Fabric apps under a folder; args: [path]' })
868 quiet(startup($, true))
869 return next(e)
870 })
871
872 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
873 quiet(startup($, false))
874 return next(e)
875 })
876
877 on('command.run', { command: PANE }, async ($, e) => {
878 const terminal = (await $.session.surfaces()).includes('terminal')
879 if (terminal && e.presentation && !e.presentation.isFullscreen) return { text: `The Fabric app pane shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /${PANE}.` }
880 if (terminal && e.presentation && e.presentation.columns < 110) return { text: `The Fabric app pane shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /${PANE}.` }
881 noDock = false
882 const raw = (e.args ?? '').trim()
883 const arg = /^["']/.test(raw) ? (tokenize(raw)[0] ?? '') : raw
884 const cwd = await cwdOf($)
885 const root = arg ? resolvePath(cwd, arg) : cwd
886 if (arg) {
887 const kind = await $.fs.stat(root).then(
888 st => st.kind,
889 () => 'missing',
890 )
891 if (kind !== 'dir') return { text: kind === 'missing' ? `No folder at ${shortPath(root)}.` : `${shortPath(root)} is not a folder.` }
892 }
893 await reset($, root, true)
894 const apps = await getApps($)
895 return { text: `${apps.length} Fabric app${apps.length === 1 ? '' : 's'} under ${shortPath(root)}.` }
896 })
897
898 on('tool.call', async ($, e, next) => {
899 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash') return next(e)
900 const cwd = await cwdOf($)
901 const calls = e.tool === 'Bash' ? cliCalls(e.command, cwd, home) : []
902 const known = e.tool === 'Bash' ? await getApps($) : []
903 const since = await sinceNow($, e.tool === 'Bash' && (calls.length > 0 || known.length > 0))
904 const busy = calls.length && !closed && !noDock && (await get($)).root ? (calls.some(deploys) ? 'teal' : 'orange') : ''
905 if (busy) await startWork($, busy, touchedBy(known, calls))
906 let result: Awaited<ReturnType<typeof next>>
907 try {
908 result = await next(e)
909 } catch (err) {
910 quiet(dropMarks($, [since.mark]))
911 if (busy) await endWork($)
912 throw err
913 }
914 const record = e.tool === 'Bash' && !result.deny && !result.isError && result.result && typeof result.result === 'object' ? (result.result as { backgroundTaskId?: unknown }) : undefined
915 const task = typeof record?.backgroundTaskId === 'string' ? record.backgroundTaskId : ''
916 if (result.deny || (result.isError && e.tool !== 'Bash')) {
917 quiet(dropMarks($, [since.mark]))
918 if (busy) await endWork($)
919 return result
920 }
921 const t = await get($)
922 if (e.tool === 'Bash') {
923 const used = calls.filter(mutates)
924 const usedCli = used.length > 0
925 if (usedCli && !t.root) await reset($, cwd)
926 const deployed = used.find(deploys)
927 const job: Job = { since, usedCli, deploy: Boolean(deployed), deployDir: deployed?.cwd ?? '', busy: Boolean(busy) }
928 if (!usedCli && (await getApps($)).length === 0) {
929 quiet(dropMarks($, [since.mark]))
930 if (task && busy) running.set(task, { ...job, usedCli: false })
931 else if (busy) await endWork($)
932 } else if (task) running.set(task, job)
933 else scheduleScan($, job)
934 } else {
935 const file =
936 'file_path' in e && typeof e.file_path === 'string'
937 ? e.file_path
938 : 'notebook_path' in e && typeof e.notebook_path === 'string'
939 ? e.notebook_path
940 : ''
941 if (file) quiet(touched($, [posix(file)]))
942 }
943 return result
944 })
945
946 on('skill.prompt', async ($, e, next) => {
947 const result = await next(e)
948 if (CLIS.some(c => c.skills.some(s => e.skill === s || e.skill.endsWith(`:${s}`)))) {
949 quiet(
950 (async () => {
951 const t = await get($)
952 const cwd = await cwdOf($)
953 if (!t.root || t.nodes.length === 0) await reset($, t.root || cwd)
954 else await openPane($, { id: PANE, title: titleOf(t.root) })
955 })(),
956 )
957 }
958 return result
959 })
960
961 on('ui.message', async ($, e, next) => {
962 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
963 const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
964 const t = await get($)
965 if (typeof data.scrollTo === 'number') {
966 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
967 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
968 return {}
969 }
970 if (typeof data.copy === 'string') {
971 await copyOf($, data.copy, t.root, Boolean(data.shift), e.surface)
972 return {}
973 }
974 if (typeof data.press === 'string') {
975 const n = t.nodes.find(x => x.id === data.press)
976 if (!n) return {}
977 if (t.scroll === null) await patch($, () => ({ scroll: view.from }))
978 if (data.ctrl || data.shift) await openNode($, n, Boolean(data.ctrl))
979 else await press($, n)
980 return {}
981 }
982 if (typeof data.key !== 'string') return {}
983 const top = topOf(t)
984 const rows = visibleRows(t, top)
985 const at = rows.findIndex(r => r.node.id === t.cursor)
986 const cur = rows[at]?.node
987 const move = (d: number) => {
988 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
989 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
990 }
991 if (data.key === 'up' || data.key === 'k') await move(-1)
992 else if (data.key === 'down' || data.key === 'j') await move(1)
993 else if (data.key === 'pageup') await move(-10)
994 else if (data.key === 'pagedown') await move(10)
995 else if (data.key === 'home') await move(-rows.length)
996 else if (data.key === 'end') await move(rows.length)
997 else if (cur && (data.key === 'y' || data.key === 'Y')) await copyOf($, cur.id, t.root, data.key === 'Y' || Boolean(data.shift), e.surface)
998 else if (cur && (data.key === 'right' || data.key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
999 else if (cur && (data.key === 'left' || data.key === 'h')) {
1000 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1001 else if (cur.parent !== (top || t.root)) await patch($, () => ({ cursor: cur.parent }))
1002 } else if (cur && data.key === 'return') await (cur.kind === 'file' ? openNode($, cur, false) : toggle($, cur))
1003 else if (cur && data.key === ' ') await toggle($, cur)
1004 return {}
1005 })
1006
1007 on('ui.close', async ($, e, next) => {
1008 const result = await next(e)
1009 if (e.id === PANE && e.origin.kind === 'person') closed = true
1010 return result
1011 })
1012
1013 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
1014 const t = await get($)
1015 const to = Math.max(0, Math.min(view.max, (t.scroll ?? view.from) + Math.sign(e.by) * Math.max(3, Math.abs(e.by))))
1016 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1017 return {}
1018 })
1019
1020 on('prompt.submit', async ($, e, next) => {
1021 if (e.origin.kind === 'task-notification') {
1022 for (const [task, job] of running) {
1023 if (!e.text.includes(task)) continue
1024 running.delete(task)
1025 if (job.usedCli || (await getApps($)).length > 0) scheduleScan($, job)
1026 else {
1027 quiet(dropMarks($, [job.since.mark]))
1028 if (job.busy) await endWork($)
1029 }
1030 }
1031 }
1032 const t = await get($)
1033 const lines: string[] = []
1034 const app = t.selected ? appOf(await getApps($), t.selected) : undefined
1035 if (app) {
1036 lines.push(
1037 t.selected === app.dir
1038 ? 'The user has this Fabric app selected in the Fabric app pane; "this" or "it" likely refers to it.'
1039 : `The user has this file of a Fabric app selected in the Fabric app pane; "this" or "it" likely refers to it: ${t.selected}`,
1040 `app: ${app.title} (${app.cli}) at ${app.dir}`,
1041 )
1042 if (app.item) lines.push(`deployed item: workspace ${app.workspace}, item ${app.item}`)
1043 }
1044 const hint = closed || noDock ? '' : fontNote()
1045 const context = [...(lines.length ? [lines.join('\n')] : []), ...(hint ? [hint] : [])]
1046 return context.length ? next({ ...e, context: [...(e.context ?? []), ...context] }) : next(e)
1047 })
1048
1049 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1050 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
1051 if (e.surface === 'terminal' && e.props.placement === 'inline') {
1052 noDock = true
1053 quiet($.ui.close({ id: PANE }))
1054 const { Box: Empty } = $.ui.resolve(e)
1055 return <Empty />
1056 }
1057 if (glyphSetting === 'auto' && e.surface === 'terminal' && detected !== 'fabric' && (await $.clock.now()) - lastDetect > 60_000) {
1058 const was = detected
1059 quiet(
1060 detectGlyphs($).then(() => {
1061 if (detected !== was) $.ui.invalidate('ui.render')
1062 }),
1063 )
1064 }
1065 const tier = tierFor(e.surface)
1066 if (e.surface === 'terminal' && tier === 'plain' && glyphSetting === 'auto') fellBack = true
1067 const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
1068 const t = await get($)
1069 const apps = await getApps($)
1070 shownApps = apps
1071 const appByDir = new Map(apps.map(a => [a.dir, a]))
1072 const theme: Theme = (await $.state.get(THEME)).value ?? DEFAULT_THEME
1073 const now = await $.clock.now()
1074 const work = t.work && (t.work.until ? now < t.work.until : now - t.work.at < BUSY_MAX_MS) ? t.work : null
1075 const shining = [...(t.flashOn ? t.flash : []), ...(work ? work.lit : [])]
1076 const bright = new Set(shining)
1077 const dimmed = new Set([...(t.flashOn ? t.flashDim : []), ...(work ? work.dim : [])].filter(id => !bright.has(id)))
1078 const toneOf = (id: string) => (work && (work.lit.includes(id) || work.dim.includes(id)) ? work.tone : (t.flashTones[id] ?? 'orange'))
1079 const ignored = new Set(t.ignored)
1080 const untracked = new Set(t.untrackedDirs)
1081 const width = Math.max(24, e.props.bodyColumns)
1082 const rows = itemsOf(t, apps)
1083 const room = Math.max(5, (e.props.scroll?.bodyRows ?? 40) - 3 - (t.selected ? 1 : 0))
1084 const isLit = (id: string) => bright.has(id) || dimmed.has(id)
1085 const focus = follow && shining.length ? (shining[shining.length - 1] ?? t.cursor) : t.cursor
1086 const at = Math.max(0, rows.findIndex(r => r.node.id === focus))
1087 const lit = follow && (bright.size > 0 || dimmed.size > 0) ? rows.findIndex(r => isLit(r.node.id)) : -1
1088 const fits = lit >= 0 && at - lit < room - 2
1089 const base = Math.max(0, Math.min(Math.max(0, rows.findIndex(r => r.node.id === t.cursor)) - Math.floor(room / 2), rows.length - room))
1090 const inView = lit >= base && at < base + room
1091 const flashing = lit >= 0 && !inView
1092 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))
1093 let from = flashing ? nearBottom(room) : base
1094 const cap = Math.max(1, Math.floor(room / 3))
1095 const chain = new Set(flashing ? ancestorsOf(focus, t.root) : [])
1096 const keep = (id: string) => chain.has(id) || isLit(id)
1097 let pinned = flashing ? rows.slice(0, from).filter(r => keep(r.node.id)).slice(-cap) : []
1098 if (pinned.length) {
1099 from = nearBottom(Math.max(3, room - pinned.length - 1))
1100 pinned = rows.slice(0, from).filter(r => keep(r.node.id)).slice(-cap)
1101 }
1102 const max = Math.max(0, rows.length - room)
1103 if (t.scroll !== null) {
1104 from = Math.max(0, Math.min(t.scroll, max))
1105 pinned = []
1106 }
1107 const shown = rows.slice(from, from + room - pinned.length - (pinned.length ? 1 : 0))
1108 view = { from, max, ids: [...pinned, ...shown].map(r => r.node.id) }
1109
1110 const sourceSpec = (r: Item): RowSpec => {
1111 const src = r.source
1112 if (!src || src.kind === 'none' || src.kind === 'error')
1113 return {
1114 id: '',
1115 left: clean([{ t: ' '.repeat(r.depth + 1) }, { t: fit(src?.name ?? '', Math.max(4, width - r.depth * 2 - 4)), c: src?.kind === 'error' ? theme.urgent : theme.muted, i: true }]),
1116 right: [],
1117 }
1118 const g = glyphFor(r.child ? (src.kind === 'UserDataFunction' ? 'function' : 'table') : src.kind, tier)
1119 const note = r.child ? '' : src.note
1120 const cols = Math.max(4, width - r.depth * 2 - 6 - (note ? note.length + 1 : 0))
1121 const name = r.node.name.length > cols ? r.node.name.slice(0, cols - 1) + '…' : r.node.name
1122 return {
1123 id: '',
1124 left: clean([{ t: ' '.repeat(r.depth + 1) }, { t: g.char + ' ', c: g.color }, { t: name, c: r.child ? theme.fg || undefined : g.color, b: !r.child }]),
1125 right: note ? [{ t: ` ${note}`, c: theme.muted }] : [],
1126 }
1127 }
1128
1129 const rowSpec = (r: Item): RowSpec => {
1130 if (r.source) return sourceSpec(r)
1131 const n = r.node
1132 const app = appByDir.get(n.id)
1133 const status = t.git[n.id] ?? (underAny(dirname(n.id), untracked, t.root) ? '?' : undefined)
1134 const isIgnored = !status && underAny(n.id, ignored, t.root)
1135 const gitColor = status === 'D' || status === 'U' ? theme.urgent : status ? (GIT_COLOR[status] ?? theme.muted) : undefined
1136 const isBright = bright.has(n.id)
1137 const isDim = !isBright && dimmed.has(n.id)
1138 const glyph = app ? glyphFor('app', tier).char : tier === 'plain' ? plainIcon(n, r.open) : fileIcon(n, r.open, n.kind === 'dir' && gitTops.has(n.id))
1139 const iconColor = app
1140 ? APP_COLOR
1141 : isIgnored
1142 ? theme.muted
1143 : (gitColor ?? (n.hidden ? theme.muted : n.kind === 'dir' ? theme.accent : theme.muted))
1144 const nameColor = app ? APP_COLOR : isIgnored ? theme.muted : (gitColor ?? (n.hidden ? theme.muted : theme.fg || undefined))
1145 const loc = t.diff[n.id]
1146 const meta = app ? `${app.cli}${app.item ? ' deployed' : ' local'}` : loc ? '' : n.kind === 'file' ? stamp(n.mtime) : ''
1147 const locText = loc ? `${loc[0] ? ` +${loc[0]}` : ''}${loc[1] ? ` -${loc[1]}` : ''}` : ''
1148 const c = n.kind === 'dir' ? t.counts[n.id] : undefined
1149 const dirCounts: Seg[] = c
1150 ? [
1151 ...(c[0] ? [{ t: ` ?:${c[0]}`, c: GIT_COLOR['?'] ?? ADD_COLOR }] : []),
1152 ...(c[1] ? [{ t: ` M:${c[1]}`, c: GIT_COLOR.M ?? '#e5c07b' }] : []),
1153 ...(c[2] ? [{ t: ` D:${c[2]}`, c: theme.urgent }] : []),
1154 ]
1155 : []
1156 const countsText = dirCounts.map(x => x.t).join('')
1157 const badge = app || dirCounts.length ? '' : status ? ` ${status}` : isIgnored ? (tier === 'plain' ? ' ⊘' : ' \u{f05e}') : ' '
1158 const cols = Math.max(4, width - r.depth * 2 - 6 - (meta ? meta.length + 1 : 0) - locText.length - badge.length - countsText.length)
1159 const name = n.name.length > cols ? n.name.slice(0, cols - 1) + '…' : n.name
1160 const caret = n.kind === 'dir' ? (tier === 'plain' ? (r.open ? '▾' : '▸') : r.open ? CHEVRON_OPEN : CHEVRON_CLOSED) + ' ' : ' '
1161 const left: Seg[] = [
1162 { t: ' '.repeat(r.depth) },
1163 { t: caret, c: theme.muted },
1164 isBright || isDim ? { t: glyph + ' ', sh: toneOf(n.id), dim: isDim, one: true } : { t: glyph + ' ', c: iconColor },
1165 ]
1166 if (isBright || isDim) left.push({ t: name, sh: toneOf(n.id), dim: isDim, b: isBright })
1167 else left.push({ t: name, c: nameColor, b: Boolean(app) || n.id === t.selected, s: status === 'D' && n.kind !== 'dir' })
1168 const right: Seg[] = []
1169 if (meta) right.push({ t: ` ${meta}`, c: app?.item ? '#98c379' : theme.muted })
1170 if (loc && loc[0] > 0) right.push({ t: ` +${loc[0]}`, c: ADD_COLOR })
1171 if (loc && loc[1] > 0) right.push({ t: ` -${loc[1]}`, c: DEL_COLOR })
1172 right.push(...dirCounts)
1173 if (badge) right.push({ t: badge, c: status ? gitColor : theme.muted, b: true })
1174 return { id: n.id, left: clean(left), right: clean(right) }
1175 }
1176
1177 const note = (text: string): RowSpec => ({ id: '', left: [{ t: text, c: theme.muted }], right: [] })
1178 const specs: RowSpec[] = [...pinned.map(rowSpec), ...(pinned.length > 0 ? [note(' ⋮')] : []), ...shown.map(rowSpec)]
1179 const barSize = Math.max(1, Math.round((specs.length * shown.length) / Math.max(1, rows.length)))
1180 const bar =
1181 rows.length > shown.length + pinned.length
1182 ? { pos: max ? Math.round((from / max) * (specs.length - barSize)) : 0, size: barSize, thumb: theme.accent, track: theme.muted }
1183 : undefined
1184 const icon = (nerd: string, fallback: string) => (tier === 'plain' ? fallback : nerd)
1185 const topNode = t.nodes.find(n => n.id === topOf(t))
1186
1187 return (
1188 <Box flexDirection="column" minHeight={Math.max(1, e.props.scroll?.bodyRows ?? 1)}>
1189 <Box flexDirection="row">
1190 <Text bold color={theme.accent} wrap="truncate-start">
1191 {topNode ? topNode.name : t.root.split('/').pop() || t.root}
1192 {` ${apps.length} app${apps.length === 1 ? '' : 's'}`}
1193 </Text>
1194 <Box flexGrow={1} />
1195 <Box flexDirection="row" gap={2}>
1196 {topNode && <Button key="up" plain dimColor label={icon('\u{f005d}', '↑')} onPress={() => quiet(goUp($))} />}
1197 {topNode && <Button key="home" plain dimColor label={icon('\u{f02dc}', '⌂')} onPress={() => quiet(goHome($))} />}
1198 <Button
1199 key="rescan"
1200 plainhooks/open.ts 41 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
41hooks/apps.ts 194 lines1import type { AppInfo, Source } from '../types'
2import { CONNECTOR_KIND, KIND_LABEL } from './glyphs'
3
4const PORTAL = 'https://app.fabric.microsoft.com'
5const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
6
7export type Cli = {
8 name: string
9 commands: string[]
10 packages: string[]
11 mutating: string[]
12 deploy: string[]
13 marker: string
14 inputs: string[]
15 start: string
16 skills: string[]
17 read: (dir: string, text: (rel: string) => Promise<string>) => Promise<AppInfo>
18}
19
20export function yamlScalar(raw: string): string {
21 const v = raw.trim()
22 const quoted = v.match(/^"((?:[^"\\]|\\.)*)"|^'((?:[^']|'')*)'/)
23 if (quoted) return quoted[1] !== undefined ? quoted[1].replace(/\\(.)/g, '$1') : (quoted[2] ?? '').replace(/''/g, "'")
24 return v.replace(/(^|\s+)#.*$/, '').trim()
25}
26
27export function portalUrl(workspace: string, item: string): string {
28 return GUID.test(workspace) && GUID.test(item) ? `${PORTAL}/groups/${workspace}/appbackends/${item}` : ''
29}
30
31export function merge(a: AppInfo, b: AppInfo): AppInfo {
32 return {
33 ...a,
34 title: b.title !== b.name ? b.title : a.title,
35 cli: a.cli === b.cli ? a.cli : `${a.cli}+${b.cli}`,
36 workspace: a.workspace || b.workspace,
37 item: a.item || b.item,
38 portal: a.portal || b.portal,
39 hosting: a.hosting || b.hosting,
40 error: [a.error, b.error].filter(Boolean).join('; '),
41 }
42}
43
44export type SourceInputs = {
45 fabricYaml: string
46 rayfinYml: string
47 schemaTs: string
48 udfDefs: { folder: string; text: string }[]
49 tsFunctions: string[]
50}
51
52function indentOf(line: string): number {
53 return line.length - line.trimStart().length
54}
55
56function yamlSection(yaml: string, key: string): string[] {
57 const lines = yaml.split('\n')
58 const start = lines.findIndex(l => new RegExp(`^\\s*${key}:\\s*$`).test(l))
59 if (start < 0) return []
60 const base = indentOf(lines[start] ?? '')
61 const out: string[] = []
62 for (const line of lines.slice(start + 1)) {
63 if (line.trim() === '' || line.trim().startsWith('#')) continue
64 if (indentOf(line) <= base && !line.trimStart().startsWith('- ')) break
65 if (indentOf(line) < base) break
66 out.push(line)
67 }
68 return out
69}
70
71function fabricYamlModels(yaml: string): Source[] {
72 const block = yamlSection(yaml, 'semanticModels')
73 if (block.length === 0) return []
74 const keyIndent = Math.min(...block.map(indentOf))
75 const out: Source[] = []
76 for (const l of block) {
77 if (indentOf(l) === keyIndent && /^\s*[^\s:]+:\s*$/.test(l)) {
78 out.push({ kind: 'SemanticModel', name: l.trim().replace(/:$/, ''), note: 'semantic model' })
79 continue
80 }
81 const item = l.match(/^\s*itemId:\s*["']?([0-9a-fA-F-]{36})/)?.[1]
82 const last = out[out.length - 1]
83 if (item && last && !last.item) last.item = item
84 }
85 return out
86}
87
88function rayfinConnectors(yaml: string): Source[] {
89 const lines = yaml.split('\n')
90 const start = lines.findIndex(l => /^connectors:\s*(#.*)?$/.test(l))
91 if (start < 0) return []
92 const items: { keys: Record<string, string>; itemId: string }[] = []
93 let dash = -1
94 let keyIndent = -1
95 const take = (text: string, indent: number) => {
96 const item = items[items.length - 1]
97 const kv = text.match(/^([\w-]+):(.*)$/)
98 if (!item || !kv?.[1]) return
99 const value = yamlScalar(kv[2] ?? '')
100 if (indent === keyIndent && !(kv[1] in item.keys)) item.keys[kv[1]] = value
101 if (kv[1] === 'itemId' && !item.itemId && /^[0-9a-fA-F-]{36}$/.test(value)) item.itemId = value
102 }
103 for (const line of lines.slice(start + 1)) {
104 if (line.trim() === '' || line.trim().startsWith('#')) continue
105 if (/^\S/.test(line) && !line.startsWith('-')) break
106 const entry = line.match(/^(\s*)-(\s+)(.*)$/)
107 if (entry && (dash < 0 || (entry[1] ?? '').length === dash)) {
108 dash = (entry[1] ?? '').length
109 keyIndent = dash + 1 + (entry[2] ?? '').length
110 items.push({ keys: {}, itemId: '' })
111 take(entry[3] ?? '', keyIndent)
112 continue
113 }
114 take(line.trimStart(), indentOf(line))
115 }
116 return items
117 .filter(i => i.keys.name)
118 .map(i => {
119 const type = i.keys.type ?? ''
120 const kind = CONNECTOR_KIND[type] ?? 'table'
121 return { kind, name: i.keys.name ?? '', note: KIND_LABEL[kind] ?? type, item: i.itemId || undefined }
122 })
123}
124
125function service(yaml: string, name: string): { enabled: boolean; dialect: string } {
126 const block = yamlSection(yaml, name)
127 const enabled = block.some(l => /^\s*enabled:\s*true\b/.test(l) && indentOf(l) === Math.min(...block.map(indentOf)))
128 const dialect = block.map(l => l.match(/^\s*dialect:\s*["']?([\w-]+)/)?.[1]).find(Boolean) ?? ''
129 return { enabled, dialect }
130}
131
132function schemaEntities(ts: string): string[] {
133 const list = ts.match(/schema\s*=\s*\[([^\]]*)\]/)?.[1] ?? ''
134 return list
135 .split(',')
136 .map(s => s.trim())
137 .filter(Boolean)
138}
139
140export function parseSources(input: SourceInputs): Source[] {
141 const seen = new Set<string>()
142 const out: Source[] = []
143 const add = (s: Source) => {
144 const key = s.item ? `id:${s.item.toLowerCase()}` : `${s.kind}:${s.name.toLowerCase()}`
145 if (seen.has(key)) return
146 seen.add(key)
147 out.push(s)
148 }
149 for (const s of rayfinConnectors(input.rayfinYml)) add(s)
150 for (const s of fabricYamlModels(input.fabricYaml)) add(s)
151 for (const def of input.udfDefs) {
152 try {
153 const parsed = JSON.parse(def.text) as {
154 connectedDataSources?: { alias?: string; artifactType?: string }[]
155 }
156 for (const c of parsed.connectedDataSources ?? []) {
157 const kind = c.artifactType && KIND_LABEL[c.artifactType] ? c.artifactType : 'table'
158 add({ kind, name: c.alias ?? 'connection', note: `${KIND_LABEL[kind] ?? c.artifactType ?? 'source'} (UDF)` })
159 }
160 } catch {
161 continue
162 }
163 }
164 const data = service(input.rayfinYml, 'data')
165 if (data.enabled) {
166 const entities = schemaEntities(input.schemaTs)
167 add({
168 kind: 'SQLDatabase',
169 name: 'SQL database',
170 note: [data.dialect || 'sql', entities.length ? `${entities.length} table${entities.length === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '),
171 children: entities,
172 })
173 }
174 const functions: string[] = [...input.tsFunctions]
175 for (const def of input.udfDefs) {
176 try {
177 const parsed = JSON.parse(def.text) as { functions?: { name?: string }[] }
178 for (const f of parsed.functions ?? []) if (f.name) functions.push(f.name)
179 } catch {
180 continue
181 }
182 }
183 const fnService = service(input.rayfinYml, 'functions')
184 if (functions.length || fnService.enabled) {
185 add({
186 kind: 'UserDataFunction',
187 name: 'User data functions',
188 note: functions.length ? `${functions.length} function${functions.length === 1 ? '' : 's'}` : 'enabled, none yet',
189 children: [...new Set(functions)],
190 })
191 }
192 return out
193}
194hooks/clis.ts 4 lines1import { rayfin } from './rayfin'
2
3export const CLIS = [rayfin]
4hooks/glyphs.ts 80 lines1export type Tier = 'fabric' | 'nerd' | 'plain'
2type Rgb = [number, number, number]
3type Glyph = { fabric: number; nerd: string; plain: string; rgb: Rgb }
4
5const GLYPHS: Record<string, Glyph> = {
6 app: { fabric: 991302, nerd: '\u{f003b}', plain: '▣', rgb: [29, 83, 164] },
7 SemanticModel: { fabric: 991234, nerd: '\u{f01bc}', plain: '◆', rgb: [90, 64, 156] },
8 Lakehouse: { fabric: 991258, nerd: '\u{f0b9d}', plain: '◇', rgb: [29, 83, 164] },
9 Warehouse: { fabric: 991243, nerd: '\u{f0a0b}', plain: '▤', rgb: [29, 83, 164] },
10 SQLDatabase: { fabric: 991280, nerd: '\u{f1063}', plain: '▦', rgb: [0, 142, 230] },
11 KQLDatabase: { fabric: 991255, nerd: '\u{f1063}', plain: '▦', rgb: [0, 142, 230] },
12 UserDataFunction: { fabric: 991284, nerd: '\u{f0295}', plain: 'ƒ', rgb: [63, 125, 53] },
13 function: { fabric: 0, nerd: '\u{f0295}', plain: 'ƒ', rgb: [63, 125, 53] },
14}
15
16const TABLE: Glyph = { fabric: 0, nerd: '\u{f04eb}', plain: '⊞', rgb: [0, 142, 230] }
17
18function hls(rgb: Rgb): [number, number, number] {
19 const [r, g, b] = rgb.map(v => v / 255) as Rgb
20 const max = Math.max(r, g, b)
21 const min = Math.min(r, g, b)
22 const l = (max + min) / 2
23 const range = max - min
24 if (range < 1e-6) return [0, l, 0]
25 const s = l <= 0.5 ? range / (max + min) : range / (2 - max - min)
26 const rc = (max - r) / range
27 const gc = (max - g) / range
28 const bc = (max - b) / range
29 const h = r === max ? bc - gc : g === max ? 2 + rc - bc : 4 + gc - rc
30 return [(((h / 6) % 1) + 1) % 1, l, s]
31}
32
33function component(m1: number, m2: number, hue: number): number {
34 const h = ((hue % 1) + 1) % 1
35 if (h < 1 / 6) return m1 + (m2 - m1) * h * 6
36 if (h < 0.5) return m2
37 if (h < 2 / 3) return m1 + (m2 - m1) * (2 / 3 - h) * 6
38 return m1
39}
40
41function lifted(rgb: Rgb): string {
42 const [h, l0, s] = hls(rgb)
43 const l = Math.max(l0, 0.62)
44 const out =
45 s < 1e-6
46 ? [l, l, l]
47 : (() => {
48 const m2 = l <= 0.5 ? l * (1 + s) : l + s - l * s
49 const m1 = 2 * l - m2
50 return [h + 1 / 3, h, h - 1 / 3].map(x => component(m1, m2, x))
51 })()
52 return '#' + out.map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('')
53}
54
55export function glyphFor(kind: string, tier: Tier): { char: string; color: string } {
56 const g = GLYPHS[kind] ?? TABLE
57 const char = tier === 'fabric' && g.fabric ? String.fromCodePoint(g.fabric) : tier === 'plain' ? g.plain : g.nerd
58 return { char, color: lifted(g.rgb) }
59}
60
61export const KIND_LABEL: Record<string, string> = {
62 SemanticModel: 'semantic model',
63 Lakehouse: 'lakehouse',
64 Warehouse: 'warehouse',
65 SQLDatabase: 'SQL database',
66 KQLDatabase: 'KQL database',
67 UserDataFunction: 'user data functions',
68}
69
70export const CONNECTOR_KIND: Record<string, string> = {
71 'fabric-semanticmodel': 'SemanticModel',
72 'fabric-lakehouse': 'Lakehouse',
73 'fabric-warehouse': 'Warehouse',
74 'fabric-sqldatabase': 'SQLDatabase',
75 'fabric-sql': 'SQLDatabase',
76 'fabric-kqldatabase': 'KQLDatabase',
77 'fabric-kusto': 'KQLDatabase',
78 kusto: 'KQLDatabase',
79}
80hooks/rows.tsx 123 lines1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean }
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 bar?: { pos: number; size: number; thumb: string; track: string }
12}
13type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void } }
14
15const TICK_MS = 90
16
17function shimmer(i: number, phase: number, len: number, palette: string[]): string {
18 const band = ((phase * 1.6) % (len + 8)) - 4
19 const d = Math.abs(i - band)
20 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
21}
22
23const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
24 const { Box, Text } = surface.elements
25 let state = surface.state
26 if (state === undefined) {
27 state = { hover: -1, phase: 0, drag: false, ref: {} }
28 surface.setState(state)
29 }
30 const lit = props.rows.some(r => r.left.some(s => s.sh) || r.right.some(s => s.sh))
31 if (lit && !state.ref.stop) {
32 state.ref.stop = surface.every(TICK_MS, () => {
33 const cur = surface.state
34 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
35 })
36 } else if (!lit && state.ref.stop) {
37 state.ref.stop()
38 state.ref.stop = undefined
39 }
40 surface.onPointer(e => {
41 const cur = surface.state ?? state
42 const span = Math.max(1, props.rows.length - 1)
43 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
44 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
45 if (e.type === 'up' || e.type === 'leave') {
46 surface.setState({ ...cur, drag: false })
47 return
48 }
49 if (e.type === 'down' || e.type === 'move') {
50 if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
51 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
52 return
53 }
54 }
55 if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
56 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
57 return
58 }
59 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
60 const row = props.rows[e.y]
61 if (e.type === 'down' && e.button === 'right' && row?.id) {
62 surface.post({ copy: row.id, shift: Boolean(e.shift) })
63 return
64 }
65 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row?.id) return
66 surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
67 })
68 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
69 const draw = (s: Seg, k: number) => {
70 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
71 if (!palette) {
72 return (
73 <Text key={`s${k}`} color={s.c} bold={s.b} strikethrough={s.s} italic={s.i}>
74 {s.t}
75 </Text>
76 )
77 }
78 if (s.one) {
79 return (
80 <Text key={`s${k}`} color={shimmer(-1, state.phase, s.t.length, palette)}>
81 {s.t}
82 </Text>
83 )
84 }
85 const chars = [...s.t]
86 return (
87 <Text key={`s${k}`} bold={s.b}>
88 {chars.map((ch, i) => (
89 <Text key={`c${i}`} color={shimmer(i, state.phase, chars.length, palette)}>
90 {ch}
91 </Text>
92 ))}
93 </Text>
94 )
95 }
96 return (
97 <Box flexDirection="column">
98 {props.rows.map((r, i) => (
99 <Box
100 key={r.id || `row-${i}`}
101 flexDirection="row"
102 height={1}
103 overflow="hidden"
104 backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
105 >
106 <Box flexShrink={1} overflow="hidden">
107 {r.left.map(draw)}
108 </Box>
109 <Box flexGrow={1} />
110 {r.right.map(draw)}
111 {props.bar && (
112 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
113 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
114 </Text>
115 )}
116 </Box>
117 ))}
118 </Box>
119 )
120}
121
122export default Rows
123hooks/icons.ts 138 lines1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9 'agents.md': 0xf0354,
10 'changelog.md': 0xf0354,
11 'contributing.md': 0xf0354,
12 'todo.md': 0xf0354,
13 'readme.md': 0xf0354,
14 license: 0xe60a,
15 dockerfile: 0xf0868,
16 makefile: 0xf1064,
17 'cmakelists.txt': 0xf1064,
18 justfile: 0xf05b7,
19 gemfile: 0xf0d2d,
20 rakefile: 0xf0d2d,
21 'cargo.toml': 0xe6b2,
22 'cargo.lock': 0xe6b2,
23 'pyproject.toml': 0xe6b2,
24 'requirements.txt': 0xf160e,
25 'go.mod': 0xf0afa,
26 'go.sum': 0xf07d3,
27 'package.json': 0xf0626,
28 'package-lock.json': 0xf0626,
29 'tsconfig.json': 0xf0626,
30 '.prettierrc': 0xf0626,
31 '.eslintrc': 0xf0626,
32 'pnpm-lock.yaml': 0xe6a8,
33 'yarn.lock': 0xe6a8,
34 '.gitignore': 0xf02a2,
35 '.gitmodules': 0xf0493,
36 '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40 lua: 0xe620,
41 py: 0xe606,
42 pyi: 0xe606,
43 js: 0xe60c,
44 mjs: 0xe60c,
45 cjs: 0xe60c,
46 ts: 0xe628,
47 jsx: 0xe625,
48 tsx: 0xe7ba,
49 json: 0xe60b,
50 jsonc: 0xe60b,
51 yaml: 0xe6a8,
52 yml: 0xe6a8,
53 sh: 0xe795,
54 bash: 0xe795,
55 zsh: 0xe795,
56 fish: 0xe795,
57 md: 0xf0354,
58 markdown: 0xf0354,
59 css: 0xe6b8,
60 scss: 0xf031c,
61 html: 0xe736,
62 htm: 0xe736,
63 go: 0xe627,
64 rs: 0xe68b,
65 rb: 0xe791,
66 php: 0xe608,
67 java: 0xe738,
68 cs: 0xf031b,
69 sql: 0xe706,
70 graphql: 0xf20e,
71 gql: 0xf20e,
72 xml: 0xf05c0,
73 toml: 0xe6b2,
74 ini: 0xf0bc2,
75 conf: 0xf0493,
76 pdf: 0xeaeb,
77 svg: 0xf0721,
78 jpg: 0xf0225,
79 jpeg: 0xf0225,
80 png: 0xe60d,
81 gif: 0xf0d78,
82 webp: 0xf021f,
83 mp4: 0xf022b,
84 mkv: 0xf022b,
85 mov: 0xf022b,
86 mp3: 0xf0223,
87 wav: 0xf0223,
88 flac: 0xf0223,
89 zip: 0xf05c4,
90 gz: 0xf05c4,
91 xz: 0xf05c4,
92 tar: 0xf05c4,
93 qml: 0xf375,
94 cpp: 0xe61d,
95 cc: 0xe61d,
96 cxx: 0xe61d,
97 c: 0xe61e,
98 h: 0xf0af5,
99 hpp: 0xf0af5,
100 vue: 0xe6a0,
101 svelte: 0xe697,
102 csv: 0xe64a,
103 txt: 0xf09aa,
104}
105
106export function plainIcon(n: FileNode, open: boolean): string {
107 if (n.kind === 'link') return '↪'
108 if (n.kind === 'dir') return open ? '□' : '■'
109 return '▫'
110}
111
112export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
113 if (n.kind === 'link') return cp(0xf481)
114 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
115 const lower = n.name.toLowerCase()
116 const named = NAMES[lower]
117 if (named) return cp(named)
118 const dot = lower.lastIndexOf('.')
119 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
120 return cp(EXTS[ext] ?? 0xf0214)
121}
122
123export const GIT_COLOR: Record<string, string> = {
124 A: '#98c379',
125 '?': '#98c379',
126 R: '#61afef',
127 C: '#61afef',
128 M: '#e5c07b',
129 T: '#e5c07b',
130}
131
132const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
133
134export function stronger(a: string | undefined, b: string): string {
135 if (!a) return b
136 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
137}
138hooks/parse.ts 311 lines1import type { Cli } from './apps'
2import { CLIS } from './clis'
3import { isAbsolute, posix, resolvePath } from './tree'
4
5export type Invocation = { head: string; args: string[]; cwd: string }
6export type CliCall = { cli: Cli; args: string[]; cwd: string }
7
8const SEPARATORS = new Set(['&&', '||', ';', '|', '&', '\n', '(', ')', '`'])
9const PREFIXES = new Set(['do', 'then', 'else', 'elif', 'if', '!', 'time', '{', 'env', 'command', 'builtin', 'exec', 'nohup', 'sudo', 'nice', 'xargs', 'timeout', 'while', 'until'])
10const PREFIX_VALUED: Record<string, Set<string>> = {
11 sudo: new Set(['-u', '-g', '-C', '-h', '-p', '-U', '-r', '-t', '-D']),
12 timeout: new Set(['-k', '-s', '--kill-after', '--signal']),
13 xargs: new Set(['-n', '-I', '-P', '-L', '-s', '-d', '-E', '-a']),
14 env: new Set(['-u', '-C', '-S', '--unset', '--chdir']),
15 nice: new Set(['-n']),
16 exec: new Set(['-a']),
17}
18const RUNNERS: Record<string, string[]> = { npx: [], bunx: [], pnpx: [], bun: ['x'], npm: ['exec', 'x'], pnpm: ['dlx', 'exec'], yarn: ['dlx', 'exec'] }
19const MAX_CALLS = 50
20const REDIRECT = /^(\d*>>?|\d*<|\d*>&\d*|&>>?)$/
21
22export function tokenize(command: string): string[] {
23 const out: string[] = []
24 let cur = ''
25 let quote = ''
26 let has = false
27 for (let i = 0; i < command.length; i++) {
28 const ch = command[i] ?? ''
29 if (!quote && ch === '#' && !has && !cur) {
30 while (i + 1 < command.length && command[i + 1] !== '\n') i++
31 continue
32 }
33 if (quote) {
34 if (ch === quote) quote = ''
35 else if (ch === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i] ?? ''
36 else cur += ch
37 continue
38 }
39 if (ch === '"' || ch === "'") {
40 quote = ch
41 has = true
42 } else if (ch === '\\' && i + 1 < command.length) {
43 cur += command[++i] ?? ''
44 has = true
45 } else if (ch === '\n') {
46 if (has || cur) out.push(cur)
47 cur = ''
48 has = false
49 out.push('\n')
50 } else if (/\s/.test(ch)) {
51 if (has || cur) out.push(cur)
52 cur = ''
53 has = false
54 } else if (ch === '>' || ch === '<') {
55 let op = /^\d+$/.test(cur) || (cur === '&' && !has) ? cur : ''
56 if (!op && (has || cur)) out.push(cur)
57 op += ch
58 while (command[i + 1] === '>' || command[i + 1] === '&' || /\d/.test(command[i + 1] ?? '')) {
59 if (command[i + 1] === '&' && op.endsWith('&')) break
60 op += command[++i]
61 }
62 out.push(op)
63 cur = ''
64 has = false
65 } else if (ch === '(' || ch === ')' || ch === '`') {
66 if (has || cur) out.push(cur)
67 cur = ''
68 has = false
69 out.push(ch)
70 } else if (ch === ';' || ch === '|' || ch === '&') {
71 if (ch === '&' && command[i + 1] === '>') {
72 if (has || cur) out.push(cur)
73 cur = '&'
74 has = false
75 continue
76 }
77 if (has || cur) out.push(cur)
78 cur = ''
79 has = false
80 const two = command.slice(i, i + 2)
81 if (two === '&&' || two === '||') {
82 out.push(two)
83 i++
84 } else out.push(ch)
85 } else {
86 cur += ch
87 has = true
88 }
89 }
90 if (has || cur) out.push(cur)
91 if (!quote) return out
92 return command
93 .split(/(&&|\|\||[;|\n])/)
94 .flatMap(part => (/^(&&|\|\||[;|\n])$/.test(part) ? [part] : part.trim().split(/\s+/).filter(Boolean)))
95}
96
97function operators(line: string): { word: string; tabs: boolean; expand: boolean }[] {
98 const found: { word: string; tabs: boolean; expand: boolean }[] = []
99 const outer: boolean[] = []
100 let single = false
101 let double = false
102 for (let i = 0; i < line.length; i++) {
103 const ch = line[i]
104 if (single) {
105 if (ch === "'") single = false
106 continue
107 }
108 if (ch === '\\') {
109 i++
110 continue
111 }
112 if (ch === '$' && line[i + 1] === '(') {
113 outer.push(double)
114 double = false
115 i++
116 continue
117 }
118 if (ch === ')' && outer.length && !double) {
119 double = outer.pop() ?? false
120 continue
121 }
122 if (ch === '"') {
123 double = !double
124 continue
125 }
126 if (double) continue
127 if (ch === "'") {
128 single = true
129 continue
130 }
131 if (ch === '#' && (i === 0 || /\s/.test(line[i - 1] ?? ''))) break
132 if (ch === '<' && line[i + 1] === '<' && line[i + 2] !== '<') {
133 const m = line.slice(i).match(/^<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/)
134 if (m) {
135 found.push({ word: m[3] ?? '', tabs: m[1] === '-', expand: !m[2] })
136 i += m[0].length - 1
137 }
138 }
139 }
140 return found
141}
142
143function heredocless(command: string): string {
144 const out: string[] = []
145 const pending: { word: string; tabs: boolean; expand: boolean }[] = []
146 for (const line of command.split('\n')) {
147 const head = pending[0]
148 if (head) {
149 if ((head.tabs ? line.replace(/^\t+/, '') : line) === head.word) pending.shift()
150 else if (head.expand) out.push(...substitutions(line))
151 continue
152 }
153 out.push(line)
154 pending.push(...operators(line))
155 }
156 return out.join('\n')
157}
158
159function substitutions(command: string): string[] {
160 const out: string[] = []
161 let single = false
162 let double = false
163 for (let i = 0; i < command.length; i++) {
164 const ch = command[i]
165 if (single) {
166 if (ch === "'") single = false
167 continue
168 }
169 if (ch === '\\') {
170 i++
171 continue
172 }
173 if (ch === "'" && !double) {
174 single = true
175 continue
176 }
177 if (ch === '"') {
178 double = !double
179 continue
180 }
181 if (ch === '#' && !double && (i === 0 || /\s/.test(command[i - 1] ?? ''))) {
182 while (i + 1 < command.length && command[i + 1] !== '\n') i++
183 continue
184 }
185 if (ch === '$' && command[i + 1] === '(') {
186 let depth = 0
187 for (let j = i + 1; j < command.length; j++) {
188 if (command[j] === '(') depth++
189 else if (command[j] === ')' && --depth === 0) {
190 out.push(command.slice(i + 2, j))
191 i = j
192 break
193 }
194 }
195 continue
196 }
197 if (ch === '`') {
198 let j = i + 1
199 while (j < command.length && command[j] !== '`') j += command[j] === '\\' ? 2 : 1
200 if (j < command.length) out.push(command.slice(i + 1, j))
201 i = j
202 }
203 }
204 return out
205}
206
207function skipFlags(toks: string[], j: number, word: string): number {
208 const valued = PREFIX_VALUED[word]
209 while (j < toks.length && (toks[j] ?? '').startsWith('-') && !SEPARATORS.has(toks[j] ?? '')) j += valued?.has(toks[j] ?? '') ? 2 : 1
210 return j
211}
212
213export function cdTo(cwd: string, dir: string, home: string): string {
214 if (dir === '~' || dir.startsWith('~/')) return home && isAbsolute(home) ? resolvePath(home, dir.slice(2)) : ''
215 if (dir === '-' || /[~$`]/.test(dir)) return ''
216 if (isAbsolute(posix(dir))) return resolvePath('/', dir)
217 return cwd && isAbsolute(cwd) && !/[~$`]/.test(cwd) ? resolvePath(cwd, dir) : ''
218}
219
220export function invocations(command: string, sessionCwd: string, home: string, wanted: (head: string) => boolean): Invocation[] {
221 const text = heredocless(command)
222 const toks = tokenize(text)
223 const found: Invocation[] = []
224 let cwd = sessionCwd
225 let start = true
226 for (let i = 0; i < toks.length && found.length < MAX_CALLS; i++) {
227 const t = toks[i] ?? ''
228 if (SEPARATORS.has(t)) {
229 start = true
230 continue
231 }
232 if (!start) continue
233 start = false
234 let j = i
235 for (;;) {
236 const tok = toks[j] ?? ''
237 const word = tok.split('/').pop() ?? ''
238 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) j++
239 else if (word === 'timeout') {
240 j = skipFlags(toks, j + 1, word)
241 if (/^\d/.test(toks[j] ?? '')) j++
242 } else if (word === 'command' && /^-[A-Za-z]*[vV]/.test(toks[j + 1] ?? '')) break
243 else if (PREFIXES.has(word)) j = skipFlags(toks, j + 1, word)
244 else break
245 }
246 const head = toks[j]?.split('/').pop() ?? ''
247 const next = toks[j + 1]
248 if (head === 'cd') {
249 cwd = next === undefined || SEPARATORS.has(next) ? cdTo(cwd, '~', home) : cdTo(cwd, next, home)
250 continue
251 }
252 if (!head || !wanted(head)) continue
253 const args: string[] = []
254 let k = j + 1
255 while (k < toks.length && !SEPARATORS.has(toks[k] ?? '')) {
256 const tok = toks[k++] ?? ''
257 if (REDIRECT.test(tok)) {
258 if (!/&\d*$/.test(tok) || tok.startsWith('&')) k++
259 continue
260 }
261 args.push(tok)
262 }
263 found.push({ head, args, cwd })
264 i = k - 1
265 }
266 const keyOf = (inv: Invocation) => [inv.head, ...inv.args].join('\0')
267 const seen = new Set(found.map(keyOf))
268 for (const body of substitutions(text)) {
269 for (const inv of invocations(body, cwd, home, wanted)) {
270 const key = keyOf(inv)
271 if (seen.has(key) || found.length >= MAX_CALLS) continue
272 seen.add(key)
273 found.push(inv)
274 }
275 }
276 return found
277}
278
279export function cliCalls(command: string, cwd: string, home: string): CliCall[] {
280 const names = new Set(CLIS.flatMap(c => c.commands))
281 const out: CliCall[] = []
282 for (const inv of invocations(command, cwd, home, head => names.has(head) || head in RUNNERS)) {
283 let head = inv.head
284 let args = inv.args
285 const subs = RUNNERS[head]
286 if (subs && !names.has(head)) {
287 let k = subs.length === 0 ? 0 : subs.includes(args[0] ?? '') ? 1 : -1
288 if (k < 0) continue
289 while (k < args.length && (args[k] ?? '').startsWith('-')) k++
290 head = (args[k] ?? '').replace(/(.)@[^/]*$/, '$1')
291 args = args.slice(k + 1)
292 }
293 const cli = CLIS.find(c => c.commands.includes(head) || c.packages.includes(head))
294 if (cli) out.push({ cli, args, cwd: inv.cwd })
295 }
296 return out
297}
298
299function asksHelp(call: CliCall): boolean {
300 return call.args.some(a => a === '--help' || a === '-h')
301}
302
303export function mutates(call: CliCall): boolean {
304 const verb = call.args.find(a => !a.startsWith('-'))
305 return !asksHelp(call) && verb !== undefined && call.cli.mutating.includes(verb)
306}
307
308export function deploys(call: CliCall): boolean {
309 return !asksHelp(call) && call.args.filter(a => !a.startsWith('-')).slice(0, 2).some(a => call.cli.deploy.includes(a))
310}
311hooks/tree.ts 310 lines1import type { FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10 fg: '',
11 accent: '#5b9bd5',
12 muted: '#808a96',
13 urgent: '#d0605e',
14 selection: '#3e4451',
15}
16
17export function emptyTree(root: string): FileTree {
18 return {
19 root,
20 nodes: [],
21 expanded: [],
22 cursor: '',
23 selected: '',
24 query: '',
25 showHidden: true,
26 git: {},
27 ignored: [],
28 untrackedDirs: [],
29 diff: {},
30 counts: {},
31 status: '',
32 flash: [],
33 flashDim: [],
34 flashOn: false,
35 flashTones: {},
36 scroll: null,
37 top: '',
38 work: null,
39 }
40}
41
42export function parseTheme(toml: string): Theme {
43 const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
44 return {
45 fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
46 accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
47 muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
48 urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
49 selection: get('selection') ?? DEFAULT_THEME.selection,
50 }
51}
52
53export function join(dir: string, name: string): string {
54 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
55}
56
57let drives = false
58
59export function dirname(path: string): string {
60 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
61 const i = path.lastIndexOf('/')
62 return i <= 0 ? '/' : path.slice(0, i)
63}
64
65export function useDrives(on: boolean): void {
66 drives = on
67}
68
69export function posix(path: string): string {
70 if (!drives) return path
71 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
72}
73
74export function isAbsolute(path: string): boolean {
75 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
76}
77
78export function resolvePath(base: string, path: string): string {
79 const p = posix(path)
80 const abs = isAbsolute(p) ? p : join(base, p)
81 const head = (drives ? abs.match(/^[A-Za-z]:\//)?.[0] : undefined) ?? '/'
82 const parts: string[] = []
83 for (const seg of abs.slice(head.length).split('/')) {
84 if (seg === '' || seg === '.') continue
85 if (seg === '..') parts.pop()
86 else parts.push(seg)
87 }
88 return head + parts.join('/')
89}
90
91export function toNodes(dir: string, list: Entry[]): FileNode[] {
92 return list
93 .map(e => ({
94 id: join(dir, e.name),
95 parent: dir,
96 name: e.name,
97 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
98 hidden: e.name.startsWith('.'),
99 mtime: e.mtimeMs,
100 loaded: false,
101 }))
102 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
103}
104
105function under(id: string, set: Set<string>): boolean {
106 for (let i = id.lastIndexOf('/'); i > 0; i = id.lastIndexOf('/', i - 1)) if (set.has(id.slice(0, i))) return true
107 return false
108}
109
110export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
111 if (listed.size === 0) return nodes
112 const keep = new Map<string, FileNode>()
113 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
114 const fresh: FileNode[] = []
115 for (const kids of listed.values())
116 for (const k of kids) {
117 const old = keep.get(k.id)
118 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
119 }
120 const freshIds = new Set(fresh.map(k => k.id))
121 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
122 const out: FileNode[] = []
123 for (const n of nodes) {
124 if (listed.has(n.parent) || (gone.size > 0 && under(n.id, gone))) continue
125 out.push(listed.has(n.id) ? { ...n, loaded: true } : n)
126 }
127 const taken = new Set(out.map(n => n.id))
128 return [...out, ...fresh.filter(k => !taken.has(k.id) && !(gone.size > 0 && under(k.id, gone)))]
129}
130
131export async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
132 const out: R[] = new Array(items.length)
133 let next = 0
134 const worker = async () => {
135 while (next < items.length) {
136 const i = next++
137 out[i] = await fn(items[i] as T)
138 }
139 }
140 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
141 return out
142}
143
144export type GitStatus = {
145 git: Record<string, string>
146 ignored: string[]
147 untrackedDirs: string[]
148 files: Record<string, Change>
149}
150
151export type Change = 'new' | 'mod' | 'del'
152
153export function mapper(root: string, prefix: string): (rel: string) => string | null {
154 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
155}
156
157export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
158 const map = mapper(root, prefix)
159 const git: Record<string, string> = {}
160 const ignored: string[] = []
161 const untrackedDirs: string[] = []
162 const files: Record<string, Change> = {}
163 const parts = stdout.split('\0')
164 for (let i = 0; i < parts.length; i++) {
165 const rec = parts[i] ?? ''
166 if (rec.length < 4) continue
167 const xy = rec.slice(0, 2)
168 const raw = rec.slice(3)
169 if (xy[0] === 'R' || xy[0] === 'C') i++
170 const path = map(raw)
171 if (!path) continue
172 if (xy === '!!') {
173 ignored.push(path)
174 continue
175 }
176 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
177 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
178 const letter =
179 xy === '??'
180 ? '?'
181 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
182 ? 'U'
183 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
184 if (!letter) continue
185 git[path] = stronger(git[path], letter)
186 let dir = dirname(path)
187 while (dir.length >= root.length && dir !== '/') {
188 git[dir] = stronger(git[dir], letter)
189 if (dir === root) break
190 dir = dirname(dir)
191 }
192 }
193 return { git, ignored, untrackedDirs, files }
194}
195
196export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
197 const map = mapper(root, prefix)
198 const parts = stdout.split('\0')
199 for (let i = 0; i < parts.length; i++) {
200 const rec = parts[i] ?? ''
201 if (!rec) continue
202 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
203 if (!m) continue
204 let path = m[3] ?? ''
205 if (path === '') {
206 i += 2
207 path = parts[i] ?? ''
208 }
209 if (!path) continue
210 const add = m[1] === '-' ? 0 : Number(m[1])
211 const del = m[2] === '-' ? 0 : Number(m[2])
212 const abs = map(path)
213 if (!abs) continue
214 const prev = into[abs] ?? [0, 0]
215 into[abs] = [prev[0] + add, prev[1] + del]
216 }
217}
218
219export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
220 const out: Record<string, [number, number]> = { ...diff }
221 for (const [path, [add, del]] of Object.entries(diff)) {
222 let dir = dirname(path)
223 while (dir.length >= root.length && dir !== '/') {
224 const prev = out[dir] ?? [0, 0]
225 out[dir] = [prev[0] + add, prev[1] + del]
226 if (dir === root) break
227 dir = dirname(dir)
228 }
229 }
230 return out
231}
232
233export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
234 const out: Record<string, [number, number, number]> = {}
235 const slot = { new: 0, mod: 1, del: 2 } as const
236 for (const [path, change] of Object.entries(files)) {
237 let dir = dirname(path)
238 while (dir.length >= root.length && dir !== '/') {
239 const cur = out[dir] ?? [0, 0, 0]
240 cur[slot[change]] += 1
241 out[dir] = cur
242 if (dir === root) break
243 dir = dirname(dir)
244 }
245 }
246 return out
247}
248
249export function underAny(id: string, set: Set<string>, root: string): boolean {
250 if (set.size === 0) return false
251 let cur = id
252 while (cur.length >= root.length) {
253 if (set.has(cur)) return true
254 const up = dirname(cur)
255 if (up === cur) break
256 cur = up
257 }
258 return false
259}
260
261export function inside(root: string, path: string): boolean {
262 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
263}
264
265export function ancestorsOf(id: string, root: string): string[] {
266 const out: string[] = []
267 if (!root) return out
268 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
269 return out
270}
271
272export function visibleRows(t: FileTree, top = ''): Row[] {
273 const kids = new Map<string, FileNode[]>()
274 for (const n of t.nodes) {
275 if (!t.showHidden && n.hidden) continue
276 const list = kids.get(n.parent)
277 if (list) list.push(n)
278 else kids.set(n.parent, [n])
279 }
280 const q = t.query.trim().toLowerCase()
281 let keep: Set<string> | null = null
282 if (q) {
283 keep = new Set()
284 for (const n of t.nodes) {
285 if (!n.name.toLowerCase().includes(q)) continue
286 keep.add(n.id)
287 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
288 }
289 }
290 const open = new Set(t.expanded)
291 const rows: Row[] = []
292 const walk = (dir: string, depth: number) => {
293 for (const n of kids.get(dir) ?? []) {
294 if (keep && !keep.has(n.id)) continue
295 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
296 rows.push({ node: n, depth, open: isOpen })
297 if (isOpen) walk(n.id, depth + 1)
298 }
299 }
300 walk(top || t.root, 0)
301 return rows
302}
303
304export function stamp(ms: number): string {
305 if (!ms) return ''
306 const d = new Date(ms)
307 const p = (v: number) => String(v).padStart(2, '0')
308 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
309}
310hooks/rayfin.ts 81 lines1import type { AppInfo } from '../types'
2import { type Cli, portalUrl, yamlScalar } from './apps'
3
4type Deployment = {
5 fabricItemId: string
6 fabricWorkspaceId: string
7 hostingUrl: string
8 fabricDeepLink: string
9 deployedAt: string
10}
11
12const DEPLOYMENT_FIELDS = ['fabricItemId', 'fabricWorkspaceId', 'hostingUrl', 'fabricDeepLink', 'deployedAt'] as const
13
14export function yamlName(yaml: string): string {
15 return yamlScalar(yaml.match(/^name:(.*)$/m)?.[1] ?? '')
16}
17
18function deploymentsOf(text: string): { all: Deployment[]; error: string } {
19 if (!text.trim()) return { all: [], error: '' }
20 let parsed: unknown
21 try {
22 parsed = JSON.parse(text)
23 } catch (err) {
24 return { all: [], error: `.deployments.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
25 }
26 const map = parsed && typeof parsed === 'object' ? (parsed as { deployments?: unknown }).deployments : undefined
27 if (map === undefined) return { all: [], error: '' }
28 if (!map || typeof map !== 'object' || Array.isArray(map)) return { all: [], error: '.deployments.json: "deployments" is not an object' }
29 const bad: string[] = []
30 const all: Deployment[] = []
31 for (const [key, raw] of Object.entries(map)) {
32 if (!raw || typeof raw !== 'object') {
33 bad.push(key)
34 continue
35 }
36 const d = raw as Record<string, unknown>
37 const out = { fabricItemId: '', fabricWorkspaceId: '', hostingUrl: '', fabricDeepLink: '', deployedAt: '' }
38 for (const f of DEPLOYMENT_FIELDS) {
39 const v = d[f]
40 if (typeof v === 'string') out[f] = v
41 else if (v !== undefined && v !== null) bad.push(`${key}.${f}`)
42 }
43 all.push(out)
44 }
45 return { all, error: bad.length ? `.deployments.json: ${bad.join(', ')} ${bad.length === 1 ? 'is' : 'are'} not of the expected type` : '' }
46}
47
48export function fromRayfin(dir: string, yaml: string, deployments: string): AppInfo {
49 const name = yamlName(yaml) || dir.split('/').pop() || dir
50 const { all, error } = deploymentsOf(deployments)
51 const best = all.filter(d => d.fabricItemId).sort((a, b) => b.deployedAt.localeCompare(a.deployedAt))[0]
52 const workspace = best?.fabricWorkspaceId ?? ''
53 const item = best?.fabricItemId ?? ''
54 const deep = best?.fabricDeepLink ?? ''
55 return {
56 dir,
57 name,
58 title: name,
59 cli: 'rayfin',
60 workspace,
61 item,
62 portal: deep.startsWith('https://') ? deep : portalUrl(workspace, item),
63 hosting: best?.hostingUrl ?? '',
64 sources: [],
65 error,
66 }
67}
68
69export const rayfin: Cli = {
70 name: 'rayfin',
71 commands: ['rayfin'],
72 packages: ['@microsoft/rayfin-cli'],
73 mutating: ['init', 'new', 'create', 'link', 'up', 'deploy', 'env', 'functions', 'ai-files', 'skills', 'template', 'add', 'remove'],
74 deploy: ['up'],
75 marker: 'rayfin/rayfin.yml',
76 inputs: ['rayfin/.deployments.json'],
77 start: 'rayfin init',
78 skills: [],
79 read: async (dir, text) => fromRayfin(dir, await text('rayfin/rayfin.yml'), await text('rayfin/.deployments.json')),
80}
81types/index.d.ts 72 lines1export type FileNode = {
2 id: string
3 parent: string
4 name: string
5 kind: 'dir' | 'file' | 'link'
6 hidden: boolean
7 mtime: number
8 loaded: boolean
9}
10
11export type Source = {
12 kind: string
13 name: string
14 note: string
15 item?: string
16 children?: string[]
17}
18
19export type AppInfo = {
20 dir: string
21 name: string
22 title: string
23 cli: string
24 workspace: string
25 item: string
26 portal: string
27 hosting: string
28 sources: Source[]
29 error: string
30}
31
32export type FileTree = {
33 root: string
34 nodes: FileNode[]
35 expanded: string[]
36 cursor: string
37 selected: string
38 query: string
39 showHidden: boolean
40 git: Record<string, string>
41 ignored: string[]
42 untrackedDirs: string[]
43 diff: Record<string, [number, number]>
44 counts: Record<string, [number, number, number]>
45 status: string
46 flash: string[]
47 flashDim: string[]
48 flashOn: boolean
49 flashTones: Record<string, string>
50 scroll: number | null
51 top: string
52 work: { tone: string; n: number; at: number; until: number; lit: string[]; dim: string[] } | null
53}
54
55export type Theme = {
56 fg: string
57 accent: string
58 muted: string
59 urgent: string
60 selection: string
61}
62
63declare module 'claude-code' {
64 interface PluginState {
65 'fabric-data-app': {
66 tree: FileTree
67 apps: AppInfo[]
68 theme: Theme
69 }
70 }
71}
72