A sidebar pane showing every file in the working directory as a collapsible tree, with git status, file icons and right-click actions

A Claude Code mod that shows every file in your working directory as a collapsible tree in a pane beside the conversation – not just the files git says changed, and whether or not the folder is a git repository.
my-project/ 4 changed
refresh collapse all
▾ src/ ··················· +4 -1 ●
│ ├ index.ts ············· +3 -1 M
│ ├ App.tsx
│ └ ▸ utils/ ··············· +1 ●
├ ▸ node_modules/
├ NOTES.md ················· +3 U
├ package.json
└ README.md
.git, .hg, .svn) are hidden. The tree stays in sync with the session if it changes directory – until you move up out of it.M modified, A added, U untracked, R renamed, D deleted, C conflict – and +added -removed line counts against HEAD. Folders sum up everything beneath them and show a ● in the colour of the most serious change, so a closed folder still tells you something changed inside. A dotted leader ties each name to its badge. Deleted files stay in the tree, struck through. Ignored paths are dimmed.← cwd; ↻ sync brings it back.git on your PATH for the status marks; a Nerd Font as your terminal font for the icons; VS Code (code on your PATH) for the open actions.Developed and tested on Windows. macOS and Linux should work (opening images uses open / xdg-open) but are untested.
In Claude Code:
/plugin marketplace add sebnor/claude-cli-filetree
/plugin install file-tree@claude-cli-filetree
Or from a shell:
claude plugin install file-tree --marketplace sebnor/claude-cli-filetree
The pane opens by itself at the start of a session once the terminal is wide enough to dock it (about 144 columns); otherwise run /files.
| Action | Result |
|---|---|
/files | Show or hide the pane |
/files sync | Put the tree back on the session's working directory, whatever state the pane is in |
| Left click on a folder | Open or close it |
| Left click on a file | Select it |
| Double click on a file | Open it: images in the system's default viewer, everything else in your editor |
| Drag a file left out of the pane and release | Reference it in your prompt (@path, at the prompt's cursor); release inside the pane to cancel |
| Right click on a file | Insert path (adds @path to your prompt – relative below the session's folder, absolute outside it), Edit file |
| Right click on a folder | New .md file (asks for a name, creates the file inside it, then opens it), Open VS Code (on that folder) |
| Right click below the last row | The same, for the top folder of the tree |
| Click anywhere else in the pane | Clear the selection, close the menu |
| ↑ up | Show the folder above as the top and pin the tree there (title: · pinned); the folder you came from stays open, marked ← cwd |
| ↻ sync (while pinned) | Back in sync with the session's working directory; folders open below it stay open |
| refresh, collapse all | Re-read the tree and git status; close every folder |
With the tree focused (click it): ↑/↓ move, Enter opens, →/← open and close folders, m opens the menu, u moves up (once per press – holding it does not climb on), s syncs. In a menu: ↑/↓ or Tab, Enter, or the item's number; q or ← closes it.
New .md file opens a name box at the top of the pane. A name without an extension gets .md (notes → notes.md), one with an extension keeps it, and docs/today creates the subfolder too. An empty name takes the suggested file.md (or file-1.md, ... when taken). A name that exists or holds characters a file name cannot is refused in the box. cancel closes it.
Editor: files open in $VISUAL, else $EDITOR, else VS Code. Use a graphical editor – a terminal editor such as vim cannot share Claude Code's terminal. Open VS Code always uses VS Code.
/config lists the plugin's one option:
Everything runs locally. The plugin reads your files and runs git status / git diff in your working directory; nothing is sent anywhere.
git clone https://github.com/sebnor/claude-cli-filetree
cd claude-cli-filetree
claude --plugin-dir . # a session with the plugin loaded from disk; saves reload it
claude plugin validate . # what the engine would refuse, before loading
claude plugin test . # the tests in tests/
npx -p typescript@5 tsc -p . # type-check (after one load has written .claude-plugin/types/)
| Path | What it is |
|---|---|
hooks/register.tsx | The hooks module: the pane, the /files command, refreshes, opening files |
hooks/tree-view.tsx | The surface module drawing the tree and reading the mouse and keys |
hooks/paths.ts | Path helpers: the tree's top folder may be above the session's cwd |
hooks/git.ts | Reads git status and git diff --numstat |
hooks/marks.ts, hooks/icons.ts | Git badges and file icons |
types/index.d.ts | The plugin's state contract |
hooks/register.tsx 568 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, FsEntry, Register } from 'claude-code'
3
4import type { GitStatus } from '../types'
5import { NO_GIT, deletedEntries, folderMarks, isIgnored, mergeGit, readGit } from './git'
6import { ICON_CELLS, iconOf } from './icons'
7import { badgeOf } from './marks'
8import type { TreeMark } from './marks'
9import { join, nameOf, normalize, parentOf, relativeTo } from './paths'
10import type { TreeMessage, TreeProps } from './tree-view'
11
12const PANE = 'file-tree'
13const TITLE = 'Files'
14const expanded = atom({ plugin: 'file-tree', key: 'expanded' } as const, [])
15const pinned = atom({ plugin: 'file-tree', key: 'pinned' } as const, '')
16const newFile = atom({ plugin: 'file-tree', key: 'newFile' } as const, { isOpen: false, dir: '', suggested: '', name: '', error: '' })
17const tick = atom({ plugin: 'file-tree', key: 'tick' } as const, 0)
18const git = atom({ plugin: 'file-tree', key: 'git' } as const, NO_GIT)
19
20// Hidden however the tree is opened: version-control internals, never useful here.
21const ALWAYS_HIDDEN = new Set(['.git', '.hg', '.svn'])
22// Bounds one drawing, so opening node_modules cannot stall the pane (and keeps the
23// tree view's props under a Client's 100,000 characters).
24const MAX_ROWS = 1500
25const POLL_MS = 4000
26const MUTATING_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell'])
27
28export type Row = {
29 path: string
30 name: string
31 prefix: string
32 isDir: boolean
33 isOpen: boolean
34 mark?: TreeMark
35}
36
37/** What one walk draws from: the cwd, the open folders, and the git status and what it derives. */
38type Context = {
39 root: string
40 open: ReadonlySet<string>
41 status: GitStatus
42 folders: ReturnType<typeof folderMarks>
43 deleted: ReturnType<typeof deletedEntries>
44}
45
46const contextOf = (root: string, open: ReadonlySet<string>, status: GitStatus): Context => ({
47 root,
48 open,
49 status,
50 folders: folderMarks(status),
51 deleted: deletedEntries(status),
52})
53
54type Listed = { entry: FsEntry; isGone: boolean }
55
56const byKindThenName = (a: Listed, b: Listed) => {
57 const aDir = a.entry.kind === 'dir' ? 0 : 1
58 const bDir = b.entry.kind === 'dir' ? 0 : 1
59 return aDir - bDir || a.entry.name.localeCompare(b.entry.name, undefined, { sensitivity: 'base' })
60}
61
62// The folder's entries, plus what git says was deleted from it: gone from disk, still a change.
63async function listDir($: EngineInterface, context: Context, dir: string): Promise<Listed[]> {
64 const path = join(context.root, dir)
65 const entries = await $.fs.list(path).catch(() => [] as FsEntry[])
66 const listed = entries.filter(entry => !ALWAYS_HIDDEN.has(entry.name)).map(entry => ({ entry, isGone: false }))
67 const present = new Set(entries.map(entry => entry.name))
68 for (const [name, isDir] of context.deleted.get(dir) ?? []) {
69 if (present.has(name)) continue
70 listed.push({ entry: { name, kind: isDir ? 'dir' : 'file', size: 0, mtimeMs: 0, isLink: false }, isGone: true })
71 }
72
73 return listed.sort(byKindThenName)
74}
75
76function markOf(context: Context, path: string, isDir: boolean, isGone: boolean): TreeMark | undefined {
77 if (!context.status.isRepo) return undefined
78 const change = isDir ? context.folders.get(path) : context.status.files[path]
79 const mark: TreeMark = change ? { c: change.code, a: change.added, r: change.removed } : {}
80 if (isIgnored(context.status, path)) mark.i = 1
81 if (isGone) mark.x = 1
82
83 return Object.keys(mark).length > 0 ? mark : undefined
84}
85
86// Depth-first over the open directories only; a symbolic link is listed but never followed.
87// Paths in the rows stay relative to the tree's top folder.
88export async function walk($: EngineInterface, context: Context, dir = '', prefix = '', rows: Row[] = []) {
89 const listed = await listDir($, context, dir)
90 for (const [index, { entry, isGone }] of listed.entries()) {
91 if (rows.length >= MAX_ROWS) break
92 const isLast = index === listed.length - 1
93 const path = dir === '' ? entry.name : `${dir}/${entry.name}`
94 const isDir = entry.kind === 'dir'
95 const isOpen = isDir && context.open.has(path)
96 const mark = markOf(context, path, isDir, isGone)
97 rows.push({ path, name: entry.name, prefix: prefix + (isLast ? '└ ' : '├ '), isDir, isOpen, mark })
98 if (isOpen) await walk($, context, path, prefix + (isLast ? ' ' : '│ '), rows)
99 }
100
101 return rows
102}
103
104const signature = (rows: Row[]) => rows.map(row => (row.isDir ? 'd:' : 'f:') + row.path).join('\n')
105
106const fit = (text: string, room: number) =>
107 room <= 1 ? '…' : text.length > room ? text.slice(0, room - 1) + '…' : text
108
109
110const toggle = ($: EngineInterface, path: string) =>
111 update($, expanded, list => (list.includes(path) ? list.filter(one => one !== path) : [...list, path]))
112
113const refresh = ($: EngineInterface) => update($, tick, n => n + 1)
114
115/** The session's working directory, spelled as the tree spells paths. */
116const cwdOf = async ($: EngineInterface) => normalize(await $.session.cwd())
117
118/** The tree's top folder: where the person pinned it by moving up, else the session's cwd. */
119const rootOf = async ($: EngineInterface) => (await read($, pinned)) || cwdOf($)
120
121/** Every row path below `from`'s open folders, respelled from `to` (dropped where outside `to`). */
122function rebase(list: readonly string[], from: string, to: string): string[] {
123 return list.flatMap(path => {
124 const rel = relativeTo(to, join(from, path))
125 return rel === undefined || rel === '' ? [] : [rel]
126 })
127}
128
129// Moves the tree's top up one folder and pins it there: it no longer follows the session's cwd.
130// The folder it came from stays open, so the place the person was is still in view.
131async function moveUp($: EngineInterface) {
132 const root = await rootOf($)
133 const parent = parentOf(root)
134 if (parent === undefined) return
135 const came = relativeTo(parent, root)
136 await update($, expanded, list => [...new Set([...(came ? [came] : []), ...rebase(list, root, parent)])])
137 await update($, pinned, () => parent)
138 await refreshAll($)
139}
140
141// Back in sync with the session's cwd: the open folders below it stay open.
142async function sync($: EngineInterface) {
143 const root = await rootOf($)
144 const cwd = await cwdOf($)
145 await update($, expanded, list => rebase(list, root, cwd))
146 await update($, pinned, () => '')
147 await refreshAll($)
148}
149
150// Bounds the folders checked for a `.git` of their own on one read.
151const MAX_REPO_PROBES = 200
152
153// The tree's repository; where its top is in none (a folder of projects), every folder on screen
154// that is a repository's top, each read by its own git. Written only when it changed, so a quiet
155// repository redraws nothing.
156async function refreshGit($: EngineInterface) {
157 const root = await rootOf($)
158 let next = await readGit($, root)
159 if (!next.isRepo) {
160 const rows = await walk($, contextOf(root, new Set(await read($, expanded)), NO_GIT))
161 const dirs = rows.filter(row => row.isDir).slice(0, MAX_REPO_PROBES)
162 const isTop = await Promise.all(dirs.map(row => $.fs.exists(`${join(root, row.path)}/.git`).catch(() => false)))
163 const repos = dirs.filter((_, index) => isTop[index]).map(row => row.path)
164 next = mergeGit(await Promise.all(repos.map(async repo => [repo, await readGit($, join(root, repo))] as const)))
165 }
166 const known = await read($, git)
167 if (JSON.stringify(next) !== JSON.stringify(known)) await update($, git, () => next)
168}
169
170// `@path` as Claude reads it: relative to the session's cwd where the file is below it, else absolute.
171async function insertPath($: EngineInterface, path: string) {
172 const file = join(await rootOf($), path)
173 const reference = relativeTo(await cwdOf($), file) || file
174 return $.prompt.fill({ text: `@${reference} `, mode: 'insert' })
175}
176
177// $VISUAL, then $EDITOR, else VS Code; through cmd on Windows so a `.cmd` shim like code.cmd resolves.
178async function openInEditor($: EngineInterface, path: string) {
179 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
180 const editor = ((await $.env.get('VISUAL')) || (await $.env.get('EDITOR')) || 'code').trim().split(/\s+/)
181 const file = join(await rootOf($), path)
182 const argv = isWindows ? ['cmd', '/c', ...editor, file.replace(/\//g, '\\')] : [...editor, file]
183 $.ui.toast(`Opening ${path} in ${editor[0]}`)
184 const ran = await $.process.run(argv, { timeoutMs: 15000 }).catch((error: unknown) => ({
185 exitCode: -1,
186 stderr: String(error),
187 }))
188 if (ran.exitCode !== 0) {
189 const reason = ran.stderr.trim().split('\n')[0] || `exit ${ran.exitCode}`
190 $.ui.toast(`Could not open ${path}: ${reason}`)
191 }
192}
193
194// VS Code itself (not $EDITOR) on `dir` (a tree path; `''` the tree's top folder, from the room below
195// the rows); through cmd on Windows for code.cmd.
196async function openFolderInVsCode($: EngineInterface, dir: string) {
197 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
198 const folder = join(await rootOf($), dir)
199 const argv = isWindows ? ['cmd', '/c', 'code', folder.replace(/\//g, '\\')] : ['code', folder]
200 $.ui.toast(`Opening ${nameOf(folder)} in VS Code`)
201 const ran = await $.process.run(argv, { timeoutMs: 15000 }).catch((error: unknown) => ({
202 exitCode: -1,
203 stderr: String(error),
204 }))
205 if (ran.exitCode !== 0) {
206 const reason = ran.stderr.trim().split('\n')[0] || `exit ${ran.exitCode}`
207 $.ui.toast(`Could not open VS Code: ${reason}`)
208 }
209}
210
211const IMAGE = /\.(png|jpe?g|gif|bmp|webp|svg|ico|tiff?|heic|avif)$/i
212
213// The system's own handler for the file: on Windows `start`, which hands an image to Photos
214// (or whatever app the person made the default); `open` on macOS, `xdg-open` elsewhere.
215async function openWithSystem($: EngineInterface, path: string) {
216 const file = join(await rootOf($), path)
217 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
218 const isMac = !isWindows && (await $.process.run(['uname']).catch(() => undefined))?.stdout.trim() === 'Darwin'
219 const argv = isWindows
220 ? ['cmd', '/c', 'start', '', file.replace(/\//g, '\\')]
221 : [isMac ? 'open' : 'xdg-open', file]
222 $.ui.toast(`Opening ${path}`)
223 const ran = await $.process.run(argv, { timeoutMs: 15000 }).catch((error: unknown) => ({
224 exitCode: -1,
225 stderr: String(error),
226 }))
227 if (ran.exitCode !== 0) {
228 const reason = ran.stderr.trim().split('\n')[0] || `exit ${ran.exitCode}`
229 $.ui.toast(`Could not open ${path}: ${reason}`)
230 }
231}
232
233const inDir = (dir: string, name: string) => (dir === '' ? name : `${dir}/${name}`)
234
235// Characters no file name may hold on Windows (and so nowhere, for a name meant to travel).
236const BAD_NAME = /[<>:"|?*\u0000-\u001f]/
237
238/**
239 * The name typed into the box as a path below its folder, or why it cannot be one: `.md` added
240 * where the last part has no extension; `/` (or `\`) makes subfolders; no `..`, no absolute path.
241 */
242export function nameToPath(raw: string): { name: string } | { error: string } {
243 const name = raw.trim().replace(/\\/g, '/')
244 if (name.startsWith('/')) return { error: 'Give a name inside the folder, not an absolute path.' }
245 const parts = name.split('/')
246 if (parts.some(part => part === '' || part === '.' || part === '..')) {
247 return { error: 'A name cannot have empty parts, "." or "..".' }
248 }
249 if (BAD_NAME.test(name)) return { error: 'A name cannot hold < > : " | ? * or control characters.' }
250 const last = parts[parts.length - 1] ?? ''
251 return { name: last.includes('.') && !last.endsWith('.') ? name : `${name.replace(/\.$/, '')}.md` }
252}
253
254/** The first of `file.md`, `file-1.md`, ... that is free in `dir`. */
255async function freeName($: EngineInterface, root: string, dir: string) {
256 let name = 'file.md'
257 for (let n = 1; await $.fs.exists(join(root, inDir(dir, name))); n++) name = `file-${n}.md`
258 return name
259}
260
261const NAME_KEY = 'new-name'
262
263// **New .md file**: opens the name box at the top of the pane, for a file in `dir` (a tree path;
264// `''` the tree's top folder), and puts the keyboard in its field where the pane holds the keys.
265async function openNewFile($: EngineInterface, dir: string) {
266 const suggested = await freeName($, await rootOf($), dir)
267 await update($, newFile, () => ({ isOpen: true, dir, suggested, name: '', error: '' }))
268 await $.ui.focus({ requestId: PANE, key: NAME_KEY }).catch(() => undefined)
269}
270
271const closeNewFile = ($: EngineInterface) => update($, newFile, box => ({ ...box, isOpen: false, error: '' }))
272
273/**
274 * Creates the file the box names (empty: the suggested `file.md`), opens the folders down to it
275 * so it shows, then opens it in the editor; a name refused or taken stays in the box with why.
276 */
277async function submitNewFile($: EngineInterface, raw: string) {
278 const box = await read($, newFile)
279 if (!box.isOpen) return
280 const parsed = nameToPath(raw.trim() === '' ? box.suggested : raw)
281 const refuse = (error: string) => update($, newFile, one => ({ ...one, name: raw, error }))
282 if ('error' in parsed) return refuse(parsed.error)
283 const root = await rootOf($)
284 const path = inDir(box.dir, parsed.name)
285 if (await $.fs.exists(join(root, path))) return refuse(`${parsed.name} already exists.`)
286 await $.fs.write(join(root, path), '')
287
288 const parts = path.split('/').slice(0, -1)
289 const folders = parts.map((_, index) => parts.slice(0, index + 1).join('/'))
290 await update($, expanded, list => [...new Set([...list, ...folders])])
291 await closeNewFile($)
292 await refreshAll($)
293 void openInEditor($, path)
294}
295
296/** What a double click (or Enter) does to a file: an image to the system viewer, the rest to the editor. */
297const openFile = ($: EngineInterface, path: string) =>
298 IMAGE.test(path) ? openWithSystem($, path) : openInEditor($, path)
299
300const refreshAll = ($: EngineInterface) => Promise.all([refresh($), refreshGit($)])
301
302const collapseAll = ($: EngineInterface) => update($, expanded, () => [])
303
304const summaryOf = (status: GitStatus) => {
305 const changes = Object.keys(status.files).length
306 return !status.isRepo ? '' : changes === 0 ? 'clean' : `${changes} changed`
307}
308
309/** How the header reads the tree's top: pinned (moved up, out of sync) or in sync with the cwd. */
310type Where = { isPinned: boolean; canGoUp: boolean; here?: string }
311
312/** Where the tree stands: pinned or not, whether there is a folder above, the cwd's row when pinned. */
313async function whereOf($: EngineInterface, root: string): Promise<Where> {
314 const isPinned = (await read($, pinned)) !== ''
315 const here = isPinned ? relativeTo(root, await cwdOf($)) : undefined
316 return { isPinned, canGoUp: parentOf(root) !== undefined, ...(here ? { here } : {}) }
317}
318
319// A Client's props may serialize to 100,000 characters at most, or the surface unmounts it, header
320// and all. The rows get this much of it; the rest of the props are a few hundred at most.
321const ROWS_BUDGET = 90_000
322
323/** The rows as the tree view takes them, as many as fit the budget, and whether some were cut. */
324function fitRows(rows: readonly Row[]): { rows: TreeProps['rows']; isCut: boolean } {
325 const fitted: TreeProps['rows'] = []
326 let used = 2
327 for (const row of rows) {
328 const kind = row.isDir ? (row.isOpen ? 'o' : 'd') : 'f'
329 const tuple: TreeProps['rows'][number] = row.mark ? [row.path, row.prefix, kind, row.mark] : [row.path, row.prefix, kind]
330 used += JSON.stringify(tuple).length + 1
331 if (used > ROWS_BUDGET) return { rows: fitted, isCut: true }
332 fitted.push(tuple)
333 }
334
335 return { rows: fitted, isCut: false }
336}
337
338/** The window the pane shows of the tree view's region: its first row and how many. */
339type Window = { offset: number; bodyRows: number }
340
341const treeProps = (root: string, where: Where, status: GitStatus, rows: TreeProps['rows'], icons: boolean, window: Window): TreeProps => ({
342 root: nameOf(root),
343 ...where,
344 summary: summaryOf(status),
345 icons,
346 rows,
347 ...window,
348})
349
350// A held key repeats far faster than this; a person pressing again waits longer.
351const KEY_REPEAT_MS = 400
352
353export const register: Register = (on, options) => {
354 // Nerd Font icons, unless the person's terminal font has none (the `icons` option).
355 const icons = options.icons !== false
356 let lastSignature: string | undefined
357 let isPolling = false
358 // When the last `u` arrived: a key held down sends one up, its repeats are dropped.
359 let lastUpKeyAt = 0
360
361 on('session.start', async ($, e, next) => {
362 await $.command.register({
363 name: 'files',
364 description: 'Show or hide the file tree of the working directory; `sync` puts its top back on the cwd',
365 argumentHint: '[sync]',
366 })
367 void $.ui.open({ id: PANE, title: TITLE })
368 void refreshGit($)
369
370 // Catch changes made outside the tools (an editor, a build, a commit) without redrawing when nothing moved.
371 $.clock.every(POLL_MS, () => {
372 if (isPolling) return
373 isPolling = true
374 void (async () => {
375 await refreshGit($)
376 const root = await rootOf($)
377 const context = contextOf(root, new Set(await read($, expanded)), await read($, git))
378 // The top folder is in the signature: a `cd` while in sync redraws the new tree.
379 const current = `${root}\n${signature(await walk($, context))}`
380 if (lastSignature !== undefined && current !== lastSignature) await refresh($)
381 lastSignature = current
382 })().finally(() => (isPolling = false))
383 })
384
385 return next(e)
386 })
387
388 on('command.run', { command: 'files' }, async ($, e) => {
389 // The way back from anywhere, the pane drawn or not: in sync with the cwd, pane open.
390 if (e.args.trim() === 'sync') {
391 await sync($)
392 await $.ui.open({ id: PANE, title: TITLE })
393 return { text: 'File tree back in sync with the working directory.' }
394 }
395 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
396 if (isUp) {
397 await $.ui.close({ id: PANE })
398 return { text: 'File tree closed.' }
399 }
400 await $.ui.open({ id: PANE, title: TITLE })
401
402 return { text: 'File tree opened.' }
403 })
404
405 on('tool.call', async ($, e, next) => {
406 const ran = await next(e)
407 if (MUTATING_TOOLS.has(e.tool)) {
408 await refresh($).catch(() => undefined)
409 void refreshGit($).catch(() => undefined)
410 }
411
412 return ran
413 })
414
415 // What the tree view posts: a left click, a key, or a pick from the right-click menu.
416 on('ui.message', { requestId: PANE }, async ($, e) => {
417 const { action, path } = e.data as TreeMessage
418 // An opened folder may hold a repository of its own: look now, not at the next poll.
419 if (action === 'toggle') await toggle($, path).then(() => void refreshGit($).catch(() => undefined))
420 else if (action === 'insert') await insertPath($, path)
421 else if (action === 'edit') void openInEditor($, path)
422 else if (action === 'open') void openFile($, path)
423 else if (action === 'refresh') await refreshAll($)
424 else if (action === 'collapse') await collapseAll($)
425 else if (action === 'new-md') await openNewFile($, path)
426 else if (action === 'open-vscode') void openFolderInVsCode($, path)
427 else if (action === 'up') {
428 // From the keyboard, one step per press: a held key's repeats arrive closer than a person presses.
429 const now = Date.now()
430 const isRepeat = path === 'key' && now - lastUpKeyAt < KEY_REPEAT_MS
431 if (path === 'key') lastUpKeyAt = now
432 if (!isRepeat) await moveUp($)
433 } else if (action === 'sync') await sync($)
434
435 return {}
436 })
437
438 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
439 const { Box, Text, Button } = $.ui.resolve(e)
440 await read($, tick)
441 const top = await rootOf($)
442 const where = await whereOf($, top)
443 const context = contextOf(top, new Set(await read($, expanded)), await read($, git))
444 const rows = await walk($, context)
445 const root = nameOf(top)
446 const width = Math.max(12, e.props.bodyColumns)
447 // The tree view needs the raw pointer for its right-click menu: a desktop, or the fullscreen
448 // terminal that reports clicks. Elsewhere the rows are plain Buttons (left click only).
449 const Client = e.surface === 'desktop' || e.surface === 'terminal' ? $.ui.resolve(e).Client : undefined
450 const hasPointer = Client !== undefined && (e.surface === 'desktop' || e.viewport?.isFullscreen === true)
451 const summary = summaryOf(context.status)
452 // Too many rows for one drawing (by count, or by size for the tree view): the first ones show.
453 const fitted = fitRows(rows)
454 const more = (rows.length >= MAX_ROWS || (hasPointer && fitted.isCut)) && (
455 <Text dimColor>… more entries than fit, collapse some folders</Text>
456 )
457
458 // The name box of **New .md file**, above the tree while open. A surface with no text field
459 // (mobile) has no box: there the menu that opens it is not drawn either.
460 const box = await read($, newFile)
461 const table = $.ui.resolve(e)
462 const Input = 'Input' in table ? table.Input : undefined
463 const boxRows = box.isOpen && Input ? 5 + (box.error ? 1 : 0) : 0
464 const nameBox = boxRows > 0 && Input && (
465 <Box flexDirection="column" borderStyle="round" borderColor="suggestion" paddingX={1}>
466 <Text wrap="truncate">
467 New file in <Text bold>{box.dir === '' ? `${root}/` : `${box.dir}/`}</Text>
468 </Text>
469 <Input
470 key={NAME_KEY}
471 label="Name "
472 placeholder={box.suggested}
473 value={box.name}
474 submitLabel="create"
475 autoFocus
476 onSubmit={name => void submitNewFile($, name)}
477 />
478 {box.error && <Text color="error">{box.error}</Text>}
479 <Box flexDirection="row">
480 <Button key="new-cancel" plain dimColor onPress={() => void closeNewFile($)}>
481 cancel
482 </Button>
483 <Text dimColor wrap="truncate"> Enter creates · empty: {box.suggested}</Text>
484 </Box>
485 </Box>
486 )
487
488 // With a pointer the tree view draws the whole body, header included, and fills the pane,
489 // so every click in the sidebar reaches it (a click on no row clears the selection).
490 if (hasPointer) {
491 const height = Math.max(fitted.rows.length + 2, e.props.scroll.bodyRows - (more ? 1 : 0) - boxRows)
492 return (
493 <Box flexDirection="column">
494 {nameBox}
495 <Client key="tree" module="./tree-view.tsx" props={treeProps(top, where, context.status, fitted.rows, icons, { offset: Math.max(0, e.props.scroll.offset - boxRows), bodyRows: e.props.scroll.bodyRows })} width={width} height={height} />
496 {more}
497 </Box>
498 )
499 }
500
501 return (
502 <Box flexDirection="column">
503 {nameBox}
504 <Text bold>
505 {fit(`${root}/`, width - (summary ? summary.length + 1 : 0) - (where.isPinned ? 9 : 0))}
506 {summary && <Text dimColor> {summary}</Text>}
507 {where.isPinned && <Text color="warning"> · pinned</Text>}
508 </Text>
509 <Box flexDirection="row">
510 {where.canGoUp && (
511 <Button key="up" plain dimColor onPress={() => void moveUp($)}>
512 ↑ up
513 </Button>
514 )}
515 {where.canGoUp && <Text> </Text>}
516 <Button key="refresh" plain dimColor onPress={() => void refreshAll($)}>
517 refresh
518 </Button>
519 <Text> </Text>
520 <Button key="collapse" plain dimColor onPress={() => void collapseAll($)}>
521 collapse all
522 </Button>
523 {where.isPinned && <Text> </Text>}
524 {where.isPinned && (
525 <Button key="sync" plain onPress={() => void sync($)}>
526 ↻ sync
527 </Button>
528 )}
529 </Box>
530 {rows.length === 0 && <Text dimColor>(empty)</Text>}
531 {rows.map(row => {
532 const marker = row.isDir ? (row.isOpen ? '▾ ' : '▸ ') : ' '
533 const badge = badgeOf(row.mark, row.isDir, width)
534 const icon = icons ? iconOf(row.name, row.isDir ? (row.isOpen ? 'o' : 'd') : 'f') : undefined
535 const room = width - row.prefix.length - marker.length - (icon ? ICON_CELLS : 0) - (badge?.width ?? 0)
536 const label = fit(row.name + (row.isDir ? '/' : ''), room)
537 // A Button has one press, no double: a file opens on it.
538 const onPress = row.isDir ? () => void toggle($, row.path) : () => void openFile($, row.path)
539
540 return (
541 <Button key={(row.isDir ? 'd:' : 'f:') + row.path} plain onPress={onPress}>
542 <Text dimColor>{row.prefix}</Text>
543 {marker}
544 {icon && (
545 <Text color={icon.color} dimColor={row.mark?.i === 1}>
546 {icon.glyph}{' '}
547 </Text>
548 )}
549 <Text
550 bold={row.isDir}
551 color={badge?.color}
552 dimColor={row.mark?.i === 1}
553 strikethrough={row.mark?.x === 1}
554 >
555 {label}
556 </Text>
557 {badge?.added && <Text color="diffAdded"> {badge.added}</Text>}
558 {badge?.removed && <Text color="diffRemoved"> {badge.removed}</Text>}
559 {badge?.code && <Text color={badge.color}> {badge.code}</Text>}
560 </Button>
561 )
562 })}
563 {more}
564 </Box>
565 )
566 })
567}
568hooks/git.ts 147 lines1import type { EngineInterface } from 'claude-code'
2
3import type { GitCode, GitFile, GitStatus } from '../types'
4
5export const NO_GIT: GitStatus = { isRepo: false, files: {}, ignored: [] }
6
7// Bounds the untracked files whose lines are counted on one read.
8const MAX_COUNTED_UNTRACKED = 200
9
10const RANK: Record<GitCode, number> = { C: 5, D: 4, M: 3, R: 2, A: 2, U: 1 }
11
12/** The code a folder shows: the most serious change beneath it. */
13export const strongest = (a: GitCode | undefined, b: GitCode) => (a === undefined || RANK[b] > RANK[a] ? b : a)
14
15function codeOf(xy: string): GitCode {
16 const [x, y] = [xy[0], xy[1]]
17 if (xy === '??') return 'U'
18 if (x === 'U' || y === 'U' || xy === 'AA' || xy === 'DD') return 'C'
19 if (x === 'D' || y === 'D') return 'D'
20 if (x === 'R' || x === 'C') return 'R'
21 if (x === 'A') return 'A'
22
23 return 'M'
24}
25
26const countLines = (text: string) =>
27 text.includes('\0') || text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
28
29/**
30 * Reads `git status` and `git diff HEAD --numstat` for the cwd. Git spells paths from the
31 * repository root; they are cut to the cwd (`rev-parse --show-prefix`) and anything outside dropped.
32 */
33export async function readGit($: EngineInterface, cwd: string): Promise<GitStatus> {
34 const git = (args: string[]) =>
35 $.process.run(['git', '-c', 'core.quotepath=false', ...args], { cwd, timeoutMs: 10000 }).catch(() => undefined)
36
37 const prefix = await git(['rev-parse', '--show-prefix'])
38 if (!prefix || prefix.exitCode !== 0) return NO_GIT
39 const base = prefix.stdout.trim()
40 const local = (path: string) => (path.startsWith(base) ? path.slice(base.length) : undefined)
41
42 const [status, numstat] = await Promise.all([
43 git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=matching']),
44 git(['diff', 'HEAD', '--numstat', '-z', '-M']),
45 ])
46 if (!status || status.exitCode !== 0) return NO_GIT
47
48 const files: Record<string, GitFile> = {}
49 const ignored: string[] = []
50 // `XY path\0`, a rename or copy followed by its source path as one more field.
51 const fields = status.stdout.split('\0')
52 for (let index = 0; index < fields.length; index++) {
53 const field = fields[index] ?? ''
54 if (field.length < 4) continue
55 const xy = field.slice(0, 2)
56 if (xy[0] === 'R' || xy[0] === 'C') index++
57 const path = local(field.slice(3))
58 if (path === undefined || path === '') continue
59 if (xy === '!!') ignored.push(path)
60 else files[path] = { code: codeOf(xy), added: 0, removed: 0 }
61 }
62
63 // `added\tremoved\tpath\0`; a rename leaves the path empty, then `old\0new\0`. Binary is `-`.
64 const parts = numstat?.exitCode === 0 ? numstat.stdout.split('\0') : []
65 for (let index = 0; index < parts.length; index++) {
66 const match = /^(-|\d+)\t(-|\d+)\t(.*)$/s.exec(parts[index] ?? '')
67 if (!match) continue
68 let path = match[3] ?? ''
69 if (path === '') {
70 path = parts[index + 2] ?? ''
71 index += 2
72 }
73 const file = files[local(path) ?? '']
74 if (!file) continue
75 file.added = match[1] === '-' ? 0 : Number(match[1])
76 file.removed = match[2] === '-' ? 0 : Number(match[2])
77 }
78
79 // Untracked files have no diff: their lines count as added.
80 const untracked = Object.entries(files).filter(([, file]) => file.code === 'U').slice(0, MAX_COUNTED_UNTRACKED)
81 await Promise.all(
82 untracked.map(async ([path, file]) => {
83 const text = await $.fs.read(`${cwd}/${path}`).catch(() => '')
84 file.added = countLines(text)
85 }),
86 )
87
88 return { isRepo: true, files, ignored }
89}
90
91/**
92 * Several repositories' statuses as one, each read at its own top and spelled from the tree's
93 * root: a folder of projects whose root is no repository itself.
94 */
95export function mergeGit(repos: readonly (readonly [dir: string, status: GitStatus])[]): GitStatus {
96 const merged: GitStatus = { isRepo: false, files: {}, ignored: [] }
97 for (const [dir, status] of repos) {
98 if (!status.isRepo) continue
99 merged.isRepo = true
100 for (const [path, file] of Object.entries(status.files)) merged.files[`${dir}/${path}`] = file
101 merged.ignored.push(...status.ignored.map(rule => `${dir}/${rule}`))
102 }
103
104 return merged
105}
106
107export const isIgnored =(status: GitStatus, path: string) =>
108 status.ignored.some(rule => (rule.endsWith('/') ? `${path}/`.startsWith(rule) : path === rule))
109
110export type FolderMark = { code: GitCode; added: number; removed: number }
111
112/** Every folder with a change beneath it: the most serious code and the summed line counts. */
113export function folderMarks(status: GitStatus): Map<string, FolderMark> {
114 const marks = new Map<string, FolderMark>()
115 for (const [path, file] of Object.entries(status.files)) {
116 const parts = path.split('/')
117 for (let depth = 1; depth < parts.length; depth++) {
118 const dir = parts.slice(0, depth).join('/')
119 const mark = marks.get(dir)
120 marks.set(dir, {
121 code: strongest(mark?.code, file.code),
122 added: (mark?.added ?? 0) + file.added,
123 removed: (mark?.removed ?? 0) + file.removed,
124 })
125 }
126 }
127
128 return marks
129}
130
131/** Deleted files are gone from disk: per folder, the names to show anyway (and whether each is a folder). */
132export function deletedEntries(status: GitStatus): Map<string, Map<string, boolean>> {
133 const ghosts = new Map<string, Map<string, boolean>>()
134 for (const [path, file] of Object.entries(status.files)) {
135 if (file.code !== 'D') continue
136 const parts = path.split('/')
137 for (let depth = 0; depth < parts.length; depth++) {
138 const dir = parts.slice(0, depth).join('/')
139 const names = ghosts.get(dir) ?? new Map<string, boolean>()
140 names.set(parts[depth] ?? '', depth < parts.length - 1)
141 ghosts.set(dir, names)
142 }
143 }
144
145 return ghosts
146}
147hooks/icons.ts 123 lines1// Nerd Font glyphs (v3 code points) and colours per file type, after nvim-web-devicons and
2// VS Code's Seti theme. Drawn only where the font has them: the `icons` option turns them off.
3
4export type Icon = { glyph: string; color: string }
5
6const icon = (glyph: string, color: string): Icon => ({ glyph, color })
7
8const FILE = icon('', '#8b949e')
9const FOLDER = icon('', '#7aa2f7')
10const FOLDER_OPEN = icon('', '#7aa2f7')
11
12const IMAGE = icon('', '#a074c4')
13const ARCHIVE = icon('', '#eca517')
14const CONFIG = icon('', '#6d8086')
15const SHELL = icon('', '#89e051')
16const TEXT = icon('', '#89e051')
17
18/** By extension, lower case, without the dot. */
19const BY_EXTENSION: Record<string, Icon> = {
20 ts: icon('', '#519aba'),
21 mts: icon('', '#519aba'),
22 cts: icon('', '#519aba'),
23 tsx: icon('', '#20c2e3'),
24 jsx: icon('', '#20c2e3'),
25 js: icon('', '#cbcb41'),
26 mjs: icon('', '#cbcb41'),
27 cjs: icon('', '#cbcb41'),
28 json: icon('', '#cbcb41'),
29 jsonc: icon('', '#cbcb41'),
30 md: icon('', '#519aba'),
31 mdx: icon('', '#519aba'),
32 html: icon('', '#e44d26'),
33 htm: icon('', '#e44d26'),
34 css: icon('', '#42a5f5'),
35 scss: icon('', '#f55385'),
36 less: icon('', '#563d7c'),
37 vue: icon('', '#8dc149'),
38 svelte: icon('', '#ff3e00'),
39 py: icon('', '#ffbc03'),
40 cs: icon('', '#9b4f96'),
41 csproj: icon('', '#854cc7'),
42 sln: icon('', '#854cc7'),
43 go: icon('', '#519aba'),
44 rs: icon('', '#dea584'),
45 java: icon('', '#cc3e44'),
46 sql: icon('', '#dad8d8'),
47 xml: icon('', '#e37933'),
48 yml: icon('', '#a074c4'),
49 yaml: icon('', '#a074c4'),
50 toml: icon('', '#9c4221'),
51 ini: CONFIG,
52 cfg: CONFIG,
53 conf: CONFIG,
54 sh: SHELL,
55 bash: SHELL,
56 zsh: SHELL,
57 ps1: icon('', '#4273ca'),
58 bat: icon('', '#c1f12e'),
59 cmd: icon('', '#c1f12e'),
60 txt: TEXT,
61 log: TEXT,
62 pdf: icon('', '#b30b00'),
63 png: IMAGE,
64 jpg: IMAGE,
65 jpeg: IMAGE,
66 gif: IMAGE,
67 bmp: IMAGE,
68 webp: IMAGE,
69 ico: IMAGE,
70 svg: icon('', '#ffb13b'),
71 zip: ARCHIVE,
72 gz: ARCHIVE,
73 tar: ARCHIVE,
74 '7z': ARCHIVE,
75 rar: ARCHIVE,
76 lock: icon('', '#bbbbbb'),
77}
78
79/** By whole name, lower case: wins over the extension. */
80const BY_NAME: Record<string, Icon> = {
81 '.gitignore': icon('', '#f54d27'),
82 '.gitattributes': icon('', '#f54d27'),
83 '.gitmodules': icon('', '#f54d27'),
84 '.env': icon('', '#faf743'),
85 'package.json': icon('', '#e8274b'),
86 'package-lock.json': icon('', '#7a0d21'),
87 'tsconfig.json': icon('', '#519aba'),
88 dockerfile: icon('', '#458ee6'),
89 containerfile: icon('', '#458ee6'),
90 'compose.yaml': icon('', '#458ee6'),
91 'docker-compose.yml': icon('', '#458ee6'),
92 makefile: icon('', '#6d8086'),
93 license: icon('', '#d0bf41'),
94 'license.md': icon('', '#d0bf41'),
95 'readme.md': icon('', '#ededed'),
96 'claude.md': icon('', '#d97757'),
97}
98
99/** Folders with a look of their own, closed and open. */
100const BY_FOLDER: Record<string, [Icon, Icon]> = {
101 node_modules: [icon('', '#cc3e44'), icon('', '#cc3e44')],
102 '.github': [icon('', '#7aa2f7'), icon('', '#7aa2f7')],
103 '.config': [icon('', '#6d8086'), icon('', '#6d8086')],
104 '.claude': [icon('', '#d97757'), icon('', '#d97757')],
105}
106
107/** The icon for an entry by its name: `f` a file, `d` a closed folder, `o` an open one. */
108export function iconOf(name: string, kind: 'f' | 'd' | 'o'): Icon {
109 const lower = name.toLowerCase()
110 if (kind !== 'f') {
111 const special = BY_FOLDER[lower]
112 return special ? special[kind === 'o' ? 1 : 0] : kind === 'o' ? FOLDER_OPEN : FOLDER
113 }
114 const dot = lower.lastIndexOf('.')
115 // `.env.local` and the like read as `.env`.
116 const named = BY_NAME[lower] ?? (lower.startsWith('.env') ? BY_NAME['.env'] : undefined)
117
118 return named ?? (dot > 0 ? BY_EXTENSION[lower.slice(dot + 1)] : undefined) ?? FILE
119}
120
121/** The cells an icon takes before the name: the glyph and one space. */
122export const ICON_CELLS = 2
123hooks/marks.ts 32 lines1import type { GitCode } from '../types'
2
3/** A row's git mark, short keys as it rides a Client's props: code, added, removed, ignored, deleted. */
4export type TreeMark = { c?: GitCode; a?: number; r?: number; i?: 1; x?: 1 }
5
6/** Theme colours, as the engine draws its own diffs: green new, yellow changed, red gone. */
7export const CODE_COLOR: Record<GitCode, string> = {
8 M: 'warning',
9 A: 'success',
10 U: 'success',
11 D: 'error',
12 R: 'suggestion',
13 C: 'error',
14}
15
16export type Badge = { added?: string; removed?: string; code?: string; color?: string; width: number }
17
18// Below this many columns a row keeps only its status letter (or a folder's dot).
19const ROOM_FOR_COUNTS = 28
20
21/** What a row draws at its right edge: `+12 -3 M` for a file, `+40 -7 ●` for a folder. */
22export function badgeOf(mark: TreeMark | undefined, isDir: boolean, columns: number): Badge | undefined {
23 if (!mark?.c) return undefined
24 const hasRoom = columns >= ROOM_FOR_COUNTS
25 const added = hasRoom && mark.a ? `+${mark.a}` : undefined
26 const removed = hasRoom && mark.r ? `-${mark.r}` : undefined
27 const code = isDir ? '●' : mark.c
28 const width = [added, removed, code].reduce((sum, part) => sum + (part ? part.length + 1 : 0), 0)
29
30 return { added, removed, code, color: CODE_COLOR[mark.c], width }
31}
32hooks/paths.ts 39 lines1// Absolute paths as the tree spells them: forward slashes, no trailing slash, except a root's
2// own (`C:/`, `/`). Windows and POSIX both.
3
4const DRIVE = /^[A-Za-z]:$/
5
6/** `C:\Users\me\` → `C:/Users/me`; `C:` or `C:\` → `C:/`. */
7export function normalize(path: string): string {
8 const slashed = path.replace(/\\/g, '/')
9 const trimmed = slashed.replace(/\/+$/, '')
10 if (DRIVE.test(trimmed)) return `${trimmed}/`
11 return trimmed === '' && slashed.startsWith('/') ? '/' : trimmed
12}
13
14/** `root` joined with `rel` (a tree path, `/`-separated; `''` the root itself). */
15export const join = (root: string, rel: string) =>
16 rel === '' ? root : root.endsWith('/') ? `${root}${rel}` : `${root}/${rel}`
17
18/** The folder above `path`, or undefined at a root (`C:/`, `/`). */
19export function parentOf(path: string): string | undefined {
20 if (path === '/' || /^[A-Za-z]:\/$/.test(path)) return undefined
21 const cut = path.lastIndexOf('/')
22 if (cut < 0) return undefined
23 const parent = path.slice(0, cut)
24 return parent === '' ? '/' : DRIVE.test(parent) ? `${parent}/` : parent
25}
26
27/** The last name in `path`; a root is its own name (`C:`, `/`). */
28export const nameOf = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
29
30// Windows paths compare without case, as its file systems do.
31const same = (a: string, b: string) => (/^[A-Za-z]:/.test(a) ? a.toLowerCase() === b.toLowerCase() : a === b)
32
33/** `path` below `root` as a tree path (`''` for the root itself), or undefined when outside it. */
34export function relativeTo(root: string, path: string): string | undefined {
35 if (same(root, path)) return ''
36 const prefix = root.endsWith('/') ? root : `${root}/`
37 return same(path.slice(0, prefix.length), prefix) ? path.slice(prefix.length) : undefined
38}
39hooks/tree-view.tsx 353 lines1import type { ClientModule, ClientPointerEvent, JsonValue } from 'claude-code'
2
3import { ICON_CELLS, iconOf } from './icons'
4import { badgeOf } from './marks'
5import type { TreeMark } from './marks'
6
7// Surface module: draws the whole pane body (title, header actions, rows) and reads the raw
8// pointer there, so a right click can open the action menu and a click anywhere that is no row
9// clears the selection. It posts what was chosen; register.tsx acts on it.
10
11/**
12 * One row: its path relative to the cwd, its tree glyphs, `f` file, `d` closed or `o` open
13 * folder, and its git mark when it has one.
14 */
15export type TreeRow = [path: string, prefix: string, kind: 'f' | 'd' | 'o', mark?: TreeMark]
16/**
17 * `root` the top folder's name, `summary` the git line beside it (`3 changed`), `rows` the body
18 * rows, `icons` whether to draw Nerd Font icons. `isPinned` while the person moved up out of sync
19 * with the session's cwd, `canGoUp` while there is a folder above, `here` the cwd's row then.
20 */
21export type TreeProps = {
22 root: string
23 summary: string
24 rows: TreeRow[]
25 icons: boolean
26 isPinned: boolean
27 canGoUp: boolean
28 here?: string
29 /** The first of the region's rows the pane shows, and how many it shows: the drawn window. */
30 offset: number
31 bodyRows: number
32}
33export type TreeAction =
34 | 'toggle' | 'insert' | 'edit' | 'open' | 'refresh' | 'collapse' | 'new-md' | 'open-vscode' | 'up' | 'sync'
35export type TreeMessage = { action: TreeAction; path: string }
36
37/**
38 * `row` is an index into the rows, as is `cursor`; `-1` for the empty room below them, where
39 * the menu opens at `y` (region cells) instead of under a row.
40 */
41type Menu = { row: number; x: number; y: number; selected: number }
42/**
43 * `cursor` is the selected row (drawn while `hasSelection`); `lastClick` arms a double click.
44 * `pressed` is a file row the left button went down on (region cells), `drag` that row once the
45 * pointer has moved far enough to be dragging it, and whether it is outside the pane now.
46 */
47type State = {
48 cursor: number
49 hasSelection: boolean
50 menu?: Menu
51 lastClick?: { row: number; at: number }
52 pressed?: { row: number; x: number; y: number }
53 drag?: { row: number; isOutside: boolean }
54}
55
56// Two left clicks on one file this close together open it.
57const DOUBLE_CLICK_MS = 400
58// A held press becomes a drag once the pointer is this many cells away, so a click never does.
59const DRAG_CELLS = 2
60// The title and the header actions sit above the first row.
61const HEADER_ROWS = 2
62// Rows drawn beyond the window on each side, so a scroll shows rows while the next draw comes;
63// and the most drawn at once, which keeps a drawing well under its 100,000 characters.
64const WINDOW_MARGIN = 20
65const MAX_DRAWN_ROWS = 110
66const HEADER_GAP = 2
67
68// The header's actions as drawn, each with the column it starts at: ↑ up while there is a folder
69// above, ↻ sync while pinned (out of sync with the session's cwd).
70function headerActions(canGoUp: boolean, isPinned: boolean) {
71 const actions: { label: string; action: TreeAction }[] = [
72 ...(canGoUp ? [{ label: '↑ up', action: 'up' as const }] : []),
73 { label: 'refresh', action: 'refresh' },
74 { label: 'collapse all', action: 'collapse' },
75 ...(isPinned ? [{ label: '↻ sync', action: 'sync' as const }] : []),
76 ]
77 let from = 0
78 return actions.map(one => {
79 const placed = { ...one, from }
80 from += one.label.length + HEADER_GAP
81 return placed
82 })
83}
84
85type MenuItem = { label: string; action: TreeAction }
86
87// A file's menu, and a folder's: new files go into the folder, VS Code opens on it. The empty room below the rows
88// stands for the cwd, the outermost folder, and has the folder's menu.
89const FILE_MENU: readonly MenuItem[] = [
90 { label: 'Insert path', action: 'insert' },
91 { label: 'Edit file', action: 'edit' },
92]
93const FOLDER_MENU: readonly MenuItem[] = [
94 { label: 'New .md file', action: 'new-md' },
95 { label: 'Open VS Code', action: 'open-vscode' },
96]
97const MENU_INNER = Math.max(...[...FILE_MENU, ...FOLDER_MENU].map(item => item.label.length)) + 4
98const MENU_WIDTH = MENU_INNER + 2
99const MENU_HEIGHT_MAX = Math.max(FILE_MENU.length, FOLDER_MENU.length) + 2
100const INITIAL: State = { cursor: 0, hasSelection: false }
101
102const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
103
104const itemsFor = (menu: Menu, rows: readonly TreeRow[]) => (rows[menu.row]?.[2] === 'f' ? FILE_MENU : FOLDER_MENU)
105
106// In region cells: below the row (or the click) when it fits, else above; never past the right edge.
107const placeMenu = (menu: Menu, items: readonly MenuItem[], columns: number, height: number) => {
108 const y = menu.row < 0 ? menu.y : menu.row + HEADER_ROWS
109 const menuHeight = items.length + 2
110 return {
111 top: y + 1 + menuHeight <= height ? y + 1 : Math.max(0, y - menuHeight),
112 left: Math.max(0, Math.min(menu.x, columns - MENU_WIDTH)),
113 }
114}
115
116// Typed as plain JSON at the seam: a mark's optional fields are not `JsonValue` to the compiler,
117// though they never cross as `undefined` (register.tsx leaves an absent field out).
118const TreeView: ClientModule<JsonValue, State> = (props, surface) => {
119 const { Box, Text } = surface.elements
120 const { root, summary, rows, icons, isPinned, canGoUp, here, offset, bodyRows } = props as unknown as TreeProps
121 // Only the rows around the pane's window are drawn: a drawing over 100,000 characters unmounts
122 // the region, and a row is some 500 of them. Empty Boxes keep the rest's height, so the rows
123 // drawn sit where they belong and a click's row is still `y` less the header.
124 const first = Math.max(0, offset - HEADER_ROWS - WINDOW_MARGIN)
125 const last = Math.min(rows.length, first + Math.min(MAX_DRAWN_ROWS, bodyRows + 2 * WINDOW_MARGIN))
126 const header = headerActions(canGoUp, isPinned)
127 // The region fills the pane body (register.tsx sizes it), so a click on the empty room below
128 // the rows reaches it too; at least as tall as the rows and a menu under the last one.
129 const height = Math.max(surface.rows, rows.length + HEADER_ROWS, MENU_HEIGHT_MAX + HEADER_ROWS + 1)
130 const columns = surface.columns || 40
131 const state = surface.state ?? INITIAL
132 const cursor = Math.min(state.cursor, Math.max(0, rows.length - 1))
133
134 const set = (next: State) => surface.setState(next)
135 const act = (path: string, action: TreeAction) => surface.post({ action, path })
136 const clear = () => (state.hasSelection || state.menu ? set({ cursor, hasSelection: false }) : undefined)
137 const openMenu = (row: number, x: number) => set({ cursor: row, hasSelection: true, menu: { row, x, y: 0, selected: 0 } })
138 // The empty room's menu: the selection goes, the menu opens where the click was.
139 const openEmptyMenu = (x: number, y: number) => set({ cursor, hasSelection: false, menu: { row: -1, x, y, selected: 0 } })
140 const choose = (menu: Menu, index: number) => {
141 const item = itemsFor(menu, rows)[index]
142 set({ ...state, menu: undefined })
143 if (!item) return
144 if (menu.row < 0) return act('', item.action)
145 const row = rows[menu.row]
146 if (row) act(row[0], item.action)
147 }
148 // Enter, or a double click: a file opens, a folder opens or closes.
149 const press = (index: number) => {
150 const row = rows[index]
151 if (row) act(row[0], row[2] === 'f' ? 'open' : 'toggle')
152 }
153
154 const menuItemAt = (menu: Menu, event: ClientPointerEvent) => {
155 const items = itemsFor(menu, rows)
156 const { top, left } = placeMenu(menu, items, columns, height)
157 const index = event.y - top - 1
158 const isInside = event.x > left && event.x < left + MENU_WIDTH - 1
159 return isInside && index >= 0 && index < items.length ? index : undefined
160 }
161
162 surface.onPointer(event => {
163 const menu = state.menu
164 // Hover over the open menu moves its highlight, where the terminal reports motion.
165 if (event.type === 'move' && event.button === undefined) {
166 const index = menu && menuItemAt(menu, event)
167 if (menu && index !== undefined && index !== menu.selected) set({ ...state, menu: { ...menu, selected: index } })
168 return
169 }
170 // Dragging a file: the region holds the pointer from the press to the release, so moves
171 // arrive past its edges too (x below 0 is left of the pane: the conversation, the prompt).
172 if (event.type === 'move') {
173 const pressed = state.pressed
174 if (!pressed) return
175 const isOutside = event.x < 0
176 if (!state.drag) {
177 const distance = Math.max(Math.abs(event.x - pressed.x), Math.abs(event.y - pressed.y))
178 if (distance >= DRAG_CELLS) set({ ...state, drag: { row: pressed.row, isOutside } })
179 } else if (state.drag.isOutside !== isOutside) {
180 set({ ...state, drag: { ...state.drag, isOutside } })
181 }
182 return
183 }
184 // Dropped left of the pane: the file is referenced in the prompt. Anywhere else: cancelled.
185 // The release alone decides, so a drop works where no moves arrive while the button is
186 // held (a terminal or multiplexer that reports presses and releases only).
187 if (event.type === 'up') {
188 const { pressed, drag } = state
189 if (!pressed && !drag) return
190 const from = drag?.row ?? pressed?.row
191 const isDrop = event.x < 0 && (drag !== undefined || (pressed !== undefined && pressed.x - event.x >= DRAG_CELLS))
192 set({ ...state, pressed: undefined, drag: undefined, lastClick: isDrop ? undefined : state.lastClick })
193 const row = isDrop && from !== undefined ? rows[from] : undefined
194 if (row) act(row[0], 'insert')
195 return
196 }
197 if (event.type !== 'down') return
198 if (menu) {
199 const index = menuItemAt(menu, event)
200 if (index !== undefined) return event.button === 'left' ? choose(menu, index) : undefined
201 }
202 const clicked = event.y === 1 && event.button === 'left'
203 ? header.find(one => event.x >= one.from && event.x < one.from + one.label.length)
204 : undefined
205 if (clicked) {
206 clear()
207 return act('', clicked.action)
208 }
209 const index = event.y - HEADER_ROWS
210 const row = rows[index]
211 // The empty room below the last row: a right click there is for new files.
212 if (!row && index >= rows.length && event.button === 'right') return openEmptyMenu(event.x, event.y)
213 // Anywhere else that is no row (the title, a left click below the rows): nothing is selected.
214 if (!row) return clear()
215 if (event.button === 'right') return openMenu(index, event.x)
216 if (event.button !== 'left') return
217 // A folder opens or closes on one click; a file is selected, and opened on a second click.
218 if (row[2] !== 'f') {
219 set({ cursor: index, hasSelection: true })
220 return press(index)
221 }
222 const now = Date.now()
223 const last = state.lastClick
224 const isDouble = last !== undefined && last.row === index && now - last.at <= DOUBLE_CLICK_MS
225 set({
226 cursor: index,
227 hasSelection: true,
228 lastClick: isDouble ? undefined : { row: index, at: now },
229 pressed: { row: index, x: event.x, y: event.y },
230 })
231 if (isDouble) press(index)
232 })
233
234 surface.onKey(event => {
235 const menu = state.menu
236 if (menu) {
237 const count = itemsFor(menu, rows).length
238 if (event.key === 'up') set({ ...state, menu: { ...menu, selected: (menu.selected + count - 1) % count } })
239 else if (event.key === 'down' || event.key === 'tab') set({ ...state, menu: { ...menu, selected: (menu.selected + 1) % count } })
240 else if (event.key === 'return') choose(menu, menu.selected)
241 else if (/^[1-9]$/.test(event.key) && Number(event.key) <= count) choose(menu, Number(event.key) - 1)
242 else if (event.key === 'q' || event.key === 'left' || event.key === 'backspace') set({ ...state, menu: undefined })
243 return
244 }
245 // `u` moves the tree's top up a folder (pinning it), once per press: the hooks module drops a
246 // held key's repeats (`path: 'key'`). `s` brings it back in sync.
247 if (event.key === 'u' && canGoUp) return act('key', 'up')
248 if (event.key === 's' && isPinned) return act('', 'sync')
249 const row = rows[cursor]
250 if (event.key === 'up') set({ cursor: Math.max(0, cursor - 1), hasSelection: true })
251 else if (event.key === 'down') set({ cursor: Math.min(rows.length - 1, cursor + 1), hasSelection: true })
252 else if (event.key === 'return') press(cursor)
253 else if (!row) return
254 else if (event.key === 'm') openMenu(cursor, row[1].length + 2)
255 else if (event.key === 'right' && row[2] === 'd') act(row[0], 'toggle')
256 else if (event.key === 'left' && row[2] === 'o') act(row[0], 'toggle')
257 })
258
259 const menu = state.menu
260 const menuItems = menu ? itemsFor(menu, rows) : []
261 const place = menu && placeMenu(menu, menuItems, columns, height)
262 // While a file is dragged, the header's action row says what a release does.
263 const dragged = state.drag ? rows[state.drag.row] : undefined
264
265 return (
266 <Box flexDirection="column" height={height}>
267 <Text wrap="truncate" bold>
268 {`${root}/`}
269 {summary && <Text dimColor bold={false}> {summary}</Text>}
270 {isPinned && <Text color="warning" bold={false}> · pinned</Text>}
271 </Text>
272 {dragged ? (
273 <Text wrap="truncate" color="suggestion">
274 {state.drag?.isOutside ? '↳ release to reference ' : '↳ drag left of the pane to reference '}
275 <Text bold>@{dragged[0]}</Text>
276 </Text>
277 ) : (
278 <Text>
279 {header.map((one, index) => (
280 <Text dimColor={one.action !== 'sync'} color={one.action === 'sync' ? 'warning' : undefined}>
281 {index > 0 ? ' '.repeat(HEADER_GAP) : ''}
282 {one.label}
283 </Text>
284 ))}
285 </Text>
286 )}
287 {rows.length === 0 && <Text dimColor>(empty)</Text>}
288 {first > 0 && <Box height={first} />}
289 {rows.slice(first, last).map(([path, prefix, kind, mark], at) => {
290 const index = first + at
291 const name = baseName(path) + (kind === 'f' ? '' : '/')
292 const marker = kind === 'o' ? '▾ ' : kind === 'd' ? '▸ ' : ' '
293 const isMarked = menu ? menu.row === index : state.hasSelection && cursor === index
294 const badge = badgeOf(mark, kind !== 'f', columns)
295 const icon = icons ? iconOf(baseName(path), kind) : undefined
296 // While pinned, the session's cwd is marked, so the place the person came from stays found.
297 const hereMark = here === path ? ' ← cwd' : ''
298 // A dim dotted leader across the gap ties a name to its badge at the right edge.
299 const used = prefix.length + marker.length + (icon ? ICON_CELLS : 0) + name.length + hereMark.length
300 const gap = badge ? columns - badge.width - used - 1 : 0
301 const leader = gap >= 2 ? ` ${'·'.repeat(gap - 1)}` : ''
302 return (
303 <Box flexDirection="row">
304 <Box flexGrow={1} flexShrink={1} overflow="hidden">
305 <Text wrap="truncate" inverse={isMarked}>
306 <Text dimColor>{prefix}</Text>
307 {marker}
308 {icon && (
309 <Text color={icon.color} dimColor={mark?.i === 1}>
310 {icon.glyph}{' '}
311 </Text>
312 )}
313 <Text bold={kind !== 'f'} color={badge?.color} dimColor={mark?.i === 1} strikethrough={mark?.x === 1}>
314 {name}
315 </Text>
316 {hereMark && <Text color="warning">{hereMark}</Text>}
317 {leader && <Text dimColor>{leader}</Text>}
318 </Text>
319 </Box>
320 {badge && (
321 <Box flexShrink={0}>
322 <Text>
323 {badge.added && <Text color="diffAdded"> {badge.added}</Text>}
324 {badge.removed && <Text color="diffRemoved"> {badge.removed}</Text>}
325 <Text color={badge.color}> {badge.code}</Text>
326 </Text>
327 </Box>
328 )}
329 </Box>
330 )
331 })}
332 {last < rows.length && <Box height={rows.length - last} />}
333 {menu && place && (
334 <Box
335 position="absolute"
336 top={place.top}
337 left={place.left}
338 width={MENU_WIDTH}
339 flexDirection="column"
340 borderStyle="round"
341 borderColor="cyan"
342 >
343 {menuItems.map((item, index) => (
344 <Text inverse={index === menu.selected}>{` ${index + 1} ${item.label}`.padEnd(MENU_INNER)}</Text>
345 ))}
346 </Box>
347 )}
348 </Box>
349 )
350}
351
352export default TreeView
353types/index.d.ts 44 lines1/** Directory paths relative to the tree's top folder, `/`-separated. */
2export type DirPath = string
3
4/** A file's change against HEAD: Modified, Added, Deleted, Renamed, Untracked, Conflicted. */
5export type GitCode = 'M' | 'A' | 'D' | 'R' | 'U' | 'C'
6
7/** One changed file: its code and its line counts against HEAD (untracked: its lines as added). */
8export type GitFile = { code: GitCode; added: number; removed: number }
9
10/** `git status` of the cwd, paths relative to the cwd. */
11export type GitStatus = {
12 /** False outside a work tree (or where git is missing): no marks are drawn. */
13 isRepo: boolean
14 files: Record<string, GitFile>
15 /** Ignored paths that match an ignore rule; a directory ends with `/`. */
16 ignored: string[]
17}
18
19/**
20 * The name box of **New .md file**: open or not, the folder the file goes into (a tree path),
21 * the default name an empty field takes, the name last tried and why it was refused.
22 */
23export type NewFile = { isOpen: boolean; dir: string; suggested: string; name: string; error: string }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'file-tree': {
28 /** Directories shown open, relative to the tree's top folder. */
29 expanded: DirPath[]
30 /**
31 * The tree's top folder while pinned (absolute, `/`-separated), after the person moved up;
32 * `''` while the tree is in sync with the session's working directory.
33 */
34 pinned: string
35 /** Bumped whenever the tree on disk may have changed; drawings read it. */
36 tick: number
37 /** Last `git status` read; written only when it changed. */
38 git: GitStatus
39 /** The name box a **New .md file** opens at the top of the pane. */
40 newFile: NewFile
41 }
42 }
43}
44