SLOPSHOPPER

tecktree

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

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

Draws

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

<h1 align="center">teckTree Mod</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.4.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="media/tecktree-shimmer.gif" alt="teckTree shimmering the file Claude is editing" width="900">

[!NOTE] teckTree 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 /tecktree 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

It installs from the claudemods marketplace. Run this in the terminal:

claude plugin marketplace add laynem/claudemods
claude plugin install tecktree@claudemods

Or inside a Claude Code session:

/plugin marketplace add laynem/claudemods
/plugin install tecktree@claudemods

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

Features

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

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

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

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

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

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

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

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

Keyboard navigation

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

Resizing the pane

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

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

Settings

All settings are in /config under tecktree.

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

herdr

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

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

Contributing

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

git config core.hooksPath .githooks

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

License

MIT

This project is inspired by my Omarchy app FileBlade

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