SLOPSHOPPER

filetree-hiroshi

Árvore de arquivos da sessão num painel lateral, mostrando onde o agente lê e escreve e os diffs que ele faz. Inspirado no claude-code-filetree (data-goblin…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree-hiroshi
│ ┃ filetree-hiroshi ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ f: focus ⏺ Read(src/auth.ts) │ ┃ /work/app/package.json ⎿ Read 6 lines │ ┃ /work/app/README.md ⏺ Update(src/auth.ts) │ ┃ /work/app/src ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /filetree │ ┃ ⎿ filetree-hiroshi: filetree fechado. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · filetree-hiroshi
app f: focus /work/app/package.json /work/app/README.md /work/app/src ─ Alterações ──────────────────── c: clear d: diffs change:/work/app/src/cache.ts change:/work/app/src/audit.ts change:/work/app/src/auth.ts
README

filetree-hiroshi

A árvore de arquivos da sessão num painel lateral do Claude Code, mostrando onde o agente lê e escreve e, abaixo dela, os diffs que ele faz.

Inspirado no claude-code-filetree, do data-goblin (MIT). Esta é uma implementação própria, mais enxuta, com modo foco e seção de diffs.

O que mostra

  • Árvore do diretório da sessão, com pastas antes e ordem natural. Clique ou Enter abre e fecha uma pasta.
  • Status git por arquivo: amarelo modificado, verde novo, vermelho apagado ou em conflito, apagado o que está no .gitignore. À direita, as linhas +N -N contra o último commit, somadas nas pastas. No cabeçalho, o total e a branch com ↑↓.
  • Onde o agente está: ● pulsando em azul quando lê e em laranja quando escreve, ○ até o próximo prompt (com os ícones ligados, o próprio ícone do arquivo pulsa e fica na cor do tom até o próximo prompt). A pasta do arquivo se abre e a árvore rola até ele.
  • Modo foco: quando os arquivos ativos estão longe demais na árvore para caberem juntos, ela mostra só o caminho até eles e resume o resto em ⋯ N arquivos. f fixa o foco ligado ou desligado; de novo, volta ao automático. A label ao lado do f: acompanha o estado: ◉ foco e ○ completa no automático, foco fixo e foco desligado quando fixado. Com icons em nerd, o ◉ vira uma mira (\uf05b) e o ○ um organograma (\uf0e8).
  • Alterações: abaixo da árvore, cada arquivo escrito na sessão com +N -N e os trechos alterados. O mais recente fica aberto; clique abre e fecha os outros. d recolhe a seção e c a limpa. Com icons em nerd, limpar ganha uma lixeira (\uf1f8) e ocultar/mostrar um olho cortado (\uf070) ou aberto (\uf06e).
  • Abrir no editor: com o mouse sobre a linha de um arquivo aparece um botão ⧉ abrir logo depois do nome (na ponta direita, sobre a contagem, quando o nome é comprido). Clicar nele abre o arquivo no Zed ou no VS Code; numa alteração, abre o diff. Se o projeto já está aberto no editor, o arquivo vira uma aba na janela dele; se não, o editor abre o projeto com o arquivo em foco. Ao clicar, um aviso (toast) no Claude Code confirma que o arquivo está sendo aberto. Só o mouse abre: não há atalho de teclado.

Quando abre

Sozinho, no início da sessão e na primeira escrita do agente, se o terminal estiver em tela cheia (/tui fullscreen) e largo o bastante para o painel caber ao lado da conversa: 144 colunas, ou 110 depois que você abriu o painel com /filetree uma vez. Um /reload-plugins fecha o painel e o abre de novo. Um /clear começa uma sessão nova: a árvore é lida de novo, as alterações somem e o painel abre como no início (o Claude Code não avisa o mod de um /clear com session.start, então o mod percebe pela troca do id da sessão). Escritas fora do diretório da sessão aparecem só em Alterações, com o caminho a partir de ~. /filetree abre ou fecha a qualquer momento; depois que você fecha o painel, ele não reabre sozinho na mesma sessão.

O painel de diff do próprio Claude Code (/diff) também abre sozinho quando o agente edita, e os dois viram abas no mesmo espaço: o que abriu por último fica na frente. Como o filetree já mostra os diffs, feche o de diff no ×; ele fica fechado, nesta e nas próximas sessões, até você rodar /diff de novo.

Teclas (com o painel focado)

TeclaO que faz
Enter ou cliqueabre ou fecha a pasta, ou o bloco de alterações
roda do mouserola a árvore; ela para de seguir o agente até o próximo prompt, ou até ele tocar outro arquivo depois de uns segundos sem você mexer no painel
ffoco: fixa ligado ou desligado, e de novo volta ao automático
drecolhe ou mostra as alterações
climpa as alterações
clique no ⧉abre o arquivo no editor; numa alteração, o diff

O ⧉ só aparece com o mouse sobre a linha e fica fora do Tab e das setas.

Ícones

Para onde e como mudar as opções, e o que fazer quando não pegam, veja o guia de configuração.

A opção icons do plugin (em /config) liga os ícones de Nerd Fonts: um por tipo de arquivo (TypeScript, JSON, Markdown, Dockerfile, package.json…) e um de pasta aberta ou fechada, no lugar do ▾/▸. O padrão é off, porque o mod não tem como saber se o terminal tem os glifos: sem eles aparecem caixinhas.

Com nerd, o ícone do arquivo tem a cor do tipo e o da pasta, a do nome (a do status git). Alguns têm ícone próprio: os arquivos e pastas de ferramentas de IA (CLAUDE.md e .claude com um asterisco que lembra o logo do Claude, AGENTS.md, .agents, .cursor e .cursorrules com um robô, .codex, GEMINI.md…) e os do Docker (Dockerfile, Dockerfile.*, docker-compose*.yml, compose*.yaml, .dockerignore, com a baleia). O botão abrir também troca o ⧉ por um ícone de link externo. Quando o agente lê ou escreve num arquivo, o ícone dele passa a ter a cor e o brilho da atividade, no lugar do ●/○.

Os glifos vêm da Nerd Fonts. O jeito seguro de tê-los é a fonte do terminal ser uma Nerd Font (o nome termina em NF ou Nerd Font), de qualquer versão a partir da 2.3.

Os valores de icons:

ValorO que faz
off (padrão)a árvore com ▾/▸ e ●/○, sem ícones
nerdos ícones, e os logos do Claude, da OpenAI, do Gemini e do Copilot e o robô dos agentes (da Nerd Fonts 3.5) quando o terminal consegue desenhá-los; senão, o asterisco, o cérebro, a varinha e outro robô, que a 2.3 já tinha
nerd-2.3os ícones, sempre só com os glifos da 2.3

No nerd, o mod pergunta ao fontconfig no início de cada sessão (fc-list, cerca de 20 ms) se há uma fonte com os logos onde o terminal vai procurar. No Warp (detectado por TERM_PROGRAM ou WARP_CLIENT_VERSION, inclusive dentro de um tmux), só conta a fonte do terminal em ~/.config/warp-terminal/settings.toml. Nos outros terminais conta qualquer fonte instalada, porque eles buscam nas outras fontes o glifo que falta na principal. Sem fc-list (macOS, Windows) ficam os glifos da 2.3. Use o nerd-2.3 num terminal que não busca em outras fontes e cuja fonte é anterior à 3.5: lá os logos aparecem como caixinha.

Sem trocar a fonte do terminal, dá para instalar a Symbols Nerd Font Mono (NerdFontsSymbolsOnly.zip na página de releases), por exemplo em ~/.local/share/fonts seguido de fc-cache -f, e reiniciar o terminal. Isso só funciona em terminais que buscam nas fontes do sistema os glifos que a fonte principal não tem. O terminal do GNOME busca. O Warp não: ele não consegue carregar essa fonte (failed to load font 'SymbolsNFM' no ~/.local/state/warp-terminal/warp.log) e, quando precisa de um glifo que só ela tem, a tela inteira some. No Warp, use uma Nerd Font como fonte do terminal. Na Symbols, use a variante Mono: a outra desenha ícones mais largos que uma coluna.

Largura

A opção width do plugin (em /config) pede ao Claude Code quantas colunas o painel deve ter ao lado da transcrição. O padrão, 0, deixa a escolha com ele; uma largura arrastada no painel vale mais que a opção. Detalhes em CONFIGURACAO.md.

Editor

A opção editor do plugin (em /config) escolhe onde abrir: auto (padrão), zed ou code. No auto, vale o editor em cujo terminal a sessão roda (o terminal integrado do VS Code ou do Zed); fora deles, o primeiro instalado, nessa ordem. O $EDITOR não entra na conta.

O diff compara o arquivo com a versão do último commit; um arquivo fora do git é comparado com uma cópia feita antes da primeira escrita do agente na sessão. Essas versões ficam numa pasta temporária (/tmp/filetree-hiroshi-*), apagada no fim da sessão.

Limitações

  • Escritas feitas por comandos de shell (sed -i, >>) são descobertas comparando o git antes e depois: aparecem com +N -N, sem os trechos. Um arquivo novo, que o git ainda não acompanha, alterado de novo por shell passa despercebido.
  • Leituras por shell são reconhecidas só quando o comando é de leitura e cita o arquivo (cat, sed -n, head), até 5 arquivos por comando.
  • No Zed, o ⧉ de uma alteração abre o arquivo na linha do trecho mais recente, não o diff lado a lado: o --diff do Zed (1.24) sempre abre outra janela. O Zed marca na margem as linhas alteradas contra o último commit; um arquivo fora do git fica sem essas marcas.
  • No Zed, o mod lê o banco do próprio Zed (com python3) para saber se o projeto já tem uma janela só dele; se não tem, abre uma janela nova em vez de deixar o Zed pôr o projeto na janela de outro. Se a leitura falhar, o Zed decide a janela, e pode acrescentar o projeto à janela ativa.
  • O VS Code só reaproveita a janela aberta exatamente na pasta da sessão. Se você abriu nele só uma subpasta do projeto, o arquivo abre numa janela nova.
  • git add, commits e checkouts feitos em outro terminal atualizam o painel na hora a partir da sessão seguinte à instalação; até lá, ele se atualiza a cada prompt.

Desenvolvimento

claude plugin validate filetree-hiroshi
claude plugin test "$PWD/filetree-hiroshi"
npx tsc -p filetree-hiroshi   # depois que o Claude Code carregou o mod e gravou os tipos
Source 9 files
hooks/register.tsx 906 lines
1// filetree-hiroshi: the session's folder as a tree in a side pane, and the changes the agent made.
2//   Inspired by data-goblin/claude-code-filetree (MIT). Phases 1 to 5 of the plan:
3//   the pane, the tree read on demand, folders opened and closed by a press, /filetree to
4//   open or close it, git's view of each row (color, lines added and removed, branch), and
5//   where the agent reads and writes: a pulsing ● while active, ○ for the rest of the turn,
6//   collapsed folders opened to show the file, the tree following it. The pane opens by
7//   itself as a fullscreen session starts and on the agent's writes; a write outside the
8//   session's folder is listed with the changes, never in the tree. When the agent's files
9//   lie too far apart to show together, focus mode cuts the tree down to the way to them
10//   (f turns it on or off for good).
11//   Below the tree, the changes section shows each written file's hunks, the newest open
12//   (d folds it, c clears it). The tree scrolls in a window of its own, so the section stays
13//   in view: the pane's body is drawn to fit, and the wheel and the focus ring move the window.
14//   A ⧉ abrir chip after a file's name, shown under the pointer, opens it in Zed or VS Code; on
15//   a change, its diff (in Zed, the file: see editor.ts).
16import { atom, read, update } from 'claude-code'
17import type { Color, EngineInterface, Register, Timer } from 'claude-code'
18
19import type { Act, Before, Change, Focus, GitCode, GitState, Hunk, Panel, Tone, Tree } from '../types'
20import { absolute, activeFolders, anyActive, baseShade, gitChanges, mark, markOf, pathWords, SEARCH_LIMIT, shade, shownPath } from './activity'
21import { hunksOf, layout, normalizeChange, recordChange } from './diff'
22import {
23  beforePath,
24  chooseEditor,
25  diffArgv,
26  diffKey,
27  EDITORS,
28  hasOwnWindow,
29  OPENER,
30  OPENER_NERD,
31  installedFrom,
32  isIcon,
33  isTempDir,
34  needsBefore,
35  openArgv,
36  openKey,
37  skipIcons,
38  tempDir,
39  ZED_WINDOWS_PY,
40} from './editor'
41import type { Editor, ZedWorkspace } from './editor'
42import { moreLabel, project, RELEASE_MS, spreadOut } from './focus'
43import type { FocusRow } from './focus'
44import { codeAt, load, locate, noGit } from './git'
45import type { Run } from './git'
46import { canDrawNewer, clearLabel, diffsLabel, focusLabel, iconFor, iconMode, isWarp, newerGlyphsProbe } from './icons'
47import type { IconMode } from './icons'
48import {
49  ancestors,
50  basename,
51  dirname,
52  emptyTree,
53  expand,
54  fitRow,
55  inside,
56  isExpanded,
57  needsListing,
58  posix,
59  toggle,
60  visibleRows,
61  windowFor,
62  withChildren,
63} from './tree'
64
65const PANE = 'filetree-hiroshi'
66const TITLE = 'filetree'
67const INDENT = '  '
68const REFRESH_MS = 300 // a burst of writes, index changes and turn ends reads git once
69const PULSE_MS = 300
70const HEADER_ROWS = 2 // the folder's name and totals, then the branch and the focus toggle
71const SECTION_SHARE = 0.4 // of the pane's rows, at most, for the changes below the tree
72const WHEEL_STEP = 3
73// The person's scroll or press holds the tree this long past their last one; then the agent's
74// next file brings it back, so a long turn does not leave the tree behind for good.
75const HOLD_MS = 8000
76const WRITES = new Set(['Edit', 'Write', 'NotebookEdit'])
77const SHELLS = new Set(['Bash', 'PowerShell'])
78const SEARCHES = new Set(['Grep', 'Glob']) // not on every build: compared by name
79
80const CODE_COLOR: Record<GitCode, Color | undefined> = {
81  M: 'warning', R: 'warning', A: 'success', '?': 'success', D: 'error', U: 'error', '!': undefined,
82}
83const CODE_TAIL: Partial<Record<GitCode, string>> = { '?': 'U', A: 'A', U: '!' }
84
85// Held by the host, so all of it survives a hot reload of this file.
86const tree = atom({ plugin: 'filetree-hiroshi', key: 'tree' } as const, emptyTree())
87const PANEL: Panel = { closedByUser: false, showTree: false, manual: false, manualAt: 0, offset: 0, diffsHidden: false, newerIcons: false }
88const panel = atom({ plugin: 'filetree-hiroshi', key: 'panel' } as const, PANEL)
89const git = atom({ plugin: 'filetree-hiroshi', key: 'git' } as const, noGit())
90const activity = atom({ plugin: 'filetree-hiroshi', key: 'activity' } as const, {} as Record<string, Act>)
91const pulse = atom({ plugin: 'filetree-hiroshi', key: 'pulse' } as const, 0)
92const changes = atom({ plugin: 'filetree-hiroshi', key: 'changes' } as const, [] as Change[])
93const focus = atom({ plugin: 'filetree-hiroshi', key: 'focus' } as const, { mode: 'auto', engaged: false } as Focus)
94const before = atom({ plugin: 'filetree-hiroshi', key: 'before' } as const, { dir: '', files: {} } as Before)
95// The id of the session the state above belongs to: a /clear goes on under another.
96const bound = atom({ plugin: 'filetree-hiroshi', key: 'bound' } as const, '')
97
98// Read again at every session.start, which a reload raises too.
99let home = ''
100// Whether the terminal docks panes; unknown until a drawing says.
101let isFullscreen: boolean | undefined
102// Set at every session.start: the first drawing that says the terminal docks panes opens the pane.
103let opensAtStart = false
104// The rows the tree's window has, and the first one it showed, as last drawn.
105let room = 20
106let drawnFrom = 0
107// The editor option (`auto`, `zed` or `code`), the editors on the PATH once looked up, the
108// element the focus ring is on, and the pane's focusable keys in drawing order.
109let editorSetting: unknown = 'auto'
110// The icons option: `nerd` and `nerd-2.3` draw a Nerd Font icon where a row had its ▾ ▸ ● ○ glyph.
111let iconsSetting: IconMode = 'off'
112// The width option: the body columns the docked pane asks for, or undefined to leave it to the engine.
113let widthSetting: number | undefined
114let installed: Editor[] | undefined
115let focused: string | undefined
116let order: string[] = []
117
118const runner =
119  ($: EngineInterface): Run =>
120  async argv => {
121    const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 5000 })
122    return { exitCode, stdout }
123  }
124
125// The rows the tree shows: in focus, only the way to what the agent touched this turn
126// (and the cursor); else every open folder's.
127function treeRows(t: Tree, acts: Record<string, Act>, f: Focus): { rows: FocusRow[]; showing: boolean } {
128  const touched = Object.keys(acts).filter(path => inside(t.root, path) && path in t.nodes)
129  const wantsFocus = f.mode === 'on' || (f.mode === 'auto' && f.engaged)
130  const projected = wantsFocus ? project(t, [...touched, t.cursor]) : []
131  if (projected.length) return { rows: projected, showing: true }
132  return {
133    rows: visibleRows(t).map(({ node, depth }) => ({ kind: 'node', node, depth, label: node.name, isOpen: isExpanded(t, node.path) })),
134    showing: false,
135  }
136}
137
138// Where a path shows among the rows: its own row, else the closed folder it lies in.
139function rowOf(rows: readonly FocusRow[], t: Tree, path: string): number {
140  for (const p of [path, ...ancestors(t.root, path).reverse()]) {
141    const i = rows.findIndex(row => row.kind === 'node' && row.node.path === p)
142    if (i >= 0) return i
143  }
144  return -1
145}
146
147// Moves the tree's window so row `at` shows, `margin` rows from either edge when it can.
148async function bringIntoView($: EngineInterface, at: number, total: number, margin: number) {
149  if (at < 0) return
150  const offset = windowFor(drawnFrom, at, room, total, margin)
151  await update($, panel, p => (offset === p.offset ? p : { ...p, offset }))
152}
153
154// The file the tree follows: the one the agent touched last.
155function followed(t: Tree, acts: Record<string, Act>): string | undefined {
156  let best: [string, number] | undefined
157  for (const [path, act] of Object.entries(acts)) if (inside(t.root, path) && path in t.nodes && (!best || act.at >= best[1])) best = [path, act.at]
158  return best?.[0]
159}
160
161// Reads a folder into the tree; one that cannot be read counts as empty, so it is not retried on every press.
162// The same listing writes nothing, so a refresh that found no change redraws nothing.
163async function list($: EngineInterface, dir: string) {
164  const entries = await $.fs.list(dir).catch(() => [])
165  const cur = await read($, tree)
166  if (withChildren(cur, dir, entries) !== cur) await update($, tree, t => withChildren(t, dir, entries))
167}
168
169// Set when a shell command may have written files: the next refresh reads them off git.
170let afterShell = false
171
172// Every folder on screen read again (files come and go), then git.
173async function refresh($: EngineInterface) {
174  const t = await read($, tree)
175  if (!t.root) return
176  const fromShell = afterShell
177  afterShell = false
178  const shown = t.expanded.filter(path => path === t.root || t.nodes[path]?.children !== undefined)
179  await Promise.all(shown.map(dir => list($, dir)))
180  const next = await load(runner($), t.root).catch(() => undefined)
181  if (next === undefined) return
182  const before: GitState = await read($, git)
183  if (JSON.stringify(before) === JSON.stringify(next)) return
184  await update($, git, () => next)
185  if (fromShell && before.top) await wrote($, gitChanges(before, next).filter(path => inside(t.root, path)))
186}
187
188// One refresh at a time: a call while one runs asks for another once it ends.
189let timer: Timer | undefined
190let isRefreshing = false
191let again = false
192
193function schedule($: EngineInterface) {
194  timer?.cancel()
195  timer = $.clock.after(REFRESH_MS, () => {
196    timer = undefined
197    if (isRefreshing) {
198      again = true
199      return
200    }
201    isRefreshing = true
202    void refresh($)
203      .catch(() => {})
204      .finally(() => {
205        isRefreshing = false
206        if (again) {
207          again = false
208          schedule($)
209        }
210      })
211  })
212}
213
214// While a path is active the pane redraws on a beat, so its ● pulses; the beat stops once none is.
215let ticker: Timer | undefined
216
217function beat($: EngineInterface) {
218  if (ticker) return
219  ticker = $.clock.every(PULSE_MS, () => {
220    void (async () => {
221      const now = await $.clock.now()
222      if (!anyActive(await read($, activity), now)) {
223        ticker?.cancel()
224        ticker = undefined
225        release($)
226      }
227      await update($, pulse, n => n + 1)
228    })().catch(() => {})
229  })
230}
231
232// The paths in the session's folder the agent touched this turn: the active ones, or all.
233async function touchedFiles($: EngineInterface, onlyActive: boolean): Promise<string[]> {
234  const t = await read($, tree)
235  const now = await $.clock.now()
236  return Object.entries(await read($, activity))
237    .filter(([path, act]) => inside(t.root, path) && path in t.nodes && (!onlyActive || markOf(act, now) === 'active'))
238    .map(([path]) => path)
239}
240
241// Auto focus engages once two active files lie too far apart to show together, unless the
242// person scrolled the pane this turn.
243async function engage($: EngineInterface) {
244  const f = await read($, focus)
245  if (f.mode !== 'auto' || f.engaged || (await read($, panel)).manual) return
246  const t = await read($, tree)
247  const active = (await touchedFiles($, true)).filter(path => t.nodes[path]?.kind !== 'dir')
248  if (spreadOut(t, active, room)) await update($, focus, cur => ({ ...cur, engaged: true }))
249}
250
251// ...and lets go a little after the agent's files go quiet.
252function release($: EngineInterface) {
253  $.clock.after(RELEASE_MS, () => {
254    void (async () => {
255      if (anyActive(await read($, activity), await $.clock.now())) return
256      await update($, focus, f => (f.engaged ? { ...f, engaged: false } : f))
257    })().catch(() => {})
258  })
259}
260
261// The f key: from auto it holds the opposite of what shows; pressed again, back to auto.
262async function toggleFocus($: EngineInterface, showing: boolean) {
263  await update($, focus, (f): Focus => (f.mode === 'auto' ? { ...f, mode: showing ? 'off' : 'on' } : { mode: 'auto', engaged: false }))
264}
265
266async function touch($: EngineInterface, paths: readonly string[], tone: Tone, live: -1 | 0 | 1) {
267  if (paths.length === 0) return
268  const now = await $.clock.now()
269  await update($, activity, acts => mark(acts, paths, tone, now, live))
270  beat($)
271}
272
273// The person moved the tree: it stops following the agent for a while (HOLD_MS).
274async function hold($: EngineInterface, moved: (p: Panel) => Partial<Panel> = () => ({})) {
275  const now = await $.clock.now()
276  await update($, panel, p => ({ ...p, ...moved(p), manual: true, manualAt: now }))
277}
278
279// Opens the folders down to each path and, unless the person moved the tree a moment ago, brings the last into view.
280async function reveal($: EngineInterface, paths: readonly string[]) {
281  const t = await read($, tree)
282  const within = paths.filter(path => inside(t.root, path) && path !== t.root)
283  if (within.length === 0) return
284  const dirs = [...new Set(within.flatMap(path => ancestors(t.root, path)))]
285  for (const dir of [t.root, ...dirs]) if (dir === t.root || needsListing(await read($, tree), dir)) await list($, dir)
286  await update($, tree, cur => expand(cur, dirs))
287  const now = await $.clock.now()
288  await update($, panel, p => (p.manual && now - p.manualAt >= HOLD_MS ? { ...p, manual: false } : p))
289  await engage($)
290  if ((await read($, panel)).manual) return
291  const cur = await read($, tree)
292  const { rows } = treeRows(cur, await read($, activity), await read($, focus))
293  await bringIntoView($, rowOf(rows, cur, within[within.length - 1] as string), rows.length, Math.floor(room / 3))
294}
295
296// The pane opens by itself as the session starts and on the agent's writes, unless the person
297// closed it this session or the terminal has no room beside the transcript for it. A reload
298// closes it, and its session.start opens it again.
299async function autoOpen($: EngineInterface, hasInside: boolean) {
300  if (hasInside) await update($, panel, p => (p.showTree ? p : { ...p, showTree: true }))
301  if ((await read($, panel)).closedByUser || isFullscreen === false) return
302  if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
303  await $.ui.open(paneArgs())
304}
305
306async function wrote($: EngineInterface, paths: readonly string[], hunks: readonly Hunk[] = []) {
307  if (paths.length === 0) return
308  const { root } = await read($, tree)
309  const now = await $.clock.now()
310  await update($, changes, list => paths.reduce((acc, path) => recordChange(acc, path, !inside(root, path), now, hunks), list))
311  const within = paths.filter(path => inside(root, path))
312  await touch($, within, 'write', 0)
313  await autoOpen($, within.length > 0)
314  await reveal($, within)
315}
316
317// The files a read-only shell command names that exist in the session's folder.
318async function shellReads($: EngineInterface, command: string, cwd: string): Promise<string[]> {
319  const { root } = await read($, tree)
320  const candidates = pathWords(command)
321    .slice(0, 10)
322    .map(word => absolute(word, cwd || root))
323    .filter(path => inside(root, path) && path !== root)
324  const found: string[] = []
325  for (const path of candidates) {
326    if (found.length >= SEARCH_LIMIT) break
327    if (await $.fs.exists(path).catch(() => false)) found.push(path)
328  }
329  return found
330}
331
332// A press is the person's: the tree stops following the agent for a while.
333async function press($: EngineInterface, path: string) {
334  const t = await read($, tree)
335  const node = t.nodes[path]
336  if (!node) return
337  await hold($, p => (p.manual ? {} : { offset: drawnFrom }))
338  if (node.kind !== 'dir') {
339    await update($, tree, cur => ({ ...cur, cursor: path }))
340    return
341  }
342  if (!isExpanded(t, path) && needsListing(t, path)) await list($, path)
343  await update($, tree, cur => toggle(cur, path))
344}
345
346async function toggleChange($: EngineInterface, path: string, isOpen: boolean) {
347  await update($, changes, list => list.map(c => (c.path === path ? { ...c, expanded: !isOpen } : c)))
348}
349
350// `+12 -3`, or a letter for a file git has no line counts for (untracked, added, conflict).
351function tailOf(lines: readonly [number, number] | undefined, code: GitCode | undefined): { text: string; color: Color }[] {
352  const parts: { text: string; color: Color }[] = []
353  if (lines && lines[0] > 0) parts.push({ text: `+${lines[0]}`, color: 'success' })
354  if (lines && lines[1] > 0) parts.push({ text: `-${lines[1]}`, color: 'error' })
355  if (parts.length === 0 && code && CODE_TAIL[code]) parts.push({ text: CODE_TAIL[code] as string, color: CODE_COLOR[code] ?? 'text' })
356  return parts
357}
358
359const EDITOR_NAME: Record<Editor, string> = { zed: 'Zed', code: 'VS Code' }
360const NO_EDITOR = 'filetree: nenhum editor encontrado (zed ou code no PATH); escolha um nas opções do plugin'
361
362// The agent's first write to a file this session: a copy of the file as it was, the before
363// side of its diff when git has no version of it.
364async function snapshot($: EngineInterface, path: string) {
365  const b = await read($, before)
366  if (!b.dir || path in b.files) return
367  const was = await $.fs.read(path).then(
368    text => (typeof text === 'string' ? text : undefined),
369    () => undefined,
370  )
371  const copy = was === undefined ? '' : beforePath(b.dir, path, 'antes')
372  if (was !== undefined) await $.fs.write(copy, was)
373  await update($, before, cur => ({ ...cur, files: { ...cur.files, [path]: copy } }))
374}
375
376// The before side of a file's diff: HEAD's version when git has one, else the copy from
377// before the agent's first write, else an empty file (the file is new).
378async function beforeFile($: EngineInterface, path: string): Promise<string> {
379  const b = await read($, before)
380  const head = await $.process.run(['git', '-C', dirname(path), 'show', `HEAD:./${basename(path)}`], { timeoutMs: 5000 }).catch(() => undefined)
381  if (head?.exitCode === 0 && !head.isStdoutTruncated) {
382    const file = beforePath(b.dir, path, 'HEAD')
383    await $.fs.write(file, head.stdout)
384    return file
385  }
386  if (b.files[path]) return b.files[path] as string
387  const empty = beforePath(b.dir, path, 'antes')
388  await $.fs.write(empty, '')
389  return empty
390}
391
392async function chosenEditor($: EngineInterface): Promise<Editor | undefined> {
393  if (editorSetting === 'zed' || editorSetting === 'code') return editorSetting
394  installed ??= await $.process.run(['which', ...EDITORS], { timeoutMs: 5000 }).then(
395    ran => installedFrom(ran.stdout),
396    () => [],
397  )
398  const term = await $.env.get('TERM_PROGRAM').catch(() => undefined)
399  return chooseEditor(editorSetting, term ?? undefined, installed)
400}
401
402// The editor's command returns at once (it hands the path to the editor already running, or
403// starts one that outlives it); a failure is told in a toast.
404async function launch($: EngineInterface, argv: readonly string[]) {
405  const ran = await $.process.run(argv, { timeoutMs: 15000 }).catch((err: unknown) => (err instanceof Error ? err : new Error(String(err))))
406  if (ran instanceof Error) $.ui.toast(`filetree: não deu para rodar o ${argv[0]}: ${ran.message}`)
407  else if (ran.exitCode !== 0) $.ui.toast(`filetree: ${argv[0]} saiu com ${ran.exitCode}${ran.stderr.trim() ? `: ${ran.stderr.trim().split('\n')[0]}` : ''}`)
408}
409
410// Whether Zed should open the project in a window of its own (see ZED_WINDOWS_PY); when its
411// database cannot be read, the window is left to Zed, as before.
412async function zedFresh($: EngineInterface, editor: Editor, root: string): Promise<boolean> {
413  if (editor !== 'zed') return false
414  const ran = await $.process.run(['python3', '-I', '-c', ZED_WINDOWS_PY], { timeoutMs: 5000 }).catch(() => undefined)
415  if (ran?.exitCode !== 0 || ran.isStdoutTruncated) return false
416  try {
417    return !hasOwnWindow(JSON.parse(ran.stdout) as ZedWorkspace[], root)
418  } catch {
419    return false
420  }
421}
422
423async function openFile($: EngineInterface, path: string) {
424  const editor = await chosenEditor($)
425  if (!editor) return $.ui.toast(NO_EDITOR)
426  const root = (await read($, tree)).root || dirname(path)
427  $.ui.toast(`filetree: abrindo ${basename(path)} no ${EDITOR_NAME[editor]}…`)
428  await launch($, openArgv(editor, root, path, undefined, await zedFresh($, editor, root)))
429}
430
431async function openChange($: EngineInterface, path: string) {
432  const editor = await chosenEditor($)
433  if (!editor) return $.ui.toast(NO_EDITOR)
434  const root = (await read($, tree)).root || dirname(path)
435  const line = (await read($, changes)).find(c => c.path === path)?.hunks[0]?.newStart
436  $.ui.toast(`filetree: abrindo o diff de ${basename(path)} no ${EDITOR_NAME[editor]}…`)
437  const was = needsBefore(editor) ? await beforeFile($, path) : ''
438  await launch($, diffArgv(editor, root, was, path, line, await zedFresh($, editor, root)))
439}
440
441// A press runs one of the above off the drawing; what throws is told in a toast.
442function opening($: EngineInterface, run: Promise<void>) {
443  void run.catch((err: unknown) => $.ui.toast(`filetree: ${err instanceof Error ? err.message : String(err)}`))
444}
445
446// Whether the terminal can draw the Nerd Fonts 3.5 logos, asked once per session start (fc-list,
447// some 20 ms). Warp's font comes from its settings; anything that fails keeps the 2.3 stand-ins.
448async function probeIcons($: EngineInterface) {
449  let newerIcons = false
450  if (iconsSetting === 'nerd') {
451    const term = (await $.env.get('TERM_PROGRAM').catch(() => undefined)) ?? undefined
452    const inWarp = isWarp(term, (await $.env.get('WARP_CLIENT_VERSION').catch(() => undefined)) ?? undefined)
453    const settings = inWarp && home ? await $.fs.read(`${home}/.config/warp-terminal/settings.toml`).catch(() => undefined) : undefined
454    const argv = newerGlyphsProbe(inWarp, settings)
455    newerIcons = argv ? canDrawNewer(await $.process.run(argv, { timeoutMs: 5000 }).catch(() => undefined)) : false
456  }
457  await update($, panel, p => (p.newerIcons === newerIcons ? p : { ...p, newerIcons }))
458}
459
460// Brings the state to the session it runs in. A /clear raises no session.start: the process
461// goes on under a new session id, with the old tree, changes and temporary folder (removed by
462// its session.end) still in the host. A new session starts from nothing; a reload, which keeps
463// the id, keeps what was open.
464async function bind($: EngineInterface, cwd: string): Promise<boolean> {
465  const id = await $.session.id().catch(() => '')
466  const isNew = id !== '' && (await read($, bound)) !== id
467  const root = posix(cwd)
468  if (isNew) {
469    // What the terminal can draw outlives a /clear: only the session's start asks it again.
470    await update($, panel, p => ({ ...PANEL, newerIcons: p?.newerIcons === true }))
471    await update($, activity, () => ({}))
472    await update($, changes, () => [])
473    await update($, focus, () => ({ mode: 'auto', engaged: false }))
474    await update($, tree, () => emptyTree(root))
475    await update($, before, () => ({ dir: '', files: {} }))
476    await update($, git, () => noGit())
477  }
478  // The host keeps the state across a reload: what an older version of the mod wrote
479  // is brought to today's shape before anything draws it.
480  await update($, panel, p => ({ ...PANEL, ...p }))
481  await update($, changes, list => (Array.isArray(list) ? list.filter(c => typeof c?.path === 'string').map(normalizeChange) : []))
482  // A hot reload starts the session again with the same folder: keep what was open.
483  if ((await read($, tree)).root !== root) await update($, tree, () => emptyTree(root))
484  // One folder of temporary files per session, kept across a reload.
485  if (!isTempDir((await read($, before)).dir)) {
486    const dir = tempDir((await $.env.get('TMPDIR').catch(() => undefined)) ?? '/tmp', await $.clock.now())
487    await update($, before, () => ({ dir, files: {} }))
488  }
489  await update($, bound, () => id)
490  seenId = id
491  await refresh($).catch(() => {})
492  return isNew
493}
494
495// The session the state was last bound to, and the binding in progress.
496let seenId = ''
497let binding: Promise<boolean> | undefined
498
499// Binds when the session is not the one the state belongs to (after a /clear), and opens the
500// pane as a new session does, once a drawing has said the terminal docks panes.
501async function ensure($: EngineInterface, opens = true) {
502  const id = await $.session.id().catch(() => '')
503  if (!id || id === seenId) return
504  binding ??= bind($, (await $.session.cwd().catch(() => '')) || (await read($, tree)).root).finally(() => {
505    binding = undefined
506  })
507  if (!(await binding) || !opens) return
508  opensAtStart = true
509  if (isFullscreen === true) {
510    opensAtStart = false
511    await autoOpen($, true)
512  }
513}
514
515// A width the person set (a whole number of columns) is asked of the dock; 0, a fraction or
516// anything else leaves the engine's own share. The width the person dragged still wins.
517const paneWidth = (value: unknown): number | undefined =>
518  typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined
519
520const paneArgs = () => ({ id: PANE, title: TITLE, ...(widthSetting ? { columns: widthSetting } : {}) })
521
522const stringField = (input: unknown, key: string): string | undefined => {
523  const value = (input as Record<string, unknown>)[key]
524  return typeof value === 'string' ? value : undefined
525}
526
527export const register: Register = (on, options) => {
528  editorSetting = options.editor ?? 'auto'
529  iconsSetting = iconMode(options.icons)
530  widthSetting = paneWidth(options.width)
531  installed = undefined
532
533  // Git's index and HEAD change on every add, commit and checkout, made here or in another
534  // terminal: watching them makes the engine raise FileChanged for each.
535  on('classic.SessionStart', async ($, e, next) => {
536    const result = await next(e)
537    const where = await locate(runner($), posix(e.cwd)).catch(() => undefined)
538    if (!where) return result
539    return { ...result, watchPaths: [...(result.watchPaths ?? []), `${where.gitDir}/index`, `${where.gitDir}/HEAD`] }
540  })
541
542  on('session.start', async ($, e, next) => {
543    const r = await next(e)
544    await $.command.register({ name: 'filetree', description: 'Abre ou fecha a árvore de arquivos da sessão' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
545    home = posix((await $.env.get('HOME').catch(() => undefined)) ?? '')
546    await bind($, e.cwd)
547    await probeIcons($)
548    opensAtStart = true
549    return r
550  })
551
552  on('classic.FileChanged', async ($, e, next) => {
553    const result = await next(e)
554    schedule($)
555    return result
556  })
557
558  on('tool.call', async ($, e, next) => {
559    await ensure($).catch(() => {})
560    const tool = String(e.tool)
561    const cwd = posix((await $.session.cwd().catch(() => '')) || (await read($, tree)).root)
562    const readPath = tool === 'Read' ? stringField(e, 'file_path') : undefined
563    const reading = readPath ? [absolute(readPath, cwd)] : []
564    await touch($, reading, 'read', 1)
565    const writing = WRITES.has(tool) ? (stringField(e, 'file_path') ?? stringField(e, 'notebook_path')) : undefined
566    if (writing) await snapshot($, absolute(writing, cwd)).catch(() => {})
567    let result: Awaited<ReturnType<typeof next>>
568    try {
569      result = await next(e)
570    } finally {
571      await touch($, reading, 'read', -1)
572    }
573    if (result.deny || result.isError) return result
574
575    if (reading.length) await reveal($, reading)
576    else if (SEARCHES.has(tool)) {
577      const found = (result.result as { filenames?: unknown } | undefined)?.filenames
578      const files = Array.isArray(found)
579        ? found.filter((f): f is string => typeof f === 'string').slice(0, SEARCH_LIMIT).map(f => absolute(f, cwd))
580        : []
581      await touch($, files, 'read', 0)
582      await reveal($, files)
583    } else if (WRITES.has(tool)) {
584      const path = stringField(e, 'file_path') ?? stringField(e, 'notebook_path')
585      if (path) await wrote($, [absolute(path, cwd)], hunksOf(result.result))
586      schedule($)
587    } else if (SHELLS.has(tool)) {
588      if (result.isReadOnly) {
589        const files = await shellReads($, stringField(e, 'command') ?? '', cwd)
590        await touch($, files, 'read', 0)
591        await reveal($, files)
592      } else {
593        afterShell = true
594        schedule($)
595      }
596    }
597    return result
598  })
599
600  // A new turn: the person may have edited files in another program, last turn's ○ go,
601  // and the tree follows the agent again.
602  on('prompt.submit', async ($, e, next) => {
603    await ensure($).catch(() => {})
604    const now = await $.clock.now()
605    await update($, activity, acts => Object.fromEntries(Object.entries(acts).filter(([, act]) => markOf(act, now) === 'active')))
606    await update($, panel, p => (p.manual ? { ...p, manual: false } : p))
607    await update($, focus, f => (f.engaged ? { ...f, engaged: false } : f))
608    schedule($)
609    return next(e)
610  })
611
612  on('session.end', async ($, e, next) => {
613    const { dir } = await read($, before)
614    if (isTempDir(dir)) await $.process.run(['rm', '-rf', dir], { timeoutMs: 5000 }).catch(() => {})
615    return next(e)
616  })
617
618  on('turn.complete', async ($, e, next) => {
619    schedule($)
620    return next(e)
621  })
622
623  // The person walking the rows with the focus ring: the window keeps a row of the tree
624  // past the ring in view, so the arrows go on through it. The ⧉ icons and the e key's
625  // Button stay out of the ring (no prop does that): it lands on a row instead.
626  on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
627    schedule($)
628    const element = isIcon(e.element) ? skipIcons(order, focused, e.element as string) : e.element
629    if (isIcon(e.element) && element === undefined) return {}
630    const result = await next(element === e.element ? e : { ...e, element })
631    if (!result.deny) focused = element
632    const t = await read($, tree)
633    if (e.origin.kind === 'person' && element && element in t.nodes) {
634      await hold($)
635      const { rows } = treeRows(t, await read($, activity), await read($, focus))
636      await bringIntoView($, rowOf(rows, t, element), rows.length, 1)
637    }
638    return result
639  })
640
641  // The wheel over the tree moves its window; the pane's body itself is drawn to fit.
642  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
643    if (e.origin.kind !== 'person') return next(e)
644    const overTree = !e.pointer || (e.pointer.row >= HEADER_ROWS && e.pointer.row < HEADER_ROWS + room)
645    if (e.by !== 0 && overTree) {
646      const step = Math.sign(e.by) * Math.max(WHEEL_STEP, Math.abs(e.by))
647      await hold($, () => ({ offset: Math.max(0, drawnFrom + step) }))
648    }
649    return next(e)
650  })
651
652  // Draws nothing: whether the terminal docks panes decides if the pane may open unasked,
653  // and the first drawing to say so opens it at the session's start, off the drawing,
654  // which may not write.
655  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
656    if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
657    // A drawing may not write: a session the state is not bound to is bound off it.
658    if ((await $.session.id().catch(() => '')) !== seenId) $.clock.after(0, () => void ensure($).catch(() => {}))
659    if (opensAtStart && isFullscreen === true) {
660      opensAtStart = false
661      $.clock.after(0, () => void autoOpen($, true).catch(() => {}))
662    }
663    return next(e)
664  })
665
666  on('command.run', { command: 'filetree' }, async ($, e) => {
667    isFullscreen = e.presentation?.isFullscreen ?? isFullscreen
668    await ensure($, false).catch(() => {}) // the person asked for the pane: it is not opened for them first
669    if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
670      // The close is the mod's own (origin plugin), but the person asked for it.
671      await update($, panel, p => ({ ...p, closedByUser: true }))
672      await $.ui.close({ id: PANE })
673      return { text: 'filetree fechado.' }
674    }
675    await update($, panel, p => ({ ...p, showTree: true, closedByUser: false }))
676    const opened = await $.ui.open(paneArgs())
677    return { text: opened.isPlaced ? 'filetree aberto.' : `filetree aberto, aguardando espaço: ${opened.reason}` }
678  })
679
680  on('ui.close', { id: PANE }, async ($, e, next) => {
681    if (e.origin.kind === 'person') await update($, panel, p => ({ ...p, closedByUser: true }))
682    return next(e)
683  })
684
685  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
686    // A drawing that throws leaves the pane blank: say why in it instead.
687    try {
688      return await (async () => {
689        const { Box, Button, Code, Text } = $.ui.resolve(e)
690        if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
691        const t = await read($, tree)
692        const g: GitState = await read($, git)
693        const p = await read($, panel)
694        const acts = await read($, activity)
695        const beatNow = await read($, pulse)
696        const written = await read($, changes)
697        const f = await read($, focus)
698        const now = await $.clock.now()
699        const width = Math.max(16, e.props.bodyColumns)
700        const height = Math.max(6, e.props.scroll.bodyRows)
701        const busyDirs = activeFolders(acts, now, t.root)
702
703        // The changes take up to their share of the rows (all of them with no tree), the tree the rest.
704        const header = p.showTree ? HEADER_ROWS : 0
705        const budget = p.showTree ? Math.max(4, Math.floor(height * SECTION_SHARE)) : height
706        let section = layout(written, budget - 1)
707        if (section.left > 0) section = layout(written, budget - 2)
708        const sectionRows =
709          written.length === 0 ? 0 : p.diffsHidden ? 1 : 1 + section.blocks.reduce((sum, b) => sum + b.rows, 0) + (section.left > 0 ? 1 : 0)
710        room = Math.max(1, height - header - sectionRows)
711
712        const { rows, showing } = p.showTree ? treeRows(t, acts, f) : { rows: [], showing: false }
713        // Following the agent, the window is checked at every drawing: the room changes as the changes grow.
714        const target = p.manual ? undefined : followed(t, acts)
715        const from = windowFor(p.offset, target ? rowOf(rows, t, target) : -1, room, rows.length, Math.floor(room / 3))
716        drawnFrom = from
717        const shown = rows.slice(from, from + room)
718        const focusText = focusLabel(f.mode, showing, iconsSetting !== 'off')
719
720        const total = tailOf(g.lines[t.root], undefined)
721        const head = fitRow('', basename(t.root), total.map(part => part.text).join(' '), width)
722        const ruleLabel = '─ Alterações '
723        const nerd = iconsSetting !== 'off'
724        const clearText = clearLabel(nerd)
725        const diffsText = diffsLabel(p.diffsHidden, nerd)
726        const ruleKeys = `c: ${clearText}  d: ${diffsText}`
727        // What the focus ring walks, in drawing order, for the ui.focus hook to step past the icons.
728        order = [
729          ...(p.showTree ? ['focus'] : []),
730          ...shown.flatMap(row => (row.kind !== 'node' ? [] : row.node.kind === 'file' ? [row.node.path, openKey(row.node.path)] : [row.node.path])),
731          ...(written.length > 0 ? ['clear', 'diffs'] : []),
732          ...(p.diffsHidden ? [] : section.blocks.flatMap(({ change }) => [`change:${change.path}`, diffKey(change.path)])),
733        ]
734        // A chip shown under the pointer right after a row's name, or over its line counts when the
735        // name leaves no room before them.
736        const opener = (key: string, lead: string, fit: { name: string; tail: string }, onPress: () => void) => {
737          const label = iconsSetting !== 'off' ? OPENER_NERD : OPENER
738          const at = lead.length + fit.name.length + 1
739          const fits = at + label.length <= width - (fit.tail ? fit.tail.length + 1 : 0)
740          return (
741            <Box position="absolute" {...(fits ? { left: at } : { right: 0 })} display="none" hover={{ display: 'flex' }}>
742              {!fits && <Text>{' '.repeat(fit.tail ? fit.tail.length + 1 : 1)}</Text>}
743              <Button key={key} plain onPress={onPress}>
744                <Text backgroundColor="suggestion" color="inverseText">{label}</Text>
745              </Button>
746            </Box>
747          )
748        }
749
750        return (
751          <Box flexDirection="column">
752            {p.showTree && (
753              <Text wrap="truncate-end">
754                <Text bold>{head.name}</Text>
755                {head.pad}
756                {head.tail && total.map((part, i) => <Text key={`total-${i}`} color={part.color}>{(i ? ' ' : '') + part.text}</Text>)}
757              </Text>
758            )}
759            {p.showTree && (
760              <Box flexDirection="row" justifyContent="space-between">
761                <Box flexShrink={1}>
762                  <Text wrap="truncate-end" color={g.isDetached ? 'warning' : 'cyan'}>
763                    {g.top ? `${g.isDetached ? '◆' : '⎇'} ${g.branch}` : ''}
764                    {g.ahead > 0 && <Text color="success"> ↑{g.ahead}</Text>}
765                    {g.behind > 0 && <Text color="error"> ↓{g.behind}</Text>}
766                  </Text>
767                </Box>
768                <Box>
769                  <Button key="focus" plain hotkey="f" onPress={() => void toggleFocus($, showing)}>
770                    <Text color={showing ? 'claude' : undefined} dimColor={!showing}>
771                      {focusText}
772                    </Text>
773                  </Button>
774                </Box>
775              </Box>
776            )}
777            {shown.map(row => {
778              if (row.kind === 'more')
779                return (
780                  <Text key={row.key} dimColor wrap="truncate-end">
781                    {INDENT.repeat(row.depth)}
782                    {moreLabel(row.count, row.what)}
783                  </Text>
784                )
785              const { node, depth, label } = row
786              const isDir = node.kind === 'dir'
787              const open = row.isOpen
788              const code = codeAt(g, node.path)
789              const act = acts[node.path]
790              const marked = markOf(act, now)
791              // A collapsed folder with the agent inside pulses too.
792              const busy = isDir && !open ? busyDirs.get(node.path) : undefined
793              const icon = iconsSetting !== 'off' ? iconFor(node.name, node.kind, open, iconsSetting === 'nerd' && p.newerIcons) : undefined
794              const glyph = icon ? icon.glyph : isDir ? (open ? '▾' : '▸') : marked === 'active' ? '●' : marked === 'recent' ? '○' : ' '
795              const lead = `${INDENT.repeat(depth)}${glyph} `
796              const tail = tailOf(g.lines[node.path], code)
797              const fit = fitRow(lead, label, tail.map(part => part.text).join(' '), width)
798              const gitColor: Color | undefined =
799                code && code !== '!' ? CODE_COLOR[code] : isDir ? (g.changed[node.path] ? 'warning' : 'suggestion') : undefined
800              const glow = act && marked === 'active' ? shade(act.tone, beatNow) : busy ? shade(busy, beatNow) : undefined
801              const actColor = !isDir && act ? (marked === 'active' ? glow : baseShade(act.tone)) : undefined
802              // An icon takes the agent's color while it works there, else its type's; a folder's takes its name's.
803              // A folder with an icon of its own keeps its color, unless git has something to say about it.
804              const folderColor = icon?.color && !code && !g.changed[node.path] ? icon.color : gitColor
805              const glyphColor = icon ? (actColor ?? (isDir ? (glow ?? folderColor) : icon.color)) : actColor
806              const dimmed = glow === undefined && (code === '!' || node.kind === 'link' || node.name.startsWith('.'))
807              const line = (
808                <Button key={node.path} plain onPress={() => void press($, node.path)}>
809                  <Text wrap="truncate-end">
810                    {INDENT.repeat(depth)}
811                    <Text color={glyphColor} dimColor={icon !== undefined && dimmed && actColor === undefined}>
812                      {glyph}
813                    </Text>
814                    {' '}
815                    <Text
816                      bold={node.path === t.cursor || glow !== undefined}
817                      color={glow ?? gitColor}
818                      dimColor={dimmed}
819                      italic={node.kind === 'link'}
820                    >
821                      {fit.name}
822                    </Text>
823                    {fit.pad}
824                    {fit.tail && tail.map((part, i) => <Text key={`${node.path}-tail-${i}`} color={part.color}>{(i ? ' ' : '') + part.text}</Text>)}
825                  </Text>
826                </Button>
827              )
828              if (node.kind !== 'file') return line
829              return (
830                <Box key={`row:${node.path}`} flexDirection="row" width={width}>
831                  {line}
832                  {opener(openKey(node.path), lead, fit, () => opening($, openFile($, node.path)))}
833                </Box>
834              )
835            })}
836            {p.showTree && shown.length < room && written.length > 0 && <Box height={room - shown.length} />}
837            {written.length > 0 && (
838              <Box flexDirection="row" justifyContent="space-between">
839                <Box flexShrink={1}>
840                  <Text dimColor wrap="truncate-end">
841                    {ruleLabel}
842                    {p.diffsHidden ? `(${written.length}) ` : ''}
843                    {'─'.repeat(Math.max(1, width - ruleLabel.length - ruleKeys.length - 2))}
844                  </Text>
845                </Box>
846                <Box>
847                  <Button key="clear" plain hotkey="c" onPress={() => void update($, changes, () => [])}>
848                    <Text dimColor>{clearText}</Text>
849                  </Button>
850                  <Text> </Text>
851                  <Button key="diffs" plain hotkey="d" onPress={() => void update($, panel, cur => ({ ...cur, diffsHidden: !cur.diffsHidden }))}>
852                    <Text dimColor>{diffsText}</Text>
853                  </Button>
854                </Box>
855              </Box>
856            )}
857            {!p.diffsHidden &&
858              section.blocks.map(({ change, isOpen, source, rest }) => {
859                const marked = change.outside ? undefined : markOf(acts[change.path], now)
860                const name = shownPath(change.path, t.root, home) + (change.outside ? ' ↗' : '')
861                const counts = tailOf(!change.outside && g.lines[change.path] ? g.lines[change.path] : [change.added, change.removed], undefined)
862                const glyph = change.hunks.length === 0 ? '·' : isOpen ? '▾' : '▸'
863                const fit = fitRow(`${glyph} `, name, counts.map(part => part.text).join(' '), width)
864                return (
865                  <Box key={`change-${change.path}`} flexDirection="column">
866                    <Box key={`row-change:${change.path}`} flexDirection="row" width={width}>
867                      <Button key={`change:${change.path}`} plain onPress={() => void toggleChange($, change.path, isOpen)}>
868                        <Text wrap="truncate-end">
869                          {glyph}{' '}
870                          <Text bold color={marked === 'active' ? shade('write', beatNow) : change.outside ? 'subtle' : undefined}>
871                            {fit.name}
872                          </Text>
873                          {fit.pad}
874                          {fit.tail && counts.map((part, i) => <Text key={`${change.path}-n-${i}`} color={part.color}>{(i ? ' ' : '') + part.text}</Text>)}
875                        </Text>
876                      </Button>
877                      {opener(diffKey(change.path), `${glyph} `, fit, () => opening($, openChange($, change.path)))}
878                    </Box>
879                    {source && <Code format="diff" source={source} path={change.path} wrap="truncate-end" />}
880                    {rest > 0 && (
881                      <Text dimColor wrap="truncate-end">
882                        {'  '}⋯ +{rest} {rest === 1 ? 'linha' : 'linhas'}
883                      </Text>
884                    )}
885                  </Box>
886                )
887              })}
888            {!p.diffsHidden && section.left > 0 && (
889              <Text dimColor wrap="truncate-end">
890                ⋯ mais {section.left} {section.left === 1 ? 'arquivo' : 'arquivos'}
891              </Text>
892            )}
893          </Box>
894        )
895      })()
896    } catch (err) {
897      const { Text } = $.ui.resolve(e)
898      return (
899        <Text color="error">
900          filetree-hiroshi: {err instanceof Error ? err.message : String(err)}
901        </Text>
902      )
903    }
904  })
905}
906
hooks/activity.ts 81 lines
1// What the agent is doing where, as plain data: no `$`, so the tests drive it without the engine.
2import type { Act, GitState, Tone } from '../types'
3import { dirname, inside, join, posix } from './tree'
4
5// A path stays active (the pulsing ●) this long after the agent last touched it; then it
6// is recent (○) until the next prompt.
7export const ACTIVE_MS = 4000
8// A search marks at most this many of the files it found.
9export const SEARCH_LIMIT = 5
10
11export type Activity = Record<string, Act>
12
13// The paths touched with a tone, `live` counting calls still running. A write is not
14// turned back into a read while it is still active.
15export function mark(acts: Activity, paths: readonly string[], tone: Tone, now: number, live: -1 | 0 | 1): Activity {
16  const out = { ...acts }
17  for (const path of paths) {
18    const was = out[path]
19    const keepWrite = was?.tone === 'write' && tone === 'read' && now - was.at < ACTIVE_MS
20    out[path] = { tone: keepWrite ? 'write' : tone, at: now, live: Math.max(0, (was?.live ?? 0) + live) }
21  }
22  return out
23}
24
25export type Mark = 'active' | 'recent'
26
27export function markOf(act: Act | undefined, now: number): Mark | undefined {
28  if (!act) return undefined
29  return act.live > 0 || now - act.at < ACTIVE_MS ? 'active' : 'recent'
30}
31
32export const anyActive = (acts: Activity, now: number) => Object.values(acts).some(act => markOf(act, now) === 'active')
33
34// The folders that hold an active path, so a collapsed folder still shows the agent is inside it.
35export function activeFolders(acts: Activity, now: number, root: string): Map<string, Tone> {
36  const out = new Map<string, Tone>()
37  for (const [path, act] of Object.entries(acts)) {
38    if (markOf(act, now) !== 'active') continue
39    for (let dir = dirname(path); inside(root, dir) && dir !== root; dir = dirname(dir)) {
40      if (out.get(dir) !== 'write') out.set(dir, act.tone)
41    }
42  }
43  return out
44}
45
46// The tone's colors in turn, one step per pulse: a glow that brightens and fades.
47const SHADES: Record<Tone, string[]> = {
48  write: ['#d97757', '#e4906f', '#efaa88', '#e4906f'],
49  // A strong blue: folders are drawn lavender, and green is git's color for a new file.
50  read: ['#2f80ff', '#4f95ff', '#70aaff', '#4f95ff'],
51}
52
53export const shade = (tone: Tone, pulse: number) => SHADES[tone][pulse % SHADES[tone].length] as string
54export const baseShade = (tone: Tone) => SHADES[tone][0] as string
55
56// How a change is named: from the session's folder inside it, from ~ outside it.
57export function shownPath(path: string, root: string, home: string): string {
58  if (inside(root, path) && path !== root) return path.slice(root.length + 1)
59  if (home && inside(home, path)) return `~${path.slice(home.length)}`
60  return path
61}
62
63// A tool's path argument, made absolute against the folder it ran in.
64export const absolute = (path: string, cwd: string) => posix(path.startsWith('/') ? path : join(cwd, path))
65
66// The words of a shell command that may be files: no options, globs, variables or redirections.
67export function pathWords(command: string): string[] {
68  const words = command.split(/[\s;|&()<>]+/).map(w => w.replace(/^['"]|['"]$/g, ''))
69  return [...new Set(words.filter(w => w && !w.startsWith('-') && !/[*?$=`{}]/.test(w) && (w.includes('/') || /\.\w+$/.test(w))))]
70}
71
72// The files git saw change between two reads: a new code, or new line counts.
73export function gitChanges(before: GitState, after: GitState): string[] {
74  const paths = new Set([...Object.keys(before.files), ...Object.keys(after.files)])
75  return [...paths].filter(path => {
76    const code = after.files[path]
77    if (code === '!' || code === 'D') return false
78    return before.files[path] !== code || JSON.stringify(before.lines[path]) !== JSON.stringify(after.lines[path])
79  })
80}
81
hooks/diff.ts 110 lines
1// The changes section as plain data: hunks taken from a tool's result, gathered per file,
2// and cut to the rows the pane has. No `$`, so the tests drive it without the engine.
3import type { Change, Hunk } from '../types'
4
5const MAX_HUNKS = 20 // kept per file, newest first
6const MAX_LINES = 200 // kept per hunk
7
8// The hunks an Edit or Write left in its result; a new file written whole is one hunk of
9// added lines, since its patch is empty.
10export function hunksOf(result: unknown): Hunk[] {
11  const r = (result ?? {}) as { type?: unknown; content?: unknown; structuredPatch?: unknown }
12  const patch = Array.isArray(r.structuredPatch) ? r.structuredPatch : []
13  const hunks = patch
14    .map(h => h as { oldStart?: unknown; newStart?: unknown; lines?: unknown })
15    .filter(h => typeof h.oldStart === 'number' && typeof h.newStart === 'number' && Array.isArray(h.lines))
16    .map(h => ({
17      oldStart: h.oldStart as number,
18      newStart: h.newStart as number,
19      lines: (h.lines as unknown[]).filter((l): l is string => typeof l === 'string' && /^[ +-]/.test(l)).slice(0, MAX_LINES),
20    }))
21    .filter(h => h.lines.length > 0)
22  if (hunks.length === 0 && r.type === 'create' && typeof r.content === 'string' && r.content !== '') {
23    const lines = r.content.replace(/\n$/, '').split('\n')
24    return [{ oldStart: 0, newStart: 1, lines: lines.slice(0, MAX_LINES).map(l => `+${l}`) }]
25  }
26  return hunks
27}
28
29export function countLines(hunks: readonly Hunk[]): [number, number] {
30  let add = 0
31  let del = 0
32  for (const h of hunks) for (const l of h.lines) l[0] === '+' ? add++ : l[0] === '-' ? del++ : 0
33  return [add, del]
34}
35
36// A change as an older version of the mod kept it (no hunks yet), brought to today's shape.
37export function normalizeChange(c: Partial<Change> & { path: string }): Change {
38  const hunks = Array.isArray(c.hunks) ? c.hunks : []
39  const [added, removed] = countLines(hunks)
40  return {
41    path: c.path,
42    outside: c.outside === true,
43    at: typeof c.at === 'number' ? c.at : 0,
44    hunks,
45    added: typeof c.added === 'number' ? c.added : added,
46    removed: typeof c.removed === 'number' ? c.removed : removed,
47    ...(typeof c.expanded === 'boolean' ? { expanded: c.expanded } : {}),
48  }
49}
50
51// The newest write first: a file written again moves to the top, its new hunks ahead of the old.
52export function recordChange(changes: readonly Change[], path: string, outside: boolean, now: number, hunks: readonly Hunk[] = []): Change[] {
53  const was = changes.find(c => c.path === path)
54  const all = [...hunks, ...(was?.hunks ?? [])].slice(0, MAX_HUNKS)
55  const [added, removed] = countLines(all)
56  const change: Change = { path, outside, at: now, hunks: all, added, removed, ...(was?.expanded === undefined ? {} : { expanded: was.expanded }) }
57  return [change, ...changes.filter(c => c.path !== path)]
58}
59
60// A unified diff of the hunks, cut after `max` lines; each header counts only the lines
61// kept, so a cut hunk still parses.
62export function clipHunks(hunks: readonly Hunk[], max: number): { source: string; shown: number; rest: number; parts: number } {
63  const out: string[] = []
64  let shown = 0
65  let total = 0
66  let parts = 0
67  for (const h of hunks) {
68    total += h.lines.length
69    const room = max - shown
70    if (room <= 0) continue
71    const kept = h.lines.slice(0, room)
72    const olds = kept.filter(l => l[0] !== '+').length
73    const news = kept.filter(l => l[0] !== '-').length
74    out.push(`@@ -${h.oldStart},${olds} +${h.newStart},${news} @@`, ...kept.map(l => l.replace(/\t/g, '  ')))
75    shown += kept.length
76    parts++
77  }
78  return { source: out.join('\n'), shown, rest: total - shown, parts }
79}
80
81export type Block = { change: Change; isOpen: boolean; source: string; shown: number; rest: number; rows: number }
82
83// The section in `budget` rows: a header row per file and, for open ones, their lines (a
84// row between hunks, one for "more"); the newest file opens unless the person closed it.
85// What does not fit is counted in `left`.
86export function layout(changes: readonly Change[], budget: number, perFile = 12): { blocks: Block[]; left: number } {
87  const blocks: Block[] = []
88  let used = 0
89  for (const [i, change] of changes.entries()) {
90    if (used + 1 > budget) return { blocks, left: changes.length - i }
91    used += 1
92    const isOpen = change.expanded ?? i === 0
93    if (!isOpen || change.hunks.length === 0) {
94      blocks.push({ change, isOpen, source: '', shown: 0, rest: 0, rows: 1 })
95      continue
96    }
97    // Leave a row for each file still below while there is room for it.
98    const reserve = Math.min(changes.length - i - 1, Math.max(0, budget - used - 3))
99    const room = Math.min(perFile, budget - used - reserve)
100    // a hunk costs a row of its own between the previous one and itself, and "more" one row
101    let max = room
102    let clip = clipHunks(change.hunks, max)
103    while (max > 0 && clip.shown + (clip.parts - 1) + (clip.rest > 0 ? 1 : 0) > room) clip = clipHunks(change.hunks, --max)
104    const rows = clip.shown === 0 ? 0 : clip.shown + (clip.parts - 1) + (clip.rest > 0 ? 1 : 0)
105    used += rows
106    blocks.push({ change, isOpen, source: clip.shown ? clip.source : '', shown: clip.shown, rest: clip.shown ? clip.rest : 0, rows: rows + 1 })
107  }
108  return { blocks, left: 0 }
109}
110
hooks/editor.ts 121 lines
1// Opening a file or a change in the editor, as plain data: which editor, the command that
2// opens it there, where the "before" side of a diff is kept, and the focus ring's way past
3// the ⧉ icons. The tests drive all of it without the engine.
4import { basename } from './tree'
5
6export type Editor = 'zed' | 'code'
7
8export const EDITORS: readonly Editor[] = ['zed', 'code']
9export const OPENER = ' ⧉ abrir '
10// With the Nerd Font icons on, an "external link" icon (the same length) says it opens elsewhere.
11export const OPENER_NERD = ' \uf08e abrir '
12
13// The keys of the Buttons the focus ring passes over: a row's ⧉ in the tree and in the
14// changes section.
15export const openKey = (path: string) => `abrir:${path}`
16export const diffKey = (path: string) => `abrir-diff:${path}`
17
18export const isIcon = (key: string | undefined) => key?.startsWith('abrir') === true
19
20// The row a ⧉ sits on: a tree row is keyed by its path, a change by `change:` and its path.
21function homeOf(key: string): string | undefined {
22  if (key.startsWith('abrir:')) return key.slice(6)
23  if (key.startsWith('abrir-diff:')) return `change:${key.slice(11)}`
24  return undefined
25}
26
27// Where the ring goes instead of a ⧉, given the keys in drawing order: from elsewhere (a
28// click, or Shift+Tab from the row below) to the icon's own row; from its own row, on in
29// the direction it moved, past every icon, round to the other end when none is left.
30export function skipIcons(order: readonly string[], from: string | undefined, to: string): string | undefined {
31  const home = homeOf(to)
32  if (home !== undefined && home !== from && order.includes(home)) return home
33  const at = order.indexOf(to)
34  if (at < 0) return undefined
35  const step = from !== undefined && order.indexOf(from) > at ? -1 : 1
36  for (let n = 1; n < order.length; n++) {
37    const key = order[(at + step * n + order.length * n) % order.length] as string
38    if (!isIcon(key)) return key
39  }
40  return undefined
41}
42
43// The configured editor; on `auto`, the one whose terminal the session runs in, else the
44// first installed. $EDITOR is no guide: it is often a terminal editor.
45export function chooseEditor(setting: unknown, termProgram: string | undefined, installed: readonly Editor[]): Editor | undefined {
46  if (setting === 'zed' || setting === 'code') return setting
47  const term: Editor | undefined = termProgram === 'vscode' ? 'code' : termProgram === 'zed' ? 'zed' : undefined
48  if (term && installed.includes(term)) return term
49  return EDITORS.find(editor => installed.includes(editor))
50}
51
52// `which zed code` prints the path of each it finds, one per line.
53export function installedFrom(which: string): Editor[] {
54  const found = which.split('\n').map(line => basename(line.trim()))
55  return EDITORS.filter(editor => found.includes(editor))
56}
57
58const at = (path: string, line: number | undefined) => (line !== undefined && line > 0 ? `${path}:${line}` : path)
59
60// Always with the project's folder: Zed takes the window whose folder holds it, VS Code
61// only the window of exactly that folder, and without one either may pick another project's.
62// When the folder has no window of its own, Zed would add it to the active window, beside
63// whatever project is there: `fresh` gives it one (-n, which opens a window every time).
64export function openArgv(editor: Editor, root: string, path: string, line?: number, fresh = false): string[] {
65  if (editor === 'code') return ['code', root, '-g', at(path, line)]
66  return fresh ? ['zed', '-n', root, at(path, line)] : ['zed', root, at(path, line)]
67}
68
69// VS Code shows the two sides in a tab of the project's window. Zed's --diff opens another
70// window however it is called, so there the change opens as the file at its newest hunk,
71// where Zed marks the lines changed against HEAD.
72export function diffArgv(editor: Editor, root: string, before: string, path: string, line?: number, fresh = false): string[] {
73  return editor === 'code' ? ['code', root, '--diff', before, path] : openArgv(editor, root, path, line, fresh)
74}
75
76// Zed's CLI cannot list its windows; its own database can. Each open project is a row of
77// `workspaces` stamped with the running session's id (kv_store's session_id), and the stamp
78// is cleared when its window closes; projects in one window share its window_id. The
79// format is Zed's own and may change: any failure prints nothing and the caller opens as
80// before. Read-only, with python3, as sqlite3 is seldom installed.
81export const ZED_WINDOWS_PY = `
82import json, os, sqlite3, sys
83db = os.path.join(os.environ.get('XDG_DATA_HOME') or os.path.expanduser('~/.local/share'), 'zed/db/0-stable/db.sqlite')
84c = sqlite3.connect('file:' + db + '?mode=ro', uri=True, timeout=2)
85s = c.execute("select value from kv_store where key = 'session_id'").fetchone()
86if not s: sys.exit(1)
87rows = c.execute('select paths, window_id from workspaces where session_id = ?', (s[0],)).fetchall()
88print(json.dumps([{'paths': p.split('\\n') if p else [], 'window': w} for p, w in rows]))
89`
90
91export type ZedWorkspace = { paths: string[]; window: number | null }
92
93const trimmed = (path: string) => path.replace(/\/+$/, '') || '/'
94
95// Whether the project's folder is open in a window that holds nothing else; when it is,
96// opening reuses that window, and otherwise it gets one of its own.
97export function hasOwnWindow(rows: readonly ZedWorkspace[], root: string): boolean {
98  const home = rows.find(row => row.paths.length === 1 && trimmed(row.paths[0] as string) === trimmed(root))
99  if (!home || home.window === null) return false
100  return rows.every(row => row === home || row.window !== home.window)
101}
102
103export const needsBefore = (editor: Editor) => editor === 'code'
104
105// A short, stable name for a path, so two files of the same name keep apart.
106export function hashOf(text: string): string {
107  let h = 5381
108  for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) >>> 0
109  return h.toString(36)
110}
111
112// Where the "before" side of a file is written: HEAD's version, or the copy taken before
113// the agent's first write this session. The file's own name stays, for the editor's tab.
114export function beforePath(dir: string, path: string, side: 'HEAD' | 'antes'): string {
115  return `${dir}/${hashOf(path)}/${basename(path)}.${side}`
116}
117
118// The folder of this session's temporary files: removed at its end, and only one such.
119export const tempDir = (tmp: string, now: number) => `${tmp.replace(/\/+$/, '') || '/tmp'}/filetree-hiroshi-${now}`
120export const isTempDir = (dir: string) => /\/filetree-hiroshi-\d+$/.test(dir)
121
hooks/focus.ts 89 lines
1// Focus mode as plain data: the tree cut down to the way to the files the agent touched,
2// the rest summed up in `⋯ N` rows. It only shapes what is drawn: `expanded` is never
3// changed, so leaving focus brings the tree back exactly as it was.
4import type { Tree, TreeNode } from '../types'
5import { ancestors, inside, visibleRows } from './tree'
6
7export type FocusRow =
8  // A row of the tree; `label` joins a chain of folders that each show one folder ("git-band/hooks").
9  | { kind: 'node'; node: TreeNode; depth: number; label: string; isOpen: boolean }
10  | { kind: 'more'; key: string; depth: number; count: number; what: 'itens' | 'pastas' | 'arquivos' }
11
12// Focus stays on this long after the agent's last active file goes quiet.
13export const RELEASE_MS = 3000
14
15const ONE = { itens: 'item', pastas: 'pasta', arquivos: 'arquivo' } as const
16
17// `⋯ 21 arquivos`, `⋯ 1 pasta`.
18export const moreLabel = (count: number, what: 'itens' | 'pastas' | 'arquivos') => `⋯ ${count} ${count === 1 ? ONE[what] : what}`
19
20export function project(tree: Tree, targets: readonly string[]): FocusRow[] {
21  const wanted = targets.filter(path => inside(tree.root, path) && path !== tree.root && path in tree.nodes)
22  if (wanted.length === 0) return []
23  const keep = new Set<string>()
24  const through = new Set<string>() // folders shown open: they lead to a target
25  for (const path of wanted) {
26    keep.add(path)
27    for (const dir of ancestors(tree.root, path)) {
28      keep.add(dir)
29      through.add(dir)
30    }
31  }
32
33  const rows: FocusRow[] = []
34  const walk = (dir: string, depth: number) => {
35    const children = tree.nodes[dir]?.children ?? []
36    let hidden: TreeNode[] = []
37    const flush = (at: number) => {
38      if (hidden.length === 0) return
39      const dirs = hidden.filter(n => n.kind === 'dir').length
40      const what = dirs === hidden.length ? 'pastas' : dirs === 0 ? 'arquivos' : 'itens'
41      rows.push({ kind: 'more', key: `${dir}#${at}`, depth, count: hidden.length, what })
42      hidden = []
43    }
44    children.forEach((path, i) => {
45      const node = tree.nodes[path]
46      if (!node) return
47      if (!keep.has(path)) {
48        hidden.push(node)
49        return
50      }
51      flush(i)
52      // A folder whose only shown child is a folder leading on is drawn on one row with it.
53      let last = node
54      let label = node.name
55      while (through.has(last.path)) {
56        const shown = (last.children ?? []).filter(child => keep.has(child))
57        const only = shown.length === 1 ? tree.nodes[shown[0] as string] : undefined
58        if (!only || only.kind !== 'dir' || !through.has(only.path)) break
59        last = only
60        label = `${label}/${only.name}`
61      }
62      const isOpen = through.has(last.path)
63      rows.push({ kind: 'node', node: last, depth, label, isOpen })
64      if (isOpen) walk(last.path, depth + 1)
65    })
66    flush(children.length)
67  }
68  walk(tree.root, 0)
69  return rows
70}
71
72// Whether the active files are too far apart in the tree to be seen together: the rows
73// between the first and the last (each counted where it shows, or at its closed folder)
74// outnumber the rows the pane has.
75export function spreadOut(tree: Tree, active: readonly string[], room: number): boolean {
76  if (active.length < 2) return false
77  const rows = visibleRows(tree).map(row => row.node.path)
78  const at = (path: string) => {
79    for (const p of [path, ...ancestors(tree.root, path).reverse()]) {
80      const i = rows.indexOf(p)
81      if (i >= 0) return i
82    }
83    return -1
84  }
85  const places = active.map(at).filter(i => i >= 0)
86  if (places.length < 2) return false
87  return Math.max(...places) - Math.min(...places) + 1 > room
88}
89
hooks/git.ts 134 lines
1// Git as plain data: the parsers take git's output and `load` takes a runner, so the
2// tests drive both without the engine.
3import type { GitCode, GitState } from '../types'
4import { dirname, inside, posix } from './tree'
5
6export const noGit = (): GitState => ({
7  top: '', branch: '', isDetached: false, ahead: 0, behind: 0, files: {}, changed: {}, lines: {},
8})
9
10const abs = (top: string, rel: string) => posix(top === '/' ? `/${rel}` : `${top}/${rel}`)
11
12// The path after the first `n` space-separated fields (a path may hold spaces).
13const after = (line: string, n: number) => line.split(' ').slice(n).join(' ')
14
15// `1 XY ...`: the index side (X) and the work tree side (Y).
16function codeOf(xy: string): GitCode {
17  const [x = '.', y = '.'] = xy
18  if (x === 'D' || y === 'D') return 'D'
19  if (x === 'A') return 'A'
20  return 'M'
21}
22
23type Status = Pick<GitState, 'branch' | 'isDetached' | 'ahead' | 'behind' | 'files'>
24
25// `git status --porcelain=v2 -z --branch`: NUL-separated records, paths from the top folder.
26// A rename (`2 ...`) is followed by one more record, the path it came from.
27export function parseStatus(out: string, top: string): Status {
28  const status: Status = { branch: '', isDetached: false, ahead: 0, behind: 0, files: {} }
29  let oid = ''
30  const records = out.split('\0')
31  for (let i = 0; i < records.length; i++) {
32    const rec = records[i] ?? ''
33    if (rec.startsWith('# branch.oid ')) oid = rec.slice(13)
34    else if (rec.startsWith('# branch.head ')) status.branch = rec.slice(14)
35    else if (rec.startsWith('# branch.ab ')) {
36      const match = /^\+(\d+) -(\d+)$/.exec(rec.slice(12))
37      if (match) [status.ahead, status.behind] = [Number(match[1]), Number(match[2])]
38    } else if (rec.startsWith('1 ')) status.files[abs(top, after(rec, 8))] = codeOf(rec.slice(2, 4))
39    else if (rec.startsWith('2 ')) {
40      status.files[abs(top, after(rec, 9))] = 'R'
41      i++
42    } else if (rec.startsWith('u ')) status.files[abs(top, after(rec, 10))] = 'U'
43    else if (rec.startsWith('? ')) status.files[abs(top, rec.slice(2))] = '?'
44    else if (rec.startsWith('! ')) status.files[abs(top, rec.slice(2))] = '!'
45  }
46  if (status.branch === '(detached)') Object.assign(status, { branch: oid.slice(0, 7), isDetached: true })
47  return status
48}
49
50// `git diff --numstat -z`: `added\tremoved\tpath\0`, or for a rename `added\tremoved\t\0from\0to\0`.
51// A binary file has `-` for both and counts 0.
52export function parseNumstat(out: string, top: string): Record<string, [number, number]> {
53  const lines: Record<string, [number, number]> = {}
54  const records = out.split('\0')
55  for (let i = 0; i < records.length; i++) {
56    const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(records[i] ?? '')
57    if (!match) continue
58    let path = match[3] ?? ''
59    if (path === '') {
60      path = records[i + 2] ?? ''
61      i += 2
62    }
63    lines[abs(top, path)] = [match[1] === '-' ? 0 : Number(match[1]), match[2] === '-' ? 0 : Number(match[2])]
64  }
65  return lines
66}
67
68// Each file's lines added to every folder above it, up to the top folder.
69export function rollUp(lines: Record<string, [number, number]>, top: string): Record<string, [number, number]> {
70  const out: Record<string, [number, number]> = { ...lines }
71  for (const [path, [add, del]] of Object.entries(lines)) {
72    for (let dir = dirname(path); inside(top, dir); dir = dirname(dir)) {
73      const [a, d] = out[dir] ?? [0, 0]
74      out[dir] = [a + add, d + del]
75      if (dir === top || dir === '/') break
76    }
77  }
78  return out
79}
80
81export function countChanged(files: Record<string, GitCode>, top: string): Record<string, number> {
82  const out: Record<string, number> = {}
83  for (const [path, code] of Object.entries(files)) {
84    if (code === '!') continue
85    for (let dir = dirname(path); inside(top, dir); dir = dirname(dir)) {
86      out[dir] = (out[dir] ?? 0) + 1
87      if (dir === top || dir === '/') break
88    }
89  }
90  return out
91}
92
93// The code of a path: its own, or the one of a folder above it git reports whole.
94export function codeAt(git: GitState, path: string): GitCode | undefined {
95  const own = git.files[path]
96  if (own) return own
97  if (!git.top) return undefined
98  for (let dir = dirname(path); inside(git.top, dir) && dir !== git.top; dir = dirname(dir)) {
99    const code = git.files[dir]
100    if (code === '?' || code === '!') return code
101  }
102  return undefined
103}
104
105export type Run = (argv: string[]) => Promise<{ exitCode: number; stdout: string }>
106
107// Read-only: `--no-optional-locks` keeps `git status` from refreshing the index, which
108// could clash with a `git add` or `git commit` the person runs in another terminal.
109const gitArgv = (root: string, ...args: string[]) => ['git', '-C', root, '--no-optional-locks', ...args]
110
111// The repository's top and git folder, for the session's folder; undefined outside one.
112export async function locate(run: Run, root: string): Promise<{ top: string; gitDir: string } | undefined> {
113  const out = await run(gitArgv(root, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir'))
114  if (out.exitCode !== 0) return undefined
115  const [top = '', gitDir = ''] = out.stdout.split('\n')
116  return top ? { top: posix(top), gitDir: posix(gitDir) } : undefined
117}
118
119// Everything the pane shows of git, for what lies in the session's folder.
120export async function load(run: Run, root: string): Promise<GitState> {
121  const where = await locate(run, root)
122  if (!where) return noGit()
123  const { top } = where
124  const [status, diff] = await Promise.all([
125    run(gitArgv(root, 'status', '--porcelain=v2', '-z', '--branch', '--untracked-files=normal', '--ignored=matching', '--', '.')),
126    // An unborn branch has no HEAD to diff against: the folder then shows no line counts.
127    run(gitArgv(root, 'diff', 'HEAD', '--numstat', '-z', '--', '.')),
128  ])
129  if (status.exitCode !== 0) return noGit()
130  const parsed = parseStatus(status.stdout, top)
131  const lines = diff.exitCode === 0 ? parseNumstat(diff.stdout, top) : {}
132  return { top, ...parsed, changed: countChanged(parsed.files, top), lines: rollUp(lines, top) }
133}
134
hooks/icons.ts 223 lines
1// The Nerd Font icon of a tree row, as plain data: no `$`, so the tests drive it without the engine.
2// Every glyph sits in the Private Use Area of the BMP, one column wide in the terminal and one
3// UTF-16 unit, which is how `fitRow` measures a row. Only glyphs that Nerd Fonts 2.3 already had
4// (checked against MesloLGS NF 2.3.3 and Monaspace Krypton NF): the terminal's own font must draw
5// them, because a terminal cannot be counted on to fall back to another font. Warp fails to load
6// the Symbols font and, asked for a glyph only it has, blanks the whole screen. So no Codicons past
7// `EBEB` (the Claude and OpenAI logos), no Font Awesome 5 and later (`fa-docker`), no Material set
8// (`F0001`+, two UTF-16 units) and no older `nf-mdi` range (`F500`-`FD46`, removed in Nerd Fonts 3).
9// The exception: a few AI logos from Codicons in Nerd Fonts 3.5, drawn only when the session's
10// start found a font that has them where the terminal will look (`newerGlyphsProbe`), else their
11// 2.3 stand-in. Written as escapes: the glyphs are invisible in an editor without a Nerd Font.
12import type { NodeKind } from '../types'
13
14export type Icon = {
15  glyph: string
16  // A fixed color for the type. Undefined for folders and links, which take the color of the row.
17  color?: string
18}
19
20// `nerd` draws the newer logos when the terminal can; `nerd-2.3` never does.
21export type IconMode = 'off' | 'nerd' | 'nerd-2.3'
22
23// The `icons` option: anything else keeps the tree as it was.
24export const iconMode = (setting: unknown): IconMode => (setting === 'nerd' || setting === 'nerd-2.3' ? setting : 'off')
25
26const FOLDER = '\uf07b'
27const FOLDER_OPEN = '\uf07c'
28const LINK = '\uf0c1'
29const FILE = '\uf15b'
30const TEXT = '\uf0f6'
31const BOOK = '\uf02d'
32
33const BLUE = '#519aba'
34const CYAN = '#4dc0cf'
35const YELLOW = '#cbcb41'
36const ORANGE = '#e37933'
37const RED = '#cc3e44'
38const PINK = '#f55385'
39const GREEN = '#8dc149'
40const PURPLE = '#a074c4'
41const GREY = '#8c8c8c'
42const KRAFT = '#d4a27f' // Claude's tan: apart from the orange that shows the agent writing
43const AGENT = '#a074c4'
44const OPENAI = '#10a37f'
45const DOCKER = '#2496ed'
46
47const CLAUDE = '\uf069' // fa-asterisk: Nerd Fonts has no Claude logo before 3.5
48const ROBOT = '\ueb08' // cod-hubot
49const SPARKLE = '\uf0d0' // fa-magic, for Gemini
50const BRAIN = '\ue28c' // fae-brain, for Codex
51const WHALE = '\uf308' // linux-docker
52
53// The Codicons of Nerd Fonts 3.5, each drawn in place of its stand-in above when the terminal can.
54const CLAUDE_LOGO = '\uec82' // cod-claude
55const OPENAI_LOGO = '\uec81' // cod-openai
56const ROBOT_NEW = '\uec20' // cod-robot
57const SPARKLE_NEW = '\uec10' // cod-sparkle
58const COPILOT_NEW = '\uec1e' // cod-copilot
59export const NEWER_GLYPHS = [CLAUDE_LOGO, OPENAI_LOGO, ROBOT_NEW, SPARKLE_NEW, COPILOT_NEW]
60
61type Entry = Icon & { newer?: string }
62
63const icon = (glyph: string, color?: string, newer?: string): Entry => ({ glyph, color, newer })
64
65// By the whole file name, lower case: the files an extension does not tell apart.
66const BY_NAME: Record<string, Entry> = {
67  'package.json': icon('\ue616', RED),
68  'package-lock.json': icon('\ue672', GREY),
69  'yarn.lock': icon('\ue672', GREY),
70  'pnpm-lock.yaml': icon('\ue672', GREY),
71  'bun.lock': icon('\ue672', GREY),
72  'bun.lockb': icon('\ue672', GREY),
73  'cargo.lock': icon('\ue672', GREY),
74  'tsconfig.json': icon('\ue615', BLUE),
75  'jsconfig.json': icon('\ue615', YELLOW),
76  dockerfile: icon(WHALE, DOCKER),
77  '.dockerignore': icon(WHALE, GREY),
78  'claude.md': icon(CLAUDE, KRAFT, CLAUDE_LOGO),
79  'claude.local.md': icon(CLAUDE, KRAFT, CLAUDE_LOGO),
80  'agents.md': icon(ROBOT, AGENT, ROBOT_NEW),
81  'gemini.md': icon(SPARKLE, BLUE, SPARKLE_NEW),
82  '.cursorrules': icon(ROBOT, AGENT, ROBOT_NEW),
83  'copilot-instructions.md': icon(ROBOT, AGENT, COPILOT_NEW),
84  makefile: icon('\ue673', ORANGE),
85  '.gitignore': icon('\ue65d', ORANGE),
86  '.gitattributes': icon('\ue65d', ORANGE),
87  '.gitmodules': icon('\ue65d', ORANGE),
88  '.editorconfig': icon('\ue652', GREY),
89  '.env': icon('\uf023', YELLOW), // fa-lock: secrets; apart from the lockfiles' padlock
90  license: icon('\ue60a', YELLOW),
91  licence: icon('\ue60a', YELLOW),
92  readme: icon(BOOK, BLUE),
93  'readme.md': icon(BOOK, BLUE),
94}
95
96// By the extension after the last dot, lower case.
97const BY_EXT: Record<string, Entry> = {}
98const add = (exts: string, glyph: string, color?: string) => {
99  for (const ext of exts.split(' ')) BY_EXT[ext] = icon(glyph, color)
100}
101add('ts mts cts', '\ue628', BLUE)
102add('tsx jsx', '\ue625', CYAN)
103add('js mjs cjs', '\ue60c', YELLOW)
104add('json jsonc json5', '\ue60b', YELLOW)
105add('html htm', '\ue60e', ORANGE)
106add('css', '\ue614', BLUE)
107add('scss sass', '\ue603', PINK)
108add('md mdx markdown', '\ue609', BLUE)
109add('py pyi', '\ue606', YELLOW)
110add('java', '\ue66d', RED)
111add('kt kts', '\ue634', PURPLE)
112add('go', '\ue627', CYAN)
113add('rs', '\ue68b', ORANGE)
114add('rb', '\ue605', RED)
115add('php', '\ue608', PURPLE)
116add('c h', '\ue649', BLUE)
117add('cpp cc cxx hpp hh', '\ue646', BLUE)
118add('cs', '\ue648', GREEN)
119add('swift', '\ue699', ORANGE)
120add('dart', '\ue64c', CYAN)
121add('lua', '\ue620', BLUE)
122add('zig', '\ue6a9', ORANGE)
123add('ex exs', '\ue62d', PURPLE)
124add('hs', '\ue61f', PURPLE)
125add('scala', '\ue68e', RED)
126add('vue', '\ue6a0', GREEN)
127add('svelte', '\ue697', ORANGE)
128add('sh bash zsh fish', '\ue691', GREEN)
129add('ps1', '\ue683', BLUE)
130add('yml yaml', '\ue6a8', PURPLE)
131add('toml ini cfg conf', '\ue615', GREY)
132add('xml', '\ue619', ORANGE)
133add('svg', '\ue698', YELLOW)
134add('sql', '\ue64d', GREY)
135add('csv tsv', '\ue64a', GREEN)
136add('lock', '\ue672', GREY)
137add('gradle', '\ue660', BLUE)
138add('png jpg jpeg gif webp ico bmp avif', '\ue60d', PURPLE)
139add('pdf', '\ue67d', RED)
140add('zip tar gz tgz rar 7z xz bz2', '\ue6aa', ORANGE)
141add('mp4 mov mkv webm', '\ue69f', PURPLE)
142add('mp3 wav flac ogg', '\ue638', PURPLE)
143add('ttf otf woff woff2', '\ue659', RED)
144add('txt log', TEXT)
145
146// Folders that hold what an AI tool reads: one icon for open and closed, in the tool's color.
147const BY_FOLDER: Record<string, Entry> = {
148  '.claude': icon(CLAUDE, KRAFT, CLAUDE_LOGO),
149  '.claude-plugin': icon(CLAUDE, KRAFT, CLAUDE_LOGO),
150  '.agents': icon(ROBOT, AGENT, ROBOT_NEW),
151  '.agent': icon(ROBOT, AGENT, ROBOT_NEW),
152  '.cursor': icon(ROBOT, AGENT, ROBOT_NEW),
153  '.codex': icon(BRAIN, OPENAI, OPENAI_LOGO),
154  '.gemini': icon(SPARKLE, BLUE, SPARKLE_NEW),
155}
156
157// The Docker files with more in the name than the whole name says: `Dockerfile.dev`,
158// `docker-compose.override.yml`, `compose.prod.yaml`.
159const isDocker = (lower: string) => /^(dockerfile\.|(docker-)?compose(\.[^.]+)*\.ya?ml$)/.test(lower)
160
161function entryFor(name: string, kind: NodeKind, open: boolean): Entry {
162  if (kind === 'dir') return BY_FOLDER[name.toLowerCase()] ?? icon(open ? FOLDER_OPEN : FOLDER)
163  if (kind === 'link') return icon(LINK)
164  const lower = name.toLowerCase()
165  const byName = BY_NAME[lower] ?? (lower.startsWith('.env.') ? BY_NAME['.env'] : undefined)
166  if (byName) return byName
167  if (isDocker(lower)) return icon(WHALE, DOCKER)
168  const dot = lower.lastIndexOf('.')
169  // A leading dot is part of the name (`.bashrc`), not an extension.
170  const ext = dot > 0 ? lower.slice(dot + 1) : ''
171  return BY_EXT[ext] ?? icon(FILE)
172}
173
174// `newer`: the terminal can draw the Nerd Fonts 3.5 logos (see `newerGlyphsProbe`).
175export function iconFor(name: string, kind: NodeKind, open = false, newer = false): Icon {
176  const entry = entryFor(name, kind, open)
177  return { glyph: newer && entry.newer ? entry.newer : entry.glyph, color: entry.color }
178}
179
180// No terminal says whether it can draw a glyph, so the session's start asks fontconfig instead.
181// Warp falls back to no other font (and blanks the screen when one would be needed): only the
182// font set in its settings counts. Other terminals on Linux look through every installed font, so
183// any font with the glyphs will do. `undefined` when nothing can be asked: the 2.3 stand-ins.
184export function newerGlyphsProbe(inWarp: boolean, warpSettings: string | undefined): string[] | undefined {
185  const charset = `:charset=${NEWER_GLYPHS.map(g => (g.codePointAt(0) as number).toString(16)).join(' ')}`
186  if (!inWarp) return ['fc-list', charset, 'family']
187  const family = warpSettings ? warpFont(warpSettings) : undefined
188  return family ? ['fc-list', `${family.replace(/[\\\-:,]/g, c => `\\${c}`)}${charset}`, 'family'] : undefined
189}
190
191// The terminal font in Warp's settings.toml: `font_name` under `[appearance.text]`.
192export function warpFont(toml: string): string | undefined {
193  let section = ''
194  for (const line of toml.split('\n')) {
195    const head = line.match(/^\s*\[([^\]]+)\]/)
196    if (head) section = head[1] as string
197    const font = line.match(/^\s*font_name\s*=\s*"([^"]*)"/)
198    if (font && section === 'appearance.text') return font[1] || undefined
199  }
200  return undefined
201}
202
203// Warp says so in TERM_PROGRAM, and in WARP_CLIENT_VERSION, which still reaches a tmux run inside it.
204export const isWarp = (termProgram: string | undefined, warpVersion: string | undefined) => termProgram === 'WarpTerminal' || !!warpVersion
205
206// fc-list prints a line per font that has every glyph asked for.
207export const canDrawNewer = (ran: { exitCode: number; stdout: string } | undefined) => ran?.exitCode === 0 && ran.stdout.trim() !== ''
208
209// The label of the f button. Automatic, the word follows what shows; held by the key, it says so.
210// With icons, a crosshairs (`fa-crosshairs`) for the tree cut down to focus and a sitemap
211// (`fa-sitemap`) for the whole of it, in place of ◉ and ○.
212export function focusLabel(mode: 'auto' | 'on' | 'off', showing: boolean, nerd: boolean): string {
213  const mark = nerd ? (showing ? '\uf05b' : '\uf0e8') : showing ? '◉' : '○'
214  if (mode === 'auto') return showing ? `${mark} foco` : `${mark} completa`
215  return `${mark} foco ${mode === 'on' ? 'fixo' : 'desligado'}`
216}
217
218// The labels of the c and d buttons of the changes rule. With icons, a trash (`fa-trash`) for
219// clearing and an eye (`fa-eye`) or a crossed one (`fa-eye-slash`) for showing and hiding them,
220// in front of the word, as in the f button.
221export const clearLabel = (nerd: boolean) => (nerd ? '\uf1f8 limpar' : 'limpar')
222export const diffsLabel = (hidden: boolean, nerd: boolean) => (nerd ? (hidden ? '\uf06e mostrar' : '\uf070 ocultar') : hidden ? 'mostrar' : 'ocultar')
223
hooks/tree.ts 132 lines
1// The tree as plain data: no `$`, so the tests drive it without the engine.
2import type { FsEntry } from 'claude-code'
3
4import type { NodeKind, Tree, TreeNode } from '../types'
5
6// Never listed: git's own folder.
7const HIDDEN = new Set(['.git'])
8
9const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
10
11export const posix = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
12
13export const join = (dir: string, name: string) => (dir === '/' ? `/${name}` : `${dir}/${name}`)
14
15export const dirname = (path: string) => {
16  const at = path.lastIndexOf('/')
17  return at <= 0 ? '/' : path.slice(0, at)
18}
19
20export const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1) || path
21
22export const inside = (root: string, path: string) => path === root || path.startsWith(root === '/' ? '/' : `${root}/`)
23
24export const emptyTree = (root = ''): Tree => ({
25  root,
26  nodes: root ? { [root]: { path: root, name: basename(root), kind: 'dir' } } : {},
27  expanded: root ? [root] : [],
28  cursor: root,
29})
30
31const kindOf = (entry: FsEntry): NodeKind => (entry.isLink ? 'link' : entry.kind === 'dir' ? 'dir' : 'file')
32
33// Folders first, then files, each in natural order (file2 before file10).
34export function sortEntries(entries: readonly FsEntry[]): FsEntry[] {
35  return entries
36    .filter(entry => !HIDDEN.has(entry.name))
37    .slice()
38    .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
39}
40
41// The folder's listing in place: new children added, gone ones dropped (with what lay below them).
42export function withChildren(tree: Tree, dir: string, entries: readonly FsEntry[]): Tree {
43  const nodes = { ...tree.nodes }
44  const parent = nodes[dir]
45  if (!parent) return tree
46  const sorted = sortEntries(entries)
47  const children = sorted.map(entry => join(dir, entry.name))
48  // The same listing returns the same tree, so a refresh that found nothing new writes nothing.
49  const same =
50    parent.children !== undefined &&
51    parent.children.length === children.length &&
52    sorted.every((entry, i) => parent.children?.[i] === children[i] && nodes[children[i] as string]?.kind === kindOf(entry))
53  if (same) return tree
54  const kept = new Set(children)
55  for (const old of parent.children ?? []) if (!kept.has(old)) dropBelow(nodes, old)
56  sorted.forEach((entry, i) => {
57    const path = children[i] as string
58    const was = nodes[path]
59    const kind = kindOf(entry)
60    nodes[path] = was && was.kind === kind ? was : { path, name: entry.name, kind }
61  })
62  nodes[dir] = { ...parent, children }
63  const expanded = tree.expanded.filter(path => path in nodes)
64  return { ...tree, nodes, expanded, cursor: tree.cursor in nodes ? tree.cursor : dir }
65}
66
67function dropBelow(nodes: Record<string, TreeNode>, path: string) {
68  for (const child of nodes[path]?.children ?? []) dropBelow(nodes, child)
69  delete nodes[path]
70}
71
72export type Row = { node: TreeNode; depth: number }
73
74// The rows the tree shows: the root's children, and each expanded folder's, depth first.
75export function visibleRows(tree: Tree): Row[] {
76  const rows: Row[] = []
77  const open = new Set(tree.expanded)
78  const walk = (dir: string, depth: number) => {
79    for (const path of tree.nodes[dir]?.children ?? []) {
80      const node = tree.nodes[path]
81      if (!node) continue
82      rows.push({ node, depth })
83      if (node.kind === 'dir' && open.has(path)) walk(path, depth + 1)
84    }
85  }
86  if (tree.root) walk(tree.root, 0)
87  return rows
88}
89
90export const isExpanded = (tree: Tree, path: string) => tree.expanded.includes(path)
91
92// The folders between the root and a path, top first, neither end included.
93export function ancestors(root: string, path: string): string[] {
94  const out: string[] = []
95  for (let dir = dirname(path); inside(root, dir) && dir !== root; dir = dirname(dir)) out.unshift(dir)
96  return out
97}
98
99export const expand = (tree: Tree, dirs: readonly string[]): Tree => ({
100  ...tree,
101  expanded: [...new Set([...tree.expanded, ...dirs.filter(dir => dir in tree.nodes)])],
102})
103
104export function toggle(tree: Tree, path: string): Tree {
105  const expanded = isExpanded(tree, path) ? tree.expanded.filter(p => p !== path) : [...tree.expanded, path]
106  return { ...tree, expanded, cursor: path }
107}
108
109// A row is `lead` (indent and glyph), the name, padding, then `tail` flush right; past the
110// width the name is cut with an ellipsis, and below one character the tail is dropped.
111export function fitRow(lead: string, name: string, tail: string, width: number): { name: string; pad: string; tail: string } {
112  const room = width - lead.length - (tail ? tail.length + 1 : 0)
113  if (tail && room < 1) return fitRow(lead, name, '', width)
114  const shown = name.length <= room ? name : `${name.slice(0, Math.max(0, room - 1))}…`
115  const pad = tail ? ' '.repeat(Math.max(1, width - lead.length - shown.length - tail.length)) : ''
116  return { name: shown, pad, tail }
117}
118
119// The first row of a window of `rows` rows over `total` that shows row `at`, `margin`
120// rows from either edge when it can; `from` when it already does.
121export function windowFor(from: number, at: number, rows: number, total: number, margin: number): number {
122  const top = Math.max(0, Math.min(Number.isFinite(from) ? from : 0, total - rows))
123  if (at < 0) return top
124  const m = Math.min(margin, Math.floor((rows - 1) / 2))
125  if (at >= top + m && at <= top + rows - 1 - m) return top
126  return Math.max(0, Math.min(at < top + m ? at - m : at - rows + 1 + m, total - rows))
127}
128
129// A folder needs reading when it is open and was never listed.
130export const needsListing = (tree: Tree, path: string) =>
131  tree.nodes[path]?.kind === 'dir' && tree.nodes[path]?.children === undefined
132
types/index.d.ts 97 lines
1export type NodeKind = 'dir' | 'file' | 'link'
2
3export type TreeNode = {
4  path: string // absolute, posix
5  name: string
6  kind: NodeKind
7  // Paths of the children, sorted; undefined until the folder is read.
8  children?: string[]
9}
10
11export type Tree = {
12  root: string // the session's folder; '' before session.start
13  nodes: Record<string, TreeNode>
14  expanded: string[]
15  cursor: string
16}
17
18// M modified, A added, D deleted, R renamed, U conflict, ? untracked, ! ignored.
19export type GitCode = 'M' | 'A' | 'D' | 'R' | 'U' | '?' | '!'
20
21export type GitState = {
22  top: string // the repository's top folder; '' outside a repository
23  branch: string // the short hash when detached
24  isDetached: boolean
25  ahead: number
26  behind: number
27  // By absolute path. A folder git reports whole (untracked or ignored) is here too.
28  files: Record<string, GitCode>
29  // Folders holding at least one change (ignored files aside): how many changed files.
30  changed: Record<string, number>
31  // Lines added and removed against HEAD, by file and summed on each folder above it.
32  lines: Record<string, [number, number]>
33}
34
35export type Panel = {
36  closedByUser: boolean
37  // Whether the pane draws the tree (false while it only has writes outside the session's folder).
38  showTree: boolean
39  // The person scrolled or pressed the pane: it stops following the agent until the next prompt,
40  // or until the agent touches a file once the person has left the pane alone for a while.
41  manual: boolean
42  // When the person last moved the tree, on the engine's clock.
43  manualAt: number
44  // The first row of the tree the pane shows: with the changes below it, the tree scrolls on its own.
45  offset: number
46  // The changes section folded to its rule line (the d key).
47  diffsHidden: boolean
48  // The session's start found the terminal can draw the Nerd Fonts 3.5 logos (icons: nerd).
49  newerIcons: boolean
50}
51
52export type Tone = 'read' | 'write'
53
54// What the agent did to a path this turn: the tone, when it last touched it, and how
55// many of its calls on it are still running.
56export type Act = { tone: Tone; at: number; live: number }
57
58// One hunk of a unified diff: its lines start with ' ', '+' or '-'.
59export type Hunk = { oldStart: number; newStart: number; lines: string[] }
60
61// A file the agent wrote this session, the most recent first: its hunks newest first,
62// their lines added and removed, and whether the person opened or closed it in the section.
63export type Change = {
64  path: string
65  outside: boolean
66  at: number
67  hunks: Hunk[]
68  added: number
69  removed: number
70  expanded?: boolean
71}
72
73// `auto` cuts the tree down by itself while the agent's files lie too far apart to show
74// together (`engaged`); `on` and `off` are the person's, from the f key, until they press it again.
75export type Focus = { mode: 'auto' | 'on' | 'off'; engaged: boolean }
76
77// The before side of the diffs VS Code opens: this session's folder of temporary files, and
78// for each file the agent wrote, the copy taken before its first write ('' when the file
79// did not exist yet). Files git has in HEAD are compared with that version instead.
80export type Before = { dir: string; files: Record<string, string> }
81
82declare module 'claude-code' {
83  interface PluginState {
84    'filetree-hiroshi': {
85      tree: Tree
86      panel: Panel
87      git: GitState
88      activity: Record<string, Act>
89      pulse: number
90      changes: Change[]
91      focus: Focus
92      before: Before
93      bound: string
94    }
95  }
96}
97