Á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…

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.
.gitignore. À direita, as linhas +N -N contra o último commit, somadas nas pastas. No cabeçalho, o total e a branch com ↑↓.● 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.⋯ 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).+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 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.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.
| Tecla | O que faz |
|---|---|
| Enter ou clique | abre ou fecha a pasta, ou o bloco de alterações |
| roda do mouse | rola 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 |
f | foco: fixa ligado ou desligado, e de novo volta ao automático |
d | recolhe ou mostra as alterações |
c | limpa 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.
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:
| Valor | O que faz |
|---|---|
off (padrão) | a árvore com ▾/▸ e ●/○, sem ícones |
nerd | os í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.3 | os í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.
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.
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.
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.cat, sed -n, head), até 5 arquivos por comando.⧉ 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.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.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.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 tiposhooks/register.tsx 906 lines1// 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}
906hooks/activity.ts 81 lines1// 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}
81hooks/diff.ts 110 lines1// 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}
110hooks/editor.ts 121 lines1// 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)
121hooks/focus.ts 89 lines1// 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}
89hooks/git.ts 134 lines1// 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}
134hooks/icons.ts 223 lines1// 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')
223hooks/tree.ts 132 lines1// 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
132types/index.d.ts 97 lines1export 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