SLOPSHOPPER

filetree

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

newpaneguardcommandtoastprompt
v0.3.2MITupdated 2026-10-20woolleymusic/claude-mods/mods/filetree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree
│ ┃ Files: app ✕ › fix the failing auth test and add an audit log call │ ┃ app▣ client module ./button.tsx ▣ client mod │ ┃  no git repo · git status starts after git ⏺ Read(src/auth.ts) │ ┃ init ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ / : search ⏎ jump ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ j: 󰁅 k: 󰁝 h: 󰁍 l: 󰁔 o: 󰏋 open s: 󰍉 sear ⎿ 3 pass, 1 fail │ ┃ ▣ client module ./rows.tsx │ ┃ 󰕒 pushed ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /filetree │ ┃ ⎿ filetree: File tree on /work/app (follows the cwd). │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files: app
app▣ client module ./button.tsx ▣ client module ./button.tsx  no git repo · git status starts after git init / : search ⏎ jump j: 󰁅 k: 󰁝 h: 󰁍 l: 󰁔 o: 󰏋 open s: 󰍉 search ▣ client module ./rows.tsx 󰕒 pushed
README

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

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

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

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

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


Installation

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

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

Or inside a Claude Code session:

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

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

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

Features

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

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

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

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

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

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

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

<img src="media/filetree-diff.gif" alt="Pressing d on a file and a folder shows their diffs in the pane, and j/k jump between changed files" width="800">

Keyboard navigation

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

Resizing the pane

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

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

Settings

All settings are in /config under filetree.

  • Claude activity: what shimmers: reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.
  • Follow Claude: on (default) scrolls the tree to what Claude reads, writes or commits; off keeps the view where you put it, and highlights still show.
  • Open at start: on (default) opens the pane when a session starts; off keeps it closed until you run /filetree.
  • Right column: date (default), age or size; what the right column shows when a session starts. age shows how long ago a file changed in a few characters: now, 12m, 3h, 5d, then the day (Oct 9) past a week and the year past a year. The Σ button in the header toggles between sizes and the dates or ages.
  • Hints: on (default) shows a line under the header outside a git repo saying git status starts after git init; off hides it.
  • Search spacing: on (default) puts a blank line above and below the search bar; off gives the tree those two rows.
  • Shortcut row: on (default) shows the j/k/h/l/open/search row under the search bar; off hides it and the keys still work with the tree focused.
  • Open with: empty (default) opens files in their default app on double-click or Enter, including inside tmux. editor opts into $VISUAL, then $EDITOR, then vi in tmux or herdr on Linux/macOS. In tmux, nvim reuses a running nvim in the same tmux server and session; otherwise the editor opens in a new window in the file's folder. In herdr without tmux, the editor opens in a new pane to the right of Claude's pane, in the file's folder, and that pane closes when the editor exits; the file name reaches the editor through the pane's environment, not through its command line, so names with spaces or shell characters are safe. Outside tmux and herdr it shows a notice instead of opening the file: a mod cannot hand Claude Code's terminal to another program, so a terminal editor needs a tmux window or a herdr pane to run in. For a GUI editor, or outside both, use a command instead, such as code -g {path} or one that opens your terminal's new window. Editor commands can include arguments; quote an executable path or argument that contains spaces, for example EDITOR='"/opt/My Editor/nvim" -u NONE'. Arguments apply when starting a new editor; reusing nvim keeps that instance's configuration. preview shows the file in the pane instead: Markdown rendered, code highlighted, j/k or the arrow keys, f/b, Page Up/Down or Ctrl+F/B for a page, and the wheel to scroll, ←/h or ← files back to the tree, ↗ always the default app, e the editor from Edit with at the top line shown, which the button names (edit :42). 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}.
  • Edit with: what e does in the preview: it opens the file at the top line shown. Empty (default) uses $VISUAL, then $EDITOR, then vi in tmux or herdr on Linux/macOS, the same way as Open with: editor, passing the line as +N; elsewhere the e button is hidden. Otherwise a command like Open with, where {line} is that line: code -g {path}:{line} or zed {path}:{line}.
  • Diff with: what d does on a changed file, in the tree or the preview. Empty (default) shows its diff against HEAD in the pane, drawn by Claude Code's own diff view; an untracked file shows as all added, and d again goes back to the file. On a folder it shows every changed file under it, each under its name; j/k jump to the next or previous file (or hunk, for one file), the arrows scroll by line and f/b by page. Otherwise a terminal command run on the file in a tmux window, a herdr pane or, on Linux, a new terminal window through xdg-terminal-exec, with the file path last: hunk diff --, lazygit -f, git difftool -y -- or nvim -c Gvdiffsplit.
  • Palette: your own colors, key=#hex pairs separated by spaces, laid over the Omarchy, light or default theme. A key you leave out keeps the theme's color, so accent=#83a598 alone recolors only folders. Keys: fg, bg, accent (folders), muted (dates and sizes), urgent (deleted files and conflicts), selection (the selected row), hover (the hovered row), added, modified and renamed (git status), add and del (line counts). An entry it cannot read is skipped and named in a notice at session start. Gruvbox Dark Hard, from #13 by @Mourey: fg=#ebdbb2 bg=#1d2021 accent=#83a598 muted=#928374 urgent=#fb4934 selection=#3c3836 added=#b8bb26 modified=#fabd2f renamed=#83a598 add=#b8bb26 del=#fb4934
  • 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.

With Open with: editor, or e in the preview, the editor opens in a new pane to the right of Claude's pane and that pane closes when you quit the editor; see Settings.

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