SLOPSHOPPER

filetree

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

paneguardcommandtoastpromptprocess
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▣ client module ./button.tsx ▣ client mod │ ┃  no git repo · git status starts after git ⏺ Read(src/auth.ts) │ ┃ init ⎿ Read 6 lines │ ┃ / : search ⏎ jump ⏺ Update(src/auth.ts) │ ┃ j: ↓ k: ↑ h: ← l: → o: open s: search ⎿ Added 2 lines, removed 1 line │ ┃ ▣ client module ./rows.tsx ⏺ Bash(bun test) │ ┃ 󰕒 pushed ⎿ 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▣ client module ./button.tsx ▣ client module ./button.tsx  no git repo · git status starts after git init / : search ⏎ jump j: ↓ k: ↑ h: ← l: → o: open s: search ▣ 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.26-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">

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

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


Installation

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

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

Or inside a Claude Code session:

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

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

Install fails with Unrecognized key: "options"? Your Claude Code is older than 2.1.287: run claude update, then install again.

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
  • On macOS, Space on a file shows it in Quick Look
  • Click to select, arrow keys or j/k/h/l to move through the tree
  • Hover a header button to see what it does
  • 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
  • A light /config theme in the terminal gets its own palette, with every text color at WCAG AAA contrast (7:1 or more), also on the selected and hovered rows

Keyboard navigation

With the filetree pane focused, use j/k to move down/up, l to expand a folder, and h to collapse it or move to its parent. o or Enter opens a file or toggles a folder. Press s to focus search, type a query, then Enter to jump to the first match and return focus to the tree controls.

Resizing the pane

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

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

Settings

All settings are in /config under filetree.

  • Claude activity: what shimmers: reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.
  • Follow Claude: on (default) scrolls the tree to what Claude reads, writes or commits; off keeps the view where you put it, and highlights still show.
  • Open at start: on (default) opens the pane when a session starts; off keeps it closed until you run /filetree.
  • Right column: date (default), age or size; what the right column shows when a session starts. age shows how long ago a file changed in a few characters: now, 12m, 3h, 5d, then the day (Oct 9) past a week and the year past a year. The Σ button in the header toggles between sizes and the dates or ages.
  • Hints: on (default) shows a line under the header outside a git repo saying git status starts after git init; off hides it.
  • Open with: empty (default) opens files in their default app on double-click or Enter, including inside tmux. editor opts into $VISUAL, then $EDITOR, then vi in tmux on Linux/macOS: nvim reuses a running nvim in the same tmux server and session; otherwise the editor opens in a new window in the file's folder. Outside a supported tmux environment it shows a notice instead of opening the file: a mod cannot hand Claude Code's terminal to another program, so a terminal editor needs a tmux window to run in. For a GUI editor, or outside tmux, use a command instead, such as code -g {path} or one that opens your terminal's new window. Editor commands can include arguments; quote an executable path or argument that contains spaces, for example EDITOR='"/opt/My Editor/nvim" -u NONE'. Arguments apply when starting a new editor; reusing nvim keeps that instance's configuration. preview shows the file in the pane instead: Markdown rendered, code highlighted, arrow keys, Page Up/Down and the wheel to scroll, ←/h or ← files back to the tree, ↗ always the default app. Otherwise a command that double-click and Enter run instead: {path} is the file and {dir} its folder, and without {path} the path goes last. It runs without a shell, so quote a word that holds spaces, and it should start the viewer and return rather than wait for it. Examples: zed {path}, code -g {path}, or in herdr a tab running herdr-file-viewer on the file: herdr plugin pane open --plugin herdr-file-viewer --entrypoint file-viewer --placement tab --focus --env HERDR_FILE_VIEWER_OPEN={path}.
  • Glyphs: auto (default) uses Nerd Font icons when a Nerd Font is installed and your terminal started after it was installed, plain Unicode in the desktop app, and Nerd Font over SSH. nerd or plain forces one.

herdr

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

Contributing

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

git config core.hooksPath .githooks

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

License

MIT

This project is inspired by my Omarchy app FileBlade

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