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.26-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.
Install fails with Unrecognized key: "options"? Your Claude Code is older than 2.1.287: run claude update, then install again.
/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 writesj/k/h/l to move through the tree/config theme in the terminal gets its own palette, with every text color at WCAG AAA contrast (7:1 or more), also on the selected and hovered rowsWith the filetree pane focused, use j/k to move down/up, l to expand a folder, and h to collapse it or move to its parent. o or Enter opens a file or toggles a folder. Press s to focus search, type a query, then Enter to jump to the first match and return focus to the tree controls.
You 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), age or size; what the right column shows when a session starts. age shows how long ago a file changed in a few characters: now, 12m, 3h, 5d, then the day (Oct 9) past a week and the year past a year. The Σ button in the header toggles between sizes and the dates or ages.on (default) shows a line under the header outside a git repo saying git status starts after git init; off hides it.editor opts into $VISUAL, then $EDITOR, then vi in tmux on Linux/macOS: nvim reuses a running nvim in the same tmux server and session; otherwise the editor opens in a new window in the file's folder. Outside a supported tmux environment it shows a notice instead of opening the file: a mod cannot hand Claude Code's terminal to another program, so a terminal editor needs a tmux window to run in. For a GUI editor, or outside tmux, use a command instead, such as code -g {path} or one that opens your terminal's new window. Editor commands can include arguments; quote an executable path or argument that contains spaces, for example EDITOR='"/opt/My Editor/nvim" -u NONE'. Arguments apply when starting a new editor; reusing nvim keeps that instance's configuration. preview shows the file 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, ↗ always 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 1642 lines1import { type BuiltinToolResults, type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { LINUX_LAUNCH, openCommand, openWithCommand, tmuxOpenCommand } from './open'
3import type { ButtonProps } from './button'
4
5import type { Activity, FileNode, FileTree, Theme } from '../types'
6import { BRANCH_ICON, chainOf, type GitAction, gitActions, LIGHT_TONES, readOnly, readTargets, resolve, TONES } from './git'
7import type { RowSpec, RowsProps, Seg } from './rows'
8import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR, LIGHT_GIT_COLOR } from './icons'
9import {
10 age,
11 ancestorsOf,
12 type Change,
13 DEFAULT_THEME,
14 dirname,
15 emptyTree,
16 formatSize,
17 inside,
18 isAbsolute,
19 join,
20 LIGHT_THEME,
21 mapper,
22 parseGit,
23 parseNumstat,
24 parseTheme,
25 posix,
26 relative,
27 dropBelow,
28 useDrives,
29 rollCounts,
30 replaceChildren,
31 rollUp,
32 stamp,
33 toNodes,
34 underAny,
35 visibleRows,
36} from './tree'
37
38const TREE = { plugin: 'filetree', key: 'tree' } as const
39const THEME = { plugin: 'filetree', key: 'theme' } as const
40const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
41const PANE = 'filetree'
42const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
43const LIGHT_SHIMMER = Object.fromEntries(Object.entries(LIGHT_TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
44const BRANCH_ROW = '#branch'
45const FLASH_MS = 2700
46const RUNNING_MAX_MS = 600_000
47const NO_REPO_DEPTH = 4
48const DOUBLE_MS = 450
49const FIND_LIMIT = 200
50const ROWS_BUDGET = 80_000
51const READ_REVEAL_LIMIT = 12
52const SEARCH_REVEAL_LIMIT = 60
53const ACTIVITY_TTL_MS = 45_000
54const ADD_COLOR = '#98c379'
55const DEL_COLOR = '#e06c75'
56const LIGHT_ADD_COLOR = '#14532d'
57const LIGHT_DEL_COLOR = '#7f1d1d'
58const LIGHT_HOVER = '#f0f2f5'
59const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
60const THEME_POLL_MS = 2000
61const FONT_SCRIPT =
62 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | grep -vi lastresort | head -n1 | cut -d: -f1); fi; ' +
63 '[ -n "$f" ] || f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); ' +
64 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
65 '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 ' +
66 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
67 '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; ' +
68 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
69const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
70const HOME_PRUNE = ['Library', 'AppData', '.Trash']
71const SIZE_TIMEOUT_MS = 15_000
72const SIZE_JOBS = 2
73const SIZE_WALK_LIMIT = 50_000
74const AGE_TICK_MS = 60_000
75
76let blink: Timer | null = null
77let themePoll: Timer | null = null
78let ageTick: Timer | null = null
79let themeMtime: number | null = null
80let lightTerminal = false
81let generation = 0
82let lastPress = { key: '', at: 0 }
83let noNerd = false
84let glyphSetting = 'auto'
85let openWithSetting = ''
86let previewed: { path: string; text: string; note: string } | null = null
87let previewRows = 20
88let follow = true
89let followClaude = true
90let scanning: Promise<void> | null = null
91let scanJobs: Job[] = []
92let gitRun: Promise<void> | null = null
93let gitNext: Promise<void> | null = null
94let queuedReads: string[] = []
95let showReads = true
96let showWrites = true
97let searchIndex: { root: string; paths: Promise<string[]> } | null = null
98let activityId = 0
99let pointer = true
100let hovered = ''
101let view = { from: 0, max: 0 }
102let nativeScroll = false
103let lastSync = 0
104let noDock = false
105let home = ''
106let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
107let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
108let markId = 0
109let listSeq = 0
110let lastRoot = ''
111let sizeDefault = false
112let showAge = false
113let showHints = true
114let sizeEpoch = 0
115let sizeActive = 0
116let sizeQueue: string[] = []
117const sizing = new Set<string>()
118const listLatest = new Map<string, number>()
119const unreadable = new Set<string>()
120const background = new Map<string, Background>()
121
122function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
123 platform ??= (async () => {
124 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
125 try {
126 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
127 } catch {
128 return 'linux'
129 }
130 })()
131 return platform
132}
133
134async function cwdOf($: EngineInterface): Promise<string> {
135 return posix(await $.session.cwd())
136}
137
138function clean(segs: Seg[]): Seg[] {
139 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
140 return segs
141}
142
143function faint(hex: string): string {
144 const v = parseInt(hex.slice(1), 16)
145 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
146 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
147}
148
149async function readThemeSetting($: EngineInterface): Promise<void> {
150 const row = (await $.config.list()).find(r => r.key === 'theme')
151 const light = typeof row?.value === 'string' && row.value.startsWith('light')
152 if (light === lightTerminal) return
153 lightTerminal = light
154 void $.ui.invalidate('ui.render')
155}
156
157async function get($: EngineInterface): Promise<FileTree> {
158 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
159}
160
161async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
162 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
163}
164
165function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
166 return put($, t => ({ ...t, ...fn(t) }))
167}
168
169async function activities($: EngineInterface): Promise<Activity[]> {
170 return (await $.state.get(ACTIVITY)).value ?? []
171}
172
173async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
174 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
175}
176
177async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
178 try {
179 const entries = await $.fs.list(dir)
180 const resolved = await Promise.all(
181 entries.map(async e => {
182 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
183 try {
184 const target = await $.fs.stat(join(dir, e.name))
185 return target.kind === 'dir'
186 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
187 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
188 } catch {
189 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
190 }
191 }),
192 )
193 unreadable.delete(dir)
194 return toNodes(dir, resolved)
195 } catch (err) {
196 if (!(await exists($, dir))) return []
197 if (!unreadable.has(dir)) {
198 unreadable.add(dir)
199 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
200 }
201 return null
202 }
203}
204
205async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
206 const root = (await get($)).root
207 const seq = ++listSeq
208 for (const dir of dirs) listLatest.set(dir, seq)
209 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
210 const listed = new Map<string, FileNode[]>()
211 for (const [dir, kids] of results) {
212 if (listLatest.get(dir) !== seq) continue
213 listLatest.delete(dir)
214 if (kids) listed.set(dir, kids)
215 }
216 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
217 return listed
218}
219
220async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
221 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
222}
223
224async function detectRepo($: EngineInterface): Promise<void> {
225 const t = await get($)
226 if (!t.root) return
227 let top = ''
228 let prefix = ''
229 try {
230 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
231 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
232 prefix = p.trim()
233 top = tl.trim()
234 } catch {
235 top = ''
236 }
237 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
238}
239
240async function readGit($: EngineInterface): Promise<void> {
241 const t = await get($)
242 if (!t.root || !t.top) return
243 const root = t.root
244 try {
245 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
246 if (status.exitCode !== 0) return
247 const parsed = parseGit(status.stdout, root, t.prefix)
248 const diff: Record<string, [number, number]> = {}
249 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
250 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
251 const files = { ...parsed.files }
252 if (parsed.untrackedDirs.length) {
253 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
254 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
255 }
256 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
257 dirty = { root, files }
258 await patch($, cur =>
259 cur.root === root
260 ? {
261 git: parsed.git,
262 ignored: parsed.ignored,
263 untrackedDirs: parsed.untrackedDirs,
264 branch: parsed.branch,
265 counts: rollCounts(files, root),
266 diff: rollUp(diff, root),
267 }
268 : {},
269 )
270 } catch {
271 return
272 }
273}
274
275function refreshGit($: EngineInterface): Promise<void> {
276 if (!gitRun) {
277 gitRun = readGit($).finally(() => {
278 gitRun = null
279 })
280 return gitRun
281 }
282 const rerun = () => {
283 gitNext = null
284 return refreshGit($)
285 }
286 gitNext ??= gitRun.then(rerun, rerun)
287 return gitNext
288}
289
290async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
291 const prev = await get($)
292 generation += 1
293 blink?.cancel()
294 blink = null
295 lastRoot = root
296 searchIndex = null
297 sizeEpoch += 1
298 sizeQueue = []
299 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
300 const title = `Files: ${root.split('/').pop() || root}`
301 if (focus) await $.ui.open({ id: PANE, title, focus: true })
302 else if (!noDock) await $.ui.open({ id: PANE, title })
303 await loadDirs($, [root])
304 await detectRepo($)
305 await refreshGit($)
306}
307
308async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
309 const tried = new Set<string>()
310 for (let pass = 0; pass < 32; pass++) {
311 const t = await get($)
312 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
313 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
314 const need = new Set<string>()
315 for (const p of paths) {
316 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
317 if (!loaded.has(dir)) {
318 if (!tried.has(dir)) need.add(dir)
319 break
320 }
321 }
322 }
323 if (need.size === 0) return
324 for (const dir of need) tried.add(dir)
325 await loadDirs($, [...need])
326 }
327}
328
329function pruneArgs(root: string, ignored: string[] = []): string[] {
330 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
331 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
332}
333
334async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
335 const stats = await Promise.all(
336 paths.map(async p => {
337 try {
338 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
339 } catch {
340 return ''
341 }
342 }),
343 )
344 return stats.filter(Boolean)
345}
346
347function broad(root: string): boolean {
348 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
349}
350
351function rootOfDisk(root: string): boolean {
352 return root === '/' || /^[A-Za-z]:\/$/.test(root)
353}
354
355async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
356 if (rootOfDisk(root)) return []
357 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
358 try {
359 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 })
360 return run.stdout.split('\0').filter(p => p && p !== root)
361 } catch {
362 return []
363 }
364}
365
366async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
367 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
368 if (dirty.root !== root) return { hits: [], gone: [] }
369 const entries = Object.entries(dirty.files)
370 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
371 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
372 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
373}
374
375type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
376
377async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
378 const ms = await $.clock.now()
379 const os = await osName($)
380 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
381 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
382 try {
383 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
384 return { ms, mark, os }
385 } catch {
386 return { ms, mark: '', os }
387 }
388}
389
390async function walkSize($: EngineInterface, dir: string): Promise<number> {
391 let total = 0
392 let seen = 0
393 let level = [dir]
394 while (level.length) {
395 const next: string[] = []
396 for (const d of level) {
397 let entries
398 try {
399 entries = await $.fs.list(d)
400 } catch {
401 continue
402 }
403 for (const e of entries) {
404 if (++seen > SIZE_WALK_LIMIT) return -1
405 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
406 else if (e.kind === 'file') total += e.size
407 }
408 }
409 level = next
410 }
411 return total
412}
413
414async function dirSize($: EngineInterface, dir: string): Promise<number> {
415 if (rootOfDisk(dir)) return -1
416 if ((await osName($)) === 'win32') return walkSize($, dir)
417 try {
418 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
419 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
420 return Number.isFinite(kb) ? kb * 1024 : -1
421 } catch {
422 return -1
423 }
424}
425
426function pumpSizes($: EngineInterface): void {
427 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
428 const dir = sizeQueue.shift() ?? ''
429 const epoch = sizeEpoch
430 sizing.add(dir)
431 sizeActive += 1
432 void dirSize($, dir)
433 .then(async bytes => {
434 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
435 const cur = await get($)
436 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
437 })
438 .catch(() => undefined)
439 .finally(() => {
440 sizing.delete(dir)
441 sizeActive -= 1
442 pumpSizes($)
443 })
444 }
445}
446
447function wantSizes($: EngineInterface, dirs: string[]): void {
448 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
449 pumpSizes($)
450}
451
452async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
453 sizeEpoch += 1
454 sizeQueue = []
455 await patch($, cur => {
456 if (!paths) return { dirSizes: {} }
457 const keep: Record<string, number> = {}
458 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
459 return { dirSizes: keep }
460 })
461}
462
463function openDirs(t: FileTree): string[] {
464 const open = new Set(t.expanded)
465 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
466}
467
468function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
469 if (followClaude) return { scroll: null }
470 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
471 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
472 return at < 0 ? {} : { scroll: at }
473}
474
475async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
476 const unique = Object.keys(tones)
477 if (unique.length === 0) return
478 const mine = ++generation
479 const root = (await get($)).root
480 blink?.cancel()
481 blink = null
482 await patch($, cur => {
483 if (cur.root !== root) return {}
484 const open = new Set(cur.expanded)
485 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
486 const dim = new Set<string>()
487 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
488 if (cur.flashOn) {
489 for (const id of cur.flashDim) dim.add(id)
490 }
491 for (const id of unique) {
492 const tone = tones[id] ?? 'orange'
493 all[id] = tone
494 if (id === BRANCH_ROW) continue
495 const chain = ancestorsOf(id, cur.root)
496 if (!chain.some(a => !open.has(a))) continue
497 for (const a of chain) {
498 if (!open.has(a)) {
499 dim.add(a)
500 all[a] = all[a] ?? tone
501 }
502 open.add(a)
503 }
504 }
505 for (const id of bright) dim.delete(id)
506 return {
507 flash: [...bright],
508 flashDim: [...dim],
509 flashOn: true,
510 flashTones: all,
511 expanded: [...open],
512 ...keepTop(cur, [...open]),
513 }
514 })
515 if (generation !== mine) return
516 blink = $.clock.after(FLASH_MS, () => {
517 if (generation !== mine) return
518 blink = null
519 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
520 })
521}
522
523async function followCwd($: EngineInterface): Promise<boolean> {
524 if (!follow) return false
525 const cwd = await cwdOf($)
526 const t = await get($)
527 if (t.root === cwd) return false
528 await reset($, cwd)
529 return true
530}
531
532type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
533type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
534type Background = { p: Pending | null; job: Job; reads: string[] }
535type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
536type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
537
538const PROOF: Record<string, (op: GitOperation) => boolean> = {
539 push: op => Boolean(op.push),
540 merge: op => op.branch?.action === 'merged',
541 rebase: op => op.branch?.action === 'rebased',
542 'pr create': op => op.pr?.action === 'created',
543 'pr merge': op => op.pr?.action === 'merged',
544 'pr comment': op => op.pr?.action === 'commented',
545}
546
547async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
548 const now = await $.clock.now()
549 const ids = actions.map(() => ++activityId)
550 await setActivities($, cur => [
551 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
552 ...actions.map((a, i) => ({
553 id: ids[i] ?? 0,
554 kind: a.kind,
555 label: a.running,
556 state: 'running' as const,
557 detail: '',
558 at: now,
559 tone: a.tone,
560 nerd: a.icon.nerd,
561 plain: a.icon.plain,
562 })),
563 ])
564 return ids
565}
566
567async function headOf($: EngineInterface, cwd: string): Promise<string> {
568 try {
569 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
570 return run.exitCode === 0 ? run.stdout.trim() : ''
571 } catch {
572 return ''
573 }
574}
575
576async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
577 const chain = chainOf(p.command)
578 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
579 return p.actions.map(a => {
580 if (a.verb === 'commit') {
581 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
582 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
583 }
584 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
585 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
586 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
587 })
588}
589
590async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
591 const now = await $.clock.now()
592 await setActivities($, cur =>
593 cur.map(a => {
594 const i = p.ids.indexOf(a.id)
595 const result = i < 0 ? undefined : results[i]
596 return result ? { ...a, ...result, at: now } : a
597 }),
598 )
599 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
600}
601
602function failAll(p: Pending): Outcome[] {
603 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
604}
605
606function proven(p: Pending | null, results: Outcome[]): GitAction[] {
607 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
608}
609
610function taskEnds(text: string): [string, string][] {
611 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
612 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
613 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
614 return id && status ? [[id, status] as [string, string]] : []
615 })
616}
617
618async function settleBackground($: EngineInterface, text: string): Promise<void> {
619 if (background.size === 0 || !text.includes('<task-notification>')) return
620 for (const [id, status] of taskEnds(text)) {
621 const task = background.get(id)
622 if (!task) continue
623 background.delete(id)
624 const ok = status === 'completed'
625 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
626 if (task.p) await finishGit($, task.p, results)
627 if (showReads) queuedReads.push(...task.reads)
628 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
629 }
630}
631
632async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
633 const map = mapper(root, prefix)
634 try {
635 const run = committed
636 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
637 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
638 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
639 } catch {
640 return []
641 }
642}
643
644async function gitDir($: EngineInterface, cwd: string): Promise<string> {
645 for (let dir = cwd, i = 0; i < 64; i++) {
646 try {
647 const dot = join(dir, '.git')
648 const st = await $.fs.stat(dot)
649 if (st.kind === 'dir') return dot
650 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
651 if (ref) return resolve(dir, ref, home)
652 } catch {
653 const up = dirname(dir)
654 if (up === dir) return ''
655 dir = up
656 continue
657 }
658 return ''
659 }
660 return ''
661}
662
663async function sync($: EngineInterface, force = false): Promise<void> {
664 const now = await $.clock.now()
665 if (!force && now - lastSync < 2_000) return
666 lastSync = now
667 searchIndex = null
668 const t = await get($)
669 if (!t.root) return
670 await loadDirs($, [t.root, ...openDirs(t)])
671 if (t.top) await refreshGit($)
672}
673
674async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
675 const t = await get($)
676 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
677 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
678 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
679}
680
681async function exists($: EngineInterface, path: string): Promise<boolean> {
682 try {
683 await $.fs.stat(path)
684 return true
685 } catch {
686 return false
687 }
688}
689
690async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
691 const reads = queuedReads
692 queuedReads = []
693 if (await followCwd($)) return
694 const t = await get($)
695 if (!t.root) return
696 const actions = jobs.flatMap(j => j.actions)
697 const writers = jobs.filter(j => !j.readOnly)
698 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 })
699 const writes = !jobs.every(j => j.readOnly)
700 const before = dirty.root === t.root ? dirty.files : {}
701 if (jobs.some(j => j.initRepo)) await detectRepo($)
702 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
703 const probed = await get($)
704 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
705 if (writes) {
706 searchIndex = null
707 await staleSizes($)
708 }
709 const fresh = await get($)
710 const ignored = new Set(fresh.ignored)
711 const tones: Record<string, string> = {}
712 if (writes) {
713 const found = fresh.top
714 ? await changedInRepo($, t.root, since, before, fresh.ignored)
715 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
716 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
717 await revealPaths($, hits)
718 const loaded = await get($)
719 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
720 const listed = await loadDirs($, dirs)
721 if (!fresh.top && since.os === 'win32')
722 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
723 await patch($, cur => {
724 const ids = new Set(cur.nodes.map(n => n.id))
725 return { expanded: cur.expanded.filter(id => ids.has(id)) }
726 })
727 const present = new Set((await get($)).nodes.map(n => n.id))
728 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
729 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
730 }
731 if (showReads) {
732 const found: string[] = []
733 for (const r of reads) {
734 if (found.length >= READ_REVEAL_LIMIT) break
735 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
736 }
737 if (found.length) {
738 await revealPaths($, found)
739 const shown = new Set((await get($)).nodes.map(n => n.id))
740 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
741 }
742 }
743 if (showWrites && actions.length) {
744 const final = await get($)
745 const committed = actions.some(a => a.verb === 'commit')
746 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
747 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
748 await revealPaths($, paths)
749 const shown = new Set((await get($)).nodes.map(n => n.id))
750 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
751 }
752 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
753 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
754 else if (committed) tones[BRANCH_ROW] = 'green'
755 }
756 await flash($, tones)
757}
758
759function scheduleScan($: EngineInterface, job: Job): void {
760 scanJobs.push(job)
761 if (scanning) return
762 scanning = (async () => {
763 while (scanJobs.length) {
764 const jobs = scanJobs
765 scanJobs = []
766 try {
767 await afterBash($, jobs)
768 } finally {
769 const marks = jobs.map(j => j.since.mark).filter(Boolean)
770 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
771 }
772 }
773 })().finally(() => {
774 scanning = null
775 })
776}
777
778async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
779 if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
780 if (await followCwd($)) return
781 const t = await get($)
782 if (!t.root) return
783 const within = paths.map(posix).filter(p => inside(t.root, p))
784 if (within.length === 0) return
785 if (tone !== 'purple') {
786 searchIndex = null
787 await staleSizes($, within)
788 await revealPaths($, within.map(dirname))
789 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
790 await refreshGit($)
791 } else await revealPaths($, within)
792 if (!show) return
793 const tones: Record<string, string> = {}
794 for (const p of within) tones[p] = tone
795 await flash($, tones)
796}
797
798async function reveal($: EngineInterface, paths: string[]): Promise<void> {
799 if (await followCwd($)) return
800 await revealPaths($, paths)
801 await patch($, cur => {
802 const open = new Set(cur.expanded)
803 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
804 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
805 })
806}
807
808async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
809 const out: string[] = []
810 let level = [root]
811 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
812 const next: string[] = []
813 for (const dir of level) {
814 for (const n of (await list($, dir)) ?? []) {
815 if (n.kind === 'dir') {
816 if (!PRUNE.includes(n.name)) next.push(n.id)
817 } else out.push(n.id)
818 }
819 if (out.length >= limit) break
820 }
821 level = next
822 }
823 return out
824}
825
826async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
827 try {
828 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
829 const run = t.top
830 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
831 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
832 if (t.top && run.exitCode !== 0) return null
833 const paths = run.stdout.split('\0').filter(Boolean)
834 if (run.isStdoutTruncated) paths.pop()
835 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
836 } catch {
837 return null
838 }
839}
840
841function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
842 if (searchIndex?.root !== t.root) {
843 const index: { root: string; paths: Promise<string[]> } = {
844 root: t.root,
845 paths: listAll($, t).then(paths => {
846 if (paths === null && searchIndex === index) searchIndex = null
847 return paths ?? []
848 }),
849 }
850 searchIndex = index
851 }
852 return searchIndex.paths
853}
854
855async function search($: EngineInterface, query: string): Promise<string[]> {
856 if (!(await get($)).query.trim()) searchIndex = null
857 await patch($, () => ({ query }))
858 const q = query.trim().toLowerCase()
859 if (!q) return []
860 const t = await get($)
861 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
862 if ((await get($)).query !== query) return []
863 await revealPaths($, hits)
864 const shown = await get($)
865 const ids = new Set(shown.nodes.map(n => n.id))
866 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
867 if (stale.length) await loadDirs($, stale)
868 return hits
869}
870
871async function jump($: EngineInterface, query: string): Promise<void> {
872 const hits = (await search($, query)).slice(0, 10)
873 await patch($, cur => {
874 const open = new Set(cur.expanded)
875 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
876 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
877 })
878}
879
880async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
881 try {
882 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
883 return out === 'stale' || out === 'missing' ? out : 'ok'
884 } catch {
885 return 'missing'
886 }
887}
888
889async function finePointerOk($: EngineInterface): Promise<boolean> {
890 if (!(await $.env.get('HERDR_ENV'))) return true
891 try {
892 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
893 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
894 return major * 1e6 + minor * 1e3 + fix >= 9_001
895 } catch {
896 return false
897 }
898}
899
900const PREVIEW_CHARS = 90_000
901const PREVIEW_LINE = 2_000
902
903function fitSerialized(lines: string[], max: number): string[] {
904 let size = 0
905 for (let i = 0; i < lines.length; i++) {
906 size += JSON.stringify(lines[i]).length
907 if (size > max) return lines.slice(0, i)
908 }
909 return lines
910}
911
912async function loadPreview($: EngineInterface, path: string): Promise<void> {
913 let text = ''
914 let note = ''
915 try {
916 const stat = await $.fs.stat(path)
917 if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
918 else {
919 text = await $.fs.read(path)
920 if (text.slice(0, 8000).includes('\0')) {
921 text = ''
922 note = `binary file, ${formatSize(stat.size)}`
923 }
924 }
925 } catch (err) {
926 note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
927 }
928 const total = text.length
929 let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
930 if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
931 lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
932 const fit = fitSerialized(lines, PREVIEW_CHARS)
933 if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
934 previewed = { path, text: fit.join('\n'), note }
935}
936
937async function showPreview($: EngineInterface, path: string): Promise<void> {
938 await loadPreview($, path)
939 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
940}
941
942async function closePreview($: EngineInterface): Promise<void> {
943 await patch($, () => ({ preview: '', previewTop: 0 }))
944}
945
946async function scrollPreview($: EngineInterface, by: number): Promise<void> {
947 const t = await get($)
948 const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
949 const top = Math.max(0, Math.min(last, t.previewTop + by))
950 if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
951}
952
953function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
954 const lines = text.split('\n')
955 const from = Math.max(0, Math.min(top, lines.length - 1))
956 let shown = lines.slice(from, from + rows)
957 if (markdown) {
958 // Reopen a code fence the window starts inside, so the rest is not read as prose.
959 let fence = ''
960 let opener = ''
961 for (const line of lines.slice(0, from)) {
962 const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
963 if (!m) continue
964 const mark = m[1] ?? ''
965 if (!fence) {
966 fence = mark
967 opener = line.trimStart()
968 } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
969 }
970 if (fence) shown = [opener, ...shown]
971 }
972 return { text: shown.join('\n'), top: from }
973}
974
975async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
976 const mode = external ? '' : openWithSetting.trim()
977 if (mode === 'preview') return showPreview($, path)
978 const os = await osName($)
979 if (mode === 'editor' && (os === 'win32' || !(await $.env.get('TMUX')))) {
980 $.ui.toast('Open with: editor requires tmux on Linux or macOS. Clear Open with to use the default app, or set a custom command.')
981 return
982 }
983 const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
984 const custom = mode === 'editor' ? null : openWithCommand(mode, native(path), native(dirname(path)))
985 const editor = mode === 'editor' ? (await $.env.get('VISUAL')) || (await $.env.get('EDITOR')) || 'vi' : undefined
986 try {
987 const { argv, init } = editor ? tmuxOpenCommand(editor, path) : custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
988 const run = await $.process.run(argv, init)
989 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${editor ?? custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
990 } catch {
991 $.ui.toast(`could not open ${path} with ${editor ?? custom?.[0] ?? 'the default app'}`)
992 }
993}
994
995async function quickLook($: EngineInterface, path: string): Promise<void> {
996 try {
997 await $.process.run(['sh', '-c', '(nohup qlmanage -p "$1" >/dev/null 2>&1 &); sleep 0.4; osascript -e \'tell application "System Events" to set frontmost of first process whose name is "qlmanage" to true\' >/dev/null 2>&1 || true', 'sh', path], { timeoutMs: 10_000 })
998 } catch {
999 $.ui.toast(`could not preview ${path} with Quick Look`)
1000 }
1001}
1002
1003async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1004 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1005 await patch($, t => {
1006 const open = new Set(t.expanded)
1007 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1008 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1009 })
1010}
1011
1012async function press($: EngineInterface, n: FileNode): Promise<void> {
1013 const now = await $.clock.now()
1014 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1015 lastPress = { key: isDouble ? '' : n.id, at: now }
1016 if (!isDouble) {
1017 await toggle($, n)
1018 return
1019 }
1020 await openNode($, n)
1021}
1022
1023async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1024 if (n.kind === 'dir') {
1025 follow = false
1026 await reset($, n.id)
1027 } else await openFile($, n.id)
1028}
1029
1030async function navigate($: EngineInterface, key: string, shift = false, surface?: string): Promise<void> {
1031 const t = await get($)
1032 const rows = visibleRows(t)
1033 const at = rows.findIndex(r => r.node.id === t.cursor)
1034 const cur = rows[at]?.node
1035 const move = (d: number) => {
1036 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1037 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1038 }
1039 if (key === 'up' || key === 'k') await move(-1)
1040 else if (key === 'down' || key === 'j') await move(1)
1041 else if (key === 'pageup') await move(-10)
1042 else if (key === 'pagedown') await move(10)
1043 else if (key === 'home') await move(-rows.length)
1044 else if (key === 'end') await move(rows.length)
1045 else if (cur && (key === 'y' || key === 'Y')) await copyPath($, cur.id, key === 'Y' || shift, surface)
1046 else if (cur && (key === 'right' || key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1047 else if (cur && (key === 'left' || key === 'h')) {
1048 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1049 else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1050 } else if (cur && key === 'return') await (cur.kind !== 'dir' ? openNode($, cur) : toggle($, cur))
1051 else if (cur && (key === ' ' || key === 'space')) await (cur.kind !== 'dir' && (await osName($)) === 'darwin' ? quickLook($, cur.id) : toggle($, cur))
1052}
1053
1054async function loadTheme($: EngineInterface): Promise<void> {
1055 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
1056 try {
1057 const stat = await $.fs.stat(path)
1058 if (stat.kind !== 'file') throw new Error('no theme file')
1059 if (stat.mtimeMs === themeMtime) return
1060 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1061 themeMtime = stat.mtimeMs
1062 } catch {
1063 if (themeMtime === 0) return
1064 await $.state.set(THEME, DEFAULT_THEME)
1065 themeMtime = 0
1066 }
1067}
1068
1069async function pressHeader($: EngineInterface, id: string): Promise<void> {
1070 const t = await get($)
1071 if (id === 'up') {
1072 follow = false
1073 await reset($, dirname(t.root))
1074 } else if (id === 'cwd') {
1075 follow = true
1076 await reset($, await cwdOf($))
1077 } else if (id === 'refresh') {
1078 searchIndex = null
1079 await staleSizes($)
1080 await loadDirs($, [t.root, ...t.expanded])
1081 await detectRepo($)
1082 await refreshGit($)
1083 if (t.query.trim()) await search($, t.query)
1084 } else if (id === 'hidden') await patch($, cur => ({ showHidden: !cur.showHidden }))
1085 else if (id === 'size') await patch($, cur => ({ showSize: !cur.showSize }))
1086 else if (id === 'collapse') await patch($, cur => ({ expanded: [], nodes: dropBelow(cur.nodes, cur.nodes.filter(x => x.parent === cur.root && x.kind === 'dir').map(x => x.id)) }))
1087 else if (id === 'unselect') await patch($, () => ({ selected: '' }))
1088 else if (id === 'external' && t.preview) await openFile($, t.preview, true)
1089}
1090
1091function shortPath(path: string): string {
1092 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1093}
1094
1095export const register: Register = (on, options) => {
1096 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
1097 const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
1098 showReads = activity.includes('reads')
1099 showWrites = activity.includes('writes')
1100 followClaude = options?.follow !== 'off'
1101 sizeDefault = options?.column === 'size'
1102 showAge = options?.column === 'age'
1103 showHints = options?.hints !== 'off'
1104 openWithSetting = typeof options?.open === 'string' ? options.open : ''
1105 noDock = options?.start === 'off'
1106 on('session.start', async ($, e, next) => {
1107 await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
1108 const windows = (await $.env.get('OS')) === 'Windows_NT'
1109 useDrives(windows)
1110 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1111 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1112 void (async () => {
1113 pointer = await finePointerOk($)
1114 try {
1115 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1116 noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
1117 } catch {
1118 noNerd = true
1119 }
1120 await loadTheme($)
1121 await readThemeSetting($).catch(() => undefined)
1122 themePoll?.cancel()
1123 themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
1124 ageTick?.cancel()
1125 ageTick = showAge ? $.clock.every(AGE_TICK_MS, () => $.ui.invalidate('ui.render')) : null
1126 const t = await get($)
1127 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1128 if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1129 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1130 const cwd = await cwdOf($)
1131 if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
1132 else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
1133 })()
1134 return next(e)
1135 })
1136
1137 on('config.set', { key: 'theme' }, async ($, e, next) => {
1138 const result = await next(e)
1139 await readThemeSetting($).catch(() => undefined)
1140 return result
1141 })
1142
1143 on('command.run', { command: 'filetree' }, async ($, e) => {
1144 // The layout checks are the terminal's: the desktop app docks the pane itself and
1145 // reports no fullscreen layout, so a session no terminal draws skips them.
1146 const terminal = await $.session.surfaces().then(s => s.includes('terminal'), () => true)
1147 if (terminal && !e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
1148 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.' }
1149 noDock = false
1150 const arg = (e.args ?? '').trim()
1151 const cwd = await cwdOf($)
1152 follow = !arg
1153 const root = arg ? resolve(cwd, arg, home) : cwd
1154 await reset($, root, true)
1155 return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
1156 })
1157
1158 on('tool.call', async ($, e, next) => {
1159 const shell = e.tool === 'Bash' || e.tool === 'PowerShell'
1160 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && !shell) return next(e)
1161 const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : ''
1162 const cwd = shell ? await cwdOf($) : ''
1163 const actions = gitActions(command)
1164 const quiet = !actions.length && readOnly(command)
1165 const since = await sinceNow($, shell && !quiet)
1166 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1167 const head = before?.top ? await headOf($, before.root) : ''
1168 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1169 let result: Awaited<ReturnType<typeof next>>
1170 try {
1171 result = await next(e)
1172 } catch (err) {
1173 if (pending) await finishGit($, pending, failAll(pending))
1174 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1175 throw err
1176 }
1177 if (pending && result.deny) {
1178 const ids = pending.ids
1179 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1180 }
1181 if (result.deny || (result.isError && !shell)) {
1182 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1183 return result
1184 }
1185 if (shell) {
1186 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? (result.result as Partial<BuiltinToolResults['Bash']>) : {}
1187 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1188 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1189 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1190 else void (async () => {
1191 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1192 if (pending) await finishGit($, pending, results)
1193 queuedReads.push(...reads)
1194 scheduleScan($, { ...job, actions: proven(pending, results) })
1195 })()
1196 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1197 } else {
1198 const file =
1199 'file_path' in e && typeof e.file_path === 'string'
1200 ? e.file_pathhooks/open.ts 110 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
25export const TMUX_OPEN = [
26 'f=$1 d=$2; shift 2',
27 'bin=$1',
28 'case ${bin##*/} in nvim) ;; *) exec tmux new-window -c "$d" -- "$@" "$f" ;; esac',
29 'probe() {',
30 ' "$bin" --server "$s" --remote-expr \'$TMUX . " " . $TMUX_PANE\' 2>/dev/null &',
31 ' request=$!',
32 ' (sleep 1; kill -KILL "$request") >/dev/null 2>&1 &',
33 ' timer=$!',
34 ' wait "$request" 2>/dev/null; result=$?',
35 ' kill "$timer" 2>/dev/null',
36 ' wait "$timer" 2>/dev/null',
37 ' return "$result"',
38 '}',
39 'here=$(tmux display-message -p \'#{socket_path} #{session_id}\')',
40 'best= act=-1',
41 'for s in "${XDG_RUNTIME_DIR:-/nonexistent}"/nvim.*.0 "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"/nvim."$(id -un)"/*/nvim.*.0; do',
42 ' [ -S "$s" ] && [ -O "$s" ] || continue',
43 ' t=$(probe) || continue',
44 ' p=${t##* } sock=${t%%,*}',
45 ' [ -n "$p" ] && [ "$sock" != "$t" ] || continue',
46 ' info=$(tmux display-message -p -t "$p" \'#{socket_path} #{session_id} #{window_activity}\' 2>/dev/null) || continue',
47 ' location=${info% *} activity=${info##* }',
48 ' [ "$location" = "$here" ] && [ "${location% *}" = "$sock" ] && [ "$activity" -gt "$act" ] || continue',
49 ' best=$s pane=$p act=$activity',
50 'done',
51 'if [ -n "$best" ] && "$bin" --server "$best" --remote "$f"; then',
52 ' exec tmux select-window -t "$pane" \\; select-pane -t "$pane"',
53 'fi',
54 'exec tmux new-window -c "$d" -- "$@" "$f"',
55].join('\n')
56
57const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
58
59export function tmuxOpenCommand(editor: string, target: string) {
60 const argv = words(editor)
61 if (!argv[0]) throw new Error('Editor command is empty')
62 const dir = target.slice(0, target.lastIndexOf('/')) || '/'
63 return {
64 argv: ['sh', '-c', TMUX_OPEN, 'sh', target, dir, ...argv],
65 init: { timeoutMs: 10_000 },
66 }
67}
68
69export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
70 const argv = os === 'linux'
71 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
72 : os === 'darwin'
73 ? ['open', '--', target]
74 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
75 return {
76 argv,
77 init: {
78 timeoutMs: 10_000,
79 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
80 },
81 }
82}
83
84function words(command: string): string[] {
85 const out: string[] = []
86 let word: string | null = null
87 let quote = ''
88 for (const ch of command) {
89 if (quote) {
90 if (ch === quote) quote = ''
91 else word = (word ?? '') + ch
92 } else if (ch === '"' || ch === "'") {
93 quote = ch
94 word ??= ''
95 } else if (/\s/.test(ch)) {
96 if (word !== null) out.push(word)
97 word = null
98 } else word = (word ?? '') + ch
99 }
100 if (word !== null) out.push(word)
101 return out
102}
103
104export function openWithCommand(command: string, path: string, dir: string): string[] | null {
105 const argv = words(command)
106 if (!argv.length) return null
107 const filled = argv.map(w => w.replace(/\{(path|dir)\}/g, (_, key) => (key === 'path' ? path : dir)))
108 return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
109}
110hooks/button.tsx 19 lines1import type { ClientModule } from 'claude-code'
2
3export type ButtonProps = { id: string; label: string; dim: boolean }
4
5// A header button that also tells the hooks module when the pointer is over it, which a Button
6// alone doesn't, so the header can say what the button does.
7const HeaderButton: ClientModule<ButtonProps, { over: boolean }> = (props, surface) => {
8 const { Button } = surface.elements
9 surface.onPointer(e => {
10 const over = e.type !== 'leave'
11 if (over === Boolean(surface.state?.over)) return
12 surface.setState({ over })
13 surface.post({ over })
14 })
15 return <Button key={props.id} plain dimColor={props.dim} label={props.label} onPress={() => surface.post({ press: true })} />
16}
17
18export default HeaderButton
19hooks/git.ts 272 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
37export const LIGHT_TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
38 orange: { bright: ['#7c2d12', '#69250e', '#561c0b', '#431407'], dim: ['#5d362e', '#52322c', '#482d2a', '#3e2828'], solid: '#7c2d12' },
39 green: { bright: ['#14532d', '#0f4725', '#0a3a1e', '#052e16'], dim: ['#244b3d', '#214439', '#1e3d35', '#1b3731'], solid: '#14532d' },
40 teal: { bright: ['#134e4a', '#0e4441', '#093937', '#042f2e'], dim: ['#23484d', '#204348', '#1e3d43', '#1b373e'], solid: '#134e4a' },
41 blue: { bright: ['#1e3a8a', '#1c3378', '#192c66', '#172554'], dim: ['#293d70', '#283966', '#26355d', '#253253'], solid: '#1e3a8a' },
42 purple: { bright: ['#581c87', '#4e157b', '#450e70', '#3b0764'], dim: ['#492d6f', '#442968', '#3f2562', '#39215b'], solid: '#581c87' },
43 cyan: { bright: ['#164e63', '#114559', '#0d3c4e', '#083344'], dim: ['#25485b', '#224355', '#203e4f', '#1d394a'], solid: '#164e63' },
44 red: { bright: ['#7f1d1d', '#6c1717', '#581010', '#450a0a'], dim: ['#5f2d34', '#542a31', '#49262d', '#3f232a'], solid: '#7f1d1d' },
45}
46
47const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
48 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
49 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
50 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
51 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
52 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
53 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
54 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
55 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
56 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
57 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
58 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
59 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
60 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
61 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
62 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
63 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
64 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
65 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
66 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
67 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
68}
69
70const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
71 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
72 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
73 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
74 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
75 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
76 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
77 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
78 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
79}
80
81const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
82
83function segments(command: string, seps: string[] = []): string[][] {
84 const out: string[][] = []
85 let cur: string[] = []
86 let tok = ''
87 let quote = ''
88 let has = false
89 const endTok = () => {
90 if (has) cur.push(tok)
91 tok = ''
92 has = false
93 }
94 const endSeg = () => {
95 endTok()
96 if (cur.length) out.push(cur)
97 cur = []
98 }
99 for (let i = 0; i < command.length; i++) {
100 const ch = command[i] ?? ''
101 if (quote) {
102 if (ch === quote) quote = ''
103 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
104 else tok += ch
105 } else if (ch === '"' || ch === "'") {
106 quote = ch
107 has = true
108 } else if (ch === '\\' && i + 1 < command.length) {
109 tok += command[++i]
110 has = true
111 } else if (ch === '#' && !has) {
112 while (i + 1 < command.length && command[i + 1] !== '\n') i++
113 } else if (ch === ' ' || ch === '\t') endTok()
114 else if (ch === '\n' || ch === ';') {
115 seps.push(';')
116 endSeg()
117 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
118 tok += ch
119 has = true
120 } else if (ch === '&' || ch === '|') {
121 const twice = command[i + 1] === ch
122 if (twice) i++
123 seps.push(twice ? ch + ch : ch)
124 endSeg()
125 } else {
126 tok += ch
127 has = true
128 }
129 }
130 endSeg()
131 if (!quote) return out
132 seps.push(';')
133 return command
134 .split(/&&|\|\||;|\||\n/)
135 .map(s => s.trim().split(/\s+/).filter(Boolean))
136 .filter(s => s.length > 0)
137}
138
139function stripGlobals(tokens: string[]): string[] {
140 const out = [...tokens]
141 if (out[0] === 'env') out.shift()
142 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
143 return out
144}
145
146function gitVerb(tokens: string[]): number {
147 let i = 1
148 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
149 if (tokens[i] === '-C' || tokens[i] === '-c') i++
150 i++
151 }
152 return i
153}
154
155function gitReadOnly(verb: string, rest: string[]): boolean {
156 if (!verb || READ_ONLY_GIT.has(verb)) return true
157 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
158 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
159 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
160 return false
161}
162
163export function chainOf(command: string): { size: number; and: boolean } {
164 const seps: string[] = []
165 const size = segments(command, seps).length
166 return { size, and: seps.every(s => s === '&&') }
167}
168
169export function gitActions(command: string): GitAction[] {
170 const out: GitAction[] = []
171 for (const raw of segments(command)) {
172 const tokens = stripGlobals(raw)
173 const head = tokens[0]?.split('/').pop()
174 if (head === 'git') {
175 const i = gitVerb(tokens)
176 const verb = tokens[i] ?? ''
177 const rest = tokens.slice(i + 1)
178 if (gitReadOnly(verb, rest)) continue
179 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
180 if (spec) out.push({ kind: `git ${verb}`, ...spec })
181 } else if (head === 'gh') {
182 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
183 const spec = GH_VERBS[pair]
184 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
185 }
186 }
187 return out
188}
189
190export const BRANCH_ICON = ICON.branch
191
192const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
193const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
194const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
195
196function tidy(path: string): string {
197 const s = path.replace(/\/+$/, '')
198 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
199}
200
201export function resolve(cwd: string, p: string, home = ''): string {
202 let path = posix(p)
203 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
204 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
205 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
206 for (const seg of path.slice(drive.length).split('/')) {
207 if (!seg || seg === '.') continue
208 if (seg === '..') {
209 if (parts.length > 1) parts.pop()
210 } else parts.push(seg)
211 }
212 return tidy(parts.join('/'))
213}
214
215const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
216
217export function readOnly(command: string): boolean {
218 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
219 if (/[^>]>[^>&]|>>/.test(bare)) return false
220 return segments(command).every(raw => {
221 const tokens = stripGlobals(raw)
222 const head = tokens[0]?.split('/').pop() ?? ''
223 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
224 if (head === 'git') {
225 const i = gitVerb(tokens)
226 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
227 }
228 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
229 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
230 return READERS.has(head)
231 })
232}
233
234export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
235 let cwd = sessionCwd
236 const out = new Set<string>()
237 let listsPaths = false
238 for (const raw of segments(command)) {
239 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
240 const head = tokens[0]?.split('/').pop() ?? ''
241 if (head === 'cd' && tokens[1]) {
242 cwd = resolve(cwd, tokens[1], home)
243 continue
244 }
245 if (!READERS.has(head)) continue
246 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
247 if (OUTPUT_PATHS.has(head)) listsPaths = true
248 const args = tokens.slice(1)
249 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
250 for (let i = 0; i < args.length; i++) {
251 const a = args[i] ?? ''
252 if (a.startsWith('-')) {
253 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
254 continue
255 }
256 if (skipPattern) {
257 skipPattern = false
258 continue
259 }
260 if (/[*?<>|]/.test(a)) continue
261 out.add(resolve(cwd, a, home))
262 }
263 }
264 if (listsPaths) {
265 for (const line of stdout.split('\n').slice(0, 400)) {
266 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
267 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
268 }
269 }
270 return [...out].slice(0, 60)
271}
272hooks/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 141 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
126export const LIGHT_GIT_COLOR: Record<string, string> = {
127 A: '#14532d',
128 '?': '#14532d',
129 R: '#1e3a8a',
130 C: '#1e3a8a',
131 M: '#78350f',
132 T: '#78350f',
133}
134
135const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
136
137export function stronger(a: string | undefined, b: string): string {
138 if (!a) return b
139 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
140}
141hooks/tree.ts 355 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 const LIGHT_THEME: Theme = {
19 fg: '',
20 accent: '#1e3a8a',
21 muted: '#374151',
22 urgent: '#7f1d1d',
23 selection: '#e2e6ec',
24 bg: '',
25}
26
27export function emptyTree(root: string): FileTree {
28 return {
29 root,
30 nodes: [],
31 expanded: [],
32 cursor: '',
33 selected: '',
34 query: '',
35 showHidden: true,
36 showSize: false,
37 dirSizes: {},
38 git: {},
39 diff: {},
40 ignored: [],
41 untrackedDirs: [],
42 top: '',
43 prefix: '',
44 branch: null,
45 counts: {},
46 flash: [],
47 flashDim: [],
48 flashOn: false,
49 flashTones: {},
50 scroll: null,
51 preview: '',
52 previewTop: 0,
53 }
54}
55
56export function parseTheme(toml: string): Theme {
57 const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
58 return {
59 fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
60 accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
61 muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
62 urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
63 selection: get('selection') ?? DEFAULT_THEME.selection,
64 bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
65 }
66}
67
68export function join(dir: string, name: string): string {
69 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
70}
71
72let drives = false
73
74export function dirname(path: string): string {
75 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
76 const i = path.lastIndexOf('/')
77 return i <= 0 ? '/' : path.slice(0, i)
78}
79
80export function useDrives(on: boolean): void {
81 drives = on
82}
83
84export function drivesOn(): boolean {
85 return drives
86}
87
88export function posix(path: string): string {
89 if (!drives) return path
90 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
91}
92
93export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
94 if (dirs.length === 0) return nodes
95 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
96 const gone = new Set(dirs)
97 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
98}
99
100export function isAbsolute(path: string): boolean {
101 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
102}
103
104export function inside(root: string, path: string): boolean {
105 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
106}
107
108export function toNodes(dir: string, list: Entry[]): FileNode[] {
109 return list
110 .map(e => ({
111 id: join(dir, e.name),
112 parent: dir,
113 name: e.name,
114 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
115 hidden: e.name.startsWith('.'),
116 mtime: e.mtimeMs,
117 size: e.size,
118 loaded: false,
119 }))
120 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
121}
122
123export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
124 if (listed.size === 0) return nodes
125 const keep = new Map<string, FileNode>()
126 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
127 const freshIds = new Set<string>()
128 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
129 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
130 const under = (id: string) => {
131 if (gone.size === 0) return false
132 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
133 return false
134 }
135 const fresh: FileNode[] = []
136 for (const kids of listed.values()) {
137 for (const k of kids) {
138 if (under(k.id)) continue
139 const old = keep.get(k.id)
140 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
141 }
142 }
143 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
144 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
145}
146
147export type GitStatus = {
148 git: Record<string, string>
149 ignored: string[]
150 untrackedDirs: string[]
151 branch: Branch | null
152 files: Record<string, Change>
153}
154
155export type Change = 'new' | 'mod' | 'del'
156
157export function parseBranch(record: string): Branch {
158 const body = record.replace(/^## /, '')
159 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
160 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
161 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
162 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
163 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
164 return { head, upstream, ahead, behind }
165}
166
167export function mapper(root: string, prefix: string): (rel: string) => string | null {
168 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
169}
170
171export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
172 const map = mapper(root, prefix)
173 const git: Record<string, string> = {}
174 const ignored: string[] = []
175 const untrackedDirs: string[] = []
176 const files: Record<string, Change> = {}
177 let branch: Branch | null = null
178 const parts = stdout.split('\0')
179 for (let i = 0; i < parts.length; i++) {
180 const rec = parts[i] ?? ''
181 if (rec.startsWith('## ')) {
182 branch = parseBranch(rec)
183 continue
184 }
185 if (rec.length < 4) continue
186 const xy = rec.slice(0, 2)
187 const raw = rec.slice(3)
188 if (xy[0] === 'R' || xy[0] === 'C') i++
189 const path = map(raw)
190 if (!path) continue
191 if (xy === '!!') {
192 ignored.push(path)
193 continue
194 }
195 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
196 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
197 const letter =
198 xy === '??'
199 ? '?'
200 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
201 ? 'U'
202 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
203 if (!letter) continue
204 git[path] = stronger(git[path], letter)
205 let dir = dirname(path)
206 while (dir.length >= root.length && dir !== '/') {
207 git[dir] = stronger(git[dir], letter)
208 if (dir === root) break
209 dir = dirname(dir)
210 }
211 }
212 return { git, ignored, untrackedDirs, branch, files }
213}
214
215export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
216 const map = mapper(root, prefix)
217 const parts = stdout.split('\0')
218 for (let i = 0; i < parts.length; i++) {
219 const rec = parts[i] ?? ''
220 if (!rec) continue
221 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
222 if (!m) continue
223 let path = m[3] ?? ''
224 if (path === '') {
225 i += 2
226 path = parts[i] ?? ''
227 }
228 if (!path) continue
229 const add = m[1] === '-' ? 0 : Number(m[1])
230 const del = m[2] === '-' ? 0 : Number(m[2])
231 const abs = map(path)
232 if (!abs) continue
233 const prev = into[abs] ?? [0, 0]
234 into[abs] = [prev[0] + add, prev[1] + del]
235 }
236}
237
238export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
239 const out: Record<string, [number, number]> = { ...diff }
240 for (const [path, [add, del]] of Object.entries(diff)) {
241 let dir = dirname(path)
242 while (dir.length >= root.length && dir !== '/') {
243 const prev = out[dir] ?? [0, 0]
244 out[dir] = [prev[0] + add, prev[1] + del]
245 if (dir === root) break
246 dir = dirname(dir)
247 }
248 }
249 return out
250}
251
252export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
253 const out: Record<string, [number, number, number]> = {}
254 const slot = { new: 0, mod: 1, del: 2 } as const
255 for (const [path, change] of Object.entries(files)) {
256 let dir = dirname(path)
257 while (dir.length >= root.length && dir !== '/') {
258 const cur = out[dir] ?? [0, 0, 0]
259 cur[slot[change]] += 1
260 out[dir] = cur
261 if (dir === root) break
262 dir = dirname(dir)
263 }
264 }
265 return out
266}
267
268export function underAny(id: string, set: Set<string>, root: string): boolean {
269 if (set.size === 0) return false
270 let cur = id
271 while (cur.length >= root.length) {
272 if (set.has(cur)) return true
273 const up = dirname(cur)
274 if (up === cur) break
275 cur = up
276 }
277 return false
278}
279
280export function ancestorsOf(id: string, root: string): string[] {
281 const out: string[] = []
282 if (!root) return out
283 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
284 return out
285}
286
287export function relative(root: string, path: string): string {
288 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
289}
290
291export function visibleRows(t: FileTree): Row[] {
292 const kids = new Map<string, FileNode[]>()
293 for (const n of t.nodes) {
294 if (!t.showHidden && n.hidden) continue
295 const list = kids.get(n.parent)
296 if (list) list.push(n)
297 else kids.set(n.parent, [n])
298 }
299 const q = t.query.trim().toLowerCase()
300 let keep: Set<string> | null = null
301 if (q) {
302 keep = new Set()
303 for (const n of t.nodes) {
304 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
305 keep.add(n.id)
306 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
307 }
308 }
309 const open = new Set(t.expanded)
310 const rows: Row[] = []
311 const walk = (dir: string, depth: number) => {
312 for (const n of kids.get(dir) ?? []) {
313 if (keep && !keep.has(n.id)) continue
314 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
315 rows.push({ node: n, depth, open: isOpen })
316 if (isOpen) walk(n.id, depth + 1)
317 }
318 }
319 walk(t.root, 0)
320 return rows
321}
322
323export function stamp(ms: number): string {
324 if (!ms) return ''
325 const d = new Date(ms)
326 const p = (v: number) => String(v).padStart(2, '0')
327 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
328}
329
330const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
331
332export function age(ms: number, now: number): string {
333 if (!ms) return ''
334 const s = (now - ms) / 1000
335 if (s < 60 && s > -60) return 'now'
336 if (s > 0 && s < 3600) return `${Math.floor(s / 60)}m`
337 if (s > 0 && s < 86_400) return `${Math.floor(s / 3600)}h`
338 if (s > 0 && s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`
339 const d = new Date(ms)
340 return Math.abs(s) < 365 * 86_400 ? `${MONTHS[d.getMonth()]} ${d.getDate()}` : String(d.getFullYear())
341}
342
343export function formatSize(bytes: number): string {
344 if (bytes < 0) return '?'
345 if (bytes < 1024) return `${bytes} B`
346 const units = ['K', 'M', 'G', 'T']
347 let v = bytes / 1024
348 let i = 0
349 while (v >= 1024 && i < units.length - 1) {
350 v /= 1024
351 i++
352 }
353 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
354}
355types/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