Side panel that opens when Claude edits files: the whole folder tree, every edited file numbered in order, and a highlighted trail from the project root to the…

See where Claude is working, as it works.
A Claude Code mod that opens a live map of your project whenever Claude edits a file:<br> the folder tree, every edited file numbered in order, and a trail to the file being edited right now.
<img src="assets/desktop-pane.svg" alt="The folder-map pane in the Claude desktop app: stat cards, the last-edit breadcrumb, and the project tree with edited files numbered 1 to 4" width="480">
| 🌳 Live folder tree | The project tree, rescanned on each edit so new files and folders show up right away. |
| 🔢 Numbered edits | Each edited file gets a number (①②③…) in the order Claude first edited it, ×N for repeat edits, and a NEW tag for files Claude created. |
| 📍 Trail to now | A highlighted path runs from the project root to the file being edited, with a breadcrumb at the top. |
| 🎯 Edited branches mode | Hides untouched folders so you only see the folders that hold edits. |
| 🔕 Stays out of the way | If you close the pane, it stays closed for edits in that folder and opens again when Claude moves somewhere else. |
| 🖥️ Terminal and desktop | Box-drawn text rows in the terminal, a drawn tree in the desktop app's Code tab. |
<img src="assets/terminal-pane.svg" alt="folder-map drawn as text in the terminal, with box-drawing guides, numbered edits and a ◀ now marker on the latest file" width="600">
<sub>Both images are rendered from the mod's own drawing code for a sample project in which Claude edits four files. The desktop image above follows your light or dark theme.</sub>
Requires Claude Code 2.1.287 or later.
In a Claude Code terminal session
/plugin install folder-map --marketplace letusnotc/folder-map
Answer y to add the marketplace, then pick a scope. The user scope loads it in every session.
From a shell
claude plugin install folder-map --marketplace letusnotc/folder-map
In the desktop app (where /plugin does not run): click + next to the prompt box, choose Plugins, then Add marketplace with letusnotc/folder-map, then Add plugin.
Ask Claude to edit something and the map opens on its own.
| Control | What it does |
|---|---|
/folder-map | Show or hide the pane |
| ◎ Edited branches / ▤ Full tree | Show only the folders that hold edits, or the whole tree |
| ● Auto-open | Turn opening on edits on or off |
| ↻ Refresh | Rescan the folders |
Heavy folders (node_modules, .git, dist, build, .venv, and others) are listed but never opened. Big trees are capped so the pane stays fast.
What the mod uses, as reported by claude plugin validate:
| 📂 Reads | Folder listings and file-exists checks ($.fs.list, $.fs.exists) in the project being edited |
| 🪝 Hooks | Edit, Write, MultiEdit and NotebookEdit tool calls. It records the path after the edit runs and never blocks or changes a tool call. |
| 🪟 UI | One side pane and the /folder-map command |
| 🚫 Never | Writes files, runs processes or uses the network |
git clone https://github.com/letusnotc/folder-map
claude --plugin-dir ./folder-map
claude plugin validate ./folder-map
claude plugin test ./folder-map
MIT © 2026 Arnav Pandey
hooks/register.tsx 848 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { EditMark, Entry, Listing, View } from '../types'
5
6const roots = atom({ plugin: 'folder-map', key: 'roots' } as const, [])
7const dirs = atom({ plugin: 'folder-map', key: 'dirs' } as const, {})
8const edits = atom({ plugin: 'folder-map', key: 'edits' } as const, [])
9const last = atom({ plugin: 'folder-map', key: 'last' } as const, '')
10const view = atom({ plugin: 'folder-map', key: 'view' } as const, {
11 mode: 'tree',
12 autoOpen: true,
13 quietFolder: '',
14})
15
16const PANE = 'folder-map'
17const TITLE = 'Folder map'
18
19// Shared with savvy-progress so the two panels read as one family.
20const ACCENT = '#8f8cf4'
21const DONE = '#5fbf8f'
22const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
23
24const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
25const SKIP = new Set([
26 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build', '.next', '.nuxt',
27 '.cache', 'target', '.turbo', '.pytest_cache', '.mypy_cache', 'coverage', '.gradle', 'Pods', '.idea',
28])
29const MARKERS = ['.git', '.claude-plugin', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']
30const MAX_DIRS = 300
31const MAX_DEPTH = 10
32const MAX_PER_DIR = 200
33const MAX_ROWS = 600
34const MAX_ROOTS = 4
35const ROW_H = 22
36const CHUNK = 80
37const INDENT = 20
38
39// ---------------------------------------------------------------------------
40// Paths: always `/`-separated; compared case-blind on Windows drives.
41
42const norm = (p: string): string => {
43 const s = p.replace(/\\/g, '/').replace(/\/+$/, '')
44 return /^[A-Za-z]:$/.test(s) ? s + '/' : s || '/'
45}
46const keyOf = (p: string): string => (/^[A-Za-z]:/.test(p) ? p.toLowerCase() : p)
47const join = (dir: string, name: string): string => (dir.endsWith('/') ? dir + name : `${dir}/${name}`)
48const isUnder = (p: string, root: string): boolean => {
49 const a = keyOf(p)
50 const b = keyOf(root)
51 return a === b || a.startsWith(b.endsWith('/') ? b : b + '/')
52}
53const parentOf = (p: string): string => {
54 const i = p.lastIndexOf('/')
55 if (i < 0) return p
56 return norm(p.slice(0, i) || '/')
57}
58const baseName = (p: string): string => p.slice(p.lastIndexOf('/') + 1) || p
59const absolute = (p: string, cwd: string): string => (/^[A-Za-z]:|^\//.test(p.replace(/\\/g, '/')) ? norm(p) : norm(join(cwd, p)))
60
61/** Directory names from `root` (exclusive) down to `p` (inclusive). */
62const segmentsBelow = (root: string, p: string): string[] =>
63 keyOf(p) === keyOf(root) ? [] : p.slice(root.endsWith('/') ? root.length : root.length + 1).split('/').filter(Boolean)
64
65/** Every directory from `root` down to `dir`, both included. */
66const chainDirs = (root: string, dir: string): string[] => {
67 const out = [root]
68 let at = root
69 for (const seg of segmentsBelow(root, dir)) {
70 at = join(at, seg)
71 out.push(at)
72 }
73 return out
74}
75
76// ---------------------------------------------------------------------------
77// Scanning.
78
79// Folders first, then by name.
80const byKindThenName = (a: Entry, b: Entry): number =>
81 (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
82
83async function listDir($: EngineInterface, abs: string): Promise<Listing> {
84 const raw = await $.fs.list(abs)
85 const entries: Entry[] = raw
86 .map((r): Entry => {
87 const kind = r.kind === 'dir' ? 'dir' : r.kind === 'file' ? 'file' : 'other'
88 return kind === 'dir' && SKIP.has(r.name) ? { name: r.name, kind, isSkipped: true } : { name: r.name, kind }
89 })
90 .sort(byKindThenName)
91 return { entries: entries.slice(0, MAX_PER_DIR), more: Math.max(0, entries.length - MAX_PER_DIR) }
92}
93
94// Breadth first, a level at a time, so a huge repo still shows its top.
95async function scanTree($: EngineInterface, root: string): Promise<Record<string, Listing>> {
96 const out: Record<string, Listing> = {}
97 let level = [root]
98 let count = 0
99 for (let depth = 0; level.length && depth <= MAX_DEPTH && count < MAX_DIRS; depth++) {
100 const batch = level.slice(0, MAX_DIRS - count)
101 count += batch.length
102 const lists = await Promise.all(batch.map(d => listDir($, d).catch(() => null)))
103 const next: string[] = []
104 batch.forEach((d, i) => {
105 const l = lists[i]
106 if (!l) return
107 out[keyOf(d)] = l
108 for (const en of l.entries) if (en.kind === 'dir' && !en.isSkipped) next.push(join(d, en.name))
109 })
110 level = next
111 }
112 return out
113}
114
115// The folder a file belongs to: a root already shown, the session's folder, or
116// the nearest folder above it holding a project marker.
117async function findRoot($: EngineInterface, file: string, cwd: string, shown: string[]): Promise<string> {
118 const existing = shown.find(r => isUnder(file, r))
119 if (existing) return existing
120 if (cwd && isUnder(file, cwd)) return cwd
121 let dir = parentOf(file)
122 for (let i = 0; i < 6; i++) {
123 const found = await Promise.all(MARKERS.map(m => $.fs.exists(join(dir, m)).catch(() => false)))
124 if (found.some(Boolean)) return dir
125 const up = parentOf(dir)
126 if (up === dir) break
127 dir = up
128 }
129 return parentOf(file)
130}
131
132async function rememberRoot($: EngineInterface, root: string, scanned: Record<string, Listing>): Promise<void> {
133 const list = await update($, roots, prev => {
134 // A wider root swallows the narrower ones beneath it.
135 const kept = prev.filter(r => !isUnder(r, root))
136 return [root, ...kept].slice(0, MAX_ROOTS)
137 })
138 await update($, dirs, prev => {
139 const merged: Record<string, Listing> = {}
140 for (const [k, v] of Object.entries({ ...prev, ...scanned })) if (list.some(r => isUnder(k, keyOf(r)))) merged[k] = v
141 return merged
142 })
143}
144
145async function track($: EngineInterface, file: string, tool: string, isNew: boolean): Promise<void> {
146 const cwd = norm(await $.session.cwd())
147 const root = await findRoot($, file, cwd, await read($, roots))
148 const known = await read($, dirs)
149 const scanned = known[keyOf(root)] ? {} : await scanTree($, root)
150 // The file's own chain is listed again, so a new file or folder shows.
151 const chain = chainDirs(root, parentOf(file))
152 const lists = await Promise.all(chain.map(d => listDir($, d).catch(() => null)))
153 chain.forEach((d, i) => {
154 const l = lists[i]
155 if (l) scanned[keyOf(d)] = l
156 })
157 await rememberRoot($, root, scanned)
158
159 await update($, edits, list => {
160 const at = list.findIndex(m => keyOf(m.path) === keyOf(file))
161 if (at >= 0) return list.map((m, i) => (i === at ? { ...m, tool, count: m.count + 1 } : m))
162 const seq = list.reduce((n, m) => Math.max(n, m.seq), 0) + 1
163 return [...list, { path: file, tool, seq, count: 1, isNew }].slice(-200)
164 })
165 await update($, last, () => file)
166
167 const v = await read($, view)
168 if (!v.autoOpen) return
169 if (v.quietFolder && keyOf(v.quietFolder) === keyOf(parentOf(file))) return
170 if (v.quietFolder) await update($, view, prev => ({ ...prev, quietFolder: '' }))
171 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
172 if (!isOpen) await $.ui.open({ id: PANE, title: TITLE })
173}
174
175async function rescanAll($: EngineInterface): Promise<void> {
176 const cwd = norm(await $.session.cwd())
177 const list = await read($, roots)
178 const targets = list.length ? list : [cwd]
179 const scans = await Promise.all(targets.map(r => scanTree($, r)))
180 const merged: Record<string, Listing> = Object.assign({}, ...scans)
181 if (!list.length) await update($, roots, () => [cwd])
182 await update($, dirs, () => merged)
183}
184
185// Closing the map quiets the folder of the latest edit until Claude edits elsewhere.
186async function quietLastFolder($: EngineInterface): Promise<void> {
187 const file = await read($, last)
188 if (file) await update($, view, prev => ({ ...prev, quietFolder: parentOf(file) }))
189}
190
191async function togglePane($: EngineInterface): Promise<boolean> {
192 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
193 if (isOpen) {
194 // A close by command is the person's too; the `ui.close` hook sees only the plugin.
195 await quietLastFolder($)
196 await $.ui.close({ id: PANE })
197 return false
198 }
199 await update($, view, prev => ({ ...prev, quietFolder: '' }))
200 if (!(await read($, roots)).length) await rescanAll($)
201 await $.ui.open({ id: PANE, title: TITLE })
202 return true
203}
204
205// ---------------------------------------------------------------------------
206// Flattening a root into drawable rows.
207
208type Row = {
209 abs: string
210 name: string
211 depth: number
212 kind: 'dir' | 'file' | 'other' | 'more'
213 isOpen: boolean
214 isSkipped: boolean
215 isLastSibling: boolean
216 /** Per column above this row: whether that ancestor's line continues. */
217 guides: boolean[]
218 /** Keys of this row's ancestors below the root, itself last. */
219 ancestors: string[]
220 mark?: EditMark
221 /** Edited files beneath a folder. */
222 touched: number
223 more: number
224}
225
226type Tree = {
227 root: string
228 rows: Row[]
229 trail: string[]
230 index: Map<string, number>
231 hidden: number
232 isFocused: boolean
233}
234
235function flatten(root: string, listings: Record<string, Listing>, marks: EditMark[], lastFile: string, mode: View['mode']): Tree {
236 const byKey = new Map(marks.map(m => [keyOf(m.path), m]))
237 const touched = new Map<string, number>()
238 for (const m of marks) {
239 if (!isUnder(m.path, root)) continue
240 for (const d of chainDirs(root, parentOf(m.path))) touched.set(keyOf(d), (touched.get(keyOf(d)) ?? 0) + 1)
241 }
242 const trail = lastFile && isUnder(lastFile, root) ? [...chainDirs(root, parentOf(lastFile)), lastFile].map(keyOf) : []
243
244 // A listing past its cap may have left out an edited file, or a folder on
245 // the way to one: put those back so every edit keeps its row.
246 const withTracked = (dir: string, l: Listing): Listing => {
247 if (!l.more) return l
248 const have = new Set(l.entries.map(en => keyOf(join(dir, en.name))))
249 const extra: Entry[] = []
250 for (const m of marks) {
251 if (keyOf(m.path) === keyOf(dir) || !isUnder(m.path, dir)) continue
252 const segs = segmentsBelow(dir, m.path)
253 const name = segs[0]
254 if (!name) continue
255 const k = keyOf(join(dir, name))
256 if (have.has(k)) continue
257 have.add(k)
258 extra.push({ name, kind: segs.length === 1 ? 'file' : 'dir' })
259 }
260 if (!extra.length) return l
261 return { entries: [...l.entries, ...extra].sort(byKindThenName), more: Math.max(0, l.more - extra.length) }
262 }
263
264 const build = (focus: boolean): Row[] => {
265 const rows: Row[] = []
266 const walk = (dir: string, depth: number, guides: boolean[], ancestors: string[]) => {
267 const listed = listings[keyOf(dir)]
268 if (!listed) return
269 const l = withTracked(dir, listed)
270 // Edited branches: folders with no edits beneath them are left out.
271 const entries = focus
272 ? l.entries.filter(en => en.kind !== 'dir' || (touched.get(keyOf(join(dir, en.name))) ?? 0) > 0)
273 : l.entries
274 entries.forEach((en, i) => {
275 const isLastSibling = i === entries.length - 1 && !l.more
276 const abs = join(dir, en.name)
277 const k = keyOf(abs)
278 const below = touched.get(k) ?? 0
279 const isOpen = en.kind === 'dir' && !!listings[k] && (!focus || below > 0)
280 const chain = [...ancestors, k]
281 rows.push({
282 abs,
283 name: en.name,
284 depth,
285 kind: en.kind,
286 isOpen,
287 isSkipped: !!en.isSkipped,
288 isLastSibling,
289 guides,
290 ancestors: chain,
291 mark: en.kind === 'file' ? byKey.get(k) : undefined,
292 touched: en.kind === 'dir' ? below : 0,
293 more: 0,
294 })
295 if (isOpen) walk(abs, depth + 1, [...guides, !isLastSibling], chain)
296 })
297 if (l.more)
298 rows.push({
299 abs: join(dir, '…'),
300 name: `${l.more} more`,
301 depth,
302 kind: 'more',
303 isOpen: false,
304 isSkipped: false,
305 isLastSibling: true,
306 guides,
307 ancestors: [...ancestors, keyOf(join(dir, '…'))],
308 touched: 0,
309 more: l.more,
310 })
311 }
312 walk(root, 0, [], [])
313 return rows
314 }
315
316 let isFocused = mode === 'focus'
317 let rows = build(isFocused)
318 // A whole tree too big to read falls back to the edited branches.
319 if (!isFocused && rows.length > MAX_ROWS) {
320 isFocused = true
321 rows = build(true)
322 }
323 const hidden = Math.max(0, rows.length - MAX_ROWS)
324 rows = rows.slice(0, MAX_ROWS)
325 const index = new Map(rows.map((r, i) => [keyOf(r.abs), i]))
326 return { root, rows, trail, index, hidden, isFocused }
327}
328
329// How each guide column of a row is lit on the trail from the root to the
330// latest file: column j belongs to the folder at depth j-1 (the root for 0).
331function lit(t: Tree, r: Row, rowIdx: number, j: number): { up: boolean; down: boolean; across: boolean } {
332 const none = { up: false, down: false, across: false }
333 const owner = j === 0 ? keyOf(t.root) : r.ancestors[j - 1]
334 if (!t.trail.length || t.trail[j] !== owner) return none
335 const target = t.trail[j + 1]
336 if (!target) return none
337 const targetIdx = t.index.get(target)
338 if (targetIdx === undefined) return none
339 const self = r.ancestors[j]
340 if (self === undefined) return none
341 const selfIdx = j === r.depth ? rowIdx : t.index.get(self) ?? Infinity
342 if (j < r.depth) return selfIdx < targetIdx ? { up: true, down: true, across: false } : none
343 if (self === target) return { up: true, down: false, across: true }
344 return selfIdx < targetIdx ? { up: true, down: true, across: false } : none
345}
346
347// ---------------------------------------------------------------------------
348// Look: file colors by extension, the same SVG idiom as savvy-progress.
349
350const EXT_COLOR: Record<string, string> = {
351 ts: '#3178c6', tsx: '#3178c6', mts: '#3178c6', cts: '#3178c6',
352 js: '#d4a514', jsx: '#d4a514', mjs: '#d4a514', cjs: '#d4a514',
353 py: '#4b8bbe', ipynb: '#f37726', json: '#cb8a2c', md: '#7d7d79', mdx: '#7d7d79',
354 css: '#a35bd6', scss: '#c6538c', html: '#e2582c', rs: '#ce7c4a', go: '#00a7d0',
355 java: '#e76f00', kt: '#a97bff', sh: '#5fbf8f', ps1: '#5fbf8f', yml: '#c94a7a', yaml: '#c94a7a',
356 toml: '#9c4221', sql: '#e38c00', csv: '#5fbf8f', txt: '#9a9a96', svg: '#e2a03f', png: '#b07cc6', jpg: '#b07cc6',
357}
358const extOf = (name: string): string => (name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : '')
359const colorOfFile = (name: string): string => EXT_COLOR[extOf(name)] ?? '#9a9a96'
360
361const xml = (s: string): string =>
362 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
363
364const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56)
365const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
366const fitText = (s: string, size: number, maxW: number): string => {
367 if (textWidth(s, size) <= maxW) return s
368 let out = ''
369 for (const ch of s) {
370 if (textWidth(out + ch + '…', size) > maxW) break
371 out += ch
372 }
373 return out + '…'
374}
375
376const CSS = `<style>
377.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.tile{fill:#f4f3f0}.fd{fill:#c9c6bd}.fdo{fill:#e2dfd6}
378.g{stroke:#dddcd7}.bg{fill:#1f1f1f;opacity:0}.r:hover .bg{opacity:.05}
379@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.tile{fill:#262625}.fd{fill:#6b6964}.fdo{fill:#55534f}.g{stroke:#3a3a38}.bg{fill:#ffffff}}
380.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
381@media (prefers-reduced-motion: reduce){.live{animation:none}}
382</style>`
383
384// Pixel Clawd, as savvy-progress draws it (from DockCrab / Clawdy, via
385// github.com/JohnnyVizz/claude-kit): a 24×18 crab on a 30×28 grid.
386const CLAY = '#D97757'
387const INK = '#1F1E1D'
388
389type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
390
391const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
392 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
393
394const crabBody = (f: Fill): void => {
395 f(7, 10, 16, 12, CLAY)
396 f(3, 14, 4, 4, CLAY)
397 f(23, 14, 4, 4, CLAY)
398 f(9, 12, 2, 2, INK)
399 f(19, 12, 2, 2, INK)
400 f(7, 22, 2, 4, CLAY, 'la')
401 f(17, 22, 2, 4, CLAY, 'la')
402 f(11, 22, 2, 4, CLAY, 'lb')
403 f(21, 22, 2, 4, CLAY, 'lb')
404}
405
406// `builder` wears the hard hat and turns a wrench: Claude made a new file.
407const COSTUMES: Record<string, (f: Fill) => void> = {
408 builder: f => {
409 crabBody(f)
410 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
411 f(13, 5, 4, 2, ACCENT)
412 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
413 },
414 plain: f => crabBody(f),
415}
416
417// Pure CSS, run by the compositor: legs step, the body bobs, a patrolling crab
418// walks the row and turns at each end; a resting one breathes.
419const CRAB_CSS = `<style>
420.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
421.run .bd{animation:bob .5s steps(1) infinite -.125s}
422.run g{transform-box:fill-box}
423@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
424.c-builder.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
425@keyframes twist{50%{transform:rotate(-35deg)}}
426.patrol{transform-box:fill-box;transform-origin:center;animation:patrol var(--t) linear infinite}
427@keyframes patrol{0%{transform:translateX(0) scaleX(1)}49.9%{transform:translateX(var(--d)) scaleX(1)}50%{transform:translateX(var(--d)) scaleX(-1)}100%{transform:translateX(0) scaleX(-1)}}
428.rest{transform-box:fill-box;transform-origin:50% 100%;animation:breathe 2.4s ease-in-out infinite}
429@keyframes breathe{50%{transform:scaleY(.94)}}
430@media (prefers-reduced-motion: reduce){.run,.run g,.patrol,.rest{animation:none!important}}
431</style>`
432
433const crab = (x: number, y: number, costume: string, isWalking: boolean, scale: number, dim = false): string => {
434 const groups = new Map<string, string[]>([['bd', []]])
435 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
436 if (!groups.has(cls)) groups.set(cls, [])
437 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
438 }
439 ;(COSTUMES[costume] ?? crabBody)(f)
440 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
441 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
442 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
443 return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.5 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
444}
445
446// A crab that walks between two x positions, turning at each end.
447const patrolCrab = (x0: number, x1: number, y: number, scale: number, costume: string): string => {
448 const d = Math.max(0, Math.round(x1 - x0))
449 if (d < 12) return crab(x0, y, costume, true, scale)
450 return `<g class="patrol" style="--d:${d}px;--t:${Math.max(2, d / 22).toFixed(1)}s">${crab(x0, y, costume, true, scale)}</g>`
451}
452
453const costumeOf = (m: EditMark | undefined): string => (m?.isNew ? 'builder' : 'plain')
454
455const svg = (W: number, H: number, body: string): string =>
456 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${CRAB_CSS}${body}</svg>`
457
458const folderIcon = (x: number, y: number, isOpen: boolean, fill: string | null): string => {
459 const cls = fill ? '' : ' class="fd"'
460 const f = fill ? ` fill="${fill}"` : ''
461 const back = `<path${cls}${f} d="M${x} ${y + 2.5}a1.5 1.5 0 0 1 1.5-1.5h4l1.6 1.8h7.4a1.5 1.5 0 0 1 1.5 1.5v8.2a1.5 1.5 0 0 1-1.5 1.5h-13a1.5 1.5 0 0 1-1.5-1.5z"/>`
462 if (!isOpen) return back
463 const front = fill
464 ? `<path fill="${fill}" opacity=".55" d="M${x + 1.2} ${y + 6}h14.6l-1.4 6.6a1.4 1.4 0 0 1-1.4 1.1h-12.4z"/>`
465 : `<path class="fdo" d="M${x + 1.2} ${y + 6}h14.6l-1.4 6.6a1.4 1.4 0 0 1-1.4 1.1h-12.4z"/>`
466 return back + front
467}
468
469const fileIcon = (x: number, y: number, color: string): string =>
470 `<path d="M${x + 2.5} ${y}h6.5l4 4v9.5a1.5 1.5 0 0 1-1.5 1.5h-9a1.5 1.5 0 0 1-1.5-1.5v-12a1.5 1.5 0 0 1 1.5-1.5z" fill="${color}" fill-opacity=".16" stroke="${color}" stroke-width="1.2"/><path d="M${x + 9} ${y}v4h4" fill="none" stroke="${color}" stroke-width="1.2"/>`
471
472const badge = (cx: number, cy: number, n: number): string =>
473 `<circle cx="${cx}" cy="${cy}" r="8" fill="${ACCENT}"/><text x="${cx}" y="${cy + 3.6}" text-anchor="middle" font-family="${FONT}" font-size="${n > 99 ? 7.5 : 10}" font-weight="700" fill="#fff" font-variant-numeric="tabular-nums">${n}</text>`
474
475function rowSvg(t: Tree, r: Row, i: number, y: number, W: number, lastKey: string): string {
476 const mid = y + ROW_H / 2
477 const k = keyOf(r.abs)
478 const isLast = k === lastKey
479 const parts: string[] = []
480 parts.push(`<rect class="bg" x="0" y="${y + 1}" width="${W}" height="${ROW_H - 2}" rx="6"/>`)
481 if (isLast) {
482 parts.push(`<rect x="0" y="${y + 1}" width="${W}" height="${ROW_H - 2}" rx="6" fill="${ACCENT}" fill-opacity=".14"/>`)
483 parts.push(`<rect x="0" y="${y + 4}" width="3" height="${ROW_H - 8}" rx="1.5" fill="${ACCENT}"/>`)
484 }
485 // Guides: dim lines, the trail to the latest file in accent.
486 for (let j = 0; j <= r.depth; j++) {
487 const x = 12 + j * INDENT + 0.5
488 const l = lit(t, r, i, j)
489 const stroke = (on: boolean) => (on ? ` stroke="${ACCENT}" stroke-width="1.6"` : ' class="g" stroke-width="1"')
490 if (j < r.depth) {
491 if (r.guides[j]) parts.push(`<line x1="${x}" y1="${y}" x2="${x}" y2="${y + ROW_H}"${stroke(l.up)}/>`)
492 continue
493 }
494 parts.push(`<path d="M${x} ${y}V${mid - 3}q0 3 3 3H${x + 8}" fill="none"${stroke(l.up)}/>`)
495 if (!r.isLastSibling) parts.push(`<line x1="${x}" y1="${mid}" x2="${x}" y2="${y + ROW_H}"${stroke(l.down)}/>`)
496 }
497 const ix = 12 + r.depth * INDENT + 11
498 const isOnTrail = t.trail.includes(k)
499 if (r.kind === 'dir') parts.push(folderIcon(ix, mid - 7, r.isOpen, r.touched ? ACCENT : null))
500 else if (r.kind === 'file' || r.kind === 'other') parts.push(fileIcon(ix + 1, mid - 7.5, colorOfFile(r.name)))
501
502 // Right side: the edit's number, its count, a "new" tag; a folder's tally.
503 const right: string[] = []
504 let rx = W - 4
505 if (r.mark) {
506 right.push(badge(rx - 8, mid, r.mark.seq))
507 rx -= 22
508 if (r.mark.count > 1) {
509 right.push(`<text class="s" x="${rx}" y="${mid + 4}" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">×${r.mark.count}</text>`)
510 rx -= textWidth(`×${r.mark.count}`, 11) + 6
511 }
512 if (r.mark.isNew) {
513 right.push(`<rect x="${rx - 28}" y="${mid - 7}" width="28" height="14" rx="7" fill="${DONE}"/><text x="${rx - 14}" y="${mid + 3.5}" text-anchor="middle" font-family="${FONT}" font-size="9" font-weight="700" fill="#fff">NEW</text>`)
514 rx -= 34
515 }
516 } else if (r.kind === 'dir' && r.touched) {
517 right.push(`<text x="${rx}" y="${mid + 4}" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="600" fill="${ACCENT}" font-variant-numeric="tabular-nums">${r.touched} edited</text>`)
518 rx -= textWidth(`${r.touched} edited`, 11) + 8
519 }
520
521 const nx = ix + 22
522 const maxW = Math.max(30, rx - nx - 6)
523 if (r.kind === 'more') {
524 parts.push(`<text class="m" x="${nx - 22}" y="${mid + 4}" font-family="${FONT}" font-size="12" font-style="italic">${xml(r.name)}</text>`)
525 } else {
526 const name = fitText(r.name + (r.isSkipped ? ' …' : ''), 12.5, maxW)
527 const style = r.mark
528 ? `fill="${ACCENT}" font-weight="600"`
529 : r.isSkipped
530 ? 'class="m"'
531 : `class="t"${isOnTrail || r.touched ? ' font-weight="600"' : r.kind === 'dir' ? ' font-weight="500"' : ''}`
532 parts.push(`<text ${style} x="${nx}" y="${mid + 4.3}" font-family="${FONT}" font-size="12.5">${xml(name)}</text>`)
533 // The crab walks the free stretch of the current file's row.
534 if (isLast) {
535 const CRAB_W = 30 * 0.6
536 const from = nx + textWidth(name, 12.5) + 10
537 right.push(patrolCrab(from, Math.max(from, rx - CRAB_W - 4), mid - 8.6, 0.6, costumeOf(r.mark)))
538 }
539 }
540 const tip = r.mark ? `${r.abs} — #${r.mark.seq}, ${r.mark.count} edit${r.mark.count > 1 ? 's' : ''}` : r.abs
541 return `<g class="r"><title>${xml(tip)}</title>${parts.join('')}${right.join('')}</g>`
542}
543
544function rootRowSvg(t: Tree, W: number, touched: number): string {
545 const mid = ROW_H / 2
546 const label = fitText(baseName(t.root), 13.5, W - 120)
547 const tally = touched
548 ? `<text x="${W - 4}" y="${mid + 4}" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="600" fill="${ACCENT}">${touched} edited</text>`
549 : ''
550 return `<g class="r"><title>${xml(t.root)}</title>${folderIcon(4, mid - 7, true, touched ? ACCENT : null)}<text class="t" x="28" y="${mid + 4.5}" font-family="${FONT}" font-size="13.5" font-weight="700">${xml(label)}</text>${tally}</g>`
551}
552
553function treeSvgs(t: Tree, W: number, lastKey: string, touched: number): { source: string; height: number; alt: string }[] {
554 const out: { source: string; height: number; alt: string }[] = []
555 for (let start = 0; start === 0 || start < t.rows.length; start += CHUNK) {
556 const slice = t.rows.slice(start, start + CHUNK)
557 const head = start === 0 ? ROW_H : 0
558 const body = slice.map((r, n) => rowSvg(t, r, start + n, head + n * ROW_H, W, lastKey)).join('')
559 const height = head + slice.length * ROW_H + 2
560 out.push({
561 source: svg(W, height, (start === 0 ? rootRowSvg(t, W, touched) : '') + body),
562 height,
563 alt: `${baseName(t.root)}: rows ${start + 1}-${start + slice.length} of ${t.rows.length}`,
564 })
565 if (!slice.length) break
566 }
567 return out
568}
569
570type Stats = { files: number; folders: number; edits: number }
571
572const statsOf = (marks: EditMark[]): Stats => ({
573 files: marks.length,
574 folders: new Set(marks.map(m => keyOf(parentOf(m.path)))).size,
575 edits: marks.reduce((n, m) => n + m.count, 0),
576})
577
578// The header: three tiles, then the latest file's place as a breadcrumb.
579function headerSvg(W: number, st: Stats, crumbs: string[], mark: EditMark | undefined): { source: string; height: number } {
580 const gap = 6
581 const tw = (W - gap * 2) / 3
582 const tile = (i: number, k: string, v: string) =>
583 `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
584<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
585<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
586 let body = tile(0, 'Files edited', String(st.files)) + tile(1, 'Folders', String(st.folders)) + tile(2, 'Edits', String(st.edits))
587 if (!mark || !crumbs.length) {
588 // Nothing edited yet: the crab rests in an empty card.
589 body += `<rect class="tile" x="0" y="48" width="${W}" height="40" rx="8"/>
590<g class="rest">${crab(10, 54, 'plain', false, 1)}</g>
591<text class="s" x="48" y="72" font-family="${FONT}" font-size="12">No edits yet. The map opens when Claude edits a file.</text>`
592 return { source: svg(W, 92, body), height: 92 }
593 }
594
595 // Chips flow left to right and wrap; the file itself is the accent pill.
596 const top = 48
597 const pad = 10
598 const lineH = 24
599 const CRAB = 36
600 const chips: string[] = []
601 let x = pad
602 let y = top + 34
603 crumbs.forEach((c, i) => {
604 const isFile = i === crumbs.length - 1
605 const label = fitText(c, 12, W - pad * 2 - 40 - CRAB)
606 const w = textWidth(label, 12) + (isFile ? 22 : 20)
607 const sepW = i ? 14 : 0
608 if (i && x + sepW + w > W - pad - CRAB) {
609 x = pad
610 y += lineH
611 } else if (i) {
612 chips.push(`<path d="M${x + 4} ${y - 9}l4 4-4 4" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`)
613 x += sepW
614 }
615 if (isFile) {
616 chips.push(`<rect x="${x}" y="${y - 14}" width="${w}" height="19" rx="9.5" fill="${ACCENT}"/><text x="${x + 11}" y="${y - 0.5}" font-family="${FONT}" font-size="12" font-weight="600" fill="#fff">${xml(label)}</text>`)
617 } else {
618 chips.push(`${folderIcon(x + 1, y - 12, false, null)}<text class="t" x="${x + 20}" y="${y - 0.5}" font-family="${FONT}" font-size="12">${xml(label)}</text>`)
619 }
620 x += w
621 })
622 const cardH = y - top + 12
623 const meta = `#${mark.seq} · ${mark.tool}${mark.count > 1 ? ` · ${mark.count} edits` : ''} · depth ${crumbs.length - 1}`
624 body += `<rect class="tile" x="0" y="${top}" width="${W}" height="${cardH}" rx="8"/>
625<circle class="live" cx="${pad + 3}" cy="${top + 13}" r="3.5" fill="${ACCENT}"/>
626<text class="s" x="${pad + 12}" y="${top + 17}" font-family="${FONT}" font-size="11">Last edit</text>
627<text class="m" x="${W - pad - CRAB}" y="${top + 17}" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(meta)}</text>
628${chips.join('')}
629${crab(W - pad - 30, top + (cardH - 28) / 2, costumeOf(mark), true, 1)}`
630 return { source: svg(W, top + cardH + 4, body), height: top + cardH + 4 }
631}
632
633// ---------------------------------------------------------------------------
634// Terminal drawing: the same rows in text.
635
636const CIRCLED = '①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳'
637const seqGlyph = (n: number): string => (n >= 1 && n <= 20 ? [...CIRCLED][n - 1] ?? `#${n}` : `#${n}`)
638
639export const register: Register = on => {
640 on('session.start', async ($, e, next) => {
641 const started = await next(e)
642 await $.command.register({
643 name: 'folder-map',
644 description: 'Show or hide the folder map: the project tree with every file Claude edited, in order',
645 })
646 return started
647 })
648
649 on('command.run', { command: 'folder-map' }, async $ => {
650 const isOpen = await togglePane($)
651 return { text: isOpen ? 'Folder map opened.' : 'Folder map closed.' }
652 })
653
654 on('tool.call', async ($, e, next) => {
655 const tool = String(e.tool)
656 if (!EDIT_TOOLS.has(tool)) return next(e)
657 const raw = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
658 if (typeof raw !== 'string' || !raw) return next(e)
659
660 let file = norm(raw)
661 let isNew = false
662 try {
663 file = absolute(raw, norm(await $.session.cwd()))
664 isNew = tool === 'Write' && !(await $.fs.exists(file))
665 } catch {
666 // The map is a view: it never stands in the edit's way.
667 }
668 const ran = await next(e)
669 if (ran.deny !== undefined || ran.isError) return ran
670 try {
671 await track($, file, tool, isNew)
672 } catch {
673 // Same: a failed scan leaves the map as it was.
674 }
675 return ran
676 })
677
678 // The person closed it: stay quiet for edits in that folder.
679 on('ui.close', { id: PANE }, async ($, e, next) => {
680 if (e.origin.kind === 'person') await quietLastFolder($)
681 return next(e)
682 })
683
684 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
685 const ui = $.ui.resolve(e)
686 const { Box, Text, Button } = ui
687 const rootList = await read($, roots)
688 const listings = await read($, dirs)
689 const marks = await read($, edits)
690 const lastFile = await read($, last)
691 const v: View = await read($, view)
692 const lastKey = keyOf(lastFile)
693 const st = statsOf(marks)
694 const trees = rootList.map(r => flatten(r, listings, marks, lastFile, v.mode))
695 const lastRoot = rootList.find(r => lastFile && isUnder(lastFile, r))
696 const crumbs = lastRoot && lastFile ? [baseName(lastRoot), ...segmentsBelow(lastRoot, lastFile)] : []
697 const lastMark = marks.find(m => keyOf(m.path) === lastKey)
698 const touchedIn = (r: string) => marks.filter(m => isUnder(m.path, r)).length
699
700 const controls = (
701 <Box key="controls" flexDirection="row" gap={2}>
702 <Button
703 key="mode"
704 plain
705 label={v.mode === 'tree' ? '◎ Edited branches' : '▤ Full tree'}
706 onPress={() => update($, view, (prev): View => ({ ...prev, mode: prev.mode === 'tree' ? 'focus' : 'tree' }))}
707 />
708 <Button
709 key="auto"
710 plain
711 label={v.autoOpen ? '● Auto-open' : '○ Auto-open'}
712 onPress={() => update($, view, prev => ({ ...prev, autoOpen: !prev.autoOpen }))}
713 />
714 <Button key="refresh" plain label="↻ Refresh" onPress={() => rescanAll($)} />
715 </Box>
716 )
717 const empty = !rootList.length && (
718 <Text key="empty" dimColor>
719 No edits yet. The map opens on its own when Claude edits a file.
720 </Text>
721 )
722 const notes = (t: Tree) =>
723 [
724 t.isFocused && v.mode === 'tree' ? 'Large folder: showing the edited branches.' : '',
725 t.hidden ? `${t.hidden} more rows not shown.` : '',
726 ].filter(Boolean)
727
728 if (e.surface === 'desktop' && 'Svg' in ui) {
729 const { Svg } = ui
730 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
731 const head = headerSvg(W, st, crumbs, lastMark)
732 return (
733 <Box flexDirection="column" gap={1}>
734 <Svg
735 key="header"
736 source={head.source}
737 alt={`${st.files} files edited in ${st.folders} folders${crumbs.length ? `; last: ${crumbs.join(' / ')}` : ''}`}
738 width={W}
739 height={head.height}
740 />
741 {controls}
742 {trees.map(t => (
743 <Box key={`root-${keyOf(t.root)}`} flexDirection="column">
744 {treeSvgs(t, W, lastKey, touchedIn(t.root)).map((c, n) => (
745 <Svg key={`tree-${keyOf(t.root)}-${n}`} source={c.source} alt={c.alt} width={W} height={c.height} isInteractive />
746 ))}
747 {notes(t).map(s => (
748 <Text key={s} dimColor>
749 {s}
750 </Text>
751 ))}
752 </Box>
753 ))}
754 </Box>
755 )
756 }
757
758 // Terminal and the other surfaces: text rows with box-drawing guides.
759 const line = (t: Tree, r: Row, i: number) => {
760 const k = keyOf(r.abs)
761 const isLast = k === lastKey
762 const cols: { text: string; on: boolean }[] = []
763 for (let j = 0; j <= r.depth; j++) {
764 const l = lit(t, r, i, j)
765 if (j < r.depth) cols.push({ text: r.guides[j] ? '│ ' : ' ', on: l.up && !!r.guides[j] })
766 else cols.push({ text: r.isLastSibling ? '└─ ' : '├─ ', on: l.across || l.up })
767 }
768 const icon = r.kind === 'dir' ? (r.isOpen ? '▾ ' : '▸ ') : r.kind === 'more' ? '' : '• '
769 const iconColor = r.kind === 'dir' ? (r.touched ? ACCENT : undefined) : colorOfFile(r.name)
770 return (
771 <Text key={`${keyOf(t.root)}:${i}`} wrap="truncate-end">
772 {cols.map((c, n) =>
773 c.on ? (
774 <Text key={`g${n}`} color={ACCENT}>
775 {c.text}
776 </Text>
777 ) : (
778 <Text key={`g${n}`} dimColor>
779 {c.text}
780 </Text>
781 ),
782 )}
783 {icon && <Text color={iconColor}>{icon}</Text>}
784 {r.mark ? (
785 <Text color={ACCENT} bold>
786 {r.name}
787 </Text>
788 ) : r.kind === 'more' || r.isSkipped ? (
789 <Text dimColor>{r.isSkipped ? `${r.name} …` : r.name}</Text>
790 ) : (
791 <Text bold={t.trail.includes(k) || r.touched > 0}>{r.name}</Text>
792 )}
793 {r.mark && (
794 <Text color={ACCENT}>
795 {' '}
796 {seqGlyph(r.mark.seq)}
797 {r.mark.count > 1 ? ` ×${r.mark.count}` : ''}
798 </Text>
799 )}
800 {r.mark?.isNew && <Text color={DONE}> new</Text>}
801 {r.kind === 'dir' && r.touched > 0 && <Text color={ACCENT}> ({r.touched})</Text>}
802 {isLast && <Text color={CLAY}> ▣</Text>}
803 {isLast && <Text color={ACCENT}> ◀ now</Text>}
804 </Text>
805 )
806 }
807
808 return (
809 <Box flexDirection="column">
810 {crumbs.length > 0 && (
811 <Text wrap="truncate-start">
812 <Text color={CLAY}>▣ </Text>
813 {crumbs.slice(0, -1).map((c, n) => (
814 <Text key={`c${n}`} dimColor>
815 {c} ›{' '}
816 </Text>
817 ))}
818 <Text color={ACCENT} bold>
819 {crumbs[crumbs.length - 1]}
820 </Text>
821 </Text>
822 )}
823 <Text dimColor>
824 {st.files} files · {st.folders} folders · {st.edits} edits
825 {lastMark ? ` · last ${seqGlyph(lastMark.seq)} ${lastMark.tool}` : ''}
826 </Text>
827 {controls}
828 {empty}
829 {trees.map(t => (
830 <Box key={`root-${keyOf(t.root)}`} flexDirection="column" marginTop={1}>
831 <Text wrap="truncate-end">
832 <Text color={touchedIn(t.root) ? ACCENT : undefined}>▾ </Text>
833 <Text bold>{baseName(t.root)}</Text>
834 <Text dimColor> {t.root}</Text>
835 </Text>
836 {t.rows.map((r, i) => line(t, r, i))}
837 {notes(t).map(s => (
838 <Text key={s} dimColor>
839 {s}
840 </Text>
841 ))}
842 </Box>
843 ))}
844 </Box>
845 )
846 })
847}
848types/index.d.ts 46 lines1export type EntryKind = 'file' | 'dir' | 'other'
2
3export type Entry = {
4 name: string
5 kind: EntryKind
6 /** A heavy folder (node_modules, .git, ...): listed, never descended. */
7 isSkipped?: boolean
8}
9
10/** One directory's listing, dirs first; `more` counts entries past the cap. */
11export type Listing = {
12 entries: Entry[]
13 more: number
14}
15
16export type EditMark = {
17 /** Absolute path, `/`-separated. */
18 path: string
19 tool: string
20 /** Order of first edit in the session, from 1. */
21 seq: number
22 count: number
23 isNew: boolean
24}
25
26export type View = {
27 mode: 'tree' | 'focus'
28 autoOpen: boolean
29 /** The folder whose edits stay quiet after the person closed the pane. */
30 quietFolder: string
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'folder-map': {
36 /** Project roots shown, the one holding the latest edit first. */
37 roots: string[]
38 /** Listings keyed by directory path (lower-cased on Windows). */
39 dirs: Record<string, Listing>
40 edits: EditMark[]
41 last: string
42 view: View
43 }
44 }
45}
46