A file manager pane for Claude Code: colored tree, editable code, Git and PR diff, search, new/rename/delete.

A Claude Code mod: a file manager in a side pane, modeled on an editor's Explorer. It lists a folder in color, opens a file as editable code, compares it with a Git baseline, searches, and creates, renames, and deletes files. Everything is in the mod's own pane. Nothing is drawn in the conversation, and the mod makes no model calls.
Written in TypeScript against Claude Code 2.1.288. The mods API is early access and can change between releases.
Made by Avid (@Av1dlive).
This repository is its own plugin marketplace, named av1dlive.
claude plugin marketplace add codejunkie99/claude-code-file-explorer
claude plugin install file-explorer@av1dlive --scope user
Start a new session, or run /reload-plugins in an open one.
To get a later version: claude plugin marketplace update av1dlive && claude plugin update file-explorer@av1dlive.
The mod opens its pane by itself at the start of every session. On a terminal under 144 columns the host does not draw a pane that nobody asked for; a notice then tells you to run /files once.
EXPLORER ‹ Back Forward › ↑ Up
╭──────────────────────────────────────────────────────╮
│ Find files │ search field: its target follows the mode
╰──────────────────────────────────────────────────────╯
[ Files ] [ Code ] [ Diff ] [ 💾 ] the three modes (keys 1, 2, 3); Save, in Code
REPO ⎇ main the folder and its branch
/Users/you/Projects/repo
New File New Folder Refresh Collapse More
▾ ■ src • folder icon: the color of its main language
│ TS index.ts M names take the Git color
MD README.md
‹ Back and Forward › walk the places you visited: folders, files, modes. With no history, Back leaves a file for the tree.↑ Up makes the parent folder the root.Every action has a control for the pointer and a way to do it with keys.
| Action | Mouse | Keyboard |
|---|---|---|
| Give the pane the keyboard | Click anywhere in the pane | /files, or ctrl+x tab |
| Move in the tree | Click a row; wheel; [ ↑ ] [ ↓ ] [ ← ] [ → ] under the tree | k j h l. After a click on the tree: the arrow keys, PgUp, PgDn, Home, End |
| Open a file, open or close a folder | Click the row, or [ ⏎ ] under the tree | o. After a click on the tree: Enter, Right, Left |
| Actions for one row | Right-click the row: it is selected and More opens | Move to the row, then Tab to More |
| Any button, tab, or icon | Click | Tab or the arrow keys move the focus ring, Enter presses. Tabs: 1 2 3 (not while the editor is shown) |
| Search | Click the field and type: the result follows each letter. [ ⌫ ] clears | Tab to the field and type. Erase the text, or an empty Enter, to clear (Files). Enter goes to the next match (Code, Diff) |
| A name (new file, rename) | Type, then [ ✓ ]. [ ✕ ] cancels | Type, Enter. An empty Enter cancels |
| Next and previous match | [ ⌄ ] [ ⌃ ] | Tab to them, Enter. In Diff: f b |
| Previous and next file | [ ‹ ] [ › ] | Tab to them, Enter. In Diff: p n |
| Type in the editor | Click the text | Not possible without one click: the host gives the editor keys only after a click |
| Scroll | Wheel | PgUp, PgDn in the editor and the tree after a click |
| Leave the pane | Click the conversation | Esc, or Left |
Two host rules shape this:
While the editor is shown, no control has a letter or digit key. Text typed before the editor has the keyboard then does nothing, and cannot press a button by accident.
| Action | Effect |
|---|---|
| Click a folder | Expands or collapses it |
| Click a file | Opens it in Code mode |
| Arrow keys, after a click in the tree | Up/Down move. Right opens a folder, then steps in. Left closes it, then goes to its parent. Enter opens. PgUp, PgDn, Home, End |
| Wheel | Scrolls |
| Search field | Find files: fzf matches names and paths, and the tree filters while you type. Letters match in their order (mnpy finds api/main.py). Words match in any order (api py). 'word is an exact part, !word excludes. Lower case matches any case. Matches keep their parent folders. An empty field, or an empty Enter, clears it |
| New File / New Folder | Asks for a name. It goes in the selected folder, or beside the selected file |
| Refresh / Collapse | Reads the folder again / closes every folder |
| More | The second row of actions |
More:
| Action | Effect |
|---|---|
| Rename | A field with the current name. An existing name is refused |
| Delete | Asks once, then moves the entry to the Trash (~/.Trash). Nothing is removed for good |
| Set as root | The selected folder becomes the root |
| Open folder… | A field for a path: absolute, ~/…, or relative to the root |
| Home | Back to the session's directory |
| Text search | The search field searches text in files (literal, smart case, rg). Fields for files to include and exclude appear. hidden and ignored widen the search. A result opens the file at that line |
| .files / ignored / untracked | Hide or show those entries |
"Selected" is the row with the bar: the last row you clicked or moved to.
Colors:
■ has the color of the language most of its files are in (its own files and the files one level down): TypeScript blue, JavaScript yellow, Python blue-green, Rust tan, Go cyan, Swift orange, and so on. A folder with no code takes a color from its name (.git, docs, tests, assets, scripts, build output) or the default tan. The first 80 folders of a listing are read for this; the rest take the name color.TS, PY, RS, GO, MD, {}).M, added and untracked green A ?, deleted red D, conflict red U, renamed blue R, ignored dim !, a folder with changes •.│ per level shows the nesting. The selected row and the row under the pointer are a bar across the pane.⟲ loop and does not expand. A folder that cannot be read shows the reason on its row.A file opens as an editor, with syntax colors on every line. Click the text, then type.
The toolbar has icons only:
| Icon | Action |
|---|---|
[ 💾 ] at the top right, under the search field | Save. It takes the accent color while there is text to save. ctrl+s in the text saves too |
[ ‹ ] [ › ] | The previous or next file, in the tree's order. At the end of a folder the step goes on into the next folder, however deep, and opens its first file. Folders that are not expanded are read on the way. Git's own folder and Git-ignored folders are skipped |
[ ↶ ] | Undo: takes back one change, up to 300 |
[ ✕ ] (only while there is unsaved text) | Drops every unsaved change |
● after the file name marks text that is not saved. The mod does not leave such a file: it asks for Save, Undo, or ✕ first./pattern/ is a regular expression, /pattern/i one in any case. Enter goes to the next match. :120 and Enter goes to line 120.A file over 60,000 characters, a partly loaded file (over 256 KB), or a file with control characters is shown read-only, with the reason. Load more reads the next part of a large file.
The current side is always the file in the working tree: the net result of staged and unstaged edits.
Changed files (N) lists modified, added, deleted, renamed, and untracked files. Deleted files are selectable there.vs HEAD · main 1881bbc3 shows the pinned baseline. Select it to choose another:| Compare against | Baseline commit |
|---|---|
| Latest commit (default) | HEAD |
| Previous commit | The first parent of HEAD. Refused with a reason when there is none |
| Pull request head | The head commit of the pull request: what changed since the version pushed to the PR |
| Pull request base | git merge-base <PR base> <PR head>: the PR's changes and your local changes beyond it |
Read this baseline again.gh pr list --head <branch>. One match is selected. With several or none, you choose from the list in the chooser.git fetch --no-tags --no-write-fetch-head <remote> <sha>: objects only, no ref, no checkout, no change to working files.gh is missing, not authenticated, or offline, the reason is shown and the pinned baseline stays.[ ‹ ] [ › ] go to the previous or next changed file. ‹ Hunk, Hunk › move between hunks. Context changes the context: 3, 10, 30, all.● Side by side (on at the start) shows the baseline on the left and the working tree on the right. A removed line has a red bar, an added line has a green bar. A removed line and the added line that replaces it are on one row. Select it again for the unified view.1 def add(a, b): 1 def add(a, b):
2 - return a + b 2 + return int(a) + int(b)
3 3
4 +def sub(a, b):
The red and the green are fixed colors with light text, the same on every theme. A file that the baseline does not have (a new file) has an empty left side.
The root is the session's directory until you choose another folder (Set as root, Open folder…, ↑ Up, or the command). The choice is remembered for that session directory, across sessions.
| Command | Effect |
|---|---|
/files | Gives the pane the keyboard |
/files ~/Projects/my-app | That folder becomes the root |
/files src/app.ts:120 | Opens that file at line 120 |
/files home | Back to the session's directory |
/files close | Closes the pane until the next session or /files |
Git status, diff, and pull requests use the repository that contains the root.
In Code and Diff mode the mod asks the host for a pane as wide as the longest line, up to 60% of the terminal; Files asks for 46 columns. The host treats this as a request. A width that you set by dragging the pane's edge wins, and the host keeps it (pluginPanes.dockColumns in ~/.claude.json). At any width, a line that does not fit wraps.
$ has no file watch API. While the pane is open the mod compares modification times every 3 seconds: the open file (not while it has unsaved text), and up to 40 expanded folders. Git status is read again every 12 seconds, after each change found, and when a turn of Claude ends.
git for status and diff. Optional: gh (authenticated) for pull requests, rg for text search (falls back to grep), fzf for the file-name search (without it: grep, a part of the path in any case). bash, find, sed, dd, mkdir, mv (macOS and Linux have them). Delete needs ~/.Trash.
More; there is no pop-up menu.ctrl+z is not Undo: the host suspends Claude Code on it. Use the [ ↶ ] icon..git contents. Text search stops at 1000 results. Both say so.git clone https://github.com/codejunkie99/claude-code-file-explorer
cd claude-code-file-explorer
claude plugin validate .
claude plugin test .
hooks/register.tsx: hooks, file and process work, the pane's frame.hooks/tree.tsx: the tree, a surface module (colors, pointer, arrow keys).hooks/editor.tsx: the editor, a surface module.hooks/lib.ts: pure logic (sorting, Git output parsing, diff rows, find).types/index.d.ts: the $.state contract. Navigation state lives there and survives a hot reload.Test a change with claude --plugin-dir on a copy only while the installed copy is disabled: two copies clash on the pane and on the state.
hooks/register.tsx 2053 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, ProcessSpawnChunk, ProcessSpawnResult, Register, RenderElement } from 'claude-code'
3
4import type { Base, Nav, Pr } from '../types'
5import {
6 badgeOf,
7 basename,
8 dirname,
9 emptyStatus,
10 findMatches,
11 fit,
12 flatten,
13 folderTint,
14 fmtSize,
15 ghError,
16 isAncestor,
17 join,
18 pairRows,
19 parseDiff,
20 parseHit,
21 parseNameStatus,
22 parsePrs,
23 parseStatus,
24 remoteFor,
25 rowsFromPaths,
26 sortEntries,
27 splitGlobs,
28 splitLines,
29 typeOf,
30} from './lib'
31import type { Change, DiffRow, Dir, Entry, Hit, Pair, Row } from './lib'
32
33type E = EngineInterface
34type UI = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input' | 'Code'> & { Client?: Elements['terminal']['Client'] }
35type Style = { color?: string; dimColor?: boolean; bold?: boolean }
36type Btn = { k: string; label: string; hot?: string; on: () => void }
37type Doc = {
38 path: string
39 state: 'loading' | 'ok' | 'empty' | 'binary' | 'deleted' | 'error'
40 text: string
41 lines: string[]
42 size: number
43 loaded: number
44 sig: string
45 info: string
46}
47type DiffDoc = {
48 path: string
49 state: 'loading' | 'ok' | 'same' | 'info'
50 rows: DiffRow[]
51 pairs: Pair[] // the rows side by side
52 at: number[] // row → the pair that holds it
53 numW: number // digits of the largest line number
54 hunks: number[]
55 binary: boolean
56 cut: boolean
57 info: string
58}
59type Kid = AsyncGenerator<ProcessSpawnChunk, ProcessSpawnResult>
60type Ask = { kind: 'newfile' | 'newfolder' | 'rename' | 'open'; base: string; label: string; hint: string; value: string; typed?: string }
61type Loc = { pin: string | null; mode: Nav['mode']; selected: string | null }
62
63const PANE = 'file-explorer'
64const CHUNK = 262144 // bytes read per load of a file larger than this
65const MAX_LINE = 2000 // characters drawn of one line; the rest is announced in the line
66const HIT_CAP = 1000
67const FILE_CAP = 500
68const CONTEXTS = [3, 10, 30, 100000]
69const EDIT_MAX = 60000 // characters of a file the editor takes: its text crosses to the editor as one value
70const BADGE: Record<string, string> = { M: 'warning', A: 'success', '?': 'success', D: 'error', U: 'error', R: 'suggestion', '•': 'warning' }
71// Side-by-side diff: red for a removed line, green for an added line, with light text, on every theme.
72// The theme's own `diffRemoved` and `diffAdded` are valid here too, but a daltonized theme draws them blue.
73const DEL_BG = '#6b2222'
74const ADD_BG = '#1f5a2e'
75const DIFF_FG = '#f2f2f2'
76const PR_FIELDS = 'number,title,state,headRefName,headRefOid,baseRefName,baseRefOid,updatedAt,url,author'
77
78const INITIAL: Nav = {
79 root: '',
80 expanded: [],
81 selected: null,
82 cursor: null,
83 treeTop: 0,
84 view: 'files',
85 hide: { dot: false, ignored: false, untracked: false },
86 fileQuery: '',
87 pin: null,
88 mode: 'files',
89 wrap: true,
90 scroll: {},
91 find: { q: '', cs: false, re: false, idx: 0 },
92 grep: { q: '', hidden: false, ignored: false, include: '', exclude: '' },
93 base: null,
94 pr: null,
95 want: 'head',
96 ctx: 3,
97 listing: false,
98 picker: false,
99 myPrs: false,
100}
101const nav = atom({ plugin: 'file-explorer', key: 'nav' } as const, INITIAL)
102
103// What was read from disk and from git. A hot reload drops it; `hydrate` reads it again from `nav`.
104let cur: Nav = INITIAL
105let dirs = new Map<string, Dir>()
106let reals = new Map<string, string>()
107let dirSig = new Map<string, string>()
108let tints = new Map<string, string>() // folder path → the color of its icon: the language most of its files are in
109let peeked = new Set<string>() // folders already read for their color
110let status = emptyStatus()
111let statusRaw = ''
112let git: { top: string; prefix: string; branch: string; hasHead: boolean; hasParent: boolean } | null = null
113let rows: Row[] = []
114let doc: Doc | null = null
115let diff: DiffDoc | null = null
116let changes: Change[] = []
117let found: { hits: Hit[]; capped: boolean; error?: string; byLine: Map<number, Hit[]> } = { hits: [], capped: false, byLine: new Map() }
118let grep = { q: '', running: false, groups: new Map<string, { line: number; text: string }[]>(), hits: 0, capped: false, error: '', tool: 'rg' }
119let filt = { q: '', running: false, paths: [] as string[], capped: false, error: '' }
120let prs = { branch: [] as Pr[], recent: [] as Pr[], error: '', loading: false, loaded: false }
121let note = ''
122let dtop = 0 // the first diff line on screen: a pair while `split`, else a row
123let split = true // Diff draws the old file and the new file side by side
124let pickTop = 0
125let paneUp = false
126let busy = false
127let warming = false
128let where: 'dock' | 'inline' = 'dock'
129let screen = 0 // the terminal's columns, as the last drawing saw them
130let asked = 0 // the pane width last asked of the host
131let sessionRoot = ''
132let moreOpen = false // the second toolbar row is drawn
133let choosing = false // the baseline chooser is drawn in Diff mode
134let ask: Ask | null = null // the name field is drawn, for a new file, a new folder, a rename, or a folder to open
135let doomed = '' // the path a second press of Delete moves to the Trash
136let jumpTo = { n: 0, line: 0 } // a line the editor is asked to go to
137let findNav = { n: 0, by: 1 } // a step to the next or previous match the editor is asked to make
138const hist = { back: [] as Loc[], fwd: [] as Loc[], busy: false }
139let dirty = false // the editor holds text that is not saved
140let stale = false // a save was refused once: the file changed on disk after it was opened
141let editSeq = 0
142let saveN = 0
143let undoN = 0
144let typedSearch = '' // what is in the search field now, for the button that submits it
145let liveN = 0 // counts the changes of the search field: a newer change drops an older one that still waits
146let applied = '' // the find text that is applied now, as it was typed
147let afterEnter = false // the host empties the field after Enter: that change is not the person's
148let fieldDrop = false // the search field is drawn once with another value, so the host drops what was typed
149let wheel = { n: 0, by: 0 }
150let ring: string | null = null // the row under the focus ring; `nav.cursor` is the last row pressed
151let ticks = 0
152const seq = { tree: 0, open: 0, diff: 0, grep: 0, filt: 0 }
153const kids: { grep?: Kid; filt?: Kid } = {}
154const last = { tree: 10, reader: 10 }
155
156const abs = (root: string, rel: string) => (rel ? `${root.replace(/\/$/, '')}/${rel}` : root)
157const text = (err: unknown) => (err instanceof Error ? err.message : String(err))
158const first = (s: string) => s.trim().split('\n')[0] ?? ''
159const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
160const clip = (s: string, w: number) => (s.length > w ? `${s.slice(0, Math.max(1, w - 1))}…` : s)
161const dot = (on: boolean) => (on ? '●' : '○')
162/** The place of a diff row in the diff window, and the window's length. */
163const dpos = (row: number) => (split ? (diff?.at[row] ?? 0) : row)
164const dlen = () => (split ? diff?.pairs.length : diff?.rows.length) ?? 0
165/** A file system error in a few words for a tree row; the whole message goes to the note line on a press. */
166const brief = (why: string) => {
167 const code = /\bE[A-Z]{3,}\b/.exec(why)?.[0]
168 if (code === 'EACCES' || code === 'EPERM') return 'permission denied'
169 if (code === 'ENOENT') return 'not found'
170 return code ?? why.replace(/^.*\$\.fs\.\w+\([^)]*\)[:\s]*/, '')
171}
172const tab = (s: string) => s.replaceAll('\t', ' ')
173// a value an older load of this module wrote can lack a field: the defaults fill it
174const get = async ($: E) => (cur = { ...INITIAL, ...(await read($, nav)) })
175const put = async ($: E, p: Partial<Nav>) => (cur = await update($, nav, n => ({ ...INITIAL, ...n, ...p })))
176const redraw = ($: E) => $.ui.invalidate('ui.render')
177/**
178 * Empties the search field. The host keeps what the person typed until the field is drawn with another value:
179 * one drawing with a zero-width space drops it, and the next drawing, with no value, shows the hint again.
180 */
181const dropTyped = ($: E) => {
182 liveN++
183 typedSearch = ''
184 fieldDrop = true
185 redraw($)
186 $.clock.after(80, () => {
187 fieldDrop = false
188 redraw($)
189 })
190}
191const git$ = ($: E, root: string, args: string[], timeoutMs = 20000) =>
192 $.process.run(['git', '-c', 'core.quotepath=off', ...args], { cwd: root, timeoutMs })
193/** A handler closure: the work runs on after the press returns, and a failure is shown, not lost. */
194const go = ($: E, f: () => Promise<unknown>) => () => {
195 void f().catch(err => {
196 note = text(err)
197 redraw($)
198 })
199}
200
201// ---------------------------------------------------------------- tree
202
203const entryAt = (path: string): Entry | undefined =>
204 dirs.get(dirname(path))?.entries.find(e => e.name === basename(path))
205
206function grepRows(): Row[] {
207 const out: Row[] = []
208 for (const [file, hits] of grep.groups) {
209 out.push({ key: `gh:${file}`, path: file, depth: 0, kind: 'head', label: `${file} (${hits.length})` })
210 for (const h of hits) out.push({ key: `hit:${file}:${h.line}`, path: file, depth: 1, kind: 'hit', line: h.line, label: `${h.line}: ${h.text}` })
211 }
212 return out
213}
214
215/** Builds the rows of the view on screen. Called after every change of what they are made from, never while drawing. */
216function build(n: Nav) {
217 if (n.view === 'search') rows = grepRows()
218 else if (n.fileQuery) rows = rowsFromPaths(filt.paths, status)
219 else rows = flatten(dirs, new Set(n.expanded), n.hide, status)
220}
221
222async function loadDir($: E, root: string, rel: string, my: number) {
223 let dir: Dir
224 const links: [string, string][] = []
225 try {
226 const here = reals.get(rel) ?? abs(root, rel)
227 const list = await $.fs.list(abs(root, rel))
228 const entries = await Promise.all(
229 list.map(async (f): Promise<Entry> => {
230 const path = join(rel, f.name)
231 if (!f.isLink) {
232 if (f.kind === 'dir') links.push([path, `${here.replace(/\/$/, '')}/${f.name}`])
233 return { name: f.name, kind: f.kind, isLink: false, size: f.size }
234 }
235 const st = await $.fs.stat(abs(root, path), { resolve: true }).catch(() => undefined)
236 if (!st?.realPath) return { name: f.name, kind: 'other', isLink: true, size: 0 }
237 if (st.kind === 'dir') links.push([path, st.realPath])
238 // a link to an ancestor of the directory it is in would expand into itself for ever
239 const top = reals.get('') ?? root
240 const target = st.realPath === top ? '. (the root)' : isAncestor(top, st.realPath) ? st.realPath.slice(top.replace(/\/$/, '').length + 1) : st.realPath
241 return { name: f.name, kind: st.kind, isLink: true, size: st.size, target, loop: st.kind === 'dir' && isAncestor(st.realPath, here) }
242 }),
243 )
244 dir = { entries: sortEntries(entries) }
245 } catch (err) {
246 dir = { entries: [], error: first(text(err)) }
247 }
248 if (my !== seq.tree) return
249 for (const [p, r] of links) reals.set(p, r)
250 dirs.set(rel, dir)
251 // a folder that was looked into keeps that color: it counts one level more than this listing has
252 if (!dir.error && !peeked.has(rel)) tints.set(rel, folderTint(dir.entries.filter(e => e.kind === 'file').map(e => e.name), basename(rel)))
253}
254
255/**
256 * Colors the folders that are listed but not opened: each is read once, for the names of its files only.
257 * It runs after the listing is on screen, for 80 folders at most, so a large folder does not hold the tree.
258 */
259async function peek($: E, root: string, rel: string, my: number) {
260 const kids = (dirs.get(rel)?.entries ?? []).filter(e => e.kind === 'dir' && !e.isLink && !peeked.has(join(rel, e.name))).slice(0, 80)
261 const files = async (path: string) => (await $.fs.list(abs(root, path)).catch(() => [])).filter(f => !f.isLink)
262 await Promise.all(
263 kids.map(async e => {
264 const path = join(rel, e.name)
265 peeked.add(path)
266 // its own files, and the files one level down: `src` is often where a project's language is
267 const own = await files(path)
268 const below = await Promise.all(own.filter(f => f.kind === 'dir' && !/^(node_modules|\.git|dist|build|target|vendor)$/.test(f.name)).slice(0, 6).map(f => files(join(path, f.name))))
269 if (my === seq.tree) tints.set(path, folderTint([...own, ...below.flat()].filter(f => f.kind === 'file').map(f => f.name), e.name))
270 }),
271 )
272 if (kids.length && my === seq.tree) redraw($)
273}
274
275/** Loads a directory and, under it, every directory the person left expanded. */
276async function loadOpen($: E, root: string, rel: string, open: Set<string>, my: number): Promise<void> {
277 await loadDir($, root, rel, my)
278 void peek($, root, rel, my).catch(() => undefined)
279 const kidsOf = dirs.get(rel)?.entries.filter(e => e.kind === 'dir' && !e.loop && open.has(join(rel, e.name))) ?? []
280 await Promise.all(kidsOf.map(e => loadOpen($, root, join(rel, e.name), open, my)))
281}
282
283async function loadGit($: E, root: string) {
284 // `git` changes only once the answer is whole: a second load under way never sees a half state
285 try {
286 const top = await git$($, root, ['rev-parse', '--show-toplevel', '--show-prefix'])
287 if (top.exitCode !== 0) return void (git = null)
288 const [dir = '', prefix = ''] = top.stdout.split('\n')
289 const [br, head, prev] = await Promise.all([
290 git$($, root, ['symbolic-ref', '--short', '-q', 'HEAD']),
291 git$($, root, ['rev-parse', '--verify', '-q', 'HEAD^{commit}']),
292 git$($, root, ['rev-parse', '--verify', '-q', 'HEAD^^{commit}']),
293 ])
294 git = { top: dir, prefix, branch: br.stdout.trim(), hasHead: head.exitCode === 0, hasParent: prev.exitCode === 0 }
295 } catch {
296 git = null // git did not start: the tree, the reader and search work without it
297 }
298}
299
300async function loadStatus($: E, root: string): Promise<boolean> {
301 const g = git
302 let raw = ''
303 if (g) {
304 const r = await git$($, root, ['status', '--porcelain=v1', '-z', '--ignored', '--untracked-files=normal', '--', '.']).catch(() => null)
305 if (r?.exitCode === 0) raw = r.stdout
306 }
307 if (raw === statusRaw) return false
308 statusRaw = raw
309 status = g ? parseStatus(raw, g.prefix) : emptyStatus()
310 // the files that differ from the baseline change when the status does: a save, a new file, a commit
311 if (g && cur.base && cur.root === root) await loadChanges($, root, cur.base)
312 return true
313}
314
315/** Reads the session's root again and, when it moved or `force`, the tree under it. Selection and scroll stay. */
316async function sync($: E, force: boolean): Promise<Nav> {
317 sessionRoot = await $.session.root()
318 let n = await get($)
319 if (n.pin && (await $.fs.stat(n.pin).catch(() => null))?.kind !== 'dir') {
320 note = `${n.pin} is not a directory now. The explorer is back at the session's directory.`
321 n = await put($, { pin: null })
322 }
323 const root = n.pin ?? sessionRoot
324 if (n.root !== root) {
325 const old = n.root
326 const keep = (rel: string) => {
327 const a = abs(old, rel)
328 return old && a !== root && isAncestor(root, a) ? a.slice(root.replace(/\/$/, '').length + 1) : null
329 }
330 const selected = n.selected === null ? null : keep(n.selected)
331 n = await put($, {
332 root,
333 expanded: n.expanded.map(keep).filter((p): p is string => p !== null),
334 selected,
335 mode: selected === null ? 'files' : n.mode,
336 listing: false,
337 cursor: null,
338 treeTop: 0,
339 scroll: {},
340 base: null,
341 pr: null,
342 fileQuery: '',
343 picker: false,
344 })
345 seq.open++
346 seq.diff++
347 dropTyped($) // the text in the search field was for the folder before
348 void cancel('grep')
349 void cancel('filt')
350 dirs = new Map()
351 tints = new Map()
352 peeked = new Set()
353 reals = new Map()
354 dirSig = new Map()
355 statusRaw = '\0'
356 doc = diff = null
357 dirty = false
358 changes = []
359 grep = { ...grep, q: '', running: false, groups: new Map(), hits: 0, error: '' }
360 filt = { ...filt, q: '', running: false, paths: [] }
361 prs = { branch: [], recent: [], error: '', loading: false, loaded: false }
362 force = true
363 }
364 if (!force && dirs.has('')) return n
365 const my = ++seq.tree
366 const st = await $.fs.stat(root, { resolve: true }).catch(() => undefined)
367 reals.set('', st?.realPath ?? root)
368 await loadGit($, root)
369 await Promise.all([loadOpen($, root, '', new Set(n.expanded), my), loadStatus($, root)])
370 build(n)
371 redraw($)
372 return n
373}
374
375async function refresh($: E) {
376 note = ''
377 const n = await sync($, true)
378 if (n.base && git) await loadChanges($, n.root, n.base)
379 if (n.mode !== 'files' && n.selected && !dirty) await openFile($, n.selected, { keep: true })
380 build(await get($))
381 redraw($)
382}
383
384async function toggle($: E, path: string) {
385 const n = await get($)
386 const e = entryAt(path)
387 if (e?.loop) {
388 note = `Not expanded: ${path} links to ${e.target}, a directory that contains it.`
389 return redraw($)
390 }
391 const isOpen = n.expanded.includes(path)
392 const next = await put($, { expanded: isOpen ? n.expanded.filter(p => p !== path) : [...n.expanded, path] })
393 build(next)
394 redraw($)
395 if (isOpen) return
396 await loadOpen($, next.root, path, new Set(next.expanded), seq.tree)
397 const why = dirs.get(path)?.error
398 if (why) note = `${path}: ${why}`
399 build(await get($))
400 redraw($)
401}
402
403/** Expands every ancestor of a file so its row is in the tree. */
404async function reveal($: E, rel: string) {
405 const n = await get($)
406 const up: string[] = []
407 for (let d = dirname(rel); d; d = dirname(d)) if (!n.expanded.includes(d)) up.unshift(d)
408 if (!up.length) return
409 const next = await put($, { expanded: [...n.expanded, ...up] })
410 for (const d of up) await loadDir($, next.root, d, seq.tree)
411 build(next)
412}
413
414const treeTop = (n: Nav) => clamp(n.treeTop, 0, Math.max(0, rows.length - last.tree))
415const ringKey = () => ring ?? cur.cursor
416
417const ringRow = () => rows.find(r => r.key === ringKey())
418
419/** Puts the cursor on row `i` and moves the window so the row is in it. */
420async function showRow($: E, i: number) {
421 const at = clamp(i, 0, rows.length - 1)
422 const r = rows[at]
423 if (!r) return
424 ring = r.key
425 doomed = ''
426 const top = treeTop(cur)
427 const next = at < top ? at : at >= top + last.tree ? at - last.tree + 1 : top
428 if (next !== cur.treeTop) await put($, { treeTop: next })
429 else redraw($)
430}
431
432/** One step in the tree, from an arrow button or its key: what the arrow keys do in the tree after a click. */
433async function treeStep($: E, to: 'up' | 'down' | 'left' | 'right' | 'open') {
434 const i = rows.findIndex(r => r.key === ringKey())
435 const r = rows[i]
436 if (to === 'up' || to === 'down') return showRow($, i < 0 ? treeTop(cur) : i + (to === 'up' ? -1 : 1))
437 if (!r || r.kind === 'note') return
438 if (to === 'open') return pressRow($, r)
439 const canFold = r.kind === 'dir' && !cur.fileQuery
440 if (to === 'right') return canFold && !r.open ? toggle($, r.path) : showRow($, i + 1)
441 if (canFold && r.open) return toggle($, r.path)
442 const up = rows.findIndex(x => x.key === `row:${dirname(r.path)}`)
443 if (up >= 0) await showRow($, up)
444}
445
446/**
447 * Asks the host to give the pane the keyboard. Every click in the pane does this. Without it, a click on
448 * the tree or the editor gives them the keys while the prompt still has them too, and what is typed goes to both.
449 * The host does not let a mod hand the keys to the tree or the editor itself, so the first click in an unfocused
450 * pane focuses the pane, and the next click on the tree or the text gives that part the keys.
451 */
452function grab($: E) {
453 void (async () => {
454 // the engine's own record: a drawing does not always run again when the pane loses the keyboard
455 if ((await $.ui.panes()).some(p => p.id === PANE && p.isFocused)) return
456 await $.ui.open({ id: PANE, title: 'Files', focus: true, columns: asked || wantColumns() })
457 })().catch(() => undefined)
458}
459
460async function pressRow($: E, r: Row) {
461 note = ''
462 doomed = ''
463 ring = r.key
464 await put($, { cursor: r.key })
465 if (r.kind === 'dir') {
466 if (cur.fileQuery) {
467 note = 'Clear the file filter to expand or collapse directories.'
468 return redraw($)
469 }
470 return toggle($, r.path)
471 }
472 if (r.kind === 'hit' || r.kind === 'head') {
473 await reveal($, r.path)
474 return openFile($, r.path, { line: r.line, mode: 'code' })
475 }
476 return openFile($, r.path)
477}
478
479// ---------------------------------------------------------------- reader
480
481function refind(n: Nav) {
482 const lines = n.mode === 'diff' ? (diff?.rows.map(r => r.s) ?? []) : (doc?.lines ?? [])
483 const f = findMatches(lines, n.find.q, n.find)
484 const byLine = new Map<number, Hit[]>()
485 for (const h of f.hits) {
486 const list = byLine.get(h.line)
487 if (list) list.push(h)
488 else byLine.set(h.line, [h])
489 }
490 found = { ...f, byLine }
491}
492
493async function chunk($: E, path: string, k: number): Promise<string> {
494 const r = await $.process.run(['dd', `if=${path}`, `bs=${CHUNK}`, `skip=${k}`, 'count=1'], { timeoutMs: 15000 })
495 if (r.exitCode !== 0) throw new Error(first(r.stderr) || 'dd failed')
496 return r.stdout
497}
498
499async function loadDoc($: E, root: string, rel: string): Promise<Doc> {
500 const path = abs(root, rel)
501 const d: Doc = { path: rel, state: 'error', text: '', lines: [], size: 0, loaded: 0, sig: 'gone', info: '' }
502 const st = await $.fs.stat(path).catch(() => null)
503 if (!st) return { ...d, state: 'deleted' }
504 d.sig = `${st.mtimeMs}:${st.size}`
505 d.size = st.size
506 if (st.kind !== 'file') return { ...d, info: st.kind === 'dir' ? 'This entry is a directory.' : 'This entry is not a regular file (a device, a socket, or a broken link).' }
507 if (st.size === 0) return { ...d, state: 'empty' }
508 try {
509 const body = st.size <= CHUNK ? await $.fs.read(path) : await chunk($, path, 0)
510 if (body.slice(0, 8000).includes('\0')) return { ...d, state: 'binary' }
511 return { ...d, state: 'ok', text: body, lines: splitLines(body, MAX_LINE), loaded: Math.min(st.size, CHUNK) }
512 } catch (err) {
513 return { ...d, info: first(text(err)) }
514 }
515}
516
517/** Opens a file in the pane. A click opens Code mode; `keep` reads the open file again and changes nothing else. */
518async function openFile($: E, rel: string, o: { line?: number; mode?: Nav['mode']; keep?: boolean } = {}) {
519 if (!o.keep && blocked($)) return
520 if (!o.keep) visit()
521 const my = ++seq.open
522 let n = await get($)
523 const scroll = o.line ? { ...n.scroll, [rel]: Math.max(0, o.line - 1) } : n.scroll
524 const keys = Object.keys(scroll)
525 if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) if (k !== rel) delete scroll[k]
526 if (!o.keep) note = ''
527 n = o.keep ? await put($, { scroll }) : await put($, { selected: rel, picker: false, listing: false, mode: o.mode ?? 'code', scroll })
528 if (!o.keep) {
529 choosing = false
530 dirty = stale = false
531 if (o.line) jumpTo = { n: jumpTo.n + 1, line: o.line - 1 }
532 }
533 if (!o.keep || doc?.path !== rel) {
534 doc = { path: rel, state: 'loading', text: '', lines: [], size: 0, loaded: 0, sig: '', info: '' }
535 diff = null
536 dtop = 0
537 refind(n)
538 build(n)
539 redraw($)
540 }
541 const next = await loadDoc($, n.root, rel)
542 if (my !== seq.open) return // a newer selection owns the reader
543 doc = next
544 editSeq++ // the editor takes the text that was just read
545 refind(n)
546 if (n.mode === 'diff') await loadDiff($, n, rel)
547 void resize($)
548 redraw($)
549}
550
551async function loadMore($: E) {
552 const d = doc
553 if (!d || d.state !== 'ok' || d.loaded >= d.size) return
554 const my = seq.open
555 const more = await chunk($, abs(cur.root, d.path), d.loaded / CHUNK)
556 if (my !== seq.open || doc !== d) return
557 d.text += more
558 d.lines = splitLines(d.text, MAX_LINE)
559 d.loaded = Math.min(d.size, d.loaded + CHUNK)
560 refind(cur)
561 redraw($)
562}
563
564async function scrollTree($: E, by: number) {
565 const n = await get($)
566 const top = clamp(n.treeTop + by, 0, Math.max(0, rows.length - last.tree))
567 if (top !== n.treeTop) await put($, { treeTop: top })
568}
569
570async function scrollReader($: E, by: number, to?: number) {
571 const n = await get($)
572 if (n.picker) pickTop = Math.max(0, to ?? pickTop + by)
573 else if (n.mode === 'diff') dtop = clamp(to ?? dtop + by, 0, Math.max(0, dlen() - 1))
574 else if (doc && n.selected) {
575 const top = clamp(to ?? (n.scroll[n.selected] ?? 0) + by, 0, Math.max(0, doc.lines.length - 1))
576 return put($, { scroll: { ...n.scroll, [n.selected]: top } })
577 }
578 redraw($)
579}
580
581async function setFind($: E, p: Partial<Nav['find']>) {
582 if (cur.mode === 'code' && !canEdit()) {
583 // the editor finds in its own text, which can be newer than the file
584 await put($, { find: { ...cur.find, ...p, idx: 0 } })
585 if (cur.find.q) findNav = { n: findNav.n + 1, by: 0 }
586 return redraw($)
587 }
588 const n = await get($)
589 const next = { ...n, find: { ...n.find, ...p, idx: 0 } }
590 refind(next)
591 const top = n.mode === 'diff' ? dtop : (n.scroll[n.selected ?? ''] ?? 0)
592 const pos = (line: number) => (n.mode === 'diff' ? dpos(line) : line)
593 next.find.idx = Math.max(0, found.hits.findIndex(h => pos(h.line) >= top))
594 await put($, { find: next.find })
595 const hit = found.hits[next.find.idx]
596 if (hit) await scrollReader($, 0, Math.max(0, pos(hit.line) - 2))
597 redraw($)
598}
599
600async function jump($: E, by: number) {
601 if (cur.mode === 'code' && !canEdit()) {
602 findNav = { n: findNav.n + 1, by }
603 return redraw($)
604 }
605 const n = await get($)
606 if (!found.hits.length) return
607 const idx = (n.find.idx + by + found.hits.length) % found.hits.length
608 await put($, { find: { ...n.find, idx } })
609 const line = found.hits[idx]?.line ?? 0
610 await scrollReader($, 0, Math.max(0, (n.mode === 'diff' ? dpos(line) : line) - 2))
611}
612
613async function gotoLine($: E, value: string) {
614 const line = Number(value.trim())
615 const total = doc?.lines.length ?? 0
616 if (!value.trim()) return
617 if (!Number.isInteger(line) || line < 1 || line > total) {
618 const part = doc && doc.loaded < doc.size ? ' in the loaded part' : ''
619 note = `Line "${value.trim()}" is not a line of this file${part} (1 to ${total}).`
620 return redraw($)
621 }
622 note = ''
623 if (cur.mode === 'code' && !canEdit()) {
624 jumpTo = { n: jumpTo.n + 1, line: line - 1 }
625 return redraw($)
626 }
627 await scrollReader($, 0, line - 1)
628}
629
630async function setMode($: E, mode: Nav['mode']) {
631 if (mode === cur.mode) return
632 if (blocked($)) return
633 visit()
634 note = ''
635 choosing = false
636 dropTyped($) // the text in the search field was for the mode before
637 const n = await put($, { mode, picker: false, listing: mode === 'diff' && !cur.selected })
638 void resize($)
639 redraw($)
640 if (mode !== 'files' && n.selected && doc?.path !== n.selected) await openFile($, n.selected, { keep: true })
641 if (mode === 'diff' && n.selected) await loadDiff($, n, n.selected)
642 else if (mode === 'diff') await ensureBase($, n).catch(err => (note = text(err)))
643 refind(cur)
644 build(cur)
645 void resize($)
646 redraw($)
647}
648
649// ---------------------------------------------------------------- pane, root, editor
650
651/**
652 * The width the docked pane asks of the host. The tree is narrow. Code and diffs ask for the width of
653 * their longest line, up to a share of the terminal; a line longer than that wraps.
654 */
655function wantColumns(): number {
656 if (cur.mode === 'files') return 46
657 let longest = 0
658 if (cur.mode === 'diff') for (const r of diff?.rows ?? []) longest = Math.max(longest, split ? 2 * (r.s.length + 8) : r.s.length + 14)
659 else for (const l of doc?.lines ?? []) longest = Math.max(longest, l.length + 8)
660 return clamp(longest, 60, Math.max(60, Math.floor((screen || 160) * 0.6)))
661}
662
663async function resize($: E) {
664 if (where !== 'dock') return
665 const columns = wantColumns()
666 if (columns === asked) return
667 asked = columns
668 await $.ui.open({ id: PANE, title: 'Files', columns }).catch(() => undefined)
669}
670
671/** True, with the reason on screen, while the editor holds text that is not saved. */
672function blocked($: E): boolean {
673 if (!dirty) return false
674 note = `${cur.selected} is not saved. 💾 saves it. ↶ takes back one change. ✕ drops all changes.`
675 redraw($)
676 return true
677}
678
679/** Makes a directory the root, or with null goes back to the session's directory. The choice is kept per session directory. */
680async function setRoot($: E, input: string | null) {
681 if (blocked($)) return
682 visit()
683 note = ''
684 let pin: string | null = null
685 if (input !== null) {
686 let p = input.trim()
687 if (!p) return
688 if (p === '~' || p.startsWith('~/')) p = `${(await $.env.get('HOME')) ?? ''}${p.slice(1)}`
689 else if (!p.startsWith('/')) p = abs(cur.root, p)
690 const st = await $.fs.stat(p, { resolve: true }).catch(() => null)
691 if (st?.kind !== 'dir' || !st.realPath) {
692 note = `${p} is not a directory.`
693 return redraw($)
694 }
695 pin = st.realPath === sessionRoot ? null : st.realPath
696 }
697 await put($, { pin })
698 const saved = await $.store.get('pins')
699 const pins: Record<string, string> = typeof saved === 'object' && saved !== null ? { ...(saved as Record<string, string>) } : {}
700 if (pin) pins[sessionRoot] = pin
701 else delete pins[sessionRoot]
702 await $.store.set('pins', pins)
703 await sync($, true)
704 build(cur)
705 redraw($)
706}
707
708function canEdit(): string {
709 const d = doc
710 if (!d || (d.state !== 'ok' && d.state !== 'empty')) return 'Only a text file that is on disk can be edited.'
711 if (d.loaded < d.size) return 'This file is only partly loaded. It is too large to edit here.'
712 if (d.text.length > EDIT_MAX) return `This file has more than ${EDIT_MAX} characters. It is too large to edit here.`
713 if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(d.text)) return 'This file has control characters. It cannot be edited here.'
714 return ''
715}
716
717/** Puts the file back as it is on disk. */
718async function revert($: E) {
719 dirty = stale = false
720 note = ''
721 if (cur.selected) await openFile($, cur.selected, { keep: true })
722 redraw($)
723}
724
725/** Writes the editor's text to the selected file. A file that changed on disk since it was opened is not overwritten at the first try. */
726async function saveFile($: E, body: string) {
727 const n = cur
728 const d = doc
729 if (!n.selected || !d || d.path !== n.selected) return
730 const path = abs(n.root, n.selected)
731 const st = await $.fs.stat(path).catch(() => null)
732 if ((st ? `${st.mtimeMs}:${st.size}` : 'gone') !== d.sig && !stale) {
733 stale = true
734 note = 'This file changed on disk after you opened it. 💾 again overwrites it. ✕ drops your changes.'
735 return redraw($)
736 }
737 await $.fs.write(path, body)
738 doc = await loadDoc($, n.root, n.selected)
739 dirty = stale = false
740 note = `Saved ${n.selected} (${fmtSize(doc.size)}).`
741 refind(cur)
742 await loadStatus($, n.root)
743 build(cur)
744 redraw($)
745}
746
747// ---------------------------------------------------------------- previous and next file
748
749/** The entries of a folder that a step to the next file can land on or go into, in the tree's order. */
750async function walkable($: E, dir: string): Promise<Entry[]> {
751 if (!dirs.has(dir)) await loadDir($, cur.root, dir, seq.tree)
752 return (dirs.get(dir)?.entries ?? []).filter(e => {
753 const path = join(dir, e.name)
754 const badge = badgeOf(path, e.kind === 'dir', status)
755 if ((cur.hide.dot && e.name.startsWith('.')) || (cur.hide.ignored && badge === '!') || (cur.hide.untracked && badge === '?')) return false
756 // a step never goes into Git's own folder, a folder Git ignores, or a link that loops
757 return e.kind === 'file' || (e.kind === 'dir' && !e.loop && e.name !== '.git' && badge !== '!')
758 })
759}
760
761/** The first file (or with `by` -1 the last) in a folder or below it; folders come before files, as in the tree. */
762async function edgeFile($: E, dir: string, by: 1 | -1, budget: { left: number }): Promise<string | null> {
763 if (budget.left-- <= 0) return null
764 const list = await walkable($, dir)
765 for (const e of by > 0 ? list : [...list].reverse()) {
766 const path = join(dir, e.name)
767 if (e.kind === 'file') return path
768 const inside = await edgeFile($, path, by, budget)
769 if (inside) return inside
770 }
771 return null
772}
773
774/**
775 * The file after (or before) a file in the tree's order. At the end of a folder the step goes on into the
776 * next folder, however deep, and takes its first file: folders that are not expanded are read on the way.
777 */
778async function fileBeside($: E, path: string, by: 1 | -1): Promise<string | null> {
779 const budget = { left: 400 } // folders read for one step
780 for (let at = path; ; at = dirname(at)) {
781 const dir = dirname(at)
782 const list = await walkable($, dir)
783 const i = list.findIndex(e => e.name === basename(at))
784 const rest = by > 0 ? list.slice(i + 1) : list.slice(0, Math.max(0, i)).reverse()
785 for (const e of i < 0 ? [] : rest) {
786 const next = join(dir, e.name)
787 if (e.kind === 'file') return next
788 const inside = await edgeFile($, next, by, budget)
789 if (inside) return inside
790 }
791 if (!dir) return null
792 }
793}
794
795/** The back and next signs: in Code the file before or after this one, in Diff the changed file before or after. */
796async function stepFile($: E, by: 1 | -1) {
797 if (blocked($)) return
798 const from = cur.selected
799 let to: string | null = null
800 if (cur.mode === 'diff') {
801 const i = changes.findIndex(c => c.path === from)
802 to = changes[i < 0 ? (by > 0 ? 0 : changes.length - 1) : i + by]?.path ?? null
803 } else to = from === null ? await edgeFile($, '', by, { left: 400 }) : await fileBeside($, from, by)
804 if (to === null) {
805 note = by > 0 ? 'This is the last file.' : 'This is the first file.'
806 return redraw($)
807 }
808 await reveal($, to)
809 ring = `row:${to}`
810 await put($, { cursor: ring })
811 await openFile($, to, { mode: cur.mode === 'diff' ? 'diff' : 'code' })
812}
813
814// ---------------------------------------------------------------- history and file operations
815
816const here = (): Loc => ({ pin: cur.pin, mode: cur.mode, selected: cur.selected })
817
818/** Records where the person is, before they go elsewhere, so Back can return there. */
819function visit() {
820 if (hist.busy) return
821 const loc = here()
822 const top = hist.back[hist.back.length - 1]
823 if (!top || top.pin !== loc.pin || top.mode !== loc.mode || top.selected !== loc.selected) hist.back.push(loc)
824 if (hist.back.length > 50) hist.back.shift()
825 hist.fwd = []
826}
827
828async function travel($: E, by: -1 | 1) {
829 if (blocked($)) return
830 const loc = (by < 0 ? hist.back : hist.fwd).pop()
831 if (!loc && by < 0 && cur.mode !== 'files') return setMode($, 'files') // with no history, Back still leaves a file for the tree
832 if (!loc) {
833 note = by < 0 ? 'There is nothing to go back to.' : 'There is nothing to go forward to.'
834 return redraw($)
835 }
836 ;(by < 0 ? hist.fwd : hist.back).push(here())
837 hist.busy = true
838 try {
839 if (loc.pin !== cur.pin) await setRoot($, loc.pin)
840 if (loc.selected && loc.mode !== 'files') await openFile($, loc.selected, { mode: loc.mode })
841 else await setMode($, loc.mode)
842 } finally {
843 hist.busy = false
844 }
845}
846
847/** Opens the name field for a new file, a new folder, a rename, or a folder to open. */
848function startAsk($: E, kind: Ask['kind']) {
849 const r = ringRow()
850 const isEntry = r !== undefined && (r.kind === 'dir' || r.kind === 'file' || r.kind === 'other') && cur.view === 'files' && !cur.fileQuery
851 doomed = ''
852 note = ''
853 if (kind === 'rename') {
854 if (!r || !isEntry) {
855 note = 'Select a file or a folder first: click it, or move to it with the arrow keys.'
856 return redraw($)
857 }
858 ask = { kind, base: r.path, label: 'Rename to', hint: basename(r.path), value: basename(r.path) }
859 } else if (kind === 'open') ask = { kind, base: '', label: 'Open folder', hint: 'a path: /abs, ~/in-home, or ../relative', value: '' }
860 else {
861 const dir = r && isEntry ? (r.kind === 'dir' ? r.path : dirname(r.path)) : ''
862 ask = { kind, base: dir, label: kind === 'newfile' ? 'New file' : 'New folder', hint: `name, in ${dir || 'the root'}`, value: '' }
863 }
864 redraw($)
865 void $.ui.focus({ requestId: PANE, key: 'ask' }).catch(() => undefined)
866}
867
868async function answer($: E, value: string) {
869 const a = ask
870 ask = null
871 const name = value.trim()
872 if (!a || !name) return redraw($) // an empty Enter cancels
873 if (a.kind === 'open') return setRoot($, name)
874 if (blocked($)) return
875 if (name.startsWith('/') || name.split('/').some(part => part === '' || part === '.' || part === '..')) {
876 note = `"${name}" is not a name inside this folder.`
877 return redraw($)
878 }
879 const root = cur.root
880 const rel = a.kind === 'rename' ? join(dirname(a.base), name) : join(a.base, name)
881 if (await $.fs.exists(abs(root, rel))) {
882 note = `${rel} exists already.`
883 return redraw($)
884 }
885 if (a.kind === 'newfile') await $.fs.write(abs(root, rel), '')
886 else {
887 const argv = a.kind === 'newfolder' ? ['mkdir', '-p', abs(root, rel)] : ['mv', '-n', abs(root, a.base), abs(root, rel)]
888 const r = await $.process.run(argv, { timeoutMs: 15000 })
889 if (r.exitCode !== 0) {
890 note = first(r.stderr) || `${argv[0]} failed.`
891 return redraw($)
892 }
893 }
894 if (a.kind === 'rename') {
895 // what was open or expanded under the old name is under the new one
896 const moved = (p: string) => (p === a.base || p.startsWith(`${a.base}/`) ? rel + p.slice(a.base.length) : p)
897 await put($, { expanded: cur.expanded.map(moved), selected: cur.selected === null ? null : moved(cur.selected) })
898 } else if (a.kind === 'newfolder') await put($, { expanded: [...cur.expanded, rel] })
899 await reveal($, rel)
900 await refresh($)
901 ring = `row:${rel}`
902 await put($, { cursor: ring })
903 note = a.kind === 'rename' ? `Renamed ${a.base} to ${rel}.` : `Created ${rel}.` // after the refresh, which clears the note
904 redraw($)
905 if (a.kind === 'newfile') await openFile($, rel)
906}
907
908/** Moves the selected entry to the Trash, at the second press. Nothing is removed for good. */
909async function trash($: E) {
910 const r = ringRow()
911 if (!r || (r.kind !== 'dir' && r.kind !== 'file' && r.kind !== 'other') || cur.view !== 'files' || cur.fileQuery) {
912 note = 'Select a file or a folder first: click it, or move to it with the arrow keys.'
913 return redraw($)
914 }
915 if (blocked($)) return
916 if (doomed !== r.path) {
917 doomed = r.path
918 note = `Select Delete again to move ${r.path}${r.kind === 'dir' ? ' and everything in it' : ''} to the Trash.`
919 return redraw($)
920 }
921 doomed = ''
922 const home = await $.env.get('HOME')
923 const bin = `${home}/.Trash`
924 const from = abs(cur.root, r.path)
925 if (!home || !(await $.fs.exists(bin))) {
926 note = 'There is no Trash folder (~/.Trash) on this system. The mod does not delete without one.'
927 return redraw($)
928 }
929 // `mv -n` never overwrites: a name that is taken in the Trash gets the time added
930 const stamp = new Date(await $.clock.now()).toISOString().replace(/[:.]/g, '-')
931 for (const to of [`${bin}/${basename(r.path)}`, `${bin}/${basename(r.path)} ${stamp}`]) {
932 await $.process.run(['mv', '-n', from, to], { timeoutMs: 30000 }).catch(() => undefined)
933 if (!(await $.fs.exists(from))) break
934 }
935 if (await $.fs.exists(from)) {
936 note = `${r.path} was not moved to the Trash.`
937 return redraw($)
938 }
939 ring = null
940 if (cur.selected !== null && isAncestor(r.path, cur.selected)) {
941 doc = diff = null
942 await put($, { selected: null, cursor: null, mode: 'files' })
943 }
944 await refresh($)
945 note = `Moved ${r.path} to the Trash.` // after the refresh, which clears the note
946 redraw($)
947}
948
949// ---------------------------------------------------------------- diff
950
951async function resolveBase($: E, root: string, kind: Base['kind'], pr: Pr | null): Promise<Base> {
952 const branch = git?.branch || 'detached HEAD'
953 if (kind === 'head' || kind === 'prev') {
954 const rev = kind === 'head' ? 'HEAD' : 'HEAD^'
955 const r = await git$($, root, ['rev-parse', '--verify', '-q', `${rev}^{commit}`])
956 if (r.exitCode !== 0 || !r.stdout.trim())
957 throw new Error(kind === 'head' ? 'This repository has no commit. There is no HEAD to compare against.' : 'HEAD has no parent commit.')
958 return { kind, sha: r.stdout.trim(), label: `${rev} · ${branch}` }
959 }
960 if (!pr) throw new Error('No pull request is selected.')
961 await ensure($, root, pr.headOid, pr, `refs/pull/${pr.number}/head`)
962 if (kind === 'prhead') return { kind, sha: pr.headOid, label: `PR #${pr.number} head · ${pr.head}` }
963 await ensure($, root, pr.baseOid, pr, `refs/heads/${pr.base}`)
964 const mb = await git$($, root, ['merge-base', pr.baseOid, pr.headOid])
965 if (mb.exitCode !== 0 || !mb.stdout.trim()) throw new Error(`PR #${pr.number} has no merge base (${pr.base}…${pr.head}).`)
966 return { kind, sha: mb.stdout.trim(), label: `PR #${pr.number} merge base · ${pr.base}…${pr.head}` }
967}
968
969/** Makes one commit available locally. The fetch gets objects only: no ref, no FETCH_HEAD, no working file changes. */
970async function ensure($: E, root: string, sha: string, pr: Pr, ref: string) {
971 const has = async () => (await git$($, root, ['cat-file', '-e', `${sha}^{commit}`])).exitCode === 0
972 if (await has()) return
973 const remote = remoteFor((await git$($, root, ['remote', '-v'])).stdout, pr.url)
974 let why = ''
975 for (const what of [sha, ref]) {
976 const r = await git$($, root, ['fetch', '--no-tags', '--no-write-fetch-head', remote, what], 60000)
977 if (await has()) return
978 why = first(r.stderr)
979 }
980 throw new Error(`Commit ${sha.slice(0, 8)} is not in the local repository and the fetch from ${remote} failed: ${why}`)
981}
982
983async function loadChanges($: E, root: string, base: Base) {
984 const [d, u] = await Promise.all([
985 git$($, root, ['diff', '--name-status', '-M', '-z', '--relative', base.sha]),
986 git$($, root, ['ls-files', '-o', '--exclude-standard', '-z']),
987 ])
988 const list = d.exitCode === 0 ? parseNameStatus(d.stdout) : []
989 if (u.exitCode === 0) for (const p of u.stdout.split('\0').filter(Boolean).slice(0, 2000)) list.push({ path: p, code: '?' })
990 changes = list.sort((a, b) => (a.path < b.path ? -1 : 1))
991}
992
993/** The pinned baseline; the first use pins HEAD and reads what changed against it. */
994async function ensureBase($: E, n: Nav): Promise<Base> {
995 if (n.base) return n.base
996 if (!git) throw new Error('No Git repository contains this root.')
997 const base = await resolveBase($, n.root, 'head', null)
998 await put($, { base, want: 'head' })
999 await loadChanges($, n.root, base)
1000 return base
1001}
1002
1003async function loadDiff($: E, n: Nav, rel: string) {
1004 const my = ++seq.diff
1005 const d: DiffDoc = { path: rel, state: 'info', rows: [], pairs: [], at: [], numW: 1, hunks: [], binary: false, cut: false, info: '' }
1006 const done = (next: DiffDoc) => {
1007 if (my !== seq.diff) return
1008 diff = next
1009 dtop = Math.min(dtop, Math.max(0, dlen() - 1))
1010 refind(cur)
1011 void resize($)
1012 redraw($)
1013 }
1014 if (!git) return done({ ...d, info: 'No Git repository contains this root. Diff is not available. Code view and search work.' })
1015 try {
1016 const base = await ensureBase($, n)
1017 const old = changes.find(c => c.path === rel)?.old
1018 const badge = badgeOf(rel, false, status)
1019 const isNew =
1020 (badge === '?' || badge === '!') &&
1021 (await git$($, n.root, ['cat-file', '-e', `${base.sha}:${git.prefix}${rel}`])).exitCode !== 0
1022 const r = isNew // not in the index and not in the baseline: every line is an addition
1023 ? await git$($, n.root, ['diff', '--no-color', '--no-ext-diff', `-U${n.ctx}`, '--no-index', '--', '/dev/null', rel])
1024 : await git$($, n.root, ['diff', '--no-color', '--no-ext-diff', '-M', `-U${n.ctx}`, base.sha, '--', ...(old ? [old] : []), rel])
1025 if (r.exitCode > 1) return done({ ...d, info: `git diff failed: ${first(r.stderr)}` })
1026 const p = parseDiff(r.stdout, MAX_LINE)
1027 const numW = String(p.rows.reduce((m, row) => Math.max(m, row.o ?? 0, row.n ?? 0), 1)).length
1028 done({ ...d, ...p, ...pairRows(p.rows), numW, state: p.rows.length ? 'ok' : 'same', cut: r.isStdoutTruncated })
1029 } catch (err) {
1030 done({ ...d, info: text(err) })
1031 }
1032}
1033
1034async function gh($: E, root: string, args: string[]): Promise<Pr[]> {
1035 const r = await $.process.run(['gh', 'pr', ...args, '--json', PR_FIELDS], { cwd: root, timeoutMs: 30000 }).catch(() => {
1036 throw new Error('The GitHub CLI (gh) did not start. Install it to compare against pull requests. Local comparison still works.')
1037 })
1038 if (r.exitCode !== 0) throw new Error(ghError(r.stderr))
1039 return parsePrs(r.stdout)
1040}
1041
1042async function discover($: E, n: Nav) {
1043 prs = { ...prs, loading: true, error: '' }
1044 redraw($)
1045 try {
1046 const branch = git?.branch ?? ''
1047 const [mine, recent] = await Promise.all([
1048 branch ? gh($, n.root, ['list', '--head', branch, '--state', 'all', '--limit', '30']) : Promise.resolve([]),
1049 gh($, n.root, ['list', '--state', 'all', '--limit', '30', '--search', 'sort:updated-desc', ...(n.myPrs ? ['--author', '@me'] : [])]),
1050 ])
1051 prs = { branch: mine.filter(p => p.head === branch), recent, error: '', loading: false, loaded: true }
1052 } catch (err) {
1053 prs = { branch: [], recent: [], error: text(err), loading: false, loaded: false }
1054 }
1055 redraw($)
1056}
1057
1058/** Selects a baseline. On any failure the pinned baseline stays as it was and the reason is shown. */
1059async function chooseBase($: E, kind: Base['kind'], pick?: Pr) {
1060 const n = await get($)
1061 let pr = pick ?? n.pr
1062 note = ''
1063 try {
1064 if (!git) throw new Error('No Git repository contains this directory.')
1065 if (kind !== 'head' && kind !== 'prev' && !pr) {
1066 await discover($, n)
1067 if (prs.error) throw new Error(prs.error)
1068 const only = prs.branch.length === 1 ? prs.branch[0] : undefined
1069 if (!only) {
1070 note = prs.branch.length
1071 ? `${prs.branch.length} pull requests match branch ${git.branch}. Select one.`
1072 : `No pull request found for ${git.branch ? `branch ${git.branch}` : 'a detached HEAD'}. Select one from the recent list, or keep the current baseline.`
1073 pickTop = 0
1074 choosing = true
1075 await put($, { want: kind })
1076 return redraw($)
1077 }
1078 pr = only
1079 }
1080 const base = await resolveBase($, n.root, kind, pr)
1081 choosing = false
1082 const next = await put($, { base, pr, picker: false, want: kind })
1083 await loadChanges($, next.root, base)
1084 build(next)
1085 if (next.selected) await loadDiff($, next, next.selected)
1086 redraw($)
1087 } catch (err) {
1088 note = `The baseline did not change. ${text(err)}`
1089 redraw($)
1090 }
1091}
1092
1093async function refreshBase($: E) {
1094 const n = await get($)
1095 const kind = n.base?.kind ?? 'head'
1096 if ((kind === 'head' || kind === 'prev') || !n.pr) return chooseBase($, kind)
1097 try {
1098 const [pr] = await gh($, n.root, ['view', String(n.pr.number)])
1099 return chooseBase($, kind, pr ?? n.pr)
1100 } catch (err) {
1101 note = `The baseline did not change. ${text(err)}`
1102 redraw($)
1103 }
1104}
1105
1106/** Opens or closes the baseline chooser; the pull requests are read the first time it opens. */
1107async function toggleChooser($: E, p: Partial<Nav> = {}) {
1108 choosing = p.myPrs === undefined ? !choosing : true
1109 pickTop = 0
1110 const n = await put($, { listing: false, ...p })
1111 redraw($)
1112 if (choosing && git && (!prs.loaded || p.myPrs !== undefined)) await discover($, n)
1113 redraw($)
1114}
1115
1116// ---------------------------------------------------------------- search
1117
1118async function cancel(slot: 'grep' | 'filt') {
1119 seq[slot]++
1120 if (slot === 'grep') grep.running = false
1121 else filt.running = false
1122 await kids[slot]?.return({ code: null, signal: null }).catch(() => undefined)
1123}
1124
1125/** Reads a child's lines as they come. Leaving the loop kills the child, so a cancel or a cap stops the work. */
1126async function stream($: E, slot: 'grep' | 'filt', argv: string[], cwd: string, my: number, onLine: (line: string) => boolean): Promise<string> {
1127 const child = $.process.spawn({ argv, cwd })
1128 kids[slot] = child
1129 let buf = ''
1130 let err = ''
1131 let stop = false
1132 for await (const c of child) {
1133 if (my !== seq[slot]) return err
1134 if (c.stream === 'stderr') {
1135 if (err.length < 2000) err += c.text
1136 continue
1137 }
1138 const parts = (buf + c.text).split('\n')
1139 buf = parts.pop() ?? ''
1140 for (const p of parts) {
1141 if (p && !onLine(p)) {
1142 stop = true
1143 break
1144 }
1145 }
1146 build(cur)
1147 redraw($) // progressive results; the engine folds redraws that come too fast
1148 if (stop) break
1149 }
1150 if (!stop && my === seq[slot] && buf) onLine(buf)
1151 return err
1152}
1153
1154async function runGrep($: E, q: string) {
1155 await cancel('grep')
1156 const my = seq.grep
1157 const n = await put($, { grep: { ...cur.grep, q }, treeTop: 0 })
1158 grep = { q, running: q !== '', groups: new Map(), hits: 0, capped: false, error: '', tool: 'rg' }
1159 build(n)
1160 redraw($)
1161 if (!q) return
1162 const inc = splitGlobs(n.grep.include)
1163 const exc = splitGlobs(n.grep.exclude)
1164 const rg = ['rg', '--line-number', '--no-heading', '--color', 'never', '--null', '--smart-case', '--fixed-strings', '--max-columns', '300', '--max-columns-preview']
1165 if (n.grep.hidden) rg.push('--hidden')
1166 if (n.grep.ignored) rg.push('--no-ignore')
1167 rg.push(...inc.flatMap(g => ['-g', g]), ...exc.flatMap(g => ['-g', `!${g}`]), '-g', '!.git/', '-e', q, '.')
1168 const onLine = (rec: string) => {
1169 const h = parseHit(rec)
1170 if (!h) return true
1171 const list = grep.groups.get(h.file)
1172 if (list) list.push(h)
1173 else grep.groups.set(h.file, [h])
1174 grep.capped = ++grep.hits >= HIT_CAP
1175 return !grep.capped
1176 }
1177 let err = ''
1178 try {
1179 err = await stream($, 'grep', rg, n.root, my, onLine)
1180 } catch (e1) {
1181 if (grep.hits) err = text(e1)
1182 else {
1183 // rg did not start. grep reads hidden and ignored files always, and the scope line says so.
1184 grep.tool = 'grep'
1185 const smart = q === q.toLowerCase() ? ['-i'] : []
1186 const argv = ['grep', '-rnI', '--null', '-F', ...smart, '--exclude-dir=.git', ...inc.map(g => `--include=${g}`), ...exc.map(g => `--exclude=${g}`), '-e', q, '.']
1187 err = await stream($, 'grep', argv, n.root, my, onLine).catch(e2 => `Neither rg nor grep started: ${text(e2)}`)
1188 }
1189 }
1190 if (my !== seq.grep) return
1191 grep.running = false
1192 if (!grep.hits && err) grep.error = first(err)
1193 build(cur)
1194 redraw($)
1195}
1196
1197function hiddenByFilters(p: string, n: Nav): boolean {
1198 if (n.hide.dot && p.split('/').some(s => s.startsWith('.'))) return true
1199 if (!n.hide.ignored && !n.hide.untracked) return false
1200 const b = badgeOf(p, false, status)hooks/lib.ts 441 lines1// Pure logic of the file explorer: no `$`, no I/O. The hooks module does the reading.
2import type { Pr } from '../types'
3
4export type Kind = 'file' | 'dir' | 'other'
5export type Entry = { name: string; kind: Kind; isLink: boolean; size: number; target?: string; loop?: boolean }
6export type Dir = { entries: Entry[]; error?: string }
7export type Row = {
8 key: string
9 path: string
10 depth: number
11 kind: Kind | 'note' | 'head' | 'hit' | 'chg'
12 label: string
13 badge?: string
14 open?: boolean
15 error?: string
16 line?: number
17}
18export type Status = { map: Map<string, string>; dirty: Set<string> }
19export type Hide = { dot: boolean; ignored: boolean; untracked: boolean }
20export type Change = { path: string; code: string; old?: string }
21export type DiffRow = { t: 'meta' | 'hunk' | 'ctx' | 'add' | 'del'; o?: number; n?: number; s: string }
22export type Hit = { line: number; s: number; e: number }
23
24export const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name)
25export const dirname = (p: string) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')
26export const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1)
27export const isAncestor = (a: string, b: string) => a === b || b.startsWith(a.endsWith('/') ? a : `${a}/`)
28export const emptyStatus = (): Status => ({ map: new Map(), dirty: new Set() })
29
30/** Directories first, then files, each alphabetical without regard to case. */
31export function sortEntries<T extends { name: string; kind: string }>(list: readonly T[]): T[] {
32 return [...list].sort((a, b) => {
33 const d = Number(b.kind === 'dir') - Number(a.kind === 'dir')
34 if (d) return d
35 const x = a.name.toLowerCase()
36 const y = b.name.toLowerCase()
37 return x < y ? -1 : x > y ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0
38 })
39}
40
41export function statusCode(xy: string): string {
42 if (xy === '??') return '?'
43 if (xy === '!!') return '!'
44 if (xy.includes('U') || xy === 'AA' || xy === 'DD') return 'U'
45 if (xy.includes('R')) return 'R'
46 if (xy.includes('D')) return 'D'
47 if (xy.includes('A')) return 'A'
48 return 'M'
49}
50
51/** Reads `git status --porcelain=v1 -z`; `prefix` is the root's path inside the repository (`sub/dir/` or ``). */
52export function parseStatus(z: string, prefix: string): Status {
53 const s = emptyStatus()
54 const parts = z.split('\0')
55 for (let i = 0; i < parts.length; i++) {
56 const rec = parts[i] ?? ''
57 if (rec.length < 4) continue
58 const xy = rec.slice(0, 2)
59 const full = rec.slice(3)
60 if (xy.includes('R') || xy.includes('C')) i++ // the original path follows
61 const code = statusCode(xy)
62 if (full.endsWith('/') && prefix.startsWith(full)) {
63 s.map.set('', code) // the root itself is inside an untracked or ignored directory
64 continue
65 }
66 if (!full.startsWith(prefix)) continue
67 const path = full.slice(prefix.length).replace(/\/$/, '')
68 if (!path) continue
69 s.map.set(path, code)
70 if (code !== '!') for (let d = dirname(path); d; d = dirname(d)) s.dirty.add(d)
71 }
72 return s
73}
74
75/** The badge of one entry: its own code, an untracked or ignored ancestor's, or a dot for a directory with changes. */
76export function badgeOf(path: string, isDir: boolean, s: Status): string {
77 const own = s.map.get(path)
78 if (own) return own
79 for (let d = dirname(path); ; d = dirname(d)) {
80 const up = s.map.get(d)
81 if (up === '?' || up === '!') return up
82 if (!d) break
83 }
84 return isDir && s.dirty.has(path) ? '•' : ''
85}
86
87const note = (dir: string, depth: number, label: string): Row => ({
88 key: `note:${dir}`,
89 path: dir,
90 depth,
91 kind: 'note',
92 label,
93})
94
95/** The visible tree as rows, children under each expanded directory that is loaded. */
96export function flatten(dirs: Map<string, Dir>, expanded: Set<string>, hide: Hide, s: Status): Row[] {
97 const out: Row[] = []
98 const walk = (dir: string, depth: number) => {
99 const d = dirs.get(dir)
100 if (!d) return void out.push(note(dir, depth, 'loading…'))
101 if (d.error !== undefined) {
102 if (!dir) out.push(note(dir, depth, `⚠ ${d.error}`)) // a child's error is on its own row
103 return
104 }
105 let shown = 0
106 for (const e of d.entries) {
107 const path = join(dir, e.name)
108 const isDir = e.kind === 'dir'
109 const badge = badgeOf(path, isDir, s)
110 if ((hide.dot && e.name.startsWith('.')) || (hide.ignored && badge === '!') || (hide.untracked && badge === '?'))
111 continue
112 shown++
113 const open = isDir && !e.loop && expanded.has(path)
114 const link = e.isLink ? `${e.loop ? ' ⟲ loop' : ''} → ${e.target ?? '(broken link)'}` : ''
115 out.push({ key: `row:${path}`, path, depth, kind: e.kind, label: e.name + link, badge, open, error: dirs.get(path)?.error })
116 if (open) walk(path, depth + 1)
117 }
118 if (!shown) out.push(note(dir, depth, d.entries.length ? '(all entries hidden by filters)' : '(empty)'))
119 }
120 walk('', 0)
121 return out
122}
123
124/** Matched file paths as a tree: every ancestor directory is kept so a result keeps its location. */
125export function rowsFromPaths(paths: readonly string[], s: Status): Row[] {
126 type Node = { dirs: Map<string, Node>; files: string[] }
127 const root: Node = { dirs: new Map(), files: [] }
128 for (const p of paths) {
129 const parts = p.split('/')
130 let n = root
131 for (const part of parts.slice(0, -1)) {
132 let next = n.dirs.get(part)
133 if (!next) n.dirs.set(part, (next = { dirs: new Map(), files: [] }))
134 n = next
135 }
136 n.files.push(parts[parts.length - 1] ?? p)
137 }
138 const out: Row[] = []
139 const walk = (n: Node, dir: string, depth: number) => {
140 const list = [
141 ...[...n.dirs.keys()].map(name => ({ name, kind: 'dir' as const })),
142 ...n.files.map(name => ({ name, kind: 'file' as const })),
143 ]
144 for (const e of sortEntries(list)) {
145 const path = join(dir, e.name)
146 const isDir = e.kind === 'dir'
147 out.push({ key: `row:${path}`, path, depth, kind: e.kind, label: e.name, badge: badgeOf(path, isDir, s), open: isDir })
148 const child = n.dirs.get(e.name)
149 if (isDir && child) walk(child, path, depth + 1)
150 }
151 }
152 walk(root, '', 0)
153 return out
154}
155
156/** Reads `git diff --name-status -M -z`: `M\0path\0`, and for a rename `R100\0old\0new\0`. */
157export function parseNameStatus(z: string): Change[] {
158 const p = z.split('\0')
159 const out: Change[] = []
160 for (let i = 0; i < p.length; ) {
161 const st = p[i] ?? ''
162 const a = p[i + 1]
163 const b = p[i + 2]
164 if (!st || a === undefined) break
165 if ((st[0] === 'R' || st[0] === 'C') && b !== undefined) {
166 out.push({ code: st[0], old: a, path: b })
167 i += 3
168 } else {
169 out.push({ code: st[0] === 'T' ? 'M' : (st[0] ?? 'M'), path: a })
170 i += 2
171 }
172 }
173 return out
174}
175
176/** One line made safe to draw: no carriage return, no control character but tab. */
177export const clean = (s: string) => s.replace(/\r$/, '').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '�')
178
179/** Cuts a very long line and says so in the line itself: never a silent cut. */
180export function clampLine(s: string, max: number): string {
181 if (s.length <= max) return s
182 const c = s.charCodeAt(max - 1)
183 const cut = c >= 0xd800 && c <= 0xdbff ? max - 1 : max
184 return `${s.slice(0, cut)} …[+${s.length - cut} chars not shown]`
185}
186
187export function splitLines(text: string, max: number): string[] {
188 const lines = text.split('\n')
189 if (lines[lines.length - 1] === '') lines.pop()
190 return lines.map(l => clampLine(clean(l), max))
191}
192
193/** Unified diff text to rows with old and new line numbers. Hunk sizes decide where a hunk ends. */
194export function parseDiff(text: string, max = 2000): { rows: DiffRow[]; hunks: number[]; binary: boolean } {
195 const rows: DiffRow[] = []
196 const hunks: number[] = []
197 let o = 0
198 let n = 0
199 let left = 0
200 let right = 0
201 let binary = false
202 for (const line of splitLines(text, max + 1)) {
203 const c = line[0]
204 if ((left > 0 || right > 0) && (c === ' ' || c === '+' || c === '-' || line === '')) {
205 const s = line.slice(1)
206 if (c === '+') (rows.push({ t: 'add', n: n++, s }), right--)
207 else if (c === '-') (rows.push({ t: 'del', o: o++, s }), left--)
208 else (rows.push({ t: 'ctx', o: o++, n: n++, s }), left--, right--)
209 continue
210 }
211 const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
212 if (m) {
213 o = Number(m[1])
214 left = m[2] === undefined ? 1 : Number(m[2])
215 n = Number(m[3])
216 right = m[4] === undefined ? 1 : Number(m[4])
217 hunks.push(rows.length)
218 rows.push({ t: 'hunk', s: line })
219 continue
220 }
221 if (/^Binary files .* differ$/.test(line) || line === 'GIT binary patch') binary = true
222 // the blob ids and the ---/+++ pair repeat what `diff --git` and the rename lines say: rows are scarce
223 if (!/^(index [0-9a-f]+\.\.|--- |\+\+\+ )/.test(line)) rows.push({ t: 'meta', s: line })
224 }
225 return { rows, hunks, binary }
226}
227
228/** One line of the side-by-side view: the row on the old side and the row on the new side, as indexes of the diff rows. */
229export type Pair = { l?: number; r?: number }
230
231/**
232 * Diff rows as side-by-side pairs: a removed line is on the left, and the added line that replaces it
233 * is on the right of the same pair. `at[i]` is the pair that holds row `i`.
234 */
235export function pairRows(rows: readonly DiffRow[]): { pairs: Pair[]; at: number[] } {
236 const pairs: Pair[] = []
237 const at: number[] = []
238 let open: number[] = [] // pairs that have a removed line and no added line yet
239 rows.forEach((row, i) => {
240 const p = row.t === 'add' ? open.shift() : undefined
241 const to = p === undefined ? undefined : pairs[p]
242 if (to) to.r = i
243 else pairs.push(row.t === 'del' ? { l: i } : row.t === 'add' ? { r: i } : { l: i, r: i })
244 at.push(p ?? pairs.length - 1)
245 if (row.t === 'del') open.push(pairs.length - 1)
246 // a meta row here is "\ No newline at end of file": it sits between the two sides of one change
247 else if (row.t !== 'add' && row.t !== 'meta') open = []
248 })
249 return { pairs, at }
250}
251
252export function findMatches(
253 lines: readonly string[],
254 q: string,
255 o: { cs: boolean; re: boolean },
256 cap = 5000,
257): { hits: Hit[]; capped: boolean; error?: string } {
258 if (!q) return { hits: [], capped: false }
259 let rx: RegExp
260 try {
261 rx = new RegExp(o.re ? q : q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), o.cs ? 'g' : 'gi')
262 } catch (err) {
263 const why = err instanceof Error ? err.message : String(err)
264 return { hits: [], capped: false, error: why.startsWith('Invalid') ? why : `Invalid regular expression: ${why}` }
265 }
266 // ponytail: no regex timeout; a pathological pattern on a long line overruns the hook budget and is dropped
267 const hits: Hit[] = []
268 for (let i = 0; i < lines.length; i++) {
269 const line = lines[i] ?? ''
270 rx.lastIndex = 0
271 for (let m = rx.exec(line); m; m = rx.exec(line)) {
272 if (m[0].length === 0) {
273 if (++rx.lastIndex > line.length) break
274 continue
275 }
276 hits.push({ line: i, s: m.index, e: m.index + m[0].length })
277 if (hits.length >= cap) return { hits, capped: true }
278 }
279 }
280 return { hits, capped: false }
281}
282
283/** How many lines from `top` fit in `rows` screen rows and in the character budget of one drawing. */
284export function fit(
285 len: (i: number) => number,
286 total: number,
287 top: number,
288 rows: number,
289 width: number,
290 wrap: boolean,
291 budget = 9000,
292): number {
293 let used = 0
294 let chars = 0
295 let count = 0
296 for (let i = top; i < total && used < rows; i++) {
297 const l = len(i)
298 const need = wrap ? Math.max(1, Math.ceil(l / Math.max(1, width))) : 1
299 if (count && (used + need > rows || chars + l + 1 > budget)) break
300 used += need
301 chars += l + 1
302 count++
303 }
304 return count
305}
306
307/** One `rg --null -n` or `grep --null -n` record: `path\0line:text`. */
308export function parseHit(rec: string): { file: string; line: number; text: string } | null {
309 const z = rec.indexOf('\0')
310 const rest = rec.slice(z + 1)
311 const c = rest.indexOf(':')
312 const line = Number(rest.slice(0, c))
313 if (z < 0 || c < 1 || !Number.isInteger(line)) return null
314 return { file: rec.slice(0, z).replace(/^\.\//, ''), line, text: clean(rest.slice(c + 1)).trim().slice(0, 200) }
315}
316
317export const splitGlobs = (s: string) => s.split(/[,\s]+/).filter(Boolean)
318
319export const fmtSize = (n: number) =>
320 n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`
321
322/** `gh pr list --json ...` or `gh pr view --json ...` to PRs, most recently updated first. */
323export function parsePrs(json: string): Pr[] {
324 const data: unknown = JSON.parse(json)
325 const list: unknown[] = Array.isArray(data) ? data : [data]
326 const str = (v: unknown) => (typeof v === 'string' ? v : '')
327 return list
328 .filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null)
329 .map(p => ({
330 number: Number(p.number),
331 title: str(p.title),
332 state: str(p.state),
333 head: str(p.headRefName),
334 headOid: str(p.headRefOid),
335 base: str(p.baseRefName),
336 baseOid: str(p.baseRefOid),
337 updated: str(p.updatedAt),
338 url: str(p.url),
339 author: str((p.author as { login?: unknown } | null)?.login),
340 }))
341 .filter(p => Number.isInteger(p.number) && p.headOid !== '')
342 .sort((a, b) => (a.updated < b.updated ? 1 : a.updated > b.updated ? -1 : 0))
343}
344
345/** Why `gh` failed, in words the person can act on; the first line of its own message is kept. */
346export function ghError(stderr: string): string {
347 const line = stderr.trim().split('\n')[0] ?? ''
348 if (/auth login|not logged in|authentication|HTTP 401/i.test(stderr))
349 return `GitHub CLI is not authenticated. Run "gh auth login". (${line})`
350 if (/could not resolve host|dial tcp|network|timeout|connection|no such host/i.test(stderr))
351 return `GitHub is not reachable. Local comparison still works. (${line})`
352 if (/none of the git remotes|not a github|no git remotes/i.test(stderr))
353 return `This repository has no GitHub remote that gh knows. (${line})`
354 return `gh failed: ${line || 'no message'}`
355}
356
357/** The remote whose URL names the PR's repository, else that repository's own URL. */
358export function remoteFor(remotes: string, prUrl: string): string {
359 const m = /^(https?:\/\/[^/]+)\/([^/]+\/[^/]+)\/pull\//.exec(prUrl)
360 if (!m) return 'origin'
361 const slug = (m[2] ?? '').toLowerCase()
362 for (const line of remotes.split('\n')) {
363 const [name, url = ''] = line.split(/\s+/)
364 const u = url.toLowerCase().replace(/\.git$/, '')
365 if (name && (u.endsWith(`/${slug}`) || u.endsWith(`:${slug}`))) return name
366 }
367 return `${m[1]}/${m[2]}.git`
368}
369
370// ---------------------------------------------------------------- file types and folder colors
371
372/** A language by file name: a two-letter mark and the color the language is known by. */
373const LANGS: [RegExp, string, string][] = [
374 [/\.(ts|tsx|mts|cts)$/, 'TS', '#3178c6'],
375 [/\.(js|jsx|mjs|cjs)$/, 'JS', '#f1e05a'],
376 [/\.py$/, 'PY', '#4b8bbe'],
377 [/\.rs$/, 'RS', '#dea584'],
378 [/\.go$/, 'GO', '#00add8'],
379 [/\.swift$/, 'SW', '#f05138'],
380 [/\.(kt|kts)$/, 'KT', '#a97bff'],
381 [/\.java$/, 'JV', '#e76f00'],
382 [/\.(c|h)$/, 'C', '#a8b9cc'],
383 [/\.(cc|cpp|cxx|hpp|hh|mm|m)$/, 'C+', '#f34b7d'],
384 [/\.cs$/, 'C#', '#68b723'],
385 [/\.rb$/, 'RB', '#cc342d'],
386 [/\.php$/, 'PH', '#8892bf'],
387 [/\.(html|htm|xml|svg)$/, '<>', '#e34c26'],
388 [/\.(css|scss|sass|less)$/, '#', '#a86ed4'],
389 [/\.(vue)$/, 'VU', '#41b883'],
390 [/\.(svelte)$/, 'SV', '#ff3e00'],
391 [/\.(sh|bash|zsh|fish)$/, '$', '#89e051'],
392 [/\.(sql)$/, 'SQ', '#e38c00'],
393 [/\.(dart)$/, 'DT', '#00b4ab'],
394 [/\.(lua)$/, 'LU', '#51a0cf'],
395 [/\.(zig)$/, 'ZG', '#ec915c'],
396 [/\.(ex|exs)$/, 'EX', '#a074c4'],
397 [/\.(md|mdx|rst)$/, 'MD', '#519aba'],
398]
399/** Files that are not a language: they have a mark, and do not count for a folder's color. */
400const OTHERS: [RegExp, string, string][] = [
401 [/\.(json|jsonl|json5)$/, '{}', '#cbcb41'],
402 [/\.(ya?ml)$/, 'YM', '#cb4b16'],
403 [/\.(toml|ini|conf|cfg|env)$|^\.[^.]+$/, '⚙', '#8a8f98'],
404 [/^(Makefile|Dockerfile|Justfile)$/, '$', '#89e051'],
405 [/\.(png|jpe?g|gif|webp|ico|pdf|mp4|mov|mp3|wav)$/, '▣', '#c586c0'],
406 [/\.(zip|gz|tar|tgz|bin|woff2?|ttf|otf|dmg)$/, '▤', '#8a8f98'],
407 [/\.lock$|^LICENSE|\.txt$|\.log$/, '≡', '#8a8f98'],
408]
409/** A folder with no language in it takes a color from its name, as icon themes do. */
410const FOLDERS: [RegExp, string][] = [
411 [/^\.git$/, '#f14e32'],
412 [/^node_modules$|^vendor$|^\.venv$|^venv$/, '#6a9955'],
413 [/^(dist|build|out|target|coverage|\.next|\.cache)$/, '#8a8f98'],
414 [/^(test|tests|__tests__|spec|e2e)$/, '#d7ba7d'],
415 [/^(docs?|documentation)$/, '#519aba'],
416 [/^(public|assets|static|images?|img|media)$/, '#c586c0'],
417 [/^(scripts?|bin|tools)$/, '#89e051'],
418 [/^\./, '#7aa2f7'],
419]
420const FOLDER = '#dcb67a'
421
422export function typeOf(name: string): { mark: string; color: string; isLang: boolean } {
423 for (const [rx, mark, color] of LANGS) if (rx.test(name)) return { mark, color, isLang: true }
424 for (const [rx, mark, color] of OTHERS) if (rx.test(name)) return { mark, color, isLang: false }
425 return { mark: '≡', color: '#8a8f98', isLang: false }
426}
427
428/** The color of a folder's icon: the language most of its files are in, else a color for its name. */
429export function folderTint(files: readonly string[], folder: string): string {
430 const count = new Map<string, number>()
431 for (const f of files) {
432 const t = typeOf(f)
433 if (t.isLang) count.set(t.color, (count.get(t.color) ?? 0) + 1)
434 }
435 let best = ''
436 for (const [color, n] of count) if (n > (count.get(best) ?? 0)) best = color
437 if (best) return best
438 for (const [rx, color] of FOLDERS) if (rx.test(folder)) return color
439 return FOLDER
440}
441hooks/tree.tsx 116 lines1// The tree: a surface module. It draws the rows in color, follows the pointer, and takes the arrow keys
2// after a click. It holds no data of its own: every action is a post to the hooks module, which answers
3// with new rows. It has no `$`.
4import type { ClientModule } from 'claude-code'
5
6/** One row: its key, depth, kind, label, Git badge, open state, error, icon mark and icon color. */
7export type TreeRow = { k: string; d: number; t: string; l: string; b?: string; o?: boolean; e?: string; m: string; c: string }
8type Props = { rows: TreeRow[]; start: number; total: number; cursor: string; selected: string; cols: number; lines: number; empty: string }
9type State = { hover: number }
10
11// name and badge colors by Git state, as an editor's explorer shows them
12const TINT: Record<string, string> = { M: '#e2c08d', A: '#73c991', '?': '#73c991', D: '#f14c4c', U: '#f14c4c', R: '#7aa2f7', '•': '#e2c08d' }
13
14const Tree: ClientModule<Props, State> = (props, surface) => {
15 const { Box, Text } = surface.elements
16 const hover = surface.state?.hover ?? -1
17 const H = surface.rows || props.lines
18 const W = surface.columns || props.cols
19 const at = props.rows.findIndex(r => r.k === props.cursor)
20 const row = (i: number) => props.rows[i]
21 // an absolute row number, so a move that passes the rows drawn here still names its target
22 const to = (abs: number) => surface.post({ to: Math.max(0, Math.min(props.total - 1, abs)) })
23
24 surface.onPointer(p => {
25 if (p.type === 'leave') return hover === -1 ? undefined : surface.setState({ hover: -1 })
26 if (p.type === 'move' && p.y !== hover) return surface.setState({ hover: p.y })
27 const r = row(p.y)
28 if (p.type !== 'down' || !r || r.t === 'note') return
29 // the right button selects the row and shows the actions for it, as a context menu does
30 surface.post(p.button === 'right' ? { menu: r.k } : { press: r.k })
31 })
32 surface.onKey(k => {
33 const r = row(at)
34 const abs = props.start + Math.max(0, at)
35 if (k.ctrl || k.meta) return
36 if (k.key === 'up') return to(at < 0 ? props.start : abs - 1)
37 if (k.key === 'down') return to(at < 0 ? props.start : abs + 1)
38 if (k.key === 'pageup') return to(abs - H)
39 if (k.key === 'pagedown') return to(abs + H)
40 if (k.key === 'home') return to(0)
41 if (k.key === 'end') return to(props.total - 1)
42 if (!r) return
43 if (k.key === 'return' || k.key === 'enter' || k.key === 'space' || k.key === ' ') return surface.post({ press: r.k })
44 // as in an editor's explorer: Right opens a folder, then steps in; Left closes it, then goes to the parent
45 if (k.key === 'right') return r.t === 'dir' && !r.o ? surface.post({ fold: r.k, open: true }) : to(abs + 1)
46 if (k.key === 'left') return r.t === 'dir' && r.o ? surface.post({ fold: r.k, open: false }) : surface.post({ parent: r.k })
47 })
48
49 if (!props.rows.length)
50 return (
51 <Box height={H}>
52 <Text dimColor>{props.empty}</Text>
53 </Box>
54 )
55 return (
56 <Box flexDirection="column" width={W} height={H}>
57 {props.rows.slice(0, H).map((r, i) => {
58 const guides = '│ '.repeat(r.d) // a line per level, so the eye follows the nesting
59 if (r.t === 'note')
60 return (
61 <Box height={1} overflow="hidden">
62 <Text dimColor italic wrap="truncate-end">
63 {guides} {r.l}
64 </Text>
65 </Box>
66 )
67 const isDir = r.t === 'dir' || r.t === 'head'
68 // a folder: its arrow, then a block in the color of its main language; a file: its type's mark
69 const arrow = isDir ? (r.o || r.t === 'head' ? '▾ ' : '▸ ') : r.t === 'hit' ? '' : ' '
70 const icon = r.t === 'hit' ? '' : r.t === 'dir' ? '■ ' : `${r.m.padEnd(2)} `
71 const badge = r.b ? ` ${r.b} ` : ''
72 const isOn = r.k === props.cursor || r.k === props.selected
73 if (isOn || i === hover) {
74 // the row under the cursor, the open file, and the pointer's row are one bar across the pane
75 const text = `${guides}${arrow}${icon}${r.l}${r.e ? ` ⚠ ${r.e}` : ''}`
76 const room = Math.max(1, W - badge.length)
77 return (
78 <Box height={1} overflow="hidden">
79 <Text inverse bold={isOn} color={isOn ? 'suggestion' : 'gray'} wrap="truncate-end">
80 {(text.length > room ? `${text.slice(0, room - 1)}…` : text.padEnd(room)) + badge}
81 </Text>
82 </Box>
83 )
84 }
85 const tint = r.b ? TINT[r.b] : undefined
86 return (
87 <Box height={1} overflow="hidden" justifyContent="space-between">
88 <Text wrap="truncate-end">
89 <Text dimColor>
90 {guides}
91 {arrow}
92 </Text>
93 <Text color={r.c} bold>
94 {icon}
95 </Text>
96 <Text bold={isDir} {...(tint ? { color: tint } : r.b === '!' ? { dimColor: true } : {})}>
97 {r.l}
98 </Text>
99 {r.e ? <Text color="error"> ⚠ {r.e}</Text> : null}
100 </Text>
101 {r.b ? (
102 <Box flexShrink={0}>
103 <Text bold {...(r.b === '!' ? { dimColor: true } : { color: tint ?? 'warning' })}>
104 {badge}
105 </Text>
106 </Box>
107 ) : null}
108 </Box>
109 )
110 })}
111 </Box>
112 )
113}
114
115export default Tree
116hooks/editor.tsx 275 lines1// The editor: a surface module. It runs on the drawing thread, holds the text while the person types, and
2// posts to the hooks module when the text first changes and when it is saved. It has no `$`.
3//
4// Long lines wrap at the width the editor has. Each drawn row is one line of a `Code` element, so the
5// engine's highlighter colors it; the cursor's row and rows with a find match are plain text instead,
6// so the cursor and the matches can show.
7import type { ClientModule, RenderElement } from 'claude-code'
8
9type Find = { q: string; cs: boolean; re: boolean; n: number; by: number }
10type Props = { id: string; text: string; path: string; line: number; rows: number; cols: number; save: number; undo: number; wheel: number; wheelBy: number; goto: number; gotoLine: number; find: Find }
11type Snap = { lines: string[]; row: number; col: number }
12type State = { id: string; lines: string[]; row: number; col: number; top: number; dirty: boolean; save: number; undo: number; wheel: number; goto: number; find: number; eol: string; tail: boolean; past: Snap[] }
13
14const TAB = 4
15const NAMED = /^(escape|insert|f\d{1,2}|clear|menu|pause|capslock|numlock|scrolllock|printscreen)$/
16const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
17const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff
18/** A line as drawn: a tab is spaces, any other control character a mark. */
19const shown = (s: string) => s.replace(/\t/g, ' '.repeat(TAB)).replace(/[\u0000-\u001f\u007f-\u009f]/g, '�')
20/** The string index of the character drawn at cell `x` of a line. */
21function indexAt(s: string, x: number): number {
22 let cell = 0
23 for (let i = 0; i < s.length; i++) {
24 const w = s[i] === '\t' ? TAB : 1
25 if (cell + w > x) return i
26 cell += w
27 }
28 return s.length
29}
30function matcher(f: Find): RegExp | null {
31 if (!f.q) return null
32 try {
33 return new RegExp(f.re ? f.q : f.q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), f.cs ? 'g' : 'gi')
34 } catch {
35 return null
36 }
37}
38/** Every match of a line, as cell ranges of the line as drawn. */
39function spans(rx: RegExp | null, text: string): [number, number][] {
40 const out: [number, number][] = []
41 if (!rx) return out
42 rx.lastIndex = 0
43 for (let m = rx.exec(text); m; m = rx.exec(text)) {
44 if (m[0].length === 0) {
45 if (++rx.lastIndex > text.length) break
46 continue
47 }
48 out.push([m.index, m.index + m[0].length])
49 }
50 return out
51}
52
53const Editor: ClientModule<Props, State> = (props, surface) => {
54 const { Box, Text, Code } = surface.elements
55 let st: State
56 if (surface.state?.id === props.id) st = surface.state
57 else {
58 const eol = props.text.includes('\r\n') ? '\r\n' : '\n'
59 const lines = props.text.split(/\r?\n/)
60 const tail = lines.length > 1 && lines[lines.length - 1] === ''
61 if (tail) lines.pop()
62 const row = clamp(props.line, 0, lines.length - 1)
63 st = { id: props.id, lines, row, col: 0, top: row, dirty: false, save: props.save, undo: props.undo, wheel: props.wheel, goto: props.goto, find: props.find.n, eol, tail: tail || props.text === '', past: [] }
64 // kept at once: without a state, each drawing would start from the props again and miss what they ask for
65 surface.setState(st)
66 }
67 const H = Math.max(2, (surface.rows || props.rows) - 1)
68 const W = Math.max(12, surface.columns || props.cols)
69 const numW = String(st.lines.length).length
70 const textW = Math.max(4, W - numW - 1)
71 const rx = matcher(props.find)
72 /** Screen rows a line takes at this width; the cursor's line keeps a cell for a cursor at its end. */
73 const tall = (s: State, i: number) => Math.max(1, Math.ceil((shown(s.lines[i] ?? '').length + (i === s.row ? 1 : 0)) / textW))
74 /** The first line to draw so that the cursor's row is on screen. */
75 const topFor = (s: State, row: number, top: number) => {
76 if (row < top) return row
77 let t = top
78 let used = 0
79 for (let i = t; i <= row; i++) used += tall({ ...s, row }, i)
80 while (used > H && t < row) used -= tall({ ...s, row }, t++)
81 return t
82 }
83 const body = (s: State) => s.lines.join(s.eol) + (s.tail ? s.eol : '')
84 const save = (s: State) => {
85 surface.post({ save: s.id, text: body(s) })
86 surface.setState({ ...s, dirty: false, save: props.save })
87 }
88 // Two keys can come in one frame, before this function runs again: each starts from what the last one left.
89 let latest = st
90 const move = (s: State, row: number, col: number, lines = s.lines, dirty = s.dirty) => {
91 const r = clamp(row, 0, lines.length - 1)
92 const c = clamp(col, 0, (lines[r] ?? '').length)
93 if (dirty && !s.dirty) surface.post({ dirty: s.id })
94 latest = { ...s, lines, row: r, col: c, top: topFor({ ...s, lines }, r, s.top), dirty }
95 surface.setState(latest)
96 }
97 // every change keeps what was there before it, so Undo can put it back; the oldest steps drop off
98 const edit = (s: State, lines: string[], row: number, col: number) => move({ ...s, past: [...s.past.slice(-299), { lines: s.lines, row: s.row, col: s.col }] }, row, col, lines, true)
99 const undo = (s: State) => {
100 const was = s.past[s.past.length - 1]
101 if (!was) return surface.setState({ ...s, undo: props.undo })
102 const next = { ...s, undo: props.undo, past: s.past.slice(0, -1), lines: was.lines, row: was.row, col: was.col }
103 const dirty = body(next) !== props.text // back at the text on disk: nothing is left to save
104 if (!dirty && s.dirty) surface.post({ clean: s.id })
105 latest = { ...next, dirty, top: topFor(next, was.row, s.top) }
106 surface.setState(latest)
107 }
108
109 // A changed number in the props is the hooks module asking: its Save button, the wheel, a line to go to,
110 // or the next or previous find match. One state change answers it; the state then holds the number.
111 if (st.save !== props.save) save(st)
112 else if (st.undo !== props.undo) undo(st)
113 else if (st.wheel !== props.wheel) {
114 st = { ...st, wheel: props.wheel, top: clamp(st.top + props.wheelBy, 0, Math.max(0, st.lines.length - 1)) }
115 surface.setState(st)
116 } else if (st.goto !== props.goto) {
117 const row = clamp(props.gotoLine, 0, st.lines.length - 1)
118 st = { ...st, goto: props.goto, row, col: 0, top: Math.max(0, row - 2) }
119 surface.setState(st)
120 } else if (st.find !== props.find.n) {
121 // from the cursor on, forward or back, and around the end of the file
122 const n = st.lines.length
123 const from = shown((st.lines[st.row] ?? '').slice(0, st.col)).length
124 let hit: [number, number] | null = null
125 for (let k = 0; k <= n && !hit; k++) {
126 const i = (((st.row + (props.find.by < 0 ? -k : k)) % n) + n) % n
127 const found = spans(rx, shown(st.lines[i] ?? ''))
128 // `by` 0 is a new find text: a match at the cursor stays the match while the person types on
129 const usable = k === 0 ? found.filter(([s]) => (props.find.by < 0 ? s < from : props.find.by > 0 ? s > from : s >= from)) : found
130 const pick = props.find.by < 0 ? usable[usable.length - 1] : usable[0]
131 if (pick) hit = [i, indexAt(st.lines[i] ?? '', pick[0])]
132 }
133 st = { ...st, find: props.find.n, ...(hit ? { row: hit[0], col: hit[1], top: Math.max(0, hit[0] - 2) } : {}) }
134 surface.setState(st)
135 }
136
137 latest = st
138 surface.onKey(k => {
139 const s = latest
140 const cur = s.lines[s.row] ?? ''
141 const set = (at: number, ...next: string[]) => [...s.lines.slice(0, at), ...next, ...s.lines.slice(at + 1)]
142 if (k.ctrl && k.key === 's') return save(s) // ctrl+z never comes here: the host suspends Claude Code on it
143 if (k.ctrl || k.meta) return
144 switch (k.key) {
145 case 'up':
146 return move(s, s.row - 1, s.col)
147 case 'down':
148 return move(s, s.row + 1, s.col)
149 case 'left':
150 if (s.col === 0) return s.row > 0 ? move(s, s.row - 1, (s.lines[s.row - 1] ?? '').length) : undefined
151 return move(s, s.row, s.col - (isLow(cur.charCodeAt(s.col - 1)) ? 2 : 1))
152 case 'right':
153 if (s.col >= cur.length) return s.row < s.lines.length - 1 ? move(s, s.row + 1, 0) : undefined
154 return move(s, s.row, s.col + (isLow(cur.charCodeAt(s.col + 1)) ? 2 : 1))
155 case 'home':
156 return move(s, s.row, 0)
157 case 'end':
158 return move(s, s.row, cur.length)
159 case 'pageup':
160 return move(s, s.row - H, s.col)
161 case 'pagedown':
162 return move(s, s.row + H, s.col)
163 case 'return':
164 case 'enter': {
165 const indent = /^[ \t]*/.exec(cur)?.[0] ?? ''
166 return edit(s, set(s.row, cur.slice(0, s.col), indent + cur.slice(s.col)), s.row + 1, indent.length)
167 }
168 case 'backspace': {
169 if (s.col === 0) {
170 if (s.row === 0) return
171 const prev = s.lines[s.row - 1] ?? ''
172 return edit(s, [...s.lines.slice(0, s.row - 1), prev + cur, ...s.lines.slice(s.row + 1)], s.row - 1, prev.length)
173 }
174 const n = isLow(cur.charCodeAt(s.col - 1)) ? 2 : 1
175 return edit(s, set(s.row, cur.slice(0, s.col - n) + cur.slice(s.col)), s.row, s.col - n)
176 }
177 case 'delete': {
178 if (s.col >= cur.length) {
179 if (s.row >= s.lines.length - 1) return
180 return edit(s, [...s.lines.slice(0, s.row), cur + (s.lines[s.row + 1] ?? ''), ...s.lines.slice(s.row + 2)], s.row, s.col)
181 }
182 const n = isLow(cur.charCodeAt(s.col + 1)) ? 2 : 1
183 return edit(s, set(s.row, cur.slice(0, s.col) + cur.slice(s.col + n)), s.row, s.col)
184 }
185 }
186 if (NAMED.test(k.key)) return // the name of a key this editor has no use for
187 // A file indented with tabs gets a tab, any other two spaces. Text that comes in one piece (fast typing,
188 // a paste) is one event with all of it, and can hold line ends.
189 const typed = (k.key === 'tab' ? (/^\t/m.test(s.lines.join('\n')) ? '\t' : ' ') : k.key === 'space' ? ' ' : k.key).split(/\r\n|\r|\n/)
190 const last = typed[typed.length - 1] ?? ''
191 const added = typed.length === 1 ? [cur.slice(0, s.col) + last + cur.slice(s.col)] : [cur.slice(0, s.col) + typed[0], ...typed.slice(1, -1), last + cur.slice(s.col)]
192 edit(s, set(s.row, ...added), s.row + typed.length - 1, typed.length === 1 ? s.col + last.length : last.length)
193 })
194
195 // the rows on screen: each line from the top, cut into pieces of the width
196 type Piece = { i: number; off: number; text: string; isFirst: boolean }
197 const pieces: Piece[] = []
198 for (let i = st.top; i < st.lines.length && pieces.length < H; i++) {
199 const text = shown(st.lines[i] ?? '')
200 for (let k = 0; k < tall(st, i) && pieces.length < H; k++) pieces.push({ i, off: k * textW, text: text.slice(k * textW, (k + 1) * textW), isFirst: k === 0 })
201 }
202 surface.onPointer(p => {
203 const at = pieces[p.y]
204 if (p.type !== 'down' || !at) return
205 move(latest, at.i, indexAt(st.lines[at.i] ?? '', at.off + Math.max(0, p.x - numW - 1)))
206 })
207
208 const cell = shown((st.lines[st.row] ?? '').slice(0, st.col)).length
209 const left = numW + 1
210 // Every row is one line of a `Code` element, so the engine's highlighter colors all of them, the cursor's
211 // row too. The cursor and the find matches are small boxes placed over the code at their cells.
212 const over: RenderElement[] = []
213 let total = 0
214 let nth = 0
215 if (rx)
216 for (let i = 0; i < st.lines.length; i++) {
217 const found = spans(rx, shown(st.lines[i] ?? ''))
218 total += found.length
219 nth += i < st.row ? found.length : i === st.row ? found.filter(([s]) => s <= cell).length : 0
220 }
221 pieces.forEach((p, y) => {
222 for (const [s, e] of spans(rx, shown(st.lines[p.i] ?? ''))) {
223 const from = Math.max(s, p.off)
224 const to = Math.min(e, p.off + textW)
225 if (to > from)
226 over.push(
227 <Box position="absolute" top={y} left={left + from - p.off}>
228 <Text inverse color="warning">
229 {p.text.slice(from - p.off, to - p.off)}
230 </Text>
231 </Box>,
232 )
233 }
234 })
235 const at = pieces.findIndex(p => p.i === st.row && cell >= p.off && cell < p.off + textW)
236 const cursorPiece = pieces[at]
237 if (cursorPiece)
238 over.push(
239 <Box position="absolute" top={at} left={left + cell - cursorPiece.off}>
240 <Text inverse>{cursorPiece.text[cell - cursorPiece.off] ?? ' '}</Text>
241 </Box>,
242 )
243 const out = (
244 <Box>
245 <Box flexDirection="column" flexShrink={0} width={left}>
246 {pieces.map(p => (
247 <Text {...(p.i === st.row ? { color: 'suggestion', bold: true } : { dimColor: true })}>{(p.isFirst ? String(p.i + 1).padStart(numW) : ' '.repeat(numW)) + ' '}</Text>
248 ))}
249 </Box>
250 {/* an empty line is given a space: the engine's highlighter drops a line with nothing in it */}
251 <Code source={pieces.map(p => p.text || ' ').join('\n') || ' '} path={props.path} wrap="truncate-end" />
252 {over}
253 </Box>
254 )
255 return (
256 <Box flexDirection="column" width={W} height={H + 1}>
257 <Box flexDirection="column" height={H} overflow="hidden">
258 {out}
259 </Box>
260 <Box height={1} overflow="hidden">
261 <Text wrap="truncate-end">
262 <Text color="suggestion">
263 Ln {st.row + 1}, Col {st.col + 1}
264 </Text>
265 {props.find.q ? <Text color="warning">{rx ? ` · ${total ? `${Math.max(1, nth)} of ${total}` : 'no'} match${total === 1 ? '' : 'es'}` : ' · the find pattern is not a valid regular expression'}</Text> : null}
266 {st.dirty ? <Text color="warning"> · ● not saved · ctrl+s saves</Text> : <Text dimColor> · saved</Text>}
267 <Text dimColor> · click the text, then type · Esc: prompt</Text>
268 </Text>
269 </Box>
270 </Box>
271 )
272}
273
274export default Editor
275types/index.d.ts 49 lines1export type Pr = {
2 number: number
3 title: string
4 state: string
5 head: string
6 headOid: string
7 base: string
8 baseOid: string
9 updated: string
10 url: string
11 author: string
12}
13
14/** A comparison baseline. `sha` stays pinned until the person refreshes or selects again. */
15export type Base = { kind: 'head' | 'prev' | 'prhead' | 'prbase'; sha: string; label: string }
16
17/** Navigation state: small, JSON, kept by the host across hot reloads. Paths are relative to `root`. */
18export type Nav = {
19 root: string
20 /** The directory the person chose as the root; null follows the session's directory. */
21 pin: string | null
22 expanded: string[]
23 selected: string | null
24 cursor: string | null
25 treeTop: number
26 view: 'files' | 'search'
27 hide: { dot: boolean; ignored: boolean; untracked: boolean }
28 fileQuery: string
29 /** What the pane shows: the tree, the selected file's code, or its diff. */
30 mode: 'files' | 'code' | 'diff'
31 wrap: boolean
32 scroll: Record<string, number>
33 find: { q: string; cs: boolean; re: boolean; idx: number }
34 grep: { q: string; hidden: boolean; ignored: boolean; include: string; exclude: string }
35 base: Base | null
36 pr: Pr | null
37 want: Base['kind']
38 ctx: number
39 listing: boolean
40 picker: boolean
41 myPrs: boolean
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 'file-explorer': { nav: Nav }
47 }
48}
49