SLOPSHOPPER

filetree

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

newpaneguardcommandprompttimer
v0.5.6MITupdated 2026-10-16satoramoto/awesome-claude/mods/filetree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /filetree ⎿ filetree: File tree on /work/app (follows the cwd). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

[!IMPORTANT] This is a fork. filetree is by Kurt Buhler (@data-goblin): the original is data-goblin/claude-code-filetree, MIT licensed (LICENSE, kept as it came). This copy was taken at upstream bf8de32 (0.2.24) and is changed here to fit the other mods in awesome-claude. Changes since then are listed under "Changes in this fork" at the end. For the original, report issues and send PRs upstream.

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

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

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

<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-shimmer.gif" alt="filetree shimmering the file Claude is editing" width="900">

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


Installation

This fork installs from the awesome-claude marketplace. Run this in the terminal:

claude plugin marketplace add satoramoto/awesome-claude
claude plugin install filetree@awesome-claude

Or inside a Claude Code session:

/plugin marketplace add satoramoto/awesome-claude
/plugin install filetree@awesome-claude

Don't install it beside the original (filetree@claude-code-filetree), or both panes load. For the original, follow its README.

Features

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

<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-read.gif" alt="Files shimmer purple while Claude reads and searches them" width="800">

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

<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-git.gif" alt="A committed file shimmers green and the footer shows the commit" width="800">

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

<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-ask.gif" alt="Selecting config.yaml in the tree and asking Claude what it changed there" width="800">

  • File and folder sizes: the Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writes
  • Double-click a file to open it in its default app
  • Click to select, arrow keys to move through the tree
  • Light on large repos: outside a repo it only checks once whether one exists, and every git call is scoped to the cwd
  • Nerd Font icons with a plain Unicode fallback
  • Follows the terminal's theme: it draws on the terminal's own background in Claude Code's theme colours, so it reads on light, dark and ANSI-only themes alike (this fork leaves out upstream's Omarchy background and selection colours, fixed colours that don't read on all of them)
  • The cursor row is marked ▌ in the gutter, bold while the pane has the keyboard; key hints show at the foot then

Resizing the pane

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

<img src="https://raw.githubusercontent.com/data-goblin/claude-code-filetree/bf8de321fcd03a9edb893b9ef3e3cc0e4af5bee5/media/filetree-resize.gif" alt="Dragging the filetree pane edge to resize it" width="900">

Settings

All settings are in /config under filetree.

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

herdr

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

Contributing

This fork is built and checked like the other mods in awesome-claude: see its AGENTS.md. Upstream's pre-commit hook and its macOS, Windows and Linux CI weren't brought over; this repository's CI runs on Linux only.

License

MIT, copyright Kurt Buhler. The license is kept unchanged with the code.

This project is inspired by my Omarchy app FileBlade

Changes in this fork

