Local fork of filetree (data-goblin/claude-code-filetree 0.2.25) with a button that collapses the dock to a strip on the right and expands it again

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