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

<h1 align="center">Claude Code Filetree</h1>
An IDE-style file tree for Claude Code that shows what Claude is doing and where in files
<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.287-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.287 or newer"> <img src="https://img.shields.io/badge/version-0.3.2-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<img src="media/filetree-shimmer.gif" alt="filetree shimmering the file Claude is editing" width="900">
[!NOTE] filetree is a Claude Code mod and needs Claude Code 2.1.287+. It shows in the right sidebar, which in the terminal needs the fullscreen layout (
/tui fullscreen) and a terminal at least 110 columns wide; the Desktop app docks it on its own, and/filetreereopens it there after you close it. Tested on Linux, macOS and Windows; mods don't load in WSL sessions of the Desktop app.
The repo is its own plugin marketplace. Run this in the terminal:
claude plugin marketplace add data-goblin/claude-code-filetree
claude plugin install filetree@claude-code-filetree
Or inside a Claude Code session:
/plugin marketplace add data-goblin/claude-code-filetree
/plugin install filetree@claude-code-filetree
Installed it as filetree@filetree before the repository was renamed? Nothing to do: that install keeps loading and keeps receiving updates.
Install fails with Unrecognized key: "options"? Your Claude Code is older than 2.1.287: run claude update, then install again.
/filetree <path> pins another folder+N -N) on modified files and ?:N M:N D:N file counts on folders<img src="media/filetree-read.gif" alt="Files shimmer purple while Claude reads and searches them" width="800">
git and gh (commit, push, pull, checkout, merge, PR and more) shown as a status at the bottom of the pane<img src="media/filetree-git.gif" alt="A committed file shimmers green and the footer shows the commit" width="800">
prompt.submit hook, and @path mentions in a prompt reveal that file in the tree<img src="media/filetree-ask.gif" alt="Selecting config.yaml in the tree and asking Claude what it changed there" width="800">
Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writesj/k/h/l to move through the tree/config theme in the terminal gets its own palette, with every text color at WCAG AAA contrast (7:1 or more), also on the selected and hovered rowsd on a changed file shows its diff, in the pane or in hunk, lazygit or any diff tool you name<img src="media/filetree-diff.gif" alt="Pressing d on a file and a folder shows their diffs in the pane, and j/k jump between changed files" width="800">
With the filetree pane focused, use j/k to move down/up, l to expand a folder, and h to collapse it or move to its parent. o or Enter opens a file or toggles a folder. Press s to focus search, type a query, then Enter to jump to the first match and return focus to the tree controls.
You can resize the pane with the mouse, or by setting custom pane:grow or pane:shrink keybindings in keybindings.json
<img src="media/filetree-resize.gif" alt="Dragging the filetree pane edge to resize it" width="900">
All settings are in /config under filetree.
reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.on (default) scrolls the tree to what Claude reads, writes or commits; off keeps the view where you put it, and highlights still show.on (default) opens the pane when a session starts; off keeps it closed until you run /filetree.date (default), age or size; what the right column shows when a session starts. age shows how long ago a file changed in a few characters: now, 12m, 3h, 5d, then the day (Oct 9) past a week and the year past a year. The Σ button in the header toggles between sizes and the dates or ages.on (default) shows a line under the header outside a git repo saying git status starts after git init; off hides it.j/k/h/l/open/search row under the search bar; off hides it and the keys still work with the tree focused.editor opts into $VISUAL, then $EDITOR, then vi in tmux or herdr on Linux/macOS. In tmux, nvim reuses a running nvim in the same tmux server and session; otherwise the editor opens in a new window in the file's folder. In herdr without tmux, the editor opens in a new pane to the right of Claude's pane, in the file's folder, and that pane closes when the editor exits; the file name reaches the editor through the pane's environment, not through its command line, so names with spaces or shell characters are safe. Outside tmux and herdr it shows a notice instead of opening the file: a mod cannot hand Claude Code's terminal to another program, so a terminal editor needs a tmux window or a herdr pane to run in. For a GUI editor, or outside both, use a command instead, such as code -g {path} or one that opens your terminal's new window. Editor commands can include arguments; quote an executable path or argument that contains spaces, for example EDITOR='"/opt/My Editor/nvim" -u NONE'. Arguments apply when starting a new editor; reusing nvim keeps that instance's configuration. preview shows the file in the pane instead: Markdown rendered, code highlighted, j/k or the arrow keys, f/b, Page Up/Down or Ctrl+F/B for a page, and the wheel to scroll, ←/h or ← files back to the tree, ↗ always the default app, e the editor from Edit with at the top line shown, which the button names (edit :42). Otherwise a command that double-click and Enter run instead: {path} is the file and {dir} its folder, and without {path} the path goes last. It runs without a shell, so quote a word that holds spaces, and it should start the viewer and return rather than wait for it. Examples: zed {path}, code -g {path}, or in herdr a tab running herdr-file-viewer on the file: herdr plugin pane open --plugin herdr-file-viewer --entrypoint file-viewer --placement tab --focus --env HERDR_FILE_VIEWER_OPEN={path}.e does in the preview: it opens the file at the top line shown. Empty (default) uses $VISUAL, then $EDITOR, then vi in tmux or herdr on Linux/macOS, the same way as Open with: editor, passing the line as +N; elsewhere the e button is hidden. Otherwise a command like Open with, where {line} is that line: code -g {path}:{line} or zed {path}:{line}.d does on a changed file, in the tree or the preview. Empty (default) shows its diff against HEAD in the pane, drawn by Claude Code's own diff view; an untracked file shows as all added, and d again goes back to the file. On a folder it shows every changed file under it, each under its name; j/k jump to the next or previous file (or hunk, for one file), the arrows scroll by line and f/b by page. Otherwise a terminal command run on the file in a tmux window, a herdr pane or, on Linux, a new terminal window through xdg-terminal-exec, with the file path last: hunk diff --, lazygit -f, git difftool -y -- or nvim -c Gvdiffsplit.key=#hex pairs separated by spaces, laid over the Omarchy, light or default theme. A key you leave out keeps the theme's color, so accent=#83a598 alone recolors only folders. Keys: fg, bg, accent (folders), muted (dates and sizes), urgent (deleted files and conflicts), selection (the selected row), hover (the hovered row), added, modified and renamed (git status), add and del (line counts). An entry it cannot read is skipped and named in a notice at session start. Gruvbox Dark Hard, from #13 by @Mourey: fg=#ebdbb2 bg=#1d2021 accent=#83a598 muted=#928374 urgent=#fb4934 selection=#3c3836 added=#b8bb26 modified=#fabd2f renamed=#83a598 add=#b8bb26 del=#fb4934auto (default) uses Nerd Font icons when a Nerd Font is installed and your terminal started after it was installed, plain Unicode in the desktop app, and Nerd Font over SSH. nerd or plain forces one.Clicking rows needs herdr 0.9.1 or later. herdr 0.9.0 and older accept pixel mouse reporting but still send cell positions, which would put every click in the session in the wrong place, so on those versions the rows ignore the mouse and the rest of the pane and session keep working. Run herdr update to get row clicks.
With Open with: editor, or e in the preview, the editor opens in a new pane to the right of Claude's pane and that pane closes when you quit the editor; see Settings.
Turn on the pre-commit hook once per clone; it runs claude plugin validate and the plugin tests before each commit that touches the plugin:
git config core.hooksPath .githooks
The same checks run on macOS, Windows and Linux in CI on every push that touches the plugin.
This project is inspired by my Omarchy app FileBlade
hooks/register.tsx 1773 lines1import { type BuiltinToolResults, type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { herdrError, herdrOpenCommand, herdrPane, LINUX_LAUNCH, openCommand, openWithCommand, tmuxOpenCommand, words } from './open'
3import type { ButtonProps } from './button'
4
5import type { Activity, FileNode, FileTree, Theme } from '../types'
6import { BRANCH_ICON, chainOf, type GitAction, gitActions, LIGHT_TONES, readOnly, readTargets, resolve, TONES } from './git'
7import type { RowSpec, RowsProps, Seg } from './rows'
8import { type DiffLine, diffWindow, splitPatch } from './diff'
9import { ADD_COLOR, DEL_COLOR, LIGHT_ADD_COLOR, LIGHT_DEL_COLOR, LIGHT_HOVER, type Palette, parsePalette } from './palette'
10import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR, LIGHT_GIT_COLOR } from './icons'
11import {
12 age,
13 ancestorsOf,
14 type Change,
15 DEFAULT_THEME,
16 dirname,
17 emptyTree,
18 formatSize,
19 inside,
20 isAbsolute,
21 join,
22 LIGHT_THEME,
23 mapper,
24 parseGit,
25 parseNumstat,
26 parseTheme,
27 posix,
28 relative,
29 dropBelow,
30 useDrives,
31 rollCounts,
32 replaceChildren,
33 rollUp,
34 stamp,
35 toNodes,
36 underAny,
37 visibleRows,
38} from './tree'
39
40const TREE = { plugin: 'filetree', key: 'tree' } as const
41const THEME = { plugin: 'filetree', key: 'theme' } as const
42const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
43const PANE = 'filetree'
44const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
45const LIGHT_SHIMMER = Object.fromEntries(Object.entries(LIGHT_TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
46const BRANCH_ROW = '#branch'
47const FLASH_MS = 2700
48const RUNNING_MAX_MS = 600_000
49const NO_REPO_DEPTH = 4
50const DOUBLE_MS = 450
51const FIND_LIMIT = 200
52const ROWS_BUDGET = 80_000
53const READ_REVEAL_LIMIT = 12
54const SEARCH_REVEAL_LIMIT = 60
55const ACTIVITY_TTL_MS = 45_000
56const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
57const THEME_POLL_MS = 2000
58const GIT_POLL_MS = 2000
59const FONT_SCRIPT =
60 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | grep -vi lastresort | head -n1 | cut -d: -f1); fi; ' +
61 '[ -n "$f" ] || f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); ' +
62 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
63 '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 ' +
64 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
65 '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; ' +
66 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
67const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
68const HOME_PRUNE = ['Library', 'AppData', '.Trash']
69const SIZE_TIMEOUT_MS = 15_000
70const SIZE_JOBS = 2
71const SIZE_WALK_LIMIT = 50_000
72const AGE_TICK_MS = 60_000
73
74let blink: Timer | null = null
75let themePoll: Timer | null = null
76let gitPoll: Timer | null = null
77let gitStamp: string | null = null
78let gitDirOf = { root: '', dir: '' }
79let ageTick: Timer | null = null
80let themeMtime: number | null = null
81let lightTerminal = false
82let generation = 0
83let lastPress = { key: '', at: 0 }
84let noNerd = false
85let glyphSetting = 'auto'
86let openWithSetting = ''
87let editWithSetting = ''
88let diffWithSetting = ''
89let palette: Palette = parsePalette('')
90let inTmux = false
91let inHerdr = false
92let previewLine = 1
93let previewed: { path: string; text: string; note: string; diff?: DiffLine[] } | null = null
94let previewRows = 20
95let follow = true
96let followClaude = true
97let scanning: Promise<void> | null = null
98let scanJobs: Job[] = []
99let gitRun: Promise<void> | null = null
100let gitNext: Promise<void> | null = null
101let queuedReads: string[] = []
102let showReads = true
103let showWrites = true
104let searchIndex: { root: string; paths: Promise<string[]> } | null = null
105let activityId = 0
106let pointer = true
107let hovered = ''
108let view = { from: 0, max: 0 }
109let nativeScroll = false
110let lastSync = 0
111let noDock = false
112let home = ''
113let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
114let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
115let markId = 0
116let listSeq = 0
117let lastRoot = ''
118let sizeDefault = false
119let showAge = false
120let showHints = true
121let searchGap = true
122let showShortcuts = true
123let sizeEpoch = 0
124let sizeActive = 0
125let sizeQueue: string[] = []
126const sizing = new Set<string>()
127const listLatest = new Map<string, number>()
128const unreadable = new Set<string>()
129const background = new Map<string, Background>()
130
131function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
132 platform ??= (async () => {
133 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
134 try {
135 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
136 } catch {
137 return 'linux'
138 }
139 })()
140 return platform
141}
142
143async function cwdOf($: EngineInterface): Promise<string> {
144 return posix(await $.session.cwd())
145}
146
147function clean(segs: Seg[]): Seg[] {
148 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
149 return segs
150}
151
152function faint(hex: string): string {
153 const v = parseInt(hex.slice(1), 16)
154 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
155 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
156}
157
158async function readThemeSetting($: EngineInterface): Promise<void> {
159 const row = (await $.config.list()).find(r => r.key === 'theme')
160 const light = typeof row?.value === 'string' && row.value.startsWith('light')
161 if (light === lightTerminal) return
162 lightTerminal = light
163 void $.ui.invalidate('ui.render')
164}
165
166async function get($: EngineInterface): Promise<FileTree> {
167 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
168}
169
170async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
171 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
172}
173
174function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
175 return put($, t => ({ ...t, ...fn(t) }))
176}
177
178async function activities($: EngineInterface): Promise<Activity[]> {
179 return (await $.state.get(ACTIVITY)).value ?? []
180}
181
182async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
183 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
184}
185
186async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
187 try {
188 const entries = await $.fs.list(dir)
189 const resolved = await Promise.all(
190 entries.map(async e => {
191 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
192 try {
193 const target = await $.fs.stat(join(dir, e.name))
194 return target.kind === 'dir'
195 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
196 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
197 } catch {
198 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
199 }
200 }),
201 )
202 unreadable.delete(dir)
203 return toNodes(dir, resolved)
204 } catch (err) {
205 if (!(await exists($, dir))) return []
206 if (!unreadable.has(dir)) {
207 unreadable.add(dir)
208 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
209 }
210 return null
211 }
212}
213
214async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
215 const root = (await get($)).root
216 const seq = ++listSeq
217 for (const dir of dirs) listLatest.set(dir, seq)
218 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
219 const listed = new Map<string, FileNode[]>()
220 for (const [dir, kids] of results) {
221 if (listLatest.get(dir) !== seq) continue
222 listLatest.delete(dir)
223 if (kids) listed.set(dir, kids)
224 }
225 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
226 return listed
227}
228
229async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000, stdin?: string) {
230 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' }, ...(stdin === undefined ? {} : { stdin }) })
231}
232
233async function detectRepo($: EngineInterface): Promise<void> {
234 const t = await get($)
235 if (!t.root) return
236 let top = ''
237 let prefix = ''
238 try {
239 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
240 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
241 prefix = p.trim()
242 top = tl.trim()
243 } catch {
244 top = ''
245 }
246 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
247}
248
249async function readGit($: EngineInterface): Promise<void> {
250 const t = await get($)
251 if (!t.root || !t.top) return
252 const root = t.root
253 try {
254 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
255 if (status.exitCode !== 0) return
256 const parsed = parseGit(status.stdout, root, t.prefix)
257 const diff: Record<string, [number, number]> = {}
258 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
259 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
260 const files = { ...parsed.files }
261 if (parsed.untrackedDirs.length) {
262 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
263 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
264 }
265 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
266 dirty = { root, files }
267 await patch($, cur =>
268 cur.root === root
269 ? {
270 git: parsed.git,
271 ignored: parsed.ignored,
272 untrackedDirs: parsed.untrackedDirs,
273 branch: parsed.branch,
274 counts: rollCounts(files, root),
275 diff: rollUp(diff, root),
276 }
277 : {},
278 )
279 } catch {
280 return
281 }
282}
283
284function refreshGit($: EngineInterface): Promise<void> {
285 if (!gitRun) {
286 gitRun = readGit($).finally(() => {
287 gitRun = null
288 })
289 return gitRun
290 }
291 const rerun = () => {
292 gitNext = null
293 return refreshGit($)
294 }
295 gitNext ??= gitRun.then(rerun, rerun)
296 return gitNext
297}
298
299async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
300 const prev = await get($)
301 generation += 1
302 blink?.cancel()
303 blink = null
304 lastRoot = root
305 searchIndex = null
306 sizeEpoch += 1
307 sizeQueue = []
308 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
309 const title = `Files: ${root.split('/').pop() || root}`
310 if (focus) await $.ui.open({ id: PANE, title, focus: true })
311 else if (!noDock) await $.ui.open({ id: PANE, title })
312 await loadDirs($, [root])
313 await detectRepo($)
314 await refreshGit($)
315}
316
317async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
318 const tried = new Set<string>()
319 for (let pass = 0; pass < 32; pass++) {
320 const t = await get($)
321 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
322 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
323 const need = new Set<string>()
324 for (const p of paths) {
325 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
326 if (!loaded.has(dir)) {
327 if (!tried.has(dir)) need.add(dir)
328 break
329 }
330 }
331 }
332 if (need.size === 0) return
333 for (const dir of need) tried.add(dir)
334 await loadDirs($, [...need])
335 }
336}
337
338function pruneArgs(root: string, ignored: string[] = []): string[] {
339 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
340 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
341}
342
343async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
344 const stats = await Promise.all(
345 paths.map(async p => {
346 try {
347 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
348 } catch {
349 return ''
350 }
351 }),
352 )
353 return stats.filter(Boolean)
354}
355
356function broad(root: string): boolean {
357 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
358}
359
360function rootOfDisk(root: string): boolean {
361 return root === '/' || /^[A-Za-z]:\/$/.test(root)
362}
363
364async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
365 if (rootOfDisk(root)) return []
366 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
367 try {
368 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 })
369 return run.stdout.split('\0').filter(p => p && p !== root)
370 } catch {
371 return []
372 }
373}
374
375async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
376 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
377 if (dirty.root !== root) return { hits: [], gone: [] }
378 const entries = Object.entries(dirty.files)
379 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
380 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
381 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
382}
383
384type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
385
386async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
387 const ms = await $.clock.now()
388 const os = await osName($)
389 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
390 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
391 try {
392 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
393 return { ms, mark, os }
394 } catch {
395 return { ms, mark: '', os }
396 }
397}
398
399async function walkSize($: EngineInterface, dir: string): Promise<number> {
400 let total = 0
401 let seen = 0
402 let level = [dir]
403 while (level.length) {
404 const next: string[] = []
405 for (const d of level) {
406 let entries
407 try {
408 entries = await $.fs.list(d)
409 } catch {
410 continue
411 }
412 for (const e of entries) {
413 if (++seen > SIZE_WALK_LIMIT) return -1
414 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
415 else if (e.kind === 'file') total += e.size
416 }
417 }
418 level = next
419 }
420 return total
421}
422
423async function dirSize($: EngineInterface, dir: string): Promise<number> {
424 if (rootOfDisk(dir)) return -1
425 if ((await osName($)) === 'win32') return walkSize($, dir)
426 try {
427 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
428 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
429 return Number.isFinite(kb) ? kb * 1024 : -1
430 } catch {
431 return -1
432 }
433}
434
435function pumpSizes($: EngineInterface): void {
436 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
437 const dir = sizeQueue.shift() ?? ''
438 const epoch = sizeEpoch
439 sizing.add(dir)
440 sizeActive += 1
441 void dirSize($, dir)
442 .then(async bytes => {
443 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
444 const cur = await get($)
445 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
446 })
447 .catch(() => undefined)
448 .finally(() => {
449 sizing.delete(dir)
450 sizeActive -= 1
451 pumpSizes($)
452 })
453 }
454}
455
456function wantSizes($: EngineInterface, dirs: string[]): void {
457 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
458 pumpSizes($)
459}
460
461async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
462 sizeEpoch += 1
463 sizeQueue = []
464 await patch($, cur => {
465 if (!paths) return { dirSizes: {} }
466 const keep: Record<string, number> = {}
467 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
468 return { dirSizes: keep }
469 })
470}
471
472function openDirs(t: FileTree): string[] {
473 const open = new Set(t.expanded)
474 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
475}
476
477function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
478 if (followClaude) return { scroll: null }
479 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
480 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
481 return at < 0 ? {} : { scroll: at }
482}
483
484async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
485 const unique = Object.keys(tones)
486 if (unique.length === 0) return
487 const mine = ++generation
488 const root = (await get($)).root
489 blink?.cancel()
490 blink = null
491 await patch($, cur => {
492 if (cur.root !== root) return {}
493 const open = new Set(cur.expanded)
494 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
495 const dim = new Set<string>()
496 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
497 if (cur.flashOn) {
498 for (const id of cur.flashDim) dim.add(id)
499 }
500 for (const id of unique) {
501 const tone = tones[id] ?? 'orange'
502 all[id] = tone
503 if (id === BRANCH_ROW) continue
504 const chain = ancestorsOf(id, cur.root)
505 if (!chain.some(a => !open.has(a))) continue
506 for (const a of chain) {
507 if (!open.has(a)) {
508 dim.add(a)
509 all[a] = all[a] ?? tone
510 }
511 open.add(a)
512 }
513 }
514 for (const id of bright) dim.delete(id)
515 return {
516 flash: [...bright],
517 flashDim: [...dim],
518 flashOn: true,
519 flashTones: all,
520 expanded: [...open],
521 ...keepTop(cur, [...open]),
522 }
523 })
524 if (generation !== mine) return
525 blink = $.clock.after(FLASH_MS, () => {
526 if (generation !== mine) return
527 blink = null
528 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
529 })
530}
531
532async function followCwd($: EngineInterface): Promise<boolean> {
533 if (!follow) return false
534 const cwd = await cwdOf($)
535 const t = await get($)
536 if (t.root === cwd) return false
537 await reset($, cwd)
538 return true
539}
540
541type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
542type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
543type Background = { p: Pending | null; job: Job; reads: string[] }
544type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
545type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
546
547const PROOF: Record<string, (op: GitOperation) => boolean> = {
548 push: op => Boolean(op.push),
549 merge: op => op.branch?.action === 'merged',
550 rebase: op => op.branch?.action === 'rebased',
551 'pr create': op => op.pr?.action === 'created',
552 'pr merge': op => op.pr?.action === 'merged',
553 'pr comment': op => op.pr?.action === 'commented',
554}
555
556async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
557 const now = await $.clock.now()
558 const ids = actions.map(() => ++activityId)
559 await setActivities($, cur => [
560 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
561 ...actions.map((a, i) => ({
562 id: ids[i] ?? 0,
563 kind: a.kind,
564 label: a.running,
565 state: 'running' as const,
566 detail: '',
567 at: now,
568 tone: a.tone,
569 nerd: a.icon.nerd,
570 plain: a.icon.plain,
571 })),
572 ])
573 return ids
574}
575
576async function headOf($: EngineInterface, cwd: string): Promise<string> {
577 try {
578 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
579 return run.exitCode === 0 ? run.stdout.trim() : ''
580 } catch {
581 return ''
582 }
583}
584
585async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
586 const chain = chainOf(p.command)
587 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
588 return p.actions.map(a => {
589 if (a.verb === 'commit') {
590 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
591 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
592 }
593 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
594 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
595 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
596 })
597}
598
599async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
600 const now = await $.clock.now()
601 await setActivities($, cur =>
602 cur.map(a => {
603 const i = p.ids.indexOf(a.id)
604 const result = i < 0 ? undefined : results[i]
605 return result ? { ...a, ...result, at: now } : a
606 }),
607 )
608 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
609}
610
611function failAll(p: Pending): Outcome[] {
612 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
613}
614
615function proven(p: Pending | null, results: Outcome[]): GitAction[] {
616 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
617}
618
619function taskEnds(text: string): [string, string][] {
620 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
621 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
622 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
623 return id && status ? [[id, status] as [string, string]] : []
624 })
625}
626
627async function settleBackground($: EngineInterface, text: string): Promise<void> {
628 if (background.size === 0 || !text.includes('<task-notification>')) return
629 for (const [id, status] of taskEnds(text)) {
630 const task = background.get(id)
631 if (!task) continue
632 background.delete(id)
633 const ok = status === 'completed'
634 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
635 if (task.p) await finishGit($, task.p, results)
636 if (showReads) queuedReads.push(...task.reads)
637 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
638 }
639}
640
641async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
642 const map = mapper(root, prefix)
643 try {
644 const run = committed
645 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
646 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
647 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
648 } catch {
649 return []
650 }
651}
652
653async function gitChanged($: EngineInterface): Promise<void> {
654 const root = (await get($)).root
655 if (gitDirOf.root !== root || !gitDirOf.dir) gitDirOf = { root, dir: await gitDir($, root) }
656 const gd = gitDirOf.dir
657 const stamps = gd ? await Promise.all(['index', 'HEAD'].map(f => $.fs.stat(join(gd, f)).then(st => st.mtimeMs, () => 0))) : []
658 const stamp = gd ? `${gd}:${stamps.join(':')}` : ''
659 const before = gitStamp
660 gitStamp = stamp
661 if (before === '' && stamp) {
662 await detectRepo($)
663 void sync($, true)
664 } else if (before && stamp && before !== stamp) void sync($, true)
665}
666
667async function settleRoot($: EngineInterface): Promise<void> {
668 await loadTheme($)
669 const root = follow || !lastRoot ? await cwdOf($) : lastRoot
670 const t = await get($)
671 if (t.root !== root || t.nodes.length === 0) await reset($, root)
672}
673
674async function gitDir($: EngineInterface, cwd: string): Promise<string> {
675 for (let dir = cwd, i = 0; i < 64; i++) {
676 try {
677 const dot = join(dir, '.git')
678 const st = await $.fs.stat(dot)
679 if (st.kind === 'dir') return dot
680 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
681 if (ref) return resolve(dir, ref, home)
682 } catch {
683 const up = dirname(dir)
684 if (up === dir) return ''
685 dir = up
686 continue
687 }
688 return ''
689 }
690 return ''
691}
692
693async function sync($: EngineInterface, force = false): Promise<void> {
694 const now = await $.clock.now()
695 if (!force && now - lastSync < 2_000) return
696 lastSync = now
697 searchIndex = null
698 const t = await get($)
699 if (!t.root) return
700 await loadDirs($, [t.root, ...openDirs(t)])
701 if (t.top) await refreshGit($)
702}
703
704async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
705 const t = await get($)
706 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
707 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
708 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
709}
710
711async function exists($: EngineInterface, path: string): Promise<boolean> {
712 try {
713 await $.fs.stat(path)
714 return true
715 } catch {
716 return false
717 }
718}
719
720async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
721 const reads = queuedReads
722 queuedReads = []
723 if (await followCwd($)) return
724 const t = await get($)
725 if (!t.root) return
726 const actions = jobs.flatMap(j => j.actions)
727 const writers = jobs.filter(j => !j.readOnly)
728 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 })
729 const writes = !jobs.every(j => j.readOnly)
730 const before = dirty.root === t.root ? dirty.files : {}
731 if (jobs.some(j => j.initRepo)) await detectRepo($)
732 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
733 const probed = await get($)
734 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
735 if (writes) {
736 searchIndex = null
737 await staleSizes($)
738 }
739 const fresh = await get($)
740 const ignored = new Set(fresh.ignored)
741 const tones: Record<string, string> = {}
742 if (writes) {
743 const found = fresh.top
744 ? await changedInRepo($, t.root, since, before, fresh.ignored)
745 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
746 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
747 await revealPaths($, hits)
748 const loaded = await get($)
749 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
750 const listed = await loadDirs($, dirs)
751 if (!fresh.top && since.os === 'win32')
752 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
753 await patch($, cur => {
754 const ids = new Set(cur.nodes.map(n => n.id))
755 return { expanded: cur.expanded.filter(id => ids.has(id)) }
756 })
757 const present = new Set((await get($)).nodes.map(n => n.id))
758 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
759 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
760 }
761 if (showReads) {
762 const found: string[] = []
763 for (const r of reads) {
764 if (found.length >= READ_REVEAL_LIMIT) break
765 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
766 }
767 if (found.length) {
768 await revealPaths($, found)
769 const shown = new Set((await get($)).nodes.map(n => n.id))
770 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
771 }
772 }
773 if (showWrites && actions.length) {
774 const final = await get($)
775 const committed = actions.some(a => a.verb === 'commit')
776 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
777 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
778 await revealPaths($, paths)
779 const shown = new Set((await get($)).nodes.map(n => n.id))
780 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
781 }
782 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
783 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
784 else if (committed) tones[BRANCH_ROW] = 'green'
785 }
786 await flash($, tones)
787}
788
789function scheduleScan($: EngineInterface, job: Job): void {
790 scanJobs.push(job)
791 if (scanning) return
792 scanning = (async () => {
793 while (scanJobs.length) {
794 const jobs = scanJobs
795 scanJobs = []
796 try {
797 await afterBash($, jobs)
798 } finally {
799 const marks = jobs.map(j => j.since.mark).filter(Boolean)
800 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
801 }
802 }
803 })().finally(() => {
804 scanning = null
805 })
806}
807
808async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
809 if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
810 if (await followCwd($)) return
811 const t = await get($)
812 if (!t.root) return
813 const within = paths.map(posix).filter(p => inside(t.root, p))
814 if (within.length === 0) return
815 if (tone !== 'purple') {
816 searchIndex = null
817 await staleSizes($, within)
818 await revealPaths($, within.map(dirname))
819 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
820 await refreshGit($)
821 } else await revealPaths($, within)
822 if (!show) return
823 const tones: Record<string, string> = {}
824 for (const p of within) tones[p] = tone
825 await flash($, tones)
826}
827
828async function reveal($: EngineInterface, paths: string[]): Promise<void> {
829 if (await followCwd($)) return
830 await revealPaths($, paths)
831 await patch($, cur => {
832 const open = new Set(cur.expanded)
833 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
834 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
835 })
836}
837
838async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
839 const out: string[] = []
840 let level = [root]
841 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
842 const next: string[] = []
843 for (const dir of level) {
844 for (const n of (await list($, dir)) ?? []) {
845 if (n.kind === 'dir') {
846 if (!PRUNE.includes(n.name)) next.push(n.id)
847 } else out.push(n.id)
848 }
849 if (out.length >= limit) break
850 }
851 level = next
852 }
853 return out
854}
855
856async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
857 try {
858 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
859 const run = t.top
860 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
861 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
862 if (t.top && run.exitCode !== 0) return null
863 const paths = run.stdout.split('\0').filter(Boolean)
864 if (run.isStdoutTruncated) paths.pop()
865 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
866 } catch {
867 return null
868 }
869}
870
871function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
872 if (searchIndex?.root !== t.root) {
873 const index: { root: string; paths: Promise<string[]> } = {
874 root: t.root,
875 paths: listAll($, t).then(paths => {
876 if (paths === null && searchIndex === index) searchIndex = null
877 return paths ?? []
878 }),
879 }
880 searchIndex = index
881 }
882 return searchIndex.paths
883}
884
885async function search($: EngineInterface, query: string): Promise<string[]> {
886 if (!(await get($)).query.trim()) searchIndex = null
887 await patch($, () => ({ query }))
888 const q = query.trim().toLowerCase()
889 if (!q) return []
890 const t = await get($)
891 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
892 if ((await get($)).query !== query) return []
893 await revealPaths($, hits)
894 const shown = await get($)
895 const ids = new Set(shown.nodes.map(n => n.id))
896 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
897 if (stale.length) await loadDirs($, stale)
898 return hits
899}
900
901async function jump($: EngineInterface, query: string): Promise<void> {
902 const hits = (await search($, query)).slice(0, 10)
903 await patch($, cur => {
904 const open = new Set(cur.expanded)
905 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
906 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
907 })
908}
909
910async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
911 try {
912 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
913 return out === 'stale' || out === 'missing' ? out : 'ok'
914 } catch {
915 return 'missing'
916 }
917}
918
919async function finePointerOk($: EngineInterface): Promise<boolean> {
920 if (!(await $.env.get('HERDR_ENV'))) return true
921 try {
922 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
923 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
924 return major * 1e6 + minor * 1e3 + fix >= 9_001
925 } catch {
926 return false
927 }
928}
929
930const PREVIEW_CHARS = 90_000
931const PREVIEW_LINE = 2_000
932
933function fitSerialized(lines: string[], max: number): string[] {
934 let size = 0
935 for (let i = 0; i < lines.length; i++) {
936 size += JSON.stringify(lines[i]).length
937 if (size > max) return lines.slice(0, i)
938 }
939 return lines
940}
941
942async function loadPreview($: EngineInterface, path: string): Promise<void> {
943 let text = ''
944 let note = ''
945 try {
946 const stat = await $.fs.stat(path)
947 if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
948 else {
949 text = await $.fs.read(path)
950 if (text.slice(0, 8000).includes('\0')) {
951 text = ''
952 note = `binary file, ${formatSize(stat.size)}`
953 }
954 }
955 } catch (err) {
956 note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
957 }
958 const total = text.length
959 let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
960 if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
961 lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
962 const fit = fitSerialized(lines, PREVIEW_CHARS)
963 if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
964 previewed = { path, text: fit.join('\n'), note }
965}
966
967async function loadDiff($: EngineInterface, path: string, folder: boolean): Promise<boolean> {
968 const cwd = folder ? path : dirname(path)
969 const born = await git($, cwd, ['rev-parse', '--verify', '--quiet', 'HEAD']).then(r => r.exitCode === 0, () => false)
970 const base = born ? 'HEAD' : await git($, cwd, ['hash-object', '-t', 'tree', '--stdin'], 10_000, '').then(r => r.stdout.trim(), () => '')
971 if (!base) return false
972 const tracked = await git($, cwd, ['diff', base, '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', '--', path]).then(r => r.stdout, () => '')
973 const others = await git($, cwd, ['ls-files', '--others', '--exclude-standard', '--full-name', '-z', '--', path]).then(r => r.stdout.split('\0').filter(Boolean), () => [] as string[])
974 const top = (await get($)).top || cwd
975 const files = splitPatch(tracked.slice(0, PREVIEW_CHARS))
976 for (const rel of others.slice(0, 50)) {
977 const run = await git($, top, ['diff', '--no-index', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', '--', '/dev/null', rel]).catch(() => null)
978 const file = run ? splitPatch(run.stdout.slice(0, PREVIEW_CHARS))[0] : undefined
979 if (file) files.push({ path: rel, lines: file.lines })
980 }
981 if (!files.length) return false
982 const many = folder || files.length > 1
983 const lines = files.flatMap(f => (many ? [{ kind: '@' as const, text: f.path, old: 0, new: 0 }, ...f.lines] : f.lines))
984 const note = many ? `${files.length} changed file${files.length === 1 ? '' : 's'} against HEAD` : others.length ? 'untracked file' : 'changes against HEAD'
985 previewed = { path, text: '', note, diff: lines }
986 return true
987}
988
989async function diffFile($: EngineInterface, path: string): Promise<void> {
990 if (!diffWithSetting) {
991 const t = await get($)
992 const folder = t.nodes.find(n => n.id === path)?.kind === 'dir'
993 if (t.preview === path && previewed?.path === path && previewed.diff) return folder ? closePreview($) : showPreview($, path)
994 if (!(await loadDiff($, path, folder))) {
995 $.ui.toast(`no changes in ${shortPath(path)}`)
996 return
997 }
998 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
999 $.clock.after(100, () => parkPreview($))
1000 return
1001 }
1002 const os = await osName($)
1003 const host = await editorHost($, os)
1004 try {
1005 if (host === 'herdr') return await openInHerdr($, diffWithSetting, path)
1006 if (host === 'tmux') {
1007 const run = await $.process.run(['tmux', 'new-window', '-c', dirname(path), '--', ...words(diffWithSetting), path], { timeoutMs: 10_000 })
1008 if (run.exitCode !== 0) $.ui.toast(`could not diff ${shortPath(path)} with ${diffWithSetting}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1009 return
1010 }
1011 if (os === 'linux') {
1012 const argv = ['sh', '-c', LINUX_LAUNCH, 'sh', 'xdg-terminal-exec', 'sh', '-c', 'cd "$1" && shift && exec "$@"', 'sh', dirname(path), ...words(diffWithSetting), path]
1013 const run = await $.process.run(argv, { timeoutMs: 10_000 })
1014 if (run.exitCode !== 0) $.ui.toast(`could not diff ${shortPath(path)} with ${diffWithSetting}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1015 return
1016 }
1017 $.ui.toast('Diff with: a terminal command needs tmux, herdr or Linux. Clear Diff with to see the diff in the pane.')
1018 } catch {
1019 $.ui.toast(`could not diff ${shortPath(path)} with ${diffWithSetting}`)
1020 }
1021}
1022
1023async function showPreview($: EngineInterface, path: string): Promise<void> {
1024 await loadPreview($, path)
1025 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
1026 $.clock.after(100, () => parkPreview($))
1027}
1028
1029function parkPreview($: EngineInterface): void {
1030 void $.ui.focus({ requestId: PANE, key: editWithSetting || inTmux || inHerdr ? 'edit' : 'back' }).catch(() => {})
1031}
1032
1033async function closePreview($: EngineInterface): Promise<void> {
1034 await patch($, () => ({ preview: '', previewTop: 0 }))
1035 $.clock.after(100, () => void $.ui.focus({ requestId: PANE, key: showShortcuts ? 'nav-o' : 'rows' }).catch(() => {}))
1036}
1037
1038async function jumpDiff($: EngineInterface, dir: 1 | -1): Promise<void> {
1039 const lines = previewed?.diff
1040 if (!lines) return
1041 const files = lines.some(l => l.kind === '@')
1042 const marks = lines.flatMap((l, i) => {
1043 if (files) return l.kind === '@' ? [i] : []
1044 const prev = lines[i - 1]
1045 const gap = !prev || l.old !== prev.old + (prev.kind === '+' ? 0 : 1) || l.new !== prev.new + (prev.kind === '-' ? 0 : 1)
1046 return gap ? [i] : []
1047 })
1048 const t = await get($)
1049 const to = dir > 0 ? marks.find(i => i > t.previewTop) : [...marks].reverse().find(i => i < t.previewTop)
1050 if (to !== undefined) await patch($, () => ({ previewTop: to }))
1051}
1052
1053async function scrollPreview($: EngineInterface, by: number): Promise<void> {
1054 const t = await get($)
1055 const last = Math.max(0, (previewed?.diff?.length ?? previewed?.text.split('\n').length ?? 1) - previewRows)
1056 const top = Math.max(0, Math.min(last, t.previewTop + by))
1057 if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
1058}
1059
1060function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
1061 const lines = text.split('\n')
1062 const from = Math.max(0, Math.min(top, lines.length - 1))
1063 let shown = lines.slice(from, from + rows)
1064 if (markdown) {
1065 // Reopen a code fence the window starts inside, so the rest is not read as prose.
1066 let fence = ''
1067 let opener = ''
1068 for (const line of lines.slice(0, from)) {
1069 const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
1070 if (!m) continue
1071 const mark = m[1] ?? ''
1072 if (!fence) {
1073 fence = mark
1074 opener = line.trimStart()
1075 } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
1076 }
1077 if (fence) shown = [opener, ...shown]
1078 }
1079 return { text: shown.join('\n'), top: from }
1080}
1081
1082async function openFile($: EngineInterface, path: string, how: { mode?: string; line?: number } = {}): Promise<void> {
1083 const mode = (how.mode ?? openWithSetting).trim()
1084 if (mode === 'preview') return showPreview($, path)
1085 const os = await osName($)
1086 const host = mode === 'editor' ? await editorHost($, os) : ''
1087 if (mode === 'editor' && !host) {
1088 $.ui.toast('Open with: editor requires tmux or herdr on Linux or macOS. Clear Open with to use the default app, or set a custom command.')
1089 return
1090 }
1091 const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
1092 const custom = mode === 'editor' ? null : openWithCommand(mode, native(path), native(dirname(path)), how.line)
1093 const editor = mode === 'editor' ? (await $.env.get('VISUAL')) || (await $.env.get('EDITOR')) || 'vi' : undefined
1094 try {
1095 if (editor && host === 'herdr') return await openInHerdr($, editor, path, how.line)
1096 const { argv, init } = editor ? tmuxOpenCommand(editor, path, how.line) : custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
1097 const run = await $.process.run(argv, init)
1098 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${editor ?? custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1099 } catch {
1100 $.ui.toast(`could not open ${path} with ${editor ?? custom?.[0] ?? 'the default app'}`)
1101 }
1102}
1103
1104async function editorHost($: EngineInterface, os: 'linux' | 'darwin' | 'win32'): Promise<'tmux' | 'herdr' | ''> {
1105 if (os === 'win32') return ''
1106 if (await $.env.get('TMUX')) return 'tmux'
1107 return (await $.env.get('HERDR_ENV')) ? 'herdr' : ''
1108}
1109
1110async function openInHerdr($: EngineInterface, editor: string, path: string, line?: number): Promise<void> {
1111 const { split, run, init } = herdrOpenCommand((await $.env.get('HERDR_BIN_PATH')) || 'herdr', editor, path, line)
1112 const made = await $.process.run(split, init)
1113 const sent = made.exitCode === 0 ? await $.process.run(run(herdrPane(made.stdout)), init) : made
1114 if (sent.exitCode !== 0) $.ui.toast(`could not open ${path} with ${editor}: ${herdrError(sent.stderr) || `exit ${sent.exitCode}`}`)
1115}
1116
1117async function editPreview($: EngineInterface, path: string): Promise<void> {
1118 if (editWithSetting || inTmux || inHerdr) await openFile($, path, { mode: editWithSetting || 'editor', line: previewLine })
1119}
1120
1121async function quickLook($: EngineInterface, path: string): Promise<void> {
1122 try {
1123 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 })
1124 } catch {
1125 $.ui.toast(`could not preview ${path} with Quick Look`)
1126 }
1127}
1128
1129async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1130 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1131 await patch($, t => {
1132 const open = new Set(t.expanded)
1133 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1134 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1135 })
1136}
1137
1138async function press($: EngineInterface, n: FileNode): Promise<void> {
1139 const now = await $.clock.now()
1140 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1141 lastPress = { key: isDouble ? '' : n.id, at: now }
1142 if (!isDouble) {
1143 await toggle($, n)
1144 return
1145 }
1146 await openNode($, n)
1147}
1148
1149async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1150 if (n.kind === 'dir') {
1151 follow = false
1152 await reset($, n.id)
1153 } else await openFile($, n.id)
1154}
1155
1156async function navigate($: EngineInterface, key: string, shift = false, surface?: string): Promise<void> {
1157 const t = await get($)
1158 const rows = visibleRows(t)
1159 const at = rows.findIndex(r => r.node.id === t.cursor)
1160 const cur = rows[at]?.node
1161 const move = (d: number) => {
1162 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1163 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1164 }
1165 if (key === 'up' || key === 'k') await move(-1)
1166 else if (key === 'down' || key === 'j') await move(1)
1167 else if (key === 'pageup') await move(-10)
1168 else if (key === 'pagedown') await move(10)
1169 else if (key === 'home') await move(-rows.length)
1170 else if (key === 'end') await move(rows.length)
1171 else if (cur && key === 'd') await diffFile($, cur.id)
1172 else if (cur && (key === 'y' || key === 'Y')) await copyPath($, cur.id, key === 'Y' || shift, surface)
1173 else if (cur && (key === 'right' || key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1174 else if (cur && (key === 'left' || key === 'h')) {
1175 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1176 else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1177 } else if (cur && key === 'return') await (cur.kind !== 'dir' ? openNode($, cur) : toggle($, cur))
1178 else if (cur && (key === ' ' || key === 'space')) await (cur.kind !== 'dir' && (await osName($)) === 'darwin' ? quickLook($, cur.id) : toggle($, cur))
1179}
1180
1181async function loadTheme($: EngineInterface): Promise<void> {
1182 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
1183 try {
1184 const stat = await $.fs.stat(path)
1185 if (stat.kind !== 'file') throw new Error('no theme file')
1186 if (stat.mtimeMs === themeMtime) return
1187 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1188 themeMtime = stat.mtimeMs
1189 } catch {
1190 if (themeMtime === 0) return
1191 await $.state.set(THEME, DEFAULT_THEME)
1192 themeMtime = 0
1193 }
1194}
1195
1196async function pressHeader($: EngineInterface, id: string): Promise<void> {
1197 const t = await get($)
1198 if (id === 'up') {
1199 follow = false
1200 await reset($, dirname(t.root))hooks/open.ts 137 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25export const TMUX_OPEN = [
26 'f=$1 d=$2 n=$3; shift 3',
27 'bin=$1',
28 'case ${bin##*/} in nvim) ;; *) exec tmux new-window -c "$d" -- "$@" ${n:+"+$n"} "$f" ;; esac',
29 'probe() {',
30 ' "$bin" --server "$s" --remote-expr \'$TMUX . " " . $TMUX_PANE\' 2>/dev/null &',
31 ' request=$!',
32 ' (sleep 1; kill -KILL "$request") >/dev/null 2>&1 &',
33 ' timer=$!',
34 ' wait "$request" 2>/dev/null; result=$?',
35 ' kill "$timer" 2>/dev/null',
36 ' wait "$timer" 2>/dev/null',
37 ' return "$result"',
38 '}',
39 'here=$(tmux display-message -p \'#{socket_path} #{session_id}\')',
40 'best= act=-1',
41 'for s in "${XDG_RUNTIME_DIR:-/nonexistent}"/nvim.*.0 "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"/nvim."$(id -un)"/*/nvim.*.0; do',
42 ' [ -S "$s" ] && [ -O "$s" ] || continue',
43 ' t=$(probe) || continue',
44 ' p=${t##* } sock=${t%%,*}',
45 ' [ -n "$p" ] && [ "$sock" != "$t" ] || continue',
46 ' info=$(tmux display-message -p -t "$p" \'#{socket_path} #{session_id} #{window_activity}\' 2>/dev/null) || continue',
47 ' location=${info% *} activity=${info##* }',
48 ' [ "$location" = "$here" ] && [ "${location% *}" = "$sock" ] && [ "$activity" -gt "$act" ] || continue',
49 ' best=$s pane=$p act=$activity',
50 'done',
51 'if [ -n "$best" ] && "$bin" --server "$best" --remote "$f"; then',
52 ' [ -z "$n" ] || "$bin" --server "$best" --remote-send "<C-\\><C-N>:$n<CR>"',
53 ' exec tmux select-window -t "$pane" \\; select-pane -t "$pane"',
54 'fi',
55 'exec tmux new-window -c "$d" -- "$@" ${n:+"+$n"} "$f"',
56].join('\n')
57
58const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
59
60export function tmuxOpenCommand(editor: string, target: string, line?: number) {
61 const argv = words(editor)
62 if (!argv[0]) throw new Error('Editor command is empty')
63 const dir = target.slice(0, target.lastIndexOf('/')) || '/'
64 return {
65 argv: ['sh', '-c', TMUX_OPEN, 'sh', target, dir, line ? String(line) : '', ...argv],
66 init: { timeoutMs: 10_000 },
67 }
68}
69
70export function herdrOpenCommand(herdr: string, editor: string, target: string, line?: number) {
71 const argv = words(editor)
72 if (!argv[0]) throw new Error('Editor command is empty')
73 const dir = target.slice(0, target.lastIndexOf('/')) || '/'
74 const command = [...argv, ...(line ? [`+${line}`] : [])].map(w => `'${w.replace(/'/g, "'\\''")}'`).join(' ')
75 return {
76 split: [herdr, 'pane', 'split', '--current', '--direction', 'right', '--focus', '--cwd', dir, '--env', `PANE_OPEN_TARGET=${target}`],
77 run: (pane: string) => [herdr, 'pane', 'run', pane, `exec ${command} "$PANE_OPEN_TARGET"`],
78 init: { timeoutMs: 10_000 },
79 }
80}
81
82export function herdrPane(stdout: string): string {
83 const id: unknown = JSON.parse(stdout)?.result?.pane?.pane_id
84 if (typeof id !== 'string' || !id) throw new Error('herdr returned no pane id')
85 return id
86}
87
88export function herdrError(stderr: string): string {
89 let message: unknown
90 try {
91 message = JSON.parse(stderr)?.error?.message
92 } catch {}
93 return typeof message === 'string' && message ? message : (stderr.trim().split('\n')[0] ?? '')
94}
95
96export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
97 const argv = os === 'linux'
98 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
99 : os === 'darwin'
100 ? ['open', '--', target]
101 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
102 return {
103 argv,
104 init: {
105 timeoutMs: 10_000,
106 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
107 },
108 }
109}
110
111export function words(command: string): string[] {
112 const out: string[] = []
113 let word: string | null = null
114 let quote = ''
115 for (const ch of command) {
116 if (quote) {
117 if (ch === quote) quote = ''
118 else word = (word ?? '') + ch
119 } else if (ch === '"' || ch === "'") {
120 quote = ch
121 word ??= ''
122 } else if (/\s/.test(ch)) {
123 if (word !== null) out.push(word)
124 word = null
125 } else word = (word ?? '') + ch
126 }
127 if (word !== null) out.push(word)
128 return out
129}
130
131export function openWithCommand(command: string, path: string, dir: string, line = 1): string[] | null {
132 const argv = words(command)
133 if (!argv.length) return null
134 const filled = argv.map(w => w.replace(/\{(path|dir|line)\}/g, (_, key) => (key === 'path' ? path : key === 'dir' ? dir : String(line))))
135 return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
136}
137hooks/button.tsx 19 lines1import type { ClientModule } from 'claude-code'
2
3export type ButtonProps = { id: string; label: string; dim: boolean }
4
5// A header button that also tells the hooks module when the pointer is over it, which a Button
6// alone doesn't, so the header can say what the button does.
7const HeaderButton: ClientModule<ButtonProps, { over: boolean }> = (props, surface) => {
8 const { Button } = surface.elements
9 surface.onPointer(e => {
10 const over = e.type !== 'leave'
11 if (over === Boolean(surface.state?.over)) return
12 surface.setState({ over })
13 surface.post({ over })
14 })
15 return <Button key={props.id} plain dimColor={props.dim} label={props.label} onPress={() => surface.post({ press: true })} />
16}
17
18export default HeaderButton
19hooks/git.ts 272 lines1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4 kind: string
5 verb: string
6 running: string
7 done: string
8 tone: string
9 icon: { nerd: string; plain: string }
10 init?: boolean
11}
12
13const ICON = {
14 commit: { nerd: '\u{f417}', plain: '●' },
15 push: { nerd: '\u{f0552}', plain: '↑' },
16 pull: { nerd: '\u{f0553}', plain: '↓' },
17 branch: { nerd: '\u{f418}', plain: '⑂' },
18 merge: { nerd: '\u{f419}', plain: '⑃' },
19 pr: { nerd: '\u{f407}', plain: '⇄' },
20 github: { nerd: '\u{f408}', plain: '◎' },
21 stash: { nerd: '\u{f01bc}', plain: '≡' },
22 undo: { nerd: '\u{f054c}', plain: '↶' },
23 tag: { nerd: '\u{f412}', plain: '⌖' },
24 git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28 orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8e491d', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29 green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#2e6644', '#1b6838', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#1b665f', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31 blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#40589c', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32 purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#76459d', '#7d3cb2', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33 cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#1c6379', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34 red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#954343', '#a43434', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37export const LIGHT_TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
38 orange: { bright: ['#7c2d12', '#69250e', '#561c0b', '#431407'], dim: ['#5d362e', '#52322c', '#482d2a', '#3e2828'], solid: '#7c2d12' },
39 green: { bright: ['#14532d', '#0f4725', '#0a3a1e', '#052e16'], dim: ['#244b3d', '#214439', '#1e3d35', '#1b3731'], solid: '#14532d' },
40 teal: { bright: ['#134e4a', '#0e4441', '#093937', '#042f2e'], dim: ['#23484d', '#204348', '#1e3d43', '#1b373e'], solid: '#134e4a' },
41 blue: { bright: ['#1e3a8a', '#1c3378', '#192c66', '#172554'], dim: ['#293d70', '#283966', '#26355d', '#253253'], solid: '#1e3a8a' },
42 purple: { bright: ['#581c87', '#4e157b', '#450e70', '#3b0764'], dim: ['#492d6f', '#442968', '#3f2562', '#39215b'], solid: '#581c87' },
43 cyan: { bright: ['#164e63', '#114559', '#0d3c4e', '#083344'], dim: ['#25485b', '#224355', '#203e4f', '#1d394a'], solid: '#164e63' },
44 red: { bright: ['#7f1d1d', '#6c1717', '#581010', '#450a0a'], dim: ['#5f2d34', '#542a31', '#49262d', '#3f232a'], solid: '#7f1d1d' },
45}
46
47const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
48 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
49 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
50 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
51 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
52 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
53 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
54 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
55 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
56 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
57 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
58 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
59 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
60 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
61 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
62 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
63 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
64 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
65 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
66 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
67 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
68}
69
70const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
71 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
72 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
73 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
74 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
75 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
76 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
77 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
78 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
79}
80
81const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
82
83function segments(command: string, seps: string[] = []): string[][] {
84 const out: string[][] = []
85 let cur: string[] = []
86 let tok = ''
87 let quote = ''
88 let has = false
89 const endTok = () => {
90 if (has) cur.push(tok)
91 tok = ''
92 has = false
93 }
94 const endSeg = () => {
95 endTok()
96 if (cur.length) out.push(cur)
97 cur = []
98 }
99 for (let i = 0; i < command.length; i++) {
100 const ch = command[i] ?? ''
101 if (quote) {
102 if (ch === quote) quote = ''
103 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
104 else tok += ch
105 } else if (ch === '"' || ch === "'") {
106 quote = ch
107 has = true
108 } else if (ch === '\\' && i + 1 < command.length) {
109 tok += command[++i]
110 has = true
111 } else if (ch === '#' && !has) {
112 while (i + 1 < command.length && command[i + 1] !== '\n') i++
113 } else if (ch === ' ' || ch === '\t') endTok()
114 else if (ch === '\n' || ch === ';') {
115 seps.push(';')
116 endSeg()
117 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
118 tok += ch
119 has = true
120 } else if (ch === '&' || ch === '|') {
121 const twice = command[i + 1] === ch
122 if (twice) i++
123 seps.push(twice ? ch + ch : ch)
124 endSeg()
125 } else {
126 tok += ch
127 has = true
128 }
129 }
130 endSeg()
131 if (!quote) return out
132 seps.push(';')
133 return command
134 .split(/&&|\|\||;|\||\n/)
135 .map(s => s.trim().split(/\s+/).filter(Boolean))
136 .filter(s => s.length > 0)
137}
138
139function stripGlobals(tokens: string[]): string[] {
140 const out = [...tokens]
141 if (out[0] === 'env') out.shift()
142 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
143 return out
144}
145
146function gitVerb(tokens: string[]): number {
147 let i = 1
148 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
149 if (tokens[i] === '-C' || tokens[i] === '-c') i++
150 i++
151 }
152 return i
153}
154
155function gitReadOnly(verb: string, rest: string[]): boolean {
156 if (!verb || READ_ONLY_GIT.has(verb)) return true
157 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
158 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
159 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
160 return false
161}
162
163export function chainOf(command: string): { size: number; and: boolean } {
164 const seps: string[] = []
165 const size = segments(command, seps).length
166 return { size, and: seps.every(s => s === '&&') }
167}
168
169export function gitActions(command: string): GitAction[] {
170 const out: GitAction[] = []
171 for (const raw of segments(command)) {
172 const tokens = stripGlobals(raw)
173 const head = tokens[0]?.split('/').pop()
174 if (head === 'git') {
175 const i = gitVerb(tokens)
176 const verb = tokens[i] ?? ''
177 const rest = tokens.slice(i + 1)
178 if (gitReadOnly(verb, rest)) continue
179 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
180 if (spec) out.push({ kind: `git ${verb}`, ...spec })
181 } else if (head === 'gh') {
182 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
183 const spec = GH_VERBS[pair]
184 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
185 }
186 }
187 return out
188}
189
190export const BRANCH_ICON = ICON.branch
191
192const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
193const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
194const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
195
196function tidy(path: string): string {
197 const s = path.replace(/\/+$/, '')
198 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
199}
200
201export function resolve(cwd: string, p: string, home = ''): string {
202 let path = posix(p)
203 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
204 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
205 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
206 for (const seg of path.slice(drive.length).split('/')) {
207 if (!seg || seg === '.') continue
208 if (seg === '..') {
209 if (parts.length > 1) parts.pop()
210 } else parts.push(seg)
211 }
212 return tidy(parts.join('/'))
213}
214
215const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
216
217export function readOnly(command: string): boolean {
218 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
219 if (/[^>]>[^>&]|>>/.test(bare)) return false
220 return segments(command).every(raw => {
221 const tokens = stripGlobals(raw)
222 const head = tokens[0]?.split('/').pop() ?? ''
223 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
224 if (head === 'git') {
225 const i = gitVerb(tokens)
226 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
227 }
228 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
229 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
230 return READERS.has(head)
231 })
232}
233
234export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
235 let cwd = sessionCwd
236 const out = new Set<string>()
237 let listsPaths = false
238 for (const raw of segments(command)) {
239 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
240 const head = tokens[0]?.split('/').pop() ?? ''
241 if (head === 'cd' && tokens[1]) {
242 cwd = resolve(cwd, tokens[1], home)
243 continue
244 }
245 if (!READERS.has(head)) continue
246 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
247 if (OUTPUT_PATHS.has(head)) listsPaths = true
248 const args = tokens.slice(1)
249 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
250 for (let i = 0; i < args.length; i++) {
251 const a = args[i] ?? ''
252 if (a.startsWith('-')) {
253 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
254 continue
255 }
256 if (skipPattern) {
257 skipPattern = false
258 continue
259 }
260 if (/[*?<>|]/.test(a)) continue
261 out.add(resolve(cwd, a, home))
262 }
263 }
264 if (listsPaths) {
265 for (const line of stdout.split('\n').slice(0, 400)) {
266 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
267 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
268 }
269 }
270 return [...out].slice(0, 60)
271}
272hooks/rows.tsx 178 lines1import type { ClientModule, RenderElement } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6 rows: RowSpec[]
7 active: string
8 activeBg: string
9 hoverBg: string
10 tones: Record<string, { bright: string[]; dim: string[] }>
11 spinner?: string[]
12 pointer?: boolean
13 bar?: { pos: number; size: number; thumb: string; track: string }
14 preview?: { text: string; path: string; startLine: number; markdown: boolean; segments?: { title: string; source: string }[]; titleColor?: string }
15}
16type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
17
18const TICK_MS = 90
19const TREE_BUDGET = 70_000
20const MAX_ROWS = 1_500
21const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
22
23function shimmer(i: number, phase: number, len: number, palette: string[]): string {
24 const band = ((phase * 1.6) % (len + 8)) - 4
25 const d = Math.abs(i - band)
26 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
27}
28
29const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
30 const { Box, Text } = surface.elements
31 let state = surface.state
32 if (state === undefined) {
33 state = { hover: -1, phase: 0, drag: false, ref: {} }
34 surface.setState(state)
35 }
36 let visible = props.rows.length
37 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
38 if (lit && !state.ref.stop) {
39 state.ref.stop = surface.every(TICK_MS, () => {
40 const cur = surface.state
41 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
42 })
43 } else if (!lit && state.ref.stop) {
44 state.ref.stop()
45 state.ref.stop = undefined
46 }
47 if (props.pointer === false) {
48 state.ref.unpoint?.()
49 state.ref.unpoint = undefined
50 } else state.ref.unpoint = surface.onPointer(e => {
51 const cur = surface.state ?? state
52 const span = Math.max(1, props.rows.length - 1)
53 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
54 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
55 if (e.type === 'up' || e.type === 'leave') {
56 surface.setState({ ...cur, drag: false })
57 return
58 }
59 if (e.type === 'down' || e.type === 'move') {
60 if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
61 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
62 return
63 }
64 }
65 if (e.type === 'leave' || e.y < 0 || e.y >= visible) {
66 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
67 return
68 }
69 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
70 const row = props.rows[e.y]
71 if (e.type === 'down' && e.button === 'right' && row?.id) {
72 surface.post({ copy: row.id, shift: Boolean(e.shift) })
73 return
74 }
75 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
76 let x = 0
77 for (const seg of row.left) {
78 const w = [...seg.t].length
79 if (seg.tab && e.x >= x && e.x < x + w) {
80 surface.post({ tab: seg.tab })
81 return
82 }
83 x += w
84 }
85 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
86 })
87 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
88 // The preview draws in this same element, so the focus a click gave the tree stays put.
89 if (props.preview) {
90 const { Markdown, Code, Box: Column, Text: Title } = surface.elements
91 const p = props.preview
92 if (p.segments) {
93 return (
94 <Column flexDirection="column">
95 {p.segments.map((g, i) => (
96 <Column key={`seg-${i}`} flexDirection="column">
97 {g.title ? <Title bold color={p.titleColor || undefined}>{g.title}</Title> : null}
98 {g.source ? <Code source={g.source} path={g.title || p.path} format="diff" wrap="truncate-end" /> : null}
99 </Column>
100 ))}
101 </Column>
102 )
103 }
104 return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
105 }
106 const frames = props.spinner?.length ? props.spinner : FRAMES
107 const draw = (s: Seg) => {
108 if (s.spin) {
109 return (
110 <Text color={s.c} bold={s.b}>
111 {s.t + (frames[state.phase % frames.length] ?? '')}
112 </Text>
113 )
114 }
115 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
116 if (!palette) {
117 return (
118 <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
119 {s.t}
120 </Text>
121 )
122 }
123 if (s.one) {
124 return (
125 <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
126 {s.t}
127 </Text>
128 )
129 }
130 const chars = [...s.t]
131 return (
132 <Text bold={s.b}>
133 {chars.map((ch, i) => (
134 <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
135 ))}
136 </Text>
137 )
138 }
139 const rowEl = (r: RowSpec, i: number) => (
140 <Box
141 flexDirection="row"
142 height={1}
143 overflow="hidden"
144 backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
145 >
146 <Box flexShrink={1} overflow="hidden">
147 {r.left.map(draw)}
148 </Box>
149 <Box flexGrow={1} />
150 {r.right.map(draw)}
151 {props.bar && (
152 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
153 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
154 </Text>
155 )}
156 </Box>
157 )
158 const drawn: RenderElement[] = []
159 let size = 0
160 for (let i = 0; i < props.rows.length; i++) {
161 const el = rowEl(props.rows[i] as RowSpec, i)
162 let cost = 1_000
163 try {
164 cost = JSON.stringify(el).length
165 } catch {}
166 if (size + cost > TREE_BUDGET || drawn.length >= MAX_ROWS) {
167 visible = i
168 drawn.push(<Text dimColor>{` … ${props.rows.length - i} more rows, collapse folders or use search`}</Text>)
169 break
170 }
171 size += cost
172 drawn.push(el)
173 }
174 return <Box flexDirection="column">{drawn}</Box>
175}
176
177export default Rows
178hooks/diff.ts 83 lines1export type DiffLine = { kind: ' ' | '+' | '-' | '@'; text: string; old: number; new: number }
2export type DiffSegment = { title: string; source: string }
3
4export function parseDiff(patch: string): DiffLine[] {
5 const out: DiffLine[] = []
6 let old = 0
7 let cur = 0
8 let inHunk = false
9 for (const line of patch.replace(/\r\n?/g, '\n').split('\n')) {
10 const head = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
11 if (head) {
12 old = Number(head[1])
13 cur = Number(head[2])
14 inHunk = true
15 continue
16 }
17 if (!inHunk || line.startsWith('\\')) continue
18 const kind = line[0]
19 if (kind === '+') out.push({ kind, text: line.slice(1), old, new: cur++ })
20 else if (kind === '-') out.push({ kind, text: line.slice(1), old: old++, new: cur })
21 else if (kind === ' ') out.push({ kind, text: line.slice(1), old: old++, new: cur++ })
22 else inHunk = false
23 }
24 return out
25}
26
27export function splitPatch(patch: string): { path: string; lines: DiffLine[] }[] {
28 return patch
29 .replace(/\r\n?/g, '\n')
30 .split(/^(?=diff --git )/m)
31 .map(part => {
32 const plus = /^\+\+\+ (?:b\/)?(.+)$/m.exec(part)?.[1]
33 const head = /^diff --git a\/.+ b\/(.+)$/m.exec(part)?.[1]
34 const lines = parseDiff(part)
35 const note = /^Binary files /m.test(part) ? 'binary file changed' : /^rename from /m.test(part) ? 'renamed, no text change' : /^copy from /m.test(part) ? 'copied, no text change' : /^(old|new) mode /m.test(part) ? 'file mode changed' : 'changed, no text diff'
36 return { path: (plus && plus !== '/dev/null' ? plus : head) ?? '', lines: lines.length || !note ? lines : [{ kind: ' ' as const, text: `(${note})`, old: 1, new: 1 }] }
37 })
38 .filter(f => f.path && f.lines.length)
39}
40
41export function diffWindow(lines: DiffLine[], top: number, rows: number): { segments: DiffSegment[]; top: number } {
42 const from = Math.max(0, Math.min(top, lines.length - 1))
43 const shown = lines.slice(from, from + rows)
44 const segments: DiffSegment[] = []
45 let title = ''
46 for (let i = from; i >= 0; i--) {
47 const l = lines[i]
48 if (l?.kind === '@') {
49 title = i === from ? '' : l.text
50 break
51 }
52 }
53 let out: string[] = []
54 let run: DiffLine[] = []
55 const flush = () => {
56 const first = run[0]
57 if (!first) return
58 const olds = run.filter(l => l.kind !== '+').length
59 const news = run.filter(l => l.kind !== '-').length
60 out.push(`@@ -${first.old},${olds} +${first.new},${news} @@`, ...run.map(l => `${l.kind}${l.text}`))
61 run = []
62 }
63 const close = () => {
64 flush()
65 if (out.length || title) segments.push({ title, source: out.join('\n') })
66 out = []
67 }
68 for (const line of shown) {
69 if (line.kind === '@') {
70 close()
71 title = line.text
72 continue
73 }
74 const prev = run[run.length - 1]
75 const nextOld = prev ? prev.old + (prev.kind === '+' ? 0 : 1) : 0
76 const nextNew = prev ? prev.new + (prev.kind === '-' ? 0 : 1) : 0
77 if (prev && (line.old !== nextOld || line.new !== nextNew)) flush()
78 run.push(line)
79 }
80 close()
81 return { segments, top: from }
82}
83hooks/palette.ts 40 lines1import type { Theme } from '../types'
2
3export type Palette = {
4 theme: Partial<Theme>
5 git: Record<string, string>
6 add?: string
7 del?: string
8 hover?: string
9 unknown: string[]
10}
11
12export const ADD_COLOR = '#98c379'
13export const DEL_COLOR = '#e06c75'
14export const LIGHT_ADD_COLOR = '#14532d'
15export const LIGHT_DEL_COLOR = '#7f1d1d'
16export const LIGHT_HOVER = '#f0f2f5'
17
18const THEME_KEYS = ['fg', 'bg', 'accent', 'muted', 'urgent', 'selection'] as const
19const GIT_KEYS: Record<string, string[]> = { added: ['A', '?'], modified: ['M', 'T'], renamed: ['R', 'C'] }
20const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
21
22export function parsePalette(text: unknown): Palette {
23 const out: Palette = { theme: {}, git: {}, unknown: [] }
24 if (typeof text !== 'string') return out
25 for (const pair of text.split(/[\s,;]+/).filter(Boolean)) {
26 const [rawKey, value = ''] = pair.split('=')
27 const key = (rawKey ?? '').trim().toLowerCase()
28 if (!HEX.test(value.trim())) {
29 out.unknown.push(pair)
30 continue
31 }
32 const color = value.trim().toLowerCase().replace(/^#(.)(.)(.)$/, '#$1$1$2$2$3$3')
33 if ((THEME_KEYS as readonly string[]).includes(key)) out.theme[key as (typeof THEME_KEYS)[number]] = color
34 else if (key in GIT_KEYS) for (const status of GIT_KEYS[key] ?? []) out.git[status] = color
35 else if (key === 'add' || key === 'del' || key === 'hover') out[key] = color
36 else out.unknown.push(pair)
37 }
38 return out
39}
40hooks/icons.ts 141 lines1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9 'agents.md': 0xf0354,
10 'changelog.md': 0xf0354,
11 'contributing.md': 0xf0354,
12 'todo.md': 0xf0354,
13 'readme.md': 0xf0354,
14 license: 0xe60a,
15 dockerfile: 0xf0868,
16 makefile: 0xf1064,
17 'cmakelists.txt': 0xf1064,
18 justfile: 0xf05b7,
19 gemfile: 0xf0d2d,
20 rakefile: 0xf0d2d,
21 'cargo.toml': 0xe6b2,
22 'cargo.lock': 0xe6b2,
23 'pyproject.toml': 0xe6b2,
24 'requirements.txt': 0xf160e,
25 'go.mod': 0xf0afa,
26 'go.sum': 0xf07d3,
27 'package.json': 0xf0626,
28 'package-lock.json': 0xf0626,
29 'tsconfig.json': 0xf0626,
30 '.prettierrc': 0xf0626,
31 '.eslintrc': 0xf0626,
32 'pnpm-lock.yaml': 0xe6a8,
33 'yarn.lock': 0xe6a8,
34 '.gitignore': 0xf02a2,
35 '.gitmodules': 0xf0493,
36 '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40 lua: 0xe620,
41 py: 0xe606,
42 pyi: 0xe606,
43 js: 0xe60c,
44 mjs: 0xe60c,
45 cjs: 0xe60c,
46 ts: 0xe628,
47 jsx: 0xe625,
48 tsx: 0xe7ba,
49 json: 0xe60b,
50 jsonc: 0xe60b,
51 yaml: 0xe6a8,
52 yml: 0xe6a8,
53 sh: 0xe795,
54 bash: 0xe795,
55 zsh: 0xe795,
56 fish: 0xe795,
57 md: 0xf0354,
58 markdown: 0xf0354,
59 css: 0xe6b8,
60 scss: 0xf031c,
61 html: 0xe736,
62 htm: 0xe736,
63 go: 0xe627,
64 rs: 0xe68b,
65 rb: 0xe791,
66 php: 0xe608,
67 java: 0xe738,
68 cs: 0xf031b,
69 sql: 0xe706,
70 graphql: 0xf20e,
71 gql: 0xf20e,
72 xml: 0xf05c0,
73 toml: 0xe6b2,
74 ini: 0xf0bc2,
75 conf: 0xf0493,
76 pdf: 0xeaeb,
77 svg: 0xf0721,
78 jpg: 0xf0225,
79 jpeg: 0xf0225,
80 png: 0xe60d,
81 gif: 0xf0d78,
82 webp: 0xf021f,
83 mp4: 0xf022b,
84 mkv: 0xf022b,
85 mov: 0xf022b,
86 mp3: 0xf0223,
87 wav: 0xf0223,
88 flac: 0xf0223,
89 zip: 0xf05c4,
90 gz: 0xf05c4,
91 xz: 0xf05c4,
92 tar: 0xf05c4,
93 qml: 0xf375,
94 cpp: 0xe61d,
95 cc: 0xe61d,
96 cxx: 0xe61d,
97 c: 0xe61e,
98 h: 0xf0af5,
99 hpp: 0xf0af5,
100 vue: 0xe6a0,
101 svelte: 0xe697,
102 csv: 0xe64a,
103 txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107 if (n.kind === 'link') return cp(0xf481)
108 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109 const lower = n.name.toLowerCase()
110 const named = NAMES[lower]
111 if (named) return cp(named)
112 const dot = lower.lastIndexOf('.')
113 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114 return cp(EXTS[ext] ?? 0xf0214)
115}
116
117export const GIT_COLOR: Record<string, string> = {
118 A: '#98c379',
119 '?': '#98c379',
120 R: '#61afef',
121 C: '#61afef',
122 M: '#e5c07b',
123 T: '#e5c07b',
124}
125
126export const LIGHT_GIT_COLOR: Record<string, string> = {
127 A: '#14532d',
128 '?': '#14532d',
129 R: '#1e3a8a',
130 C: '#1e3a8a',
131 M: '#78350f',
132 T: '#78350f',
133}
134
135const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
136
137export function stronger(a: string | undefined, b: string): string {
138 if (!a) return b
139 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
140}
141hooks/tree.ts 355 lines1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10 fg: '',
11 accent: '#5b9bd5',
12 muted: '#808a96',
13 urgent: '#d0605e',
14 selection: '#6b7280',
15 bg: '',
16}
17
18export const LIGHT_THEME: Theme = {
19 fg: '',
20 accent: '#1e3a8a',
21 muted: '#374151',
22 urgent: '#7f1d1d',
23 selection: '#e2e6ec',
24 bg: '',
25}
26
27export function emptyTree(root: string): FileTree {
28 return {
29 root,
30 nodes: [],
31 expanded: [],
32 cursor: '',
33 selected: '',
34 query: '',
35 showHidden: true,
36 showSize: false,
37 dirSizes: {},
38 git: {},
39 diff: {},
40 ignored: [],
41 untrackedDirs: [],
42 top: '',
43 prefix: '',
44 branch: null,
45 counts: {},
46 flash: [],
47 flashDim: [],
48 flashOn: false,
49 flashTones: {},
50 scroll: null,
51 preview: '',
52 previewTop: 0,
53 }
54}
55
56export function parseTheme(toml: string): Theme {
57 const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
58 return {
59 fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
60 accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
61 muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
62 urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
63 selection: get('selection') ?? DEFAULT_THEME.selection,
64 bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
65 }
66}
67
68export function join(dir: string, name: string): string {
69 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
70}
71
72let drives = false
73
74export function dirname(path: string): string {
75 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
76 const i = path.lastIndexOf('/')
77 return i <= 0 ? '/' : path.slice(0, i)
78}
79
80export function useDrives(on: boolean): void {
81 drives = on
82}
83
84export function drivesOn(): boolean {
85 return drives
86}
87
88export function posix(path: string): string {
89 if (!drives) return path
90 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
91}
92
93export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
94 if (dirs.length === 0) return nodes
95 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
96 const gone = new Set(dirs)
97 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
98}
99
100export function isAbsolute(path: string): boolean {
101 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
102}
103
104export function inside(root: string, path: string): boolean {
105 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
106}
107
108export function toNodes(dir: string, list: Entry[]): FileNode[] {
109 return list
110 .map(e => ({
111 id: join(dir, e.name),
112 parent: dir,
113 name: e.name,
114 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
115 hidden: e.name.startsWith('.'),
116 mtime: e.mtimeMs,
117 size: e.size,
118 loaded: false,
119 }))
120 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
121}
122
123export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
124 if (listed.size === 0) return nodes
125 const keep = new Map<string, FileNode>()
126 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
127 const freshIds = new Set<string>()
128 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
129 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
130 const under = (id: string) => {
131 if (gone.size === 0) return false
132 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
133 return false
134 }
135 const fresh: FileNode[] = []
136 for (const kids of listed.values()) {
137 for (const k of kids) {
138 if (under(k.id)) continue
139 const old = keep.get(k.id)
140 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
141 }
142 }
143 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
144 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
145}
146
147export type GitStatus = {
148 git: Record<string, string>
149 ignored: string[]
150 untrackedDirs: string[]
151 branch: Branch | null
152 files: Record<string, Change>
153}
154
155export type Change = 'new' | 'mod' | 'del'
156
157export function parseBranch(record: string): Branch {
158 const body = record.replace(/^## /, '')
159 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
160 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
161 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
162 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
163 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
164 return { head, upstream, ahead, behind }
165}
166
167export function mapper(root: string, prefix: string): (rel: string) => string | null {
168 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
169}
170
171export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
172 const map = mapper(root, prefix)
173 const git: Record<string, string> = {}
174 const ignored: string[] = []
175 const untrackedDirs: string[] = []
176 const files: Record<string, Change> = {}
177 let branch: Branch | null = null
178 const parts = stdout.split('\0')
179 for (let i = 0; i < parts.length; i++) {
180 const rec = parts[i] ?? ''
181 if (rec.startsWith('## ')) {
182 branch = parseBranch(rec)
183 continue
184 }
185 if (rec.length < 4) continue
186 const xy = rec.slice(0, 2)
187 const raw = rec.slice(3)
188 if (xy[0] === 'R' || xy[0] === 'C') i++
189 const path = map(raw)
190 if (!path) continue
191 if (xy === '!!') {
192 ignored.push(path)
193 continue
194 }
195 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
196 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
197 const letter =
198 xy === '??'
199 ? '?'
200 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
201 ? 'U'
202 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
203 if (!letter) continue
204 git[path] = stronger(git[path], letter)
205 let dir = dirname(path)
206 while (dir.length >= root.length && dir !== '/') {
207 git[dir] = stronger(git[dir], letter)
208 if (dir === root) break
209 dir = dirname(dir)
210 }
211 }
212 return { git, ignored, untrackedDirs, branch, files }
213}
214
215export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
216 const map = mapper(root, prefix)
217 const parts = stdout.split('\0')
218 for (let i = 0; i < parts.length; i++) {
219 const rec = parts[i] ?? ''
220 if (!rec) continue
221 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
222 if (!m) continue
223 let path = m[3] ?? ''
224 if (path === '') {
225 i += 2
226 path = parts[i] ?? ''
227 }
228 if (!path) continue
229 const add = m[1] === '-' ? 0 : Number(m[1])
230 const del = m[2] === '-' ? 0 : Number(m[2])
231 const abs = map(path)
232 if (!abs) continue
233 const prev = into[abs] ?? [0, 0]
234 into[abs] = [prev[0] + add, prev[1] + del]
235 }
236}
237
238export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
239 const out: Record<string, [number, number]> = { ...diff }
240 for (const [path, [add, del]] of Object.entries(diff)) {
241 let dir = dirname(path)
242 while (dir.length >= root.length && dir !== '/') {
243 const prev = out[dir] ?? [0, 0]
244 out[dir] = [prev[0] + add, prev[1] + del]
245 if (dir === root) break
246 dir = dirname(dir)
247 }
248 }
249 return out
250}
251
252export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
253 const out: Record<string, [number, number, number]> = {}
254 const slot = { new: 0, mod: 1, del: 2 } as const
255 for (const [path, change] of Object.entries(files)) {
256 let dir = dirname(path)
257 while (dir.length >= root.length && dir !== '/') {
258 const cur = out[dir] ?? [0, 0, 0]
259 cur[slot[change]] += 1
260 out[dir] = cur
261 if (dir === root) break
262 dir = dirname(dir)
263 }
264 }
265 return out
266}
267
268export function underAny(id: string, set: Set<string>, root: string): boolean {
269 if (set.size === 0) return false
270 let cur = id
271 while (cur.length >= root.length) {
272 if (set.has(cur)) return true
273 const up = dirname(cur)
274 if (up === cur) break
275 cur = up
276 }
277 return false
278}
279
280export function ancestorsOf(id: string, root: string): string[] {
281 const out: string[] = []
282 if (!root) return out
283 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
284 return out
285}
286
287export function relative(root: string, path: string): string {
288 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
289}
290
291export function visibleRows(t: FileTree): Row[] {
292 const kids = new Map<string, FileNode[]>()
293 for (const n of t.nodes) {
294 if (!t.showHidden && n.hidden) continue
295 const list = kids.get(n.parent)
296 if (list) list.push(n)
297 else kids.set(n.parent, [n])
298 }
299 const q = t.query.trim().toLowerCase()
300 let keep: Set<string> | null = null
301 if (q) {
302 keep = new Set()
303 for (const n of t.nodes) {
304 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
305 keep.add(n.id)
306 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
307 }
308 }
309 const open = new Set(t.expanded)
310 const rows: Row[] = []
311 const walk = (dir: string, depth: number) => {
312 for (const n of kids.get(dir) ?? []) {
313 if (keep && !keep.has(n.id)) continue
314 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
315 rows.push({ node: n, depth, open: isOpen })
316 if (isOpen) walk(n.id, depth + 1)
317 }
318 }
319 walk(t.root, 0)
320 return rows
321}
322
323export function stamp(ms: number): string {
324 if (!ms) return ''
325 const d = new Date(ms)
326 const p = (v: number) => String(v).padStart(2, '0')
327 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
328}
329
330const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
331
332export function age(ms: number, now: number): string {
333 if (!ms) return ''
334 const s = (now - ms) / 1000
335 if (s < 60 && s > -60) return 'now'
336 if (s > 0 && s < 3600) return `${Math.floor(s / 60)}m`
337 if (s > 0 && s < 86_400) return `${Math.floor(s / 3600)}h`
338 if (s > 0 && s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`
339 const d = new Date(ms)
340 return Math.abs(s) < 365 * 86_400 ? `${MONTHS[d.getMonth()]} ${d.getDate()}` : String(d.getFullYear())
341}
342
343export function formatSize(bytes: number): string {
344 if (bytes < 0) return '?'
345 if (bytes < 1024) return `${bytes} B`
346 const units = ['K', 'M', 'G', 'T']
347 let v = bytes / 1024
348 let i = 0
349 while (v >= 1024 && i < units.length - 1) {
350 v /= 1024
351 i++
352 }
353 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
354}
355types/index.d.ts 76 lines1export type FileNode = {
2 id: string
3 parent: string
4 name: string
5 kind: 'dir' | 'file' | 'link'
6 hidden: boolean
7 mtime: number
8 size: number
9 loaded: boolean
10}
11
12export type Branch = {
13 head: string
14 upstream: string
15 ahead: number
16 behind: number
17}
18
19export type Activity = {
20 id: number
21 kind: string
22 label: string
23 state: 'running' | 'done' | 'failed'
24 detail: string
25 at: number
26 tone: string
27 nerd: string
28 plain: string
29}
30
31export type FileTree = {
32 root: string
33 nodes: FileNode[]
34 expanded: string[]
35 cursor: string
36 selected: string
37 query: string
38 showHidden: boolean
39 showSize: boolean
40 dirSizes: Record<string, number>
41 git: Record<string, string>
42 diff: Record<string, [number, number]>
43 ignored: string[]
44 untrackedDirs: string[]
45 top: string
46 prefix: string
47 branch: Branch | null
48 counts: Record<string, [number, number, number]>
49 flash: string[]
50 flashDim: string[]
51 flashOn: boolean
52 flashTones: Record<string, string>
53 scroll: number | null
54 preview: string
55 previewTop: number
56}
57
58export type Theme = {
59 fg: string
60 accent: string
61 muted: string
62 urgent: string
63 selection: string
64 bg: string
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 filetree: {
70 tree: FileTree
71 theme: Theme
72 activity: Activity[]
73 }
74 }
75}
76