SLOPSHOPPER

pulse-filetree

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

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

Draws

Pane · Files: app
app  Git status starts after git init ▣ client module ./button.tsx · ▣ client module ./button.tsx / : search files ⏎ jump ▣ client module ./rows.tsx 󰕒 pushed
README

pulse-filetree

A Claude Code mod that adds a file tree pane to the right sidebar. It shows git status per file and folder, and it lights up the files Claude reads, writes and commits while it works.

  • Tree of the working folder: folders first, natural sort. Each file shows a small coloured badge for its type (TS, JS, {}, M↓) and each folder a block in a colour for its kind (green src, blue docs, teal tests, red dist). With a Nerd Font in the terminal the Nerd Font icons show instead, in the same colours. Git-ignored files and folders fade. Indent guides show how deep each row sits, and the guide of the folder holding the cursor is drawn brighter. Long names are cut in the middle so the start and the extension stay readable. It follows the session cwd, or stays on a folder you pick with /filetree <path>.
  • Colours: without an Omarchy theme file the pane uses your Claude Code theme colours, so it fits a light or a dark theme. With an Omarchy theme it uses that theme's colours.
  • Git status: each file is coloured by its status, and modified files show +N -N lines changed. Folders keep the text colour, show a dot in their status colour, and show file counts such as ?1 M2 D1. A branch row shows the branch, upstream, ahead and behind. The repo totals (or "clean") sit beside the title in the desktop app and on the branch row in the terminal.
  • Claude activity: files Claude reads or searches shimmer purple, files it writes shimmer orange, staged and committed files shimmer green. Closed folders open to show the file. After the shimmer, a mark stays in the gutter beside the file and fades out over about 90 seconds.
  • Git footer: when Claude runs a git or gh command that changes something, the footer shows it as running, then done (with the short commit hash) or failed.
  • Search: the search field filters the tree by path, including folders you have not opened yet.
  • Selection as context: the file you select is named to Claude with each prompt, and @path mentions in a prompt reveal that file in the tree.
  • Sizes and dates: the right column shows the modified date, the age or the size, lined up down the tree. Dates read like Oct 12 09:30, with the year in place of the time for older years. Narrow panes show a shorter date, and a row with too little room for its name drops the date. Folder sizes are worked out in the background for the rows on screen.
  • Empty states: the tree says "Loading files…", "Searching…", "No files match", "This folder is empty", "Only dotfiles here" or "Could not read this folder" when there are no rows to show.

Slash command

/filetree [path] opens the pane and focuses it. Without a path it shows the cwd and follows it. With a path (relative, absolute or ~/...) it shows that folder and stops following the cwd. In the terminal it needs the fullscreen layout (/tui fullscreen) and at least 110 columns; it replies with what to fix when either is missing.

In the pane

  • Click a row: select a file, or open and close a folder. Double-click a file: open it (see "Open with"). Double-click a folder: show that folder as the tree root. Ctrl-click or Shift-click opens at once.
  • Right-click a row copies its path relative to the root. Shift+right-click copies the absolute path.
  • Wheel scrolls the tree. The scrollbar on the right edge can be clicked or dragged.
  • Keys: Up/k and Down/j move, PageUp/PageDown move 10 rows, Home/End jump to the ends. Right/l opens a folder, Left/h closes it or moves to the parent. Enter opens a file or toggles a folder. Space shows a file in Quick Look on macOS (on a folder, or on other systems, it acts like a click). y copies the relative path, Y the absolute path.
  • Search field: typing filters the tree. Enter ("jump") opens the folders of the first 10 hits, moves the cursor to the first one and clears the field. "Clear search" clears it ("clear" in the terminal).
  • Buttons say what they do, and hovering one shows a short hint. In the desktop app, "Go up" and "Reload" sit beside the search field. The next row has "Hide dotfiles" or "Show dotfiles", "Show sizes" (or "Show dates" or "Show ages" while sizes show) and "Collapse all". "Follow cwd" shows only while the tree is not following the cwd. "Clear selection" sits in the footer beside "selected:". In the terminal the same buttons use short words on one row that wraps: go up, reload, show dotfiles, show sizes, collapse, follow cwd, and unselect in the footer.
  • Preview (with "Open with" set to preview): Markdown is rendered and code is highlighted. Arrow keys, j/k, Page keys, Home/End and the wheel scroll it. Left/h or "Close preview" goes back, and "Open in default app" opens the file in the default app. In the terminal these buttons read ← files and ↗ open. Files over 4 MB and binary files show a note instead.

Settings

All settings are in /config under this plugin.

SettingDefaultOptionsMeaning
activityreads and writesreads and writes, writes, reads, noneWhich Claude activity shimmers in the tree.
glyphsautoauto, nerd, plainauto uses Nerd Font icons when one is found (always over SSH), and coloured type badges and folders in the desktop app or without a Nerd Font. nerd always uses Nerd Font icons. plain always uses the badges.
followonon, offon scrolls the tree to what Claude touches. off keeps your scroll position; highlights still show.
startonon, offon opens the pane when a session starts. off waits for /filetree.
columndatedate, age, sizeWhat the right column shows at start. The sizes button switches between size and date or age.
hintsonon, offShows a line outside a git repo: "Git status starts after git init".
openemptyfree textEmpty opens files in the default app. preview shows them in the pane. Any other text is a command run without a shell, with {path} and {dir} filled in (the path goes last when {path} is missing).

What it touches

  • Events hooked: session.start, command.run (for /filetree), tool.call (after Edit, Write, NotebookEdit, Bash, PowerShell, Read, Grep and Glob finish), prompt.submit, prompt.attachment, ui.render, ui.message, ui.focus and ui.scroll for its own pane, and the classic SessionStart, CwdChanged and FileChanged events. Tool calls run as usual; the mod reads their input and result afterwards.
  • Tools registered: none. It registers one slash command, /filetree.
  • Prompt change: when a file is selected and still exists, each prompt gets one extra context line naming its full path and saying "this" or "it" likely refers to it. Nothing else is added.
  • Watched files: on SessionStart it asks Claude Code to watch the repo's .git/index and .git/HEAD, and reloads the tree when they change.
  • Files read: directory listings and file stats under the tree root, the file shown in preview, a .git file when looking for the git folder, and ~/.local/state/omarchy/current/theme/colors.toml for Omarchy theme colours (polled every 2 seconds while it exists).
  • Files written: on macOS, before a shell command that may write, it creates an empty marker file $TMPDIR/filetree-<session id>-<n>.mark with touch and deletes it with rm -f after the scan. Its own tree, theme and activity state goes to Claude Code's plugin state store.
  • Timers: the theme poll above, a redraw once a minute while the column shows ages, one timer that ends each shimmer, one timer that redraws when a gutter mark fades or ends (30 and 90 seconds after the touch), one that clears finished git footer entries after 45 seconds, and short delays before reloading after a cd or a .git change. They only redraw, reload or tidy the pane.
  • Clipboard: copies a path when you right-click or press y/Y.
  • Environment read: OS, HOME, USERPROFILE, TMPDIR, SSH_CONNECTION, SSH_TTY, HERDR_ENV, HERDR_BIN_PATH.
  • Processes it runs:
  • git --no-optional-locks -C <root> with rev-parse, status, diff HEAD --numstat, diff --cached --name-only, diff-tree, ls-files. These only read the repo.
  • find to list files changed since a shell command started and to build the search list outside a repo; du -skxH for folder sizes.
  • uname -s once, and a sh -c font check that uses fc-list, ls, stat, ps, date and awk. herdr --version only inside herdr.
  • Opening a file: open on macOS, gio, xdg-open, xdg-mime and file through sh and setsid on Linux, powershell on Windows, or your "Open with" command. Space on macOS runs qlmanage -p and an osascript call that brings Quick Look to the front.
  • Network: the mod makes no network requests. It reads the git and gh commands Claude runs to label them, and runs none of them itself.

Install

claude plugin marketplace add MrValraven/pulse-mods
claude plugin install pulse-filetree@pulse-mods --scope user

Then start a new Claude Code session.

Credits

Based on data-goblin/claude-code-filetree by Kurt Buhler, MIT licence. See LICENSE.

