SLOPSHOPPER

File Explorer

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…

newpaneguardcommandtoastprocess
v0.1.2MITupdated 2026-10-08floheissler/cc-file-explorer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-explorer
│ ┃ Explorer ✕ › fix the failing auth test and add an audit log call │ ┃ app e c r f a h: help │ ┃ ├─ package.json ⏺ Read(src/auth.ts) │ ┃ ├─ README.md ⎿ Read 6 lines │ ┃ └─ src ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /tree │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Explorer
app e c r f a h: help ├─ package.json ├─ README.md └─ src
README

file-explorer

CI

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.

/tree docked beside a Claude Code conversation: the project tree with git markers, a file preview that follows the focus, and the filter finding a CSV file shown as a table

  • Browse the project as a tree you expand and collapse in place, with the mouse or the keys.
  • Preview the file you pick: Markdown rendered as Claude's replies are, CSV and TSV as tables, source with syntax colors and line numbers. Once open, the preview follows the focus from file to file, or stays on one file you pin (p).
  • See git's status beside every name, and the files Claude wrote this session.
  • Filter the tree by name (f), or jump to any path with /tree <path>.
  • Search the previewed file as you type (g), and step from match to match with Enter, n and b.
  • Mention the focused file or folder to Claude at the prompt's cursor (a).
  • Stay current: Claude's edits and changes made outside Claude show within seconds.
  • Pick up where you left off: each project's open folders and preview are remembered.

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.

