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

[!IMPORTANT] This is a fork. filetree is by Kurt Buhler (@data-goblin): the original is data-goblin/claude-code-filetree, MIT licensed (LICENSE, kept as it came). This copy was taken at upstream
bf8de32(0.2.24) and is changed here to fit the other mods in awesome-claude. Changes since then are listed under "Changes in this fork" at the end. For the original, report issues and send PRs upstream.
<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.0-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="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/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.
This fork installs from the awesome-claude marketplace. Run this in the terminal:
claude plugin marketplace add satoramoto/awesome-claude
claude plugin install filetree@awesome-claude
Or inside a Claude Code session:
/plugin marketplace add satoramoto/awesome-claude
/plugin install filetree@awesome-claude
Don't install it beside the original (filetree@claude-code-filetree), or both panes load. For the original, follow its README.
/filetree <path> pins another folder+N -N) on modified files and ?:N M:N D:N file counts on folders<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/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="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/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="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-ask.gif" alt="Selecting config.yaml in the tree and asking Claude what it changed there" width="800">
Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writes▌ in the gutter, bold while the pane has the keyboard; key hints show at the foot thenYou can resize the pane with the mouse, or by setting custom pane:grow or pane:shrink keybindings in keybindings.json
<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/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.date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.preview shows it in the pane instead: Markdown rendered, code highlighted, arrow keys, Page Up/Down and the wheel to scroll, ←/h or ← files back to the tree, ↗ the default app. Otherwise a command that double-click and Enter run instead: {path} is the file and {dir} its folder, and without {path} the path goes last. It runs without a shell, so quote a word that holds spaces, and it should start the viewer and return rather than wait for it. Examples: zed {path}, code -g {path}, or in herdr a tab running herdr-file-viewer on the file: herdr plugin pane open --plugin herdr-file-viewer --entrypoint file-viewer --placement tab --focus --env HERDR_FILE_VIEWER_OPEN={path}.auto (default) uses Nerd Font icons when a Nerd Font is installed and your terminal started after it was installed, plain Unicode in the desktop app, and Nerd Font over SSH. nerd or plain forces one.Clicking rows needs herdr 0.9.1 or later. herdr 0.9.0 and older accept pixel mouse reporting but still send cell positions, which would put every click in the session in the wrong place, so on those versions the rows ignore the mouse and the rest of the pane and session keep working. Run herdr update to get row clicks.
This fork is built and checked like the other mods in awesome-claude: see its AGENTS.md. Upstream's pre-commit hook and its macOS, Windows and Linux CI weren't brought over; this repository's CI runs on Linux only.
MIT, copyright Kurt Buhler. The license is kept unchanged with the code.
This project is inspired by my Omarchy app FileBlade
From upstream bf8de32:
mods/filetree/ in awesome-claude.hooks/desktop.tsx). A header with the folder and its actions as icons, the branch line with ahead/behind and lines changed, the search field, then rows with GitHub's file and folder icons, git status colours, lines changed and dates or sizes in greys, the picked row highlighted, and a footer with the selection (and an open button) and git's latest step. The rows are drawn by the render hook rather than the terminal's Client, which can't draw icons; Claude's reads and writes light their rows (blue for reads, amber for writes, green for commits, with a rail beside them) for as long as the terminal's shimmer runs, and the pane scrolls to them. A press on a row selects it and opens a folder, a second press opens a file; arrow keys stay the terminal's. The terminal draws exactly as before. Changed files say so in their first line.attach(on, options, pane) in hooks/register.tsx registers the tree's hooks for that pane, the tree reading nothing until it first draws there. syns' dashboard copies hooks/ for its Files tab.media/ (this README links upstream's), the repository's own marketplace, its pre-commit hook and its platforms CI.hooks/register.tsx 1848 lines1// Changed in this fork (awesome-claude) for the desktop version: the desktop surface draws hooks/desktop.tsx.
2// The terminal draws in the kit's terminal design system: a section header, TREE
3// connectors, the kit's status colours as theme keys, and its note lines.
4//
5// Another mod can draw the tree in a tab of its own pane: it calls `attach` (see
6// there). syns' dashboard does, copying this folder. Every function that takes `$`
7// stays in this file: the engine follows `$` within one file only.
8import { type BuiltinToolResults, type Elements, type EngineInterface, type Hook, type Register, type RenderElement, type RenderInput, type Timer, update } from 'claude-code'
9import { LINUX_LAUNCH, openCommand, openWithCommand } from './open'
10
11import { ago, middle } from '@kit/format'
12import { C, MUTED, termInk, treePrefix } from '@kit/tokens'
13import { type Hint, holdButtons, kit, MARK } from '@kit/ui'
14
15import type { Activity, FileNode, FileTree } from '../types'
16import { renderDesktop, statusColor, toneColor } from './desktop'
17import type { DesktopElements } from './desktop'
18import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
19import type { RowSpec, RowsProps, Seg } from './rows'
20import { fileIcon } from './icons'
21import {
22 ancestorsOf,
23 type Change,
24 dirname,
25 emptyTree,
26 formatSize,
27 inside,
28 isAbsolute,
29 join,
30 mapper,
31 parseGit,
32 parseNumstat,
33 posix,
34 relative,
35 remoteHost,
36 remoteName,
37 HOST_MARK,
38 hostKind,
39 dropBelow,
40 useDrives,
41 rollCounts,
42 replaceChildren,
43 rollUp,
44 toNodes,
45 underAny,
46 visibleRows,
47} from './tree'
48
49const TREE = { plugin: 'filetree', key: 'tree' } as const
50const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
51/** The pane the tree draws in: its own, or a host's (see `attach`). */
52let PANE = 'filetree'
53/** The tab's name. The folder is the section header's title, so the tab doesn't repeat it. */
54const TAB = 'Files'
55/** Each activity tone's terminal colour: the kit's meaning for it (desktop's toneColor), as a theme key. */
56const SHIMMER: Record<string, string> = Object.fromEntries(TONES.map(k => [k, termInk(toneColor(k)).color ?? '']))
57/** The terminal's key hints while the pane has the keyboard, the most used first. */
58const TREE_HINTS: Hint[] = [
59 { key: '↑↓', label: 'move' },
60 { key: '⏎', label: 'open' },
61 { key: '←→', label: 'fold' },
62 { key: 'y', label: 'copy path' },
63]
64const PREVIEW_HINTS: Hint[] = [
65 { key: '↑↓', label: 'scroll' },
66 { key: '←', label: 'back' },
67]
68const BRANCH_ROW = '#branch'
69const FLASH_MS = 2700
70const RUNNING_MAX_MS = 600_000
71const NO_REPO_DEPTH = 4
72const DOUBLE_MS = 450
73const FIND_LIMIT = 200
74const READ_REVEAL_LIMIT = 12
75const SEARCH_REVEAL_LIMIT = 60
76const ACTIVITY_TTL_MS = 45_000
77const FONT_SCRIPT =
78 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
79 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
80 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
81 '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 ' +
82 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
83 '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; ' +
84 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
85const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
86const HOME_PRUNE = ['Library', 'AppData', '.Trash']
87const SIZE_TIMEOUT_MS = 15_000
88const SIZE_JOBS = 2
89const SIZE_WALK_LIMIT = 50_000
90const ROW_SCROLL_MS = 100
91
92let blink: Timer | null = null
93let generation = 0
94let lastPress = { key: '', at: 0 }
95let noNerd = false
96let glyphSetting = 'auto'
97let openWithSetting = ''
98let previewed: { path: string; text: string; note: string } | null = null
99let previewRows = 20
100let follow = true
101let followClaude = true
102let scanning: Promise<void> | null = null
103let scanJobs: Job[] = []
104let gitRun: Promise<void> | null = null
105let gitNext: Promise<void> | null = null
106let queuedReads: string[] = []
107let showReads = true
108let showWrites = true
109let searchIndex: { root: string; paths: Promise<string[]> } | null = null
110let activityId = 0
111let pointer = true
112let view = { from: 0, max: 0 }
113let nativeScroll = false
114let lastSync = 0
115// The pane opens only when asked for (/filetree), never by itself at session start.
116let noDock = true
117let home = ''
118let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
119let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
120let markId = 0
121let listSeq = 0
122let lastRoot = ''
123let sizeDefault = false
124let sizeEpoch = 0
125let sizeActive = 0
126let sizeQueue: string[] = []
127const sizing = new Set<string>()
128const listLatest = new Map<string, number>()
129const unreadable = new Set<string>()
130const background = new Map<string, Background>()
131
132function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
133 platform ??= (async () => {
134 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
135 try {
136 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
137 } catch {
138 return 'linux'
139 }
140 })()
141 return platform
142}
143
144async function cwdOf($: EngineInterface): Promise<string> {
145 return posix(await $.session.cwd())
146}
147
148function clean(segs: Seg[]): Seg[] {
149 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
150 return segs
151}
152
153async function get($: EngineInterface): Promise<FileTree> {
154 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
155}
156
157async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
158 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
159}
160
161function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
162 return put($, t => ({ ...t, ...fn(t) }))
163}
164
165async function activities($: EngineInterface): Promise<Activity[]> {
166 return (await $.state.get(ACTIVITY)).value ?? []
167}
168
169async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
170 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
171}
172
173async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
174 try {
175 const entries = await $.fs.list(dir)
176 const resolved = await Promise.all(
177 entries.map(async e => {
178 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
179 try {
180 const target = await $.fs.stat(join(dir, e.name))
181 return target.kind === 'dir'
182 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
183 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
184 } catch {
185 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
186 }
187 }),
188 )
189 unreadable.delete(dir)
190 return toNodes(dir, resolved)
191 } catch (err) {
192 if (!(await exists($, dir))) return []
193 if (!unreadable.has(dir)) {
194 unreadable.add(dir)
195 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
196 }
197 return null
198 }
199}
200
201async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
202 const root = (await get($)).root
203 const seq = ++listSeq
204 for (const dir of dirs) listLatest.set(dir, seq)
205 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
206 const listed = new Map<string, FileNode[]>()
207 for (const [dir, kids] of results) {
208 if (listLatest.get(dir) !== seq) continue
209 listLatest.delete(dir)
210 if (kids) listed.set(dir, kids)
211 }
212 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
213 return listed
214}
215
216async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
217 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
218}
219
220async function detectRepo($: EngineInterface): Promise<void> {
221 const t = await get($)
222 if (!t.root) return
223 let top = ''
224 let prefix = ''
225 try {
226 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
227 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
228 prefix = p.trim()
229 top = tl.trim()
230 } catch {
231 top = ''
232 }
233 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
234}
235
236async function readGit($: EngineInterface): Promise<void> {
237 const t = await get($)
238 if (!t.root || !t.top) return
239 const root = t.root
240 try {
241 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
242 if (status.exitCode !== 0) return
243 const parsed = parseGit(status.stdout, root, t.prefix)
244 const diff: Record<string, [number, number]> = {}
245 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
246 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
247 const files = { ...parsed.files }
248 if (parsed.untrackedDirs.length) {
249 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
250 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
251 }
252 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
253 const branch = parsed.branch ? { ...parsed.branch, host: await hostOf($, t.top, remoteName(parsed.branch.upstream)) } : null
254 dirty = { root, files }
255 await patch($, cur =>
256 cur.root === root
257 ? {
258 git: parsed.git,
259 ignored: parsed.ignored,
260 untrackedDirs: parsed.untrackedDirs,
261 branch,
262 counts: rollCounts(files, root),
263 diff: rollUp(diff, root),
264 }
265 : {},
266 )
267 } catch {
268 return
269 }
270}
271
272/** Each repo's remotes' hosts, by `top` and remote name: a remote's URL rarely changes, so it's read once a load. */
273const hosts = new Map<string, string>()
274
275/** The host of `remote`'s URL in the repo at `top` (`github.com`), or '' for none. */
276async function hostOf($: EngineInterface, top: string, remote: string): Promise<string> {
277 if (!remote) return ''
278 const id = `${top}\u0000${remote}`
279 const known = hosts.get(id)
280 if (known !== undefined) return known
281 try {
282 const run = await git($, top, ['remote', 'get-url', remote], 5_000)
283 if (run.exitCode !== 0) return ''
284 // Only a successful lookup is cached: a timeout or failure is retried on a later refresh.
285 const host = remoteHost(run.stdout)
286 hosts.set(id, host)
287 return host
288 } catch {
289 return ''
290 }
291}
292
293function refreshGit($: EngineInterface): Promise<void> {
294 if (!gitRun) {
295 gitRun = readGit($).finally(() => {
296 gitRun = null
297 })
298 return gitRun
299 }
300 const rerun = () => {
301 gitNext = null
302 return refreshGit($)
303 }
304 gitNext ??= gitRun.then(rerun, rerun)
305 return gitNext
306}
307
308async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
309 const prev = await get($)
310 generation += 1
311 blink?.cancel()
312 blink = null
313 lastRoot = root
314 searchIndex = null
315 sizeEpoch += 1
316 sizeQueue = []
317 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
318 if (focus) await $.ui.open({ id: PANE, title: TAB, focus: true })
319 else if (!noDock) await $.ui.open({ id: PANE, title: TAB })
320 await loadDirs($, [root])
321 await detectRepo($)
322 await refreshGit($)
323}
324
325async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
326 const tried = new Set<string>()
327 for (let pass = 0; pass < 32; pass++) {
328 const t = await get($)
329 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
330 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
331 const need = new Set<string>()
332 for (const p of paths) {
333 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
334 if (!loaded.has(dir)) {
335 if (!tried.has(dir)) need.add(dir)
336 break
337 }
338 }
339 }
340 if (need.size === 0) return
341 for (const dir of need) tried.add(dir)
342 await loadDirs($, [...need])
343 }
344}
345
346function pruneArgs(root: string, ignored: string[] = []): string[] {
347 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
348 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
349}
350
351async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
352 const stats = await Promise.all(
353 paths.map(async p => {
354 try {
355 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
356 } catch {
357 return ''
358 }
359 }),
360 )
361 return stats.filter(Boolean)
362}
363
364function broad(root: string): boolean {
365 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
366}
367
368function rootOfDisk(root: string): boolean {
369 return root === '/' || /^[A-Za-z]:\/$/.test(root)
370}
371
372async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
373 if (rootOfDisk(root)) return []
374 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
375 try {
376 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 })
377 return run.stdout.split('\0').filter(p => p && p !== root)
378 } catch {
379 return []
380 }
381}
382
383async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
384 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
385 if (dirty.root !== root) return { hits: [], gone: [] }
386 const entries = Object.entries(dirty.files)
387 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
388 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
389 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
390}
391
392type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
393
394async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
395 const ms = await $.clock.now()
396 const os = await osName($)
397 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
398 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
399 try {
400 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
401 return { ms, mark, os }
402 } catch {
403 return { ms, mark: '', os }
404 }
405}
406
407async function walkSize($: EngineInterface, dir: string): Promise<number> {
408 let total = 0
409 let seen = 0
410 let level = [dir]
411 while (level.length) {
412 const next: string[] = []
413 for (const d of level) {
414 let entries
415 try {
416 entries = await $.fs.list(d)
417 } catch {
418 continue
419 }
420 for (const e of entries) {
421 if (++seen > SIZE_WALK_LIMIT) return -1
422 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
423 else if (e.kind === 'file') total += e.size
424 }
425 }
426 level = next
427 }
428 return total
429}
430
431async function dirSize($: EngineInterface, dir: string): Promise<number> {
432 if (rootOfDisk(dir)) return -1
433 if ((await osName($)) === 'win32') return walkSize($, dir)
434 try {
435 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
436 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
437 return Number.isFinite(kb) ? kb * 1024 : -1
438 } catch {
439 return -1
440 }
441}
442
443function pumpSizes($: EngineInterface): void {
444 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
445 const dir = sizeQueue.shift() ?? ''
446 const epoch = sizeEpoch
447 sizing.add(dir)
448 sizeActive += 1
449 void dirSize($, dir)
450 .then(async bytes => {
451 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
452 const cur = await get($)
453 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
454 })
455 .catch(() => undefined)
456 .finally(() => {
457 sizing.delete(dir)
458 sizeActive -= 1
459 pumpSizes($)
460 })
461 }
462}
463
464function wantSizes($: EngineInterface, dirs: string[]): void {
465 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
466 pumpSizes($)
467}
468
469async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
470 sizeEpoch += 1
471 sizeQueue = []
472 await patch($, cur => {
473 if (!paths) return { dirSizes: {} }
474 const keep: Record<string, number> = {}
475 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
476 return { dirSizes: keep }
477 })
478}
479
480function openDirs(t: FileTree): string[] {
481 const open = new Set(t.expanded)
482 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
483}
484
485function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
486 if (followClaude) return { scroll: null }
487 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
488 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
489 return at < 0 ? {} : { scroll: at }
490}
491
492async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
493 const unique = Object.keys(tones)
494 if (unique.length === 0) return
495 const mine = ++generation
496 const root = (await get($)).root
497 blink?.cancel()
498 blink = null
499 await patch($, cur => {
500 if (cur.root !== root) return {}
501 const open = new Set(cur.expanded)
502 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
503 const dim = new Set<string>()
504 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
505 if (cur.flashOn) {
506 for (const id of cur.flashDim) dim.add(id)
507 }
508 for (const id of unique) {
509 const tone = tones[id] ?? 'orange'
510 all[id] = tone
511 if (id === BRANCH_ROW) continue
512 const chain = ancestorsOf(id, cur.root)
513 if (!chain.some(a => !open.has(a))) continue
514 for (const a of chain) {
515 if (!open.has(a)) {
516 dim.add(a)
517 all[a] = all[a] ?? tone
518 }
519 open.add(a)
520 }
521 }
522 for (const id of bright) dim.delete(id)
523 return {
524 flash: [...bright],
525 flashDim: [...dim],
526 flashOn: true,
527 flashTones: all,
528 expanded: [...open],
529 ...keepTop(cur, [...open]),
530 }
531 })
532 if (generation !== mine) return
533 // The desktop scrolls the pane itself, so following Claude there means asking it to show the row.
534 if (followClaude) scrollToRow($, [...unique].reverse().find(id => id !== BRANCH_ROW) ?? '')
535 blink = $.clock.after(FLASH_MS, () => {
536 if (generation !== mine) return
537 blink = null
538 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
539 })
540}
541
542/** On the desktop, brings a row into the pane's view once it's drawn; the terminal scrolls by `scroll`. */
543function scrollToRow($: EngineInterface, id: string): void {
544 if (!nativeScroll || !id) return
545 $.clock.after(ROW_SCROLL_MS, () => void $.ui.scroll({ to: { key: id }, in: PANE }).catch(() => undefined))
546}
547
548async function goUp($: EngineInterface): Promise<void> {
549 follow = false
550 await reset($, dirname((await get($)).root))
551}
552
553async function goHome($: EngineInterface): Promise<void> {
554 follow = true
555 await reset($, await cwdOf($))
556}
557
558async function refreshAll($: EngineInterface): Promise<void> {
559 const cur = await get($)
560 searchIndex = null
561 await staleSizes($)
562 await loadDirs($, [cur.root, ...cur.expanded])
563 await detectRepo($)
564 await refreshGit($)
565 if (cur.query.trim()) await search($, cur.query)
566}
567
568function collapseAll($: EngineInterface): Promise<void> {
569 return patch($, cur => ({ expanded: [], nodes: dropBelow(cur.nodes, cur.nodes.filter(x => x.parent === cur.root && x.kind === 'dir').map(x => x.id)) }))
570}
571
572async function followCwd($: EngineInterface): Promise<boolean> {
573 if (!follow) return false
574 const cwd = await cwdOf($)
575 const t = await get($)
576 if (t.root === cwd) return false
577 await reset($, cwd)
578 return true
579}
580
581type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
582type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
583type Background = { p: Pending | null; job: Job; reads: string[] }
584type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
585type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
586
587const PROOF: Record<string, (op: GitOperation) => boolean> = {
588 push: op => Boolean(op.push),
589 merge: op => op.branch?.action === 'merged',
590 rebase: op => op.branch?.action === 'rebased',
591 'pr create': op => op.pr?.action === 'created',
592 'pr merge': op => op.pr?.action === 'merged',
593 'pr comment': op => op.pr?.action === 'commented',
594}
595
596async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
597 const now = await $.clock.now()
598 const ids = actions.map(() => ++activityId)
599 await setActivities($, cur => [
600 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
601 ...actions.map((a, i) => ({
602 id: ids[i] ?? 0,
603 kind: a.kind,
604 label: a.running,
605 state: 'running' as const,
606 detail: '',
607 at: now,
608 tone: a.tone,
609 nerd: a.icon.nerd,
610 plain: a.icon.plain,
611 })),
612 ])
613 return ids
614}
615
616async function headOf($: EngineInterface, cwd: string): Promise<string> {
617 try {
618 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
619 return run.exitCode === 0 ? run.stdout.trim() : ''
620 } catch {
621 return ''
622 }
623}
624
625async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
626 const chain = chainOf(p.command)
627 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
628 return p.actions.map(a => {
629 if (a.verb === 'commit') {
630 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
631 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
632 }
633 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
634 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
635 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
636 })
637}
638
639async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
640 const now = await $.clock.now()
641 await setActivities($, cur =>
642 cur.map(a => {
643 const i = p.ids.indexOf(a.id)
644 const result = i < 0 ? undefined : results[i]
645 return result ? { ...a, ...result, at: now } : a
646 }),
647 )
648 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
649}
650
651function failAll(p: Pending): Outcome[] {
652 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
653}
654
655function proven(p: Pending | null, results: Outcome[]): GitAction[] {
656 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
657}
658
659function taskEnds(text: string): [string, string][] {
660 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
661 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
662 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
663 return id && status ? [[id, status] as [string, string]] : []
664 })
665}
666
667async function settleBackground($: EngineInterface, text: string): Promise<void> {
668 if (background.size === 0 || !text.includes('<task-notification>')) return
669 for (const [id, status] of taskEnds(text)) {
670 const task = background.get(id)
671 if (!task) continue
672 background.delete(id)
673 const ok = status === 'completed'
674 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
675 if (task.p) await finishGit($, task.p, results)
676 if (showReads) queuedReads.push(...task.reads)
677 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
678 }
679}
680
681async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
682 const map = mapper(root, prefix)
683 try {
684 const run = committed
685 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
686 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
687 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
688 } catch {
689 return []
690 }
691}
692
693async function gitDir($: EngineInterface, cwd: string): Promise<string> {
694 for (let dir = cwd, i = 0; i < 64; i++) {
695 try {
696 const dot = join(dir, '.git')
697 const st = await $.fs.stat(dot)
698 if (st.kind === 'dir') return dot
699 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
700 if (ref) return resolve(dir, ref, home)
701 } catch {
702 const up = dirname(dir)
703 if (up === dir) return ''
704 dir = up
705 continue
706 }
707 return ''
708 }
709 return ''
710}
711
712async function sync($: EngineInterface, force = false): Promise<void> {
713 const now = await $.clock.now()
714 if (!force && now - lastSync < 2_000) return
715 lastSync = now
716 searchIndex = null
717 const t = await get($)
718 if (!t.root) return
719 await loadDirs($, [t.root, ...openDirs(t)])
720 if (t.top) await refreshGit($)
721}
722
723async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
724 const t = await get($)
725 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
726 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
727 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
728}
729
730async function exists($: EngineInterface, path: string): Promise<boolean> {
731 try {
732 await $.fs.stat(path)
733 return true
734 } catch {
735 return false
736 }
737}
738
739async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
740 const reads = queuedReads
741 queuedReads = []
742 if (await followCwd($)) return
743 const t = await get($)
744 if (!t.root) return
745 const actions = jobs.flatMap(j => j.actions)
746 const writers = jobs.filter(j => !j.readOnly)
747 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 })
748 const writes = !jobs.every(j => j.readOnly)
749 const before = dirty.root === t.root ? dirty.files : {}
750 if (jobs.some(j => j.initRepo)) await detectRepo($)
751 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
752 const probed = await get($)
753 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
754 if (writes) {
755 searchIndex = null
756 await staleSizes($)
757 }
758 const fresh = await get($)
759 const ignored = new Set(fresh.ignored)
760 const tones: Record<string, string> = {}
761 if (writes) {
762 const found = fresh.top
763 ? await changedInRepo($, t.root, since, before, fresh.ignored)
764 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
765 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
766 await revealPaths($, hits)
767 const loaded = await get($)
768 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
769 const listed = await loadDirs($, dirs)
770 if (!fresh.top && since.os === 'win32')
771 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
772 await patch($, cur => {
773 const ids = new Set(cur.nodes.map(n => n.id))
774 return { expanded: cur.expanded.filter(id => ids.has(id)) }
775 })
776 const present = new Set((await get($)).nodes.map(n => n.id))
777 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
778 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
779 }
780 if (showReads) {
781 const found: string[] = []
782 for (const r of reads) {
783 if (found.length >= READ_REVEAL_LIMIT) break
784 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
785 }
786 if (found.length) {
787 await revealPaths($, found)
788 const shown = new Set((await get($)).nodes.map(n => n.id))
789 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
790 }
791 }
792 if (showWrites && actions.length) {
793 const final = await get($)
794 const committed = actions.some(a => a.verb === 'commit')
795 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
796 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
797 await revealPaths($, paths)
798 const shown = new Set((await get($)).nodes.map(n => n.id))
799 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
800 }
801 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
802 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
803 else if (committed) tones[BRANCH_ROW] = 'green'
804 }
805 await flash($, tones)
806}
807
808function scheduleScan($: EngineInterface, job: Job): void {
809 scanJobs.push(job)
810 if (scanning) return
811 scanning = (async () => {
812 while (scanJobs.length) {
813 const jobs = scanJobs
814 scanJobs = []
815 try {
816 await afterBash($, jobs)
817 } finally {
818 const marks = jobs.map(j => j.since.mark).filter(Boolean)
819 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
820 }
821 }
822 })().finally(() => {
823 scanning = null
824 })
825}
826
827async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
828 if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
829 if (await followCwd($)) return
830 const t = await get($)
831 if (!t.root) return
832 const within = paths.map(posix).filter(p => inside(t.root, p))
833 if (within.length === 0) return
834 if (tone !== 'purple') {
835 searchIndex = null
836 await staleSizes($, within)
837 await revealPaths($, within.map(dirname))
838 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
839 await refreshGit($)
840 } else await revealPaths($, within)
841 if (!show) return
842 const tones: Record<string, string> = {}
843 for (const p of within) tones[p] = tone
844 await flash($, tones)
845}
846
847async function reveal($: EngineInterface, paths: string[]): Promise<void> {
848 if (await followCwd($)) return
849 await revealPaths($, paths)
850 await patch($, cur => {
851 const open = new Set(cur.expanded)
852 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
853 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
854 })
855 scrollToRow($, paths[paths.length - 1] ?? '')
856}
857
858async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
859 const out: string[] = []
860 let level = [root]
861 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
862 const next: string[] = []
863 for (const dir of level) {
864 for (const n of (await list($, dir)) ?? []) {
865 if (n.kind === 'dir') {
866 if (!PRUNE.includes(n.name)) next.push(n.id)
867 } else out.push(n.id)
868 }
869 if (out.length >= limit) break
870 }
871 level = next
872 }
873 return out
874}
875
876async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
877 try {
878 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
879 const run = t.top
880 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
881 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
882 if (t.top && run.exitCode !== 0) return null
883 const paths = run.stdout.split('\0').filter(Boolean)
884 if (run.isStdoutTruncated) paths.pop()
885 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
886 } catch {
887 return null
888 }
889}
890
891function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
892 if (searchIndex?.root !== t.root) {
893 const index: { root: string; paths: Promise<string[]> } = {
894 root: t.root,
895 paths: listAll($, t).then(paths => {
896 if (paths === null && searchIndex === index) searchIndex = null
897 return paths ?? []
898 }),
899 }
900 searchIndex = index
901 }
902 return searchIndex.paths
903}
904
905async function search($: EngineInterface, query: string): Promise<string[]> {
906 if (!(await get($)).query.trim()) searchIndex = null
907 await patch($, () => ({ query }))
908 const q = query.trim().toLowerCase()
909 if (!q) return []
910 const t = await get($)
911 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
912 if ((await get($)).query !== query) return []
913 await revealPaths($, hits)
914 const shown = await get($)
915 const ids = new Set(shown.nodes.map(n => n.id))
916 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
917 if (stale.length) await loadDirs($, stale)
918 return hits
919}
920
921async function jump($: EngineInterface, query: string): Promise<void> {
922 const hits = (await search($, query)).slice(0, 10)
923 await patch($, cur => {
924 const open = new Set(cur.expanded)
925 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
926 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
927 })
928 scrollToRow($, hits[0] ?? '')
929}
930
931async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
932 try {
933 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
934 return out === 'stale' || out === 'missing' ? out : 'ok'
935 } catch {
936 return 'missing'
937 }
938}
939
940async function finePointerOk($: EngineInterface): Promise<boolean> {
941 if (!(await $.env.get('HERDR_ENV'))) return true
942 try {
943 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
944 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
945 return major * 1e6 + minor * 1e3 + fix >= 9_001
946 } catch {
947 return false
948 }
949}
950
951const PREVIEW_CHARS = 90_000
952const PREVIEW_LINE = 2_000
953
954function fitSerialized(lines: string[], max: number): string[] {
955 let size = 0
956 for (let i = 0; i < lines.length; i++) {
957 size += JSON.stringify(lines[i]).length
958 if (size > max) return lines.slice(0, i)
959 }
960 return lines
961}
962
963async function loadPreview($: EngineInterface, path: string): Promise<void> {
964 let text = ''
965 let note = ''
966 try {
967 const stat = await $.fs.stat(path)
968 if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
969 else {
970 text = await $.fs.read(path)
971 if (text.slice(0, 8000).includes('\0')) {
972 text = ''
973 note = `binary file, ${formatSize(stat.size)}`
974 }
975 }
976 } catch (err) {
977 note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
978 }
979 const total = text.length
980 let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
981 if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
982 lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
983 const fit = fitSerialized(lines, PREVIEW_CHARS)
984 if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
985 previewed = { path, text: fit.join('\n'), note }
986}
987
988async function showPreview($: EngineInterface, path: string): Promise<void> {
989 await loadPreview($, path)
990 await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
991}
992
993async function closePreview($: EngineInterface): Promise<void> {
994 await patch($, () => ({ preview: '', previewTop: 0 }))
995}
996
997async function scrollPreview($: EngineInterface, by: number): Promise<void> {
998 const t = await get($)
999 const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
1000 const top = Math.max(0, Math.min(last, t.previewTop + by))
1001 if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
1002}
1003
1004function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
1005 const lines = text.split('\n')
1006 const from = Math.max(0, Math.min(top, lines.length - 1))
1007 let shown = lines.slice(from, from + rows)
1008 if (markdown) {
1009 // Reopen a code fence the window starts inside, so the rest is not read as prose.
1010 let fence = ''
1011 let opener = ''
1012 for (const line of lines.slice(0, from)) {
1013 const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
1014 if (!m) continue
1015 const mark = m[1] ?? ''
1016 if (!fence) {
1017 fence = mark
1018 opener = line.trimStart()
1019 } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
1020 }
1021 if (fence) shown = [opener, ...shown]
1022 }
1023 return { text: shown.join('\n'), top: from }
1024}
1025
1026async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
1027 if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
1028 const os = await osName($)
1029 const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
1030 const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
1031 const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
1032 try {
1033 const run = await $.process.run(argv, init)
1034 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1035 } catch {
1036 $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
1037 }
1038}
1039
1040async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1041 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1042 await patch($, t => {
1043 const open = new Set(t.expanded)
1044 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1045 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1046 })
1047}
1048
1049async function press($: EngineInterface, n: FileNode): Promise<void> {
1050 const now = await $.clock.now()
1051 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1052 lastPress = { key: isDouble ? '' : n.id, at: now }
1053 if (!isDouble) {
1054 await toggle($, n)
1055 return
1056 }
1057 await openNode($, n)
1058}
1059
1060async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1061 if (n.kind === 'dir') {
1062 follow = false
1063 await reset($, n.id)
1064 } else await openFile($, n.id)
1065}
1066
1067function shortPath(path: string): string {
1068 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1069}
1070
1071/**
1072 * The tree's first look in a session: whether the pointer is fine, whether a Nerd
1073 * Font is there, and what the last session left (a flash, a preview, git steps
1074 * still running) cleared.
1075 */
1076async function firstLook($: EngineInterface): Promise<void> {
1077 pointer = await finePointerOk($)
1078 try {
1079 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1080 noNerd = !remote && ((await $.env.get('OS')) === 'Windows_NT' || (await fontState($, 'f04eb')) !== 'ok')
1081 } catch {
1082 noNerd = true
1083 }
1084 const t = await get($)
1085 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1086 if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1087 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1088}
1089
1090/** The pane of a mod that draws the tree in its own pane (a tab of it) rather than filetree's; null in filetree's own. */
1091let host: string | null = null
1092/** Whether a host's tree is on screen: set as it draws, cleared by `hideTree`. */
1093let onScreen = false
1094/** Whether a host's tree has caught up since it came on screen. */
1095let caughtUp = false
1096/** Whether a host's tree has made the session's reads and its first look. */
1097let looked = false
1098/** A host's pinned folder (`pinTree`); null, the tree follows the cwd. */
1099let pinned: string | null = null
1100
1101/** The reads every session needs before the tree reads anything: the OS, home, the last activity's id. */
1102async function sessionReads($: EngineInterface): Promise<void> {
1103 useDrives((await $.env.get('OS')) === 'Windows_NT')
1104 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1105 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1106}
1107
1108/**
1109 * A host's tree just drew, so it's on screen. The first draw since it came on
1110 * screen catches up, on a timer since a drawing writes nothing: the session's
1111 * reads and the first look the first time, then the folder listed (the cwd, or
1112 * the pinned one) when the tree isn't on it, or what changed while it was off.
1113 */
1114function drawnInHost($: EngineInterface): void {
1115 onScreen = true
1116 if (caughtUp) return
1117 caughtUp = true
1118 $.clock.after(0, () => void catchUp($).catch(() => undefined))
1119}
1120
1121async function catchUp($: EngineInterface): Promise<void> {
1122 if (!looked) {
1123 looked = true
1124 await sessionReads($)
1125 await firstLook($)
1126 }
1127 const cwd = await cwdOf($)
1128 follow = pinned === null
1129 const want = pinned === null ? cwd : resolve(cwd, pinned, home)
1130 const t = await get($)
1131 if (t.root !== want || t.nodes.length === 0) await reset($, want)
1132 else await sync($)
1133}
1134
1135/** For a host: the tree went off screen (another tab, the pane hidden); its hooks pass through until it draws again. */
1136export function hideTree(): void {
1137 onScreen = false
1138 caughtUp = false
1139}
1140
1141/** For a host: the folder the tree shows from its next draw (`root`, pinned), or the cwd, followed (null). */
1142export function pinTree(root: string | null): void {
1143 pinned = root
1144 caughtUp = false
1145}
1146
1147/** In a host's pane while the tree is off screen: every hook then passes straight through. */
1148function hidden(): boolean {
1149 return host !== null && !onScreen
1150}
1151
1152/** The tree's watch on Claude's edits, writes and Bash commands (git steps, files changed, reads). */
1153const watchTool: Hook<'tool.call'> = async ($, e, next) => {
1154 {
1155 if (hidden()) return next(e)
1156 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash') return next(e)
1157 const command = e.tool === 'Bash' ? e.command : ''
1158 const cwd = e.tool === 'Bash' ? await cwdOf($) : ''
1159 const actions = gitActions(command)
1160 const quiet = !actions.length && readOnly(command)
1161 const since = await sinceNow($, e.tool === 'Bash' && !quiet)
1162 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1163 const head = before?.top ? await headOf($, before.root) : ''
1164 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1165 let result: Awaited<ReturnType<typeof next>>
1166 try {
1167 result = await next(e)
1168 } catch (err) {
1169 if (pending) await finishGit($, pending, failAll(pending))
1170 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1171 throw err
1172 }
1173 if (pending && result.deny) {
1174 const ids = pending.ids
1175 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1176 }
1177 if (result.deny || (result.isError && e.tool !== 'Bash')) {
1178 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1179 return result
1180 }
1181 if (e.tool === 'Bash') {
1182 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1183 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1184 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1185 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1186 else void (async () => {
1187 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1188 if (pending) await finishGit($, pending, results)
1189 queuedReads.push(...reads)
1190 scheduleScan($, { ...job, actions: proven(pending, results) })
1191 })()
1192 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1193 } else {
1194 const file =
1195 'file_path' in e && typeof e.file_path === 'string'
1196 ? e.file_path
1197 : 'notebook_path' in e && typeof e.notebook_path === 'string'
1198 ? e.notebook_path
1199 : ''
1200 if (file) void touched($, [file], 'orange', showWrites)hooks/open.ts 68 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
41
42function words(command: string): string[] {
43 const out: string[] = []
44 let word: string | null = null
45 let quote = ''
46 for (const ch of command) {
47 if (quote) {
48 if (ch === quote) quote = ''
49 else word = (word ?? '') + ch
50 } else if (ch === '"' || ch === "'") {
51 quote = ch
52 word ??= ''
53 } else if (/\s/.test(ch)) {
54 if (word !== null) out.push(word)
55 word = null
56 } else word = (word ?? '') + ch
57 }
58 if (word !== null) out.push(word)
59 return out
60}
61
62export function openWithCommand(command: string, path: string, dir: string): string[] | null {
63 const argv = words(command)
64 if (!argv.length) return null
65 const filled = argv.map(w => w.replace(/\{(path|dir)\}/g, (_, key) => (key === 'path' ? path : dir)))
66 return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
67}
68hooks/desktop.tsx 425 lines1// New in this fork (awesome-claude): the desktop version of the pane, drawn with the shared kit.
2import type { Elements, RenderElement } from 'claude-code'
3
4import { ago } from '@kit/format'
5import { C, FILE_TYPE, GLYPH, GRAY, TRACK } from '@kit/tokens'
6import type { FileType } from '@kit/tokens'
7import { EDGE, SPACE, kit } from '@kit/ui'
8
9import type { Activity, FileTree } from '../types'
10import { dirname, formatSize, HOST_ICON, hostKind, remoteName, type Row, underAny } from './tree'
11
12/*
13 * The desktop's file tree, as the other mods' desktop panes draw: a header line
14 * with the folder and its actions as icons, the branch line, the search field,
15 * then one row per file or folder (indent, fold arrow, Octicon, name, figures on
16 * the right), and a footer with the selection and git activity.
17 *
18 * The rows are drawn here, not by the `Client` the terminal uses (rows.tsx): a
19 * surface module can't draw `Svg`, and the rows' icons are Octicons. A press on
20 * a row's name selects it and opens a folder, twice opens it, as a click does in
21 * the terminal; Claude's reads and writes light their rows for as long as the
22 * terminal's shimmer runs.
23 */
24
25export type DesktopElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Input' | 'Svg'>
26
27/** What the desktop pane draws from, besides the tree itself. */
28export type DesktopView = {
29 t: FileTree
30 rows: Row[]
31 /** The git activity shown in the footer: the running one, else the latest. */
32 latest: Activity | undefined
33 /** Whether the tree follows the session's cwd (the home button lit). */
34 follow: boolean
35 now: number
36 columns: number
37 /** The pane's height in rows, so the footer sits on its bottom edge. */
38 bodyRows: number
39 /** A path relative to the tree's root, or shortened under home. */
40 shown: (path: string) => string
41 /** The id the branch line flashes under. */
42 branchRow: string
43}
44
45/** What the pane's controls do; register.tsx runs them with its `$`. */
46export type DesktopActions = {
47 up: () => void
48 home: () => void
49 refresh: () => void
50 hidden: () => void
51 sizes: () => void
52 collapse: () => void
53 unselect: () => void
54 press: (id: string) => void
55 open: (id: string) => void
56 search: (query: string) => void
57 jump: (query: string) => void
58}
59
60/** The branch name's least width in cells, however many figures share its line. */
61const HEAD_MIN = 6
62
63/** An icon button's label: two Braille blanks, about the icon's two cells, drawn as nothing. */
64export const BLANK = '⠀⠀'
65
66/** The most rows drawn: a tree past it would outgrow what the desktop takes in one drawing. */
67export const MAX_ROWS = 1500
68
69const CODE = new Set(
70 'ts tsx mts cts js jsx mjs cjs py rs go rb java kt kts swift c h cc cpp hpp cs php sh bash zsh fish lua sql html htm css scss sass less vue svelte zig ex exs erl hs ml scala dart r jl pl ps1 nix'.split(' '),
71)
72
73/** A file's Octicon: code for source files, a page for anything else. */
74export const fileIcon = (name: string, kind: string, open: boolean) => {
75 if (kind === 'dir') return open ? 'folder-open' : 'folder'
76 const ext = name.includes('.') ? (name.split('.').pop() ?? '').toLowerCase() : ''
77 return CODE.has(ext) ? 'file-code' : 'file'
78}
79
80/** Extensions by the file type IntelliJ's New UI draws them as (its `fileTypes` icons). */
81const TYPE_BY_EXT = new Map<string, FileType>()
82for (const [type, exts] of Object.entries({
83 typescript: 'ts tsx mts cts',
84 javascript: 'js jsx mjs cjs',
85 json: 'json jsonc json5',
86 markdown: 'md mdx markdown',
87 yaml: 'yml yaml',
88 config: 'toml ini cfg conf properties env editorconfig',
89 style: 'css scss sass less',
90 html: 'html htm xhtml',
91 xml: 'xml xsd xsl plist',
92 data: 'csv tsv',
93 sql: 'sql',
94 image: 'png jpg jpeg gif webp ico bmp svg avif',
95 archive: 'zip tar gz tgz bz2 xz 7z rar jar',
96} satisfies Partial<Record<FileType, string>>))
97 for (const ext of exts.split(' ')) TYPE_BY_EXT.set(ext, type as FileType)
98
99/** Names IntelliJ draws as config whatever their extension. */
100const CONFIG_NAMES = new Set(['dockerfile', '.editorconfig', '.env', '.npmrc', '.nvmrc'])
101
102/**
103 * A row's file type, for its icon's colour (`FILE_TYPE`): a folder git ignores is
104 * excluded (node_modules, build output), any other folder grey, a file by its
105 * extension; lockfiles (`bun.lock`, `yarn.lock`) and anything unknown are text.
106 */
107export const fileType = (name: string, kind: string, excluded = false): FileType => {
108 if (kind === 'dir') return excluded ? 'excluded' : 'folder'
109 const lower = name.toLowerCase()
110 if (CONFIG_NAMES.has(lower) || lower.startsWith('.env.')) return 'config'
111 const ext = lower.includes('.') ? (lower.split('.').pop() ?? '') : ''
112 return TYPE_BY_EXT.get(ext) ?? 'text'
113}
114
115/**
116 * A git status letter's colour, in the kit's meanings: modified amber, new
117 * green, deleted or conflicted red, renamed or copied blue.
118 */
119export const statusColor = (status: string | undefined): string | undefined => {
120 if (!status) return undefined
121 if (status === 'D' || status === 'U') return C.bad
122 if (status === 'A' || status === '?') return C.ok
123 if (status === 'M' || status === 'T') return C.warn
124 if (status === 'R' || status === 'C') return C.blue
125 return GRAY.mid
126}
127
128/**
129 * The colour Claude's activity lights a row in. On a row: reads blue (live),
130 * writes amber (changed, as git marks a modified file), commits green, failures
131 * red. On the branch line purple is a merge, as everywhere in the kit.
132 */
133export const toneColor = (tone: string, branch = false): string => {
134 if (tone === 'green') return C.ok
135 if (tone === 'red') return C.bad
136 if (tone === 'orange') return C.warn
137 if (tone === 'purple') return branch ? C.purple : C.blue
138 return C.blue
139}
140
141/** An activity's Octicon, by what git or gh did. */
142export const activityIcon = (a: Activity) => {
143 const kind = a.kind
144 if (/\bcommit\b/.test(kind)) return 'git-commit'
145 if (/\bpush\b/.test(kind)) return 'repo-push'
146 if (/\b(pull|fetch|clone)\b/.test(kind)) return 'sync'
147 if (/\b(merge|rebase|cherry-pick)\b/.test(kind)) return 'pr-merged'
148 if (/^gh pr\b/.test(kind)) return 'pr-open'
149 if (/^gh issue\b/.test(kind)) return 'issue-opened'
150 if (/\b(reset|restore|revert|rm)\b/.test(kind)) return 'alert'
151 return GLYPH.branch
152}
153
154const activityColor = (a: Activity) => (a.state === 'failed' ? C.bad : a.state === 'running' ? C.blue : C.ok)
155
156export const renderDesktop = (els: DesktopElements, v: DesktopView, act: DesktopActions): RenderElement => {
157 const { Box, Text, Button, Input } = els
158 const { t } = v
159 const k = kit(els, 'desktop', v.columns)
160 const bright = new Set(t.flashOn ? t.flash : [])
161 const dimmed = new Set(t.flashOn ? t.flashDim : [])
162 const ignored = new Set(t.ignored)
163 const untracked = new Set(t.untrackedDirs)
164
165 /** An icon in a fixed column, so every name starts at one x. */
166 const lead = (key: string, icon: RenderElement) => (
167 <Box key={key} width={2} flexShrink={0} flexDirection="row" alignItems="center">
168 {icon}
169 </Box>
170 )
171
172 /**
173 * An action drawn as its icon. The desktop draws a Button as its label text and
174 * no children, so the icon (an SVG, named by `label` as its alt) sits in a box
175 * and a Button with a blank label the icon's size lies over it: a press anywhere
176 * on the icon is a press of the Button, its key the action's address. The label
177 * is two Braille blanks (U+2800): they draw nothing, as spaces do, but no
178 * renderer trims or collapses them.
179 */
180 const iconButton = (key: string, label: string, icon: string, color: string, onPress: () => void) => (
181 <Box key={`ib-${key}`} position="relative" width={2} height={1} flexShrink={0} flexDirection="row" alignItems="center" justifyContent="center">
182 {k.icon(icon, color, undefined, { alt: label })}
183 <Box position="absolute" top={0} left={0}>
184 <Button key={key} plain label={BLANK} onPress={onPress} />
185 </Box>
186 </Box>
187 )
188
189 /** A row: the left side gives way (truncates), the right side keeps its figures. */
190 const line = (key: string, left: (RenderElement | null)[], right: (RenderElement | null)[], extra: Record<string, unknown> = {}) => (
191 <Box key={key} flexDirection="row" justifyContent="space-between" alignItems="center" gap={SPACE.item} width="100%" {...extra}>
192 <Box flexDirection="row" alignItems="center" flexShrink={1} minWidth={0} overflow="hidden">
193 {left}
194 </Box>
195 <Box flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0}>
196 {right}
197 </Box>
198 </Box>
199 )
200
201 /** A folder's file counts: new, modified, deleted, each its letter dim and its figure in its colour. */
202 const counts = (key: string, c: [number, number, number] | undefined): RenderElement[] => {
203 if (!c) return []
204 const parts: [string, number, string][] = [
205 ['?', c[0], C.ok],
206 ['M', c[1], C.warn],
207 ['D', c[2], C.bad],
208 ]
209 return parts
210 .filter(([, n]) => n > 0)
211 .map(([letter, n, color]) => (
212 <Text key={`${key}-${letter}`}>
213 <Text color={GRAY.dark}>{letter}</Text>
214 <Text color={color}>{String(n)}</Text>
215 </Text>
216 ))
217 }
218
219 const lines = (key: string, loc: [number, number] | undefined): RenderElement[] => [
220 ...(loc && loc[0] > 0 ? [<Text key={`${key}-add`} color={C.ok}>{`+${loc[0]}`}</Text>] : []),
221 ...(loc && loc[1] > 0 ? [<Text key={`${key}-del`} color={C.bad}>{`−${loc[1]}`}</Text>] : []),
222 ]
223
224 // The header: the folder, then its actions.
225 const name = t.top ? [t.top.split('/').pop() ?? t.top, t.prefix.replace(/\/$/, '')].filter(Boolean).join('/') : t.root.split('/').pop() || t.root
226 const header = line(
227 'head',
228 [
229 lead('icon', k.icon('folder-open', GRAY.mid)),
230 <Text key="name" bold wrap="truncate">
231 {name}
232 </Text>,
233 ],
234 [
235 iconButton('up', 'Up a folder', 'arrow-up', GRAY.mid, act.up),
236 iconButton('cwd', 'Follow the working directory', 'home', v.follow ? GRAY.light : GRAY.dark, act.home),
237 iconButton('refresh', 'Refresh', 'sync', GRAY.mid, act.refresh),
238 iconButton('hidden', t.showHidden ? 'Hide hidden files' : 'Show hidden files', 'eye', t.showHidden ? GRAY.light : GRAY.dark, act.hidden),
239 iconButton('size', t.showSize ? 'Show dates' : 'Show sizes', GLYPH.context, t.showSize ? GRAY.light : GRAY.dark, act.sizes),
240 iconButton('collapse', 'Collapse all', 'fold', GRAY.mid, act.collapse),
241 t.selected ? iconButton('unselect', 'Clear the selection', GLYPH.failed, GRAY.mid, act.unselect) : null,
242 ],
243 { marginBottom: SPACE.block },
244 )
245
246 // The branch: its name, ahead and behind, then the lines and files changed.
247 const branch = () => {
248 if (!t.top) {
249 return line('branch', [lead('icon', k.icon(GLYPH.branch, GRAY.dark)), <Text key="none" color={GRAY.mid} wrap="truncate">no git repo · git status starts after git init</Text>], [], { marginBottom: SPACE.block })
250 }
251 if (!t.branch) return null
252 const b = t.branch
253 const flashing = bright.has(v.branchRow)
254 const tone = flashing ? toneColor(t.flashTones[v.branchRow] ?? 'teal', true) : undefined
255 const totals = t.diff[t.root] ?? [0, 0]
256 const rootCounts = counts('root', t.counts[t.root])
257 const changed = [...lines('root', totals), ...rootCounts]
258 return line(
259 'branch',
260 [
261 lead('icon', k.icon(GLYPH.branch, tone ?? GRAY.mid)),
262 // Never squeezed out: the figures on the right leave it a few letters (fewer when
263 // the name is shorter, so a short name doesn't leave a gap after it).
264 <Box key="head" flexShrink={1} minWidth={Math.min(HEAD_MIN, [...b.head].length)}>
265 <Text color={tone} wrap="truncate">
266 {b.head}
267 </Text>
268 </Box>,
269 b.ahead > 0 ? <Text key="ahead" color={GRAY.mid}>{` ↑${b.ahead}`}</Text> : null,
270 b.behind > 0 ? <Text key="behind" color={GRAY.mid}>{` ↓${b.behind}`}</Text> : null,
271 // The upstream as its host's icon (GitHub, GitLab, Forgejo, else a remote), its
272 // name the icon's tooltip; none when the branch tracks nothing.
273 // A local upstream (`main...main`, no remote) has no host to show.
274 b.upstream && remoteName(b.upstream) ? (
275 <Box key="upstream" flexShrink={0} marginLeft={SPACE.pair} flexDirection="row" alignItems="center">
276 {k.icon(HOST_ICON[hostKind(b.host)], GRAY.dark, undefined, { tooltip: b.upstream })}
277 </Box>
278 ) : null,
279 ],
280 changed.length ? changed : [<Text key="clean" color={GRAY.mid}>clean</Text>],
281 { marginBottom: SPACE.block },
282 )
283 }
284
285 // The search field, the pane's first row, as wide as the pane. Its submit ("jump":
286 // Enter opens the first match and clears the field) is drawn by the desktop as a
287 // button beside it: every Input has one there, labelled "submit" if not named.
288 const search = (
289 <Box key="search" flexDirection="row" alignItems="center" gap={SPACE.pair} width="100%" marginBottom={SPACE.block}>
290 {Input ? (
291 // A column, so the field stretches across the box.
292 <Box key="field" flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
293 <Input key="q" placeholder="Search files" submitLabel="jump" autoFocus value={t.query} onInput={(q: string) => act.search(q)} onSubmit={(q: string) => act.jump(q)} />
294 </Box>
295 ) : null}
296 {t.query ? iconButton('clear', 'Clear the search', GLYPH.failed, GRAY.mid, () => act.search('')) : null}
297 </Box>
298 )
299
300 // One file or folder.
301 const row = (r: Row) => {
302 const n = r.node
303 const own = t.git[n.id]
304 const status = own ?? (underAny(dirname(n.id), untracked, t.root) ? '?' : undefined)
305 const isIgnored = !status && underAny(n.id, ignored, t.root)
306 const isBright = bright.has(n.id)
307 const isDim = !isBright && dimmed.has(n.id)
308 const tone = isBright || isDim ? toneColor(t.flashTones[n.id] ?? 'orange') : undefined
309 const muted = isIgnored || n.hidden
310 // The icon in its file type's colour (IntelliJ's), a folder git ignores in the excluded tone;
311 // Claude's activity lights it in the activity's colour instead.
312 const iconColor = tone ?? FILE_TYPE[fileType(n.name, n.kind, isIgnored)]
313 const loc = t.diff[n.id]
314 const meta = t.showSize
315 ? n.kind === 'dir'
316 ? n.id in t.dirSizes
317 ? formatSize(t.dirSizes[n.id] ?? -1)
318 : '…'
319 : n.kind === 'file' || (n.kind === 'link' && n.size > 0)
320 ? formatSize(n.size)
321 : ''
322 : loc || n.kind !== 'file' || !n.mtime
323 ? ''
324 : ago(v.now - n.mtime)
325 const dirCounts = n.kind === 'dir' ? counts(n.id, t.counts[n.id]) : []
326 const isCursor = n.id === t.cursor
327 // A Button sizes to its label, so the name is cut to the room left here, with an ellipsis,
328 // rather than clipped mid-letter: the pane less the indent, arrow, icon, figures and badge.
329 const c = n.kind === 'dir' ? t.counts[n.id] : undefined
330 const figures = [meta, loc?.[0] ? `+${loc[0]}` : '', loc?.[1] ? `−${loc[1]}` : '', ...(c ?? []).filter(x => x > 0).map(x => `?${x}`)].filter(Boolean)
331 const room = Math.floor(v.columns - EDGE.left - EDGE.right - r.depth * SPACE.item - SPACE.item * 2 - 2 - figures.join(' ').length - SPACE.pair)
332 const label = n.name.length > Math.max(4, room) ? `${n.name.slice(0, Math.max(3, room - 1))}…` : n.name
333 return (
334 <Box key={`row-${n.id}`} flexDirection="row" alignItems="center" width="100%" backgroundColor={isCursor ? TRACK : undefined}>
335 {/* A rail in the activity's colour while Claude reads or writes it. */}
336 <Box width={0.25} height={1} flexShrink={0} backgroundColor={isBright ? tone : undefined} />
337 <Box width={EDGE.left - 0.25 + r.depth * SPACE.item} flexShrink={0} />
338 <Box width={SPACE.item} flexShrink={0}>
339 <Text color={GRAY.dark}>{n.kind === 'dir' ? (r.open ? '▾' : '▸') : ' '}</Text>
340 </Box>
341 {lead('icon', k.icon(fileIcon(n.name, n.kind, r.open), iconColor))}
342 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
343 {/* The desktop draws a Button as its label, plain, never its children, so a name can't
344 take its git colour or a weight here: it's dim when ignored or hidden, the git letter
345 on the right says its state, the cursor's tint and the footer say what's selected. */}
346 <Button key={n.id} plain label={label} {...(muted ? { dimColor: true } : {})} onPress={() => act.press(n.id)} />
347 </Box>
348 <Box flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0} marginLeft={SPACE.pair}>
349 {meta ? <Text color={meta === '…' ? GRAY.dark : GRAY.mid}>{meta}</Text> : null}
350 {lines(n.id, loc)}
351 {dirCounts}
352 </Box>
353 <Box width={SPACE.item + EDGE.right} flexShrink={0} flexDirection="row" justifyContent="center">
354 {dirCounts.length || !status ? null : <Text color={statusColor(status)}>{status}</Text>}
355 </Box>
356 </Box>
357 )
358 }
359
360 const drawn = v.rows.slice(0, MAX_ROWS)
361 const tree = (
362 <Box key="tree" flexDirection="column" width="100%">
363 {v.rows.length === 0 ? (
364 <Box paddingLeft={EDGE.left}>
365 <Text color={GRAY.mid}>{t.query ? 'no match' : 'empty'}</Text>
366 </Box>
367 ) : null}
368 {drawn.map(row)}
369 {v.rows.length > drawn.length ? (
370 <Box paddingLeft={EDGE.left}>
371 <Text color={GRAY.mid}>{`${v.rows.length - drawn.length} more; search to find them`}</Text>
372 </Box>
373 ) : null}
374 </Box>
375 )
376
377 // The footer: what's selected (Claude sees it as context), and git's latest step.
378 const chip = (a: Activity) => (
379 <Box key="chip" flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0}>
380 {k.icon(activityIcon(a), activityColor(a))}
381 <Text color={a.state === 'running' ? C.blue : a.state === 'failed' ? C.bad : GRAY.light}>{a.state === 'running' ? `${a.label}…` : a.label}</Text>
382 {a.state === 'done' && a.kind === 'git commit' && a.detail ? <Text color={GRAY.dark}>{a.detail.split(' ')[0] ?? ''}</Text> : null}
383 </Box>
384 )
385 const selected = t.selected
386 const footer =
387 selected || v.latest
388 ? line(
389 'foot',
390 selected
391 ? [
392 lead('icon', k.icon('file', GRAY.dark)),
393 <Box key="path" flexShrink={1} minWidth={0}>
394 <Text color={GRAY.mid} wrap="truncate-start">
395 {v.shown(selected)}
396 </Text>
397 </Box>,
398 <Box key="open" flexShrink={0} marginLeft={SPACE.pair}>
399 <Button key="open" plain dimColor label="open" onPress={() => act.open(selected)} />
400 </Box>,
401 ]
402 : [],
403 [v.latest ? chip(v.latest) : null],
404 { marginTop: SPACE.block },
405 )
406 : null
407
408 return (
409 <Box flexDirection="column" minHeight={Math.max(1, v.bodyRows)} paddingTop={SPACE.block}>
410 <Box flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right}>
411 {search}
412 {header}
413 {branch()}
414 </Box>
415 {tree}
416 <Box flexGrow={1} />
417 {footer ? (
418 <Box flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right}>
419 {footer}
420 </Box>
421 ) : null}
422 </Box>
423 )
424}
425hooks/git.ts 259 lines1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4 kind: string
5 verb: string
6 running: string
7 done: string
8 tone: string
9 icon: { nerd: string; plain: string }
10 init?: boolean
11}
12
13const ICON = {
14 commit: { nerd: '\u{f417}', plain: '●' },
15 push: { nerd: '\u{f0552}', plain: '↑' },
16 pull: { nerd: '\u{f0553}', plain: '↓' },
17 branch: { nerd: '\u{f418}', plain: '⑂' },
18 merge: { nerd: '\u{f419}', plain: '⑃' },
19 pr: { nerd: '\u{f407}', plain: '⇄' },
20 github: { nerd: '\u{f408}', plain: '◎' },
21 stash: { nerd: '\u{f01bc}', plain: '≡' },
22 undo: { nerd: '\u{f054c}', plain: '↶' },
23 tag: { nerd: '\u{f412}', plain: '⌖' },
24 git: { nerd: '\u{e702}', plain: '±' },
25}
26
27/**
28 * The activity tones a lit row or git step takes. Changed in this fork: the names
29 * only, no hex ramps; each draws in the kit's colour for its meaning (`toneColor`
30 * in desktop.tsx), a theme key on the terminal through the kit's `termInk`.
31 */
32export const TONES = ['orange', 'green', 'teal', 'blue', 'purple', 'cyan', 'red'] as const
33
34const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
35 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
36 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
37 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
38 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
39 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
40 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
41 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
42 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
43 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
44 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
45 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
46 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
47 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
48 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
49 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
50 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
51 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
52 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
53 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
54 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
55}
56
57const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
58 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
59 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
60 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
61 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
62 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
63 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
64 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
65 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
66}
67
68const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
69
70function segments(command: string, seps: string[] = []): string[][] {
71 const out: string[][] = []
72 let cur: string[] = []
73 let tok = ''
74 let quote = ''
75 let has = false
76 const endTok = () => {
77 if (has) cur.push(tok)
78 tok = ''
79 has = false
80 }
81 const endSeg = () => {
82 endTok()
83 if (cur.length) out.push(cur)
84 cur = []
85 }
86 for (let i = 0; i < command.length; i++) {
87 const ch = command[i] ?? ''
88 if (quote) {
89 if (ch === quote) quote = ''
90 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
91 else tok += ch
92 } else if (ch === '"' || ch === "'") {
93 quote = ch
94 has = true
95 } else if (ch === '\\' && i + 1 < command.length) {
96 tok += command[++i]
97 has = true
98 } else if (ch === '#' && !has) {
99 while (i + 1 < command.length && command[i + 1] !== '\n') i++
100 } else if (ch === ' ' || ch === '\t') endTok()
101 else if (ch === '\n' || ch === ';') {
102 seps.push(';')
103 endSeg()
104 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
105 tok += ch
106 has = true
107 } else if (ch === '&' || ch === '|') {
108 const twice = command[i + 1] === ch
109 if (twice) i++
110 seps.push(twice ? ch + ch : ch)
111 endSeg()
112 } else {
113 tok += ch
114 has = true
115 }
116 }
117 endSeg()
118 if (!quote) return out
119 seps.push(';')
120 return command
121 .split(/&&|\|\||;|\||\n/)
122 .map(s => s.trim().split(/\s+/).filter(Boolean))
123 .filter(s => s.length > 0)
124}
125
126function stripGlobals(tokens: string[]): string[] {
127 const out = [...tokens]
128 if (out[0] === 'env') out.shift()
129 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
130 return out
131}
132
133function gitVerb(tokens: string[]): number {
134 let i = 1
135 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
136 if (tokens[i] === '-C' || tokens[i] === '-c') i++
137 i++
138 }
139 return i
140}
141
142function gitReadOnly(verb: string, rest: string[]): boolean {
143 if (!verb || READ_ONLY_GIT.has(verb)) return true
144 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
145 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
146 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
147 return false
148}
149
150export function chainOf(command: string): { size: number; and: boolean } {
151 const seps: string[] = []
152 const size = segments(command, seps).length
153 return { size, and: seps.every(s => s === '&&') }
154}
155
156export function gitActions(command: string): GitAction[] {
157 const out: GitAction[] = []
158 for (const raw of segments(command)) {
159 const tokens = stripGlobals(raw)
160 const head = tokens[0]?.split('/').pop()
161 if (head === 'git') {
162 const i = gitVerb(tokens)
163 const verb = tokens[i] ?? ''
164 const rest = tokens.slice(i + 1)
165 if (gitReadOnly(verb, rest)) continue
166 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
167 if (spec) out.push({ kind: `git ${verb}`, ...spec })
168 } else if (head === 'gh') {
169 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
170 const spec = GH_VERBS[pair]
171 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
172 }
173 }
174 return out
175}
176
177export const BRANCH_ICON = ICON.branch
178
179const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
180const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
181const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
182
183function tidy(path: string): string {
184 const s = path.replace(/\/+$/, '')
185 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
186}
187
188export function resolve(cwd: string, p: string, home = ''): string {
189 let path = posix(p)
190 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
191 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
192 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
193 for (const seg of path.slice(drive.length).split('/')) {
194 if (!seg || seg === '.') continue
195 if (seg === '..') {
196 if (parts.length > 1) parts.pop()
197 } else parts.push(seg)
198 }
199 return tidy(parts.join('/'))
200}
201
202const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
203
204export function readOnly(command: string): boolean {
205 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
206 if (/[^>]>[^>&]|>>/.test(bare)) return false
207 return segments(command).every(raw => {
208 const tokens = stripGlobals(raw)
209 const head = tokens[0]?.split('/').pop() ?? ''
210 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
211 if (head === 'git') {
212 const i = gitVerb(tokens)
213 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
214 }
215 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
216 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
217 return READERS.has(head)
218 })
219}
220
221export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
222 let cwd = sessionCwd
223 const out = new Set<string>()
224 let listsPaths = false
225 for (const raw of segments(command)) {
226 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
227 const head = tokens[0]?.split('/').pop() ?? ''
228 if (head === 'cd' && tokens[1]) {
229 cwd = resolve(cwd, tokens[1], home)
230 continue
231 }
232 if (!READERS.has(head)) continue
233 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
234 if (OUTPUT_PATHS.has(head)) listsPaths = true
235 const args = tokens.slice(1)
236 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
237 for (let i = 0; i < args.length; i++) {
238 const a = args[i] ?? ''
239 if (a.startsWith('-')) {
240 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
241 continue
242 }
243 if (skipPattern) {
244 skipPattern = false
245 continue
246 }
247 if (/[*?<>|]/.test(a)) continue
248 out.add(resolve(cwd, a, home))
249 }
250 }
251 if (listsPaths) {
252 for (const line of stdout.split('\n').slice(0, 400)) {
253 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
254 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
255 }
256 }
257 return [...out].slice(0, 60)
258}
259hooks/rows.tsx 165 lines1// Changed in this fork (awesome-claude) for the kit's terminal look: colours are the
2// kit's terminal inks (theme keys or dim), and the shimmer is a bold band sweeping
3// through a row drawn in its activity's colour, not a hex gradient. The cursor row is
4// the kit's selected list row: a `▌` gutter mark in `suggestion` (dim while the pane
5// doesn't have the keyboard), never a background, which no fixed hex reads on every theme.
6// The scrollbar is only its thumb, dim.
7import type { ClientModule } from 'claude-code'
8
9/**
10 * One run of a row: `c` a colour (an engine theme key, never a hex: the kit's
11 * `termInk` gives it), `dc` the terminal's dim, `sh` a shimmer tone (a key of
12 * `tones`), `dim` the faint flash.
13 */
14export type Seg = { t: string; c?: string; dc?: boolean; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; tab?: string }
15export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
16export type RowsProps = {
17 rows: RowSpec[]
18 /** The cursor row's id: its one-cell gutter holds `mark`. */
19 active: string
20 /** The kit's selected-row mark (`MARK.selected`) and its colour (a theme key). */
21 mark: string
22 markColor?: string
23 /** Whether the pane has the keyboard: the mark is dim while it hasn't. */
24 focused: boolean
25 /** Each shimmer tone's colour on the terminal (a theme key, from the kit's `termInk`). */
26 tones: Record<string, string>
27 spinner?: string[]
28 pointer?: boolean
29 /** The scrollbar: its thumb a dim `┃`, no track (a full-height rule beside the names read as a border). */
30 bar?: { pos: number; size: number }
31 preview?: { text: string; path: string; startLine: number; markdown: boolean }
32}
33type Local = { phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
34
35const TICK_MS = 90
36const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
37
38/** Whether letter `i` of `len` sits in the band the shimmer sweeps across a name. */
39function inBand(i: number, phase: number, len: number): boolean {
40 const band = ((phase * 1.6) % (len + 8)) - 4
41 return Math.abs(i - band) < 1.8
42}
43
44const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
45 const { Box, Text } = surface.elements
46 let state = surface.state
47 if (state === undefined) {
48 state = { phase: 0, drag: false, ref: {} }
49 surface.setState(state)
50 }
51 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
52 if (lit && !state.ref.stop) {
53 state.ref.stop = surface.every(TICK_MS, () => {
54 const cur = surface.state
55 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
56 })
57 } else if (!lit && state.ref.stop) {
58 state.ref.stop()
59 state.ref.stop = undefined
60 }
61 if (props.pointer === false) {
62 state.ref.unpoint?.()
63 state.ref.unpoint = undefined
64 } else state.ref.unpoint = surface.onPointer(e => {
65 const cur = surface.state ?? state
66 const span = Math.max(1, props.rows.length - 1)
67 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
68 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
69 if (e.type === 'up' || e.type === 'leave') {
70 surface.setState({ ...cur, drag: false })
71 return
72 }
73 if (e.type === 'down' || e.type === 'move') {
74 if (!cur.drag) surface.setState({ ...cur, drag: true })
75 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
76 return
77 }
78 }
79 if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) return
80 const row = props.rows[e.y]
81 if (e.type === 'down' && e.button === 'right' && row?.id) {
82 surface.post({ copy: row.id, shift: Boolean(e.shift) })
83 return
84 }
85 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
86 // After the one-cell gutter.
87 let x = 1
88 for (const seg of row.left) {
89 const w = [...seg.t].length
90 if (seg.tab && e.x >= x && e.x < x + w) {
91 surface.post({ tab: seg.tab })
92 return
93 }
94 x += w
95 }
96 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
97 })
98 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
99 // The preview draws in this same element, so the focus a click gave the tree stays put.
100 if (props.preview) {
101 const { Markdown, Code } = surface.elements
102 const p = props.preview
103 return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
104 }
105 const frames = props.spinner?.length ? props.spinner : FRAMES
106 const draw = (s: Seg) => {
107 if (s.spin) {
108 return (
109 <Text color={s.c} bold={s.b}>
110 {s.t + (frames[state.phase % frames.length] ?? '')}
111 </Text>
112 )
113 }
114 const tone = s.sh ? props.tones[s.sh] : undefined
115 if (!tone) {
116 return (
117 <Text color={s.c} dimColor={s.dc} bold={s.b} strikethrough={s.s} italic={s.i}>
118 {s.t}
119 </Text>
120 )
121 }
122 // The faint flash (files Claude touched a moment ago) stays dim and still.
123 if (s.one || s.dim) {
124 return (
125 <Text color={tone} dimColor={s.dim} bold={s.b}>
126 {s.t}
127 </Text>
128 )
129 }
130 const chars = [...s.t]
131 return (
132 <Text color={tone}>
133 {chars.map((ch, i) => (
134 <Text bold={s.b || inBand(i, state.phase, chars.length)}>{ch}</Text>
135 ))}
136 </Text>
137 )
138 }
139 return (
140 <Box flexDirection="column">
141 {props.rows.map((r, i) => (
142 <Box flexDirection="row" height={1} overflow="hidden">
143 <Box width={1} flexShrink={0}>
144 {r.id && r.id === props.active ? (
145 <Text color={props.markColor} dimColor={!props.focused || undefined}>
146 {props.mark}
147 </Text>
148 ) : (
149 <Text> </Text>
150 )}
151 </Box>
152 <Box flexShrink={1} overflow="hidden">
153 {r.left.map(draw)}
154 </Box>
155 <Box flexGrow={1} />
156 {r.right.map(draw)}
157 {props.bar && <Text dimColor>{i >= props.bar.pos && i < props.bar.pos + props.bar.size ? ' ┃' : ' '}</Text>}
158 </Box>
159 ))}
160 </Box>
161 )
162}
163
164export default Rows
165hooks/icons.ts 127 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
117// Changed in this fork (awesome-claude): no git status hex palette here. A status
118// draws in the kit's colour for it (`statusColor` in desktop.tsx), a theme key on
119// the terminal through the kit's `termInk`.
120
121const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
122
123export function stronger(a: string | undefined, b: string): string {
124 if (!a) return b
125 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
126}
127hooks/tree.ts 349 lines1import type { Branch, FileNode, FileTree } 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 function emptyTree(root: string): FileTree {
10 return {
11 root,
12 nodes: [],
13 expanded: [],
14 cursor: '',
15 selected: '',
16 query: '',
17 showHidden: true,
18 showSize: false,
19 dirSizes: {},
20 git: {},
21 diff: {},
22 ignored: [],
23 untrackedDirs: [],
24 top: '',
25 prefix: '',
26 branch: null,
27 counts: {},
28 flash: [],
29 flashDim: [],
30 flashOn: false,
31 flashTones: {},
32 scroll: null,
33 preview: '',
34 previewTop: 0,
35 }
36}
37
38export function join(dir: string, name: string): string {
39 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
40}
41
42let drives = false
43
44export function dirname(path: string): string {
45 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
46 const i = path.lastIndexOf('/')
47 return i <= 0 ? '/' : path.slice(0, i)
48}
49
50export function useDrives(on: boolean): void {
51 drives = on
52}
53
54export function drivesOn(): boolean {
55 return drives
56}
57
58export function posix(path: string): string {
59 if (!drives) return path
60 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
61}
62
63export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
64 if (dirs.length === 0) return nodes
65 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
66 const gone = new Set(dirs)
67 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
68}
69
70export function isAbsolute(path: string): boolean {
71 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
72}
73
74export function inside(root: string, path: string): boolean {
75 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
76}
77
78export function toNodes(dir: string, list: Entry[]): FileNode[] {
79 return list
80 .map(e => ({
81 id: join(dir, e.name),
82 parent: dir,
83 name: e.name,
84 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
85 hidden: e.name.startsWith('.'),
86 mtime: e.mtimeMs,
87 size: e.size,
88 loaded: false,
89 }))
90 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
91}
92
93export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
94 if (listed.size === 0) return nodes
95 const keep = new Map<string, FileNode>()
96 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
97 const freshIds = new Set<string>()
98 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
99 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
100 const under = (id: string) => {
101 if (gone.size === 0) return false
102 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
103 return false
104 }
105 const fresh: FileNode[] = []
106 for (const kids of listed.values()) {
107 for (const k of kids) {
108 if (under(k.id)) continue
109 const old = keep.get(k.id)
110 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
111 }
112 }
113 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
114 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
115}
116
117export type GitStatus = {
118 git: Record<string, string>
119 ignored: string[]
120 untrackedDirs: string[]
121 branch: Branch | null
122 files: Record<string, Change>
123}
124
125export type Change = 'new' | 'mod' | 'del'
126
127export function parseBranch(record: string): Branch {
128 const body = record.replace(/^## /, '')
129 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', host: '', ahead: 0, behind: 0 }
130 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
131 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
132 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
133 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
134 return { head, upstream, host: '', ahead, behind }
135}
136
137/** The remote an upstream lives on: `origin` of `origin/feat/x`. */
138export function remoteName(upstream: string): string {
139 const slash = upstream.indexOf('/')
140 return slash > 0 ? upstream.slice(0, slash) : ''
141}
142
143/**
144 * A remote URL's host, lowercased: `https://github.com/o/r.git`,
145 * `ssh://git@host:22/o/r`, scp-like `git@gitlab.com:o/r.git`. Empty for a local
146 * path or anything else without one.
147 */
148export function remoteHost(url: string): string {
149 const u = url.trim()
150 const full = u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?(\[[^\]]+\]|[^/:?#]+)/i)
151 if (full) return (full[1] ?? '').toLowerCase()
152 const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/)
153 return scp ? (scp[1] ?? '').toLowerCase() : ''
154}
155
156/** Where an upstream lives, by its remote's host. */
157export type HostKind = 'github' | 'gitlab' | 'forgejo' | 'other'
158
159/** GitHub by its host, GitLab and Forgejo by theirs or a host that names them (a self-hosted `gitlab.corp`). */
160export function hostKind(host: string): HostKind {
161 const h = host.toLowerCase()
162 if (h === 'github.com' || h.endsWith('.github.com')) return 'github'
163 if (h.includes('gitlab')) return 'gitlab'
164 if (h.includes('codeberg') || h.includes('forgejo')) return 'forgejo'
165 return 'other'
166}
167
168/** Each host's icon (kit/icons.ts): GitHub's mark, GitLab's, Forgejo's, else a generic remote. */
169export const HOST_ICON: Record<HostKind, string> = { github: 'mark-github', gitlab: 'gitlab', forgejo: 'forgejo', other: 'cloud' }
170
171/** Each host's mark on the terminal, which has no glyph for them: two letters, or ⇅ for any other remote. */
172export const HOST_MARK: Record<HostKind, string> = { github: 'gh', gitlab: 'gl', forgejo: 'fj', other: '⇅' }
173
174export function mapper(root: string, prefix: string): (rel: string) => string | null {
175 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
176}
177
178export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
179 const map = mapper(root, prefix)
180 const git: Record<string, string> = {}
181 const ignored: string[] = []
182 const untrackedDirs: string[] = []
183 const files: Record<string, Change> = {}
184 let branch: Branch | null = null
185 const parts = stdout.split('\0')
186 for (let i = 0; i < parts.length; i++) {
187 const rec = parts[i] ?? ''
188 if (rec.startsWith('## ')) {
189 branch = parseBranch(rec)
190 continue
191 }
192 if (rec.length < 4) continue
193 const xy = rec.slice(0, 2)
194 const raw = rec.slice(3)
195 if (xy[0] === 'R' || xy[0] === 'C') i++
196 const path = map(raw)
197 if (!path) continue
198 if (xy === '!!') {
199 ignored.push(path)
200 continue
201 }
202 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
203 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
204 const letter =
205 xy === '??'
206 ? '?'
207 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
208 ? 'U'
209 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
210 if (!letter) continue
211 git[path] = stronger(git[path], letter)
212 let dir = dirname(path)
213 while (dir.length >= root.length && dir !== '/') {
214 git[dir] = stronger(git[dir], letter)
215 if (dir === root) break
216 dir = dirname(dir)
217 }
218 }
219 return { git, ignored, untrackedDirs, branch, files }
220}
221
222export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
223 const map = mapper(root, prefix)
224 const parts = stdout.split('\0')
225 for (let i = 0; i < parts.length; i++) {
226 const rec = parts[i] ?? ''
227 if (!rec) continue
228 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
229 if (!m) continue
230 let path = m[3] ?? ''
231 if (path === '') {
232 i += 2
233 path = parts[i] ?? ''
234 }
235 if (!path) continue
236 const add = m[1] === '-' ? 0 : Number(m[1])
237 const del = m[2] === '-' ? 0 : Number(m[2])
238 const abs = map(path)
239 if (!abs) continue
240 const prev = into[abs] ?? [0, 0]
241 into[abs] = [prev[0] + add, prev[1] + del]
242 }
243}
244
245export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
246 const out: Record<string, [number, number]> = { ...diff }
247 for (const [path, [add, del]] of Object.entries(diff)) {
248 let dir = dirname(path)
249 while (dir.length >= root.length && dir !== '/') {
250 const prev = out[dir] ?? [0, 0]
251 out[dir] = [prev[0] + add, prev[1] + del]
252 if (dir === root) break
253 dir = dirname(dir)
254 }
255 }
256 return out
257}
258
259export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
260 const out: Record<string, [number, number, number]> = {}
261 const slot = { new: 0, mod: 1, del: 2 } as const
262 for (const [path, change] of Object.entries(files)) {
263 let dir = dirname(path)
264 while (dir.length >= root.length && dir !== '/') {
265 const cur = out[dir] ?? [0, 0, 0]
266 cur[slot[change]] += 1
267 out[dir] = cur
268 if (dir === root) break
269 dir = dirname(dir)
270 }
271 }
272 return out
273}
274
275export function underAny(id: string, set: Set<string>, root: string): boolean {
276 if (set.size === 0) return false
277 let cur = id
278 while (cur.length >= root.length) {
279 if (set.has(cur)) return true
280 const up = dirname(cur)
281 if (up === cur) break
282 cur = up
283 }
284 return false
285}
286
287export function ancestorsOf(id: string, root: string): string[] {
288 const out: string[] = []
289 if (!root) return out
290 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
291 return out
292}
293
294export function relative(root: string, path: string): string {
295 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
296}
297
298export function visibleRows(t: FileTree): Row[] {
299 const kids = new Map<string, FileNode[]>()
300 for (const n of t.nodes) {
301 if (!t.showHidden && n.hidden) continue
302 const list = kids.get(n.parent)
303 if (list) list.push(n)
304 else kids.set(n.parent, [n])
305 }
306 const q = t.query.trim().toLowerCase()
307 let keep: Set<string> | null = null
308 if (q) {
309 keep = new Set()
310 for (const n of t.nodes) {
311 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
312 keep.add(n.id)
313 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
314 }
315 }
316 const open = new Set(t.expanded)
317 const rows: Row[] = []
318 const walk = (dir: string, depth: number) => {
319 for (const n of kids.get(dir) ?? []) {
320 if (keep && !keep.has(n.id)) continue
321 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
322 rows.push({ node: n, depth, open: isOpen })
323 if (isOpen) walk(n.id, depth + 1)
324 }
325 }
326 walk(t.root, 0)
327 return rows
328}
329
330export function stamp(ms: number): string {
331 if (!ms) return ''
332 const d = new Date(ms)
333 const p = (v: number) => String(v).padStart(2, '0')
334 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
335}
336
337export function formatSize(bytes: number): string {
338 if (bytes < 0) return '?'
339 if (bytes < 1024) return `${bytes} B`
340 const units = ['K', 'M', 'G', 'T']
341 let v = bytes / 1024
342 let i = 0
343 while (v >= 1024 && i < units.length - 1) {
344 v /= 1024
345 i++
346 }
347 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
348}
349types/index.d.ts 68 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 /** The upstream remote's host (`github.com`), for its icon; empty when untracked or unknown. */
16 host: string
17 ahead: number
18 behind: number
19}
20
21export type Activity = {
22 id: number
23 kind: string
24 label: string
25 state: 'running' | 'done' | 'failed'
26 detail: string
27 at: number
28 tone: string
29 nerd: string
30 plain: string
31}
32
33export type FileTree = {
34 root: string
35 nodes: FileNode[]
36 expanded: string[]
37 cursor: string
38 selected: string
39 query: string
40 showHidden: boolean
41 showSize: boolean
42 dirSizes: Record<string, number>
43 git: Record<string, string>
44 diff: Record<string, [number, number]>
45 ignored: string[]
46 untrackedDirs: string[]
47 top: string
48 prefix: string
49 branch: Branch | null
50 counts: Record<string, [number, number, number]>
51 flash: string[]
52 flashDim: string[]
53 flashOn: boolean
54 flashTones: Record<string, string>
55 scroll: number | null
56 preview: string
57 previewTop: number
58}
59
60declare module 'claude-code' {
61 interface PluginState {
62 filetree: {
63 tree: FileTree
64 activity: Activity[]
65 }
66 }
67}
68