An IDE-style file tree for Claude Code that shows what Claude is doing and where in files. Maintained copy of data-goblin/claude-code-filetree@b0e4098 (MIT…

[!IMPORTANT] Maintained copy. This is bracyw's copy of data-goblin/claude-code-filetree at commit
b0e4098, audited line-by-line on 2026-10-12. The original is MIT-licensed by Kurt Buhler; seeLICENSE. Runtime code is unchanged from upstream; onlyplugin.jsonmetadata differs, and upstream's site, media, CI and dev tooling were left out. Install from this repo's marketplace (see the root README), not with the upstream commands below. Upstream changes are pulled in only after reviewing the diff.
<h1 align="center">Claude Code Filetree</h1>
An IDE-style file tree for Claude Code that shows what Claude is doing and where in files
<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.287-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.287 or newer"> <img src="https://img.shields.io/badge/version-0.2.24-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<img src="media/filetree-shimmer.gif" alt="filetree shimmering the file Claude is editing" width="900">
[!NOTE] filetree is a Claude Code mod and needs Claude Code 2.1.287+. It shows in the right sidebar, which in the terminal needs the fullscreen layout (
/tui fullscreen) and a terminal at least 110 columns wide; the Desktop app docks it on its own, and/filetreereopens it there after you close it. Tested on Linux, macOS and Windows; mods don't load in WSL sessions of the Desktop app.
The repo is its own plugin marketplace. Run this in the terminal:
claude plugin marketplace add data-goblin/claude-code-filetree
claude plugin install filetree@claude-code-filetree
Or inside a Claude Code session:
/plugin marketplace add data-goblin/claude-code-filetree
/plugin install filetree@claude-code-filetree
Installed it as filetree@filetree before the repository was renamed? Nothing to do: that install keeps loading and keeps receiving updates.
/filetree <path> pins another folder+N -N) on modified files and ?:N M:N D:N file counts on folders<img src="media/filetree-read.gif" alt="Files shimmer purple while Claude reads and searches them" width="800">
git and gh (commit, push, pull, checkout, merge, PR and more) shown as a status at the bottom of the pane<img src="media/filetree-git.gif" alt="A committed file shimmers green and the footer shows the commit" width="800">
prompt.submit hook, and @path mentions in a prompt reveal that file in the tree<img src="media/filetree-ask.gif" alt="Selecting config.yaml in the tree and asking Claude what it changed there" width="800">
Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writesYou can resize the pane with the mouse, or by setting custom pane:grow or pane:shrink keybindings in keybindings.json
<img src="media/filetree-resize.gif" alt="Dragging the filetree pane edge to resize it" width="900">
All settings are in /config under filetree.
reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.on (default) scrolls the tree to what Claude reads, writes or commits; off keeps the view where you put it, and highlights still show.on (default) opens the pane when a session starts; off keeps it closed until you run /filetree.date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.preview shows it in the pane instead: Markdown rendered, code highlighted, arrow keys, Page Up/Down and the wheel to scroll, ←/h or ← files back to the tree, ↗ the default app. Otherwise a command that double-click and Enter run instead: {path} is the file and {dir} its folder, and without {path} the path goes last. It runs without a shell, so quote a word that holds spaces, and it should start the viewer and return rather than wait for it. Examples: zed {path}, code -g {path}, or in herdr a tab running herdr-file-viewer on the file: herdr plugin pane open --plugin herdr-file-viewer --entrypoint file-viewer --placement tab --focus --env HERDR_FILE_VIEWER_OPEN={path}.auto (default) uses Nerd Font icons when a Nerd Font is installed and your terminal started after it was installed, plain Unicode in the desktop app, and Nerd Font over SSH. nerd or plain forces one.Clicking rows needs herdr 0.9.1 or later. herdr 0.9.0 and older accept pixel mouse reporting but still send cell positions, which would put every click in the session in the wrong place, so on those versions the rows ignore the mouse and the rest of the pane and session keep working. Run herdr update to get row clicks.
Turn on the pre-commit hook once per clone; it runs claude plugin validate and the plugin tests before each commit that touches the plugin:
git config core.hooksPath .githooks
The same checks run on macOS, Windows and Linux in CI on every push that touches the plugin.
This project is inspired by my Omarchy app FileBlade
hooks/register.tsx 1568 lines1import { type BuiltinToolResults, type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { LINUX_LAUNCH, openCommand, openWithCommand } from './open'
3
4import type { Activity, FileNode, FileTree, Theme } from '../types'
5import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
6import type { RowSpec, RowsProps, Seg } from './rows'
7import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR } from './icons'
8import {
9 ancestorsOf,
10 type Change,
11 DEFAULT_THEME,
12 dirname,
13 emptyTree,
14 formatSize,
15 inside,
16 isAbsolute,
17 join,
18 mapper,
19 parseGit,
20 parseNumstat,
21 parseTheme,
22 posix,
23 relative,
24 dropBelow,
25 useDrives,
26 rollCounts,
27 replaceChildren,
28 rollUp,
29 stamp,
30 toNodes,
31 underAny,
32 visibleRows,
33} from './tree'
34
35const TREE = { plugin: 'filetree', key: 'tree' } as const
36const THEME = { plugin: 'filetree', key: 'theme' } as const
37const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
38const PANE = 'filetree'
39const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
40const BRANCH_ROW = '#branch'
41const FLASH_MS = 2700
42const RUNNING_MAX_MS = 600_000
43const NO_REPO_DEPTH = 4
44const DOUBLE_MS = 450
45const FIND_LIMIT = 200
46const ROWS_BUDGET = 80_000
47const READ_REVEAL_LIMIT = 12
48const SEARCH_REVEAL_LIMIT = 60
49const ACTIVITY_TTL_MS = 45_000
50const ADD_COLOR = '#98c379'
51const DEL_COLOR = '#e06c75'
52const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
53const THEME_POLL_MS = 2000
54const FONT_SCRIPT =
55 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
56 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
57 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
58 'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
59 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
60 'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
61 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
62const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
63const HOME_PRUNE = ['Library', 'AppData', '.Trash']
64const SIZE_TIMEOUT_MS = 15_000
65const SIZE_JOBS = 2
66const SIZE_WALK_LIMIT = 50_000
67
68let blink: Timer | null = null
69let themePoll: Timer | null = null
70let themeMtime: number | null = null
71let generation = 0
72let lastPress = { key: '', at: 0 }
73let noNerd = false
74let glyphSetting = 'auto'
75let openWithSetting = ''
76let previewed: { path: string; text: string; note: string } | null = null
77let previewRows = 20
78let follow = true
79let followClaude = true
80let scanning: Promise<void> | null = null
81let scanJobs: Job[] = []
82let gitRun: Promise<void> | null = null
83let gitNext: Promise<void> | null = null
84let queuedReads: string[] = []
85let showReads = true
86let showWrites = true
87let searchIndex: { root: string; paths: Promise<string[]> } | null = null
88let activityId = 0
89let pointer = true
90let view = { from: 0, max: 0 }
91let nativeScroll = false
92let lastSync = 0
93let noDock = false
94let home = ''
95let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
96let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
97let markId = 0
98let listSeq = 0
99let lastRoot = ''
100let sizeDefault = false
101let sizeEpoch = 0
102let sizeActive = 0
103let sizeQueue: string[] = []
104const sizing = new Set<string>()
105const listLatest = new Map<string, number>()
106const unreadable = new Set<string>()
107const background = new Map<string, Background>()
108
109function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
110 platform ??= (async () => {
111 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
112 try {
113 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
114 } catch {
115 return 'linux'
116 }
117 })()
118 return platform
119}
120
121async function cwdOf($: EngineInterface): Promise<string> {
122 return posix(await $.session.cwd())
123}
124
125function clean(segs: Seg[]): Seg[] {
126 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
127 return segs
128}
129
130function faint(hex: string): string {
131 const v = parseInt(hex.slice(1), 16)
132 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
133 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
134}
135
136async function get($: EngineInterface): Promise<FileTree> {
137 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
138}
139
140async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
141 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
142}
143
144function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
145 return put($, t => ({ ...t, ...fn(t) }))
146}
147
148async function activities($: EngineInterface): Promise<Activity[]> {
149 return (await $.state.get(ACTIVITY)).value ?? []
150}
151
152async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
153 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
154}
155
156async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
157 try {
158 const entries = await $.fs.list(dir)
159 const resolved = await Promise.all(
160 entries.map(async e => {
161 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
162 try {
163 const target = await $.fs.stat(join(dir, e.name))
164 return target.kind === 'dir'
165 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
166 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
167 } catch {
168 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
169 }
170 }),
171 )
172 unreadable.delete(dir)
173 return toNodes(dir, resolved)
174 } catch (err) {
175 if (!(await exists($, dir))) return []
176 if (!unreadable.has(dir)) {
177 unreadable.add(dir)
178 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
179 }
180 return null
181 }
182}
183
184async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
185 const root = (await get($)).root
186 const seq = ++listSeq
187 for (const dir of dirs) listLatest.set(dir, seq)
188 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
189 const listed = new Map<string, FileNode[]>()
190 for (const [dir, kids] of results) {
191 if (listLatest.get(dir) !== seq) continue
192 listLatest.delete(dir)
193 if (kids) listed.set(dir, kids)
194 }
195 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
196 return listed
197}
198
199async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
200 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
201}
202
203async function detectRepo($: EngineInterface): Promise<void> {
204 const t = await get($)
205 if (!t.root) return
206 let top = ''
207 let prefix = ''
208 try {
209 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
210 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
211 prefix = p.trim()
212 top = tl.trim()
213 } catch {
214 top = ''
215 }
216 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
217}
218
219async function readGit($: EngineInterface): Promise<void> {
220 const t = await get($)
221 if (!t.root || !t.top) return
222 const root = t.root
223 try {
224 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
225 if (status.exitCode !== 0) return
226 const parsed = parseGit(status.stdout, root, t.prefix)
227 const diff: Record<string, [number, number]> = {}
228 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
229 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
230 const files = { ...parsed.files }
231 if (parsed.untrackedDirs.length) {
232 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
233 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
234 }
235 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
236 dirty = { root, files }
237 await patch($, cur =>
238 cur.root === root
239 ? {
240 git: parsed.git,
241 ignored: parsed.ignored,
242 untrackedDirs: parsed.untrackedDirs,
243 branch: parsed.branch,
244 counts: rollCounts(files, root),
245 diff: rollUp(diff, root),
246 }
247 : {},
248 )
249 } catch {
250 return
251 }
252}
253
254function refreshGit($: EngineInterface): Promise<void> {
255 if (!gitRun) {
256 gitRun = readGit($).finally(() => {
257 gitRun = null
258 })
259 return gitRun
260 }
261 const rerun = () => {
262 gitNext = null
263 return refreshGit($)
264 }
265 gitNext ??= gitRun.then(rerun, rerun)
266 return gitNext
267}
268
269async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
270 const prev = await get($)
271 generation += 1
272 blink?.cancel()
273 blink = null
274 lastRoot = root
275 searchIndex = null
276 sizeEpoch += 1
277 sizeQueue = []
278 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
279 const title = `Files: ${root.split('/').pop() || root}`
280 if (focus) await $.ui.open({ id: PANE, title, focus: true })
281 else if (!noDock) await $.ui.open({ id: PANE, title })
282 await loadDirs($, [root])
283 await detectRepo($)
284 await refreshGit($)
285}
286
287async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
288 const tried = new Set<string>()
289 for (let pass = 0; pass < 32; pass++) {
290 const t = await get($)
291 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
292 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
293 const need = new Set<string>()
294 for (const p of paths) {
295 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
296 if (!loaded.has(dir)) {
297 if (!tried.has(dir)) need.add(dir)
298 break
299 }
300 }
301 }
302 if (need.size === 0) return
303 for (const dir of need) tried.add(dir)
304 await loadDirs($, [...need])
305 }
306}
307
308function pruneArgs(root: string, ignored: string[] = []): string[] {
309 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
310 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
311}
312
313async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
314 const stats = await Promise.all(
315 paths.map(async p => {
316 try {
317 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
318 } catch {
319 return ''
320 }
321 }),
322 )
323 return stats.filter(Boolean)
324}
325
326function broad(root: string): boolean {
327 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
328}
329
330function rootOfDisk(root: string): boolean {
331 return root === '/' || /^[A-Za-z]:\/$/.test(root)
332}
333
334async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
335 if (rootOfDisk(root)) return []
336 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
337 try {
338 const run = await $.process.run(['find', '-H', root, '-xdev', ...(depth ? ['-maxdepth', String(depth)] : []), ...pruneArgs(root, ignored.filter(p => inside(root, p)).slice(0, 40)), ...test, '-print0'], { timeoutMs: 8_000 })
339 return run.stdout.split('\0').filter(p => p && p !== root)
340 } catch {
341 return []
342 }
343}
344
345async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
346 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
347 if (dirty.root !== root) return { hits: [], gone: [] }
348 const entries = Object.entries(dirty.files)
349 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
350 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
351 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
352}
353
354type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
355
356async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
357 const ms = await $.clock.now()
358 const os = await osName($)
359 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
360 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
361 try {
362 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
363 return { ms, mark, os }
364 } catch {
365 return { ms, mark: '', os }
366 }
367}
368
369async function walkSize($: EngineInterface, dir: string): Promise<number> {
370 let total = 0
371 let seen = 0
372 let level = [dir]
373 while (level.length) {
374 const next: string[] = []
375 for (const d of level) {
376 let entries
377 try {
378 entries = await $.fs.list(d)
379 } catch {
380 continue
381 }
382 for (const e of entries) {
383 if (++seen > SIZE_WALK_LIMIT) return -1
384 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
385 else if (e.kind === 'file') total += e.size
386 }
387 }
388 level = next
389 }
390 return total
391}
392
393async function dirSize($: EngineInterface, dir: string): Promise<number> {
394 if (rootOfDisk(dir)) return -1
395 if ((await osName($)) === 'win32') return walkSize($, dir)
396 try {
397 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
398 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
399 return Number.isFinite(kb) ? kb * 1024 : -1
400 } catch {
401 return -1
402 }
403}
404
405function pumpSizes($: EngineInterface): void {
406 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
407 const dir = sizeQueue.shift() ?? ''
408 const epoch = sizeEpoch
409 sizing.add(dir)
410 sizeActive += 1
411 void dirSize($, dir)
412 .then(async bytes => {
413 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
414 const cur = await get($)
415 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
416 })
417 .catch(() => undefined)
418 .finally(() => {
419 sizing.delete(dir)
420 sizeActive -= 1
421 pumpSizes($)
422 })
423 }
424}
425
426function wantSizes($: EngineInterface, dirs: string[]): void {
427 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
428 pumpSizes($)
429}
430
431async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
432 sizeEpoch += 1
433 sizeQueue = []
434 await patch($, cur => {
435 if (!paths) return { dirSizes: {} }
436 const keep: Record<string, number> = {}
437 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
438 return { dirSizes: keep }
439 })
440}
441
442function openDirs(t: FileTree): string[] {
443 const open = new Set(t.expanded)
444 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
445}
446
447function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
448 if (followClaude) return { scroll: null }
449 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
450 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
451 return at < 0 ? {} : { scroll: at }
452}
453
454async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
455 const unique = Object.keys(tones)
456 if (unique.length === 0) return
457 const mine = ++generation
458 const root = (await get($)).root
459 blink?.cancel()
460 blink = null
461 await patch($, cur => {
462 if (cur.root !== root) return {}
463 const open = new Set(cur.expanded)
464 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
465 const dim = new Set<string>()
466 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
467 if (cur.flashOn) {
468 for (const id of cur.flashDim) dim.add(id)
469 }
470 for (const id of unique) {
471 const tone = tones[id] ?? 'orange'
472 all[id] = tone
473 if (id === BRANCH_ROW) continue
474 const chain = ancestorsOf(id, cur.root)
475 if (!chain.some(a => !open.has(a))) continue
476 for (const a of chain) {
477 if (!open.has(a)) {
478 dim.add(a)
479 all[a] = all[a] ?? tone
480 }
481 open.add(a)
482 }
483 }
484 for (const id of bright) dim.delete(id)
485 return {
486 flash: [...bright],
487 flashDim: [...dim],
488 flashOn: true,
489 flashTones: all,
490 expanded: [...open],
491 ...keepTop(cur, [...open]),
492 }
493 })
494 if (generation !== mine) return
495 blink = $.clock.after(FLASH_MS, () => {
496 if (generation !== mine) return
497 blink = null
498 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
499 })
500}
501
502async function followCwd($: EngineInterface): Promise<boolean> {
503 if (!follow) return false
504 const cwd = await cwdOf($)
505 const t = await get($)
506 if (t.root === cwd) return false
507 await reset($, cwd)
508 return true
509}
510
511type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
512type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
513type Background = { p: Pending | null; job: Job; reads: string[] }
514type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
515type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
516
517const PROOF: Record<string, (op: GitOperation) => boolean> = {
518 push: op => Boolean(op.push),
519 merge: op => op.branch?.action === 'merged',
520 rebase: op => op.branch?.action === 'rebased',
521 'pr create': op => op.pr?.action === 'created',
522 'pr merge': op => op.pr?.action === 'merged',
523 'pr comment': op => op.pr?.action === 'commented',
524}
525
526async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
527 const now = await $.clock.now()
528 const ids = actions.map(() => ++activityId)
529 await setActivities($, cur => [
530 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
531 ...actions.map((a, i) => ({
532 id: ids[i] ?? 0,
533 kind: a.kind,
534 label: a.running,
535 state: 'running' as const,
536 detail: '',
537 at: now,
538 tone: a.tone,
539 nerd: a.icon.nerd,
540 plain: a.icon.plain,
541 })),
542 ])
543 return ids
544}
545
546async function headOf($: EngineInterface, cwd: string): Promise<string> {
547 try {
548 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
549 return run.exitCode === 0 ? run.stdout.trim() : ''
550 } catch {
551 return ''
552 }
553}
554
555async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
556 const chain = chainOf(p.command)
557 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
558 return p.actions.map(a => {
559 if (a.verb === 'commit') {
560 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
561 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
562 }
563 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
564 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
565 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
566 })
567}
568
569async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
570 const now = await $.clock.now()
571 await setActivities($, cur =>
572 cur.map(a => {
573 const i = p.ids.indexOf(a.id)
574 const result = i < 0 ? undefined : results[i]
575 return result ? { ...a, ...result, at: now } : a
576 }),
577 )
578 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
579}
580
581function failAll(p: Pending): Outcome[] {
582 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
583}
584
585function proven(p: Pending | null, results: Outcome[]): GitAction[] {
586 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
587}
588
589function taskEnds(text: string): [string, string][] {
590 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
591 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
592 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
593 return id && status ? [[id, status] as [string, string]] : []
594 })
595}
596
597async function settleBackground($: EngineInterface, text: string): Promise<void> {
598 if (background.size === 0 || !text.includes('<task-notification>')) return
599 for (const [id, status] of taskEnds(text)) {
600 const task = background.get(id)
601 if (!task) continue
602 background.delete(id)
603 const ok = status === 'completed'
604 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
605 if (task.p) await finishGit($, task.p, results)
606 if (showReads) queuedReads.push(...task.reads)
607 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
608 }
609}
610
611async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
612 const map = mapper(root, prefix)
613 try {
614 const run = committed
615 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
616 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
617 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
618 } catch {
619 return []
620 }
621}
622
623async function gitDir($: EngineInterface, cwd: string): Promise<string> {
624 for (let dir = cwd, i = 0; i < 64; i++) {
625 try {
626 const dot = join(dir, '.git')
627 const st = await $.fs.stat(dot)
628 if (st.kind === 'dir') return dot
629 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
630 if (ref) return resolve(dir, ref, home)
631 } catch {
632 const up = dirname(dir)
633 if (up === dir) return ''
634 dir = up
635 continue
636 }
637 return ''
638 }
639 return ''
640}
641
642async function sync($: EngineInterface, force = false): Promise<void> {
643 const now = await $.clock.now()
644 if (!force && now - lastSync < 2_000) return
645 lastSync = now
646 searchIndex = null
647 const t = await get($)
648 if (!t.root) return
649 await loadDirs($, [t.root, ...openDirs(t)])
650 if (t.top) await refreshGit($)
651}
652
653async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
654 const t = await get($)
655 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
656 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
657 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
658}
659
660async function exists($: EngineInterface, path: string): Promise<boolean> {
661 try {
662 await $.fs.stat(path)
663 return true
664 } catch {
665 return false
666 }
667}
668
669async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
670 const reads = queuedReads
671 queuedReads = []
672 if (await followCwd($)) return
673 const t = await get($)
674 if (!t.root) return
675 const actions = jobs.flatMap(j => j.actions)
676 const writers = jobs.filter(j => !j.readOnly)
677 const since = (writers.length ? writers : jobs).reduce((a, j) => (j.since.ms < a.ms ? j.since : a), (writers[0] ?? jobs[0])?.since ?? { ms: 0, mark: '', os: 'linux' as const })
678 const writes = !jobs.every(j => j.readOnly)
679 const before = dirty.root === t.root ? dirty.files : {}
680 if (jobs.some(j => j.initRepo)) await detectRepo($)
681 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
682 const probed = await get($)
683 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
684 if (writes) {
685 searchIndex = null
686 await staleSizes($)
687 }
688 const fresh = await get($)
689 const ignored = new Set(fresh.ignored)
690 const tones: Record<string, string> = {}
691 if (writes) {
692 const found = fresh.top
693 ? await changedInRepo($, t.root, since, before, fresh.ignored)
694 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
695 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
696 await revealPaths($, hits)
697 const loaded = await get($)
698 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
699 const listed = await loadDirs($, dirs)
700 if (!fresh.top && since.os === 'win32')
701 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
702 await patch($, cur => {
703 const ids = new Set(cur.nodes.map(n => n.id))
704 return { expanded: cur.expanded.filter(id => ids.has(id)) }
705 })
706 const present = new Set((await get($)).nodes.map(n => n.id))
707 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
708 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
709 }
710 if (showReads) {
711 const found: string[] = []
712 for (const r of reads) {
713 if (found.length >= READ_REVEAL_LIMIT) break
714 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
715 }
716 if (found.length) {
717 await revealPaths($, found)
718 const shown = new Set((await get($)).nodes.map(n => n.id))
719 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
720 }
721 }
722 if (showWrites && actions.length) {
723 const final = await get($)
724 const committed = actions.some(a => a.verb === 'commit')
725 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
726 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
727 await revealPaths($, paths)
728 const shown = new Set((await get($)).nodes.map(n => n.id))
729 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
730 }
731 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
732 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
733 else if (committed) tones[BRANCH_ROW] = 'green'
734 }
735 await flash($, tones)
736}
737
738function scheduleScan($: EngineInterface, job: Job): void {
739 scanJobs.push(job)
740 if (scanning) return
741 scanning = (async () => {
742 while (scanJobs.length) {
743 const jobs = scanJobs
744 scanJobs = []
745 try {
746 await afterBash($, jobs)
747 } finally {
748 const marks = jobs.map(j => j.since.mark).filter(Boolean)
749 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
750 }
751 }
752 })().finally(() => {
753 scanning = null
754 })
755}
756
757async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
758 if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
759 if (await followCwd($)) return
760 const t = await get($)
761 if (!t.root) return
762 const within = paths.map(posix).filter(p => inside(t.root, p))
763 if (within.length === 0) return
764 if (tone !== 'purple') {
765 searchIndex = null
766 await staleSizes($, within)
767 await revealPaths($, within.map(dirname))
768 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
769 await refreshGit($)
770 } else await revealPaths($, within)
771 if (!show) return
772 const tones: Record<string, string> = {}
773 for (const p of within) tones[p] = tone
774 await flash($, tones)
775}
776
777async function reveal($: EngineInterface, paths: string[]): Promise<void> {
778 if (await followCwd($)) return
779 await revealPaths($, paths)
780 await patch($, cur => {
781 const open = new Set(cur.expanded)
782 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
783 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
784 })
785}
786
787async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
788 const out: string[] = []
789 let level = [root]
790 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
791 const next: string[] = []
792 for (const dir of level) {
793 for (const n of (await list($, dir)) ?? []) {
794 if (n.kind === 'dir') {
795 if (!PRUNE.includes(n.name)) next.push(n.id)
796 } else out.push(n.id)
797 }
798 if (out.length >= limit) break
799 }
800 level = next
801 }
802 return out
803}
804
805async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
806 try {
807 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
808 const run = t.top
809 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
810 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
811 if (t.top && run.exitCode !== 0) return null
812 const paths = run.stdout.split('\0').filter(Boolean)
813 if (run.isStdoutTruncated) paths.pop()
814 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
815 } catch {
816 return null
817 }
818}
819
820function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
821 if (searchIndex?.root !== t.root) {
822 const index: { root: string; paths: Promise<string[]> } = {
823 root: t.root,
824 paths: listAll($, t).then(paths => {
825 if (paths === null && searchIndex === index) searchIndex = null
826 return paths ?? []
827 }),
828 }
829 searchIndex = index
830 }
831 return searchIndex.paths
832}
833
834async function search($: EngineInterface, query: string): Promise<string[]> {
835 if (!(await get($)).query.trim()) searchIndex = null
836 await patch($, () => ({ query }))
837 const q = query.trim().toLowerCase()
838 if (!q) return []
839 const t = await get($)
840 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
841 if ((await get($)).query !== query) return []
842 await revealPaths($, hits)
843 const shown = await get($)
844 const ids = new Set(shown.nodes.map(n => n.id))
845 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
846 if (stale.length) await loadDirs($, stale)
847 return hits
848}
849
850async function jump($: EngineInterface, query: string): Promise<void> {
851 const hits = (await search($, query)).slice(0, 10)
852 await patch($, cur => {
853 const open = new Set(cur.expanded)
854 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
855 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
856 })
857}
858
859async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
860 try {
861 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
862 return out === 'stale' || out === 'missing' ? out : 'ok'
863 } catch {
864 return 'missing'
865 }
866}
867
868async function finePointerOk($: EngineInterface): Promise<boolean> {
869 if (!(await $.env.get('HERDR_ENV'))) return true
870 try {
871 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
872 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
873 return major * 1e6 + minor * 1e3 + fix >= 9_001
874 } catch {
875 return false
876 }
877}
878
879const PREVIEW_CHARS = 90_000
880const PREVIEW_LINE = 2_000
881
882function fitSerialized(lines: string[], max: number): string[] {
883 let size = 0
884 for (let i = 0; i < lines.length; i++) {
885 size += JSON.stringify(lines[i]).length
886 if (size > max) return lines.slice(0, i)
887 }
888 return lines
889}
890
891async function loadPreview($: EngineInterface, path: string): Promise<void> {
892 let text = ''
893 let note = ''
894 try {
895 const stat = await $.fs.stat(path)
896 if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
897 else {
898 text = await $.fs.read(path)
899 if (text.slice(0, 8000).includes('\0')) {
900 text = ''
901 note = `binary file, ${formatSize(stat.size)}`
902 }
903 }
904 } catch (err) {
905 note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
906 }
907 const total = text.length
908 let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
909 if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
910 lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
911 const fit = fitSerialized(lines, PREVIEW_CHARS)
912 if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
913 previewed = { path, text: fit.join('\n'), note }
914}
915
916async function showPreview($: EngineInterface, path: string): Promise<void> {
917 await loadPreview($, path)
918 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
919}
920
921async function closePreview($: EngineInterface): Promise<void> {
922 await patch($, () => ({ preview: '', previewTop: 0 }))
923}
924
925async function scrollPreview($: EngineInterface, by: number): Promise<void> {
926 const t = await get($)
927 const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
928 const top = Math.max(0, Math.min(last, t.previewTop + by))
929 if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
930}
931
932function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
933 const lines = text.split('\n')
934 const from = Math.max(0, Math.min(top, lines.length - 1))
935 let shown = lines.slice(from, from + rows)
936 if (markdown) {
937 // Reopen a code fence the window starts inside, so the rest is not read as prose.
938 let fence = ''
939 let opener = ''
940 for (const line of lines.slice(0, from)) {
941 const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
942 if (!m) continue
943 const mark = m[1] ?? ''
944 if (!fence) {
945 fence = mark
946 opener = line.trimStart()
947 } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
948 }
949 if (fence) shown = [opener, ...shown]
950 }
951 return { text: shown.join('\n'), top: from }
952}
953
954async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
955 if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
956 const os = await osName($)
957 const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
958 const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
959 const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
960 try {
961 const run = await $.process.run(argv, init)
962 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
963 } catch {
964 $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
965 }
966}
967
968async function toggle($: EngineInterface, n: FileNode): Promise<void> {
969 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
970 await patch($, t => {
971 const open = new Set(t.expanded)
972 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
973 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
974 })
975}
976
977async function press($: EngineInterface, n: FileNode): Promise<void> {
978 const now = await $.clock.now()
979 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
980 lastPress = { key: isDouble ? '' : n.id, at: now }
981 if (!isDouble) {
982 await toggle($, n)
983 return
984 }
985 await openNode($, n)
986}
987
988async function openNode($: EngineInterface, n: FileNode): Promise<void> {
989 if (n.kind === 'dir') {
990 follow = false
991 await reset($, n.id)
992 } else await openFile($, n.id)
993}
994
995async function loadTheme($: EngineInterface): Promise<void> {
996 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
997 try {
998 const stat = await $.fs.stat(path)
999 if (stat.kind !== 'file') throw new Error('no theme file')
1000 if (stat.mtimeMs === themeMtime) return
1001 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1002 themeMtime = stat.mtimeMs
1003 } catch {
1004 if (themeMtime === 0) return
1005 await $.state.set(THEME, DEFAULT_THEME)
1006 themeMtime = 0
1007 }
1008}
1009
1010function shortPath(path: string): string {
1011 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1012}
1013
1014export const register: Register = (on, options) => {
1015 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
1016 const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
1017 showReads = activity.includes('reads')
1018 showWrites = activity.includes('writes')
1019 followClaude = options?.follow !== 'off'
1020 sizeDefault = options?.column === 'size'
1021 openWithSetting = typeof options?.open === 'string' ? options.open : ''
1022 noDock = options?.start === 'off'
1023 on('session.start', async ($, e, next) => {
1024 await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
1025 const windows = (await $.env.get('OS')) === 'Windows_NT'
1026 useDrives(windows)
1027 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1028 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1029 void (async () => {
1030 pointer = await finePointerOk($)
1031 try {
1032 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1033 noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
1034 } catch {
1035 noNerd = true
1036 }
1037 await loadTheme($)
1038 themePoll?.cancel()
1039 themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
1040 const t = await get($)
1041 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1042 if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1043 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1044 const cwd = await cwdOf($)
1045 if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
1046 else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
1047 })()
1048 return next(e)
1049 })
1050
1051 on('command.run', { command: 'filetree' }, async ($, e) => {
1052 // The layout checks are the terminal's: the desktop app docks the pane itself and
1053 // reports no fullscreen layout, so a session no terminal draws skips them.
1054 const terminal = await $.session.surfaces().then(s => s.includes('terminal'), () => true)
1055 if (terminal && !e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
1056 if (terminal && e.presentation.columns < 110) return { text: 'filetree shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /filetree.' }
1057 noDock = false
1058 const arg = (e.args ?? '').trim()
1059 const cwd = await cwdOf($)
1060 follow = !arg
1061 const root = arg ? resolve(cwd, arg, home) : cwd
1062 await reset($, root, true)
1063 return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
1064 })
1065
1066 on('tool.call', async ($, e, next) => {
1067 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash') return next(e)
1068 const command = e.tool === 'Bash' ? e.command : ''
1069 const cwd = e.tool === 'Bash' ? await cwdOf($) : ''
1070 const actions = gitActions(command)
1071 const quiet = !actions.length && readOnly(command)
1072 const since = await sinceNow($, e.tool === 'Bash' && !quiet)
1073 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1074 const head = before?.top ? await headOf($, before.root) : ''
1075 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1076 let result: Awaited<ReturnType<typeof next>>
1077 try {
1078 result = await next(e)
1079 } catch (err) {
1080 if (pending) await finishGit($, pending, failAll(pending))
1081 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1082 throw err
1083 }
1084 if (pending && result.deny) {
1085 const ids = pending.ids
1086 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1087 }
1088 if (result.deny || (result.isError && e.tool !== 'Bash')) {
1089 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1090 return result
1091 }
1092 if (e.tool === 'Bash') {
1093 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1094 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1095 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1096 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1097 else void (async () => {
1098 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1099 if (pending) await finishGit($, pending, results)
1100 queuedReads.push(...reads)
1101 scheduleScan($, { ...job, actions: proven(pending, results) })
1102 })()
1103 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1104 } else {
1105 const file =
1106 'file_path' in e && typeof e.file_path === 'string'
1107 ? e.file_path
1108 : 'notebook_path' in e && typeof e.notebook_path === 'string'
1109 ? e.notebook_path
1110 : ''
1111 if (file) void touched($, [file], 'orange', showWrites)
1112 }
1113 return result
1114 })
1115
1116 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1117 const result = await next(e)
1118 if (result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1119 void touched($, [posix(e.file_path)], 'purple', true)
1120 return result
1121 })
1122
1123 on('ui.message', async ($, e, next) => {
1124 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
1125 const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
1126 void sync($)
1127 const t = await get($)
1128 if (typeof data.copy === 'string') {
1129 await copyPath($, data.copy, Boolean(data.shift), e.surface)
1130 return {}
1131 }
1132 if (typeof data.scrollTo === 'number') {
1133 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
1134 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1135 return {}
1136 }
1137 if (typeof data.press === 'string') {
1138 const n = t.nodes.find(x => x.id === data.press)
1139 if (!n) return {}
1140 if (t.scroll === null) await patch($, () => ({ scroll: view.from }))
1141 if (data.ctrl || data.shift) await openNode($, n)
1142 else await press($, n)
1143 return {}
1144 }
1145 if (typeof data.key !== 'string') return {}
1146 if (t.preview) {
1147 const page = Math.max(1, previewRows - 2)
1148 const by: Record<string, number> = { up: -1, k: -1, down: 1, j: 1, pageup: -page, pagedown: page, home: -Infinity, end: Infinity }
1149 if (data.key === 'left' || data.key === 'h') await closePreview($)
1150 else if (data.key in by) await scrollPreview($, by[data.key] ?? 0)
1151 return {}
1152 }
1153 const rows = visibleRows(t)
1154 const at = rows.findIndex(r => r.node.id === t.cursor)
1155 const cur = rows[at]?.node
1156 const move = (d: number) => {
1157 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1158 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1159 }
1160 if (data.key === 'up' || data.key === 'k') await move(-1)
1161 else if (data.key === 'down' || data.key === 'j') await move(1)
1162 else if (data.key === 'pageup') await move(-10)
1163 else if (data.key === 'pagedown') await move(10)
1164 else if (data.key === 'home') await move(-rows.length)
1165 else if (data.key === 'end') await move(rows.length)
1166 else if (cur && (data.key === 'y' || data.key === 'Y')) await copyPath($, cur.id, data.key === 'Y' || Boolean(data.shift), e.surface)
1167 else if (cur && (data.key === 'right' || data.key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1168 else if (cur && (data.key === 'left' || data.key === 'h')) {
1169 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1170 else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1171 } else if (cur && data.key === 'return') await (cur.kind !== 'dir' ? openNode($, cur) : toggle($, cur))
1172 else if (cur && data.key === ' ') await toggle($, cur)
1173 return {}
1174 })
1175
1176 on('classic.SessionStart', async ($, e, next) => {
1177 const result = await next(e)
1178 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
1179 void (async () => {
1180 await loadTheme($)
1181 const root = follow || !lastRoot ? await cwdOf($) : lastRoot
1182 const t = await get($)
1183 if (t.root !== root || t.nodes.length === 0) await reset($, root)
1184 })()
1185 }
1186 const gd = await gitDir($, posix(e.cwd))
1187 return gd ? { ...result, watchPaths: [...(result.watchPaths ?? []), join(gd, 'index'), join(gd, 'HEAD')] } : result
1188 })
1189
1190 on('classic.CwdChanged', async ($, e, next) => {
1191 const result = await next(e)
1192 void followCwd($)
1193 return result
1194 })
1195
1196 on('classic.FileChanged', async ($, e, next) => {
1197 const result = await next(e)
1198 if (/[\\/]\.git[\\/]|[\\/](index|HEAD)$/.test(e.file_path)) $.clock.after(300, () => void sync($, true))
1199 return result
1200 })hooks/open.ts 68 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
41
42function words(command: string): string[] {
43 const out: string[] = []
44 let word: string | null = null
45 let quote = ''
46 for (const ch of command) {
47 if (quote) {
48 if (ch === quote) quote = ''
49 else word = (word ?? '') + ch
50 } else if (ch === '"' || ch === "'") {
51 quote = ch
52 word ??= ''
53 } else if (/\s/.test(ch)) {
54 if (word !== null) out.push(word)
55 word = null
56 } else word = (word ?? '') + ch
57 }
58 if (word !== null) out.push(word)
59 return out
60}
61
62export function openWithCommand(command: string, path: string, dir: string): string[] | null {
63 const argv = words(command)
64 if (!argv.length) return null
65 const filled = argv.map(w => w.replace(/\{(path|dir)\}/g, (_, key) => (key === 'path' ? path : dir)))
66 return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
67}
68hooks/git.ts 262 lines1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4 kind: string
5 verb: string
6 running: string
7 done: string
8 tone: string
9 icon: { nerd: string; plain: string }
10 init?: boolean
11}
12
13const ICON = {
14 commit: { nerd: '\u{f417}', plain: '●' },
15 push: { nerd: '\u{f0552}', plain: '↑' },
16 pull: { nerd: '\u{f0553}', plain: '↓' },
17 branch: { nerd: '\u{f418}', plain: '⑂' },
18 merge: { nerd: '\u{f419}', plain: '⑃' },
19 pr: { nerd: '\u{f407}', plain: '⇄' },
20 github: { nerd: '\u{f408}', plain: '◎' },
21 stash: { nerd: '\u{f01bc}', plain: '≡' },
22 undo: { nerd: '\u{f054c}', plain: '↶' },
23 tag: { nerd: '\u{f412}', plain: '⌖' },
24 git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28 orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29 green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#14532d', '#166534', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31 blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#1e3a8a', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32 purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33 cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#155e75', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34 red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#7f1d1d', '#991b1b', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
38 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
39 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
40 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
41 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
42 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
43 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
44 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
45 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
46 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
47 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
48 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
49 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
50 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
51 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
52 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
53 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
54 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
55 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
56 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
57 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
58}
59
60const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
61 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
62 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
63 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
64 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
65 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
66 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
67 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
68 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
69}
70
71const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
72
73function segments(command: string, seps: string[] = []): string[][] {
74 const out: string[][] = []
75 let cur: string[] = []
76 let tok = ''
77 let quote = ''
78 let has = false
79 const endTok = () => {
80 if (has) cur.push(tok)
81 tok = ''
82 has = false
83 }
84 const endSeg = () => {
85 endTok()
86 if (cur.length) out.push(cur)
87 cur = []
88 }
89 for (let i = 0; i < command.length; i++) {
90 const ch = command[i] ?? ''
91 if (quote) {
92 if (ch === quote) quote = ''
93 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
94 else tok += ch
95 } else if (ch === '"' || ch === "'") {
96 quote = ch
97 has = true
98 } else if (ch === '\\' && i + 1 < command.length) {
99 tok += command[++i]
100 has = true
101 } else if (ch === '#' && !has) {
102 while (i + 1 < command.length && command[i + 1] !== '\n') i++
103 } else if (ch === ' ' || ch === '\t') endTok()
104 else if (ch === '\n' || ch === ';') {
105 seps.push(';')
106 endSeg()
107 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
108 tok += ch
109 has = true
110 } else if (ch === '&' || ch === '|') {
111 const twice = command[i + 1] === ch
112 if (twice) i++
113 seps.push(twice ? ch + ch : ch)
114 endSeg()
115 } else {
116 tok += ch
117 has = true
118 }
119 }
120 endSeg()
121 if (!quote) return out
122 seps.push(';')
123 return command
124 .split(/&&|\|\||;|\||\n/)
125 .map(s => s.trim().split(/\s+/).filter(Boolean))
126 .filter(s => s.length > 0)
127}
128
129function stripGlobals(tokens: string[]): string[] {
130 const out = [...tokens]
131 if (out[0] === 'env') out.shift()
132 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
133 return out
134}
135
136function gitVerb(tokens: string[]): number {
137 let i = 1
138 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
139 if (tokens[i] === '-C' || tokens[i] === '-c') i++
140 i++
141 }
142 return i
143}
144
145function gitReadOnly(verb: string, rest: string[]): boolean {
146 if (!verb || READ_ONLY_GIT.has(verb)) return true
147 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
148 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
149 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
150 return false
151}
152
153export function chainOf(command: string): { size: number; and: boolean } {
154 const seps: string[] = []
155 const size = segments(command, seps).length
156 return { size, and: seps.every(s => s === '&&') }
157}
158
159export function gitActions(command: string): GitAction[] {
160 const out: GitAction[] = []
161 for (const raw of segments(command)) {
162 const tokens = stripGlobals(raw)
163 const head = tokens[0]?.split('/').pop()
164 if (head === 'git') {
165 const i = gitVerb(tokens)
166 const verb = tokens[i] ?? ''
167 const rest = tokens.slice(i + 1)
168 if (gitReadOnly(verb, rest)) continue
169 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
170 if (spec) out.push({ kind: `git ${verb}`, ...spec })
171 } else if (head === 'gh') {
172 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
173 const spec = GH_VERBS[pair]
174 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
175 }
176 }
177 return out
178}
179
180export const BRANCH_ICON = ICON.branch
181
182const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
183const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
184const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
185
186function tidy(path: string): string {
187 const s = path.replace(/\/+$/, '')
188 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
189}
190
191export function resolve(cwd: string, p: string, home = ''): string {
192 let path = posix(p)
193 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
194 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
195 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
196 for (const seg of path.slice(drive.length).split('/')) {
197 if (!seg || seg === '.') continue
198 if (seg === '..') {
199 if (parts.length > 1) parts.pop()
200 } else parts.push(seg)
201 }
202 return tidy(parts.join('/'))
203}
204
205const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
206
207export function readOnly(command: string): boolean {
208 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
209 if (/[^>]>[^>&]|>>/.test(bare)) return false
210 return segments(command).every(raw => {
211 const tokens = stripGlobals(raw)
212 const head = tokens[0]?.split('/').pop() ?? ''
213 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
214 if (head === 'git') {
215 const i = gitVerb(tokens)
216 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
217 }
218 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
219 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
220 return READERS.has(head)
221 })
222}
223
224export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
225 let cwd = sessionCwd
226 const out = new Set<string>()
227 let listsPaths = false
228 for (const raw of segments(command)) {
229 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
230 const head = tokens[0]?.split('/').pop() ?? ''
231 if (head === 'cd' && tokens[1]) {
232 cwd = resolve(cwd, tokens[1], home)
233 continue
234 }
235 if (!READERS.has(head)) continue
236 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
237 if (OUTPUT_PATHS.has(head)) listsPaths = true
238 const args = tokens.slice(1)
239 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
240 for (let i = 0; i < args.length; i++) {
241 const a = args[i] ?? ''
242 if (a.startsWith('-')) {
243 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
244 continue
245 }
246 if (skipPattern) {
247 skipPattern = false
248 continue
249 }
250 if (/[*?<>|]/.test(a)) continue
251 out.add(resolve(cwd, a, home))
252 }
253 }
254 if (listsPaths) {
255 for (const line of stdout.split('\n').slice(0, 400)) {
256 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
257 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
258 }
259 }
260 return [...out].slice(0, 60)
261}
262hooks/rows.tsx 166 lines1import type { ClientModule, RenderElement } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6 rows: RowSpec[]
7 active: string
8 activeBg: string
9 hoverBg: string
10 tones: Record<string, { bright: string[]; dim: string[] }>
11 spinner?: string[]
12 pointer?: boolean
13 bar?: { pos: number; size: number; thumb: string; track: string }
14 preview?: { text: string; path: string; startLine: number; markdown: boolean }
15}
16type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
17
18const TICK_MS = 90
19const TREE_BUDGET = 70_000
20const MAX_ROWS = 1_500
21const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
22
23function shimmer(i: number, phase: number, len: number, palette: string[]): string {
24 const band = ((phase * 1.6) % (len + 8)) - 4
25 const d = Math.abs(i - band)
26 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
27}
28
29const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
30 const { Box, Text } = surface.elements
31 let state = surface.state
32 if (state === undefined) {
33 state = { hover: -1, phase: 0, drag: false, ref: {} }
34 surface.setState(state)
35 }
36 let visible = props.rows.length
37 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
38 if (lit && !state.ref.stop) {
39 state.ref.stop = surface.every(TICK_MS, () => {
40 const cur = surface.state
41 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
42 })
43 } else if (!lit && state.ref.stop) {
44 state.ref.stop()
45 state.ref.stop = undefined
46 }
47 if (props.pointer === false) {
48 state.ref.unpoint?.()
49 state.ref.unpoint = undefined
50 } else state.ref.unpoint = surface.onPointer(e => {
51 const cur = surface.state ?? state
52 const span = Math.max(1, props.rows.length - 1)
53 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
54 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
55 if (e.type === 'up' || e.type === 'leave') {
56 surface.setState({ ...cur, drag: false })
57 return
58 }
59 if (e.type === 'down' || e.type === 'move') {
60 if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
61 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
62 return
63 }
64 }
65 if (e.type === 'leave' || e.y < 0 || e.y >= visible) {
66 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
67 return
68 }
69 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
70 const row = props.rows[e.y]
71 if (e.type === 'down' && e.button === 'right' && row?.id) {
72 surface.post({ copy: row.id, shift: Boolean(e.shift) })
73 return
74 }
75 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
76 let x = 0
77 for (const seg of row.left) {
78 const w = [...seg.t].length
79 if (seg.tab && e.x >= x && e.x < x + w) {
80 surface.post({ tab: seg.tab })
81 return
82 }
83 x += w
84 }
85 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
86 })
87 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
88 // The preview draws in this same element, so the focus a click gave the tree stays put.
89 if (props.preview) {
90 const { Markdown, Code } = surface.elements
91 const p = props.preview
92 return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
93 }
94 const frames = props.spinner?.length ? props.spinner : FRAMES
95 const draw = (s: Seg) => {
96 if (s.spin) {
97 return (
98 <Text color={s.c} bold={s.b}>
99 {s.t + (frames[state.phase % frames.length] ?? '')}
100 </Text>
101 )
102 }
103 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
104 if (!palette) {
105 return (
106 <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
107 {s.t}
108 </Text>
109 )
110 }
111 if (s.one) {
112 return (
113 <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
114 {s.t}
115 </Text>
116 )
117 }
118 const chars = [...s.t]
119 return (
120 <Text bold={s.b}>
121 {chars.map((ch, i) => (
122 <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
123 ))}
124 </Text>
125 )
126 }
127 const rowEl = (r: RowSpec, i: number) => (
128 <Box
129 flexDirection="row"
130 height={1}
131 overflow="hidden"
132 backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
133 >
134 <Box flexShrink={1} overflow="hidden">
135 {r.left.map(draw)}
136 </Box>
137 <Box flexGrow={1} />
138 {r.right.map(draw)}
139 {props.bar && (
140 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
141 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
142 </Text>
143 )}
144 </Box>
145 )
146 const drawn: RenderElement[] = []
147 let size = 0
148 for (let i = 0; i < props.rows.length; i++) {
149 const el = rowEl(props.rows[i] as RowSpec, i)
150 let cost = 1_000
151 try {
152 cost = JSON.stringify(el).length
153 } catch {}
154 if (size + cost > TREE_BUDGET || drawn.length >= MAX_ROWS) {
155 visible = i
156 drawn.push(<Text dimColor>{` … ${props.rows.length - i} more rows, collapse folders or use search`}</Text>)
157 break
158 }
159 size += cost
160 drawn.push(el)
161 }
162 return <Box flexDirection="column">{drawn}</Box>
163}
164
165export default Rows
166hooks/icons.ts 132 lines1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9 'agents.md': 0xf0354,
10 'changelog.md': 0xf0354,
11 'contributing.md': 0xf0354,
12 'todo.md': 0xf0354,
13 'readme.md': 0xf0354,
14 license: 0xe60a,
15 dockerfile: 0xf0868,
16 makefile: 0xf1064,
17 'cmakelists.txt': 0xf1064,
18 justfile: 0xf05b7,
19 gemfile: 0xf0d2d,
20 rakefile: 0xf0d2d,
21 'cargo.toml': 0xe6b2,
22 'cargo.lock': 0xe6b2,
23 'pyproject.toml': 0xe6b2,
24 'requirements.txt': 0xf160e,
25 'go.mod': 0xf0afa,
26 'go.sum': 0xf07d3,
27 'package.json': 0xf0626,
28 'package-lock.json': 0xf0626,
29 'tsconfig.json': 0xf0626,
30 '.prettierrc': 0xf0626,
31 '.eslintrc': 0xf0626,
32 'pnpm-lock.yaml': 0xe6a8,
33 'yarn.lock': 0xe6a8,
34 '.gitignore': 0xf02a2,
35 '.gitmodules': 0xf0493,
36 '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40 lua: 0xe620,
41 py: 0xe606,
42 pyi: 0xe606,
43 js: 0xe60c,
44 mjs: 0xe60c,
45 cjs: 0xe60c,
46 ts: 0xe628,
47 jsx: 0xe625,
48 tsx: 0xe7ba,
49 json: 0xe60b,
50 jsonc: 0xe60b,
51 yaml: 0xe6a8,
52 yml: 0xe6a8,
53 sh: 0xe795,
54 bash: 0xe795,
55 zsh: 0xe795,
56 fish: 0xe795,
57 md: 0xf0354,
58 markdown: 0xf0354,
59 css: 0xe6b8,
60 scss: 0xf031c,
61 html: 0xe736,
62 htm: 0xe736,
63 go: 0xe627,
64 rs: 0xe68b,
65 rb: 0xe791,
66 php: 0xe608,
67 java: 0xe738,
68 cs: 0xf031b,
69 sql: 0xe706,
70 graphql: 0xf20e,
71 gql: 0xf20e,
72 xml: 0xf05c0,
73 toml: 0xe6b2,
74 ini: 0xf0bc2,
75 conf: 0xf0493,
76 pdf: 0xeaeb,
77 svg: 0xf0721,
78 jpg: 0xf0225,
79 jpeg: 0xf0225,
80 png: 0xe60d,
81 gif: 0xf0d78,
82 webp: 0xf021f,
83 mp4: 0xf022b,
84 mkv: 0xf022b,
85 mov: 0xf022b,
86 mp3: 0xf0223,
87 wav: 0xf0223,
88 flac: 0xf0223,
89 zip: 0xf05c4,
90 gz: 0xf05c4,
91 xz: 0xf05c4,
92 tar: 0xf05c4,
93 qml: 0xf375,
94 cpp: 0xe61d,
95 cc: 0xe61d,
96 cxx: 0xe61d,
97 c: 0xe61e,
98 h: 0xf0af5,
99 hpp: 0xf0af5,
100 vue: 0xe6a0,
101 svelte: 0xe697,
102 csv: 0xe64a,
103 txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107 if (n.kind === 'link') return cp(0xf481)
108 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109 const lower = n.name.toLowerCase()
110 const named = NAMES[lower]
111 if (named) return cp(named)
112 const dot = lower.lastIndexOf('.')
113 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114 return cp(EXTS[ext] ?? 0xf0214)
115}
116
117export const GIT_COLOR: Record<string, string> = {
118 A: '#98c379',
119 '?': '#98c379',
120 R: '#61afef',
121 C: '#61afef',
122 M: '#e5c07b',
123 T: '#e5c07b',
124}
125
126const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
127
128export function stronger(a: string | undefined, b: string): string {
129 if (!a) return b
130 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
131}
132hooks/tree.ts 333 lines1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10 fg: '',
11 accent: '#5b9bd5',
12 muted: '#808a96',
13 urgent: '#d0605e',
14 selection: '#6b7280',
15 bg: '',
16}
17
18export function emptyTree(root: string): FileTree {
19 return {
20 root,
21 nodes: [],
22 expanded: [],
23 cursor: '',
24 selected: '',
25 query: '',
26 showHidden: true,
27 showSize: false,
28 dirSizes: {},
29 git: {},
30 diff: {},
31 ignored: [],
32 untrackedDirs: [],
33 top: '',
34 prefix: '',
35 branch: null,
36 counts: {},
37 flash: [],
38 flashDim: [],
39 flashOn: false,
40 flashTones: {},
41 scroll: null,
42 preview: '',
43 previewTop: 0,
44 }
45}
46
47export function parseTheme(toml: string): Theme {
48 const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
49 return {
50 fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
51 accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
52 muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
53 urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
54 selection: get('selection') ?? DEFAULT_THEME.selection,
55 bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
56 }
57}
58
59export function join(dir: string, name: string): string {
60 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
61}
62
63let drives = false
64
65export function dirname(path: string): string {
66 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
67 const i = path.lastIndexOf('/')
68 return i <= 0 ? '/' : path.slice(0, i)
69}
70
71export function useDrives(on: boolean): void {
72 drives = on
73}
74
75export function drivesOn(): boolean {
76 return drives
77}
78
79export function posix(path: string): string {
80 if (!drives) return path
81 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
82}
83
84export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
85 if (dirs.length === 0) return nodes
86 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
87 const gone = new Set(dirs)
88 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
89}
90
91export function isAbsolute(path: string): boolean {
92 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
93}
94
95export function inside(root: string, path: string): boolean {
96 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
97}
98
99export function toNodes(dir: string, list: Entry[]): FileNode[] {
100 return list
101 .map(e => ({
102 id: join(dir, e.name),
103 parent: dir,
104 name: e.name,
105 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
106 hidden: e.name.startsWith('.'),
107 mtime: e.mtimeMs,
108 size: e.size,
109 loaded: false,
110 }))
111 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
112}
113
114export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
115 if (listed.size === 0) return nodes
116 const keep = new Map<string, FileNode>()
117 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
118 const freshIds = new Set<string>()
119 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
120 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
121 const under = (id: string) => {
122 if (gone.size === 0) return false
123 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
124 return false
125 }
126 const fresh: FileNode[] = []
127 for (const kids of listed.values()) {
128 for (const k of kids) {
129 if (under(k.id)) continue
130 const old = keep.get(k.id)
131 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
132 }
133 }
134 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
135 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
136}
137
138export type GitStatus = {
139 git: Record<string, string>
140 ignored: string[]
141 untrackedDirs: string[]
142 branch: Branch | null
143 files: Record<string, Change>
144}
145
146export type Change = 'new' | 'mod' | 'del'
147
148export function parseBranch(record: string): Branch {
149 const body = record.replace(/^## /, '')
150 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
151 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
152 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
153 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
154 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
155 return { head, upstream, ahead, behind }
156}
157
158export function mapper(root: string, prefix: string): (rel: string) => string | null {
159 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
160}
161
162export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
163 const map = mapper(root, prefix)
164 const git: Record<string, string> = {}
165 const ignored: string[] = []
166 const untrackedDirs: string[] = []
167 const files: Record<string, Change> = {}
168 let branch: Branch | null = null
169 const parts = stdout.split('\0')
170 for (let i = 0; i < parts.length; i++) {
171 const rec = parts[i] ?? ''
172 if (rec.startsWith('## ')) {
173 branch = parseBranch(rec)
174 continue
175 }
176 if (rec.length < 4) continue
177 const xy = rec.slice(0, 2)
178 const raw = rec.slice(3)
179 if (xy[0] === 'R' || xy[0] === 'C') i++
180 const path = map(raw)
181 if (!path) continue
182 if (xy === '!!') {
183 ignored.push(path)
184 continue
185 }
186 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
187 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
188 const letter =
189 xy === '??'
190 ? '?'
191 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
192 ? 'U'
193 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
194 if (!letter) continue
195 git[path] = stronger(git[path], letter)
196 let dir = dirname(path)
197 while (dir.length >= root.length && dir !== '/') {
198 git[dir] = stronger(git[dir], letter)
199 if (dir === root) break
200 dir = dirname(dir)
201 }
202 }
203 return { git, ignored, untrackedDirs, branch, files }
204}
205
206export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
207 const map = mapper(root, prefix)
208 const parts = stdout.split('\0')
209 for (let i = 0; i < parts.length; i++) {
210 const rec = parts[i] ?? ''
211 if (!rec) continue
212 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
213 if (!m) continue
214 let path = m[3] ?? ''
215 if (path === '') {
216 i += 2
217 path = parts[i] ?? ''
218 }
219 if (!path) continue
220 const add = m[1] === '-' ? 0 : Number(m[1])
221 const del = m[2] === '-' ? 0 : Number(m[2])
222 const abs = map(path)
223 if (!abs) continue
224 const prev = into[abs] ?? [0, 0]
225 into[abs] = [prev[0] + add, prev[1] + del]
226 }
227}
228
229export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
230 const out: Record<string, [number, number]> = { ...diff }
231 for (const [path, [add, del]] of Object.entries(diff)) {
232 let dir = dirname(path)
233 while (dir.length >= root.length && dir !== '/') {
234 const prev = out[dir] ?? [0, 0]
235 out[dir] = [prev[0] + add, prev[1] + del]
236 if (dir === root) break
237 dir = dirname(dir)
238 }
239 }
240 return out
241}
242
243export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
244 const out: Record<string, [number, number, number]> = {}
245 const slot = { new: 0, mod: 1, del: 2 } as const
246 for (const [path, change] of Object.entries(files)) {
247 let dir = dirname(path)
248 while (dir.length >= root.length && dir !== '/') {
249 const cur = out[dir] ?? [0, 0, 0]
250 cur[slot[change]] += 1
251 out[dir] = cur
252 if (dir === root) break
253 dir = dirname(dir)
254 }
255 }
256 return out
257}
258
259export function underAny(id: string, set: Set<string>, root: string): boolean {
260 if (set.size === 0) return false
261 let cur = id
262 while (cur.length >= root.length) {
263 if (set.has(cur)) return true
264 const up = dirname(cur)
265 if (up === cur) break
266 cur = up
267 }
268 return false
269}
270
271export function ancestorsOf(id: string, root: string): string[] {
272 const out: string[] = []
273 if (!root) return out
274 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
275 return out
276}
277
278export function relative(root: string, path: string): string {
279 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
280}
281
282export function visibleRows(t: FileTree): Row[] {
283 const kids = new Map<string, FileNode[]>()
284 for (const n of t.nodes) {
285 if (!t.showHidden && n.hidden) continue
286 const list = kids.get(n.parent)
287 if (list) list.push(n)
288 else kids.set(n.parent, [n])
289 }
290 const q = t.query.trim().toLowerCase()
291 let keep: Set<string> | null = null
292 if (q) {
293 keep = new Set()
294 for (const n of t.nodes) {
295 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
296 keep.add(n.id)
297 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
298 }
299 }
300 const open = new Set(t.expanded)
301 const rows: Row[] = []
302 const walk = (dir: string, depth: number) => {
303 for (const n of kids.get(dir) ?? []) {
304 if (keep && !keep.has(n.id)) continue
305 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
306 rows.push({ node: n, depth, open: isOpen })
307 if (isOpen) walk(n.id, depth + 1)
308 }
309 }
310 walk(t.root, 0)
311 return rows
312}
313
314export function stamp(ms: number): string {
315 if (!ms) return ''
316 const d = new Date(ms)
317 const p = (v: number) => String(v).padStart(2, '0')
318 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
319}
320
321export function formatSize(bytes: number): string {
322 if (bytes < 0) return '?'
323 if (bytes < 1024) return `${bytes} B`
324 const units = ['K', 'M', 'G', 'T']
325 let v = bytes / 1024
326 let i = 0
327 while (v >= 1024 && i < units.length - 1) {
328 v /= 1024
329 i++
330 }
331 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
332}
333types/index.d.ts 76 lines1export type FileNode = {
2 id: string
3 parent: string
4 name: string
5 kind: 'dir' | 'file' | 'link'
6 hidden: boolean
7 mtime: number
8 size: number
9 loaded: boolean
10}
11
12export type Branch = {
13 head: string
14 upstream: string
15 ahead: number
16 behind: number
17}
18
19export type Activity = {
20 id: number
21 kind: string
22 label: string
23 state: 'running' | 'done' | 'failed'
24 detail: string
25 at: number
26 tone: string
27 nerd: string
28 plain: string
29}
30
31export type FileTree = {
32 root: string
33 nodes: FileNode[]
34 expanded: string[]
35 cursor: string
36 selected: string
37 query: string
38 showHidden: boolean
39 showSize: boolean
40 dirSizes: Record<string, number>
41 git: Record<string, string>
42 diff: Record<string, [number, number]>
43 ignored: string[]
44 untrackedDirs: string[]
45 top: string
46 prefix: string
47 branch: Branch | null
48 counts: Record<string, [number, number, number]>
49 flash: string[]
50 flashDim: string[]
51 flashOn: boolean
52 flashTones: Record<string, string>
53 scroll: number | null
54 preview: string
55 previewTop: number
56}
57
58export type Theme = {
59 fg: string
60 accent: string
61 muted: string
62 urgent: string
63 selection: string
64 bg: string
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 filetree: {
70 tree: FileTree
71 theme: Theme
72 activity: Activity[]
73 }
74 }
75}
76