diff --git a/AGENTS.md b/AGENTS.md index c0f2826..061cc12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,11 +189,14 @@ ai-dev/ │ ├── opencode/ # OpenCode config for the Matrix Topology │ ├── opencode-lane/ # OpenCode config for the Lane Topology │ └── opencode-samples/ # Sample OpenCode configs readers copy (standard, modular, MCP) +├── mods/ # Client interface mods (one folder per client) +│ └── claude/ # Claude Code mods: one folder per mod (plugin.json + hooks module) ├── skills/ # Installable skill definitions (GitHub CLI discoverable) │ └── / # Each skill: SKILL.md + README + references/ ├── docs/ # MkDocs source — PUBLICATION ONLY, not directives │ ├── agents/ # Agent catalog pages (links to agents/ at root) │ ├── skills/ # Skills catalog (index.md) + one lightweight overview page per skill +│ ├── mods/ # Mods catalog (index.md) + one lightweight overview page per mod │ ├── harness/ # Claude Code, OpenCode, VS Code configuration guides │ └── mcp/ # MCP server documentation ├── agent-output/ # Legacy output folder — gitignored @@ -214,7 +217,7 @@ topologies are independent patterns and changes do not propagate between them. ## Documentation Synchronization -The artifact directories `agents/`, `skills/`, and `harness/` are covered by the +The artifact directories `agents/`, `skills/`, `mods/`, and `harness/` are covered by the documentation site. Whenever an item in one of these directories is added, removed, renamed, or updated, update the corresponding content page or pages under `docs/` in the same change. Keep catalog entries, descriptions, links, @@ -366,6 +369,17 @@ as local files. 4. Add the skill to `skills/README.md` and `cerebro-catalog.yaml`. 5. Run `python scripts/validate_artifact_sync.py`. +### Adding a new mod + +1. Create `mods///` with the client's plugin layout (for Claude Code: + `.claude-plugin/plugin.json`, `hooks/hooks.json`, the hooks module, and tests). +2. Create the lightweight overview page `docs/mods/.md` (what the mod does, how to load + it, and a link to `mods///` in the GitHub repo; no embedded mod code) and add + it to the `nav:` tree in `mkdocs.yml` under its client. +3. Add the mod to the catalog table in `docs/mods/index.md` and to the client's harness page + (for Claude Code, `docs/harness/claudecode/index.md`). +4. Run `python scripts/validate_artifact_sync.py`; it fails when a mod is missing from any of these. + ### Adding a new agent - **Domain specialist** (single `.agent.md` at repo root): create the file under @@ -417,7 +431,7 @@ mkdocs serve Run the relevant topology validators, the artifact synchronization validator, and the strict documentation build before committing changes to `agents/`, `harness/`, -`skills/`, or `docs/`. Resolve every failure before committing. +`skills/`, `mods/`, or `docs/`. Resolve every failure before committing. --- diff --git a/README.md b/README.md index 991d0ca..4c070d1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Practical configurations and working examples for AI-powered development tools. - **[Agents](agents/)** — Installable AI agent definitions (Lane Topology, Matrix Topology, and domain specialists), discoverable via GitHub CLI - **[Skills](skills/)** — Installable domain-specific instruction sets, discoverable via GitHub CLI +- **[Mods](docs/mods/index.md)** — Client interface additions, such as a git-aware Files pane for Claude Code - **[Harness](docs/harness/index.md)** — Claude Code, OpenCode CLI, and VS Code configurations with examples - **[MCP Servers](docs/mcp/index.md)** — Model Context Protocol integration examples @@ -29,7 +30,7 @@ Practical configurations and working examples for AI-powered development tools. **Claude Code:** -- [Claude Code](docs/harness/claudecode/index.md) — Skills, the Matrix Claude mirror, and Google Cloud Vertex AI setup +- [Claude Code](docs/harness/claudecode/index.md) — Skills, mods, the Matrix Claude mirror, and Google Cloud Vertex AI setup **OpenCode CLI:** @@ -52,11 +53,13 @@ ai-dev/ │ ├── lane-topology/ # Lane Topology (OpenCode + Copilot) │ └── *.agent.md # Domain specialist agents ├── harness/ # Client harness configs and OpenCode sample configs +├── mods/ # Client interface mods, one folder per client ├── skills/ # Installable skill definitions (GitHub CLI discoverable) │ └── / # Each skill: SKILL.md + README + references/ ├── docs/ # MkDocs documentation source (catalog & guides only) │ ├── agents/ # Agent catalog pages │ ├── skills/ # Skills catalog + one overview page per skill +│ ├── mods/ # Mods catalog + one overview page per mod │ ├── harness/ # Claude Code, OpenCode, & VS Code guides │ └── mcp/ # MCP server documentation ├── .agent-output/ # Temporary agent output (gitignored) diff --git a/docs/harness/claudecode/index.md b/docs/harness/claudecode/index.md index 15c6428..bd5f2d0 100644 --- a/docs/harness/claudecode/index.md +++ b/docs/harness/claudecode/index.md @@ -22,6 +22,15 @@ body from the canonical OpenCode definitions and translates the frontmatter to C aliases. The mirror is in [`agents/matrix-topology/claude/`](https://github.com/CowboyLogic/ai-dev/tree/main/agents/matrix-topology/claude). +## Mods + +Mods change Claude Code's own interface. The [mods catalog](../../mods/index.md) lists them and +explains how to load one. + +| Mod | Use it to | +|---|---| +| [Git-Aware File Explorer](../../mods/git-aware-file-explorer.md) | Browse the workspace with changed files marked and see a file's diff beside the tree | + ## Provider setup - [Claude Code with Google Vertex AI](claudecode-vertexai.md): run Claude Code against Claude models served through a Google Cloud project diff --git a/docs/index.md b/docs/index.md index dfa615d..c8e5eca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,6 +18,7 @@ This site complements the vendors' own documentation with ready-to-use configura - **[Agents](agents/index.md)**: two multi-agent topologies, Lane and Matrix, plus a roster of domain specialist agents (API, architecture, security, testing, and more). - **[Skills](skills/index.md)**: structured instruction sets that teach AI agents domain-specific tasks. +- **[Mods](mods/index.md)**: additions to a client's own interface, such as a git-aware Files pane for Claude Code. - **[Harness](harness/index.md)**: configurations and guides for Claude Code, OpenCode, and VS Code. - **[MCP Servers](mcp/index.md)**: working examples for Model Context Protocol server integrations. diff --git a/docs/mods/git-aware-file-explorer.md b/docs/mods/git-aware-file-explorer.md new file mode 100644 index 0000000..92626a0 --- /dev/null +++ b/docs/mods/git-aware-file-explorer.md @@ -0,0 +1,54 @@ +# Git-Aware File Explorer + +A Files pane for Claude Code that knows about git. It joins the workspace file browser and the +list of changes into one pane: the tree marks what changed, and selecting a file shows its diff +in a panel beside the tree. + +- **Mod name:** `git-aware-file-explorer` +- **Client:** Claude Code (desktop app and terminal) +- **Source:** [mods/claude/git-aware-file-explorer](https://github.com/CowboyLogic/ai-dev/tree/main/mods/claude/git-aware-file-explorer) + +--- + +## What it does + +- **Branch bar.** The current branch, the lines added and deleted across all changes, and a count of + changed files. The count is a toggle that narrows the tree to changed files only. New files count + as added lines; past 200 new files, or for a new file over 512 KB or one that cannot be read, the + added total is marked as a minimum (`+1234+`). When git's output passes 4 MB it is cut, or git cannot read a changed file, and the counts drawn from it are marked the same way. +- **Git-aware tree.** Files and folders with icons, a status letter on each changed file, and a mark + on every folder that holds a change. Ignored files are dimmed and deleted files struck through. + A filter box narrows the tree by name; it does not search inside ignored folders, and says so. + Outside git, searches stop after discovering 20,000 files and folders and report that limit. +- **Diff panel.** Selecting a changed file shows its diff against `HEAD`, with added and deleted lines + tinted and the file's own line counts in the header. Selecting an unchanged file shows a preview. + Smaller previews show the full file; larger previews use fixed sections retained across scrolls. + The branch bar, filter, tree and file content share one native scrollbar and move together. + Selecting a file brings the pane back to the top to show its header and first lines. +- **Stays current.** The pane rescans the workspace every few seconds and after each tool call that + can change files. Between changes, scrolling reuses the prepared tree and open file: it does not + run git, list folders or reread the file. Diff counts are prepared when the diff is loaded. + +## Where it applies + +Use it in a git repository when you want to watch an agent's changes land file by file without +switching between the Files and Changes views. Outside a repository it works as a plain file browser. + +The pane opens at the start of each session. `/git-files` reopens it after it is closed. + +> [!NOTE] +> The header and both columns scroll together in this version. Native scrolling avoids the desktop +> movement and snap back caused by trying to keep a mod's header fixed. Platform support for fixed +> headers and independent scrolling is tracked in [Anthropic issue #99449](https://github.com/anthropics/claude-code/issues/99449). + +--- + +## Install + +Load the mod's folder as described in [Installing a Claude Code mod](index.md#installing-a-claude-code-mod), +with `` set to `git-aware-file-explorer`. + +### Verify installation + +Start a session in a git repository. The **Files** pane opens with the branch bar at the top. If it +does not, run `/git-files`. diff --git a/docs/mods/index.md b/docs/mods/index.md new file mode 100644 index 0000000..d5302c8 --- /dev/null +++ b/docs/mods/index.md @@ -0,0 +1,49 @@ +# Mods + +Mods change an AI client's own interface: panes, panels, status lines, and slash commands that +the client does not ship with. Where a skill teaches an agent how to do a task, a mod changes +what you see and use while working with that agent. + +All mod files live at the root of the repository under +[`mods/`](https://github.com/CowboyLogic/ai-dev/tree/main/mods), in one folder per client. + +--- + +## Claude Code + +Claude Code mods are plugins built on function hooks: a `plugin.json` manifest and a TypeScript +hooks module that draws into the client's surfaces. They live under +[`mods/claude/`](https://github.com/CowboyLogic/ai-dev/tree/main/mods/claude). + +| Mod | What it does | +|---|---| +| [Git-Aware File Explorer](git-aware-file-explorer.md) | A Files pane that marks changed files in the tree and shows a file's diff beside it | + +### Installing a Claude Code mod + +Clone the repository, then load the mod's folder into Claude Code. + +```bash +git clone https://github.com/CowboyLogic/ai-dev.git +``` + +For a terminal session, pass the folder on the command line. The mod loads for that session only. + +```bash +claude --plugin-dir /path/to/ai-dev/mods/claude/ +``` + +For every session, including those the desktop app starts, name the folder in the `env` block of +`~/.claude/settings.json`. Separate several folders with `:` (`;` on Windows). + +```json +{ + "env": { + "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/ai-dev/mods/claude/" + } +} +``` + +> [!NOTE] +> The repository has no plugin marketplace yet, so `/plugin install` does not work for these mods. +> Loading the folder also means a `git pull` updates the mod. diff --git a/mkdocs.yml b/mkdocs.yml index 51c8faa..fe099db 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -115,6 +115,10 @@ nav: - Codex Configuration Manager: skills/client-config-codex.md - Copilot Configuration Manager: skills/client-config-copilotcli.md - OpenCode Configuration Manager: skills/client-config-opencode.md + - Mods: + - mods/index.md + - Claude Code: + - Git-Aware File Explorer: mods/git-aware-file-explorer.md - Harness: - harness/index.md - OpenCode: diff --git a/mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json b/mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json new file mode 100644 index 0000000..86aced8 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "git-aware-file-explorer", + "version": "0.1.0", + "description": "A git-aware Files pane: browse the workspace, see changed files highlighted, and view a file's diff in a panel beside the tree.", + "author": { + "name": "Cowboy Logic", + "email": "my.cowboy.logic@gmail.com" + }, + "types": "./types/index.d.ts" +} diff --git a/mods/claude/git-aware-file-explorer/hooks/hooks.json b/mods/claude/git-aware-file-explorer/hooks/hooks.json new file mode 100644 index 0000000..5a63f64 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx new file mode 100644 index 0000000..a9b1c6f --- /dev/null +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -0,0 +1,1117 @@ +import { atom, read, update } from 'claude-code' +import type { EngineInterface, Register } from 'claude-code' + +import type { Change, Scan } from '../types' +import { diffIndex, nativeWindow, PREVIEW_BLOCK_ROWS, previewCapacity, previewWindow, spacerHeights, visibleText } from './viewport' +import type { HunkStart } from './viewport' + +const PANE = 'git-aware-file-explorer' +const TITLE = 'Files' +const SCAN_EVERY_MS = 5000 +const PREVIEW_BYTES = 256 * 1024 +/** Leave room within the engine's drawing budget for the tree and controls. */ +const FULL_PREVIEW_CHARS = 48000 +/** Most matches a filter lists; the rest are counted, not drawn. */ +const FILTER_LIMIT = 300 +/** Files and directories a filter outside a git repository discovers, at most. */ +const WALK_LIMIT = 20000 +/** The tree column's narrowest width, in cells; its widest is half the pane. */ +const TREE_MIN_COLUMNS = 34 +/** Cells between the tree column and the file panel. */ +const GUTTER = 2 +/** Rows the branch bar takes above both sides: its line and the gap under it. */ +const BRANCH_ROWS = 2 +/** A definite row height lets offscreen tree rows keep their native layout space. */ +const TREE_ROW_ROWS = 2 +/** + * Tools whose calls can change the work tree; a scan follows each. PowerShell is the shell tool on + * Windows. A subagent's own tool calls reach the hook too, so Agent needs no entry. + */ +const WRITERS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell']) +/** Files the native Files pane leaves out too. */ +const HIDDEN = new Set(['.git', '.DS_Store', 'Thumbs.db']) +/** Row tints: a neutral grey with alpha reads in both light and dark themes. */ +const SELECTED_BG = '#8080803a' +const HOVER_BG = '#8080801f' +/** Diff line colors: light backgrounds with dark text, readable in either theme. */ +const ADDED = { bg: '#ccffd8', fg: '#116329' } +const DELETED = { bg: '#ffd7d5', fg: '#82071e' } +const scanAtom = atom({ plugin: 'git-aware-file-explorer', key: 'scan' } as const, null) +const expandedAtom = atom({ plugin: 'git-aware-file-explorer', key: 'expanded' } as const, []) +const selectedAtom = atom({ plugin: 'git-aware-file-explorer', key: 'selected' } as const, null) +const changedOnlyAtom = atom({ plugin: 'git-aware-file-explorer', key: 'changedOnly' } as const, false) +const filterAtom = atom({ plugin: 'git-aware-file-explorer', key: 'filter' } as const, '') + +type $ = EngineInterface + +type Row = { + path: string + name: string + depth: number + isDir: boolean + isOpen: boolean + change?: Change + /** Changed paths under a directory. */ + count: number + isIgnored: boolean + /** On record in git but gone from disk (deleted). */ + isMissing: boolean + /** An open folder that could not be listed, so its contents are unknown, not empty. */ + isUnreadable?: boolean +} + +/** + * Splits `git status --porcelain=v1 -z` output into changes, one per path. A path staged for + * deletion and back on disk comes as two records (`D ` and `??`); they become one, `D?`. + */ +export function parseStatus(out: string): Change[] { + const parts = out.split('\0') + const byPath = new Map() + for (let i = 0; i < parts.length; i++) { + const part = parts[i] ?? '' + if (part.length < 4) continue + const change: Change = { x: part.charAt(0), y: part.charAt(1), path: part.slice(3) } + // A rename or copy names its original path next, staged or not (an intent-to-add file). Output + // cut at 4 MiB can end before it: the record is incomplete and dropped. + if (/[RC]/.test(change.x + change.y)) { + change.from = parts[++i] + if (!change.from) continue + } + const seen = byPath.get(change.path) + if (seen && (seen.x === 'D' || change.x === 'D') && (seen.x === '?' || change.x === '?')) { + byPath.set(change.path, { x: 'D', y: '?', path: change.path }) + } else byPath.set(change.path, change) + } + return [...byPath.values()] +} + +/** A path staged for deletion and back on disk, untracked: its diff is HEAD's version against the file. */ +const isRecreated = (c: Change) => c.x === 'D' && c.y === '?' + +/** The one letter and theme color a change is drawn with. */ +export function badge(c: Change): { letter: string; color: string } { + const { x, y } = c + if (x === '?') return { letter: 'U', color: 'success' } + if (isRecreated(c)) return { letter: 'M', color: 'warning' } + if (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) { + return { letter: '!', color: 'error' } + } + const code = y !== ' ' ? y : x + if (code === 'A') return { letter: 'A', color: 'success' } + if (code === 'D') return { letter: 'D', color: 'error' } + if (code === 'R' || code === 'C') return { letter: code, color: 'suggestion' } + return { letter: code, color: 'warning' } +} + +function stageNote(c: Change): string { + if (c.x === '?') return 'untracked' + if (isRecreated(c)) return 'deletion staged, back on disk' + if (c.x !== ' ' && c.y === ' ') return 'staged' + if (c.x === ' ') return 'unstaged' + return 'partly staged' +} + +const DOC = (fill: string) => + `` + +/** A small file-type icon, by name, as SVG markup. */ +export function iconFor(name: string): string { + const lower = name.toLowerCase() + const ext = lower.includes('.') ? lower.slice(lower.lastIndexOf('.') + 1) : '' + let body: string + if (lower.startsWith('.git')) body = '' + else if (ext === 'json' || ext === 'jsonc') { + body = + '{}' + } else { + const colors: Record = { + md: '#4ea35b', mdx: '#4ea35b', yaml: '#e0604d', yml: '#e0604d', toml: '#9a8cd6', + py: '#4b8bd4', ts: '#3d8fd6', tsx: '#3d8fd6', js: '#d9c13a', jsx: '#d9c13a', mjs: '#d9c13a', + sh: '#6bab5a', zsh: '#6bab5a', bash: '#6bab5a', html: '#e07a3d', css: '#5a8fd6', + } + body = DOC(colors[ext] ?? '#8c8c8c') + } + return `${body}` +} + +/** Runs git in `root`. Paths come from git or disk, so none is read as pathspec magic (`:(exclude)x`). */ +async function git($: $, root: string, args: string[], stdin?: string) { + return $.process.run(['git', '--no-optional-locks', '--literal-pathspecs', ...args], { cwd: root, stdin }) +} + +/** + * Whether a diff failed. `--no-index` exits 1 when the files differ, and also on some errors (a + * file it can't access), which print nothing but the error. + */ +function isDiffFailed(out: { exitCode: number; stdout: string; stderr: string }) { + return out.exitCode > 1 || (out.exitCode === 1 && out.stdout === '' && out.stderr.trim() !== '') +} + +/** + * `git diff` (with `flags`) of a recreated path: its version in `base`, piped in, against the file + * on disk, so the panel and the totals see the net change. `'cut'` when that version passed 4 MiB + * and can't be piped whole. When git can't read that version or diff it, the failed run, for + * isDiffFailed to catch and the panel to show its error (`git show` fails with 128). + */ +async function recreatedDiff($: $, root: string, base: string, path: string, flags: string[]) { + const old = await git($, root, ['show', base + ':' + path]) + if (old.isStdoutTruncated) return 'cut' as const + if (old.exitCode !== 0) return old + return git($, root, ['diff', ...flags, '--no-index', '--', '/dev/stdin', path], old.stdout) +} + +async function scan($: $): Promise { + const cwd = await $.session.cwd() + try { + const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd }) + if (top.exitCode !== 0) { + // Outside a repository is no error; any other failure (dubious ownership, a broken .git) is shown. + const reason = top.stderr.trim() + const error = reason === '' || /not a git repository/i.test(reason) ? undefined : reason + return { root: cwd, isRepo: false, changes: [], ignored: [], error } + } + const root = top.stdout.trim() + const [status, ignored, branch, head] = await Promise.all([ + git($, root, ['status', '--porcelain=v1', '-z', '-uall']), + git($, root, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']), + git($, root, ['branch', '--show-current']), + git($, root, ['rev-parse', '--verify', '-q', 'HEAD']), + ]) + let name = branch.stdout.trim() + if (name === '') name = (await git($, root, ['rev-parse', '--short', 'HEAD'])).stdout.trim() + // Output past 4 MiB is cut: keep the whole entries, and mark the counts as floors. + const out = status.isStdoutTruncated ? status.stdout.slice(0, status.stdout.lastIndexOf('\0') + 1) : status.stdout + const changes = status.exitCode === 0 ? parseStatus(out) : [] + const lines = changes.length > 0 ? await lineTotals($, root, changes) : { added: 0, deleted: 0 } + return { + root, + isRepo: true, + branch: name || undefined, + head: head.stdout.trim() || undefined, + changes, + lines, + isTruncated: status.isStdoutTruncated || undefined, + // Cut the same way: whole entries only, and a filter says it may have missed some. + ignored: (ignored.isStdoutTruncated + ? ignored.stdout.slice(0, ignored.stdout.lastIndexOf('\0') + 1) + : ignored.stdout + ) + .split('\0') + .filter(Boolean), + isIgnoredTruncated: ignored.isStdoutTruncated || undefined, + // A failed status reads no changes; the error says so even when git printed nothing. + error: status.exitCode === 0 ? undefined : status.stderr.trim() || `git status exited ${status.exitCode}`, + } + } catch (err) { + return { root: cwd, isRepo: false, changes: [], ignored: [], error: String(err) } + } +} + +export type Numstat = { added: number; deleted: number; path: string } + +/** + * Entries of `git diff --numstat -z`, each path verbatim (without -z git quotes a name outside + * printable ASCII). A rename's paths follow its counts, and it is named by the new one; binary + * files (`-`) count no lines. + */ +export function parseNumstat(out: string): Numstat[] { + const entries: Numstat[] = [] + const parts = out.split('\0') + const count = (n: string) => (/^\d+$/.test(n) ? Number(n) : 0) + for (let i = 0; i < parts.length; i++) { + const record = parts[i] as string + const a = record.indexOf('\t') + const d = record.indexOf('\t', a + 1) + if (a < 0 || d < 0) continue + let path = record.slice(d + 1) + if (path === '') { + path = parts[i + 2] ?? '' + i += 2 + } + entries.push({ added: count(record.slice(0, a)), deleted: count(record.slice(a + 1, d)), path }) + } + return entries +} + +/** Lines added and deleted across numstat entries. */ +export function sumNumstat(entries: Numstat[]): { added: number; deleted: number } { + return entries.reduce((t, e) => ({ added: t.added + e.added, deleted: t.deleted + e.deleted }), { added: 0, deleted: 0 }) +} + +/** Lines in a text, as git counts them: a last line without a newline still counts. */ +export function countLines(text: string): number { + if (text === '' || text.includes('\0')) return 0 + const breaks = text.split('\n').length - 1 + return text.endsWith('\n') ? breaks : breaks + 1 +} + +/** Untracked files read for their line count, at most; larger ones are skipped. */ +const UNTRACKED_LIMIT = 200 +const UNTRACKED_BYTES = 512 * 1024 +/** Changed files whose modification time a scan reads, at most (see stampFor). */ +const STAMP_LIMIT = 200 + +/** + * What a diff compares the working tree against: HEAD, or on a branch with no commits yet the empty + * tree, so staged and unstaged changes come out as one diff and no line counts twice. + */ +async function diffBase($: $, root: string): Promise { + if ((await git($, root, ['rev-parse', '--verify', '-q', 'HEAD'])).exitCode === 0) return 'HEAD' + return (await git($, root, ['hash-object', '-t', 'tree', '/dev/null'])).stdout.trim() +} + +/** Lines added and deleted against HEAD (staged and unstaged), untracked files counted as added. */ +async function lineTotals($: $, root: string, changes: Change[]) { + const base = await diffBase($, root) + const out = await git($, root, ['diff', '--numstat', '-z', '--no-ext-diff', base]) + // Output past 4 MiB is cut: keep the whole entries (the totals are marked as floors below). + let numstat = parseNumstat(out.isStdoutTruncated ? out.stdout.slice(0, out.stdout.lastIndexOf('\0') + 1) : out.stdout) + // A recreated path counts its net change, in place of the deletion the diff against base reports. + let isUncounted = false + for (const c of changes.filter(isRecreated)) { + const net = await recreatedDiff($, root, base, c.path, ['--numstat', '-z']) + const others = numstat.filter(e => e.path !== c.path) + // Too large to diff, or git can't: neither its deletion nor its contents count, and both totals are floors. + const isMissed = net === 'cut' || isDiffFailed(net) + if (isMissed) isUncounted = true + numstat = isMissed ? others : [...others, ...parseNumstat(net.stdout)] + } + const total: { added: number; deleted: number; partial?: boolean; isTruncated?: boolean } = sumNumstat(numstat) + // A diff git could not make (an unreadable changed file) prints no counts at all: both are floors. + if (out.isStdoutTruncated || isUncounted || out.exitCode !== 0) total.isTruncated = true + const untracked = changes.filter(c => c.x === '?') + if (untracked.length > UNTRACKED_LIMIT) total.partial = true + const counted = untracked.slice(0, UNTRACKED_LIMIT) + const binary = counted.length > 0 ? await binaryPaths($, root, counted.map(c => c.path)) : new Set() + for (const c of counted) { + const path = root + '/' + c.path + const stat = await $.fs.stat(path).catch(() => undefined) + // Gone since git listed it, or unreadable: uncounted, so the total is a floor. + if (!stat) { + total.partial = true + continue + } + // git diffs a symbolic link as one line, its target, whatever it leads to. + if (stat.isLink) { + total.added += 1 + continue + } + // .gitattributes marks it binary (`binary`, `-diff`): git counts no lines for it. + if (stat.kind !== 'file' || binary.has(c.path)) continue + if (stat.size > UNTRACKED_BYTES) { + total.partial = true + continue + } + // A file that can't be read goes uncounted, so the total is a floor, not an exact zero for it. + const text = await $.fs.read(path).catch(() => undefined) + if (text === undefined) total.partial = true + else total.added += countLines(text) + } + return total +} + +/** The paths .gitattributes diffs as binary (`binary` or `-diff`); none when git can't say. */ +async function binaryPaths($: $, root: string, paths: string[]): Promise> { + const out = await git($, root, ['check-attr', '-z', 'diff', '--', ...paths]) + const parts = out.exitCode === 0 ? out.stdout.split('\0') : [] + const binary = new Set() + // Records come as path, attribute, value. + for (let i = 0; i + 2 < parts.length; i += 3) if (parts[i + 2] === 'unset') binary.add(parts[i] as string) + return binary +} + +/** + * Modification time and size of the open file, the files whose diff or preview is cached, the open + * folders and the first STAMP_LIMIT changed files. An edit that leaves git's status and line totals + * as they were (`bar` to `baz`) still changes this, so the scan that sees it redraws the pane and a + * file closed and edited does not reopen from a stale cache. While the open file's diff or preview + * can't be made, or an open folder can't be listed, every stamp differs, so each scan redraws and + * tries again. + */ +async function stampFor($: $, s: Scan): Promise { + const selected = await read($, selectedAtom) + const paths = new Set(['', ...(await read($, expandedAtom)), ...s.changes.slice(0, STAMP_LIMIT).map(c => c.path)]) + for (const p of [selected, diffCache?.path, previewCache?.path]) if (p != null) paths.add(p) + const stats = await Promise.all( + [...paths].map(p => $.fs.stat(p === '' ? s.root : s.root + '/' + p).catch(() => undefined)), + ) + const stamp = stats.map(st => (st ? st.mtimeMs + ':' + st.size : '-')).join(',') + const isRetried = + (previewCache?.isFailed && previewCache.path === selected) || + (diffCache?.isFailed && diffCache.path === selected) || + treeCache?.isFailed + return isRetried ? stamp + ',retry' + ++retries : stamp +} + +/** Counts the scans that retried a diff, preview or folder that could not be read (see stampFor). */ +let retries = 0 + +/** A short fingerprint of a file list: its length and a 32-bit hash of its paths. */ +export function fingerprint(files: string[]): string { + let hash = 5381 + for (const f of files) { + for (let i = 0; i < f.length; i++) hash = ((hash << 5) + hash + f.charCodeAt(i)) | 0 + hash = ((hash << 5) + hash) | 0 + } + return files.length + ':' + (hash >>> 0).toString(36) +} + +/** The scan running now, and whether another was asked for while it ran (see rescan). */ +let scanning: Promise | undefined +let isRescanDue = false + +/** + * Scans and writes the result only when it changed, so idle scans redraw nothing. Scans never + * overlap: one asked for while another runs is folded into a single scan after it, so an older + * scan can never finish last and overwrite a newer one. + */ +function rescan($: $): Promise { + if (scanning) { + isRescanDue = true + return scanning + } + scanning = (async () => { + try { + do { + isRescanDue = false + const found = await scan($) + // Files may have come or gone since the filter's list was made, even with the scan unchanged. + listing = undefined + // Outside git nothing else sees a file come or go in a closed folder, so a filter's walk is + // redone here (and kept for the redraw) and stamped: a new or vanished match redraws the pane. + const isWalked = !found.isRepo && (await read($, filterAtom)).trim() !== '' + const walked = isWalked ? await listFiles($, found) : undefined + const stamp = await stampFor($, found) + const next: Scan = { ...found, stamp: walked ? stamp + ',' + fingerprint(walked.files) : stamp } + // Any write redraws the pane, so an unchanged scan is not written at all. + if (JSON.stringify(await read($, scanAtom)) !== JSON.stringify(next)) await update($, scanAtom, () => next) + } while (isRescanDue) + } finally { + scanning = undefined + } + })() + return scanning +} + +/** Reveal the shared pane's top after opening it or changing its selection/filter. */ +async function settle($: $) { + await $.ui.scroll({ to: 'start', in: PANE }).catch(() => undefined) +} + +/** Changed-path counts for every directory that holds a change (`''` is the root). */ +export function rollup(changes: Change[]): Map { + const counts = new Map() + for (const c of changes) { + const segs = c.path.split('/') + for (let i = 0; i < segs.length - 1; i++) { + const dir = segs.slice(0, i + 1).join('/') + counts.set(dir, (counts.get(dir) ?? 0) + 1) + } + counts.set('', (counts.get('') ?? 0) + 1) + } + return counts +} + +function isIgnored(path: string, ignored: Set): boolean { + if (ignored.has(path) || ignored.has(path + '/')) return true + const segs = path.split('/') + for (let i = 1; i < segs.length; i++) { + if (ignored.has(segs.slice(0, i).join('/') + '/')) return true + } + return false +} + +/** + * The paths a filtered view draws, every directory on the way open: the changes alone, + * and/or the files whose path holds `filter`. Undefined for the plain lazy tree. + */ +async function pathsFor( + $: $, + s: Scan, + changedOnly: boolean, + filter: string, +): Promise<{ paths: string[]; total: number; incomplete?: string } | undefined> { + const needle = filter.trim().toLowerCase() + if (!changedOnly && needle === '') return undefined + let paths: string[] + // Why the search may have missed files, when it may have. + let incomplete: string | undefined + if (changedOnly) paths = s.changes.map(c => c.path) + else if (!s.isRepo) ({ files: paths, incomplete } = await listFiles($, s)) + else { + const listed = await listFiles($, s) + incomplete = listed.incomplete + if (!incomplete && s.isIgnoredTruncated) incomplete = "git's list of ignored files passed 4 MB; searched its start only." + // Ignored entries as the scan found them (a directory ends with `/`), so a filter narrows the + // tree without dropping what it shows dimmed; an ignored directory matches by its own path. + paths = [...new Set([...listed.files, ...s.changes.map(c => c.path), ...s.ignored])] + // The files inside an ignored directory are not listed (a .venv can hold tens of thousands). + if (needle !== '' && s.ignored.some(p => p.endsWith('/'))) { + incomplete = [incomplete, 'Files inside ignored folders are not searched.'].filter(Boolean).join(' ') + } + } + const hits = needle === '' ? paths : paths.filter(p => p.toLowerCase().includes(needle)) + return { paths: hits.slice(0, FILTER_LIMIT), total: hits.length, incomplete } +} + +/** The workspace's files as last listed for a filter; each finished scan clears it (see rescan). */ +let listing: { root: string; files: Promise<{ files: string[]; incomplete?: string }> } | undefined + +/** + * The files a filter searches, listed once per scan: a redraw (a wheel move) or a new + * filter reuses the list, and `incomplete` says why it may lack some. + */ +function listFiles($: $, s: Scan): Promise<{ files: string[]; incomplete?: string }> { + if (listing?.root !== s.root) listing = { root: s.root, files: listFilesNow($, s) } + return listing.files +} + +async function listFilesNow($: $, s: Scan): Promise<{ files: string[]; incomplete?: string }> { + if (!s.isRepo) { + const walked = await walkFiles($, s.root) + const notes = [ + walked.isCapped && `Searched the first ${WALK_LIMIT} entries (files and folders) only.`, + walked.unread === 1 && '1 folder could not be read and was not searched.', + walked.unread > 1 && `${walked.unread} folders could not be read and were not searched.`, + ] + return { files: walked.files, incomplete: notes.filter(Boolean).join(' ') || undefined } + } + const listed = await git($, s.root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']) + // Output past 4 MiB is cut: keep the whole names, and say the search stopped there. + if (!listed.isStdoutTruncated) return { files: listed.stdout.split('\0').filter(Boolean) } + return { + files: listed.stdout.slice(0, listed.stdout.lastIndexOf('\0') + 1).split('\0').filter(Boolean), + incomplete: "git's file list passed 4 MB; searched its start only.", + } +} + +/** + * File paths under `root`, breadth first, discovering at most WALK_LIMIT files and directories. + * `isCapped` says the walk stopped there, and `unread` counts the folders it could not list + * (permissions, I/O), so a search over it may miss files. + */ +async function walkFiles($: $, root: string): Promise<{ files: string[]; isCapped: boolean; unread: number }> { + const files: string[] = [] + const dirs = [''] + let unread = 0 + let seen = 0 + let isCapped = false + let i = 0 + for (; i < dirs.length && seen < WALK_LIMIT; i++) { + const dir = dirs[i] as string + const entries = await $.fs.list(root + (dir === '' ? '' : '/' + dir)).catch(() => { + unread++ + return [] + }) + for (const en of entries) { + if (HIDDEN.has(en.name)) continue + if (seen === WALK_LIMIT) { + isCapped = true + break + } + seen++ + const path = dir === '' ? en.name : dir + '/' + en.name + // A link is never walked into, so a link to an ancestor can't loop the walk. This host reports + // one as `other`; the isLink check holds on a host that reports a linked folder as a `dir`. + if (en.kind === 'dir' && !en.isLink) dirs.push(path) + else files.push(path) + } + } + return { files, isCapped: isCapped || i < dirs.length, unread } +} + +async function buildRows( + $: $, + s: Scan, + expanded: Set, + virtual: string[] | undefined, +): Promise<{ rows: Row[]; isRootUnreadable: boolean }> { + const byPath = new Map(s.changes.map(c => [c.path, c])) + const counts = rollup(s.changes) + const ignored = new Set(s.ignored) + const rows: Row[] = [] + + /** Adds the rows under `dir`; false when the disk couldn't list it. */ + async function walk(dir: string, depth: number): Promise { + const prefix = dir === '' ? '' : dir + '/' + const kids = new Map() + const source = virtual ?? s.changes.map(c => c.path) + let isListed = true + if (!virtual) { + const entries = await $.fs.list(s.root + (dir === '' ? '' : '/' + dir)).catch(() => undefined) + if (entries === undefined) isListed = false + for (const en of entries ?? []) { + if (HIDDEN.has(en.name)) continue + kids.set(en.name, { isDir: en.kind === 'dir', isMissing: false, hasKids: false }) + } + } + // In the lazy tree, changes add what the disk no longer holds (deletions). + for (const path of source) { + if (!path.startsWith(prefix)) continue + const rest = path.slice(prefix.length) + const seg = rest.split('/')[0] ?? '' + if (seg === '') continue + // A directory path ending in `/` (an ignored one) is a leaf: drawn closed, nothing under it. + const hasKids = rest.length > seg.length + 1 + const kid = kids.get(seg) + if (kid) kid.hasKids ||= hasKids + else { + const isDir = rest.includes('/') + const c = byPath.get(path) + // A recreated file (`D?`) is back on disk: not drawn as gone. + const isGone = !isDir && c !== undefined && !isRecreated(c) && (c.x === 'D' || c.y === 'D') + kids.set(seg, { isDir, isMissing: virtual ? isGone : true, hasKids }) + } + } + const sorted = [...kids].sort(([a, ka], [b, kb]) => + ka.isDir !== kb.isDir ? (ka.isDir ? -1 : 1) : a.localeCompare(b, undefined, { sensitivity: 'base' }), + ) + for (const [name, k] of sorted) { + const path = prefix + name + const isOpen = k.isDir && (virtual !== undefined ? k.hasKids : expanded.has(path)) + const row: Row = { + path, + name, + depth, + isDir: k.isDir, + isOpen, + change: byPath.get(path), + count: counts.get(path) ?? 0, + isIgnored: s.isRepo && isIgnored(path, ignored), + isMissing: k.isMissing, + } + rows.push(row) + if (isOpen && !(await walk(path, depth + 1))) row.isUnreadable = true + } + return isListed + } + + const isListed = await walk('', 0) + return { rows, isRootUnreadable: !isListed } +} + +type TreeView = { + rows: Row[] + virtual: Awaited> + widest: number + /** The workspace root itself could not be listed. */ + isRootUnreadable: boolean +} +let treeCache: { scanKey: string; viewKey: string; view: Promise; isFailed?: boolean } | undefined + +/** Scrolls reuse the prepared tree, including its filter and width, without listing directories. */ +function cachedTree($: $, s: Scan, scanKey: string, expanded: string[], changedOnly: boolean, filter: string) { + const viewKey = JSON.stringify([expanded, changedOnly, filter]) + if (treeCache?.scanKey !== scanKey || treeCache.viewKey !== viewKey) { + const view = prepareTree($, s, expanded, changedOnly, filter) + const entry: NonNullable = { scanKey, viewKey, view } + // A folder that couldn't be listed may list later with its time and size unchanged (see stampFor). + void entry.view.then(v => { + if (v.isRootUnreadable || v.rows.some(r => r.isUnreadable)) entry.isFailed = true + }) + treeCache = entry + } + return treeCache.view +} + +async function prepareTree($: $, s: Scan, expanded: string[], changedOnly: boolean, filter: string): Promise { + const virtual = await pathsFor($, s, changedOnly, filter) + const { rows, isRootUnreadable } = await buildRows($, s, new Set(expanded), virtual?.paths) + const widest = rows.reduce((w, r) => Math.max(w, r.depth * 2 + r.name.length + 8), 0) + return { rows, virtual, widest, isRootUnreadable } +} + +/** One body line of a unified diff, with the line numbers it stands at. */ +export type DiffLine = { text: string; kind: ' ' | '+' | '-'; o: number; n: number; hunk: number } + +/** Reads a unified diff's hunks into numbered lines; file headers are dropped. */ +export function diffLines(diff: string): DiffLine[] { + const out: DiffLine[] = [] + let o = 0 + let n = 0 + let hunk = -1 + for (const line of diff.split('\n')) { + const head = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line) + if (head) { + o = Number(head[1]) + n = Number(head[2]) + hunk++ + continue + } + const kind = line.charAt(0) + if (hunk < 0 || (kind !== ' ' && kind !== '+' && kind !== '-')) continue + out.push({ text: line, kind, o, n, hunk }) + if (kind !== '+') o++ + if (kind !== '-') n++ + } + return out +} + +/** + * A file's parsed diff; `isTruncated` when git's output passed 4 MiB and only its start is here, and + * `error` when git could not make it (an unreadable file). + */ +type FileDiff = { + lines: DiffLine[] + isTruncated: boolean + error?: string + counts: { added: number; deleted: number } + width: number + hunks: HunkStart[] + rows: number +} + +/** The open file's parsed diff and what it was read for, so a scroll redraws without running git. */ +let diffCache: { scanKey: string; path: string; diff: Promise; isFailed?: boolean } | undefined + +/** The parsed diff of `c`, read again only when the scan changed (a content edit changes its stamp). */ +function cachedDiff($: $, s: Scan, c: Change, scanKey: string): Promise { + if (diffCache?.scanKey !== scanKey || diffCache.path !== c.path) { + // Cache the in-flight work too: overlapping redraws share one process and one parse. + const entry: NonNullable = { scanKey, path: c.path, diff: prepareDiff($, s, c) } + // A diff git failed to make may work later with the file's status, time and size unchanged (see stampFor). + void entry.diff.then(d => { + if (d.error !== undefined) entry.isFailed = true + }) + diffCache = entry + } + return diffCache.diff +} + +async function prepareDiff($: $, s: Scan, c: Change): Promise { + const out = await diffFor($, s, c) + const lines = diffLines(out.stdout) + const counts = { added: 0, deleted: 0 } + let largest = 0 + const hunks: HunkStart[] = [] + let rows = 0 + let previousHunk = -1 + for (let i = 0; i < lines.length; i++) { + const line = lines[i] as DiffLine + if (line.hunk !== previousHunk) { + if (i > 0) rows++ + hunks.push({ line: i, row: rows }) + previousHunk = line.hunk + } + rows++ + if (line.kind === '+') counts.added++ + if (line.kind === '-') counts.deleted++ + largest = Math.max(largest, line.o, line.n) + } + const error = isDiffFailed(out) ? out.stderr.trim() || `git diff exited ${out.exitCode}` : undefined + return { lines, counts, width: String(largest).length, hunks, rows, isTruncated: out.isStdoutTruncated, error } +} + +type Preview = { lines: string[]; notice?: string; source?: string } +let previewCache: { scanKey: string; path: string; preview: Promise; isFailed?: boolean } | undefined +const UNREADABLE = 'Not a readable file.' + +function cachedPreview($: $, s: Scan, path: string, scanKey: string): Promise { + if (previewCache?.scanKey !== scanKey || previewCache.path !== path) { + const preview = preparePreview($, s.root + '/' + path) + const entry: NonNullable = { scanKey, path, preview } + // A read that failed may work later with the file's time and size unchanged (see stampFor). + void entry.preview.then(p => { + if (p.notice === UNREADABLE) entry.isFailed = true + }) + previewCache = entry + } + return previewCache.preview +} + +async function preparePreview($: $, path: string): Promise { + const stat = await $.fs.stat(path).catch(() => undefined) + if (!stat || stat.kind !== 'file') return { lines: [], notice: UNREADABLE } + if (stat.size > PREVIEW_BYTES) return { lines: [], notice: `Too large to preview (${stat.size} bytes).` } + const text = await $.fs.read(path).catch(() => undefined) + if (text === undefined) return { lines: [], notice: UNREADABLE } + if (text.includes('\0')) return { lines: [], notice: 'Binary file.' } + const lines = text === '' ? [] : text.split('\n') + if (text.endsWith('\n')) lines.pop() + // Most files can remain one Code instance with its full syntax context and natural line height. + // Reserve half the drawing budget for the rest of the pane, counting JSON escaping too. + const source = JSON.stringify(text).length <= FULL_PREVIEW_CHARS ? text : undefined + return { lines, source } +} + +async function diffFor($: $, s: Scan, c: Change) { + const flags = ['--no-color', '--no-ext-diff'] + if (c.x === '?') { + // --no-index exits 1 when the files differ, which is the point here. + return git($, s.root, ['diff', ...flags, '--no-index', '--', '/dev/null', c.path]) + } + if (isRecreated(c)) { + const net = await recreatedDiff($, s.root, await diffBase($, s.root), c.path, flags) + // A failed run is returned too, so the panel shows git's error, not the new contents as the whole change. + if (net !== 'cut') return net + const contents = await git($, s.root, ['diff', ...flags, '--no-index', '--', '/dev/null', c.path]) + // Its committed version too large to diff against: the new contents alone, marked incomplete. + return { ...contents, isStdoutTruncated: true } + } + const paths = c.from ? [c.from, c.path] : [c.path] + return git($, s.root, ['diff', ...flags, '-M', await diffBase($, s.root), '--', ...paths]) +} + +const FOLDER_ICON = + '' +const EMPTY_ICON = + '' + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + const started = await next(e) + await $.command.register({ + name: 'git-files', + description: 'Open the git-aware file explorer pane', + }) + await rescan($) + $.clock.every(SCAN_EVERY_MS, () => void rescan($)) + void $.ui.open({ id: PANE, title: TITLE }).then(() => settle($)) + return started + }) + + on('command.run', { command: 'git-files' }, async $ => { + await rescan($) + await $.ui.open({ id: PANE, title: TITLE, focus: true }) + await settle($) + return { text: 'Files pane opened.' } + }) + + on('tool.call', async ($, e, next) => { + const result = await next(e) + if (WRITERS.has(String(e.tool))) void rescan($) + return result + }).catch(($, e, next) => next(e)) + + on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { + const els = $.ui.resolve(e) + const { Box, Text, Button, Code } = els + const space = (key: string, height: number) => height <= 10000 + ? + : + {spacerHeights(height).map((part, i) => )} + + const Input = 'Input' in els ? els.Input : undefined + const Svg = 'Svg' in els ? els.Svg : undefined + // A pane drawn before the first scan landed scans for itself; only handlers and timers write. + const s = (await read($, scanAtom)) ?? (await scan($)) + const expanded = await read($, expandedAtom) + const selected = await read($, selectedAtom) + const changedOnly = await read($, changedOnlyAtom) + const filter = await read($, filterAtom) + + const scanKey = JSON.stringify(s) + const { rows, virtual, widest, isRootUnreadable } = await cachedTree($, s, scanKey, expanded, changedOnly, filter) + const change = selected === null ? undefined : s.changes.find(c => c.path === selected) + const bodyRows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : 30 + const offset = e.props.scroll.offset + const n = s.changes.length + + // The tree column fits its widest row, up to half the pane; the file panel takes the rest. + const half = Math.floor(e.props.bodyColumns / 2) + const treeColumns = Math.max(Math.min(TREE_MIN_COLUMNS, half), Math.min(widest, half)) + + const toggleDir = (path: string) => + update($, expandedAtom, list => + list.includes(path) ? list.filter(p => p !== path && !p.startsWith(path + '/')) : [...list, path], + ) + const toTop = () => settle($) + const setFilter = (value: string) => update($, filterAtom, () => value).then(toTop) + const select = (path: string) => + update($, selectedAtom, cur => (cur === path ? null : path)).then(toTop) + + // Across the whole pane: the branch on the left 70%, the change counts right-aligned in the rest. + const branchBar = ( + + + + {s.isRepo ? ( + ⎇ {s.branch ?? '?'} + ) : s.error !== undefined ? ( + git unavailable + ) : ( + not a git repo + )} + + + + {s.isRepo && n > 0 && s.lines && ( + + {/* A trailing + marks a floor: some untracked files went uncounted, or git's output was cut. */} + + +{s.lines.added} + {s.lines.partial || s.lines.isTruncated || s.isTruncated ? '+' : ''} + {' '} + + −{s.lines.deleted} + {s.lines.isTruncated || s.isTruncated ? '+' : ''} + + + )} + {s.isRepo && s.error !== undefined && status unavailable} + {s.isRepo && s.error === undefined && ( + + )} + + + ) + + const search = ( + + {Input && ( + + void setFilter(value)} + onSubmit={value => void setFilter(value)} + /> + + )} + + + + ) + + // Only the rows near the native viewport are elements. Equal-height spacers retain the full + // layout extent, so redrawing never shifts the scroll position or pins a column over it. + const treeWindow = nativeWindow(rows.length, offset - BRANCH_ROWS - 3, bodyRows, TREE_ROW_ROWS) + const tree = ( + + {treeWindow.before > 0 && space('tree-before', treeWindow.before)} + {rows.length === 0 && !isRootUnreadable && ( + {filter.trim() !== '' ? 'No matching files.' : changedOnly ? 'No changes.' : 'Empty directory.'} + )} + {rows.slice(treeWindow.start, treeWindow.end).map(r => { + const b = r.change ? badge(r.change) : undefined + const color = b?.color ?? (r.isDir && r.count > 0 ? 'warning' : undefined) + const press = () => (r.isDir ? toggleDir(r.path) : select(r.path)) + // A folder that is itself a change (a submodule) opens its diff by name; its chevron expands it. + const pressName = r.isDir && r.change ? () => select(r.path) : press + return ( + + + {r.depth > 0 && {' '.repeat(r.depth)}} + + {Svg && ( + + )} + + + {b ? ( + {b.letter} + ) : r.isDir && r.count > 0 ? ( + + ● + + ) : null} + + ) + })} + {treeWindow.after > 0 && space('tree-after', treeWindow.after)} + {/* Below the rows, so the rows keep their place: only changes git reports are drawn then. */} + {isRootUnreadable && Can't read this folder.} + {virtual && virtual.total > virtual.paths.length && ( + + Showing {virtual.paths.length} of {virtual.total} matches. + + )} + {virtual?.incomplete && {virtual.incomplete}} + + ) + + const panelColumns = Math.max(1, e.props.bodyColumns - treeColumns - GUTTER) + let body + let fileLines: { added: number; deleted: number } | undefined + let isDiffTruncated = false + if (selected === null) { + body = ( + + {Svg && } + Open files appear here + Changed files open as a diff, the rest as a preview. + + ) + } else if (change) { + const diff = await cachedDiff($, s, change, scanKey) + const lines = diff.lines + isDiffTruncated = diff.isTruncated + if (diff.error !== undefined) body = {diff.error} + else if (lines.length === 0) body = No textual difference (binary or mode change). + else { + const view = nativeWindow(diff.rows, offset - BRANCH_ROWS - 2, bodyRows) + const width = diff.width + const pad = (no: number | undefined) => (no === undefined ? '' : String(no)).padStart(width) + // Each line is a row of its own, tinted by kind; a gap between hunks costs a separator row. + const drawn = [] + for (let row = view.start; row < view.end; row++) { + const i = diffIndex(diff.hunks, row) + if (i === undefined) { + drawn.push( + + {' '.repeat(width * 2 + 2)}⋯ + , + ) + continue + } + const l = lines[i] as DiffLine + const tint = l.kind === '+' ? ADDED : l.kind === '-' ? DELETED : undefined + drawn.push( + + + {pad(l.kind === '+' ? undefined : l.o)} {pad(l.kind === '-' ? undefined : l.n)}{' '} + + + {l.kind === ' ' ? ' ' : l.kind}{' '} + + + {visibleText(l.text.slice(1), panelColumns, view.end - view.start) || ' '} + + , + ) + } + fileLines = diff.counts + body = ( + + {view.before > 0 && space('diff-before', view.before)} + {drawn} + {view.after > 0 && space('diff-after', view.after)} + + ) + } + } else { + const preview = await cachedPreview($, s, selected, scanKey) + if (preview.notice !== undefined) body = {preview.notice} + else if (preview.source !== undefined) { + body = + } else { + const lines = preview.lines + const view = previewWindow(lines.length, offset - BRANCH_ROWS - 2, bodyRows) + const blocks = [] + for (let start = view.start; start < view.end; start += PREVIEW_BLOCK_ROWS) { + const end = Math.min(lines.length, start + PREVIEW_BLOCK_ROWS) + blocks.push( + + visibleText(line, panelColumns, previewCapacity(bodyRows))).join('\n')} + path={selected} + startLine={start + 1} + wrap="truncate-end" + /> + , + ) + } + body = ( + + {view.before > 0 && space('preview-before', view.before)} + {blocks} + {view.after > 0 && space('preview-after', view.after)} + + ) + } + } + const slash = selected === null ? -1 : selected.lastIndexOf('/') + const panel = ( + + {selected !== null && ( + + + {selected.slice(slash + 1)} + {slash > 0 && {selected.slice(0, slash)}} + {change ? ( + + {' '} + · {change.from ? `from ${change.from}, ` : ''} + {stageNote(change)} + + ) : ( + · unchanged + )} + + + {fileLines && ( + + {/* A cut diff's counts are floors, as the branch bar marks them. */} + {isDiffTruncated && cut at 4 MB } + + +{fileLines.added} + {isDiffTruncated ? '+' : ''} + {' '} + + −{fileLines.deleted} + {isDiffTruncated ? '+' : ''} + + + )} + + + + )} + {body} + + ) + + return ( + + {branchBar} + + + {search} + {s.error && {s.error}} + {tree} + + {panel} + + + ) + }) +} diff --git a/mods/claude/git-aware-file-explorer/hooks/viewport.ts b/mods/claude/git-aware-file-explorer/hooks/viewport.ts new file mode 100644 index 0000000..f508849 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/hooks/viewport.ts @@ -0,0 +1,57 @@ +/** Rows prepared outside the visible region so a native wheel move has content ready to paint. */ +const OVERSCAN = 8 + +/** Keep preview Code instances in fixed blocks instead of replacing their source each line. */ +export const PREVIEW_BLOCK_ROWS = 32 + +export function previewWindow(total: number, offset: number, room: number) { + const top = Math.max(0, offset) + const buffer = PREVIEW_BLOCK_ROWS * 2 + const start = Math.min(total, Math.max(0, Math.floor(top / PREVIEW_BLOCK_ROWS) * PREVIEW_BLOCK_ROWS - buffer)) + const end = Math.min(total, Math.max(start, Math.ceil((top + room) / PREVIEW_BLOCK_ROWS) * PREVIEW_BLOCK_ROWS + buffer)) + return { start, end, before: start, after: total - end } +} + +/** A constant character budget across block transitions keeps each retained Code source stable. */ +export function previewCapacity(room: number) { + return Math.ceil(room / PREVIEW_BLOCK_ROWS) * PREVIEW_BLOCK_ROWS + PREVIEW_BLOCK_ROWS * 5 +} + +/** Individual Box dimensions are capped by the engine at 10,000 cells. */ +export function spacerHeights(rows: number) { + const heights: number[] = [] + for (let left = rows; left > 0; left -= 10000) heights.push(Math.min(10000, left)) + return heights +} + +/** Replace offscreen rows with spacers of the same height; never move the native scroll offset. */ +export function nativeWindow(total: number, offset: number, room: number, rowHeight = 1) { + const top = Math.max(0, offset) + const start = Math.min(total, Math.max(0, Math.floor(top / rowHeight) - OVERSCAN)) + const end = Math.min(total, Math.max(start, Math.ceil((top + room) / rowHeight) + OVERSCAN)) + return { start, end, before: start * rowHeight, after: (total - end) * rowHeight } +} + +/** A hunk's first data line and its visual row, including the separators before it. */ +export type HunkStart = { line: number; row: number } + +/** Locate a visual diff row without walking the preceding lines. Undefined names a separator. */ +export function diffIndex(hunks: HunkStart[], row: number): number | undefined { + let low = 0 + let high = hunks.length + while (low < high) { + const mid = Math.floor((low + high) / 2) + if ((hunks[mid]?.row ?? 0) <= row) low = mid + 1 + else high = mid + } + const next = hunks[low] + if (next && row === next.row - 1) return undefined + const hunk = hunks[low - 1] + return hunk ? hunk.line + row - hunk.row : undefined +} + +/** Only the visible prefix can be drawn; keep long lines within the engine's drawing budget too. */ +export function visibleText(text: string, columns: number, rows: number) { + const limit = Math.max(1, Math.min(Math.max(20, columns * 2), Math.floor(30000 / Math.max(1, rows)))) + return text.length > limit ? text.slice(0, limit) + '…' : text +} diff --git a/mods/claude/git-aware-file-explorer/tests/diff.test.ts b/mods/claude/git-aware-file-explorer/tests/diff.test.ts new file mode 100644 index 0000000..ac9effc --- /dev/null +++ b/mods/claude/git-aware-file-explorer/tests/diff.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { countLines, diffLines, parseNumstat, parseStatus, sumNumstat } from '../hooks/register' +import { diffIndex, nativeWindow, previewCapacity, previewWindow, spacerHeights, visibleText } from '../hooks/viewport' + +const DIFF = [ + 'diff --git a/f b/f', + '--- a/f', + '+++ b/f', + '@@ -1,3 +1,3 @@', + ' one', + '-two', + '+TWO', + ' three', + '@@ -10,2 +10,3 @@', + ' ten', + '+ten and a half', + ' eleven', +].join('\n') + +describe('diff windowing', () => { + test('numbers body lines and drops file headers', async () => { + const lines = diffLines(DIFF) + expect(lines.length).toBe(7) + expect(lines[4]).toEqual({ text: ' ten', kind: ' ', o: 10, n: 10, hunk: 1 }) + }) + + test('numstat sums lines and skips binary files', async () => { + expect(sumNumstat(parseNumstat(['3\t1\ta.md', '-\t-\tlogo.png', '2\t0\tb.ts', ''].join('\0')))).toEqual({ added: 5, deleted: 1 }) + }) + + test('numstat paths come verbatim, a rename named by its new path', async () => { + expect(parseNumstat(['1\t0\tcafé\tx.md', '4\t2\t', 'old.ts', 'new.ts', ''].join('\0')).map(e => e.path)).toEqual(['café\tx.md', 'new.ts']) + }) + + test('status reads the original path of a rename in either column', async () => { + // An unstaged rename (an intent-to-add file) names its original path too. + expect(parseStatus(' R new.ts\0old.ts\0 M README.md\0')).toEqual([ + { x: ' ', y: 'R', path: 'new.ts', from: 'old.ts' }, + { x: ' ', y: 'M', path: 'README.md' }, + ]) + }) + + test('status drops a rename whose original path was cut off', async () => { + // Output cut at 4 MiB and trimmed to its last NUL can end between a rename's two fields. + expect(parseStatus(' M README.md\0R new.ts\0')).toEqual([{ x: ' ', y: 'M', path: 'README.md' }]) + }) + + test('lines count as git counts them', async () => { + expect(countLines('')).toBe(0) + expect(countLines('a\nb\n')).toBe(2) + expect(countLines('a\nb')).toBe(2) + }) +}) + +describe('native viewport', () => { + test('aligns preview ranges to fixed blocks and keeps their height and budget bounded', () => { + for (let offset = 0; offset < 1100; offset++) { + const view = previewWindow(1000, offset, 20) + expect(view.before + view.end - view.start + view.after).toBe(1000) + expect(view.end - view.start).toBeLessThanOrEqual(previewCapacity(20)) + if (view.start < 1000) expect(view.start % 32).toBe(0) + } + expect(previewWindow(1000, 96, 20)).toEqual(previewWindow(1000, 97, 20)) + }) + test('splits tall offscreen regions into dimensions the engine accepts', () => { + expect(spacerHeights(20001)).toEqual([10000, 10000, 1]) + expect(spacerHeights(0)).toEqual([]) + expect(spacerHeights(2000000).reduce((sum, height) => sum + height, 0)).toBe(2000000) + }) + test('keeps the total height constant at every scroll position', () => { + for (const height of [1, 2]) { + for (const offset of [-5, 0, 20, 500, 2000, 4000]) { + const view = nativeWindow(1000, offset, 20, height) + expect(view.before + (view.end - view.start) * height + view.after).toBe(1000 * height) + expect(view.end - view.start).toBeLessThanOrEqual(36) + } + } + }) + + test('keeps an empty or completed column at its original height', () => { + expect(nativeWindow(0, 100, 20)).toEqual({ start: 0, end: 0, before: 0, after: 0 }) + expect(nativeWindow(10, 100, 20, 2)).toEqual({ start: 10, end: 10, before: 20, after: 0 }) + }) + + test('locates hunk separators and numbered lines by visual row', () => { + const hunks = [{ line: 0, row: 0 }, { line: 4, row: 5 }] + expect(Array.from({ length: 8 }, (_, row) => diffIndex(hunks, row))).toEqual([0, 1, 2, 3, undefined, 4, 5, 6]) + }) + + test('bounds long lines without changing short lines', () => { + expect(visibleText('short', 80, 20)).toBe('short') + expect(visibleText('x'.repeat(30000), 80, 20).length).toBeLessThanOrEqual(161) + expect(visibleText('x'.repeat(30000), 10000, 100).length).toBeLessThanOrEqual(301) + }) +}) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts new file mode 100644 index 0000000..dbcfeb3 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -0,0 +1,1271 @@ +import { describe, expect, mock, test } from 'claude-code/testing' +import type { FsEntry, On } from 'claude-code' + +const ROOT = '/repo' +const DIFF = [ + 'diff --git a/README.md b/README.md', + '--- a/README.md', + '+++ b/README.md', + '@@ -1,2 +1,2 @@', + ' # Title', + '-old line', + '+new line', + '', +].join('\n') + +const ok = (stdout: string, exitCode = 0, isStdoutTruncated = false) => ({ + value: { + exitCode, + stdout, + stderr: '', + isStdoutTruncated, + isStderrTruncated: false, + }, +}) + +const file = (name: string): FsEntry => ({ name, kind: 'file' as const, size: 10, mtimeMs: 1, isLink: false }) +const dir = (name: string): FsEntry => ({ name, kind: 'dir' as const, size: 0, mtimeMs: 0, isLink: false }) + +const DISK: Record = { + [ROOT]: [file('README.md'), dir('src'), dir('docs'), dir('node_modules'), dir('.git')], + [ROOT + '/src']: [file('a.ts'), file('new.ts')], + [ROOT + '/docs']: [file('index.md')], +} + +const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + +type Live = { + diff: string + mtimeMs: number + diffRuns?: number + lists?: number + reads?: number + stats?: number + processes?: number + text?: string + /** Modification times of single files, by path from the root, in place of `mtimeMs`. */ + fileMtimes?: Record + /** The commit HEAD names, and git's list of files, in place of the defaults. */ + head?: string + files?: string + /** A path from the root that can't be read for now, and a folder that can't be listed for now ('' the root). */ + unreadable?: string + unlistable?: string + /** git's error for README.md's diff, failing for now. */ + diffError?: string + /** Every process run, as its argv. */ + runs?: (readonly string[])[] +} + +/** + * A repository with one modified, one untracked and one deleted file. Without `hasHead` it has no + * commits yet, and README.md is staged with its second line then changed again unstaged. + * `onStatus` runs inside each `git status`, the call every scan makes first; `text` is every file's. + * `live` is read on each call: README.md's diff and every file's modification time; `diffRuns` + * counts the diffs of README.md run. With `isCut`, git's status, line counts, file list and diff + * come back cut at the 4 MiB limit, as `process.run` reports them. With `isRecreated`, gone.txt is + * staged for deletion and back on disk with its second line changed, and with `isBlobCut` too its + * committed version passes 4 MiB; with `isBlobMissing`, git cannot show it. With `isUnreadable`, no file can be read. With `isIgnoredCut`, + * the list of ignored paths comes back cut. With `isNewGone`, src/new.ts is gone by the time it is + * looked at, and with `isNewLink` it is a symbolic link to a file. With `isDiffFailed`, git cannot read README.md, so every diff against the base fails. + * With `isNewBinary`, .gitattributes marks src/new.ts binary. With `isSrcUnreadable`, src can't be listed; with `isSrcSubmodule`, src is a changed submodule. With `statusError`, git status fails with that message. `gone` names the deleted file; git's line counts quote + * it outside printable ASCII unless asked for `-z`. + */ +function fakeRepo( + on: On, + { + fileSize = 4, + hasHead = true, + disk = DISK, + onStatus, + text = 'a\nb\n', + live = { diff: DIFF, mtimeMs: 1 } as Live, + isCut = false, + isRecreated = false, + isUnreadable = false, + isBlobCut = false, + isBlobMissing = false, + isIgnoredCut = false, + gone = 'gone.txt', + isNewGone = false, + isNewLink = false, + isSrcUnreadable = false, + isSrcSubmodule = false, + isNewBinary = false, + statusError, + isDiffFailed = false, + }: { + fileSize?: number + hasHead?: boolean + disk?: Record + onStatus?: () => Promise + text?: string + live?: Live + isCut?: boolean + isRecreated?: boolean + isUnreadable?: boolean + isBlobCut?: boolean + isBlobMissing?: boolean + isIgnoredCut?: boolean + gone?: string + isNewGone?: boolean + isNewLink?: boolean + isSrcUnreadable?: boolean + isSrcSubmodule?: boolean + isNewBinary?: boolean + statusError?: string + isDiffFailed?: boolean + } = {}, +) { + // Quoted as git quotes it in output without -z (its octal escapes left out here). + const quoted = /^[\x20-\x7e]*$/.test(gone) ? gone : `"${gone}"` + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => { + live.lists = (live.lists ?? 0) + 1 + const unlistable = live.unlistable === undefined ? undefined : live.unlistable === '' ? ROOT : ROOT + '/' + live.unlistable + if ((isSrcUnreadable && e.path === ROOT + '/src') || e.path === unlistable) { + throw new Error('EACCES: permission denied') + } + return { value: disk[e.path] ?? [] } + }) + on('fs.stat', ($, e) => { + live.stats = (live.stats ?? 0) + 1 + if (isNewGone && e.path.endsWith('/src/new.ts')) throw new Error('ENOENT: no such file or directory') + const isLink = isNewLink && e.path.endsWith('/src/new.ts') + const mtimeMs = live.fileMtimes?.[e.path.slice(ROOT.length + 1)] ?? live.mtimeMs + return { value: { kind: 'file' as const, size: fileSize, mtimeMs, isLink } } + }) + on('fs.read', ($, e) => { + live.reads = (live.reads ?? 0) + 1 + if (isUnreadable || e.path === ROOT + '/' + live.unreadable) throw new Error('EACCES: permission denied') + return { value: live.text ?? text } + }) + on('process.run', async ($, e) => { + live.processes = (live.processes ?? 0) + 1 + live.runs = [...(live.runs ?? []), e.argv] + const args = e.argv.filter(a => a !== '--no-optional-locks' && a !== '--literal-pathspecs').slice(1).join(' ') + const z = e.argv.includes('-z') + if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') + if (args.startsWith('status')) { + await onStatus?.() + if (statusError !== undefined) return { value: { ...ok('', 128).value, stderr: statusError } } + if (isSrcSubmodule) return ok(' M README.md\0 M src\0') + if (isRecreated) return ok(` M README.md\0?? src/new.ts\0D ${gone}\0?? ${gone}\0`) + return isCut ? ok(' M README.md\0?? src/new.ts\0 D go', 0, true) : ok(` M README.md\0?? src/new.ts\0 D ${gone}\0`) + } + if (args.startsWith('ls-files') && args.includes('--ignored')) { + return isIgnoredCut ? ok('node_modules/\0vend', 0, true) : ok('node_modules/\0') + } + if (args.startsWith('ls-files')) return isCut ? ok('README.md\0src/a.ts\0docs/ind', 0, true) : ok(live.files ?? 'README.md\0src/a.ts\0docs/index.md\0') + if (args === 'branch --show-current') return ok('feat/x\n') + if (args.startsWith('check-attr')) return ok(isNewBinary ? 'src/new.ts\0diff\0unset\0' : 'src/new.ts\0diff\0unspecified\0') + if (args === `show HEAD:${gone}` && isBlobMissing) return { value: { ...ok('', 128).value, stderr: `fatal: bad object HEAD:${gone}` } } + if (args === `show HEAD:${gone}`) return isBlobCut ? ok('a\nb', 0, true) : ok('a\nb\nc\n') + if (args.endsWith(`--no-index -- /dev/null ${gone}`)) return ok(`diff --git a/${gone} b/${gone}\n@@ -0,0 +1,3 @@\n+a\n+B\n+c\n`, 1) + if (args.endsWith(`--no-index -- /dev/stdin ${gone}`) && e.init?.stdin === 'a\nb\nc\n') { + if (args.includes('--numstat')) return z ? ok(`1\t1\t\0/dev/stdin\0${gone}\0`, 1) : ok(`1\t1\t/dev/stdin => ${quoted}\n`, 1) + return ok(`diff --git a/dev/stdin b/${gone}\n--- a/dev/stdin\n+++ b/${gone}\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n`, 1) + } + if (args.startsWith('rev-parse --verify')) return hasHead ? ok((live.head ?? 'abc') + '\n') : ok('', 1) + if (args === 'hash-object -t tree /dev/null') return ok(EMPTY_TREE + '\n') + const end = z ? '\0' : '\n' + if (isNewGone && args.endsWith('--no-index -- /dev/null src/new.ts')) { + return { value: { ...ok('', 1).value, stderr: "error: Could not access 'src/new.ts'" } } + } + if (isDiffFailed && args.startsWith('diff') && !args.includes('--no-index')) { + return { value: { ...ok('', 128).value, stderr: 'fatal: cannot hash README.md' } } + } + if (args.startsWith('diff --numstat') && args.endsWith(EMPTY_TREE)) return ok('2\t0\tREADME.md' + end) + if (args.startsWith('diff --numstat')) { + return ok('1\t1\tREADME.md' + end + '0\t3\t' + (z ? gone : quoted) + end, 0, isCut) + } + if (args.includes('README.md')) { + live.diffRuns = (live.diffRuns ?? 0) + 1 + if (live.diffError !== undefined) return { value: { ...ok('', 128).value, stderr: live.diffError } } + return ok(live.diff, 0, isCut) + } + return ok('', 1) + }) +} + +const PROPS = { + title: 'Files', + isFocused: false, + bodyColumns: 80, + placement: 'dock' as const, + scroll: { offset: 0, bodyRows: 60 }, + view: {}, +} + +describe('git-aware-file-explorer pane', () => { + for (const surface of ['terminal', 'desktop'] as const) { + test(`shows changes in the tree and a diff beside it (${surface})`, async ($, on) => { + fakeRepo(on) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface, + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + expect(await ui.find({ key: 'node:README.md' })).toBeDefined() + expect(await ui.find({ key: 'node:gone.txt' })).toBeDefined() + expect(await ui.find({ key: 'node:src/new.ts' })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: /feat\/x/ })).toBeDefined() + // One added in README.md, two in the untracked src/new.ts; one and three deleted. + expect(await ui.find({ type: 'Text', text: '+3' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−4' })).toBeDefined() + + await ui.press({ key: 'node:src' }) + expect(await ui.find({ key: 'node:src/new.ts' })).toBeDefined() + + expect(await ui.find({ type: 'Text', text: 'Open files appear here' })).toBeDefined() + + await ui.press({ key: 'node:README.md' }) + expect(await ui.find({ type: 'Text', text: 'new line' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: 'old line' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '+1' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−1' })).toBeDefined() + + await ui.press({ key: 'close-panel' }) + expect(await ui.find({ type: 'Text', text: 'new line' })).toBeUndefined() + + await ui.press({ key: 'changes' }) + expect(await ui.find({ key: 'node:docs' })).toBeUndefined() + expect(await ui.find({ key: 'node:src/new.ts' })).toBeDefined() + expect(await ui.find({ key: 'node:gone.txt' })).toBeDefined() + + await ui.input({ key: 'filter', text: 'new', kind: 'change' }) + expect(await ui.find({ key: 'node:src/new.ts' })).toBeDefined() + expect(await ui.find({ key: 'node:README.md' })).toBeUndefined() + + // Out of the changed-only view, a filter still finds what the tree shows dimmed. + await ui.press({ key: 'changes' }) + await ui.input({ key: 'filter', text: 'node_mod', kind: 'change' }) + expect(await ui.find({ key: 'node:node_modules' })).toBeDefined() + expect(await ui.find({ key: 'node:README.md' })).toBeUndefined() + + await ui.unmount() + }) + } + + test('marks the added total as a minimum when a new file is too large to count', async ($, on) => { + fakeRepo(on, { fileSize: 1024 * 1024 }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + expect(await ui.find({ type: 'Text', text: '+1+' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−4' })).toBeDefined() + + await ui.unmount() + }) + + test('counts lines once on a branch with no commits yet', async ($, on) => { + fakeRepo(on, { hasHead: false }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // README.md's 2 staged lines and src/new.ts's 2 untracked ones; the unstaged edit adds none. + expect(await ui.find({ type: 'Text', text: '+4' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−0' })).toBeDefined() + + await ui.unmount() + }) + + test('redraws the open diff when an edit keeps the line counts', async ($, on) => { + const live = { diff: DIFF, mtimeMs: 1 } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:README.md' }) + await ui.press({ key: 'refresh' }) + expect(await ui.find({ type: 'Text', text: 'new line' })).toBeDefined() + + // The same +1 −1, a different line: only the file's modification time says it changed. + live.diff = DIFF.replace('+new line', '+newer line') + live.mtimeMs = 2 + await ui.press({ key: 'refresh' }) + expect(await ui.find({ type: 'Text', text: 'newer line' })).toBeDefined() + + await ui.unmount() + }) + + test('reads a diff once while the scan stays the same', async ($, on) => { + const live: { diff: string; mtimeMs: number; diffRuns?: number } = { diff: DIFF, mtimeMs: 1 } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:README.md' }) + const runs = live.diffRuns + // What a native scroll does: draw the pane again with nothing in the workspace changed. + for (let i = 0; i < 3; i++) await ui.redraw() + expect(live.diffRuns).toBe(runs) + + await ui.unmount() + }) + + for (const selected of ['README.md', 'src/a.ts']) { + test(`scrolling ${selected} performs no filesystem or git calls`, async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1 } + fakeRepo(on, { live, text: Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n') }) + on('ui.scroll', () => ({})) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: { ...PROPS, scroll: { offset: 0, bodyRows: 20 } }, + }) + // Initialize the scan atom as session.start does in the app. + await ui.press({ key: 'refresh' }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'node:' + selected }) + const before = [live.lists, live.reads, live.stats, live.processes] + for (let i = 0; i < 3; i++) { + await $.ui.scroll({ + component: 'Pane', requestId: 'git-aware-file-explorer', offset: 1, by: 1, + bodyRows: 20, contentRows: 100, origin: { kind: 'person' }, + }) + await ui.redraw({ ...PROPS, scroll: { offset: (i + 1) * 10, bodyRows: 20 } }) + } + expect([live.lists, live.reads, live.stats, live.processes]).toEqual(before) + await ui.unmount() + }) + } + + test('shows the current preview of a file edited while closed', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1 } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: PROPS, + }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'refresh' }) + await ui.press({ key: 'node:src/a.ts' }) + await ui.press({ key: 'node:src/a.ts' }) + // Edited while closed: only its own time moves, and git's status and line totals stay the same. + live.fileMtimes = { 'src/a.ts': 2 } + live.text = 'edited\nclosed\n' + await ui.press({ key: 'refresh' }) + await ui.press({ key: 'node:src/a.ts' }) + + expect((await ui.find({ type: 'Code' }))?.props.source).toBe('edited\nclosed\n') + + await ui.unmount() + }) + + test('retries a preview that could not be read on the next scan', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1, unreadable: 'src/a.ts' } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: PROPS, + }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'node:src/a.ts' }) + await ui.press({ key: 'refresh' }) + expect(await ui.find({ type: 'Text', text: 'Not a readable file.' })).toBeDefined() + + // Readable again, with its time and size unchanged. + live.unreadable = undefined + await ui.press({ key: 'refresh' }) + + expect((await ui.find({ type: 'Code' }))?.props.source).toBe('a\nb\n') + + await ui.unmount() + }) + + test('invalidates a cached preview when its scan stamp changes', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1 } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: PROPS, + }) + await ui.press({ key: 'refresh' }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'node:src/a.ts' }) + const reads = live.reads ?? 0 + live.mtimeMs++ + live.text = 'updated preview' + await ui.press({ key: 'refresh' }) + // One untracked-file read for the totals, plus the refreshed preview. + expect(live.reads).toBe(reads + 2) + expect((await ui.find({ type: 'Code' }))?.props.source).toBe('updated preview') + const after = live.reads + await ui.redraw() + expect(live.reads).toBe(after) + await ui.unmount() + }) + + test('opens a 4 MB diff of two million lines', async ($, on) => { + const added = Array.from({ length: 2_000_000 }, () => '+') + const diff = ['diff --git a/README.md b/README.md', '@@ -0,0 +1,2000000 @@', ...added, ''].join('\n') + fakeRepo(on, { live: { diff, mtimeMs: 1 } }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:README.md' }) + expect(await ui.find({ type: 'Text', text: '+2000000' })).toBeDefined() + await ui.redraw({ ...PROPS, scroll: { offset: 1000000, bodyRows: 60 } }) + expect(await ui.find({ key: 'line:999988' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '+2000000' })).toBeDefined() + + await ui.unmount() + }) + + test('marks counts as floors when git output was cut', async ($, on) => { + fakeRepo(on, { isCut: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // The cut entry (` D go`) is dropped, not read as a file named `go`. + expect(await ui.find({ type: 'Text', text: '● 2+ changed' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−4+' })).toBeDefined() + await ui.press({ key: 'node:README.md' }) + expect(await ui.find({ type: 'Text', text: /cut at 4 MB/ })).toBeDefined() + + await ui.unmount() + }) + + test('says when a filter searched a cut file list', async ($, on) => { + fakeRepo(on, { isCut: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // The cut name (`docs/ind`) is dropped, not matched as a file of that name. + await ui.input({ key: 'filter', text: 'ind', kind: 'change' }) + expect(await ui.find({ type: 'Text', text: 'No matching files.' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /searched its start only/ })).toBeDefined() + + await ui.unmount() + }) + + test('shows a file staged for deletion and recreated as one net change', async ($, on) => { + fakeRepo(on, { isRecreated: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // README.md +1 −1, gone.txt's net +1 −1 in place of its −3, src/new.ts's 2 untracked lines. + expect(await ui.find({ type: 'Text', text: '● 3 changed' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '+4' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−2' })).toBeDefined() + await ui.press({ key: 'node:gone.txt' }) + expect(await ui.find({ type: 'Text', text: 'B' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /deletion staged, back on disk/ })).toBeDefined() + + await ui.unmount() + }) + + test('counts a recreated file once when git quotes its name', async ($, on) => { + fakeRepo(on, { isRecreated: true, gone: 'café.txt' }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // café.txt's net +1 −1 replaces its −3, as for any other name, and is not added beside it. + expect(await ui.find({ type: 'Text', text: '+4' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−2' })).toBeDefined() + + await ui.unmount() + }) + + test('marks the added total as a minimum when a new file cannot be read', async ($, on) => { + fakeRepo(on, { isUnreadable: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // README.md's 1 added line; src/new.ts went uncounted, so the total is a floor. + expect(await ui.find({ type: 'Text', text: '+1+' })).toBeDefined() + + await ui.unmount() + }) + + test('marks the added total as a minimum when a new file is gone before it is counted', async ($, on) => { + fakeRepo(on, { isNewGone: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // README.md's 1 added line; src/new.ts went uncounted, so the total is a floor. + expect(await ui.find({ type: 'Text', text: '+1+' })).toBeDefined() + + await ui.unmount() + }) + + test("shows git's error when a new file is gone before its diff opens", async ($, on) => { + fakeRepo(on, { isNewGone: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'node:src/new.ts' }) + + // --no-index exits 1 for this error too, as it does when the files differ. + expect(await ui.find({ type: 'Text', text: /No textual difference/ })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: "error: Could not access 'src/new.ts'" })).toBeDefined() + + await ui.unmount() + }) + + test('says a folder it cannot list is unreadable, not empty', async ($, on) => { + fakeRepo(on, { isSrcUnreadable: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:src' }) + + expect(await ui.find({ type: 'Text', text: ' (unreadable)' })).toBeDefined() + + await ui.unmount() + }) + + test('passes file names to git literally, never as pathspec magic', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1 } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:README.md' }) + + // A name like `:(exclude)foo` would otherwise select other files. + const repoCalls = (live.runs ?? []).filter(a => a.join(' ') !== 'git rev-parse --show-toplevel') + expect(repoCalls.some(a => a.includes('README.md'))).toBe(true) + expect(repoCalls.every(a => a.includes('--literal-pathspecs'))).toBe(true) + + await ui.unmount() + }) + + test('opens a changed submodule by its name and expands it by its chevron', async ($, on) => { + fakeRepo(on, { isSrcSubmodule: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + await ui.press({ key: 'node:src' }) + expect(await ui.find({ key: 'close-panel' })).toBeDefined() + expect(await ui.find({ key: 'node:src/a.ts' })).toBeUndefined() + await ui.press({ key: 'chevron:src' }) + expect(await ui.find({ key: 'node:src/a.ts' })).toBeDefined() + + await ui.unmount() + }) + + test('lists a folder it could not list again on the next scan', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1, unlistable: 'src' } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'refresh' }) + expect(await ui.find({ type: 'Text', text: ' (unreadable)' })).toBeDefined() + + // Listable again, with its time and size unchanged. + live.unlistable = undefined + await ui.press({ key: 'refresh' }) + + expect(await ui.find({ key: 'node:src/a.ts' })).toBeDefined() + + await ui.unmount() + }) + + test('counts no lines for a new file .gitattributes marks binary, as git does', async ($, on) => { + fakeRepo(on, { isNewBinary: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // README.md's 1 added line; git counts no lines for src/new.ts, so the total is exact. + expect(await ui.find({ type: 'Text', text: '+1' })).toBeDefined() + + await ui.unmount() + }) + + test('says the workspace cannot be read, not that it is empty, and lists it once it can', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1, unlistable: '' } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'refresh' }) + expect(await ui.find({ type: 'Text', text: "Can't read this folder." })).toBeDefined() + expect(await ui.find({ type: 'Text', text: 'Empty directory.' })).toBeUndefined() + + live.unlistable = undefined + await ui.press({ key: 'refresh' }) + + expect(await ui.find({ key: 'node:src' })).toBeDefined() + + await ui.unmount() + }) + + test('retries a diff git could not make on the next scan', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1, diffError: 'fatal: unable to read README.md' } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:README.md' }) + await ui.press({ key: 'refresh' }) + expect(await ui.find({ type: 'Text', text: 'fatal: unable to read README.md' })).toBeDefined() + + // git can make it again, with the file's status, time and size unchanged. + live.diffError = undefined + await ui.press({ key: 'refresh' }) + + expect(await ui.find({ type: 'Text', text: 'fatal: unable to read README.md' })).toBeUndefined() + + await ui.unmount() + }) + + test('counts a new symbolic link as one added line, as git does', async ($, on) => { + fakeRepo(on, { isNewLink: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // README.md's 1 added line and src/new.ts's link target as 1, not the 2 lines of the file it leads to. + expect(await ui.find({ type: 'Text', text: '+2' })).toBeDefined() + + await ui.unmount() + }) + + for (const statusError of ['fatal: index file corrupt', '']) { + test(`says the status is unavailable when git status fails (${statusError || 'no message'})`, async ($, on) => { + fakeRepo(on, { statusError }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // No changes were read, which is not the same as a clean tree. + expect(await ui.find({ type: 'Text', text: 'clean' })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: 'status unavailable' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: statusError || 'git status exited 128' })).toBeDefined() + + await ui.unmount() + }) + } + + test('says when git could not diff a changed file', async ($, on) => { + fakeRepo(on, { isDiffFailed: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // The failed line counts give no lines at all: what is left is src/new.ts's 2, and both are floors. + expect(await ui.find({ type: 'Text', text: '+2+' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−0+' })).toBeDefined() + await ui.press({ key: 'node:README.md' }) + expect(await ui.find({ type: 'Text', text: /No textual difference/ })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: 'fatal: cannot hash README.md' })).toBeDefined() + + await ui.unmount() + }) + + test('marks a recreated file too large to diff as incomplete', async ($, on) => { + fakeRepo(on, { isRecreated: true, isBlobCut: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // gone.txt counts neither its deletion nor its contents; both totals are floors, the count exact. + expect(await ui.find({ type: 'Text', text: '+3+' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−1+' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '● 3 changed' })).toBeDefined() + await ui.press({ key: 'node:gone.txt' }) + expect(await ui.find({ type: 'Text', text: /cut at 4 MB/ })).toBeDefined() + + await ui.unmount() + }) + + test('marks both totals as floors when git cannot diff a recreated file', async ($, on) => { + fakeRepo(on, { isRecreated: true, isBlobMissing: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // gone.txt's net change is unknown: neither its −3 deletion nor its contents count, both are floors. + expect(await ui.find({ type: 'Text', text: '+3+' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '−1+' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: '● 3 changed' })).toBeDefined() + + await ui.unmount() + }) + + test("shows git's error when it cannot read a recreated file's committed version", async ($, on) => { + fakeRepo(on, { isRecreated: true, isBlobMissing: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:gone.txt' }) + + // Not the new contents alone, drawn as if they were the whole change. + expect(await ui.find({ type: 'Text', text: 'fatal: bad object HEAD:gone.txt' })).toBeDefined() + + await ui.unmount() + }) + + test('does not strike through a recreated file in the changed-only view', async ($, on) => { + fakeRepo(on, { isRecreated: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'changes' }) + + expect((await ui.find({ type: 'Text', text: 'gone.txt' }))?.props.strikethrough).toBe(false) + + await ui.unmount() + }) + + test('says an unchanged file it cannot read is unreadable, not empty', async ($, on) => { + fakeRepo(on, { isUnreadable: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'node:src/a.ts' }) + + expect(await ui.find({ type: 'Text', text: 'Not a readable file.' })).toBeDefined() + expect(await ui.find({ type: 'Code' })).toBeUndefined() + + await ui.unmount() + }) + + test('says a filter does not search inside ignored folders', async ($, on) => { + fakeRepo(on) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // git lists the ignored node_modules/ by its own path, not the files inside it. + await ui.input({ key: 'filter', text: 'read', kind: 'change' }) + expect(await ui.find({ key: 'node:README.md' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: 'Files inside ignored folders are not searched.' })).toBeDefined() + + await ui.unmount() + }) + + test('says when a filter searched a cut list of ignored files', async ($, on) => { + fakeRepo(on, { isIgnoredCut: true }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + // The cut entry (`vend`) is dropped, not drawn as a file of that name. + await ui.input({ key: 'filter', text: 'vend', kind: 'change' }) + expect(await ui.find({ key: 'node:vend' })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: /ignored files passed 4 MB/ })).toBeDefined() + + await ui.unmount() + }) + + test('runs one scan at a time and folds the requests made meanwhile into one', async ($, on) => { + const clock = mock.clock(on) + let isSlow = false + let running = 0 + let most = 0 + let scans = 0 + fakeRepo(on, { + onStatus: async () => { + if (!isSlow) return + scans++ + most = Math.max(most, ++running) + await clock.sleep(20) + running-- + }, + }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + isSlow = true + + const presses = Promise.all([ui.press({ key: 'refresh' }), ui.press({ key: 'refresh' }), ui.press({ key: 'refresh' })]) + await clock.advance(20) + await clock.advance(20) + await presses + expect(most).toBe(1) + expect(scans).toBe(2) + + await ui.unmount() + }) + + test('keeps an ordinary preview source unchanged while the native viewport moves and resizes', async ($, on) => { + const text = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n') + fakeRepo(on, { text }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: { ...PROPS, scroll: { offset: 0, bodyRows: 20 } }, + }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'node:src/a.ts' }) + expect((await ui.find({ type: 'Code' }))?.props.source).toBe(text) + await ui.redraw({ ...PROPS, scroll: { offset: 50, bodyRows: 20 } }) + expect((await ui.find({ type: 'Code' }))?.props.startLine).toBe(1) + expect((await ui.find({ type: 'Code' }))?.props.source).toBe(text) + expect(await ui.find({ key: 'preview-before' })).toBeUndefined() + expect(await ui.find({ key: 'preview-after' })).toBeUndefined() + await ui.redraw({ ...PROPS, scroll: { offset: 0, bodyRows: 200 } }) + expect((await ui.find({ type: 'Code' }))?.props.startLine).toBe(1) + expect(await ui.find({ key: 'preview-before' })).toBeUndefined() + expect(await ui.find({ key: 'preview-after' })).toBeUndefined() + await ui.unmount() + }) + + test('keeps retained large-preview Code blocks unchanged across scroll boundaries', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1 } + fakeRepo(on, { live, text: Array.from({ length: 1000 }, (_, i) => `line ${i + 1} ${'x'.repeat(80)}`).join('\n') }) + const props = { ...PROPS, scroll: { offset: 0, bodyRows: 20 } } + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props, + }) + await ui.press({ key: 'refresh' }) + await ui.press({ key: 'node:src' }) + await ui.press({ key: 'node:src/a.ts' }) + const codeProps = async () => (await ui.findAll({ type: 'Code' })).map(code => code.props) + const initial = await codeProps() + expect(initial.map(code => code.startLine)).toEqual([1, 33, 65]) + const calls = [live.lists, live.reads, live.stats, live.processes] + + await ui.redraw({ ...props, scroll: { offset: 100, bodyRows: 20 } }) + const later = await codeProps() + expect(later.map(code => code.startLine)).toEqual([33, 65, 97, 129, 161]) + expect(later[0]).toEqual(initial[1]) + expect(later[1]).toEqual(initial[2]) + expect((await ui.find({ key: 'preview-before' }))?.props.height).toBe(32) + expect((await ui.find({ key: 'preview-after' }))?.props.height).toBe(808) + + await ui.redraw({ ...props, scroll: { offset: 101, bodyRows: 20 } }) + expect(await codeProps()).toEqual(later) + expect([live.lists, live.reads, live.stats, live.processes]).toEqual(calls) + await ui.unmount() + }) + + test('puts both columns in normal flow without wheel reset space or file-only controls', async ($, on) => { + fakeRepo(on) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: PROPS, + }) + await ui.press({ key: 'node:README.md' }) + expect((await ui.find({ key: 'columns' }))?.props.flexDirection).toBe('row') + expect((await ui.find({ key: 'file-panel' }))?.props.position).toBeUndefined() + expect(await ui.find({ key: 'spacer' })).toBeUndefined() + expect(await ui.find({ key: 'spare' })).toBeUndefined() + expect(await ui.find({ key: 'line-up' })).toBeUndefined() + expect(await ui.find({ key: 'line-down' })).toBeUndefined() + await ui.press({ key: 'close-panel' }) + expect(await ui.find({ key: 'spacer' })).toBeUndefined() + expect(await ui.find({ key: 'spare' })).toBeUndefined() + await ui.unmount() + }) + + test('leaves person and plugin scroll offsets to the native surface', async ($, on) => { + fakeRepo(on) + const offsets: number[] = [] + on('ui.scroll', ($, e) => { offsets.push(e.offset); return {} }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: PROPS, + }) + for (const by of [5, -5]) { + await $.ui.scroll({ + component: 'Pane', requestId: 'git-aware-file-explorer', offset: 40 + by, by, + bodyRows: 20, contentRows: 100, origin: { kind: 'person' }, + }) + } + await $.ui.scroll({ + component: 'Pane', requestId: 'git-aware-file-explorer', offset: 25, by: 10, + bodyRows: 20, contentRows: 100, origin: { kind: 'plugin', name: 'git-aware-file-explorer' }, + }) + expect(offsets).toEqual([45, 35, 25]) + await ui.unmount() + }) + + test('bounds large trees while preserving their full native height', async ($, on) => { + fakeRepo(on, { disk: { + [ROOT]: Array.from({ length: 1000 }, (_, i) => file(`f${String(i).padStart(4, '0')}.ts`)), + } }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: { ...PROPS, scroll: { offset: 0, bodyRows: 20 } }, + }) + const treeRows = async () => (await ui.findAll({ type: 'Button' })).filter(b => b.key?.startsWith('node:')) + expect((await treeRows()).length).toBe(18) + // The fixture also contributes the three changed root entries. + expect((await ui.find({ key: 'tree-after' }))?.props.height).toBe(1970) + await ui.redraw({ ...PROPS, scroll: { offset: 1005, bodyRows: 20 } }) + expect((await treeRows()).length).toBe(26) + expect(await ui.find({ key: 'node:f0500.ts' })).toBeDefined() + expect((await ui.find({ key: 'tree-before' }))?.props.height).toBe(984) + expect((await ui.find({ key: 'tree-after' }))?.props.height).toBe(970) + await ui.redraw({ ...PROPS, scroll: { offset: 0, bodyRows: 20 } }) + expect(await ui.find({ key: 'node:f0000.ts' })).toBeDefined() + await ui.unmount() + }) + + test('says when a search outside a git repository stopped at the walk limit', async ($, on) => { + const many = Array.from({ length: 20001 }, (_, i) => file(`f${i}.txt`)) + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => ({ + value: e.path === ROOT ? [dir('bulk'), dir('deep')] : e.path === ROOT + '/bulk' ? many : [file('target.md')], + })) + on('process.run', () => ok('', 128)) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + await ui.input({ key: 'filter', text: 'target', kind: 'change' }) + expect(await ui.find({ type: 'Text', text: 'No matching files.' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /Searched the first 20000 entries/ })).toBeDefined() + + await ui.unmount() + }) + + test('bounds a non-git search even when its entries are all directories', async ($, on) => { + const many = Array.from({ length: 20001 }, (_, i) => dir(`d${i}`)) + let lists = 0 + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => { + lists++ + return { value: e.path === ROOT ? [dir('bulk')] : e.path === ROOT + '/bulk' ? many : [] } + }) + on('process.run', () => ok('', 128)) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: PROPS, + }) + const before = lists + await ui.input({ key: 'filter', text: 'target', kind: 'change' }) + expect(lists - before).toBe(2) + expect(await ui.find({ type: 'Text', text: /Searched the first 20000 entries/ })).toBeDefined() + await ui.unmount() + }) + + test('says when a search outside a git repository could not read a folder', async ($, on) => { + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => { + if (e.path === ROOT + '/locked') throw new Error('EACCES: permission denied') + return { value: e.path === ROOT ? [file('a.md'), dir('locked')] : [] } + }) + on('process.run', () => ok('', 128)) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + await ui.input({ key: 'filter', text: 'target', kind: 'change' }) + expect(await ui.find({ type: 'Text', text: 'No matching files.' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /1 folder could not be read/ })).toBeDefined() + + await ui.unmount() + }) + + test('does not walk into a linked folder outside a git repository', async ($, on) => { + // Every folder holds a link back to its parent, reported as a folder. + const loop: FsEntry = { name: 'up', kind: 'dir', size: 0, mtimeMs: 0, isLink: true } + let lists = 0 + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => { + // Bounded, so a walk that follows the link ends (a failed listing reads as empty) and fails below. + if (++lists > 50) throw new Error('ELOOP') + return { value: e.path === ROOT ? [file('a.md'), loop] : [loop] } + }) + on('process.run', () => ok('', 128)) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + const before = lists + + await ui.input({ key: 'filter', text: 'a.md', kind: 'change' }) + expect(await ui.find({ key: 'node:a.md' })).toBeDefined() + // The filter's walk lists the root alone. + expect(lists - before).toBeLessThanOrEqual(2) + + await ui.unmount() + }) + + test('rescans after a shell command, PowerShell included, and not after a read', async ($, on) => { + let scans = 0 + fakeRepo(on, { onStatus: async () => void scans++ }) + on('tool.call', () => ({ result: { text: '' } })) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + for (const [tool, isWriter] of [['Bash', true], ['PowerShell', true], ['Read', false]] as const) { + const before = scans + // Typed from this host's tools, which name no PowerShell off Windows: the input is cast. + const input = { tool, command: 'echo x > a.md', file_path: 'a.md' } + await $.tool.call(input as unknown as Parameters[0]) + // The scan runs unawaited after the call returns; a redraw lets it finish. + await ui.redraw() + expect([tool, scans > before]).toEqual([tool, isWriter]) + } + + await ui.unmount() + }) + + test('says why git could not read the workspace, not that it is no repository', async ($, on) => { + const dubious = "fatal: detected dubious ownership in repository at '/repo'" + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) + on('process.run', () => ({ value: { ...ok('', 128).value, stderr: dubious } })) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + expect(await ui.find({ type: 'Text', text: 'not a git repo' })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: 'git unavailable' })).toBeDefined() + expect(await ui.find({ type: 'Text', text: dubious })).toBeDefined() + // The files are still there to browse. + expect(await ui.find({ key: 'node:README.md' })).toBeDefined() + + await ui.unmount() + }) + + test('walks the workspace once for a filter while the scan stays the same', async ($, on) => { + let lists = 0 + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => { + lists++ + return { value: DISK[e.path] ?? [] } + }) + on('process.run', () => ok('', 128)) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.input({ key: 'filter', text: 'a', kind: 'change' }) + const walked = lists + // A native scroll and a new filter: no file on disk changed, so no new walk. + for (let i = 0; i < 3; i++) await ui.redraw() + await ui.input({ key: 'filter', text: 'a.', kind: 'change' }) + expect(lists).toBe(walked) + expect(await ui.find({ key: 'node:src/a.ts' })).toBeDefined() + // A finished scan drops the list, so files made since are found on the next draw. + await ui.press({ key: 'refresh' }) + await ui.redraw() + expect(lists).toBeGreaterThan(walked) + + await ui.unmount() + }) + + test('finds a file a pull added in a closed folder, with the status still clean', async ($, on) => { + const live: Live = { diff: DIFF, mtimeMs: 1 } + fakeRepo(on, { live }) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.input({ key: 'filter', text: 'index', kind: 'change' }) + await ui.press({ key: 'refresh' }) + expect(await ui.find({ key: 'node:docs/old-index.md' })).toBeUndefined() + + // Only HEAD moves: the status, the totals and every stamped path stay as they were. + live.head = 'def' + live.files = 'README.md\0src/a.ts\0docs/index.md\0docs/old-index.md\0' + await ui.press({ key: 'refresh' }) + + expect(await ui.find({ key: 'node:docs/old-index.md' })).toBeDefined() + + await ui.unmount() + }) + + test('finds a file added in a closed folder outside git on the next scan', async ($, on) => { + const disk = { ...DISK } + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => ({ value: disk[e.path] ?? [] })) + on('process.run', () => ok('', 128)) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + await ui.input({ key: 'filter', text: 'index', kind: 'change' }) + await ui.press({ key: 'refresh' }) + expect(await ui.find({ key: 'node:docs/old-index.md' })).toBeUndefined() + + // Nothing open or stamped changes: only the walk the filter searches can see it. + disk[ROOT + '/docs'] = [file('index.md'), file('old-index.md')] + await ui.press({ key: 'refresh' }) + + expect(await ui.find({ key: 'node:docs/old-index.md' })).toBeDefined() + + await ui.unmount() + }) + + test('filters files outside a git repository', async ($, on) => { + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) + on('process.run', () => ok('', 128)) + const ui = await $.ui.mount({ + plugin: 'git-aware-file-explorer', + surface: 'desktop', + component: 'Pane', + requestId: 'git-aware-file-explorer', + props: PROPS, + }) + + expect(await ui.find({ type: 'Text', text: /not a git repo/ })).toBeDefined() + await ui.input({ key: 'filter', text: 'index', kind: 'change' }) + expect(await ui.find({ key: 'node:docs/index.md' })).toBeDefined() + expect(await ui.find({ key: 'node:README.md' })).toBeUndefined() + + await ui.unmount() + }) +}) diff --git a/mods/claude/git-aware-file-explorer/tsconfig.json b/mods/claude/git-aware-file-explorer/tsconfig.json new file mode 100644 index 0000000..5796e97 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/tsconfig.json @@ -0,0 +1,6 @@ +{ + // The base config and the API types it names are written to .claude-plugin/types/ by Claude Code + // when it loads this mod, for the build that loads it, and are git-ignored there. Load the mod + // once (claude --plugin-dir ) before running tsc -p . + "extends": "./.claude-plugin/types/tsconfig.json" +} diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts new file mode 100644 index 0000000..a310b81 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -0,0 +1,62 @@ +/** One changed path, as `git status --porcelain` reports it. */ +export type Change = { + /** Path relative to the repository root, `/`-separated. */ + path: string + /** Index (staged) column: one of ` MADRCU?`. */ + x: string + /** Worktree column: one of ` MADRCU?`; `?` after a `D` is a deletion staged with the file back on disk. */ + y: string + /** For a rename or copy, the path it came from. */ + from?: string +} + +/** What the last scan of the workspace found. */ +export type Scan = { + /** The tree's root: the repository's top level, or the session's directory. */ + root: string + /** Whether `root` is a git work tree. */ + isRepo: boolean + /** The checked-out branch, or a short commit id when detached. */ + branch?: string + /** + * The commit HEAD names, absent before the first commit. A pull or checkout that leaves the status + * clean still moves it, so the scan changes and the tree and filter are listed again. + */ + head?: string + changes: Change[] + /** Ignored paths relative to root; a directory ends with `/`. */ + ignored: string[] + /** git's list of ignored paths passed 4 MiB and was cut, so `ignored` holds its start only. */ + isIgnoredTruncated?: boolean + /** Lines added and deleted across all changes against HEAD, untracked files included. */ + lines?: { + added: number + deleted: number + /** Untracked files went uncounted (too many, or too large to read), so `added` is a floor. */ + partial?: boolean + /** + * git's line counts passed 4 MiB of output and were cut, a recreated file went uncounted (its + * committed version passed 4 MiB, or git could not diff it), or git could not count them at all + * (an unreadable changed file), so both totals are floors. Not always a cut, despite the name. + */ + isTruncated?: boolean + } + /** git's status passed 4 MiB and was cut, so the changes and line totals are floors. */ + isTruncated?: boolean + /** Error text from the last scan, if it failed. */ + error?: string + /** Modification times and sizes of the files the pane shows, so a content edit redraws it. */ + stamp?: string +} + +declare module 'claude-code' { + interface PluginState { + 'git-aware-file-explorer': { + scan: Scan | null + expanded: string[] + selected: string | null + changedOnly: boolean + filter: string + } + } +} diff --git a/scripts/test_validate_artifact_sync.py b/scripts/test_validate_artifact_sync.py index d852be4..5c95c7c 100644 --- a/scripts/test_validate_artifact_sync.py +++ b/scripts/test_validate_artifact_sync.py @@ -32,13 +32,18 @@ def build_template(dest: Path) -> None: """Copy just the files the validator reads.""" - for rel in ("agents", "harness/opencode", "harness/opencode-lane", "docs/agents", "docs/skills"): + for rel in ("agents", "harness/opencode", "harness/opencode-lane", "docs/agents", "docs/skills", "docs/mods"): shutil.copytree(REPO / rel, dest / rel) + for manifest in (REPO / "mods").glob("*/*/.claude-plugin/plugin.json"): + target = dest / manifest.relative_to(REPO) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(manifest, target) for skill in (REPO / "skills").glob("*/SKILL.md"): target = dest / "skills" / skill.parent.name / "SKILL.md" target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(skill, target) - for rel in ("skills/README.md", "mkdocs.yml", "cerebro-catalog.yaml"): + for rel in ("skills/README.md", "mkdocs.yml", "cerebro-catalog.yaml", "docs/harness/claudecode/index.md"): + (dest / rel).parent.mkdir(parents=True, exist_ok=True) shutil.copy2(REPO / rel, dest / rel) (dest / "scripts").mkdir() shutil.copy2(REPO / "scripts" / SCRIPT_NAME, dest / "scripts" / SCRIPT_NAME) @@ -361,6 +366,64 @@ def test_overview_page_missing_from_nav_fails(self) -> None: self.assert_fails_cleanly(result) self.assertIn("git-commit-messages", result.stdout) + def test_missing_mod_overview_page_fails(self) -> None: + (self.root / "docs/mods/git-aware-file-explorer.md").unlink() + result = self.run_validator() + self.assert_fails_cleanly(result) + self.assertIn("mod git-aware-file-explorer has no overview page", result.stdout) + + def test_mod_without_manifest_fails(self) -> None: + hooks = self.root / "mods/claude/new-mod/hooks" + hooks.mkdir(parents=True) + (hooks / "register.tsx").write_text("export const register = () => {}\n") + result = self.run_validator() + self.assert_fails_cleanly(result) + self.assertIn("mod new-mod has no plugin manifest", result.stdout) + + def test_removed_last_mod_with_docs_left_fails(self) -> None: + shutil.rmtree(self.root / "mods") + result = self.run_validator() + self.assert_fails_cleanly(result) + self.assertIn("docs/mods/index.md mod coverage drift", result.stdout) + self.assertIn("mkdocs.yml nav lists mod pages with no mod", result.stdout) + self.assertIn("harness page lists mods with no mod", result.stdout) + + def test_mod_missing_from_catalog_fails(self) -> None: + text = self.read("docs/mods/index.md") + kept = "".join(line for line in text.splitlines(keepends=True) if "(git-aware-file-explorer.md)" not in line) + self.assertNotEqual(kept, text) + self.write("docs/mods/index.md", kept) + result = self.run_validator() + self.assert_fails_cleanly(result) + self.assertIn("docs/mods/index.md mod coverage drift", result.stdout) + + def test_mod_missing_from_its_harness_page_fails(self) -> None: + page = "docs/harness/claudecode/index.md" + text = self.read(page) + kept = "".join(line for line in text.splitlines(keepends=True) if "mods/git-aware-file-explorer.md" not in line) + self.assertNotEqual(kept, text) + self.write(page, kept) + result = self.run_validator() + self.assert_fails_cleanly(result) + self.assertIn("not listed on its client's harness page", result.stdout) + + def test_mod_linked_only_in_catalog_prose_fails(self) -> None: + text = self.read("docs/mods/index.md") + kept = "".join(line for line in text.splitlines(keepends=True) if "(git-aware-file-explorer.md)" not in line) + self.write("docs/mods/index.md", kept + "\nSee [the explorer](git-aware-file-explorer.md).\n") + result = self.run_validator() + self.assert_fails_cleanly(result) + self.assertIn("docs/mods/index.md mod coverage drift", result.stdout) + + def test_mod_linked_only_in_harness_prose_fails(self) -> None: + page = "docs/harness/claudecode/index.md" + text = self.read(page) + kept = "".join(line for line in text.splitlines(keepends=True) if "mods/git-aware-file-explorer.md" not in line) + self.write(page, kept + "\nSee [the explorer](../../mods/git-aware-file-explorer.md).\n") + result = self.run_validator() + self.assert_fails_cleanly(result) + self.assertIn("not listed on its client's harness page", result.stdout) + def test_mkdocs_custom_yaml_tags_are_tolerated(self) -> None: self.write("mkdocs.yml", self.read("mkdocs.yml") + "\nextra_probe: !!python/name:material.extensions.emoji.twemoji\n") result = self.run_validator() diff --git a/scripts/validate_artifact_sync.py b/scripts/validate_artifact_sync.py index c29a561..ae986ea 100644 --- a/scripts/validate_artifact_sync.py +++ b/scripts/validate_artifact_sync.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate documentation coverage for repository agents, harnesses, and skills. +"""Validate documentation coverage for repository agents, harnesses, skills, and mods. Run with ``--write`` after changing an agent roster or client format to refresh the generated inventory, roster, and install blocks in the topology pages and the @@ -14,6 +14,7 @@ import argparse import json +import os import re import sys from dataclasses import dataclass @@ -590,6 +591,92 @@ def validate_skills() -> None: ) +MOD_HARNESS_PAGES = {"claude": "docs/harness/claudecode/index.md"} +# Each client's plugin manifest, relative to a mod's folder. +MOD_MANIFESTS = {"claude": ".claude-plugin/plugin.json"} + + +def mod_rows(text: str, prefix: str = "") -> set[str]: + """Mods a page lists in a table row's first cell, as `[Name](.md)`; a link + anywhere else on the page does not list one.""" + pattern = r"^\|\s*\[[^\]]*\]\(" + re.escape(prefix) + r"([a-z0-9-]+)\.md\)" + return set(re.findall(pattern, text, flags=re.MULTILINE)) + + +def mods_link_prefix(page: str) -> str: + """How a page links into docs/mods/ (`../../mods/` from docs/harness/claudecode/index.md).""" + return Path(os.path.relpath(ROOT / "docs/mods", (ROOT / page).parent)).as_posix() + "/" + + +def validate_mods() -> None: + """Every mod under mods// has an overview page in the nav, a catalog row, + and an entry on its client's harness page.""" + folders = sorted( + path for path in (ROOT / "mods").glob("*/*") if path.is_dir() and not path.name.startswith(".") + ) + mods: dict[str, str] = {} + for folder in folders: + identifier, client = folder.name, folder.parent.name + mods[identifier] = client + manifest = MOD_MANIFESTS.get(client) + if manifest is None: + continue # reported below as an unknown client + path = folder / manifest + if not path.is_file(): + check(False, f"mod {identifier} has no plugin manifest", str(path.relative_to(ROOT))) + continue + try: + name = json.loads(path.read_text()).get("name") + except (json.JSONDecodeError, AttributeError): + check(False, "mod manifest is not a JSON object", str(path.relative_to(ROOT))) + continue + check( + name == identifier, + "mod manifest name does not match directory", + str(path.relative_to(ROOT)), + ) + # With no mods left the docs are still checked, so entries for a removed mod fail. + index = ROOT / "docs/mods/index.md" + index_text = index.read_text() if index.is_file() else "" + catalogued = mod_rows(index_text) + check( + catalogued == set(mods), + "docs/mods/index.md mod coverage drift", + f"expected {sorted(mods)}, found {sorted(catalogued)}", + ) + + mkdocs = yaml.load((ROOT / "mkdocs.yml").read_text(), Loader=_MkdocsLoader) + nav = nav_files(mkdocs.get("nav", []) if isinstance(mkdocs, dict) else []) + stale_nav = {page for page in nav if page.startswith("mods/") and page != "mods/index.md"} - { + f"mods/{identifier}.md" for identifier in mods + } + check(not stale_nav, "mkdocs.yml nav lists mod pages with no mod", f"{sorted(stale_nav)}") + for client, harness in sorted(MOD_HARNESS_PAGES.items()): + listed = mod_rows((ROOT / harness).read_text(), mods_link_prefix(harness)) + stale = listed - {identifier for identifier, owner in mods.items() if owner == client} + check(not stale, "harness page lists mods with no mod", f"{harness}: {sorted(stale)}") + for identifier, client in sorted(mods.items()): + page = f"mods/{identifier}.md" + check( + (ROOT / "docs" / page).is_file(), + f"mod {identifier} has no overview page", + f"docs/{page}", + ) + check( + page in nav, + f"mod {identifier} overview page is not in the mkdocs.yml nav", + page, + ) + harness = MOD_HARNESS_PAGES.get(client) + check(harness is not None, f"mod {identifier} is for an unknown client", client) + if harness is not None: + check( + identifier in mod_rows((ROOT / harness).read_text(), mods_link_prefix(harness)), + f"mod {identifier} is not listed on its client's harness page", + harness, + ) + + def validate_discovery() -> None: discovered_topologies = { path.name for path in (ROOT / "agents").glob("*-topology") if path.is_dir() @@ -636,6 +723,10 @@ def main() -> int: validate_skills() except (OSError, ValueError, yaml.YAMLError) as error: failures.append(f"skills: {error}") + try: + validate_mods() + except (OSError, ValueError, yaml.YAMLError) as error: + failures.append(f"mods: {error}") print(f"artifact-sync: {checks_run} checks") if failures: