SLOPSHOPPER

filetree

An IDE-style file tree for Claude Code that shows what Claude is doing and where in files. Maintained copy of data-goblin/claude-code-filetree@b0e4098 (MIT…

newpaneguardcommandtoastprompt
v0.2.24-bracyw.1MITupdated 2026-10-12bracyw/claude-mods/plugins/filetree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree
│ ┃ Files: app ✕ › fix the failing auth test and add an audit log call │ ┃ app󰁝 󰋜 󰑐 󰈈 󰋊 │ ┃  no git repo · git status starts after git ⏺ Read(src/auth.ts) │ ┃ init ⎿ Read 6 lines │ ┃ / : search ⏎ jump ⏺ Update(src/auth.ts) │ ┃ ▣ client module ./rows.tsx ⎿ Added 2 lines, removed 1 line │ ┃ 󰕒 pushed ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /filetree │ ┃ ⎿ filetree: File tree on /work/app (follows the cwd). │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files: app
app󰁝 󰋜 󰑐 󰈈 󰋊  no git repo · git status starts after git init / : search ⏎ jump ▣ client module ./rows.tsx 󰕒 pushed
README

[!IMPORTANT] Maintained copy. This is bracyw's copy of data-goblin/claude-code-filetree at commit b0e4098, audited line-by-line on 2026-10-12. The original is MIT-licensed by Kurt Buhler; see LICENSE. Runtime code is unchanged from upstream; only plugin.json metadata differs, and upstream's site, media, CI and dev tooling were left out. Install from this repo's marketplace (see the root README), not with the upstream commands below. Upstream changes are pulled in only after reviewing the diff.

<h1 align="center">Claude Code Filetree</h1>

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

<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.287-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.287 or newer"> <img src="https://img.shields.io/badge/version-0.2.24-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">

<img src="media/filetree-shimmer.gif" alt="filetree shimmering the file Claude is editing" width="900">

[!NOTE] filetree is a Claude Code mod and needs Claude Code 2.1.287+. It shows in the right sidebar, which in the terminal needs the fullscreen layout (/tui fullscreen) and a terminal at least 110 columns wide; the Desktop app docks it on its own, and /filetree reopens it there after you close it. Tested on Linux, macOS and Windows; mods don't load in WSL sessions of the Desktop app.


Installation

The repo is its own plugin marketplace. Run this in the terminal:

claude plugin marketplace add data-goblin/claude-code-filetree
claude plugin install filetree@claude-code-filetree

Or inside a Claude Code session:

/plugin marketplace add data-goblin/claude-code-filetree
/plugin install filetree@claude-code-filetree

Installed it as filetree@filetree before the repository was renamed? Nothing to do: that install keeps loading and keeps receiving updates.

Features

  • Interactive file tree for the working directory where you're using Claude Code; it follows the cwd, or /filetree <path> pins another folder
  • Search the file tree, including folders you have not opened yet
  • Git status per file and folder in color, with exact lines changed (+N -N) on modified files and ?:N M:N D:N file counts on folders
  • Branch, upstream and ahead/behind in the header
  • Visual indicator of Claude reads and searches (purple), writes (orange) and commits (green); collapsed folders open to show the file

<img src="media/filetree-read.gif" alt="Files shimmer purple while Claude reads and searches them" width="800">

  • Git and GitHub operations via git and gh (commit, push, pull, checkout, merge, PR and more) shown as a status at the bottom of the pane

<img src="media/filetree-git.gif" alt="A committed file shimmers green and the footer shows the commit" width="800">

  • Selection-aware: the selected file is passed to Claude as context through a prompt.submit hook, and @path mentions in a prompt reveal that file in the tree

<img src="media/filetree-ask.gif" alt="Selecting config.yaml in the tree and asking Claude what it changed there" width="800">

  • File and folder sizes: the Σ 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
  • Double-click a file to open it in its default app
  • Click to select, arrow keys to move through the tree
  • Light on large repos: outside a repo it only checks once whether one exists, and every git call is scoped to the cwd
  • Nerd Font icons with a plain Unicode fallback
  • On Omarchy, the pane takes its colors and background from the current theme and follows theme switches

Resizing the pane

You can resize the pane with the mouse, or by setting custom pane:grow or pane:shrink keybindings in keybindings.json

<img src="media/filetree-resize.gif" alt="Dragging the filetree pane edge to resize it" width="900">

Settings

All settings are in /config under filetree.

  • Claude activity: what shimmers: reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.
  • Follow Claude: on (default) scrolls the tree to what Claude reads, writes or commits; off keeps the view where you put it, and highlights still show.
  • Open at start: on (default) opens the pane when a session starts; off keeps it closed until you run /filetree.
  • Right column: date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.
  • Open with: empty (default) opens a double-clicked file in its default app. 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}.
  • Glyphs: 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.

herdr

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.

Contributing

Turn on the pre-commit hook once per clone; it runs claude plugin validate and the plugin tests before each commit that touches the plugin:

git config core.hooksPath .githooks

The same checks run on macOS, Windows and Linux in CI on every push that touches the plugin.

License

MIT

This project is inspired by my Omarchy app FileBlade

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