From upstream bf8de32:

  • Moved to mods/filetree/ in awesome-claude.
  • A desktop version (0.3.0): in the desktop app the pane is drawn with the shared kit, as the other mods are (hooks/desktop.tsx). A header with the folder and its actions as icons, the branch line with ahead/behind and lines changed, the search field, then rows with GitHub's file and folder icons, git status colours, lines changed and dates or sizes in greys, the picked row highlighted, and a footer with the selection (and an open button) and git's latest step. The rows are drawn by the render hook rather than the terminal's Client, which can't draw icons; Claude's reads and writes light their rows (blue for reads, amber for writes, green for commits, with a rail beside them) for as long as the terminal's shimmer runs, and the pane scrolls to them. A press on a row selects it and opens a folder, a second press opens a file; arrow keys stay the terminal's. The terminal draws exactly as before. Changed files say so in their first line.
  • Another mod can draw the tree in a tab of its own pane (0.5.6): attach(on, options, pane) in hooks/register.tsx registers the tree's hooks for that pane, the tree reading nothing until it first draws there. syns' dashboard copies hooks/ for its Files tab.
  • Not brought over: the demo GIFs in media/ (this README links upstream's), the repository's own marketplace, its pre-commit hook and its platforms CI.
  • This README: the fork notice, install from awesome-claude, and this section.
Source 8 files
hooks/register.tsx 1848 lines
1// Changed in this fork (awesome-claude) for the desktop version: the desktop surface draws hooks/desktop.tsx.
2// The terminal draws in the kit's terminal design system: a section header, TREE
3// connectors, the kit's status colours as theme keys, and its note lines.
4//
5// Another mod can draw the tree in a tab of its own pane: it calls `attach` (see
6// there). syns' dashboard does, copying this folder. Every function that takes `$`
7// stays in this file: the engine follows `$` within one file only.
8import { type BuiltinToolResults, type Elements, type EngineInterface, type Hook, type Register, type RenderElement, type RenderInput, type Timer, update } from 'claude-code'
9import { LINUX_LAUNCH, openCommand, openWithCommand } from './open'
10
11import { ago, middle } from '@kit/format'
12import { C, MUTED, termInk, treePrefix } from '@kit/tokens'
13import { type Hint, holdButtons, kit, MARK } from '@kit/ui'
14
15import type { Activity, FileNode, FileTree } from '../types'
16import { renderDesktop, statusColor, toneColor } from './desktop'
17import type { DesktopElements } from './desktop'
18import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
19import type { RowSpec, RowsProps, Seg } from './rows'
20import { fileIcon } from './icons'
21import {
22  ancestorsOf,
23  type Change,
24  dirname,
25  emptyTree,
26  formatSize,
27  inside,
28  isAbsolute,
29  join,
30  mapper,
31  parseGit,
32  parseNumstat,
33  posix,
34  relative,
35  remoteHost,
36  remoteName,
37  HOST_MARK,
38  hostKind,
39  dropBelow,
40  useDrives,
41  rollCounts,
42  replaceChildren,
43  rollUp,
44  toNodes,
45  underAny,
46  visibleRows,
47} from './tree'
48
49const TREE = { plugin: 'filetree', key: 'tree' } as const
50const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
51/** The pane the tree draws in: its own, or a host's (see `attach`). */
52let PANE = 'filetree'
53/** The tab's name. The folder is the section header's title, so the tab doesn't repeat it. */
54const TAB = 'Files'
55/** Each activity tone's terminal colour: the kit's meaning for it (desktop's toneColor), as a theme key. */
56const SHIMMER: Record<string, string> = Object.fromEntries(TONES.map(k => [k, termInk(toneColor(k)).color ?? '']))
57/** The terminal's key hints while the pane has the keyboard, the most used first. */
58const TREE_HINTS: Hint[] = [
59  { key: '↑↓', label: 'move' },
60  { key: '⏎', label: 'open' },
61  { key: '←→', label: 'fold' },
62  { key: 'y', label: 'copy path' },
63]
64const PREVIEW_HINTS: Hint[] = [
65  { key: '↑↓', label: 'scroll' },
66  { key: '←', label: 'back' },
67]
68const BRANCH_ROW = '#branch'
69const FLASH_MS = 2700
70const RUNNING_MAX_MS = 600_000
71const NO_REPO_DEPTH = 4
72const DOUBLE_MS = 450
73const FIND_LIMIT = 200
74const READ_REVEAL_LIMIT = 12
75const SEARCH_REVEAL_LIMIT = 60
76const ACTIVITY_TTL_MS = 45_000
77const FONT_SCRIPT =
78  'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
79  'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
80  '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
81  '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 ' +
82  'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
83  '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; ' +
84  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
85const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
86const HOME_PRUNE = ['Library', 'AppData', '.Trash']
87const SIZE_TIMEOUT_MS = 15_000
88const SIZE_JOBS = 2
89const SIZE_WALK_LIMIT = 50_000
90const ROW_SCROLL_MS = 100
91
92let blink: Timer | null = null
93let generation = 0
94let lastPress = { key: '', at: 0 }
95let noNerd = false
96let glyphSetting = 'auto'
97let openWithSetting = ''
98let previewed: { path: string; text: string; note: string } | null = null
99let previewRows = 20
100let follow = true
101let followClaude = true
102let scanning: Promise<void> | null = null
103let scanJobs: Job[] = []
104let gitRun: Promise<void> | null = null
105let gitNext: Promise<void> | null = null
106let queuedReads: string[] = []
107let showReads = true
108let showWrites = true
109let searchIndex: { root: string; paths: Promise<string[]> } | null = null
110let activityId = 0
111let pointer = true
112let view = { from: 0, max: 0 }
113let nativeScroll = false
114let lastSync = 0
115// The pane opens only when asked for (/filetree), never by itself at session start.
116let noDock = true
117let home = ''
118let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
119let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
120let markId = 0
121let listSeq = 0
122let lastRoot = ''
123let sizeDefault = false
124let sizeEpoch = 0
125let sizeActive = 0
126let sizeQueue: string[] = []
127const sizing = new Set<string>()
128const listLatest = new Map<string, number>()
129const unreadable = new Set<string>()
130const background = new Map<string, Background>()
131
132function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
133  platform ??= (async () => {
134    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
135    try {
136      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
137    } catch {
138      return 'linux'
139    }
140  })()
141  return platform
142}
143
144async function cwdOf($: EngineInterface): Promise<string> {
145  return posix(await $.session.cwd())
146}
147
148function clean(segs: Seg[]): Seg[] {
149  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
150  return segs
151}
152
153async function get($: EngineInterface): Promise<FileTree> {
154  return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
155}
156
157async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
158  await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
159}
160
161function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
162  return put($, t => ({ ...t, ...fn(t) }))
163}
164
165async function activities($: EngineInterface): Promise<Activity[]> {
166  return (await $.state.get(ACTIVITY)).value ?? []
167}
168
169async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
170  await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
171}
172
173async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
174  try {
175    const entries = await $.fs.list(dir)
176    const resolved = await Promise.all(
177      entries.map(async e => {
178        if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
179        try {
180          const target = await $.fs.stat(join(dir, e.name))
181          return target.kind === 'dir'
182            ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
183            : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
184        } catch {
185          return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
186        }
187      }),
188    )
189    unreadable.delete(dir)
190    return toNodes(dir, resolved)
191  } catch (err) {
192    if (!(await exists($, dir))) return []
193    if (!unreadable.has(dir)) {
194      unreadable.add(dir)
195      $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
196    }
197    return null
198  }
199}
200
201async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
202  const root = (await get($)).root
203  const seq = ++listSeq
204  for (const dir of dirs) listLatest.set(dir, seq)
205  const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
206  const listed = new Map<string, FileNode[]>()
207  for (const [dir, kids] of results) {
208    if (listLatest.get(dir) !== seq) continue
209    listLatest.delete(dir)
210    if (kids) listed.set(dir, kids)
211  }
212  await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
213  return listed
214}
215
216async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
217  return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
218}
219
220async function detectRepo($: EngineInterface): Promise<void> {
221  const t = await get($)
222  if (!t.root) return
223  let top = ''
224  let prefix = ''
225  try {
226    const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
227    const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
228    prefix = p.trim()
229    top = tl.trim()
230  } catch {
231    top = ''
232  }
233  await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
234}
235
236async function readGit($: EngineInterface): Promise<void> {
237  const t = await get($)
238  if (!t.root || !t.top) return
239  const root = t.root
240  try {
241    const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
242    if (status.exitCode !== 0) return
243    const parsed = parseGit(status.stdout, root, t.prefix)
244    const diff: Record<string, [number, number]> = {}
245    const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
246    if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
247    const files = { ...parsed.files }
248    if (parsed.untrackedDirs.length) {
249      const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
250      if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
251    }
252    for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
253    const branch = parsed.branch ? { ...parsed.branch, host: await hostOf($, t.top, remoteName(parsed.branch.upstream)) } : null
254    dirty = { root, files }
255    await patch($, cur =>
256      cur.root === root
257        ? {
258            git: parsed.git,
259            ignored: parsed.ignored,
260            untrackedDirs: parsed.untrackedDirs,
261            branch,
262            counts: rollCounts(files, root),
263            diff: rollUp(diff, root),
264          }
265        : {},
266    )
267  } catch {
268    return
269  }
270}
271
272/** Each repo's remotes' hosts, by `top` and remote name: a remote's URL rarely changes, so it's read once a load. */
273const hosts = new Map<string, string>()
274
275/** The host of `remote`'s URL in the repo at `top` (`github.com`), or '' for none. */
276async function hostOf($: EngineInterface, top: string, remote: string): Promise<string> {
277  if (!remote) return ''
278  const id = `${top}\u0000${remote}`
279  const known = hosts.get(id)
280  if (known !== undefined) return known
281  try {
282    const run = await git($, top, ['remote', 'get-url', remote], 5_000)
283    if (run.exitCode !== 0) return ''
284    // Only a successful lookup is cached: a timeout or failure is retried on a later refresh.
285    const host = remoteHost(run.stdout)
286    hosts.set(id, host)
287    return host
288  } catch {
289    return ''
290  }
291}
292
293function refreshGit($: EngineInterface): Promise<void> {
294  if (!gitRun) {
295    gitRun = readGit($).finally(() => {
296      gitRun = null
297    })
298    return gitRun
299  }
300  const rerun = () => {
301    gitNext = null
302    return refreshGit($)
303  }
304  gitNext ??= gitRun.then(rerun, rerun)
305  return gitNext
306}
307
308async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
309  const prev = await get($)
310  generation += 1
311  blink?.cancel()
312  blink = null
313  lastRoot = root
314  searchIndex = null
315  sizeEpoch += 1
316  sizeQueue = []
317  await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
318  if (focus) await $.ui.open({ id: PANE, title: TAB, focus: true })
319  else if (!noDock) await $.ui.open({ id: PANE, title: TAB })
320  await loadDirs($, [root])
321  await detectRepo($)
322  await refreshGit($)
323}
324
325async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
326  const tried = new Set<string>()
327  for (let pass = 0; pass < 32; pass++) {
328    const t = await get($)
329    const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
330    if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
331    const need = new Set<string>()
332    for (const p of paths) {
333      for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
334        if (!loaded.has(dir)) {
335          if (!tried.has(dir)) need.add(dir)
336          break
337        }
338      }
339    }
340    if (need.size === 0) return
341    for (const dir of need) tried.add(dir)
342    await loadDirs($, [...need])
343  }
344}
345
346function pruneArgs(root: string, ignored: string[] = []): string[] {
347  const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
348  return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
349}
350
351async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
352  const stats = await Promise.all(
353    paths.map(async p => {
354      try {
355        return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
356      } catch {
357        return ''
358      }
359    }),
360  )
361  return stats.filter(Boolean)
362}
363
364function broad(root: string): boolean {
365  return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
366}
367
368function rootOfDisk(root: string): boolean {
369  return root === '/' || /^[A-Za-z]:\/$/.test(root)
370}
371
372async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
373  if (rootOfDisk(root)) return []
374  const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
375  try {
376    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 })
377    return run.stdout.split('\0').filter(p => p && p !== root)
378  } catch {
379    return []
380  }
381}
382
383async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
384  if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
385  if (dirty.root !== root) return { hits: [], gone: [] }
386  const entries = Object.entries(dirty.files)
387  const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
388  const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
389  return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
390}
391
392type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
393
394async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
395  const ms = await $.clock.now()
396  const os = await osName($)
397  if (os !== 'darwin' || !writes) return { ms, mark: '', os }
398  const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
399  try {
400    await $.process.run(['touch', mark], { timeoutMs: 3_000 })
401    return { ms, mark, os }
402  } catch {
403    return { ms, mark: '', os }
404  }
405}
406
407async function walkSize($: EngineInterface, dir: string): Promise<number> {
408  let total = 0
409  let seen = 0
410  let level = [dir]
411  while (level.length) {
412    const next: string[] = []
413    for (const d of level) {
414      let entries
415      try {
416        entries = await $.fs.list(d)
417      } catch {
418        continue
419      }
420      for (const e of entries) {
421        if (++seen > SIZE_WALK_LIMIT) return -1
422        if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
423        else if (e.kind === 'file') total += e.size
424      }
425    }
426    level = next
427  }
428  return total
429}
430
431async function dirSize($: EngineInterface, dir: string): Promise<number> {
432  if (rootOfDisk(dir)) return -1
433  if ((await osName($)) === 'win32') return walkSize($, dir)
434  try {
435    const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
436    const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
437    return Number.isFinite(kb) ? kb * 1024 : -1
438  } catch {
439    return -1
440  }
441}
442
443function pumpSizes($: EngineInterface): void {
444  while (sizeActive < SIZE_JOBS && sizeQueue.length) {
445    const dir = sizeQueue.shift() ?? ''
446    const epoch = sizeEpoch
447    sizing.add(dir)
448    sizeActive += 1
449    void dirSize($, dir)
450      .then(async bytes => {
451        if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
452        const cur = await get($)
453        if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
454      })
455      .catch(() => undefined)
456      .finally(() => {
457        sizing.delete(dir)
458        sizeActive -= 1
459        pumpSizes($)
460      })
461  }
462}
463
464function wantSizes($: EngineInterface, dirs: string[]): void {
465  for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
466  pumpSizes($)
467}
468
469async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
470  sizeEpoch += 1
471  sizeQueue = []
472  await patch($, cur => {
473    if (!paths) return { dirSizes: {} }
474    const keep: Record<string, number> = {}
475    for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
476    return { dirSizes: keep }
477  })
478}
479
480function openDirs(t: FileTree): string[] {
481  const open = new Set(t.expanded)
482  return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
483}
484
485function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
486  if (followClaude) return { scroll: null }
487  const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
488  const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
489  return at < 0 ? {} : { scroll: at }
490}
491
492async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
493  const unique = Object.keys(tones)
494  if (unique.length === 0) return
495  const mine = ++generation
496  const root = (await get($)).root
497  blink?.cancel()
498  blink = null
499  await patch($, cur => {
500    if (cur.root !== root) return {}
501    const open = new Set(cur.expanded)
502    const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
503    const dim = new Set<string>()
504    const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
505    if (cur.flashOn) {
506      for (const id of cur.flashDim) dim.add(id)
507    }
508    for (const id of unique) {
509      const tone = tones[id] ?? 'orange'
510      all[id] = tone
511      if (id === BRANCH_ROW) continue
512      const chain = ancestorsOf(id, cur.root)
513      if (!chain.some(a => !open.has(a))) continue
514      for (const a of chain) {
515        if (!open.has(a)) {
516          dim.add(a)
517          all[a] = all[a] ?? tone
518        }
519        open.add(a)
520      }
521    }
522    for (const id of bright) dim.delete(id)
523    return {
524      flash: [...bright],
525      flashDim: [...dim],
526      flashOn: true,
527      flashTones: all,
528      expanded: [...open],
529      ...keepTop(cur, [...open]),
530    }
531  })
532  if (generation !== mine) return
533  // The desktop scrolls the pane itself, so following Claude there means asking it to show the row.
534  if (followClaude) scrollToRow($, [...unique].reverse().find(id => id !== BRANCH_ROW) ?? '')
535  blink = $.clock.after(FLASH_MS, () => {
536    if (generation !== mine) return
537    blink = null
538    void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
539  })
540}
541
542/** On the desktop, brings a row into the pane's view once it's drawn; the terminal scrolls by `scroll`. */
543function scrollToRow($: EngineInterface, id: string): void {
544  if (!nativeScroll || !id) return
545  $.clock.after(ROW_SCROLL_MS, () => void $.ui.scroll({ to: { key: id }, in: PANE }).catch(() => undefined))
546}
547
548async function goUp($: EngineInterface): Promise<void> {
549  follow = false
550  await reset($, dirname((await get($)).root))
551}
552
553async function goHome($: EngineInterface): Promise<void> {
554  follow = true
555  await reset($, await cwdOf($))
556}
557
558async function refreshAll($: EngineInterface): Promise<void> {
559  const cur = await get($)
560  searchIndex = null
561  await staleSizes($)
562  await loadDirs($, [cur.root, ...cur.expanded])
563  await detectRepo($)
564  await refreshGit($)
565  if (cur.query.trim()) await search($, cur.query)
566}
567
568function collapseAll($: EngineInterface): Promise<void> {
569  return patch($, cur => ({ expanded: [], nodes: dropBelow(cur.nodes, cur.nodes.filter(x => x.parent === cur.root && x.kind === 'dir').map(x => x.id)) }))
570}
571
572async function followCwd($: EngineInterface): Promise<boolean> {
573  if (!follow) return false
574  const cwd = await cwdOf($)
575  const t = await get($)
576  if (t.root === cwd) return false
577  await reset($, cwd)
578  return true
579}
580
581type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
582type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
583type Background = { p: Pending | null; job: Job; reads: string[] }
584type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
585type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
586
587const PROOF: Record<string, (op: GitOperation) => boolean> = {
588  push: op => Boolean(op.push),
589  merge: op => op.branch?.action === 'merged',
590  rebase: op => op.branch?.action === 'rebased',
591  'pr create': op => op.pr?.action === 'created',
592  'pr merge': op => op.pr?.action === 'merged',
593  'pr comment': op => op.pr?.action === 'commented',
594}
595
596async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
597  const now = await $.clock.now()
598  const ids = actions.map(() => ++activityId)
599  await setActivities($, cur => [
600    ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
601    ...actions.map((a, i) => ({
602      id: ids[i] ?? 0,
603      kind: a.kind,
604      label: a.running,
605      state: 'running' as const,
606      detail: '',
607      at: now,
608      tone: a.tone,
609      nerd: a.icon.nerd,
610      plain: a.icon.plain,
611    })),
612  ])
613  return ids
614}
615
616async function headOf($: EngineInterface, cwd: string): Promise<string> {
617  try {
618    const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
619    return run.exitCode === 0 ? run.stdout.trim() : ''
620  } catch {
621    return ''
622  }
623}
624
625async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
626  const chain = chainOf(p.command)
627  const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
628  return p.actions.map(a => {
629    if (a.verb === 'commit') {
630      const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
631      return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
632    }
633    if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
634    if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
635    return { state: 'done', label: `ran ${a.kind}`, detail: '' }
636  })
637}
638
639async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
640  const now = await $.clock.now()
641  await setActivities($, cur =>
642    cur.map(a => {
643      const i = p.ids.indexOf(a.id)
644      const result = i < 0 ? undefined : results[i]
645      return result ? { ...a, ...result, at: now } : a
646    }),
647  )
648  $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
649}
650
651function failAll(p: Pending): Outcome[] {
652  return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
653}
654
655function proven(p: Pending | null, results: Outcome[]): GitAction[] {
656  return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
657}
658
659function taskEnds(text: string): [string, string][] {
660  return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
661    const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
662    const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
663    return id && status ? [[id, status] as [string, string]] : []
664  })
665}
666
667async function settleBackground($: EngineInterface, text: string): Promise<void> {
668  if (background.size === 0 || !text.includes('<task-notification>')) return
669  for (const [id, status] of taskEnds(text)) {
670    const task = background.get(id)
671    if (!task) continue
672    background.delete(id)
673    const ok = status === 'completed'
674    const results = task.p ? await outcomes($, task.p, ok, undefined) : []
675    if (task.p) await finishGit($, task.p, results)
676    if (showReads) queuedReads.push(...task.reads)
677    scheduleScan($, { ...task.job, actions: proven(task.p, results) })
678  }
679}
680
681async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
682  const map = mapper(root, prefix)
683  try {
684    const run = committed
685      ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
686      : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
687    return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
688  } catch {
689    return []
690  }
691}
692
693async function gitDir($: EngineInterface, cwd: string): Promise<string> {
694  for (let dir = cwd, i = 0; i < 64; i++) {
695    try {
696      const dot = join(dir, '.git')
697      const st = await $.fs.stat(dot)
698      if (st.kind === 'dir') return dot
699      const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
700      if (ref) return resolve(dir, ref, home)
701    } catch {
702      const up = dirname(dir)
703      if (up === dir) return ''
704      dir = up
705      continue
706    }
707    return ''
708  }
709  return ''
710}
711
712async function sync($: EngineInterface, force = false): Promise<void> {
713  const now = await $.clock.now()
714  if (!force && now - lastSync < 2_000) return
715  lastSync = now
716  searchIndex = null
717  const t = await get($)
718  if (!t.root) return
719  await loadDirs($, [t.root, ...openDirs(t)])
720  if (t.top) await refreshGit($)
721}
722
723async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
724  const t = await get($)
725  const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
726  const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
727  $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
728}
729
730async function exists($: EngineInterface, path: string): Promise<boolean> {
731  try {
732    await $.fs.stat(path)
733    return true
734  } catch {
735    return false
736  }
737}
738
739async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
740  const reads = queuedReads
741  queuedReads = []
742  if (await followCwd($)) return
743  const t = await get($)
744  if (!t.root) return
745  const actions = jobs.flatMap(j => j.actions)
746  const writers = jobs.filter(j => !j.readOnly)
747  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 })
748  const writes = !jobs.every(j => j.readOnly)
749  const before = dirty.root === t.root ? dirty.files : {}
750  if (jobs.some(j => j.initRepo)) await detectRepo($)
751  else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
752  const probed = await get($)
753  if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
754  if (writes) {
755    searchIndex = null
756    await staleSizes($)
757  }
758  const fresh = await get($)
759  const ignored = new Set(fresh.ignored)
760  const tones: Record<string, string> = {}
761  if (writes) {
762    const found = fresh.top
763      ? await changedInRepo($, t.root, since, before, fresh.ignored)
764      : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
765    const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
766    await revealPaths($, hits)
767    const loaded = await get($)
768    const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
769    const listed = await loadDirs($, dirs)
770    if (!fresh.top && since.os === 'win32')
771      for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
772    await patch($, cur => {
773      const ids = new Set(cur.nodes.map(n => n.id))
774      return { expanded: cur.expanded.filter(id => ids.has(id)) }
775    })
776    const present = new Set((await get($)).nodes.map(n => n.id))
777    const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
778    if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
779  }
780  if (showReads) {
781    const found: string[] = []
782    for (const r of reads) {
783      if (found.length >= READ_REVEAL_LIMIT) break
784      if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
785    }
786    if (found.length) {
787      await revealPaths($, found)
788      const shown = new Set((await get($)).nodes.map(n => n.id))
789      for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
790    }
791  }
792  if (showWrites && actions.length) {
793    const final = await get($)
794    const committed = actions.some(a => a.verb === 'commit')
795    if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
796      const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
797      await revealPaths($, paths)
798      const shown = new Set((await get($)).nodes.map(n => n.id))
799      for (const x of paths) if (shown.has(x)) tones[x] = 'green'
800    }
801    const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
802    if (pushLike) tones[BRANCH_ROW] = pushLike.tone
803    else if (committed) tones[BRANCH_ROW] = 'green'
804  }
805  await flash($, tones)
806}
807
808function scheduleScan($: EngineInterface, job: Job): void {
809  scanJobs.push(job)
810  if (scanning) return
811  scanning = (async () => {
812    while (scanJobs.length) {
813      const jobs = scanJobs
814      scanJobs = []
815      try {
816        await afterBash($, jobs)
817      } finally {
818        const marks = jobs.map(j => j.since.mark).filter(Boolean)
819        if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
820      }
821    }
822  })().finally(() => {
823    scanning = null
824  })
825}
826
827async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
828  if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
829  if (await followCwd($)) return
830  const t = await get($)
831  if (!t.root) return
832  const within = paths.map(posix).filter(p => inside(t.root, p))
833  if (within.length === 0) return
834  if (tone !== 'purple') {
835    searchIndex = null
836    await staleSizes($, within)
837    await revealPaths($, within.map(dirname))
838    await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
839    await refreshGit($)
840  } else await revealPaths($, within)
841  if (!show) return
842  const tones: Record<string, string> = {}
843  for (const p of within) tones[p] = tone
844  await flash($, tones)
845}
846
847async function reveal($: EngineInterface, paths: string[]): Promise<void> {
848  if (await followCwd($)) return
849  await revealPaths($, paths)
850  await patch($, cur => {
851    const open = new Set(cur.expanded)
852    for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
853    return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
854  })
855  scrollToRow($, paths[paths.length - 1] ?? '')
856}
857
858async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
859  const out: string[] = []
860  let level = [root]
861  for (let d = 0; d < depth && level.length && out.length < limit; d++) {
862    const next: string[] = []
863    for (const dir of level) {
864      for (const n of (await list($, dir)) ?? []) {
865        if (n.kind === 'dir') {
866          if (!PRUNE.includes(n.name)) next.push(n.id)
867        } else out.push(n.id)
868      }
869      if (out.length >= limit) break
870    }
871    level = next
872  }
873  return out
874}
875
876async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
877  try {
878    if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
879    const run = t.top
880      ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
881      : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
882    if (t.top && run.exitCode !== 0) return null
883    const paths = run.stdout.split('\0').filter(Boolean)
884    if (run.isStdoutTruncated) paths.pop()
885    return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
886  } catch {
887    return null
888  }
889}
890
891function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
892  if (searchIndex?.root !== t.root) {
893    const index: { root: string; paths: Promise<string[]> } = {
894      root: t.root,
895      paths: listAll($, t).then(paths => {
896        if (paths === null && searchIndex === index) searchIndex = null
897        return paths ?? []
898      }),
899    }
900    searchIndex = index
901  }
902  return searchIndex.paths
903}
904
905async function search($: EngineInterface, query: string): Promise<string[]> {
906  if (!(await get($)).query.trim()) searchIndex = null
907  await patch($, () => ({ query }))
908  const q = query.trim().toLowerCase()
909  if (!q) return []
910  const t = await get($)
911  const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
912  if ((await get($)).query !== query) return []
913  await revealPaths($, hits)
914  const shown = await get($)
915  const ids = new Set(shown.nodes.map(n => n.id))
916  const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
917  if (stale.length) await loadDirs($, stale)
918  return hits
919}
920
921async function jump($: EngineInterface, query: string): Promise<void> {
922  const hits = (await search($, query)).slice(0, 10)
923  await patch($, cur => {
924    const open = new Set(cur.expanded)
925    for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
926    return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
927  })
928  scrollToRow($, hits[0] ?? '')
929}
930
931async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
932  try {
933    const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
934    return out === 'stale' || out === 'missing' ? out : 'ok'
935  } catch {
936    return 'missing'
937  }
938}
939
940async function finePointerOk($: EngineInterface): Promise<boolean> {
941  if (!(await $.env.get('HERDR_ENV'))) return true
942  try {
943    const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
944    const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
945    return major * 1e6 + minor * 1e3 + fix >= 9_001
946  } catch {
947    return false
948  }
949}
950
951const PREVIEW_CHARS = 90_000
952const PREVIEW_LINE = 2_000
953
954function fitSerialized(lines: string[], max: number): string[] {
955  let size = 0
956  for (let i = 0; i < lines.length; i++) {
957    size += JSON.stringify(lines[i]).length
958    if (size > max) return lines.slice(0, i)
959  }
960  return lines
961}
962
963async function loadPreview($: EngineInterface, path: string): Promise<void> {
964  let text = ''
965  let note = ''
966  try {
967    const stat = await $.fs.stat(path)
968    if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
969    else {
970      text = await $.fs.read(path)
971      if (text.slice(0, 8000).includes('\0')) {
972        text = ''
973        note = `binary file, ${formatSize(stat.size)}`
974      }
975    }
976  } catch (err) {
977    note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
978  }
979  const total = text.length
980  let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
981  if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
982  lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
983  const fit = fitSerialized(lines, PREVIEW_CHARS)
984  if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
985  previewed = { path, text: fit.join('\n'), note }
986}
987
988async function showPreview($: EngineInterface, path: string): Promise<void> {
989  await loadPreview($, path)
990  await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
991}
992
993async function closePreview($: EngineInterface): Promise<void> {
994  await patch($, () => ({ preview: '', previewTop: 0 }))
995}
996
997async function scrollPreview($: EngineInterface, by: number): Promise<void> {
998  const t = await get($)
999  const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
1000  const top = Math.max(0, Math.min(last, t.previewTop + by))
1001  if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
1002}
1003
1004function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
1005  const lines = text.split('\n')
1006  const from = Math.max(0, Math.min(top, lines.length - 1))
1007  let shown = lines.slice(from, from + rows)
1008  if (markdown) {
1009    // Reopen a code fence the window starts inside, so the rest is not read as prose.
1010    let fence = ''
1011    let opener = ''
1012    for (const line of lines.slice(0, from)) {
1013      const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
1014      if (!m) continue
1015      const mark = m[1] ?? ''
1016      if (!fence) {
1017        fence = mark
1018        opener = line.trimStart()
1019      } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
1020    }
1021    if (fence) shown = [opener, ...shown]
1022  }
1023  return { text: shown.join('\n'), top: from }
1024}
1025
1026async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
1027  if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
1028  const os = await osName($)
1029  const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
1030  const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
1031  const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
1032  try {
1033    const run = await $.process.run(argv, init)
1034    if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1035  } catch {
1036    $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
1037  }
1038}
1039
1040async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1041  if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1042  await patch($, t => {
1043    const open = new Set(t.expanded)
1044    if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1045    return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1046  })
1047}
1048
1049async function press($: EngineInterface, n: FileNode): Promise<void> {
1050  const now = await $.clock.now()
1051  const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1052  lastPress = { key: isDouble ? '' : n.id, at: now }
1053  if (!isDouble) {
1054    await toggle($, n)
1055    return
1056  }
1057  await openNode($, n)
1058}
1059
1060async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1061  if (n.kind === 'dir') {
1062    follow = false
1063    await reset($, n.id)
1064  } else await openFile($, n.id)
1065}
1066
1067function shortPath(path: string): string {
1068  return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1069}
1070
1071/**
1072 * The tree's first look in a session: whether the pointer is fine, whether a Nerd
1073 * Font is there, and what the last session left (a flash, a preview, git steps
1074 * still running) cleared.
1075 */
1076async function firstLook($: EngineInterface): Promise<void> {
1077  pointer = await finePointerOk($)
1078  try {
1079    const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1080    noNerd = !remote && ((await $.env.get('OS')) === 'Windows_NT' || (await fontState($, 'f04eb')) !== 'ok')
1081  } catch {
1082    noNerd = true
1083  }
1084  const t = await get($)
1085  if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1086  if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1087  await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1088}
1089
1090/** The pane of a mod that draws the tree in its own pane (a tab of it) rather than filetree's; null in filetree's own. */
1091let host: string | null = null
1092/** Whether a host's tree is on screen: set as it draws, cleared by `hideTree`. */
1093let onScreen = false
1094/** Whether a host's tree has caught up since it came on screen. */
1095let caughtUp = false
1096/** Whether a host's tree has made the session's reads and its first look. */
1097let looked = false
1098/** A host's pinned folder (`pinTree`); null, the tree follows the cwd. */
1099let pinned: string | null = null
1100
1101/** The reads every session needs before the tree reads anything: the OS, home, the last activity's id. */
1102async function sessionReads($: EngineInterface): Promise<void> {
1103  useDrives((await $.env.get('OS')) === 'Windows_NT')
1104  home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1105  activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1106}
1107
1108/**
1109 * A host's tree just drew, so it's on screen. The first draw since it came on
1110 * screen catches up, on a timer since a drawing writes nothing: the session's
1111 * reads and the first look the first time, then the folder listed (the cwd, or
1112 * the pinned one) when the tree isn't on it, or what changed while it was off.
1113 */
1114function drawnInHost($: EngineInterface): void {
1115  onScreen = true
1116  if (caughtUp) return
1117  caughtUp = true
1118  $.clock.after(0, () => void catchUp($).catch(() => undefined))
1119}
1120
1121async function catchUp($: EngineInterface): Promise<void> {
1122  if (!looked) {
1123    looked = true
1124    await sessionReads($)
1125    await firstLook($)
1126  }
1127  const cwd = await cwdOf($)
1128  follow = pinned === null
1129  const want = pinned === null ? cwd : resolve(cwd, pinned, home)
1130  const t = await get($)
1131  if (t.root !== want || t.nodes.length === 0) await reset($, want)
1132  else await sync($)
1133}
1134
1135/** For a host: the tree went off screen (another tab, the pane hidden); its hooks pass through until it draws again. */
1136export function hideTree(): void {
1137  onScreen = false
1138  caughtUp = false
1139}
1140
1141/** For a host: the folder the tree shows from its next draw (`root`, pinned), or the cwd, followed (null). */
1142export function pinTree(root: string | null): void {
1143  pinned = root
1144  caughtUp = false
1145}
1146
1147/** In a host's pane while the tree is off screen: every hook then passes straight through. */
1148function hidden(): boolean {
1149  return host !== null && !onScreen
1150}
1151
1152/** The tree's watch on Claude's edits, writes and Bash commands (git steps, files changed, reads). */
1153const watchTool: Hook<'tool.call'> = async ($, e, next) => {
1154  {
1155    if (hidden()) return next(e)
1156    if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash') return next(e)
1157    const command = e.tool === 'Bash' ? e.command : ''
1158    const cwd = e.tool === 'Bash' ? await cwdOf($) : ''
1159    const actions = gitActions(command)
1160    const quiet = !actions.length && readOnly(command)
1161    const since = await sinceNow($, e.tool === 'Bash' && !quiet)
1162    const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1163    const head = before?.top ? await headOf($, before.root) : ''
1164    const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1165    let result: Awaited<ReturnType<typeof next>>
1166    try {
1167      result = await next(e)
1168    } catch (err) {
1169      if (pending) await finishGit($, pending, failAll(pending))
1170      if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1171      throw err
1172    }
1173    if (pending && result.deny) {
1174      const ids = pending.ids
1175      void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1176    }
1177    if (result.deny || (result.isError && e.tool !== 'Bash')) {
1178      if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1179      return result
1180    }
1181    if (e.tool === 'Bash') {
1182      const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1183      const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1184      const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1185      if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1186      else void (async () => {
1187        const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1188        if (pending) await finishGit($, pending, results)
1189        queuedReads.push(...reads)
1190        scheduleScan($, { ...job, actions: proven(pending, results) })
1191      })()
1192      if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1193    } else {
1194      const file =
1195        'file_path' in e && typeof e.file_path === 'string'
1196          ? e.file_path
1197          : 'notebook_path' in e && typeof e.notebook_path === 'string'
1198            ? e.notebook_path
1199            : ''
1200      if (file) void touched($, [file], 'orange', showWrites)
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/desktop.tsx 425 lines
1// New in this fork (awesome-claude): the desktop version of the pane, drawn with the shared kit.
2import type { Elements, RenderElement } from 'claude-code'
3
4import { ago } from '@kit/format'
5import { C, FILE_TYPE, GLYPH, GRAY, TRACK } from '@kit/tokens'
6import type { FileType } from '@kit/tokens'
7import { EDGE, SPACE, kit } from '@kit/ui'
8
9import type { Activity, FileTree } from '../types'
10import { dirname, formatSize, HOST_ICON, hostKind, remoteName, type Row, underAny } from './tree'
11
12/*
13 * The desktop's file tree, as the other mods' desktop panes draw: a header line
14 * with the folder and its actions as icons, the branch line, the search field,
15 * then one row per file or folder (indent, fold arrow, Octicon, name, figures on
16 * the right), and a footer with the selection and git activity.
17 *
18 * The rows are drawn here, not by the `Client` the terminal uses (rows.tsx): a
19 * surface module can't draw `Svg`, and the rows' icons are Octicons. A press on
20 * a row's name selects it and opens a folder, twice opens it, as a click does in
21 * the terminal; Claude's reads and writes light their rows for as long as the
22 * terminal's shimmer runs.
23 */
24
25export type DesktopElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Input' | 'Svg'>
26
27/** What the desktop pane draws from, besides the tree itself. */
28export type DesktopView = {
29  t: FileTree
30  rows: Row[]
31  /** The git activity shown in the footer: the running one, else the latest. */
32  latest: Activity | undefined
33  /** Whether the tree follows the session's cwd (the home button lit). */
34  follow: boolean
35  now: number
36  columns: number
37  /** The pane's height in rows, so the footer sits on its bottom edge. */
38  bodyRows: number
39  /** A path relative to the tree's root, or shortened under home. */
40  shown: (path: string) => string
41  /** The id the branch line flashes under. */
42  branchRow: string
43}
44
45/** What the pane's controls do; register.tsx runs them with its `$`. */
46export type DesktopActions = {
47  up: () => void
48  home: () => void
49  refresh: () => void
50  hidden: () => void
51  sizes: () => void
52  collapse: () => void
53  unselect: () => void
54  press: (id: string) => void
55  open: (id: string) => void
56  search: (query: string) => void
57  jump: (query: string) => void
58}
59
60/** The branch name's least width in cells, however many figures share its line. */
61const HEAD_MIN = 6
62
63/** An icon button's label: two Braille blanks, about the icon's two cells, drawn as nothing. */
64export const BLANK = '⠀⠀'
65
66/** The most rows drawn: a tree past it would outgrow what the desktop takes in one drawing. */
67export const MAX_ROWS = 1500
68
69const CODE = new Set(
70  'ts tsx mts cts js jsx mjs cjs py rs go rb java kt kts swift c h cc cpp hpp cs php sh bash zsh fish lua sql html htm css scss sass less vue svelte zig ex exs erl hs ml scala dart r jl pl ps1 nix'.split(' '),
71)
72
73/** A file's Octicon: code for source files, a page for anything else. */
74export const fileIcon = (name: string, kind: string, open: boolean) => {
75  if (kind === 'dir') return open ? 'folder-open' : 'folder'
76  const ext = name.includes('.') ? (name.split('.').pop() ?? '').toLowerCase() : ''
77  return CODE.has(ext) ? 'file-code' : 'file'
78}
79
80/** Extensions by the file type IntelliJ's New UI draws them as (its `fileTypes` icons). */
81const TYPE_BY_EXT = new Map<string, FileType>()
82for (const [type, exts] of Object.entries({
83  typescript: 'ts tsx mts cts',
84  javascript: 'js jsx mjs cjs',
85  json: 'json jsonc json5',
86  markdown: 'md mdx markdown',
87  yaml: 'yml yaml',
88  config: 'toml ini cfg conf properties env editorconfig',
89  style: 'css scss sass less',
90  html: 'html htm xhtml',
91  xml: 'xml xsd xsl plist',
92  data: 'csv tsv',
93  sql: 'sql',
94  image: 'png jpg jpeg gif webp ico bmp svg avif',
95  archive: 'zip tar gz tgz bz2 xz 7z rar jar',
96} satisfies Partial<Record<FileType, string>>))
97  for (const ext of exts.split(' ')) TYPE_BY_EXT.set(ext, type as FileType)
98
99/** Names IntelliJ draws as config whatever their extension. */
100const CONFIG_NAMES = new Set(['dockerfile', '.editorconfig', '.env', '.npmrc', '.nvmrc'])
101
102/**
103 * A row's file type, for its icon's colour (`FILE_TYPE`): a folder git ignores is
104 * excluded (node_modules, build output), any other folder grey, a file by its
105 * extension; lockfiles (`bun.lock`, `yarn.lock`) and anything unknown are text.
106 */
107export const fileType = (name: string, kind: string, excluded = false): FileType => {
108  if (kind === 'dir') return excluded ? 'excluded' : 'folder'
109  const lower = name.toLowerCase()
110  if (CONFIG_NAMES.has(lower) || lower.startsWith('.env.')) return 'config'
111  const ext = lower.includes('.') ? (lower.split('.').pop() ?? '') : ''
112  return TYPE_BY_EXT.get(ext) ?? 'text'
113}
114
115/**
116 * A git status letter's colour, in the kit's meanings: modified amber, new
117 * green, deleted or conflicted red, renamed or copied blue.
118 */
119export const statusColor = (status: string | undefined): string | undefined => {
120  if (!status) return undefined
121  if (status === 'D' || status === 'U') return C.bad
122  if (status === 'A' || status === '?') return C.ok
123  if (status === 'M' || status === 'T') return C.warn
124  if (status === 'R' || status === 'C') return C.blue
125  return GRAY.mid
126}
127
128/**
129 * The colour Claude's activity lights a row in. On a row: reads blue (live),
130 * writes amber (changed, as git marks a modified file), commits green, failures
131 * red. On the branch line purple is a merge, as everywhere in the kit.
132 */
133export const toneColor = (tone: string, branch = false): string => {
134  if (tone === 'green') return C.ok
135  if (tone === 'red') return C.bad
136  if (tone === 'orange') return C.warn
137  if (tone === 'purple') return branch ? C.purple : C.blue
138  return C.blue
139}
140
141/** An activity's Octicon, by what git or gh did. */
142export const activityIcon = (a: Activity) => {
143  const kind = a.kind
144  if (/\bcommit\b/.test(kind)) return 'git-commit'
145  if (/\bpush\b/.test(kind)) return 'repo-push'
146  if (/\b(pull|fetch|clone)\b/.test(kind)) return 'sync'
147  if (/\b(merge|rebase|cherry-pick)\b/.test(kind)) return 'pr-merged'
148  if (/^gh pr\b/.test(kind)) return 'pr-open'
149  if (/^gh issue\b/.test(kind)) return 'issue-opened'
150  if (/\b(reset|restore|revert|rm)\b/.test(kind)) return 'alert'
151  return GLYPH.branch
152}
153
154const activityColor = (a: Activity) => (a.state === 'failed' ? C.bad : a.state === 'running' ? C.blue : C.ok)
155
156export const renderDesktop = (els: DesktopElements, v: DesktopView, act: DesktopActions): RenderElement => {
157  const { Box, Text, Button, Input } = els
158  const { t } = v
159  const k = kit(els, 'desktop', v.columns)
160  const bright = new Set(t.flashOn ? t.flash : [])
161  const dimmed = new Set(t.flashOn ? t.flashDim : [])
162  const ignored = new Set(t.ignored)
163  const untracked = new Set(t.untrackedDirs)
164
165  /** An icon in a fixed column, so every name starts at one x. */
166  const lead = (key: string, icon: RenderElement) => (
167    <Box key={key} width={2} flexShrink={0} flexDirection="row" alignItems="center">
168      {icon}
169    </Box>
170  )
171
172  /**
173   * An action drawn as its icon. The desktop draws a Button as its label text and
174   * no children, so the icon (an SVG, named by `label` as its alt) sits in a box
175   * and a Button with a blank label the icon's size lies over it: a press anywhere
176   * on the icon is a press of the Button, its key the action's address. The label
177   * is two Braille blanks (U+2800): they draw nothing, as spaces do, but no
178   * renderer trims or collapses them.
179   */
180  const iconButton = (key: string, label: string, icon: string, color: string, onPress: () => void) => (
181    <Box key={`ib-${key}`} position="relative" width={2} height={1} flexShrink={0} flexDirection="row" alignItems="center" justifyContent="center">
182      {k.icon(icon, color, undefined, { alt: label })}
183      <Box position="absolute" top={0} left={0}>
184        <Button key={key} plain label={BLANK} onPress={onPress} />
185      </Box>
186    </Box>
187  )
188
189  /** A row: the left side gives way (truncates), the right side keeps its figures. */
190  const line = (key: string, left: (RenderElement | null)[], right: (RenderElement | null)[], extra: Record<string, unknown> = {}) => (
191    <Box key={key} flexDirection="row" justifyContent="space-between" alignItems="center" gap={SPACE.item} width="100%" {...extra}>
192      <Box flexDirection="row" alignItems="center" flexShrink={1} minWidth={0} overflow="hidden">
193        {left}
194      </Box>
195      <Box flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0}>
196        {right}
197      </Box>
198    </Box>
199  )
200
201  /** A folder's file counts: new, modified, deleted, each its letter dim and its figure in its colour. */
202  const counts = (key: string, c: [number, number, number] | undefined): RenderElement[] => {
203    if (!c) return []
204    const parts: [string, number, string][] = [
205      ['?', c[0], C.ok],
206      ['M', c[1], C.warn],
207      ['D', c[2], C.bad],
208    ]
209    return parts
210      .filter(([, n]) => n > 0)
211      .map(([letter, n, color]) => (
212        <Text key={`${key}-${letter}`}>
213          <Text color={GRAY.dark}>{letter}</Text>
214          <Text color={color}>{String(n)}</Text>
215        </Text>
216      ))
217  }
218
219  const lines = (key: string, loc: [number, number] | undefined): RenderElement[] => [
220    ...(loc && loc[0] > 0 ? [<Text key={`${key}-add`} color={C.ok}>{`+${loc[0]}`}</Text>] : []),
221    ...(loc && loc[1] > 0 ? [<Text key={`${key}-del`} color={C.bad}>{`−${loc[1]}`}</Text>] : []),
222  ]
223
224  // The header: the folder, then its actions.
225  const name = t.top ? [t.top.split('/').pop() ?? t.top, t.prefix.replace(/\/$/, '')].filter(Boolean).join('/') : t.root.split('/').pop() || t.root
226  const header = line(
227    'head',
228    [
229      lead('icon', k.icon('folder-open', GRAY.mid)),
230      <Text key="name" bold wrap="truncate">
231        {name}
232      </Text>,
233    ],
234    [
235      iconButton('up', 'Up a folder', 'arrow-up', GRAY.mid, act.up),
236      iconButton('cwd', 'Follow the working directory', 'home', v.follow ? GRAY.light : GRAY.dark, act.home),
237      iconButton('refresh', 'Refresh', 'sync', GRAY.mid, act.refresh),
238      iconButton('hidden', t.showHidden ? 'Hide hidden files' : 'Show hidden files', 'eye', t.showHidden ? GRAY.light : GRAY.dark, act.hidden),
239      iconButton('size', t.showSize ? 'Show dates' : 'Show sizes', GLYPH.context, t.showSize ? GRAY.light : GRAY.dark, act.sizes),
240      iconButton('collapse', 'Collapse all', 'fold', GRAY.mid, act.collapse),
241      t.selected ? iconButton('unselect', 'Clear the selection', GLYPH.failed, GRAY.mid, act.unselect) : null,
242    ],
243    { marginBottom: SPACE.block },
244  )
245
246  // The branch: its name, ahead and behind, then the lines and files changed.
247  const branch = () => {
248    if (!t.top) {
249      return line('branch', [lead('icon', k.icon(GLYPH.branch, GRAY.dark)), <Text key="none" color={GRAY.mid} wrap="truncate">no git repo · git status starts after git init</Text>], [], { marginBottom: SPACE.block })
250    }
251    if (!t.branch) return null
252    const b = t.branch
253    const flashing = bright.has(v.branchRow)
254    const tone = flashing ? toneColor(t.flashTones[v.branchRow] ?? 'teal', true) : undefined
255    const totals = t.diff[t.root] ?? [0, 0]
256    const rootCounts = counts('root', t.counts[t.root])
257    const changed = [...lines('root', totals), ...rootCounts]
258    return line(
259      'branch',
260      [
261        lead('icon', k.icon(GLYPH.branch, tone ?? GRAY.mid)),
262        // Never squeezed out: the figures on the right leave it a few letters (fewer when
263        // the name is shorter, so a short name doesn't leave a gap after it).
264        <Box key="head" flexShrink={1} minWidth={Math.min(HEAD_MIN, [...b.head].length)}>
265          <Text color={tone} wrap="truncate">
266            {b.head}
267          </Text>
268        </Box>,
269        b.ahead > 0 ? <Text key="ahead" color={GRAY.mid}>{` ↑${b.ahead}`}</Text> : null,
270        b.behind > 0 ? <Text key="behind" color={GRAY.mid}>{` ↓${b.behind}`}</Text> : null,
271        // The upstream as its host's icon (GitHub, GitLab, Forgejo, else a remote), its
272        // name the icon's tooltip; none when the branch tracks nothing.
273        // A local upstream (`main...main`, no remote) has no host to show.
274        b.upstream && remoteName(b.upstream) ? (
275          <Box key="upstream" flexShrink={0} marginLeft={SPACE.pair} flexDirection="row" alignItems="center">
276            {k.icon(HOST_ICON[hostKind(b.host)], GRAY.dark, undefined, { tooltip: b.upstream })}
277          </Box>
278        ) : null,
279      ],
280      changed.length ? changed : [<Text key="clean" color={GRAY.mid}>clean</Text>],
281      { marginBottom: SPACE.block },
282    )
283  }
284
285  // The search field, the pane's first row, as wide as the pane. Its submit ("jump":
286  // Enter opens the first match and clears the field) is drawn by the desktop as a
287  // button beside it: every Input has one there, labelled "submit" if not named.
288  const search = (
289    <Box key="search" flexDirection="row" alignItems="center" gap={SPACE.pair} width="100%" marginBottom={SPACE.block}>
290      {Input ? (
291        // A column, so the field stretches across the box.
292        <Box key="field" flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
293          <Input key="q" placeholder="Search files" submitLabel="jump" autoFocus value={t.query} onInput={(q: string) => act.search(q)} onSubmit={(q: string) => act.jump(q)} />
294        </Box>
295      ) : null}
296      {t.query ? iconButton('clear', 'Clear the search', GLYPH.failed, GRAY.mid, () => act.search('')) : null}
297    </Box>
298  )
299
300  // One file or folder.
301  const row = (r: Row) => {
302    const n = r.node
303    const own = t.git[n.id]
304    const status = own ?? (underAny(dirname(n.id), untracked, t.root) ? '?' : undefined)
305    const isIgnored = !status && underAny(n.id, ignored, t.root)
306    const isBright = bright.has(n.id)
307    const isDim = !isBright && dimmed.has(n.id)
308    const tone = isBright || isDim ? toneColor(t.flashTones[n.id] ?? 'orange') : undefined
309    const muted = isIgnored || n.hidden
310    // The icon in its file type's colour (IntelliJ's), a folder git ignores in the excluded tone;
311    // Claude's activity lights it in the activity's colour instead.
312    const iconColor = tone ?? FILE_TYPE[fileType(n.name, n.kind, isIgnored)]
313    const loc = t.diff[n.id]
314    const meta = t.showSize
315      ? n.kind === 'dir'
316        ? n.id in t.dirSizes
317          ? formatSize(t.dirSizes[n.id] ?? -1)
318          : '…'
319        : n.kind === 'file' || (n.kind === 'link' && n.size > 0)
320          ? formatSize(n.size)
321          : ''
322      : loc || n.kind !== 'file' || !n.mtime
323        ? ''
324        : ago(v.now - n.mtime)
325    const dirCounts = n.kind === 'dir' ? counts(n.id, t.counts[n.id]) : []
326    const isCursor = n.id === t.cursor
327    // A Button sizes to its label, so the name is cut to the room left here, with an ellipsis,
328    // rather than clipped mid-letter: the pane less the indent, arrow, icon, figures and badge.
329    const c = n.kind === 'dir' ? t.counts[n.id] : undefined
330    const figures = [meta, loc?.[0] ? `+${loc[0]}` : '', loc?.[1] ? `−${loc[1]}` : '', ...(c ?? []).filter(x => x > 0).map(x => `?${x}`)].filter(Boolean)
331    const room = Math.floor(v.columns - EDGE.left - EDGE.right - r.depth * SPACE.item - SPACE.item * 2 - 2 - figures.join(' ').length - SPACE.pair)
332    const label = n.name.length > Math.max(4, room) ? `${n.name.slice(0, Math.max(3, room - 1))}…` : n.name
333    return (
334      <Box key={`row-${n.id}`} flexDirection="row" alignItems="center" width="100%" backgroundColor={isCursor ? TRACK : undefined}>
335        {/* A rail in the activity's colour while Claude reads or writes it. */}
336        <Box width={0.25} height={1} flexShrink={0} backgroundColor={isBright ? tone : undefined} />
337        <Box width={EDGE.left - 0.25 + r.depth * SPACE.item} flexShrink={0} />
338        <Box width={SPACE.item} flexShrink={0}>
339          <Text color={GRAY.dark}>{n.kind === 'dir' ? (r.open ? '▾' : '▸') : ' '}</Text>
340        </Box>
341        {lead('icon', k.icon(fileIcon(n.name, n.kind, r.open), iconColor))}
342        <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
343          {/* The desktop draws a Button as its label, plain, never its children, so a name can't
344              take its git colour or a weight here: it's dim when ignored or hidden, the git letter
345              on the right says its state, the cursor's tint and the footer say what's selected. */}
346          <Button key={n.id} plain label={label} {...(muted ? { dimColor: true } : {})} onPress={() => act.press(n.id)} />
347        </Box>
348        <Box flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0} marginLeft={SPACE.pair}>
349          {meta ? <Text color={meta === '…' ? GRAY.dark : GRAY.mid}>{meta}</Text> : null}
350          {lines(n.id, loc)}
351          {dirCounts}
352        </Box>
353        <Box width={SPACE.item + EDGE.right} flexShrink={0} flexDirection="row" justifyContent="center">
354          {dirCounts.length || !status ? null : <Text color={statusColor(status)}>{status}</Text>}
355        </Box>
356      </Box>
357    )
358  }
359
360  const drawn = v.rows.slice(0, MAX_ROWS)
361  const tree = (
362    <Box key="tree" flexDirection="column" width="100%">
363      {v.rows.length === 0 ? (
364        <Box paddingLeft={EDGE.left}>
365          <Text color={GRAY.mid}>{t.query ? 'no match' : 'empty'}</Text>
366        </Box>
367      ) : null}
368      {drawn.map(row)}
369      {v.rows.length > drawn.length ? (
370        <Box paddingLeft={EDGE.left}>
371          <Text color={GRAY.mid}>{`${v.rows.length - drawn.length} more; search to find them`}</Text>
372        </Box>
373      ) : null}
374    </Box>
375  )
376
377  // The footer: what's selected (Claude sees it as context), and git's latest step.
378  const chip = (a: Activity) => (
379    <Box key="chip" flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={0}>
380      {k.icon(activityIcon(a), activityColor(a))}
381      <Text color={a.state === 'running' ? C.blue : a.state === 'failed' ? C.bad : GRAY.light}>{a.state === 'running' ? `${a.label}…` : a.label}</Text>
382      {a.state === 'done' && a.kind === 'git commit' && a.detail ? <Text color={GRAY.dark}>{a.detail.split(' ')[0] ?? ''}</Text> : null}
383    </Box>
384  )
385  const selected = t.selected
386  const footer =
387    selected || v.latest
388      ? line(
389          'foot',
390          selected
391            ? [
392                lead('icon', k.icon('file', GRAY.dark)),
393                <Box key="path" flexShrink={1} minWidth={0}>
394                  <Text color={GRAY.mid} wrap="truncate-start">
395                    {v.shown(selected)}
396                  </Text>
397                </Box>,
398                <Box key="open" flexShrink={0} marginLeft={SPACE.pair}>
399                  <Button key="open" plain dimColor label="open" onPress={() => act.open(selected)} />
400                </Box>,
401              ]
402            : [],
403          [v.latest ? chip(v.latest) : null],
404          { marginTop: SPACE.block },
405        )
406      : null
407
408  return (
409    <Box flexDirection="column" minHeight={Math.max(1, v.bodyRows)} paddingTop={SPACE.block}>
410      <Box flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right}>
411        {search}
412        {header}
413        {branch()}
414      </Box>
415      {tree}
416      <Box flexGrow={1} />
417      {footer ? (
418        <Box flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right}>
419          {footer}
420        </Box>
421      ) : null}
422    </Box>
423  )
424}
425
hooks/git.ts 259 lines
1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4  kind: string
5  verb: string
6  running: string
7  done: string
8  tone: string
9  icon: { nerd: string; plain: string }
10  init?: boolean
11}
12
13const ICON = {
14  commit: { nerd: '\u{f417}', plain: '●' },
15  push: { nerd: '\u{f0552}', plain: '↑' },
16  pull: { nerd: '\u{f0553}', plain: '↓' },
17  branch: { nerd: '\u{f418}', plain: '⑂' },
18  merge: { nerd: '\u{f419}', plain: '⑃' },
19  pr: { nerd: '\u{f407}', plain: '⇄' },
20  github: { nerd: '\u{f408}', plain: '◎' },
21  stash: { nerd: '\u{f01bc}', plain: '≡' },
22  undo: { nerd: '\u{f054c}', plain: '↶' },
23  tag: { nerd: '\u{f412}', plain: '⌖' },
24  git: { nerd: '\u{e702}', plain: '±' },
25}
26
27/**
28 * The activity tones a lit row or git step takes. Changed in this fork: the names
29 * only, no hex ramps; each draws in the kit's colour for its meaning (`toneColor`
30 * in desktop.tsx), a theme key on the terminal through the kit's `termInk`.
31 */
32export const TONES = ['orange', 'green', 'teal', 'blue', 'purple', 'cyan', 'red'] as const
33
34const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
35  commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
36  push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
37  pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
38  fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
39  checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
40  switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
41  branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
42  merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
43  rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
44  'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
45  stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
46  reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
47  restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
48  revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
49  add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
50  rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
51  mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
52  tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
53  init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
54  clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
55}
56
57const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
58  'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
59  'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
60  'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
61  'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
62  'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
63  'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
64  'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
65  'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
66}
67
68const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
69
70function segments(command: string, seps: string[] = []): string[][] {
71  const out: string[][] = []
72  let cur: string[] = []
73  let tok = ''
74  let quote = ''
75  let has = false
76  const endTok = () => {
77    if (has) cur.push(tok)
78    tok = ''
79    has = false
80  }
81  const endSeg = () => {
82    endTok()
83    if (cur.length) out.push(cur)
84    cur = []
85  }
86  for (let i = 0; i < command.length; i++) {
87    const ch = command[i] ?? ''
88    if (quote) {
89      if (ch === quote) quote = ''
90      else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
91      else tok += ch
92    } else if (ch === '"' || ch === "'") {
93      quote = ch
94      has = true
95    } else if (ch === '\\' && i + 1 < command.length) {
96      tok += command[++i]
97      has = true
98    } else if (ch === '#' && !has) {
99      while (i + 1 < command.length && command[i + 1] !== '\n') i++
100    } else if (ch === ' ' || ch === '\t') endTok()
101    else if (ch === '\n' || ch === ';') {
102      seps.push(';')
103      endSeg()
104    } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
105      tok += ch
106      has = true
107    } else if (ch === '&' || ch === '|') {
108      const twice = command[i + 1] === ch
109      if (twice) i++
110      seps.push(twice ? ch + ch : ch)
111      endSeg()
112    } else {
113      tok += ch
114      has = true
115    }
116  }
117  endSeg()
118  if (!quote) return out
119  seps.push(';')
120  return command
121    .split(/&&|\|\||;|\||\n/)
122    .map(s => s.trim().split(/\s+/).filter(Boolean))
123    .filter(s => s.length > 0)
124}
125
126function stripGlobals(tokens: string[]): string[] {
127  const out = [...tokens]
128  if (out[0] === 'env') out.shift()
129  while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
130  return out
131}
132
133function gitVerb(tokens: string[]): number {
134  let i = 1
135  while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
136    if (tokens[i] === '-C' || tokens[i] === '-c') i++
137    i++
138  }
139  return i
140}
141
142function gitReadOnly(verb: string, rest: string[]): boolean {
143  if (!verb || READ_ONLY_GIT.has(verb)) return true
144  if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
145  if (verb === 'branch') return rest.every(t => t.startsWith('-'))
146  if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
147  return false
148}
149
150export function chainOf(command: string): { size: number; and: boolean } {
151  const seps: string[] = []
152  const size = segments(command, seps).length
153  return { size, and: seps.every(s => s === '&&') }
154}
155
156export function gitActions(command: string): GitAction[] {
157  const out: GitAction[] = []
158  for (const raw of segments(command)) {
159    const tokens = stripGlobals(raw)
160    const head = tokens[0]?.split('/').pop()
161    if (head === 'git') {
162      const i = gitVerb(tokens)
163      const verb = tokens[i] ?? ''
164      const rest = tokens.slice(i + 1)
165      if (gitReadOnly(verb, rest)) continue
166      const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
167      if (spec) out.push({ kind: `git ${verb}`, ...spec })
168    } else if (head === 'gh') {
169      const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
170      const spec = GH_VERBS[pair]
171      if (spec) out.push({ kind: `gh ${pair}`, ...spec })
172    }
173  }
174  return out
175}
176
177export const BRANCH_ICON = ICON.branch
178
179const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
180const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
181const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
182
183function tidy(path: string): string {
184  const s = path.replace(/\/+$/, '')
185  return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
186}
187
188export function resolve(cwd: string, p: string, home = ''): string {
189  let path = posix(p)
190  if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
191  const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
192  const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
193  for (const seg of path.slice(drive.length).split('/')) {
194    if (!seg || seg === '.') continue
195    if (seg === '..') {
196      if (parts.length > 1) parts.pop()
197    } else parts.push(seg)
198  }
199  return tidy(parts.join('/'))
200}
201
202const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
203
204export function readOnly(command: string): boolean {
205  const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
206  if (/[^>]>[^>&]|>>/.test(bare)) return false
207  return segments(command).every(raw => {
208    const tokens = stripGlobals(raw)
209    const head = tokens[0]?.split('/').pop() ?? ''
210    if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
211    if (head === 'git') {
212      const i = gitVerb(tokens)
213      return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
214    }
215    if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
216    if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
217    return READERS.has(head)
218  })
219}
220
221export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
222  let cwd = sessionCwd
223  const out = new Set<string>()
224  let listsPaths = false
225  for (const raw of segments(command)) {
226    const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
227    const head = tokens[0]?.split('/').pop() ?? ''
228    if (head === 'cd' && tokens[1]) {
229      cwd = resolve(cwd, tokens[1], home)
230      continue
231    }
232    if (!READERS.has(head)) continue
233    if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
234    if (OUTPUT_PATHS.has(head)) listsPaths = true
235    const args = tokens.slice(1)
236    let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
237    for (let i = 0; i < args.length; i++) {
238      const a = args[i] ?? ''
239      if (a.startsWith('-')) {
240        if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
241        continue
242      }
243      if (skipPattern) {
244        skipPattern = false
245        continue
246      }
247      if (/[*?<>|]/.test(a)) continue
248      out.add(resolve(cwd, a, home))
249    }
250  }
251  if (listsPaths) {
252    for (const line of stdout.split('\n').slice(0, 400)) {
253      const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
254      if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
255    }
256  }
257  return [...out].slice(0, 60)
258}
259
hooks/rows.tsx 165 lines
1// Changed in this fork (awesome-claude) for the kit's terminal look: colours are the
2// kit's terminal inks (theme keys or dim), and the shimmer is a bold band sweeping
3// through a row drawn in its activity's colour, not a hex gradient. The cursor row is
4// the kit's selected list row: a `▌` gutter mark in `suggestion` (dim while the pane
5// doesn't have the keyboard), never a background, which no fixed hex reads on every theme.
6// The scrollbar is only its thumb, dim.
7import type { ClientModule } from 'claude-code'
8
9/**
10 * One run of a row: `c` a colour (an engine theme key, never a hex: the kit's
11 * `termInk` gives it), `dc` the terminal's dim, `sh` a shimmer tone (a key of
12 * `tones`), `dim` the faint flash.
13 */
14export type Seg = { t: string; c?: string; dc?: boolean; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; tab?: string }
15export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
16export type RowsProps = {
17  rows: RowSpec[]
18  /** The cursor row's id: its one-cell gutter holds `mark`. */
19  active: string
20  /** The kit's selected-row mark (`MARK.selected`) and its colour (a theme key). */
21  mark: string
22  markColor?: string
23  /** Whether the pane has the keyboard: the mark is dim while it hasn't. */
24  focused: boolean
25  /** Each shimmer tone's colour on the terminal (a theme key, from the kit's `termInk`). */
26  tones: Record<string, string>
27  spinner?: string[]
28  pointer?: boolean
29  /** The scrollbar: its thumb a dim `┃`, no track (a full-height rule beside the names read as a border). */
30  bar?: { pos: number; size: number }
31  preview?: { text: string; path: string; startLine: number; markdown: boolean }
32}
33type Local = { phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
34
35const TICK_MS = 90
36const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
37
38/** Whether letter `i` of `len` sits in the band the shimmer sweeps across a name. */
39function inBand(i: number, phase: number, len: number): boolean {
40  const band = ((phase * 1.6) % (len + 8)) - 4
41  return Math.abs(i - band) < 1.8
42}
43
44const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
45  const { Box, Text } = surface.elements
46  let state = surface.state
47  if (state === undefined) {
48    state = { phase: 0, drag: false, ref: {} }
49    surface.setState(state)
50  }
51  const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
52  if (lit && !state.ref.stop) {
53    state.ref.stop = surface.every(TICK_MS, () => {
54      const cur = surface.state
55      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
56    })
57  } else if (!lit && state.ref.stop) {
58    state.ref.stop()
59    state.ref.stop = undefined
60  }
61  if (props.pointer === false) {
62    state.ref.unpoint?.()
63    state.ref.unpoint = undefined
64  } else state.ref.unpoint = surface.onPointer(e => {
65    const cur = surface.state ?? state
66    const span = Math.max(1, props.rows.length - 1)
67    const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
68    if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
69      if (e.type === 'up' || e.type === 'leave') {
70        surface.setState({ ...cur, drag: false })
71        return
72      }
73      if (e.type === 'down' || e.type === 'move') {
74        if (!cur.drag) surface.setState({ ...cur, drag: true })
75        surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
76        return
77      }
78    }
79    if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) return
80    const row = props.rows[e.y]
81    if (e.type === 'down' && e.button === 'right' && row?.id) {
82      surface.post({ copy: row.id, shift: Boolean(e.shift) })
83      return
84    }
85    if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
86    // After the one-cell gutter.
87    let x = 1
88    for (const seg of row.left) {
89      const w = [...seg.t].length
90      if (seg.tab && e.x >= x && e.x < x + w) {
91        surface.post({ tab: seg.tab })
92        return
93      }
94      x += w
95    }
96    if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
97  })
98  surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
99  // The preview draws in this same element, so the focus a click gave the tree stays put.
100  if (props.preview) {
101    const { Markdown, Code } = surface.elements
102    const p = props.preview
103    return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
104  }
105  const frames = props.spinner?.length ? props.spinner : FRAMES
106  const draw = (s: Seg) => {
107    if (s.spin) {
108      return (
109        <Text color={s.c} bold={s.b}>
110          {s.t + (frames[state.phase % frames.length] ?? '')}
111        </Text>
112      )
113    }
114    const tone = s.sh ? props.tones[s.sh] : undefined
115    if (!tone) {
116      return (
117        <Text color={s.c} dimColor={s.dc} bold={s.b} strikethrough={s.s} italic={s.i}>
118          {s.t}
119        </Text>
120      )
121    }
122    // The faint flash (files Claude touched a moment ago) stays dim and still.
123    if (s.one || s.dim) {
124      return (
125        <Text color={tone} dimColor={s.dim} bold={s.b}>
126          {s.t}
127        </Text>
128      )
129    }
130    const chars = [...s.t]
131    return (
132      <Text color={tone}>
133        {chars.map((ch, i) => (
134          <Text bold={s.b || inBand(i, state.phase, chars.length)}>{ch}</Text>
135        ))}
136      </Text>
137    )
138  }
139  return (
140    <Box flexDirection="column">
141      {props.rows.map((r, i) => (
142        <Box flexDirection="row" height={1} overflow="hidden">
143          <Box width={1} flexShrink={0}>
144            {r.id && r.id === props.active ? (
145              <Text color={props.markColor} dimColor={!props.focused || undefined}>
146                {props.mark}
147              </Text>
148            ) : (
149              <Text> </Text>
150            )}
151          </Box>
152          <Box flexShrink={1} overflow="hidden">
153            {r.left.map(draw)}
154          </Box>
155          <Box flexGrow={1} />
156          {r.right.map(draw)}
157          {props.bar && <Text dimColor>{i >= props.bar.pos && i < props.bar.pos + props.bar.size ? ' ┃' : '  '}</Text>}
158        </Box>
159      ))}
160    </Box>
161  )
162}
163
164export default Rows
165
hooks/icons.ts 127 lines
1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9  'agents.md': 0xf0354,
10  'changelog.md': 0xf0354,
11  'contributing.md': 0xf0354,
12  'todo.md': 0xf0354,
13  'readme.md': 0xf0354,
14  license: 0xe60a,
15  dockerfile: 0xf0868,
16  makefile: 0xf1064,
17  'cmakelists.txt': 0xf1064,
18  justfile: 0xf05b7,
19  gemfile: 0xf0d2d,
20  rakefile: 0xf0d2d,
21  'cargo.toml': 0xe6b2,
22  'cargo.lock': 0xe6b2,
23  'pyproject.toml': 0xe6b2,
24  'requirements.txt': 0xf160e,
25  'go.mod': 0xf0afa,
26  'go.sum': 0xf07d3,
27  'package.json': 0xf0626,
28  'package-lock.json': 0xf0626,
29  'tsconfig.json': 0xf0626,
30  '.prettierrc': 0xf0626,
31  '.eslintrc': 0xf0626,
32  'pnpm-lock.yaml': 0xe6a8,
33  'yarn.lock': 0xe6a8,
34  '.gitignore': 0xf02a2,
35  '.gitmodules': 0xf0493,
36  '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40  lua: 0xe620,
41  py: 0xe606,
42  pyi: 0xe606,
43  js: 0xe60c,
44  mjs: 0xe60c,
45  cjs: 0xe60c,
46  ts: 0xe628,
47  jsx: 0xe625,
48  tsx: 0xe7ba,
49  json: 0xe60b,
50  jsonc: 0xe60b,
51  yaml: 0xe6a8,
52  yml: 0xe6a8,
53  sh: 0xe795,
54  bash: 0xe795,
55  zsh: 0xe795,
56  fish: 0xe795,
57  md: 0xf0354,
58  markdown: 0xf0354,
59  css: 0xe6b8,
60  scss: 0xf031c,
61  html: 0xe736,
62  htm: 0xe736,
63  go: 0xe627,
64  rs: 0xe68b,
65  rb: 0xe791,
66  php: 0xe608,
67  java: 0xe738,
68  cs: 0xf031b,
69  sql: 0xe706,
70  graphql: 0xf20e,
71  gql: 0xf20e,
72  xml: 0xf05c0,
73  toml: 0xe6b2,
74  ini: 0xf0bc2,
75  conf: 0xf0493,
76  pdf: 0xeaeb,
77  svg: 0xf0721,
78  jpg: 0xf0225,
79  jpeg: 0xf0225,
80  png: 0xe60d,
81  gif: 0xf0d78,
82  webp: 0xf021f,
83  mp4: 0xf022b,
84  mkv: 0xf022b,
85  mov: 0xf022b,
86  mp3: 0xf0223,
87  wav: 0xf0223,
88  flac: 0xf0223,
89  zip: 0xf05c4,
90  gz: 0xf05c4,
91  xz: 0xf05c4,
92  tar: 0xf05c4,
93  qml: 0xf375,
94  cpp: 0xe61d,
95  cc: 0xe61d,
96  cxx: 0xe61d,
97  c: 0xe61e,
98  h: 0xf0af5,
99  hpp: 0xf0af5,
100  vue: 0xe6a0,
101  svelte: 0xe697,
102  csv: 0xe64a,
103  txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107  if (n.kind === 'link') return cp(0xf481)
108  if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109  const lower = n.name.toLowerCase()
110  const named = NAMES[lower]
111  if (named) return cp(named)
112  const dot = lower.lastIndexOf('.')
113  const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114  return cp(EXTS[ext] ?? 0xf0214)
115}
116
117// Changed in this fork (awesome-claude): no git status hex palette here. A status
118// draws in the kit's colour for it (`statusColor` in desktop.tsx), a theme key on
119// the terminal through the kit's `termInk`.
120
121const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
122
123export function stronger(a: string | undefined, b: string): string {
124  if (!a) return b
125  return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
126}
127
hooks/tree.ts 349 lines
1import type { Branch, FileNode, FileTree } 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 function emptyTree(root: string): FileTree {
10  return {
11    root,
12    nodes: [],
13    expanded: [],
14    cursor: '',
15    selected: '',
16    query: '',
17    showHidden: true,
18    showSize: false,
19    dirSizes: {},
20    git: {},
21    diff: {},
22    ignored: [],
23    untrackedDirs: [],
24    top: '',
25    prefix: '',
26    branch: null,
27    counts: {},
28    flash: [],
29    flashDim: [],
30    flashOn: false,
31    flashTones: {},
32    scroll: null,
33    preview: '',
34    previewTop: 0,
35  }
36}
37
38export function join(dir: string, name: string): string {
39  return dir.endsWith('/') ? dir + name : `${dir}/${name}`
40}
41
42let drives = false
43
44export function dirname(path: string): string {
45  if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
46  const i = path.lastIndexOf('/')
47  return i <= 0 ? '/' : path.slice(0, i)
48}
49
50export function useDrives(on: boolean): void {
51  drives = on
52}
53
54export function drivesOn(): boolean {
55  return drives
56}
57
58export function posix(path: string): string {
59  if (!drives) return path
60  return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
61}
62
63export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
64  if (dirs.length === 0) return nodes
65  const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
66  const gone = new Set(dirs)
67  return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
68}
69
70export function isAbsolute(path: string): boolean {
71  return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
72}
73
74export function inside(root: string, path: string): boolean {
75  return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
76}
77
78export function toNodes(dir: string, list: Entry[]): FileNode[] {
79  return list
80    .map(e => ({
81      id: join(dir, e.name),
82      parent: dir,
83      name: e.name,
84      kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
85      hidden: e.name.startsWith('.'),
86      mtime: e.mtimeMs,
87      size: e.size,
88      loaded: false,
89    }))
90    .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
91}
92
93export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
94  if (listed.size === 0) return nodes
95  const keep = new Map<string, FileNode>()
96  for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
97  const freshIds = new Set<string>()
98  for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
99  const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
100  const under = (id: string) => {
101    if (gone.size === 0) return false
102    for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
103    return false
104  }
105  const fresh: FileNode[] = []
106  for (const kids of listed.values()) {
107    for (const k of kids) {
108      if (under(k.id)) continue
109      const old = keep.get(k.id)
110      fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
111    }
112  }
113  const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
114  return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
115}
116
117export type GitStatus = {
118  git: Record<string, string>
119  ignored: string[]
120  untrackedDirs: string[]
121  branch: Branch | null
122  files: Record<string, Change>
123}
124
125export type Change = 'new' | 'mod' | 'del'
126
127export function parseBranch(record: string): Branch {
128  const body = record.replace(/^## /, '')
129  if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', host: '', ahead: 0, behind: 0 }
130  const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
131  const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
132  const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
133  const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
134  return { head, upstream, host: '', ahead, behind }
135}
136
137/** The remote an upstream lives on: `origin` of `origin/feat/x`. */
138export function remoteName(upstream: string): string {
139  const slash = upstream.indexOf('/')
140  return slash > 0 ? upstream.slice(0, slash) : ''
141}
142
143/**
144 * A remote URL's host, lowercased: `https://github.com/o/r.git`,
145 * `ssh://git@host:22/o/r`, scp-like `git@gitlab.com:o/r.git`. Empty for a local
146 * path or anything else without one.
147 */
148export function remoteHost(url: string): string {
149  const u = url.trim()
150  const full = u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?(\[[^\]]+\]|[^/:?#]+)/i)
151  if (full) return (full[1] ?? '').toLowerCase()
152  const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/)
153  return scp ? (scp[1] ?? '').toLowerCase() : ''
154}
155
156/** Where an upstream lives, by its remote's host. */
157export type HostKind = 'github' | 'gitlab' | 'forgejo' | 'other'
158
159/** GitHub by its host, GitLab and Forgejo by theirs or a host that names them (a self-hosted `gitlab.corp`). */
160export function hostKind(host: string): HostKind {
161  const h = host.toLowerCase()
162  if (h === 'github.com' || h.endsWith('.github.com')) return 'github'
163  if (h.includes('gitlab')) return 'gitlab'
164  if (h.includes('codeberg') || h.includes('forgejo')) return 'forgejo'
165  return 'other'
166}
167
168/** Each host's icon (kit/icons.ts): GitHub's mark, GitLab's, Forgejo's, else a generic remote. */
169export const HOST_ICON: Record<HostKind, string> = { github: 'mark-github', gitlab: 'gitlab', forgejo: 'forgejo', other: 'cloud' }
170
171/** Each host's mark on the terminal, which has no glyph for them: two letters, or ⇅ for any other remote. */
172export const HOST_MARK: Record<HostKind, string> = { github: 'gh', gitlab: 'gl', forgejo: 'fj', other: '⇅' }
173
174export function mapper(root: string, prefix: string): (rel: string) => string | null {
175  return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
176}
177
178export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
179  const map = mapper(root, prefix)
180  const git: Record<string, string> = {}
181  const ignored: string[] = []
182  const untrackedDirs: string[] = []
183  const files: Record<string, Change> = {}
184  let branch: Branch | null = null
185  const parts = stdout.split('\0')
186  for (let i = 0; i < parts.length; i++) {
187    const rec = parts[i] ?? ''
188    if (rec.startsWith('## ')) {
189      branch = parseBranch(rec)
190      continue
191    }
192    if (rec.length < 4) continue
193    const xy = rec.slice(0, 2)
194    const raw = rec.slice(3)
195    if (xy[0] === 'R' || xy[0] === 'C') i++
196    const path = map(raw)
197    if (!path) continue
198    if (xy === '!!') {
199      ignored.push(path)
200      continue
201    }
202    if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
203    else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
204    const letter =
205      xy === '??'
206        ? '?'
207        : /U/.test(xy) || xy === 'AA' || xy === 'DD'
208          ? 'U'
209          : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
210    if (!letter) continue
211    git[path] = stronger(git[path], letter)
212    let dir = dirname(path)
213    while (dir.length >= root.length && dir !== '/') {
214      git[dir] = stronger(git[dir], letter)
215      if (dir === root) break
216      dir = dirname(dir)
217    }
218  }
219  return { git, ignored, untrackedDirs, branch, files }
220}
221
222export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
223  const map = mapper(root, prefix)
224  const parts = stdout.split('\0')
225  for (let i = 0; i < parts.length; i++) {
226    const rec = parts[i] ?? ''
227    if (!rec) continue
228    const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
229    if (!m) continue
230    let path = m[3] ?? ''
231    if (path === '') {
232      i += 2
233      path = parts[i] ?? ''
234    }
235    if (!path) continue
236    const add = m[1] === '-' ? 0 : Number(m[1])
237    const del = m[2] === '-' ? 0 : Number(m[2])
238    const abs = map(path)
239    if (!abs) continue
240    const prev = into[abs] ?? [0, 0]
241    into[abs] = [prev[0] + add, prev[1] + del]
242  }
243}
244
245export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
246  const out: Record<string, [number, number]> = { ...diff }
247  for (const [path, [add, del]] of Object.entries(diff)) {
248    let dir = dirname(path)
249    while (dir.length >= root.length && dir !== '/') {
250      const prev = out[dir] ?? [0, 0]
251      out[dir] = [prev[0] + add, prev[1] + del]
252      if (dir === root) break
253      dir = dirname(dir)
254    }
255  }
256  return out
257}
258
259export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
260  const out: Record<string, [number, number, number]> = {}
261  const slot = { new: 0, mod: 1, del: 2 } as const
262  for (const [path, change] of Object.entries(files)) {
263    let dir = dirname(path)
264    while (dir.length >= root.length && dir !== '/') {
265      const cur = out[dir] ?? [0, 0, 0]
266      cur[slot[change]] += 1
267      out[dir] = cur
268      if (dir === root) break
269      dir = dirname(dir)
270    }
271  }
272  return out
273}
274
275export function underAny(id: string, set: Set<string>, root: string): boolean {
276  if (set.size === 0) return false
277  let cur = id
278  while (cur.length >= root.length) {
279    if (set.has(cur)) return true
280    const up = dirname(cur)
281    if (up === cur) break
282    cur = up
283  }
284  return false
285}
286
287export function ancestorsOf(id: string, root: string): string[] {
288  const out: string[] = []
289  if (!root) return out
290  for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
291  return out
292}
293
294export function relative(root: string, path: string): string {
295  return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
296}
297
298export function visibleRows(t: FileTree): Row[] {
299  const kids = new Map<string, FileNode[]>()
300  for (const n of t.nodes) {
301    if (!t.showHidden && n.hidden) continue
302    const list = kids.get(n.parent)
303    if (list) list.push(n)
304    else kids.set(n.parent, [n])
305  }
306  const q = t.query.trim().toLowerCase()
307  let keep: Set<string> | null = null
308  if (q) {
309    keep = new Set()
310    for (const n of t.nodes) {
311      if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
312      keep.add(n.id)
313      for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
314    }
315  }
316  const open = new Set(t.expanded)
317  const rows: Row[] = []
318  const walk = (dir: string, depth: number) => {
319    for (const n of kids.get(dir) ?? []) {
320      if (keep && !keep.has(n.id)) continue
321      const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
322      rows.push({ node: n, depth, open: isOpen })
323      if (isOpen) walk(n.id, depth + 1)
324    }
325  }
326  walk(t.root, 0)
327  return rows
328}
329
330export function stamp(ms: number): string {
331  if (!ms) return ''
332  const d = new Date(ms)
333  const p = (v: number) => String(v).padStart(2, '0')
334  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
335}
336
337export function formatSize(bytes: number): string {
338  if (bytes < 0) return '?'
339  if (bytes < 1024) return `${bytes} B`
340  const units = ['K', 'M', 'G', 'T']
341  let v = bytes / 1024
342  let i = 0
343  while (v >= 1024 && i < units.length - 1) {
344    v /= 1024
345    i++
346  }
347  return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
348}
349
types/index.d.ts 68 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  /** The upstream remote's host (`github.com`), for its icon; empty when untracked or unknown. */
16  host: string
17  ahead: number
18  behind: number
19}
20
21export type Activity = {
22  id: number
23  kind: string
24  label: string
25  state: 'running' | 'done' | 'failed'
26  detail: string
27  at: number
28  tone: string
29  nerd: string
30  plain: string
31}
32
33export type FileTree = {
34  root: string
35  nodes: FileNode[]
36  expanded: string[]
37  cursor: string
38  selected: string
39  query: string
40  showHidden: boolean
41  showSize: boolean
42  dirSizes: Record<string, number>
43  git: Record<string, string>
44  diff: Record<string, [number, number]>
45  ignored: string[]
46  untrackedDirs: string[]
47  top: string
48  prefix: string
49  branch: Branch | null
50  counts: Record<string, [number, number, number]>
51  flash: string[]
52  flashDim: string[]
53  flashOn: boolean
54  flashTones: Record<string, string>
55  scroll: number | null
56  preview: string
57  previewTop: number
58}
59
60declare module 'claude-code' {
61  interface PluginState {
62    filetree: {
63      tree: FileTree
64      activity: Activity[]
65    }
66  }
67}
68