SLOPSHOPPER

file-explorer

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

newpanecommandtoastprocesstimer
★ 2v0.3.7no licenseupdated 2026-10-04codejunkie99/claude-code-file-explorer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-explorer
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ EXPLORER ‹ Back Forwa │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ ⏎ Enter ⎿ Read 6 lines │ ┃ ╰─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ [ Files ] [ Code ] [ Diff ] ⎿ Added 2 lines, removed 1 line │ ┃ ■ APP ⎇ detached ⏺ Bash(bun test) │ ┃ /work/app · docked ⎿ 3 pass, 1 fail │ ┃ New File New Folder Refresh Collapse Mor │ ┃ ──────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▣ client module ./tree.tsx │ ┃ [ ↑ ] [ ↓ ] [ ← ] [ → ] [ ⏎ ] 1–3 of 3 ✻ Worked for 42s · done 4:20 PM │ │ › /files │ ⎿ file-explorer: File explorer: /work/app │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
EXPLORER ‹ Back Forward › ↑ Up ╭──────────────────────────────────────────────────────╮ │ ⏎ Enter [ ⏎ ] [ ⌫ ]│ ╰──────────────────────────────────────────────────────╯ [ Files ] [ Code ] [ Diff ] ■ APP ⎇ detached /work/app · docked New File New Folder Refresh Collapse More ──────────────────────────────────────────────────────── ▣ client module ./tree.tsx [ ↑ ] [ ↓ ] [ ← ] [ → ] [ ⏎ ] 1–3 of 3
README

file-explorer

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

Install

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.

It is there when a session starts

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.

The pane

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.
  • The search field applies while you type: the tree filters, and the matches in a file or a diff show, at each letter. Enter applies it at once. A name field applies on Enter.

Mouse and keyboard

Every action has a control for the pointer and a way to do it with keys.

ActionMouseKeyboard
Give the pane the keyboardClick anywhere in the pane/files, or ctrl+x tab
Move in the treeClick a row; wheel; [ ↑ ] [ ↓ ] [ ← ] [ → ] under the treek j h l. After a click on the tree: the arrow keys, PgUp, PgDn, Home, End
Open a file, open or close a folderClick the row, or [ ⏎ ] under the treeo. After a click on the tree: Enter, Right, Left
Actions for one rowRight-click the row: it is selected and More opensMove to the row, then Tab to More
Any button, tab, or iconClickTab or the arrow keys move the focus ring, Enter presses. Tabs: 1 2 3 (not while the editor is shown)
SearchClick the field and type: the result follows each letter. [ ⌫ ] clearsTab 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 [ ✓ ]. [ ✕ ] cancelsType, 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 editorClick the textNot possible without one click: the host gives the editor keys only after a click
ScrollWheelPgUp, PgDn in the editor and the tree after a click
Leave the paneClick the conversationEsc, or Left

Two host rules shape this:

  • The tree and the editor take keys only after a click on them. The focus ring (Tab) cannot enter them. So the tree has arrow buttons with letter keys, which work as soon as the pane has the keyboard.
  • A click in the pane gives the pane the keyboard. If the prompt had the keyboard before a click on the tree, the arrow keys then move the focus ring: use the letter keys, or click the tree once more.

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.

Files

ActionEffect
Click a folderExpands or collapses it
Click a fileOpens it in Code mode
Arrow keys, after a click in the treeUp/Down move. Right opens a folder, then steps in. Left closes it, then goes to its parent. Enter opens. PgUp, PgDn, Home, End
WheelScrolls
Search fieldFind 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 FolderAsks for a name. It goes in the selected folder, or beside the selected file
Refresh / CollapseReads the folder again / closes every folder
MoreThe second row of actions

More:

ActionEffect
RenameA field with the current name. An existing name is refused
DeleteAsks once, then moves the entry to the Trash (~/.Trash). Nothing is removed for good
Set as rootThe selected folder becomes the root
Open folder…A field for a path: absolute, ~/…, or relative to the root
HomeBack to the session's directory
Text searchThe 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 / untrackedHide or show those entries

