Project file tree in a pane, marking files read and edited this session; click folders to open them.

hooks/register.tsx 124 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { markFor } from './mark'
5
6const PANE = 'file-tree'
7const SKIP = new Set(['.git', 'node_modules', '__pycache__', '.venv', 'venv', '.next', 'dist', 'build'])
8const READS = new Set(['Read', 'Grep', 'Glob'])
9const EDITS = new Set(['Edit', 'Write', 'NotebookEdit'])
10
11const touched = atom({ plugin: 'file-tree', key: 'touched' } as const, {})
12const open = atom({ plugin: 'file-tree', key: 'open' } as const, [])
13const root = atom({ plugin: 'file-tree', key: 'root' } as const, '')
14const rev = atom({ plugin: 'file-tree', key: 'rev' } as const, 0)
15
16type Row = { path: string; name: string; depth: number; isDir: boolean }
17
18async function rows($: EngineInterface, dir: string, depth: number, opened: Set<string>, out: Row[], limit: number) {
19 if (out.length >= limit) return
20 let list
21 try {
22 list = await $.fs.list(dir)
23 } catch {
24 return
25 }
26 const sorted = list
27 .filter(f => !SKIP.has(f.name))
28 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || a.name.localeCompare(b.name))
29 for (const f of sorted) {
30 if (out.length >= limit) return
31 const path = `${dir}/${f.name}`
32 const isDir = f.kind === 'dir'
33 out.push({ path, name: f.name, depth, isDir })
34 if (isDir && opened.has(path)) await rows($, path, depth + 1, opened, out, limit)
35 }
36}
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 await $.command.register({ name: 'files', description: 'Open the File Tree pane (/files <folder> to change root)' })
41 const cwd = await $.session.cwd()
42 await update($, root, r => r || cwd)
43 return next(e)
44 })
45
46 on('command.run', { command: 'files' }, async ($, e) => {
47 const arg = e.args.trim()
48 if (arg) {
49 const cwd = await $.session.cwd()
50 const target = arg.startsWith('/') ? arg : `${cwd}/${arg}`
51 try {
52 await $.fs.list(target)
53 } catch {
54 return { text: `No folder at ${target}.` }
55 }
56 await update($, root, () => target.replace(/\/+$/, ''))
57 }
58 await $.ui.open({ id: PANE, title: 'File Tree' })
59 return { text: `File Tree: ${await read($, root)}` }
60 })
61
62 on('tool.call', async ($, e, next) => {
63 const ran = await next(e)
64 const kind: 'edit' | 'read' | null = EDITS.has(e.tool) ? 'edit' : READS.has(e.tool) ? 'read' : null
65 const x = e as unknown as Record<string, unknown>
66 const file = typeof x.file_path === 'string' ? x.file_path : typeof x.notebook_path === 'string' ? x.notebook_path : null
67 if (kind && file && !('deny' in ran && ran.deny !== undefined)) {
68 await update($, touched, t => (t[file] === 'edit' ? t : { ...t, [file]: kind }))
69 // open the folders leading to it, so it shows up
70 const base = await read($, root)
71 if (file.startsWith(`${base}/`)) {
72 const parts = file.slice(base.length + 1).split('/').slice(0, -1)
73 const dirs = parts.map((_, i) => `${base}/${parts.slice(0, i + 1).join('/')}`)
74 await update($, open, o => Array.from(new Set([...o, ...dirs])))
75 }
76 }
77 if (kind === 'edit' || e.tool === 'Bash') await update($, rev, r => r + 1)
78 return ran
79 }).catch(($, e, next) => next(e)) // an observer: never block the call
80
81 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
82 const { Box, Text, Button } = $.ui.resolve(e)
83 const base = (await read($, root)) || (await $.session.cwd())
84 await read($, rev) // redraw after edits and shell commands
85 const marks = await read($, touched)
86 const opened = new Set(await read($, open))
87 const limit = Math.max(5, (e.viewport?.rows ?? 30) - 6)
88 const out: Row[] = []
89 await rows($, base, 0, opened, out, limit)
90
91 const mark = (p: string, isDir: boolean) => markFor(marks, p, isDir)
92 const edited = Object.values(marks).filter(v => v === 'edit').length
93
94 return (
95 <Box flexDirection="column">
96 <Text bold wrap="truncate-start">{base}</Text>
97 <Text dimColor>✎ edited {edited} · • read {Object.keys(marks).length - edited}</Text>
98 {out.map(r =>
99 r.isDir ? (
100 <Button
101 key={`d:${r.path}`}
102 label={`${' '.repeat(r.depth)}${opened.has(r.path) ? '▾' : '▸'} ${r.name}/ ${mark(r.path, true)}`}
103 onPress={() =>
104 update($, open, o => (o.includes(r.path) ? o.filter(p => p !== r.path && !p.startsWith(`${r.path}/`)) : [...o, r.path]))
105 }
106 />
107 ) : (
108 <Text
109 key={`f:${r.path}`}
110 wrap="truncate-end"
111 color={marks[r.path] === 'edit' ? 'yellow' : marks[r.path] === 'read' ? 'cyan' : undefined}
112 dimColor={!marks[r.path]}
113 >
114 {' '.repeat(r.depth)} {mark(r.path, false)} {r.name}
115 </Text>
116 ),
117 )}
118 {out.length >= limit && <Text dimColor>… more (collapse a folder or enlarge the pane)</Text>}
119 <Button key="collapse" label="Collapse all" onPress={() => update($, open, () => [])} />
120 </Box>
121 )
122 })
123}
124hooks/mark.ts 8 lines1import type { Touched } from '../types'
2
3/** The marker shown next to an entry: ✎ edited, • read, · folder holding touched files. */
4export function markFor(marks: Touched, path: string, isDir: boolean): string {
5 if (!isDir) return marks[path] === 'edit' ? '✎' : marks[path] === 'read' ? '•' : ' '
6 return Object.keys(marks).some(k => k.startsWith(`${path}/`)) ? '·' : ' '
7}
8types/index.d.ts 9 lines1/** Absolute path -> 'read' or 'edit' (edit wins). */
2export type Touched = Record<string, 'read' | 'edit'>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'file-tree': { touched: Touched; open: string[]; root: string; rev: number }
7 }
8}
9