Source 9 files
hooks/register.tsx 1809 lines
1import { type BuiltinToolResults, type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { LINUX_LAUNCH, openCommand, openWithCommand } from './open'
3import type { ButtonProps } from './button'
4
5import type { Activity, FileNode, FileTree, Theme } from '../types'
6import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve } from './git'
7import type { RowSpec, RowsProps, Seg } from './rows'
8import { CHEVRON_CLOSED, CHEVRON_OPEN, fileBadge, fileIcon, folderInk, onInkFor } from './icons'
9import { buildPalette, dateLabel, gitInk, inkOnPane, mix, type Palette, RECENT_MS, RECENT_SOFT_MS, recentLevel, truncateMiddle, widthOf } from './look'
10import {
11  age,
12  ancestorsOf,
13  type Change,
14  DEFAULT_THEME,
15  dirname,
16  emptyTree,
17  formatSize,
18  inside,
19  isAbsolute,
20  join,
21  mapper,
22  parseGit,
23  parseNumstat,
24  parseTheme,
25  posix,
26  relative,
27  dropBelow,
28  useDrives,
29  rollCounts,
30  replaceChildren,
31  rollUp,
32  toNodes,
33  underAny,
34  visibleRows,
35} from './tree'
36
37const TREE = { plugin: 'pulse-filetree', key: 'tree' } as const
38const THEME = { plugin: 'pulse-filetree', key: 'theme' } as const
39const ACTIVITY = { plugin: 'pulse-filetree', key: 'activity' } as const
40const PANE = 'filetree'
41const BRANCH_ROW = '#branch'
42const FLASH_MS = 2700
43const RUNNING_MAX_MS = 600_000
44const NO_REPO_DEPTH = 4
45const DOUBLE_MS = 450
46const FIND_LIMIT = 200
47const ROWS_BUDGET = 80_000
48const READ_REVEAL_LIMIT = 12
49const SEARCH_REVEAL_LIMIT = 60
50const ACTIVITY_TTL_MS = 45_000
51const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
52const THEME_POLL_MS = 2000
53const FONT_SCRIPT =
54  'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
55  'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
56  '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
57  'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
58  'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
59  'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
60  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
61const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
62const HOME_PRUNE = ['Library', 'AppData', '.Trash']
63const SIZE_TIMEOUT_MS = 15_000
64const SIZE_JOBS = 2
65const SIZE_WALK_LIMIT = 50_000
66const AGE_TICK_MS = 60_000
67const NARROW_COLUMNS = 48
68const VERY_NARROW_COLUMNS = 34
69
70let blink: Timer | null = null
71let themePoll: Timer | null = null
72let ageTick: Timer | null = null
73let themeMtime: number | null = null
74let generation = 0
75let lastPress = { key: '', at: 0 }
76let noNerd = false
77let glyphSetting = 'auto'
78let openWithSetting = ''
79let previewed: { path: string; text: string; note: string } | null = null
80let previewRows = 20
81let follow = true
82let followClaude = true
83let scanning: Promise<void> | null = null
84let scanJobs: Job[] = []
85let gitRun: Promise<void> | null = null
86let gitNext: Promise<void> | null = null
87let queuedReads: string[] = []
88let showReads = true
89let showWrites = true
90let searchIndex: { root: string; paths: Promise<string[]> } | null = null
91let activityId = 0
92let pointer = true
93let hovered = ''
94let view = { from: 0, max: 0 }
95let nativeScroll = false
96let lastSync = 0
97let noDock = false
98let home = ''
99let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
100let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
101let markId = 0
102let listSeq = 0
103let lastRoot = ''
104let sizeDefault = false
105let showAge = false
106let showHints = true
107let sizeEpoch = 0
108let sizeActive = 0
109let sizeQueue: string[] = []
110const sizing = new Set<string>()
111const listLatest = new Map<string, number>()
112const unreadable = new Set<string>()
113const background = new Map<string, Background>()
114// Files Claude touched lately, so a quiet mark stays beside them after the shimmer ends.
115const recentTouches = new Map<string, { tone: string; at: number }>()
116// Roots whose first listing came back, so an empty tree can say loading or empty.
117const listedRoots = new Set<string>()
118// One timer redraws the tree when the next recent mark softens or ends.
119let recentTimer: Timer | null = null
120// The search still running, so an empty tree can say it is searching while it runs.
121let pendingQuery = ''
122
123function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
124  platform ??= (async () => {
125    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
126    try {
127      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
128    } catch {
129      return 'linux'
130    }
131  })()
132  return platform
133}
134
135async function cwdOf($: EngineInterface): Promise<string> {
136  return posix(await $.session.cwd())
137}
138
139function clean(segs: Seg[]): Seg[] {
140  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
141  return segs
142}
143
144async function get($: EngineInterface): Promise<FileTree> {
145  return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
146}
147
148async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
149  await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
150}
151
152function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
153  return put($, t => ({ ...t, ...fn(t) }))
154}
155
156async function activities($: EngineInterface): Promise<Activity[]> {
157  return (await $.state.get(ACTIVITY)).value ?? []
158}
159
160async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
161  await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
162}
163
164async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
165  try {
166    const entries = await $.fs.list(dir)
167    const resolved = await Promise.all(
168      entries.map(async e => {
169        if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
170        try {
171          const target = await $.fs.stat(join(dir, e.name))
172          return target.kind === 'dir'
173            ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
174            : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
175        } catch {
176          return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
177        }
178      }),
179    )
180    unreadable.delete(dir)
181    return toNodes(dir, resolved)
182  } catch (err) {
183    if (!(await exists($, dir))) return []
184    if (!unreadable.has(dir)) {
185      unreadable.add(dir)
186      $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
187    }
188    return null
189  }
190}
191
192async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
193  const root = (await get($)).root
194  const seq = ++listSeq
195  for (const dir of dirs) listLatest.set(dir, seq)
196  const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
197  const listed = new Map<string, FileNode[]>()
198  for (const [dir, kids] of results) {
199    if (listLatest.get(dir) !== seq) continue
200    listLatest.delete(dir)
201    if (kids) listed.set(dir, kids)
202  }
203  if (listed.has(root)) listedRoots.add(root)
204  await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
205  return listed
206}
207
208async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
209  return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
210}
211
212async function detectRepo($: EngineInterface): Promise<void> {
213  const t = await get($)
214  if (!t.root) return
215  let top = ''
216  let prefix = ''
217  try {
218    const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
219    const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
220    prefix = p.trim()
221    top = tl.trim()
222  } catch {
223    top = ''
224  }
225  await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
226}
227
228async function readGit($: EngineInterface): Promise<void> {
229  const t = await get($)
230  if (!t.root || !t.top) return
231  const root = t.root
232  try {
233    const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
234    if (status.exitCode !== 0) return
235    const parsed = parseGit(status.stdout, root, t.prefix)
236    const diff: Record<string, [number, number]> = {}
237    const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
238    if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
239    const files = { ...parsed.files }
240    if (parsed.untrackedDirs.length) {
241      const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
242      if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
243    }
244    for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
245    dirty = { root, files }
246    await patch($, cur =>
247      cur.root === root
248        ? {
249            git: parsed.git,
250            ignored: parsed.ignored,
251            untrackedDirs: parsed.untrackedDirs,
252            branch: parsed.branch,
253            counts: rollCounts(files, root),
254            diff: rollUp(diff, root),
255          }
256        : {},
257    )
258  } catch {
259    return
260  }
261}
262
263function refreshGit($: EngineInterface): Promise<void> {
264  if (!gitRun) {
265    gitRun = readGit($).finally(() => {
266      gitRun = null
267    })
268    return gitRun
269  }
270  const rerun = () => {
271    gitNext = null
272    return refreshGit($)
273  }
274  gitNext ??= gitRun.then(rerun, rerun)
275  return gitNext
276}
277
278async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
279  const prev = await get($)
280  generation += 1
281  blink?.cancel()
282  blink = null
283  lastRoot = root
284  searchIndex = null
285  sizeEpoch += 1
286  sizeQueue = []
287  recentTouches.clear()
288  recentTimer?.cancel()
289  recentTimer = null
290  listedRoots.delete(root)
291  await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
292  const title = `Files: ${root.split('/').pop() || root}`
293  if (focus) await $.ui.open({ id: PANE, title, focus: true })
294  else if (!noDock) await $.ui.open({ id: PANE, title })
295  await loadDirs($, [root])
296  await detectRepo($)
297  await refreshGit($)
298}
299
300async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
301  const tried = new Set<string>()
302  for (let pass = 0; pass < 32; pass++) {
303    const t = await get($)
304    const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
305    if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
306    const need = new Set<string>()
307    for (const p of paths) {
308      for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
309        if (!loaded.has(dir)) {
310          if (!tried.has(dir)) need.add(dir)
311          break
312        }
313      }
314    }
315    if (need.size === 0) return
316    for (const dir of need) tried.add(dir)
317    await loadDirs($, [...need])
318  }
319}
320
321function pruneArgs(root: string, ignored: string[] = []): string[] {
322  const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
323  return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
324}
325
326async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
327  const stats = await Promise.all(
328    paths.map(async p => {
329      try {
330        return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
331      } catch {
332        return ''
333      }
334    }),
335  )
336  return stats.filter(Boolean)
337}
338
339function broad(root: string): boolean {
340  return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
341}
342
343function rootOfDisk(root: string): boolean {
344  return root === '/' || /^[A-Za-z]:\/$/.test(root)
345}
346
347async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
348  if (rootOfDisk(root)) return []
349  const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
350  try {
351    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 })
352    return run.stdout.split('\0').filter(p => p && p !== root)
353  } catch {
354    return []
355  }
356}
357
358async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
359  if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
360  if (dirty.root !== root) return { hits: [], gone: [] }
361  const entries = Object.entries(dirty.files)
362  const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
363  const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
364  return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
365}
366
367type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
368
369async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
370  const ms = await $.clock.now()
371  const os = await osName($)
372  if (os !== 'darwin' || !writes) return { ms, mark: '', os }
373  const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
374  try {
375    await $.process.run(['touch', mark], { timeoutMs: 3_000 })
376    return { ms, mark, os }
377  } catch {
378    return { ms, mark: '', os }
379  }
380}
381
382async function walkSize($: EngineInterface, dir: string): Promise<number> {
383  let total = 0
384  let seen = 0
385  let level = [dir]
386  while (level.length) {
387    const next: string[] = []
388    for (const d of level) {
389      let entries
390      try {
391        entries = await $.fs.list(d)
392      } catch {
393        continue
394      }
395      for (const e of entries) {
396        if (++seen > SIZE_WALK_LIMIT) return -1
397        if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
398        else if (e.kind === 'file') total += e.size
399      }
400    }
401    level = next
402  }
403  return total
404}
405
406async function dirSize($: EngineInterface, dir: string): Promise<number> {
407  if (rootOfDisk(dir)) return -1
408  if ((await osName($)) === 'win32') return walkSize($, dir)
409  try {
410    const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
411    const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
412    return Number.isFinite(kb) ? kb * 1024 : -1
413  } catch {
414    return -1
415  }
416}
417
418function pumpSizes($: EngineInterface): void {
419  while (sizeActive < SIZE_JOBS && sizeQueue.length) {
420    const dir = sizeQueue.shift() ?? ''
421    const epoch = sizeEpoch
422    sizing.add(dir)
423    sizeActive += 1
424    void dirSize($, dir)
425      .then(async bytes => {
426        if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
427        const cur = await get($)
428        if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
429      })
430      .catch(() => undefined)
431      .finally(() => {
432        sizing.delete(dir)
433        sizeActive -= 1
434        pumpSizes($)
435      })
436  }
437}
438
439function wantSizes($: EngineInterface, dirs: string[]): void {
440  for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
441  pumpSizes($)
442}
443
444async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
445  sizeEpoch += 1
446  sizeQueue = []
447  await patch($, cur => {
448    if (!paths) return { dirSizes: {} }
449    const keep: Record<string, number> = {}
450    for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
451    return { dirSizes: keep }
452  })
453}
454
455function openDirs(t: FileTree): string[] {
456  const open = new Set(t.expanded)
457  return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
458}
459
460function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
461  if (followClaude) return { scroll: null }
462  const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
463  const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
464  return at < 0 ? {} : { scroll: at }
465}
466
467async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
468  const unique = Object.keys(tones)
469  if (unique.length === 0) return
470  const mine = ++generation
471  const root = (await get($)).root
472  blink?.cancel()
473  blink = null
474  await patch($, cur => {
475    if (cur.root !== root) return {}
476    const open = new Set(cur.expanded)
477    const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
478    const dim = new Set<string>()
479    const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
480    if (cur.flashOn) {
481      for (const id of cur.flashDim) dim.add(id)
482    }
483    for (const id of unique) {
484      const tone = tones[id] ?? 'orange'
485      all[id] = tone
486      if (id === BRANCH_ROW) continue
487      const chain = ancestorsOf(id, cur.root)
488      if (!chain.some(a => !open.has(a))) continue
489      for (const a of chain) {
490        if (!open.has(a)) {
491          dim.add(a)
492          all[a] = all[a] ?? tone
493        }
494        open.add(a)
495      }
496    }
497    for (const id of bright) dim.delete(id)
498    return {
499      flash: [...bright],
500      flashDim: [...dim],
501      flashOn: true,
502      flashTones: all,
503      expanded: [...open],
504      ...keepTop(cur, [...open]),
505    }
506  })
507  const touchedAt = await $.clock.now()
508  for (const id of unique) if (id !== BRANCH_ROW) recentTouches.set(id, { tone: tones[id] ?? 'orange', at: touchedAt })
509  scheduleRecent($, touchedAt)
510  if (generation !== mine) return
511  blink = $.clock.after(FLASH_MS, () => {
512    if (generation !== mine) return
513    blink = null
514    void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
515  })
516}
517
518// Sets the one timer for the next moment a recent mark softens or ends, redraws then and
519// looks again; marks past their time are dropped on the way.
520function scheduleRecent($: EngineInterface, now: number): void {
521  recentTimer?.cancel()
522  recentTimer = null
523  let next = Infinity
524  for (const [id, touch] of recentTouches) {
525    if (now - touch.at >= RECENT_MS) {
526      recentTouches.delete(id)
527      continue
528    }
529    for (const edge of [touch.at + RECENT_SOFT_MS, touch.at + RECENT_MS]) if (edge > now && edge < next) next = edge
530  }
531  if (next === Infinity) return
532  recentTimer = $.clock.after(next - now + 50, () => {
533    recentTimer = null
534    void $.ui.invalidate('ui.render')
535    void $.clock.now().then(later => scheduleRecent($, later))
536  })
537}
538
539async function followCwd($: EngineInterface): Promise<boolean> {
540  if (!follow) return false
541  const cwd = await cwdOf($)
542  const t = await get($)
543  if (t.root === cwd) return false
544  await reset($, cwd)
545  return true
546}
547
548type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
549type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
550type Background = { p: Pending | null; job: Job; reads: string[] }
551type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
552type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
553
554const PROOF: Record<string, (op: GitOperation) => boolean> = {
555  push: op => Boolean(op.push),
556  merge: op => op.branch?.action === 'merged',
557  rebase: op => op.branch?.action === 'rebased',
558  'pr create': op => op.pr?.action === 'created',
559  'pr merge': op => op.pr?.action === 'merged',
560  'pr comment': op => op.pr?.action === 'commented',
561}
562
563async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
564  const now = await $.clock.now()
565  const ids = actions.map(() => ++activityId)
566  await setActivities($, cur => [
567    ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
568    ...actions.map((a, i) => ({
569      id: ids[i] ?? 0,
570      kind: a.kind,
571      label: a.running,
572      state: 'running' as const,
573      detail: '',
574      at: now,
575      tone: a.tone,
576      nerd: a.icon.nerd,
577      plain: a.icon.plain,
578    })),
579  ])
580  return ids
581}
582
583async function headOf($: EngineInterface, cwd: string): Promise<string> {
584  try {
585    const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
586    return run.exitCode === 0 ? run.stdout.trim() : ''
587  } catch {
588    return ''
589  }
590}
591
592async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
593  const chain = chainOf(p.command)
594  const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
595  return p.actions.map(a => {
596    if (a.verb === 'commit') {
597      const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
598      return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
599    }
600    if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
601    if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
602    return { state: 'done', label: `ran ${a.kind}`, detail: '' }
603  })
604}
605
606async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
607  const now = await $.clock.now()
608  await setActivities($, cur =>
609    cur.map(a => {
610      const i = p.ids.indexOf(a.id)
611      const result = i < 0 ? undefined : results[i]
612      return result ? { ...a, ...result, at: now } : a
613    }),
614  )
615  $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
616}
617
618function failAll(p: Pending): Outcome[] {
619  return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
620}
621
622function proven(p: Pending | null, results: Outcome[]): GitAction[] {
623  return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
624}
625
626function taskEnds(text: string): [string, string][] {
627  return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
628    const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
629    const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
630    return id && status ? [[id, status] as [string, string]] : []
631  })
632}
633
634async function settleBackground($: EngineInterface, text: string): Promise<void> {
635  if (background.size === 0 || !text.includes('<task-notification>')) return
636  for (const [id, status] of taskEnds(text)) {
637    const task = background.get(id)
638    if (!task) continue
639    background.delete(id)
640    const ok = status === 'completed'
641    const results = task.p ? await outcomes($, task.p, ok, undefined) : []
642    if (task.p) await finishGit($, task.p, results)
643    if (showReads) queuedReads.push(...task.reads)
644    scheduleScan($, { ...task.job, actions: proven(task.p, results) })
645  }
646}
647
648async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
649  const map = mapper(root, prefix)
650  try {
651    const run = committed
652      ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
653      : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
654    return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
655  } catch {
656    return []
657  }
658}
659
660async function gitDir($: EngineInterface, cwd: string): Promise<string> {
661  for (let dir = cwd, i = 0; i < 64; i++) {
662    try {
663      const dot = join(dir, '.git')
664      const st = await $.fs.stat(dot)
665      if (st.kind === 'dir') return dot
666      const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
667      if (ref) return resolve(dir, ref, home)
668    } catch {
669      const up = dirname(dir)
670      if (up === dir) return ''
671      dir = up
672      continue
673    }
674    return ''
675  }
676  return ''
677}
678
679async function sync($: EngineInterface, force = false): Promise<void> {
680  const now = await $.clock.now()
681  if (!force && now - lastSync < 2_000) return
682  lastSync = now
683  searchIndex = null
684  const t = await get($)
685  if (!t.root) return
686  await loadDirs($, [t.root, ...openDirs(t)])
687  if (t.top) await refreshGit($)
688}
689
690async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
691  const t = await get($)
692  const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
693  const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
694  $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
695}
696
697async function exists($: EngineInterface, path: string): Promise<boolean> {
698  try {
699    await $.fs.stat(path)
700    return true
701  } catch {
702    return false
703  }
704}
705
706async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
707  const reads = queuedReads
708  queuedReads = []
709  if (await followCwd($)) return
710  const t = await get($)
711  if (!t.root) return
712  const actions = jobs.flatMap(j => j.actions)
713  const writers = jobs.filter(j => !j.readOnly)
714  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 })
715  const writes = !jobs.every(j => j.readOnly)
716  const before = dirty.root === t.root ? dirty.files : {}
717  if (jobs.some(j => j.initRepo)) await detectRepo($)
718  else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
719  const probed = await get($)
720  if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
721  if (writes) {
722    searchIndex = null
723    await staleSizes($)
724  }
725  const fresh = await get($)
726  const ignored = new Set(fresh.ignored)
727  const tones: Record<string, string> = {}
728  if (writes) {
729    const found = fresh.top
730      ? await changedInRepo($, t.root, since, before, fresh.ignored)
731      : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
732    const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
733    await revealPaths($, hits)
734    const loaded = await get($)
735    const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
736    const listed = await loadDirs($, dirs)
737    if (!fresh.top && since.os === 'win32')
738      for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
739    await patch($, cur => {
740      const ids = new Set(cur.nodes.map(n => n.id))
741      return { expanded: cur.expanded.filter(id => ids.has(id)) }
742    })
743    const present = new Set((await get($)).nodes.map(n => n.id))
744    const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
745    if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
746  }
747  if (showReads) {
748    const found: string[] = []
749    for (const r of reads) {
750      if (found.length >= READ_REVEAL_LIMIT) break
751      if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
752    }
753    if (found.length) {
754      await revealPaths($, found)
755      const shown = new Set((await get($)).nodes.map(n => n.id))
756      for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
757    }
758  }
759  if (showWrites && actions.length) {
760    const final = await get($)
761    const committed = actions.some(a => a.verb === 'commit')
762    if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
763      const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
764      await revealPaths($, paths)
765      const shown = new Set((await get($)).nodes.map(n => n.id))
766      for (const x of paths) if (shown.has(x)) tones[x] = 'green'
767    }
768    const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
769    if (pushLike) tones[BRANCH_ROW] = pushLike.tone
770    else if (committed) tones[BRANCH_ROW] = 'green'
771  }
772  await flash($, tones)
773}
774
775function scheduleScan($: EngineInterface, job: Job): void {
776  scanJobs.push(job)
777  if (scanning) return
778  scanning = (async () => {
779    while (scanJobs.length) {
780      const jobs = scanJobs
781      scanJobs = []
782      try {
783        await afterBash($, jobs)
784      } finally {
785        const marks = jobs.map(j => j.since.mark).filter(Boolean)
786        if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
787      }
788    }
789  })().finally(() => {
790    scanning = null
791  })
792}
793
794async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
795  if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
796  if (await followCwd($)) return
797  const t = await get($)
798  if (!t.root) return
799  const within = paths.map(posix).filter(p => inside(t.root, p))
800  if (within.length === 0) return
801  if (tone !== 'purple') {
802    searchIndex = null
803    await staleSizes($, within)
804    await revealPaths($, within.map(dirname))
805    await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
806    await refreshGit($)
807  } else await revealPaths($, within)
808  if (!show) return
809  const tones: Record<string, string> = {}
810  for (const p of within) tones[p] = tone
811  await flash($, tones)
812}
813
814async function reveal($: EngineInterface, paths: string[]): Promise<void> {
815  if (await followCwd($)) return
816  await revealPaths($, paths)
817  await patch($, cur => {
818    const open = new Set(cur.expanded)
819    for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
820    return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
821  })
822}
823
824async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
825  const out: string[] = []
826  let level = [root]
827  for (let d = 0; d < depth && level.length && out.length < limit; d++) {
828    const next: string[] = []
829    for (const dir of level) {
830      for (const n of (await list($, dir)) ?? []) {
831        if (n.kind === 'dir') {
832          if (!PRUNE.includes(n.name)) next.push(n.id)
833        } else out.push(n.id)
834      }
835      if (out.length >= limit) break
836    }
837    level = next
838  }
839  return out
840}
841
842async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
843  try {
844    if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
845    const run = t.top
846      ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
847      : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
848    if (t.top && run.exitCode !== 0) return null
849    const paths = run.stdout.split('\0').filter(Boolean)
850    if (run.isStdoutTruncated) paths.pop()
851    return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
852  } catch {
853    return null
854  }
855}
856
857function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
858  if (searchIndex?.root !== t.root) {
859    const index: { root: string; paths: Promise<string[]> } = {
860      root: t.root,
861      paths: listAll($, t).then(paths => {
862        if (paths === null && searchIndex === index) searchIndex = null
863        return paths ?? []
864      }),
865    }
866    searchIndex = index
867  }
868  return searchIndex.paths
869}
870
871async function search($: EngineInterface, query: string): Promise<string[]> {
872  if (!(await get($)).query.trim()) searchIndex = null
873  await patch($, () => ({ query }))
874  const q = query.trim().toLowerCase()
875  if (!q) return []
876  pendingQuery = query
877  try {
878    const t = await get($)
879    const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
880    if ((await get($)).query !== query) return []
881    await revealPaths($, hits)
882    const shown = await get($)
883    const ids = new Set(shown.nodes.map(n => n.id))
884    const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
885    if (stale.length) await loadDirs($, stale)
886    return hits
887  } finally {
888    if (pendingQuery === query) {
889      pendingQuery = ''
890      void $.ui.invalidate('ui.render')
891    }
892  }
893}
894
895async function jump($: EngineInterface, query: string): Promise<void> {
896  const hits = (await search($, query)).slice(0, 10)
897  await patch($, cur => {
898    const open = new Set(cur.expanded)
899    for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
900    return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
901  })
902}
903
904async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
905  try {
906    const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
907    return out === 'stale' || out === 'missing' ? out : 'ok'
908  } catch {
909    return 'missing'
910  }
911}
912
913async function finePointerOk($: EngineInterface): Promise<boolean> {
914  if (!(await $.env.get('HERDR_ENV'))) return true
915  try {
916    const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
917    const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
918    return major * 1e6 + minor * 1e3 + fix >= 9_001
919  } catch {
920    return false
921  }
922}
923
924const PREVIEW_CHARS = 90_000
925const PREVIEW_LINE = 2_000
926
927function fitSerialized(lines: string[], max: number): string[] {
928  let size = 0
929  for (let i = 0; i < lines.length; i++) {
930    size += JSON.stringify(lines[i]).length
931    if (size > max) return lines.slice(0, i)
932  }
933  return lines
934}
935
936async function loadPreview($: EngineInterface, path: string): Promise<void> {
937  let text = ''
938  let note = ''
939  try {
940    const stat = await $.fs.stat(path)
941    if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
942    else {
943      text = await $.fs.read(path)
944      if (text.slice(0, 8000).includes('\0')) {
945        text = ''
946        note = `binary file, ${formatSize(stat.size)}`
947      }
948    }
949  } catch (err) {
950    note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
951  }
952  const total = text.length
953  let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
954  if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
955  lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
956  const fit = fitSerialized(lines, PREVIEW_CHARS)
957  if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
958  previewed = { path, text: fit.join('\n'), note }
959}
960
961async function showPreview($: EngineInterface, path: string): Promise<void> {
962  await loadPreview($, path)
963  await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
964}
965
966async function closePreview($: EngineInterface): Promise<void> {
967  await patch($, () => ({ preview: '', previewTop: 0 }))
968}
969
970async function scrollPreview($: EngineInterface, by: number): Promise<void> {
971  const t = await get($)
972  const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
973  const top = Math.max(0, Math.min(last, t.previewTop + by))
974  if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
975}
976
977function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
978  const lines = text.split('\n')
979  const from = Math.max(0, Math.min(top, lines.length - 1))
980  let shown = lines.slice(from, from + rows)
981  if (markdown) {
982    // Reopen a code fence the window starts inside, so the rest is not read as prose.
983    let fence = ''
984    let opener = ''
985    for (const line of lines.slice(0, from)) {
986      const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
987      if (!m) continue
988      const mark = m[1] ?? ''
989      if (!fence) {
990        fence = mark
991        opener = line.trimStart()
992      } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
993    }
994    if (fence) shown = [opener, ...shown]
995  }
996  return { text: shown.join('\n'), top: from }
997}
998
999async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
1000  if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
1001  const os = await osName($)
1002  const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
1003  const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
1004  const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
1005  try {
1006    const run = await $.process.run(argv, init)
1007    if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1008  } catch {
1009    $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
1010  }
1011}
1012
1013async function quickLook($: EngineInterface, path: string): Promise<void> {
1014  try {
1015    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 })
1016  } catch {
1017    $.ui.toast(`could not preview ${path} with Quick Look`)
1018  }
1019}
1020
1021async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1022  if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1023  await patch($, t => {
1024    const open = new Set(t.expanded)
1025    if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1026    return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1027  })
1028}
1029
1030async function press($: EngineInterface, n: FileNode): Promise<void> {
1031  const now = await $.clock.now()
1032  const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1033  lastPress = { key: isDouble ? '' : n.id, at: now }
1034  if (!isDouble) {
1035    await toggle($, n)
1036    return
1037  }
1038  await openNode($, n)
1039}
1040
1041async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1042  if (n.kind === 'dir') {
1043    follow = false
1044    await reset($, n.id)
1045  } else await openFile($, n.id)
1046}
1047
1048async function loadTheme($: EngineInterface): Promise<void> {
1049  const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
1050  try {
1051    const stat = await $.fs.stat(path)
1052    if (stat.kind !== 'file') throw new Error('no theme file')
1053    if (stat.mtimeMs === themeMtime) return
1054    await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1055    themeMtime = stat.mtimeMs
1056  } catch {
1057    if (themeMtime === 0) return
1058    await $.state.set(THEME, DEFAULT_THEME)
1059    themeMtime = 0
1060  }
1061}
1062
1063async function pressHeader($: EngineInterface, id: string): Promise<void> {
1064  const t = await get($)
1065  if (id === 'up') {
1066    follow = false
1067    await reset($, dirname(t.root))
1068  } else if (id === 'cwd') {
1069    follow = true
1070    await reset($, await cwdOf($))
1071  } else if (id === 'refresh') {
1072    searchIndex = null
1073    await staleSizes($)
1074    await loadDirs($, [t.root, ...t.expanded])
1075    await detectRepo($)
1076    await refreshGit($)
1077    if (t.query.trim()) await search($, t.query)
1078  } else if (id === 'hidden') await patch($, cur => ({ showHidden: !cur.showHidden }))
1079  else if (id === 'size') await patch($, cur => ({ showSize: !cur.showSize }))
1080  else if (id === 'collapse') await patch($, cur => ({ expanded: [], nodes: dropBelow(cur.nodes, cur.nodes.filter(x => x.parent === cur.root && x.kind === 'dir').map(x => x.id)) }))
1081  else if (id === 'unselect') await patch($, () => ({ selected: '' }))
1082  else if (id === 'external' && t.preview) await openFile($, t.preview, true)
1083}
1084
1085function shortPath(path: string): string {
1086  return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1087}
1088
1089export const register: Register = (on, options) => {
1090  glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
1091  const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
1092  showReads = activity.includes('reads')
1093  showWrites = activity.includes('writes')
1094  followClaude = options?.follow !== 'off'
1095  sizeDefault = options?.column === 'size'
1096  showAge = options?.column === 'age'
1097  showHints = options?.hints !== 'off'
1098  openWithSetting = typeof options?.open === 'string' ? options.open : ''
1099  noDock = options?.start === 'off'
1100  on('session.start', async ($, e, next) => {
1101    await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
1102    const windows = (await $.env.get('OS')) === 'Windows_NT'
1103    useDrives(windows)
1104    home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1105    activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1106    void (async () => {
1107      pointer = await finePointerOk($)
1108      try {
1109        const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1110        noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
1111      } catch {
1112        noNerd = true
1113      }
1114      await loadTheme($)
1115      themePoll?.cancel()
1116      themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
1117      ageTick?.cancel()
1118      ageTick = showAge ? $.clock.every(AGE_TICK_MS, () => $.ui.invalidate('ui.render')) : null
1119      const t = await get($)
1120      if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1121      if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1122      await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1123      const cwd = await cwdOf($)
1124      if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
1125      else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
1126    })()
1127    return next(e)
1128  })
1129
1130  on('command.run', { command: 'filetree' }, async ($, e) => {
1131    // The layout checks are the terminal's: the desktop app docks the pane itself and
1132    // reports no fullscreen layout, so a session no terminal draws skips them.
1133    const terminal = await $.session.surfaces().then(s => s.includes('terminal'), () => true)
1134    if (terminal && !e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
1135    if (terminal && e.presentation.columns < 110) return { text: 'filetree shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /filetree.' }
1136    noDock = false
1137    const arg = (e.args ?? '').trim()
1138    const cwd = await cwdOf($)
1139    follow = !arg
1140    const root = arg ? resolve(cwd, arg, home) : cwd
1141    await reset($, root, true)
1142    return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
1143  })
1144
1145  on('tool.call', async ($, e, next) => {
1146    const shell = e.tool === 'Bash' || e.tool === 'PowerShell'
1147    if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && !shell) return next(e)
1148    const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : ''
1149    const cwd = shell ? await cwdOf($) : ''
1150    const actions = gitActions(command)
1151    const quiet = !actions.length && readOnly(command)
1152    const since = await sinceNow($, shell && !quiet)
1153    const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1154    const head = before?.top ? await headOf($, before.root) : ''
1155    const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1156    let result: Awaited<ReturnType<typeof next>>
1157    try {
1158      result = await next(e)
1159    } catch (err) {
1160      if (pending) await finishGit($, pending, failAll(pending))
1161      if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1162      throw err
1163    }
1164    if (pending && result.deny) {
1165      const ids = pending.ids
1166      void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1167    }
1168    if (result.deny || (result.isError && !shell)) {
1169      if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1170      return result
1171    }
1172    if (shell) {
1173      const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? (result.result as Partial<BuiltinToolResults['Bash']>) : {}
1174      const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1175      const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1176      if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1177      else void (async () => {
1178        const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1179        if (pending) await finishGit($, pending, results)
1180        queuedReads.push(...reads)
1181        scheduleScan($, { ...job, actions: proven(pending, results) })
1182      })()
1183      if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1184    } else {
1185      const file =
1186        'file_path' in e && typeof e.file_path === 'string'
1187          ? e.file_path
1188          : 'notebook_path' in e && typeof e.notebook_path === 'string'
1189            ? e.notebook_path
1190            : ''
1191      if (file) void touched($, [file], 'orange', showWrites)
1192    }
1193    return result
1194  })
1195
1196  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1197    const result = await next(e)
1198    if (result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1199    void touched($, [posix(e.file_path)], 'purple', true)
1200    return result
hooks/open.ts 68 lines
1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4  'set -f',
5  'f=$1',
6  'if command -v gio >/dev/null; then',
7  '  if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8  '    case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9  '      video/* | audio/*)',
10  '        app=$(xdg-mime query default text/plain)',
11  '        IFS=:',
12  '        for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13  '          [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14  '        done',
15  '        echo "No text/plain desktop application found for $f" >&2',
16  '        exit 1',
17  '        ;;',
18  '    esac',
19  '  fi',
20  '  if (exec gio open "$f"); then exit 0; fi',
21  'fi',
22  'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28  const argv = os === 'linux'
29    ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30    : os === 'darwin'
31      ? ['open', '--', target]
32      : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33  return {
34    argv,
35    init: {
36      timeoutMs: 10_000,
37      ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38    },
39  }
40}
41
42function words(command: string): string[] {
43  const out: string[] = []
44  let word: string | null = null
45  let quote = ''
46  for (const ch of command) {
47    if (quote) {
48      if (ch === quote) quote = ''
49      else word = (word ?? '') + ch
50    } else if (ch === '"' || ch === "'") {
51      quote = ch
52      word ??= ''
53    } else if (/\s/.test(ch)) {
54      if (word !== null) out.push(word)
55      word = null
56    } else word = (word ?? '') + ch
57  }
58  if (word !== null) out.push(word)
59  return out
60}
61
62export function openWithCommand(command: string, path: string, dir: string): string[] | null {
63  const argv = words(command)
64  if (!argv.length) return null
65  const filled = argv.map(w => w.replace(/\{(path|dir)\}/g, (_, key) => (key === 'path' ? path : dir)))
66  return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
67}
68
hooks/button.tsx 19 lines
1import type { ClientModule } from 'claude-code'
2
3export type ButtonProps = { id: string; label: string; dim: boolean }
4
5// A header button that also tells the hooks module when the pointer is over it, which a Button
6// alone doesn't, so the header can say what the button does.
7const HeaderButton: ClientModule<ButtonProps, { over: boolean }> = (props, surface) => {
8  const { Button } = surface.elements
9  surface.onPointer(e => {
10    const over = e.type !== 'leave'
11    if (over === Boolean(surface.state?.over)) return
12    surface.setState({ over })
13    surface.post({ over })
14  })
15  return <Button key={props.id} plain dimColor={props.dim} label={props.label} onPress={() => surface.post({ press: true })} />
16}
17
18export default HeaderButton
19
hooks/git.ts 262 lines
1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4  kind: string
5  verb: string
6  running: string
7  done: string
8  tone: string
9  icon: { nerd: string; plain: string }
10  init?: boolean
11}
12
13const ICON = {
14  commit: { nerd: '\u{f417}', plain: '●' },
15  push: { nerd: '\u{f0552}', plain: '↑' },
16  pull: { nerd: '\u{f0553}', plain: '↓' },
17  branch: { nerd: '\u{f418}', plain: '⑂' },
18  merge: { nerd: '\u{f419}', plain: '⑃' },
19  pr: { nerd: '\u{f407}', plain: '⇄' },
20  github: { nerd: '\u{f408}', plain: '◎' },
21  stash: { nerd: '\u{f01bc}', plain: '≡' },
22  undo: { nerd: '\u{f054c}', plain: '↶' },
23  tag: { nerd: '\u{f412}', plain: '⌖' },
24  git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28  orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29  green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#14532d', '#166534', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30  teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31  blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#1e3a8a', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32  purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33  cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#155e75', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34  red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#7f1d1d', '#991b1b', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
38  commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
39  push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
40  pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
41  fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
42  checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
43  switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
44  branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
45  merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
46  rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
47  'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
48  stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
49  reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
50  restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
51  revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
52  add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
53  rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
54  mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
55  tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
56  init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
57  clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
58}
59
60const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
61  'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
62  'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
63  'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
64  'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
65  'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
66  'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
67  'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
68  'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
69}
70
71const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
72
73function segments(command: string, seps: string[] = []): string[][] {
74  const out: string[][] = []
75  let cur: string[] = []
76  let tok = ''
77  let quote = ''
78  let has = false
79  const endTok = () => {
80    if (has) cur.push(tok)
81    tok = ''
82    has = false
83  }
84  const endSeg = () => {
85    endTok()
86    if (cur.length) out.push(cur)
87    cur = []
88  }
89  for (let i = 0; i < command.length; i++) {
90    const ch = command[i] ?? ''
91    if (quote) {
92      if (ch === quote) quote = ''
93      else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
94      else tok += ch
95    } else if (ch === '"' || ch === "'") {
96      quote = ch
97      has = true
98    } else if (ch === '\\' && i + 1 < command.length) {
99      tok += command[++i]
100      has = true
101    } else if (ch === '#' && !has) {
102      while (i + 1 < command.length && command[i + 1] !== '\n') i++
103    } else if (ch === ' ' || ch === '\t') endTok()
104    else if (ch === '\n' || ch === ';') {
105      seps.push(';')
106      endSeg()
107    } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
108      tok += ch
109      has = true
110    } else if (ch === '&' || ch === '|') {
111      const twice = command[i + 1] === ch
112      if (twice) i++
113      seps.push(twice ? ch + ch : ch)
114      endSeg()
115    } else {
116      tok += ch
117      has = true
118    }
119  }
120  endSeg()
121  if (!quote) return out
122  seps.push(';')
123  return command
124    .split(/&&|\|\||;|\||\n/)
125    .map(s => s.trim().split(/\s+/).filter(Boolean))
126    .filter(s => s.length > 0)
127}
128
129function stripGlobals(tokens: string[]): string[] {
130  const out = [...tokens]
131  if (out[0] === 'env') out.shift()
132  while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
133  return out
134}
135
136function gitVerb(tokens: string[]): number {
137  let i = 1
138  while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
139    if (tokens[i] === '-C' || tokens[i] === '-c') i++
140    i++
141  }
142  return i
143}
144
145function gitReadOnly(verb: string, rest: string[]): boolean {
146  if (!verb || READ_ONLY_GIT.has(verb)) return true
147  if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
148  if (verb === 'branch') return rest.every(t => t.startsWith('-'))
149  if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
150  return false
151}
152
153export function chainOf(command: string): { size: number; and: boolean } {
154  const seps: string[] = []
155  const size = segments(command, seps).length
156  return { size, and: seps.every(s => s === '&&') }
157}
158
159export function gitActions(command: string): GitAction[] {
160  const out: GitAction[] = []
161  for (const raw of segments(command)) {
162    const tokens = stripGlobals(raw)
163    const head = tokens[0]?.split('/').pop()
164    if (head === 'git') {
165      const i = gitVerb(tokens)
166      const verb = tokens[i] ?? ''
167      const rest = tokens.slice(i + 1)
168      if (gitReadOnly(verb, rest)) continue
169      const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
170      if (spec) out.push({ kind: `git ${verb}`, ...spec })
171    } else if (head === 'gh') {
172      const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
173      const spec = GH_VERBS[pair]
174      if (spec) out.push({ kind: `gh ${pair}`, ...spec })
175    }
176  }
177  return out
178}
179
180export const BRANCH_ICON = ICON.branch
181
182const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
183const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
184const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
185
186function tidy(path: string): string {
187  const s = path.replace(/\/+$/, '')
188  return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
189}
190
191export function resolve(cwd: string, p: string, home = ''): string {
192  let path = posix(p)
193  if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
194  const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
195  const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
196  for (const seg of path.slice(drive.length).split('/')) {
197    if (!seg || seg === '.') continue
198    if (seg === '..') {
199      if (parts.length > 1) parts.pop()
200    } else parts.push(seg)
201  }
202  return tidy(parts.join('/'))
203}
204
205const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
206
207export function readOnly(command: string): boolean {
208  const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
209  if (/[^>]>[^>&]|>>/.test(bare)) return false
210  return segments(command).every(raw => {
211    const tokens = stripGlobals(raw)
212    const head = tokens[0]?.split('/').pop() ?? ''
213    if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
214    if (head === 'git') {
215      const i = gitVerb(tokens)
216      return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
217    }
218    if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
219    if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
220    return READERS.has(head)
221  })
222}
223
224export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
225  let cwd = sessionCwd
226  const out = new Set<string>()
227  let listsPaths = false
228  for (const raw of segments(command)) {
229    const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
230    const head = tokens[0]?.split('/').pop() ?? ''
231    if (head === 'cd' && tokens[1]) {
232      cwd = resolve(cwd, tokens[1], home)
233      continue
234    }
235    if (!READERS.has(head)) continue
236    if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
237    if (OUTPUT_PATHS.has(head)) listsPaths = true
238    const args = tokens.slice(1)
239    let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
240    for (let i = 0; i < args.length; i++) {
241      const a = args[i] ?? ''
242      if (a.startsWith('-')) {
243        if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
244        continue
245      }
246      if (skipPattern) {
247        skipPattern = false
248        continue
249      }
250      if (/[*?<>|]/.test(a)) continue
251      out.add(resolve(cwd, a, home))
252    }
253  }
254  if (listsPaths) {
255    for (const line of stdout.split('\n').slice(0, 400)) {
256      const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
257      if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
258    }
259  }
260  return [...out].slice(0, 60)
261}
262
hooks/rows.tsx 294 lines
1import type { ClientModule, RenderElement } from 'claude-code'
2
3// role marks two segments: the folder caret, drawn in the accent while its folder unfolds, and
4// the file or folder icon, reserved for icon styling.
5export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string; role?: 'caret' | 'icon' }
6export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
7export type RowsProps = {
8  rows: RowSpec[]
9  active: string
10  activeBg: string
11  hoverBg: string
12  tones: Record<string, { bright: string[]; dim: string[] }>
13  spinner?: string[]
14  pointer?: boolean
15  bar?: { pos: number; size: number; thumb: string; track: string }
16  preview?: { text: string; path: string; startLine: number; markdown: boolean }
17  accent?: string
18}
19export type Transition = { kind: 'insert' | 'remove' | 'snap'; start: number; count: number }
20type Motion = Transition & { firstFrame: number; ghosts: RowSpec[]; spare: RowSpec[]; limit: number }
21type Ref = { stop?: () => void; unpoint?: () => void; stopFrames?: () => void; lastRows?: RowSpec[]; lastKey?: string; wasWindowed?: boolean; motion?: Motion }
22type Local = { phase: number; frame: number; drag: boolean; ref: Ref }
23// A ghost row is drawn yet no longer in the tree; a faded one draws dim while it arrives or leaves.
24type DrawnRow = { row: RowSpec; isGhost: boolean; isFaded: boolean; isAnchor: boolean }
25
26const TICK_MS = 90
27const FRAME_MS = 33
28const INSERT_FRAMES = 6
29const REMOVE_FRAMES = 5
30const FRESH_FRAMES = 2
31const MAX_ANIMATED_ROWS = 80
32const TREE_BUDGET = 70_000
33const MAX_ROWS = 1_500
34const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
35
36const isInside = (folder: string, id: string) => folder !== '' && id !== folder && (id.startsWith(folder.endsWith('/') ? folder : `${folder}/`) || id.startsWith(`${folder}\\`))
37
38// Rows `withBlock` holds at `start` that `withoutBlock` lacks, all inside the anchor folder and with the
39// rows after them unchanged; 0 for any other change. A scrolled window may cut or extend its tail.
40function blockAfter(anchor: string, start: number, withoutBlock: readonly string[], withBlock: readonly string[], isWindowed: boolean): number {
41  const known = new Set(withoutBlock)
42  let end = start
43  while (end < withBlock.length && !known.has(withBlock[end] as string)) {
44    if (!isInside(anchor, withBlock[end] as string)) return 0
45    end++
46  }
47  const count = end - start
48  const tailWith = withBlock.length - end
49  const tailWithout = withoutBlock.length - start
50  if (count === 0 || count > MAX_ANIMATED_ROWS) return 0
51  if (isWindowed ? tailWithout < tailWith : tailWithout !== tailWith) return 0
52  for (let offset = 0; offset < tailWith; offset++) {
53    if (withBlock[end + offset] !== withoutBlock[start + offset]) return 0
54  }
55  return count
56}
57
58// One folder unfolding or folding shows as a single block of its descendants appearing or leaving right
59// under it; anything else (first draw, scrolling, search, reloads, reorders) snaps.
60export function planTransition(previousIds: readonly string[] | undefined, nextIds: readonly string[], isWindowed = false): Transition {
61  const snap: Transition = { kind: 'snap', start: 0, count: 0 }
62  if (!previousIds || previousIds.length === 0 || nextIds.length === 0) return snap
63  let shared = 0
64  while (shared < previousIds.length && shared < nextIds.length && previousIds[shared] === nextIds[shared]) shared++
65  const anchor = shared > 0 ? (previousIds[shared - 1] as string) : ''
66  if (!anchor) return snap
67  const inserted = blockAfter(anchor, shared, previousIds, nextIds, isWindowed)
68  if (inserted > 0) return { kind: 'insert', start: shared, count: inserted }
69  const removed = blockAfter(anchor, shared, nextIds, previousIds, isWindowed)
70  if (removed > 0) return { kind: 'remove', start: shared, count: removed }
71  return snap
72}
73
74// The rows drawn on one frame of a motion, and whether the motion has run its course.
75function frameRows(rows: RowSpec[], motion: Motion, elapsed: number): { drawnRows: DrawnRow[]; isDone: boolean } {
76  const plain = (row: RowSpec): DrawnRow => ({ row, isGhost: false, isFaded: false, isAnchor: false })
77  const head = rows.slice(0, motion.start).map((row, index) => ({ ...plain(row), isAnchor: index === motion.start - 1 }))
78  if (motion.kind === 'insert') {
79    const perFrame = Math.max(1, Math.ceil(motion.count / INSERT_FRAMES))
80    const revealed = Math.min(motion.count, (elapsed + 1) * perFrame)
81    const block = rows.slice(motion.start, motion.start + revealed).map((row, index) => ({ ...plain(row), isFaded: elapsed - Math.floor(index / perFrame) < FRESH_FRAMES }))
82    const tail = rows.slice(motion.start + motion.count).map(plain)
83    // Rows the unfolding pushed out of a scrolled window stay until the block has filled their room.
84    const spare = motion.spare.map(row => ({ ...plain(row), isGhost: true }))
85    const isDone = elapsed >= Math.ceil(motion.count / perFrame) - 1 + FRESH_FRAMES
86    return { drawnRows: [...head, ...block, ...tail, ...spare].slice(0, motion.limit), isDone }
87  }
88  const perFrame = Math.max(1, Math.ceil(motion.count / REMOVE_FRAMES))
89  const remaining = Math.max(0, motion.count - elapsed * perFrame)
90  const ghosts = motion.ghosts.slice(0, remaining).map(row => ({ ...plain(row), isGhost: true, isFaded: true }))
91  const tail = rows.slice(motion.start).map(plain)
92  return { drawnRows: [...head, ...ghosts, ...tail].slice(0, motion.limit), isDone: remaining === 0 }
93}
94
95// A soft band of the tone's lighter shades that sweeps along the name while Claude is on it.
96function shimmer(i: number, phase: number, len: number, palette: string[]): string {
97  const band = ((phase * 1.2) % (len + 8)) - 4
98  const d = Math.abs(i - band)
99  return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
100}
101
102const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
103  const { Box, Text } = surface.elements
104  let state = surface.state
105  if (state === undefined) {
106    state = { phase: 0, frame: 0, drag: false, ref: {} }
107    surface.setState(state)
108  }
109  const ref = state.ref
110  // A state kept from an older build of this module may lack the frame counter.
111  const frame = state.frame || 0
112  const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
113  if (lit && !ref.stop) {
114    ref.stop = surface.every(TICK_MS, () => {
115      const cur = surface.state
116      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
117    })
118  } else if (!lit && ref.stop) {
119    ref.stop()
120    ref.stop = undefined
121  }
122  const finishMotion = () => {
123    ref.motion = undefined
124    ref.stopFrames?.()
125    ref.stopFrames = undefined
126  }
127  // New props compare against the last props: a running motion snaps to its end before the next is planned.
128  const ids = props.rows.map(r => r.id)
129  const idsKey = ids.join('\n')
130  // A window that gains or loses its scrollbar in this change still cuts or extends its tail.
131  const isWindowed = Boolean(props.bar) || Boolean(ref.wasWindowed)
132  if (props.preview) {
133    finishMotion()
134    ref.lastRows = undefined
135    ref.lastKey = undefined
136  } else if (idsKey !== ref.lastKey) {
137    finishMotion()
138    const lastRows = ref.lastRows ?? []
139    const plan = planTransition(ref.lastRows?.map(r => r.id), ids, isWindowed)
140    if (plan.kind !== 'snap') {
141      const ghosts = plan.kind === 'remove' ? lastRows.slice(plan.start, plan.start + plan.count) : []
142      const spare = plan.kind === 'insert' && isWindowed ? lastRows.slice(ids.length - plan.count) : []
143      // A scrolled window keeps its height while the block moves through it.
144      ref.motion = { ...plan, firstFrame: frame, ghosts, spare, limit: isWindowed ? ids.length : Number.MAX_SAFE_INTEGER }
145      ref.stopFrames = surface.every(FRAME_MS, () => {
146        const cur = surface.state
147        if (cur) surface.setState({ ...cur, frame: (cur.frame || 0) + 1 })
148      })
149    }
150    ref.lastKey = idsKey
151  }
152  if (!props.preview) ref.lastRows = props.rows
153  ref.wasWindowed = Boolean(props.bar)
154  let drawnRows: DrawnRow[] = props.rows.map(row => ({ row, isGhost: false, isFaded: false, isAnchor: false }))
155  if (ref.motion) {
156    const next = frameRows(props.rows, ref.motion, frame - ref.motion.firstFrame)
157    if (next.isDone) finishMotion()
158    else drawnRows = next.drawnRows
159  }
160  let visible = drawnRows.length
161  if (props.pointer === false) {
162    ref.unpoint?.()
163    ref.unpoint = undefined
164  } else ref.unpoint = surface.onPointer(e => {
165    const cur = surface.state ?? state
166    const span = Math.max(1, props.rows.length - 1)
167    const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
168    if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
169      if (e.type === 'up' || e.type === 'leave') {
170        surface.setState({ ...cur, drag: false })
171        return
172      }
173      if (e.type === 'down' || e.type === 'move') {
174        if (!cur.drag) surface.setState({ ...cur, drag: true })
175        surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
176        return
177      }
178    }
179    if (e.type === 'leave' || e.y < 0 || e.y >= visible) return
180    // Ghost rows are leaving the tree, so a press on one lands nowhere.
181    const drawnRow = drawnRows[e.y]
182    if (!drawnRow || drawnRow.isGhost) return
183    const row = drawnRow.row
184    if (e.type === 'down' && e.button === 'right' && row.id) {
185      surface.post({ copy: row.id, shift: Boolean(e.shift) })
186      return
187    }
188    if (e.type !== 'down' || (e.button ?? 'left') !== 'left') return
189    let x = 0
190    for (const seg of row.left) {
191      const w = [...seg.t].length
192      if (seg.tab && e.x >= x && e.x < x + w) {
193        surface.post({ tab: seg.tab })
194        return
195      }
196      x += w
197    }
198    if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
199  })
200  surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
201  // The preview draws in this same element, so the focus a click gave the tree stays put.
202  if (props.preview) {
203    const { Markdown, Code } = surface.elements
204    const p = props.preview
205    return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
206  }
207  const frames = props.spinner?.length ? props.spinner : FRAMES
208  // A row fading in or out draws its text dim and steady; chips keep their full colour so the fill stays even.
209  const draw = (s: Seg, isDim: boolean, isAnchor: boolean) => {
210    const color = isAnchor && s.role === 'caret' ? (props.accent ?? s.c) : s.c
211    const tail = s.spin ? (frames[state.phase % frames.length] ?? '') : ''
212    if (s.spin && !isDim) {
213      return (
214        <Text color={color} bold={s.b}>
215          {s.t + tail}
216        </Text>
217      )
218    }
219    const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
220    if (!palette || isDim) {
221      return (
222        <Text color={color ?? palette?.[0]} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i} dimColor={isDim && !s.bg ? true : undefined}>
223          {s.t + tail}
224        </Text>
225      )
226    }
227    if (s.one) {
228      return (
229        <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
230          {s.t}
231        </Text>
232      )
233    }
234    const chars = [...s.t]
235    return (
236      <Text bold={s.b}>
237        {chars.map((ch, i) => (
238          <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
239        ))}
240      </Text>
241    )
242  }
243  // Each real row is a keyed Box, so the surface lights it under the pointer with no round trip here.
244  const keys = new Set<string>()
245  const rowEl = (drawnRow: DrawnRow, i: number) => {
246    const r = drawnRow.row
247    const isActive = Boolean(r.id) && r.id === props.active && !drawnRow.isGhost
248    const hasKey = Boolean(r.id) && !drawnRow.isGhost && !keys.has(r.id)
249    if (hasKey) keys.add(r.id)
250    return (
251      <Box
252        {...(hasKey ? { key: r.id } : {})}
253        {...(hasKey && !isActive ? { hover: { backgroundColor: props.hoverBg } } : {})}
254        flexDirection="row"
255        height={1}
256        overflow="hidden"
257        backgroundColor={isActive ? props.activeBg : undefined}
258      >
259        <Box flexShrink={1} overflow="hidden">
260          {r.left.map(s => draw(s, drawnRow.isFaded, drawnRow.isAnchor))}
261        </Box>
262        <Box flexGrow={1} />
263        <Box flexDirection="row" flexShrink={0}>
264          {r.right.map(s => draw(s, drawnRow.isFaded, false))}
265        </Box>
266        {props.bar && (
267          <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
268            {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
269          </Text>
270        )}
271      </Box>
272    )
273  }
274  const drawn: RenderElement[] = []
275  let size = 0
276  for (let i = 0; i < drawnRows.length; i++) {
277    const el = rowEl(drawnRows[i] as DrawnRow, i)
278    let cost = 1_000
279    try {
280      cost = JSON.stringify(el).length
281    } catch {}
282    if (size + cost > TREE_BUDGET || drawn.length >= MAX_ROWS) {
283      visible = i
284      drawn.push(<Text dimColor>{`  … ${drawnRows.length - i} more rows, collapse folders or use search`}</Text>)
285      break
286    }
287    size += cost
288    drawn.push(el)
289  }
290  return <Box flexDirection="column">{drawn}</Box>
291}
292
293export default Rows
294
hooks/icons.ts 275 lines
1import type { FileNode } from '../types'
2import { luminance } from './look'
3
4const cp = (n: number) => String.fromCodePoint(n)
5
6export const CHEVRON_OPEN = cp(0xf47c)
7export const CHEVRON_CLOSED = cp(0xf460)
8
9const NAMES: Record<string, number> = {
10  'agents.md': 0xf0354,
11  'changelog.md': 0xf0354,
12  'contributing.md': 0xf0354,
13  'todo.md': 0xf0354,
14  'readme.md': 0xf0354,
15  license: 0xe60a,
16  dockerfile: 0xf0868,
17  makefile: 0xf1064,
18  'cmakelists.txt': 0xf1064,
19  justfile: 0xf05b7,
20  gemfile: 0xf0d2d,
21  rakefile: 0xf0d2d,
22  'cargo.toml': 0xe6b2,
23  'cargo.lock': 0xe6b2,
24  'pyproject.toml': 0xe6b2,
25  'requirements.txt': 0xf160e,
26  'go.mod': 0xf0afa,
27  'go.sum': 0xf07d3,
28  'package.json': 0xf0626,
29  'package-lock.json': 0xf0626,
30  'tsconfig.json': 0xf0626,
31  '.prettierrc': 0xf0626,
32  '.eslintrc': 0xf0626,
33  'pnpm-lock.yaml': 0xe6a8,
34  'yarn.lock': 0xe6a8,
35  '.gitignore': 0xf02a2,
36  '.gitmodules': 0xf0493,
37  '.editorconfig': 0xe652,
38}
39
40const EXTS: Record<string, number> = {
41  lua: 0xe620,
42  py: 0xe606,
43  pyi: 0xe606,
44  js: 0xe60c,
45  mjs: 0xe60c,
46  cjs: 0xe60c,
47  ts: 0xe628,
48  jsx: 0xe625,
49  tsx: 0xe7ba,
50  json: 0xe60b,
51  jsonc: 0xe60b,
52  yaml: 0xe6a8,
53  yml: 0xe6a8,
54  sh: 0xe795,
55  bash: 0xe795,
56  zsh: 0xe795,
57  fish: 0xe795,
58  md: 0xf0354,
59  markdown: 0xf0354,
60  css: 0xe6b8,
61  scss: 0xf031c,
62  html: 0xe736,
63  htm: 0xe736,
64  go: 0xe627,
65  rs: 0xe68b,
66  rb: 0xe791,
67  php: 0xe608,
68  java: 0xe738,
69  cs: 0xf031b,
70  sql: 0xe706,
71  graphql: 0xf20e,
72  gql: 0xf20e,
73  xml: 0xf05c0,
74  toml: 0xe6b2,
75  ini: 0xf0bc2,
76  conf: 0xf0493,
77  pdf: 0xeaeb,
78  svg: 0xf0721,
79  jpg: 0xf0225,
80  jpeg: 0xf0225,
81  png: 0xe60d,
82  gif: 0xf0d78,
83  webp: 0xf021f,
84  mp4: 0xf022b,
85  mkv: 0xf022b,
86  mov: 0xf022b,
87  mp3: 0xf0223,
88  wav: 0xf0223,
89  flac: 0xf0223,
90  zip: 0xf05c4,
91  gz: 0xf05c4,
92  xz: 0xf05c4,
93  tar: 0xf05c4,
94  qml: 0xf375,
95  cpp: 0xe61d,
96  cc: 0xe61d,
97  cxx: 0xe61d,
98  c: 0xe61e,
99  h: 0xf0af5,
100  hpp: 0xf0af5,
101  vue: 0xe6a0,
102  svelte: 0xe697,
103  csv: 0xe64a,
104  txt: 0xf09aa,
105}
106
107export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
108  if (n.kind === 'link') return cp(0xf481)
109  if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
110  const lower = n.name.toLowerCase()
111  const named = NAMES[lower]
112  if (named) return cp(named)
113  const dot = lower.lastIndexOf('.')
114  const ext = dot >= 0 ? lower.slice(dot + 1) : ''
115  return cp(EXTS[ext] ?? 0xf0214)
116}
117
118// Without a Nerd Font a file shows a two-cell chip in its language's colour, as in VS Code's
119// Material icon theme, and a folder a block in its kind's colour. The chip's text is white or
120// near-black, whichever reads better on that colour.
121export type Badge = { text: string; ink: string; onInk: string }
122
123const DARK_TEXT = '#1b1b19'
124const GREY = '#8a877f'
125
126export function onInkFor(ink: string): string {
127  const level = luminance(ink)
128  return 1.05 / (level + 0.05) >= (level + 0.05) / (luminance(DARK_TEXT) + 0.05) ? '#ffffff' : DARK_TEXT
129}
130
131// Each row: the names or extensions (space separated), the chip's two cells and its colour.
132const NAME_BADGES: [string, string, string][] = [
133  ['package.json', 'NP', '#cb3837'],
134  ['package-lock.json pnpm-lock.yaml yarn.lock bun.lockb bun.lock cargo.lock poetry.lock gemfile.lock composer.lock', 'LK', GREY],
135  ['dockerfile containerfile .dockerignore docker-compose.yml docker-compose.yaml compose.yml compose.yaml', 'DK', '#2496ed'],
136  ['.gitignore .gitattributes .gitmodules .gitkeep', 'GI', '#e2643c'],
137  ['.env', 'EV', '#d9b54a'],
138  ['license licence license.md license.txt', '© ', '#c9a25a'],
139  ['readme readme.md readme.txt', 'i ', '#5b8fd6'],
140  ['makefile gnumakefile', 'MK', '#6d8086'],
141  ['claude.md agents.md', '✱ ', '#d97757'],
142  ['.bashrc .bash_profile .zshrc .zprofile .zshenv .profile', '$_', '#7cc36e'],
143]
144
145const PATTERN_BADGES: [RegExp, string, string][] = [
146  [/^tsconfig.*\.json$/, 'TS', '#3178c6'],
147  [/^\.env\./, 'EV', '#d9b54a'],
148  [/^(\.prettierrc|prettier\.config\.)/, 'PR', '#c596c7'],
149  [/^(eslint\.config\.|\.eslintrc)/, 'ES', '#8080f2'],
150  [/^vite\.config\./, 'VT', '#a855f7'],
151  [/^vitest\.config\./, 'VI', '#6e9f18'],
152  [/^(dockerfile|containerfile)\.|\.dockerfile$|^docker-compose\..+\.ya?ml$/, 'DK', '#2496ed'],
153]
154
155const EXTENSION_BADGES: [string, string, string][] = [
156  ['ts mts cts', 'TS', '#3178c6'],
157  ['tsx', 'TX', '#2fa7c9'],
158  ['js mjs cjs', 'JS', '#f0db4f'],
159  ['jsx', 'JX', '#e8c93a'],
160  ['json jsonc json5', '{}', '#d9a93a'],
161  ['md mdx markdown', 'M↓', '#5b8fd6'],
162  ['css', '# ', '#8f6fd6'],
163  ['scss sass', 'S#', '#cd6799'],
164  ['html htm', '<>', '#e2643c'],
165  ['sh bash zsh fish', '$_', '#7cc36e'],
166  ['ps1 psm1', 'PS', '#5391fe'],
167  ['yaml yml', 'YM', '#cb5d5d'],
168  ['toml', 'TM', '#9c4221'],
169  ['ini cfg conf properties', 'CF', GREY],
170  ['py pyi pyw', 'PY', '#3776ab'],
171  ['ipynb', 'NB', '#f37626'],
172  ['go', 'GO', '#00add8'],
173  ['rs', 'RS', '#dea584'],
174  ['rb erb', 'RB', '#cc342d'],
175  ['java', 'JV', '#b07219'],
176  ['kt kts', 'KT', '#a97bff'],
177  ['scala', 'SC', '#dc322f'],
178  ['swift', 'SW', '#f05138'],
179  ['dart', 'DT', '#00b4ab'],
180  ['c', 'C ', '#555fbf'],
181  ['h', 'H ', '#7b83c9'],
182  ['cpp cc cxx', 'C+', '#f34b7d'],
183  ['hpp hh hxx', 'H+', '#c96a8f'],
184  ['cs', 'C#', '#178600'],
185  ['php', 'PH', '#777bb4'],
186  ['lua', 'LU', '#4f6fd6'],
187  ['ex exs', 'EX', '#8e6fb8'],
188  ['hs', 'HS', '#5e5086'],
189  ['zig', 'ZG', '#f7a41d'],
190  ['r', 'R ', '#276dc3'],
191  ['sql', 'SQ', '#e38c00'],
192  ['db sqlite sqlite3', 'DB', '#e38c00'],
193  ['graphql gql', 'GQ', '#e10098'],
194  ['proto', 'PB', '#4f9de0'],
195  ['tf tfvars', 'TF', '#7b42bc'],
196  ['vue', 'VU', '#41b883'],
197  ['svelte', 'SV', '#ff3e00'],
198  ['astro', 'AS', '#ff5d01'],
199  ['xml plist', '<>', GREY],
200  ['svg', 'SG', '#ffb13b'],
201  ['png jpg jpeg gif webp avif ico bmp heic', 'IM', '#a074c4'],
202  ['mp4 mkv mov webm avi', 'VD', '#e05d44'],
203  ['mp3 wav flac ogg m4a aac', 'AU', '#e06c9f'],
204  ['pdf', 'PD', '#e5252a'],
205  ['zip gz tgz tar xz 7z bz2 rar', 'ZP', GREY],
206  ['csv tsv', 'CS', '#3fae6a'],
207  ['txt log', 'Tx', GREY],
208  ['diff patch', 'DF', '#7cc36e'],
209  ['ttf otf woff woff2', 'FT', GREY],
210  ['wasm', 'WA', '#654ff0'],
211  ['lock', 'LK', GREY],
212]
213
214const badgeOf = (text: string, ink: string): Badge => ({ text, ink, onInk: onInkFor(ink) })
215
216function badgeTable(entries: [string, string, string][]): Map<string, Badge> {
217  const table = new Map<string, Badge>()
218  for (const [keys, text, ink] of entries) {
219    const badge = badgeOf(text, ink)
220    for (const key of keys.split(' ')) table.set(key, badge)
221  }
222  return table
223}
224
225const BY_NAME = badgeTable(NAME_BADGES)
226const BY_EXTENSION = badgeTable(EXTENSION_BADGES)
227const BY_PATTERN = PATTERN_BADGES.map(([pattern, text, ink]) => [pattern, badgeOf(text, ink)] as const)
228const DEFAULT_BADGE = badgeOf('··', GREY)
229
230export function fileBadge(node: Pick<FileNode, 'name'>): Badge {
231  const lower = node.name.toLowerCase()
232  const named = BY_NAME.get(lower)
233  if (named) return named
234  for (const [pattern, badge] of BY_PATTERN) if (pattern.test(lower)) return badge
235  const dot = lower.lastIndexOf('.')
236  return (dot > 0 ? BY_EXTENSION.get(lower.slice(dot + 1)) : undefined) ?? DEFAULT_BADGE
237}
238
239const FOLDER_INKS: [string, string][] = [
240  ['.claude .github .vscode', '#e0795a'],
241  ['src source lib', '#58b86a'],
242  ['docs doc', '#4f9de0'],
243  ['test tests __tests__ e2e spec coverage', '#3fae9e'],
244  ['public static assets', '#3fb6d8'],
245  ['dist build out', '#d8605e'],
246  ['node_modules', '#7aa35a'],
247  ['scripts bin config configs', '#7f93ad'],
248  ['hooks plugins', '#a98be8'],
249  ['components ui', '#e8a33d'],
250  ['types @types', '#4f7cc4'],
251  ['styles css', '#8f6fd6'],
252  ['images img icons', '#a074c4'],
253]
254const FOLDER_BY_NAME = new Map(FOLDER_INKS.flatMap(([names, ink]) => names.split(' ').map(name => [name, ink] as const)))
255
256export function folderInk(name: string): string {
257  return FOLDER_BY_NAME.get(name.toLowerCase()) ?? '#c9a25a'
258}
259
260export const GIT_COLOR: Record<string, string> = {
261  A: '#98c379',
262  '?': '#98c379',
263  R: '#61afef',
264  C: '#61afef',
265  M: '#e5c07b',
266  T: '#e5c07b',
267}
268
269const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
270
271export function stronger(a: string | undefined, b: string): string {
272  if (!a) return b
273  return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
274}
275
hooks/look.ts 192 lines
1import type { Theme } from '../types'
2import { DEFAULT_THEME } from './tree'
3
4// The pane's colours. Without a theme file the pane can sit on a cream (#faf6f0) or a
5// near-black (#141414) background and nothing says which, so text colours there are the
6// person's own theme keys (inactive, success, warning, error, merged), which follow their
7// light or dark theme. The house inks below colour marks only: the activity bar, the
8// shimmer, icons. An Omarchy theme names its background, so there the inks are picked for
9// that background, and its own accent, muted, red and selection win.
10
11type Hue = 'coral' | 'jade' | 'amber' | 'red' | 'lilac' | 'teal' | 'blue' | 'cyan'
12export type Mode = 'light' | 'dark' | 'both'
13
14const INKS: Record<Mode, Record<Hue, string>> = {
15  light: { coral: '#a8492f', jade: '#2f6e52', amber: '#875c14', red: '#a83e3b', lilac: '#6a50b8', teal: '#1f6e69', blue: '#3a62a8', cyan: '#1f6c84' },
16  both: { coral: '#cc6448', jade: '#3f8a68', amber: '#a57524', red: '#c4524f', lilac: '#8a6fd0', teal: '#2e8a84', blue: '#4f7cc4', cyan: '#2b88a3' },
17  dark: { coral: '#f0907a', jade: '#7cd7ad', amber: '#e3b65e', red: '#f08a84', lilac: '#b9a6f0', teal: '#6fd0c8', blue: '#8db4f0', cyan: '#74cde6' },
18}
19
20const MUTED: Record<Mode, string> = { light: '#6e6478', both: '#7a7183', dark: '#a89cb4' }
21
22// Claude's activity tones (git.ts TONES names) mapped onto the house hues.
23const TONE_HUE: Record<string, Hue> = { orange: 'coral', green: 'jade', teal: 'teal', blue: 'blue', purple: 'lilac', cyan: 'cyan', red: 'red' }
24
25export type Palette = {
26  isCustomTheme: boolean
27  // light or dark when a theme file names the background, both when nothing says which.
28  mode: Mode
29  text: string | undefined
30  muted: string
31  // The muted colour as a hex, for mixing: a theme key cannot be mixed.
32  mutedHex: string
33  faint: string
34  accent: string
35  cursorBg: string
36  hoverBg: string
37  added: string
38  modified: string
39  deleted: string
40  renamed: string
41  addedLines: string
42  deletedLines: string
43  ink: (tone: string) => string
44  shimmer: Record<string, { bright: string[]; dim: string[] }>
45}
46
47function channels(hex: string): [number, number, number] {
48  const value = parseInt(hex.slice(1, 7), 16)
49  return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
50}
51
52export function mix(from: string, to: string, amount: number): string {
53  if (!/^#[0-9a-fA-F]{6}$/.test(from) || !/^#[0-9a-fA-F]{6}$/.test(to)) return from
54  const start = channels(from)
55  const end = channels(to)
56  return `#${start.map((channel, index) => Math.round(channel + ((end[index] ?? channel) - channel) * amount).toString(16).padStart(2, '0')).join('')}`
57}
58
59export function luminance(hex: string): number {
60  const linear = (channel: number) => {
61    const value = channel / 255
62    return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
63  }
64  const [red, green, blue] = channels(hex).map(linear) as [number, number, number]
65  return 0.2126 * red + 0.7152 * green + 0.0722 * blue
66}
67
68// The old selection hover: the selection colour pulled most of the way to a dark grey.
69function towardDark(hex: string): string {
70  return mix(hex, '#262626', 0.7)
71}
72
73export function buildPalette(theme: Theme, isDesktop: boolean): Palette {
74  const isCustomTheme = (Object.keys(DEFAULT_THEME) as (keyof Theme)[]).some(key => theme[key] !== DEFAULT_THEME[key])
75  const hasBackground = /^#[0-9a-fA-F]{6}$/.test(theme.bg)
76  const mode: Mode = hasBackground ? (luminance(theme.bg) < 0.35 ? 'dark' : 'light') : 'both'
77  const isBoth = mode === 'both'
78  const inks = INKS[mode]
79  const mutedHex = isCustomTheme ? theme.muted : MUTED[mode]
80  const muted = isCustomTheme ? theme.muted : isBoth ? 'inactive' : MUTED[mode]
81  const deleted = isCustomTheme ? theme.urgent : isBoth ? 'error' : inks.red
82  const peakOf = (hue: Hue) => (mode === 'dark' ? mix(INKS.dark[hue], '#ffffff', 0.45) : mode === 'light' ? INKS.both[hue] : mix(INKS.both[hue], INKS.dark[hue], 0.5))
83  const shimmer: Record<string, { bright: string[]; dim: string[] }> = {}
84  for (const [tone, hue] of Object.entries(TONE_HUE)) {
85    const ink = inks[hue]
86    const peak = peakOf(hue)
87    const soft = mix(ink, mutedHex, 0.55)
88    shimmer[tone] = { bright: [ink, mix(ink, peak, 0.35), mix(ink, peak, 0.7), peak], dim: [soft, mix(soft, ink, 0.35), mix(soft, ink, 0.7), ink] }
89  }
90  return {
91    isCustomTheme,
92    mode,
93    text: theme.fg || undefined,
94    muted,
95    mutedHex,
96    faint: hasBackground ? mix(mutedHex, theme.bg, 0.55) : 'subtle',
97    accent: isCustomTheme ? theme.accent : inks.coral,
98    cursorBg: isCustomTheme ? theme.selection : 'userMessageBackground',
99    hoverBg: isCustomTheme ? (hasBackground ? mix(theme.selection, theme.bg, 0.55) : towardDark(theme.selection)) : isDesktop ? 'userMessageBackgroundHover' : 'userMessageBackground',
100    added: isBoth ? 'success' : inks.jade,
101    modified: isBoth ? 'warning' : inks.amber,
102    deleted,
103    renamed: isBoth ? 'merged' : inks.lilac,
104    addedLines: isBoth ? 'success' : inks.jade,
105    deletedLines: deleted,
106    ink: tone => inks[TONE_HUE[tone] ?? 'coral'],
107    shimmer,
108  }
109}
110
111// A file or folder ink drawn as text, clamped in luminance so it keeps about 3:1 against
112// cream (#faf6f0) at most 0.28 and against near-black (#141414) at least 0.12. Without a
113// known background both bounds apply.
114const INK_LUMINANCE_BOUNDS: Record<Mode, { lowest: number; highest: number }> = {
115  light: { lowest: 0, highest: 0.28 },
116  dark: { lowest: 0.12, highest: 1 },
117  both: { lowest: 0.12, highest: 0.28 },
118}
119
120export function inkOnPane(palette: Palette, ink: string): string {
121  if (!/^#[0-9a-fA-F]{6}$/.test(ink)) return ink
122  const { lowest, highest } = INK_LUMINANCE_BOUNDS[palette.mode]
123  const level = luminance(ink)
124  if (level >= lowest && level <= highest) return ink
125  const target = level > highest ? '#000000' : '#ffffff'
126  const isInside = (candidate: string) => {
127    const candidateLevel = luminance(candidate)
128    return candidateLevel >= lowest && candidateLevel <= highest
129  }
130  for (let step = 1; step <= 20; step++) {
131    const candidate = mix(ink, target, step * 0.05)
132    if (isInside(candidate)) return candidate
133  }
134  return mix(ink, target, 1)
135}
136
137export function gitInk(palette: Palette, letter: string | undefined): string | undefined {
138  if (!letter) return undefined
139  if (letter === 'D' || letter === 'U') return palette.deleted
140  if (letter === 'M' || letter === 'T') return palette.modified
141  if (letter === 'R' || letter === 'C') return palette.renamed
142  if (letter === 'A' || letter === '?') return palette.added
143  return palette.muted
144}
145
146// Cuts a long name in its middle so the start and the extension both stay readable.
147export function truncateMiddle(name: string, room: number): string {
148  const characters = [...name]
149  if (characters.length <= room) return name
150  if (room <= 1) return '…'
151  // Too little room for both ends: the start alone says more.
152  if (room < 10) return `${characters.slice(0, room - 1).join('')}…`
153  const dot = name.lastIndexOf('.')
154  const extension = dot > 0 && name.length - dot <= 8 ? [...name.slice(dot)] : []
155  const tailLength = Math.min(Math.max(extension.length + 2, Math.floor((room - 1) / 3)), room - 2)
156  const headLength = Math.max(1, room - 1 - tailLength)
157  return `${characters.slice(0, headLength).join('')}…${characters.slice(characters.length - (room - 1 - headLength)).join('')}`
158}
159
160const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
161
162// The date column, in the month words age() uses: this year's with the time (Oct 12 09:30),
163// an older one with its year (Nov 14 2023). Very narrow panes keep one part: today's time
164// (09:30), this year's day (Oct 12), an older one's year (2023). The day is padded so the
165// column lines up.
166export function dateLabel(ms: number, now: number, isVeryNarrow = false): string {
167  if (!ms) return ''
168  const date = new Date(ms)
169  const today = new Date(now)
170  const pad = (value: number) => String(value).padStart(2, '0')
171  const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
172  const day = `${MONTHS[date.getMonth()]} ${String(date.getDate()).padStart(2, ' ')}`
173  const isThisYear = date.getFullYear() === today.getFullYear()
174  if (!isVeryNarrow) return `${day} ${isThisYear ? time : date.getFullYear()}`
175  if (isThisYear && date.getMonth() === today.getMonth() && date.getDate() === today.getDate()) return time
176  return isThisYear ? day : String(date.getFullYear())
177}
178
179export function widthOf(text: string): number {
180  return [...text].length
181}
182
183export const RECENT_SOFT_MS = 30_000
184export const RECENT_MS = 90_000
185
186// How strongly a file Claude touched a while ago is still marked: 2 just after the shimmer,
187// 1 once it has settled, 0 when the mark is gone.
188export function recentLevel(at: number, now: number): 0 | 1 | 2 {
189  const elapsed = now - at
190  return elapsed < RECENT_SOFT_MS ? 2 : elapsed < RECENT_MS ? 1 : 0
191}
192
hooks/tree.ts 346 lines
1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10  fg: '',
11  accent: '#5b9bd5',
12  muted: '#808a96',
13  urgent: '#d0605e',
14  selection: '#6b7280',
15  bg: '',
16}
17
18export function emptyTree(root: string): FileTree {
19  return {
20    root,
21    nodes: [],
22    expanded: [],
23    cursor: '',
24    selected: '',
25    query: '',
26    showHidden: true,
27    showSize: false,
28    dirSizes: {},
29    git: {},
30    diff: {},
31    ignored: [],
32    untrackedDirs: [],
33    top: '',
34    prefix: '',
35    branch: null,
36    counts: {},
37    flash: [],
38    flashDim: [],
39    flashOn: false,
40    flashTones: {},
41    scroll: null,
42    preview: '',
43    previewTop: 0,
44  }
45}
46
47export function parseTheme(toml: string): Theme {
48  const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
49  return {
50    fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
51    accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
52    muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
53    urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
54    selection: get('selection') ?? DEFAULT_THEME.selection,
55    bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
56  }
57}
58
59export function join(dir: string, name: string): string {
60  return dir.endsWith('/') ? dir + name : `${dir}/${name}`
61}
62
63let drives = false
64
65export function dirname(path: string): string {
66  if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
67  const i = path.lastIndexOf('/')
68  return i <= 0 ? '/' : path.slice(0, i)
69}
70
71export function useDrives(on: boolean): void {
72  drives = on
73}
74
75export function drivesOn(): boolean {
76  return drives
77}
78
79export function posix(path: string): string {
80  if (!drives) return path
81  return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
82}
83
84export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
85  if (dirs.length === 0) return nodes
86  const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
87  const gone = new Set(dirs)
88  return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
89}
90
91export function isAbsolute(path: string): boolean {
92  return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
93}
94
95export function inside(root: string, path: string): boolean {
96  return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
97}
98
99export function toNodes(dir: string, list: Entry[]): FileNode[] {
100  return list
101    .map(e => ({
102      id: join(dir, e.name),
103      parent: dir,
104      name: e.name,
105      kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
106      hidden: e.name.startsWith('.'),
107      mtime: e.mtimeMs,
108      size: e.size,
109      loaded: false,
110    }))
111    .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
112}
113
114export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
115  if (listed.size === 0) return nodes
116  const keep = new Map<string, FileNode>()
117  for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
118  const freshIds = new Set<string>()
119  for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
120  const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
121  const under = (id: string) => {
122    if (gone.size === 0) return false
123    for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
124    return false
125  }
126  const fresh: FileNode[] = []
127  for (const kids of listed.values()) {
128    for (const k of kids) {
129      if (under(k.id)) continue
130      const old = keep.get(k.id)
131      fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
132    }
133  }
134  const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
135  return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
136}
137
138export type GitStatus = {
139  git: Record<string, string>
140  ignored: string[]
141  untrackedDirs: string[]
142  branch: Branch | null
143  files: Record<string, Change>
144}
145
146export type Change = 'new' | 'mod' | 'del'
147
148export function parseBranch(record: string): Branch {
149  const body = record.replace(/^## /, '')
150  if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
151  const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
152  const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
153  const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
154  const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
155  return { head, upstream, ahead, behind }
156}
157
158export function mapper(root: string, prefix: string): (rel: string) => string | null {
159  return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
160}
161
162export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
163  const map = mapper(root, prefix)
164  const git: Record<string, string> = {}
165  const ignored: string[] = []
166  const untrackedDirs: string[] = []
167  const files: Record<string, Change> = {}
168  let branch: Branch | null = null
169  const parts = stdout.split('\0')
170  for (let i = 0; i < parts.length; i++) {
171    const rec = parts[i] ?? ''
172    if (rec.startsWith('## ')) {
173      branch = parseBranch(rec)
174      continue
175    }
176    if (rec.length < 4) continue
177    const xy = rec.slice(0, 2)
178    const raw = rec.slice(3)
179    if (xy[0] === 'R' || xy[0] === 'C') i++
180    const path = map(raw)
181    if (!path) continue
182    if (xy === '!!') {
183      ignored.push(path)
184      continue
185    }
186    if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
187    else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
188    const letter =
189      xy === '??'
190        ? '?'
191        : /U/.test(xy) || xy === 'AA' || xy === 'DD'
192          ? 'U'
193          : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
194    if (!letter) continue
195    git[path] = stronger(git[path], letter)
196    let dir = dirname(path)
197    while (dir.length >= root.length && dir !== '/') {
198      git[dir] = stronger(git[dir], letter)
199      if (dir === root) break
200      dir = dirname(dir)
201    }
202  }
203  return { git, ignored, untrackedDirs, branch, files }
204}
205
206export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
207  const map = mapper(root, prefix)
208  const parts = stdout.split('\0')
209  for (let i = 0; i < parts.length; i++) {
210    const rec = parts[i] ?? ''
211    if (!rec) continue
212    const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
213    if (!m) continue
214    let path = m[3] ?? ''
215    if (path === '') {
216      i += 2
217      path = parts[i] ?? ''
218    }
219    if (!path) continue
220    const add = m[1] === '-' ? 0 : Number(m[1])
221    const del = m[2] === '-' ? 0 : Number(m[2])
222    const abs = map(path)
223    if (!abs) continue
224    const prev = into[abs] ?? [0, 0]
225    into[abs] = [prev[0] + add, prev[1] + del]
226  }
227}
228
229export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
230  const out: Record<string, [number, number]> = { ...diff }
231  for (const [path, [add, del]] of Object.entries(diff)) {
232    let dir = dirname(path)
233    while (dir.length >= root.length && dir !== '/') {
234      const prev = out[dir] ?? [0, 0]
235      out[dir] = [prev[0] + add, prev[1] + del]
236      if (dir === root) break
237      dir = dirname(dir)
238    }
239  }
240  return out
241}
242
243export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
244  const out: Record<string, [number, number, number]> = {}
245  const slot = { new: 0, mod: 1, del: 2 } as const
246  for (const [path, change] of Object.entries(files)) {
247    let dir = dirname(path)
248    while (dir.length >= root.length && dir !== '/') {
249      const cur = out[dir] ?? [0, 0, 0]
250      cur[slot[change]] += 1
251      out[dir] = cur
252      if (dir === root) break
253      dir = dirname(dir)
254    }
255  }
256  return out
257}
258
259export function underAny(id: string, set: Set<string>, root: string): boolean {
260  if (set.size === 0) return false
261  let cur = id
262  while (cur.length >= root.length) {
263    if (set.has(cur)) return true
264    const up = dirname(cur)
265    if (up === cur) break
266    cur = up
267  }
268  return false
269}
270
271export function ancestorsOf(id: string, root: string): string[] {
272  const out: string[] = []
273  if (!root) return out
274  for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
275  return out
276}
277
278export function relative(root: string, path: string): string {
279  return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
280}
281
282export function visibleRows(t: FileTree): Row[] {
283  const kids = new Map<string, FileNode[]>()
284  for (const n of t.nodes) {
285    if (!t.showHidden && n.hidden) continue
286    const list = kids.get(n.parent)
287    if (list) list.push(n)
288    else kids.set(n.parent, [n])
289  }
290  const q = t.query.trim().toLowerCase()
291  let keep: Set<string> | null = null
292  if (q) {
293    keep = new Set()
294    for (const n of t.nodes) {
295      if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
296      keep.add(n.id)
297      for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
298    }
299  }
300  const open = new Set(t.expanded)
301  const rows: Row[] = []
302  const walk = (dir: string, depth: number) => {
303    for (const n of kids.get(dir) ?? []) {
304      if (keep && !keep.has(n.id)) continue
305      const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
306      rows.push({ node: n, depth, open: isOpen })
307      if (isOpen) walk(n.id, depth + 1)
308    }
309  }
310  walk(t.root, 0)
311  return rows
312}
313
314export function stamp(ms: number): string {
315  if (!ms) return ''
316  const d = new Date(ms)
317  const p = (v: number) => String(v).padStart(2, '0')
318  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
319}
320
321const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
322
323export function age(ms: number, now: number): string {
324  if (!ms) return ''
325  const s = (now - ms) / 1000
326  if (s < 60 && s > -60) return 'now'
327  if (s > 0 && s < 3600) return `${Math.floor(s / 60)}m`
328  if (s > 0 && s < 86_400) return `${Math.floor(s / 3600)}h`
329  if (s > 0 && s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`
330  const d = new Date(ms)
331  return Math.abs(s) < 365 * 86_400 ? `${MONTHS[d.getMonth()]} ${d.getDate()}` : String(d.getFullYear())
332}
333
334export function formatSize(bytes: number): string {
335  if (bytes < 0) return '?'
336  if (bytes < 1024) return `${bytes} B`
337  const units = ['K', 'M', 'G', 'T']
338  let v = bytes / 1024
339  let i = 0
340  while (v >= 1024 && i < units.length - 1) {
341    v /= 1024
342    i++
343  }
344  return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
345}
346
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    'pulse-filetree': {
70      tree: FileTree
71      theme: Theme
72      activity: Activity[]
73    }
74  }
75}
76