A file explorer pane beside the conversation: browse the project as a collapsible tree with git status markers, and preview Markdown, CSV and text files…

A file explorer pane for Claude Code. /tree opens your project beside the conversation, so you can browse it, preview files and hand them to Claude without leaving the session.

p).f), or jump to any path with /tree <path>.g), and step from match to match with Enter, n and b.a).It is a mod: a plugin of function hooks that draws in Claude Code's own interface, in the terminal and in the Desktop app's Code tab.
/tui fullscreen), the pane docks beside the conversation and the mouse works; without it, the pane opens above the prompt and works from the keys. See Use.git on the PATH for the status markers. Without it, the tree, the preview and the filter still work.claude -p.At the Claude Code prompt in a terminal (v2.1.275 or later for this one-step form):
/plugin install file-explorer --marketplace floheissler/cc-file-explorer
Claude Code asks you to confirm adding the marketplace, then shows the plugin's details: review what it adds and pick a scope. Or, in two steps from your shell:
claude plugin marketplace add floheissler/cc-file-explorer
claude plugin install file-explorer@cc-file-explorer
A user-scope install from the terminal also shows in the Desktop app's Code tab. Like every mod, it runs with your permissions: Privacy lists what it touches, and you can check that yourself before installing.
This repository is its own marketplace, named cc-file-explorer. Marketplaces other than Anthropic's don't update on their own: run claude plugin update file-explorer@cc-file-explorer for a new release, or turn on auto-update for cc-file-explorer in /plugin under Marketplaces.
To run it from a local checkout for one session instead:
claude --plugin-dir /path/to/cc-file-explorer
Run /tree to open the pane, and again to close it. Where it opens depends on how Claude Code draws:
/tui fullscreen) on a terminal at least 110 columns wide. The tree and the picked file's preview show together, the preview following the focus (see Follow or pin the preview), and the mouse works.x, Esc or the close mark step back to the tree, and the file's row keeps the focus. On the tree, Esc or the close mark closes the pane./tree <path> opens the pane onto one entry, or shows it in a pane already open (it never closes the pane):
The path counts from the project root, or is absolute; under Windows either separator works, and names match as Windows matches them (readme.md finds README.md). It takes what you would mention to Claude, too: @src/a.ts, @"my notes.md" and @a.ts#L10 work, and a name that starts with @ (@types) is tried as typed. A path outside the project, or one the tree does not list (.git included), is said in a toast, and the pane opens as usual.
The first time /tree opens under the classic renderer, it leaves a one-line tip about /tui fullscreen, and never again. On a fullscreen terminal too narrow to dock the pane, it says once a session how wide the terminal must be.
The header shows the tree's keys, e: expand c: collapse r: refresh f: filter a: mention h: help, and a previewed file's row its own, w: ↑ s: ↓ g: search m: source p: pin x: close (a: mention x: back above the prompt); h opens a help view with all of them.
In a narrow pane (the dock opens 40 columns wide on a 110-column terminal) the rows shorten to fit. The header's keys become e c r f a h: help, then h: help alone; the project's name keeps at least 8 columns. A previewed file's facts shorten from size, lines, mode and pinned to its size, and its keys step down the same way. Every key keeps working, and the help view's descriptions wrap to the width.
| Do this | To |
|---|---|
| Click a folder, or focus it and press Enter | Expand or collapse it |
| Click a file, or focus it and press Enter | Preview it under the tree (docked), or in the tree's place (above the prompt) |
| Move the focus onto a file, docked, with the preview open | Preview that file; a pinned preview stays where it is |
p | Pin the preview to its file, or let it follow the focus again (docked) |
e | Open every folder in view one level deeper; repeat for more |
c | Close the deepest open folders, one level; repeat for more |
1 – 9 | Open folders exactly that many levels deep |
0 | Close every folder |
r | Re-read the tree, the previewed file and git's status at once |
f | Filter the tree by name: shows the filter's field over the tree, the keyboard in it; again to close the filter |
g | Search the previewed file: shows the search's field over the preview, the keyboard in it; again to close the search |
n / b, or Enter on next | Go to the next matching line, or back to the one before, while the search has matches |
a | Mention the focused row to Claude at the prompt's cursor (@src/, @README.md), or the previewed file when no row has the focus |
h | Show or hide the help view |
x, or click a pinned preview's file again (docked) | Close the preview |
m | Switch a Markdown preview between rendered and source |
| Wheel over the tree or the preview (fullscreen only) | Scroll that part alone, as far per notch as the conversation scrolls (/scroll-speed); the header stays put |
w / s, Page Up / Page Down | Scroll the preview |
| Tab, Up, Down | Walk the tree; the focus comes in at its first row, Up from there reaches the header's controls, the tree scrolls with the focus, and Down stops at its last row |
| Ctrl+X then an arrow | Resize the pane |
| Esc | Docked, return the keyboard to the prompt; above the prompt under the classic renderer, step back (from a search to its file, from the file to the tree), then close |
e and the digits open at most 200 folders per press and say so when they stop early; press again to go further.
Keys work while the pane has the keyboard: click it, or press Ctrl+X then Tab.
Docked, an open preview follows the focus: as Tab, the arrows or a click land on a file, the preview shows it from its top. A folder's row and the header's controls leave it as it is. Walking quickly reads only the file the focus stops on, and the file shown stays drawn until the next one is read.
x closes the preview.p pins the preview to its file, and its row of facts says pinned. The focus then moves without changing it; Enter or a click on another file shows that file, still pinned, and on the pinned file closes the preview.p again lets go, and the preview moves to the focused file at once. Closing the preview lets go too: the next file you preview follows the focus again.a puts an @ mention of the focused row at the prompt's cursor, as typing @ and picking the file would: Claude Code reads the file, or lists the folder, when you send the prompt. With no row focused it mentions the previewed file, and above the prompt the file in view.
/, and a path with a space (or one a bare mention would cut short, such as notes.txt~) is quoted: @"My Notes/plan.md".@../README.md after a shell cd src, and absolute once the session has left the project.# cannot be mentioned (Claude Code reads what follows it as a line range), and a toast says so, as it does when the prompt is behind a dialog.tree command draws them (├─, └─ for the last entry of a folder, │ while a folder continues), dim beside the names.file2 before file10)..git, git-ignored ones (node_modules/, build output) included, as other file explorers show them./tree opens them again, as do /clear, /resume and /branch; later opens in a session keep that session's own. The 50 projects changed most recently are remembered, and a saved folder that is gone stays closed.h lists them; the colors follow your Claude Code theme):| Marker | Means |
|---|---|
M (yellow) | Changed since the last commit, staged or not |
A, R (green) | Staged as new, or renamed |
? (green) | New, not tracked by git yet |
D (red) | Removed from git but still on disk (git rm --cached) |
U (red) | In a merge conflict |
! (dim) | Ignored by git, or inside an ignored folder |
• | A folder with changes inside, in the color of the strongest |
✻ (Claude's color) | Claude wrote it this session with Write, Edit or NotebookEdit; on a folder, something inside |
The letters are git status --short's. Ignored entries keep the tree's own styling (folders bright, files dim) and only gain their !. Outside a git repository, or without git, rows show only Claude's marks. A root inside a repository marks its own entries; a root inside an ignored folder is ignored whole.
The tree and the open preview keep up with the disk:
ls or git status), the open folders and the previewed file are read again.git pull or another terminal appear within a few seconds. A check looks at at most 64 folders, the rest taking turns, and lists again only the folders that changed. A hidden or closed pane checks nothing.r, after Claude's edits and commands, and when a check found a change, never on every check. Each check also looks at the repository's index and HEAD, so a commit, a stage or a checkout in another terminal shows within a few seconds. A file edited in place outside Claude moves neither its folder nor the index: its M shows at the next read (press r).f shows a field over the tree and puts the keyboard in it. As you type, the tree narrows to the files and folders whose names match, under the folders that hold them, opened; the field's row counts the matches.
readme finds README.md), and every word must match (button test finds Button.test.tsx). A word with a / matches the path from the project root (src/comp).e, c and the digits work on it. A matched folder opens to everything it holds.f again closes the filter. The whole tree comes back as it was, with a file picked while filtering shown in it, its folders opened.In a git work tree, the filter searches what git lists: the tracked files, the untracked ones it does not ignore, and what it ignores by name only, so node_modules and dist match as folders but what they hold does not. Elsewhere it walks the folders, at most 1,000 of them. It shows at most 500 matches (500 of 2,140); type more to narrow. The list is read when the filter opens, and again after Claude's edits, a press of r, or a change the checks notice in a folder the filtered tree shows.
g shows a field over the preview and puts the keyboard in it. As you type, the preview moves to the first matching line from where the search started, and the field's row counts the matches (2/9).
needle finds Needle); one with a capital letter matches case exactly. The query matches as typed, spaces included; there are no patterns.next, so Enter again goes to the next match, and again. n goes to the next match and b back to the one before, wherever the focus is but in the field; both wrap around the file's ends. With nothing typed, Enter closes the search.n starts from its top. g again, or closing the preview, closes the search.| File | Preview |
|---|---|
.md, .markdown, .mdx | Rendered Markdown, or its source with m |
.csv, .tsv | A table under its header row; long cells are cut at 32 columns, or shorter so the table fits the pane's width |
| Any other text | Source with syntax colors by extension, and line numbers; long lines wrap, and the preview scrolls until the file's last line shows |
| Binary, empty, over 2 MB | A notice in place of the text |
The preview is saved for the project with the open folders: the file it shows, whether it is pinned, and whether Markdown shows as source. A session's first /tree opens it again from the file's top, as do /clear, /resume and /branch; above the prompt the tree shows first, as always. A saved file that is gone opens no preview.
File names and text are drawn safely: control characters show as their Control Pictures (an escape as ␛, a tab in a name as ␉), bidirectional controls and other unsafe characters as �, and long runs of combining marks are cut. A file cannot send escape sequences to your terminal or reorder a name.
file-explorer runs inside Claude Code on your machine, with your permissions, as every mod does. It only reads:
a put there.a, to set the mention off from the words around the cursor.Write, Edit, NotebookEdit, Bash and PowerShell calls, to refresh the pane and mark the files Claude wrote. It passes them on unchanged and never holds one.It makes no network requests, never calls a model, and writes no files of its own: nothing it reads leaves your machine. It reads no credentials and no environment variables. The one program it starts is git, read-only (status, ls-files, rev-parse), in the project's own folder, to draw the status markers and list the files the filter searches, with optional locks off so it never gets in the way of your own commits. Between sessions it keeps two things in the store Claude Code keeps for each plugin: whether its one-time fullscreen tip was shown, and for the 50 projects you changed most recently, the open folders and the previewed file, as paths relative to each project, with the preview's pin and Markdown mode.
To check this before you install, clone the repository and run claude plugin validate .: it lists every event the mod handles and every call it makes, read from the source. How it works explains each one.
| Platform | Status |
|---|---|
| Linux, WSL2 | Tested with Claude Code 2.1.294 |
| Windows | Smoke-tested on native Windows (2026-10-08). Drive roots (C:\), shares (\\server\share) and WSL's share (\\wsl.localhost\…) are covered by tests |
| macOS | Not tested. Paths are POSIX, as on Linux, and composed and decomposed accents in names both draw |
| Desktop app (Code tab) | Draws there from v2.1.286, per the mods docs; the tests mount the pane on its surface too |
\\?\ paths are refused; both are the engine's rules for $.fs.git on the PATH. They are left out where git cannot answer within 10 seconds, on a share it cannot open (\\wsl.localhost\… from Windows), or in a repository git holds unsafe (safe.directory). A status larger than 4 MB marks what fits.hooks/register.tsx is the hooks module; the rest of hooks/ is its parts.
| Event | What the hook does |
|---|---|
session.start | Registers /tree [path]; after a reload of the module, starts the checks again for a pane still open |
command.run of tree | Opens the pane on its tree, focused (above the prompt: up to 40 rows, closed by Esc under the classic renderer), reads git's status beside it, and starts its checks for outside changes, or closes it when it is shown; a session's first open starts from the project's saved folders and preview; leaves the one-time tip. With a path, closes a filter first, places the path under the root (an absolute one spelled through a link by where it lands), finds it folder by folder, each folder read afresh, opens its folders, picks a file, and moves the whole tree's window to its row, never closing the pane |
ui.render of the Pane | Draws for where the pane sits. Docked: the header, the filter's row while it is shown, the tree's window (filtered while the filter holds a query) and, set off by a blank row and a rule with the file's name, the preview's window under the search's row while it is shown, each exactly as tall as its region. Above the prompt: one view, the tree or the file or the help, as tall as its content. Rows carry their markers, and a searched source its bars. Reads what the drawing needs but lacks |
ui.scroll of the pane | Moves the tree's or the preview's own window by the region under the pointer, as far as the wheel's rows say; the engine's window over the pane stays still |
ui.focus in the pane | Keeps the focused row and its neighbors in view, so the arrows always have a drawn row to move to, and lands the focus where that row is drawn after the window moves; brings the focus into the pane at the tree's first row, ahead of the header's controls, and wraps it back there; keeps the focus off the hidden digit keys; records where the focus rests, for a to mention the row it marks; docked, shows the file it lands on in an open preview that is not pinned. The row /tree <path> revealed keeps the focus's start until you move it, and the row a mentioned until the focus lands anywhere |
| ui.close of the pane | Above the prompt, a person's close steps back first: from a searched file to the file, from the file or the help to the tree, from a filtered tree to the whole tree; a close that goes through stop
hooks/register.tsx 2473 lines1import { atom, read, update } from 'claude-code'
2import type { CommandPresentation, EngineInterface, Register, Timer, ToolCallResult } from 'claude-code'
3
4import {
5 filteredListingOf,
6 filteredTreeOf,
7 filterIndexOf,
8 filterStatusOf,
9 filterViewOf,
10 firstMatchOf,
11 foldedSetOf,
12 openedOf,
13 queryOf,
14 searchingRows,
15 type FilterIndex,
16 type FilterView,
17} from './filter'
18import { focusOrderOf, focusStepOf, ringElementOf, ringPlaceOf, type FocusOrder } from './focus'
19import { followedFileOf, pressStepOf } from './follow'
20import { hasStampMoved, isSameRead, readGitStatus, stampsOf, type GitRead } from './git'
21import type { Host } from './host'
22import {
23 escapeStepOf,
24 inlineLayoutOf,
25 inlineViewOf,
26 paneLayoutOf,
27 regionAt,
28 type PaneLayout,
29} from './layout'
30import Limits from './limits'
31import { collapseOneLevel, expandOneLevel, expandToDepth, type ListingOf, type ReadDirs } from './levels'
32import {
33 fileStampOf,
34 openListing,
35 readDirs,
36 readFileList,
37 readPreview,
38 readTree,
39 realKeyOf,
40 stampDirs,
41 type Listing,
42} from './listing'
43import { insertionOf, mentionOf, mentionPathOf, mentionTargetOf, mentionToastOf } from './mention'
44import {
45 COMMAND_DESCRIPTION,
46 filterKeyOf,
47 FULLSCREEN_TIP_TEXT,
48 KEYS,
49 PANE_TITLE,
50 ROW_KEY_PREFIX,
51 searchKeyOf,
52 TIP_SHOWN_KEY,
53 WIDEN_TIP_TEXT,
54} from './names'
55import { ancestorsOf, foldKey, isAbsolute, isSameKey, keyOf, rootLabelOf, styleOf } from './paths'
56import {
57 changedDirsOf,
58 hasSucceeded,
59 mayHaveWritten,
60 pollBatchOf,
61 shownDirsOf,
62 writtenPathOf,
63} from './poll'
64import { maxPreviewTop, previewHeightOf, sourceColumnsOf, type Preview } from './preview'
65import { findEntry, revealPathsOf, rowsRevealing } from './reveal'
66import { loadView, saveView } from './saved'
67import {
68 matchLinesOf,
69 searchQueryOf,
70 searchStatusOf,
71 stepMatchOf,
72 topShowingMatch,
73 type PreviewFit,
74 type SearchQuery,
75} from './search'
76import { NO_MARKS, treeMarksOf } from './status'
77import { messageOf, sanitize, truncateMiddle } from './text'
78import {
79 clamp,
80 flattenTree,
81 focusLandingOf,
82 maxTreeTop,
83 topRevealing,
84 treeWindowOf,
85 type Entry,
86 type PathSet,
87 type TreeRow,
88} from './tree'
89import { helpHeightOf, paneView, type FilterModel, type PaneActions, type SearchModel, type Seat } from './view'
90
91/**
92 * The pane's id and the command that toggles it. The hooks' matchers spell
93 * them as literals too, so `claude plugin validate` and an administrator's
94 * review read exactly what each hook matches.
95 *
96 * The command is not named after the plugin: Claude Code 2.1.293's command
97 * menu draws any command whose name starts with `file-` as a one-line
98 * `+ /name – description` row instead of its two columns.
99 */
100const PANE_ID = 'file-explorer'
101const COMMAND_NAME = 'tree'
102
103/**
104 * The pane's state the drawing reads, held by the session: it survives a
105 * reload of the module, a write redraws the pane, and `/clear`, `/resume`
106 * and `/branch` reset it. The open folders and the preview's file, pin and
107 * Markdown mode are also saved for the project (saved.ts), and a session's
108 * first open and those resets start from them.
109 */
110const EXPANDED = atom({ plugin: 'file-explorer', key: 'expanded' } as const, [])
111const SELECTED = atom({ plugin: 'file-explorer', key: 'selected' } as const, null)
112const TREE_TOP = atom({ plugin: 'file-explorer', key: 'treeTop' } as const, 0)
113const PREVIEW_TOP = atom({ plugin: 'file-explorer', key: 'previewTop' } as const, 0)
114const PINNED = atom({ plugin: 'file-explorer', key: 'pinned' } as const, false)
115const MARKDOWN_MODE = atom({ plugin: 'file-explorer', key: 'markdownMode' } as const, 'rendered')
116const HELP_SHOWN = atom({ plugin: 'file-explorer', key: 'helpShown' } as const, false)
117const FILTER = atom({ plugin: 'file-explorer', key: 'filter' } as const, null)
118const SEARCH = atom({ plugin: 'file-explorer', key: 'search' } as const, null)
119
120/**
121 * The files Claude wrote this session, which the tree marks: session state
122 * too, so `/clear` and `/resume` start a new list, as they start a new
123 * session.
124 */
125const WRITTEN = atom({ plugin: 'file-explorer', key: 'written' } as const, [])
126
127/**
128 * What the last drawing laid out: the scroll and focus hooks steer by it.
129 */
130type Drawn = {
131 readonly rows: readonly TreeRow[]
132 readonly layout: PaneLayout
133 /**
134 * The cells across a row the preview's text has: the row's, less the
135 * in-file search's marks beside a source preview.
136 */
137 readonly textColumns: number
138 /**
139 * Whether the filter's row sat over the tree.
140 */
141 readonly hasFilter: boolean
142 /**
143 * Whether the in-file search's row sat over the preview.
144 */
145 readonly hasSearch: boolean
146}
147
148/**
149 * What a query matches in one read of a file: the matching lines, as a
150 * list and a set.
151 */
152type Found = {
153 readonly preview: Preview
154 readonly text: string
155 readonly query: SearchQuery
156 readonly lines: readonly number[]
157 readonly set: ReadonlySet<number>
158}
159
160const NO_MATCHES: ReadonlySet<number> = new Set()
161
162/**
163 * Why an inline pane's close was turned into a step back, by the step.
164 */
165const DENIALS = {
166 unsearch: 'back to the file',
167 tree: 'back to the tree',
168 unfilter: 'back to the whole tree',
169} as const
170
171/**
172 * The tree a level step works on, the one the pane shows: its folders as
173 * drawn, which are open, how to read more, and where the step's result is
174 * kept.
175 */
176type ShownFolders = {
177 readonly listingOf: ListingOf
178 readonly expanded: PathSet
179 readonly read: ReadDirs
180 readonly open: (paths: readonly string[]) => Promise<void>
181}
182
183/**
184 * Binds the engine calls the explorer makes to one hook's `$`. Every call
185 * the mod makes on `$` is spelled here or in a hook below.
186 *
187 * @param $ the engine, as a hook receives it
188 * @returns the calls, as a plain record
189 */
190function hostOf($: EngineInterface): Host {
191 return {
192 root: () => $.session.root(),
193 cwd: () => $.session.cwd(),
194 run: (argv, init) => $.process.run(argv, init),
195 list: path => $.fs.list(path),
196 stat: path => $.fs.stat(path),
197 realPath: async path => (await $.fs.stat(path, { resolve: true })).realPath,
198 read: path => $.fs.read(path),
199 panes: () => $.ui.panes(),
200 invalidate: () => $.ui.invalidate('ui.render'),
201 focus: key => $.ui.focus({ requestId: PANE_ID, key }),
202 after: (ms, fn) => $.clock.after(ms, fn),
203 toast: text => $.ui.toast(text),
204 prompt: {
205 read: () => $.prompt.read(),
206 fill: args => $.prompt.fill(args),
207 },
208 store: {
209 get: key => $.store.get(key),
210 set: (key, value) => $.store.set(key, value),
211 delete: key => $.store.delete(key),
212 keys: () => $.store.keys(),
213 },
214 state: {
215 expanded: {
216 get: () => read($, EXPANDED),
217 set: async fn => {
218 await update($, EXPANDED, fn)
219 },
220 // The atom reads its default while unset, the plain reference nothing
221 isSet: async () =>
222 (await $.state.get({ plugin: 'file-explorer', key: 'expanded' })).value !== undefined,
223 },
224 selected: {
225 get: () => read($, SELECTED),
226 set: async fn => {
227 await update($, SELECTED, fn)
228 },
229 },
230 treeTop: {
231 get: () => read($, TREE_TOP),
232 set: async fn => {
233 await update($, TREE_TOP, fn)
234 },
235 },
236 previewTop: {
237 get: () => read($, PREVIEW_TOP),
238 set: async fn => {
239 await update($, PREVIEW_TOP, fn)
240 },
241 },
242 pinned: {
243 get: () => read($, PINNED),
244 set: async fn => {
245 await update($, PINNED, fn)
246 },
247 },
248 markdownMode: {
249 get: () => read($, MARKDOWN_MODE),
250 set: async fn => {
251 await update($, MARKDOWN_MODE, fn)
252 },
253 },
254 helpShown: {
255 get: () => read($, HELP_SHOWN),
256 set: async fn => {
257 await update($, HELP_SHOWN, fn)
258 },
259 },
260 filter: {
261 get: () => read($, FILTER),
262 set: async fn => {
263 await update($, FILTER, fn)
264 },
265 },
266 search: {
267 get: () => read($, SEARCH),
268 set: async fn => {
269 await update($, SEARCH, fn)
270 },
271 },
272 written: {
273 get: () => read($, WRITTEN),
274 set: async fn => {
275 await update($, WRITTEN, fn)
276 },
277 },
278 },
279 }
280}
281
282/**
283 * What `/tree` opens the pane with, and opens it again with: focused, as
284 * tall as its content inline up to `INLINE_ROWS` (the dock ignores `rows`),
285 * and under the classic renderer closed by Esc, as Claude Code's own dialogs
286 * are. One builder, as each open sets every one of these anew.
287 *
288 * @param isClassic whether the session draws with the classic renderer
289 * @returns the open's argument
290 */
291function paneArgsOf(isClassic: boolean) {
292 const base = { id: PANE_ID, title: PANE_TITLE, focus: true, rows: Limits.INLINE_ROWS } as const
293
294 return isClassic ? { ...base, closeOnEscape: true as const } : base
295}
296
297/**
298 * Registers the explorer: `/tree` toggles a pane that lists the project
299 * as a tree, folders opening in place, and previews the file picked under
300 * it. The pane scrolls its tree and its preview itself, under a header that
301 * stays put, and re-reads what it shows after Claude's edits and commands.
302 *
303 * The person's view of the pane (open folders, the picked file, where each
304 * window stands) is session state; the folders and the file as read are
305 * this module's, read again after a reload as the pane draws.
306 *
307 * @param on the engine's registrar
308 */
309export const register: Register = on => {
310 let listing: Listing | null = null
311 let listingLoad: Promise<Listing> | null = null
312 let preview: Preview | null = null
313 let previewLoad: string | null = null
314 let refreshTimer: Timer | null = null
315 let drawn: Drawn | null = null
316 const dirLoads = new Set<string>()
317
318 /**
319 * The focusable elements of the last drawing, and the key the focus ring
320 * last landed on: the focus hook keeps the ring off the hidden ones by
321 * where it comes from.
322 */
323 let focusOrder: FocusOrder = { shown: [], hidden: new Set() }
324 let lastFocused: string | undefined
325
326 /**
327 * Where the focus ring rests in the focus order. Claude Code keeps it at
328 * that place across a redraw, so `a` reads the row under it off the last
329 * drawing, not off `lastFocused`, which a redraw that adds rows above it
330 * leaves naming a row the ring has left.
331 */
332 let ringPlace: number | null = null
333
334 /**
335 * The row the ring was on when `a` handed the keyboard to the prompt: the
336 * ring starts there again as the pane takes the keyboard back, so the
337 * person walks on from it. Cleared once the ring lands anywhere.
338 */
339 let ringReturn: string | null = null
340
341 /**
342 * Forgets where the ring was: a pane without the keyboard shows none, and
343 * takes the keyboard back with the ring on nothing, or on its autofocused
344 * row.
345 */
346 const dropRing = () => {
347 ringPlace = null
348 lastFocused = undefined
349 }
350
351 /**
352 * Notes where the ring went: the element it landed on, and its place in
353 * the drawing it landed in. The row `a` left lets go of the ring then, and
354 * a preview that follows the ring shows the file it landed on.
355 */
356 const noteRing = (host: Host, element: string | undefined, place: number | null) => {
357 lastFocused = element
358 ringPlace = place
359
360 if (ringReturn !== null) {
361 ringReturn = null
362 host.invalidate()
363 }
364
365 // The ring moves on at once; the preview catches up once the file is read
366 void followRing(host, element).catch(() => undefined)
367 }
368
369 /**
370 * The previewed file's stamp when it was read, and whether `refresh` is
371 * reading the tree: a poll leaves the disk alone while it is.
372 */
373 let previewStamp: { readonly path: string; readonly stamp: string | null } | null = null
374 let isRefreshing = false
375
376 /**
377 * Where the last drawing sat, and whether an inline pane shows the picked
378 * file in place of the tree. `/tree` opens on the tree; picking a file
379 * shows it; `x`, Esc or the close mark step back.
380 */
381 let seat: Seat = { placement: 'dock', isClassic: false }
382 let isFileShown = false
383
384 /**
385 * The rows the pane's body had at its last drawing (docked, its height;
386 * inline, the most it may take), and the entry `/tree <path>` revealed,
387 * whose row takes the focus ring as the pane takes the keyboard until the
388 * person moves the ring. A pane opened afresh has neither yet.
389 */
390 let room: number | null = null
391 let revealed: string | null = null
392
393 /**
394 * Whether this session was told once to widen a fullscreen terminal too
395 * narrow to dock the pane.
396 */
397 let hasToldWiden = false
398
399 /**
400 * Switches of the preview to another file, one at a time: the one asked
401 * for next, which a later ask replaces, so walking the tree reads the file
402 * the ring stops on, not each one it passed; the run under way; and a
403 * generation `forget` moves on, dropping both.
404 */
405 const switching = {
406 next: null as {
407 readonly host: Host
408 readonly path: string
409 readonly isFollow: boolean
410 readonly generation: number
411 } | null,
412 run: null as Promise<void> | null,
413 generation: 0,
414 }
415
416 /**
417 * The preview drawn while a switch to another file waits for the session
418 * state to name it: the file read is `preview` from the moment it is read,
419 * and this one stays drawn until then, so the title, the facts and the
420 * text switch together.
421 */
422 let leaving: Preview | null = null
423
424 /**
425 * The polls for changes made outside Claude, while the pane is open: one
426 * pending wait at a time, each poll scheduling the next as it ends, so
427 * polls never overlap. A stop moves `generation` on, so a poll still
428 * running when the pane closed schedules nothing.
429 */
430 const polling = { isOn: false, generation: 0, cursor: 0, timer: null as Timer | null }
431
432 /**
433 * The filter, beyond its query in the session state:
434 * - the project's files as read for it, as an index, read once per
435 * filter and again after a refresh (`generation` drops a read overtaken
436 * by one);
437 * - the text in its field, ahead of the query while typing pauses;
438 * - how many times Enter was pressed in the field, which keys the field;
439 * - the last query's matches, and the folders the filtered tree has open
440 * for them;
441 * - where the whole tree's window stood when the filter opened.
442 */
443 const filtering = {
444 index: null as FilterIndex | null,
445 load: null as Promise<void> | null,
446 generation: 0,
447 typed: null as string | null,
448 submits: 0,
449 debounce: null as Timer | null,
450 found: null as { readonly index: FilterIndex; readonly text: string; readonly view: FilterView } | null,
451 open: null as { readonly text: string; readonly view: FilterView; readonly folders: Set<string> } | null,
452 treeTopBefore: 0,
453 }
454
455 /**
456 * Drops the filter's file list, so the next drawing that filters reads it
457 * afresh.
458 */
459 const dropFileList = () => {
460 filtering.generation += 1
461 filtering.index = null
462 filtering.load = null
463 filtering.found = null
464 }
465
466 /**
467 * Leaves the filter's own state: the field's text and the open folders.
468 */
469 const leaveFilter = () => {
470 filtering.debounce?.cancel()
471 filtering.debounce = null
472 filtering.typed = null
473 filtering.open = null
474 }
475
476 /**
477 * The in-file search, beyond its query in the session state:
478 * - the text in its field, ahead of the query while typing pauses;
479 * - how many times Enter was pressed in the field, which keys the field;
480 * - what the last query matched in the last file read;
481 * - where the steps stand, in which file: the match they stand on, and
482 * the line a query typed searches from, the top when the search opened
483 * or the file showed, then each match stepped to. Another file starts
484 * again from its top.
485 */
486 const searching = {
487 typed: null as string | null,
488 submits: 0,
489 debounce: null as Timer | null,
490 found: null as Found | null,
491 spot: null as { readonly path: string; readonly current: number | null; readonly origin: number } | null,
492 }
493
494 /**
495 * Leaves the search's own state: the field's text and where it stands.
496 */
497 const leaveSearch = () => {
498 searching.debounce?.cancel()
499 searching.debounce = null
500 searching.typed = null
501 searching.spot = null
502 }
503
504 /**
505 * What a query matches in a file as read, worked out once per query and
506 * read, as every drawing and step asks again.
507 *
508 * @returns what it matched, or null for a blank query
509 */
510 const foundIn = (shown: Preview, text: string): Found | null => {
511 const query = searchQueryOf(text)
512
513 if (query === null) {
514 return null
515 }
516
517 if (searching.found?.preview !== shown || searching.found.text !== text) {
518 const lines = matchLinesOf(shown, query)
519
520 searching.found = { preview: shown, text, query, lines, set: new Set(lines) }
521 }
522
523 return searching.found
524 }
525
526 /**
527 * The match the steps stand on in a file, while it still matches.
528 */
529 const currentMatchOf = (path: string, found: Found): number | null => {
530 const spot = searching.spot
531
532 return spot !== null && spot.path === path && spot.current !== null && found.set.has(spot.current)
533 ? spot.current
534 : null
535 }
536
537 /**
538 * Git's view of the tree as last read, null where the root is in no
539 * repository or git failed; the read under way, one at a time, and
540 * whether another was asked for meanwhile; and whether what is held is
541 * due a read, as after `forget`, which keeps it drawn until then.
542 */
543 let gitRead: GitRead | null = null
544 let gitLoad: Promise<void> | null = null
545 let isGitDue = false
546 let isGitStale = true
547
548 /**
549 * Drops what was read, so the next drawing reads the project afresh. Git's
550 * view stays drawn until it is read again, so markers do not blink.
551 */
552 const forget = () => {
553 listing = null
554 listingLoad = null
555 preview = null
556 previewLoad = null
557 previewStamp = null
558 leaving = null
559 switching.next = null
560 switching.generation += 1
561 drawn = null
562 room = null
563 revealed = null
564 dirLoads.clear()
565 polling.cursor = 0
566 dropFileList()
567 leaveFilter()
568 leaveSearch()
569 searching.found = null
570 isGitStale = true
571 }
572
573 /**
574 * Reads git's view of the tree, and redraws when it says something new.
575 * A read asked for while one runs is run once after it, and the promise
576 * settles when that one has: every caller sees git as it stood after it
577 * asked.
578 */
579 const loadGit = (host: Host): Promise<void> => {
580 if (gitLoad !== null) {
581 isGitDue = true
582
583 return gitLoad
584 }
585
586 gitLoad = (async () => {
587 try {
588 do {
589 isGitDue = false
590
591 const read = await readGitStatus(host, await host.root())
592 const isSame = isSameRead(gitRead, read)
593
594 gitRead = read
595 isGitStale = false
596
597 if (!isSame) {
598 host.invalidate()
599 }
600 } while (isGitDue)
601 } finally {
602 gitLoad = null
603 }
604 })()
605
606 return gitLoad
607 }
608
609 /**
610 * Whether the repository's index or HEAD moved since git was read: a
611 * commit, a stage or a checkout made outside Claude. Not while a read
612 * runs, which stamps them afresh.
613 */
614 const hasGitMoved = async (host: Host): Promise<boolean> => {
615 const read = gitRead
616
617 if (read === null || gitLoad !== null) {
618 return false
619 }
620
621 return hasStampMoved(read.stamps, await stampsOf(host, [...read.stamps.keys()]))
622 }
623
624 /**
625 * Remembers a file Claude wrote this session by its key, the latest
626 * last; a path outside the root is left out.
627 */
628 const recordWritten = async (host: Host, path: string) => {
629 const root = await host.root()
630 const key = keyOf(root, path) ?? (await realKeyOf(host, root, path))
631
632 if (key === null || key === '') {
633 return
634 }
635
636 const style = styleOf(root)
637
638 await host.state.written.set(keys =>
639 [...keys.filter(known => !isSameKey(known, key, style)), key].slice(-Limits.MAX_WRITTEN_FILES),
640 )
641 }
642
643 /**
644 * The listing, with the root folder read: one read at a time.
645 */
646 const ensureListing = (host: Host): Promise<Listing> => {
647 if (listing !== null) {
648 return Promise.resolve(listing)
649 }
650
651 listingLoad ??= (async () => {
652 try {
653 const opened = await openListing(host)
654
655 await readDirs(host, opened, [''])
656 listing = opened
657
658 return opened
659 } finally {
660 listingLoad = null
661 }
662 })()
663
664 return listingLoad
665 }
666
667 /**
668 * Reads the project's file list for the filter anew. The index it had
669 * stays drawn until the new one is in; a read a later one overtook is
670 * dropped.
671 */
672 const loadFilterIndex = (host: Host): Promise<void> => {
673 filtering.generation += 1
674
675 const generation = filtering.generation
676
677 const load: Promise<void> = (async () => {
678 const opened = await ensureListing(host)
679 const index = filterIndexOf(await readFileList(host, opened))
680
681 if (generation === filtering.generation) {
682 filtering.index = index
683 host.invalidate()
684 }
685 })().finally(() => {
686 if (filtering.load === load) {
687 filtering.load = null
688 }
689 })
690
691 filtering.load = load
692
693 return load
694 }
695
696 /**
697 * The filter's index of the project's files, read once.
698 */
699 const ensureFilterIndex = (host: Host): Promise<void> =>
700 filtering.index !== null ? Promise.resolve() : (filtering.load ?? loadFilterIndex(host))
701
702 /**
703 * What a query finds in the index, worked out once per query and index,
704 * as every drawing, focus move and scroll asks again.
705 *
706 * @returns what it found, or null for a blank query
707 */
708 const filterViewFor = (index: FilterIndex, text: string, root: string): FilterView | null => {
709 const query = queryOf(text)
710
711 if (query === null) {
712 return null
713 }
714
715 if (filtering.found?.index !== index || filtering.found.text !== text) {
716 const view = filterViewOf(index, query, Limits.MAX_FILTER_MATCHES, styleOf(root))
717
718 filtering.found = { index, text, view }
719 }
720
721 return filtering.found.view
722 }
723
724 /**
725 * The folders the filtered tree has open, folded keys: every folder that
726 * holds a match, until the person opens or closes some. A new query starts
727 * again from those; the same query over a file list read again keeps the
728 * person's choices and opens the folders of new matches.
729 */
730 const openFoldersOf = (view: FilterView, text: string): Set<string> => {
731 const open = filtering.open
732
733 if (open !== null && open.text === text && open.view === view) {
734 return open.folders
735 }
736
737 const folders =
738 open === null || open.text !== text
739 ? openedOf(view)
740 : new Set([...open.folders, ...[...view.ancestors].filter(dir => !open.view.ancestors.has(dir))])
741
742 filtering.open = { text, view, folders }
743
744 return folders
745 }
746
747 /**
748 * The query the tree is filtered by, what it found, and the folders as
749 * the filtered tree draws them; null while the tree is not filtered, or
750 * its file list is still being read.
751 */
752 const filterNow = async (host: Host, opened: Listing) => {
753 const text = await host.state.filter.get()
754
755 if (text === null || filtering.index === null) {
756 return null
757 }
758
759 const view = filterViewFor(filtering.index, text, opened.root)
760
761 return view === null
762 ? null
763 : { text, view, listingOf: filteredListingOf(dir => opened.dirs.get(dir), view) }
764 }
765
766 /**
767 * The tree as the pane shows it: the whole tree, or while the filter holds
768 * a query, the tree of what it found, a note while its files are read.
769 *
770 * @param current the folders as read
771 * @param expanded the whole tree's open folders
772 * @param text the filter's query, null while it is not shown
773 * @param onUnread told each open folder not read yet
774 * @returns the rows, and what the query found
775 */
776 const shownTreeOf = (
777 current: Listing,
778 expanded: readonly string[],
779 text: string | null,
780 onUnread: (dir: string) => void = () => undefined,
781 ): { readonly rows: TreeRow[]; readonly view: FilterView | null } => {
782 const listingOf: ListingOf = dir => {
783 const found = current.dirs.get(dir)
784
785 if (found === undefined) {
786 onUnread(dir)
787 }
788
789 return found
790 }
791
792 if (text === null || queryOf(text) === null) {
793 return { rows: flattenTree(listingOf, new Set(expanded)), view: null }
794 }
795
796 const view = filtering.index === null ? null : filterViewFor(filtering.index, text, current.root)
797
798 return view === null
799 ? { rows: searchingRows(), view: null }
800 : { rows: filteredTreeOf(listingOf, view, openFoldersOf(view, text)), view }
801 }
802
803 /**
804 * Saves of the pane's view to the store, one at a time and in order, each
805 * saving the view as it stands when it runs: the last save leaves the
806 * store as the pane stands. One waits at most, as it saves every change
807 * made before it runs, so a preview following held arrows saves as often
808 * as the store keeps up, not once a row.
809 */
810 const saving = { last: Promise.resolve(), isWaiting: false }
811
812 /**
813 * Saves the pane's view for the project after a change to it, so its next
814 * session opens the pane where this one left it: the whole tree's open
815 * folders, and the preview's file, pin and Markdown mode. A filtered
816 * tree's own open folders are the module's, unsaved.
817 *
818 * @returns settles once a save that holds the change has run
819 */
820 const queueSave = (host: Host): Promise<void> => {
821 if (saving.isWaiting) {
822 return saving.last
823 }
824
825 saving.isWaiting = true
826
827 // A failed save keeps the one before it; the next change saves again
828 saving.last = saving.last
829 .then(async () => {
830 saving.isWaiting = false
831 await saveView(host, Date.now())
832 })
833 .catch(() => undefined)
834
835 return saving.last
836 }
837
838 /**
839 * Opens or closes folders of the whole tree, and saves the view.
840 */
841 const setExpanded = async (host: Host, change: (paths: string[]) => string[]) => {
842 await host.state.expanded.set(change)
843 await queueSave(host)
844 }
845
846 /**
847 * Opens the pane where the project's view was last saved, by this session
848 * or another: the open folders, and the preview's file (from its top), pin
849 * and Markdown mode. Nothing saved leaves the pane as it is. Either way the
850 * open folders are written, so this session's later opens keep its own
851 * view, whatever another session saves meanwhile.
852 */
853 const restoreView = async (host: Host) => {
854 const saved = await loadView(host)
855
856 if (saved === null) {
857 await host.state.expanded.set(paths => paths)
858
859 return
860 }
861
862 const isSameFile = (await host.state.selected.get()) === saved.selected
863
864 await Promise.all([
865 host.state.expanded.set(() => [...saved.expanded]),
866 host.state.selected.set(() => saved.selected),
867 host.state.pinned.set(() => saved.pinned),
868 host.state.markdownMode.set(() => saved.markdownMode),
869 ...(isSameFile ? [] : [host.state.previewTop.set(() => 0)]),
870 ])
871 }
872
873 /**
874 * The tree a level step works on: the filtered tree while the filter holds
875 * a query, its open folders kept here; else the whole tree, its open
876 * folders kept in the session state.
877 */
878 const shownFoldersOf = async (host: Host): Promise<ShownFolders> => {
879 const opened = await ensureListing(host)
880 const read: ReadDirs = dirs => readDirs(host, opened, dirs)
881 const filter = await filterNow(host, opened)
882
883 if (filter === null) {
884 return {
885 listingOf: dir => opened.dirs.get(dir),
886 expanded: new Set(await host.state.expanded.get()),
887 read,
888 open: async paths => {
889 await setExpanded(host, () => [...paths])
890 },
891 }
892 }
893
894 const { text, view, listingOf } = filter
895 const folders = openFoldersOf(view, text)
896
897 return {
898 listingOf,
899 expanded: foldedSetOf(folders, view.style),
900 read,
901 open: async paths => {
902 folders.clear()
903 paths.forEach(path => folders.add(foldKey(path, view.style)))
904 host.invalidate()
905 },
906 }
907 }
908
909 /**
910 * Reads folders the tree shows open but has not read, then redraws.
911 */
912 const loadDirs = async (host: Host, into: Listing, dirs: readonly string[]) => {
913 const fresh = dirs.filter(dir => !dirLoads.has(dir))
914
915 if (fresh.length === 0) {
916 return
917 }
918
919 fresh.forEach(dir => dirLoads.add(dir))
920
921 try {
922 await readDirs(host, into, fresh)
923 } finally {
924 fresh.forEach(dir => dirLoads.delete(dir))
925 }
926
927 if (listing === into) {
928 host.invalidate()
929 }
930 }
931
932 /**
933 * Reads the file the preview shows. A read overtaken by another pick is
934 * dropped.
935 */
936 const loadPreview = async (host: Host, path: string) => {
937 previewLoad = path
938
939 try {
940 const opened = await ensureListing(host)
941 const loaded = await readPreview(host, opened.root, path)
942
943 if (previewLoad === path) {
944 preview = loaded.preview
945 previewStamp = { path, stamp: loaded.stamp }
946 }
947 } finally {
948 if (previewLoad === path) {
949 previewLoad = null
950 }
951 }
952 }
953
954 /**
955 * Shows a file in the preview from its top: read first, then named in the
956 * session state, `leaving` drawn meanwhile, then saved for the project:
957 * the next switch waits for that save, which a burst of switches shares,
958 * and the ring never waits. A later switch to another file
959 * waiting, or `forget`, drops it; a follow of the ring lapses where the
960 * preview was closed or pinned since the ring moved, and a pick of the
961 * person's never does. A file the preview shows already is left as it is.
962 */
963 const switchOnce = async (host: Host, path: string, isFollow: boolean, generation: number) => {
964 const isWanted = async () => {
965 const [selected, isPinned] = await Promise.all([host.state.selected.get(), host.state.pinned.get()])
966
967 // A click on a file asks twice, as the ring lands on its row and as it
968 // presses it: the second ask waits for this one
969 const isReplaced = switching.next !== null && switching.next.path !== path
970
971 return (
972 !isReplaced &&
973 generation === switching.generation &&
974 selected !== path &&
975 !(isFollow && (selected === null || isPinned))
976 )
977 }
978
979 if (!(await isWanted())) {
980 return
981 }
982
983 const opened = await ensureListing(host)
984 const loaded = await readPreview(host, opened.root, path)
985
986 if (!(await isWanted())) {
987 return
988 }
989
990 // A read of the leaving file still under way is dropped
991 leaving = preview
992 preview = loaded.preview
993 previewStamp = { path, stamp: loaded.stamp }
994 previewLoad = null
995
996 try {
997 // A follow never opens a preview closed meanwhile
998 await Promise.all([
999 host.state.selected.set(current => (isFollow && current === null ? null : path)),
1000 host.state.previewTop.set(() => 0),
1001 ])
1002 } finally {
1003 leaving = null
1004 }
1005
1006 await queueSave(host)
1007 }
1008
1009 /**
1010 * Asks for the preview to show a file, after any switch under way.
1011 *
1012 * @param isFollow whether the ring's move asks it, not the person's pick
1013 * @returns settles once the switches asked for so far have run
1014 */
1015 const switchPreview = (host: Host, path: string, isFollow: boolean): Promise<void> => {
1016 switching.next = { host, path, isFollow, generation: switching.generation }
1017
1018 switching.run ??= (async () => {
1019 try {
1020 while (switching.next !== null) {
1021 const ask = switching.next
1022
1023 switching.next = null
1024
1025 // A failed switch leaves the preview as it was, and the next runs
1026 await switchOnce(ask.host, ask.path, ask.isFollow, ask.generation).catch(() => undefined)
1027 }
1028 } finally {
1029 switching.run = null
1030 }
1031 })()
1032
1033 return switching.run
1034 }
1035
1036 /**
1037 * Shows the file of the row the ring landed on, docked, in a preview that
1038 * is open and follows the ring.
1039 */
1040 const followRing = async (host: Host, element: string | undefined) => {
1041 const rows = drawn?.rows ?? []
1042 const { placement } = seat
1043 const [selected, isPinned] = await Promise.all([host.state.selected.get(), host.state.pinned.get()])
1044 const path = followedFileOf(element, rows, { selected, isPinned, placement })
1045
1046 if (path !== null) {
1047 await switchPreview(host, path, true)
1048 }
1049 }
1050
1051 /**
1052 * Closes the preview, inline back to the tree, with its search, and lets
1053 * go of its pin: the next file previewed follows the ring again.
1054 */
1055 const closePreview = async (host: Host) => {
1056 isFileShown = false
1057 leaveSearch()
1058 await Promise.all([
1059 host.state.selected.set(() => null),
1060 host.state.pinned.set(() => false),
1061 host.state.search.set(() => null),
1062 ])
1063 await queueSave(host)
1064 }
1065
1066 /**
1067 * Moves the tree's window so a row and its neighbors show, under the
1068 * layout the pane has with or without a preview.
1069 */
1070 const revealRow = async (host: Host, path: string, hasPreview: boolean) => {
1071 if (drawn === null || seat.placement === 'inline') {
1072 return
1073 }
1074
1075 const { rows, layout } = drawn
1076 const index = rows.findIndex(row => row.type === 'entry' && row.path === path)
1077
1078 if (index < 0) {
1079 return
1080 }
1081
1082 const hasSearch = hasPreview && (await host.state.search.get()) !== null
1083 const { treeRows } = paneLayoutOf(layout.bodyRows, hasPreview, { hasFilter: drawn.hasFilter, hasSearch })
1084
1085 await host.state.treeTop.set(top => topRevealing(index, top, rows.length, treeRows))
1086 }
1087
1088 /**
1089 * The tree window's top that shows a row and its neighbors. On a pane
1090 * drawn before, it moves as little as the focus ring moves it, under the
1091 * layout the pane will have. On one opened afresh, whose height is not
1092 * known until it draws, the row above it comes first, which shows the row
1093 * in a tree of `MIN_TREE_ROWS` rows or more, the markers of the rows out
1094 * of view included.
1095 */
1096 const revealTopOf = (
1097 rows: readonly TreeRow[],
1098 index: number,
1099 top: number,
1100 hasPreview: boolean,
1101 hasSearch: boolean,
1102 ): number => {
1103 if (index < 0) {
1104 return top
1105 }
1106
1107 if (room === null) {
1108 return Math.max(0, index - 1)
1109 }
1110
1111 const { treeRows } =
1112 seat.placement === 'inline'
1113 ? inlineLayoutOf(room, 'tree', rows.length)
1114 : paneLayoutOf(room, hasPreview, { hasSearch: hasPreview && hasSearch })
1115
1116 return topRevealing(index, top, rows.length, treeRows)
1117 }
1118
1119 /**
1120 * Opens the tree onto an entry: the folders above it open, and a folder
1121 * itself; a file is picked and previewed, inline in the tree's place. The
1122 * window moves to the entry's row, the open folders above it read first so
1123 * the row is drawn where the window expects it, and the row takes the
1124 * focus ring as the pane takes the keyboard.
1125 */
1126 const showEntry = async (host: Host, opened: Listing, entry: Entry) => {
1127 const isDir = entry.kind === 'dir'
1128 const before = await host.state.expanded.get()
1129 const opening = [...ancestorsOf(entry.path), ...(isDir ? [entry.path] : [])]
1130 const expanded = [...before, ...opening.filter(dir => !before.includes(dir))]
1131
1132 if (isDir) {
1133 await readDirs(host, opened, [entry.path])
1134 }
1135
1136 const { rows, index } = await rowsRevealing(
1137 dir => opened.dirs.get(dir),
1138 new Set(expanded),
1139 entry.path,
1140 dirs => readDirs(host, opened, dirs),
1141 )
1142
1143 if (!isDir) {
1144 await switchPreview(host, entry.path, false)
1145 }
1146
1147 const hasPreview = (await host.state.selected.get()) !== null
1148 const hasSearch = (await host.state.search.get()) !== null
1149
1150 isFileShown = !isDir
1151 revealed = entry.path
1152 await setExpanded(host, () => expanded)
1153 await host.state.treeTop.set(top => revealTopOf(rows, index, top, hasPreview, hasSearch))
1154 }
1155
1156 /**
1157 * `/tree <path>`: shows the first of the paths the tree lists, each read
1158 * afresh along the way, so an entry made a moment ago is found. The root
1159 * itself shows the tree from its top. A path outside the project, or one
1160 * the tree does not list, is said in a toast, the path as typed.
1161 *
1162 * An absolute path that spells the root another way, through a link above
1163 * it (macOS's `/tmp` for `/private/tmp`), is placed by where it lands. A
1164 * relative one counts from the root alone: `$.fs` would resolve it from
1165 * the engine's working folder.
1166 */
1167 const reveal = async (host: Host, paths: readonly string[], typed: string) => {
1168 const opened = await ensureListing(host)
1169 const style = styleOf(opened.root)
1170
1171 const placed = async (path: string) =>
1172 keyOf(opened.root, path) ??
1173 (isAbsolute(path, style) ? await realKeyOf(host, opened.root, path) : null)
1174
1175 const readDir = async (dir: string) => {
1176 await readDirs(host, opened, [dir])
1177
1178 return opened.dirs.get(dir)
1179 }
1180
1181 let isOutside = true
1182
1183 for (const path of paths) {
1184 const key = await placed(path)
1185
1186 if (key === null) {
1187 continue
1188 }
1189
1190 isOutside = false
1191
1192 if (key === '') {
1193 await host.state.treeTop.set(() => 0)
1194
1195 return
1196 }
1197
1198 const entry = await findEntry(key, style, readDir)
1199
1200 if (entry !== null) {hooks/filter.ts 343 lines1import type { ListingOf } from './levels'
2import { ancestorsOf, foldKey, nameOf, type PathStyle } from './paths'
3import { flattenTree, type PathSet, type TreeRow } from './tree'
4
5/**
6 * Filtering the tree by name: the project's files as one list, the entries
7 * a query matches, and the tree drawn as those entries and the folders that
8 * hold them.
9 *
10 * The list finds the matches; the tree draws them from the folders as
11 * `$.fs.list` reads them, so every row is a real entry, spelled and sorted
12 * as the whole tree spells and sorts it. A list path and a row's key are
13 * compared folded (`foldKey`), as git may spell a name another way than
14 * the file system lists it.
15 */
16
17/**
18 * One file or folder of the project, by its key as the list spelled it.
19 */
20export type FoundEntry = {
21 readonly path: string
22 readonly kind: 'file' | 'dir'
23}
24
25/**
26 * The project's files and folders the filter searches, and whether the
27 * search had to stop short of all of them.
28 */
29export type FileList = {
30 readonly entries: readonly FoundEntry[]
31 readonly isPartial: boolean
32}
33
34/**
35 * One entry ready to match: its name and its path folded once.
36 */
37type IndexedEntry = FoundEntry & {
38 readonly foldedName: string
39 readonly foldedPath: string
40}
41
42/**
43 * A file list ready to match: every folder that holds a listed entry is an
44 * entry too, so a query finds folders by name.
45 */
46export type FilterIndex = {
47 readonly entries: readonly IndexedEntry[]
48 readonly isPartial: boolean
49}
50
51/**
52 * What a query asks: every term in the name, or for a term with a `/`, in
53 * the path from the root.
54 */
55export type FilterQuery = {
56 readonly terms: readonly { readonly text: string; readonly isPath: boolean }[]
57}
58
59/**
60 * What a query found: the matches it shows (at most the cap, folded keys),
61 * the folders that hold them, and how many entries matched in all.
62 */
63export type FilterView = {
64 readonly style: PathStyle
65 readonly matches: ReadonlySet<string>
66 readonly ancestors: ReadonlySet<string>
67 readonly total: number
68 readonly isCapped: boolean
69 readonly isPartial: boolean
70}
71
72/**
73 * A text as a query compares it: composed and lowercase, so `readme`
74 * finds `README.md` and a decomposed accent its composed spelling.
75 */
76const foldText = (text: string) => text.normalize('NFC').toLowerCase()
77
78/**
79 * The paths of `git ls-files -z` output, one ended by each NUL. Output cut
80 * at the engine's cap ends inside its last path, which is dropped.
81 */
82function pathsOfNul(output: string): string[] {
83 const parts = output.split('\0')
84
85 // Whole output ends in a NUL, leaving an empty last part; cut output
86 // leaves the cut path there
87 parts.pop()
88
89 return parts.filter(part => part !== '')
90}
91
92/**
93 * The file list git gives for a work tree: the files it tracks and the
94 * untracked ones it does not ignore, less the tracked files deleted from
95 * disk; then what it ignores, a wholly ignored folder as the folder alone,
96 * so `node_modules` and `dist` are found by name but not searched.
97 *
98 * @param output the three listings' text, null for one that failed, and
99 * whether any was cut at the engine's output cap
100 * @returns the list
101 */
102export function gitFileListOf(output: {
103 readonly listed: string
104 readonly deleted: string | null
105 readonly ignored: string | null
106 readonly isTruncated: boolean
107}): FileList {
108 const deleted = new Set(output.deleted === null ? [] : pathsOfNul(output.deleted))
109 const seen = new Set<string>()
110 const entries: FoundEntry[] = []
111
112 const add = (path: string, kind: FoundEntry['kind']) => {
113 if (path !== '' && path !== '.' && !seen.has(path) && !deleted.has(path)) {
114 seen.add(path)
115 entries.push({ path, kind })
116 }
117 }
118
119 // An unmerged file is listed once per stage
120 for (const path of pathsOfNul(output.listed)) {
121 add(path, 'file')
122 }
123
124 for (const path of output.ignored === null ? [] : pathsOfNul(output.ignored)) {
125 if (path.endsWith('/')) {
126 add(path.slice(0, -1), 'dir')
127 } else {
128 add(path, 'file')
129 }
130 }
131
132 return { entries, isPartial: output.isTruncated }
133}
134
135/**
136 * Makes a file list ready to match: names every folder that holds an entry,
137 * and folds each name and path once, not at every keystroke.
138 *
139 * @param list the files and folders found
140 * @returns the index
141 */
142export function filterIndexOf(list: FileList): FilterIndex {
143 const seen = new Set<string>()
144 const entries: IndexedEntry[] = []
145
146 const add = (path: string, kind: FoundEntry['kind']) => {
147 if (seen.has(path)) {
148 return
149 }
150
151 seen.add(path)
152
153 const foldedPath = foldText(path)
154
155 entries.push({ path, kind, foldedPath, foldedName: foldText(nameOf(path)) })
156 }
157
158 for (const entry of list.entries) {
159 for (const dir of ancestorsOf(entry.path)) {
160 add(dir, 'dir')
161 }
162
163 add(entry.path, entry.kind)
164 }
165
166 return { entries, isPartial: list.isPartial }
167}
168
169/**
170 * Reads a query: its words, each to be found in an entry's name, or in its
171 * path from the root when the word holds a `/` (`src/comp`).
172 *
173 * @param text what the person typed
174 * @returns the query, or null for a blank one, which filters nothing
175 */
176export function queryOf(text: string): FilterQuery | null {
177 const words = foldText(text).split(/\s+/).filter(word => word !== '')
178
179 if (words.length === 0) {
180 return null
181 }
182
183 return { terms: words.map(word => ({ text: word, isPath: word.includes('/') })) }
184}
185
186function isMatch(entry: IndexedEntry, query: FilterQuery): boolean {
187 return query.terms.every(term => (term.isPath ? entry.foldedPath : entry.foldedName).includes(term.text))
188}
189
190/**
191 * The entries a query matches, up to `cap` of them shown, and the folders
192 * that hold those.
193 *
194 * @param index the project's entries
195 * @param query the query
196 * @param cap the most matches shown
197 * @param style the root's style, which keys fold by
198 * @returns the view
199 */
200export function filterViewOf(index: FilterIndex, query: FilterQuery, cap: number, style: PathStyle): FilterView {
201 const matches = new Set<string>()
202 const ancestors = new Set<string>()
203 let total = 0
204
205 for (const entry of index.entries) {
206 if (!isMatch(entry, query)) {
207 continue
208 }
209
210 total += 1
211
212 if (matches.size < cap) {
213 matches.add(foldKey(entry.path, style))
214 ancestorsOf(entry.path).forEach(dir => ancestors.add(foldKey(dir, style)))
215 }
216 }
217
218 return { style, matches, ancestors, total, isCapped: total > matches.size, isPartial: index.isPartial }
219}
220
221/**
222 * The folders as a filtered tree reads them: the root and every folder that
223 * holds a match list only their matches and the folders that hold one; any
224 * other folder, opened by the person, lists all it holds.
225 *
226 * @param listingOf the folders as read
227 * @param view what the query found
228 * @returns the folders as the filtered tree draws them
229 */
230export function filteredListingOf(listingOf: ListingOf, view: FilterView): ListingOf {
231 const isKept = (path: string) => {
232 const folded = foldKey(path, view.style)
233
234 return view.matches.has(folded) || view.ancestors.has(folded)
235 }
236
237 return dir => {
238 const listing = listingOf(dir)
239
240 if (listing === undefined || 'error' in listing) {
241 return listing
242 }
243
244 if (dir !== '' && !view.ancestors.has(foldKey(dir, view.style))) {
245 return listing
246 }
247
248 // A match past the folder's entry cap was never listed: none are noted
249 return { entries: listing.entries.filter(entry => isKept(entry.path)), truncated: 0 }
250 }
251}
252
253/**
254 * The folders a filtered tree opens before the person opens or closes any:
255 * every folder that holds a match.
256 *
257 * @param view what the query found
258 * @returns the folded keys
259 */
260export function openedOf(view: FilterView): Set<string> {
261 return new Set(view.ancestors)
262}
263
264/**
265 * A set of folded keys asked by key: a filtered tree's open folders.
266 *
267 * @param folded the folded keys
268 * @param style the root's style
269 * @returns the set, answering any spelling of a key in it
270 */
271export function foldedSetOf(folded: ReadonlySet<string>, style: PathStyle): PathSet {
272 return { has: path => folded.has(foldKey(path, style)) }
273}
274
275/**
276 * The filtered tree's rows: the tree of the matches and the folders that
277 * hold them, open where `open` says; one note when nothing matched.
278 *
279 * @param listingOf the folders as read
280 * @param view what the query found
281 * @param open the open folders, folded keys
282 * @returns the rows
283 */
284export function filteredTreeOf(listingOf: ListingOf, view: FilterView, open: ReadonlySet<string>): TreeRow[] {
285 if (view.total === 0) {
286 return [noteRow('filter', 'No match')]
287 }
288
289 return flattenTree(filteredListingOf(listingOf, view), foldedSetOf(open, view.style))
290}
291
292/**
293 * The tree's one row while the file list is read.
294 */
295export function searchingRows(): TreeRow[] {
296 return [noteRow('filter', 'Searching…')]
297}
298
299function noteRow(key: string, text: string): TreeRow {
300 return { type: 'note', key: `note:${key}`, depth: 0, guides: [], isLast: true, text, isError: false }
301}
302
303/**
304 * The first row of a filtered tree that is a match, where Enter in the
305 * filter takes the focus.
306 *
307 * @param rows the filtered tree's rows
308 * @param view what the query found
309 * @returns the row's key, or null when none is drawn
310 */
311export function firstMatchOf(rows: readonly TreeRow[], view: FilterView): string | null {
312 const found = rows.find(row => row.type === 'entry' && view.matches.has(foldKey(row.path, view.style)))
313
314 return found?.type === 'entry' ? found.path : null
315}
316
317/**
318 * What the filter's row says beside the field: how many entries match, the
319 * shown ones of all when the cap cut them (`500 of 2,140`), `+` when the
320 * search stopped short of the project.
321 *
322 * @param view what the query found, null while the file list is read
323 * @returns the text
324 */
325export function filterStatusOf(view: FilterView | null): string {
326 if (view === null) {
327 return 'searching…'
328 }
329
330 const more = view.isPartial ? '+' : ''
331 const count = (n: number) => n.toLocaleString('en-US')
332
333 if (view.total === 0) {
334 return view.isPartial ? 'no match in the part searched' : 'no match'
335 }
336
337 if (view.isCapped) {
338 return `${count(view.matches.size)} of ${count(view.total)}${more}`
339 }
340
341 return `${count(view.total)}${more} ${view.total === 1 && more === '' ? 'match' : 'matches'}`
342}
343hooks/focus.ts 159 lines1import type { RenderElement, RenderNode } from 'claude-code'
2
3import { ROW_KEY_PREFIX } from './names'
4
5/**
6 * The pane's focusable elements as last drawn: the ones the person sees, in
7 * the order the focus ring walks them, and the ones in a `display: 'none'`
8 * box, drawn only so their hotkeys stay armed.
9 */
10export type FocusOrder = {
11 readonly shown: readonly string[]
12 readonly hidden: ReadonlySet<string>
13}
14
15/**
16 * Where the ring goes as it moves onto an element: on as asked, kept where
17 * it is, or onto another element.
18 */
19export type FocusStep = 'pass' | 'stay' | { readonly element: string }
20
21const FOCUSABLE_TYPES: ReadonlySet<string> = new Set(['Button', 'Input'])
22
23/**
24 * The plain-data shape every element of a drawn tree shares, as far as the
25 * walk below reads it.
26 */
27type ElementData = {
28 readonly type: string
29 readonly props?: Readonly<Record<string, unknown>>
30 readonly children?: readonly RenderNode[]
31}
32
33/**
34 * The focusable elements of a drawn tree, shown and hidden, in document
35 * order.
36 *
37 * @param tree what the pane's render hook returns
38 * @returns the elements' keys
39 */
40export function focusOrderOf(tree: RenderElement): FocusOrder {
41 const shown: string[] = []
42 const hidden = new Set<string>()
43
44 const visit = (node: RenderNode, isHidden: boolean): void => {
45 if (typeof node === 'string') {
46 return
47 }
48
49 const element = node as ElementData
50 const key = element.props?.key
51
52 if (FOCUSABLE_TYPES.has(element.type)) {
53 if (typeof key === 'string') {
54 if (isHidden) {
55 hidden.add(key)
56 } else {
57 shown.push(key)
58 }
59 }
60
61 return
62 }
63
64 const hides = isHidden || (element.type === 'Box' && element.props?.display === 'none')
65
66 for (const child of element.children ?? []) {
67 visit(child, hides)
68 }
69 }
70
71 visit(tree, false)
72
73 return { shown, hidden }
74}
75
76/**
77 * Where the ring rests in the shown focus order once it landed on an
78 * element. Claude Code keeps the ring at that place across a redraw, not on
79 * the element's key, so a redraw that adds rows above it rests it on
80 * another element.
81 *
82 * @param order the pane's focusable elements as drawn when the ring landed
83 * @param element the key it landed on; absent for Claude Code's own stops
84 * @returns the place, or null when it rests on none of the pane's shown
85 * elements
86 */
87export function ringPlaceOf(order: FocusOrder, element: string | undefined): number | null {
88 const at = element === undefined ? -1 : order.shown.indexOf(element)
89
90 return at < 0 ? null : at
91}
92
93/**
94 * The element the ring rests on in a drawing: the one at its place.
95 *
96 * @param order the pane's focusable elements as last drawn
97 * @param place where the ring rests, from `ringPlaceOf`
98 * @returns its key, or undefined when nothing of the pane's is there
99 */
100export function ringElementOf(order: FocusOrder, place: number | null): string | undefined {
101 return place === null ? undefined : order.shown[place]
102}
103
104/**
105 * Where the ring starts its walk of the pane: the tree's first drawn row,
106 * ahead of the header's controls drawn above it, else (the help, a file
107 * shown inline) the first shown element.
108 *
109 * @param order the pane's focusable elements as last drawn
110 * @returns its key, or undefined when nothing is shown
111 */
112export function ringStartOf(order: FocusOrder): string | undefined {
113 return order.shown.find(key => key.startsWith(ROW_KEY_PREFIX)) ?? order.shown[0]
114}
115
116/**
117 * Starts the ring at the tree and keeps it off the hidden elements, which
118 * Claude Code lists in the focus order like any other.
119 *
120 * Coming in onto the first shown element, from nothing or from one of
121 * Claude Code's own stops, the ring starts at the tree's first row instead,
122 * so the arrows walk the tree before the header's controls; Up from that
123 * row still reaches them. Moving onto a hidden element from the tree's last
124 * row stops there, as a list stops at its end; from another shown element
125 * the ring wraps to the start, and back from the first shown element (or
126 * from one of Claude Code's own stops) to the last.
127 *
128 * @param element the key the ring moves onto; absent for Claude Code's stops
129 * @param last the key the ring left; absent for Claude Code's stops, and
130 * while it rests on nothing
131 * @param order the pane's focusable elements as last drawn
132 * @returns the step
133 */
134export function focusStepOf(
135 element: string | undefined,
136 last: string | undefined,
137 order: FocusOrder,
138): FocusStep {
139 const first = order.shown[0]
140 const final = order.shown.at(-1)
141 const start = ringStartOf(order)
142
143 if (element === undefined) {
144 return 'pass'
145 }
146
147 if (!order.hidden.has(element)) {
148 const isComingIn = last === undefined && element === first
149
150 return isComingIn && start !== undefined && start !== element ? { element: start } : 'pass'
151 }
152
153 if (start === undefined || final === undefined || last?.startsWith(ROW_KEY_PREFIX) === true) {
154 return 'stay'
155 }
156
157 return { element: last === undefined || last === first ? final : start }
158}
159hooks/follow.ts 77 lines1import { ROW_KEY_PREFIX } from './names'
2import type { TreeRow } from './tree'
3
4/**
5 * The preview following the focus ring: docked, with a file previewed, each
6 * file's row the ring lands on shows that file, until `p` pins the preview
7 * to the file it shows. Inline, the file and the tree never show together,
8 * so nothing follows there.
9 */
10
11/**
12 * What the preview shows, and how it takes a ring's move, as the pane
13 * stands.
14 */
15export type PreviewSeat = {
16 /**
17 * The previewed file, null while the preview is closed.
18 */
19 readonly selected: string | null
20 readonly isPinned: boolean
21 readonly placement: 'dock' | 'inline'
22}
23
24/**
25 * The file the preview moves to as the ring lands on an element: the file
26 * whose row it landed on, while the pane is docked and its preview open and
27 * not pinned. A folder's row, the header's controls and Claude Code's own
28 * stops leave the preview as it is.
29 *
30 * @param element the key the ring landed on; absent for Claude Code's stops
31 * @param rows the tree's rows as last drawn
32 * @param seat what the preview shows and how
33 * @returns the file's key, or null to leave the preview
34 */
35export function followedFileOf(
36 element: string | undefined,
37 rows: readonly TreeRow[],
38 seat: PreviewSeat,
39): string | null {
40 if (seat.placement !== 'dock' || seat.isPinned || seat.selected === null) {
41 return null
42 }
43
44 if (element === undefined || !element.startsWith(ROW_KEY_PREFIX)) {
45 return null
46 }
47
48 const path = element.slice(ROW_KEY_PREFIX.length)
49
50 if (path === seat.selected) {
51 return null
52 }
53
54 const row = rows.find(drawn => drawn.type === 'entry' && drawn.path === path)
55
56 return row?.type === 'entry' && row.kind !== 'dir' ? path : null
57}
58
59/**
60 * What a press on a file's row does to a docked preview: shows the file.
61 * On the file the preview shows, a press keeps it while the preview follows
62 * the ring, as the ring's file is the one shown and Enter must not close it;
63 * pinned, a press closes it.
64 *
65 * @param path the pressed file
66 * @param selected the previewed file, null while the preview is closed
67 * @param isPinned whether the preview is pinned to its file
68 * @returns the step
69 */
70export function pressStepOf(path: string, selected: string | null, isPinned: boolean): 'show' | 'keep' | 'close' {
71 if (path !== selected) {
72 return 'show'
73 }
74
75 return isPinned ? 'close' : 'keep'
76}
77hooks/git.ts 166 lines1import type { ProcessRunResult } from 'claude-code'
2
3import type { Host } from './host'
4import Limits from './limits'
5import { nativePathOf, styleOf } from './paths'
6import { parsePorcelain, repoPlaceOf, statusOf, type GitStatus } from './status'
7
8/**
9 * Every git run the mod makes, through the engine's calls: the filter's file
10 * list (`listing.ts`), and the markers' view of the tree, where the root
11 * sits in its repository and the status of everything under it.
12 */
13
14/**
15 * Set over the session's environment for every git run: a read never takes
16 * a lock a commit running beside it needs (so a status never writes the
17 * index), and git speaks plain C, whatever the person's language.
18 */
19export const GIT_ENV: Readonly<Record<string, string>> = { GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' }
20
21/**
22 * Runs git in the root, under `GIT_ENV` and `GIT_TIMEOUT_MS`.
23 *
24 * @param host the engine's calls
25 * @param root the session's project root, native
26 * @param args git's arguments, the command first
27 * @returns what it wrote, or null where it exited otherwise than 0, could
28 * not start (no git) or ran out of time
29 */
30export async function runGit(host: Host, root: string, args: readonly string[]): Promise<ProcessRunResult | null> {
31 try {
32 const run = await host.run(['git', ...args], {
33 cwd: nativePathOf(root, ''),
34 env: { ...GIT_ENV },
35 timeoutMs: Limits.GIT_TIMEOUT_MS,
36 })
37
38 return run.exitCode === 0 ? run : null
39 } catch {
40 return null
41 }
42}
43
44/**
45 * The status of everything under the root, once per file: untracked files
46 * one by one (`??`), a folder git ignores whole as the folder alone
47 * (`!! node_modules/`). Plain `--ignored` would list every file inside
48 * an ignored folder with `--untracked-files=all`, tens of thousands of
49 * lines for a real `node_modules`.
50 */
51const STATUS_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=matching', '--', '.'] as const
52
53const PLACE_ARGS = ['rev-parse', '--show-prefix', '--absolute-git-dir'] as const
54
55/**
56 * Git's view of the tree as read once, and what tells the next read due.
57 */
58export type GitRead = {
59 /**
60 * The session root it was read for, native.
61 */
62 readonly root: string
63 readonly status: GitStatus
64 /**
65 * What `git status` wrote: two reads that wrote the same say the same.
66 */
67 readonly output: string
68 /**
69 * The repository's index and HEAD, native, and their stamps taken just
70 * before the status ran: a commit, a stage or a checkout made outside
71 * Claude moves them, so a poll that finds them moved reads git again.
72 */
73 readonly stamps: ReadonlyMap<string, string | null>
74}
75
76/**
77 * The stamps of files by native path, each its modification time and size
78 * as one key, as the previewed file's stamp is.
79 *
80 * @param host the engine's calls
81 * @param paths the files
82 * @returns each file's stamp, null where it cannot be stat'ed
83 */
84export async function stampsOf(host: Host, paths: readonly string[]): Promise<Map<string, string | null>> {
85 const read = await Promise.all(
86 paths.map(path =>
87 host.stat(path).then(
88 stat => `${stat.mtimeMs}:${stat.size}`,
89 () => null,
90 ),
91 ),
92 )
93
94 return new Map(paths.map((path, at) => [path, read[at] ?? null]))
95}
96
97/**
98 * Whether any stamp differs from the one taken before.
99 *
100 * @param then the stamps as taken before
101 * @param now the same files' stamps now
102 * @returns whether one moved
103 */
104export function hasStampMoved(
105 then: ReadonlyMap<string, string | null>,
106 now: ReadonlyMap<string, string | null>,
107): boolean {
108 return [...then].some(([path, stamp]) => now.get(path) !== stamp)
109}
110
111/**
112 * Reads git's view of the tree: where the root sits in its repository
113 * (`git rev-parse`), the stamps of the repository's index and HEAD, then
114 * the status. Never rejects.
115 *
116 * Git paths are `/`-separated and relative to the repository's top on
117 * every platform, even when git runs in a folder below it, so they are
118 * read against the root's prefix (`statusOf`). Output cut at the engine's
119 * 4 MiB shows the markers of what was read.
120 *
121 * @param host the engine's calls
122 * @param root the session's project root, native
123 * @returns the read, or null where the root is in no work tree, git is not
124 * installed, failed or took too long (a share git cannot open, `\\wsl.localhost\…`
125 * from Windows, a repository git holds unsafe)
126 */
127export async function readGitStatus(host: Host, root: string): Promise<GitRead | null> {
128 const where = await runGit(host, root, PLACE_ARGS)
129 const place = where === null ? null : repoPlaceOf(where.stdout)
130
131 if (place === null) {
132 return null
133 }
134
135 // Git for Windows spells the folder `C:/…`, which `$.fs` takes as it is
136 const stamps = await stampsOf(host, ['index', 'HEAD'].map(name => nativePathOf(place.gitDir, name)))
137 const listed = await runGit(host, root, STATUS_ARGS)
138
139 if (listed === null) {
140 return null
141 }
142
143 return {
144 root,
145 status: statusOf(parsePorcelain(listed.stdout), place.prefix, styleOf(root)),
146 output: listed.stdout,
147 stamps,
148 }
149}
150
151/**
152 * Whether two reads draw the same markers: none and none, or the same
153 * status of the same root.
154 *
155 * @param a a read, or null for none
156 * @param b another
157 * @returns whether they say the same
158 */
159export function isSameRead(a: GitRead | null, b: GitRead | null): boolean {
160 if (a === null || b === null) {
161 return a === b
162 }
163
164 return a.root === b.root && a.output === b.output
165}
166hooks/host.ts 119 lines1import type {
2 FsEntry,
3 FsStat,
4 ProcessRunInit,
5 ProcessRunResult,
6 PromptBox,
7 PromptFillArgs,
8 PromptFilled,
9 Timer,
10 UiFocusResult,
11 UiPane,
12} from 'claude-code'
13
14import type { MarkdownMode } from '../types'
15
16/**
17 * One value of the pane's session state: read it, or write it from its
18 * current value, which redraws the pane.
19 */
20export type StateCell<T> = {
21 readonly get: () => Promise<T>
22 readonly set: (fn: (value: T) => T) => Promise<void>
23}
24
25/**
26 * A value of session state that a value kept in the store can start: it
27 * also says whether this session wrote it yet, as a session starts on its
28 * default.
29 */
30export type SeededStateCell<T> = StateCell<T> & {
31 readonly isSet: () => Promise<boolean>
32}
33
34/**
35 * The pane's session state, value by value.
36 */
37export type PaneState = {
38 readonly expanded: SeededStateCell<string[]>
39 readonly selected: StateCell<string | null>
40 readonly treeTop: StateCell<number>
41 readonly previewTop: StateCell<number>
42 readonly pinned: StateCell<boolean>
43 readonly markdownMode: StateCell<MarkdownMode>
44 readonly helpShown: StateCell<boolean>
45 readonly filter: StateCell<string | null>
46 readonly search: StateCell<string | null>
47 readonly written: StateCell<string[]>
48}
49
50/**
51 * What the explorer asks of the engine, bound to one hook's `$` by
52 * `hostOf` in register.tsx: every engine call the mod makes is spelled
53 * there, where `claude plugin validate` reads it, and the parts beyond
54 * that file take this plain record instead of `$`.
55 */
56export type Host = {
57 /**
58 * The session's project root, absolute: the tree's root.
59 */
60 readonly root: () => Promise<string>
61 /**
62 * The session's working folder, absolute: where a shell `cd` took it, and
63 * what Claude Code resolves a mention's path against.
64 */
65 readonly cwd: () => Promise<string>
66 /**
67 * Runs a program by its argument vector, no shell, and resolves once it
68 * exits, any exit code. The mod runs `git` alone, through `git.ts`: the
69 * filter's file list and the markers' status.
70 */
71 readonly run: (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
72 readonly list: (path: string) => Promise<readonly FsEntry[]>
73 readonly stat: (path: string) => Promise<FsStat>
74 /**
75 * Where a path lands, every link followed and `.`/`..` folded; undefined
76 * where it leads nowhere. Rejects when the path is missing.
77 */
78 readonly realPath: (path: string) => Promise<string | undefined>
79 readonly read: (path: string) => Promise<string>
80 /**
81 * This plugin's open panes.
82 */
83 readonly panes: () => Promise<readonly UiPane[]>
84 /**
85 * Asks for the pane to be drawn again from what the module holds.
86 */
87 readonly invalidate: () => void
88 /**
89 * Moves the pane's focus ring onto an element it draws, waiting a while
90 * for one not drawn yet; denied while the pane does not hold the keys.
91 */
92 readonly focus: (key: string) => Promise<UiFocusResult>
93 readonly after: (ms: number, fn: () => void) => Timer
94 /**
95 * Says something briefly without a turn: a level step that stopped early.
96 */
97 readonly toast: (text: string) => void
98 /**
99 * The person's prompt box: read as it stands, and written into, which
100 * hands it the keyboard.
101 */
102 readonly prompt: {
103 readonly read: () => Promise<PromptBox>
104 readonly fill: (args: PromptFillArgs) => Promise<PromptFilled>
105 }
106 /**
107 * This plugin's own store, kept between sessions and shared by every
108 * session that runs the mod: what the pane has told the person once, and
109 * each project's view of the pane.
110 */
111 readonly store: {
112 readonly get: (key: string) => Promise<unknown>
113 readonly set: (key: string, value: unknown) => Promise<void>
114 readonly delete: (key: string) => Promise<void>
115 readonly keys: () => Promise<readonly string[]>
116 }
117 readonly state: PaneState
118}
119hooks/layout.ts 205 lines1import Limits from './limits'
2
3/**
4 * How the pane's body is split, top to bottom: the header row, the filter's
5 * row while it is shown, the tree, and while a file is previewed its head
6 * (a blank row, its title rule, its meta row and the in-file search's row
7 * while it is shown) and its text.
8 */
9export type PaneLayout = {
10 readonly bodyRows: number
11 readonly treeRow: number
12 readonly treeRows: number
13 /**
14 * The preview's first text row; the preview's head sits above it.
15 */
16 readonly previewRow: number
17 /**
18 * 0 while no file is previewed.
19 */
20 readonly previewRows: number
21}
22
23/**
24 * The regions a body row belongs to.
25 */
26export type Region = 'header' | 'tree' | 'preview-head' | 'preview'
27
28const HEADER_ROWS = 1
29/**
30 * The filter's row: its field and what it found.
31 */
32const FILTER_ROWS = 1
33/**
34 * The in-file search's row: its field, what it found and its steps.
35 */
36const SEARCH_ROWS = 1
37/**
38 * A blank row setting the preview off from the tree, the rule that carries
39 * the file's name, and the row of its size and controls.
40 */
41const PREVIEW_HEAD_ROWS = 3
42
43/**
44 * The rows the pane shows while asked for: the filter's over the tree, the
45 * in-file search's over the previewed file.
46 */
47export type ShownRows = {
48 readonly hasFilter?: boolean
49 readonly hasSearch?: boolean
50}
51
52/**
53 * The rows above the tree: the header, and the filter's row while shown.
54 */
55const headRowsOf = (hasFilter: boolean) => HEADER_ROWS + (hasFilter ? FILTER_ROWS : 0)
56
57/**
58 * Splits a body of `bodyRows` rows: the tree alone, or the tree over the
59 * preview, the tree keeping its share and each at least its minimum.
60 *
61 * @param bodyRows the rows the pane's body has
62 * @param hasPreview whether a file is previewed
63 * @param shown the filter's and the search's rows shown
64 * @returns the layout
65 */
66export function paneLayoutOf(bodyRows: number, hasPreview: boolean, shown: ShownRows = {}): PaneLayout {
67 const treeRow = headRowsOf(shown.hasFilter === true)
68 const rows = Math.max(treeRow + 1, bodyRows)
69
70 if (!hasPreview) {
71 return {
72 bodyRows: rows,
73 treeRow,
74 treeRows: rows - treeRow,
75 previewRow: rows,
76 previewRows: 0,
77 }
78 }
79
80 const previewHeadRows = PREVIEW_HEAD_ROWS + (shown.hasSearch === true ? SEARCH_ROWS : 0)
81 const shared = Math.max(2, rows - treeRow - previewHeadRows)
82 const isRoomy = shared >= Limits.MIN_TREE_ROWS + Limits.MIN_PREVIEW_ROWS
83
84 const treeRows = isRoomy
85 ? Math.min(
86 shared - Limits.MIN_PREVIEW_ROWS,
87 Math.max(Limits.MIN_TREE_ROWS, Math.round(shared * Limits.TREE_SHARE)),
88 )
89 : Math.max(1, Math.floor(shared / 2))
90
91 const previewRow = treeRow + treeRows + previewHeadRows
92
93 return {
94 bodyRows: rows,
95 treeRow,
96 treeRows,
97 previewRow,
98 previewRows: Math.max(1, shared - treeRows),
99 }
100}
101
102/**
103 * Which region a body row lies in, as the wheel's pointer names the row.
104 *
105 * @param layout the body as last drawn
106 * @param row the body row, 0 at the top
107 * @returns its region
108 */
109export function regionAt(layout: PaneLayout, row: number): Region {
110 if (row < layout.treeRow) {
111 return 'header'
112 }
113
114 if (row < layout.treeRow + layout.treeRows) {
115 return 'tree'
116 }
117
118 return row < layout.previewRow ? 'preview-head' : 'preview'
119}
120
121/**
122 * What an inline pane shows, one view at a time: the tree, the file picked
123 * from it, or the help.
124 */
125export type InlineView = 'tree' | 'file' | 'help'
126
127/**
128 * The view an inline pane shows: the help while it is asked for, else the
129 * file while one is picked and shown, else the tree.
130 *
131 * @param state the help's state, whether the file view is shown, and the
132 * picked file
133 * @returns the view
134 */
135export function inlineViewOf(state: {
136 readonly helpShown: boolean
137 readonly isFileShown: boolean
138 readonly selected: string | null
139}): InlineView {
140 if (state.helpShown) {
141 return 'help'
142 }
143
144 return state.isFileShown && state.selected !== null ? 'file' : 'tree'
145}
146
147/**
148 * How an inline pane's body is split: its header (the tree's, or the file's
149 * head row), the filter's row over the tree or the search's over the file
150 * while shown, and the one view under them, as tall as its content and at
151 * most what the room leaves. The frame of an inline pane fits what is
152 * drawn, so a short tree takes few rows. The help is drawn whole: taller
153 * than the room, Claude Code's window over the pane scrolls it.
154 *
155 * @param bodyRows the most rows the pane may take
156 * @param view the view shown
157 * @param contentRows the rows the view's content would take
158 * @param shown the filter's and the search's rows shown; each sits over
159 * its own view
160 * @returns the layout
161 */
162export function inlineLayoutOf(
163 bodyRows: number,
164 view: InlineView,
165 contentRows: number,
166 shown: ShownRows = {},
167): PaneLayout {
168 const headRows =
169 view === 'file'
170 ? HEADER_ROWS + (shown.hasSearch === true ? SEARCH_ROWS : 0)
171 : headRowsOf(shown.hasFilter === true && view === 'tree')
172 const room = Math.max(1, bodyRows - headRows)
173 const rows = Math.max(1, view === 'help' ? contentRows : Math.min(contentRows, room))
174
175 return {
176 bodyRows: headRows + rows,
177 treeRow: headRows,
178 treeRows: view === 'tree' ? rows : 0,
179 previewRow: view === 'file' ? headRows : headRows + rows,
180 previewRows: view === 'file' ? rows : 0,
181 }
182}
183
184/**
185 * Where closing an inline pane by hand (Esc, its close mark) takes it first:
186 * from a searched file to the file, from the file or the help back to the
187 * tree, from a filtered tree to the whole tree; from the whole tree it
188 * closes.
189 *
190 * @param view the view shown
191 * @param shown the filter's and the search's rows shown
192 * @returns the step back, or null to close
193 */
194export function escapeStepOf(view: InlineView, shown: ShownRows = {}): 'unsearch' | 'tree' | 'unfilter' | null {
195 if (view === 'file' && shown.hasSearch === true) {
196 return 'unsearch'
197 }
198
199 if (view !== 'tree') {
200 return 'tree'
201 }
202
203 return shown.hasFilter === true ? 'unfilter' : null
204}
205hooks/limits.ts 159 lines1/**
2 * The sizes the pane draws and reads within.
3 *
4 * A pane draws the first 100,000 characters of a tree's texts and
5 * `$.fs.read` stops at 4 MiB, so the preview reads and draws well inside
6 * both and shows the rest as the person scrolls.
7 */
8const Limits = {
9 /**
10 * Lines one press of `w` or `s` moves the preview.
11 */
12 KEY_ROWS: 3,
13 /**
14 * The largest file the preview reads, in bytes.
15 */
16 MAX_PREVIEW_BYTES: 2 * 1024 * 1024,
17 /**
18 * Characters of preview text one drawing holds.
19 */
20 MAX_PREVIEW_CHARS: 40_000,
21 /**
22 * Characters kept of one line of a preview; the rest is cut with `…`.
23 */
24 MAX_LINE_CHARS: 1_000,
25 /**
26 * Terminal columns kept of one cell of a CSV or TSV preview at most;
27 * fewer where the table would be wider than the row.
28 */
29 MAX_CELL_COLUMNS: 32,
30 /**
31 * Rows of a CSV or TSV file the preview parses.
32 */
33 MAX_TABLE_ROWS: 100_000,
34 /**
35 * Entries the tree lists for one folder; a note counts the rest.
36 */
37 MAX_DIR_ENTRIES: 2_000,
38 /**
39 * Characters sniffed at a file's start to tell binary from text.
40 */
41 SNIFF_CHARS: 8_000,
42 /**
43 * The share of the body the tree keeps while a file is previewed.
44 */
45 TREE_SHARE: 0.4,
46 MIN_TREE_ROWS: 4,
47 MIN_PREVIEW_ROWS: 4,
48 /**
49 * Columns kept clear at the body's right edge.
50 */
51 RIGHT_PAD_COLUMNS: 1,
52 /**
53 * The cells a row's name keeps before its controls give up their labels.
54 */
55 NAME_FLOOR_CELLS: 8,
56 /**
57 * The cells the source preview's gutter takes beyond its line numbers'
58 * digits: one before the right-aligned numbers and one after (checked on
59 * Claude Code 2.1.294).
60 */
61 CODE_GUTTER_PAD: 2,
62 /**
63 * The body rows an inline pane asks for: as tall as its content, up to
64 * this, so a long tree leaves some of the conversation in view.
65 */
66 INLINE_ROWS: 40,
67 /**
68 * The terminal width from which Claude Code's fullscreen layout docks a
69 * pane beside the conversation; narrower, it seats the pane inline.
70 */
71 DOCK_MIN_COLUMNS: 110,
72 /**
73 * The quiet time after Claude's last edit or command before an open pane
74 * re-reads the tree.
75 */
76 REFRESH_DEBOUNCE_MS: 300,
77 /**
78 * How often an open, shown pane polls the folders it shows and the
79 * previewed file for changes made outside Claude.
80 */
81 POLL_MS: 2_000,
82 /**
83 * Folders one poll stats at most, the root included; past it the open
84 * folders take turns.
85 */
86 MAX_POLL_STATS: 64,
87 /**
88 * Changed folders one poll lists again at most; the rest wait for the
89 * next poll.
90 */
91 MAX_POLL_RELISTS: 8,
92 /**
93 * Folders read at once, so a level opened in a large repository does not
94 * list hundreds of folders in one go.
95 */
96 READ_CONCURRENCY: 8,
97 /**
98 * Folders one press of `e` or a digit opens at most; past it the press
99 * stops and says so.
100 */
101 MAX_LEVEL_FOLDERS: 200,
102 /**
103 * The quiet time after the last keystroke in the filter before the tree
104 * narrows to it.
105 */
106 FILTER_DEBOUNCE_MS: 150,
107 /**
108 * Matches a filtered tree shows at most; the filter's row counts the rest.
109 */
110 MAX_FILTER_MATCHES: 500,
111 /**
112 * The quiet time after the last keystroke in the in-file search before
113 * the preview moves to the first match.
114 */
115 SEARCH_DEBOUNCE_MS: 150,
116 /**
117 * The cells the in-file search's marks take left of a source preview,
118 * while the search is shown.
119 */
120 SEARCH_MARK_CELLS: 1,
121 /**
122 * How long one git call may run (the filter's file list, the markers'
123 * status) before it is dropped: the filter walks the folders instead, and
124 * the tree draws no git markers.
125 */
126 GIT_TIMEOUT_MS: 10_000,
127 /**
128 * Folders the filter reads at most outside a git work tree, shallowest
129 * first, and the entries it collects at most.
130 */
131 MAX_WALK_FOLDERS: 1_000,
132 MAX_WALK_ENTRIES: 100_000,
133 /**
134 * Files Claude wrote this session that the tree marks; past it the
135 * earliest are forgotten.
136 */
137 MAX_WRITTEN_FILES: 1_000,
138 /**
139 * Cells of a typed path a toast keeps; a longer one is cut in the middle.
140 */
141 TOAST_PATH_CELLS: 60,
142 /**
143 * Projects whose view of the pane the store keeps; past it the one saved
144 * longest ago is dropped. The store holds 4 MiB in all, shared with every
145 * other value the mod keeps there.
146 */
147 MAX_SAVED_PROJECTS: 50,
148 /**
149 * Characters of JSON one project's saved folders and previewed file take
150 * at most, the file first, then the shallowest folders. Its pin and
151 * Markdown mode add a few dozen more. With `MAX_SAVED_PROJECTS`, 800,000 in
152 * all: a fifth of the store in ASCII names, under 2.5 MiB even at three
153 * bytes a character.
154 */
155 MAX_SAVED_CHARS: 16_000,
156} as const
157
158export default Limits
159hooks/levels.ts 157 lines1import { flattenTree, type DirListing, type PathSet, type TreeRow } from './tree'
2
3/**
4 * A folder's listing, or undefined while it is not read.
5 */
6export type ListingOf = (dir: string) => DirListing | undefined
7
8/**
9 * Reads the listings of folders not read yet, so `ListingOf` answers them.
10 */
11export type ReadDirs = (dirs: readonly string[]) => Promise<void>
12
13/**
14 * What a level step leaves open, and whether it stopped at the folder cap
15 * before it opened all it meant to.
16 */
17export type LevelStep = {
18 readonly expanded: string[]
19 readonly isCapped: boolean
20}
21
22type FolderRow = Extract<TreeRow, { type: 'entry' }>
23
24function folderRowsOf(listingOf: ListingOf, expanded: PathSet): FolderRow[] {
25 return flattenTree(listingOf, expanded).filter(
26 (row): row is FolderRow => row.type === 'entry' && row.kind === 'dir',
27 )
28}
29
30/**
31 * The folders directly in a folder, in tree order; none while it is unread.
32 */
33function subfoldersOf(listingOf: ListingOf, dir: string): string[] {
34 const listing = listingOf(dir)
35
36 return listing === undefined || 'error' in listing
37 ? []
38 : listing.entries.filter(entry => entry.kind === 'dir').map(entry => entry.path)
39}
40
41/**
42 * Reads the open folders in view that are not read yet (after a reload, the
43 * tree is open where it was but nothing is read), so a step sees their
44 * subfolders. Each pass can show more open folders; it stops when one reads
45 * nothing new.
46 */
47async function readOpenFolders(
48 listingOf: ListingOf,
49 expanded: PathSet,
50 readDirs: ReadDirs,
51): Promise<void> {
52 let unread: string[] = []
53
54 for (;;) {
55 const before = unread
56
57 unread = folderRowsOf(listingOf, expanded)
58 .filter(row => row.isExpanded && listingOf(row.path) === undefined)
59 .map(row => row.path)
60
61 const isStuck = unread.length > 0 && unread.join('\0') === before.join('\0')
62
63 if (unread.length === 0 || isStuck) {
64 return
65 }
66
67 await readDirs(unread)
68 }
69}
70
71/**
72 * One level deeper everywhere: every folder in view that is closed opens,
73 * in tree order, up to `cap` of them.
74 *
75 * The step works on the tree in view: a folder left open inside a closed one
76 * is not kept, so pressing `c` after `e` returns to where `e` began.
77 *
78 * @param listingOf the folders read so far
79 * @param expanded the folders open now
80 * @param readDirs reads the folders about to open
81 * @param cap the most folders the step opens
82 * @returns the folders left open
83 */
84export async function expandOneLevel(
85 listingOf: ListingOf,
86 expanded: PathSet,
87 readDirs: ReadDirs,
88 cap: number,
89): Promise<LevelStep> {
90 await readOpenFolders(listingOf, expanded, readDirs)
91
92 const folders = folderRowsOf(listingOf, expanded)
93 const open = folders.filter(row => row.isExpanded).map(row => row.path)
94 const closed = folders.filter(row => !row.isExpanded).map(row => row.path)
95 const opening = closed.slice(0, Math.max(0, cap))
96
97 await readDirs(opening.filter(dir => listingOf(dir) === undefined))
98
99 return { expanded: [...open, ...opening], isCapped: opening.length < closed.length }
100}
101
102/**
103 * One level shallower everywhere: every open folder in view that holds no
104 * open folder closes.
105 *
106 * @param listingOf the folders read so far
107 * @param expanded the folders open now
108 * @returns the folders left open
109 */
110export function collapseOneLevel(listingOf: ListingOf, expanded: PathSet): string[] {
111 const open = folderRowsOf(listingOf, expanded)
112 .filter(row => row.isExpanded)
113 .map(row => row.path)
114
115 const isParent = new Set(
116 open.filter(dir => open.some(other => other.startsWith(`${dir}/`))),
117 )
118
119 return open.filter(dir => isParent.has(dir))
120}
121
122/**
123 * The tree opened exactly `levels` deep: the root's folders open for 1, and
124 * theirs too for 2, and so on, up to `cap` folders in all; nothing deeper
125 * stays open.
126 *
127 * @param listingOf the folders read so far; the root must be read
128 * @param levels how many levels of folders open, 0 for none
129 * @param readDirs reads the folders about to open
130 * @param cap the most folders the step opens
131 * @returns the folders left open
132 */
133export async function expandToDepth(
134 listingOf: ListingOf,
135 levels: number,
136 readDirs: ReadDirs,
137 cap: number,
138): Promise<LevelStep> {
139 const expanded: string[] = []
140 let level = subfoldersOf(listingOf, '')
141
142 for (let depth = 0; depth < levels && level.length > 0; depth += 1) {
143 const opening = level.slice(0, Math.max(0, cap - expanded.length))
144
145 await readDirs(opening.filter(dir => listingOf(dir) === undefined))
146 expanded.push(...opening)
147
148 if (opening.length < level.length) {
149 return { expanded, isCapped: true }
150 }
151
152 level = opening.flatMap(dir => subfoldersOf(listingOf, dir))
153 }
154
155 return { expanded, isCapped: false }
156}
157hooks/listing.ts 381 lines1import type { FsStat } from 'claude-code'
2
3import { gitFileListOf, type FileList, type FoundEntry } from './filter'
4import { runGit } from './git'
5import type { Host } from './host'
6import Limits from './limits'
7import { depthOf, joinPath, keyOf, nativePathOf, rootOf } from './paths'
8import { isKnownBinary, noticeOf, previewOf, type Preview } from './preview'
9import { formatBytes, messageOf } from './text'
10import { compareEntries, type DirListing, type Entry } from './tree'
11
12/**
13 * The project's folders as read: the root they hang from, each folder read
14 * so far by its path, and the time each was modified when it was read.
15 */
16export type Listing = {
17 readonly root: string
18 readonly dirs: Map<string, DirListing>
19 /**
20 * Each read folder's modification time, taken just before it was listed,
21 * so a change made while it was listed shows at the next check.
22 */
23 readonly stamps: Map<string, number>
24}
25
26/**
27 * A previewed file as read, and its stamp: its modification time and size
28 * when it was read, null where it could not be stat'ed.
29 */
30export type PreviewRead = {
31 readonly preview: Preview
32 readonly stamp: string | null
33}
34
35/**
36 * The folder git keeps its repository in: never listed, as no explorer
37 * lists it. Everything else is, git-ignored entries included.
38 */
39const GIT_DIR_NAME = '.git'
40
41/**
42 * Starts a listing at the session's project root, nothing read yet.
43 *
44 * @param host the engine's calls
45 * @returns the listing
46 */
47export async function openListing(host: Host): Promise<Listing> {
48 return { root: await host.root(), dirs: new Map(), stamps: new Map() }
49}
50
51/**
52 * Reads one folder: its entries less `.git`, in tree order, up to the entry
53 * cap.
54 *
55 * @param host the engine's calls
56 * @param listing the listing the folder belongs to
57 * @param dir the folder, relative to the root
58 * @returns the folder's listing
59 */
60export async function readDir(
61 host: Host,
62 listing: Listing,
63 dir: string,
64): Promise<DirListing> {
65 try {
66 const found = await host.list(nativePathOf(listing.root, dir))
67
68 const entries: Entry[] = found
69 .filter(entry => entry.name !== GIT_DIR_NAME)
70 .map(entry => ({
71 name: entry.name,
72 path: joinPath(dir, entry.name),
73 kind: entry.kind,
74 size: entry.size,
75 }))
76 .sort(compareEntries)
77
78 return {
79 entries: entries.slice(0, Limits.MAX_DIR_ENTRIES),
80 truncated: Math.max(0, entries.length - Limits.MAX_DIR_ENTRIES),
81 }
82 } catch (error) {
83 return { error: messageOf(error) }
84 }
85}
86
87/**
88 * Reads folders into a listing, `READ_CONCURRENCY` at a time.
89 *
90 * @param host the engine's calls
91 * @param listing the listing to fill
92 * @param dirs the folders, relative to the root
93 */
94export async function readDirs(
95 host: Host,
96 listing: Listing,
97 dirs: readonly string[],
98): Promise<void> {
99 for (let at = 0; at < dirs.length; at += Limits.READ_CONCURRENCY) {
100 const batch = dirs.slice(at, at + Limits.READ_CONCURRENCY)
101
102 const read = await Promise.all(
103 batch.map(async dir => {
104 const stamp = await dirStampOf(host, listing, dir)
105
106 return { stamp, found: await readDir(host, listing, dir) }
107 }),
108 )
109
110 batch.forEach((dir, index) => {
111 const done = read[index]
112
113 if (done === undefined) {
114 return
115 }
116
117 listing.dirs.set(dir, done.found)
118
119 if (done.stamp === null) {
120 listing.stamps.delete(dir)
121 } else {
122 listing.stamps.set(dir, done.stamp)
123 }
124 })
125 }
126}
127
128/**
129 * A folder's modification time, or null where it cannot be stat'ed. A
130 * listing carries no folder's time (`FsEntry.mtimeMs` is 0 for folders), so
131 * each folder takes a stat of its own.
132 */
133async function dirStampOf(host: Host, listing: Listing, dir: string): Promise<number | null> {
134 return host.stat(nativePathOf(listing.root, dir)).then(
135 stat => stat.mtimeMs,
136 () => null,
137 )
138}
139
140/**
141 * Folders' modification times now, `READ_CONCURRENCY` at a time.
142 *
143 * @param host the engine's calls
144 * @param listing the listing the folders belong to
145 * @param dirs the folders, relative to the root
146 * @returns each folder's time, null where it cannot be stat'ed
147 */
148export async function stampDirs(
149 host: Host,
150 listing: Listing,
151 dirs: readonly string[],
152): Promise<Map<string, number | null>> {
153 const stamps = new Map<string, number | null>()
154
155 for (let at = 0; at < dirs.length; at += Limits.READ_CONCURRENCY) {
156 const batch = dirs.slice(at, at + Limits.READ_CONCURRENCY)
157 const read = await Promise.all(batch.map(dir => dirStampOf(host, listing, dir)))
158
159 batch.forEach((dir, index) => stamps.set(dir, read[index] ?? null))
160 }
161
162 return stamps
163}
164
165const stampOfStat = (stat: FsStat) => `${stat.mtimeMs}:${stat.size}`
166
167/**
168 * A file's stamp now: its modification time and size as one key.
169 *
170 * @param host the engine's calls
171 * @param root the project root
172 * @param path the file, relative to the root
173 * @returns the stamp, or null where the file cannot be stat'ed
174 */
175export async function fileStampOf(host: Host, root: string, path: string): Promise<string | null> {
176 return host.stat(nativePathOf(root, path)).then(stampOfStat, () => null)
177}
178
179/**
180 * The key of a path from outside the tree that its spelling does not place
181 * under the root (`keyOf`): where it lands, every link followed, under where
182 * the root lands, as `/tmp/p/x` lies under the root `/private/tmp/p`.
183 *
184 * @param host the engine's calls
185 * @param root the project root
186 * @param path the path, native
187 * @returns its key, or null where either does not resolve or it lands
188 * outside the root
189 */
190export async function realKeyOf(host: Host, root: string, path: string): Promise<string | null> {
191 const [realRoot, realPath] = await Promise.all([
192 host.realPath(rootOf(root)).catch(() => undefined),
193 host.realPath(path).catch(() => undefined),
194 ])
195
196 return realRoot === undefined || realPath === undefined ? null : keyOf(realRoot, realPath)
197}
198
199/**
200 * Reads the root and every open folder still in the tree, level by level, so
201 * a folder that was removed or is now ignored is not read.
202 *
203 * @param host the engine's calls
204 * @param listing the listing to fill
205 * @param expanded the open folders
206 */
207export async function readTree(
208 host: Host,
209 listing: Listing,
210 expanded: readonly string[],
211): Promise<void> {
212 await readDirs(host, listing, [''])
213
214 const byDepth = new Map<number, string[]>()
215
216 for (const dir of expanded) {
217 const depth = depthOf(dir)
218
219 byDepth.set(depth, [...(byDepth.get(depth) ?? []), dir])
220 }
221
222 const depths = [...byDepth.keys()].sort((a, b) => a - b)
223
224 for (const depth of depths) {
225 const reachable = (byDepth.get(depth) ?? []).filter(dir => isListedDir(listing, dir))
226
227 await readDirs(host, listing, reachable)
228 }
229}
230
231/**
232 * Whether a folder appears in its parent's listing as a folder.
233 */
234function isListedDir(listing: Listing, dir: string): boolean {
235 const cut = dir.lastIndexOf('/')
236 const parent = listing.dirs.get(cut < 0 ? '' : dir.slice(0, cut))
237
238 return (
239 parent !== undefined &&
240 'entries' in parent &&
241 parent.entries.some(entry => entry.path === dir && entry.kind === 'dir')
242 )
243}
244
245/**
246 * Reads a file for the preview: binary files by extension, files past the
247 * size cap and anything but a regular file get a notice instead.
248 *
249 * @param host the engine's calls
250 * @param root the project root
251 * @param path the file, relative to the root
252 * @returns its preview, and its stamp from the stat taken before reading
253 */
254export async function readPreview(
255 host: Host,
256 root: string,
257 path: string,
258): Promise<PreviewRead> {
259 const absolute = nativePathOf(root, path)
260 let stamp: string | null = null
261
262 try {
263 const stat = await host.stat(absolute)
264
265 stamp = stampOfStat(stat)
266
267 if (stat.kind !== 'file') {
268 return { preview: noticeOf(path, stat.size, 'Not a regular file'), stamp }
269 }
270
271 if (isKnownBinary(path)) {
272 return { preview: noticeOf(path, stat.size, `Binary file · ${formatBytes(stat.size)}`), stamp }
273 }
274
275 if (stat.size > Limits.MAX_PREVIEW_BYTES) {
276 return {
277 preview: noticeOf(path, stat.size, `Too large to preview · ${formatBytes(stat.size)}`),
278 stamp,
279 }
280 }
281
282 return { preview: previewOf(path, stat.size, await host.read(absolute)), stamp }
283 } catch (error) {
284 return { preview: noticeOf(path, 0, `Can't read this file: ${messageOf(error)}`), stamp }
285 }
286}
287
288/**
289 * Reads the project's files and folders for the filter: from git in a work
290 * tree, which knows them all at once and what it ignores; elsewhere, or
291 * where git cannot answer, from the folders themselves, within bounds.
292 *
293 * @param host the engine's calls
294 * @param listing the listing the walk reads folders into
295 * @returns the list
296 */
297export async function readFileList(host: Host, listing: Listing): Promise<FileList> {
298 return (await gitFileList(host, listing.root)) ?? walkFileList(host, listing)
299}
300
301/**
302 * The file list git gives for the root: its tracked files, the untracked
303 * ones it does not ignore, and what it ignores, an ignored folder as itself
304 * alone. Paths are relative to the root, as git lists from its working
305 * directory, and `/`-separated on every platform.
306 *
307 * @returns the list, or null where git is missing, the root is no work
308 * tree, or git lists nothing (a root inside an ignored folder)
309 */
310async function gitFileList(host: Host, root: string): Promise<FileList | null> {
311 const git = (args: readonly string[]) => runGit(host, root, args)
312
313 const [listed, deleted, ignored] = await Promise.all([
314 git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']),
315 git(['ls-files', '-z', '--deleted']),
316 git(['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']),
317 ])
318
319 if (listed === null) {
320 return null
321 }
322
323 const list = gitFileListOf({
324 listed: listed.stdout,
325 deleted: deleted?.stdout ?? null,
326 ignored: ignored?.stdout ?? null,
327 isTruncated: listed.isStdoutTruncated || ignored?.isStdoutTruncated === true,
328 })
329
330 return list.entries.length === 0 ? null : list
331}
332
333/**
334 * The file list a walk of the folders gives, shallowest first, `.git` left
335 * out, up to `MAX_WALK_FOLDERS` folders and `MAX_WALK_ENTRIES` entries.
336 * Every folder is read anew, as one the tree read before may have changed
337 * since, into the listing, so the filtered tree draws from them at once.
338 */
339async function walkFileList(host: Host, listing: Listing): Promise<FileList> {
340 const entries: FoundEntry[] = []
341 let level = ['']
342 let folders = 0
343 let isPartial = false
344
345 while (level.length > 0) {
346 const batch = level.slice(0, Math.max(0, Limits.MAX_WALK_FOLDERS - folders))
347
348 isPartial ||= batch.length < level.length
349 folders += batch.length
350 await readDirs(host, listing, batch)
351
352 const next: string[] = []
353
354 for (const dir of batch) {
355 const read = listing.dirs.get(dir)
356
357 if (read === undefined || 'error' in read) {
358 continue
359 }
360
361 isPartial ||= read.truncated > 0
362
363 for (const entry of read.entries) {
364 if (entries.length >= Limits.MAX_WALK_ENTRIES) {
365 return { entries, isPartial: true }
366 }
367
368 entries.push({ path: entry.path, kind: entry.kind === 'dir' ? 'dir' : 'file' })
369
370 if (entry.kind === 'dir') {
371 next.push(entry.path)
372 }
373 }
374 }
375
376 level = next
377 }
378
379 return { entries, isPartial }
380}
381hooks/mention.ts 183 lines1import type { PromptBox } from 'claude-code'
2
3import { ROW_KEY_PREFIX } from './names'
4import { keyOf, nameOf, nativePathOf, relativePathOf, styleOf } from './paths'
5import { sanitize, truncateMiddle } from './text'
6import type { TreeRow } from './tree'
7
8/**
9 * Mentioning an entry of the tree to Claude: `a` puts `@<path>` at the
10 * prompt's cursor, as Claude Code's own `@` completion would, and Claude
11 * Code reads the file (or lists the folder) when the prompt is sent.
12 *
13 * How Claude Code reads a mention (checked in 2.1.294's source and live):
14 * - It resolves the path against the session's working folder, which a
15 * shell `cd` moves away from the project root.
16 * - `@path` runs to the next whitespace, and drops what trails its last
17 * ASCII letter, digit or `_` (`@a.ts,` names `a.ts`; `@ファイル` nothing).
18 * - `@"path"` keeps everything between its quotes; no `"` inside.
19 * - A `#` ends the path in either form: what follows is a line range.
20 * - `~` at the start is the home folder.
21 */
22
23/**
24 * The entry a mention names: a key of the tree, and whether it is a folder.
25 */
26export type MentionTarget = {
27 readonly key: string
28 readonly isDir: boolean
29}
30
31/**
32 * What `a` mentions: the tree row the focus ring is on, else the previewed
33 * file.
34 *
35 * @param focused the key of the element the ring is on, if any
36 * @param rows the tree's rows as last drawn; null while the help shows
37 * @param selected the previewed file's key
38 * @returns the target, or null when there is none
39 */
40export function mentionTargetOf(
41 focused: string | undefined,
42 rows: readonly TreeRow[] | null,
43 selected: string | null,
44): MentionTarget | null {
45 if (focused?.startsWith(ROW_KEY_PREFIX) === true && rows !== null) {
46 const path = focused.slice(ROW_KEY_PREFIX.length)
47 const row = rows.find(drawn => drawn.type === 'entry' && drawn.path === path)
48
49 if (row?.type === 'entry') {
50 return { key: row.path, isDir: row.kind === 'dir' }
51 }
52 }
53
54 return selected === null ? null : { key: selected, isDir: false }
55}
56
57/**
58 * The path a mention spells for an entry: relative to the session's working
59 * folder while that lies in the project (with `..` when it is a subfolder
60 * the entry is not in), else absolute. A folder ends in a separator, as
61 * Claude Code's completion writes one.
62 *
63 * @param root the session's project root, native
64 * @param cwd the session's working folder, native
65 * @param target the entry
66 * @returns the path
67 */
68export function mentionPathOf(root: string, cwd: string, target: MentionTarget): string {
69 const style = styleOf(root)
70 const cwdKey = keyOf(root, cwd)
71
72 if (cwdKey === null) {
73 const absolute = nativePathOf(root, target.key)
74
75 return target.isDir ? `${absolute}${style === 'win32' ? '\\' : '/'}` : absolute
76 }
77
78 const relative = relativePathOf(cwdKey, target.key, style)
79
80 // A name starting with `~` would read as the home folder, and one starting
81 // with `"` as a quoted mention
82 const path = /^[~"]/.test(relative) ? `./${relative}` : relative
83
84 return target.isDir ? `${path}/` : path
85}
86
87/**
88 * Why a path cannot be written as a mention that names it.
89 */
90export type MentionProblem = 'control' | 'hash' | 'quote'
91
92/**
93 * A path as a mention: `@path`, or `@"path"` when it holds whitespace, or
94 * would lose its last characters bare (a folder's separator aside, which
95 * names the folder either way).
96 *
97 * @param path the path, as `mentionPathOf` spells it
98 * @param isDir whether it names a folder, its last character a separator
99 * @returns the mention, or why there is none
100 */
101export function mentionOf(
102 path: string,
103 isDir: boolean,
104): { readonly text: string } | { readonly problem: MentionProblem } {
105 if (/[\u0000-\u001f\u007f-\u009f]/.test(path)) {
106 return { problem: 'control' }
107 }
108
109 if (path.includes('#')) {
110 return { problem: 'hash' }
111 }
112
113 const named = isDir ? path.slice(0, -1) : path
114 const isQuoted = /\s/.test(path) || !/[0-9A-Za-z_]$/.test(named)
115
116 if (!isQuoted) {
117 return { text: `@${path}` }
118 }
119
120 return path.includes('"') ? { problem: 'quote' } : { text: `@"${path}"` }
121}
122
123/**
124 * The text that goes in at the prompt's cursor: the mention, set off by a
125 * space from the words on either side, as Claude Code reads a mention only
126 * after whitespace or at the start.
127 *
128 * @param box the prompt box as it stands
129 * @param mention the mention
130 * @returns the text to insert
131 */
132export function insertionOf(box: PromptBox, mention: string): string {
133 const before = box.text.slice(0, box.cursor)
134 const after = box.text.slice(box.cursor)
135 const lead = before === '' || /\s$/.test(before) ? '' : ' '
136 const trail = /^\s/.test(after) ? '' : ' '
137
138 return `${lead}${mention}${trail}`
139}
140
141/**
142 * The cells of an entry's name a toast quotes.
143 */
144const NAME_CELLS = 40
145
146/**
147 * Why `a` mentioned nothing: no target, a path no mention can name, the
148 * prompt box refusing the text (under a dialog, without a box, or a hook's
149 * own refusal), or a call that failed.
150 */
151export type MentionFailure = 'none' | MentionProblem | 'dialog' | 'no_composer' | 'refused' | 'failed'
152
153/**
154 * What a toast says when `a` mentions nothing.
155 *
156 * @param reason why
157 * @param key the entry's key, when there is one
158 * @param detail the failed call's message, sanitized
159 * @returns the toast's text
160 */
161export function mentionToastOf(reason: MentionFailure, key?: string, detail?: string): string {
162 const name = key === undefined ? 'it' : truncateMiddle(sanitize(nameOf(key)), NAME_CELLS)
163
164 switch (reason) {
165 case 'none':
166 return 'Focus a row or preview a file to mention it'
167 case 'control':
168 return `Cannot mention ${name}: its path holds a control character`
169 case 'hash':
170 return `Cannot mention ${name}: Claude Code reads a # in a mention as a line range`
171 case 'quote':
172 return `Cannot mention ${name}: its path holds both a space and a "`
173 case 'dialog':
174 return 'A dialog holds the prompt: close it, then press a again'
175 case 'no_composer':
176 return 'This session has no prompt box to mention a file in'
177 case 'refused':
178 return `The prompt box did not take the mention of ${name}`
179 case 'failed':
180 return `Could not mention ${name}: ${detail ?? 'unknown error'}`
181 }
182}
183hooks/names.ts 92 lines1/**
2 * The pane's tab label while other panes are open beside it.
3 */
4export const PANE_TITLE = 'Explorer'
5
6export const COMMAND_DESCRIPTION = 'Toggle the file explorer pane'
7
8/**
9 * The line `/tree` leaves the first time it opens a pane under Claude
10 * Code's classic renderer, once ever: Claude Code offers the switch itself,
11 * and many people chose the classic renderer on purpose.
12 */
13export const FULLSCREEN_TIP_TEXT =
14 'Tip: /tui fullscreen docks the tree beside the conversation and adds mouse support.'
15
16/**
17 * The line `/tree` leaves, once a session, when a fullscreen terminal is too
18 * narrow to dock the pane.
19 */
20export const WIDEN_TIP_TEXT = 'Widen the terminal to 110 columns to dock the tree beside the conversation.'
21
22/**
23 * The store key that says the fullscreen tip was shown.
24 */
25export const TIP_SHOWN_KEY = 'fullscreenTipShown'
26
27/**
28 * The prefix of the store keys that keep a project's view of the pane; the
29 * rest of each key is the project root's identity (`rootIdOf`). It keeps
30 * the spelling it had when the open folders were all a view saved, so the
31 * folders saved then open still.
32 */
33export const SAVED_KEY_PREFIX = 'expanded:'
34
35/**
36 * The key prefix of a tree row's Button; the rest of the key is the row's
37 * path, so a focus event names the row it lands on.
38 */
39export const ROW_KEY_PREFIX = 'row:'
40
41/**
42 * The keys of the pane's own controls.
43 */
44export const KEYS = {
45 refresh: 'refresh',
46 expandLevel: 'expand-level',
47 collapseLevel: 'collapse-level',
48 filter: 'filter',
49 help: 'help',
50 mention: 'mention',
51 previewUp: 'preview-up',
52 previewDown: 'preview-down',
53 previewMode: 'preview-mode',
54 previewPin: 'preview-pin',
55 previewClose: 'preview-close',
56 search: 'search',
57 searchBack: 'search-back',
58 searchNext: 'search-next',
59} as const
60
61/**
62 * The key of the filter's field, drawn anew under the next key each time
63 * Enter is pressed in it: Claude Code empties a field on Enter and only
64 * hands a field the `value` drawn when it differs from the last one, so a
65 * new field is how the query stays in it.
66 *
67 * @param submits how many times Enter was pressed in the field
68 * @returns the key
69 */
70export function filterKeyOf(submits: number): string {
71 return `filter-field-${submits}`
72}
73
74/**
75 * The key of the in-file search's field, drawn anew under the next key each
76 * time Enter is pressed in it, as the filter's is (`filterKeyOf`).
77 *
78 * @param submits how many times Enter was pressed in the field
79 * @returns the key
80 */
81export function searchKeyOf(submits: number): string {
82 return `search-field-${submits}`
83}
84
85/**
86 * The key of the hidden Button whose digit hotkey opens the tree `levels`
87 * deep; `depth-0` closes every folder.
88 */
89export function depthKeyOf(levels: number): string {
90 return `depth-${levels}`
91}
92