A file explorer pane for the working directory, with a file viewer and copy-path buttons

A Claude Code mod that adds a Files pane: a tree of the working directory.
@path into the prompt at the cursor..git, node_modules, dist, build, .next, .cache, __pycache__, .venv, coverage and .DS_Store are hidden (edit IGNORED in hooks/register.tsx)./files reopens the pane; /files <path> roots it at another folder.From a terminal Claude Code session:
/plugin install file-explorer --marketplace anunes9/claude-plugins
Answer y to add the marketplace, then pick the scope (user scope loads it in every session). To try it from a local checkout without installing: claude --plugin-dir plugins/file-explorer.
claude plugin validate .
claude plugin test .
Limits: shows at most 1,000 rows; the viewer shows the first 80,000 characters and skips binary files and files over 4 MiB; paths are joined with /, so Windows is not supported.
hooks/register.tsx 383 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5
6const PANE = 'file-explorer'
7const VIEWER = 'file-view-'
8
9const IGNORED = new Set([
10 '.git',
11 'node_modules',
12 'dist',
13 'build',
14 '.next',
15 '.cache',
16 '__pycache__',
17 '.venv',
18 'coverage',
19 '.DS_Store',
20])
21
22/** Rows drawn at most, to stay inside a tree's node bounds. */
23const MAX_ROWS = 1000
24/** Characters of a file the viewer draws at most. */
25const MAX_VIEW_CHARS = 80_000
26/** How long a file Claude wrote keeps its marker. */
27const TOUCHED_MS = 5 * 60_000
28const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
29
30const root = atom({ plugin: 'file-explorer', key: 'root' } as const, '')
31const expanded = atom({ plugin: 'file-explorer', key: 'expanded' } as const, [''])
32const entries = atom({ plugin: 'file-explorer', key: 'entries' } as const, {})
33const touched = atom({ plugin: 'file-explorer', key: 'touched' } as const, {})
34const viewers = atom({ plugin: 'file-explorer', key: 'viewers' } as const, {})
35
36const join = (base: string, rel: string) =>
37 rel === '' ? base : `${base.replace(/\/$/, '')}/${rel}`
38
39const childOf = (rel: string, name: string) => (rel === '' ? name : `${rel}/${name}`)
40
41const parentOf = (path: string) => {
42 const cut = path.replace(/\/$/, '').lastIndexOf('/')
43
44 return cut <= 0 ? '/' : path.slice(0, cut)
45}
46
47const sortEntries = (list: Entry[]) =>
48 [...list].sort((a, b) =>
49 (a.kind === 'dir') !== (b.kind === 'dir')
50 ? a.kind === 'dir'
51 ? -1
52 : 1
53 : a.name.localeCompare(b.name),
54 )
55
56const formatSize = (bytes: number) =>
57 bytes < 1024
58 ? `${bytes} B`
59 : bytes < 1024 * 1024
60 ? `${(bytes / 1024).toFixed(1)} KB`
61 : `${(bytes / 1024 / 1024).toFixed(1)} MB`
62
63const formatAge = (mtimeMs: number) => {
64 const s = Math.max(0, (Date.now() - mtimeMs) / 1000)
65 if (s < 60) return 'now'
66 if (s < 3600) return `${Math.floor(s / 60)}m`
67 if (s < 86400) return `${Math.floor(s / 3600)}h`
68
69 return `${Math.floor(s / 86400)}d`
70}
71
72/** Lists one folder: ignored names dropped, links resolved to what they lead to. */
73const listDir = async ($: EngineInterface, abs: string): Promise<Entry[]> => {
74 const raw = await $.fs.list(abs)
75 const kept = raw.filter(one => !IGNORED.has(one.name))
76 const resolved = await Promise.all(
77 kept.map(async (one): Promise<Entry> => {
78 if (!one.isLink) {
79 return { name: one.name, kind: one.kind, size: one.size, mtimeMs: one.mtimeMs, isLink: false }
80 }
81 const target = await $.fs.stat(`${abs}/${one.name}`).catch(() => undefined)
82
83 return {
84 name: one.name,
85 kind: target?.kind ?? 'other',
86 size: target?.size ?? 0,
87 mtimeMs: target?.mtimeMs ?? 0,
88 isLink: true,
89 }
90 }),
91 )
92
93 return sortEntries(resolved)
94}
95
96/** Re-reads the given folders (relative to the root); one that fails is collapsed. */
97const reload = async ($: EngineInterface, dirs: string[]) => {
98 const base = await read($, root)
99 const results = await Promise.all(
100 dirs.map(async rel => ({
101 rel,
102 list: await listDir($, join(base, rel)).catch(() => undefined),
103 })),
104 )
105 const failed = new Set(results.filter(r => r.list === undefined).map(r => r.rel))
106
107 await update($, entries, all => {
108 const next = { ...all }
109 for (const { rel, list } of results) {
110 if (list === undefined) delete next[rel]
111 else next[rel] = list
112 }
113
114 return next
115 })
116 if (failed.size > 0) {
117 await update($, expanded, list => list.filter(rel => rel === '' || !failed.has(rel)))
118 }
119}
120
121const reloadExpanded = async ($: EngineInterface) => reload($, await read($, expanded))
122
123const setRoot = async ($: EngineInterface, path: string) => {
124 await update($, root, () => path)
125 await update($, expanded, () => [''])
126 await update($, entries, () => ({}))
127 await reload($, [''])
128}
129
130const toggle = async ($: EngineInterface, rel: string) => {
131 const isOpen = (await read($, expanded)).includes(rel)
132 if (isOpen) {
133 await update($, expanded, list => list.filter(one => one !== rel && !one.startsWith(`${rel}/`)))
134
135 return
136 }
137 await update($, expanded, list => [...list, rel])
138 await reload($, [rel])
139}
140
141/** The path as the prompt should carry it: relative to the session's folder when inside it. */
142const promptPath = async ($: EngineInterface, abs: string) => {
143 const cwd = (await $.session.cwd()).replace(/\/$/, '')
144
145 return abs.startsWith(`${cwd}/`) ? abs.slice(cwd.length + 1) : abs
146}
147
148const copyToPrompt = async ($: EngineInterface, abs: string) => {
149 const path = await promptPath($, abs)
150 await $.prompt.fill({ text: `@${path} `, mode: 'insert' })
151 $.ui.toast(`Added @${path} to the prompt`)
152}
153
154/** A pane id for a path: ids are short and plain, so the path is hashed (FNV-1a) and kept in state. */
155const viewerId = (abs: string) => {
156 let hash = 0x811c9dc5
157 for (let i = 0; i < abs.length; i++) {
158 hash ^= abs.charCodeAt(i)
159 hash = Math.imul(hash, 0x01000193) >>> 0
160 }
161
162 return `${VIEWER}${hash.toString(16)}`
163}
164
165const openViewer = async ($: EngineInterface, abs: string) => {
166 const id = viewerId(abs)
167 await update($, viewers, all => ({ ...all, [id]: abs }))
168
169 return $.ui.open({ id, title: abs.slice(abs.lastIndexOf('/') + 1) })
170}
171
172const openExplorer = async ($: EngineInterface) => {
173 if ((await read($, root)) === '') await setRoot($, await $.session.cwd())
174 else await reloadExpanded($)
175
176 return $.ui.open({ id: PANE, title: 'Files' })
177}
178
179type Row = { rel: string; entry: Entry; depth: number }
180
181/** The tree flattened into rows, depth first, open folders' children under them. */
182const flatten = (
183 all: Record<string, Entry[]>,
184 open: Set<string>,
185 rel: string,
186 depth: number,
187 rows: Row[],
188) => {
189 for (const entry of all[rel] ?? []) {
190 if (rows.length >= MAX_ROWS) return
191 const child = childOf(rel, entry.name)
192 rows.push({ rel: child, entry, depth })
193 if (entry.kind === 'dir' && open.has(child)) flatten(all, open, child, depth + 1, rows)
194 }
195}
196
197export const register: Register = on => {
198 on('session.start', async ($, e, next) => {
199 await $.command.register({
200 name: 'files',
201 description: 'Open the file explorer; /files <path> roots it at another folder',
202 })
203 void openExplorer($)
204
205 return next(e)
206 })
207
208 on('command.run', { command: 'files' }, async ($, e) => {
209 const arg = e.args.trim()
210 if (arg !== '') {
211 const cwd = await $.session.cwd()
212 const target = arg.startsWith('/') ? arg : join(cwd, arg)
213 const stat = await $.fs.stat(target, { resolve: true }).catch(() => undefined)
214 if (stat?.kind !== 'dir' || stat.realPath === undefined) {
215 return { text: `Not a folder: ${arg}` }
216 }
217 await setRoot($, stat.realPath)
218 }
219 await openExplorer($)
220
221 return { text: `File explorer opened at ${await read($, root)}.` }
222 })
223
224 // Claude's own writes: re-read the written file's folder and mark the file.
225 on('tool.call', async ($, e, next) => {
226 const ran = await next(e)
227 const tool = String(e.tool)
228 const args = e as unknown as { file_path?: unknown; notebook_path?: unknown }
229
230 if (WRITE_TOOLS.has(tool)) {
231 const path = typeof args.file_path === 'string' ? args.file_path : args.notebook_path
232 if (typeof path === 'string') {
233 const cwd = await $.session.cwd()
234 const abs = path.startsWith('/') ? path : join(cwd, path)
235 const now = Date.now()
236 await update($, touched, all => {
237 const fresh = Object.fromEntries(
238 Object.entries(all).filter(([, at]) => now - at < TOUCHED_MS),
239 )
240
241 return { ...fresh, [abs]: now }
242 })
243 const base = await read($, root)
244 const folder = parentOf(abs)
245 if (folder === base || folder.startsWith(`${base}/`)) {
246 const rel = folder === base ? '' : folder.slice(base.length + 1)
247 if ((await read($, expanded)).includes(rel)) await reload($, [rel])
248 }
249 }
250 } else if (tool === 'Bash') {
251 await reloadExpanded($)
252 }
253
254 return ran
255 }).catch(($, e, next) => next(e))
256
257 // Anything missed during the turn.
258 on('turn.complete', async ($, e, next) => {
259 const ran = await next(e)
260 if ((await read($, root)) !== '') await reloadExpanded($)
261
262 return ran
263 })
264
265 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
266 const { Box, Text, Button } = $.ui.resolve(e)
267 const base = await read($, root)
268 const open = new Set(await read($, expanded))
269 const all = await read($, entries)
270 const marks = await read($, touched)
271 const rows: Row[] = []
272 flatten(all, open, '', 0, rows)
273
274 return (
275 <Box flexDirection="column">
276 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
277 <Box flexShrink={1}>
278 <Text bold wrap="truncate-start">
279 {base}
280 </Text>
281 </Box>
282 <Box flexDirection="row" gap={1} flexShrink={0}>
283 <Button key="up" label=".." variant="secondary" onPress={() => setRoot($, parentOf(base))} />
284 <Button key="refresh" label="↻ Refresh" variant="primary" onPress={() => reloadExpanded($)} />
285 </Box>
286 </Box>
287 {rows.length === 0 && <Text dimColor>Empty folder.</Text>}
288 {rows.map(({ rel, entry, depth }) => {
289 const abs = join(base, rel)
290 const indent = ' '.repeat(depth)
291 const isDir = entry.kind === 'dir'
292 const at = marks[abs]
293 const isChanged = at !== undefined && Date.now() - at < TOUCHED_MS
294
295 return (
296 <Box key={`row:${rel}`} flexDirection="row" justifyContent="space-between">
297 <Box flexDirection="row" flexShrink={1}>
298 <Text>{indent}</Text>
299 <Button
300 key={`open:${rel}`}
301 plain
302 onPress={() => (isDir ? toggle($, rel) : openViewer($, abs))}
303 >
304 {isDir ? `${open.has(rel) ? '▾' : '▸'} ${entry.name}/` : ` ${entry.name}`}
305 {entry.isLink ? ' →' : ''}
306 </Button>
307 </Box>
308 <Box flexDirection="row" gap={2} flexShrink={0}>
309 {isChanged && <Text color="yellow">● changed</Text>}
310 {!isDir && <Text dimColor>{formatSize(entry.size)}</Text>}
311 {!isDir && entry.mtimeMs > 0 && <Text dimColor>{formatAge(entry.mtimeMs)}</Text>}
312 <Button
313 key={`copy:${rel}`}
314 label="⧉"
315 plain
316 dimColor
317 hover={{ dimColor: false, bold: true }}
318 onPress={() => copyToPrompt($, abs)}
319 />
320 </Box>
321 </Box>
322 )
323 })}
324 {rows.length >= MAX_ROWS && <Text dimColor>… more rows not shown; collapse a folder.</Text>}
325 </Box>
326 )
327 })
328
329 on('ui.render', { component: 'Pane', requestId: /^file-view-/ }, async ($, e) => {
330 const { Box, Text, Button, Code } = $.ui.resolve(e)
331 const abs = (await read($, viewers))[e.requestId]
332 if (abs === undefined) {
333 return <Text dimColor>Open a file from the Files pane.</Text>
334 }
335 // Subscribes the viewer to Claude's writes, so an edited file redraws.
336 await read($, touched)
337
338 const header = (
339 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
340 <Box flexShrink={1}>
341 <Text bold wrap="truncate-start">
342 {abs}
343 </Text>
344 </Box>
345 <Box flexDirection="row" gap={1} flexShrink={0}>
346 <Button key="copy" label="Copy path" onPress={() => copyToPrompt($, abs)} />
347 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: e.requestId })} />
348 </Box>
349 </Box>
350 )
351
352 const notice = (text: string) => (
353 <Box flexDirection="column">
354 {header}
355 <Text dimColor>{text}</Text>
356 </Box>
357 )
358
359 const stat = await $.fs.stat(abs).catch(() => undefined)
360 if (stat === undefined) return notice('This file no longer exists.')
361 if (stat.kind !== 'file') return notice('Not a regular file.')
362 if (stat.size > 4 * 1024 * 1024) return notice(`Too large to show (${formatSize(stat.size)}).`)
363
364 const text = await $.fs.read(abs).catch(() => undefined)
365 if (typeof text !== 'string') return notice('Could not read this file.')
366 if (text.includes('\u0000')) return notice(`Binary file (${formatSize(stat.size)}).`)
367
368 const isCut = text.length > MAX_VIEW_CHARS
369 const source = (isCut ? text.slice(0, MAX_VIEW_CHARS) : text)
370 .replace(/\r\n?/g, '\n')
371 // eslint-disable-next-line no-control-regex
372 .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
373
374 return (
375 <Box flexDirection="column">
376 {header}
377 {source === '' ? <Text dimColor>Empty file.</Text> : <Code source={source} path={abs} startLine={1} />}
378 {isCut && <Text dimColor>… cut at {MAX_VIEW_CHARS.toLocaleString()} characters.</Text>}
379 </Box>
380 )
381 })
382}
383types/index.d.ts 25 lines1export type Entry = {
2 name: string
3 kind: 'file' | 'dir' | 'other'
4 size: number
5 mtimeMs: number
6 isLink: boolean
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'file-explorer': {
12 /** The absolute folder the tree is rooted at. */
13 root: string
14 /** Expanded folders, relative to `root` ('' is the root itself). */
15 expanded: string[]
16 /** Each listed folder's entries, keyed like `expanded`. */
17 entries: Record<string, Entry[]>
18 /** Absolute paths Claude wrote recently, to when. */
19 touched: Record<string, number>
20 /** Open viewer panes: pane id to the absolute path it shows. */
21 viewers: Record<string, string>
22 }
23 }
24}
25