An IDE-style file tree for Claude Code that shows what Claude is doing and where in files

<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.
In a Claude Code terminal session:
/plugin install filetree --marketplace whykothari/whystack
See the mods README to run it from a local copy.
/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.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.
Run these from the mods folder before each commit that touches the mod:
claude plugin validate filetree
claude plugin test filetree
Based on Claude Code Filetree by Kurt Buhler, MIT licence. It drew on his Omarchy app FileBlade. Outside contributions to the original are in ACKNOWLEDGEMENTS.md.
hooks/register.tsx 1561 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 READ_REVEAL_LIMIT = 12
47const SEARCH_REVEAL_LIMIT = 60
48const ACTIVITY_TTL_MS = 45_000
49const ADD_COLOR = '#98c379'
50const DEL_COLOR = '#e06c75'
51const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
52const THEME_POLL_MS = 2000
53const FONT_SCRIPT =
54 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
55 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
56 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
57 '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 ' +
58 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
59 '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; ' +
60 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
61const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
62const HOME_PRUNE = ['Library', 'AppData', '.Trash']
63const SIZE_TIMEOUT_MS = 15_000
64const SIZE_JOBS = 2
65const SIZE_WALK_LIMIT = 50_000
66
67let blink: Timer | null = null
68let themePoll: Timer | null = null
69let themeMtime: number | null = null
70let generation = 0
71let lastPress = { key: '', at: 0 }
72let noNerd = false
73let glyphSetting = 'auto'
74let openWithSetting = ''
75let previewed: { path: string; text: string; note: string } | null = null
76let previewRows = 20
77let follow = true
78let followClaude = true
79let scanning: Promise<void> | null = null
80let scanJobs: Job[] = []
81let gitRun: Promise<void> | null = null
82let gitNext: Promise<void> | null = null
83let queuedReads: string[] = []
84let showReads = true
85let showWrites = true
86let searchIndex: { root: string; paths: Promise<string[]> } | null = null
87let activityId = 0
88let pointer = true
89let view = { from: 0, max: 0 }
90let nativeScroll = false
91let lastSync = 0
92let noDock = false
93let home = ''
94let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
95let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
96let markId = 0
97let listSeq = 0
98let lastRoot = ''
99let sizeDefault = false
100let sizeEpoch = 0
101let sizeActive = 0
102let sizeQueue: string[] = []
103const sizing = new Set<string>()
104const listLatest = new Map<string, number>()
105const unreadable = new Set<string>()
106const background = new Map<string, Background>()
107
108function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
109 platform ??= (async () => {
110 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
111 try {
112 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
113 } catch {
114 return 'linux'
115 }
116 })()
117 return platform
118}
119
120async function cwdOf($: EngineInterface): Promise<string> {
121 return posix(await $.session.cwd())
122}
123
124function clean(segs: Seg[]): Seg[] {
125 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
126 return segs
127}
128
129function faint(hex: string): string {
130 const v = parseInt(hex.slice(1), 16)
131 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
132 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
133}
134
135async function get($: EngineInterface): Promise<FileTree> {
136 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
137}
138
139async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
140 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
141}
142
143function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
144 return put($, t => ({ ...t, ...fn(t) }))
145}
146
147async function activities($: EngineInterface): Promise<Activity[]> {
148 return (await $.state.get(ACTIVITY)).value ?? []
149}
150
151async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
152 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
153}
154
155async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
156 try {
157 const entries = await $.fs.list(dir)
158 const resolved = await Promise.all(
159 entries.map(async e => {
160 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
161 try {
162 const target = await $.fs.stat(join(dir, e.name))
163 return target.kind === 'dir'
164 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
165 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
166 } catch {
167 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
168 }
169 }),
170 )
171 unreadable.delete(dir)
172 return toNodes(dir, resolved)
173 } catch (err) {
174 if (!(await exists($, dir))) return []
175 if (!unreadable.has(dir)) {
176 unreadable.add(dir)
177 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
178 }
179 return null
180 }
181}
182
183async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
184 const root = (await get($)).root
185 const seq = ++listSeq
186 for (const dir of dirs) listLatest.set(dir, seq)
187 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
188 const listed = new Map<string, FileNode[]>()
189 for (const [dir, kids] of results) {
190 if (listLatest.get(dir) !== seq) continue
191 listLatest.delete(dir)
192 if (kids) listed.set(dir, kids)
193 }
194 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
195 return listed
196}
197
198async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
199 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
200}
201
202async function detectRepo($: EngineInterface): Promise<void> {
203 const t = await get($)
204 if (!t.root) return
205 let top = ''
206 let prefix = ''
207 try {
208 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
209 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
210 prefix = p.trim()
211 top = tl.trim()
212 } catch {
213 top = ''
214 }
215 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
216}
217
218async function readGit($: EngineInterface): Promise<void> {
219 const t = await get($)
220 if (!t.root || !t.top) return
221 const root = t.root
222 try {
223 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
224 if (status.exitCode !== 0) return
225 const parsed = parseGit(status.stdout, root, t.prefix)
226 const diff: Record<string, [number, number]> = {}
227 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
228 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
229 const files = { ...parsed.files }
230 if (parsed.untrackedDirs.length) {
231 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
232 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
233 }
234 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
235 dirty = { root, files }
236 await patch($, cur =>
237 cur.root === root
238 ? {
239 git: parsed.git,
240 ignored: parsed.ignored,
241 untrackedDirs: parsed.untrackedDirs,
242 branch: parsed.branch,
243 counts: rollCounts(files, root),
244 diff: rollUp(diff, root),
245 }
246 : {},
247 )
248 } catch {
249 return
250 }
251}
252
253function refreshGit($: EngineInterface): Promise<void> {
254 if (!gitRun) {
255 gitRun = readGit($).finally(() => {
256 gitRun = null
257 })
258 return gitRun
259 }
260 const rerun = () => {
261 gitNext = null
262 return refreshGit($)
263 }
264 gitNext ??= gitRun.then(rerun, rerun)
265 return gitNext
266}
267
268async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
269 const prev = await get($)
270 generation += 1
271 blink?.cancel()
272 blink = null
273 lastRoot = root
274 searchIndex = null
275 sizeEpoch += 1
276 sizeQueue = []
277 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
278 const title = `Files: ${root.split('/').pop() || root}`
279 if (focus) await $.ui.open({ id: PANE, title, focus: true })
280 else if (!noDock) await $.ui.open({ id: PANE, title })
281 await loadDirs($, [root])
282 await detectRepo($)
283 await refreshGit($)
284}
285
286async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
287 const tried = new Set<string>()
288 for (let pass = 0; pass < 32; pass++) {
289 const t = await get($)
290 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
291 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
292 const need = new Set<string>()
293 for (const p of paths) {
294 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
295 if (!loaded.has(dir)) {
296 if (!tried.has(dir)) need.add(dir)
297 break
298 }
299 }
300 }
301 if (need.size === 0) return
302 for (const dir of need) tried.add(dir)
303 await loadDirs($, [...need])
304 }
305}
306
307function pruneArgs(root: string, ignored: string[] = []): string[] {
308 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
309 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
310}
311
312async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
313 const stats = await Promise.all(
314 paths.map(async p => {
315 try {
316 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
317 } catch {
318 return ''
319 }
320 }),
321 )
322 return stats.filter(Boolean)
323}
324
325function broad(root: string): boolean {
326 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
327}
328
329function rootOfDisk(root: string): boolean {
330 return root === '/' || /^[A-Za-z]:\/$/.test(root)
331}
332
333async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
334 if (rootOfDisk(root)) return []
335 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
336 try {
337 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 })
338 return run.stdout.split('\0').filter(p => p && p !== root)
339 } catch {
340 return []
341 }
342}
343
344async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
345 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
346 if (dirty.root !== root) return { hits: [], gone: [] }
347 const entries = Object.entries(dirty.files)
348 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
349 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
350 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
351}
352
353type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
354
355async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
356 const ms = await $.clock.now()
357 const os = await osName($)
358 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
359 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
360 try {
361 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
362 return { ms, mark, os }
363 } catch {
364 return { ms, mark: '', os }
365 }
366}
367
368async function walkSize($: EngineInterface, dir: string): Promise<number> {
369 let total = 0
370 let seen = 0
371 let level = [dir]
372 while (level.length) {
373 const next: string[] = []
374 for (const d of level) {
375 let entries
376 try {
377 entries = await $.fs.list(d)
378 } catch {
379 continue
380 }
381 for (const e of entries) {
382 if (++seen > SIZE_WALK_LIMIT) return -1
383 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
384 else if (e.kind === 'file') total += e.size
385 }
386 }
387 level = next
388 }
389 return total
390}
391
392async function dirSize($: EngineInterface, dir: string): Promise<number> {
393 if (rootOfDisk(dir)) return -1
394 if ((await osName($)) === 'win32') return walkSize($, dir)
395 try {
396 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
397 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
398 return Number.isFinite(kb) ? kb * 1024 : -1
399 } catch {
400 return -1
401 }
402}
403
404function pumpSizes($: EngineInterface): void {
405 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
406 const dir = sizeQueue.shift() ?? ''
407 const epoch = sizeEpoch
408 sizing.add(dir)
409 sizeActive += 1
410 void dirSize($, dir)
411 .then(async bytes => {
412 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
413 const cur = await get($)
414 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
415 })
416 .catch(() => undefined)
417 .finally(() => {
418 sizing.delete(dir)
419 sizeActive -= 1
420 pumpSizes($)
421 })
422 }
423}
424
425function wantSizes($: EngineInterface, dirs: string[]): void {
426 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
427 pumpSizes($)
428}
429
430async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
431 sizeEpoch += 1
432 sizeQueue = []
433 await patch($, cur => {
434 if (!paths) return { dirSizes: {} }
435 const keep: Record<string, number> = {}
436 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
437 return { dirSizes: keep }
438 })
439}
440
441function openDirs(t: FileTree): string[] {
442 const open = new Set(t.expanded)
443 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
444}
445
446function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
447 if (followClaude) return { scroll: null }
448 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
449 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
450 return at < 0 ? {} : { scroll: at }
451}
452
453async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
454 const unique = Object.keys(tones)
455 if (unique.length === 0) return
456 const mine = ++generation
457 const root = (await get($)).root
458 blink?.cancel()
459 blink = null
460 await patch($, cur => {
461 if (cur.root !== root) return {}
462 const open = new Set(cur.expanded)
463 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
464 const dim = new Set<string>()
465 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
466 if (cur.flashOn) {
467 for (const id of cur.flashDim) dim.add(id)
468 }
469 for (const id of unique) {
470 const tone = tones[id] ?? 'orange'
471 all[id] = tone
472 if (id === BRANCH_ROW) continue
473 const chain = ancestorsOf(id, cur.root)
474 if (!chain.some(a => !open.has(a))) continue
475 for (const a of chain) {
476 if (!open.has(a)) {
477 dim.add(a)
478 all[a] = all[a] ?? tone
479 }
480 open.add(a)
481 }
482 }
483 for (const id of bright) dim.delete(id)
484 return {
485 flash: [...bright],
486 flashDim: [...dim],
487 flashOn: true,
488 flashTones: all,
489 expanded: [...open],
490 ...keepTop(cur, [...open]),
491 }
492 })
493 if (generation !== mine) return
494 blink = $.clock.after(FLASH_MS, () => {
495 if (generation !== mine) return
496 blink = null
497 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
498 })
499}
500
501async function followCwd($: EngineInterface): Promise<boolean> {
502 if (!follow) return false
503 const cwd = await cwdOf($)
504 const t = await get($)
505 if (t.root === cwd) return false
506 await reset($, cwd)
507 return true
508}
509
510type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
511type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
512type Background = { p: Pending | null; job: Job; reads: string[] }
513type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
514type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
515
516const PROOF: Record<string, (op: GitOperation) => boolean> = {
517 push: op => Boolean(op.push),
518 merge: op => op.branch?.action === 'merged',
519 rebase: op => op.branch?.action === 'rebased',
520 'pr create': op => op.pr?.action === 'created',
521 'pr merge': op => op.pr?.action === 'merged',
522 'pr comment': op => op.pr?.action === 'commented',
523}
524
525async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
526 const now = await $.clock.now()
527 const ids = actions.map(() => ++activityId)
528 await setActivities($, cur => [
529 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
530 ...actions.map((a, i) => ({
531 id: ids[i] ?? 0,
532 kind: a.kind,
533 label: a.running,
534 state: 'running' as const,
535 detail: '',
536 at: now,
537 tone: a.tone,
538 nerd: a.icon.nerd,
539 plain: a.icon.plain,
540 })),
541 ])
542 return ids
543}
544
545async function headOf($: EngineInterface, cwd: string): Promise<string> {
546 try {
547 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
548 return run.exitCode === 0 ? run.stdout.trim() : ''
549 } catch {
550 return ''
551 }
552}
553
554async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
555 const chain = chainOf(p.command)
556 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
557 return p.actions.map(a => {
558 if (a.verb === 'commit') {
559 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
560 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
561 }
562 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
563 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
564 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
565 })
566}
567
568async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
569 const now = await $.clock.now()
570 await setActivities($, cur =>
571 cur.map(a => {
572 const i = p.ids.indexOf(a.id)
573 const result = i < 0 ? undefined : results[i]
574 return result ? { ...a, ...result, at: now } : a
575 }),
576 )
577 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
578}
579
580function failAll(p: Pending): Outcome[] {
581 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
582}
583
584function proven(p: Pending | null, results: Outcome[]): GitAction[] {
585 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
586}
587
588function taskEnds(text: string): [string, string][] {
589 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
590 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
591 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
592 return id && status ? [[id, status] as [string, string]] : []
593 })
594}
595
596async function settleBackground($: EngineInterface, text: string): Promise<void> {
597 if (background.size === 0 || !text.includes('<task-notification>')) return
598 for (const [id, status] of taskEnds(text)) {
599 const task = background.get(id)
600 if (!task) continue
601 background.delete(id)
602 const ok = status === 'completed'
603 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
604 if (task.p) await finishGit($, task.p, results)
605 if (showReads) queuedReads.push(...task.reads)
606 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
607 }
608}
609
610async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
611 const map = mapper(root, prefix)
612 try {
613 const run = committed
614 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
615 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
616 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
617 } catch {
618 return []
619 }
620}
621
622async function gitDir($: EngineInterface, cwd: string): Promise<string> {
623 for (let dir = cwd, i = 0; i < 64; i++) {
624 try {
625 const dot = join(dir, '.git')
626 const st = await $.fs.stat(dot)
627 if (st.kind === 'dir') return dot
628 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
629 if (ref) return resolve(dir, ref, home)
630 } catch {
631 const up = dirname(dir)
632 if (up === dir) return ''
633 dir = up
634 continue
635 }
636 return ''
637 }
638 return ''
639}
640
641async function sync($: EngineInterface, force = false): Promise<void> {
642 const now = await $.clock.now()
643 if (!force && now - lastSync < 2_000) return
644 lastSync = now
645 searchIndex = null
646 const t = await get($)
647 if (!t.root) return
648 await loadDirs($, [t.root, ...openDirs(t)])
649 if (t.top) await refreshGit($)
650}
651
652async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
653 const t = await get($)
654 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
655 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
656 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
657}
658
659async function exists($: EngineInterface, path: string): Promise<boolean> {
660 try {
661 await $.fs.stat(path)
662 return true
663 } catch {
664 return false
665 }
666}
667
668async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
669 const reads = queuedReads
670 queuedReads = []
671 if (await followCwd($)) return
672 const t = await get($)
673 if (!t.root) return
674 const actions = jobs.flatMap(j => j.actions)
675 const writers = jobs.filter(j => !j.readOnly)
676 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 })
677 const writes = !jobs.every(j => j.readOnly)
678 const before = dirty.root === t.root ? dirty.files : {}
679 if (jobs.some(j => j.initRepo)) await detectRepo($)
680 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
681 const probed = await get($)
682 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
683 if (writes) {
684 searchIndex = null
685 await staleSizes($)
686 }
687 const fresh = await get($)
688 const ignored = new Set(fresh.ignored)
689 const tones: Record<string, string> = {}
690 if (writes) {
691 const found = fresh.top
692 ? await changedInRepo($, t.root, since, before, fresh.ignored)
693 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
694 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
695 await revealPaths($, hits)
696 const loaded = await get($)
697 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
698 const listed = await loadDirs($, dirs)
699 if (!fresh.top && since.os === 'win32')
700 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
701 await patch($, cur => {
702 const ids = new Set(cur.nodes.map(n => n.id))
703 return { expanded: cur.expanded.filter(id => ids.has(id)) }
704 })
705 const present = new Set((await get($)).nodes.map(n => n.id))
706 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
707 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
708 }
709 if (showReads) {
710 const found: string[] = []
711 for (const r of reads) {
712 if (found.length >= READ_REVEAL_LIMIT) break
713 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
714 }
715 if (found.length) {
716 await revealPaths($, found)
717 const shown = new Set((await get($)).nodes.map(n => n.id))
718 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
719 }
720 }
721 if (showWrites && actions.length) {
722 const final = await get($)
723 const committed = actions.some(a => a.verb === 'commit')
724 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
725 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
726 await revealPaths($, paths)
727 const shown = new Set((await get($)).nodes.map(n => n.id))
728 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
729 }
730 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
731 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
732 else if (committed) tones[BRANCH_ROW] = 'green'
733 }
734 await flash($, tones)
735}
736
737function scheduleScan($: EngineInterface, job: Job): void {
738 scanJobs.push(job)
739 if (scanning) return
740 scanning = (async () => {
741 while (scanJobs.length) {
742 const jobs = scanJobs
743 scanJobs = []
744 try {
745 await afterBash($, jobs)
746 } finally {
747 const marks = jobs.map(j => j.since.mark).filter(Boolean)
748 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
749 }
750 }
751 })().finally(() => {
752 scanning = null
753 })
754}
755
756async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
757 if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
758 if (await followCwd($)) return
759 const t = await get($)
760 if (!t.root) return
761 const within = paths.map(posix).filter(p => inside(t.root, p))
762 if (within.length === 0) return
763 if (tone !== 'purple') {
764 searchIndex = null
765 await staleSizes($, within)
766 await revealPaths($, within.map(dirname))
767 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
768 await refreshGit($)
769 } else await revealPaths($, within)
770 if (!show) return
771 const tones: Record<string, string> = {}
772 for (const p of within) tones[p] = tone
773 await flash($, tones)
774}
775
776async function reveal($: EngineInterface, paths: string[]): Promise<void> {
777 if (await followCwd($)) return
778 await revealPaths($, paths)
779 await patch($, cur => {
780 const open = new Set(cur.expanded)
781 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
782 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
783 })
784}
785
786async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
787 const out: string[] = []
788 let level = [root]
789 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
790 const next: string[] = []
791 for (const dir of level) {
792 for (const n of (await list($, dir)) ?? []) {
793 if (n.kind === 'dir') {
794 if (!PRUNE.includes(n.name)) next.push(n.id)
795 } else out.push(n.id)
796 }
797 if (out.length >= limit) break
798 }
799 level = next
800 }
801 return out
802}
803
804async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
805 try {
806 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
807 const run = t.top
808 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
809 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
810 if (t.top && run.exitCode !== 0) return null
811 const paths = run.stdout.split('\0').filter(Boolean)
812 if (run.isStdoutTruncated) paths.pop()
813 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
814 } catch {
815 return null
816 }
817}
818
819function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
820 if (searchIndex?.root !== t.root) {
821 const index: { root: string; paths: Promise<string[]> } = {
822 root: t.root,
823 paths: listAll($, t).then(paths => {
824 if (paths === null && searchIndex === index) searchIndex = null
825 return paths ?? []
826 }),
827 }
828 searchIndex = index
829 }
830 return searchIndex.paths
831}
832
833async function search($: EngineInterface, query: string): Promise<string[]> {
834 if (!(await get($)).query.trim()) searchIndex = null
835 await patch($, () => ({ query }))
836 const q = query.trim().toLowerCase()
837 if (!q) return []
838 const t = await get($)
839 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
840 if ((await get($)).query !== query) return []
841 await revealPaths($, hits)
842 const shown = await get($)
843 const ids = new Set(shown.nodes.map(n => n.id))
844 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
845 if (stale.length) await loadDirs($, stale)
846 return hits
847}
848
849async function jump($: EngineInterface, query: string): Promise<void> {
850 const hits = (await search($, query)).slice(0, 10)
851 await patch($, cur => {
852 const open = new Set(cur.expanded)
853 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
854 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
855 })
856}
857
858async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
859 try {
860 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
861 return out === 'stale' || out === 'missing' ? out : 'ok'
862 } catch {
863 return 'missing'
864 }
865}
866
867async function finePointerOk($: EngineInterface): Promise<boolean> {
868 if (!(await $.env.get('HERDR_ENV'))) return true
869 try {
870 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
871 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
872 return major * 1e6 + minor * 1e3 + fix >= 9_001
873 } catch {
874 return false
875 }
876}
877
878const PREVIEW_CHARS = 90_000
879const PREVIEW_LINE = 2_000
880
881function fitSerialized(lines: string[], max: number): string[] {
882 let size = 0
883 for (let i = 0; i < lines.length; i++) {
884 size += JSON.stringify(lines[i]).length
885 if (size > max) return lines.slice(0, i)
886 }
887 return lines
888}
889
890async function loadPreview($: EngineInterface, path: string): Promise<void> {
891 let text = ''
892 let note = ''
893 try {
894 const stat = await $.fs.stat(path)
895 if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
896 else {
897 text = await $.fs.read(path)
898 if (text.slice(0, 8000).includes('\0')) {
899 text = ''
900 note = `binary file, ${formatSize(stat.size)}`
901 }
902 }
903 } catch (err) {
904 note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
905 }
906 const total = text.length
907 let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
908 if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
909 lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
910 const fit = fitSerialized(lines, PREVIEW_CHARS)
911 if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
912 previewed = { path, text: fit.join('\n'), note }
913}
914
915async function showPreview($: EngineInterface, path: string): Promise<void> {
916 await loadPreview($, path)
917 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
918}
919
920async function closePreview($: EngineInterface): Promise<void> {
921 await patch($, () => ({ preview: '', previewTop: 0 }))
922}
923
924async function scrollPreview($: EngineInterface, by: number): Promise<void> {
925 const t = await get($)
926 const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
927 const top = Math.max(0, Math.min(last, t.previewTop + by))
928 if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
929}
930
931function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
932 const lines = text.split('\n')
933 const from = Math.max(0, Math.min(top, lines.length - 1))
934 let shown = lines.slice(from, from + rows)
935 if (markdown) {
936 // Reopen a code fence the window starts inside, so the rest is not read as prose.
937 let fence = ''
938 let opener = ''
939 for (const line of lines.slice(0, from)) {
940 const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
941 if (!m) continue
942 const mark = m[1] ?? ''
943 if (!fence) {
944 fence = mark
945 opener = line.trimStart()
946 } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
947 }
948 if (fence) shown = [opener, ...shown]
949 }
950 return { text: shown.join('\n'), top: from }
951}
952
953async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
954 if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
955 const os = await osName($)
956 const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
957 const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
958 const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
959 try {
960 const run = await $.process.run(argv, init)
961 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
962 } catch {
963 $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
964 }
965}
966
967async function toggle($: EngineInterface, n: FileNode): Promise<void> {
968 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
969 await patch($, t => {
970 const open = new Set(t.expanded)
971 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
972 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
973 })
974}
975
976async function press($: EngineInterface, n: FileNode): Promise<void> {
977 const now = await $.clock.now()
978 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
979 lastPress = { key: isDouble ? '' : n.id, at: now }
980 if (!isDouble) {
981 await toggle($, n)
982 return
983 }
984 await openNode($, n)
985}
986
987async function openNode($: EngineInterface, n: FileNode): Promise<void> {
988 if (n.kind === 'dir') {
989 follow = false
990 await reset($, n.id)
991 } else await openFile($, n.id)
992}
993
994async function loadTheme($: EngineInterface): Promise<void> {
995 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
996 try {
997 const stat = await $.fs.stat(path)
998 if (stat.kind !== 'file') throw new Error('no theme file')
999 if (stat.mtimeMs === themeMtime) return
1000 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1001 themeMtime = stat.mtimeMs
1002 } catch {
1003 if (themeMtime === 0) return
1004 await $.state.set(THEME, DEFAULT_THEME)
1005 themeMtime = 0
1006 }
1007}
1008
1009function shortPath(path: string): string {
1010 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1011}
1012
1013export const register: Register = (on, options) => {
1014 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
1015 const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
1016 showReads = activity.includes('reads')
1017 showWrites = activity.includes('writes')
1018 followClaude = options?.follow !== 'off'
1019 sizeDefault = options?.column === 'size'
1020 openWithSetting = typeof options?.open === 'string' ? options.open : ''
1021 on('session.start', async ($, e, next) => {
1022 await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
1023 const windows = (await $.env.get('OS')) === 'Windows_NT'
1024 useDrives(windows)
1025 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1026 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1027 void (async () => {
1028 pointer = await finePointerOk($)
1029 try {
1030 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1031 noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
1032 } catch {
1033 noNerd = true
1034 }
1035 await loadTheme($)
1036 themePoll?.cancel()
1037 themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
1038 const t = await get($)
1039 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1040 if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1041 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1042 const cwd = await cwdOf($)
1043 if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
1044 else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
1045 })()
1046 return next(e)
1047 })
1048
1049 on('command.run', { command: 'filetree' }, async ($, e) => {
1050 // The layout checks are the terminal's: the desktop app docks the pane itself and
1051 // reports no fullscreen layout, so a session no terminal draws skips them.
1052 const terminal = await $.session.surfaces().then(s => s.includes('terminal'), () => true)
1053 if (terminal && !e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
1054 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.' }
1055 noDock = false
1056 const arg = (e.args ?? '').trim()
1057 const cwd = await cwdOf($)
1058 follow = !arg
1059 const root = arg ? resolve(cwd, arg, home) : cwd
1060 await reset($, root, true)
1061 return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
1062 })
1063
1064 on('tool.call', async ($, e, next) => {
1065 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash') return next(e)
1066 const command = e.tool === 'Bash' ? e.command : ''
1067 const cwd = e.tool === 'Bash' ? await cwdOf($) : ''
1068 const actions = gitActions(command)
1069 const quiet = !actions.length && readOnly(command)
1070 const since = await sinceNow($, e.tool === 'Bash' && !quiet)
1071 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1072 const head = before?.top ? await headOf($, before.root) : ''
1073 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1074 let result: Awaited<ReturnType<typeof next>>
1075 try {
1076 result = await next(e)
1077 } catch (err) {
1078 if (pending) await finishGit($, pending, failAll(pending))
1079 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1080 throw err
1081 }
1082 if (pending && result.deny) {
1083 const ids = pending.ids
1084 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1085 }
1086 if (result.deny || (result.isError && e.tool !== 'Bash')) {
1087 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1088 return result
1089 }
1090 if (e.tool === 'Bash') {
1091 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1092 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1093 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1094 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1095 else void (async () => {
1096 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1097 if (pending) await finishGit($, pending, results)
1098 queuedReads.push(...reads)
1099 scheduleScan($, { ...job, actions: proven(pending, results) })
1100 })()
1101 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1102 } else {
1103 const file =
1104 'file_path' in e && typeof e.file_path === 'string'
1105 ? e.file_path
1106 : 'notebook_path' in e && typeof e.notebook_path === 'string'
1107 ? e.notebook_path
1108 : ''
1109 if (file) void touched($, [file], 'orange', showWrites)
1110 }
1111 return result
1112 })
1113
1114 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1115 const result = await next(e)
1116 if (result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1117 void touched($, [posix(e.file_path)], 'purple', true)
1118 return result
1119 })
1120
1121 on('ui.message', async ($, e, next) => {
1122 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
1123 const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
1124 void sync($)
1125 const t = await get($)
1126 if (typeof data.copy === 'string') {
1127 await copyPath($, data.copy, Boolean(data.shift), e.surface)
1128 return {}
1129 }
1130 if (typeof data.scrollTo === 'number') {
1131 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
1132 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1133 return {}
1134 }
1135 if (typeof data.press === 'string') {
1136 const n = t.nodes.find(x => x.id === data.press)
1137 if (!n) return {}
1138 if (t.scroll === null) await patch($, () => ({ scroll: view.from }))
1139 if (data.ctrl || data.shift) await openNode($, n)
1140 else await press($, n)
1141 return {}
1142 }
1143 if (typeof data.key !== 'string') return {}
1144 if (t.preview) {
1145 const page = Math.max(1, previewRows - 2)
1146 const by: Record<string, number> = { up: -1, k: -1, down: 1, j: 1, pageup: -page, pagedown: page, home: -Infinity, end: Infinity }
1147 if (data.key === 'left' || data.key === 'h') await closePreview($)
1148 else if (data.key in by) await scrollPreview($, by[data.key] ?? 0)
1149 return {}
1150 }
1151 const rows = visibleRows(t)
1152 const at = rows.findIndex(r => r.node.id === t.cursor)
1153 const cur = rows[at]?.node
1154 const move = (d: number) => {
1155 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1156 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1157 }
1158 if (data.key === 'up' || data.key === 'k') await move(-1)
1159 else if (data.key === 'down' || data.key === 'j') await move(1)
1160 else if (data.key === 'pageup') await move(-10)
1161 else if (data.key === 'pagedown') await move(10)
1162 else if (data.key === 'home') await move(-rows.length)
1163 else if (data.key === 'end') await move(rows.length)
1164 else if (cur && (data.key === 'y' || data.key === 'Y')) await copyPath($, cur.id, data.key === 'Y' || Boolean(data.shift), e.surface)
1165 else if (cur && (data.key === 'right' || data.key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1166 else if (cur && (data.key === 'left' || data.key === 'h')) {
1167 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1168 else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1169 } else if (cur && data.key === 'return') await (cur.kind !== 'dir' ? openNode($, cur) : toggle($, cur))
1170 else if (cur && data.key === ' ') await toggle($, cur)
1171 return {}
1172 })
1173
1174 on('classic.SessionStart', async ($, e, next) => {
1175 const result = await next(e)
1176 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
1177 void (async () => {
1178 await loadTheme($)
1179 const root = follow || !lastRoot ? await cwdOf($) : lastRoot
1180 const t = await get($)
1181 if (t.root !== root || t.nodes.length === 0) await reset($, root)
1182 })()
1183 }
1184 const gd = await gitDir($, posix(e.cwd))
1185 return gd ? { ...result, watchPaths: [...(result.watchPaths ?? []), join(gd, 'index'), join(gd, 'HEAD')] } : result
1186 })
1187
1188 on('classic.CwdChanged', async ($, e, next) => {
1189 const result = await next(e)
1190 void followCwd($)
1191 return result
1192 })
1193
1194 on('classic.FileChanged', async ($, e, next) => {
1195 const result = await next(e)
1196 if (/[\\/]\.git[\\/]|[\\/](index|HEAD)$/.test(e.file_path)) $.clock.after(300, () => void sync($, true))
1197 return result
1198 })
1199
1200 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {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 150 lines1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; 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 FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
20
21function shimmer(i: number, phase: number, len: number, palette: string[]): string {
22 const band = ((phase * 1.6) % (len + 8)) - 4
23 const d = Math.abs(i - band)
24 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
25}
26
27const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
28 const { Box, Text } = surface.elements
29 let state = surface.state
30 if (state === undefined) {
31 state = { hover: -1, phase: 0, drag: false, ref: {} }
32 surface.setState(state)
33 }
34 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
35 if (lit && !state.ref.stop) {
36 state.ref.stop = surface.every(TICK_MS, () => {
37 const cur = surface.state
38 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
39 })
40 } else if (!lit && state.ref.stop) {
41 state.ref.stop()
42 state.ref.stop = undefined
43 }
44 if (props.pointer === false) {
45 state.ref.unpoint?.()
46 state.ref.unpoint = undefined
47 } else state.ref.unpoint = surface.onPointer(e => {
48 const cur = surface.state ?? state
49 const span = Math.max(1, props.rows.length - 1)
50 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
51 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
52 if (e.type === 'up' || e.type === 'leave') {
53 surface.setState({ ...cur, drag: false })
54 return
55 }
56 if (e.type === 'down' || e.type === 'move') {
57 if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
58 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
59 return
60 }
61 }
62 if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
63 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
64 return
65 }
66 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
67 const row = props.rows[e.y]
68 if (e.type === 'down' && e.button === 'right' && row?.id) {
69 surface.post({ copy: row.id, shift: Boolean(e.shift) })
70 return
71 }
72 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
73 let x = 0
74 for (const seg of row.left) {
75 const w = [...seg.t].length
76 if (seg.tab && e.x >= x && e.x < x + w) {
77 surface.post({ tab: seg.tab })
78 return
79 }
80 x += w
81 }
82 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
83 })
84 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
85 // The preview draws in this same element, so the focus a click gave the tree stays put.
86 if (props.preview) {
87 const { Markdown, Code } = surface.elements
88 const p = props.preview
89 return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
90 }
91 const frames = props.spinner?.length ? props.spinner : FRAMES
92 const draw = (s: Seg) => {
93 if (s.spin) {
94 return (
95 <Text color={s.c} bold={s.b}>
96 {s.t + (frames[state.phase % frames.length] ?? '')}
97 </Text>
98 )
99 }
100 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
101 if (!palette) {
102 return (
103 <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
104 {s.t}
105 </Text>
106 )
107 }
108 if (s.one) {
109 return (
110 <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
111 {s.t}
112 </Text>
113 )
114 }
115 const chars = [...s.t]
116 return (
117 <Text bold={s.b}>
118 {chars.map((ch, i) => (
119 <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
120 ))}
121 </Text>
122 )
123 }
124 return (
125 <Box flexDirection="column">
126 {props.rows.map((r, i) => (
127 <Box
128 flexDirection="row"
129 height={1}
130 overflow="hidden"
131 backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
132 >
133 <Box flexShrink={1} overflow="hidden">
134 {r.left.map(draw)}
135 </Box>
136 <Box flexGrow={1} />
137 {r.right.map(draw)}
138 {props.bar && (
139 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
140 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
141 </Text>
142 )}
143 </Box>
144 ))}
145 </Box>
146 )
147}
148
149export default Rows
150hooks/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