SLOPSHOPPER

filetree

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

newpaneguardcommandtoastprompt
v0.2.24MITupdated 2026-10-22whykothari/whystack/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 in the terminal needs the fullscreen layout (/tui fullscreen) and a terminal at least 110 columns wide; the Desktop app docks it on its own, and /filetree reopens it there after you close it. Tested on Linux, macOS and Windows; mods don't load in WSL sessions of the Desktop app.


Installation

In a Claude Code terminal session:

/plugin install filetree --marketplace whykothari/whystack

See the mods README to run it from a local copy.

Features

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

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

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

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

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

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

  • File and folder sizes: the Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writes
  • Double-click a file to open it in its default app
  • Click to select, arrow keys to move through the tree
  • Light on large repos: outside a repo it only checks once whether one exists, and every git call is scoped to the cwd
  • Nerd Font icons with a plain Unicode fallback
  • On Omarchy, the pane takes its colors and background from the current theme and follows theme switches

Resizing the pane

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

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

Settings

All settings are in /config under filetree.

  • Claude activity: what shimmers: reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.
  • Follow Claude: on (default) scrolls the tree to what Claude reads, writes or commits; off keeps the view where you put it, and highlights still show.
  • Right column: date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.
  • Open with: empty (default) opens a double-clicked file in its default app. preview shows it in the pane instead: Markdown rendered, code highlighted, arrow keys, Page Up/Down and the wheel to scroll, ←/h or ← files back to the tree, ↗ the default app. Otherwise a command that double-click and Enter run instead: {path} is the file and {dir} its folder, and without {path} the path goes last. It runs without a shell, so quote a word that holds spaces, and it should start the viewer and return rather than wait for it. Examples: zed {path}, code -g {path}, or in herdr a tab running herdr-file-viewer on the file: herdr plugin pane open --plugin herdr-file-viewer --entrypoint file-viewer --placement tab --focus --env HERDR_FILE_VIEWER_OPEN={path}.
  • Glyphs: auto (default) uses Nerd Font icons when a Nerd Font is installed and your terminal started after it was installed, plain Unicode in the desktop app, and Nerd Font over SSH. nerd or plain forces one.

herdr

Clicking rows needs herdr 0.9.1 or later. herdr 0.9.0 and older accept pixel mouse reporting but still send cell positions, which would put every click in the session in the wrong place, so on those versions the rows ignore the mouse and the rest of the pane and session keep working. Run herdr update to get row clicks.

Checks

Run these from the mods folder before each commit that touches the mod:

claude plugin validate filetree
claude plugin test filetree

License

MIT

Credits

Based on Claude Code Filetree by Kurt Buhler, MIT licence. It drew on his Omarchy app FileBlade. Outside contributions to the original are in ACKNOWLEDGEMENTS.md.

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