Requirements

  • Claude Code v2.1.287 or later in a terminal, or the Desktop app's Code tab from v2.1.286: mods are on by default from those versions. Tested with Claude Code 2.1.294. Mods are a young part of Claude Code and their API can change between releases, so after a Claude Code update, update this mod too.
  • Fullscreen rendering is optional. With it (/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.
  • The pane draws nowhere else: not in the VS Code extension's chat panel, a WSL session in the Desktop app, or claude -p.

Install

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

Use

Run /tree to open the pane, and again to close it. Where it opens depends on how Claude Code draws:

  • Docked beside the conversation, under fullscreen rendering (/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.
  • Above the prompt, under Claude Code's classic renderer, or under fullscreen on a narrower terminal. The pane is as tall as its content, up to 40 rows, and shows one view at a time: the tree, the picked file, or the help.
  • Enter (or a click) shows a file in the tree's place.
  • 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.
  • The classic renderer has no mouse, so everything works from the keys.
  • Ctrl+X ↑/↓ resizes the pane; Claude Code keeps that size for every pane above the prompt.

Show a file or folder

/tree <path> opens the pane onto one entry, or shows it in a pane already open (it never closes the pane):

  • A filter in the pane closes first: the entry shows in the whole tree.
  • The folders above it open, and a folder itself opens too.
  • A file is picked and previewed; above the prompt, the pane opens straight into the file, and Esc steps back to the tree, onto the file's row.
  • The tree scrolls to its row, and the focus starts on it.

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 thisTo
Click a folder, or focus it and press EnterExpand or collapse it
Click a file, or focus it and press EnterPreview it under the tree (docked), or in the tree's place (above the prompt)
Move the focus onto a file, docked, with the preview openPreview that file; a pinned preview stays where it is
pPin the preview to its file, or let it follow the focus again (docked)
eOpen every folder in view one level deeper; repeat for more
cClose the deepest open folders, one level; repeat for more
1 – 9Open folders exactly that many levels deep
0Close every folder
rRe-read the tree, the previewed file and git's status at once
fFilter the tree by name: shows the filter's field over the tree, the keyboard in it; again to close the filter
gSearch 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 nextGo to the next matching line, or back to the one before, while the search has matches
aMention the focused row to Claude at the prompt's cursor (@src/, @README.md), or the previewed file when no row has the focus
hShow or hide the help view
x, or click a pinned preview's file again (docked)Close the preview
mSwitch 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 DownScroll the preview
Tab, Up, DownWalk 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 arrowResize the pane
EscDocked, 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.

Follow or pin the preview

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.

  • Enter or a click on the file the preview shows keeps it; 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.
  • Above the prompt the file and the tree never show together, so nothing follows there: Enter shows a file in the tree's place.

Mention a file to Claude

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.

  • The mention is set off by a space from the words around it. A folder ends in /, and a path with a space (or one a bare mention would cut short, such as notes.txt~) is quoted: @"My Notes/plan.md".
  • The path is spelled from the session's working folder, as Claude Code resolves it: @../README.md after a shell cd src, and absolute once the session has left the project.
  • The prompt takes the keyboard with the text, so you can type on. Ctrl+X then Tab returns to the pane with the focus on the row you mentioned, to walk on to the next.
  • A name with a # 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.

What the tree shows

  • Branch lines as the tree command draws them (├─, └─ for the last entry of a folder, │ while a folder continues), dim beside the names.
  • The session's project root in Windows File Explorer's order: every folder before any file, and within each, dot names first and the rest sorted as people sort them (file2 before file10).
  • Every entry but .git, git-ignored ones (node_modules/, build output) included, as other file explorers show them.
  • Up to 2,000 entries per folder; a note counts the rest.
  • The folders you left open in this project. Every change to the open folders is saved for the project, with the preview, and a session's first /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.
  • Names too long for the pane cut in the middle, measured in terminal columns as Claude Code measures them: CJK and most emoji take two, accents none, and a cut never splits a character.
  • Markers at the right end of a row, in a column of their own (h lists them; the colors follow your Claude Code theme):
MarkerMeans
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:

  • Shortly after Claude edits a file or runs a shell command that may write (not one the engine holds read-only, such as ls or git status), the open folders and the previewed file are read again.
  • While the pane is shown, it checks the open folders and the previewed file every 2 seconds, so changes from an editor, 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.
  • A previewed file that is deleted shows a notice in place of its text, and comes back if the file does.
  • Git's status is read when the pane opens, on 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).

Filter by name

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.

  • Words match parts of names in any case (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).
  • Enter takes the focus to the first match; with nothing typed, it closes the filter. ↓ moves from the field into the tree, ↑ from the tree's top back to it.
  • Folders open and close in the filtered tree as in the whole tree, and 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.
  • Above the prompt under the classic renderer, Esc in the field closes the filter first; elsewhere Esc gives the keyboard back to the prompt, as in the rest of the pane.

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.

Search the previewed file

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).

  • An all-lowercase query matches any case (needle finds Needle); one with a capital letter matches case exactly. The query matches as typed, spaces included; there are no patterns.
  • Enter keeps the match and moves the focus to the row's 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.
  • A match in view leaves the preview where it is; one out of view scrolls in two lines below the top.
  • A bar left of the source marks the matching lines: in the theme's warning color the match you are on, dim the others, on every row a wrapped line takes. A Markdown file shows its source while the search is open, so the bars can mark it, and its rendered form again once the search closes. A table bolds the matching cells of the row you are on.
  • The search stays open as the preview follows the focus to another file: it counts that file's matches, and n starts from its top. g again, or closing the preview, closes the search.
  • Above the prompt under the classic renderer, Esc in the field closes the search first; elsewhere Esc gives the keyboard back to the prompt.

What the preview shows

FilePreview
.md, .markdown, .mdxRendered Markdown, or its source with m
.csv, .tsvA table under its header row; long cells are cut at 32 columns, or shorter so the table fits the pane's width
Any other textSource 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 MBA 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.

Privacy

file-explorer runs inside Claude Code on your machine, with your permissions, as every mod does. It only reads:

  • Files and folders, through Claude Code, to draw the tree and the preview. What the pane shows stays in the pane: nothing reaches Claude unless you send a prompt with a mention that a put there.
  • The prompt's draft, only when you press a, to set the mention off from the words around the cursor.
  • Claude's 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.

Platforms

PlatformStatus
Linux, WSL2Tested with Claude Code 2.1.294
WindowsSmoke-tested on native Windows (2026-10-08). Drive roots (C:\), shares (\\server\share) and WSL's share (\\wsl.localhost\…) are covered by tests
macOSNot 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

Known limits

  • It browses and previews; it does not create, rename, move or delete files. Ask Claude, or use your editor.
  • Binary files, images and PDFs among them, show a notice instead of a preview, as do files over 2 MB. A folder shows up to 2,000 entries, and a note counts the rest.
  • The in-file search steps line by line and marks lines, not the words in them: Claude Code colors the source itself, and a mod cannot highlight inside it. It searches the first 1,000 characters of each line, as the preview holds them.
  • A network share lists only when Claude Code started on that host, and \\?\ paths are refused; both are the engine's rules for $.fs.
  • Symbolic links show as plain rows: a linked folder does not open and a linked file does not preview. Windows junctions are expected to behave the same.
  • Windows cannot read names that end in a dot or a space.
  • Git markers need 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.
  • A file deleted from disk has no row to mark; its folder still shows the change's dot.
  • Under Windows git's paths match the tree's in any case; under macOS and Linux they match in case, and composed and decomposed accents match.

How it works

hooks/register.tsx is the hooks module; the rest of hooks/ is its parts.

EventWhat the hook does
session.startRegisters /tree [path]; after a reload of the module, starts the checks again for a pane still open
command.run of treeOpens 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 PaneDraws 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 paneMoves 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 paneKeeps 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

Source 25 files
hooks/register.tsx 2473 lines
1import { 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 lines
1import 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}
343
hooks/focus.ts 159 lines
1import 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}
159
hooks/follow.ts 77 lines
1import { 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}
77
hooks/git.ts 166 lines
1import 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}
166
hooks/host.ts 119 lines
1import 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}
119
hooks/layout.ts 205 lines
1import 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}
205
hooks/limits.ts 159 lines
1/**
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
159
hooks/levels.ts 157 lines
1import { 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}
157
hooks/listing.ts 381 lines
1import 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}
381
hooks/mention.ts 183 lines
1import 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}
183
hooks/names.ts 92 lines
1/**
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