"Selected" is the row with the bar: the last row you clicked or moved to.

Colors:

  • A folder's icon ■ 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.
  • A file has a two-letter mark in its language's color (TS, PY, RS, GO, MD, {}).
  • Names take the Git color: modified yellow M, added and untracked green A ?, deleted red D, conflict red U, renamed blue R, ignored dim !, a folder with changes •.
  • A line │ per level shows the nesting. The selected row and the row under the pointer are a bar across the pane.
  • A link to a folder that contains it shows ⟲ loop and does not expand. A folder that cannot be read shows the reason on its row.

Code

A file opens as an editor, with syntax colors on every line. Click the text, then type.

The toolbar has icons only:

IconAction
[ 💾 ] at the top right, under the search fieldSave. 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
  • Keys in the text: arrows, Home, End, PgUp, PgDn, Enter (keeps the indent), Backspace, Delete, Tab. Pasted text goes in whole.
  • A dot ● 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.
  • If the file changed on disk after you opened it, the first Save refuses and says so. A second Save overwrites.
  • Search field: find in the file, any case, while you type. /pattern/ is a regular expression, /pattern/i one in any case. Enter goes to the next match. :120 and Enter goes to line 120.
  • Long lines wrap at the pane's width. Nothing is cut.
  • Line endings and the final newline are kept.

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.

Diff

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 againstBaseline commit
Latest commit (default)HEAD
Previous commitThe first parent of HEAD. Refused with a reason when there is none
Pull request headThe head commit of the pull request: what changed since the version pushed to the PR
Pull request basegit merge-base <PR base> <PR head>: the PR's changes and your local changes beyond it
  • The baseline's SHA changes only when you select a baseline or Read this baseline again.
  • The PR for the current branch comes from gh pr list --head <branch>. One match is selected. With several or none, you choose from the list in the chooser.
  • A missing commit is fetched with git fetch --no-tags --no-write-fetch-head <remote> <sha>: objects only, no ref, no checkout, no change to working files.
  • When 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.

Which folder it shows

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.

CommandEffect
/filesGives the pane the keyboard
/files ~/Projects/my-appThat folder becomes the root
/files src/app.ts:120Opens that file at line 120
/files homeBack to the session's directory
/files closeCloses the pane until the next session or /files

Git status, diff, and pull requests use the repository that contains the root.

Width and wrapping

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.

Refresh

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

Dependencies

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.

Host limits and known limitations

  • The host places panes. A mod cannot put anything below the prompt and cannot dock two panes side by side, so code, diff, and the editor are modes of the one pane.
  • On a terminal under 110 columns, or not in fullscreen, the host puts the pane inline above the prompt at about a third of the terminal's height.
  • The tree and the editor take keys only after a click on them, and the focus ring cannot enter them. Esc gives the keyboard back to the prompt. After a file opens, the keyboard is at the prompt until you click the text.
  • Double click is the same as a click: the host reports presses, not click counts. A right-click selects the row and opens More; there is no pop-up menu.
  • There is no drag selection and no copy in the editor.
  • Wrapping breaks a line at the pane's edge, not at a word.
  • The editor counts every character as one cell wide. A wrapped line is colored piece by piece, so a token cut at the pane's edge can lose its color.
  • ctrl+z is not Undo: the host suspends Claude Code on it. Use the [ ↶ ] icon.
  • A click on an icon takes the keyboard from the text: click the text again to type.
  • The diff view has no syntax colors. Side by side pairs the lines of one change by their position, not by their likeness.
  • The file-name search stops at 500 matches and does not list .git contents. Text search stops at 1000 results. Both say so.
  • The mobile app has no text fields, so the mod shows a notice there. Desktop and VS Code were not opened; only the terminal was checked live.

Develop

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.

Source 5 files
hooks/register.tsx 2053 lines
1import { 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 lines
1// 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}
441
hooks/tree.tsx 116 lines
1// 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
116
hooks/editor.tsx 275 lines
1// 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
275
types/index.d.ts 49 lines
1export 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