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

A Claude Code mod that adds a file tree pane to the right sidebar. It shows git status per file and folder, and it lights up the files Claude reads, writes and commits while it works.
TS, JS, {}, M↓) and each folder a block in a colour for its kind (green src, blue docs, teal tests, red dist). With a Nerd Font in the terminal the Nerd Font icons show instead, in the same colours. Git-ignored files and folders fade. Indent guides show how deep each row sits, and the guide of the folder holding the cursor is drawn brighter. Long names are cut in the middle so the start and the extension stay readable. It follows the session cwd, or stays on a folder you pick with /filetree <path>.+N -N lines changed. Folders keep the text colour, show a dot in their status colour, and show file counts such as ?1 M2 D1. A branch row shows the branch, upstream, ahead and behind. The repo totals (or "clean") sit beside the title in the desktop app and on the branch row in the terminal.git or gh command that changes something, the footer shows it as running, then done (with the short commit hash) or failed.@path mentions in a prompt reveal that file in the tree.Oct 12 09:30, with the year in place of the time for older years. Narrow panes show a shorter date, and a row with too little room for its name drops the date. Folder sizes are worked out in the background for the rows on screen./filetree [path] opens the pane and focuses it. Without a path it shows the cwd and follows it. With a path (relative, absolute or ~/...) it shows that folder and stops following the cwd. In the terminal it needs the fullscreen layout (/tui fullscreen) and at least 110 columns; it replies with what to fix when either is missing.
Up/k and Down/j move, PageUp/PageDown move 10 rows, Home/End jump to the ends. Right/l opens a folder, Left/h closes it or moves to the parent. Enter opens a file or toggles a folder. Space shows a file in Quick Look on macOS (on a folder, or on other systems, it acts like a click). y copies the relative path, Y the absolute path.Enter ("jump") opens the folders of the first 10 hits, moves the cursor to the first one and clears the field. "Clear search" clears it ("clear" in the terminal).preview): Markdown is rendered and code is highlighted. Arrow keys, j/k, Page keys, Home/End and the wheel scroll it. Left/h or "Close preview" goes back, and "Open in default app" opens the file in the default app. In the terminal these buttons read ← files and ↗ open. Files over 4 MB and binary files show a note instead.All settings are in /config under this plugin.
| Setting | Default | Options | Meaning |
|---|---|---|---|
activity | reads and writes | reads and writes, writes, reads, none | Which Claude activity shimmers in the tree. |
glyphs | auto | auto, nerd, plain | auto uses Nerd Font icons when one is found (always over SSH), and coloured type badges and folders in the desktop app or without a Nerd Font. nerd always uses Nerd Font icons. plain always uses the badges. |
follow | on | on, off | on scrolls the tree to what Claude touches. off keeps your scroll position; highlights still show. |
start | on | on, off | on opens the pane when a session starts. off waits for /filetree. |
column | date | date, age, size | What the right column shows at start. The sizes button switches between size and date or age. |
hints | on | on, off | Shows a line outside a git repo: "Git status starts after git init". |
open | empty | free text | Empty opens files in the default app. preview shows them in the pane. Any other text is a command run without a shell, with {path} and {dir} filled in (the path goes last when {path} is missing). |
session.start, command.run (for /filetree), tool.call (after Edit, Write, NotebookEdit, Bash, PowerShell, Read, Grep and Glob finish), prompt.submit, prompt.attachment, ui.render, ui.message, ui.focus and ui.scroll for its own pane, and the classic SessionStart, CwdChanged and FileChanged events. Tool calls run as usual; the mod reads their input and result afterwards./filetree.SessionStart it asks Claude Code to watch the repo's .git/index and .git/HEAD, and reloads the tree when they change..git file when looking for the git folder, and ~/.local/state/omarchy/current/theme/colors.toml for Omarchy theme colours (polled every 2 seconds while it exists).$TMPDIR/filetree-<session id>-<n>.mark with touch and deletes it with rm -f after the scan. Its own tree, theme and activity state goes to Claude Code's plugin state store.cd or a .git change. They only redraw, reload or tidy the pane.y/Y.OS, HOME, USERPROFILE, TMPDIR, SSH_CONNECTION, SSH_TTY, HERDR_ENV, HERDR_BIN_PATH.git --no-optional-locks -C <root> with rev-parse, status, diff HEAD --numstat, diff --cached --name-only, diff-tree, ls-files. These only read the repo.find to list files changed since a shell command started and to build the search list outside a repo; du -skxH for folder sizes.uname -s once, and a sh -c font check that uses fc-list, ls, stat, ps, date and awk. herdr --version only inside herdr.open on macOS, gio, xdg-open, xdg-mime and file through sh and setsid on Linux, powershell on Windows, or your "Open with" command. Space on macOS runs qlmanage -p and an osascript call that brings Quick Look to the front.git and gh commands Claude runs to label them, and runs none of them itself.claude plugin marketplace add MrValraven/pulse-mods
claude plugin install pulse-filetree@pulse-mods --scope user
Then start a new Claude Code session.
Based on data-goblin/claude-code-filetree by Kurt Buhler, MIT licence. See LICENSE.
hooks/register.tsx 1809 lines1import { type BuiltinToolResults, type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { LINUX_LAUNCH, openCommand, openWithCommand } from './open'
3import type { ButtonProps } from './button'
4
5import type { Activity, FileNode, FileTree, Theme } from '../types'
6import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve } from './git'
7import type { RowSpec, RowsProps, Seg } from './rows'
8import { CHEVRON_CLOSED, CHEVRON_OPEN, fileBadge, fileIcon, folderInk, onInkFor } from './icons'
9import { buildPalette, dateLabel, gitInk, inkOnPane, mix, type Palette, RECENT_MS, RECENT_SOFT_MS, recentLevel, truncateMiddle, widthOf } from './look'
10import {
11 age,
12 ancestorsOf,
13 type Change,
14 DEFAULT_THEME,
15 dirname,
16 emptyTree,
17 formatSize,
18 inside,
19 isAbsolute,
20 join,
21 mapper,
22 parseGit,
23 parseNumstat,
24 parseTheme,
25 posix,
26 relative,
27 dropBelow,
28 useDrives,
29 rollCounts,
30 replaceChildren,
31 rollUp,
32 toNodes,
33 underAny,
34 visibleRows,
35} from './tree'
36
37const TREE = { plugin: 'pulse-filetree', key: 'tree' } as const
38const THEME = { plugin: 'pulse-filetree', key: 'theme' } as const
39const ACTIVITY = { plugin: 'pulse-filetree', key: 'activity' } as const
40const PANE = 'filetree'
41const BRANCH_ROW = '#branch'
42const FLASH_MS = 2700
43const RUNNING_MAX_MS = 600_000
44const NO_REPO_DEPTH = 4
45const DOUBLE_MS = 450
46const FIND_LIMIT = 200
47const ROWS_BUDGET = 80_000
48const READ_REVEAL_LIMIT = 12
49const SEARCH_REVEAL_LIMIT = 60
50const ACTIVITY_TTL_MS = 45_000
51const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
52const THEME_POLL_MS = 2000
53const FONT_SCRIPT =
54 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
55 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
56 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
57 'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
58 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
59 'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
60 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
61const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
62const HOME_PRUNE = ['Library', 'AppData', '.Trash']
63const SIZE_TIMEOUT_MS = 15_000
64const SIZE_JOBS = 2
65const SIZE_WALK_LIMIT = 50_000
66const AGE_TICK_MS = 60_000
67const NARROW_COLUMNS = 48
68const VERY_NARROW_COLUMNS = 34
69
70let blink: Timer | null = null
71let themePoll: Timer | null = null
72let ageTick: Timer | null = null
73let themeMtime: number | null = null
74let generation = 0
75let lastPress = { key: '', at: 0 }
76let noNerd = false
77let glyphSetting = 'auto'
78let openWithSetting = ''
79let previewed: { path: string; text: string; note: string } | null = null
80let previewRows = 20
81let follow = true
82let followClaude = true
83let scanning: Promise<void> | null = null
84let scanJobs: Job[] = []
85let gitRun: Promise<void> | null = null
86let gitNext: Promise<void> | null = null
87let queuedReads: string[] = []
88let showReads = true
89let showWrites = true
90let searchIndex: { root: string; paths: Promise<string[]> } | null = null
91let activityId = 0
92let pointer = true
93let hovered = ''
94let view = { from: 0, max: 0 }
95let nativeScroll = false
96let lastSync = 0
97let noDock = false
98let home = ''
99let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
100let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
101let markId = 0
102let listSeq = 0
103let lastRoot = ''
104let sizeDefault = false
105let showAge = false
106let showHints = true
107let sizeEpoch = 0
108let sizeActive = 0
109let sizeQueue: string[] = []
110const sizing = new Set<string>()
111const listLatest = new Map<string, number>()
112const unreadable = new Set<string>()
113const background = new Map<string, Background>()
114// Files Claude touched lately, so a quiet mark stays beside them after the shimmer ends.
115const recentTouches = new Map<string, { tone: string; at: number }>()
116// Roots whose first listing came back, so an empty tree can say loading or empty.
117const listedRoots = new Set<string>()
118// One timer redraws the tree when the next recent mark softens or ends.
119let recentTimer: Timer | null = null
120// The search still running, so an empty tree can say it is searching while it runs.
121let pendingQuery = ''
122
123function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
124 platform ??= (async () => {
125 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
126 try {
127 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
128 } catch {
129 return 'linux'
130 }
131 })()
132 return platform
133}
134
135async function cwdOf($: EngineInterface): Promise<string> {
136 return posix(await $.session.cwd())
137}
138
139function clean(segs: Seg[]): Seg[] {
140 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
141 return segs
142}
143
144async function get($: EngineInterface): Promise<FileTree> {
145 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
146}
147
148async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
149 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
150}
151
152function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
153 return put($, t => ({ ...t, ...fn(t) }))
154}
155
156async function activities($: EngineInterface): Promise<Activity[]> {
157 return (await $.state.get(ACTIVITY)).value ?? []
158}
159
160async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
161 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
162}
163
164async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
165 try {
166 const entries = await $.fs.list(dir)
167 const resolved = await Promise.all(
168 entries.map(async e => {
169 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
170 try {
171 const target = await $.fs.stat(join(dir, e.name))
172 return target.kind === 'dir'
173 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
174 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
175 } catch {
176 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
177 }
178 }),
179 )
180 unreadable.delete(dir)
181 return toNodes(dir, resolved)
182 } catch (err) {
183 if (!(await exists($, dir))) return []
184 if (!unreadable.has(dir)) {
185 unreadable.add(dir)
186 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
187 }
188 return null
189 }
190}
191
192async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
193 const root = (await get($)).root
194 const seq = ++listSeq
195 for (const dir of dirs) listLatest.set(dir, seq)
196 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
197 const listed = new Map<string, FileNode[]>()
198 for (const [dir, kids] of results) {
199 if (listLatest.get(dir) !== seq) continue
200 listLatest.delete(dir)
201 if (kids) listed.set(dir, kids)
202 }
203 if (listed.has(root)) listedRoots.add(root)
204 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
205 return listed
206}
207
208async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
209 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
210}
211
212async function detectRepo($: EngineInterface): Promise<void> {
213 const t = await get($)
214 if (!t.root) return
215 let top = ''
216 let prefix = ''
217 try {
218 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
219 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
220 prefix = p.trim()
221 top = tl.trim()
222 } catch {
223 top = ''
224 }
225 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
226}
227
228async function readGit($: EngineInterface): Promise<void> {
229 const t = await get($)
230 if (!t.root || !t.top) return
231 const root = t.root
232 try {
233 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
234 if (status.exitCode !== 0) return
235 const parsed = parseGit(status.stdout, root, t.prefix)
236 const diff: Record<string, [number, number]> = {}
237 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
238 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
239 const files = { ...parsed.files }
240 if (parsed.untrackedDirs.length) {
241 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
242 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
243 }
244 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
245 dirty = { root, files }
246 await patch($, cur =>
247 cur.root === root
248 ? {
249 git: parsed.git,
250 ignored: parsed.ignored,
251 untrackedDirs: parsed.untrackedDirs,
252 branch: parsed.branch,
253 counts: rollCounts(files, root),
254 diff: rollUp(diff, root),
255 }
256 : {},
257 )
258 } catch {
259 return
260 }
261}
262
263function refreshGit($: EngineInterface): Promise<void> {
264 if (!gitRun) {
265 gitRun = readGit($).finally(() => {
266 gitRun = null
267 })
268 return gitRun
269 }
270 const rerun = () => {
271 gitNext = null
272 return refreshGit($)
273 }
274 gitNext ??= gitRun.then(rerun, rerun)
275 return gitNext
276}
277
278async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
279 const prev = await get($)
280 generation += 1
281 blink?.cancel()
282 blink = null
283 lastRoot = root
284 searchIndex = null
285 sizeEpoch += 1
286 sizeQueue = []
287 recentTouches.clear()
288 recentTimer?.cancel()
289 recentTimer = null
290 listedRoots.delete(root)
291 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
292 const title = `Files: ${root.split('/').pop() || root}`
293 if (focus) await $.ui.open({ id: PANE, title, focus: true })
294 else if (!noDock) await $.ui.open({ id: PANE, title })
295 await loadDirs($, [root])
296 await detectRepo($)
297 await refreshGit($)
298}
299
300async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
301 const tried = new Set<string>()
302 for (let pass = 0; pass < 32; pass++) {
303 const t = await get($)
304 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
305 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
306 const need = new Set<string>()
307 for (const p of paths) {
308 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
309 if (!loaded.has(dir)) {
310 if (!tried.has(dir)) need.add(dir)
311 break
312 }
313 }
314 }
315 if (need.size === 0) return
316 for (const dir of need) tried.add(dir)
317 await loadDirs($, [...need])
318 }
319}
320
321function pruneArgs(root: string, ignored: string[] = []): string[] {
322 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
323 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
324}
325
326async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
327 const stats = await Promise.all(
328 paths.map(async p => {
329 try {
330 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
331 } catch {
332 return ''
333 }
334 }),
335 )
336 return stats.filter(Boolean)
337}
338
339function broad(root: string): boolean {
340 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
341}
342
343function rootOfDisk(root: string): boolean {
344 return root === '/' || /^[A-Za-z]:\/$/.test(root)
345}
346
347async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
348 if (rootOfDisk(root)) return []
349 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
350 try {
351 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 })
352 return run.stdout.split('\0').filter(p => p && p !== root)
353 } catch {
354 return []
355 }
356}
357
358async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
359 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
360 if (dirty.root !== root) return { hits: [], gone: [] }
361 const entries = Object.entries(dirty.files)
362 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
363 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
364 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
365}
366
367type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
368
369async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
370 const ms = await $.clock.now()
371 const os = await osName($)
372 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
373 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
374 try {
375 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
376 return { ms, mark, os }
377 } catch {
378 return { ms, mark: '', os }
379 }
380}
381
382async function walkSize($: EngineInterface, dir: string): Promise<number> {
383 let total = 0
384 let seen = 0
385 let level = [dir]
386 while (level.length) {
387 const next: string[] = []
388 for (const d of level) {
389 let entries
390 try {
391 entries = await $.fs.list(d)
392 } catch {
393 continue
394 }
395 for (const e of entries) {
396 if (++seen > SIZE_WALK_LIMIT) return -1
397 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
398 else if (e.kind === 'file') total += e.size
399 }
400 }
401 level = next
402 }
403 return total
404}
405
406async function dirSize($: EngineInterface, dir: string): Promise<number> {
407 if (rootOfDisk(dir)) return -1
408 if ((await osName($)) === 'win32') return walkSize($, dir)
409 try {
410 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
411 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
412 return Number.isFinite(kb) ? kb * 1024 : -1
413 } catch {
414 return -1
415 }
416}
417
418function pumpSizes($: EngineInterface): void {
419 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
420 const dir = sizeQueue.shift() ?? ''
421 const epoch = sizeEpoch
422 sizing.add(dir)
423 sizeActive += 1
424 void dirSize($, dir)
425 .then(async bytes => {
426 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
427 const cur = await get($)
428 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
429 })
430 .catch(() => undefined)
431 .finally(() => {
432 sizing.delete(dir)
433 sizeActive -= 1
434 pumpSizes($)
435 })
436 }
437}
438
439function wantSizes($: EngineInterface, dirs: string[]): void {
440 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
441 pumpSizes($)
442}
443
444async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
445 sizeEpoch += 1
446 sizeQueue = []
447 await patch($, cur => {
448 if (!paths) return { dirSizes: {} }
449 const keep: Record<string, number> = {}
450 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
451 return { dirSizes: keep }
452 })
453}
454
455function openDirs(t: FileTree): string[] {
456 const open = new Set(t.expanded)
457 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
458}
459
460function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
461 if (followClaude) return { scroll: null }
462 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
463 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
464 return at < 0 ? {} : { scroll: at }
465}
466
467async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
468 const unique = Object.keys(tones)
469 if (unique.length === 0) return
470 const mine = ++generation
471 const root = (await get($)).root
472 blink?.cancel()
473 blink = null
474 await patch($, cur => {
475 if (cur.root !== root) return {}
476 const open = new Set(cur.expanded)
477 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
478 const dim = new Set<string>()
479 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
480 if (cur.flashOn) {
481 for (const id of cur.flashDim) dim.add(id)
482 }
483 for (const id of unique) {
484 const tone = tones[id] ?? 'orange'
485 all[id] = tone
486 if (id === BRANCH_ROW) continue
487 const chain = ancestorsOf(id, cur.root)
488 if (!chain.some(a => !open.has(a))) continue
489 for (const a of chain) {
490 if (!open.has(a)) {
491 dim.add(a)
492 all[a] = all[a] ?? tone
493 }
494 open.add(a)
495 }
496 }
497 for (const id of bright) dim.delete(id)
498 return {
499 flash: [...bright],
500 flashDim: [...dim],
501 flashOn: true,
502 flashTones: all,
503 expanded: [...open],
504 ...keepTop(cur, [...open]),
505 }
506 })
507 const touchedAt = await $.clock.now()
508 for (const id of unique) if (id !== BRANCH_ROW) recentTouches.set(id, { tone: tones[id] ?? 'orange', at: touchedAt })
509 scheduleRecent($, touchedAt)
510 if (generation !== mine) return
511 blink = $.clock.after(FLASH_MS, () => {
512 if (generation !== mine) return
513 blink = null
514 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
515 })
516}
517
518// Sets the one timer for the next moment a recent mark softens or ends, redraws then and
519// looks again; marks past their time are dropped on the way.
520function scheduleRecent($: EngineInterface, now: number): void {
521 recentTimer?.cancel()
522 recentTimer = null
523 let next = Infinity
524 for (const [id, touch] of recentTouches) {
525 if (now - touch.at >= RECENT_MS) {
526 recentTouches.delete(id)
527 continue
528 }
529 for (const edge of [touch.at + RECENT_SOFT_MS, touch.at + RECENT_MS]) if (edge > now && edge < next) next = edge
530 }
531 if (next === Infinity) return
532 recentTimer = $.clock.after(next - now + 50, () => {
533 recentTimer = null
534 void $.ui.invalidate('ui.render')
535 void $.clock.now().then(later => scheduleRecent($, later))
536 })
537}
538
539async function followCwd($: EngineInterface): Promise<boolean> {
540 if (!follow) return false
541 const cwd = await cwdOf($)
542 const t = await get($)
543 if (t.root === cwd) return false
544 await reset($, cwd)
545 return true
546}
547
548type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
549type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
550type Background = { p: Pending | null; job: Job; reads: string[] }
551type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
552type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
553
554const PROOF: Record<string, (op: GitOperation) => boolean> = {
555 push: op => Boolean(op.push),
556 merge: op => op.branch?.action === 'merged',
557 rebase: op => op.branch?.action === 'rebased',
558 'pr create': op => op.pr?.action === 'created',
559 'pr merge': op => op.pr?.action === 'merged',
560 'pr comment': op => op.pr?.action === 'commented',
561}
562
563async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
564 const now = await $.clock.now()
565 const ids = actions.map(() => ++activityId)
566 await setActivities($, cur => [
567 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
568 ...actions.map((a, i) => ({
569 id: ids[i] ?? 0,
570 kind: a.kind,
571 label: a.running,
572 state: 'running' as const,
573 detail: '',
574 at: now,
575 tone: a.tone,
576 nerd: a.icon.nerd,
577 plain: a.icon.plain,
578 })),
579 ])
580 return ids
581}
582
583async function headOf($: EngineInterface, cwd: string): Promise<string> {
584 try {
585 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
586 return run.exitCode === 0 ? run.stdout.trim() : ''
587 } catch {
588 return ''
589 }
590}
591
592async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
593 const chain = chainOf(p.command)
594 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
595 return p.actions.map(a => {
596 if (a.verb === 'commit') {
597 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
598 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
599 }
600 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
601 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
602 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
603 })
604}
605
606async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
607 const now = await $.clock.now()
608 await setActivities($, cur =>
609 cur.map(a => {
610 const i = p.ids.indexOf(a.id)
611 const result = i < 0 ? undefined : results[i]
612 return result ? { ...a, ...result, at: now } : a
613 }),
614 )
615 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
616}
617
618function failAll(p: Pending): Outcome[] {
619 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
620}
621
622function proven(p: Pending | null, results: Outcome[]): GitAction[] {
623 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
624}
625
626function taskEnds(text: string): [string, string][] {
627 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
628 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
629 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
630 return id && status ? [[id, status] as [string, string]] : []
631 })
632}
633
634async function settleBackground($: EngineInterface, text: string): Promise<void> {
635 if (background.size === 0 || !text.includes('<task-notification>')) return
636 for (const [id, status] of taskEnds(text)) {
637 const task = background.get(id)
638 if (!task) continue
639 background.delete(id)
640 const ok = status === 'completed'
641 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
642 if (task.p) await finishGit($, task.p, results)
643 if (showReads) queuedReads.push(...task.reads)
644 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
645 }
646}
647
648async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
649 const map = mapper(root, prefix)
650 try {
651 const run = committed
652 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
653 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
654 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
655 } catch {
656 return []
657 }
658}
659
660async function gitDir($: EngineInterface, cwd: string): Promise<string> {
661 for (let dir = cwd, i = 0; i < 64; i++) {
662 try {
663 const dot = join(dir, '.git')
664 const st = await $.fs.stat(dot)
665 if (st.kind === 'dir') return dot
666 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
667 if (ref) return resolve(dir, ref, home)
668 } catch {
669 const up = dirname(dir)
670 if (up === dir) return ''
671 dir = up
672 continue
673 }
674 return ''
675 }
676 return ''
677}
678
679async function sync($: EngineInterface, force = false): Promise<void> {
680 const now = await $.clock.now()
681 if (!force && now - lastSync < 2_000) return
682 lastSync = now
683 searchIndex = null
684 const t = await get($)
685 if (!t.root) return
686 await loadDirs($, [t.root, ...openDirs(t)])
687 if (t.top) await refreshGit($)
688}
689
690async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
691 const t = await get($)
692 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
693 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
694 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
695}
696
697async function exists($: EngineInterface, path: string): Promise<boolean> {
698 try {
699 await $.fs.stat(path)
700 return true
701 } catch {
702 return false
703 }
704}
705
706async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
707 const reads = queuedReads
708 queuedReads = []
709 if (await followCwd($)) return
710 const t = await get($)
711 if (!t.root) return
712 const actions = jobs.flatMap(j => j.actions)
713 const writers = jobs.filter(j => !j.readOnly)
714 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 })
715 const writes = !jobs.every(j => j.readOnly)
716 const before = dirty.root === t.root ? dirty.files : {}
717 if (jobs.some(j => j.initRepo)) await detectRepo($)
718 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
719 const probed = await get($)
720 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
721 if (writes) {
722 searchIndex = null
723 await staleSizes($)
724 }
725 const fresh = await get($)
726 const ignored = new Set(fresh.ignored)
727 const tones: Record<string, string> = {}
728 if (writes) {
729 const found = fresh.top
730 ? await changedInRepo($, t.root, since, before, fresh.ignored)
731 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
732 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
733 await revealPaths($, hits)
734 const loaded = await get($)
735 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
736 const listed = await loadDirs($, dirs)
737 if (!fresh.top && since.os === 'win32')
738 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
739 await patch($, cur => {
740 const ids = new Set(cur.nodes.map(n => n.id))
741 return { expanded: cur.expanded.filter(id => ids.has(id)) }
742 })
743 const present = new Set((await get($)).nodes.map(n => n.id))
744 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
745 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
746 }
747 if (showReads) {
748 const found: string[] = []
749 for (const r of reads) {
750 if (found.length >= READ_REVEAL_LIMIT) break
751 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
752 }
753 if (found.length) {
754 await revealPaths($, found)
755 const shown = new Set((await get($)).nodes.map(n => n.id))
756 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
757 }
758 }
759 if (showWrites && actions.length) {
760 const final = await get($)
761 const committed = actions.some(a => a.verb === 'commit')
762 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
763 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
764 await revealPaths($, paths)
765 const shown = new Set((await get($)).nodes.map(n => n.id))
766 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
767 }
768 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
769 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
770 else if (committed) tones[BRANCH_ROW] = 'green'
771 }
772 await flash($, tones)
773}
774
775function scheduleScan($: EngineInterface, job: Job): void {
776 scanJobs.push(job)
777 if (scanning) return
778 scanning = (async () => {
779 while (scanJobs.length) {
780 const jobs = scanJobs
781 scanJobs = []
782 try {
783 await afterBash($, jobs)
784 } finally {
785 const marks = jobs.map(j => j.since.mark).filter(Boolean)
786 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
787 }
788 }
789 })().finally(() => {
790 scanning = null
791 })
792}
793
794async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
795 if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
796 if (await followCwd($)) return
797 const t = await get($)
798 if (!t.root) return
799 const within = paths.map(posix).filter(p => inside(t.root, p))
800 if (within.length === 0) return
801 if (tone !== 'purple') {
802 searchIndex = null
803 await staleSizes($, within)
804 await revealPaths($, within.map(dirname))
805 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
806 await refreshGit($)
807 } else await revealPaths($, within)
808 if (!show) return
809 const tones: Record<string, string> = {}
810 for (const p of within) tones[p] = tone
811 await flash($, tones)
812}
813
814async function reveal($: EngineInterface, paths: string[]): Promise<void> {
815 if (await followCwd($)) return
816 await revealPaths($, paths)
817 await patch($, cur => {
818 const open = new Set(cur.expanded)
819 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
820 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
821 })
822}
823
824async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
825 const out: string[] = []
826 let level = [root]
827 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
828 const next: string[] = []
829 for (const dir of level) {
830 for (const n of (await list($, dir)) ?? []) {
831 if (n.kind === 'dir') {
832 if (!PRUNE.includes(n.name)) next.push(n.id)
833 } else out.push(n.id)
834 }
835 if (out.length >= limit) break
836 }
837 level = next
838 }
839 return out
840}
841
842async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
843 try {
844 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
845 const run = t.top
846 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
847 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
848 if (t.top && run.exitCode !== 0) return null
849 const paths = run.stdout.split('\0').filter(Boolean)
850 if (run.isStdoutTruncated) paths.pop()
851 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
852 } catch {
853 return null
854 }
855}
856
857function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
858 if (searchIndex?.root !== t.root) {
859 const index: { root: string; paths: Promise<string[]> } = {
860 root: t.root,
861 paths: listAll($, t).then(paths => {
862 if (paths === null && searchIndex === index) searchIndex = null
863 return paths ?? []
864 }),
865 }
866 searchIndex = index
867 }
868 return searchIndex.paths
869}
870
871async function search($: EngineInterface, query: string): Promise<string[]> {
872 if (!(await get($)).query.trim()) searchIndex = null
873 await patch($, () => ({ query }))
874 const q = query.trim().toLowerCase()
875 if (!q) return []
876 pendingQuery = query
877 try {
878 const t = await get($)
879 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
880 if ((await get($)).query !== query) return []
881 await revealPaths($, hits)
882 const shown = await get($)
883 const ids = new Set(shown.nodes.map(n => n.id))
884 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
885 if (stale.length) await loadDirs($, stale)
886 return hits
887 } finally {
888 if (pendingQuery === query) {
889 pendingQuery = ''
890 void $.ui.invalidate('ui.render')
891 }
892 }
893}
894
895async function jump($: EngineInterface, query: string): Promise<void> {
896 const hits = (await search($, query)).slice(0, 10)
897 await patch($, cur => {
898 const open = new Set(cur.expanded)
899 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
900 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
901 })
902}
903
904async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
905 try {
906 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
907 return out === 'stale' || out === 'missing' ? out : 'ok'
908 } catch {
909 return 'missing'
910 }
911}
912
913async function finePointerOk($: EngineInterface): Promise<boolean> {
914 if (!(await $.env.get('HERDR_ENV'))) return true
915 try {
916 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
917 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
918 return major * 1e6 + minor * 1e3 + fix >= 9_001
919 } catch {
920 return false
921 }
922}
923
924const PREVIEW_CHARS = 90_000
925const PREVIEW_LINE = 2_000
926
927function fitSerialized(lines: string[], max: number): string[] {
928 let size = 0
929 for (let i = 0; i < lines.length; i++) {
930 size += JSON.stringify(lines[i]).length
931 if (size > max) return lines.slice(0, i)
932 }
933 return lines
934}
935
936async function loadPreview($: EngineInterface, path: string): Promise<void> {
937 let text = ''
938 let note = ''
939 try {
940 const stat = await $.fs.stat(path)
941 if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
942 else {
943 text = await $.fs.read(path)
944 if (text.slice(0, 8000).includes('\0')) {
945 text = ''
946 note = `binary file, ${formatSize(stat.size)}`
947 }
948 }
949 } catch (err) {
950 note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
951 }
952 const total = text.length
953 let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
954 if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
955 lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
956 const fit = fitSerialized(lines, PREVIEW_CHARS)
957 if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
958 previewed = { path, text: fit.join('\n'), note }
959}
960
961async function showPreview($: EngineInterface, path: string): Promise<void> {
962 await loadPreview($, path)
963 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
964}
965
966async function closePreview($: EngineInterface): Promise<void> {
967 await patch($, () => ({ preview: '', previewTop: 0 }))
968}
969
970async function scrollPreview($: EngineInterface, by: number): Promise<void> {
971 const t = await get($)
972 const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
973 const top = Math.max(0, Math.min(last, t.previewTop + by))
974 if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
975}
976
977function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
978 const lines = text.split('\n')
979 const from = Math.max(0, Math.min(top, lines.length - 1))
980 let shown = lines.slice(from, from + rows)
981 if (markdown) {
982 // Reopen a code fence the window starts inside, so the rest is not read as prose.
983 let fence = ''
984 let opener = ''
985 for (const line of lines.slice(0, from)) {
986 const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
987 if (!m) continue
988 const mark = m[1] ?? ''
989 if (!fence) {
990 fence = mark
991 opener = line.trimStart()
992 } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
993 }
994 if (fence) shown = [opener, ...shown]
995 }
996 return { text: shown.join('\n'), top: from }
997}
998
999async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
1000 if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
1001 const os = await osName($)
1002 const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
1003 const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
1004 const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
1005 try {
1006 const run = await $.process.run(argv, init)
1007 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1008 } catch {
1009 $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
1010 }
1011}
1012
1013async function quickLook($: EngineInterface, path: string): Promise<void> {
1014 try {
1015 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 })
1016 } catch {
1017 $.ui.toast(`could not preview ${path} with Quick Look`)
1018 }
1019}
1020
1021async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1022 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1023 await patch($, t => {
1024 const open = new Set(t.expanded)
1025 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1026 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1027 })
1028}
1029
1030async function press($: EngineInterface, n: FileNode): Promise<void> {
1031 const now = await $.clock.now()
1032 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1033 lastPress = { key: isDouble ? '' : n.id, at: now }
1034 if (!isDouble) {
1035 await toggle($, n)
1036 return
1037 }
1038 await openNode($, n)
1039}
1040
1041async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1042 if (n.kind === 'dir') {
1043 follow = false
1044 await reset($, n.id)
1045 } else await openFile($, n.id)
1046}
1047
1048async function loadTheme($: EngineInterface): Promise<void> {
1049 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
1050 try {
1051 const stat = await $.fs.stat(path)
1052 if (stat.kind !== 'file') throw new Error('no theme file')
1053 if (stat.mtimeMs === themeMtime) return
1054 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1055 themeMtime = stat.mtimeMs
1056 } catch {
1057 if (themeMtime === 0) return
1058 await $.state.set(THEME, DEFAULT_THEME)
1059 themeMtime = 0
1060 }
1061}
1062
1063async function pressHeader($: EngineInterface, id: string): Promise<void> {
1064 const t = await get($)
1065 if (id === 'up') {
1066 follow = false
1067 await reset($, dirname(t.root))
1068 } else if (id === 'cwd') {
1069 follow = true
1070 await reset($, await cwdOf($))
1071 } else if (id === 'refresh') {
1072 searchIndex = null
1073 await staleSizes($)
1074 await loadDirs($, [t.root, ...t.expanded])
1075 await detectRepo($)
1076 await refreshGit($)
1077 if (t.query.trim()) await search($, t.query)
1078 } else if (id === 'hidden') await patch($, cur => ({ showHidden: !cur.showHidden }))
1079 else if (id === 'size') await patch($, cur => ({ showSize: !cur.showSize }))
1080 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)) }))
1081 else if (id === 'unselect') await patch($, () => ({ selected: '' }))
1082 else if (id === 'external' && t.preview) await openFile($, t.preview, true)
1083}
1084
1085function shortPath(path: string): string {
1086 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1087}
1088
1089export const register: Register = (on, options) => {
1090 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
1091 const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
1092 showReads = activity.includes('reads')
1093 showWrites = activity.includes('writes')
1094 followClaude = options?.follow !== 'off'
1095 sizeDefault = options?.column === 'size'
1096 showAge = options?.column === 'age'
1097 showHints = options?.hints !== 'off'
1098 openWithSetting = typeof options?.open === 'string' ? options.open : ''
1099 noDock = options?.start === 'off'
1100 on('session.start', async ($, e, next) => {
1101 await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
1102 const windows = (await $.env.get('OS')) === 'Windows_NT'
1103 useDrives(windows)
1104 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1105 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1106 void (async () => {
1107 pointer = await finePointerOk($)
1108 try {
1109 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1110 noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
1111 } catch {
1112 noNerd = true
1113 }
1114 await loadTheme($)
1115 themePoll?.cancel()
1116 themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
1117 ageTick?.cancel()
1118 ageTick = showAge ? $.clock.every(AGE_TICK_MS, () => $.ui.invalidate('ui.render')) : null
1119 const t = await get($)
1120 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1121 if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1122 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1123 const cwd = await cwdOf($)
1124 if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
1125 else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
1126 })()
1127 return next(e)
1128 })
1129
1130 on('command.run', { command: 'filetree' }, async ($, e) => {
1131 // The layout checks are the terminal's: the desktop app docks the pane itself and
1132 // reports no fullscreen layout, so a session no terminal draws skips them.
1133 const terminal = await $.session.surfaces().then(s => s.includes('terminal'), () => true)
1134 if (terminal && !e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
1135 if (terminal && e.presentation.columns < 110) return { text: 'filetree shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /filetree.' }
1136 noDock = false
1137 const arg = (e.args ?? '').trim()
1138 const cwd = await cwdOf($)
1139 follow = !arg
1140 const root = arg ? resolve(cwd, arg, home) : cwd
1141 await reset($, root, true)
1142 return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
1143 })
1144
1145 on('tool.call', async ($, e, next) => {
1146 const shell = e.tool === 'Bash' || e.tool === 'PowerShell'
1147 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && !shell) return next(e)
1148 const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : ''
1149 const cwd = shell ? await cwdOf($) : ''
1150 const actions = gitActions(command)
1151 const quiet = !actions.length && readOnly(command)
1152 const since = await sinceNow($, shell && !quiet)
1153 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1154 const head = before?.top ? await headOf($, before.root) : ''
1155 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1156 let result: Awaited<ReturnType<typeof next>>
1157 try {
1158 result = await next(e)
1159 } catch (err) {
1160 if (pending) await finishGit($, pending, failAll(pending))
1161 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1162 throw err
1163 }
1164 if (pending && result.deny) {
1165 const ids = pending.ids
1166 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1167 }
1168 if (result.deny || (result.isError && !shell)) {
1169 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1170 return result
1171 }
1172 if (shell) {
1173 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? (result.result as Partial<BuiltinToolResults['Bash']>) : {}
1174 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1175 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1176 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1177 else void (async () => {
1178 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1179 if (pending) await finishGit($, pending, results)
1180 queuedReads.push(...reads)
1181 scheduleScan($, { ...job, actions: proven(pending, results) })
1182 })()
1183 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1184 } else {
1185 const file =
1186 'file_path' in e && typeof e.file_path === 'string'
1187 ? e.file_path
1188 : 'notebook_path' in e && typeof e.notebook_path === 'string'
1189 ? e.notebook_path
1190 : ''
1191 if (file) void touched($, [file], 'orange', showWrites)
1192 }
1193 return result
1194 })
1195
1196 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1197 const result = await next(e)
1198 if (result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1199 void touched($, [posix(e.file_path)], 'purple', true)
1200 return resulthooks/open.ts 68 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
41
42function words(command: string): string[] {
43 const out: string[] = []
44 let word: string | null = null
45 let quote = ''
46 for (const ch of command) {
47 if (quote) {
48 if (ch === quote) quote = ''
49 else word = (word ?? '') + ch
50 } else if (ch === '"' || ch === "'") {
51 quote = ch
52 word ??= ''
53 } else if (/\s/.test(ch)) {
54 if (word !== null) out.push(word)
55 word = null
56 } else word = (word ?? '') + ch
57 }
58 if (word !== null) out.push(word)
59 return out
60}
61
62export function openWithCommand(command: string, path: string, dir: string): string[] | null {
63 const argv = words(command)
64 if (!argv.length) return null
65 const filled = argv.map(w => w.replace(/\{(path|dir)\}/g, (_, key) => (key === 'path' ? path : dir)))
66 return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
67}
68hooks/button.tsx 19 lines1import type { ClientModule } from 'claude-code'
2
3export type ButtonProps = { id: string; label: string; dim: boolean }
4
5// A header button that also tells the hooks module when the pointer is over it, which a Button
6// alone doesn't, so the header can say what the button does.
7const HeaderButton: ClientModule<ButtonProps, { over: boolean }> = (props, surface) => {
8 const { Button } = surface.elements
9 surface.onPointer(e => {
10 const over = e.type !== 'leave'
11 if (over === Boolean(surface.state?.over)) return
12 surface.setState({ over })
13 surface.post({ over })
14 })
15 return <Button key={props.id} plain dimColor={props.dim} label={props.label} onPress={() => surface.post({ press: true })} />
16}
17
18export default HeaderButton
19hooks/git.ts 262 lines1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4 kind: string
5 verb: string
6 running: string
7 done: string
8 tone: string
9 icon: { nerd: string; plain: string }
10 init?: boolean
11}
12
13const ICON = {
14 commit: { nerd: '\u{f417}', plain: '●' },
15 push: { nerd: '\u{f0552}', plain: '↑' },
16 pull: { nerd: '\u{f0553}', plain: '↓' },
17 branch: { nerd: '\u{f418}', plain: '⑂' },
18 merge: { nerd: '\u{f419}', plain: '⑃' },
19 pr: { nerd: '\u{f407}', plain: '⇄' },
20 github: { nerd: '\u{f408}', plain: '◎' },
21 stash: { nerd: '\u{f01bc}', plain: '≡' },
22 undo: { nerd: '\u{f054c}', plain: '↶' },
23 tag: { nerd: '\u{f412}', plain: '⌖' },
24 git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28 orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29 green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#14532d', '#166534', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31 blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#1e3a8a', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32 purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33 cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#155e75', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34 red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#7f1d1d', '#991b1b', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
38 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
39 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
40 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
41 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
42 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
43 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
44 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
45 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
46 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
47 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
48 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
49 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
50 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
51 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
52 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
53 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
54 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
55 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
56 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
57 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
58}
59
60const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
61 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
62 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
63 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
64 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
65 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
66 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
67 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
68 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
69}
70
71const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
72
73function segments(command: string, seps: string[] = []): string[][] {
74 const out: string[][] = []
75 let cur: string[] = []
76 let tok = ''
77 let quote = ''
78 let has = false
79 const endTok = () => {
80 if (has) cur.push(tok)
81 tok = ''
82 has = false
83 }
84 const endSeg = () => {
85 endTok()
86 if (cur.length) out.push(cur)
87 cur = []
88 }
89 for (let i = 0; i < command.length; i++) {
90 const ch = command[i] ?? ''
91 if (quote) {
92 if (ch === quote) quote = ''
93 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
94 else tok += ch
95 } else if (ch === '"' || ch === "'") {
96 quote = ch
97 has = true
98 } else if (ch === '\\' && i + 1 < command.length) {
99 tok += command[++i]
100 has = true
101 } else if (ch === '#' && !has) {
102 while (i + 1 < command.length && command[i + 1] !== '\n') i++
103 } else if (ch === ' ' || ch === '\t') endTok()
104 else if (ch === '\n' || ch === ';') {
105 seps.push(';')
106 endSeg()
107 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
108 tok += ch
109 has = true
110 } else if (ch === '&' || ch === '|') {
111 const twice = command[i + 1] === ch
112 if (twice) i++
113 seps.push(twice ? ch + ch : ch)
114 endSeg()
115 } else {
116 tok += ch
117 has = true
118 }
119 }
120 endSeg()
121 if (!quote) return out
122 seps.push(';')
123 return command
124 .split(/&&|\|\||;|\||\n/)
125 .map(s => s.trim().split(/\s+/).filter(Boolean))
126 .filter(s => s.length > 0)
127}
128
129function stripGlobals(tokens: string[]): string[] {
130 const out = [...tokens]
131 if (out[0] === 'env') out.shift()
132 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
133 return out
134}
135
136function gitVerb(tokens: string[]): number {
137 let i = 1
138 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
139 if (tokens[i] === '-C' || tokens[i] === '-c') i++
140 i++
141 }
142 return i
143}
144
145function gitReadOnly(verb: string, rest: string[]): boolean {
146 if (!verb || READ_ONLY_GIT.has(verb)) return true
147 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
148 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
149 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
150 return false
151}
152
153export function chainOf(command: string): { size: number; and: boolean } {
154 const seps: string[] = []
155 const size = segments(command, seps).length
156 return { size, and: seps.every(s => s === '&&') }
157}
158
159export function gitActions(command: string): GitAction[] {
160 const out: GitAction[] = []
161 for (const raw of segments(command)) {
162 const tokens = stripGlobals(raw)
163 const head = tokens[0]?.split('/').pop()
164 if (head === 'git') {
165 const i = gitVerb(tokens)
166 const verb = tokens[i] ?? ''
167 const rest = tokens.slice(i + 1)
168 if (gitReadOnly(verb, rest)) continue
169 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
170 if (spec) out.push({ kind: `git ${verb}`, ...spec })
171 } else if (head === 'gh') {
172 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
173 const spec = GH_VERBS[pair]
174 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
175 }
176 }
177 return out
178}
179
180export const BRANCH_ICON = ICON.branch
181
182const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
183const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
184const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
185
186function tidy(path: string): string {
187 const s = path.replace(/\/+$/, '')
188 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
189}
190
191export function resolve(cwd: string, p: string, home = ''): string {
192 let path = posix(p)
193 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
194 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
195 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
196 for (const seg of path.slice(drive.length).split('/')) {
197 if (!seg || seg === '.') continue
198 if (seg === '..') {
199 if (parts.length > 1) parts.pop()
200 } else parts.push(seg)
201 }
202 return tidy(parts.join('/'))
203}
204
205const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
206
207export function readOnly(command: string): boolean {
208 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
209 if (/[^>]>[^>&]|>>/.test(bare)) return false
210 return segments(command).every(raw => {
211 const tokens = stripGlobals(raw)
212 const head = tokens[0]?.split('/').pop() ?? ''
213 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
214 if (head === 'git') {
215 const i = gitVerb(tokens)
216 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
217 }
218 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
219 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
220 return READERS.has(head)
221 })
222}
223
224export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
225 let cwd = sessionCwd
226 const out = new Set<string>()
227 let listsPaths = false
228 for (const raw of segments(command)) {
229 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
230 const head = tokens[0]?.split('/').pop() ?? ''
231 if (head === 'cd' && tokens[1]) {
232 cwd = resolve(cwd, tokens[1], home)
233 continue
234 }
235 if (!READERS.has(head)) continue
236 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
237 if (OUTPUT_PATHS.has(head)) listsPaths = true
238 const args = tokens.slice(1)
239 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
240 for (let i = 0; i < args.length; i++) {
241 const a = args[i] ?? ''
242 if (a.startsWith('-')) {
243 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
244 continue
245 }
246 if (skipPattern) {
247 skipPattern = false
248 continue
249 }
250 if (/[*?<>|]/.test(a)) continue
251 out.add(resolve(cwd, a, home))
252 }
253 }
254 if (listsPaths) {
255 for (const line of stdout.split('\n').slice(0, 400)) {
256 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
257 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
258 }
259 }
260 return [...out].slice(0, 60)
261}
262hooks/rows.tsx 294 lines1import type { ClientModule, RenderElement } from 'claude-code'
2
3// role marks two segments: the folder caret, drawn in the accent while its folder unfolds, and
4// the file or folder icon, reserved for icon styling.
5export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string; role?: 'caret' | 'icon' }
6export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
7export type RowsProps = {
8 rows: RowSpec[]
9 active: string
10 activeBg: string
11 hoverBg: string
12 tones: Record<string, { bright: string[]; dim: string[] }>
13 spinner?: string[]
14 pointer?: boolean
15 bar?: { pos: number; size: number; thumb: string; track: string }
16 preview?: { text: string; path: string; startLine: number; markdown: boolean }
17 accent?: string
18}
19export type Transition = { kind: 'insert' | 'remove' | 'snap'; start: number; count: number }
20type Motion = Transition & { firstFrame: number; ghosts: RowSpec[]; spare: RowSpec[]; limit: number }
21type Ref = { stop?: () => void; unpoint?: () => void; stopFrames?: () => void; lastRows?: RowSpec[]; lastKey?: string; wasWindowed?: boolean; motion?: Motion }
22type Local = { phase: number; frame: number; drag: boolean; ref: Ref }
23// A ghost row is drawn yet no longer in the tree; a faded one draws dim while it arrives or leaves.
24type DrawnRow = { row: RowSpec; isGhost: boolean; isFaded: boolean; isAnchor: boolean }
25
26const TICK_MS = 90
27const FRAME_MS = 33
28const INSERT_FRAMES = 6
29const REMOVE_FRAMES = 5
30const FRESH_FRAMES = 2
31const MAX_ANIMATED_ROWS = 80
32const TREE_BUDGET = 70_000
33const MAX_ROWS = 1_500
34const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
35
36const isInside = (folder: string, id: string) => folder !== '' && id !== folder && (id.startsWith(folder.endsWith('/') ? folder : `${folder}/`) || id.startsWith(`${folder}\\`))
37
38// Rows `withBlock` holds at `start` that `withoutBlock` lacks, all inside the anchor folder and with the
39// rows after them unchanged; 0 for any other change. A scrolled window may cut or extend its tail.
40function blockAfter(anchor: string, start: number, withoutBlock: readonly string[], withBlock: readonly string[], isWindowed: boolean): number {
41 const known = new Set(withoutBlock)
42 let end = start
43 while (end < withBlock.length && !known.has(withBlock[end] as string)) {
44 if (!isInside(anchor, withBlock[end] as string)) return 0
45 end++
46 }
47 const count = end - start
48 const tailWith = withBlock.length - end
49 const tailWithout = withoutBlock.length - start
50 if (count === 0 || count > MAX_ANIMATED_ROWS) return 0
51 if (isWindowed ? tailWithout < tailWith : tailWithout !== tailWith) return 0
52 for (let offset = 0; offset < tailWith; offset++) {
53 if (withBlock[end + offset] !== withoutBlock[start + offset]) return 0
54 }
55 return count
56}
57
58// One folder unfolding or folding shows as a single block of its descendants appearing or leaving right
59// under it; anything else (first draw, scrolling, search, reloads, reorders) snaps.
60export function planTransition(previousIds: readonly string[] | undefined, nextIds: readonly string[], isWindowed = false): Transition {
61 const snap: Transition = { kind: 'snap', start: 0, count: 0 }
62 if (!previousIds || previousIds.length === 0 || nextIds.length === 0) return snap
63 let shared = 0
64 while (shared < previousIds.length && shared < nextIds.length && previousIds[shared] === nextIds[shared]) shared++
65 const anchor = shared > 0 ? (previousIds[shared - 1] as string) : ''
66 if (!anchor) return snap
67 const inserted = blockAfter(anchor, shared, previousIds, nextIds, isWindowed)
68 if (inserted > 0) return { kind: 'insert', start: shared, count: inserted }
69 const removed = blockAfter(anchor, shared, nextIds, previousIds, isWindowed)
70 if (removed > 0) return { kind: 'remove', start: shared, count: removed }
71 return snap
72}
73
74// The rows drawn on one frame of a motion, and whether the motion has run its course.
75function frameRows(rows: RowSpec[], motion: Motion, elapsed: number): { drawnRows: DrawnRow[]; isDone: boolean } {
76 const plain = (row: RowSpec): DrawnRow => ({ row, isGhost: false, isFaded: false, isAnchor: false })
77 const head = rows.slice(0, motion.start).map((row, index) => ({ ...plain(row), isAnchor: index === motion.start - 1 }))
78 if (motion.kind === 'insert') {
79 const perFrame = Math.max(1, Math.ceil(motion.count / INSERT_FRAMES))
80 const revealed = Math.min(motion.count, (elapsed + 1) * perFrame)
81 const block = rows.slice(motion.start, motion.start + revealed).map((row, index) => ({ ...plain(row), isFaded: elapsed - Math.floor(index / perFrame) < FRESH_FRAMES }))
82 const tail = rows.slice(motion.start + motion.count).map(plain)
83 // Rows the unfolding pushed out of a scrolled window stay until the block has filled their room.
84 const spare = motion.spare.map(row => ({ ...plain(row), isGhost: true }))
85 const isDone = elapsed >= Math.ceil(motion.count / perFrame) - 1 + FRESH_FRAMES
86 return { drawnRows: [...head, ...block, ...tail, ...spare].slice(0, motion.limit), isDone }
87 }
88 const perFrame = Math.max(1, Math.ceil(motion.count / REMOVE_FRAMES))
89 const remaining = Math.max(0, motion.count - elapsed * perFrame)
90 const ghosts = motion.ghosts.slice(0, remaining).map(row => ({ ...plain(row), isGhost: true, isFaded: true }))
91 const tail = rows.slice(motion.start).map(plain)
92 return { drawnRows: [...head, ...ghosts, ...tail].slice(0, motion.limit), isDone: remaining === 0 }
93}
94
95// A soft band of the tone's lighter shades that sweeps along the name while Claude is on it.
96function shimmer(i: number, phase: number, len: number, palette: string[]): string {
97 const band = ((phase * 1.2) % (len + 8)) - 4
98 const d = Math.abs(i - band)
99 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
100}
101
102const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
103 const { Box, Text } = surface.elements
104 let state = surface.state
105 if (state === undefined) {
106 state = { phase: 0, frame: 0, drag: false, ref: {} }
107 surface.setState(state)
108 }
109 const ref = state.ref
110 // A state kept from an older build of this module may lack the frame counter.
111 const frame = state.frame || 0
112 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
113 if (lit && !ref.stop) {
114 ref.stop = surface.every(TICK_MS, () => {
115 const cur = surface.state
116 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
117 })
118 } else if (!lit && ref.stop) {
119 ref.stop()
120 ref.stop = undefined
121 }
122 const finishMotion = () => {
123 ref.motion = undefined
124 ref.stopFrames?.()
125 ref.stopFrames = undefined
126 }
127 // New props compare against the last props: a running motion snaps to its end before the next is planned.
128 const ids = props.rows.map(r => r.id)
129 const idsKey = ids.join('\n')
130 // A window that gains or loses its scrollbar in this change still cuts or extends its tail.
131 const isWindowed = Boolean(props.bar) || Boolean(ref.wasWindowed)
132 if (props.preview) {
133 finishMotion()
134 ref.lastRows = undefined
135 ref.lastKey = undefined
136 } else if (idsKey !== ref.lastKey) {
137 finishMotion()
138 const lastRows = ref.lastRows ?? []
139 const plan = planTransition(ref.lastRows?.map(r => r.id), ids, isWindowed)
140 if (plan.kind !== 'snap') {
141 const ghosts = plan.kind === 'remove' ? lastRows.slice(plan.start, plan.start + plan.count) : []
142 const spare = plan.kind === 'insert' && isWindowed ? lastRows.slice(ids.length - plan.count) : []
143 // A scrolled window keeps its height while the block moves through it.
144 ref.motion = { ...plan, firstFrame: frame, ghosts, spare, limit: isWindowed ? ids.length : Number.MAX_SAFE_INTEGER }
145 ref.stopFrames = surface.every(FRAME_MS, () => {
146 const cur = surface.state
147 if (cur) surface.setState({ ...cur, frame: (cur.frame || 0) + 1 })
148 })
149 }
150 ref.lastKey = idsKey
151 }
152 if (!props.preview) ref.lastRows = props.rows
153 ref.wasWindowed = Boolean(props.bar)
154 let drawnRows: DrawnRow[] = props.rows.map(row => ({ row, isGhost: false, isFaded: false, isAnchor: false }))
155 if (ref.motion) {
156 const next = frameRows(props.rows, ref.motion, frame - ref.motion.firstFrame)
157 if (next.isDone) finishMotion()
158 else drawnRows = next.drawnRows
159 }
160 let visible = drawnRows.length
161 if (props.pointer === false) {
162 ref.unpoint?.()
163 ref.unpoint = undefined
164 } else ref.unpoint = surface.onPointer(e => {
165 const cur = surface.state ?? state
166 const span = Math.max(1, props.rows.length - 1)
167 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
168 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
169 if (e.type === 'up' || e.type === 'leave') {
170 surface.setState({ ...cur, drag: false })
171 return
172 }
173 if (e.type === 'down' || e.type === 'move') {
174 if (!cur.drag) surface.setState({ ...cur, drag: true })
175 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
176 return
177 }
178 }
179 if (e.type === 'leave' || e.y < 0 || e.y >= visible) return
180 // Ghost rows are leaving the tree, so a press on one lands nowhere.
181 const drawnRow = drawnRows[e.y]
182 if (!drawnRow || drawnRow.isGhost) return
183 const row = drawnRow.row
184 if (e.type === 'down' && e.button === 'right' && row.id) {
185 surface.post({ copy: row.id, shift: Boolean(e.shift) })
186 return
187 }
188 if (e.type !== 'down' || (e.button ?? 'left') !== 'left') return
189 let x = 0
190 for (const seg of row.left) {
191 const w = [...seg.t].length
192 if (seg.tab && e.x >= x && e.x < x + w) {
193 surface.post({ tab: seg.tab })
194 return
195 }
196 x += w
197 }
198 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
199 })
200 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
201 // The preview draws in this same element, so the focus a click gave the tree stays put.
202 if (props.preview) {
203 const { Markdown, Code } = surface.elements
204 const p = props.preview
205 return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
206 }
207 const frames = props.spinner?.length ? props.spinner : FRAMES
208 // A row fading in or out draws its text dim and steady; chips keep their full colour so the fill stays even.
209 const draw = (s: Seg, isDim: boolean, isAnchor: boolean) => {
210 const color = isAnchor && s.role === 'caret' ? (props.accent ?? s.c) : s.c
211 const tail = s.spin ? (frames[state.phase % frames.length] ?? '') : ''
212 if (s.spin && !isDim) {
213 return (
214 <Text color={color} bold={s.b}>
215 {s.t + tail}
216 </Text>
217 )
218 }
219 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
220 if (!palette || isDim) {
221 return (
222 <Text color={color ?? palette?.[0]} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i} dimColor={isDim && !s.bg ? true : undefined}>
223 {s.t + tail}
224 </Text>
225 )
226 }
227 if (s.one) {
228 return (
229 <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
230 {s.t}
231 </Text>
232 )
233 }
234 const chars = [...s.t]
235 return (
236 <Text bold={s.b}>
237 {chars.map((ch, i) => (
238 <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
239 ))}
240 </Text>
241 )
242 }
243 // Each real row is a keyed Box, so the surface lights it under the pointer with no round trip here.
244 const keys = new Set<string>()
245 const rowEl = (drawnRow: DrawnRow, i: number) => {
246 const r = drawnRow.row
247 const isActive = Boolean(r.id) && r.id === props.active && !drawnRow.isGhost
248 const hasKey = Boolean(r.id) && !drawnRow.isGhost && !keys.has(r.id)
249 if (hasKey) keys.add(r.id)
250 return (
251 <Box
252 {...(hasKey ? { key: r.id } : {})}
253 {...(hasKey && !isActive ? { hover: { backgroundColor: props.hoverBg } } : {})}
254 flexDirection="row"
255 height={1}
256 overflow="hidden"
257 backgroundColor={isActive ? props.activeBg : undefined}
258 >
259 <Box flexShrink={1} overflow="hidden">
260 {r.left.map(s => draw(s, drawnRow.isFaded, drawnRow.isAnchor))}
261 </Box>
262 <Box flexGrow={1} />
263 <Box flexDirection="row" flexShrink={0}>
264 {r.right.map(s => draw(s, drawnRow.isFaded, false))}
265 </Box>
266 {props.bar && (
267 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
268 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
269 </Text>
270 )}
271 </Box>
272 )
273 }
274 const drawn: RenderElement[] = []
275 let size = 0
276 for (let i = 0; i < drawnRows.length; i++) {
277 const el = rowEl(drawnRows[i] as DrawnRow, i)
278 let cost = 1_000
279 try {
280 cost = JSON.stringify(el).length
281 } catch {}
282 if (size + cost > TREE_BUDGET || drawn.length >= MAX_ROWS) {
283 visible = i
284 drawn.push(<Text dimColor>{` … ${drawnRows.length - i} more rows, collapse folders or use search`}</Text>)
285 break
286 }
287 size += cost
288 drawn.push(el)
289 }
290 return <Box flexDirection="column">{drawn}</Box>
291}
292
293export default Rows
294hooks/icons.ts 275 lines1import type { FileNode } from '../types'
2import { luminance } from './look'
3
4const cp = (n: number) => String.fromCodePoint(n)
5
6export const CHEVRON_OPEN = cp(0xf47c)
7export const CHEVRON_CLOSED = cp(0xf460)
8
9const NAMES: Record<string, number> = {
10 'agents.md': 0xf0354,
11 'changelog.md': 0xf0354,
12 'contributing.md': 0xf0354,
13 'todo.md': 0xf0354,
14 'readme.md': 0xf0354,
15 license: 0xe60a,
16 dockerfile: 0xf0868,
17 makefile: 0xf1064,
18 'cmakelists.txt': 0xf1064,
19 justfile: 0xf05b7,
20 gemfile: 0xf0d2d,
21 rakefile: 0xf0d2d,
22 'cargo.toml': 0xe6b2,
23 'cargo.lock': 0xe6b2,
24 'pyproject.toml': 0xe6b2,
25 'requirements.txt': 0xf160e,
26 'go.mod': 0xf0afa,
27 'go.sum': 0xf07d3,
28 'package.json': 0xf0626,
29 'package-lock.json': 0xf0626,
30 'tsconfig.json': 0xf0626,
31 '.prettierrc': 0xf0626,
32 '.eslintrc': 0xf0626,
33 'pnpm-lock.yaml': 0xe6a8,
34 'yarn.lock': 0xe6a8,
35 '.gitignore': 0xf02a2,
36 '.gitmodules': 0xf0493,
37 '.editorconfig': 0xe652,
38}
39
40const EXTS: Record<string, number> = {
41 lua: 0xe620,
42 py: 0xe606,
43 pyi: 0xe606,
44 js: 0xe60c,
45 mjs: 0xe60c,
46 cjs: 0xe60c,
47 ts: 0xe628,
48 jsx: 0xe625,
49 tsx: 0xe7ba,
50 json: 0xe60b,
51 jsonc: 0xe60b,
52 yaml: 0xe6a8,
53 yml: 0xe6a8,
54 sh: 0xe795,
55 bash: 0xe795,
56 zsh: 0xe795,
57 fish: 0xe795,
58 md: 0xf0354,
59 markdown: 0xf0354,
60 css: 0xe6b8,
61 scss: 0xf031c,
62 html: 0xe736,
63 htm: 0xe736,
64 go: 0xe627,
65 rs: 0xe68b,
66 rb: 0xe791,
67 php: 0xe608,
68 java: 0xe738,
69 cs: 0xf031b,
70 sql: 0xe706,
71 graphql: 0xf20e,
72 gql: 0xf20e,
73 xml: 0xf05c0,
74 toml: 0xe6b2,
75 ini: 0xf0bc2,
76 conf: 0xf0493,
77 pdf: 0xeaeb,
78 svg: 0xf0721,
79 jpg: 0xf0225,
80 jpeg: 0xf0225,
81 png: 0xe60d,
82 gif: 0xf0d78,
83 webp: 0xf021f,
84 mp4: 0xf022b,
85 mkv: 0xf022b,
86 mov: 0xf022b,
87 mp3: 0xf0223,
88 wav: 0xf0223,
89 flac: 0xf0223,
90 zip: 0xf05c4,
91 gz: 0xf05c4,
92 xz: 0xf05c4,
93 tar: 0xf05c4,
94 qml: 0xf375,
95 cpp: 0xe61d,
96 cc: 0xe61d,
97 cxx: 0xe61d,
98 c: 0xe61e,
99 h: 0xf0af5,
100 hpp: 0xf0af5,
101 vue: 0xe6a0,
102 svelte: 0xe697,
103 csv: 0xe64a,
104 txt: 0xf09aa,
105}
106
107export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
108 if (n.kind === 'link') return cp(0xf481)
109 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
110 const lower = n.name.toLowerCase()
111 const named = NAMES[lower]
112 if (named) return cp(named)
113 const dot = lower.lastIndexOf('.')
114 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
115 return cp(EXTS[ext] ?? 0xf0214)
116}
117
118// Without a Nerd Font a file shows a two-cell chip in its language's colour, as in VS Code's
119// Material icon theme, and a folder a block in its kind's colour. The chip's text is white or
120// near-black, whichever reads better on that colour.
121export type Badge = { text: string; ink: string; onInk: string }
122
123const DARK_TEXT = '#1b1b19'
124const GREY = '#8a877f'
125
126export function onInkFor(ink: string): string {
127 const level = luminance(ink)
128 return 1.05 / (level + 0.05) >= (level + 0.05) / (luminance(DARK_TEXT) + 0.05) ? '#ffffff' : DARK_TEXT
129}
130
131// Each row: the names or extensions (space separated), the chip's two cells and its colour.
132const NAME_BADGES: [string, string, string][] = [
133 ['package.json', 'NP', '#cb3837'],
134 ['package-lock.json pnpm-lock.yaml yarn.lock bun.lockb bun.lock cargo.lock poetry.lock gemfile.lock composer.lock', 'LK', GREY],
135 ['dockerfile containerfile .dockerignore docker-compose.yml docker-compose.yaml compose.yml compose.yaml', 'DK', '#2496ed'],
136 ['.gitignore .gitattributes .gitmodules .gitkeep', 'GI', '#e2643c'],
137 ['.env', 'EV', '#d9b54a'],
138 ['license licence license.md license.txt', '© ', '#c9a25a'],
139 ['readme readme.md readme.txt', 'i ', '#5b8fd6'],
140 ['makefile gnumakefile', 'MK', '#6d8086'],
141 ['claude.md agents.md', '✱ ', '#d97757'],
142 ['.bashrc .bash_profile .zshrc .zprofile .zshenv .profile', '$_', '#7cc36e'],
143]
144
145const PATTERN_BADGES: [RegExp, string, string][] = [
146 [/^tsconfig.*\.json$/, 'TS', '#3178c6'],
147 [/^\.env\./, 'EV', '#d9b54a'],
148 [/^(\.prettierrc|prettier\.config\.)/, 'PR', '#c596c7'],
149 [/^(eslint\.config\.|\.eslintrc)/, 'ES', '#8080f2'],
150 [/^vite\.config\./, 'VT', '#a855f7'],
151 [/^vitest\.config\./, 'VI', '#6e9f18'],
152 [/^(dockerfile|containerfile)\.|\.dockerfile$|^docker-compose\..+\.ya?ml$/, 'DK', '#2496ed'],
153]
154
155const EXTENSION_BADGES: [string, string, string][] = [
156 ['ts mts cts', 'TS', '#3178c6'],
157 ['tsx', 'TX', '#2fa7c9'],
158 ['js mjs cjs', 'JS', '#f0db4f'],
159 ['jsx', 'JX', '#e8c93a'],
160 ['json jsonc json5', '{}', '#d9a93a'],
161 ['md mdx markdown', 'M↓', '#5b8fd6'],
162 ['css', '# ', '#8f6fd6'],
163 ['scss sass', 'S#', '#cd6799'],
164 ['html htm', '<>', '#e2643c'],
165 ['sh bash zsh fish', '$_', '#7cc36e'],
166 ['ps1 psm1', 'PS', '#5391fe'],
167 ['yaml yml', 'YM', '#cb5d5d'],
168 ['toml', 'TM', '#9c4221'],
169 ['ini cfg conf properties', 'CF', GREY],
170 ['py pyi pyw', 'PY', '#3776ab'],
171 ['ipynb', 'NB', '#f37626'],
172 ['go', 'GO', '#00add8'],
173 ['rs', 'RS', '#dea584'],
174 ['rb erb', 'RB', '#cc342d'],
175 ['java', 'JV', '#b07219'],
176 ['kt kts', 'KT', '#a97bff'],
177 ['scala', 'SC', '#dc322f'],
178 ['swift', 'SW', '#f05138'],
179 ['dart', 'DT', '#00b4ab'],
180 ['c', 'C ', '#555fbf'],
181 ['h', 'H ', '#7b83c9'],
182 ['cpp cc cxx', 'C+', '#f34b7d'],
183 ['hpp hh hxx', 'H+', '#c96a8f'],
184 ['cs', 'C#', '#178600'],
185 ['php', 'PH', '#777bb4'],
186 ['lua', 'LU', '#4f6fd6'],
187 ['ex exs', 'EX', '#8e6fb8'],
188 ['hs', 'HS', '#5e5086'],
189 ['zig', 'ZG', '#f7a41d'],
190 ['r', 'R ', '#276dc3'],
191 ['sql', 'SQ', '#e38c00'],
192 ['db sqlite sqlite3', 'DB', '#e38c00'],
193 ['graphql gql', 'GQ', '#e10098'],
194 ['proto', 'PB', '#4f9de0'],
195 ['tf tfvars', 'TF', '#7b42bc'],
196 ['vue', 'VU', '#41b883'],
197 ['svelte', 'SV', '#ff3e00'],
198 ['astro', 'AS', '#ff5d01'],
199 ['xml plist', '<>', GREY],
200 ['svg', 'SG', '#ffb13b'],
201 ['png jpg jpeg gif webp avif ico bmp heic', 'IM', '#a074c4'],
202 ['mp4 mkv mov webm avi', 'VD', '#e05d44'],
203 ['mp3 wav flac ogg m4a aac', 'AU', '#e06c9f'],
204 ['pdf', 'PD', '#e5252a'],
205 ['zip gz tgz tar xz 7z bz2 rar', 'ZP', GREY],
206 ['csv tsv', 'CS', '#3fae6a'],
207 ['txt log', 'Tx', GREY],
208 ['diff patch', 'DF', '#7cc36e'],
209 ['ttf otf woff woff2', 'FT', GREY],
210 ['wasm', 'WA', '#654ff0'],
211 ['lock', 'LK', GREY],
212]
213
214const badgeOf = (text: string, ink: string): Badge => ({ text, ink, onInk: onInkFor(ink) })
215
216function badgeTable(entries: [string, string, string][]): Map<string, Badge> {
217 const table = new Map<string, Badge>()
218 for (const [keys, text, ink] of entries) {
219 const badge = badgeOf(text, ink)
220 for (const key of keys.split(' ')) table.set(key, badge)
221 }
222 return table
223}
224
225const BY_NAME = badgeTable(NAME_BADGES)
226const BY_EXTENSION = badgeTable(EXTENSION_BADGES)
227const BY_PATTERN = PATTERN_BADGES.map(([pattern, text, ink]) => [pattern, badgeOf(text, ink)] as const)
228const DEFAULT_BADGE = badgeOf('··', GREY)
229
230export function fileBadge(node: Pick<FileNode, 'name'>): Badge {
231 const lower = node.name.toLowerCase()
232 const named = BY_NAME.get(lower)
233 if (named) return named
234 for (const [pattern, badge] of BY_PATTERN) if (pattern.test(lower)) return badge
235 const dot = lower.lastIndexOf('.')
236 return (dot > 0 ? BY_EXTENSION.get(lower.slice(dot + 1)) : undefined) ?? DEFAULT_BADGE
237}
238
239const FOLDER_INKS: [string, string][] = [
240 ['.claude .github .vscode', '#e0795a'],
241 ['src source lib', '#58b86a'],
242 ['docs doc', '#4f9de0'],
243 ['test tests __tests__ e2e spec coverage', '#3fae9e'],
244 ['public static assets', '#3fb6d8'],
245 ['dist build out', '#d8605e'],
246 ['node_modules', '#7aa35a'],
247 ['scripts bin config configs', '#7f93ad'],
248 ['hooks plugins', '#a98be8'],
249 ['components ui', '#e8a33d'],
250 ['types @types', '#4f7cc4'],
251 ['styles css', '#8f6fd6'],
252 ['images img icons', '#a074c4'],
253]
254const FOLDER_BY_NAME = new Map(FOLDER_INKS.flatMap(([names, ink]) => names.split(' ').map(name => [name, ink] as const)))
255
256export function folderInk(name: string): string {
257 return FOLDER_BY_NAME.get(name.toLowerCase()) ?? '#c9a25a'
258}
259
260export const GIT_COLOR: Record<string, string> = {
261 A: '#98c379',
262 '?': '#98c379',
263 R: '#61afef',
264 C: '#61afef',
265 M: '#e5c07b',
266 T: '#e5c07b',
267}
268
269const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
270
271export function stronger(a: string | undefined, b: string): string {
272 if (!a) return b
273 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
274}
275hooks/look.ts 192 lines1import type { Theme } from '../types'
2import { DEFAULT_THEME } from './tree'
3
4// The pane's colours. Without a theme file the pane can sit on a cream (#faf6f0) or a
5// near-black (#141414) background and nothing says which, so text colours there are the
6// person's own theme keys (inactive, success, warning, error, merged), which follow their
7// light or dark theme. The house inks below colour marks only: the activity bar, the
8// shimmer, icons. An Omarchy theme names its background, so there the inks are picked for
9// that background, and its own accent, muted, red and selection win.
10
11type Hue = 'coral' | 'jade' | 'amber' | 'red' | 'lilac' | 'teal' | 'blue' | 'cyan'
12export type Mode = 'light' | 'dark' | 'both'
13
14const INKS: Record<Mode, Record<Hue, string>> = {
15 light: { coral: '#a8492f', jade: '#2f6e52', amber: '#875c14', red: '#a83e3b', lilac: '#6a50b8', teal: '#1f6e69', blue: '#3a62a8', cyan: '#1f6c84' },
16 both: { coral: '#cc6448', jade: '#3f8a68', amber: '#a57524', red: '#c4524f', lilac: '#8a6fd0', teal: '#2e8a84', blue: '#4f7cc4', cyan: '#2b88a3' },
17 dark: { coral: '#f0907a', jade: '#7cd7ad', amber: '#e3b65e', red: '#f08a84', lilac: '#b9a6f0', teal: '#6fd0c8', blue: '#8db4f0', cyan: '#74cde6' },
18}
19
20const MUTED: Record<Mode, string> = { light: '#6e6478', both: '#7a7183', dark: '#a89cb4' }
21
22// Claude's activity tones (git.ts TONES names) mapped onto the house hues.
23const TONE_HUE: Record<string, Hue> = { orange: 'coral', green: 'jade', teal: 'teal', blue: 'blue', purple: 'lilac', cyan: 'cyan', red: 'red' }
24
25export type Palette = {
26 isCustomTheme: boolean
27 // light or dark when a theme file names the background, both when nothing says which.
28 mode: Mode
29 text: string | undefined
30 muted: string
31 // The muted colour as a hex, for mixing: a theme key cannot be mixed.
32 mutedHex: string
33 faint: string
34 accent: string
35 cursorBg: string
36 hoverBg: string
37 added: string
38 modified: string
39 deleted: string
40 renamed: string
41 addedLines: string
42 deletedLines: string
43 ink: (tone: string) => string
44 shimmer: Record<string, { bright: string[]; dim: string[] }>
45}
46
47function channels(hex: string): [number, number, number] {
48 const value = parseInt(hex.slice(1, 7), 16)
49 return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
50}
51
52export function mix(from: string, to: string, amount: number): string {
53 if (!/^#[0-9a-fA-F]{6}$/.test(from) || !/^#[0-9a-fA-F]{6}$/.test(to)) return from
54 const start = channels(from)
55 const end = channels(to)
56 return `#${start.map((channel, index) => Math.round(channel + ((end[index] ?? channel) - channel) * amount).toString(16).padStart(2, '0')).join('')}`
57}
58
59export function luminance(hex: string): number {
60 const linear = (channel: number) => {
61 const value = channel / 255
62 return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
63 }
64 const [red, green, blue] = channels(hex).map(linear) as [number, number, number]
65 return 0.2126 * red + 0.7152 * green + 0.0722 * blue
66}
67
68// The old selection hover: the selection colour pulled most of the way to a dark grey.
69function towardDark(hex: string): string {
70 return mix(hex, '#262626', 0.7)
71}
72
73export function buildPalette(theme: Theme, isDesktop: boolean): Palette {
74 const isCustomTheme = (Object.keys(DEFAULT_THEME) as (keyof Theme)[]).some(key => theme[key] !== DEFAULT_THEME[key])
75 const hasBackground = /^#[0-9a-fA-F]{6}$/.test(theme.bg)
76 const mode: Mode = hasBackground ? (luminance(theme.bg) < 0.35 ? 'dark' : 'light') : 'both'
77 const isBoth = mode === 'both'
78 const inks = INKS[mode]
79 const mutedHex = isCustomTheme ? theme.muted : MUTED[mode]
80 const muted = isCustomTheme ? theme.muted : isBoth ? 'inactive' : MUTED[mode]
81 const deleted = isCustomTheme ? theme.urgent : isBoth ? 'error' : inks.red
82 const peakOf = (hue: Hue) => (mode === 'dark' ? mix(INKS.dark[hue], '#ffffff', 0.45) : mode === 'light' ? INKS.both[hue] : mix(INKS.both[hue], INKS.dark[hue], 0.5))
83 const shimmer: Record<string, { bright: string[]; dim: string[] }> = {}
84 for (const [tone, hue] of Object.entries(TONE_HUE)) {
85 const ink = inks[hue]
86 const peak = peakOf(hue)
87 const soft = mix(ink, mutedHex, 0.55)
88 shimmer[tone] = { bright: [ink, mix(ink, peak, 0.35), mix(ink, peak, 0.7), peak], dim: [soft, mix(soft, ink, 0.35), mix(soft, ink, 0.7), ink] }
89 }
90 return {
91 isCustomTheme,
92 mode,
93 text: theme.fg || undefined,
94 muted,
95 mutedHex,
96 faint: hasBackground ? mix(mutedHex, theme.bg, 0.55) : 'subtle',
97 accent: isCustomTheme ? theme.accent : inks.coral,
98 cursorBg: isCustomTheme ? theme.selection : 'userMessageBackground',
99 hoverBg: isCustomTheme ? (hasBackground ? mix(theme.selection, theme.bg, 0.55) : towardDark(theme.selection)) : isDesktop ? 'userMessageBackgroundHover' : 'userMessageBackground',
100 added: isBoth ? 'success' : inks.jade,
101 modified: isBoth ? 'warning' : inks.amber,
102 deleted,
103 renamed: isBoth ? 'merged' : inks.lilac,
104 addedLines: isBoth ? 'success' : inks.jade,
105 deletedLines: deleted,
106 ink: tone => inks[TONE_HUE[tone] ?? 'coral'],
107 shimmer,
108 }
109}
110
111// A file or folder ink drawn as text, clamped in luminance so it keeps about 3:1 against
112// cream (#faf6f0) at most 0.28 and against near-black (#141414) at least 0.12. Without a
113// known background both bounds apply.
114const INK_LUMINANCE_BOUNDS: Record<Mode, { lowest: number; highest: number }> = {
115 light: { lowest: 0, highest: 0.28 },
116 dark: { lowest: 0.12, highest: 1 },
117 both: { lowest: 0.12, highest: 0.28 },
118}
119
120export function inkOnPane(palette: Palette, ink: string): string {
121 if (!/^#[0-9a-fA-F]{6}$/.test(ink)) return ink
122 const { lowest, highest } = INK_LUMINANCE_BOUNDS[palette.mode]
123 const level = luminance(ink)
124 if (level >= lowest && level <= highest) return ink
125 const target = level > highest ? '#000000' : '#ffffff'
126 const isInside = (candidate: string) => {
127 const candidateLevel = luminance(candidate)
128 return candidateLevel >= lowest && candidateLevel <= highest
129 }
130 for (let step = 1; step <= 20; step++) {
131 const candidate = mix(ink, target, step * 0.05)
132 if (isInside(candidate)) return candidate
133 }
134 return mix(ink, target, 1)
135}
136
137export function gitInk(palette: Palette, letter: string | undefined): string | undefined {
138 if (!letter) return undefined
139 if (letter === 'D' || letter === 'U') return palette.deleted
140 if (letter === 'M' || letter === 'T') return palette.modified
141 if (letter === 'R' || letter === 'C') return palette.renamed
142 if (letter === 'A' || letter === '?') return palette.added
143 return palette.muted
144}
145
146// Cuts a long name in its middle so the start and the extension both stay readable.
147export function truncateMiddle(name: string, room: number): string {
148 const characters = [...name]
149 if (characters.length <= room) return name
150 if (room <= 1) return '…'
151 // Too little room for both ends: the start alone says more.
152 if (room < 10) return `${characters.slice(0, room - 1).join('')}…`
153 const dot = name.lastIndexOf('.')
154 const extension = dot > 0 && name.length - dot <= 8 ? [...name.slice(dot)] : []
155 const tailLength = Math.min(Math.max(extension.length + 2, Math.floor((room - 1) / 3)), room - 2)
156 const headLength = Math.max(1, room - 1 - tailLength)
157 return `${characters.slice(0, headLength).join('')}…${characters.slice(characters.length - (room - 1 - headLength)).join('')}`
158}
159
160const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
161
162// The date column, in the month words age() uses: this year's with the time (Oct 12 09:30),
163// an older one with its year (Nov 14 2023). Very narrow panes keep one part: today's time
164// (09:30), this year's day (Oct 12), an older one's year (2023). The day is padded so the
165// column lines up.
166export function dateLabel(ms: number, now: number, isVeryNarrow = false): string {
167 if (!ms) return ''
168 const date = new Date(ms)
169 const today = new Date(now)
170 const pad = (value: number) => String(value).padStart(2, '0')
171 const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
172 const day = `${MONTHS[date.getMonth()]} ${String(date.getDate()).padStart(2, ' ')}`
173 const isThisYear = date.getFullYear() === today.getFullYear()
174 if (!isVeryNarrow) return `${day} ${isThisYear ? time : date.getFullYear()}`
175 if (isThisYear && date.getMonth() === today.getMonth() && date.getDate() === today.getDate()) return time
176 return isThisYear ? day : String(date.getFullYear())
177}
178
179export function widthOf(text: string): number {
180 return [...text].length
181}
182
183export const RECENT_SOFT_MS = 30_000
184export const RECENT_MS = 90_000
185
186// How strongly a file Claude touched a while ago is still marked: 2 just after the shimmer,
187// 1 once it has settled, 0 when the mark is gone.
188export function recentLevel(at: number, now: number): 0 | 1 | 2 {
189 const elapsed = now - at
190 return elapsed < RECENT_SOFT_MS ? 2 : elapsed < RECENT_MS ? 1 : 0
191}
192hooks/tree.ts 346 lines1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10 fg: '',
11 accent: '#5b9bd5',
12 muted: '#808a96',
13 urgent: '#d0605e',
14 selection: '#6b7280',
15 bg: '',
16}
17
18export function emptyTree(root: string): FileTree {
19 return {
20 root,
21 nodes: [],
22 expanded: [],
23 cursor: '',
24 selected: '',
25 query: '',
26 showHidden: true,
27 showSize: false,
28 dirSizes: {},
29 git: {},
30 diff: {},
31 ignored: [],
32 untrackedDirs: [],
33 top: '',
34 prefix: '',
35 branch: null,
36 counts: {},
37 flash: [],
38 flashDim: [],
39 flashOn: false,
40 flashTones: {},
41 scroll: null,
42 preview: '',
43 previewTop: 0,
44 }
45}
46
47export function parseTheme(toml: string): Theme {
48 const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
49 return {
50 fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
51 accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
52 muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
53 urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
54 selection: get('selection') ?? DEFAULT_THEME.selection,
55 bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
56 }
57}
58
59export function join(dir: string, name: string): string {
60 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
61}
62
63let drives = false
64
65export function dirname(path: string): string {
66 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
67 const i = path.lastIndexOf('/')
68 return i <= 0 ? '/' : path.slice(0, i)
69}
70
71export function useDrives(on: boolean): void {
72 drives = on
73}
74
75export function drivesOn(): boolean {
76 return drives
77}
78
79export function posix(path: string): string {
80 if (!drives) return path
81 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
82}
83
84export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
85 if (dirs.length === 0) return nodes
86 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
87 const gone = new Set(dirs)
88 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
89}
90
91export function isAbsolute(path: string): boolean {
92 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
93}
94
95export function inside(root: string, path: string): boolean {
96 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
97}
98
99export function toNodes(dir: string, list: Entry[]): FileNode[] {
100 return list
101 .map(e => ({
102 id: join(dir, e.name),
103 parent: dir,
104 name: e.name,
105 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
106 hidden: e.name.startsWith('.'),
107 mtime: e.mtimeMs,
108 size: e.size,
109 loaded: false,
110 }))
111 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
112}
113
114export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
115 if (listed.size === 0) return nodes
116 const keep = new Map<string, FileNode>()
117 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
118 const freshIds = new Set<string>()
119 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
120 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
121 const under = (id: string) => {
122 if (gone.size === 0) return false
123 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
124 return false
125 }
126 const fresh: FileNode[] = []
127 for (const kids of listed.values()) {
128 for (const k of kids) {
129 if (under(k.id)) continue
130 const old = keep.get(k.id)
131 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
132 }
133 }
134 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
135 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
136}
137
138export type GitStatus = {
139 git: Record<string, string>
140 ignored: string[]
141 untrackedDirs: string[]
142 branch: Branch | null
143 files: Record<string, Change>
144}
145
146export type Change = 'new' | 'mod' | 'del'
147
148export function parseBranch(record: string): Branch {
149 const body = record.replace(/^## /, '')
150 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
151 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
152 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
153 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
154 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
155 return { head, upstream, ahead, behind }
156}
157
158export function mapper(root: string, prefix: string): (rel: string) => string | null {
159 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
160}
161
162export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
163 const map = mapper(root, prefix)
164 const git: Record<string, string> = {}
165 const ignored: string[] = []
166 const untrackedDirs: string[] = []
167 const files: Record<string, Change> = {}
168 let branch: Branch | null = null
169 const parts = stdout.split('\0')
170 for (let i = 0; i < parts.length; i++) {
171 const rec = parts[i] ?? ''
172 if (rec.startsWith('## ')) {
173 branch = parseBranch(rec)
174 continue
175 }
176 if (rec.length < 4) continue
177 const xy = rec.slice(0, 2)
178 const raw = rec.slice(3)
179 if (xy[0] === 'R' || xy[0] === 'C') i++
180 const path = map(raw)
181 if (!path) continue
182 if (xy === '!!') {
183 ignored.push(path)
184 continue
185 }
186 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
187 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
188 const letter =
189 xy === '??'
190 ? '?'
191 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
192 ? 'U'
193 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
194 if (!letter) continue
195 git[path] = stronger(git[path], letter)
196 let dir = dirname(path)
197 while (dir.length >= root.length && dir !== '/') {
198 git[dir] = stronger(git[dir], letter)
199 if (dir === root) break
200 dir = dirname(dir)
201 }
202 }
203 return { git, ignored, untrackedDirs, branch, files }
204}
205
206export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
207 const map = mapper(root, prefix)
208 const parts = stdout.split('\0')
209 for (let i = 0; i < parts.length; i++) {
210 const rec = parts[i] ?? ''
211 if (!rec) continue
212 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
213 if (!m) continue
214 let path = m[3] ?? ''
215 if (path === '') {
216 i += 2
217 path = parts[i] ?? ''
218 }
219 if (!path) continue
220 const add = m[1] === '-' ? 0 : Number(m[1])
221 const del = m[2] === '-' ? 0 : Number(m[2])
222 const abs = map(path)
223 if (!abs) continue
224 const prev = into[abs] ?? [0, 0]
225 into[abs] = [prev[0] + add, prev[1] + del]
226 }
227}
228
229export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
230 const out: Record<string, [number, number]> = { ...diff }
231 for (const [path, [add, del]] of Object.entries(diff)) {
232 let dir = dirname(path)
233 while (dir.length >= root.length && dir !== '/') {
234 const prev = out[dir] ?? [0, 0]
235 out[dir] = [prev[0] + add, prev[1] + del]
236 if (dir === root) break
237 dir = dirname(dir)
238 }
239 }
240 return out
241}
242
243export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
244 const out: Record<string, [number, number, number]> = {}
245 const slot = { new: 0, mod: 1, del: 2 } as const
246 for (const [path, change] of Object.entries(files)) {
247 let dir = dirname(path)
248 while (dir.length >= root.length && dir !== '/') {
249 const cur = out[dir] ?? [0, 0, 0]
250 cur[slot[change]] += 1
251 out[dir] = cur
252 if (dir === root) break
253 dir = dirname(dir)
254 }
255 }
256 return out
257}
258
259export function underAny(id: string, set: Set<string>, root: string): boolean {
260 if (set.size === 0) return false
261 let cur = id
262 while (cur.length >= root.length) {
263 if (set.has(cur)) return true
264 const up = dirname(cur)
265 if (up === cur) break
266 cur = up
267 }
268 return false
269}
270
271export function ancestorsOf(id: string, root: string): string[] {
272 const out: string[] = []
273 if (!root) return out
274 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
275 return out
276}
277
278export function relative(root: string, path: string): string {
279 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
280}
281
282export function visibleRows(t: FileTree): Row[] {
283 const kids = new Map<string, FileNode[]>()
284 for (const n of t.nodes) {
285 if (!t.showHidden && n.hidden) continue
286 const list = kids.get(n.parent)
287 if (list) list.push(n)
288 else kids.set(n.parent, [n])
289 }
290 const q = t.query.trim().toLowerCase()
291 let keep: Set<string> | null = null
292 if (q) {
293 keep = new Set()
294 for (const n of t.nodes) {
295 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
296 keep.add(n.id)
297 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
298 }
299 }
300 const open = new Set(t.expanded)
301 const rows: Row[] = []
302 const walk = (dir: string, depth: number) => {
303 for (const n of kids.get(dir) ?? []) {
304 if (keep && !keep.has(n.id)) continue
305 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
306 rows.push({ node: n, depth, open: isOpen })
307 if (isOpen) walk(n.id, depth + 1)
308 }
309 }
310 walk(t.root, 0)
311 return rows
312}
313
314export function stamp(ms: number): string {
315 if (!ms) return ''
316 const d = new Date(ms)
317 const p = (v: number) => String(v).padStart(2, '0')
318 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
319}
320
321const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
322
323export function age(ms: number, now: number): string {
324 if (!ms) return ''
325 const s = (now - ms) / 1000
326 if (s < 60 && s > -60) return 'now'
327 if (s > 0 && s < 3600) return `${Math.floor(s / 60)}m`
328 if (s > 0 && s < 86_400) return `${Math.floor(s / 3600)}h`
329 if (s > 0 && s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`
330 const d = new Date(ms)
331 return Math.abs(s) < 365 * 86_400 ? `${MONTHS[d.getMonth()]} ${d.getDate()}` : String(d.getFullYear())
332}
333
334export function formatSize(bytes: number): string {
335 if (bytes < 0) return '?'
336 if (bytes < 1024) return `${bytes} B`
337 const units = ['K', 'M', 'G', 'T']
338 let v = bytes / 1024
339 let i = 0
340 while (v >= 1024 && i < units.length - 1) {
341 v /= 1024
342 i++
343 }
344 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
345}
346types/index.d.ts 76 lines1export type FileNode = {
2 id: string
3 parent: string
4 name: string
5 kind: 'dir' | 'file' | 'link'
6 hidden: boolean
7 mtime: number
8 size: number
9 loaded: boolean
10}
11
12export type Branch = {
13 head: string
14 upstream: string
15 ahead: number
16 behind: number
17}
18
19export type Activity = {
20 id: number
21 kind: string
22 label: string
23 state: 'running' | 'done' | 'failed'
24 detail: string
25 at: number
26 tone: string
27 nerd: string
28 plain: string
29}
30
31export type FileTree = {
32 root: string
33 nodes: FileNode[]
34 expanded: string[]
35 cursor: string
36 selected: string
37 query: string
38 showHidden: boolean
39 showSize: boolean
40 dirSizes: Record<string, number>
41 git: Record<string, string>
42 diff: Record<string, [number, number]>
43 ignored: string[]
44 untrackedDirs: string[]
45 top: string
46 prefix: string
47 branch: Branch | null
48 counts: Record<string, [number, number, number]>
49 flash: string[]
50 flashDim: string[]
51 flashOn: boolean
52 flashTones: Record<string, string>
53 scroll: number | null
54 preview: string
55 previewTop: number
56}
57
58export type Theme = {
59 fg: string
60 accent: string
61 muted: string
62 urgent: string
63 selection: string
64 bg: string
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 'pulse-filetree': {
70 tree: FileTree
71 theme: Theme
72 activity: Activity[]
73 }
74 }
75}
76