IDE-style file tree that shows only the files Claude touched (fork of data-goblin/claude-code-filetree 0.2.26)

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