From b0a0ea361d8062f50e97baa214e165e6f16c2304 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 01:52:35 -0500 Subject: [PATCH 01/61] feat(mods): add git-aware-file-explorer Claude Code mod A Files pane for the Claude desktop app that marries the file tree with git status: changed files and the folders holding them are highlighted, a filter and a changed-only view narrow the tree, and the selected file opens beside the tree as a diff (or a preview when unchanged) that scrolls on its own. Adds mods/claude/ as the home for Claude Code interface mods and records it in the AGENTS.md repository structure. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 + .../.claude-plugin/plugin.json | 6 + .../git-aware-file-explorer/hooks/hooks.json | 1 + .../hooks/register.tsx | 584 ++++++++++++++++++ .../tests/diff.test.ts | 31 + .../tests/pane.test.ts | 100 +++ .../git-aware-file-explorer/tsconfig.json | 3 + .../git-aware-file-explorer/types/index.d.ts | 40 ++ 8 files changed, 767 insertions(+) create mode 100644 mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json create mode 100644 mods/claude/git-aware-file-explorer/hooks/hooks.json create mode 100644 mods/claude/git-aware-file-explorer/hooks/register.tsx create mode 100644 mods/claude/git-aware-file-explorer/tests/diff.test.ts create mode 100644 mods/claude/git-aware-file-explorer/tests/pane.test.ts create mode 100644 mods/claude/git-aware-file-explorer/tsconfig.json create mode 100644 mods/claude/git-aware-file-explorer/types/index.d.ts diff --git a/AGENTS.md b/AGENTS.md index c0f28264..d44012c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,6 +189,8 @@ 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 (not published) +│ └── 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 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 00000000..3e869cab --- /dev/null +++ b/mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "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.", + "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 00000000..5a63f649 --- /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 00000000..4ef8fb6d --- /dev/null +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -0,0 +1,584 @@ +import { atom, read, update } from 'claude-code' +import type { EngineInterface, Register } from 'claude-code' + +import type { Change, Scan } from '../types' + +const PANE = 'git-aware-file-explorer' +const TITLE = 'Files' +const SCAN_EVERY_MS = 5000 +const PREVIEW_BYTES = 256 * 1024 +/** Most matches a filter lists; the rest are counted, not drawn. */ +const FILTER_LIMIT = 300 +/** 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 +/** Tools whose calls can change the work tree; a scan follows each. */ +const WRITERS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash']) +/** 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' + +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, '') +const panelOffsetAtom = atom({ plugin: 'git-aware-file-explorer', key: 'panelOffset' } as const, 0) + +type $ = EngineInterface + +/** + * What the last drawing laid out, for the scroll hook to tell the two sides apart. + * A module variable on purpose: it is layout, redrawn on every render, not state. + */ +const layout = { treeColumns: 0, panelLines: 0, panelRoom: 0, hasFile: false } + +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 +} + +/** Splits `git status --porcelain=v1 -z` output into changes. */ +export function parseStatus(out: string): Change[] { + const parts = out.split('\0') + const changes: Change[] = [] + 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) } + if (change.x === 'R' || change.x === 'C') change.from = parts[++i] + changes.push(change) + } + return changes +} + +/** 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 (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 (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}` +} + +async function git($: $, root: string, args: string[]) { + return $.process.run(['git', '--no-optional-locks', ...args], { cwd: root }) +} + +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) return { root: cwd, isRepo: false, changes: [], ignored: [] } + const root = top.stdout.trim() + const [status, ignored, branch] = 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']), + ]) + let name = branch.stdout.trim() + if (name === '') name = (await git($, root, ['rev-parse', '--short', 'HEAD'])).stdout.trim() + return { + root, + isRepo: true, + branch: name || undefined, + changes: status.exitCode === 0 ? parseStatus(status.stdout) : [], + ignored: ignored.stdout.split('\0').filter(Boolean), + error: status.exitCode === 0 ? undefined : status.stderr.trim(), + } + } catch (err) { + return { root: cwd, isRepo: false, changes: [], ignored: [], error: String(err) } + } +} + +/** Scans and writes the result only when it changed, so idle scans redraw nothing. */ +async function rescan($: $) { + const next = await scan($) + const text = JSON.stringify(next) + await update($, scanAtom, prev => (JSON.stringify(prev) === text ? prev : next)) +} + +/** 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 } | undefined> { + const needle = filter.trim().toLowerCase() + if (!changedOnly && needle === '') return undefined + let paths: string[] + if (changedOnly || !s.isRepo) paths = s.changes.map(c => c.path) + else { + const listed = await git($, s.root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']) + paths = [...new Set([...listed.stdout.split('\0').filter(Boolean), ...s.changes.map(c => c.path)])] + } + const hits = needle === '' ? paths : paths.filter(p => p.toLowerCase().includes(needle)) + return { paths: hits.slice(0, FILTER_LIMIT), total: hits.length } +} + +async function buildRows( + $: $, + s: Scan, + expanded: Set, + virtual: string[] | undefined, +): Promise { + 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[] = [] + + async function walk(dir: string, depth: number) { + const prefix = dir === '' ? '' : dir + '/' + const kids = new Map() + const source = virtual ?? s.changes.map(c => c.path) + if (!virtual) { + const entries = await $.fs.list(s.root + (dir === '' ? '' : '/' + dir)).catch(() => []) + for (const en of entries) { + if (HIDDEN.has(en.name)) continue + kids.set(en.name, { isDir: en.kind === 'dir', isMissing: 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 (!kids.has(seg)) { + const isDir = rest.includes('/') + const c = byPath.get(path) + const isGone = !isDir && (c?.x === 'D' || c?.y === 'D') + kids.set(seg, { isDir, isMissing: virtual ? isGone : true }) + } + } + 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 || expanded.has(path)) + rows.push({ + 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, + }) + if (isOpen) await walk(path, depth + 1) + } + } + + await walk('', 0) + return rows +} + +/** 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 +} + +/** + * The lines `[start, start + count)` as a diff of their own: each run from one hunk + * gets a header recounted for the run, so the window still parses as hunks. + */ +export function windowDiff(lines: DiffLine[], start: number, count: number): string { + const runs: DiffLine[][] = [] + for (const line of lines.slice(start, start + count)) { + const run = runs[runs.length - 1] + if (run && run[0]?.hunk === line.hunk) run.push(line) + else runs.push([line]) + } + return runs + .map(run => { + const first = run[0] as DiffLine + const oCount = run.filter(l => l.kind !== '+').length + const nCount = run.filter(l => l.kind !== '-').length + const oStart = oCount === 0 ? first.o - 1 : first.o + const nStart = nCount === 0 ? first.n - 1 : first.n + return [`@@ -${oStart},${oCount} +${nStart},${nCount} @@`, ...run.map(l => l.text)].join('\n') + }) + .join('\n') +} + +async function diffFor($: $, s: Scan, c: Change): Promise { + const flags = ['--no-color', '--no-ext-diff'] + if (c.x === '?') { + // --no-index exits 1 when the files differ, which is the point here. + return (await git($, s.root, ['diff', ...flags, '--no-index', '--', '/dev/null', c.path])).stdout + } + const paths = c.from ? [c.from, c.path] : [c.path] + const hasHead = (await git($, s.root, ['rev-parse', '--verify', '-q', 'HEAD'])).exitCode === 0 + if (hasHead) return (await git($, s.root, ['diff', ...flags, '-M', 'HEAD', '--', ...paths])).stdout + const staged = await git($, s.root, ['diff', ...flags, '--cached', '--', ...paths]) + const unstaged = await git($, s.root, ['diff', ...flags, '--', ...paths]) + return staged.stdout + unstaged.stdout +} + +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 }) + return started + }) + + on('command.run', { command: 'git-files' }, async $ => { + await rescan($) + await $.ui.open({ id: PANE, title: TITLE, focus: true }) + 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)) + + // The wheel over the file panel scrolls the panel alone; over the tree, the pane scrolls as usual. + on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => { + const isOverPanel = e.pointer !== undefined && e.pointer.column >= layout.treeColumns + GUTTER + if (!layout.hasFile || !isOverPanel) return next(e) + const last = Math.max(0, layout.panelLines - Math.ceil(layout.panelRoom / 2)) + await update($, panelOffsetAtom, cur => Math.min(last, Math.max(0, cur + e.by))) + return { deny: 'the file panel scrolls on its own' } + }).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 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 panelOffset = await read($, panelOffsetAtom) + + const virtual = await pathsFor($, s, changedOnly, filter) + const rows = await buildRows($, s, new Set(expanded), virtual?.paths) + 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 n = s.changes.length + + // The tree column fits its widest row, up to half the pane; the file panel takes the rest. + const widest = rows.reduce((w, r) => Math.max(w, r.depth * 2 + r.name.length + 8), 0) + 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 select = (path: string) => + update($, selectedAtom, cur => (cur === path ? null : path)).then(() => update($, panelOffsetAtom, () => 0)) + + const toolbar = ( + + + {s.isRepo ? ⎇ {s.branch ?? '?'} : not a git repo} + + + {s.isRepo && ( + + )} + + + + + ) + + const search = Input && ( + void update($, filterAtom, () => value)} + onSubmit={value => void update($, filterAtom, () => value)} + /> + ) + + const tree = ( + + {rows.length === 0 && ( + {filter.trim() !== '' ? 'No matching files.' : changedOnly ? 'No changes.' : 'Empty directory.'} + )} + {rows.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)) + return ( + + + {r.depth > 0 && {' '.repeat(r.depth)}} + + {Svg && ( + + )} + + + {b ? ( + {b.letter} + ) : r.isDir && r.count > 0 ? ( + + ● + + ) : null} + + ) + })} + {virtual && virtual.total > virtual.paths.length && ( + + Showing {virtual.paths.length} of {virtual.total} matches. + + )} + + ) + + // The file panel is pinned over the pane's window: it moves down as the tree scrolls, + // so it stays in view, and scrolls its own content through panelOffset. + const panelRoom = Math.max(4, bodyRows - 3) + let panelLines = 0 + let body + let range = '' + if (selected === null) { + body = ( + + {Svg && } + Open files appear here + Changed files open as a diff, the rest as a preview. + + ) + } else if (change) { + const lines = diffLines(await diffFor($, s, change)) + panelLines = lines.length + if (lines.length === 0) body = No textual difference (binary or mode change). + else { + const top = Math.min(panelOffset, Math.max(0, lines.length - 1)) + // Each hunk the window crosses costs a header row. + const hunks = new Set(lines.slice(top, top + panelRoom).map(l => l.hunk)).size + const count = Math.max(1, panelRoom - hunks) + range = `${top + 1}–${Math.min(lines.length, top + count)} of ${lines.length}` + body = + } + } else { + const stat = await $.fs.stat(s.root + '/' + selected).catch(() => undefined) + if (!stat || stat.kind !== 'file') body = Not a readable file. + else if (stat.size > PREVIEW_BYTES) body = Too large to preview ({stat.size} bytes). + else { + const text = await $.fs.read(s.root + '/' + selected).catch(() => '') + if (text.includes('\0')) body = Binary file. + else { + const lines = text.split('\n') + panelLines = lines.length + const top = Math.min(panelOffset, Math.max(0, lines.length - 1)) + range = `${top + 1}–${Math.min(lines.length, top + panelRoom)} of ${lines.length}` + body = ( + + ) + } + } + } + Object.assign(layout, { treeColumns, panelLines, panelRoom, hasFile: selected !== null }) + + 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 + )} + + + {range !== '' && {range}} + + + + )} + {body} + + ) + + return ( + + + {toolbar} + {search} + {s.error && {s.error}} + {tree} + + {panel} + + ) + }) +} 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 00000000..fe4e2c09 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/tests/diff.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { diffLines, windowDiff } from '../hooks/register' + +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('a window across two hunks gets a recounted header for each run', async () => { + const out = windowDiff(diffLines(DIFF), 2, 4) + expect(out).toBe(['@@ -3,1 +2,2 @@', '+TWO', ' three', '@@ -10,1 +10,2 @@', ' ten', '+ten and a half'].join('\n')) + }) +}) 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 00000000..a4005292 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, 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) => ({ + value: { + exitCode, + stdout, + stderr: '', + isStdoutTruncated: false, + 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')], +} + +/** A repository with one modified, one untracked and one deleted file. */ +function fakeRepo(on: On) { + on('session.cwd', () => ({ value: ROOT })) + on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) + on('process.run', ($, e) => { + const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') + if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') + if (args.startsWith('status')) return ok(' M README.md\0?? src/new.ts\0 D gone.txt\0') + if (args.startsWith('ls-files')) return ok('node_modules/\0') + if (args === 'branch --show-current') return ok('feat/x\n') + if (args.startsWith('rev-parse --verify')) return ok('abc\n') + if (args.includes('README.md')) return ok(DIFF) + 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() + + 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: 'Code', text: /\+new line/ })).toBeDefined() + + await ui.press({ key: 'close-panel' }) + expect(await ui.find({ type: 'Code' })).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() + + 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 00000000..8681ec01 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/tsconfig.json @@ -0,0 +1,3 @@ +{ + "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 00000000..f0a22de3 --- /dev/null +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -0,0 +1,40 @@ +/** 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?`. */ + 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 + changes: Change[] + /** Ignored paths relative to root; a directory ends with `/`. */ + ignored: string[] + /** Error text from the last scan, if it failed. */ + error?: string +} + +declare module 'claude-code' { + interface PluginState { + 'git-aware-file-explorer': { + scan: Scan | null + expanded: string[] + selected: string | null + changedOnly: boolean + filter: string + /** First line of the open file the right panel shows. */ + panelOffset: number + } + } +} From 53dafeb4e557d2bbefc31160bd31e0ef061840e9 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 03:10:50 -0500 Subject: [PATCH 02/61] feat(mods): branch bar, line totals, tinted diff and scrolling in git-aware-file-explorer - Full-width branch bar: branch on the left, +added/-deleted line totals and the changed-files toggle on the right; refresh and collapse sit beside the filter. - File panel header shows the file's own +/- counts. - Diff drawn as tinted rows (light green/red with dark text), hunks separated. - The wheel scrolls the diff while a file is open and the tree otherwise; the branch bar and filter stay fixed. Line-by-line up/down buttons in the header. - The desktop raises no wheel event for a window that cannot move, so the pane keeps one row of room only where the tree or diff can still scroll. - Idle rescans no longer redraw the pane. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 290 +++++++++++++----- .../tests/diff.test.ts | 13 +- .../tests/pane.test.ts | 13 +- .../git-aware-file-explorer/types/index.d.ts | 4 + 4 files changed, 238 insertions(+), 82 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 4ef8fb6d..3efc2482 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -13,6 +13,16 @@ const FILTER_LIMIT = 300 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 +/** + * The desktop raises no wheel event for a window that cannot move, and moves the window a row + * before a hook answers. So the pane's window rests over a blank spacer row above the branch bar + * only while the tree or the file can scroll up, and has a spare row below only while the file can + * scroll down (a long tree makes its own): at a limit the window has nowhere to go, and nothing + * bounces. The mod moves the tree or the file panel itself. + */ +const ROOM = 1 /** Tools whose calls can change the work tree; a scan follows each. */ const WRITERS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash']) /** Files the native Files pane leaves out too. */ @@ -20,13 +30,16 @@ 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, '') const panelOffsetAtom = atom({ plugin: 'git-aware-file-explorer', key: 'panelOffset' } as const, 0) +const treeOffsetAtom = atom({ plugin: 'git-aware-file-explorer', key: 'treeOffset' } as const, 0) type $ = EngineInterface @@ -34,7 +47,7 @@ type $ = EngineInterface * What the last drawing laid out, for the scroll hook to tell the two sides apart. * A module variable on purpose: it is layout, redrawn on every render, not state. */ -const layout = { treeColumns: 0, panelLines: 0, panelRoom: 0, hasFile: false } +const layout = { treeColumns: 0, panelLines: 0, panelRoom: 0, hasFile: false, offset: 0, spacer: 0, spare: 0 } type Row = { path: string @@ -125,11 +138,13 @@ async function scan($: $): Promise { ]) let name = branch.stdout.trim() if (name === '') name = (await git($, root, ['rev-parse', '--short', 'HEAD'])).stdout.trim() + const changes = status.exitCode === 0 ? parseStatus(status.stdout) : [] return { root, isRepo: true, branch: name || undefined, - changes: status.exitCode === 0 ? parseStatus(status.stdout) : [], + changes, + lines: changes.length > 0 ? await lineTotals($, root, changes) : { added: 0, deleted: 0 }, ignored: ignored.stdout.split('\0').filter(Boolean), error: status.exitCode === 0 ? undefined : status.stderr.trim(), } @@ -138,11 +153,57 @@ async function scan($: $): Promise { } } +/** Sums `git diff --numstat` output; binary files (`-`) count no lines. */ +export function sumNumstat(out: string): { added: number; deleted: number } { + let added = 0 + let deleted = 0 + for (const line of out.split('\n')) { + const [a, d] = line.split('\t') + if (a && /^\d+$/.test(a)) added += Number(a) + if (d && /^\d+$/.test(d)) deleted += Number(d) + } + return { added, deleted } +} + +/** 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 + +/** Lines added and deleted against HEAD (staged and unstaged), untracked files counted as added. */ +async function lineTotals($: $, root: string, changes: Change[]) { + const hasHead = (await git($, root, ['rev-parse', '--verify', '-q', 'HEAD'])).exitCode === 0 + const outs = hasHead + ? [await git($, root, ['diff', '--numstat', '--no-ext-diff', 'HEAD'])] + : [await git($, root, ['diff', '--numstat', '--no-ext-diff', '--cached']), await git($, root, ['diff', '--numstat', '--no-ext-diff'])] + const total = sumNumstat(outs.map(o => o.stdout).join('\n')) + const untracked = changes.filter(c => c.x === '?').slice(0, UNTRACKED_LIMIT) + for (const c of untracked) { + const path = root + '/' + c.path + const stat = await $.fs.stat(path).catch(() => undefined) + if (!stat || stat.kind !== 'file' || stat.size > UNTRACKED_BYTES) continue + total.added += countLines(await $.fs.read(path).catch(() => '')) + } + return total +} + /** Scans and writes the result only when it changed, so idle scans redraw nothing. */ async function rescan($: $) { const next = await scan($) - const text = JSON.stringify(next) - await update($, scanAtom, prev => (JSON.stringify(prev) === text ? prev : next)) + // Any write redraws the pane, so an unchanged scan is not written at all. + if (JSON.stringify(await read($, scanAtom)) === JSON.stringify(next)) return + await update($, scanAtom, () => next) +} + +/** Rests the pane's window with the branch bar at its top (see ROOM). */ +async function settle($: $) { + await $.ui.scroll({ to: { key: 'branch-bar' }, in: PANE, block: 'start' }).catch(() => undefined) } /** Changed-path counts for every directory that holds a change (`''` is the root). */ @@ -275,29 +336,6 @@ export function diffLines(diff: string): DiffLine[] { return out } -/** - * The lines `[start, start + count)` as a diff of their own: each run from one hunk - * gets a header recounted for the run, so the window still parses as hunks. - */ -export function windowDiff(lines: DiffLine[], start: number, count: number): string { - const runs: DiffLine[][] = [] - for (const line of lines.slice(start, start + count)) { - const run = runs[runs.length - 1] - if (run && run[0]?.hunk === line.hunk) run.push(line) - else runs.push([line]) - } - return runs - .map(run => { - const first = run[0] as DiffLine - const oCount = run.filter(l => l.kind !== '+').length - const nCount = run.filter(l => l.kind !== '-').length - const oStart = oCount === 0 ? first.o - 1 : first.o - const nStart = nCount === 0 ? first.n - 1 : first.n - return [`@@ -${oStart},${oCount} +${nStart},${nCount} @@`, ...run.map(l => l.text)].join('\n') - }) - .join('\n') -} - async function diffFor($: $, s: Scan, c: Change): Promise { const flags = ['--no-color', '--no-ext-diff'] if (c.x === '?') { @@ -326,13 +364,14 @@ export const register: Register = on => { }) await rescan($) $.clock.every(SCAN_EVERY_MS, () => void rescan($)) - void $.ui.open({ id: PANE, title: TITLE }) + 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.' } }) @@ -342,16 +381,34 @@ export const register: Register = on => { return result }).catch(($, e, next) => next(e)) - // The wheel over the file panel scrolls the panel alone; over the tree, the pane scrolls as usual. + // The pane's window stays at rest (see ROOM), so the branch bar and the filter stay put: the wheel + // moves the open file's lines, or the tree's rows when the pointer is over the tree or no file is open. on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => { - const isOverPanel = e.pointer !== undefined && e.pointer.column >= layout.treeColumns + GUTTER - if (!layout.hasFile || !isOverPanel) return next(e) - const last = Math.max(0, layout.panelLines - Math.ceil(layout.panelRoom / 2)) - await update($, panelOffsetAtom, cur => Math.min(last, Math.max(0, cur + e.by))) - return { deny: 'the file panel scrolls on its own' } + if (e.origin.kind === 'plugin') return next(e) + // A surface that reports no pointer (the desktop, keys) scrolls the panel whenever a file is open. + const isOverPanel = e.pointer ? e.pointer.column >= layout.treeColumns + GUTTER : true + let moved: number + if (layout.hasFile && isOverPanel) { + const last = Math.max(0, layout.panelLines - Math.ceil(layout.panelRoom / 2)) + moved = Math.min(last, Math.max(0, (await read($, panelOffsetAtom)) + e.by)) + await update($, panelOffsetAtom, () => moved) + } else { + // The tree rows hidden below the window are as far as it can move down; a tree row is at least one. + const below = Math.max(0, e.contentRows - e.bodyRows - layout.spacer - layout.spare) + moved = Math.max(0, (await read($, treeOffsetAtom)) + Math.min(e.by, below)) + await update($, treeOffsetAtom, () => moved) + } + const rest = moved > 0 ? ROOM : 0 + return layout.offset === rest ? {} : next({ ...e, offset: rest }) }).catch(($, e, next) => next(e)) on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { + /** Moves the file panel's window by `by` lines, kept within the file. */ + const scrollPanel = (by: number) => + update($, panelOffsetAtom, cur => + Math.min(Math.max(0, layout.panelLines - Math.ceil(layout.panelRoom / 2)), Math.max(0, cur + by)), + ) + const els = $.ui.resolve(e) const { Box, Text, Button, Code } = els const Input = 'Input' in els ? els.Input : undefined @@ -363,9 +420,12 @@ export const register: Register = on => { const changedOnly = await read($, changedOnlyAtom) const filter = await read($, filterAtom) const panelOffset = await read($, panelOffsetAtom) + const treeOffsetSet = await read($, treeOffsetAtom) const virtual = await pathsFor($, s, changedOnly, filter) const rows = await buildRows($, s, new Set(expanded), virtual?.paths) + // The tree draws from its first visible row down; the window clips the rest. + const treeOffset = Math.min(treeOffsetSet, Math.max(0, rows.length - 1)) 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 n = s.changes.length @@ -379,17 +439,28 @@ export const register: Register = on => { update($, expandedAtom, list => list.includes(path) ? list.filter(p => p !== path && !p.startsWith(path + '/')) : [...list, path], ) + // A new filter, view or collapse redraws the tree from its first row. + const toTreeTop = () => update($, treeOffsetAtom, () => 0) + const setFilter = (value: string) => update($, filterAtom, () => value).then(toTreeTop) const select = (path: string) => update($, selectedAtom, cur => (cur === path ? null : path)).then(() => update($, panelOffsetAtom, () => 0)) - const toolbar = ( - - - {s.isRepo ? ⎇ {s.branch ?? '?'} : not a git repo} - - + // Across the whole pane: the branch on the left 70%, the change counts right-aligned in the rest. + const branchBar = ( + + + + {s.isRepo ? ⎇ {s.branch ?? '?'} : not a git repo} + + + + {s.isRepo && n > 0 && s.lines && ( + + +{s.lines.added} −{s.lines.deleted} + + )} {s.isRepo && ( - )} - - ) - const search = Input && ( - void update($, filterAtom, () => value)} - onSubmit={value => void update($, filterAtom, () => value)} - /> + const search = ( + + {Input && ( + + void setFilter(value)} + onSubmit={value => void setFilter(value)} + /> + + )} + + + ) const tree = ( @@ -424,7 +501,7 @@ export const register: Register = on => { {rows.length === 0 && ( {filter.trim() !== '' ? 'No matching files.' : changedOnly ? 'No changes.' : 'Empty directory.'} )} - {rows.map(r => { + {rows.slice(treeOffset).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)) @@ -480,10 +557,10 @@ export const register: Register = on => { // The file panel is pinned over the pane's window: it moves down as the tree scrolls, // so it stays in view, and scrolls its own content through panelOffset. - const panelRoom = Math.max(4, bodyRows - 3) + const panelRoom = Math.max(4, bodyRows - BRANCH_ROWS - 3) let panelLines = 0 let body - let range = '' + let fileLines: { added: number; deleted: number } | undefined if (selected === null) { body = ( @@ -498,11 +575,41 @@ export const register: Register = on => { if (lines.length === 0) body = No textual difference (binary or mode change). else { const top = Math.min(panelOffset, Math.max(0, lines.length - 1)) - // Each hunk the window crosses costs a header row. - const hunks = new Set(lines.slice(top, top + panelRoom).map(l => l.hunk)).size - const count = Math.max(1, panelRoom - hunks) - range = `${top + 1}–${Math.min(lines.length, top + count)} of ${lines.length}` - body = + const width = String(Math.max(...lines.map(l => Math.max(l.o, l.n)))).length + 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 i = top, used = 0; i < lines.length && used < panelRoom; i++) { + const l = lines[i] as DiffLine + const tint = l.kind === '+' ? ADDED : l.kind === '-' ? DELETED : undefined + if (i > top && lines[i - 1]?.hunk !== l.hunk) { + drawn.push( + + {' '.repeat(width * 2 + 2)}⋯ + , + ) + used++ + } + drawn.push( + + + {pad(l.kind === '+' ? undefined : l.o)} {pad(l.kind === '-' ? undefined : l.n)}{' '} + + + {l.kind === ' ' ? ' ' : l.kind}{' '} + + + {l.text.slice(1) || ' '} + + , + ) + used++ + } + fileLines = { + added: lines.filter(l => l.kind === '+').length, + deleted: lines.filter(l => l.kind === '-').length, + } + body = {drawn} } } else { const stat = await $.fs.stat(s.root + '/' + selected).catch(() => undefined) @@ -515,7 +622,6 @@ export const register: Register = on => { const lines = text.split('\n') panelLines = lines.length const top = Math.min(panelOffset, Math.max(0, lines.length - 1)) - range = `${top + 1}–${Math.min(lines.length, top + panelRoom)} of ${lines.length}` body = ( { } } } - Object.assign(layout, { treeColumns, panelLines, panelRoom, hasFile: selected !== null }) + const offset = e.props.scroll.offset + // Room above while what the wheel moves can go up; a window still below that rest is matched, so + // the branch bar stays at its top until the next wheel move brings it to rest. + const canGoUp = selected !== null ? panelOffset > 0 : treeOffset > 0 + const spacer = Math.max(canGoUp ? ROOM : 0, offset) + const spare = selected !== null && panelOffset < Math.max(0, panelLines - Math.ceil(panelRoom / 2)) ? ROOM : 0 + Object.assign(layout, { treeColumns, panelLines, panelRoom, hasFile: selected !== null, offset, spacer, spare }) const slash = selected === null ? -1 : selected.lastIndexOf('/') + // Under the branch bar, down to the bottom of the pane's window. const panel = ( { )} - {range !== '' && {range}} + {fileLines && ( + + +{fileLines.added} −{fileLines.deleted} + + )} + {layout.hasFile && ( + + + + + )} @@ -570,14 +697,25 @@ export const register: Register = on => { ) return ( - - - {toolbar} - {search} - {s.error && {s.error}} - {tree} + + {spacer > 0 && } + {/* With a file open the wheel moves the file, so the tree below the window is clipped, not scrolled to. */} + + {branchBar} + + {search} + {s.error && {s.error}} + {tree} + + {panel} - {panel} + {spare > 0 && } ) }) diff --git a/mods/claude/git-aware-file-explorer/tests/diff.test.ts b/mods/claude/git-aware-file-explorer/tests/diff.test.ts index fe4e2c09..25ffd9c6 100644 --- a/mods/claude/git-aware-file-explorer/tests/diff.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'claude-code/testing' -import { diffLines, windowDiff } from '../hooks/register' +import { countLines, diffLines, sumNumstat } from '../hooks/register' const DIFF = [ 'diff --git a/f b/f', @@ -24,8 +24,13 @@ describe('diff windowing', () => { expect(lines[4]).toEqual({ text: ' ten', kind: ' ', o: 10, n: 10, hunk: 1 }) }) - test('a window across two hunks gets a recounted header for each run', async () => { - const out = windowDiff(diffLines(DIFF), 2, 4) - expect(out).toBe(['@@ -3,1 +2,2 @@', '+TWO', ' three', '@@ -10,1 +10,2 @@', ' ten', '+ten and a half'].join('\n')) + test('numstat sums lines and skips binary files', async () => { + expect(sumNumstat('3\t1\ta.md\n-\t-\tlogo.png\n2\t0\tb.ts\n')).toEqual({ added: 5, deleted: 1 }) + }) + + 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) }) }) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index a4005292..3d58fe64 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -36,6 +36,8 @@ const DISK: Record = { function fakeRepo(on: On) { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) + on('fs.stat', () => ({ value: { kind: 'file' as const, size: 4, mtimeMs: 1, isLink: false } })) + on('fs.read', () => ({ value: 'a\nb\n' })) on('process.run', ($, e) => { const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') @@ -43,6 +45,7 @@ function fakeRepo(on: On) { if (args.startsWith('ls-files')) return ok('node_modules/\0') if (args === 'branch --show-current') return ok('feat/x\n') if (args.startsWith('rev-parse --verify')) return ok('abc\n') + if (args.startsWith('diff --numstat')) return ok('1\t1\tREADME.md\n0\t3\tgone.txt\n') if (args.includes('README.md')) return ok(DIFF) return ok('', 1) }) @@ -73,6 +76,9 @@ describe('git-aware-file-explorer pane', () => { 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() @@ -80,10 +86,13 @@ describe('git-aware-file-explorer pane', () => { expect(await ui.find({ type: 'Text', text: 'Open files appear here' })).toBeDefined() await ui.press({ key: 'node:README.md' }) - expect(await ui.find({ type: 'Code', text: /\+new line/ })).toBeDefined() + 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: 'Code' })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: 'new line' })).toBeUndefined() await ui.press({ key: 'changes' }) expect(await ui.find({ key: 'node:docs' })).toBeUndefined() diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index f0a22de3..aaa90e78 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -21,6 +21,8 @@ export type Scan = { changes: Change[] /** Ignored paths relative to root; a directory ends with `/`. */ ignored: string[] + /** Lines added and deleted across all changes against HEAD, untracked files included. */ + lines?: { added: number; deleted: number } /** Error text from the last scan, if it failed. */ error?: string } @@ -35,6 +37,8 @@ declare module 'claude-code' { filter: string /** First line of the open file the right panel shows. */ panelOffset: number + /** First row of the tree the left column shows. */ + treeOffset: number } } } From 13206634d2909b158f1d53fe7a3b1155c80f8f6d Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 03:14:20 -0500 Subject: [PATCH 03/61] chore(mods): add author to git-aware-file-explorer manifest Co-Authored-By: Claude Opus 5.5 --- .../claude/git-aware-file-explorer/.claude-plugin/plugin.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json b/mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json index 3e869cab..86aced8e 100644 --- a/mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json +++ b/mods/claude/git-aware-file-explorer/.claude-plugin/plugin.json @@ -2,5 +2,9 @@ "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" } From 98b4d87bdf16c4d939abab03d4dcfba504ab914f Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 03:24:15 -0500 Subject: [PATCH 04/61] docs(mods): add a top-level Mods section with the git-aware-file-explorer page - docs/mods/index.md: what a mod is, the Claude Code catalog, and how to load a mod folder (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS); no marketplace yet. - docs/mods/git-aware-file-explorer.md: lightweight overview and install. - Nav entry after Skills, a Mods section on the Claude Code harness page, and entries on the home page and README. - AGENTS.md: mods/ is now published and covered by documentation synchronization, with an "Adding a new mod" checklist. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 17 ++++++++-- README.md | 5 ++- docs/harness/claudecode/index.md | 9 +++++ docs/index.md | 1 + docs/mods/git-aware-file-explorer.md | 42 ++++++++++++++++++++++++ docs/mods/index.md | 49 ++++++++++++++++++++++++++++ mkdocs.yml | 4 +++ 7 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 docs/mods/git-aware-file-explorer.md create mode 100644 docs/mods/index.md diff --git a/AGENTS.md b/AGENTS.md index d44012c9..93d18eb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,13 +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 (not published) +├── 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 @@ -216,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, @@ -368,6 +369,16 @@ 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`). + ### Adding a new agent - **Domain specialist** (single `.agent.md` at repo root): create the file under @@ -419,7 +430,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 991d0cab..4c070d17 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 15c6428a..bd5f2d0f 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 dfa615d5..c8e5ecad 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 00000000..d5e45b74 --- /dev/null +++ b/docs/mods/git-aware-file-explorer.md @@ -0,0 +1,42 @@ +# 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. +- **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. +- **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. +- **Stays current.** The pane rescans the workspace every few seconds and after each tool call that + can change files. + +## 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. + +--- + +## 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 00000000..d5302c8b --- /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 51c8faac..fe099db7 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: From 94b459217e6bbeb652d46acbb02c5b8afff0e7f3 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:16:59 -0500 Subject: [PATCH 05/61] fix(mods): filter files outside a git repository in git-aware-file-explorer Outside a repository a filter drew candidates from the (always empty) change list, so any filter showed no files. Walk the disk instead, breadth first and bounded, skipping the same hidden entries as the tree. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 22 ++++++++++++++++++- .../tests/pane.test.ts | 20 +++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 3efc2482..c3a580c9 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -9,6 +9,8 @@ const SCAN_EVERY_MS = 5000 const PREVIEW_BYTES = 256 * 1024 /** Most matches a filter lists; the rest are counted, not drawn. */ const FILTER_LIMIT = 300 +/** Paths a filter outside a git repository walks the disk for, 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. */ @@ -242,7 +244,8 @@ async function pathsFor( const needle = filter.trim().toLowerCase() if (!changedOnly && needle === '') return undefined let paths: string[] - if (changedOnly || !s.isRepo) paths = s.changes.map(c => c.path) + if (changedOnly) paths = s.changes.map(c => c.path) + else if (!s.isRepo) paths = await walkFiles($, s.root) else { const listed = await git($, s.root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']) paths = [...new Set([...listed.stdout.split('\0').filter(Boolean), ...s.changes.map(c => c.path)])] @@ -251,6 +254,23 @@ async function pathsFor( return { paths: hits.slice(0, FILTER_LIMIT), total: hits.length } } +/** File paths under `root`, breadth first, up to WALK_LIMIT: what a filter searches without git. */ +async function walkFiles($: $, root: string): Promise { + const files: string[] = [] + const dirs = [''] + for (let i = 0; i < dirs.length && files.length < WALK_LIMIT; i++) { + const dir = dirs[i] as string + const entries = await $.fs.list(root + (dir === '' ? '' : '/' + dir)).catch(() => []) + for (const en of entries) { + if (HIDDEN.has(en.name)) continue + const path = dir === '' ? en.name : dir + '/' + en.name + if (en.kind === 'dir') dirs.push(path) + else files.push(path) + } + } + return files.slice(0, WALK_LIMIT) +} + async function buildRows( $: $, s: Scan, diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 3d58fe64..7d1b2666 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -106,4 +106,24 @@ describe('git-aware-file-explorer pane', () => { 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() + }) }) From b42a57e09e73c0175c364e592aec4f33dbc8d2f6 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:17:25 -0500 Subject: [PATCH 06/61] fix(mods): keep ignored entries in git-aware-file-explorer filters A filter listed files with ls-files --exclude-standard, which drops ignored paths the plain tree shows dimmed. Add the scan's ignored entries to the candidates; an ignored directory matches by its own path and is drawn closed. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 19 +++++++++++++------ .../tests/pane.test.ts | 9 ++++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index c3a580c9..8e9222e1 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -248,7 +248,9 @@ async function pathsFor( else if (!s.isRepo) paths = await walkFiles($, s.root) else { const listed = await git($, s.root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']) - paths = [...new Set([...listed.stdout.split('\0').filter(Boolean), ...s.changes.map(c => c.path)])] + // 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.stdout.split('\0').filter(Boolean), ...s.changes.map(c => c.path), ...s.ignored])] } const hits = needle === '' ? paths : paths.filter(p => p.toLowerCase().includes(needle)) return { paths: hits.slice(0, FILTER_LIMIT), total: hits.length } @@ -284,13 +286,13 @@ async function buildRows( async function walk(dir: string, depth: number) { const prefix = dir === '' ? '' : dir + '/' - const kids = new Map() + const kids = new Map() const source = virtual ?? s.changes.map(c => c.path) if (!virtual) { const entries = await $.fs.list(s.root + (dir === '' ? '' : '/' + dir)).catch(() => []) for (const en of entries) { if (HIDDEN.has(en.name)) continue - kids.set(en.name, { isDir: en.kind === 'dir', isMissing: false }) + 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). @@ -298,11 +300,16 @@ async function buildRows( if (!path.startsWith(prefix)) continue const rest = path.slice(prefix.length) const seg = rest.split('/')[0] ?? '' - if (!kids.has(seg)) { + 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) const isGone = !isDir && (c?.x === 'D' || c?.y === 'D') - kids.set(seg, { isDir, isMissing: virtual ? isGone : true }) + kids.set(seg, { isDir, isMissing: virtual ? isGone : true, hasKids }) } } const sorted = [...kids].sort(([a, ka], [b, kb]) => @@ -310,7 +317,7 @@ async function buildRows( ) for (const [name, k] of sorted) { const path = prefix + name - const isOpen = k.isDir && (virtual !== undefined || expanded.has(path)) + const isOpen = k.isDir && (virtual !== undefined ? k.hasKids : expanded.has(path)) rows.push({ path, name, diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 7d1b2666..28678184 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -42,7 +42,8 @@ function fakeRepo(on: On) { const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') if (args.startsWith('status')) return ok(' M README.md\0?? src/new.ts\0 D gone.txt\0') - if (args.startsWith('ls-files')) return ok('node_modules/\0') + if (args.startsWith('ls-files') && args.includes('--ignored')) return ok('node_modules/\0') + if (args.startsWith('ls-files')) return ok('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') if (args.startsWith('rev-parse --verify')) return ok('abc\n') if (args.startsWith('diff --numstat')) return ok('1\t1\tREADME.md\n0\t3\tgone.txt\n') @@ -103,6 +104,12 @@ describe('git-aware-file-explorer pane', () => { 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() }) } From a6d046d6703adf8dbbb5081bd83965511839bcaa Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:18:00 -0500 Subject: [PATCH 07/61] fix(mods): mark git-aware-file-explorer line totals as a minimum when capped New files beyond the first 200, or over 512 KB, were skipped silently, so the branch bar's added total read as exact when it was not. Record that in the scan and draw the total with a trailing + (as in 99+), and say so on the docs page. Co-Authored-By: Claude Opus 5.5 --- docs/mods/git-aware-file-explorer.md | 4 +++- .../hooks/register.tsx | 20 ++++++++++++++----- .../tests/pane.test.ts | 20 +++++++++++++++++-- .../git-aware-file-explorer/types/index.d.ts | 7 ++++++- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/mods/git-aware-file-explorer.md b/docs/mods/git-aware-file-explorer.md index d5e45b74..94b4fe4a 100644 --- a/docs/mods/git-aware-file-explorer.md +++ b/docs/mods/git-aware-file-explorer.md @@ -13,7 +13,9 @@ in a panel beside the tree. ## 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. + 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, the added total is marked as a + minimum (`+1234+`). - **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. diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 8e9222e1..cab64215 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -184,12 +184,17 @@ async function lineTotals($: $, root: string, changes: Change[]) { const outs = hasHead ? [await git($, root, ['diff', '--numstat', '--no-ext-diff', 'HEAD'])] : [await git($, root, ['diff', '--numstat', '--no-ext-diff', '--cached']), await git($, root, ['diff', '--numstat', '--no-ext-diff'])] - const total = sumNumstat(outs.map(o => o.stdout).join('\n')) - const untracked = changes.filter(c => c.x === '?').slice(0, UNTRACKED_LIMIT) - for (const c of untracked) { + const total: { added: number; deleted: number; partial?: boolean } = sumNumstat(outs.map(o => o.stdout).join('\n')) + const untracked = changes.filter(c => c.x === '?') + if (untracked.length > UNTRACKED_LIMIT) total.partial = true + for (const c of untracked.slice(0, UNTRACKED_LIMIT)) { const path = root + '/' + c.path const stat = await $.fs.stat(path).catch(() => undefined) - if (!stat || stat.kind !== 'file' || stat.size > UNTRACKED_BYTES) continue + if (!stat || stat.kind !== 'file') continue + if (stat.size > UNTRACKED_BYTES) { + total.partial = true + continue + } total.added += countLines(await $.fs.read(path).catch(() => '')) } return total @@ -483,7 +488,12 @@ export const register: Register = on => { {s.isRepo && n > 0 && s.lines && ( - +{s.lines.added} −{s.lines.deleted} + {/* A trailing + marks a floor: some untracked files went uncounted. */} + + +{s.lines.added} + {s.lines.partial ? '+' : ''} + {' '} + −{s.lines.deleted} )} {s.isRepo && ( diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 28678184..e523b6b0 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -33,10 +33,10 @@ const DISK: Record = { } /** A repository with one modified, one untracked and one deleted file. */ -function fakeRepo(on: On) { +function fakeRepo(on: On, fileSize = 4) { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) - on('fs.stat', () => ({ value: { kind: 'file' as const, size: 4, mtimeMs: 1, isLink: false } })) + on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: 1, isLink: false } })) on('fs.read', () => ({ value: 'a\nb\n' })) on('process.run', ($, e) => { const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') @@ -114,6 +114,22 @@ describe('git-aware-file-explorer pane', () => { }) } + test('marks the added total as a minimum when a new file is too large to count', async ($, on) => { + fakeRepo(on, 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('filters files outside a git repository', async ($, on) => { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index aaa90e78..031fd239 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -22,7 +22,12 @@ export type Scan = { /** Ignored paths relative to root; a directory ends with `/`. */ ignored: string[] /** Lines added and deleted across all changes against HEAD, untracked files included. */ - lines?: { added: number; deleted: number } + lines?: { + added: number + deleted: number + /** Untracked files went uncounted (too many, or too large to read), so `added` is a floor. */ + partial?: boolean + } /** Error text from the last scan, if it failed. */ error?: string } From a7e40242eca298cd100bf26f1915032a464408f7 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:18:39 -0500 Subject: [PATCH 08/61] fix(mods): stop git-aware-file-explorer diff scrolling at the last line The panel's scroll limit allowed half a viewport past the end, leaving blank rows. Compute the furthest start that still shows the last line, counting the separator rows between hunks, and use it for the wheel, the line buttons and the spare row. The diff drawing now never overruns the panel's rows either. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 39 ++++++++++++------- .../tests/diff.test.ts | 12 +++++- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index cab64215..29029d57 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -49,7 +49,7 @@ type $ = EngineInterface * What the last drawing laid out, for the scroll hook to tell the two sides apart. * A module variable on purpose: it is layout, redrawn on every render, not state. */ -const layout = { treeColumns: 0, panelLines: 0, panelRoom: 0, hasFile: false, offset: 0, spacer: 0, spare: 0 } +const layout = { treeColumns: 0, panelLast: 0, panelRoom: 0, hasFile: false, offset: 0, spacer: 0, spare: 0 } type Row = { path: string @@ -345,6 +345,19 @@ async function buildRows( /** 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 } +/** + * The furthest a diff's window can start and still show its last line in `room` rows, counting + * the separator row drawn between two hunks. + */ +export function lastTop(lines: DiffLine[], room: number): number { + let used = 0 + for (let i = lines.length - 1; i >= 0; i--) { + used += 1 + (i < lines.length - 1 && lines[i]?.hunk !== lines[i + 1]?.hunk ? 1 : 0) + if (used > room) return i + 1 + } + return 0 +} + /** Reads a unified diff's hunks into numbered lines; file headers are dropped. */ export function diffLines(diff: string): DiffLine[] { const out: DiffLine[] = [] @@ -421,8 +434,7 @@ export const register: Register = on => { const isOverPanel = e.pointer ? e.pointer.column >= layout.treeColumns + GUTTER : true let moved: number if (layout.hasFile && isOverPanel) { - const last = Math.max(0, layout.panelLines - Math.ceil(layout.panelRoom / 2)) - moved = Math.min(last, Math.max(0, (await read($, panelOffsetAtom)) + e.by)) + moved = Math.min(layout.panelLast, Math.max(0, (await read($, panelOffsetAtom)) + e.by)) await update($, panelOffsetAtom, () => moved) } else { // The tree rows hidden below the window are as far as it can move down; a tree row is at least one. @@ -437,9 +449,7 @@ export const register: Register = on => { on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { /** Moves the file panel's window by `by` lines, kept within the file. */ const scrollPanel = (by: number) => - update($, panelOffsetAtom, cur => - Math.min(Math.max(0, layout.panelLines - Math.ceil(layout.panelRoom / 2)), Math.max(0, cur + by)), - ) + update($, panelOffsetAtom, cur => Math.min(layout.panelLast, Math.max(0, cur + by))) const els = $.ui.resolve(e) const { Box, Text, Button, Code } = els @@ -595,7 +605,8 @@ export const register: Register = on => { // The file panel is pinned over the pane's window: it moves down as the tree scrolls, // so it stays in view, and scrolls its own content through panelOffset. const panelRoom = Math.max(4, bodyRows - BRANCH_ROWS - 3) - let panelLines = 0 + // The first line the panel can start at and still end on the file's last line. + let panelLast = 0 let body let fileLines: { added: number; deleted: number } | undefined if (selected === null) { @@ -608,7 +619,7 @@ export const register: Register = on => { ) } else if (change) { const lines = diffLines(await diffFor($, s, change)) - panelLines = lines.length + panelLast = lastTop(lines, panelRoom) if (lines.length === 0) body = No textual difference (binary or mode change). else { const top = Math.min(panelOffset, Math.max(0, lines.length - 1)) @@ -616,10 +627,12 @@ export const register: Register = on => { 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 i = top, used = 0; i < lines.length && used < panelRoom; i++) { + for (let i = top, used = 0; i < lines.length; i++) { const l = lines[i] as DiffLine const tint = l.kind === '+' ? ADDED : l.kind === '-' ? DELETED : undefined - if (i > top && lines[i - 1]?.hunk !== l.hunk) { + const gap = i > top && lines[i - 1]?.hunk !== l.hunk + if (used + (gap ? 2 : 1) > panelRoom) break + if (gap) { drawn.push( {' '.repeat(width * 2 + 2)}⋯ @@ -657,7 +670,7 @@ export const register: Register = on => { if (text.includes('\0')) body = Binary file. else { const lines = text.split('\n') - panelLines = lines.length + panelLast = Math.max(0, lines.length - panelRoom) const top = Math.min(panelOffset, Math.max(0, lines.length - 1)) body = ( { // the branch bar stays at its top until the next wheel move brings it to rest. const canGoUp = selected !== null ? panelOffset > 0 : treeOffset > 0 const spacer = Math.max(canGoUp ? ROOM : 0, offset) - const spare = selected !== null && panelOffset < Math.max(0, panelLines - Math.ceil(panelRoom / 2)) ? ROOM : 0 - Object.assign(layout, { treeColumns, panelLines, panelRoom, hasFile: selected !== null, offset, spacer, spare }) + const spare = selected !== null && panelOffset < panelLast ? ROOM : 0 + Object.assign(layout, { treeColumns, panelLast, panelRoom, hasFile: selected !== null, offset, spacer, spare }) const slash = selected === null ? -1 : selected.lastIndexOf('/') // Under the branch bar, down to the bottom of the pane's window. diff --git a/mods/claude/git-aware-file-explorer/tests/diff.test.ts b/mods/claude/git-aware-file-explorer/tests/diff.test.ts index 25ffd9c6..45f2beaa 100644 --- a/mods/claude/git-aware-file-explorer/tests/diff.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'claude-code/testing' -import { countLines, diffLines, sumNumstat } from '../hooks/register' +import { countLines, diffLines, lastTop, sumNumstat } from '../hooks/register' const DIFF = [ 'diff --git a/f b/f', @@ -24,6 +24,16 @@ describe('diff windowing', () => { expect(lines[4]).toEqual({ text: ' ten', kind: ' ', o: 10, n: 10, hunk: 1 }) }) + test('the window stops where the last line reaches the bottom, separators counted', async () => { + const lines = diffLines(DIFF) + // 7 lines and one separator need 8 rows: they fit whole. + expect(lastTop(lines, 8)).toBe(0) + // In 4 rows the last hunk's 3 lines fit, and not the separator above them. + expect(lastTop(lines, 4)).toBe(4) + expect(lastTop(lines, 3)).toBe(4) + expect(lastTop(lines, 2)).toBe(5) + }) + test('numstat sums lines and skips binary files', async () => { expect(sumNumstat('3\t1\ta.md\n-\t-\tlogo.png\n2\t0\tb.ts\n')).toEqual({ added: 5, deleted: 1 }) }) From 148505cc03c36bfd8b2c1c7088c3663566910b76 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:19:29 -0500 Subject: [PATCH 09/61] fix(scripts): check mod documentation coverage in validate_artifact_sync AGENTS.md runs the sync validator for mods/ changes, but the script checked only topologies, specialists and skills. It now checks that every mod's manifest name matches its folder and that the mod has an overview page in the nav, a row in docs/mods/index.md and an entry on its client's harness page. Tests cover the three coverage failures; AGENTS.md's new-mod steps say to run it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + scripts/test_validate_artifact_sync.py | 34 +++++++++++++- scripts/validate_artifact_sync.py | 63 +++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 93d18eb5..061cc12c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -378,6 +378,7 @@ as local files. 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 diff --git a/scripts/test_validate_artifact_sync.py b/scripts/test_validate_artifact_sync.py index d852be46..9818b08f 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,31 @@ 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_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_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 c29a561f..683e9214 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 @@ -590,6 +590,63 @@ def validate_skills() -> None: ) +MOD_HARNESS_PAGES = {"claude": "docs/harness/claudecode/index.md"} + + +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.""" + manifests = sorted((ROOT / "mods").glob("*/*/.claude-plugin/plugin.json")) + mods: dict[str, str] = {} + for path in manifests: + folder = path.parents[1] + identifier, client = folder.name, folder.parent.name + mods[identifier] = client + 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)), + ) + if not mods: + return + + index_text = (ROOT / "docs/mods/index.md").read_text() + catalogued = set(re.findall(r"\]\(([a-z0-9-]+)\.md\)", 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 []) + 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( + f"mods/{identifier}.md" in (ROOT / harness).read_text(), + 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 +693,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: From ba338ea493267cea20898a870c41f16823019235 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:30:09 -0500 Subject: [PATCH 10/61] fix(mods): say when a git-aware-file-explorer search stopped at the walk limit Outside a git repository the filter searches a breadth-first walk capped at 20000 files. A file past the cap was reported as "No matching files." with no hint the search was partial. The walk now reports when it stopped early and the tree says so. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 20 +++++++++++------ .../tests/pane.test.ts | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 29029d57..7a089931 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -245,12 +245,13 @@ async function pathsFor( s: Scan, changedOnly: boolean, filter: string, -): Promise<{ paths: string[]; total: number } | undefined> { +): Promise<{ paths: string[]; total: number; isWalkCapped?: boolean } | undefined> { const needle = filter.trim().toLowerCase() if (!changedOnly && needle === '') return undefined let paths: string[] + let isWalkCapped = false if (changedOnly) paths = s.changes.map(c => c.path) - else if (!s.isRepo) paths = await walkFiles($, s.root) + else if (!s.isRepo) ({ files: paths, isCapped: isWalkCapped } = await walkFiles($, s.root)) else { const listed = await git($, s.root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']) // Ignored entries as the scan found them (a directory ends with `/`), so a filter narrows the @@ -258,14 +259,18 @@ async function pathsFor( paths = [...new Set([...listed.stdout.split('\0').filter(Boolean), ...s.changes.map(c => c.path), ...s.ignored])] } const hits = needle === '' ? paths : paths.filter(p => p.toLowerCase().includes(needle)) - return { paths: hits.slice(0, FILTER_LIMIT), total: hits.length } + return { paths: hits.slice(0, FILTER_LIMIT), total: hits.length, isWalkCapped } } -/** File paths under `root`, breadth first, up to WALK_LIMIT: what a filter searches without git. */ -async function walkFiles($: $, root: string): Promise { +/** + * File paths under `root`, breadth first, up to WALK_LIMIT: what a filter searches without git. + * `isCapped` says the walk stopped there, so a search over it may miss files. + */ +async function walkFiles($: $, root: string): Promise<{ files: string[]; isCapped: boolean }> { const files: string[] = [] const dirs = [''] - for (let i = 0; i < dirs.length && files.length < WALK_LIMIT; i++) { + let i = 0 + for (; i < dirs.length && files.length < WALK_LIMIT; i++) { const dir = dirs[i] as string const entries = await $.fs.list(root + (dir === '' ? '' : '/' + dir)).catch(() => []) for (const en of entries) { @@ -275,7 +280,7 @@ async function walkFiles($: $, root: string): Promise { else files.push(path) } } - return files.slice(0, WALK_LIMIT) + return { files: files.slice(0, WALK_LIMIT), isCapped: files.length > WALK_LIMIT || i < dirs.length } } async function buildRows( @@ -599,6 +604,7 @@ export const register: Register = on => { Showing {virtual.paths.length} of {virtual.total} matches. )} + {virtual?.isWalkCapped && Searched the first {WALK_LIMIT} files only.} ) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index e523b6b0..802b8162 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -130,6 +130,28 @@ describe('git-aware-file-explorer pane', () => { 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 files only/ })).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] ?? [] })) From 312ed7cd31ca265e84d9c28b35054f06dbc92b17 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:31:01 -0500 Subject: [PATCH 11/61] fix(mods): diff an unborn branch against the empty tree in git-aware-file-explorer With no commits yet, the line totals added the staged and unstaged numstat together, counting a line twice when it was staged and then edited again, and the diff panel showed two diffs for one file. Both now compare the working tree against the empty tree, which yields one diff, as HEAD does once a commit exists. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 22 +++++++------ .../tests/pane.test.ts | 32 +++++++++++++++++-- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 7a089931..785418a0 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -178,13 +178,19 @@ export function countLines(text: string): number { const UNTRACKED_LIMIT = 200 const UNTRACKED_BYTES = 512 * 1024 +/** + * 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 hasHead = (await git($, root, ['rev-parse', '--verify', '-q', 'HEAD'])).exitCode === 0 - const outs = hasHead - ? [await git($, root, ['diff', '--numstat', '--no-ext-diff', 'HEAD'])] - : [await git($, root, ['diff', '--numstat', '--no-ext-diff', '--cached']), await git($, root, ['diff', '--numstat', '--no-ext-diff'])] - const total: { added: number; deleted: number; partial?: boolean } = sumNumstat(outs.map(o => o.stdout).join('\n')) + const out = await git($, root, ['diff', '--numstat', '--no-ext-diff', await diffBase($, root)]) + const total: { added: number; deleted: number; partial?: boolean } = sumNumstat(out.stdout) const untracked = changes.filter(c => c.x === '?') if (untracked.length > UNTRACKED_LIMIT) total.partial = true for (const c of untracked.slice(0, UNTRACKED_LIMIT)) { @@ -393,11 +399,7 @@ async function diffFor($: $, s: Scan, c: Change): Promise { return (await git($, s.root, ['diff', ...flags, '--no-index', '--', '/dev/null', c.path])).stdout } const paths = c.from ? [c.from, c.path] : [c.path] - const hasHead = (await git($, s.root, ['rev-parse', '--verify', '-q', 'HEAD'])).exitCode === 0 - if (hasHead) return (await git($, s.root, ['diff', ...flags, '-M', 'HEAD', '--', ...paths])).stdout - const staged = await git($, s.root, ['diff', ...flags, '--cached', '--', ...paths]) - const unstaged = await git($, s.root, ['diff', ...flags, '--', ...paths]) - return staged.stdout + unstaged.stdout + return (await git($, s.root, ['diff', ...flags, '-M', await diffBase($, s.root), '--', ...paths])).stdout } const FOLDER_ICON = diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 802b8162..cfbd64f4 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -32,8 +32,13 @@ const DISK: Record = { [ROOT + '/docs']: [file('index.md')], } -/** A repository with one modified, one untracked and one deleted file. */ -function fakeRepo(on: On, fileSize = 4) { +const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + +/** + * 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. + */ +function fakeRepo(on: On, fileSize = 4, hasHead = true) { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: 1, isLink: false } })) @@ -45,7 +50,11 @@ function fakeRepo(on: On, fileSize = 4) { if (args.startsWith('ls-files') && args.includes('--ignored')) return ok('node_modules/\0') if (args.startsWith('ls-files')) return ok('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') - if (args.startsWith('rev-parse --verify')) return ok('abc\n') + if (args.startsWith('rev-parse --verify')) return hasHead ? ok('abc\n') : ok('', 1) + if (args === 'hash-object -t tree /dev/null') return ok(EMPTY_TREE + '\n') + if (args === 'diff --numstat --no-ext-diff ' + EMPTY_TREE) return ok('2\t0\tREADME.md\n') + if (args === 'diff --numstat --no-ext-diff --cached') return ok('2\t0\tREADME.md\n') + if (args === 'diff --numstat --no-ext-diff') return ok('1\t1\tREADME.md\n') if (args.startsWith('diff --numstat')) return ok('1\t1\tREADME.md\n0\t3\tgone.txt\n') if (args.includes('README.md')) return ok(DIFF) return ok('', 1) @@ -130,6 +139,23 @@ describe('git-aware-file-explorer pane', () => { await ui.unmount() }) + test('counts lines once on a branch with no commits yet', async ($, on) => { + fakeRepo(on, 4, 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('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 })) From 558d0ed7cf3bb5ac60841553f810601995afff6d Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:31:45 -0500 Subject: [PATCH 12/61] fix(mods): run one git-aware-file-explorer scan at a time The timer, tool calls and the refresh button each started a scan, and scans could overlap; an older one finishing last overwrote newer results until the next scan. A scan asked for while one runs now waits, and all such requests fold into one scan after it. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 33 +++++++++--- .../tests/pane.test.ts | 53 ++++++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 785418a0..90a0de0f 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -206,12 +206,33 @@ async function lineTotals($: $, root: string, changes: Change[]) { return total } -/** Scans and writes the result only when it changed, so idle scans redraw nothing. */ -async function rescan($: $) { - const next = await scan($) - // Any write redraws the pane, so an unchanged scan is not written at all. - if (JSON.stringify(await read($, scanAtom)) === JSON.stringify(next)) return - await update($, scanAtom, () => next) +/** 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 next = await scan($) + // 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 } /** Rests the pane's window with the branch bar at its top (see ROOM). */ diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index cfbd64f4..10c3655e 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'claude-code/testing' +import { describe, expect, mock, test } from 'claude-code/testing' import type { FsEntry, On } from 'claude-code' const ROOT = '/repo' @@ -37,16 +37,23 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' /** * 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. */ -function fakeRepo(on: On, fileSize = 4, hasHead = true) { +function fakeRepo( + on: On, + { fileSize = 4, hasHead = true, onStatus }: { fileSize?: number; hasHead?: boolean; onStatus?: () => Promise } = {}, +) { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: 1, isLink: false } })) on('fs.read', () => ({ value: 'a\nb\n' })) - on('process.run', ($, e) => { + on('process.run', async ($, e) => { const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') - if (args.startsWith('status')) return ok(' M README.md\0?? src/new.ts\0 D gone.txt\0') + if (args.startsWith('status')) { + await onStatus?.() + return ok(' M README.md\0?? src/new.ts\0 D gone.txt\0') + } if (args.startsWith('ls-files') && args.includes('--ignored')) return ok('node_modules/\0') if (args.startsWith('ls-files')) return ok('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') @@ -124,7 +131,7 @@ describe('git-aware-file-explorer pane', () => { } test('marks the added total as a minimum when a new file is too large to count', async ($, on) => { - fakeRepo(on, 1024 * 1024) + fakeRepo(on, { fileSize: 1024 * 1024 }) const ui = await $.ui.mount({ plugin: 'git-aware-file-explorer', surface: 'desktop', @@ -140,7 +147,7 @@ describe('git-aware-file-explorer pane', () => { }) test('counts lines once on a branch with no commits yet', async ($, on) => { - fakeRepo(on, 4, false) + fakeRepo(on, { hasHead: false }) const ui = await $.ui.mount({ plugin: 'git-aware-file-explorer', surface: 'desktop', @@ -156,6 +163,40 @@ describe('git-aware-file-explorer pane', () => { 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('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 })) From e527df74b8b35a3fdd09c11f517a7571e1c6a470 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:33:20 -0500 Subject: [PATCH 13/61] fix(mods): keep the git-aware-file-explorer panel window within the file The diff and preview clamped their first line to the file's last line, not to the last window that fills the panel. After the pane grew or the file shrank, a scrolled panel showed a short tail with empty space below until the next scroll. Both now clamp to that last full window. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 4 +-- .../tests/pane.test.ts | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 90a0de0f..c6f6ee95 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -651,7 +651,7 @@ export const register: Register = on => { panelLast = lastTop(lines, panelRoom) if (lines.length === 0) body = No textual difference (binary or mode change). else { - const top = Math.min(panelOffset, Math.max(0, lines.length - 1)) + const top = Math.min(panelOffset, panelLast) const width = String(Math.max(...lines.map(l => Math.max(l.o, l.n)))).length 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. @@ -700,7 +700,7 @@ export const register: Register = on => { else { const lines = text.split('\n') panelLast = Math.max(0, lines.length - panelRoom) - const top = Math.min(panelOffset, Math.max(0, lines.length - 1)) + const top = Math.min(panelOffset, panelLast) body = ( Promise } = {}, + { + fileSize = 4, + hasHead = true, + onStatus, + text = 'a\nb\n', + }: { fileSize?: number; hasHead?: boolean; onStatus?: () => Promise; text?: string } = {}, ) { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: 1, isLink: false } })) - on('fs.read', () => ({ value: 'a\nb\n' })) + on('fs.read', () => ({ value: text })) on('process.run', async ($, e) => { const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') @@ -197,6 +202,27 @@ describe('git-aware-file-explorer pane', () => { await ui.unmount() }) + test('keeps a scrolled preview within the file when the pane grows', async ($, on) => { + fakeRepo(on, { text: Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n') }) + 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' }) + for (let i = 0; i < 30; i++) await ui.press({ key: 'line-down' }) + expect((await ui.find({ type: 'Code' }))?.props.startLine).toBe(31) + + // Tall enough for the whole file: the window can't sit 30 lines down any more. + await ui.redraw({ ...PROPS, scroll: { offset: 0, bodyRows: 200 } }) + expect((await ui.find({ type: 'Code' }))?.props.startLine).toBe(1) + + 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 })) From fd508532eab49a9c3945b16ae5d9e6a34df54ddc Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:41:11 -0500 Subject: [PATCH 14/61] docs(mods): say where git-aware-file-explorer's tsconfig base comes from The tsconfig extends .claude-plugin/types/tsconfig.json, which Claude Code writes, git-ignored, when it loads the mod. On a clean checkout tsc failed with no hint why. The config now says to load the mod once before running tsc. Co-Authored-By: Claude Opus 5.5 --- mods/claude/git-aware-file-explorer/tsconfig.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mods/claude/git-aware-file-explorer/tsconfig.json b/mods/claude/git-aware-file-explorer/tsconfig.json index 8681ec01..5796e972 100644 --- a/mods/claude/git-aware-file-explorer/tsconfig.json +++ b/mods/claude/git-aware-file-explorer/tsconfig.json @@ -1,3 +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" } From 4443b574c8c658a96d756177e01a1e59c98591fd Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:42:24 -0500 Subject: [PATCH 15/61] fix(mods): redraw git-aware-file-explorer when an edit keeps the line counts A scan that found the same git status and line totals wrote nothing, so the pane did not redraw: an edit from `bar` to `baz` (still +1 -1), or any change outside a git repository, left the open diff, preview or tree stale. The scan now carries the modification time and size of the open file, the open folders and the changed files, so such an edit changes the scan and redraws the pane. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 20 +++++++++- .../tests/pane.test.ts | 37 +++++++++++++++++-- .../git-aware-file-explorer/types/index.d.ts | 2 + 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index c6f6ee95..c114acf1 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -177,6 +177,8 @@ export function countLines(text: string): number { /** 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 @@ -206,6 +208,21 @@ async function lineTotals($: $, root: string, changes: Change[]) { return total } +/** + * Modification time and size of the open file, 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. + */ +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)]) + if (selected !== null) paths.add(selected) + const stats = await Promise.all( + [...paths].map(p => $.fs.stat(p === '' ? s.root : s.root + '/' + p).catch(() => undefined)), + ) + return stats.map(st => (st ? st.mtimeMs + ':' + st.size : '-')).join(',') +} + /** The scan running now, and whether another was asked for while it ran (see rescan). */ let scanning: Promise | undefined let isRescanDue = false @@ -224,7 +241,8 @@ function rescan($: $): Promise { try { do { isRescanDue = false - const next = await scan($) + const found = await scan($) + const next: Scan = { ...found, stamp: await stampFor($, found) } // 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) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 425f1d00..eed6a7b9 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -38,6 +38,7 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * 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. */ function fakeRepo( on: On, @@ -46,11 +47,18 @@ function fakeRepo( hasHead = true, onStatus, text = 'a\nb\n', - }: { fileSize?: number; hasHead?: boolean; onStatus?: () => Promise; text?: string } = {}, + live = { diff: DIFF, mtimeMs: 1 }, + }: { + fileSize?: number + hasHead?: boolean + onStatus?: () => Promise + text?: string + live?: { diff: string; mtimeMs: number } + } = {}, ) { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) - on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: 1, isLink: false } })) + on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink: false } })) on('fs.read', () => ({ value: text })) on('process.run', async ($, e) => { const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') @@ -68,7 +76,7 @@ function fakeRepo( if (args === 'diff --numstat --no-ext-diff --cached') return ok('2\t0\tREADME.md\n') if (args === 'diff --numstat --no-ext-diff') return ok('1\t1\tREADME.md\n') if (args.startsWith('diff --numstat')) return ok('1\t1\tREADME.md\n0\t3\tgone.txt\n') - if (args.includes('README.md')) return ok(DIFF) + if (args.includes('README.md')) return ok(live.diff) return ok('', 1) }) } @@ -168,6 +176,29 @@ describe('git-aware-file-explorer pane', () => { 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('runs one scan at a time and folds the requests made meanwhile into one', async ($, on) => { const clock = mock.clock(on) let isSlow = false diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index 031fd239..f41c2904 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -30,6 +30,8 @@ export type Scan = { } /** 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' { From 2c2d08161f25fd02b815d01ba5e0d5248f9d5d7c Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:42:52 -0500 Subject: [PATCH 16/61] fix(mods): read the open diff once per scan in git-aware-file-explorer Every draw with a changed file open ran git diff and parsed it again, and the wheel and the line buttons draw on every step, so scrolling a large diff ran git over and over. The parsed diff is now kept until the scan changes; the scan's file stamps change with the file, so an edit still reads it afresh. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 12 ++++++- .../tests/pane.test.ts | 31 ++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index c114acf1..00157a7b 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -431,6 +431,16 @@ export function diffLines(diff: string): DiffLine[] { return out } +/** The open file's parsed diff and what it was read for, so a scroll redraws without running git. */ +let diffCache: { key: string; lines: DiffLine[] } | undefined + +/** The parsed diff of `c`, read again only when the scan changed (a content edit changes its stamp). */ +async function cachedDiff($: $, s: Scan, c: Change): Promise { + const key = JSON.stringify([s, c]) + if (diffCache?.key !== key) diffCache = { key, lines: diffLines(await diffFor($, s, c)) } + return diffCache.lines +} + async function diffFor($: $, s: Scan, c: Change): Promise { const flags = ['--no-color', '--no-ext-diff'] if (c.x === '?') { @@ -665,7 +675,7 @@ export const register: Register = on => { ) } else if (change) { - const lines = diffLines(await diffFor($, s, change)) + const lines = await cachedDiff($, s, change) panelLast = lastTop(lines, panelRoom) if (lines.length === 0) body = No textual difference (binary or mode change). else { diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index eed6a7b9..7a52051c 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -38,7 +38,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * 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. + * `live` is read on each call: README.md's diff and every file's modification time; `diffRuns` + * counts the diffs of README.md run. */ function fakeRepo( on: On, @@ -47,13 +48,13 @@ function fakeRepo( hasHead = true, onStatus, text = 'a\nb\n', - live = { diff: DIFF, mtimeMs: 1 }, + live = { diff: DIFF, mtimeMs: 1 } as { diff: string; mtimeMs: number; diffRuns?: number }, }: { fileSize?: number hasHead?: boolean onStatus?: () => Promise text?: string - live?: { diff: string; mtimeMs: number } + live?: { diff: string; mtimeMs: number; diffRuns?: number } } = {}, ) { on('session.cwd', () => ({ value: ROOT })) @@ -76,7 +77,10 @@ function fakeRepo( if (args === 'diff --numstat --no-ext-diff --cached') return ok('2\t0\tREADME.md\n') if (args === 'diff --numstat --no-ext-diff') return ok('1\t1\tREADME.md\n') if (args.startsWith('diff --numstat')) return ok('1\t1\tREADME.md\n0\t3\tgone.txt\n') - if (args.includes('README.md')) return ok(live.diff) + if (args.includes('README.md')) { + live.diffRuns = (live.diffRuns ?? 0) + 1 + return ok(live.diff) + } return ok('', 1) }) } @@ -199,6 +203,25 @@ describe('git-aware-file-explorer pane', () => { 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 wheel or ▲/▼ press 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() + }) + 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 From c6c824e06ebe11e3046f1489bcbbfe61efa5c1c8 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:44:01 -0500 Subject: [PATCH 17/61] fix(mods): mark git-aware-file-explorer counts when git output is cut process.run keeps the first 4 MiB of a stream and says when it cut one, but the mod read cut status, line counts and diffs as whole. A cut status now drops its partial last entry, and the change count and line totals are marked as floors (a trailing +); a cut diff says so in the panel header and marks its counts the same way. Co-Authored-By: Claude Opus 5.5 --- docs/mods/git-aware-file-explorer.md | 2 +- .../hooks/register.tsx | 59 ++++++++++++++----- .../tests/pane.test.ts | 34 +++++++++-- .../git-aware-file-explorer/types/index.d.ts | 4 ++ 4 files changed, 76 insertions(+), 23 deletions(-) diff --git a/docs/mods/git-aware-file-explorer.md b/docs/mods/git-aware-file-explorer.md index 94b4fe4a..f260586d 100644 --- a/docs/mods/git-aware-file-explorer.md +++ b/docs/mods/git-aware-file-explorer.md @@ -15,7 +15,7 @@ in a panel beside the tree. - **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, the added total is marked as a - minimum (`+1234+`). + minimum (`+1234+`). When git's output passes 4 MB it is cut, and every count is 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. diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 00157a7b..af1144b5 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -140,13 +140,17 @@ async function scan($: $): Promise { ]) let name = branch.stdout.trim() if (name === '') name = (await git($, root, ['rev-parse', '--short', 'HEAD'])).stdout.trim() - const changes = status.exitCode === 0 ? parseStatus(status.stdout) : [] + // 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, changes, - lines: changes.length > 0 ? await lineTotals($, root, changes) : { added: 0, deleted: 0 }, + lines, + isTruncated: status.isStdoutTruncated || lines.isTruncated || undefined, ignored: ignored.stdout.split('\0').filter(Boolean), error: status.exitCode === 0 ? undefined : status.stderr.trim(), } @@ -192,7 +196,8 @@ async function diffBase($: $, root: string): Promise { /** Lines added and deleted against HEAD (staged and unstaged), untracked files counted as added. */ async function lineTotals($: $, root: string, changes: Change[]) { const out = await git($, root, ['diff', '--numstat', '--no-ext-diff', await diffBase($, root)]) - const total: { added: number; deleted: number; partial?: boolean } = sumNumstat(out.stdout) + const total: { added: number; deleted: number; partial?: boolean; isTruncated?: boolean } = sumNumstat(out.stdout) + if (out.isStdoutTruncated) total.isTruncated = true const untracked = changes.filter(c => c.x === '?') if (untracked.length > UNTRACKED_LIMIT) total.partial = true for (const c of untracked.slice(0, UNTRACKED_LIMIT)) { @@ -431,24 +436,30 @@ export function diffLines(diff: string): DiffLine[] { return out } +/** A file's parsed diff; `isTruncated` when git's output passed 4 MiB and only its start is here. */ +type FileDiff = { lines: DiffLine[]; isTruncated: boolean } + /** The open file's parsed diff and what it was read for, so a scroll redraws without running git. */ -let diffCache: { key: string; lines: DiffLine[] } | undefined +let diffCache: { key: string; diff: FileDiff } | undefined /** The parsed diff of `c`, read again only when the scan changed (a content edit changes its stamp). */ -async function cachedDiff($: $, s: Scan, c: Change): Promise { +async function cachedDiff($: $, s: Scan, c: Change): Promise { const key = JSON.stringify([s, c]) - if (diffCache?.key !== key) diffCache = { key, lines: diffLines(await diffFor($, s, c)) } - return diffCache.lines + if (diffCache?.key !== key) { + const out = await diffFor($, s, c) + diffCache = { key, diff: { lines: diffLines(out.stdout), isTruncated: out.isStdoutTruncated } } + } + return diffCache.diff } -async function diffFor($: $, s: Scan, c: Change): Promise { +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 (await git($, s.root, ['diff', ...flags, '--no-index', '--', '/dev/null', c.path])).stdout + return git($, s.root, ['diff', ...flags, '--no-index', '--', '/dev/null', c.path]) } const paths = c.from ? [c.from, c.path] : [c.path] - return (await git($, s.root, ['diff', ...flags, '-M', await diffBase($, s.root), '--', ...paths])).stdout + return git($, s.root, ['diff', ...flags, '-M', await diffBase($, s.root), '--', ...paths]) } const FOLDER_ICON = @@ -554,12 +565,15 @@ export const register: Register = on => { {s.isRepo && n > 0 && s.lines && ( - {/* A trailing + marks a floor: some untracked files went uncounted. */} + {/* A trailing + marks a floor: some untracked files went uncounted, or git's output was cut. */} +{s.lines.added} - {s.lines.partial ? '+' : ''} + {s.lines.partial || s.isTruncated ? '+' : ''} {' '} - −{s.lines.deleted} + + −{s.lines.deleted} + {s.isTruncated ? '+' : ''} + )} {s.isRepo && ( @@ -568,7 +582,8 @@ export const register: Register = on => { clean ) : ( - ● {n} changed + ● {n} + {s.isTruncated ? '+' : ''} changed )} @@ -666,6 +681,7 @@ export const register: Register = on => { let panelLast = 0 let body let fileLines: { added: number; deleted: number } | undefined + let isDiffTruncated = false if (selected === null) { body = ( @@ -675,7 +691,9 @@ export const register: Register = on => { ) } else if (change) { - const lines = await cachedDiff($, s, change) + const diff = await cachedDiff($, s, change) + const lines = diff.lines + isDiffTruncated = diff.isTruncated panelLast = lastTop(lines, panelRoom) if (lines.length === 0) body = No textual difference (binary or mode change). else { @@ -780,7 +798,16 @@ export const register: Register = on => { {fileLines && ( - +{fileLines.added} −{fileLines.deleted} + {/* A cut diff's counts are floors, as the branch bar marks them. */} + {isDiffTruncated && cut at 4 MB } + + +{fileLines.added} + {isDiffTruncated ? '+' : ''} + {' '} + + −{fileLines.deleted} + {isDiffTruncated ? '+' : ''} + )} {layout.hasFile && ( diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 7a52051c..45a86640 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -13,12 +13,12 @@ const DIFF = [ '', ].join('\n') -const ok = (stdout: string, exitCode = 0) => ({ +const ok = (stdout: string, exitCode = 0, isStdoutTruncated = false) => ({ value: { exitCode, stdout, stderr: '', - isStdoutTruncated: false, + isStdoutTruncated, isStderrTruncated: false, }, }) @@ -39,7 +39,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * 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. + * counts the diffs of README.md run. With `isCut`, git's status, line counts and diff come back cut + * at the 4 MiB limit, as `process.run` reports them. */ function fakeRepo( on: On, @@ -49,12 +50,14 @@ function fakeRepo( onStatus, text = 'a\nb\n', live = { diff: DIFF, mtimeMs: 1 } as { diff: string; mtimeMs: number; diffRuns?: number }, + isCut = false, }: { fileSize?: number hasHead?: boolean onStatus?: () => Promise text?: string live?: { diff: string; mtimeMs: number; diffRuns?: number } + isCut?: boolean } = {}, ) { on('session.cwd', () => ({ value: ROOT })) @@ -66,7 +69,7 @@ function fakeRepo( if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') if (args.startsWith('status')) { await onStatus?.() - return ok(' M README.md\0?? src/new.ts\0 D gone.txt\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.txt\0') } if (args.startsWith('ls-files') && args.includes('--ignored')) return ok('node_modules/\0') if (args.startsWith('ls-files')) return ok('README.md\0src/a.ts\0docs/index.md\0') @@ -76,10 +79,10 @@ function fakeRepo( if (args === 'diff --numstat --no-ext-diff ' + EMPTY_TREE) return ok('2\t0\tREADME.md\n') if (args === 'diff --numstat --no-ext-diff --cached') return ok('2\t0\tREADME.md\n') if (args === 'diff --numstat --no-ext-diff') return ok('1\t1\tREADME.md\n') - if (args.startsWith('diff --numstat')) return ok('1\t1\tREADME.md\n0\t3\tgone.txt\n') + if (args.startsWith('diff --numstat')) return ok('1\t1\tREADME.md\n0\t3\tgone.txt\n', 0, isCut) if (args.includes('README.md')) { live.diffRuns = (live.diffRuns ?? 0) + 1 - return ok(live.diff) + return ok(live.diff, 0, isCut) } return ok('', 1) }) @@ -222,6 +225,25 @@ describe('git-aware-file-explorer pane', () => { 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('runs one scan at a time and folds the requests made meanwhile into one', async ($, on) => { const clock = mock.clock(on) let isSlow = false diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index f41c2904..4db2ca79 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -27,7 +27,11 @@ export type Scan = { 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, so both totals are floors. */ + isTruncated?: boolean } + /** git's output 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. */ From 15322598d84819f249fad3d7248d395708709247 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:44:06 -0500 Subject: [PATCH 18/61] docs(mods): say what git-aware-file-explorer's settle does Its comment said it "rests" the pane's window, which read as leaving it alone. It scrolls the window back so the branch bar is at its top. Co-Authored-By: Claude Opus 5.5 --- mods/claude/git-aware-file-explorer/hooks/register.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index af1144b5..95c52e5c 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -258,7 +258,7 @@ function rescan($: $): Promise { return scanning } -/** Rests the pane's window with the branch bar at its top (see ROOM). */ +/** Scrolls the pane's window back so the branch bar is at its top, where it rests (see ROOM). */ async function settle($: $) { await $.ui.scroll({ to: { key: 'branch-bar' }, in: PANE, block: 'start' }).catch(() => undefined) } From a7079b10e5076b445b1811df1beb77f837c17b34 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:50:34 -0500 Subject: [PATCH 19/61] fix(scripts): report mod folders without a plugin manifest validate_artifact_sync found mods through their manifest path alone, so a mods/// folder with a missing or misnamed manifest was skipped and its overview page, nav, catalog and harness entries went unchecked. Mod folders are now found first, and a folder without its client's manifest fails. Co-Authored-By: Claude Opus 5.5 --- scripts/test_validate_artifact_sync.py | 8 ++++++++ scripts/validate_artifact_sync.py | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/test_validate_artifact_sync.py b/scripts/test_validate_artifact_sync.py index 9818b08f..47e04735 100644 --- a/scripts/test_validate_artifact_sync.py +++ b/scripts/test_validate_artifact_sync.py @@ -372,6 +372,14 @@ def test_missing_mod_overview_page_fails(self) -> None: 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_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) diff --git a/scripts/validate_artifact_sync.py b/scripts/validate_artifact_sync.py index 683e9214..476b3893 100644 --- a/scripts/validate_artifact_sync.py +++ b/scripts/validate_artifact_sync.py @@ -591,17 +591,27 @@ 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 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.""" - manifests = sorted((ROOT / "mods").glob("*/*/.claude-plugin/plugin.json")) + folders = sorted( + path for path in (ROOT / "mods").glob("*/*") if path.is_dir() and not path.name.startswith(".") + ) mods: dict[str, str] = {} - for path in manifests: - folder = path.parents[1] + 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): From f098dc510e393cb66c70a2c64fd536aced4d8de1 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:51:03 -0500 Subject: [PATCH 20/61] fix(scripts): check mod docs coverage when no mods remain validate_mods returned early when it found no mods, so removing the last one left its catalog row, nav entry and harness page entry in place with CI still passing. The coverage checks now run on an empty inventory too, and nav and harness entries for mods that do not exist fail. Co-Authored-By: Claude Opus 5.5 --- scripts/test_validate_artifact_sync.py | 8 ++++++++ scripts/validate_artifact_sync.py | 15 +++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/test_validate_artifact_sync.py b/scripts/test_validate_artifact_sync.py index 47e04735..bd699335 100644 --- a/scripts/test_validate_artifact_sync.py +++ b/scripts/test_validate_artifact_sync.py @@ -380,6 +380,14 @@ def test_mod_without_manifest_fails(self) -> None: 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) diff --git a/scripts/validate_artifact_sync.py b/scripts/validate_artifact_sync.py index 476b3893..a219e0b3 100644 --- a/scripts/validate_artifact_sync.py +++ b/scripts/validate_artifact_sync.py @@ -622,10 +622,9 @@ def validate_mods() -> None: "mod manifest name does not match directory", str(path.relative_to(ROOT)), ) - if not mods: - return - - index_text = (ROOT / "docs/mods/index.md").read_text() + # 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 = set(re.findall(r"\]\(([a-z0-9-]+)\.md\)", index_text)) check( catalogued == set(mods), @@ -635,6 +634,14 @@ def validate_mods() -> None: 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 = set(re.findall(r"mods/([a-z0-9-]+)\.md", (ROOT / harness).read_text())) - {"index"} + 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( From 27f71c857b90b0bdec175e030c1aa142f6f46eaf Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 05:51:31 -0500 Subject: [PATCH 21/61] fix(mods): say when a git-aware-file-explorer filter searched a cut file list The filter searched git ls-files output as whole even when process.run had cut it at 4 MiB, so a file past the cut read as "No matching files." and the cut name could match as a file. The partial last name is now dropped and the tree says the search covered only the start of the list, as it does for a capped walk outside a repository. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 24 +++++++++++++------ .../tests/pane.test.ts | 24 ++++++++++++++++--- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 95c52e5c..6aaf9a9d 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -295,21 +295,31 @@ async function pathsFor( s: Scan, changedOnly: boolean, filter: string, -): Promise<{ paths: string[]; total: number; isWalkCapped?: boolean } | undefined> { +): Promise<{ paths: string[]; total: number; incomplete?: string } | undefined> { const needle = filter.trim().toLowerCase() if (!changedOnly && needle === '') return undefined let paths: string[] - let isWalkCapped = false + // 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, isCapped: isWalkCapped } = await walkFiles($, s.root)) - else { + else if (!s.isRepo) { + const walked = await walkFiles($, s.root) + paths = walked.files + if (walked.isCapped) incomplete = `Searched the first ${WALK_LIMIT} files only.` + } else { 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. + let names = listed.stdout + if (listed.isStdoutTruncated) { + names = names.slice(0, names.lastIndexOf('\0') + 1) + incomplete = "git's file list 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.stdout.split('\0').filter(Boolean), ...s.changes.map(c => c.path), ...s.ignored])] + paths = [...new Set([...names.split('\0').filter(Boolean), ...s.changes.map(c => c.path), ...s.ignored])] } const hits = needle === '' ? paths : paths.filter(p => p.toLowerCase().includes(needle)) - return { paths: hits.slice(0, FILTER_LIMIT), total: hits.length, isWalkCapped } + return { paths: hits.slice(0, FILTER_LIMIT), total: hits.length, incomplete } } /** @@ -670,7 +680,7 @@ export const register: Register = on => { Showing {virtual.paths.length} of {virtual.total} matches. )} - {virtual?.isWalkCapped && Searched the first {WALK_LIMIT} files only.} + {virtual?.incomplete && {virtual.incomplete}} ) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 45a86640..9d98b103 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -39,8 +39,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * 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 and diff come back cut - * at the 4 MiB limit, as `process.run` reports them. + * 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. */ function fakeRepo( on: On, @@ -72,7 +72,7 @@ function fakeRepo( 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.txt\0') } if (args.startsWith('ls-files') && args.includes('--ignored')) return ok('node_modules/\0') - if (args.startsWith('ls-files')) return ok('README.md\0src/a.ts\0docs/index.md\0') + if (args.startsWith('ls-files')) return isCut ? ok('README.md\0src/a.ts\0docs/ind', 0, true) : ok('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') if (args.startsWith('rev-parse --verify')) return hasHead ? ok('abc\n') : ok('', 1) if (args === 'hash-object -t tree /dev/null') return ok(EMPTY_TREE + '\n') @@ -244,6 +244,24 @@ describe('git-aware-file-explorer pane', () => { 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('runs one scan at a time and folds the requests made meanwhile into one', async ($, on) => { const clock = mock.clock(on) let isSlow = false From e8aaa25874eae2f2c7d8231b41a4c3e3e46c78f7 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 06:01:24 -0500 Subject: [PATCH 22/61] fix(mods): show a deleted-then-recreated path as one change in git-aware-file-explorer After `git rm f` and writing f again, git status reports `D f` and `?? f`. The mod kept both: the path counted twice, the tree marked it untracked, and the panel opened the staged deletion alone. The two records now collapse into one change, marked M, whose diff and line counts are its HEAD version piped into `git diff --no-index` against the file: the net change. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 57 ++++++++++++++++--- .../tests/pane.test.ts | 32 ++++++++++- .../git-aware-file-explorer/types/index.d.ts | 2 +- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 6aaf9a9d..783d6468 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -65,24 +65,34 @@ type Row = { isMissing: boolean } -/** Splits `git status --porcelain=v1 -z` output into changes. */ +/** + * 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 changes: Change[] = [] + 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) } if (change.x === 'R' || change.x === 'C') change.from = parts[++i] - changes.push(change) + 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 changes + 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' } } @@ -95,6 +105,7 @@ export function badge(c: Change): { letter: string; color: string } { 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' @@ -123,8 +134,20 @@ export function iconFor(name: string): string { return `${body}` } -async function git($: $, root: string, args: string[]) { - return $.process.run(['git', '--no-optional-locks', ...args], { cwd: root }) +async function git($: $, root: string, args: string[], stdin?: string) { + return $.process.run(['git', '--no-optional-locks', ...args], { cwd: root, stdin }) +} + +/** + * `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. Undefined when git can't run it so. + */ +async function recreatedDiff($: $, root: string, base: string, path: string, flags: string[]) { + const old = await git($, root, ['show', base + ':' + path]) + if (old.exitCode !== 0 || old.isStdoutTruncated) return undefined + // --no-index exits 1 when the files differ, which is the point here. + const out = await git($, root, ['diff', ...flags, '--no-index', '--', '/dev/stdin', path], old.stdout) + return out.exitCode <= 1 ? out : undefined } async function scan($: $): Promise { @@ -195,9 +218,22 @@ async function diffBase($: $, root: string): Promise { /** Lines added and deleted against HEAD (staged and unstaged), untracked files counted as added. */ async function lineTotals($: $, root: string, changes: Change[]) { - const out = await git($, root, ['diff', '--numstat', '--no-ext-diff', await diffBase($, root)]) - const total: { added: number; deleted: number; partial?: boolean; isTruncated?: boolean } = sumNumstat(out.stdout) + const base = await diffBase($, root) + const out = await git($, root, ['diff', '--numstat', '--no-ext-diff', base]) + // A recreated path counts its net change, in place of the deletion the diff against base reports. + let numstat = out.stdout + let isUncounted = false + for (const c of changes.filter(isRecreated)) { + const net = await recreatedDiff($, root, base, c.path, ['--numstat']) + if (!net) { + isUncounted = true + continue + } + numstat = numstat.split('\n').filter(line => line.split('\t')[2] !== c.path).join('\n') + '\n' + net.stdout + } + const total: { added: number; deleted: number; partial?: boolean; isTruncated?: boolean } = sumNumstat(numstat) if (out.isStdoutTruncated) total.isTruncated = true + if (isUncounted) total.partial = true const untracked = changes.filter(c => c.x === '?') if (untracked.length > UNTRACKED_LIMIT) total.partial = true for (const c of untracked.slice(0, UNTRACKED_LIMIT)) { @@ -468,6 +504,11 @@ async function diffFor($: $, s: Scan, c: Change) { // --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) + if (net) return net + return git($, s.root, ['diff', ...flags, '--no-index', '--', '/dev/null', c.path]) + } const paths = c.from ? [c.from, c.path] : [c.path] return git($, s.root, ['diff', ...flags, '-M', await diffBase($, s.root), '--', ...paths]) } diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 9d98b103..7109c576 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -40,7 +40,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * `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. + * 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. */ function fakeRepo( on: On, @@ -51,6 +52,7 @@ function fakeRepo( text = 'a\nb\n', live = { diff: DIFF, mtimeMs: 1 } as { diff: string; mtimeMs: number; diffRuns?: number }, isCut = false, + isRecreated = false, }: { fileSize?: number hasHead?: boolean @@ -58,6 +60,7 @@ function fakeRepo( text?: string live?: { diff: string; mtimeMs: number; diffRuns?: number } isCut?: boolean + isRecreated?: boolean } = {}, ) { on('session.cwd', () => ({ value: ROOT })) @@ -69,11 +72,17 @@ function fakeRepo( if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') if (args.startsWith('status')) { await onStatus?.() + if (isRecreated) return ok(' M README.md\0?? src/new.ts\0D gone.txt\0?? gone.txt\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.txt\0') } if (args.startsWith('ls-files') && args.includes('--ignored')) return ok('node_modules/\0') if (args.startsWith('ls-files')) return isCut ? ok('README.md\0src/a.ts\0docs/ind', 0, true) : ok('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') + if (args === 'show HEAD:gone.txt') return ok('a\nb\nc\n') + if (args.endsWith('--no-index -- /dev/stdin gone.txt') && e.init?.stdin === 'a\nb\nc\n') { + if (args.includes('--numstat')) return ok('1\t1\t/dev/stdin => gone.txt\n', 1) + return ok('diff --git a/dev/stdin b/gone.txt\n--- a/dev/stdin\n+++ b/gone.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n', 1) + } if (args.startsWith('rev-parse --verify')) return hasHead ? ok('abc\n') : ok('', 1) if (args === 'hash-object -t tree /dev/null') return ok(EMPTY_TREE + '\n') if (args === 'diff --numstat --no-ext-diff ' + EMPTY_TREE) return ok('2\t0\tREADME.md\n') @@ -262,6 +271,27 @@ describe('git-aware-file-explorer pane', () => { 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('runs one scan at a time and folds the requests made meanwhile into one', async ($, on) => { const clock = mock.clock(on) let isSlow = false diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index 4db2ca79..8286282c 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -4,7 +4,7 @@ export type Change = { path: string /** Index (staged) column: one of ` MADRCU?`. */ x: string - /** Worktree column: one of ` MADRCU?`. */ + /** 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 From 8f164d416e7d0f17ce9035abeaa3bc2deaea13ac Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 06:01:46 -0500 Subject: [PATCH 23/61] fix(mods): mark git-aware-file-explorer's added total when a new file can't be read An untracked file that could be stat'ed but not read (permissions, an I/O error) was counted as empty, so its lines dropped out of the added total while the total showed as exact. Such a file now marks the total as a floor, as a file too large to read does. Co-Authored-By: Claude Opus 5.5 --- docs/mods/git-aware-file-explorer.md | 4 +-- .../hooks/register.tsx | 5 +++- .../tests/pane.test.ts | 26 +++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/mods/git-aware-file-explorer.md b/docs/mods/git-aware-file-explorer.md index f260586d..64fcbfe6 100644 --- a/docs/mods/git-aware-file-explorer.md +++ b/docs/mods/git-aware-file-explorer.md @@ -14,8 +14,8 @@ in a panel beside the tree. - **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, the added total is marked as a - minimum (`+1234+`). When git's output passes 4 MB it is cut, and every count is marked the same way. + 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, and every count is 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. diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 783d6468..1d4800c2 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -244,7 +244,10 @@ async function lineTotals($: $, root: string, changes: Change[]) { total.partial = true continue } - total.added += countLines(await $.fs.read(path).catch(() => '')) + // 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 } diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 7109c576..6d9b14ad 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -41,7 +41,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * `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. + * staged for deletion and back on disk with its second line changed. With `isUnreadable`, no file + * can be read. */ function fakeRepo( on: On, @@ -53,6 +54,7 @@ function fakeRepo( live = { diff: DIFF, mtimeMs: 1 } as { diff: string; mtimeMs: number; diffRuns?: number }, isCut = false, isRecreated = false, + isUnreadable = false, }: { fileSize?: number hasHead?: boolean @@ -61,12 +63,16 @@ function fakeRepo( live?: { diff: string; mtimeMs: number; diffRuns?: number } isCut?: boolean isRecreated?: boolean + isUnreadable?: boolean } = {}, ) { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink: false } })) - on('fs.read', () => ({ value: text })) + on('fs.read', () => { + if (isUnreadable) throw new Error('EACCES: permission denied') + return { value: text } + }) on('process.run', async ($, e) => { const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') @@ -292,6 +298,22 @@ describe('git-aware-file-explorer pane', () => { 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('runs one scan at a time and folds the requests made meanwhile into one', async ($, on) => { const clock = mock.clock(on) let isSlow = false From 87b4c2559a303274eecf4707e14d48b95d693130 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 06:10:16 -0500 Subject: [PATCH 24/61] fix(mods): mark a recreated file too large to diff in git-aware-file-explorer When a recreated file's committed version passed 4 MiB, it could not be piped whole for the net diff: the totals kept its full deletion and the panel showed the new contents alone, both unmarked. Its lines now count neither way and both totals are marked as floors, and the panel marks the diff as cut. A cut line count no longer marks the changed-file count, which comes from status alone. Co-Authored-By: Claude Opus 5.5 --- docs/mods/git-aware-file-explorer.md | 2 +- .../hooks/register.tsx | 28 +++++++++++------- .../tests/pane.test.ts | 29 +++++++++++++++++-- .../git-aware-file-explorer/types/index.d.ts | 7 +++-- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/mods/git-aware-file-explorer.md b/docs/mods/git-aware-file-explorer.md index 64fcbfe6..4044bc1a 100644 --- a/docs/mods/git-aware-file-explorer.md +++ b/docs/mods/git-aware-file-explorer.md @@ -15,7 +15,7 @@ in a panel beside the tree. - **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, and every count is marked the same way. + added total is marked as a minimum (`+1234+`). When git's output passes 4 MB it is cut, 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. diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 1d4800c2..d99008f7 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -140,11 +140,13 @@ async function git($: $, root: string, args: string[], stdin?: string) { /** * `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. Undefined when git can't run it so. + * 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; undefined when git can't run it so. */ async function recreatedDiff($: $, root: string, base: string, path: string, flags: string[]) { const old = await git($, root, ['show', base + ':' + path]) - if (old.exitCode !== 0 || old.isStdoutTruncated) return undefined + if (old.isStdoutTruncated) return 'cut' as const + if (old.exitCode !== 0) return undefined // --no-index exits 1 when the files differ, which is the point here. const out = await git($, root, ['diff', ...flags, '--no-index', '--', '/dev/stdin', path], old.stdout) return out.exitCode <= 1 ? out : undefined @@ -173,7 +175,7 @@ async function scan($: $): Promise { branch: name || undefined, changes, lines, - isTruncated: status.isStdoutTruncated || lines.isTruncated || undefined, + isTruncated: status.isStdoutTruncated || undefined, ignored: ignored.stdout.split('\0').filter(Boolean), error: status.exitCode === 0 ? undefined : status.stderr.trim(), } @@ -223,16 +225,20 @@ async function lineTotals($: $, root: string, changes: Change[]) { // A recreated path counts its net change, in place of the deletion the diff against base reports. let numstat = out.stdout let isUncounted = false + let isCut = false for (const c of changes.filter(isRecreated)) { const net = await recreatedDiff($, root, base, c.path, ['--numstat']) - if (!net) { + if (net === undefined) { isUncounted = true continue } - numstat = numstat.split('\n').filter(line => line.split('\t')[2] !== c.path).join('\n') + '\n' + net.stdout + const others = numstat.split('\n').filter(line => line.split('\t')[2] !== c.path).join('\n') + // Too large to diff: neither its deletion nor its contents count, and both totals are floors. + if (net === 'cut') isCut = true + numstat = net === 'cut' ? others : others + '\n' + net.stdout } const total: { added: number; deleted: number; partial?: boolean; isTruncated?: boolean } = sumNumstat(numstat) - if (out.isStdoutTruncated) total.isTruncated = true + if (out.isStdoutTruncated || isCut) total.isTruncated = true if (isUncounted) total.partial = true const untracked = changes.filter(c => c.x === '?') if (untracked.length > UNTRACKED_LIMIT) total.partial = true @@ -509,8 +515,10 @@ async function diffFor($: $, s: Scan, c: Change) { } if (isRecreated(c)) { const net = await recreatedDiff($, s.root, await diffBase($, s.root), c.path, flags) - if (net) return net - return git($, s.root, ['diff', ...flags, '--no-index', '--', '/dev/null', c.path]) + if (net !== undefined && 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 net === 'cut' ? { ...contents, isStdoutTruncated: true } : contents } const paths = c.from ? [c.from, c.path] : [c.path] return git($, s.root, ['diff', ...flags, '-M', await diffBase($, s.root), '--', ...paths]) @@ -622,11 +630,11 @@ export const register: Register = on => { {/* A trailing + marks a floor: some untracked files went uncounted, or git's output was cut. */} +{s.lines.added} - {s.lines.partial || s.isTruncated ? '+' : ''} + {s.lines.partial || s.lines.isTruncated || s.isTruncated ? '+' : ''} {' '} −{s.lines.deleted} - {s.isTruncated ? '+' : ''} + {s.lines.isTruncated || s.isTruncated ? '+' : ''} )} diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 6d9b14ad..21498af6 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -41,8 +41,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * `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. With `isUnreadable`, no file - * can be read. + * staged for deletion and back on disk with its second line changed, and with `isBlobCut` too its + * committed version passes 4 MiB. With `isUnreadable`, no file can be read. */ function fakeRepo( on: On, @@ -55,6 +55,7 @@ function fakeRepo( isCut = false, isRecreated = false, isUnreadable = false, + isBlobCut = false, }: { fileSize?: number hasHead?: boolean @@ -64,6 +65,7 @@ function fakeRepo( isCut?: boolean isRecreated?: boolean isUnreadable?: boolean + isBlobCut?: boolean } = {}, ) { on('session.cwd', () => ({ value: ROOT })) @@ -84,7 +86,8 @@ function fakeRepo( if (args.startsWith('ls-files') && args.includes('--ignored')) return ok('node_modules/\0') if (args.startsWith('ls-files')) return isCut ? ok('README.md\0src/a.ts\0docs/ind', 0, true) : ok('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') - if (args === 'show HEAD:gone.txt') return ok('a\nb\nc\n') + if (args === 'show HEAD:gone.txt') return isBlobCut ? ok('a\nb', 0, true) : ok('a\nb\nc\n') + if (args.endsWith('--no-index -- /dev/null gone.txt')) return ok('diff --git a/gone.txt b/gone.txt\n@@ -0,0 +1,3 @@\n+a\n+B\n+c\n', 1) if (args.endsWith('--no-index -- /dev/stdin gone.txt') && e.init?.stdin === 'a\nb\nc\n') { if (args.includes('--numstat')) return ok('1\t1\t/dev/stdin => gone.txt\n', 1) return ok('diff --git a/dev/stdin b/gone.txt\n--- a/dev/stdin\n+++ b/gone.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n', 1) @@ -314,6 +317,26 @@ describe('git-aware-file-explorer pane', () => { 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('runs one scan at a time and folds the requests made meanwhile into one', async ($, on) => { const clock = mock.clock(on) let isSlow = false diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index 8286282c..5fb7c6fa 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -27,10 +27,13 @@ export type Scan = { 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, so both totals are floors. */ + /** + * git's line counts passed 4 MiB of output and were cut, or a recreated file's committed version + * did and went uncounted, so both totals are floors. + */ isTruncated?: boolean } - /** git's output passed 4 MiB and was cut, so the changes and line totals are floors. */ + /** 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 From 0a132fa55068cb718a476ecb2ce56895eb974f87 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 06:16:25 -0500 Subject: [PATCH 25/61] fix(mods): never walk into a linked folder in git-aware-file-explorer The filter's walk outside a git repository queued every `dir` entry. This host reports a symbolic link as `other`, so links were already skipped, but on a host that reports a linked folder as a `dir` a link back to an ancestor would loop the walk. The walk now checks isLink as well. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 4 ++- .../tests/pane.test.ts | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index d99008f7..8af9a81f 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -381,7 +381,9 @@ async function walkFiles($: $, root: string): Promise<{ files: string[]; isCappe for (const en of entries) { if (HIDDEN.has(en.name)) continue const path = dir === '' ? en.name : dir + '/' + en.name - if (en.kind === 'dir') dirs.push(path) + // 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) } } diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 21498af6..70df78c0 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -414,6 +414,34 @@ describe('git-aware-file-explorer pane', () => { 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('filters files outside a git repository', async ($, on) => { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) From 7be21760cb117077e2d8d6e4bcaa249c08e74fd8 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 06:19:35 -0500 Subject: [PATCH 26/61] fix(mods): show an unreadable file as unreadable in git-aware-file-explorer A preview read that failed (permissions, an I/O error) was taken as empty text, so the panel drew a blank file. It now shows "Not a readable file.", as it does for a path it can't stat. Co-Authored-By: Claude Opus 5.5 --- .../git-aware-file-explorer/hooks/register.tsx | 6 ++++-- .../git-aware-file-explorer/tests/pane.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 8af9a81f..4e0c3111 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -805,8 +805,10 @@ export const register: Register = on => { if (!stat || stat.kind !== 'file') body = Not a readable file. else if (stat.size > PREVIEW_BYTES) body = Too large to preview ({stat.size} bytes). else { - const text = await $.fs.read(s.root + '/' + selected).catch(() => '') - if (text.includes('\0')) body = Binary file. + // A read that fails (permissions, I/O) is not an empty file. + const text = await $.fs.read(s.root + '/' + selected).catch(() => undefined) + if (text === undefined) body = Not a readable file. + else if (text.includes('\0')) body = Binary file. else { const lines = text.split('\n') panelLast = Math.max(0, lines.length - panelRoom) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 70df78c0..7e8315c4 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -337,6 +337,24 @@ describe('git-aware-file-explorer pane', () => { 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('runs one scan at a time and folds the requests made meanwhile into one', async ($, on) => { const clock = mock.clock(on) let isSlow = false From c348f93018e9006e19f627fdc2da0dadcc3dd71a Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 06:19:58 -0500 Subject: [PATCH 27/61] fix(mods): mark a cut list of ignored files in git-aware-file-explorer The scan took git's list of ignored paths as whole even when process.run had cut it at 4 MiB, keeping a partial last path and saying nothing. The partial path is now dropped, and a filter says it searched only the start of the list, as it does for a cut list of files. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 12 +++++++-- .../tests/pane.test.ts | 27 +++++++++++++++++-- .../git-aware-file-explorer/types/index.d.ts | 2 ++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 4e0c3111..6bb4c137 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -176,7 +176,14 @@ async function scan($: $): Promise { changes, lines, isTruncated: status.isStdoutTruncated || undefined, - ignored: ignored.stdout.split('\0').filter(Boolean), + // 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, error: status.exitCode === 0 ? undefined : status.stderr.trim(), } } catch (err) { @@ -358,7 +365,8 @@ async function pathsFor( if (listed.isStdoutTruncated) { names = names.slice(0, names.lastIndexOf('\0') + 1) incomplete = "git's file list passed 4 MB; searched its start only." - } + } else if (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([...names.split('\0').filter(Boolean), ...s.changes.map(c => c.path), ...s.ignored])] diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 7e8315c4..6d1544d0 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -42,7 +42,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * 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 `isUnreadable`, no file can be read. + * committed version passes 4 MiB. With `isUnreadable`, no file can be read. With `isIgnoredCut`, + * the list of ignored paths comes back cut. */ function fakeRepo( on: On, @@ -56,6 +57,7 @@ function fakeRepo( isRecreated = false, isUnreadable = false, isBlobCut = false, + isIgnoredCut = false, }: { fileSize?: number hasHead?: boolean @@ -66,6 +68,7 @@ function fakeRepo( isRecreated?: boolean isUnreadable?: boolean isBlobCut?: boolean + isIgnoredCut?: boolean } = {}, ) { on('session.cwd', () => ({ value: ROOT })) @@ -83,7 +86,9 @@ function fakeRepo( if (isRecreated) return ok(' M README.md\0?? src/new.ts\0D gone.txt\0?? gone.txt\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.txt\0') } - if (args.startsWith('ls-files') && args.includes('--ignored')) return ok('node_modules/\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('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') if (args === 'show HEAD:gone.txt') return isBlobCut ? ok('a\nb', 0, true) : ok('a\nb\nc\n') @@ -355,6 +360,24 @@ describe('git-aware-file-explorer pane', () => { 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 diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index 5fb7c6fa..67a6c97b 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -21,6 +21,8 @@ export type Scan = { 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 From f293efcfd7b9419b20d29de813ce586690e099d1 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 06:20:31 -0500 Subject: [PATCH 28/61] fix(mods): keep a scrolled tree reachable with a file open in git-aware-file-explorer The pane kept a row of room above its window only while the open file could scroll up. Where the wheel carries a pointer (the terminal), it also reaches the tree with a file open, and a tree scrolled down then had no room above: no wheel event came, and it stayed scrolled until the file closed. The rule now counts the tree too once a pointer has been seen, in one function the render and the scroll hook share. Without a pointer (the desktop) nothing changes. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 33 ++++++++++++++++--- .../tests/diff.test.ts | 19 ++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 6bb4c137..be8a8409 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -49,7 +49,17 @@ type $ = EngineInterface * What the last drawing laid out, for the scroll hook to tell the two sides apart. * A module variable on purpose: it is layout, redrawn on every render, not state. */ -const layout = { treeColumns: 0, panelLast: 0, panelRoom: 0, hasFile: false, offset: 0, spacer: 0, spare: 0 } +const layout = { + treeColumns: 0, + panelLast: 0, + panelRoom: 0, + hasFile: false, + offset: 0, + spacer: 0, + spare: 0, + /** A wheel event has carried a pointer, so the wheel reaches the tree while a file is open. */ + hasPointer: false, +} type Row = { path: string @@ -465,6 +475,14 @@ async function buildRows( /** 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 } +/** + * Whether something the wheel can move can still go up, so the window keeps a row of room above + * (see ROOM). Without a pointer the wheel moves the open file alone; with one it reaches the tree too. + */ +export function canGoUp(at: { hasFile: boolean; hasPointer: boolean; panelOffset: number; treeOffset: number }) { + return (at.hasFile && at.panelOffset > 0) || ((!at.hasFile || at.hasPointer) && at.treeOffset > 0) +} + /** * The furthest a diff's window can start and still show its last line in `room` rows, counting * the separator row drawn between two hunks. @@ -569,6 +587,7 @@ export const register: Register = on => { // moves the open file's lines, or the tree's rows when the pointer is over the tree or no file is open. on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => { if (e.origin.kind === 'plugin') return next(e) + if (e.pointer) layout.hasPointer = true // A surface that reports no pointer (the desktop, keys) scrolls the panel whenever a file is open. const isOverPanel = e.pointer ? e.pointer.column >= layout.treeColumns + GUTTER : true let moved: number @@ -581,7 +600,13 @@ export const register: Register = on => { moved = Math.max(0, (await read($, treeOffsetAtom)) + Math.min(e.by, below)) await update($, treeOffsetAtom, () => moved) } - const rest = moved > 0 ? ROOM : 0 + const isRoomAbove = canGoUp({ + hasFile: layout.hasFile, + hasPointer: layout.hasPointer, + panelOffset: await read($, panelOffsetAtom), + treeOffset: await read($, treeOffsetAtom), + }) + const rest = isRoomAbove ? ROOM : 0 return layout.offset === rest ? {} : next({ ...e, offset: rest }) }).catch(($, e, next) => next(e)) @@ -836,8 +861,8 @@ export const register: Register = on => { const offset = e.props.scroll.offset // Room above while what the wheel moves can go up; a window still below that rest is matched, so // the branch bar stays at its top until the next wheel move brings it to rest. - const canGoUp = selected !== null ? panelOffset > 0 : treeOffset > 0 - const spacer = Math.max(canGoUp ? ROOM : 0, offset) + const isRoomAbove = canGoUp({ hasFile: selected !== null, hasPointer: layout.hasPointer, panelOffset, treeOffset }) + const spacer = Math.max(isRoomAbove ? ROOM : 0, offset) const spare = selected !== null && panelOffset < panelLast ? ROOM : 0 Object.assign(layout, { treeColumns, panelLast, panelRoom, hasFile: selected !== null, offset, spacer, spare }) diff --git a/mods/claude/git-aware-file-explorer/tests/diff.test.ts b/mods/claude/git-aware-file-explorer/tests/diff.test.ts index 45f2beaa..5b723508 100644 --- a/mods/claude/git-aware-file-explorer/tests/diff.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'claude-code/testing' -import { countLines, diffLines, lastTop, sumNumstat } from '../hooks/register' +import { canGoUp, countLines, diffLines, lastTop, sumNumstat } from '../hooks/register' const DIFF = [ 'diff --git a/f b/f', @@ -44,3 +44,20 @@ describe('diff windowing', () => { expect(countLines('a\nb')).toBe(2) }) }) + +describe('canGoUp', () => { + test('follows the tree with no file open', () => { + expect(canGoUp({ hasFile: false, hasPointer: false, panelOffset: 0, treeOffset: 3 })).toBe(true) + expect(canGoUp({ hasFile: false, hasPointer: false, panelOffset: 0, treeOffset: 0 })).toBe(false) + }) + + test('follows the open file alone where the wheel has no pointer', () => { + expect(canGoUp({ hasFile: true, hasPointer: false, panelOffset: 0, treeOffset: 3 })).toBe(false) + expect(canGoUp({ hasFile: true, hasPointer: false, panelOffset: 2, treeOffset: 0 })).toBe(true) + }) + + test('keeps a scrolled tree reachable with a file open where the wheel has a pointer', () => { + expect(canGoUp({ hasFile: true, hasPointer: true, panelOffset: 0, treeOffset: 3 })).toBe(true) + expect(canGoUp({ hasFile: true, hasPointer: true, panelOffset: 0, treeOffset: 0 })).toBe(false) + }) +}) From 6a771ca0e7e615c240d4dac00de4e93d4fd66ae4 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 11:59:27 -0500 Subject: [PATCH 29/61] fix(mods): open a diff of millions of lines in git-aware-file-explorer The line-number width spread every diff line into Math.max; a diff near the 4 MB output limit holds more lines than a call takes arguments, so the render hook threw. Take the maximum with reduce. Co-Authored-By: Claude Opus 5.5 --- .../git-aware-file-explorer/hooks/register.tsx | 3 ++- .../git-aware-file-explorer/tests/pane.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index be8a8409..bd09e123 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -795,7 +795,8 @@ export const register: Register = on => { if (lines.length === 0) body = No textual difference (binary or mode change). else { const top = Math.min(panelOffset, panelLast) - const width = String(Math.max(...lines.map(l => Math.max(l.o, l.n)))).length + // A 4 MB diff can hold millions of lines, more than a call takes arguments, so no spread. + const width = String(lines.reduce((w, l) => Math.max(w, l.o, l.n), 0)).length 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 = [] diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 6d1544d0..6fa9a1f5 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -248,6 +248,23 @@ describe('git-aware-file-explorer pane', () => { 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.unmount() + }) + test('marks counts as floors when git output was cut', async ($, on) => { fakeRepo(on, { isCut: true }) const ui = await $.ui.mount({ From 05938ca86266c48a2da1eb709b9c72ce7a15540c Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 12:01:21 -0500 Subject: [PATCH 30/61] fix(mods): count a recreated file with a quoted name once in git-aware-file-explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without -z, git diff --numstat quotes a path outside printable ASCII (café.txt, a tab or newline), so the staged deletion of a recreated file never matched its path and its net change was added beside it. Read numstat with -z, whose paths come verbatim, and keep whole entries of a cut list. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 48 +++++++++++++------ .../tests/diff.test.ts | 8 +++- .../tests/pane.test.ts | 48 ++++++++++++++----- 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index bd09e123..3bb8db30 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -201,16 +201,35 @@ async function scan($: $): Promise { } } -/** Sums `git diff --numstat` output; binary files (`-`) count no lines. */ -export function sumNumstat(out: string): { added: number; deleted: number } { - let added = 0 - let deleted = 0 - for (const line of out.split('\n')) { - const [a, d] = line.split('\t') - if (a && /^\d+$/.test(a)) added += Number(a) - if (d && /^\d+$/.test(d)) deleted += Number(d) +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 { added, deleted } + 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. */ @@ -238,21 +257,22 @@ async function diffBase($: $, root: string): Promise { /** 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', '--no-ext-diff', base]) + 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 numstat = out.stdout let isUncounted = false let isCut = false for (const c of changes.filter(isRecreated)) { - const net = await recreatedDiff($, root, base, c.path, ['--numstat']) + const net = await recreatedDiff($, root, base, c.path, ['--numstat', '-z']) if (net === undefined) { isUncounted = true continue } - const others = numstat.split('\n').filter(line => line.split('\t')[2] !== c.path).join('\n') + const others = numstat.filter(e => e.path !== c.path) // Too large to diff: neither its deletion nor its contents count, and both totals are floors. if (net === 'cut') isCut = true - numstat = net === 'cut' ? others : others + '\n' + net.stdout + numstat = net === 'cut' ? others : [...others, ...parseNumstat(net.stdout)] } const total: { added: number; deleted: number; partial?: boolean; isTruncated?: boolean } = sumNumstat(numstat) if (out.isStdoutTruncated || isCut) total.isTruncated = true diff --git a/mods/claude/git-aware-file-explorer/tests/diff.test.ts b/mods/claude/git-aware-file-explorer/tests/diff.test.ts index 5b723508..767068de 100644 --- a/mods/claude/git-aware-file-explorer/tests/diff.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'claude-code/testing' -import { canGoUp, countLines, diffLines, lastTop, sumNumstat } from '../hooks/register' +import { canGoUp, countLines, diffLines, lastTop, parseNumstat, sumNumstat } from '../hooks/register' const DIFF = [ 'diff --git a/f b/f', @@ -35,7 +35,11 @@ describe('diff windowing', () => { }) test('numstat sums lines and skips binary files', async () => { - expect(sumNumstat('3\t1\ta.md\n-\t-\tlogo.png\n2\t0\tb.ts\n')).toEqual({ added: 5, deleted: 1 }) + 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('lines count as git counts them', async () => { diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 6fa9a1f5..e5ee7995 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -43,7 +43,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * 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 `isUnreadable`, no file can be read. With `isIgnoredCut`, - * the list of ignored paths comes back cut. + * the list of ignored paths comes back cut. `gone` names the deleted file; git's line counts quote + * it outside printable ASCII unless asked for `-z`. */ function fakeRepo( on: On, @@ -58,6 +59,7 @@ function fakeRepo( isUnreadable = false, isBlobCut = false, isIgnoredCut = false, + gone = 'gone.txt', }: { fileSize?: number hasHead?: boolean @@ -69,8 +71,11 @@ function fakeRepo( isUnreadable?: boolean isBlobCut?: boolean isIgnoredCut?: boolean + gone?: string } = {}, ) { + // 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) => ({ value: DISK[e.path] ?? [] })) on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink: false } })) @@ -80,29 +85,31 @@ function fakeRepo( }) on('process.run', async ($, e) => { const args = e.argv.filter(a => a !== '--no-optional-locks').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 (isRecreated) return ok(' M README.md\0?? src/new.ts\0D gone.txt\0?? gone.txt\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.txt\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('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') - if (args === 'show HEAD:gone.txt') return isBlobCut ? ok('a\nb', 0, true) : ok('a\nb\nc\n') - if (args.endsWith('--no-index -- /dev/null gone.txt')) return ok('diff --git a/gone.txt b/gone.txt\n@@ -0,0 +1,3 @@\n+a\n+B\n+c\n', 1) - if (args.endsWith('--no-index -- /dev/stdin gone.txt') && e.init?.stdin === 'a\nb\nc\n') { - if (args.includes('--numstat')) return ok('1\t1\t/dev/stdin => gone.txt\n', 1) - return ok('diff --git a/dev/stdin b/gone.txt\n--- a/dev/stdin\n+++ b/gone.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n', 1) + 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('abc\n') : ok('', 1) if (args === 'hash-object -t tree /dev/null') return ok(EMPTY_TREE + '\n') - if (args === 'diff --numstat --no-ext-diff ' + EMPTY_TREE) return ok('2\t0\tREADME.md\n') - if (args === 'diff --numstat --no-ext-diff --cached') return ok('2\t0\tREADME.md\n') - if (args === 'diff --numstat --no-ext-diff') return ok('1\t1\tREADME.md\n') - if (args.startsWith('diff --numstat')) return ok('1\t1\tREADME.md\n0\t3\tgone.txt\n', 0, isCut) + const end = z ? '\0' : '\n' + 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 return ok(live.diff, 0, isCut) @@ -323,6 +330,23 @@ describe('git-aware-file-explorer pane', () => { 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({ From 3c554ea4909c10a66587a8816710714dc22ce64e Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 12:01:49 -0500 Subject: [PATCH 31/61] fix(mods): say a filter does not search inside ignored folders in git-aware-file-explorer git lists an ignored directory by its own path, not its files, so a filter never reached a file inside one that the tree can browse to. Listing them all would mean walking folders like .venv, so the pane says they were not searched instead. Co-Authored-By: Claude Opus 5.5 --- docs/mods/git-aware-file-explorer.md | 2 +- .../git-aware-file-explorer/hooks/register.tsx | 4 ++++ .../git-aware-file-explorer/tests/pane.test.ts | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/mods/git-aware-file-explorer.md b/docs/mods/git-aware-file-explorer.md index 4044bc1a..faa72aee 100644 --- a/docs/mods/git-aware-file-explorer.md +++ b/docs/mods/git-aware-file-explorer.md @@ -18,7 +18,7 @@ in a panel beside the tree. added total is marked as a minimum (`+1234+`). When git's output passes 4 MB it is cut, 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. + A filter box narrows the tree by name; it does not search inside ignored folders, and says so. - **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. - **Stays current.** The pane rescans the workspace every few seconds and after each tool call that diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 3bb8db30..d064bded 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -400,6 +400,10 @@ async function pathsFor( // 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([...names.split('\0').filter(Boolean), ...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 } diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index e5ee7995..86f8925f 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -401,6 +401,24 @@ describe('git-aware-file-explorer pane', () => { 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({ From 0ee75d47f61ae22fc1b3d10335ad54f410be4746 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 12:02:31 -0500 Subject: [PATCH 32/61] fix(scripts): count a mod as listed only from a table row The mod catalog and harness checks matched a mod's link anywhere on the page, so a link in prose passed for the catalog row they require. Read only the first cell of a table row, with the harness page's link prefix taken from where the page sits. Co-Authored-By: Claude Opus 5.5 --- scripts/test_validate_artifact_sync.py | 17 +++++++++++++++++ scripts/validate_artifact_sync.py | 19 ++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/scripts/test_validate_artifact_sync.py b/scripts/test_validate_artifact_sync.py index bd699335..5c95c7c2 100644 --- a/scripts/test_validate_artifact_sync.py +++ b/scripts/test_validate_artifact_sync.py @@ -407,6 +407,23 @@ def test_mod_missing_from_its_harness_page_fails(self) -> None: 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 a219e0b3..ae986eac 100644 --- a/scripts/validate_artifact_sync.py +++ b/scripts/validate_artifact_sync.py @@ -14,6 +14,7 @@ import argparse import json +import os import re import sys from dataclasses import dataclass @@ -595,6 +596,18 @@ def validate_skills() -> None: 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.""" @@ -625,7 +638,7 @@ def validate_mods() -> None: # 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 = set(re.findall(r"\]\(([a-z0-9-]+)\.md\)", index_text)) + catalogued = mod_rows(index_text) check( catalogued == set(mods), "docs/mods/index.md mod coverage drift", @@ -639,7 +652,7 @@ def validate_mods() -> None: } 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 = set(re.findall(r"mods/([a-z0-9-]+)\.md", (ROOT / harness).read_text())) - {"index"} + 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()): @@ -658,7 +671,7 @@ def validate_mods() -> None: check(harness is not None, f"mod {identifier} is for an unknown client", client) if harness is not None: check( - f"mods/{identifier}.md" in (ROOT / harness).read_text(), + 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, ) From ccd9121c60874bff8bbb4bf08a5e6df9afb65b39 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 12:09:18 -0500 Subject: [PATCH 33/61] perf(mods): list a filter's files once per scan in git-aware-file-explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a filter on, every redraw (a wheel move, a ▲/▼ press) ran git ls-files again, or outside a repository walked up to 20,000 files. Keep the list until the next scan finishes, and filter it in place. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 48 +++++++++++++------ .../tests/pane.test.ts | 30 ++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index d064bded..ba8d8086 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -330,6 +330,8 @@ function rescan($: $): Promise { isRescanDue = false const found = await scan($) const next: Scan = { ...found, stamp: await stampFor($, found) } + // Files may have come or gone since the filter's list was made, even with the scan unchanged. + listing = undefined // 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) @@ -384,22 +386,14 @@ async function pathsFor( // 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) { - const walked = await walkFiles($, s.root) - paths = walked.files - if (walked.isCapped) incomplete = `Searched the first ${WALK_LIMIT} files only.` - } else { - 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. - let names = listed.stdout - if (listed.isStdoutTruncated) { - names = names.slice(0, names.lastIndexOf('\0') + 1) - incomplete = "git's file list passed 4 MB; searched its start only." - } else if (s.isIgnoredTruncated) incomplete = "git's list of ignored files passed 4 MB; searched its start only." - + 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([...names.split('\0').filter(Boolean), ...s.changes.map(c => c.path), ...s.ignored])] + 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(' ') @@ -409,6 +403,32 @@ async function pathsFor( 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, a ▲/▼ press) 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) + return { files: walked.files, incomplete: walked.isCapped ? `Searched the first ${WALK_LIMIT} files only.` : 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, up to WALK_LIMIT: what a filter searches without git. * `isCapped` says the walk stopped there, so a search over it may miss files. diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 86f8925f..b891af09 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -542,6 +542,36 @@ describe('git-aware-file-explorer pane', () => { 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 + // What a wheel or ▲/▼ press does, 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('filters files outside a git repository', async ($, on) => { on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => ({ value: DISK[e.path] ?? [] })) From 0c9455c6e9428bb76680d3f3d7b9d1c1b59d34a4 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 12:09:44 -0500 Subject: [PATCH 34/61] fix(mods): say when a filter could not read a folder in git-aware-file-explorer Outside a git repository the filter's walk read a folder it could not list as empty, so a file inside it went unfound with no sign the search was incomplete. Count those folders and say they were not searched. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 20 ++++++++++++----- .../tests/pane.test.ts | 22 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index ba8d8086..6dea864a 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -418,7 +418,12 @@ function listFiles($: $, s: Scan): Promise<{ files: string[]; incomplete?: strin async function listFilesNow($: $, s: Scan): Promise<{ files: string[]; incomplete?: string }> { if (!s.isRepo) { const walked = await walkFiles($, s.root) - return { files: walked.files, incomplete: walked.isCapped ? `Searched the first ${WALK_LIMIT} files only.` : undefined } + const notes = [ + walked.isCapped && `Searched the first ${WALK_LIMIT} files 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. @@ -431,15 +436,20 @@ async function listFilesNow($: $, s: Scan): Promise<{ files: string[]; incomplet /** * File paths under `root`, breadth first, up to WALK_LIMIT: what a filter searches without git. - * `isCapped` says the walk stopped there, so a search over it may miss files. + * `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 }> { +async function walkFiles($: $, root: string): Promise<{ files: string[]; isCapped: boolean; unread: number }> { const files: string[] = [] const dirs = [''] + let unread = 0 let i = 0 for (; i < dirs.length && files.length < WALK_LIMIT; i++) { const dir = dirs[i] as string - const entries = await $.fs.list(root + (dir === '' ? '' : '/' + dir)).catch(() => []) + const entries = await $.fs.list(root + (dir === '' ? '' : '/' + dir)).catch(() => { + unread++ + return [] + }) for (const en of entries) { if (HIDDEN.has(en.name)) continue const path = dir === '' ? en.name : dir + '/' + en.name @@ -449,7 +459,7 @@ async function walkFiles($: $, root: string): Promise<{ files: string[]; isCappe else files.push(path) } } - return { files: files.slice(0, WALK_LIMIT), isCapped: files.length > WALK_LIMIT || i < dirs.length } + return { files: files.slice(0, WALK_LIMIT), isCapped: files.length > WALK_LIMIT || i < dirs.length, unread } } async function buildRows( diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index b891af09..2f936212 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -514,6 +514,28 @@ describe('git-aware-file-explorer pane', () => { 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 } From be30d3d22f16b7f725d59ccb850f5a7a31ce09c9 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 12:17:59 -0500 Subject: [PATCH 35/61] fix(mods): mark the added total when a new file can't be stat'd in git-aware-file-explorer A new file gone between git status and its stat, or one stat could not read, was skipped without marking the added total as a minimum, so the branch bar showed an exact-looking count that missed it. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 7 ++++- .../tests/pane.test.ts | 26 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 6dea864a..e6c1e0ca 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -282,7 +282,12 @@ async function lineTotals($: $, root: string, changes: Change[]) { for (const c of untracked.slice(0, UNTRACKED_LIMIT)) { const path = root + '/' + c.path const stat = await $.fs.stat(path).catch(() => undefined) - if (!stat || stat.kind !== 'file') continue + // Gone since git listed it, or unreadable: uncounted, so the total is a floor. + if (!stat) { + total.partial = true + continue + } + if (stat.kind !== 'file') continue if (stat.size > UNTRACKED_BYTES) { total.partial = true continue diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 2f936212..3c3313ae 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -43,7 +43,8 @@ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' * 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 `isUnreadable`, no file can be read. With `isIgnoredCut`, - * the list of ignored paths comes back cut. `gone` names the deleted file; git's line counts quote + * the list of ignored paths comes back cut. With `isNewGone`, src/new.ts is gone by the time it is + * looked at. `gone` names the deleted file; git's line counts quote * it outside printable ASCII unless asked for `-z`. */ function fakeRepo( @@ -60,6 +61,7 @@ function fakeRepo( isBlobCut = false, isIgnoredCut = false, gone = 'gone.txt', + isNewGone = false, }: { fileSize?: number hasHead?: boolean @@ -72,13 +74,17 @@ function fakeRepo( isBlobCut?: boolean isIgnoredCut?: boolean gone?: string + isNewGone?: 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) => ({ value: DISK[e.path] ?? [] })) - on('fs.stat', () => ({ value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink: false } })) + on('fs.stat', ($, e) => { + if (isNewGone && e.path.endsWith('/src/new.ts')) throw new Error('ENOENT: no such file or directory') + return { value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink: false } } + }) on('fs.read', () => { if (isUnreadable) throw new Error('EACCES: permission denied') return { value: text } @@ -363,6 +369,22 @@ describe('git-aware-file-explorer pane', () => { 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('marks a recreated file too large to diff as incomplete', async ($, on) => { fakeRepo(on, { isRecreated: true, isBlobCut: true }) const ui = await $.ui.mount({ From c81fa4e6cbd1a253dbcbebe64062e82cdddc2950 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Fri, 16 Oct 2026 12:18:26 -0500 Subject: [PATCH 36/61] fix(mods): show a failed git status as unavailable in git-aware-file-explorer A failed git status left the scan with no changes, so the branch bar said clean about a repository it had not read. Say the status is unavailable instead, and give the error text even when git printed none. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 6 +++-- .../tests/pane.test.ts | 25 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index e6c1e0ca..a04bcce8 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -194,7 +194,8 @@ async function scan($: $): Promise { .split('\0') .filter(Boolean), isIgnoredTruncated: ignored.isStdoutTruncated || undefined, - error: status.exitCode === 0 ? undefined : status.stderr.trim(), + // 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) } @@ -732,7 +733,8 @@ export const register: Register = on => { )} - {s.isRepo && ( + {s.isRepo && s.error !== undefined && status unavailable} + {s.isRepo && s.error === undefined && ( - ) + // 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 && ( {filter.trim() !== '' ? 'No matching files.' : changedOnly ? 'No changes.' : 'Empty directory.'} )} - {rows.slice(treeOffset).map(r => { + {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)) @@ -849,7 +821,8 @@ export const register: Register = on => { justifyContent="space-between" alignItems="center" paddingX={1} - paddingY="xs" + height={TREE_ROW_ROWS} + flexShrink={0} backgroundColor={r.path === selected ? SELECTED_BG : undefined} hover={{ backgroundColor: HOVER_BG }} > @@ -884,6 +857,7 @@ export const register: Register = on => { ) })} + {treeWindow.after > 0 && space('tree-after', treeWindow.after)} {virtual && virtual.total > virtual.paths.length && ( Showing {virtual.paths.length} of {virtual.total} matches. @@ -893,11 +867,7 @@ export const register: Register = on => { ) - // The file panel is pinned over the pane's window: it moves down as the tree scrolls, - // so it stays in view, and scrolls its own content through panelOffset. - const panelRoom = Math.max(4, bodyRows - BRANCH_ROWS - 3) - // The first line the panel can start at and still end on the file's last line. - let panelLast = 0 + const panelColumns = Math.max(1, e.props.bodyColumns - treeColumns - GUTTER) let body let fileLines: { added: number; deleted: number } | undefined let isDiffTruncated = false @@ -910,34 +880,31 @@ export const register: Register = on => { ) } else if (change) { - const diff = await cachedDiff($, s, change) + const diff = await cachedDiff($, s, change, scanKey) const lines = diff.lines isDiffTruncated = diff.isTruncated - panelLast = lastTop(lines, panelRoom) if (diff.error !== undefined) body = {diff.error} else if (lines.length === 0) body = No textual difference (binary or mode change). else { - const top = Math.min(panelOffset, panelLast) - // A 4 MB diff can hold millions of lines, more than a call takes arguments, so no spread. - const width = String(lines.reduce((w, l) => Math.max(w, l.o, l.n), 0)).length + 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 i = top, used = 0; i < lines.length; i++) { - const l = lines[i] as DiffLine - const tint = l.kind === '+' ? ADDED : l.kind === '-' ? DELETED : undefined - const gap = i > top && lines[i - 1]?.hunk !== l.hunk - if (used + (gap ? 2 : 1) > panelRoom) break - if (gap) { + for (let row = view.start; row < view.end; row++) { + const i = diffIndex(diff.hunks, row) + if (i === undefined) { drawn.push( - - {' '.repeat(width * 2 + 2)}⋯ - , + + {' '.repeat(width * 2 + 2)}⋯ + , ) - used++ + 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)}{' '} @@ -945,63 +912,60 @@ export const register: Register = on => { {l.kind === ' ' ? ' ' : l.kind}{' '} - {l.text.slice(1) || ' '} + {visibleText(l.text.slice(1), panelColumns, view.end - view.start) || ' '} , ) - used++ } - fileLines = { - added: lines.filter(l => l.kind === '+').length, - deleted: lines.filter(l => l.kind === '-').length, - } - body = {drawn} + fileLines = diff.counts + body = ( + + {view.before > 0 && space('diff-before', view.before)} + {drawn} + {view.after > 0 && space('diff-after', view.after)} + + ) } } else { - const stat = await $.fs.stat(s.root + '/' + selected).catch(() => undefined) - if (!stat || stat.kind !== 'file') body = Not a readable file. - else if (stat.size > PREVIEW_BYTES) body = Too large to preview ({stat.size} bytes). - else { - // A read that fails (permissions, I/O) is not an empty file. - const text = await $.fs.read(s.root + '/' + selected).catch(() => undefined) - if (text === undefined) body = Not a readable file. - else if (text.includes('\0')) body = Binary file. - else { - const lines = text.split('\n') - panelLast = Math.max(0, lines.length - panelRoom) - const top = Math.min(panelOffset, panelLast) - body = ( - + 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 offset = e.props.scroll.offset - // Room above while what the wheel moves can go up; a window still below that rest is matched, so - // the branch bar stays at its top until the next wheel move brings it to rest. - // With a file open the clipped tree can move until its last row is the only one drawn. - const treeLast = Math.max(0, rows.length - 1) - const at = { hasFile: selected !== null, panelOffset, panelLast, treeOffset, treeLast } - const spacer = Math.max(canGoUp(at) ? ROOM : 0, offset) - const spare = canGoDown(at) ? ROOM : 0 - Object.assign(layout, { treeColumns, panelLast, panelRoom, hasFile: selected !== null, offset, spacer, spare, treeLast }) - const slash = selected === null ? -1 : selected.lastIndexOf('/') - // Under the branch bar, down to the bottom of the pane's window. const panel = ( @@ -1035,16 +999,6 @@ export const register: Register = on => { )} - {layout.hasFile && ( - - - - - )} @@ -1056,17 +1010,9 @@ export const register: Register = on => { ) return ( - - {spacer > 0 && } - {/* With a file open the tree is clipped to the window, and the wheel moves it through treeOffset. */} - - {branchBar} + + {branchBar} + {search} {s.error && {s.error}} @@ -1074,7 +1020,6 @@ export const register: Register = on => { {panel} - {spare > 0 && } ) }) 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 00000000..f5088497 --- /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 index 66d30759..cf9d5a5a 100644 --- a/mods/claude/git-aware-file-explorer/tests/diff.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/diff.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'claude-code/testing' -import { canGoDown, canGoUp, countLines, diffLines, lastTop, parseNumstat, sumNumstat, wheel } from '../hooks/register' +import { countLines, diffLines, parseNumstat, sumNumstat } from '../hooks/register' +import { diffIndex, nativeWindow, previewCapacity, previewWindow, spacerHeights, visibleText } from '../hooks/viewport' const DIFF = [ 'diff --git a/f b/f', @@ -24,16 +25,6 @@ describe('diff windowing', () => { expect(lines[4]).toEqual({ text: ' ten', kind: ' ', o: 10, n: 10, hunk: 1 }) }) - test('the window stops where the last line reaches the bottom, separators counted', async () => { - const lines = diffLines(DIFF) - // 7 lines and one separator need 8 rows: they fit whole. - expect(lastTop(lines, 8)).toBe(0) - // In 4 rows the last hunk's 3 lines fit, and not the separator above them. - expect(lastTop(lines, 4)).toBe(4) - expect(lastTop(lines, 3)).toBe(4) - expect(lastTop(lines, 2)).toBe(5) - }) - 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 }) }) @@ -49,61 +40,44 @@ describe('diff windowing', () => { }) }) -// A file open that fits its panel, over a tree of 40 rows: the desktop's pane with a short file. -const FITS = { hasFile: true, panelOffset: 0, panelLast: 0, treeOffset: 0, treeLast: 39 } -const LONG = { ...FITS, panelLast: 10 } - -describe('wheel', () => { - test('moves the tree when the open file fits and the wheel has no pointer', () => { - expect(wheel({ ...FITS, by: 3 })).toEqual({ panelOffset: 0, treeOffset: 3 }) - expect(wheel({ ...FITS, treeOffset: 3, by: -5 })).toEqual({ panelOffset: 0, treeOffset: 0 }) +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('moves a long file first, then the tree with the rest', () => { - expect(wheel({ ...LONG, panelOffset: 4, by: 3 })).toEqual({ panelOffset: 7, treeOffset: 0 }) - expect(wheel({ ...LONG, panelOffset: 8, by: 3 })).toEqual({ panelOffset: 10, treeOffset: 1 }) - expect(wheel({ ...LONG, panelOffset: 1, treeOffset: 5, by: -3 })).toEqual({ panelOffset: 0, treeOffset: 3 }) + 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('moves the tree first once it was clicked, then the file with the rest', () => { - expect(wheel({ ...LONG, by: 3, side: 'tree' })).toEqual({ panelOffset: 0, treeOffset: 3 }) - expect(wheel({ ...LONG, treeOffset: 38, by: 3, side: 'tree' })).toEqual({ panelOffset: 2, treeOffset: 39 }) - expect(wheel({ ...LONG, panelOffset: 5, treeOffset: 1, by: -3, side: 'tree' })).toEqual({ panelOffset: 3, treeOffset: 0 }) + 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('moves the file first once it was clicked', () => { - expect(wheel({ ...LONG, treeOffset: 5, by: 3, side: 'panel' })).toEqual({ panelOffset: 3, treeOffset: 5 }) + 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('moves only the side under the pointer', () => { - expect(wheel({ ...LONG, panelOffset: 10, by: 3, over: 'panel' })).toEqual({ panelOffset: 10, treeOffset: 0 }) - expect(wheel({ ...LONG, by: 3, over: 'tree' })).toEqual({ panelOffset: 0, treeOffset: 3 }) - expect(wheel({ ...LONG, by: 3, over: 'panel', side: 'tree' })).toEqual({ panelOffset: 3, treeOffset: 0 }) - }) - - test('keeps the tree within its rows', () => { - expect(wheel({ ...FITS, treeOffset: 38, by: 3 })).toEqual({ panelOffset: 0, treeOffset: 39 }) - expect(wheel({ ...FITS, hasFile: false, treeOffset: 0, treeLast: 2, by: 3 })).toEqual({ panelOffset: 0, treeOffset: 2 }) - }) - - test('a file shorter than its stale offset passes the whole move on', () => { - expect(wheel({ ...FITS, panelOffset: 12, by: 2 })).toEqual({ panelOffset: 0, treeOffset: 2 }) - }) -}) - -describe('room around the window', () => { - test('above, while the tree or the open file can go up', () => { - expect(canGoUp({ ...FITS, hasFile: false, treeOffset: 3 })).toBe(true) - expect(canGoUp({ ...FITS, treeOffset: 3 })).toBe(true) - expect(canGoUp({ ...LONG, panelOffset: 2 })).toBe(true) - expect(canGoUp({ ...FITS, panelOffset: 2 })).toBe(false) - expect(canGoUp(FITS)).toBe(false) + 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('below, while a file is open and the file or the tree can go down', () => { - expect(canGoDown(FITS)).toBe(true) - expect(canGoDown({ ...FITS, treeOffset: 39 })).toBe(false) - expect(canGoDown({ ...LONG, treeOffset: 39 })).toBe(true) - expect(canGoDown({ ...FITS, hasFile: false })).toBe(false) + 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 index d6874a20..d3bceab2 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -34,6 +34,17 @@ const DISK: Record = { const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' +type Live = { + diff: string + mtimeMs: number + diffRuns?: number + lists?: number + reads?: number + stats?: number + processes?: number + text?: 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. @@ -53,9 +64,10 @@ function fakeRepo( { fileSize = 4, hasHead = true, + disk = DISK, onStatus, text = 'a\nb\n', - live = { diff: DIFF, mtimeMs: 1 } as { diff: string; mtimeMs: number; diffRuns?: number }, + live = { diff: DIFF, mtimeMs: 1 } as Live, isCut = false, isRecreated = false, isUnreadable = false, @@ -68,9 +80,10 @@ function fakeRepo( }: { fileSize?: number hasHead?: boolean + disk?: Record onStatus?: () => Promise text?: string - live?: { diff: string; mtimeMs: number; diffRuns?: number } + live?: Live isCut?: boolean isRecreated?: boolean isUnreadable?: boolean @@ -85,16 +98,22 @@ function fakeRepo( // 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) => ({ value: DISK[e.path] ?? [] })) + on('fs.list', ($, e) => { + live.lists = (live.lists ?? 0) + 1 + 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') return { value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink: false } } }) on('fs.read', () => { + live.reads = (live.reads ?? 0) + 1 if (isUnreadable) throw new Error('EACCES: permission denied') - return { value: text } + return { value: live.text ?? text } }) on('process.run', async ($, e) => { + live.processes = (live.processes ?? 0) + 1 const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') const z = e.argv.includes('-z') if (args === 'rev-parse --show-toplevel') return ok(ROOT + '\n') @@ -263,13 +282,62 @@ describe('git-aware-file-explorer pane', () => { }) await ui.press({ key: 'node:README.md' }) const runs = live.diffRuns - // What a wheel or ▲/▼ press does: draw the pane again with nothing in the workspace changed. + // 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('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') @@ -283,6 +351,9 @@ describe('git-aware-file-explorer pane', () => { }) 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() }) @@ -542,94 +613,118 @@ describe('git-aware-file-explorer pane', () => { await ui.unmount() }) - test('keeps a scrolled preview within the file when the pane grows', async ($, on) => { - fakeRepo(on, { text: Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n') }) + 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 } }, + 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' }) - for (let i = 0; i < 30; i++) await ui.press({ key: 'line-down' }) - expect((await ui.find({ type: 'Code' }))?.props.startLine).toBe(31) - - // Tall enough for the whole file: the window can't sit 30 lines down any more. + 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 room below for the wheel while a short open file leaves the tree to move (desktop)', async ($, on) => { - // The desktop raises no wheel event for a window that cannot move: with a file that fits and no - // spare row, the wheel reached neither the file nor the tree. - fakeRepo(on) + 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: { ...PROPS, scroll: { offset: 0, bodyRows: 20 } }, + 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' }) - expect(await ui.find({ key: 'spare' })).toBeDefined() + 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() + }) - // With the file closed the tree is unclipped, and a long one makes its own room. + 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('gives the wheel to the side last clicked (desktop, no pointer)', async ($, on) => { - fakeRepo(on, { text: Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n') }) - // The surface's own move of the window, beneath the mod's hook. - on('ui.scroll', () => ({})) + 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, scroll: { offset: 0, bodyRows: 20 } }, + plugin: 'git-aware-file-explorer', surface: 'desktop', component: 'Pane', + requestId: 'git-aware-file-explorer', props: PROPS, }) - // A wheel tick as the desktop raises it: one row down, no pointer. - const tick = () => - $.ui.scroll({ - component: 'Pane', - requestId: 'git-aware-file-explorer', - offset: 1, - by: 1, - bodyRows: 20, - contentRows: 20, - origin: { kind: 'person' }, + 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' }, }) - const startLine = async () => (await ui.find({ type: 'Code' }))?.props.startLine - const treeRows = async () => (await ui.findAll({ type: 'Button' })).filter(b => b.key?.startsWith('node:')).length - - await ui.press({ key: 'node:src' }) - await ui.press({ key: 'node:src/a.ts' }) - const rows = await treeRows() - await tick() - expect(await startLine()).toBe(2) - expect(await treeRows()).toBe(rows) - - // A folder clicked (closed and opened again) takes the wheel from the long file. - await ui.press({ key: 'chevron:src' }) - await ui.press({ key: 'chevron:src' }) - await tick() - expect(await startLine()).toBe(2) - expect(await treeRows()).toBe(rows - 1) - - // ▲/▼ hands it back to the file. - await ui.press({ key: 'line-down' }) - await tick() - expect(await startLine()).toBe(4) - expect(await treeRows()).toBe(rows - 1) + } + 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() }) @@ -650,11 +745,31 @@ describe('git-aware-file-explorer pane', () => { 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 files only/ })).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) => { @@ -768,7 +883,7 @@ describe('git-aware-file-explorer pane', () => { }) await ui.input({ key: 'filter', text: 'a', kind: 'change' }) const walked = lists - // What a wheel or ▲/▼ press does, and a new filter: no file on disk changed, so no new walk. + // 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) diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index b9ead348..6495f88d 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -52,12 +52,6 @@ declare module 'claude-code' { selected: string | null changedOnly: boolean filter: string - /** First line of the open file the right panel shows. */ - panelOffset: number - /** First row of the tree the left column shows. */ - treeOffset: number - /** The side the wheel moves first while a file is open: the one last clicked. */ - wheelSide: 'tree' | 'panel' } } } From 2b3bf57cf67e775cca4640fae48656a40df800ec Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 20:22:14 -0500 Subject: [PATCH 44/61] fix(mods): mark both totals as floors when a recreated file can't be diffed in git-aware-file-explorer When git could not make a recreated file's net diff, its full deletion stayed in the totals and only the added count was marked a floor. Drop both sides for that file, as for one too large to diff, and mark both totals as floors. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 14 ++++------- .../tests/pane.test.ts | 23 ++++++++++++++++++- .../git-aware-file-explorer/types/index.d.ts | 6 ++--- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index d254eee8..8ccd627d 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -251,22 +251,16 @@ async function lineTotals($: $, root: string, changes: Change[]) { 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 - let isCut = false for (const c of changes.filter(isRecreated)) { const net = await recreatedDiff($, root, base, c.path, ['--numstat', '-z']) - if (net === undefined) { - isUncounted = true - continue - } const others = numstat.filter(e => e.path !== c.path) - // Too large to diff: neither its deletion nor its contents count, and both totals are floors. - if (net === 'cut') isCut = true - numstat = net === 'cut' ? others : [...others, ...parseNumstat(net.stdout)] + // Too large to diff, or git can't: neither its deletion nor its contents count, and both totals are floors. + if (net === 'cut' || net === undefined) isUncounted = true + numstat = net === 'cut' || net === undefined ? 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 || isCut || out.exitCode !== 0) total.isTruncated = true - if (isUncounted) total.partial = true + 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 for (const c of untracked.slice(0, UNTRACKED_LIMIT)) { diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index d3bceab2..87c1f376 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -53,7 +53,7 @@ type Live = { * 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 `isUnreadable`, no file can be read. With `isIgnoredCut`, + * 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. With `isDiffFailed`, git cannot read README.md, so every diff against the base fails. * With `statusError`, git status fails with that message. `gone` names the deleted file; git's line counts quote @@ -72,6 +72,7 @@ function fakeRepo( isRecreated = false, isUnreadable = false, isBlobCut = false, + isBlobMissing = false, isIgnoredCut = false, gone = 'gone.txt', isNewGone = false, @@ -88,6 +89,7 @@ function fakeRepo( isRecreated?: boolean isUnreadable?: boolean isBlobCut?: boolean + isBlobMissing?: boolean isIgnoredCut?: boolean gone?: string isNewGone?: boolean @@ -128,6 +130,7 @@ function fakeRepo( } if (args.startsWith('ls-files')) return isCut ? ok('README.md\0src/a.ts\0docs/ind', 0, true) : ok('README.md\0src/a.ts\0docs/index.md\0') if (args === 'branch --show-current') return ok('feat/x\n') + 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') { @@ -525,6 +528,24 @@ describe('git-aware-file-explorer pane', () => { 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('says an unchanged file it cannot read is unreadable, not empty', async ($, on) => { fakeRepo(on, { isUnreadable: true }) const ui = await $.ui.mount({ diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index 6495f88d..cdff23f0 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -30,9 +30,9 @@ export type Scan = { /** 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's committed version - * did and went uncounted, or git could not count them at all (an unreadable changed file), so - * both totals are floors. + * 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 } From 9b7f10a14a4643097cde47e3a656e4d2f809a261 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 20:27:04 -0500 Subject: [PATCH 45/61] fix(mods): count a new symbolic link as one added line in git-aware-file-explorer git diffs a symbolic link as a single line, its target. The branch bar read the file a link led to and counted its lines, or skipped a link to anything else without marking the total a floor. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 5 ++++ .../tests/pane.test.ts | 23 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 8ccd627d..e55543f6 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -271,6 +271,11 @@ async function lineTotals($: $, root: string, changes: Change[]) { 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 + } if (stat.kind !== 'file') continue if (stat.size > UNTRACKED_BYTES) { total.partial = true diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 87c1f376..e5b53454 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -55,7 +55,7 @@ type Live = { * 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. With `isDiffFailed`, git cannot read README.md, so every diff against the base fails. + * 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 `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`. */ @@ -76,6 +76,7 @@ function fakeRepo( isIgnoredCut = false, gone = 'gone.txt', isNewGone = false, + isNewLink = false, statusError, isDiffFailed = false, }: { @@ -93,6 +94,7 @@ function fakeRepo( isIgnoredCut?: boolean gone?: string isNewGone?: boolean + isNewLink?: boolean statusError?: string isDiffFailed?: boolean } = {}, @@ -107,7 +109,8 @@ function fakeRepo( 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') - return { value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink: false } } + const isLink = isNewLink && e.path.endsWith('/src/new.ts') + return { value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink } } }) on('fs.read', () => { live.reads = (live.reads ?? 0) + 1 @@ -468,6 +471,22 @@ describe('git-aware-file-explorer pane', () => { 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 }) From 965f2583abbe721155d6419cb8986e09fbf5ae6a Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 20:27:36 -0500 Subject: [PATCH 46/61] fix(mods): show a no-index diff error that exits 1 in git-aware-file-explorer git diff --no-index exits 1 when the files differ and also when it can't access one, so a new file gone before its diff opened showed "No textual difference". An exit 1 with no output and an error now counts as a failure, in the panel and for a recreated file's totals. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 14 ++++++++---- .../tests/pane.test.ts | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index e55543f6..e509bfc8 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -131,6 +131,14 @@ async function git($: $, root: string, args: string[], stdin?: string) { return $.process.run(['git', '--no-optional-locks', ...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 @@ -140,9 +148,8 @@ async function recreatedDiff($: $, root: string, base: string, path: string, fla const old = await git($, root, ['show', base + ':' + path]) if (old.isStdoutTruncated) return 'cut' as const if (old.exitCode !== 0) return undefined - // --no-index exits 1 when the files differ, which is the point here. const out = await git($, root, ['diff', ...flags, '--no-index', '--', '/dev/stdin', path], old.stdout) - return out.exitCode <= 1 ? out : undefined + return isDiffFailed(out) ? undefined : out } async function scan($: $): Promise { @@ -618,8 +625,7 @@ async function prepareDiff($: $, s: Scan, c: Change): Promise { if (line.kind === '-') counts.deleted++ largest = Math.max(largest, line.o, line.n) } - // A diff exits 0, or 1 with --no-index when the files differ; past that, git failed. - const error = out.exitCode > 1 ? out.stderr.trim() || `git diff exited ${out.exitCode}` : undefined + 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 } } diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index e5b53454..ba93fedb 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -143,6 +143,9 @@ function fakeRepo( if (args.startsWith('rev-parse --verify')) return hasHead ? ok('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' } } } @@ -471,6 +474,25 @@ describe('git-aware-file-explorer pane', () => { 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('counts a new symbolic link as one added line, as git does', async ($, on) => { fakeRepo(on, { isNewLink: true }) const ui = await $.ui.mount({ From 23c4001626d411909899d6aa47800dccfbad07ad Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 20:32:00 -0500 Subject: [PATCH 47/61] fix(mods): don't strike through a recreated file in git-aware-file-explorer views The changed-only and filtered views drew a file whose deletion is staged while it is back on disk (D?) as gone, though its header says it is back on disk. Co-Authored-By: Claude Opus 5.5 --- .../git-aware-file-explorer/hooks/register.tsx | 3 ++- .../git-aware-file-explorer/tests/pane.test.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index e509bfc8..654f831d 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -505,7 +505,8 @@ async function buildRows( else { const isDir = rest.includes('/') const c = byPath.get(path) - const isGone = !isDir && (c?.x === 'D' || c?.y === 'D') + // 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 }) } } diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index ba93fedb..f079783a 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -587,6 +587,22 @@ describe('git-aware-file-explorer pane', () => { 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({ From 4e439c0f0f42ead2252c840fe67443b7c63d26bb Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 20:32:05 -0500 Subject: [PATCH 48/61] fix(mods): show git's error for a recreated file it can't diff in git-aware-file-explorer When git could not read a recreated file's committed version, the panel showed the new contents as an addition-only diff, as if that were the whole change. The failed run now reaches the panel, which shows git's error. Co-Authored-By: Claude Opus 5.5 --- .../git-aware-file-explorer/hooks/register.tsx | 18 ++++++++++-------- .../git-aware-file-explorer/tests/pane.test.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 654f831d..5428fae9 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -142,14 +142,14 @@ function isDiffFailed(out: { exitCode: number; stdout: string; stderr: string }) /** * `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; undefined when git can't run it so. + * 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 undefined - const out = await git($, root, ['diff', ...flags, '--no-index', '--', '/dev/stdin', path], old.stdout) - return isDiffFailed(out) ? undefined : out + if (old.exitCode !== 0) return old + return git($, root, ['diff', ...flags, '--no-index', '--', '/dev/stdin', path], old.stdout) } async function scan($: $): Promise { @@ -262,8 +262,9 @@ async function lineTotals($: $, root: string, changes: Change[]) { 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. - if (net === 'cut' || net === undefined) isUncounted = true - numstat = net === 'cut' || net === undefined ? others : [...others, ...parseNumstat(net.stdout)] + 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. @@ -663,10 +664,11 @@ async function diffFor($: $, s: Scan, c: Change) { } if (isRecreated(c)) { const net = await recreatedDiff($, s.root, await diffBase($, s.root), c.path, flags) - if (net !== undefined && net !== 'cut') return net + // 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 net === 'cut' ? { ...contents, isStdoutTruncated: true } : contents + 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]) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index f079783a..4fefe579 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -603,6 +603,23 @@ describe('git-aware-file-explorer pane', () => { 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('says an unchanged file it cannot read is unreadable, not empty', async ($, on) => { fakeRepo(on, { isUnreadable: true }) const ui = await $.ui.mount({ From 95f190a53abb7573326b877e60b45e4f8bd20fbd Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 20:32:09 -0500 Subject: [PATCH 49/61] fix(mods): reopen a file edited while closed fresh in git-aware-file-explorer The stamp stopped watching a file once it closed, so an edit that left git's status and totals alone reopened it from a stale cached diff or preview. The stamp now covers the cached files too. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 9 ++-- .../tests/pane.test.ts | 45 ++++++++++++++----- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 5428fae9..c86a3579 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -298,14 +298,15 @@ async function lineTotals($: $, root: string, changes: Change[]) { } /** - * Modification time and size of the open file, 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. + * 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. */ 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)]) - if (selected !== null) paths.add(selected) + 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)), ) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 4fefe579..552fe339 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -43,6 +43,8 @@ type Live = { stats?: number processes?: number text?: string + /** Modification times of single files, by path from the root, in place of `mtimeMs`. */ + fileMtimes?: Record } /** @@ -110,7 +112,8 @@ function fakeRepo( 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') - return { value: { kind: 'file' as const, size: fileSize, mtimeMs: live.mtimeMs, isLink } } + 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', () => { live.reads = (live.reads ?? 0) + 1 @@ -324,6 +327,28 @@ describe('git-aware-file-explorer pane', () => { }) } + 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('invalidates a cached preview when its scan stamp changes', async ($, on) => { const live: Live = { diff: DIFF, mtimeMs: 1 } fakeRepo(on, { live }) @@ -587,8 +612,8 @@ describe('git-aware-file-explorer pane', () => { await ui.unmount() }) - test('does not strike through a recreated file in the changed-only view', async ($, on) => { - fakeRepo(on, { isRecreated: true }) + 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', @@ -596,15 +621,16 @@ describe('git-aware-file-explorer pane', () => { requestId: 'git-aware-file-explorer', props: PROPS, }) - await ui.press({ key: 'changes' }) + await ui.press({ key: 'node:gone.txt' }) - expect((await ui.find({ type: 'Text', text: 'gone.txt' }))?.props.strikethrough).toBe(false) + // 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("shows git's error when it cannot read a recreated file's committed version", async ($, on) => { - fakeRepo(on, { isRecreated: true, isBlobMissing: true }) + 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', @@ -612,10 +638,9 @@ describe('git-aware-file-explorer pane', () => { requestId: 'git-aware-file-explorer', props: PROPS, }) - await ui.press({ key: 'node:gone.txt' }) + await ui.press({ key: 'changes' }) - // 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() + expect((await ui.find({ type: 'Text', text: 'gone.txt' }))?.props.strikethrough).toBe(false) await ui.unmount() }) From c99b8eacad50bd4e6ba6573caecb3530acc8651d Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 20:41:43 -0500 Subject: [PATCH 50/61] fix(mods): mark a folder it can't list as unreadable in git-aware-file-explorer An open folder whose listing failed (permissions, an I/O error) was drawn as empty. Its row now says "(unreadable)", so it can't pass for an empty folder. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 20 ++++++++++++------ .../tests/pane.test.ts | 21 ++++++++++++++++++- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index c86a3579..7d89c735 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -56,6 +56,8 @@ type Row = { 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 } /** @@ -483,13 +485,16 @@ async function buildRows( const ignored = new Set(s.ignored) const rows: Row[] = [] - async function walk(dir: string, depth: number) { + /** 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(() => []) - for (const en of entries) { + 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 }) } @@ -518,7 +523,7 @@ async function buildRows( for (const [name, k] of sorted) { const path = prefix + name const isOpen = k.isDir && (virtual !== undefined ? k.hasKids : expanded.has(path)) - rows.push({ + const row: Row = { path, name, depth, @@ -528,9 +533,11 @@ async function buildRows( count: counts.get(path) ?? 0, isIgnored: s.isRepo && isIgnored(path, ignored), isMissing: k.isMissing, - }) - if (isOpen) await walk(path, depth + 1) + } + rows.push(row) + if (isOpen && !(await walk(path, depth + 1))) row.isUnreadable = true } + return isListed } await walk('', 0) @@ -853,6 +860,7 @@ export const register: Register = on => { diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 552fe339..566ef378 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -58,7 +58,7 @@ type Live = { * 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 `statusError`, git status fails with that message. `gone` names the deleted file; git's line counts quote + * With `isSrcUnreadable`, src can't be listed. 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( @@ -79,6 +79,7 @@ function fakeRepo( gone = 'gone.txt', isNewGone = false, isNewLink = false, + isSrcUnreadable = false, statusError, isDiffFailed = false, }: { @@ -97,6 +98,7 @@ function fakeRepo( gone?: string isNewGone?: boolean isNewLink?: boolean + isSrcUnreadable?: boolean statusError?: string isDiffFailed?: boolean } = {}, @@ -106,6 +108,7 @@ function fakeRepo( on('session.cwd', () => ({ value: ROOT })) on('fs.list', ($, e) => { live.lists = (live.lists ?? 0) + 1 + if (isSrcUnreadable && e.path === ROOT + '/src') throw new Error('EACCES: permission denied') return { value: disk[e.path] ?? [] } }) on('fs.stat', ($, e) => { @@ -518,6 +521,22 @@ describe('git-aware-file-explorer pane', () => { 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('counts a new symbolic link as one added line, as git does', async ($, on) => { fakeRepo(on, { isNewLink: true }) const ui = await $.ui.mount({ From b43461ba347d31f5cfc8bae89ad967dd97e10571 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 22:03:58 -0500 Subject: [PATCH 51/61] fix(mods): find files added outside git while a filter is on in git-aware-file-explorer Outside a git repository the stamp watched only the root and open folders, so a matching file added or removed in a closed folder left the scan unchanged and the filtered tree stale. While a filter is on there, each rescan redoes the filter's walk, keeps it for the redraw, and stamps a fingerprint of it. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 17 ++++++++++++- .../tests/pane.test.ts | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 7d89c735..72c1387f 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -315,6 +315,16 @@ async function stampFor($: $, s: Scan): Promise { return stats.map(st => (st ? st.mtimeMs + ':' + st.size : '-')).join(',') } +/** 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 @@ -334,9 +344,14 @@ function rescan($: $): Promise { do { isRescanDue = false const found = await scan($) - const next: Scan = { ...found, stamp: await stampFor($, found) } // 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) diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 566ef378..cf46ef84 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -1035,6 +1035,31 @@ describe('git-aware-file-explorer pane', () => { 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] ?? [] })) From cf3af77c3031ceee4e964de51a61e21a3bf656c9 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 22:13:22 -0500 Subject: [PATCH 52/61] fix(mods): rescan the tree when HEAD moves in git-aware-file-explorer A pull or checkout that left the status clean could add or remove tracked files in a closed folder without changing the scan, so a filtered tree stayed stale. The scan now records the commit HEAD names. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 4 ++- .../tests/pane.test.ts | 31 +++++++++++++++++-- .../git-aware-file-explorer/types/index.d.ts | 5 +++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 72c1387f..5b2df3e5 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -165,10 +165,11 @@ async function scan($: $): Promise { return { root: cwd, isRepo: false, changes: [], ignored: [], error } } const root = top.stdout.trim() - const [status, ignored, branch] = await Promise.all([ + 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() @@ -180,6 +181,7 @@ async function scan($: $): Promise { root, isRepo: true, branch: name || undefined, + head: head.stdout.trim() || undefined, changes, lines, isTruncated: status.isStdoutTruncated || undefined, diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index cf46ef84..a761d105 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -45,6 +45,9 @@ type Live = { 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 } /** @@ -137,7 +140,7 @@ function fakeRepo( 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('README.md\0src/a.ts\0docs/index.md\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 === `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') @@ -146,7 +149,7 @@ function fakeRepo( 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('abc\n') : ok('', 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')) { @@ -1035,6 +1038,30 @@ describe('git-aware-file-explorer pane', () => { 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 })) diff --git a/mods/claude/git-aware-file-explorer/types/index.d.ts b/mods/claude/git-aware-file-explorer/types/index.d.ts index cdff23f0..a310b816 100644 --- a/mods/claude/git-aware-file-explorer/types/index.d.ts +++ b/mods/claude/git-aware-file-explorer/types/index.d.ts @@ -18,6 +18,11 @@ export type Scan = { 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[] From 0ecfeccd158474e6591abfaf20851e5171283170 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 22:14:12 -0500 Subject: [PATCH 53/61] fix(mods): retry a preview it couldn't read in git-aware-file-explorer A preview read that failed was cached until the scan changed, and a file that became readable again with the same time and size never did. While the open file's preview can't be read, each scan's stamp now differs, so the pane redraws and reads it again. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 23 +++++++++++----- .../tests/pane.test.ts | 27 +++++++++++++++++-- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 5b2df3e5..98b33f6e 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -305,7 +305,8 @@ async function lineTotals($: $, root: string, changes: Change[]) { * 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. + * file closed and edited does not reopen from a stale cache. While the open preview can't be read, + * every stamp differs, so each scan redraws and reads it again. */ async function stampFor($: $, s: Scan): Promise { const selected = await read($, selectedAtom) @@ -314,9 +315,13 @@ async function stampFor($: $, s: Scan): Promise { const stats = await Promise.all( [...paths].map(p => $.fs.stat(p === '' ? s.root : s.root + '/' + p).catch(() => undefined)), ) - return stats.map(st => (st ? st.mtimeMs + ':' + st.size : '-')).join(',') + const stamp = stats.map(st => (st ? st.mtimeMs + ':' + st.size : '-')).join(',') + return previewCache?.isFailed && previewCache.path === selected ? stamp + ',retry' + ++retries : stamp } +/** Counts the scans that retried a preview 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 @@ -657,21 +662,27 @@ async function prepareDiff($: $, s: Scan, c: Change): Promise { } type Preview = { lines: string[]; notice?: string; source?: string } -let previewCache: { scanKey: string; path: string; preview: Promise } | undefined +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) { - previewCache = { scanKey, path, preview: preparePreview($, s.root + '/' + path) } + const entry: NonNullable = { scanKey, path, preview: preparePreview($, s.root + '/' + path) } + // 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: 'Not a readable file.' } + 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: 'Not a readable file.' } + 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() diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index a761d105..03452c32 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -48,6 +48,8 @@ type Live = { /** 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. */ + unreadable?: string } /** @@ -121,9 +123,9 @@ function fakeRepo( 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', () => { + on('fs.read', ($, e) => { live.reads = (live.reads ?? 0) + 1 - if (isUnreadable) throw new Error('EACCES: permission denied') + if (isUnreadable || e.path === ROOT + '/' + live.unreadable) throw new Error('EACCES: permission denied') return { value: live.text ?? text } }) on('process.run', async ($, e) => { @@ -355,6 +357,27 @@ describe('git-aware-file-explorer pane', () => { 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 }) From 2fe86f2172caefc2b6e3e48887be0348da2be2ce Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 22:23:20 -0500 Subject: [PATCH 54/61] fix(mods): read an unstaged rename's original path in git-aware-file-explorer git status names the original path after any rename or copy, including an unstaged one (an intent-to-add file, ` R new\0old`). Only the staged column was checked, so the original path was read as a record of its own. Co-Authored-By: Claude Opus 5.5 --- mods/claude/git-aware-file-explorer/hooks/register.tsx | 3 ++- mods/claude/git-aware-file-explorer/tests/diff.test.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index 98b33f6e..fca60400 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -71,7 +71,8 @@ export function parseStatus(out: string): Change[] { const part = parts[i] ?? '' if (part.length < 4) continue const change: Change = { x: part.charAt(0), y: part.charAt(1), path: part.slice(3) } - if (change.x === 'R' || change.x === 'C') change.from = parts[++i] + // A rename or copy names its original path next, staged or not (an intent-to-add file). + if (/[RC]/.test(change.x + change.y)) change.from = parts[++i] 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 }) diff --git a/mods/claude/git-aware-file-explorer/tests/diff.test.ts b/mods/claude/git-aware-file-explorer/tests/diff.test.ts index cf9d5a5a..dd8de633 100644 --- a/mods/claude/git-aware-file-explorer/tests/diff.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'claude-code/testing' -import { countLines, diffLines, parseNumstat, sumNumstat } from '../hooks/register' +import { countLines, diffLines, parseNumstat, parseStatus, sumNumstat } from '../hooks/register' import { diffIndex, nativeWindow, previewCapacity, previewWindow, spacerHeights, visibleText } from '../hooks/viewport' const DIFF = [ @@ -33,6 +33,14 @@ describe('diff windowing', () => { 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('lines count as git counts them', async () => { expect(countLines('')).toBe(0) expect(countLines('a\nb\n')).toBe(2) From a0b79f09fc864233a9f61393293371f0487f17f8 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 22:24:31 -0500 Subject: [PATCH 55/61] fix(mods): pass file names to git literally in git-aware-file-explorer A file named with pathspec magic, such as `:(exclude)foo`, was read as a pathspec, so its diff showed nothing or other files' changes. Every git call now runs with --literal-pathspecs. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 3 ++- .../tests/pane.test.ts | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index fca60400..ebe89d7d 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -130,8 +130,9 @@ export function iconFor(name: string): string { 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', ...args], { cwd: root, stdin }) + return $.process.run(['git', '--no-optional-locks', '--literal-pathspecs', ...args], { cwd: root, stdin }) } /** diff --git a/mods/claude/git-aware-file-explorer/tests/pane.test.ts b/mods/claude/git-aware-file-explorer/tests/pane.test.ts index 03452c32..26e7dd10 100644 --- a/mods/claude/git-aware-file-explorer/tests/pane.test.ts +++ b/mods/claude/git-aware-file-explorer/tests/pane.test.ts @@ -50,6 +50,8 @@ type Live = { files?: string /** A path from the root that can't be read for now. */ unreadable?: string + /** Every process run, as its argv. */ + runs?: (readonly string[])[] } /** @@ -130,7 +132,8 @@ function fakeRepo( }) on('process.run', async ($, e) => { live.processes = (live.processes ?? 0) + 1 - const args = e.argv.filter(a => a !== '--no-optional-locks').slice(1).join(' ') + 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')) { @@ -563,6 +566,26 @@ describe('git-aware-file-explorer pane', () => { 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('counts a new symbolic link as one added line, as git does', async ($, on) => { fakeRepo(on, { isNewLink: true }) const ui = await $.ui.mount({ From 328edbbd9dbb9e8244dc3740fdf4e189b78d4a80 Mon Sep 17 00:00:00 2001 From: MRSchexnayder Date: Sun, 18 Oct 2026 22:33:11 -0500 Subject: [PATCH 56/61] fix(mods): open a changed submodule's diff by its name in git-aware-file-explorer git reports a modified submodule's root as a changed path, but the disk lists it as a folder, so pressing it only ever expanded it. For a folder that is itself a change, the name now opens its diff and the chevron expands it; other folders are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../hooks/register.tsx | 4 +++- .../tests/pane.test.ts | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/mods/claude/git-aware-file-explorer/hooks/register.tsx b/mods/claude/git-aware-file-explorer/hooks/register.tsx index ebe89d7d..327c937b 100644 --- a/mods/claude/git-aware-file-explorer/hooks/register.tsx +++ b/mods/claude/git-aware-file-explorer/hooks/register.tsx @@ -860,6 +860,8 @@ export const register: Register = on => { 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 ( { height={16} /> )} -