SLOPSHOPPER

filetree

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

newpaneguardcommandtoastprompt
v0.2.25MITupdated 2026-10-16aecheman27-gif/claude-mods/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

<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 needs the fullscreen layout (/tui fullscreen) and a terminal at least 110 columns wide. 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 file you select is passed to Claude as context with the next prompt you send, 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 (or Ctrl/Shift-click) a file to open it in its default app; on Windows a file that app would run (a script, program, installer or shortcut) opens in Notepad or is shown in Explorer instead
  • Click to select, arrow keys to move through the tree; Enter and Space select a file or fold a folder, and never open anything
  • Closing the pane keeps it closed, across sessions, until you run /filetree
  • 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.
  • Right column: date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.
  • 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 1453 lines
1import { type BuiltinToolResults, type EngineInterface, type PromptOrigin, type Register, type Timer, update } from 'claude-code'
2import { openCommand } 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 CLOSED = 'paneClosed'
40const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
41const BRANCH_ROW = '#branch'
42const FLASH_MS = 2700
43const RUNNING_MAX_MS = 600_000
44const NO_REPO_DEPTH = 4
45const DOUBLE_MS = 450
46const FIND_LIMIT = 200
47const 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 follow = true
76let followClaude = true
77let scanning: Promise<void> | null = null
78let scanJobs: Job[] = []
79let gitRun: Promise<void> | null = null
80let gitNext: Promise<void> | null = null
81let queuedReads: string[] = []
82let showReads = true
83let showWrites = true
84let searchIndex: { root: string; paths: Promise<string[]> } | null = null
85let activityId = 0
86let pointer = true
87let view = { from: 0, max: 0 }
88let lastSync = 0
89let noDock = false
90let home = ''
91let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
92let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
93let markId = 0
94let listSeq = 0
95let lastRoot = ''
96let offerSelection = false
97let sizeDefault = false
98let sizeEpoch = 0
99let sizeActive = 0
100let sizeQueue: string[] = []
101const sizing = new Set<string>()
102const listLatest = new Map<string, number>()
103const unreadable = new Set<string>()
104const background = new Map<string, Background>()
105
106function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
107  platform ??= (async () => {
108    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
109    try {
110      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
111    } catch {
112      return 'linux'
113    }
114  })()
115  return platform
116}
117
118async function cwdOf($: EngineInterface): Promise<string> {
119  return posix(await $.session.cwd())
120}
121
122function clean(segs: Seg[]): Seg[] {
123  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
124  return segs
125}
126
127function faint(hex: string): string {
128  const v = parseInt(hex.slice(1), 16)
129  const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
130  return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
131}
132
133async function get($: EngineInterface): Promise<FileTree> {
134  return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
135}
136
137async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
138  await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
139}
140
141function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
142  return put($, t => ({ ...t, ...fn(t) }))
143}
144
145async function activities($: EngineInterface): Promise<Activity[]> {
146  return (await $.state.get(ACTIVITY)).value ?? []
147}
148
149async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
150  await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
151}
152
153async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
154  try {
155    const entries = await $.fs.list(dir)
156    const resolved = await Promise.all(
157      entries.map(async e => {
158        if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
159        try {
160          const target = await $.fs.stat(join(dir, e.name))
161          return target.kind === 'dir'
162            ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
163            : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
164        } catch {
165          return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
166        }
167      }),
168    )
169    unreadable.delete(dir)
170    return toNodes(dir, resolved)
171  } catch (err) {
172    if (!(await exists($, dir))) return []
173    if (!unreadable.has(dir)) {
174      unreadable.add(dir)
175      $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
176    }
177    return null
178  }
179}
180
181async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
182  const root = (await get($)).root
183  const seq = ++listSeq
184  for (const dir of dirs) listLatest.set(dir, seq)
185  const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
186  const listed = new Map<string, FileNode[]>()
187  for (const [dir, kids] of results) {
188    if (listLatest.get(dir) !== seq) continue
189    listLatest.delete(dir)
190    if (kids) listed.set(dir, kids)
191  }
192  await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
193  return listed
194}
195
196async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
197  return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
198}
199
200async function detectRepo($: EngineInterface): Promise<void> {
201  const t = await get($)
202  if (!t.root) return
203  let top = ''
204  let prefix = ''
205  try {
206    const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
207    const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
208    prefix = p.trim()
209    top = tl.trim()
210  } catch {
211    top = ''
212  }
213  await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
214}
215
216async function readGit($: EngineInterface): Promise<void> {
217  const t = await get($)
218  if (!t.root || !t.top) return
219  const root = t.root
220  try {
221    const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
222    if (status.exitCode !== 0) return
223    const parsed = parseGit(status.stdout, root, t.prefix)
224    const diff: Record<string, [number, number]> = {}
225    const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
226    if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
227    const files = { ...parsed.files }
228    if (parsed.untrackedDirs.length) {
229      const untracked = new Set(parsed.untrackedDirs)
230      const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', '.'])
231      if (others.exitCode === 0) for (const path of others.stdout.split('\0').filter(Boolean).map(rel => join(root, rel))) if (underAny(path, untracked, root)) files[path] = 'new'
232    }
233    for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
234    dirty = { root, files }
235    await patch($, cur =>
236      cur.root === root
237        ? {
238            git: parsed.git,
239            ignored: parsed.ignored,
240            untrackedDirs: parsed.untrackedDirs,
241            branch: parsed.branch,
242            counts: rollCounts(files, root),
243            diff: rollUp(diff, root),
244          }
245        : {},
246    )
247  } catch {
248    return
249  }
250}
251
252function refreshGit($: EngineInterface): Promise<void> {
253  if (!gitRun) {
254    gitRun = readGit($).finally(() => {
255      gitRun = null
256    })
257    return gitRun
258  }
259  const rerun = () => {
260    gitNext = null
261    return refreshGit($)
262  }
263  gitNext ??= gitRun.then(rerun, rerun)
264  return gitNext
265}
266
267async function closedByPerson($: EngineInterface): Promise<boolean> {
268  return (await $.store.get(CLOSED)) === true
269}
270
271async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
272  const prev = await get($)
273  generation += 1
274  blink?.cancel()
275  blink = null
276  lastRoot = root
277  searchIndex = null
278  sizeEpoch += 1
279  sizeQueue = []
280  await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
281  const title = `Files: ${root.split('/').pop() || root}`
282  if (focus) await $.ui.open({ id: PANE, title, focus: true })
283  else if (!noDock && !(await closedByPerson($))) await $.ui.open({ id: PANE, title })
284  await loadDirs($, [root])
285  await detectRepo($)
286  await refreshGit($)
287}
288
289async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
290  const tried = new Set<string>()
291  for (let pass = 0; pass < 32; pass++) {
292    const t = await get($)
293    const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
294    if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
295    const need = new Set<string>()
296    for (const p of paths) {
297      for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
298        if (!loaded.has(dir)) {
299          if (!tried.has(dir)) need.add(dir)
300          break
301        }
302      }
303    }
304    if (need.size === 0) return
305    for (const dir of need) tried.add(dir)
306    await loadDirs($, [...need])
307  }
308}
309
310function pruneArgs(root: string, ignored: string[] = []): string[] {
311  const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
312  return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
313}
314
315async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
316  const stats = await Promise.all(
317    paths.map(async p => {
318      try {
319        return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
320      } catch {
321        return ''
322      }
323    }),
324  )
325  return stats.filter(Boolean)
326}
327
328function broad(root: string): boolean {
329  return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
330}
331
332function rootOfDisk(root: string): boolean {
333  return root === '/' || /^[A-Za-z]:\/$/.test(root)
334}
335
336async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
337  if (rootOfDisk(root)) return []
338  const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
339  try {
340    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 })
341    return run.stdout.split('\0').filter(p => p && p !== root)
342  } catch {
343    return []
344  }
345}
346
347async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
348  if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
349  if (dirty.root !== root) return { hits: [], gone: [] }
350  const entries = Object.entries(dirty.files)
351  const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
352  const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
353  return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
354}
355
356type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
357
358async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
359  const ms = await $.clock.now()
360  const os = await osName($)
361  if (os !== 'darwin' || !writes) return { ms, mark: '', os }
362  const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
363  try {
364    await $.process.run(['touch', mark], { timeoutMs: 3_000 })
365    return { ms, mark, os }
366  } catch {
367    return { ms, mark: '', os }
368  }
369}
370
371async function walkSize($: EngineInterface, dir: string): Promise<number> {
372  let total = 0
373  let seen = 0
374  let level = [dir]
375  while (level.length) {
376    const next: string[] = []
377    for (const d of level) {
378      let entries
379      try {
380        entries = await $.fs.list(d)
381      } catch {
382        continue
383      }
384      for (const e of entries) {
385        if (++seen > SIZE_WALK_LIMIT) return -1
386        if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
387        else if (e.kind === 'file') total += e.size
388      }
389    }
390    level = next
391  }
392  return total
393}
394
395async function dirSize($: EngineInterface, dir: string): Promise<number> {
396  if (rootOfDisk(dir)) return -1
397  if ((await osName($)) === 'win32') return walkSize($, dir)
398  try {
399    const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
400    const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
401    return Number.isFinite(kb) ? kb * 1024 : -1
402  } catch {
403    return -1
404  }
405}
406
407function pumpSizes($: EngineInterface): void {
408  while (sizeActive < SIZE_JOBS && sizeQueue.length) {
409    const dir = sizeQueue.shift() ?? ''
410    const epoch = sizeEpoch
411    sizing.add(dir)
412    sizeActive += 1
413    void dirSize($, dir)
414      .then(async bytes => {
415        if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
416        const cur = await get($)
417        if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
418      })
419      .catch(() => undefined)
420      .finally(() => {
421        sizing.delete(dir)
422        sizeActive -= 1
423        pumpSizes($)
424      })
425  }
426}
427
428function wantSizes($: EngineInterface, dirs: string[]): void {
429  for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
430  pumpSizes($)
431}
432
433async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
434  sizeEpoch += 1
435  sizeQueue = []
436  await patch($, cur => {
437    if (!paths) return { dirSizes: {} }
438    const keep: Record<string, number> = {}
439    for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
440    return { dirSizes: keep }
441  })
442}
443
444function openDirs(t: FileTree): string[] {
445  const open = new Set(t.expanded)
446  return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
447}
448
449function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
450  if (followClaude) return { scroll: null }
451  const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
452  const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
453  return at < 0 ? {} : { scroll: at }
454}
455
456async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
457  const unique = Object.keys(tones)
458  if (unique.length === 0) return
459  const mine = ++generation
460  const root = (await get($)).root
461  blink?.cancel()
462  blink = null
463  await patch($, cur => {
464    if (cur.root !== root) return {}
465    const open = new Set(cur.expanded)
466    const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
467    const dim = new Set<string>()
468    const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
469    if (cur.flashOn) {
470      for (const id of cur.flashDim) dim.add(id)
471    }
472    for (const id of unique) {
473      const tone = tones[id] ?? 'orange'
474      all[id] = tone
475      if (id === BRANCH_ROW) continue
476      const chain = ancestorsOf(id, cur.root)
477      if (!chain.some(a => !open.has(a))) continue
478      for (const a of chain) {
479        if (!open.has(a)) {
480          dim.add(a)
481          all[a] = all[a] ?? tone
482        }
483        open.add(a)
484      }
485    }
486    for (const id of bright) dim.delete(id)
487    return {
488      flash: [...bright],
489      flashDim: [...dim],
490      flashOn: true,
491      flashTones: all,
492      expanded: [...open],
493      ...keepTop(cur, [...open]),
494    }
495  })
496  if (generation !== mine) return
497  blink = $.clock.after(FLASH_MS, () => {
498    if (generation !== mine) return
499    blink = null
500    void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
501  })
502}
503
504async function followCwd($: EngineInterface): Promise<boolean> {
505  if (!follow) return false
506  const cwd = await cwdOf($)
507  const t = await get($)
508  if (t.root === cwd) return false
509  await reset($, cwd)
510  return true
511}
512
513type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
514type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
515type Background = { p: Pending | null; job: Job; reads: string[] }
516type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
517type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
518
519const PROOF: Record<string, (op: GitOperation) => boolean> = {
520  push: op => Boolean(op.push),
521  merge: op => op.branch?.action === 'merged',
522  rebase: op => op.branch?.action === 'rebased',
523  'pr create': op => op.pr?.action === 'created',
524  'pr merge': op => op.pr?.action === 'merged',
525  'pr comment': op => op.pr?.action === 'commented',
526}
527
528async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
529  const now = await $.clock.now()
530  const ids = actions.map(() => ++activityId)
531  await setActivities($, cur => [
532    ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
533    ...actions.map((a, i) => ({
534      id: ids[i] ?? 0,
535      kind: a.kind,
536      label: a.running,
537      state: 'running' as const,
538      detail: '',
539      at: now,
540      tone: a.tone,
541      nerd: a.icon.nerd,
542      plain: a.icon.plain,
543    })),
544  ])
545  return ids
546}
547
548async function headOf($: EngineInterface, cwd: string): Promise<string> {
549  try {
550    const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
551    return run.exitCode === 0 ? run.stdout.trim() : ''
552  } catch {
553    return ''
554  }
555}
556
557async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
558  const chain = chainOf(p.command)
559  const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
560  return p.actions.map(a => {
561    if (a.verb === 'commit') {
562      const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
563      return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
564    }
565    if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
566    if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
567    return { state: 'done', label: `ran ${a.kind}`, detail: '' }
568  })
569}
570
571async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
572  const now = await $.clock.now()
573  await setActivities($, cur =>
574    cur.map(a => {
575      const i = p.ids.indexOf(a.id)
576      const result = i < 0 ? undefined : results[i]
577      return result ? { ...a, ...result, at: now } : a
578    }),
579  )
580  $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
581}
582
583function failAll(p: Pending): Outcome[] {
584  return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
585}
586
587function proven(p: Pending | null, results: Outcome[]): GitAction[] {
588  return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
589}
590
591function taskEnds(text: string): [string, string][] {
592  return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
593    const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
594    const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
595    return id && status ? [[id, status] as [string, string]] : []
596  })
597}
598
599async function settleBackground($: EngineInterface, text: string): Promise<void> {
600  if (background.size === 0 || !text.includes('<task-notification>')) return
601  for (const [id, status] of taskEnds(text)) {
602    const task = background.get(id)
603    if (!task) continue
604    background.delete(id)
605    const ok = status === 'completed'
606    const results = task.p ? await outcomes($, task.p, ok, undefined) : []
607    if (task.p) await finishGit($, task.p, results)
608    if (showReads) queuedReads.push(...task.reads)
609    scheduleScan($, { ...task.job, actions: proven(task.p, results) })
610  }
611}
612
613async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
614  const map = mapper(root, prefix)
615  try {
616    const run = committed
617      ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
618      : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
619    return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
620  } catch {
621    return []
622  }
623}
624
625async function gitDir($: EngineInterface, cwd: string): Promise<string> {
626  for (let dir = cwd, i = 0; i < 64; i++) {
627    try {
628      const dot = join(dir, '.git')
629      const st = await $.fs.stat(dot)
630      if (st.kind === 'dir') return dot
631      const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
632      if (ref) return resolve(dir, ref, home)
633    } catch {
634      const up = dirname(dir)
635      if (up === dir) return ''
636      dir = up
637      continue
638    }
639    return ''
640  }
641  return ''
642}
643
644async function sync($: EngineInterface, force = false): Promise<void> {
645  const now = await $.clock.now()
646  if (!force && now - lastSync < 2_000) return
647  lastSync = now
648  searchIndex = null
649  const t = await get($)
650  if (!t.root) return
651  await loadDirs($, [t.root, ...openDirs(t)])
652  if (t.top) await refreshGit($)
653}
654
655async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
656  const t = await get($)
657  const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
658  const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
659  $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
660}
661
662async function exists($: EngineInterface, path: string): Promise<boolean> {
663  try {
664    await $.fs.stat(path)
665    return true
666  } catch {
667    return false
668  }
669}
670
671async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
672  const reads = queuedReads
673  queuedReads = []
674  if (await followCwd($)) return
675  const t = await get($)
676  if (!t.root) return
677  const actions = jobs.flatMap(j => j.actions)
678  const writers = jobs.filter(j => !j.readOnly)
679  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 })
680  const writes = !jobs.every(j => j.readOnly)
681  const before = dirty.root === t.root ? dirty.files : {}
682  if (jobs.some(j => j.initRepo)) await detectRepo($)
683  else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
684  const probed = await get($)
685  if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
686  if (writes) {
687    searchIndex = null
688    await staleSizes($)
689  }
690  const fresh = await get($)
691  const ignored = new Set(fresh.ignored)
692  const tones: Record<string, string> = {}
693  if (writes) {
694    const found = fresh.top
695      ? await changedInRepo($, t.root, since, before, fresh.ignored)
696      : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
697    const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
698    await revealPaths($, hits)
699    const loaded = await get($)
700    const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
701    const listed = await loadDirs($, dirs)
702    if (!fresh.top && since.os === 'win32')
703      for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
704    await patch($, cur => {
705      const ids = new Set(cur.nodes.map(n => n.id))
706      return { expanded: cur.expanded.filter(id => ids.has(id)) }
707    })
708    const present = new Set((await get($)).nodes.map(n => n.id))
709    const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
710    if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
711  }
712  if (showReads) {
713    const found: string[] = []
714    for (const r of reads) {
715      if (found.length >= READ_REVEAL_LIMIT) break
716      if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
717    }
718    if (found.length) {
719      await revealPaths($, found)
720      const shown = new Set((await get($)).nodes.map(n => n.id))
721      for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
722    }
723  }
724  if (showWrites && actions.length) {
725    const final = await get($)
726    const committed = actions.some(a => a.verb === 'commit')
727    if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
728      const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
729      await revealPaths($, paths)
730      const shown = new Set((await get($)).nodes.map(n => n.id))
731      for (const x of paths) if (shown.has(x)) tones[x] = 'green'
732    }
733    const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
734    if (pushLike) tones[BRANCH_ROW] = pushLike.tone
735    else if (committed) tones[BRANCH_ROW] = 'green'
736  }
737  await flash($, tones)
738}
739
740function scheduleScan($: EngineInterface, job: Job): void {
741  scanJobs.push(job)
742  if (scanning) return
743  scanning = (async () => {
744    while (scanJobs.length) {
745      const jobs = scanJobs
746      scanJobs = []
747      try {
748        await afterBash($, jobs)
749      } finally {
750        const marks = jobs.map(j => j.since.mark).filter(Boolean)
751        if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
752      }
753    }
754  })().finally(() => {
755    scanning = null
756  })
757}
758
759async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
760  if (await followCwd($)) return
761  const t = await get($)
762  if (!t.root) return
763  const within = paths.map(posix).filter(p => inside(t.root, p))
764  if (within.length === 0) return
765  if (tone !== 'purple') {
766    searchIndex = null
767    await staleSizes($, within)
768    await revealPaths($, within.map(dirname))
769    await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
770    await refreshGit($)
771  } else await revealPaths($, within)
772  if (!show) return
773  const tones: Record<string, string> = {}
774  for (const p of within) tones[p] = tone
775  await flash($, tones)
776}
777
778async function reveal($: EngineInterface, paths: string[]): Promise<void> {
779  if (await followCwd($)) return
780  await revealPaths($, paths)
781  await patch($, cur => {
782    const open = new Set(cur.expanded)
783    for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
784    return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
785  })
786}
787
788async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
789  const out: string[] = []
790  let level = [root]
791  for (let d = 0; d < depth && level.length && out.length < limit; d++) {
792    const next: string[] = []
793    for (const dir of level) {
794      for (const n of (await list($, dir)) ?? []) {
795        if (n.kind === 'dir') {
796          if (!PRUNE.includes(n.name)) next.push(n.id)
797        } else out.push(n.id)
798      }
799      if (out.length >= limit) break
800    }
801    level = next
802  }
803  return out
804}
805
806async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
807  try {
808    if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
809    const run = t.top
810      ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
811      : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
812    if (t.top && run.exitCode !== 0) return null
813    const paths = run.stdout.split('\0').filter(Boolean)
814    if (run.isStdoutTruncated) paths.pop()
815    return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
816  } catch {
817    return null
818  }
819}
820
821function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
822  if (searchIndex?.root !== t.root) {
823    const index: { root: string; paths: Promise<string[]> } = {
824      root: t.root,
825      paths: listAll($, t).then(paths => {
826        if (paths === null && searchIndex === index) searchIndex = null
827        return paths ?? []
828      }),
829    }
830    searchIndex = index
831  }
832  return searchIndex.paths
833}
834
835async function search($: EngineInterface, query: string): Promise<string[]> {
836  if (!(await get($)).query.trim()) searchIndex = null
837  await patch($, () => ({ query }))
838  const q = query.trim().toLowerCase()
839  if (!q) return []
840  const t = await get($)
841  const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
842  if ((await get($)).query !== query) return []
843  await revealPaths($, hits)
844  const shown = await get($)
845  const ids = new Set(shown.nodes.map(n => n.id))
846  const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
847  if (stale.length) await loadDirs($, stale)
848  return hits
849}
850
851async function jump($: EngineInterface, query: string): Promise<void> {
852  const hits = (await search($, query)).slice(0, 10)
853  await patch($, cur => {
854    const open = new Set(cur.expanded)
855    for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
856    return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
857  })
858}
859
860async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
861  try {
862    const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
863    return out === 'stale' || out === 'missing' ? out : 'ok'
864  } catch {
865    return 'missing'
866  }
867}
868
869async function finePointerOk($: EngineInterface): Promise<boolean> {
870  if (!(await $.env.get('HERDR_ENV'))) return true
871  try {
872    const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
873    const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
874    return major * 1e6 + minor * 1e3 + fix >= 9_001
875  } catch {
876    return false
877  }
878}
879
880async function openFile($: EngineInterface, path: string): Promise<void> {
881  const os = await osName($)
882  const { argv, init } = openCommand(os, path, os === 'win32' ? ((await $.env.get('PATHEXT')) ?? '') : '')
883  try {
884    const run = await $.process.run(argv, init)
885    if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
886  } catch {
887    $.ui.toast(`could not open ${path} with ${argv[0]}`)
888  }
889}
890
891function byPerson(origin: PromptOrigin): boolean {
892  return origin.kind === 'composer' || origin.kind === 'bridge' || (origin.kind === 'plugin' && origin.asUser === true)
893}
894
895async function toggle($: EngineInterface, n: FileNode): Promise<void> {
896  if (n.kind !== 'dir') offerSelection = true
897  if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
898  await patch($, t => {
899    const open = new Set(t.expanded)
900    if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
901    return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
902  })
903}
904
905async function press($: EngineInterface, n: FileNode): Promise<void> {
906  const now = await $.clock.now()
907  const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
908  lastPress = { key: isDouble ? '' : n.id, at: now }
909  if (!isDouble) {
910    await toggle($, n)
911    return
912  }
913  await openNode($, n)
914}
915
916async function openNode($: EngineInterface, n: FileNode): Promise<void> {
917  if (n.kind === 'dir') {
918    follow = false
919    await reset($, n.id)
920  } else await openFile($, n.id)
921}
922
923async function loadTheme($: EngineInterface): Promise<void> {
924  const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
925  try {
926    const stat = await $.fs.stat(path)
927    if (stat.kind !== 'file') throw new Error('no theme file')
928    if (stat.mtimeMs === themeMtime) return
929    await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
930    themeMtime = stat.mtimeMs
931  } catch {
932    if (themeMtime === 0) return
933    await $.state.set(THEME, DEFAULT_THEME)
934    themeMtime = 0
935  }
936}
937
938function shortPath(path: string): string {
939  return home && inside(home, path) ? `~${path.slice(home.length)}` : path
940}
941
942export const register: Register = (on, options) => {
943  glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
944  const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
945  showReads = activity.includes('reads')
946  showWrites = activity.includes('writes')
947  followClaude = options?.follow !== 'off'
948  sizeDefault = options?.column === 'size'
949  on('session.start', async ($, e, next) => {
950    await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
951    const windows = (await $.env.get('OS')) === 'Windows_NT'
952    useDrives(windows)
953    home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
954    activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
955    void (async () => {
956      pointer = await finePointerOk($)
957      try {
958        const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
959        noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
960      } catch {
961        noNerd = true
962      }
963      await loadTheme($)
964      themePoll?.cancel()
965      themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
966      const t = await get($)
967      if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
968      await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
969      const cwd = await cwdOf($)
970      if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
971      else if (!noDock && !(await closedByPerson($))) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
972    })()
973    return next(e)
974  })
975
976  on('command.run', { command: 'filetree' }, async ($, e) => {
977    if (!e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
978    if (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.' }
979    noDock = false
980    await $.store.delete(CLOSED)
981    const arg = (e.args ?? '').trim()
982    const cwd = await cwdOf($)
983    follow = !arg
984    const root = arg ? resolve(cwd, arg, home) : cwd
985    await reset($, root, true)
986    return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
987  })
988
989  on('ui.close', { id: PANE, origin: { kind: 'person' } }, async ($, e, next) => {
990    const result = await next(e)
991    await $.store.set(CLOSED, true)
992    return result
993  }).catch(($, e, next) => next(e))
994
995  on('tool.call', async ($, e, next) => {
996    if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
997    const shell = e.tool === 'Bash' || e.tool === 'PowerShell'
998    const command = shell ? e.command : ''
999    const cwd = shell ? await cwdOf($) : ''
1000    const actions = gitActions(command)
1001    const quiet = !actions.length && readOnly(command)
1002    const since = await sinceNow($, shell && !quiet)
1003    const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1004    const head = before?.top ? await headOf($, before.root) : ''
1005    const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1006    let result: Awaited<ReturnType<typeof next>>
1007    try {
1008      result = await next(e)
1009    } catch (err) {
1010      if (pending) await finishGit($, pending, failAll(pending))
1011      if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1012      throw err
1013    }
1014    if (pending && result.deny) {
1015      const ids = pending.ids
1016      void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1017    }
1018    if (result.deny || (result.isError && !shell)) {
1019      if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1020      return result
1021    }
1022    if (shell) {
1023      const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1024      const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1025      const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1026      if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1027      else void (async () => {
1028        const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1029        if (pending) await finishGit($, pending, results)
1030        queuedReads.push(...reads)
1031        scheduleScan($, { ...job, actions: proven(pending, results) })
1032      })()
1033      if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1034    } else {
1035      const file =
1036        'file_path' in e && typeof e.file_path === 'string'
1037          ? e.file_path
1038          : 'notebook_path' in e && typeof e.notebook_path === 'string'
1039            ? e.notebook_path
1040            : ''
1041      if (file) void touched($, [file], 'orange', showWrites)
1042    }
1043    return result
1044  })
1045
1046  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1047    const result = await next(e)
1048    if (result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1049    void touched($, [posix(e.file_path)], 'purple', true)
1050    return result
1051  })
1052
1053  on('ui.message', async ($, e, next) => {
1054    if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
1055    const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
1056    void sync($)
1057    const t = await get($)
1058    if (typeof data.copy === 'string') {
1059      await copyPath($, data.copy, Boolean(data.shift), e.surface)
1060      return {}
1061    }
1062    if (typeof data.scrollTo === 'number') {
1063      const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
1064      if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1065      return {}
1066    }
1067    if (typeof data.press === 'string') {
1068      const n = t.nodes.find(x => x.id === data.press)
1069      if (!n) return {}
1070      if (t.scroll === null) await patch($, () => ({ scroll: view.from }))
1071      if (data.ctrl || data.shift) await openNode($, n)
1072      else await press($, n)
1073      return {}
1074    }
1075    if (typeof data.key !== 'string') return {}
1076    const rows = visibleRows(t)
1077    const at = rows.findIndex(r => r.node.id === t.cursor)
1078    const cur = rows[at]?.node
1079    const move = (d: number) => {
1080      const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1081      return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1082    }
1083    if (data.key === 'up' || data.key === 'k') await move(-1)
1084    else if (data.key === 'down' || data.key === 'j') await move(1)
1085    else if (data.key === 'pageup') await move(-10)
1086    else if (data.key === 'pagedown') await move(10)
1087    else if (data.key === 'home') await move(-rows.length)
1088    else if (data.key === 'end') await move(rows.length)
1089    else if (cur && (data.key === 'y' || data.key === 'Y')) await copyPath($, cur.id, data.key === 'Y' || Boolean(data.shift), e.surface)
1090    else if (cur && (data.key === 'right' || data.key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1091    else if (cur && (data.key === 'left' || data.key === 'h')) {
1092      if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1093      else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1094    } else if (cur && (data.key === 'return' || data.key === ' ')) await toggle($, cur)
1095    return {}
1096  })
1097
1098  on('classic.SessionStart', async ($, e, next) => {
1099    const result = await next(e)
1100    if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
1101      void (async () => {
1102        await loadTheme($)
1103        const root = follow || !lastRoot ? await cwdOf($) : lastRoot
1104        const t = await get($)
1105        if (t.root !== root || t.nodes.length === 0) await reset($, root)
1106      })()
1107    }
1108    const gd = await gitDir($, posix(e.cwd))
1109    return gd ? { ...result, watchPaths: [...(result.watchPaths ?? []), join(gd, 'index'), join(gd, 'HEAD')] } : result
1110  })
1111
1112  on('classic.CwdChanged', async ($, e, next) => {
1113    const result = await next(e)
1114    void followCwd($)
1115    return result
1116  })
1117
1118  on('classic.FileChanged', async ($, e, next) => {
1119    const result = await next(e)
1120    if (/[\\/]\.git[\\/]|[\\/](index|HEAD)$/.test(e.file_path)) $.clock.after(300, () => void sync($, true))
1121    return result
1122  })
1123
1124  on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1125    const result = await next(e)
1126    void sync($)
1127    return result
1128  })
1129
1130  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
1131    const t = await get($)
1132    const to = Math.max(0, Math.min(view.max, (t.scroll ?? view.from) + Math.sign(e.by) * Math.max(3, Math.abs(e.by))))
1133    if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1134    return {}
1135  })
1136
1137  on('prompt.submit', async ($, e, next) => {
1138    void settleBackground($, e.text)
1139    if (!(await followCwd($))) void sync($)
1140    const t = await get($)
1141    const context = [...(e.context ?? [])]
1142    if (offerSelection && t.selected && byPerson(e.origin)) {
1143      offerSelection = false
1144      if (await exists($, t.selected)) context.push(`The user has this file selected in the file tree; "this" or "it" in the prompt likely refers to it: ${t.selected}`)
1145    }
1146    const mentions = [...e.text.matchAll(/@([^\s"'`]+)/g)]
1147      .map(m => (m[1] ?? '').replace(/[.,;:!?)]+$/, ''))
1148      .filter(Boolean)
1149      .map(p => resolve(t.root, p, home))
1150      .filter(p => inside(t.root, p))
1151    if (mentions.length) {
1152      void (async () => {
1153        const found: string[] = []
1154        for (const p of mentions) {
1155          try {
1156            await $.fs.stat(p)
1157            found.push(p.replace(/\/$/, ''))
1158          } catch {
1159            continue
1160          }
1161        }
1162        if (found.length) await reveal($, found)
1163      })()
1164    }
1165    return next(context.length ? { ...e, context } : e)
1166  })
1167
1168  on('prompt.attachment', async ($, e, next) => {
1169    void settleBackground($, e.text)
1170    return next(e)
1171  })
1172
1173  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1174    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
1175    if (e.surface === 'terminal' && e.props.placement === 'inline') {
1176      noDock = true
1177      void $.ui.close({ id: PANE }).catch(() => undefined)
1178      const { Box: Empty } = $.ui.resolve(e)
1179      return <Empty />
1180    }
1181    const unicode = glyphSetting === 'plain' || (glyphSetting === 'auto' && (noNerd || e.surface === 'desktop'))
1182    const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
1183    const t = await get($)
1184    const theme: Theme = (await $.state.get(THEME)).value ?? DEFAULT_THEME
1185    const now = await $.clock.now()
1186    const live = (await activities($)).filter(a => now - a.at < (a.state === 'running' ? RUNNING_MAX_MS : ACTIVITY_TTL_MS))
1187    const latest = [...live].reverse().find(a => a.state === 'running') ?? live[live.length - 1]
1188    const bright = new Set(t.flashOn ? t.flash : [])
1189    const dimmed = new Set(t.flashOn ? t.flashDim : [])
1190    const ignored = new Set(t.ignored)
1191    const untracked = new Set(t.untrackedDirs)
1192    const width = Math.max(24, e.props.bodyColumns)
1193    const rows = visibleRows(t)
1194    const fixed = 2 + (t.top ? (t.branch ? 1 : 0) : 1) + (t.selected || latest ? 1 : 0)
1195    const room = Math.max(5, (e.props.scroll?.bodyRows ?? 40) - fixed)
1196    const isLit = (id: string) => bright.has(id) || dimmed.has(id)
1197    const focus = followClaude && t.flashOn ? ([...t.flash].reverse().find(id => id !== BRANCH_ROW) ?? t.cursor) : t.cursor
1198    const at = Math.max(0, rows.findIndex(r => r.node.id === focus))
1199    const lit = followClaude && t.flashOn ? rows.findIndex(r => isLit(r.node.id)) : -1
1200    const cap = Math.max(1, Math.floor(room / 3))
hooks/open.ts 57 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// A file Windows' default action would run opens in Notepad (a script) or is shown in Explorer (the rest), never with that action.
27const WIN_EDIT = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = (Join-Path $env:SystemRoot 'System32\\notepad.exe'); Arguments = [char]34 + $env:PANE_OPEN_TARGET + [char]34; UseShellExecute = $true }) | Out-Null"
28const WIN_REVEAL = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = (Join-Path $env:SystemRoot 'explorer.exe'); Arguments = '/select,' + [char]34 + $env:PANE_OPEN_TARGET + [char]34; UseShellExecute = $true }) | Out-Null"
29const WIN_SCRIPTS = new Set(['bat', 'cmd', 'ps1', 'psm1', 'psd1', 'psc1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh', 'wsc', 'ws', 'sct', 'hta', 'reg', 'py', 'pyw', 'sh', 'bash', 'pl', 'rb', 'php', 'tcl', 'ahk', 'au3'])
30const WIN_PROGRAMS = new Set(['exe', 'com', 'scr', 'cpl', 'pif', 'msi', 'msp', 'mst', 'msc', 'msix', 'msixbundle', 'appx', 'appxbundle', 'appinstaller', 'application', 'appref-ms', 'gadget', 'lnk', 'url', 'website', 'scf', 'jar', 'pyc', 'pyo', 'pyz', 'chm', 'diagcab', 'xbap', 'xll', 'settingcontent-ms', 'library-ms', 'search-ms', 'searchconnector-ms'])
31const URL_TARGET = /^[a-z][a-z0-9+.-]*:\/\//i
32
33export function winOpener(target: string, pathext = ''): 'open' | 'edit' | 'reveal' {
34  if (URL_TARGET.test(target)) return 'open'
35  const name = (target.split(/[\\/]/).pop() ?? '').replace(/[. ]+$/, '')
36  const ext = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : ''
37  if (!ext) return 'open'
38  if (WIN_PROGRAMS.has(ext)) return 'reveal'
39  return WIN_SCRIPTS.has(ext) || pathext.toLowerCase().split(';').includes(`.${ext}`) ? 'edit' : 'open'
40}
41
42export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string, pathext = '') {
43  const how = os === 'win32' ? winOpener(target, pathext) : 'open'
44  const argv = os === 'linux'
45    ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
46    : os === 'darwin'
47      ? ['open', '--', target]
48      : ['powershell', '-NoProfile', '-NonInteractive', '-Command', how === 'edit' ? WIN_EDIT : how === 'reveal' ? WIN_REVEAL : WIN_OPEN]
49  return {
50    argv,
51    init: {
52      timeoutMs: 10_000,
53      ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: URL_TARGET.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
54    },
55  }
56}
57
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 143 lines
1import type { ClientModule } 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}
15type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
16
17const TICK_MS = 90
18const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20function shimmer(i: number, phase: number, len: number, palette: string[]): string {
21  const band = ((phase * 1.6) % (len + 8)) - 4
22  const d = Math.abs(i - band)
23  return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
24}
25
26const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
27  const { Box, Text } = surface.elements
28  let state = surface.state
29  if (state === undefined) {
30    state = { hover: -1, phase: 0, drag: false, ref: {} }
31    surface.setState(state)
32  }
33  const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
34  if (lit && !state.ref.stop) {
35    state.ref.stop = surface.every(TICK_MS, () => {
36      const cur = surface.state
37      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
38    })
39  } else if (!lit && state.ref.stop) {
40    state.ref.stop()
41    state.ref.stop = undefined
42  }
43  if (props.pointer === false) {
44    state.ref.unpoint?.()
45    state.ref.unpoint = undefined
46  } else state.ref.unpoint = surface.onPointer(e => {
47    const cur = surface.state ?? state
48    const span = Math.max(1, props.rows.length - 1)
49    const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
50    if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
51      if (e.type === 'up' || e.type === 'leave') {
52        surface.setState({ ...cur, drag: false })
53        return
54      }
55      if (e.type === 'down' || e.type === 'move') {
56        if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
57        surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
58        return
59      }
60    }
61    if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
62      if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
63      return
64    }
65    if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
66    const row = props.rows[e.y]
67    if (e.type === 'down' && e.button === 'right' && row?.id) {
68      surface.post({ copy: row.id, shift: Boolean(e.shift) })
69      return
70    }
71    if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
72    let x = 0
73    for (const seg of row.left) {
74      const w = [...seg.t].length
75      if (seg.tab && e.x >= x && e.x < x + w) {
76        surface.post({ tab: seg.tab })
77        return
78      }
79      x += w
80    }
81    if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
82  })
83  surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
84  const frames = props.spinner?.length ? props.spinner : FRAMES
85  const draw = (s: Seg) => {
86    if (s.spin) {
87      return (
88        <Text color={s.c} bold={s.b}>
89          {s.t + (frames[state.phase % frames.length] ?? '')}
90        </Text>
91      )
92    }
93    const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
94    if (!palette) {
95      return (
96        <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
97          {s.t}
98        </Text>
99      )
100    }
101    if (s.one) {
102      return (
103        <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
104          {s.t}
105        </Text>
106      )
107    }
108    const chars = [...s.t]
109    return (
110      <Text bold={s.b}>
111        {chars.map((ch, i) => (
112          <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
113        ))}
114      </Text>
115    )
116  }
117  return (
118    <Box flexDirection="column">
119      {props.rows.map((r, i) => (
120        <Box
121          flexDirection="row"
122          height={1}
123          overflow="hidden"
124          backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
125        >
126          <Box flexShrink={1} overflow="hidden">
127            {r.left.map(draw)}
128          </Box>
129          <Box flexGrow={1} />
130          {r.right.map(draw)}
131          {props.bar && (
132            <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
133              {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
134            </Text>
135          )}
136        </Box>
137      ))}
138    </Box>
139  )
140}
141
142export default Rows
143
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 331 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  }
43}
44
45export function parseTheme(toml: string): Theme {
46  const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
47  return {
48    fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
49    accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
50    muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
51    urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
52    selection: get('selection') ?? DEFAULT_THEME.selection,
53    bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
54  }
55}
56
57export function join(dir: string, name: string): string {
58  return dir.endsWith('/') ? dir + name : `${dir}/${name}`
59}
60
61let drives = false
62
63export function dirname(path: string): string {
64  if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
65  const i = path.lastIndexOf('/')
66  return i <= 0 ? '/' : path.slice(0, i)
67}
68
69export function useDrives(on: boolean): void {
70  drives = on
71}
72
73export function drivesOn(): boolean {
74  return drives
75}
76
77export function posix(path: string): string {
78  if (!drives) return path
79  return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
80}
81
82export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
83  if (dirs.length === 0) return nodes
84  const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
85  const gone = new Set(dirs)
86  return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
87}
88
89export function isAbsolute(path: string): boolean {
90  return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
91}
92
93export function inside(root: string, path: string): boolean {
94  return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
95}
96
97export function toNodes(dir: string, list: Entry[]): FileNode[] {
98  return list
99    .map(e => ({
100      id: join(dir, e.name),
101      parent: dir,
102      name: e.name,
103      kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
104      hidden: e.name.startsWith('.'),
105      mtime: e.mtimeMs,
106      size: e.size,
107      loaded: false,
108    }))
109    .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
110}
111
112export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
113  if (listed.size === 0) return nodes
114  const keep = new Map<string, FileNode>()
115  for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
116  const freshIds = new Set<string>()
117  for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
118  const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
119  const under = (id: string) => {
120    if (gone.size === 0) return false
121    for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
122    return false
123  }
124  const fresh: FileNode[] = []
125  for (const kids of listed.values()) {
126    for (const k of kids) {
127      if (under(k.id)) continue
128      const old = keep.get(k.id)
129      fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
130    }
131  }
132  const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
133  return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
134}
135
136export type GitStatus = {
137  git: Record<string, string>
138  ignored: string[]
139  untrackedDirs: string[]
140  branch: Branch | null
141  files: Record<string, Change>
142}
143
144export type Change = 'new' | 'mod' | 'del'
145
146export function parseBranch(record: string): Branch {
147  const body = record.replace(/^## /, '')
148  if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
149  const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
150  const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
151  const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
152  const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
153  return { head, upstream, ahead, behind }
154}
155
156export function mapper(root: string, prefix: string): (rel: string) => string | null {
157  return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
158}
159
160export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
161  const map = mapper(root, prefix)
162  const git: Record<string, string> = {}
163  const ignored: string[] = []
164  const untrackedDirs: string[] = []
165  const files: Record<string, Change> = {}
166  let branch: Branch | null = null
167  const parts = stdout.split('\0')
168  for (let i = 0; i < parts.length; i++) {
169    const rec = parts[i] ?? ''
170    if (rec.startsWith('## ')) {
171      branch = parseBranch(rec)
172      continue
173    }
174    if (rec.length < 4) continue
175    const xy = rec.slice(0, 2)
176    const raw = rec.slice(3)
177    if (xy[0] === 'R' || xy[0] === 'C') i++
178    const path = map(raw)
179    if (!path) continue
180    if (xy === '!!') {
181      ignored.push(path)
182      continue
183    }
184    if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
185    else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
186    const letter =
187      xy === '??'
188        ? '?'
189        : /U/.test(xy) || xy === 'AA' || xy === 'DD'
190          ? 'U'
191          : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
192    if (!letter) continue
193    git[path] = stronger(git[path], letter)
194    let dir = dirname(path)
195    while (dir.length >= root.length && dir !== '/') {
196      git[dir] = stronger(git[dir], letter)
197      if (dir === root) break
198      dir = dirname(dir)
199    }
200  }
201  return { git, ignored, untrackedDirs, branch, files }
202}
203
204export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
205  const map = mapper(root, prefix)
206  const parts = stdout.split('\0')
207  for (let i = 0; i < parts.length; i++) {
208    const rec = parts[i] ?? ''
209    if (!rec) continue
210    const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
211    if (!m) continue
212    let path = m[3] ?? ''
213    if (path === '') {
214      i += 2
215      path = parts[i] ?? ''
216    }
217    if (!path) continue
218    const add = m[1] === '-' ? 0 : Number(m[1])
219    const del = m[2] === '-' ? 0 : Number(m[2])
220    const abs = map(path)
221    if (!abs) continue
222    const prev = into[abs] ?? [0, 0]
223    into[abs] = [prev[0] + add, prev[1] + del]
224  }
225}
226
227export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
228  const out: Record<string, [number, number]> = { ...diff }
229  for (const [path, [add, del]] of Object.entries(diff)) {
230    let dir = dirname(path)
231    while (dir.length >= root.length && dir !== '/') {
232      const prev = out[dir] ?? [0, 0]
233      out[dir] = [prev[0] + add, prev[1] + del]
234      if (dir === root) break
235      dir = dirname(dir)
236    }
237  }
238  return out
239}
240
241export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
242  const out: Record<string, [number, number, number]> = {}
243  const slot = { new: 0, mod: 1, del: 2 } as const
244  for (const [path, change] of Object.entries(files)) {
245    let dir = dirname(path)
246    while (dir.length >= root.length && dir !== '/') {
247      const cur = out[dir] ?? [0, 0, 0]
248      cur[slot[change]] += 1
249      out[dir] = cur
250      if (dir === root) break
251      dir = dirname(dir)
252    }
253  }
254  return out
255}
256
257export function underAny(id: string, set: Set<string>, root: string): boolean {
258  if (set.size === 0) return false
259  let cur = id
260  while (cur.length >= root.length) {
261    if (set.has(cur)) return true
262    const up = dirname(cur)
263    if (up === cur) break
264    cur = up
265  }
266  return false
267}
268
269export function ancestorsOf(id: string, root: string): string[] {
270  const out: string[] = []
271  if (!root) return out
272  for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
273  return out
274}
275
276export function relative(root: string, path: string): string {
277  return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
278}
279
280export function visibleRows(t: FileTree): Row[] {
281  const kids = new Map<string, FileNode[]>()
282  for (const n of t.nodes) {
283    if (!t.showHidden && n.hidden) continue
284    const list = kids.get(n.parent)
285    if (list) list.push(n)
286    else kids.set(n.parent, [n])
287  }
288  const q = t.query.trim().toLowerCase()
289  let keep: Set<string> | null = null
290  if (q) {
291    keep = new Set()
292    for (const n of t.nodes) {
293      if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
294      keep.add(n.id)
295      for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
296    }
297  }
298  const open = new Set(t.expanded)
299  const rows: Row[] = []
300  const walk = (dir: string, depth: number) => {
301    for (const n of kids.get(dir) ?? []) {
302      if (keep && !keep.has(n.id)) continue
303      const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
304      rows.push({ node: n, depth, open: isOpen })
305      if (isOpen) walk(n.id, depth + 1)
306    }
307  }
308  walk(t.root, 0)
309  return rows
310}
311
312export function stamp(ms: number): string {
313  if (!ms) return ''
314  const d = new Date(ms)
315  const p = (v: number) => String(v).padStart(2, '0')
316  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
317}
318
319export function formatSize(bytes: number): string {
320  if (bytes < 0) return '?'
321  if (bytes < 1024) return `${bytes} B`
322  const units = ['K', 'M', 'G', 'T']
323  let v = bytes / 1024
324  let i = 0
325  while (v >= 1024 && i < units.length - 1) {
326    v /= 1024
327    i++
328  }
329  return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
330}
331
types/index.d.ts 74 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}
55
56export type Theme = {
57  fg: string
58  accent: string
59  muted: string
60  urgent: string
61  selection: string
62  bg: string
63}
64
65declare module 'claude-code' {
66  interface PluginState {
67    filetree: {
68      tree: FileTree
69      theme: Theme
70      activity: Activity[]
71    }
72  }
73}
74