SLOPSHOPPER

filetree

IDE-style file tree that shows only the files Claude touched (fork of data-goblin/claude-code-filetree 0.2.26)

newpaneguardcommandtoastprompt
v0.3.2MITupdated 2026-10-23jz-wilson/claude-mods/filetree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree
│ ┃ Files: app ✕ › fix the failing auth test and add an audit log call │ ┃ app▣ client module ./button.tsx ▣ client mod │ ┃  no git repo · git status starts after git… ⏺ Read(src/auth.ts) │ ┃ / : search ⏎ jump ⎿ Read 6 lines │ ┃ j: 󰁅 k: 󰁝 h: 󰁍 l: 󰁔 o: 󰏋 open s: 󰍉 sear ⏺ Update(src/auth.ts) │ ┃ ▣ client module ./rows.tsx ⎿ Added 2 lines, removed 1 line │ ┃ click the pane to focus it · /filetree [pat… ⏺ 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 click the pane to focus it · /filetree [path] retargets it 󰕒 pushed
README

filetree

Fork of data-goblin/claude-code-filetree 0.2.26 (commit 4f04e60), MIT. Everything upstream does is unchanged; see its README for the full feature list.

What the fork adds: the tree shows only the files Claude touched this session (read, written, or returned by a Grep or Glob search) and the folders above them. Files Claude has not touched stay hidden.

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