SLOPSHOPPER

filetree-collapsible

Local fork of filetree (data-goblin/claude-code-filetree 0.2.25) with a button that collapses the dock to a strip on the right and expands it again

newpanebandguardcommandtoast
v0.2.25-collapsible.1MITupdated 2026-10-14MagnusPladsen/claude-mods/filetree-collapsible
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree-collapsible
│ ┃ Files: app ✕ › fix the failing auth test and add an audit log call │ ┃ app▣ client module ./button.tsx ▣ client mod │ ┃  no git repo · git status starts after git ⏺ Read(src/auth.ts) │ ┃ init ⎿ Read 6 lines │ ┃ / : search ⏎ jump ⏺ Update(src/auth.ts) │ ┃ ▣ client module ./rows.tsx ⎿ Added 2 lines, removed 1 line │ ┃ 󰕒 pushed ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /filetree │ ┃ ⎿ filetree-collapsible: 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 ▣ client module ./rows.tsx 󰕒 pushed
README

Local fork (filetree-collapsible) of data-goblin/claude-code-filetree 0.2.25. Adds a » header button that collapses the dock to a thin strip on the right; « at the top of the strip (or /filetree) expands it again at the width it had. Where the dock keeps a width you dragged it to, the pane closes instead and a « Files button waits at the right end of the band above the prompt. Disable the original (filetree@claude-code-filetree) while this one is on, or both trees show.

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

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

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

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

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


Installation

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

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

Or inside a Claude Code session:

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

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

Features

  • Interactive file tree for the working directory where you're using Claude Code; it follows the cwd, or /filetree <path> pins another folder
  • Search the file tree, including folders you have not opened yet
  • Git status per file and folder in color, with exact lines changed (+N -N) on modified files and ?:N M:N D:N file counts on folders
  • Branch, upstream and ahead/behind in the header
  • Worktree switcher: in a repo with more than one worktree, the ⑂ header button lists them (git worktree list); picking one pins the tree to it like /filetree <path>, and follow the cwd goes back
  • The dock remembers being collapsed: a new session starts as a strip if the last one left it collapsed, and « brings it back at the saved width
  • Visual indicator of Claude reads and searches (purple), writes (orange) and commits (green); collapsed folders open to show the file

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

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

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

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

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

  • File and folder sizes: the Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writes
  • Double-click a file to open it in its default app
  • On macOS, Space on a file shows it in Quick Look
  • Click to select, arrow keys to move through the tree
  • Hover a header button to see what it does
  • Light on large repos: outside a repo it only checks once whether one exists, and every git call is scoped to the cwd
  • Nerd Font icons with a plain Unicode fallback
  • On Omarchy, the pane takes its colors and background from the current theme and follows theme switches
  • A light /config theme in the terminal gets its own palette, with every text color at WCAG AAA contrast (7:1 or more), also on the selected and hovered rows

Resizing the pane

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

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

Settings

All settings are in /config under filetree.

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

herdr

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

Contributing

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

git config core.hooksPath .githooks

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

License

MIT

This project is inspired by my Omarchy app FileBlade

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