Browse, read and edit your whole repo in a pane beside Claude Code, right in the terminal.

Browse, read and edit your whole repo in a pane beside Claude Code, right in the terminal.

The Claude desktop app has a file pane; the CLI didn't. repoviewer is a Claude Code mod that docks one on the right:
.gitignore respected) with git marks (M A ? D R U) and a magenta ● on every file Claude edited this session.↑ ↓ move, → opens a folder or file, ← steps back out, and ← at the top level hands the keys back to Claude's prompt.git diff per file, PNGs (kitty / Ghostty).→ on an open file edits it in place. ctrl+s saves, ctrl+q is done, ctrl+z undoes. A save never silently overwrites a file that changed on disk.show_file tool.regtsx → hooks/register.tsx, Enter opens it.Typing anywhere outside the finder and the editor goes straight to Claude's prompt.
If it's useful, a ⭐ helps others find it.
Requires Claude Code 2.1.259 or newer, in fullscreen mode (/tui fullscreen) for the pane to dock on the right; otherwise it sits above the prompt.
claude plugin marketplace add kesavreddy-commits/repoviewer
claude plugin install kesav@repoviewer
/files (or /repo) | toggle the pane |
/files <path> | open a file, or reveal a folder |
/files <text> | fuzzy-find |
| click the tree | gives it the arrow keys; Esc gives them back |
The pane takes 40% of a wide terminal (at least 44 columns, and Claude keeps at least 70); drag the divider to change it. There are no settings to configure: it remembers whether you left the pane open and whether follow was on.
/files, click the tree, then use → to open hooks/ and a file inside it, and ← to come back out./files README.md opens it rendered; /files hooks reveals the folder; /files regtsx filters the tree down to hooks/register.tsx.show_file tool.follow on, ask Claude to change something ("rename this function and update its callers"). Each file opens in the pane as it's edited, with a magenta ● in the tree.→ to edit, make a change, ctrl+s to save, ctrl+q when done.It works in the desktop app's Code tab too; VS Code gets a click-only tree.
/tui fullscreen./files opens it either way.Esc or ← at the top level..gitignore. Outside a git repo, folders like node_modules are skipped. A + after the file count means a very large repo's list was cut short. /files <path> still opens a file directly.ctrl+s again overwrites it; ctrl+q leaves without saving.claude plugin list shows kesav@repoviewer enabled, and that Claude Code is 2.1.259 or newer (claude --version). claude --debug logs why a plugin didn't load.Still stuck? Open an issue. For a security problem, see SECURITY.md instead.
repoviewer reads your repo and shows it to you, and changes nothing unless you save a file. In detail:
show_file tool, all it gets back is one line, like "Showing hooks/register.tsx in the repo pane."git, locally, and only to read. These are the whole commands, run in your repo: git rev-parse --show-toplevel # where the repo is
git symbolic-ref --short -q HEAD # the branch
git rev-parse --short HEAD # the commit, when there is no branch
git --no-optional-locks ls-files -z --cached --others --exclude-standard # the tree
git --no-optional-locks status --porcelain=v1 -z --untracked-files=all # the change marks
git --no-optional-locks diff [HEAD] --no-color --no-ext-diff --no-textconv -- <file> # a diff
<file> is the file whose diff you opened. It never commits, checks out, fetches or changes your repo.
package.json, a Makefile or CLAUDE.md. It writes only that file, at its own path, only when you press ctrl+s, and refuses if the file changed on disk in the meantime. It never writes outside the repo, never to its own folder, and never on its own. Beyond that it keeps two small values in its own plugin storage: whether you left the pane open, and whether follow is on.It adds the commands /files, /repo and /repoviewer, and a show_file tool Claude can use to open a file for you. It answers its own show_file calls (that's how a plugin serves its tool), and never answers for, replaces or changes any other tool or command.
session.start: registers the commands and the tool, reads the file list, and opens the pane unless you left it closed. While the pane is open, it re-reads git status every 8 seconds, and reads the file list again if /clear, /resume or /branch reset it.command.run: answers its own /files, /repo and /repoviewer only: toggle the pane, or jump to a file, folder or search. No other command reaches it.ui.message: handles the pane's own key presses (move, open, edit, save, typed text); everything else passes on.tool.call on show_file: answers its own tool by opening the file in the pane, and returns one line.tool.call on every other tool: lets the tool run unchanged first. After a successful Edit, Write or NotebookEdit, it marks that file and opens it in the pane if follow is on. The tool's result is returned unchanged.turn.complete: passes the event on, then refreshes the file list and git marks.ui.render: draws its own pane only.git clone https://github.com/kesavreddy-commits/repoviewer && cd repoviewer
claude --plugin-dir . --settings '{"tui":"fullscreen"}'
claude plugin test .
hooks/register.tsx wires the events and does all file and git I/O; hooks/nav.tsx is the keyboard Client (tree), hooks/navfile.tsx the viewer and editor inside it, hooks/editor.ts the text buffer, hooks/repo.ts the parsing; tree.tsx and viewer.tsx draw the chrome. Run /plugin-types once for editor types, then npx -p typescript tsc -p ..
MIT - Add a reference to my name, Kesav E. and my GitHub username if using this repo for a video please!
repoviewer is an independent project, not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.
hooks/register.tsx 687 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DocPage, FileDoc, GitMark, NavFile, NavOp, NavProps, NavRow, RepoIndex, RepoView, TreeRow } from '../types'
5import {
6 IGNORED_DIRS,
7 MAX_FILES,
8 docFromStat,
9 docFromText,
10 emptyDoc,
11 errText,
12 filterRows,
13 pageOf,
14 parentDirs,
15 parseDiff,
16 parseGitStatus,
17 parseLsFiles,
18 toRepoPath,
19 treeRows,
20} from './repo'
21import { Tree, treeChromeRows } from './tree'
22import { FILE_CHROME_ROWS, Viewer } from './viewer'
23
24const PANE = 'repoviewer'
25const TOOL = 'mcp__kesav__show_file'
26/** `/files` is the name the CLI's feature request asked for; `/repo` the short one; `/repoviewer` its own. */
27const COMMANDS = ['files', 'repo', 'repoviewer'] as const
28const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
29/** How often the pane notices changes made outside Claude (another editor, a git checkout). */
30const POLL_MS = 8000
31/** A file at most this long goes to the pane's Client whole, and can be edited there. */
32const NAV_MAX_TEXT = 90000
33/** Characters of tree rows handed to the Client (its props are bounded at 100,000). */
34const NAV_ROW_BUDGET = 60000
35/** Rows the hooks-drawn chrome takes above the Client: treeChromeRows (header, counts, finder, toolbar) and
36 * FILE_CHROME_ROWS (viewer header, breadcrumb, toolbar), each drawn beside its own layout in tree.tsx and viewer.tsx. */
37const FILE_CHROME = FILE_CHROME_ROWS
38
39const DEFAULT_VIEW: RepoView = {
40 mode: 'tree',
41 expanded: [],
42 cursor: '',
43 filter: '',
44 page: 0,
45 isRaw: false,
46 showDiff: false,
47 follow: true,
48 cursorAt: 0,
49 lineAt: 0,
50}
51
52const index = atom({ plugin: 'kesav', key: 'index' } as const, null)
53const git = atom({ plugin: 'kesav', key: 'git' } as const, {})
54const touched = atom({ plugin: 'kesav', key: 'touched' } as const, {})
55const view = atom({ plugin: 'kesav', key: 'view' } as const, DEFAULT_VIEW)
56const revision = atom({ plugin: 'kesav', key: 'revision' } as const, 0)
57
58type Engine = EngineInterface
59
60function setView($: Engine, change: (v: RepoView) => RepoView) {
61 return update($, view, change)
62}
63
64/** Choices kept across sessions in $.store: whether the pane was left open, and whether it follows
65 * Claude's edits. (Not userConfig: any option there makes every install print "not yet set".) */
66const OPEN_KEY = 'open'
67const FOLLOW_KEY = 'follow'
68
69/** The person closed the pane: it stays closed in the sessions after this one too, until /files. */
70async function closeByUser($: Engine) {
71 await $.ui.close({ id: PANE })
72 await $.store.set(OPEN_KEY, false)
73}
74
75/** The toolbar's follow toggle, kept for the sessions after this one too. */
76async function toggleFollow($: Engine) {
77 await setView($, v => ({ ...v, follow: !v.follow }))
78 await $.store.set(FOLLOW_KEY, (await read($, view)).follow)
79}
80
81/** The terminal's width as last measured, and the width it was when the dock was last sized. */
82let termColumns = 0
83let sizedFor = 0
84/** How many times the pane has drawn: leavePane waits on it to see the reopened pane drawn. */
85let draws = 0
86const WIDTH_PERCENT = 40
87
88/** The dock width to ask for: 40% of the terminal, at least 44 columns so code stays readable, at
89 * most 100 so a wide screen keeps its transcript, and never leaving Claude under 70 columns. Below
90 * that (a terminal of about 115 columns or less) it asks for nothing and the engine's share stands. */
91function paneWidth(): number | undefined {
92 if (termColumns <= 0) return undefined
93 const want = Math.min(100, Math.max(44, Math.round((termColumns * WIDTH_PERCENT) / 100)), termColumns - 70)
94 return want >= 44 ? want : undefined
95}
96
97/** Opens (or retitles and resizes) the pane, floor to ceiling on the right when docked. */
98async function openPane($: Engine, focus = false) {
99 const repo = await read($, index)
100 const columns = paneWidth()
101 if (columns !== undefined) sizedFor = termColumns
102 return $.ui.open({
103 id: PANE,
104 title: repo ? baseName(repo.root) : 'Repo',
105 ...(columns !== undefined ? { columns } : {}),
106 ...(focus ? { focus: true as const } : {}),
107 })
108}
109
110async function notify($: Engine, text: string, tone: 'info' | 'warn' | 'error') {
111 const at = await $.clock.now().catch(() => 0)
112 await setView($, v => ({ ...v, notice: { text, tone, at } }))
113}
114
115// ---------------------------------------------------------------------------
116// Host I/O. The engine follows `$` only into functions of this file, so every
117// command and read happens here and repo.ts parses what comes back.
118
119async function loadIndex($: Engine): Promise<RepoIndex> {
120 const cwd = await $.session.cwd().catch(() => '')
121 const loadedAt = await $.clock.now().catch(() => 0)
122 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 20000 }).catch(() => undefined)
123 const root = top?.exitCode === 0 ? top.stdout.trim() : ''
124 if (root !== '') {
125 const [ls, sym, sha] = await Promise.all([
126 $.process.run(['git', '--no-optional-locks', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, timeoutMs: 30000 }).catch(() => undefined),
127 $.process.run(['git', 'symbolic-ref', '--short', '-q', 'HEAD'], { cwd: root, timeoutMs: 20000 }).catch(() => undefined),
128 $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { cwd: root, timeoutMs: 20000 }).catch(() => undefined),
129 ])
130 const branch = sym?.exitCode === 0 && sym.stdout.trim() ? sym.stdout.trim() : sha?.exitCode === 0 ? sha.stdout.trim() || undefined : undefined
131 if (ls?.exitCode === 0) return { root, isGit: true, branch, ...parseLsFiles(ls.stdout, ls.isStdoutTruncated), loadedAt }
132 return { root, isGit: true, branch, ...(await walk($, root)), loadedAt }
133 }
134 return { root: cwd, isGit: false, ...(await walk($, cwd)), loadedAt }
135}
136
137/** The file list of a folder that is not a git repo: breadth first, skipping IGNORED_DIRS and links. */
138async function walk($: Engine, root: string): Promise<{ files: string[]; isTruncated: boolean }> {
139 const files: string[] = []
140 let queue = ['']
141 while (queue.length > 0 && files.length < MAX_FILES) {
142 const batch = queue.slice(0, 16)
143 queue = queue.slice(16)
144 const listed = await Promise.all(
145 batch.map(async dir => ({ dir, entries: await $.fs.list(dir === '' ? root : `${root}/${dir}`).catch(() => []) })),
146 )
147 for (const { dir, entries } of listed) {
148 for (const entry of entries) {
149 if (IGNORED_DIRS.has(entry.name)) continue
150 const rel = dir === '' ? entry.name : `${dir}/${entry.name}`
151 if (entry.kind === 'dir') queue.push(rel)
152 else if (entry.kind === 'file' && files.length < MAX_FILES) files.push(rel)
153 }
154 }
155 }
156 return { files: files.sort(), isTruncated: queue.length > 0 || files.length >= MAX_FILES }
157}
158
159async function loadGitStatus($: Engine, root: string): Promise<Record<string, GitMark>> {
160 if (root === '') return {}
161 const status = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: root, timeoutMs: 30000 }).catch(() => undefined)
162 return status?.exitCode === 0 ? parseGitStatus(status.stdout, status.isStdoutTruncated) : {}
163}
164
165async function loadDoc($: Engine, root: string, path: string): Promise<FileDoc> {
166 const abs = `${root}/${path}`
167 let stat
168 try {
169 stat = await $.fs.stat(abs)
170 } catch (error) {
171 return emptyDoc(path, 'missing', { error: errText(error, 'file not found') })
172 }
173 const decided = docFromStat(root, path, stat)
174 if (decided) return decided
175 try {
176 return docFromText(path, stat, await $.fs.read(abs))
177 } catch (error) {
178 return emptyDoc(path, 'missing', { size: stat.size, mtimeMs: stat.mtimeMs, error: errText(error, 'could not read file') })
179 }
180}
181
182async function loadDiff($: Engine, root: string, path: string) {
183 let diff = await $.process
184 .run(['git', '--no-optional-locks', 'diff', 'HEAD', '--no-color', '--no-ext-diff', '--no-textconv', '--', path], { cwd: root, timeoutMs: 20000 })
185 .catch(() => undefined)
186 // A repo with no commit yet has no HEAD to diff against.
187 if (diff?.exitCode !== 0) {
188 diff = await $.process
189 .run(['git', '--no-optional-locks', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--', path], { cwd: root, timeoutMs: 20000 })
190 .catch(() => undefined)
191 }
192 return diff?.exitCode === 0 ? parseDiff(diff.stdout, diff.isStdoutTruncated) : undefined
193}
194
195const isSameMarks = (a: Record<string, GitMark>, b: Record<string, GitMark>) => {
196 const keys = Object.keys(a)
197 return keys.length === Object.keys(b).length && keys.every(key => a[key] === b[key])
198}
199
200async function refreshGit($: Engine) {
201 const repo = await read($, index)
202 if (!repo) return
203 const marks = await loadGitStatus($, repo.root)
204 // Writing only on change keeps the pane from redrawing every poll.
205 if (!isSameMarks(marks, await read($, git))) await update($, git, () => marks)
206}
207
208async function refreshAll($: Engine) {
209 const repo = await loadIndex($)
210 await update($, index, () => repo)
211 await refreshGit($)
212 await update($, revision, n => n + 1)
213}
214
215/** /clear, /resume and /branch reset every $.state value and fire no session.start: when the file
216 * list is gone, read it again (once at a time). The pane's drawing and its poll both ask. */
217let isLoading = false
218async function loadIfMissing($: Engine) {
219 if (isLoading || (await read($, index))) return
220 isLoading = true
221 try {
222 await refreshAll($)
223 } finally {
224 isLoading = false
225 }
226}
227
228/** The page of a doc that holds a 1-based line. */
229function pageForLine(doc: Parameters<typeof pageOf>[0], line: number): number {
230 const first = pageOf(doc, 0)
231 for (let page = first.pageCount - 1; page > 0; page -= 1) {
232 if (pageOf(doc, page).startLine <= line) return page
233 }
234 return 0
235}
236
237/** Shows a file in the viewer: opens the pane, and reveals the file's folder in the tree. */
238async function showFile($: Engine, path: string, line?: number) {
239 const repo = await read($, index)
240 let page = 0
241 if (repo && line !== undefined) {
242 const doc = await loadDoc($, repo.root, path)
243 page = pageForLine(doc, line)
244 }
245 await setView($, v => ({
246 ...v,
247 mode: 'file',
248 openPath: path,
249 cursor: path,
250 cursorAt: v.cursorAt + 1,
251 page,
252 showDiff: false,
253 editing: v.editing === path ? v.editing : undefined,
254 // A line only comes with show_file; a file opened any other way starts at its top.
255 ...(line !== undefined ? { line, lineAt: v.lineAt + 1 } : { line: undefined }),
256 expanded: [...new Set([...v.expanded, ...parentDirs(path)])],
257 }))
258 await openPane($)
259}
260
261const collapsed = (expanded: string[], dir: string) =>
262 expanded.filter(open => open !== dir && !open.startsWith(`${dir}/`))
263
264/** Hands the keys back to Claude Code's prompt. There is no call for that, but closing a focused
265 * pane gives the prompt the keys, and reopening it unasked puts the pane back without taking them. */
266async function leavePane($: Engine) {
267 await $.ui.close({ id: PANE })
268 // Reopening in the same breath as the close can leave the pane placed but never drawn again (a
269 // blank box with only the divider). Let the close settle, reopen, then ask for a redraw until
270 // one has actually happened, rather than trusting a fixed delay.
271 await $.clock.sleep(100)
272 const before = draws
273 await openPane($)
274 for (let tries = 0; tries < 8 && draws === before; tries++) {
275 $.ui.invalidate('ui.render')
276 await $.clock.sleep(60)
277 }
278}
279
280/** Writes the Client's buffer back, refusing when the file changed on disk since it was opened. */
281async function saveFile($: Engine, op: Extract<NavOp, { op: 'save' }>) {
282 const repo = await read($, index)
283 if (!repo) return
284 const path = toRepoPath(repo.root, repo.root, op.path)
285 if (path === undefined || path !== op.path) return notify($, `${op.path} is outside the repo`, 'error')
286 const abs = `${repo.root}/${path}`
287 const stat = await $.fs.stat(abs).catch(() => undefined)
288 if (stat && !op.force && Math.abs(stat.mtimeMs - op.baseMtimeMs) > 1) {
289 return notify($, `${path} changed on disk: ctrl+s again overwrites it`, 'warn')
290 }
291 let text = op.text
292 // The pane edits normalised text: give the file back its CRLF endings and BOM.
293 const before = stat ? await $.fs.read(abs).catch(() => '') : ''
294 if (before.includes('\r\n') && !text.includes('\r\n')) text = text.replace(/\n/g, '\r\n')
295 if (before.charCodeAt(0) === 0xfeff && text.charCodeAt(0) !== 0xfeff) text = `\ufeff${text}`
296 try {
297 await $.fs.write(abs, text)
298 } catch (error) {
299 return notify($, `could not save ${path}: ${errText(error, 'write failed')}`, 'error')
300 }
301 await notify($, `saved ${path}`, 'info')
302 await update($, revision, n => n + 1)
303 await refreshGit($)
304}
305
306/** One op from the pane's Client: a key press or a click the person made there. */
307async function handleOp($: Engine, op: NavOp) {
308 switch (op.op) {
309 case 'cursor':
310 return setView($, v => ({ ...v, cursor: op.path }))
311 case 'open':
312 return showFile($, op.path)
313 case 'toggle':
314 return setView($, v => ({
315 ...v,
316 cursor: op.path,
317 expanded: v.expanded.includes(op.path) ? collapsed(v.expanded, op.path) : [...v.expanded, op.path],
318 }))
319 case 'expand':
320 return setView($, v => ({ ...v, cursor: op.path, expanded: v.expanded.includes(op.path) ? v.expanded : [...v.expanded, op.path] }))
321 case 'collapse':
322 return setView($, v => ({ ...v, cursor: op.path, expanded: collapsed(v.expanded, op.path) }))
323 case 'back':
324 return setView($, v => ({ ...v, mode: 'tree', editing: undefined, cursor: v.openPath ?? v.cursor, cursorAt: v.cursorAt + 1 }))
325 case 'page':
326 return setView($, v => ({ ...v, page: Math.max(0, op.page) }))
327 case 'raw':
328 return setView($, v => ({ ...v, isRaw: !v.isRaw }))
329 case 'diff':
330 return setView($, v => ({ ...v, showDiff: !v.showDiff }))
331 case 'edit':
332 return setView($, v => ({ ...v, editing: op.isEditing ? op.path : undefined }))
333 case 'save':
334 return saveFile($, op)
335 case 'type':
336 // Typing outside the editor is meant for Claude: put it in the prompt and give the prompt the keys.
337 await $.prompt.fill({ text: op.text, mode: 'insert' })
338 return leavePane($)
339 case 'leave':
340 return leavePane($)
341 }
342}
343
344/** A Client's props must be plain JSON: drop the optional fields that are unset (undefined is refused). */
345function plain<T>(value: T): T {
346 return JSON.parse(JSON.stringify(value)) as T
347}
348
349/** Tree rows in the Client's compact form, cut to fit its props. */
350function navRows(rows: TreeRow[]): { rows: NavRow[]; sent: number } {
351 const out: NavRow[] = []
352 let budget = NAV_ROW_BUDGET
353 for (const row of rows) {
354 const flags = `${row.kind === 'dir' ? 'd' : ''}${row.isExpanded ? 'o' : ''}${row.hasChanges ? 'c' : ''}${row.isTouched ? 't' : ''}${row.mark ?? ''}`
355 const matches = row.matches?.flat()
356 budget -= row.path.length + flags.length + 12 + (matches?.length ?? 0) * 4
357 if (budget < 0) break
358 out.push(matches ? [row.path, row.depth, flags, matches] : [row.path, row.depth, flags])
359 }
360 return { rows: out, sent: out.length }
361}
362
363const baseName = (path: string) => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
364
365/** Resolves what the person typed after /repo: a file, a folder, or a query. */
366async function resolveArg($: Engine, arg: string) {
367 const repo = await read($, index)
368 if (!repo) return { kind: 'query' as const, value: arg }
369 const path = toRepoPath(repo.root, await $.session.cwd(), arg)
370 if (path !== undefined) {
371 const stat = await $.fs.stat(`${repo.root}/${path}`).catch(() => undefined)
372 if (stat?.kind === 'file') return { kind: 'file' as const, value: path }
373 if (stat?.kind === 'dir') return { kind: 'dir' as const, value: path }
374 }
375 return { kind: 'query' as const, value: arg }
376}
377
378/** /repo and /files: no argument toggles the pane; a file opens it, a folder reveals it, anything else searches. */
379async function runCommand($: Engine, args: string, columns: number): Promise<{ text?: string }> {
380 const arg = args.trim()
381 if (columns > 0) termColumns = columns
382 const panes = await $.ui.panes()
383 const isOpen = panes.some(pane => pane.id === PANE && pane.isPlaced)
384 if (!(await read($, index))) await refreshAll($)
385
386 if (arg === '') {
387 if (isOpen) {
388 await closeByUser($)
389 return {}
390 }
391 await $.store.set(OPEN_KEY, true)
392 const opened = await openPane($, true)
393 return opened.isPlaced ? {} : { text: `repoviewer: could not open the pane (${opened.reason})` }
394 }
395
396 const target = await resolveArg($, arg)
397 if (target.kind === 'file') {
398 await showFile($, target.value)
399 } else if (target.kind === 'dir') {
400 await setView($, v => ({
401 ...v,
402 mode: 'tree',
403 filter: '',
404 cursor: target.value,
405 cursorAt: v.cursorAt + 1,
406 expanded: [...new Set([...v.expanded, ...parentDirs(target.value), target.value])],
407 }))
408 } else {
409 await setView($, v => ({ ...v, mode: 'tree', filter: target.value }))
410 }
411 await $.store.set(OPEN_KEY, true)
412 await openPane($, true)
413 return {}
414}
415
416export const register: Register = on => {
417 on('session.start', async ($, e, next) => {
418 for (const name of COMMANDS) {
419 await $.command.register({
420 name,
421 description: `Browse the repo in a side pane: /${name} to toggle, /${name} <file|folder|query> to jump`,
422 argumentHint: '[file|folder|query]',
423 // Opens the pane mid-turn too, so you can watch Claude's edits land.
424 immediate: true,
425 })
426 }
427 await $.tool.register({
428 name: 'show_file',
429 description:
430 "Open a file from the current repo in the repoviewer pane beside the conversation, syntax-highlighted (markdown rendered), optionally at a line. Use only when the user asks to see, open or look at a file. It shows the file to the user; it does not return the file's content to you.",
431 inputSchema: {
432 type: 'object',
433 properties: {
434 path: { type: 'string', description: 'File path, absolute or relative to the working directory' },
435 line: { type: 'number', description: 'Optional 1-based line to bring into view' },
436 },
437 required: ['path'],
438 },
439 })
440 if ((await $.store.get(FOLLOW_KEY)) === false) await setView($, v => ({ ...v, follow: false }))
441
442 // Loading the file list can take a moment in a big repo: never hold the first prompt for it.
443 $.clock.after(0, () => {
444 void loadIfMissing($).then(async () => {
445 if ((await $.store.get(OPEN_KEY)) !== false) await openPane($)
446 })
447 })
448 let lastSeen = 0
449 $.clock.every(POLL_MS, () => {
450 void (async () => {
451 if (!(await $.ui.panes()).some(pane => pane.id === PANE)) return
452 await loadIfMissing($)
453 await refreshGit($)
454 // An open file changed on disk by someone else: re-read it.
455 const [repo, current] = [await read($, index), await read($, view)]
456 if (!repo || current.mode !== 'file' || !current.openPath) return
457 const stat = await $.fs.stat(`${repo.root}/${current.openPath}`).catch(() => undefined)
458 if (stat && lastSeen !== 0 && stat.mtimeMs !== lastSeen) await update($, revision, n => n + 1)
459 lastSeen = stat?.mtimeMs ?? 0
460 })()
461 })
462
463 return next(e)
464 })
465
466 on('command.run', { command: 'files' }, ($, e) => runCommand($, e.args, e.presentation.columns))
467 on('command.run', { command: 'repo' }, ($, e) => runCommand($, e.args, e.presentation.columns))
468 on('command.run', { command: 'repoviewer' }, ($, e) => runCommand($, e.args, e.presentation.columns))
469
470 on('ui.message', async ($, e, next) => {
471 if (e.requestId !== PANE || e.element !== 'nav') return next(e)
472 await handleOp($, e.data as NavOp)
473 return {}
474 })
475
476 on('tool.call', { tool: TOOL }, async ($, e) => {
477 const input = e as unknown as { path?: unknown; line?: unknown }
478 const repo = (await read($, index)) ?? (await refreshAll($), await read($, index))
479 if (!repo || typeof input.path !== 'string') return { result: 'No repo loaded, or no path given.' }
480 const path = toRepoPath(repo.root, await $.session.cwd(), input.path)
481 if (path === undefined) return { result: `${input.path} is outside the repo at ${repo.root}.` }
482 if (!(await $.fs.exists(`${repo.root}/${path}`))) return { result: `${path} does not exist.` }
483 await showFile($, path, typeof input.line === 'number' ? input.line : undefined)
484 return { result: `Showing ${path} in the repo pane.` }
485 })
486
487 // Claude's own edits: mark the file, re-read it, and follow it when asked to.
488 on('tool.call', async ($, e, next) => {
489 const ran = await next(e)
490 if (!EDIT_TOOLS.has(e.tool) || ran.deny !== undefined || ran.isError) return ran
491 const filePath = e.tool === 'NotebookEdit' ? e.notebook_path : e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : undefined
492 const repo = await read($, index)
493 if (!repo || typeof filePath !== 'string') return ran
494 const path = toRepoPath(repo.root, await $.session.cwd(), filePath)
495 if (path === undefined) return ran
496
497 await update($, touched, marks => ({ ...marks, [path]: (marks[path] ?? 0) + 1 }))
498 if (!repo.files.includes(path)) {
499 await update($, index, current =>
500 current && !current.files.includes(path) ? { ...current, files: [...current.files, path].sort() } : current,
501 )
502 }
503 const now = await read($, view)
504 if (now.follow && now.editing === undefined) {
505 await setView($, v => ({
506 ...v,
507 mode: 'file',
508 openPath: path,
509 cursor: path,
510 cursorAt: v.cursorAt + 1,
511 page: v.openPath === path ? v.page : 0,
512 expanded: [...new Set([...v.expanded, ...parentDirs(path)])],
513 }))
514 }
515 await update($, revision, n => n + 1)
516 await refreshGit($)
517 return ran
518 })
519
520 // Bash and other tools change files too: catch up once the turn is over.
521 on('turn.complete', async ($, e, next) => {
522 const done = await next(e)
523 if (await read($, index)) await refreshAll($)
524 return done
525 })
526
527 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
528 draws += 1
529 const el = $.ui.resolve(e)
530 const columns = e.props.bodyColumns
531 const rows = e.props.scroll.bodyRows
532 const [repo, marks, edits, current] = [await read($, index), await read($, git), await read($, touched), await read($, view)]
533 if (!repo) $.clock.after(0, () => void loadIfMissing($))
534
535 // Docked: the terminal is the transcript beside the pane, the pane, and the divider between.
536 // Size the dock once per terminal width, so a width the person drags stays theirs.
537 if (e.props.placement === 'dock' && e.viewport?.columns) {
538 termColumns = e.viewport.columns + columns + 1
539 if (termColumns !== sizedFor && paneWidth() !== undefined) {
540 sizedFor = termColumns
541 $.clock.after(0, () => void openPane($))
542 }
543 }
544
545 // The keyboard Client: terminal and desktop draw one; the editor's surfaces fall back to buttons.
546 const hasClient = (e.surface === 'terminal' || e.surface === 'desktop') && 'Client' in el
547 const nav = (props: NavProps, height: number) =>
548 'Client' in el ? <el.Client key="nav" module="./nav.tsx" props={plain(props)} height={height} /> : undefined
549 const navBase = {
550 columns,
551 surface: e.surface === 'desktop' ? ('desktop' as const) : ('terminal' as const),
552 cursor: current.cursor,
553 cursorAt: current.cursorAt,
554 line: current.line,
555 lineAt: current.lineAt,
556 editing: current.editing,
557 notice: current.notice,
558 }
559
560 if (current.mode === 'file' && current.openPath && repo) {
561 await read($, revision)
562 const path = current.openPath
563 const doc = await loadDoc($, repo.root, path)
564 const isText = doc.kind === 'text' || doc.kind === 'markdown'
565 const isWhole = isText && (doc.text?.length ?? 0) <= NAV_MAX_TEXT
566 // The Client holds a file up to NAV_MAX_TEXT whole and scrolls it itself; past that, and without a Client, it pages.
567 const page: DocPage | undefined = !isText
568 ? undefined
569 : hasClient && isWhole
570 ? { text: doc.text ?? '', startLine: 1, pageCount: 1 }
571 : pageOf(doc, current.page)
572 const diff = current.showDiff ? await loadDiff($, repo.root, path) : undefined
573 let body: ReturnType<typeof nav>
574 if (hasClient && doc.kind !== 'image') {
575 const shown = isWhole ? { text: doc.text ?? '', startLine: 1, pageCount: 1 } : page
576 const file: NavFile = {
577 path,
578 kind: doc.kind,
579 text: shown?.text,
580 startLine: shown?.startLine ?? 1,
581 page: isWhole ? 0 : Math.min(current.page, (shown?.pageCount ?? 1) - 1),
582 pageCount: shown?.pageCount ?? 1,
583 lineCount: doc.lineCount,
584 size: doc.size,
585 mtimeMs: doc.mtimeMs,
586 isEditable: isWhole,
587 isRaw: current.isRaw,
588 showDiff: current.showDiff,
589 diff,
590 mark: marks[path],
591 isTouched: (edits[path] ?? 0) > 0,
592 }
593 body = nav({ ...navBase, mode: 'file', rows: Math.max(4, rows - FILE_CHROME), file }, Math.max(4, rows - FILE_CHROME))
594 }
595 return Viewer(el, {
596 surface: e.surface,
597 columns,
598 rows,
599 doc,
600 page,
601 diff,
602 view: current,
603 mark: marks[path],
604 isTouched: (edits[path] ?? 0) > 0,
605 repoName: baseName(repo.root),
606 body,
607 actions: {
608 close: () => void closeByUser($),
609 back: () => void handleOp($, { op: 'back' }),
610 setPage: page => void setView($, v => ({ ...v, page: Math.max(0, page) })),
611 toggleRaw: () => void setView($, v => ({ ...v, isRaw: !v.isRaw })),
612 toggleDiff: () => void setView($, v => ({ ...v, showDiff: !v.showDiff })),
613 reveal: () => {
614 void setView($, v => ({
615 ...v,
616 mode: 'tree',
617 filter: '',
618 editing: undefined,
619 cursor: path,
620 cursorAt: v.cursorAt + 1,
621 expanded: [...new Set([...v.expanded, ...parentDirs(path)])],
622 })).then(() => $.clock.after(50, () => void $.ui.scroll({ to: { key: `row:${path}` }, in: PANE, block: 'center' })))
623 },
624 },
625 })
626 }
627
628 const isFiltering = current.filter.trim() !== ''
629 const listed = !repo
630 ? { rows: [], total: 0 }
631 : isFiltering
632 ? filterRows(repo, current.filter, marks, edits)
633 : { rows: treeRows(repo, new Set(current.expanded), marks, edits), total: 0 }
634
635 let body: ReturnType<typeof nav>
636 if (hasClient) {
637 const compact = navRows(listed.rows)
638 const total = isFiltering ? Math.max(listed.total, listed.rows.length) : listed.rows.length
639 const height = Math.max(4, rows - treeChromeRows(rows))
640 body = nav(
641 {
642 ...navBase,
643 mode: 'tree',
644 rows: height,
645 tree: repo ? { rows: compact.rows, more: total - compact.sent, isFiltering, query: current.filter } : undefined,
646 },
647 height,
648 )
649 }
650
651 return Tree(el, {
652 surface: e.surface,
653 columns,
654 rows,
655 index: repo,
656 rows_: hasClient ? [] : listed.rows,
657 total: listed.total,
658 view: current,
659 changedCount: Object.keys(marks).length,
660 touchedCount: Object.keys(edits).length,
661 body,
662 actions: {
663 press: row => void handleOp($, row.kind === 'dir' ? { op: 'toggle', path: row.path } : { op: 'open', path: row.path }),
664 setFilter: query => void setView($, v => ({ ...v, filter: query })),
665 close: () => void closeByUser($),
666 collapseAll: () => void setView($, v => ({ ...v, expanded: [], filter: '' })),
667 refresh: () => {
668 void refreshAll($).then(() => $.ui.toast('repoviewer: refreshed'))
669 },
670 toggleFollow: () => void toggleFollow($),
671 openFirstMatch: () => {
672 void (async () => {
673 const [latest, now] = [await read($, index), await read($, view)]
674 if (!latest || now.filter.trim() === '') return
675 const first = filterRows(latest, now.filter, await read($, git), await read($, touched), 1).rows[0]
676 if (!first) return
677 // Like an editor's quick open, the finder resets once it has opened something: coming
678 // back shows the whole tree (the field itself comes back empty, so a kept query would hide files).
679 await setView($, v => ({ ...v, filter: '' }))
680 await showFile($, first.path)
681 })()
682 },
683 },
684 })
685 })
686}
687hooks/repo.ts 719 lines1// repoviewer data layer: parsing git output, tree rows, fuzzy find, file
2// classification and paging, diffs. Pure: no JSX, no Node, no DOM, and no `$`
3// (the engine follows `$` only into functions of the file that holds it, so
4// register.tsx runs the commands and reads the files, and hands the output here).
5
6import type { DocPage, FileDoc, GitMark, RepoIndex, TreeRow } from '../types'
7
8export const MAX_FILES = 20000
9export const PAGE_CHARS = 9000
10export const PAGE_LINES = 400
11
12const MAX_READ_BYTES = 4 * 1024 * 1024
13const MAX_DIFF_CHARS = 9500
14
15export const IGNORED_DIRS: ReadonlySet<string> = new Set([
16 '.git', '.hg', '.svn', 'node_modules', 'dist', 'build', '.next', '.nuxt',
17 '.turbo', '.parcel-cache', 'target', '__pycache__', '.pytest_cache',
18 '.mypy_cache', '.ruff_cache', '.tox', '.venv', 'venv', '.cache', 'coverage',
19 '.gradle', '.idea', '.DS_Store',
20])
21
22export function splitNul(out: string, isTruncated: boolean): string[] {
23 const parts = out.split('\0')
24 // A trailing NUL leaves an empty last field; a cut stream leaves a partial one.
25 if (parts.length > 0) parts.pop()
26 if (isTruncated && parts.length > 0) parts.pop()
27 return parts
28}
29
30// ---------------------------------------------------------------------------
31// Index
32
33/** `git ls-files -z` output → sorted unique paths, capped at MAX_FILES. */
34export function parseLsFiles(out: string, isStdoutTruncated: boolean): { files: string[]; isTruncated: boolean } {
35 const uniq = Array.from(new Set(splitNul(out, isStdoutTruncated).filter((p) => p !== '')))
36 uniq.sort()
37 const isTruncated = isStdoutTruncated || uniq.length > MAX_FILES
38 return { files: uniq.length > MAX_FILES ? uniq.slice(0, MAX_FILES) : uniq, isTruncated }
39}
40
41// ---------------------------------------------------------------------------
42// Git status
43
44const CONFLICTS = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'])
45
46function markOf(x: string, y: string): GitMark | undefined {
47 const xy = x + y
48 if (xy === '??') return '?'
49 if (xy === '!!') return undefined
50 if (CONFLICTS.has(xy)) return 'U'
51 // A removal in the work tree wins; a renamed or newly added file keeps that
52 // identity even when edited further; otherwise the work tree column, then the index.
53 if (y === 'D') return 'D'
54 if (x === 'R' || x === 'C') return 'R'
55 if (x === 'A') return 'A'
56 const col = y !== ' ' ? y : x
57 switch (col) {
58 case 'M':
59 case 'T':
60 return 'M'
61 case 'A':
62 return 'A'
63 case 'D':
64 return 'D'
65 case 'R':
66 case 'C':
67 return 'R'
68 default:
69 return undefined
70 }
71}
72
73/** `git status --porcelain=v1 -z` output → path → mark. */
74export function parseGitStatus(stdout: string, isStdoutTruncated: boolean): Record<string, GitMark> {
75 const out: Record<string, GitMark> = {}
76 const fields = splitNul(stdout, isStdoutTruncated)
77 for (let i = 0; i < fields.length; i++) {
78 const f = fields[i] ?? ''
79 if (f.length < 4) continue
80 const x = f[0] ?? ' '
81 const y = f[1] ?? ' '
82 let path = f.slice(3)
83 // Renames and copies are followed by a field holding the original path.
84 if (x === 'R' || x === 'C' || y === 'R' || y === 'C') i++
85 if (path.endsWith('/')) path = path.slice(0, -1)
86 const m = markOf(x, y)
87 if (m !== undefined && path !== '') out[path] = m
88 }
89 return out
90}
91
92// ---------------------------------------------------------------------------
93// Paths
94
95export function parentDirs(path: string): string[] {
96 const out: string[] = []
97 let i = path.indexOf('/')
98 while (i !== -1) {
99 out.push(path.slice(0, i))
100 i = path.indexOf('/', i + 1)
101 }
102 return out
103}
104
105function baseName(path: string): string {
106 const i = path.lastIndexOf('/')
107 return i === -1 ? path : path.slice(i + 1)
108}
109
110function normalizeAbs(p: string): { prefix: string; rest: string } {
111 const s = p.replace(/\\/g, '/')
112 let prefix = '/'
113 let body = s
114 const drive = /^[A-Za-z]:(\/|$)/.exec(s)
115 if (drive) {
116 prefix = s.slice(0, 2) + '/'
117 body = s.slice(2)
118 }
119 const out: string[] = []
120 for (const seg of body.split('/')) {
121 if (seg === '' || seg === '.') continue
122 if (seg === '..') out.pop()
123 else out.push(seg)
124 }
125 return { prefix, rest: out.join('/') }
126}
127
128function isAbsolute(p: string): boolean {
129 return p.startsWith('/') || p.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(p)
130}
131
132function stripPrivate(p: string): string {
133 return p.replace(/^\/private(?=\/(tmp|var|etc)\/)/, '')
134}
135
136export function toRepoPath(root: string, cwd: string, filePath: string): string | undefined {
137 if (filePath === '' || root === '') return undefined
138 const joined = isAbsolute(filePath) ? filePath : `${cwd === '' ? root : cwd}/${filePath}`
139 const a = normalizeAbs(joined)
140 const r = normalizeAbs(root)
141 const isDrive = r.prefix !== '/'
142 const fold = (s: string) => (isDrive ? s.toLowerCase() : s)
143 if (fold(a.prefix) !== fold(r.prefix)) return undefined
144 const inside = (ap: string, rp: string): string | undefined => {
145 if (rp === '') return ap === '' ? undefined : ap
146 if (fold(ap).startsWith(fold(rp) + '/')) return ap.slice(rp.length + 1)
147 return undefined
148 }
149 const direct = inside(a.rest, r.rest)
150 if (direct !== undefined) return direct
151 // macOS: /tmp, /var and /etc are links into /private.
152 if (!isDrive) {
153 return inside(stripPrivate('/' + a.rest).slice(1), stripPrivate('/' + r.rest).slice(1))
154 }
155 return undefined
156}
157
158// ---------------------------------------------------------------------------
159// Tree
160
161type Dir = { dirs: string[]; files: string[] }
162type TreeIndex = { dirs: Map<string, Dir>; fileSet: Set<string> }
163
164const treeCache = new WeakMap<string[], TreeIndex>()
165
166function compareNatural(a: string, b: string): number {
167 // a and b are already lower-cased keys.
168 const n = a.length
169 const m = b.length
170 let i = 0
171 let j = 0
172 while (i < n && j < m) {
173 const ca = a.charCodeAt(i)
174 const cb = b.charCodeAt(j)
175 const da = ca >= 48 && ca <= 57
176 const db = cb >= 48 && cb <= 57
177 if (da && db) {
178 let si = i
179 let sj = j
180 while (si < n && a.charCodeAt(si) === 48) si++
181 while (sj < m && b.charCodeAt(sj) === 48) sj++
182 let ei = si
183 let ej = sj
184 while (ei < n && a.charCodeAt(ei) >= 48 && a.charCodeAt(ei) <= 57) ei++
185 while (ej < m && b.charCodeAt(ej) >= 48 && b.charCodeAt(ej) <= 57) ej++
186 const la = ei - si
187 const lb = ej - sj
188 if (la !== lb) return la < lb ? -1 : 1
189 for (let k = 0; k < la; k++) {
190 const x = a.charCodeAt(si + k)
191 const y = b.charCodeAt(sj + k)
192 if (x !== y) return x < y ? -1 : 1
193 }
194 i = ei
195 j = ej
196 continue
197 }
198 if (ca !== cb) return ca < cb ? -1 : 1
199 i++
200 j++
201 }
202 if (i < n) return 1
203 if (j < m) return -1
204 return 0
205}
206
207function sortPaths(paths: string[]): string[] {
208 if (paths.length < 2) return paths
209 const keyed = paths.map((p) => {
210 const name = baseName(p)
211 return { p, name, key: name.toLowerCase() }
212 })
213 keyed.sort((x, y) => {
214 const c = compareNatural(x.key, y.key)
215 if (c !== 0) return c
216 return x.name < y.name ? -1 : x.name > y.name ? 1 : 0
217 })
218 return keyed.map((k) => k.p)
219}
220
221function buildTree(files: string[]): TreeIndex {
222 const dirs = new Map<string, Dir>()
223 const fileSet = new Set<string>()
224 dirs.set('', { dirs: [], files: [] })
225 const ensureDir = (d: string): Dir => {
226 let e = dirs.get(d)
227 if (e) return e
228 e = { dirs: [], files: [] }
229 dirs.set(d, e)
230 const i = d.lastIndexOf('/')
231 const parent = ensureDir(i === -1 ? '' : d.slice(0, i))
232 parent.dirs.push(d)
233 return e
234 }
235 for (const f of files) {
236 fileSet.add(f)
237 const i = f.lastIndexOf('/')
238 ensureDir(i === -1 ? '' : f.slice(0, i)).files.push(f)
239 }
240 for (const d of dirs.values()) {
241 d.dirs = sortPaths(d.dirs)
242 d.files = sortPaths(d.files)
243 }
244 return { dirs, fileSet }
245}
246
247function treeOf(files: string[]): TreeIndex {
248 let t = treeCache.get(files)
249 if (!t) {
250 t = buildTree(files)
251 treeCache.set(files, t)
252 }
253 return t
254}
255
256export function treeRows(
257 index: RepoIndex,
258 expanded: ReadonlySet<string>,
259 git: Record<string, GitMark>,
260 touched: Record<string, number>,
261): TreeRow[] {
262 const base = treeOf(index.files)
263
264 // Deleted files that git knows about but the file list does not.
265 let overlay: Map<string, { dirs: Set<string>; files: string[] }> | undefined
266 const changedDirs = new Set<string>()
267 for (const p in git) {
268 for (const d of parentDirs(p)) changedDirs.add(d)
269 if (git[p] === 'D' && !base.fileSet.has(p)) {
270 overlay ??= new Map()
271 const add = (dir: string) => {
272 let o = overlay!.get(dir)
273 if (!o) {
274 o = { dirs: new Set(), files: [] }
275 overlay!.set(dir, o)
276 }
277 return o
278 }
279 const i = p.lastIndexOf('/')
280 add(i === -1 ? '' : p.slice(0, i)).files.push(p)
281 for (const d of parentDirs(p)) {
282 const j = d.lastIndexOf('/')
283 add(j === -1 ? '' : d.slice(0, j)).dirs.add(d)
284 }
285 }
286 }
287 const touchedDirs = new Set<string>()
288 for (const p in touched) {
289 if ((touched[p] ?? 0) > 0) for (const d of parentDirs(p)) touchedDirs.add(d)
290 }
291
292 const childrenOf = (dir: string): Dir => {
293 const b = base.dirs.get(dir) ?? { dirs: [], files: [] }
294 const o = overlay?.get(dir)
295 if (!o) return b
296 const dirSet = new Set(b.dirs)
297 const extraDirs = [...o.dirs].filter((d) => !dirSet.has(d))
298 const fileSet = new Set(b.files)
299 const extraFiles = o.files.filter((f) => !fileSet.has(f))
300 return {
301 dirs: extraDirs.length > 0 ? sortPaths([...b.dirs, ...extraDirs]) : b.dirs,
302 files: extraFiles.length > 0 ? sortPaths([...b.files, ...extraFiles]) : b.files,
303 }
304 }
305
306 const rows: TreeRow[] = []
307 const visit = (dir: string, depth: number) => {
308 const c = childrenOf(dir)
309 for (const d of c.dirs) {
310 const isExpanded = expanded.has(d)
311 rows.push({
312 path: d,
313 name: baseName(d),
314 depth,
315 kind: 'dir',
316 isExpanded,
317 hasChanges: changedDirs.has(d),
318 isTouched: touchedDirs.has(d),
319 })
320 if (isExpanded) visit(d, depth + 1)
321 }
322 for (const f of c.files) {
323 rows.push({
324 path: f,
325 name: baseName(f),
326 depth,
327 kind: 'file',
328 isExpanded: false,
329 mark: git[f],
330 isTouched: (touched[f] ?? 0) > 0,
331 })
332 }
333 }
334 visit('', 0)
335 return rows
336}
337
338// ---------------------------------------------------------------------------
339// Fuzzy find
340
341const lowerCache = new WeakMap<string[], string[]>()
342
343function lowerFiles(files: string[]): string[] {
344 let l = lowerCache.get(files)
345 if (!l) {
346 l = files.map((f) => {
347 const x = f.toLowerCase()
348 return x.length === f.length ? x : f
349 })
350 lowerCache.set(files, l)
351 }
352 return l
353}
354
355const NEG = -1e9
356const MATCH_SCORE = 16
357const CONSECUTIVE_BONUS = 6
358const GAP_OPEN = 3
359const BASENAME_BONUS = 2
360
361let predBuf = new Int32Array(4096)
362
363function boundaryBonus(orig: string, j: number): number {
364 if (j === 0) return 10
365 const p = orig.charCodeAt(j - 1)
366 if (p === 47) return 10 // '/'
367 if (p === 95 || p === 45 || p === 46 || p === 32) return 8 // _ - . space
368 const c = orig.charCodeAt(j)
369 if (p >= 97 && p <= 122 && c >= 65 && c <= 90) return 6 // camelCase
370 return 0
371}
372
373// Best subsequence alignment of `q` in `tc` (both already case-folded as needed);
374// `orig` is the original text for boundary detection. Returns the score and the
375// matched indices, or undefined when `q` is not a subsequence.
376function fuzzyMatch(q: string, tc: string, orig: string): { score: number; pos: number[] } | undefined {
377 const m = q.length
378 const n = tc.length
379 if (m === 0) return { score: 0, pos: [] }
380 if (m > n) return undefined
381 // Cheap subsequence precheck.
382 {
383 let j = 0
384 for (let i = 0; i < m; i++) {
385 const k = tc.indexOf(q[i] as string, j)
386 if (k === -1) return undefined
387 j = k + 1
388 }
389 }
390 if (predBuf.length < m * n) predBuf = new Int32Array(m * n * 2)
391 const pred = predBuf
392 const baseStart = orig.lastIndexOf('/') + 1
393 let P = new Array<number>(n).fill(NEG)
394 let C = new Array<number>(n).fill(NEG)
395 for (let i = 0; i < m; i++) {
396 const qc = q[i] as string
397 let carry = NEG
398 let carryK = -1
399 for (let j = 0; j < n; j++) {
400 C[j] = NEG
401 if (i > 0) {
402 carry -= 1
403 if (j >= 2 && (P[j - 2] as number) > NEG / 2 && (P[j - 2] as number) - GAP_OPEN > carry) {
404 carry = (P[j - 2] as number) - GAP_OPEN
405 carryK = j - 2
406 }
407 }
408 if (tc[j] !== qc) continue
409 const bonus = boundaryBonus(orig, j) + (j >= baseStart ? BASENAME_BONUS : 0)
410 if (i === 0) {
411 C[j] = MATCH_SCORE + bonus
412 pred[i * n + j] = -1
413 continue
414 }
415 let best = NEG
416 let bestK = -1
417 if (j >= 1 && (P[j - 1] as number) > NEG / 2) {
418 best = (P[j - 1] as number) + CONSECUTIVE_BONUS
419 bestK = j - 1
420 }
421 if (carry > NEG / 2 && carry > best) {
422 best = carry
423 bestK = carryK
424 }
425 if (bestK === -1) continue
426 C[j] = best + MATCH_SCORE + bonus
427 pred[i * n + j] = bestK
428 }
429 const t = P
430 P = C
431 C = t
432 }
433 // After the last swap, P holds row m-1.
434 let bestJ = -1
435 let bestS = NEG / 2
436 for (let j = 0; j < n; j++) {
437 const s = P[j] as number
438 if (s > bestS) {
439 bestS = s
440 bestJ = j
441 }
442 }
443 if (bestJ === -1) return undefined
444 const pos = new Array<number>(m)
445 let j = bestJ
446 for (let i = m - 1; i >= 0; i--) {
447 pos[i] = j
448 j = pred[i * n + j] as number
449 }
450 return { score: bestS, pos }
451}
452
453function mergeRanges(pos: number[]): Array<[number, number]> {
454 pos.sort((a, b) => a - b)
455 const out: Array<[number, number]> = []
456 for (const p of pos) {
457 const last = out[out.length - 1]
458 if (last && p <= last[1]) {
459 if (p + 1 > last[1]) last[1] = p + 1
460 } else out.push([p, p + 1])
461 }
462 return out
463}
464
465/** An empty query returns the first `limit` files in order (total = every file). */
466export function filterRows(
467 index: RepoIndex,
468 query: string,
469 git: Record<string, GitMark>,
470 touched: Record<string, number>,
471 limit = 200,
472): { rows: TreeRow[]; total: number } {
473 const files = index.files
474 const terms = query.split(/\s+/).filter((t) => t !== '')
475 const lowers = lowerFiles(files)
476 type Hit = { i: number; score: number; pos: number[] }
477 const hits: Hit[] = []
478
479 if (terms.length === 0) {
480 const n = Math.min(limit, files.length)
481 for (let i = 0; i < n; i++) hits.push({ i, score: 0, pos: [] })
482 } else {
483 const prepared = terms.map((t) => {
484 const isCase = t !== t.toLowerCase()
485 return { q: isCase ? t : t.toLowerCase(), isCase }
486 })
487 for (let i = 0; i < files.length; i++) {
488 const orig = files[i] as string
489 let total = 0
490 const allPos: number[] = []
491 let ok = true
492 for (const { q, isCase } of prepared) {
493 const tc = isCase ? orig : (lowers[i] as string)
494 const r = fuzzyMatch(q, tc, orig)
495 if (!r) {
496 ok = false
497 break
498 }
499 total += r.score
500 for (const p of r.pos) allPos.push(p)
501 }
502 if (ok) hits.push({ i, score: total, pos: allPos })
503 }
504 hits.sort((a, b) => {
505 if (b.score !== a.score) return b.score - a.score
506 const fa = files[a.i] as string
507 const fb = files[b.i] as string
508 if (fa.length !== fb.length) return fa.length - fb.length
509 return fa < fb ? -1 : fa > fb ? 1 : 0
510 })
511 }
512
513 const total = terms.length === 0 ? files.length : hits.length
514 const rows: TreeRow[] = []
515 for (const h of hits.slice(0, Math.max(0, limit))) {
516 const path = files[h.i] as string
517 const row: TreeRow = {
518 path,
519 name: path,
520 depth: 0,
521 kind: 'file',
522 isExpanded: false,
523 mark: git[path],
524 isTouched: (touched[path] ?? 0) > 0,
525 }
526 if (h.pos.length > 0) row.matches = mergeRanges(h.pos)
527 rows.push(row)
528 }
529 return { rows, total }
530}
531
532// ---------------------------------------------------------------------------
533// Documents
534
535const BINARY_EXTS = new Set([
536 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'icns', 'tif', 'tiff', 'heic', 'avif', 'psd',
537 'pdf', 'zip', 'gz', 'tgz', 'bz2', 'xz', 'zst', '7z', 'rar', 'tar', 'jar', 'war',
538 'woff', 'woff2', 'ttf', 'otf', 'eot',
539 'mp3', 'mp4', 'mov', 'avi', 'mkv', 'webm', 'wav', 'flac', 'ogg', 'm4a',
540 'exe', 'dll', 'so', 'dylib', 'a', 'o', 'obj', 'class', 'pyc', 'wasm', 'bin', 'dat',
541 'sqlite', 'sqlite3', 'db', 'node', 'dmg', 'iso', 'pkl', 'npy', 'parquet', 'lockb',
542])
543const MARKDOWN_EXTS = new Set(['md', 'mdx', 'markdown'])
544
545function extOf(path: string): string {
546 const name = baseName(path)
547 const i = name.lastIndexOf('.')
548 return i <= 0 ? '' : name.slice(i + 1).toLowerCase()
549}
550
551function countLines(text: string): number {
552 if (text === '') return 0
553 let n = 1
554 let i = text.indexOf('\n')
555 while (i !== -1) {
556 if (i < text.length - 1) n++
557 i = text.indexOf('\n', i + 1)
558 }
559 return n
560}
561
562function sanitize(text: string): string {
563 let t = text
564 if (t.charCodeAt(0) === 0xfeff) t = t.slice(1)
565 if (t.indexOf('\r') !== -1) t = t.replace(/\r\n/g, '\n')
566 // Everything but tab and newline: C0, DEL, C1 (a lone \r included).
567 return t.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '')
568}
569
570/** A FileDoc with nothing read yet. */
571export function emptyDoc(path: string, kind: FileDoc['kind'], extra: Partial<FileDoc> = {}): FileDoc {
572 return { path, kind, lineCount: 0, size: 0, mtimeMs: 0, ...extra }
573}
574
575/** What a stat alone decides about a file; undefined when its text must be read. */
576export function docFromStat(
577 root: string,
578 path: string,
579 st: { kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number },
580): FileDoc | undefined {
581 const base = { size: st.size, mtimeMs: st.mtimeMs }
582 if (st.kind === 'dir') return emptyDoc(path, 'missing', { ...base, error: 'is a directory' })
583 if (st.kind !== 'file') return emptyDoc(path, 'missing', { ...base, error: 'not a regular file' })
584 if (st.size > MAX_READ_BYTES) return emptyDoc(path, 'too-large', base)
585 const ext = extOf(path)
586 if (ext === 'png') return emptyDoc(path, 'image', { ...base, absPath: `${root}/${path}` })
587 if (BINARY_EXTS.has(ext)) return emptyDoc(path, 'binary', base)
588 return undefined
589}
590
591/** A file's text → its doc: binary when it holds a NUL early, else markdown or text, sanitized. */
592export function docFromText(path: string, st: { size: number; mtimeMs: number }, raw: string): FileDoc {
593 const base = { size: st.size, mtimeMs: st.mtimeMs }
594 if (raw.slice(0, 8000).indexOf('\0') !== -1) return emptyDoc(path, 'binary', base)
595 const text = sanitize(raw)
596 return emptyDoc(path, MARKDOWN_EXTS.has(extOf(path)) ? 'markdown' : 'text', { ...base, text, lineCount: countLines(text) })
597}
598
599export function errText(e: unknown, fallback: string): string {
600 if (e && typeof e === 'object' && 'message' in e) {
601 const m = String((e as { message: unknown }).message)
602 if (m !== '') return m
603 }
604 return fallback
605}
606
607// ---------------------------------------------------------------------------
608// Paging
609
610type PageSpan = { start: number; end: number; startLine: number }
611let pageCache: { text: string; spans: PageSpan[] } | undefined
612
613function spansOf(fullText: string): PageSpan[] {
614 if (pageCache && pageCache.text === fullText) return pageCache.spans
615 // A final newline ends the last line rather than starting another.
616 const text = fullText.endsWith('\n') ? fullText.slice(0, -1) : fullText
617 const spans: PageSpan[] = []
618 const len = text.length
619 let pos = 0 // start of the next unconsumed line (or mid-line remainder)
620 let lineNo = 1 // logical line number of the text at `pos`
621 while (pos < len || spans.length === 0) {
622 if (len === 0) {
623 spans.push({ start: 0, end: 0, startLine: 1 })
624 break
625 }
626 const start = pos
627 const startLine = lineNo
628 let end = pos
629 let lines = 0
630 for (;;) {
631 if (end >= len) break
632 if (lines >= PAGE_LINES) break
633 const nl = text.indexOf('\n', end)
634 const lineEnd = nl === -1 ? len : nl
635 const next = nl === -1 ? len : nl + 1
636 // Chars this page would span if it took the line (newline excluded at the end).
637 if (lineEnd - start > PAGE_CHARS) {
638 if (lines === 0) {
639 // One line longer than a page: hard-cut it, never inside a surrogate pair.
640 let cut = start + PAGE_CHARS
641 const c = text.charCodeAt(cut - 1)
642 if (c >= 0xd800 && c <= 0xdbff) cut--
643 end = cut
644 pos = cut
645 // The rest of the line continues on the next page under the same number.
646 lines = -1
647 }
648 break
649 }
650 end = next
651 lines++
652 lineNo++
653 }
654 if (lines === -1) {
655 spans.push({ start, end, startLine })
656 continue
657 }
658 pos = end
659 // Drop the newline that ends the page's last line.
660 const spanEnd = end > start && text.charCodeAt(end - 1) === 10 ? end - 1 : end
661 spans.push({ start, end: spanEnd, startLine })
662 }
663 pageCache = { text: fullText, spans }
664 return spans
665}
666
667export function pageOf(doc: FileDoc, page: number): DocPage {
668 const fullText = doc.text ?? ''
669 const spans = spansOf(fullText)
670 const text = fullText.endsWith('\n') ? fullText.slice(0, -1) : fullText
671 const p = Math.min(Math.max(Number.isFinite(page) ? Math.trunc(page) : 0, 0), spans.length - 1)
672 const s = spans[p] as PageSpan
673 return { text: text.slice(s.start, s.end), startLine: s.startLine, pageCount: spans.length }
674}
675
676// ---------------------------------------------------------------------------
677// Diff
678
679/** `git diff` output → its hunks, cut at a hunk boundary to MAX_DIFF_CHARS; undefined when none. */
680export function parseDiff(stdout: string, isStdoutTruncated: boolean): { hunks: string; isTruncated: boolean } | undefined {
681 if (stdout === '') return undefined
682 const out = sanitize(stdout)
683 const at = out.startsWith('@@') ? 0 : out.indexOf('\n@@')
684 if (at === -1) return undefined
685 const body = (at === 0 ? out : out.slice(at + 1)).replace(/\n$/, '')
686 if (body.length <= MAX_DIFF_CHARS && !isStdoutTruncated) return { hunks: body, isTruncated: false }
687 // Keep whole hunks while they fit.
688 let cut = -1
689 let from = 0
690 for (;;) {
691 const next = body.indexOf('\n@@', from + 1)
692 if (next === -1 || next > MAX_DIFF_CHARS) break
693 cut = next
694 from = next
695 }
696 if (body.length <= MAX_DIFF_CHARS) return { hunks: body, isTruncated: true }
697 if (cut === -1) {
698 // The first hunk alone is too long: cut at a line boundary.
699 const nl = body.lastIndexOf('\n', MAX_DIFF_CHARS)
700 cut = nl > 0 ? nl : MAX_DIFF_CHARS
701 }
702 return { hunks: body.slice(0, cut), isTruncated: true }
703}
704
705// ---------------------------------------------------------------------------
706
707export function formatSize(bytes: number): string {
708 if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
709 if (bytes < 1024) return `${Math.round(bytes)} B`
710 const units = ['KB', 'MB', 'GB', 'TB']
711 let v = bytes / 1024
712 let u = 0
713 while (Math.round(v * 10) / 10 >= 1024 && u < units.length - 1) {
714 v /= 1024
715 u++
716 }
717 return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[u]}`
718}
719hooks/tree.tsx 497 lines1// The repo tree and fuzzy finder, drawn as a pure function of the pane state.
2//
3// Every row is exactly one terminal line: a keyed plain Button (the label is
4// the row's left side: gutter, indent, chevron, name) beside a small Text for
5// the right-edge markers (touched dot, git mark). A Button's label is one
6// string, so styling inside it (matched characters, struck-through deleted
7// names, the cursor bar) is drawn as small absolutely placed Texts painted over
8// the label's cells; the Button stays the one thing Tab, arrows, Enter and
9// clicks reach.
10import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
11import type { GitMark, RepoView, RepoIndex, TreeRow } from '../types'
12
13export type TreeActions = {
14 press(row: TreeRow): void // dir: toggle expand; file: open in viewer
15 setFilter(query: string): void
16 collapseAll(): void
17 refresh(): void
18 toggleFollow(): void
19 openFirstMatch(): void // Enter in the finder
20 close(): void // our own close button, beside the engine's ✕
21}
22
23export type TreeCtx = {
24 surface: RenderSurface
25 columns: number
26 rows: number
27 index: RepoIndex | null // null → loading
28 rows_: TreeRow[] // visible rows (tree or filtered)
29 total: number // filter mode: total matches
30 view: RepoView
31 changedCount: number // files with a git mark
32 touchedCount: number
33 actions: TreeActions
34 /** Drawn in place of the rows and the hint: the pane's keyboard Client, where the surface has one. */
35 body?: RenderElement
36 /** Extra toolbar buttons (the keyboard-only h/j/k/l moves). */
37 tools?: RenderElement[]
38}
39
40/** Most rows drawn; the rest collapse into a dim "… N more". */
41export const MAX_ROWS = 500
42/**
43 * The engine bounds a drawn tree to 100,000 serialized characters (past it the
44 * instance unmounts), so rows are also drawn only up to this many characters.
45 */
46const ROW_CHAR_BUDGET = 85_000
47
48const ELLIPSIS = '…'
49
50// ---------------------------------------------------------------- text helpers
51
52/** Terminal cells a code point takes: 0 combining, 2 wide, else 1. */
53function cellWidth(cp: number): number {
54 if (cp === 0) return 0
55 if (cp < 0x300) return 1
56 if (
57 (cp >= 0x300 && cp <= 0x36f) ||
58 (cp >= 0x200b && cp <= 0x200f) ||
59 (cp >= 0xfe00 && cp <= 0xfe0f) ||
60 cp === 0x20e3
61 ) {
62 return 0
63 }
64 if (
65 (cp >= 0x1100 && cp <= 0x115f) ||
66 (cp >= 0x2e80 && cp <= 0xa4cf) ||
67 (cp >= 0xac00 && cp <= 0xd7a3) ||
68 (cp >= 0xf900 && cp <= 0xfaff) ||
69 (cp >= 0xfe30 && cp <= 0xfe6f) ||
70 (cp >= 0xff00 && cp <= 0xff60) ||
71 (cp >= 0xffe0 && cp <= 0xffe6) ||
72 (cp >= 0x1f300 && cp <= 0x1faff) ||
73 (cp >= 0x20000 && cp <= 0x3fffd)
74 ) {
75 return 2
76 }
77 return 1
78}
79
80function strWidth(s: string): number {
81 let w = 0
82 for (const ch of s) w += cellWidth(ch.codePointAt(0) ?? 0)
83 return w
84}
85
86/** Truncate with a trailing ellipsis so the result is at most `max` cells. */
87function fit(s: string, max: number): string {
88 if (max <= 0) return ''
89 if (strWidth(s) <= max) return s
90 if (max === 1) return ELLIPSIS
91 let out = ''
92 let w = 0
93 for (const ch of s) {
94 const cw = cellWidth(ch.codePointAt(0) ?? 0)
95 if (w + cw > max - 1) break
96 out += ch
97 w += cw
98 }
99 return out + ELLIPSIS
100}
101
102/** One drawn character: its text, cells, and whether the query matched it. */
103type Glyph = { s: string; w: number; hit: boolean }
104
105/** Control characters in a file name would break the one-line rule. */
106function glyphsOf(text: string, ranges: Array<[number, number]> | undefined): Glyph[] {
107 const out: Glyph[] = []
108 let at = 0
109 for (const ch of text) {
110 const cp = ch.codePointAt(0) ?? 0
111 const isControl = cp < 32 || (cp >= 127 && cp < 160)
112 const hit = !!ranges && ranges.some(r => at >= r[0] && at < r[1])
113 out.push({ s: isControl ? '?' : ch, w: isControl ? 1 : cellWidth(cp), hit })
114 at += ch.length
115 }
116 return out
117}
118
119const sumWidth = (g: readonly Glyph[]): number => g.reduce((n, x) => n + x.w, 0)
120
121/** Cut to `max` cells keeping the start (tree names). */
122function cutEnd(g: Glyph[], max: number): Glyph[] {
123 if (sumWidth(g) <= max) return g
124 const out: Glyph[] = []
125 let w = 0
126 for (const x of g) {
127 if (w + x.w > max - 1) break
128 out.push(x)
129 w += x.w
130 }
131 out.push({ s: ELLIPSIS, w: 1, hit: false })
132 return out
133}
134
135/** Cut to `max` cells with the ellipsis in the middle, favouring the tail (the file name). */
136function cutMiddle(g: Glyph[], max: number): Glyph[] {
137 if (sumWidth(g) <= max) return g
138 if (max <= 1) return max === 1 ? [{ s: ELLIPSIS, w: 1, hit: false }] : []
139 const room = max - 1
140 const headRoom = Math.floor(room / 3)
141 const head: Glyph[] = []
142 let hw = 0
143 for (const x of g) {
144 if (hw + x.w > headRoom) break
145 head.push(x)
146 hw += x.w
147 }
148 const tail: Glyph[] = []
149 let tw = 0
150 for (let i = g.length - 1; i >= head.length; i--) {
151 const x = g[i]!
152 if (tw + x.w > room - hw) break
153 tail.unshift(x)
154 tw += x.w
155 }
156 return [...head, { s: ELLIPSIS, w: 1, hit: false }, ...tail]
157}
158
159/** [startCell, text] runs of consecutive matched glyphs, cell-offset by `base`. */
160function hitRuns(g: readonly Glyph[], base: number): Array<[number, string]> {
161 const runs: Array<[number, string]> = []
162 let cell = base
163 let cur: [number, string] | undefined
164 for (const x of g) {
165 if (x.hit) {
166 if (cur) cur[1] += x.s
167 else {
168 cur = [cell, x.s]
169 runs.push(cur)
170 }
171 } else {
172 cur = undefined
173 }
174 cell += x.w
175 }
176 return runs
177}
178
179// ------------------------------------------------------------------ git marks
180
181type Mark = { ch: string; color: string; dim?: boolean; bold?: boolean }
182
183function markOf(mark: GitMark): Mark {
184 switch (mark) {
185 case 'M':
186 return { ch: 'M', color: 'yellow' }
187 case 'A':
188 return { ch: 'A', color: 'green' }
189 case '?':
190 return { ch: '?', color: 'green', dim: true }
191 case 'D':
192 return { ch: 'D', color: 'red' }
193 case 'R':
194 return { ch: 'R', color: 'cyan' }
195 case 'U':
196 return { ch: 'U', color: 'red', bold: true }
197 }
198}
199
200// ------------------------------------------------------------------- the rows
201
202/** Width of the right-hand marker cluster (`● M`) plus one gap cell. */
203const MARKER_CELLS = 3
204const MARKER_GAP = 1
205
206function Row(el: ElementTable, ctx: TreeCtx, row: TreeRow, isFilter: boolean): RenderElement {
207 const { Box, Text, Button } = el
208 const W = Math.max(1, ctx.columns)
209
210 // Right side: touched dot, then the git mark (files) or a changes dot (dirs).
211 const mark = row.kind === 'file' && row.mark ? markOf(row.mark) : undefined
212 const hasDirDot = row.kind === 'dir' && !!row.hasChanges
213 const hasMarker = !!mark || hasDirDot || row.isTouched
214 const room = hasMarker ? W - MARKER_CELLS - MARKER_GAP : W
215
216 // Left side: gutter (the cursor bar draws over it), indent, chevron, name.
217 const isCursor = ctx.view.cursor !== '' && ctx.view.cursor === row.path
218 let prefix = ' '
219 if (!isFilter) {
220 const indent = Math.min(row.depth * 2, Math.max(0, Math.floor(room / 2)))
221 prefix += ' '.repeat(indent)
222 prefix += row.kind === 'dir' ? (row.isExpanded ? '▾ ' : '▸ ') : ' '
223 } else {
224 prefix += row.kind === 'dir' ? (row.isExpanded ? '▾ ' : '▸ ') : ''
225 }
226 const prefixW = strWidth(prefix)
227 const nameRoom = Math.max(1, room - prefixW)
228
229 const suffix = row.kind === 'dir' && !isFilter ? '/' : ''
230 const raw = glyphsOf(row.name + suffix, isFilter ? row.matches : undefined)
231 const glyphs = isFilter ? cutMiddle(raw, nameRoom) : cutEnd(raw, nameRoom)
232 const label = prefix + glyphs.map(x => x.s).join('')
233
234 const overlays: RenderElement[] = []
235 if (isCursor) {
236 overlays.push(
237 <Box position="absolute" left={0}>
238 <Text color="cyan" bold>
239 ▎
240 </Text>
241 </Box>,
242 )
243 }
244 if (isFilter) {
245 for (const [left, text] of hitRuns(glyphs, prefixW)) {
246 overlays.push(
247 <Box position="absolute" left={left}>
248 <Text color="cyan" bold>
249 {text}
250 </Text>
251 </Box>,
252 )
253 }
254 } else if (mark?.ch === 'D') {
255 // A deleted file's name, struck through in red.
256 overlays.push(
257 <Box position="absolute" left={prefixW}>
258 <Text color="red" strikethrough>
259 {glyphs.map(x => x.s).join('')}
260 </Text>
261 </Box>,
262 )
263 }
264
265 let markers: RenderElement | undefined
266 if (hasMarker) {
267 const touched = row.isTouched ? (
268 <Text color="magenta">●</Text>
269 ) : (
270 ' '
271 )
272 const right = mark ? (
273 <Text color={mark.color} dimColor={mark.dim} bold={mark.bold}>
274 {mark.ch}
275 </Text>
276 ) : hasDirDot ? (
277 <Text dimColor>•</Text>
278 ) : (
279 ' '
280 )
281 markers = (
282 <Text>
283 {touched} {right}
284 </Text>
285 )
286 }
287
288 const button = (
289 <Button
290 key={`row:${row.path}`}
291 plain
292 dimColor={mark?.ch === 'D'}
293 label={label}
294 onPress={() => ctx.actions.press(row)}
295 />
296 )
297 // A bare row (no markers, nothing drawn over it) is just the Button: lighter on the tree budget.
298 if (!markers && overlays.length === 0) return button
299
300 return (
301 <Box flexDirection="row" justifyContent="space-between" width={W}>
302 {button}
303 {markers}
304 {overlays}
305 </Box>
306 )
307}
308
309// ----------------------------------------------------------------- the pane
310
311function repoName(root: string): string {
312 const parts = root.split(/[\\/]/).filter(Boolean)
313 return parts[parts.length - 1] ?? root
314}
315
316const n = (value: number): string => value.toLocaleString('en-US')
317
318/** Cells between the divider and the pane's chrome, matching the tree rows' own gutter. */
319const PAD = 1
320/** Cells the engine's close mark covers at the right edge, plus a gap: the header stops short of them. */
321const CLOSE_RESERVE = 2 + 5 + 1
322
323/** The finder gets a rounded border (three rows) when the pane has this many rows to spare. */
324const hasBoxedFinder = (rows: number): boolean => rows >= 16
325
326/** Rows the hooks-drawn chrome takes above the Client: header, counts, finder (1 or 3), toolbar. */
327export const treeChromeRows = (rows: number): number => (hasBoxedFinder(rows) ? 6 : 4)
328
329export function Tree(el: ElementTable, ctx: TreeCtx): RenderElement {
330 const { Box, Text, Button } = el
331 const W = Math.max(1, ctx.columns)
332 const { index, view, actions } = ctx
333 const isFilter = view.filter.trim() !== ''
334
335 // 1. Header: repo folder bold, branch dim, our close button at the right (the engine's own dim
336 // ✕ is drawn over the last two cells of this row, so the button stops short of them and the two
337 // read as one `close ✕`). Then the counts, dim. Everything sits one cell in from the divider,
338 // lined up with the tree's chevrons.
339 const hasClose = W >= 28
340 const left = Math.max(4, W - PAD - (hasClose ? CLOSE_RESERVE : PAD))
341 let header: RenderElement
342 let counts: RenderElement | undefined
343 if (index) {
344 const name = fit(repoName(index.root), left)
345 const branchRoom = left - strWidth(name) - 3
346 const branch = index.isGit && index.branch && branchRoom >= 4 ? fit(index.branch, branchRoom) : ''
347 header = (
348 <Text wrap="truncate-end">
349 <Text bold>{name}</Text>
350 {branch ? <Text dimColor>{` ⎇ ${branch}`}</Text> : null}
351 </Text>
352 )
353 const bits = [`${n(index.files.length)}${index.isTruncated ? '+' : ''} files`]
354 if (ctx.changedCount > 0) bits.push(`${n(ctx.changedCount)} changed`)
355 if (ctx.touchedCount > 0) bits.push(`${n(ctx.touchedCount)} edited by Claude`)
356 counts = (
357 <Box paddingLeft={PAD}>
358 <Text dimColor wrap="truncate-end">
359 {fit(bits.join(' · '), W - PAD * 2)}
360 </Text>
361 </Box>
362 )
363 } else {
364 header = <Text bold>Repo</Text>
365 }
366 const top = (
367 <Box flexDirection="row" justifyContent="space-between" paddingLeft={PAD} paddingRight={hasClose ? 2 : PAD}>
368 {header}
369 {hasClose ? <Button key="close" plain role="dismiss" label="close" onPress={() => actions.close()} /> : null}
370 </Box>
371 )
372
373 // 2. Finder: a search field. Boxed (three rows) when the pane is tall enough to spare them.
374 const isBoxed = hasBoxedFinder(ctx.rows)
375 let finder: RenderElement | undefined
376 if ('Input' in el) {
377 const { Input } = el
378 // The field sits alone in a column box that grows, so it stretches across the whole box: a click
379 // anywhere in the row focuses it (a field beside a glyph in a row box took only its text's cells).
380 // autoFocus: after ctrl+x tab, a click on the pane or `/files`, the ring starts on the finder.
381 const field = (
382 <Input
383 key="filter"
384 placeholder="Filter files…"
385 value={view.filter}
386 autoFocus
387 onInput={(value: string) => actions.setFilter(value)}
388 onSubmit={() => actions.openFirstMatch()}
389 />
390 )
391 finder = isBoxed ? (
392 <Box paddingX={PAD}>
393 <Box key="finder" flexGrow={1} borderStyle="round" borderDimColor paddingX={1} gap={1} hover={{ borderDimColor: false }}>
394 <Text dimColor>⌕</Text>
395 <Box flexGrow={1} flexDirection="column">
396 {field}
397 </Box>
398 </Box>
399 </Box>
400 ) : (
401 <Box paddingLeft={PAD} gap={1}>
402 <Text dimColor>⌕</Text>
403 <Box flexGrow={1} flexDirection="column">
404 {field}
405 </Box>
406 </Box>
407 )
408 }
409
410 // 3. Toolbar: secondary actions, dim until pointed at or focused.
411 const collapseLabel = W >= 44 ? 'collapse all' : 'collapse'
412 const toolbar = (
413 <Box flexDirection="row" columnGap={3} flexWrap="wrap" paddingLeft={PAD}>
414 <Button key="tb:refresh" plain dimColor label="refresh" onPress={() => actions.refresh()} />
415 <Button key="tb:collapse" plain dimColor label={collapseLabel} onPress={() => actions.collapseAll()} />
416 <Button
417 key="tb:follow"
418 plain
419 dimColor={!view.follow}
420 label={`follow ${view.follow ? 'on' : 'off'}`}
421 onPress={() => actions.toggleFollow()}
422 />
423 </Box>
424 )
425
426 // 4. Body: rows or an empty state.
427 const body: RenderElement[] = []
428 const shown = ctx.rows_
429 let drawn = 0
430 if (!index) {
431 body.push(<Text dimColor>Loading files…</Text>)
432 } else if (shown.length === 0) {
433 if (isFilter) {
434 body.push(<Text dimColor wrap="truncate-end">{fit(`No files match "${view.filter}"`, W)}</Text>)
435 } else if (index.files.length === 0) {
436 body.push(<Text dimColor>No files in this repo.</Text>)
437 } else {
438 body.push(<Text dimColor>Nothing to show.</Text>)
439 }
440 } else {
441 if (isFilter) {
442 const matches = Math.max(ctx.total, shown.length)
443 const info = `${n(matches)} match${matches === 1 ? '' : 'es'}`
444 body.push(<Text dimColor>{fit(info, W)}</Text>)
445 }
446 let budget = ROW_CHAR_BUDGET
447 for (const row of shown) {
448 if (drawn >= MAX_ROWS) break
449 const rendered = Row(el, ctx, row, isFilter)
450 budget -= JSON.stringify(rendered).length
451 if (budget < 0) break
452 body.push(rendered)
453 drawn++
454 }
455 const total = isFilter ? Math.max(ctx.total, shown.length) : shown.length
456 const more = total - drawn
457 if (more > 0) {
458 const text = isFilter ? `… ${n(more)} more · keep typing to narrow` : `… ${n(more)} more`
459 body.push(<Text dimColor>{fit(text, W)}</Text>)
460 }
461 }
462
463 // 5. Hint.
464 const hintFull = '↑↓ move · ⏎ open · ctrl+x tab focus'
465 const hint = strWidth(hintFull) <= W ? hintFull : fit('↑↓ move · ⏎ open', W)
466
467 if (ctx.body) {
468 return (
469 <Box flexDirection="column">
470 {top}
471 {counts}
472 {finder}
473 {toolbar}
474 {ctx.tools && ctx.tools.length > 0 ? (
475 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
476 {ctx.tools}
477 </Box>
478 ) : null}
479 {ctx.body}
480 </Box>
481 )
482 }
483
484 return (
485 <Box flexDirection="column">
486 {top}
487 {counts}
488 {finder}
489 {toolbar}
490 <Box flexDirection="column">{body}</Box>
491 <Box marginTop={1}>
492 <Text dimColor>{hint}</Text>
493 </Box>
494 </Box>
495 )
496}
497hooks/viewer.tsx 310 lines1import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
2
3import type { DocPage, FileDoc, GitMark, RepoView } from '../types'
4
5export type ViewerActions = {
6 /** Return to the tree. */
7 back(): void
8 setPage(page: number): void
9 /** Markdown rendered <-> source. */
10 toggleRaw(): void
11 toggleDiff(): void
12 /** Show this file in the tree. */
13 reveal(): void
14 /** Our own close button, beside the engine's ✕. */
15 close(): void
16}
17
18export type ViewerCtx = {
19 surface: RenderSurface
20 columns: number
21 rows: number
22 doc: FileDoc
23 /** For text/markdown. */
24 page: DocPage | undefined
25 /** Loaded only when view.showDiff. */
26 diff: { hunks: string; isTruncated: boolean } | undefined
27 view: RepoView
28 mark?: GitMark
29 isTouched: boolean
30 /** The repo's folder name: the first crumb of the path. */
31 repoName?: string
32 actions: ViewerActions
33 /** Drawn in place of the body and footer: the pane's keyboard Client, where the surface has one. */
34 body?: RenderElement
35}
36
37/** Code.source and Markdown.text are refused above 10000 characters. */
38const LIMIT = 10000
39/** Rows the chrome (header, meta, toolbar, gap, footer, spare) takes around an image. */
40const CHROME_ROWS = 6
41/** Cells between the divider and the pane's chrome, matching the tree rows' own gutter. */
42const PAD = 1
43/** `‹ back`. */
44const BACK_WIDTH = 6
45/** Cells the engine's close mark covers at the right edge, plus a gap and our `close` label. */
46const CLOSE_RESERVE = 2 + 5 + 1
47
48/** Rows the hooks-drawn chrome takes above the Client: header, breadcrumb, toolbar with the file's facts. */
49export const FILE_CHROME_ROWS = 3
50
51const LANGUAGES: Record<string, string> = {
52 ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
53 js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
54 json: 'json', jsonc: 'json', md: 'markdown', mdx: 'markdown', markdown: 'markdown',
55 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', kt: 'kotlin',
56 swift: 'swift', c: 'c', 'h': 'c', cc: 'c++', cpp: 'c++', hpp: 'c++', cs: 'c#',
57 php: 'php', lua: 'lua', sh: 'shell', bash: 'shell', zsh: 'shell', fish: 'shell',
58 ps1: 'powershell', sql: 'sql', html: 'html', htm: 'html', css: 'css',
59 scss: 'scss', less: 'less', xml: 'xml', svg: 'svg', yaml: 'yaml', yml: 'yaml',
60 toml: 'toml', ini: 'ini', env: 'env', txt: 'text', csv: 'csv', tsv: 'tsv',
61 vue: 'vue', svelte: 'svelte', graphql: 'graphql', proto: 'protobuf',
62 tf: 'terraform', diff: 'diff', patch: 'diff', lock: 'lockfile', log: 'log',
63}
64
65const NAMED: Record<string, string> = {
66 dockerfile: 'dockerfile', makefile: 'makefile', gemfile: 'ruby', rakefile: 'ruby',
67}
68
69function languageOf(path: string): string | undefined {
70 const base = path.slice(path.lastIndexOf('/') + 1).toLowerCase()
71 const named = NAMED[base]
72 if (named) return named
73 const dot = base.lastIndexOf('.')
74 if (dot <= 0 && !(dot === 0 && base.length > 1)) return undefined
75
76 return LANGUAGES[base.slice(dot + 1)]
77}
78
79function formatSize(bytes: number): string {
80 if (bytes < 1024) return `${bytes} B`
81 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
82
83 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
84}
85
86/** `abcdefgh` to `abc…fgh` within max characters. */
87function middle(s: string, max: number): string {
88 if (max <= 0) return ''
89 if (s.length <= max) return s
90 if (max === 1) return '…'
91 const keep = max - 1
92 const head = Math.ceil(keep / 2)
93 const tail = keep - head
94
95 return s.slice(0, head) + '…' + (tail > 0 ? s.slice(s.length - tail) : '')
96}
97
98/** Strips what Code and Markdown refuse (all control characters but tab and newline) and caps the length. */
99function clean(text: string): string {
100 // eslint-disable-next-line no-control-regex
101 return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').slice(0, LIMIT)
102}
103
104function countLines(text: string): number {
105 if (text === '') return 0
106 let n = 1
107 for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) n++
108 if (text.charCodeAt(text.length - 1) === 10) n--
109
110 return n
111}
112
113function countDiff(hunks: string): { add: number; del: number } {
114 let add = 0
115 let del = 0
116 for (const line of hunks.split('\n')) {
117 const c = line.charCodeAt(0)
118 if (c === 43) add++
119 else if (c === 45) del++
120 }
121
122 return { add, del }
123}
124
125function markStyle(mark: GitMark): { color: string; dimColor?: boolean; bold?: boolean } {
126 switch (mark) {
127 case 'M':
128 return { color: 'yellow' }
129 case 'A':
130 return { color: 'green' }
131 case '?':
132 return { color: 'green', dimColor: true }
133 case 'D':
134 return { color: 'red' }
135 case 'R':
136 return { color: 'cyan' }
137 case 'U':
138 return { color: 'red', bold: true }
139 }
140}
141
142export function Viewer(el: ElementTable, ctx: ViewerCtx): RenderElement {
143 const { Box, Text, Button, Code, Markdown } = el
144 const { doc, view, mark, actions } = ctx
145 const columns = Math.max(20, Math.floor(ctx.columns) || 20)
146 const pageCount = ctx.page?.pageCount ?? 1
147 const current = Math.min(Math.max(view.page, 0), Math.max(0, pageCount - 1))
148 const isText = doc.kind === 'text' || doc.kind === 'markdown'
149 const isMarkdown = doc.kind === 'markdown'
150 // A deleted file has no content to show, only its diff. Keep the toggle
151 // reachable while the diff is showing so the person can always get out.
152 const hasDiffMark = mark !== undefined && mark !== '?'
153 const canDiff = (isText || doc.kind === 'missing') && (hasDiffMark || view.showDiff)
154 const showingDiff = canDiff && view.showDiff
155 const isPaged = isText && !showingDiff && pageCount > 1
156
157 // 1. Header, the file's tab: back, the file name bold, git mark, touched dot; our close button at
158 // the right (the engine's own dim ✕ covers the last two cells of this row, so it stops short of
159 // them and the two read as one `close ✕`). One cell in from the divider, as the tree's chrome is.
160 const hasClose = columns >= 28
161 const tail = (mark ? 2 : 0) + (ctx.isTouched ? 2 : 0)
162 const base = doc.path.slice(doc.path.lastIndexOf('/') + 1)
163 const baseRoom = Math.max(6, columns - PAD - BACK_WIDTH - 1 - tail - (hasClose ? CLOSE_RESERVE : PAD))
164 const header = (
165 <Box flexDirection="row" justifyContent="space-between" paddingLeft={PAD} paddingRight={hasClose ? 2 : PAD}>
166 <Box flexDirection="row" gap={1}>
167 <Button key="back" label="‹ back" plain dimColor onPress={() => actions.back()} />
168 <Text bold wrap="truncate-end">{middle(base, baseRoom)}</Text>
169 {mark ? <Text {...markStyle(mark)}>{mark}</Text> : null}
170 {ctx.isTouched ? <Text color="magenta">●</Text> : null}
171 </Box>
172 {hasClose ? <Button key="close" plain role="dismiss" label="close" onPress={() => actions.close()} /> : null}
173 </Box>
174 )
175
176 // 2. Breadcrumb: repo › folders, dim, the last crumb a touch stronger.
177 const crumbs = [...(ctx.repoName ? [ctx.repoName] : []), ...doc.path.split('/').slice(0, -1)]
178 const crumbRoom = Math.max(4, columns - PAD * 2)
179 const crumbText = middle(crumbs.length > 0 ? crumbs.join(' › ') : '·', crumbRoom)
180 const breadcrumb = (
181 <Box paddingLeft={PAD}>
182 <Text dimColor wrap="truncate-end">{crumbText}</Text>
183 </Box>
184 )
185
186 // 3. Toolbar (only the buttons that apply) with the file's facts at its right edge when they fit.
187 const parts: string[] = []
188 if (isText) {
189 parts.push(`${doc.lineCount} ${doc.lineCount === 1 ? 'line' : 'lines'}`)
190 parts.push(formatSize(doc.size))
191 const language = languageOf(doc.path)
192 if (language) parts.push(language)
193 if (isPaged) parts.push(`page ${current + 1}/${pageCount}`)
194 } else if (doc.kind === 'image') {
195 parts.push('png image', formatSize(doc.size))
196 } else if (doc.kind === 'binary' || doc.kind === 'too-large') {
197 parts.push(formatSize(doc.size))
198 } else if (doc.kind === 'missing') {
199 parts.push('not on disk')
200 }
201 if (showingDiff) {
202 if (ctx.diff) {
203 const { add, del } = countDiff(ctx.diff.hunks)
204 parts.push(`diff vs HEAD +${add} −${del}`)
205 } else {
206 parts.push('diff vs HEAD')
207 }
208 }
209 const metaText = parts.join(' · ')
210
211 const buttons: RenderElement[] = []
212 const labels: string[] = []
213 const add = (key: string, label: string, onPress: () => void) => {
214 labels.push(label)
215 buttons.push(<Button key={key} label={label} plain dimColor onPress={onPress} />)
216 }
217 if (isPaged && current > 0) add('prev', '‹ prev', () => actions.setPage(current - 1))
218 if (isPaged && current < pageCount - 1) add('next', 'next ›', () => actions.setPage(current + 1))
219 if (isMarkdown && !showingDiff) add('raw', view.isRaw ? 'rendered' : 'raw', () => actions.toggleRaw())
220 if (canDiff) add('diff', showingDiff ? 'file' : 'diff', () => actions.toggleDiff())
221 add('reveal', 'reveal', () => actions.reveal())
222 const buttonsWidth = labels.reduce((w, l) => w + l.length, 0) + 3 * (labels.length - 1)
223 const metaRoom = columns - PAD * 2 - buttonsWidth - 3
224 const toolbar = (
225 <Box flexDirection="row" justifyContent="space-between" paddingX={PAD}>
226 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
227 {buttons}
228 </Box>
229 {metaRoom >= 8 ? <Text dimColor wrap="truncate-end">{middle(metaText, metaRoom)}</Text> : null}
230 </Box>
231 )
232
233 // 4. Body.
234 let body: RenderElement
235 let footer: string | undefined
236 if (showingDiff) {
237 if (ctx.diff && ctx.diff.hunks.trim() !== '') {
238 body = <Code source={clean(ctx.diff.hunks)} format="diff" wrap="truncate-end" />
239 if (ctx.diff.isTruncated) footer = '… diff truncated'
240 } else {
241 body = <Text dimColor>No changes against HEAD.</Text>
242 }
243 } else if (doc.kind === 'missing') {
244 body = <Text dimColor wrap="truncate-end">{doc.error ?? 'File not found.'}</Text>
245 } else if (doc.kind === 'too-large') {
246 body = <Text dimColor>Too large to show ({formatSize(doc.size)}; the limit is 4.0 MB).</Text>
247 } else if (doc.kind === 'binary') {
248 body = <Text dimColor>Binary file, not shown.</Text>
249 } else if (doc.kind === 'image') {
250 if (ctx.surface === 'terminal' && 'Image' in el && doc.absPath) {
251 const { Image } = el
252 body = (
253 <Image
254 key="image"
255 source={{ file: doc.absPath, format: 'png', generation: Math.floor(doc.mtimeMs) }}
256 columns={Math.min(255, Math.max(1, Math.floor(columns)))}
257 rows={Math.min(255, Math.max(1, Math.floor(ctx.rows) - CHROME_ROWS))}
258 alt={doc.path}
259 />
260 )
261 } else {
262 body = <Text dimColor>Image preview is only available in the terminal.</Text>
263 }
264 } else if (!ctx.page) {
265 body = <Text dimColor>Loading…</Text>
266 } else if (ctx.page.text === '') {
267 body = <Text dimColor>Empty file.</Text>
268 } else if (isMarkdown && !view.isRaw) {
269 body = <Markdown text={clean(ctx.page.text)} />
270 } else {
271 body = (
272 <Code
273 source={clean(ctx.page.text)}
274 {...(isMarkdown ? { language: 'markdown', wrap: 'wrap' as const } : { path: doc.path, wrap: 'truncate-end' as const })}
275 startLine={ctx.page.startLine}
276 />
277 )
278 }
279
280 // 5. Footer when paged.
281 if (isPaged && ctx.page && !footer) {
282 const first = ctx.page.startLine
283 const last = first + Math.max(1, countLines(ctx.page.text)) - 1
284 footer = `lines ${first}–${last} of ${doc.lineCount}`
285 }
286
287 if (ctx.body) {
288 return (
289 <Box flexDirection="column">
290 {header}
291 {breadcrumb}
292 {toolbar}
293 {ctx.body}
294 </Box>
295 )
296 }
297
298 return (
299 <Box flexDirection="column">
300 {header}
301 {breadcrumb}
302 {toolbar}
303 <Box flexDirection="column" marginTop={1}>
304 {body}
305 </Box>
306 {footer ? <Text dimColor wrap="truncate-end">{footer}</Text> : null}
307 </Box>
308 )
309}
310hooks/nav.tsx 569 lines1// repoviewer's keyboard Client: ONE surface module that draws the pane body and
2// takes the person's keys once a click has focused it. Tree mode (arrow-key
3// navigation, scrolling window, git marks) lives here; file mode (viewer and
4// editor) is delegated to ./navfile. The hooks module answers the ops posted
5// from here and sends back fresh props.
6//
7// Rules this file keeps (engine): elements only from `surface.elements`, no `$`,
8// one post per key press, setState only from handlers, once on init, or once per
9// change of props (guarded), every drawn line one terminal line within
10// `props.columns`.
11import type { ClientKeyEvent, ClientPointerEvent, ClientSurface, RenderElement } from 'claude-code'
12import type { GitMark, NavOp, NavProps, NavRow } from '../types'
13import { fileKey, filePointer, fileRender, initFile } from './navfile'
14import type { FileState } from './navfile'
15
16export type NavState = {
17 /** The path highlighted in the tree. */
18 cursor: string
19 /** The last `props.cursorAt` adopted. */
20 cursorAt: number
21 /** First tree row drawn (index into props.tree.rows). */
22 top: number
23 /** File mode's state (viewer / editor), created from props.file. */
24 file: FileState | undefined
25 /** The props last adopted from. */
26 lastProps: NavProps
27 /** `path|mtimeMs|lineAt` of the file adopted into `file` ('' none): adoption happens once per change. */
28 fileSeen: string
29}
30
31// ---------------------------------------------------------------- text helpers
32
33const ELLIPSIS = '…'
34
35/** Terminal cells a code point takes: 0 combining, 2 wide, else 1. */
36function cellWidth(cp: number): number {
37 if (cp === 0) return 0
38 if (cp < 0x300) return 1
39 if (
40 (cp >= 0x300 && cp <= 0x36f) ||
41 (cp >= 0x200b && cp <= 0x200f) ||
42 (cp >= 0xfe00 && cp <= 0xfe0f) ||
43 cp === 0x20e3
44 ) {
45 return 0
46 }
47 if (
48 (cp >= 0x1100 && cp <= 0x115f) ||
49 (cp >= 0x2e80 && cp <= 0xa4cf) ||
50 (cp >= 0xac00 && cp <= 0xd7a3) ||
51 (cp >= 0xf900 && cp <= 0xfaff) ||
52 (cp >= 0xfe30 && cp <= 0xfe6f) ||
53 (cp >= 0xff00 && cp <= 0xff60) ||
54 (cp >= 0xffe0 && cp <= 0xffe6) ||
55 (cp >= 0x1f300 && cp <= 0x1faff) ||
56 (cp >= 0x20000 && cp <= 0x3fffd)
57 ) {
58 return 2
59 }
60 return 1
61}
62
63function strWidth(s: string): number {
64 let w = 0
65 for (const ch of s) w += cellWidth(ch.codePointAt(0) ?? 0)
66 return w
67}
68
69/** Truncate with a trailing ellipsis so the result is at most `max` cells. */
70function fit(s: string, max: number): string {
71 if (max <= 0) return ''
72 if (strWidth(s) <= max) return s
73 if (max === 1) return ELLIPSIS
74 let out = ''
75 let w = 0
76 for (const ch of s) {
77 const cw = cellWidth(ch.codePointAt(0) ?? 0)
78 if (w + cw > max - 1) break
79 out += ch
80 w += cw
81 }
82 return out + ELLIPSIS
83}
84
85/** One drawn character: its text, cells, and whether the query matched it. */
86type Glyph = { s: string; w: number; hit: boolean }
87
88/** Ranges are flat [start, end, start, end…] UTF-16 offsets into `text`. Control characters become '?'. */
89function glyphsOf(text: string, ranges: readonly number[] | undefined): Glyph[] {
90 const out: Glyph[] = []
91 let at = 0
92 for (const ch of text) {
93 const cp = ch.codePointAt(0) ?? 0
94 const isControl = cp < 32 || (cp >= 127 && cp < 160)
95 let hit = false
96 if (ranges) {
97 for (let i = 0; i + 1 < ranges.length; i += 2) {
98 if (at >= ranges[i]! && at < ranges[i + 1]!) {
99 hit = true
100 break
101 }
102 }
103 }
104 out.push({ s: isControl ? '?' : ch, w: isControl ? 1 : cellWidth(cp), hit })
105 at += ch.length
106 }
107 return out
108}
109
110const sumWidth = (g: readonly Glyph[]): number => g.reduce((n, x) => n + x.w, 0)
111
112const ELL: Glyph = { s: ELLIPSIS, w: 1, hit: false }
113
114/** Cut to `max` cells keeping the start (tree names). */
115function cutEnd(g: Glyph[], max: number): Glyph[] {
116 if (sumWidth(g) <= max) return g
117 if (max <= 0) return []
118 const out: Glyph[] = []
119 let w = 0
120 for (const x of g) {
121 if (w + x.w > max - 1) break
122 out.push(x)
123 w += x.w
124 }
125 out.push(ELL)
126 return out
127}
128
129/** Cut to `max` cells with the ellipsis in the middle, favouring the tail (the file name). */
130function cutMiddle(g: Glyph[], max: number): Glyph[] {
131 if (sumWidth(g) <= max) return g
132 if (max <= 0) return []
133 if (max === 1) return [ELL]
134 const room = max - 1
135 const headRoom = Math.floor(room / 3)
136 const head: Glyph[] = []
137 let hw = 0
138 for (const x of g) {
139 if (hw + x.w > headRoom) break
140 head.push(x)
141 hw += x.w
142 }
143 const tail: Glyph[] = []
144 let tw = 0
145 for (let i = g.length - 1; i >= head.length; i--) {
146 const x = g[i]!
147 if (tw + x.w > room - hw) break
148 tail.unshift(x)
149 tw += x.w
150 }
151 return [...head, ELL, ...tail]
152}
153
154// ------------------------------------------------------------------ git marks
155
156type Mark = { ch: string; color: string; dim?: boolean; bold?: boolean }
157
158function markOf(mark: GitMark): Mark {
159 switch (mark) {
160 case 'M':
161 return { ch: 'M', color: 'yellow' }
162 case 'A':
163 return { ch: 'A', color: 'green' }
164 case '?':
165 return { ch: '?', color: 'green', dim: true }
166 case 'D':
167 return { ch: 'D', color: 'red' }
168 case 'R':
169 return { ch: 'R', color: 'cyan' }
170 case 'U':
171 return { ch: 'U', color: 'red', bold: true }
172 }
173}
174
175const GIT_MARKS = 'MA?DRU'
176
177/** A row's flags, parsed. */
178type Flags = { isDir: boolean; isOpen: boolean; hasChanges: boolean; isTouched: boolean; mark: GitMark | undefined }
179
180function parseFlags(flags: string): Flags {
181 let mark: GitMark | undefined
182 for (const ch of flags) if (GIT_MARKS.includes(ch)) mark = ch as GitMark
183 return {
184 isDir: flags.includes('d'),
185 isOpen: flags.includes('o'),
186 hasChanges: flags.includes('c'),
187 isTouched: flags.includes('t'),
188 mark,
189 }
190}
191
192// ------------------------------------------------------------ tree geometry
193
194/** The most recent props, for listeners registered once. */
195let latestProps: NavProps | undefined
196/** The most recent state (adopted or committed), for listeners registered once. */
197let liveState: NavState | undefined
198
199/** Rows the tree window shows: the region less the `… N more` line when rows were left out. */
200function viewRows(props: NavProps): number {
201 const reserve = props.tree && props.tree.more > 0 ? 1 : 0
202 return Math.max(1, props.rows - reserve)
203}
204
205/** Scroll so row `idx` is within the window; also clamps to the list. */
206function fitTop(top: number, idx: number, window: number, total: number): number {
207 let t = Math.min(top, Math.max(0, total - window))
208 if (idx >= 0) {
209 if (idx < t) t = idx
210 else if (idx >= t + window) t = idx - window + 1
211 }
212 return Math.max(0, t)
213}
214
215function indexOfPath(rows: readonly NavRow[], path: string): number {
216 if (path === '') return -1
217 for (let i = 0; i < rows.length; i++) if (rows[i]![0] === path) return i
218 return -1
219}
220
221/** The top the tree is drawn from, for these state and props. */
222function topFor(state: NavState, props: NavProps): number {
223 const rows = props.tree?.rows ?? []
224 return fitTop(state.top, indexOfPath(rows, state.cursor), viewRows(props), rows.length)
225}
226
227// ------------------------------------------------------------------ adoption
228
229function fileSeenKey(props: NavProps): string {
230 if (props.mode !== 'file' || !props.file) return ''
231 return `${props.file.path}|${props.file.mtimeMs}|${props.lineAt}`
232}
233
234/**
235 * Fold new props into the state. Returns the same object when there is nothing
236 * to adopt, so the caller calls setState only when something changed.
237 */
238function adopt(state: NavState | undefined, props: NavProps): NavState {
239 let next: NavState
240 if (state === undefined) {
241 next = {
242 cursor: props.cursor,
243 cursorAt: props.cursorAt,
244 top: 0,
245 file: undefined,
246 lastProps: props,
247 fileSeen: '',
248 }
249 next.top = topFor(next, props)
250 } else {
251 next = state
252 // The hooks side moved the cursor (reveal, follow, hotkeys): take it and scroll it into view.
253 if (props.cursorAt !== next.cursorAt) {
254 const moved: NavState = { ...next, cursor: props.cursor, cursorAt: props.cursorAt, lastProps: props }
255 moved.top = topFor(moved, props)
256 next = moved
257 }
258 }
259
260 // The open file: create or refresh the file state once per (path, mtime, line bump).
261 const seen = fileSeenKey(props)
262 if (seen !== next.fileSeen) {
263 const previous = next.file
264 const isDirty = !!previous?.buffer?.isDirty
265 if (seen === '') {
266 // Left file mode. A dirty buffer is never thrown away by the tree coming back.
267 next = { ...next, fileSeen: '', file: isDirty ? previous : undefined, lastProps: props }
268 } else if (isDirty && previous && props.file && previous.path !== props.file.path) {
269 // Never switch away from unsaved edits.
270 next = { ...next, fileSeen: seen, lastProps: props }
271 } else {
272 let file = initFile(props, previous)
273 // Same file with unsaved edits: the buffer survives whatever initFile did.
274 if (isDirty && previous && file.path === previous.path && !file.buffer?.isDirty) file = previous
275 next = { ...next, fileSeen: seen, file, lastProps: props }
276 }
277 }
278 return next
279}
280
281// ----------------------------------------------------------------- the rows
282
283/** One styled run of a drawn line. */
284type Run = { s: string; color?: string; bold?: boolean; dim?: boolean; strike?: boolean }
285
286const sameStyle = (a: Run, b: Run): boolean =>
287 a.color === b.color && a.bold === b.bold && a.dim === b.dim && a.strike === b.strike
288
289/** Merge neighbours of one style so a row stays a handful of nodes. */
290function mergeRuns(runs: Run[]): Run[] {
291 const out: Run[] = []
292 for (const r of runs) {
293 if (r.s === '') continue
294 const last = out[out.length - 1]
295 if (last && sameStyle(last, r)) last.s += r.s
296 else out.push({ ...r })
297 }
298 return out
299}
300
301/** Width of the right-hand marker cluster (`● M`) plus one gap cell. */
302const MARKER_CELLS = 3
303const MARKER_GAP = 1
304/** Cells kept clear at the right edge, after the markers. */
305const EDGE = 1
306
307/** The styled runs of one tree row, exactly `W` cells wide. */
308function rowRuns(row: NavRow, W: number, isFilter: boolean): Run[] {
309 const [path, depth, flagText, matches] = row
310 const f = parseFlags(flagText)
311 const isDeleted = f.mark === 'D'
312 const mark = !f.isDir && f.mark ? markOf(f.mark) : undefined
313 const hasDirDot = f.isDir && f.hasChanges
314 const hasMarker = (!!mark || hasDirDot || f.isTouched) && W >= 14
315 // A cell of margin at the right edge, as the chrome above keeps, so the markers never touch it.
316 const room = hasMarker ? W - MARKER_CELLS - MARKER_GAP - EDGE : W
317
318 // Left side: one gutter cell, indent, chevron, name.
319 let prefix = ' '
320 if (!isFilter) {
321 prefix += ' '.repeat(Math.min(depth * 2, Math.max(0, Math.floor(room / 2))))
322 prefix += f.isDir ? (f.isOpen ? '▾ ' : '▸ ') : ' '
323 } else if (f.isDir) {
324 prefix += f.isOpen ? '▾ ' : '▸ '
325 }
326 const prefixW = strWidth(prefix)
327 const nameRoom = Math.max(1, room - prefixW)
328
329 let name: string
330 if (isFilter) name = path
331 else {
332 const slash = path.lastIndexOf('/')
333 name = (slash >= 0 ? path.slice(slash + 1) : path) + (f.isDir ? '/' : '')
334 }
335 const raw = glyphsOf(name, isFilter ? matches : undefined)
336 const glyphs = isFilter ? cutMiddle(raw, nameRoom) : cutEnd(raw, nameRoom)
337
338 const runs: Run[] = [{ s: prefix, dim: isFilter && f.isDir }]
339 for (const g of glyphs) {
340 if (isDeleted) runs.push({ s: g.s, color: 'red', strike: true, dim: true })
341 else if (g.hit) runs.push({ s: g.s, color: 'cyan', bold: true })
342 else runs.push({ s: g.s, dim: isFilter && f.isDir })
343 }
344
345 const used = prefixW + sumWidth(glyphs)
346 if (hasMarker) {
347 runs.push({ s: ' '.repeat(Math.max(0, W - EDGE - MARKER_CELLS - used)) })
348 runs.push(f.isTouched ? { s: '●', color: 'magenta' } : { s: ' ' })
349 runs.push({ s: ' ' })
350 if (mark) runs.push({ s: mark.ch, color: mark.color, dim: mark.dim, bold: mark.bold })
351 else if (hasDirDot) runs.push({ s: '•', dim: true })
352 else runs.push({ s: ' ' })
353 runs.push({ s: ' '.repeat(EDGE) })
354 } else {
355 runs.push({ s: ' '.repeat(Math.max(0, W - used)) })
356 }
357 return mergeRuns(runs)
358}
359
360function renderTree(surface: ClientSurface<NavState>, state: NavState, props: NavProps): RenderElement {
361 const { Box, Text } = surface.elements
362 const W = Math.max(1, props.columns)
363 const tree = props.tree
364
365 if (!tree) return <Text dimColor>Loading files…</Text>
366 if (tree.rows.length === 0) {
367 const msg = tree.isFiltering ? `No files match "${tree.query}"` : 'No files in this repo.'
368 return (
369 <Text dimColor wrap="truncate-end">
370 {fit(msg, W)}
371 </Text>
372 )
373 }
374
375 const window = viewRows(props)
376 const top = topFor(state, props)
377 const end = Math.min(tree.rows.length, top + window)
378 const lines: RenderElement[] = []
379 for (let i = top; i < end; i++) {
380 const row = tree.rows[i]!
381 const isCursor = row[0] === state.cursor
382 const runs = rowRuns(row, W, tree.isFiltering)
383 lines.push(
384 <Text key={`r:${row[0]}`} wrap="truncate-end" backgroundColor={isCursor ? 'selectionBg' : undefined}>
385 {runs.map(r => (
386 <Text color={r.color} bold={r.bold} dimColor={r.dim} strikethrough={r.strike}>
387 {r.s}
388 </Text>
389 ))}
390 </Text>,
391 )
392 }
393 // Rows left out of the props: say so once the window reaches the end.
394 if (tree.more > 0 && end >= tree.rows.length) {
395 lines.push(
396 <Text key="more" dimColor wrap="truncate-end">
397 {fit(` … ${tree.more.toLocaleString('en-US')} more`, W)}
398 </Text>,
399 )
400 }
401 return <Box flexDirection="column">{lines}</Box>
402}
403
404// -------------------------------------------------------------- tree input
405
406/** Page size: a screen less one row of overlap. */
407const pageOf = (props: NavProps): number => Math.max(1, viewRows(props) - 1)
408
409/** The nearest row above `idx` that is a directory one level up, or -1. */
410function parentIndex(rows: readonly NavRow[], idx: number): number {
411 const depth = rows[idx]![1]
412 if (depth <= 0) return -1
413 for (let i = idx - 1; i >= 0; i--) {
414 const r = rows[i]!
415 if (r[1] === depth - 1 && r[2].includes('d')) return i
416 if (r[1] < depth - 1) return -1
417 }
418 return -1
419}
420
421/** Key names the Client receives that are not typed text. */
422const SPECIAL_KEYS = new Set([
423 'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'home', 'end',
424 'pageup', 'pagedown', 'escape', 'esc', 'insert', 'backtab', 'clear',
425])
426
427/** Text the person typed (a character, or a paste), as opposed to a key with a name. */
428export function typedText(k: ClientKeyEvent): string | undefined {
429 if (k.ctrl || k.meta || k.key === '' || SPECIAL_KEYS.has(k.key) || /^f\d+$/.test(k.key)) return undefined
430 return k.key === 'space' ? ' ' : k.key
431}
432
433/** What a tree key does: the new state and the one op to post (either may be absent).
434 * Only named keys navigate; typed text goes to Claude Code's prompt, and ← at the top level hands the keys back. */
435function treeKey(state: NavState, k: ClientKeyEvent, props: NavProps): { state: NavState; op?: NavOp } {
436 const text = typedText(k)
437 if (text !== undefined) return { state, op: { op: 'type', text } }
438 const tree = props.tree
439 if (k.ctrl || k.meta) return { state }
440 if (!tree || tree.rows.length === 0) return k.key === 'left' ? { state, op: { op: 'leave' } } : { state }
441 const rows = tree.rows
442 const idx = indexOfPath(rows, state.cursor)
443 const cur = idx >= 0 ? rows[idx]! : undefined
444 const f = cur ? parseFlags(cur[2]) : undefined
445
446 /** Move the highlight to row `to`. */
447 const move = (to: number): { state: NavState; op?: NavOp } => {
448 const at = Math.max(0, Math.min(rows.length - 1, to))
449 const path = rows[at]![0]
450 if (path === state.cursor) return { state }
451 const moved: NavState = { ...state, cursor: path }
452 moved.top = fitTop(topFor(state, props), at, viewRows(props), rows.length)
453 return { state: moved, op: { op: 'cursor', path } }
454 }
455
456 switch (k.key) {
457 case 'up':
458 return idx < 0 ? move(0) : move(idx - 1)
459 case 'down':
460 return idx < 0 ? move(0) : move(idx + 1)
461 case 'pageup':
462 return move((idx < 0 ? 0 : idx) - pageOf(props))
463 case 'pagedown':
464 return move((idx < 0 ? 0 : idx) + pageOf(props))
465 case 'home':
466 return move(0)
467 case 'end':
468 return move(rows.length - 1)
469 case 'right': {
470 if (!cur || !f) return move(0)
471 if (!f.isDir) return { state, op: { op: 'open', path: cur[0] } }
472 if (!f.isOpen) return { state, op: { op: 'expand', path: cur[0] } }
473 const next = rows[idx + 1]
474 // An expanded dir's first child is the next row, one level deeper.
475 if (next && next[1] === cur[1] + 1) return move(idx + 1)
476 return { state }
477 }
478 case 'left': {
479 if (!cur || !f) return { state, op: { op: 'leave' } }
480 if (f.isDir && f.isOpen) return { state, op: { op: 'collapse', path: cur[0] } }
481 const parent = parentIndex(rows, idx)
482 // At the repo's top level there is nowhere further out: back to Claude Code's prompt.
483 return parent >= 0 ? move(parent) : { state, op: { op: 'leave' } }
484 }
485 case 'return':
486 case 'enter':
487 if (!cur || !f) return move(0)
488 return { state, op: { op: f.isDir ? 'toggle' : 'open', path: cur[0] } }
489 }
490 return { state }
491}
492
493function treePointer(state: NavState, p: ClientPointerEvent, props: NavProps): { state: NavState; op?: NavOp } {
494 const tree = props.tree
495 if (p.type !== 'down' || !tree) return { state }
496 if (p.y < 0 || p.y >= viewRows(props)) return { state }
497 const at = topFor(state, props) + p.y
498 const row = tree.rows[at]
499 if (!row) return { state }
500 const f = parseFlags(row[2])
501 // A press on the row already selected acts on it (second click); otherwise it selects.
502 if (row[0] === state.cursor) return { state, op: { op: f.isDir ? 'toggle' : 'open', path: row[0] } }
503 return { state: { ...state, cursor: row[0], top: topFor(state, props) }, op: { op: 'cursor', path: row[0] } }
504}
505
506// ------------------------------------------------------------------ the Client
507
508export default function Nav(props: NavProps, surface: ClientSurface<NavState>): RenderElement {
509 latestProps = props
510
511 // Adopt new props into the state: once on init, then once per change.
512 const isFirst = surface.state === undefined
513 const state = adopt(surface.state, props)
514 if (state !== surface.state) surface.setState(state)
515 liveState = state
516
517 // Listeners are registered once, while the instance has no state yet. They read the live
518 // state and props: `commit` keeps `liveState` current between a press and the next render.
519 if (isFirst) {
520 const commit = (next: NavState) => {
521 liveState = next
522 surface.setState(next)
523 }
524 surface.onKey(k => {
525 const st = liveState
526 const pr = latestProps
527 if (!st || !pr) return
528 if (pr.mode === 'file') {
529 if (!st.file) return
530 let posted: NavOp | undefined
531 const file = fileKey(st.file, k, pr, op => {
532 posted = op
533 })
534 if (file !== st.file) commit({ ...st, file })
535 if (posted) surface.post(posted)
536 return
537 }
538 const r = treeKey(st, k, pr)
539 if (r.state !== st) commit(r.state)
540 if (r.op) surface.post(r.op)
541 })
542 surface.onPointer(p => {
543 const st = liveState
544 const pr = latestProps
545 if (!st || !pr) return
546 if (pr.mode === 'file') {
547 if (!st.file) return
548 let posted: NavOp | undefined
549 const file = filePointer(st.file, p, pr, op => {
550 posted = op
551 })
552 if (file !== st.file) commit({ ...st, file })
553 if (posted) surface.post(posted)
554 return
555 }
556 const r = treePointer(st, p, pr)
557 if (r.state !== st) commit(r.state)
558 if (r.op) surface.post(r.op)
559 })
560 }
561
562 const { Text } = surface.elements
563 if (props.mode === 'file') {
564 if (!state.file) return <Text dimColor>Loading…</Text>
565 return fileRender(surface.elements, state.file, props)
566 }
567 return renderTree(surface, state, props)
568}
569hooks/navfile.tsx 1100 lines1// File mode of canopy's keyboard Client (hooks/nav.tsx): the scrolling,
2// syntax-highlighted file view with a line cursor, the rendered-markdown view, the diff
3// view and the in-pane text editor UI.
4//
5// Everything here is pure: `fileKey`, `filePointer` and `initFile` return new FileState
6// objects (nav.tsx keeps them in surface state) and `fileRender` only reads. The hooks side
7// draws the header and toolbar above the Client, so this draws props.rows rows of at most
8// props.columns cells: a body (rows - 1 lines) and one dim status line.
9import type { ClientElements, ClientKeyEvent, ClientPointerEvent, RenderElement } from 'claude-code'
10
11import type { NavFile, NavOp, NavProps } from '../types'
12import { applyKey, displayCol, fromText, scrollIntoView, toText, view } from './editor'
13import type { Buffer } from './editor'
14
15export type FileState = {
16 path: string
17 mtimeMs: number
18 /** View mode: the first visible line (1-based, within the file) and the cursor line. */
19 top: number
20 line: number
21 /** Markdown: rendered (default) or source. Diff: first visible hunk. */
22 hunk: number
23 /** Present while editing. */
24 buffer?: Buffer
25 /** A save was refused because the file changed on disk: the next ctrl+s forces. */
26 isConflict: boolean
27 lineAt: number
28 // Extras (optional, so nav.tsx need not know them):
29 /** Diff: lines already scrolled off the top of the first visible hunk. */
30 diffTop?: number
31 /** The page the view state refers to (paged files): a change resets the cursor to the page's edge. */
32 page?: number
33 /** The `at` of the newest notice already seen; only a newer one is shown. */
34 noticeAt?: number
35 /** The first ctrl+q on a dirty buffer asked; the next one discards. */
36 isDiscarding?: boolean
37 /** The text of the save last posted: when the file on disk becomes it, the save landed. */
38 saved?: string
39}
40
41/** Code.source and Markdown.text are refused above 10000 characters. */
42const LIMIT = 10000
43const BUDGET = 9500
44
45// --------------------------------------------------------------------------- helpers
46
47/** Strips what Code and Markdown refuse (control characters but tab and newline, incl. \r). */
48function clean(text: string): string {
49 // eslint-disable-next-line no-control-regex
50 return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
51}
52
53function clamp(n: number, lo: number, hi: number): number {
54 return Math.max(lo, Math.min(hi, n))
55}
56
57/** `abcdefgh` to `abcdefg…` within n characters. */
58function cut(s: string, n: number): string {
59 if (n <= 0) return ''
60 if (s.length <= n) return s
61 return n === 1 ? '…' : s.slice(0, n - 1) + '…'
62}
63
64/** Cuts at n UTF-16 units without splitting a surrogate pair. */
65function slice(s: string, n: number): string {
66 if (s.length <= n) return s
67 const c = s.charCodeAt(n - 1)
68 return s.slice(0, c >= 0xd800 && c <= 0xdbff ? n - 1 : n)
69}
70
71function formatSize(bytes: number): string {
72 if (bytes < 1024) return `${bytes} B`
73 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
74
75 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
76}
77
78let cachedText: string | undefined
79let cachedLines: string[] = []
80
81/** The lines of a text (a trailing newline does not make an extra line); memoised on the last text. */
82function linesOf(text: string | undefined): string[] {
83 if (text === undefined || text === '') return []
84 if (text === cachedText) return cachedLines
85 const lines = text.split('\n')
86 if (lines[lines.length - 1] === '') lines.pop()
87 cachedText = text
88 cachedLines = lines
89
90 return lines
91}
92
93const isTextual = (f: NavFile): boolean => f.kind === 'text' || f.kind === 'markdown'
94const isRendered = (f: NavFile): boolean => f.kind === 'markdown' && !f.isRaw && !f.showDiff
95const hasDiffView = (f: NavFile): boolean => f.showDiff && (isTextual(f) || f.kind === 'missing')
96
97function dims(props: NavProps): { columns: number; rows: number; body: number } {
98 const columns = Math.max(1, Math.floor(props.columns) || 1)
99 const rows = Math.max(0, Math.floor(props.rows) || 0)
100
101 return { columns, rows, body: Math.max(0, rows - 1) }
102}
103
104/** Gutter: marker, right-aligned number, space. None when the pane is too narrow. */
105function gutter(maxLine: number, columns: number): { gw: number; digits: number } {
106 const digits = Math.max(2, String(Math.max(1, maxLine)).length)
107 const gw = digits + 2
108
109 return columns >= gw + 8 ? { gw, digits } : { gw: 0, digits }
110}
111
112/** The file's line range: [start, last] (last < start when empty). */
113function extent(f: NavFile): { start: number; lines: string[]; last: number } {
114 const start = Math.max(1, Math.floor(f.startLine) || 1)
115 const lines = linesOf(f.text)
116
117 return { start, lines, last: start + lines.length - 1 }
118}
119
120// --------------------------------------------------------------------------- state
121
122function blank(props: NavProps): FileState {
123 return { path: props.file?.path ?? '', mtimeMs: props.file?.mtimeMs ?? 0, top: 1, line: 1, hunk: 0, isConflict: false, lineAt: props.lineAt }
124}
125
126/** The view fields kept inside the file and the cursor in the window. Returns `s` when nothing changes. */
127function clampView(s: FileState, props: NavProps): FileState {
128 const f = props.file
129 if (!f || s.buffer) return s
130 const { body } = dims(props)
131 const viewRows = Math.max(1, body)
132 let { top, line, hunk } = s
133 let diffTop = s.diffTop ?? 0
134 if (hasDiffView(f)) {
135 const hunks = parseHunks(f.diff?.hunks ?? '')
136 hunk = clamp(hunk, 0, Math.max(0, hunks.length - 1))
137 diffTop = clamp(diffTop, 0, Math.max(0, (hunks[hunk]?.length ?? 1) - 2))
138 } else if (isTextual(f)) {
139 const { start, last } = extent(f)
140 const end = Math.max(start, last)
141 if (isRendered(f)) {
142 top = clamp(top, start, end)
143 line = top
144 } else {
145 line = clamp(line, start, end)
146 if (line < top) top = line
147 else if (line >= top + viewRows) top = line - viewRows + 1
148 top = clamp(top, start, Math.max(start, end - viewRows + 1))
149 }
150 }
151 if (top === s.top && line === s.line && hunk === s.hunk && diffTop === (s.diffTop ?? 0)) return s
152
153 return { ...s, top, line, hunk, diffTop }
154}
155
156/** New props for the same file while a buffer is open: the external-change rules. */
157function reconcileBuffer(s: FileState, f: NavFile, props: NavProps): FileState {
158 let n = s
159 const b = s.buffer!
160 const note = props.notice
161 if (note && note.tone === 'warn' && /changed/i.test(note.text) && note.at > (n.noticeAt ?? 0)) {
162 n = { ...n, isConflict: true, noticeAt: note.at }
163 }
164 if (f.mtimeMs > s.mtimeMs) {
165 const text = f.text
166 if (text === undefined) {
167 n = b.isDirty ? { ...n, isConflict: true } : { ...n, mtimeMs: f.mtimeMs }
168 } else if (text === toText(b) || text === s.saved) {
169 // The save landed (or the file now equals the buffer): clean, and the new mtime is the base.
170 n = { ...n, mtimeMs: f.mtimeMs, isConflict: false, saved: undefined, buffer: { ...b, original: text, isDirty: toText(b) !== text } }
171 } else if (!b.isDirty) {
172 const nb = fromText(text, b.cursor.row)
173 const row = nb.cursor.row
174 const col = Math.min(b.cursor.col, (nb.lines[row] ?? '').length)
175 n = {
176 ...n,
177 mtimeMs: f.mtimeMs,
178 isConflict: false,
179 buffer: { ...nb, cursor: { row, col }, goalCol: displayCol(nb.lines[row] ?? '', col), top: b.top, left: b.left },
180 }
181 } else {
182 n = { ...n, isConflict: true }
183 }
184 }
185
186 return n
187}
188
189/** Brings state up to date with props: the file switched, the page changed, the hooks side moved the line, the file changed on disk. */
190function sync(s: FileState, props: NavProps): FileState {
191 const f = props.file
192 if (!f) return s
193 if (f.path !== s.path && !s.buffer?.isDirty) return initFile(props)
194 if (f.path !== s.path) return s
195 let n = s
196 if (n.buffer) {
197 if (props.lineAt !== n.lineAt) n = { ...n, lineAt: props.lineAt }
198 n = reconcileBuffer(n, f, props)
199 } else {
200 const { body } = dims(props)
201 if (n.page !== undefined && n.page !== f.page) {
202 const { start, last } = extent(f)
203 n =
204 f.page > n.page
205 ? { ...n, page: f.page, line: start, top: start }
206 : { ...n, page: f.page, line: Math.max(start, last), top: Math.max(start, last - Math.max(1, body) + 1) }
207 } else if (n.page === undefined) {
208 n = { ...n, page: f.page }
209 }
210 if (props.lineAt !== n.lineAt) {
211 n = { ...n, lineAt: props.lineAt }
212 if (props.line !== undefined) n = { ...n, line: props.line, top: props.line - Math.floor(Math.max(1, body) / 3) }
213 }
214 if (f.mtimeMs !== n.mtimeMs) n = { ...n, mtimeMs: f.mtimeMs }
215 }
216
217 return n
218}
219
220function settle(s: FileState, props: NavProps): FileState {
221 return clampView(sync(s, props), props)
222}
223
224/** Call when the open file or its mtime changes (nav.tsx), or with nothing to start. Keeps the view and an open buffer for the same path. */
225export function initFile(props: NavProps, previous?: FileState): FileState {
226 const f = props.file
227 if (!f) return blank(props)
228 if (previous && previous.path === f.path) {
229 return clampView(sync({ ...previous, mtimeMs: previous.buffer ? previous.mtimeMs : f.mtimeMs }, props), props)
230 }
231 const { body } = dims(props)
232 const start = Math.max(1, Math.floor(f.startLine) || 1)
233 let line = start
234 let top = start
235 if (props.line !== undefined) {
236 line = props.line
237 top = line - Math.floor(Math.max(1, body) / 3)
238 }
239
240 return clampView(
241 {
242 path: f.path,
243 mtimeMs: f.mtimeMs,
244 top,
245 line,
246 hunk: 0,
247 isConflict: false,
248 lineAt: props.lineAt,
249 diffTop: 0,
250 page: f.page,
251 noticeAt: props.notice?.at ?? 0,
252 },
253 props,
254 )
255}
256
257/** The state as fileKey would see it for these props (file switched, page turned, external change applied). nav.tsx may call it to persist what render derived. */
258export function syncFile(state: FileState, props: NavProps): FileState {
259 return settle(state, props)
260}
261
262// --------------------------------------------------------------------------- diff
263
264type Hunk = string[]
265
266/** Splits `git diff` hunks into hunks (each starting with its `@@` header); what precedes the first one is dropped. */
267function parseHunks(text: string): Hunk[] {
268 if (text === '') return []
269 const out: Hunk[] = []
270 let cur: Hunk | undefined
271 for (const raw of text.split('\n')) {
272 const line = clean(raw)
273 if (line.startsWith('@@')) {
274 cur = [line]
275 out.push(cur)
276 } else if (cur) {
277 cur.push(line)
278 }
279 }
280 if (cur && cur.length > 1 && cur[cur.length - 1] === '') cur.pop()
281
282 return out
283}
284
285const HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/
286
287/** A hunk from its header (start lines kept, counts recomputed from the body so a cut hunk still parses) and body lines. */
288function buildHunk(header: string, body: string[], oldSkip = 0, newSkip = 0): string[] {
289 const m = HEADER.exec(header)
290 if (!m) return [header, ...body]
291 let oldN = 0
292 let newN = 0
293 for (const l of body) {
294 const c = l.charAt(0)
295 if (c === '\\') continue
296 if (c !== '+') oldN++
297 if (c !== '-') newN++
298 }
299 const a = Number(m[1]) + oldSkip
300 const c = Number(m[2]) + newSkip
301
302 return [`@@ -${a},${oldN} +${c},${newN} @@${m[3] ?? ''}`, ...body]
303}
304
305/** The hunk with its first `skip` body lines scrolled off, header renumbered. */
306function skipLines(hunk: Hunk, skip: number): string[] {
307 const header = hunk[0] ?? ''
308 const body = hunk.slice(1)
309 let i = 0
310 let oldSkip = 0
311 let newSkip = 0
312 let counted = 0
313 while (i < body.length && counted < skip) {
314 const c = body[i]!.charAt(0)
315 if (c !== '\\') {
316 counted++
317 if (c !== '+') oldSkip++
318 if (c !== '-') newSkip++
319 }
320 i++
321 }
322 while (i < body.length && body[i]!.startsWith('\\')) i++
323
324 return buildHunk(header, body.slice(i), oldSkip, newSkip)
325}
326
327/** The diff source drawn from hunk `from` (its first `skip` lines scrolled off): whole hunks within the 10000-character limit. */
328function diffSource(hunks: Hunk[], from: number, skip: number): string {
329 const out: string[] = []
330 let chars = 0
331 for (let hunkAt = from; hunkAt < hunks.length; hunkAt++) {
332 let lines = hunkAt === from && skip > 0 ? skipLines(hunks[hunkAt]!, skip) : hunks[hunkAt]!
333 lines = lines.map((l, i) => (i === 0 ? l : slice(l, 400)))
334 let size = lines.reduce((n, l) => n + l.length + 1, 0)
335 if (size > BUDGET - chars) {
336 if (hunkAt !== from) break
337 // The first hunk alone is too long: keep its leading lines, with the counts to match.
338 const body: string[] = []
339 let used = (lines[0]?.length ?? 0) + 40
340 for (const l of lines.slice(1)) {
341 if (used + l.length + 1 > BUDGET) break
342 body.push(l)
343 used += l.length + 1
344 }
345 lines = buildHunk(lines[0] ?? '', body)
346 size = lines.reduce((n, l) => n + l.length + 1, 0)
347 }
348 out.push(...lines)
349 chars += size
350 }
351
352 return out.join('\n')
353}
354
355// --------------------------------------------------------------------------- markdown
356
357const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/
358
359/** The fence opener line when line `idx` sits inside a fenced code block, else undefined. */
360function fenceOpener(lines: string[], idx: number): string | undefined {
361 let open: string | undefined
362 let mark = ''
363 for (let i = 0; i < idx && i < lines.length; i++) {
364 const l = lines[i]!
365 const m = FENCE.exec(l)
366 if (!m) continue
367 const run = m[1]!
368 if (open === undefined) {
369 if (run.charAt(0) === '`' && (m[2] ?? '').includes('`')) continue
370 open = l
371 mark = run
372 } else if (run.charAt(0) === mark.charAt(0) && run.length >= mark.length && (m[2] ?? '').trim() === '') {
373 open = undefined
374 }
375 }
376
377 return open
378}
379
380/** Whether source line `line` continues the paragraph or list item on `prev` (a soft break), which
381 * the renderer would otherwise draw as a line break: hard-wrapped prose breaks mid-sentence. */
382function continues(prev: string, line: string): boolean {
383 const BLOCK = /^\s*([-*+]\s|\d+[.)]\s|#{1,6}\s|>|\||```|~~~|<|(=+|-+|\*+|_+)\s*$)/
384 if (prev.trim() === '' || line.trim() === '') return false
385 if (/^\s*(#{1,6}\s|\||```|~~~|<)/.test(prev) || /( {2}|\\)$/.test(prev)) return false
386 return !BLOCK.test(line)
387}
388
389/** The markdown source drawn from line `top`: enough lines to fill `body` rows (the Box clips the rest), within the limit. */
390/** Rows a markdown source line is likely to take once rendered `columns` wide: wrapped prose, a
391 * table row with its rule, a heading with its gap. An estimate that errs tall, since a Markdown that
392 * outgrows its box overprints the rows below it in the terminal rather than clipping. */
393function renderedRows(line: string, columns: number): number {
394 const text = line.trim()
395 if (text === '') return 1
396 if (text.startsWith('|')) return 2
397 if (/^#{1,6}\s/.test(text)) return 2
398 return Math.max(1, Math.ceil((text.length + 4) / Math.max(10, columns - 2)))
399}
400
401function markdownSource(f: NavFile, top: number, body: number, columns: number): string {
402 const { start, lines } = extent(f)
403 const idx = clamp(top - start, 0, Math.max(0, lines.length - 1))
404 const opener = fenceOpener(lines, idx)
405 const out: string[] = []
406 let chars = 0
407 let rows = 0
408 if (opener !== undefined) {
409 out.push(clean(opener))
410 chars += out[0]!.length + 1
411 rows += 1
412 }
413 let inTable = false
414 for (let i = idx; i < lines.length; i++) {
415 const l = slice(clean(lines[i]!), 2000)
416 const prev = out[out.length - 1]
417 if (prev !== undefined && i > idx && continues(prev, l) && fenceOpener(lines, i) === undefined) {
418 const joined = `${prev.trimEnd()} ${l.trim()}`
419 const grew = renderedRows(joined, columns) - renderedRows(prev, columns)
420 if (rows + grew > body || chars + l.length + 1 > BUDGET) break
421 out[out.length - 1] = joined
422 chars += l.length + 1
423 rows += grew
424 continue
425 }
426 const isRow = l.trim().startsWith('|')
427 // A table's top border and header rule.
428 const cost = renderedRows(l, columns) + (isRow && !inTable ? 2 : 0)
429 if (rows + cost > body && out.length > 0) break
430 // A table starts only if it fits whole (a cut one draws as an empty bordered row): else it
431 // opens the next screen. One taller than the pane still draws, cut, when it is at the top.
432 if (isRow && !inTable && out.length > 0) {
433 let end = i
434 while (end + 1 < lines.length && lines[end + 1]!.trim().startsWith('|')) end++
435 const tableRows = 3 + (end - i + 1) * 2
436 if (rows + tableRows > body) break
437 }
438 if (chars + l.length + 1 > BUDGET && out.length > 0) break
439 inTable = isRow
440 out.push(l)
441 chars += l.length + 1
442 rows += cost
443 }
444 // A fence left open would swallow nothing after it, but keeps the renderer from guessing.
445 if (fenceOpener(out, out.length) !== undefined) out.push('```')
446
447 return slice(out.join('\n'), LIMIT)
448}
449
450// --------------------------------------------------------------------------- status line
451
452type Seg = { t: string; color?: string; dim?: boolean; bold?: boolean }
453
454const width = (segs: Seg[]): number => segs.reduce((n, s) => n + s.t.length, 0)
455
456/** Cuts the segments to `cols` cells, with an ellipsis where it cut. */
457function cutSegs(segs: Seg[], cols: number): Seg[] {
458 if (width(segs) <= cols) return segs
459 const out: Seg[] = []
460 let left = cols
461 for (const s of segs) {
462 if (left <= 0) break
463 if (s.t.length <= left) {
464 out.push(s)
465 left -= s.t.length
466 } else {
467 out.push({ ...s, t: cut(s.t, left) })
468 break
469 }
470 }
471
472 return out
473}
474
475/** The first candidate that fits `cols`, else the first one cut. */
476function pick(cols: number, candidates: Seg[][]): Seg[] {
477 for (const c of candidates) if (width(c) <= cols) return c
478
479 return cutSegs(candidates[0] ?? [], cols)
480}
481
482const TONE: Record<string, string> = { info: 'green', warn: 'yellow', error: 'red' }
483
484function freshNotice(s: FileState, props: NavProps): Seg[] {
485 const n = props.notice
486 if (!n || n.at <= (s.noticeAt ?? 0) || n.text === '') return []
487
488 return [{ t: ' · ', dim: true }, { t: n.text, color: TONE[n.tone] ?? 'green' }]
489}
490
491/** `head hints · pos tail`, tried with each tail and, for each, with fewer hints (dropped from the right). */
492function hintCandidates(head: Seg[], hints: string[], pos: string, tails: Seg[][]): Seg[][] {
493 const out: Seg[][] = []
494 for (const tail of tails) {
495 for (let k = hints.length; k >= 0; k--) {
496 const segs: Seg[] = [...head]
497 if (k > 0) segs.push({ t: (segs.length > 0 ? ' ' : '') + hints.slice(0, k).join(' '), dim: true })
498 if (pos !== '') segs.push({ t: (segs.length > 0 ? ' · ' : '') + pos, dim: true })
499 segs.push(...tail)
500 out.push(segs)
501 }
502 }
503
504 return out
505}
506
507function renderStatus(el: ClientElements, segs: Seg[]): RenderElement {
508 const { Text } = el
509
510 return (
511 <Text wrap="truncate-end">
512 {' '}
513 {segs.map(s => (
514 <Text {...(s.color ? { color: s.color } : {})} {...(s.dim ? { dimColor: true } : {})} {...(s.bold ? { bold: true } : {})}>
515 {s.t}
516 </Text>
517 ))}
518 </Text>
519 )
520}
521
522function viewStatus(el: ClientElements, s: FileState, props: NavProps, columns: number): RenderElement {
523 const f = props.file
524 const notice = freshNotice(s, props)
525 const tails: Seg[][] = notice.length > 0 ? [notice, []] : [[]]
526 let hints: string[]
527 let pos = ''
528 if (!f) {
529 hints = ['← back']
530 } else if (hasDiffView(f)) {
531 const hunks = parseHunks(f.diff?.hunks ?? '')
532 hints = ['← file', '↑↓ hunk', 'pgdn more']
533 if (hunks.length > 0) pos = `hunk ${s.hunk + 1}/${hunks.length}`
534 if (f.diff?.isTruncated) tails.unshift([{ t: ' · diff truncated', dim: true }])
535 } else if (isTextual(f)) {
536 hints = ['← back']
537 if (f.isEditable) hints.push('→ edit')
538 hints.push('↑↓ move')
539 if (f.pageCount > 1) hints.push(`page ${f.page + 1}/${f.pageCount}`)
540 pos = `${isRendered(f) ? s.top : s.line}/${f.lineCount}`
541 } else {
542 hints = ['← back']
543 }
544
545 return renderStatus(el, pick(columns, hintCandidates([], hints, pos, tails)))
546}
547
548function editStatus(el: ClientElements, s: FileState, props: NavProps, columns: number): RenderElement {
549 const b = s.buffer!
550 const head: Seg[] = [{ t: 'EDIT', bold: true }]
551 const mod: Seg[] = b.isDirty ? [{ t: ' ● modified', color: 'yellow' }] : []
552 const pos = `${b.cursor.row + 1}:${displayCol(b.lines[b.cursor.row] ?? '', b.cursor.col) + 1}`
553 const msg = s.isDiscarding
554 ? { full: 'unsaved changes — ctrl+q again to discard', short: 'unsaved — ctrl+q again to discard' }
555 : s.isConflict
556 ? { full: 'changed on disk — ctrl+s overwrites', short: 'changed — ctrl+s overwrites' }
557 : undefined
558 let candidates: Seg[][]
559 if (msg) {
560 const m = (t: string): Seg => ({ t, color: 'yellow', bold: true })
561 candidates = [
562 [...head, { t: ' ' }, m(msg.full), { t: ' · ' + pos, dim: true }],
563 [...head, { t: ' ' }, m(msg.full)],
564 [m(msg.full)],
565 [...head, { t: ' ' }, m(msg.short)],
566 [m(msg.short)],
567 ]
568 } else {
569 const notice = freshNotice(s, props)
570 const tails: Seg[][] = []
571 if (notice.length > 0) tails.push([...mod, ...notice])
572 tails.push(mod, [])
573 candidates = hintCandidates(head, ['ctrl+s save', 'ctrl+q done', 'ctrl+z undo'], pos, tails)
574 candidates.push([...head, ...mod])
575 }
576
577 return renderStatus(el, pick(columns, candidates))
578}
579
580// --------------------------------------------------------------------------- drawing
581
582/** A gutter cell: the cursor line's bright with a marker, the rest dim. */
583function cell(el: ClientElements, n: number, digits: number, isCursor: boolean): RenderElement {
584 const { Text } = el
585 const num = String(n).padStart(digits)
586 if (isCursor) {
587 return (
588 <Text wrap="truncate-end">
589 <Text color="cyan">▸</Text>
590 <Text bold>{num + ' '}</Text>
591 </Text>
592 )
593 }
594
595 return (
596 <Text dimColor wrap="truncate-end">
597 {' ' + num + ' '}
598 </Text>
599 )
600}
601
602function message(el: ClientElements, lines: string[], columns: number, body: number): RenderElement {
603 const { Box, Text } = el
604
605 return (
606 <Box flexDirection="column" height={body} width={columns} flexShrink={0} overflow="hidden">
607 {lines.slice(0, body).map(l => (
608 <Text dimColor wrap="truncate-end">
609 {' ' + cut(l, Math.max(0, columns - 1))}
610 </Text>
611 ))}
612 </Box>
613 )
614}
615
616function textBody(el: ClientElements, s: FileState, f: NavFile, props: NavProps): RenderElement {
617 const { Box, Code } = el
618 const { columns, body } = dims(props)
619 const { start, lines, last } = extent(f)
620 if (lines.length === 0) return message(el, ['Empty file.'], columns, body)
621 const { gw, digits } = gutter(Math.max(f.lineCount, last), columns)
622 const codeW = Math.max(1, columns - gw)
623 const first = s.top - start
624 const win = lines.slice(first, first + body)
625 const cap = Math.max(codeW + 1, Math.min(400, Math.floor(9900 / Math.max(1, win.length)) - 1))
626 // An empty line is drawn as one space so the Code element keeps exactly one row per line.
627 const source = win.map(l => slice(clean(l), cap) || ' ').join('\n')
628
629 return (
630 <Box flexDirection="row" height={body} width={columns} flexShrink={0} overflow="hidden">
631 {gw > 0 ? (
632 <Box flexDirection="column" width={gw} flexShrink={0}>
633 {win.map((_, i) => cell(el, s.top + i, digits, s.top + i === s.line))}
634 </Box>
635 ) : null}
636 <Box width={codeW} flexShrink={0} overflow="hidden">
637 <Code source={source} path={f.path} wrap="truncate-end" />
638 </Box>
639 </Box>
640 )
641}
642
643function renderedBody(el: ClientElements, s: FileState, f: NavFile, props: NavProps): RenderElement {
644 const { Box, Markdown } = el
645 const { columns, body } = dims(props)
646 if (extent(f).lines.length === 0) return message(el, ['Empty file.'], columns, body)
647
648 return (
649 // minHeight keeps the status line at the pane's foot when the markdown is short; a fixed height
650 // with overflow hidden would overprint in the terminal, so the source itself is cut to fit.
651 <Box flexDirection="column" width={columns} minHeight={body} flexShrink={0} paddingLeft={1}>
652 <Markdown text={markdownSource(f, s.top, body, Math.max(1, columns - 1))} />
653 </Box>
654 )
655}
656
657function diffBody(el: ClientElements, s: FileState, f: NavFile, props: NavProps): RenderElement {
658 const { Box } = el
659 const { columns, body } = dims(props)
660 if (!f.diff) return message(el, ['Loading diff…'], columns, body)
661 const hunks = parseHunks(f.diff.hunks)
662 if (hunks.length === 0) {
663 const raw = clean(f.diff.hunks)
664 .split('\n')
665 .filter(l => l.trim() !== '')
666
667 return message(el, raw.length === 0 ? ['No changes against HEAD.'] : raw, columns, body)
668 }
669
670 return (
671 <Box flexDirection="column" height={body} width={columns} flexShrink={0} overflow="hidden">
672 {diffRows(el, diffSource(hunks, s.hunk, s.diffTop ?? 0), columns, body)}
673 </Box>
674 )
675}
676
677/** `text` cut to `max` terminal cells (a wide character counts two), tabs as two spaces. */
678function cutCells(text: string, max: number): { text: string; cells: number } {
679 let out = ''
680 let cells = 0
681 for (const ch of text.replace(/\t/g, ' ')) {
682 const w = displayCol(ch, ch.length)
683 if (cells + w > max) break
684 out += ch
685 cells += w
686 }
687
688 return { text: out, cells }
689}
690
691/**
692 * The diff drawn row by row: a gutter number (the old line for a removed line, else the new one),
693 * the line's marker and text coloured, every row padded to the full width. The engine's Code
694 * element in diff format leaves cells a shorter row no longer covers as they were, so a diff
695 * drawn through it kept stray tails of the rows (or the file view) it replaced.
696 */
697function diffRows(el: ClientElements, source: string, columns: number, body: number): RenderElement[] {
698 const { Text } = el
699 const lines = source.split('\n')
700 let top = 0
701 for (const l of lines) {
702 const hunkHead = HEADER.exec(l)
703 if (hunkHead) top = Math.max(top, Number(hunkHead[1]), Number(hunkHead[2]))
704 }
705 const digits = Math.max(2, String(top + lines.length).length)
706 const gw = columns >= digits + 10 ? digits + 2 : 0
707 const out: RenderElement[] = []
708 let oldN = 0
709 let newN = 0
710 for (const line of lines) {
711 if (out.length >= body) break
712 const m = HEADER.exec(line)
713 if (m) {
714 oldN = Number(m[1])
715 newN = Number(m[2])
716 const head = cutCells(line, columns - 1)
717 out.push(
718 <Text key={`d${out.length}`} wrap="truncate-end">
719 <Text color="cyan" dimColor>{' ' + head.text + ' '.repeat(Math.max(0, columns - 1 - head.cells))}</Text>
720 </Text>,
721 )
722 continue
723 }
724 const mark = line.charAt(0)
725 if (mark === '\\') continue
726 const color = mark === '+' ? 'green' : mark === '-' ? 'red' : undefined
727 let num: number
728 if (mark === '-') num = oldN++
729 else if (mark === '+') num = newN++
730 else {
731 oldN++
732 num = newN++
733 }
734 const shown = mark === '+' || mark === '-' || mark === ' ' ? line.slice(1) : line
735 const text = cutCells(shown, Math.max(0, columns - gw - 2))
736 const gutterText = gw > 0 ? ' ' + String(num).padStart(digits) + ' ' : ' '
737 const pad = ' '.repeat(Math.max(0, columns - gutterText.length - 1 - text.cells))
738 out.push(
739 <Text key={`d${out.length}`} wrap="truncate-end">
740 <Text dimColor>{gutterText}</Text>
741 <Text color={color}>{(color ? mark : ' ') + text.text + pad}</Text>
742 </Text>,
743 )
744 }
745
746 return out
747}
748
749function editGeom(props: NavProps, b: Buffer): { columns: number; gw: number; digits: number; vr: number; vc: number } {
750 const { columns, body } = dims(props)
751 const { gw, digits } = gutter(b.lines.length, columns)
752
753 return { columns, gw, digits, vr: Math.max(1, body), vc: Math.max(1, columns - gw) }
754}
755
756/** The cursor row split around its cell: before, the character under it (a space at the end of the line), after. */
757function splitAt(text: string, col: number): { before: string; ch: string; after: string } {
758 let i = 0
759 while (i < text.length && displayCol(text, i) < col) {
760 const c = text.charCodeAt(i)
761 i += c >= 0xd800 && c <= 0xdbff && i + 1 < text.length ? 2 : 1
762 }
763 if (i >= text.length) return { before: text, ch: ' ', after: '' }
764 const c = text.charCodeAt(i)
765 const n = c >= 0xd800 && c <= 0xdbff && i + 1 < text.length ? 2 : 1
766
767 return { before: text.slice(0, i), ch: text.slice(i, i + n), after: text.slice(i + n) }
768}
769
770function editBody(el: ClientElements, s: FileState, props: NavProps): RenderElement {
771 const { Box, Text } = el
772 const b0 = s.buffer!
773 const { columns, gw, digits, vr, vc } = editGeom(props, b0)
774 const b = scrollIntoView(b0, vr, vc)
775 const rows = view(b, vr, vc)
776 const { body } = dims(props)
777
778 return (
779 <Box flexDirection="column" height={body} width={columns} flexShrink={0} overflow="hidden">
780 {rows.map(r => {
781 const isCursor = r.cursorCol !== undefined
782 const g =
783 gw > 0 ? (
784 <Text dimColor={!isCursor} bold={isCursor}>
785 {' ' + String(r.lineNo).padStart(digits) + ' '}
786 </Text>
787 ) : null
788 if (r.cursorCol === undefined) {
789 return (
790 <Text wrap="truncate-end">
791 {g}
792 {r.text === '' ? ' ' : r.text}
793 </Text>
794 )
795 }
796 const { before, ch, after } = splitAt(r.text, Math.max(0, r.cursorCol))
797
798 return (
799 <Text wrap="truncate-end">
800 {g}
801 {before}
802 <Text inverse>{ch}</Text>
803 {after}
804 </Text>
805 )
806 })}
807 </Box>
808 )
809}
810
811export function fileRender(el: ClientElements, state: FileState, props: NavProps): RenderElement {
812 const { Box } = el
813 const s = settle(state, props)
814 const f = props.file
815 const { columns, rows, body } = dims(props)
816 let content: RenderElement
817 let status: RenderElement
818 if (s.buffer) {
819 content = editBody(el, s, props)
820 status = editStatus(el, s, props, columns - 1)
821 } else {
822 status = viewStatus(el, s, props, columns - 1)
823 if (!f) {
824 content = message(el, ['Nothing open.'], columns, body)
825 } else if (hasDiffView(f)) {
826 content = diffBody(el, s, f, props)
827 } else if (f.kind === 'image') {
828 content = message(el, [`PNG image (${formatSize(f.size)}): preview isn't available in the pane.`], columns, body)
829 } else if (f.kind === 'binary') {
830 content = message(el, [`Binary file, not shown (${formatSize(f.size)}).`], columns, body)
831 } else if (f.kind === 'too-large') {
832 content = message(el, [`Too large to show (${formatSize(f.size)}; the limit is 4.0 MB).`], columns, body)
833 } else if (f.kind === 'missing') {
834 content = message(el, ['File not found: it is not on disk.'], columns, body)
835 } else if (f.text === undefined) {
836 content = message(el, ['Loading…'], columns, body)
837 } else if (isRendered(f)) {
838 content = renderedBody(el, s, f, props)
839 } else {
840 content = textBody(el, s, f, props)
841 }
842 }
843
844 return (
845 <Box flexDirection="column" width={columns} height={rows} flexShrink={0} overflow="hidden">
846 {rows > 1 ? content : null}
847 {status}
848 </Box>
849 )
850}
851
852// --------------------------------------------------------------------------- keys
853
854const NO_MODS = (k: ClientKeyEvent): boolean => !k.ctrl && !k.meta
855
856/** Moves the cursor line, scrolling only as far as needed. */
857function setLine(s: FileState, props: NavProps, f: NavFile, line: number): FileState {
858 const { body } = dims(props)
859 const { start, last } = extent(f)
860 const end = Math.max(start, last)
861 const viewRows = Math.max(1, body)
862 const l = clamp(line, start, end)
863 let top = s.top
864 if (l < top) top = l
865 else if (l >= top + viewRows) top = l - viewRows + 1
866
867 return { ...s, line: l, top: clamp(top, start, Math.max(start, end - viewRows + 1)) }
868}
869
870/** Moves a screen's worth: the cursor and the window together. */
871function setPage(s: FileState, props: NavProps, f: NavFile, delta: number): FileState {
872 const { body } = dims(props)
873 const { start, last } = extent(f)
874 const end = Math.max(start, last)
875 const viewRows = Math.max(1, body)
876 const top = clamp(s.top + delta, start, Math.max(start, end - viewRows + 1))
877 const line = clamp(s.line + delta, start, end)
878
879 return { ...s, top, line: clamp(line, top, Math.min(end, top + viewRows - 1)) }
880}
881
882function enterEdit(s: FileState, props: NavProps, f: NavFile, post: (op: NavOp) => void): FileState {
883 if (!f.isEditable || f.text === undefined || f.pageCount > 1) return s
884 const { start } = extent(f)
885 const row = Math.max(0, (isRendered(f) ? s.top : s.line) - start)
886 const b0 = fromText(f.text, row)
887 const geo = editGeom(props, b0)
888 const b = scrollIntoView({ ...b0, top: clamp(s.top - start, 0, b0.cursor.row) }, geo.vr, geo.vc)
889 post({ op: 'edit', path: f.path, isEditing: true })
890
891 return { ...s, buffer: b, mtimeMs: f.mtimeMs, isConflict: false, isDiscarding: false, saved: undefined, noticeAt: props.notice?.at ?? s.noticeAt ?? 0 }
892}
893
894function leaveEdit(s: FileState, props: NavProps, post: (op: NavOp) => void): FileState {
895 const b = s.buffer!
896 const f = props.file
897 const start = f ? extent(f).start : 1
898 post({ op: 'edit', path: s.path, isEditing: false })
899 const next: FileState = { ...s, buffer: undefined, isConflict: false, isDiscarding: false, saved: undefined, line: start + b.cursor.row, top: start + b.top }
900
901 return clampView(next, props)
902}
903
904function editKey(s: FileState, k: ClientKeyEvent, props: NavProps, post: (op: NavOp) => void): FileState {
905 const b = s.buffer!
906 const key = k.key.length === 1 ? k.key.toLowerCase() : k.key
907 if (k.ctrl && !k.meta && key === 's') {
908 // Nothing to save: stay quiet (a conflict still lets ctrl+s force the buffer over the disk).
909 if (!b.isDirty && !s.isConflict) return s.isDiscarding ? { ...s, isDiscarding: false } : s
910 const text = toText(b)
911 post({ op: 'save', path: s.path, text, baseMtimeMs: s.mtimeMs, force: s.isConflict })
912
913 return { ...s, saved: text, isDiscarding: false, noticeAt: props.notice?.at ?? s.noticeAt }
914 }
915 if (k.ctrl && !k.meta && key === 'q') {
916 if (b.isDirty && !s.isDiscarding) return { ...s, isDiscarding: true }
917
918 return leaveEdit(s, props, post)
919 }
920 const { vr } = editGeom(props, b)
921 let nb = applyKey(b, k, vr)
922 const geo = editGeom(props, nb)
923 nb = scrollIntoView(nb, geo.vr, geo.vc)
924 if (nb === b) return s.isDiscarding ? { ...s, isDiscarding: false } : s
925
926 return { ...s, buffer: nb, isDiscarding: false, noticeAt: nb.lines !== b.lines ? (props.notice?.at ?? s.noticeAt) : s.noticeAt }
927}
928
929/** Keys that are not text: everything else without ctrl/meta is typing and belongs to Claude Code's prompt. */
930const SPECIAL = new Set([
931 'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'home', 'end', 'pageup', 'pagedown',
932 'escape', 'esc', 'insert', 'backtab', 'clear',
933])
934
935function isPrintable(k: ClientKeyEvent): boolean {
936 return !k.ctrl && !k.meta && k.key.length > 0 && !SPECIAL.has(k.key) && !/^f\d{1,2}$/.test(k.key) && k.key.charCodeAt(0) !== 0x1b
937}
938
939function diffKey(s: FileState, k: ClientKeyEvent, props: NavProps, f: NavFile, post: (op: NavOp) => void): FileState {
940 const { body } = dims(props)
941 const hunks = parseHunks(f.diff?.hunks ?? '')
942 const lastHunk = Math.max(0, hunks.length - 1)
943 const key = k.key
944 const goto = (hunk: number): FileState => ({ ...s, hunk: clamp(hunk, 0, lastHunk), diffTop: 0 })
945 if (!NO_MODS(k)) return s
946 if (key === 'left') {
947 post({ op: 'diff' })
948
949 return { ...s, hunk: 0, diffTop: 0 }
950 }
951 if (key === 'up') return goto(s.hunk - 1)
952 if (key === 'down') return goto(s.hunk + 1)
953 if (key === 'home') return goto(0)
954 if (key === 'end') return goto(lastHunk)
955 const step = Math.max(1, body - 2)
956 const size = Math.max(0, (hunks[s.hunk]?.length ?? 1) - 1)
957 const top = s.diffTop ?? 0
958 if (key === 'pagedown') {
959 // The rest of this hunk is longer than the window: scroll within it; otherwise on to the next hunk.
960 return size - top > body ? { ...s, diffTop: top + step } : goto(s.hunk + 1)
961 }
962 if (key === 'pageup') return top > 0 ? { ...s, diffTop: Math.max(0, top - step) } : goto(s.hunk - 1)
963
964 return s
965}
966
967function viewKey(s: FileState, k: ClientKeyEvent, props: NavProps, f: NavFile, post: (op: NavOp) => void): FileState {
968 // No letter commands in view mode: typing goes to Claude Code's prompt.
969 if (isPrintable(k)) {
970 post({ op: 'type', text: k.key })
971
972 return s
973 }
974 if (hasDiffView(f)) return diffKey(s, k, props, f, post)
975 const key = k.key
976 const { body } = dims(props)
977 const viewRows = Math.max(1, body)
978 if (!NO_MODS(k)) return s
979 if (key === 'left') {
980 post({ op: 'back' })
981
982 return s
983 }
984 if (!isTextual(f)) return s
985 if (key === 'right' || key === 'return' || key === 'enter') return enterEdit(s, props, f, post)
986
987 const { start, last } = extent(f)
988 const end = Math.max(start, last)
989 const rendered = isRendered(f)
990 const atEnd = (rendered ? s.top : s.line) >= end
991 const atStart = (rendered ? s.top : s.line) <= start
992 // Past the end of a page (or before its start) the next page is asked for; `sync` puts the cursor at its edge.
993 const nextPage = (): FileState => {
994 if (f.pageCount > 1 && f.page < f.pageCount - 1) post({ op: 'page', page: f.page + 1 })
995
996 return s
997 }
998 const prevPage = (): FileState => {
999 if (f.pageCount > 1 && f.page > 0) post({ op: 'page', page: f.page - 1 })
1000
1001 return s
1002 }
1003
1004 if (rendered) {
1005 // Rendered markdown scrolls by source lines (blank lines skipped, so each press moves something).
1006 const lines = extent(f).lines
1007 const at = (n: number): string => lines[n - start] ?? ''
1008 const scroll = (top: number): FileState => ({ ...s, top: clamp(top, start, end), line: clamp(top, start, end) })
1009 if (key === 'down') {
1010 if (atEnd) return s
1011 let t = s.top + 1
1012 while (t < end && at(t).trim() === '') t++
1013
1014 return scroll(t)
1015 }
1016 if (key === 'up') {
1017 if (atStart) return s
1018 let t = s.top - 1
1019 while (t > start && at(t).trim() === '') t--
1020
1021 return scroll(t)
1022 }
1023 if (key === 'pagedown') return atEnd ? nextPage() : scroll(s.top + Math.max(1, viewRows - 2))
1024 if (key === 'pageup') return atStart ? prevPage() : scroll(s.top - Math.max(1, viewRows - 2))
1025 if (key === 'home') return scroll(start)
1026 if (key === 'end') return scroll(end - viewRows + 1)
1027
1028 return s
1029 }
1030
1031 if (key === 'down') return setLine(s, props, f, s.line + 1)
1032 if (key === 'up') return setLine(s, props, f, s.line - 1)
1033 if (key === 'pagedown') return atEnd ? nextPage() : setPage(s, props, f, viewRows)
1034 if (key === 'pageup') return atStart ? prevPage() : setPage(s, props, f, -viewRows)
1035 if (key === 'home') return setLine(s, props, f, start)
1036 if (key === 'end') return setLine(s, props, f, end)
1037
1038 return s
1039}
1040
1041export function fileKey(state: FileState, k: ClientKeyEvent, props: NavProps, post: (op: NavOp) => void): FileState {
1042 const s = settle(state, props)
1043 if (s.buffer) return editKey(s, k, props, post)
1044 const f = props.file
1045 if (!f) return s
1046
1047 return viewKey(s, k, props, f, post)
1048}
1049
1050// --------------------------------------------------------------------------- pointer
1051
1052/** The UTF-16 index in `text` whose display column is the largest one at most `target`. */
1053function colAt(text: string, target: number): number {
1054 let i = 0
1055 while (i < text.length) {
1056 const c = text.charCodeAt(i)
1057 const n = c >= 0xd800 && c <= 0xdbff && i + 1 < text.length ? 2 : 1
1058 if (displayCol(text, i + n) > target) return i
1059 i += n
1060 }
1061
1062 return text.length
1063}
1064
1065export function filePointer(state: FileState, p: ClientPointerEvent, props: NavProps, post: (op: NavOp) => void): FileState {
1066 const s = settle(state, props)
1067 if (p.type !== 'down' || p.button === 'right' || p.button === 'middle') return s
1068 const f = props.file
1069 const { body } = dims(props)
1070 if (p.y === body) {
1071 // The status line: its first item is the way back.
1072 if (s.buffer || !f || p.x >= 6) return s
1073 if (hasDiffView(f)) {
1074 post({ op: 'diff' })
1075
1076 return { ...s, hunk: 0, diffTop: 0 }
1077 }
1078 post({ op: 'back' })
1079
1080 return s
1081 }
1082 if (p.y < 0 || p.y >= body) return s
1083 if (s.buffer) {
1084 const b = s.buffer
1085 const { gw, vr, vc } = editGeom(props, b)
1086 const row = clamp(b.top + Math.floor(p.y), 0, b.lines.length - 1)
1087 const text = b.lines[row] ?? ''
1088 const col = colAt(text, Math.max(0, Math.floor(p.x) - gw) + b.left)
1089 const nb = scrollIntoView({ ...b, cursor: { row, col }, goalCol: displayCol(text, col), run: undefined }, vr, vc)
1090
1091 return { ...s, buffer: nb, isDiscarding: false }
1092 }
1093 if (!f || !isTextual(f) || hasDiffView(f) || isRendered(f)) return s
1094 const { start, last } = extent(f)
1095 const line = s.top + Math.floor(p.y)
1096 if (line < start || line > last) return s
1097
1098 return { ...s, line }
1099}
1100hooks/editor.ts 541 lines1// Pure text-editing buffer for repoviewer's in-pane editor. No I/O, no JSX, no DOM.
2// Everything is immutable: every function returns new objects and never mutates its input.
3
4export const TAB_WIDTH = 4
5
6/** col is a UTF-16 index into the line. */
7export type Pos = { row: number; col: number }
8export type Snapshot = { lines: string[]; cursor: Pos }
9
10type Run = { kind: 'ins' | 'bs' | 'del'; at: Pos }
11
12export type Buffer = {
13 lines: string[]
14 cursor: Pos
15 /** Desired display column kept across up/down through short lines. */
16 goalCol: number
17 /** First visible line and first visible display column. */
18 top: number
19 left: number
20 isDirty: boolean
21 /** Undo/redo stacks of snapshots { lines, cursor }, capped at 200; typing coalesces into one step per word. */
22 undo: Snapshot[]
23 redo: Snapshot[]
24 // Extras (not part of the contract, but harmless to read):
25 /** The text the buffer was created from; isDirty is `toText(b) !== original`. */
26 original: string
27 /** What tab inserts: '\t', or two/four spaces, detected in fromText. */
28 indent: string
29 /** The last edit, while it can still be extended by the next keystroke (undo coalescing). */
30 run?: Run
31}
32
33export type EditKey = { key: string; ctrl?: boolean; shift?: boolean; meta?: boolean }
34
35const UNDO_CAP = 200
36
37// ---------------------------------------------------------------------------------------------
38// Character widths
39// ---------------------------------------------------------------------------------------------
40
41function isLow(c: number): boolean {
42 return c >= 0xdc00 && c <= 0xdfff
43}
44function isHigh(c: number): boolean {
45 return c >= 0xd800 && c <= 0xdbff
46}
47
48/** Width in terminal cells of one code point (tab handled by the caller). */
49function cpWidth(cp: number): number {
50 if (cp === 0x0d) return 0
51 if (cp < 0x20 || cp === 0x7f) return 1 // shown as a placeholder
52 if ((cp >= 0x300 && cp <= 0x36f) || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0
53 if (cp >= 0xe0100 && cp <= 0xe01ef) return 0
54 if (
55 (cp >= 0x1100 && cp <= 0x115f) ||
56 (cp >= 0x2e80 && cp <= 0x303e) ||
57 (cp >= 0x3041 && cp <= 0x33ff) ||
58 (cp >= 0x3400 && cp <= 0x4dbf) ||
59 (cp >= 0x4e00 && cp <= 0x9fff) ||
60 (cp >= 0xa000 && cp <= 0xa4cf) ||
61 (cp >= 0xac00 && cp <= 0xd7a3) ||
62 (cp >= 0xf900 && cp <= 0xfaff) ||
63 (cp >= 0xfe30 && cp <= 0xfe6f) ||
64 (cp >= 0xff00 && cp <= 0xff60) ||
65 (cp >= 0xffe0 && cp <= 0xffe6) ||
66 (cp >= 0x1f300 && cp <= 0x1f64f) ||
67 (cp >= 0x1f680 && cp <= 0x1f6ff) ||
68 (cp >= 0x1f900 && cp <= 0x1faff) ||
69 (cp >= 0x20000 && cp <= 0x3fffd)
70 ) {
71 return 2
72 }
73 return 1
74}
75
76/** Code point at UTF-16 index i, and its UTF-16 length. */
77function cpAt(s: string, i: number): [number, number] {
78 const c = s.charCodeAt(i)
79 if (isHigh(c) && i + 1 < s.length && isLow(s.charCodeAt(i + 1))) {
80 return [s.codePointAt(i)!, 2]
81 }
82 return [c, 1]
83}
84
85/** Display column of UTF-16 index `col` in `line`; tabs advance to the next TAB_WIDTH stop. */
86export function displayCol(line: string, col: number): number {
87 const end = Math.min(col, line.length)
88 let dc = 0
89 let i = 0
90 while (i < end) {
91 const [cp, n] = cpAt(line, i)
92 dc += cp === 0x09 ? TAB_WIDTH - (dc % TAB_WIDTH) : cpWidth(cp)
93 i += n
94 }
95 return dc
96}
97
98/** The UTF-16 index whose display column is the largest one <= target (never inside a char). */
99function colFromDisplay(line: string, target: number): number {
100 let dc = 0
101 let i = 0
102 while (i < line.length) {
103 const [cp, n] = cpAt(line, i)
104 const w = cp === 0x09 ? TAB_WIDTH - (dc % TAB_WIDTH) : cpWidth(cp)
105 if (dc + w > target) return i
106 dc += w
107 i += n
108 }
109 return line.length
110}
111
112/** Length (1 or 2) of the code point ending at index col (col > 0). */
113function stepBack(line: string, col: number): number {
114 return col >= 2 && isLow(line.charCodeAt(col - 1)) && isHigh(line.charCodeAt(col - 2)) ? 2 : 1
115}
116/** Length (1 or 2) of the code point starting at index col (col < line.length). */
117function stepFwd(line: string, col: number): number {
118 return cpAt(line, col)[1]
119}
120
121// ---------------------------------------------------------------------------------------------
122// Construction
123// ---------------------------------------------------------------------------------------------
124
125function detectIndent(lines: string[]): string {
126 let spaces = 0
127 let tabs = 0
128 let minSpaces = Infinity
129 const steps = new Map<number, number>()
130 let prev = 0 // leading spaces of the previous non-blank line
131 for (const l of lines) {
132 if (l.trim() === '') continue
133 let n = 0
134 while (l[n] === ' ') n++
135 if (l[0] === '\t') tabs++
136 else if (n > 0) {
137 spaces++
138 if (n < minSpaces) minSpaces = n
139 if (n > prev) steps.set(n - prev, (steps.get(n - prev) ?? 0) + 1)
140 }
141 prev = n
142 }
143 if (spaces === 0 || spaces <= tabs) return '\t'
144 const two = steps.get(2) ?? 0
145 const four = steps.get(4) ?? 0
146 if (four > two) return ' '
147 if (two > four) return ' '
148 return minSpaces >= 4 ? ' ' : ' '
149}
150
151export function fromText(text: string, cursorRow = 0): Buffer {
152 const lines = text.split('\n')
153 const row = Math.max(0, Math.min(lines.length - 1, Math.floor(cursorRow) || 0))
154 return {
155 lines,
156 cursor: { row, col: 0 },
157 goalCol: 0,
158 top: 0,
159 left: 0,
160 isDirty: false,
161 undo: [],
162 redo: [],
163 original: text,
164 indent: detectIndent(lines),
165 }
166}
167
168export function toText(b: Buffer): string {
169 return b.lines.join('\n')
170}
171
172function isSameText(lines: string[], original: string): boolean {
173 return lines.join('\n') === original
174}
175
176// ---------------------------------------------------------------------------------------------
177// Internals
178// ---------------------------------------------------------------------------------------------
179
180const posEq = (a: Pos, b: Pos): boolean => a.row === b.row && a.col === b.col
181
182function line(b: Buffer, row: number): string {
183 return b.lines[row] ?? ''
184}
185
186/** A cursor move: no text change, ends any coalescing run. */
187function moved(b: Buffer, cursor: Pos, keepGoal: boolean): Buffer {
188 return {
189 ...b,
190 cursor,
191 goalCol: keepGoal ? b.goalCol : displayCol(line(b, cursor.row), cursor.col),
192 run: undefined,
193 }
194}
195
196/** A text change. `kind` lets consecutive edits of the same kind share one undo step. */
197function commit(b: Buffer, lines: string[], cursor: Pos, kind?: Run['kind']): Buffer {
198 const continuing = kind !== undefined && b.run !== undefined && b.run.kind === kind && posEq(b.run.at, b.cursor)
199 let undo = b.undo
200 if (!continuing) {
201 undo = [...b.undo, { lines: b.lines, cursor: b.cursor }]
202 if (undo.length > UNDO_CAP) undo = undo.slice(undo.length - UNDO_CAP)
203 }
204 return {
205 ...b,
206 lines,
207 cursor,
208 goalCol: displayCol(lines[cursor.row] ?? '', cursor.col),
209 isDirty: !isSameText(lines, b.original),
210 undo,
211 redo: [],
212 run: kind ? { kind, at: cursor } : undefined,
213 }
214}
215
216function replaceLine(lines: string[], row: number, text: string): string[] {
217 const out = lines.slice()
218 out[row] = text
219 return out
220}
221
222function insertText(b: Buffer, text: string): Buffer {
223 const { row, col } = b.cursor
224 const cur = line(b, row)
225 const before = cur.slice(0, col)
226 const after = cur.slice(col)
227 const parts = text.split(/\r\n|\r|\n/)
228 if (parts.length === 1) {
229 const kind = text.length > 0 && !/[\s\p{P}\p{S}]/u.test(text) ? 'ins' : undefined
230 return commit(b, replaceLine(b.lines, row, before + text + after), { row, col: col + text.length }, kind)
231 }
232 const last = parts[parts.length - 1]!
233 const mid = parts.slice(1, -1)
234 const first = before + parts[0]!
235 const newLines = [...b.lines.slice(0, row), first, ...mid, last + after, ...b.lines.slice(row + 1)]
236 return commit(b, newLines, { row: row + parts.length - 1, col: last.length })
237}
238
239function newline(b: Buffer): Buffer {
240 const { row, col } = b.cursor
241 const cur = line(b, row)
242 const lead = /^[ \t]*/.exec(cur)![0].slice(0, col)
243 const newLines = [...b.lines.slice(0, row), cur.slice(0, col), lead + cur.slice(col), ...b.lines.slice(row + 1)]
244 return commit(b, newLines, { row: row + 1, col: lead.length })
245}
246
247function backspace(b: Buffer): Buffer {
248 const { row, col } = b.cursor
249 const cur = line(b, row)
250 if (col > 0) {
251 const n = stepBack(cur, col)
252 return commit(b, replaceLine(b.lines, row, cur.slice(0, col - n) + cur.slice(col)), { row, col: col - n }, 'bs')
253 }
254 if (row === 0) return b
255 const prev = line(b, row - 1)
256 const newLines = [...b.lines.slice(0, row - 1), prev + cur, ...b.lines.slice(row + 1)]
257 return commit(b, newLines, { row: row - 1, col: prev.length }, 'bs')
258}
259
260function del(b: Buffer): Buffer {
261 const { row, col } = b.cursor
262 const cur = line(b, row)
263 if (col < cur.length) {
264 const n = stepFwd(cur, col)
265 return commit(b, replaceLine(b.lines, row, cur.slice(0, col) + cur.slice(col + n)), b.cursor, 'del')
266 }
267 if (row >= b.lines.length - 1) return b
268 const newLines = [...b.lines.slice(0, row), cur + line(b, row + 1), ...b.lines.slice(row + 2)]
269 return commit(b, newLines, b.cursor, 'del')
270}
271
272function killToEnd(b: Buffer): Buffer {
273 const { row, col } = b.cursor
274 const cur = line(b, row)
275 if (col < cur.length) return commit(b, replaceLine(b.lines, row, cur.slice(0, col)), b.cursor)
276 return del(b) // at end of line: join the next line (no run coalescing)
277}
278
279function killToStart(b: Buffer): Buffer {
280 const { row, col } = b.cursor
281 if (col === 0) return b
282 const cur = line(b, row)
283 return commit(b, replaceLine(b.lines, row, cur.slice(col)), { row, col: 0 })
284}
285
286function dedent(b: Buffer): Buffer {
287 const { row, col } = b.cursor
288 const cur = line(b, row)
289 let n = 0
290 if (cur[0] === '\t') n = 1
291 else {
292 const max = b.indent === '\t' ? 4 : b.indent.length
293 while (n < max && cur[n] === ' ') n++
294 }
295 if (n === 0) return b
296 return commit(b, replaceLine(b.lines, row, cur.slice(n)), { row, col: Math.max(0, col - n) })
297}
298
299function wordClass(s: string): 0 | 1 | 2 {
300 if (/\s/u.test(s)) return 0
301 if (/[\p{L}\p{N}_]/u.test(s)) return 1
302 return 2
303}
304
305function wordLeft(b: Buffer): Pos {
306 const { row, col } = b.cursor
307 if (col === 0) return row > 0 ? { row: row - 1, col: line(b, row - 1).length } : b.cursor
308 const cur = line(b, row)
309 let c = col
310 const at = (i: number): number => wordClass(cur.slice(i - stepBack(cur, i), i))
311 while (c > 0 && at(c) === 0) c -= stepBack(cur, c)
312 if (c > 0) {
313 const cls = at(c)
314 while (c > 0 && at(c) === cls) c -= stepBack(cur, c)
315 }
316 return { row, col: c }
317}
318
319function wordRight(b: Buffer): Pos {
320 const { row, col } = b.cursor
321 const cur = line(b, row)
322 if (col >= cur.length) return row < b.lines.length - 1 ? { row: row + 1, col: 0 } : b.cursor
323 let c = col
324 const at = (i: number): number => wordClass(cur.slice(i, i + stepFwd(cur, i)))
325 while (c < cur.length && at(c) === 0) c += stepFwd(cur, c)
326 if (c < cur.length) {
327 const cls = at(c)
328 while (c < cur.length && at(c) === cls) c += stepFwd(cur, c)
329 }
330 return { row, col: c }
331}
332
333function undoStep(b: Buffer): Buffer {
334 const snap = b.undo[b.undo.length - 1]
335 if (!snap) return moved(b, b.cursor, true)
336 return {
337 ...b,
338 lines: snap.lines,
339 cursor: snap.cursor,
340 goalCol: displayCol(snap.lines[snap.cursor.row] ?? '', snap.cursor.col),
341 isDirty: !isSameText(snap.lines, b.original),
342 undo: b.undo.slice(0, -1),
343 redo: [...b.redo, { lines: b.lines, cursor: b.cursor }],
344 run: undefined,
345 }
346}
347
348function redoStep(b: Buffer): Buffer {
349 const snap = b.redo[b.redo.length - 1]
350 if (!snap) return moved(b, b.cursor, true)
351 return {
352 ...b,
353 lines: snap.lines,
354 cursor: snap.cursor,
355 goalCol: displayCol(snap.lines[snap.cursor.row] ?? '', snap.cursor.col),
356 isDirty: !isSameText(snap.lines, b.original),
357 undo: [...b.undo, { lines: b.lines, cursor: b.cursor }],
358 redo: b.redo.slice(0, -1),
359 run: undefined,
360 }
361}
362
363function moveRows(b: Buffer, delta: number): Buffer {
364 const { row } = b.cursor
365 const target = row + delta
366 if (target < 0) return moved(b, { row: 0, col: 0 }, false)
367 if (target > b.lines.length - 1) {
368 return moved(b, { row: b.lines.length - 1, col: line(b, b.lines.length - 1).length }, false)
369 }
370 return moved(b, { row: target, col: colFromDisplay(line(b, target), b.goalCol) }, true)
371}
372
373// Named keys that are not text. Other multi-char keys are pasted text.
374const NAMED = new Set([
375 'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'home', 'end', 'pageup', 'pagedown',
376 'escape', 'esc', 'insert', 'backtab', 'clear',
377])
378
379/** Apply one key; returns the new buffer (never mutates). Cursor moves do not scroll: call scrollIntoView. */
380export function applyKey(b: Buffer, k: EditKey, viewRows: number): Buffer {
381 const key = k.key
382 const mod = !!(k.ctrl || k.meta)
383 const rows = Math.max(1, Math.floor(viewRows) || 1)
384
385 if (k.ctrl && !k.meta) {
386 switch (key.toLowerCase()) {
387 case 'z':
388 return k.shift ? redoStep(b) : undoStep(b)
389 case 'y':
390 return redoStep(b)
391 case 'k':
392 return killToEnd(b)
393 case 'u':
394 return killToStart(b)
395 }
396 }
397
398 switch (key) {
399 case 'up':
400 return mod ? b : moveRows(b, -1)
401 case 'down':
402 return mod ? b : moveRows(b, 1)
403 case 'left':
404 if (mod) return moved(b, wordLeft(b), false)
405 if (b.cursor.col > 0) {
406 return moved(b, { row: b.cursor.row, col: b.cursor.col - stepBack(line(b, b.cursor.row), b.cursor.col) }, false)
407 }
408 return b.cursor.row > 0 ? moved(b, { row: b.cursor.row - 1, col: line(b, b.cursor.row - 1).length }, false) : moved(b, b.cursor, false)
409 case 'right': {
410 if (mod) return moved(b, wordRight(b), false)
411 const cur = line(b, b.cursor.row)
412 if (b.cursor.col < cur.length) return moved(b, { row: b.cursor.row, col: b.cursor.col + stepFwd(cur, b.cursor.col) }, false)
413 return b.cursor.row < b.lines.length - 1 ? moved(b, { row: b.cursor.row + 1, col: 0 }, false) : moved(b, b.cursor, false)
414 }
415 case 'home':
416 if (k.ctrl) return moved(b, { row: 0, col: 0 }, false)
417 return moved(b, { row: b.cursor.row, col: 0 }, false)
418 case 'end':
419 if (k.ctrl) {
420 const last = b.lines.length - 1
421 return moved(b, { row: last, col: line(b, last).length }, false)
422 }
423 return moved(b, { row: b.cursor.row, col: line(b, b.cursor.row).length }, false)
424 case 'pageup':
425 case 'pagedown': {
426 if (mod) return b
427 const delta = key === 'pageup' ? -rows : rows
428 const next = moveRows(b, delta)
429 const maxTop = Math.max(0, b.lines.length - 1)
430 return { ...next, top: Math.max(0, Math.min(maxTop, b.top + delta)) }
431 }
432 case 'return':
433 case 'enter':
434 return mod ? b : newline(b)
435 case 'backspace':
436 return mod ? b : backspace(b)
437 case 'delete':
438 return mod ? b : del(b)
439 case 'tab':
440 if (mod) return b
441 return k.shift ? dedent(b) : commit(b, ...tabArgs(b))
442 case 'escape':
443 case 'esc':
444 case 'insert':
445 case 'backtab':
446 case 'clear':
447 return b
448 }
449
450 if (mod) return b // unhandled ctrl/meta combo: never insert the letter
451 if (key.length === 0 || NAMED.has(key) || /^f\d{1,2}$/.test(key)) return b
452 if (key.charCodeAt(0) === 0x1b) return b
453 return insertText(b, key === 'space' ? ' ' : key)
454}
455
456function tabArgs(b: Buffer): [string[], Pos] {
457 const { row, col } = b.cursor
458 const cur = line(b, row)
459 return [replaceLine(b.lines, row, cur.slice(0, col) + b.indent + cur.slice(col)), { row, col: col + b.indent.length }]
460}
461
462// ---------------------------------------------------------------------------------------------
463// Scrolling and viewing
464// ---------------------------------------------------------------------------------------------
465
466/** Scroll minimally so the cursor is visible in a viewRows x viewCols window (gutter excluded). */
467export function scrollIntoView(b: Buffer, viewRows: number, viewCols: number): Buffer {
468 const rows = Math.max(1, Math.floor(viewRows) || 1)
469 const cols = Math.max(1, Math.floor(viewCols) || 1)
470 let top = Math.max(0, b.top)
471 const { row } = b.cursor
472 if (row < top) top = row
473 else if (row >= top + rows) top = row - rows + 1
474 top = Math.max(0, top)
475
476 const dc = displayCol(line(b, row), b.cursor.col)
477 let left = Math.max(0, b.left)
478 if (dc < left) left = dc
479 else if (dc >= left + cols) left = dc - cols + 1
480 left = Math.max(0, left)
481
482 return top === b.top && left === b.left ? b : { ...b, top, left }
483}
484
485/** Display string of a line (tabs expanded, control chars replaced), as per-cell strings. */
486function cells(text: string): { s: string; w: number }[] {
487 const out: { s: string; w: number }[] = []
488 let dc = 0
489 let i = 0
490 while (i < text.length) {
491 const [cp, n] = cpAt(text, i)
492 if (cp === 0x09) {
493 const w = TAB_WIDTH - (dc % TAB_WIDTH)
494 for (let j = 0; j < w; j++) out.push({ s: ' ', w: 1 })
495 dc += w
496 } else if (cp === 0x0d) {
497 // zero width, not drawn
498 } else if (cp < 0x20 || cp === 0x7f) {
499 out.push({ s: '·', w: 1 })
500 dc += 1
501 } else {
502 const w = cpWidth(cp)
503 out.push({ s: text.slice(i, i + n), w })
504 dc += w
505 }
506 i += n
507 }
508 return out
509}
510
511/** The visible window: rows from b.top, each cut to [left, left+viewCols) display columns. */
512export function view(b: Buffer, viewRows: number, viewCols: number): { lineNo: number; text: string; cursorCol?: number }[] {
513 const rows = Math.max(0, Math.floor(viewRows) || 0)
514 const cols = Math.max(0, Math.floor(viewCols) || 0)
515 const top = Math.max(0, b.top)
516 const left = Math.max(0, b.left)
517 const out: { lineNo: number; text: string; cursorCol?: number }[] = []
518 for (let r = top; r < b.lines.length && r < top + rows; r++) {
519 let text = ''
520 let dc = 0
521 for (const c of cells(b.lines[r]!)) {
522 const start = dc
523 dc += c.w
524 if (c.w === 0) {
525 if (start >= left && start < left + cols) text += c.s
526 continue
527 }
528 if (start < left) {
529 if (dc > left) text += ' '.repeat(dc - left) // wide char cut by the left edge
530 continue
531 }
532 if (dc > left + cols) break
533 text += c.s
534 }
535 const entry: { lineNo: number; text: string; cursorCol?: number } = { lineNo: r + 1, text }
536 if (r === b.cursor.row) entry.cursorCol = displayCol(b.lines[r]!, b.cursor.col) - left
537 out.push(entry)
538 }
539 return out
540}
541types/index.d.ts 176 lines1// repoviewer's type contract: every value it keeps in $.state, and the shapes
2// its modules hand each other. Paths are repo-relative with '/' separators.
3
4/** A file's git state: Modified, Added, Deleted, Renamed, untracked (?), conflict (U). */
5export type GitMark = 'M' | 'A' | 'D' | 'R' | '?' | 'U'
6
7/** The repo's file list, loaded once and on refresh. */
8export type RepoIndex = {
9 /** Absolute project root (git top level, else the session's project root). */
10 root: string
11 isGit: boolean
12 branch?: string
13 /** Every file path, sorted, at most MAX_FILES. Directories are implied by paths. */
14 files: string[]
15 /** True when the listing hit MAX_FILES and was cut. */
16 isTruncated: boolean
17 loadedAt: number
18}
19
20/** What the pane shows and how; the one value the person's presses change. */
21export type RepoView = {
22 mode: 'tree' | 'file'
23 /** Expanded directory paths. */
24 expanded: string[]
25 /** The tree row last pressed or revealed ('' none): drawn highlighted. */
26 cursor: string
27 /** Fuzzy query; non-empty switches the tree to a flat ranked list. */
28 filter: string
29 /** The file the viewer shows. */
30 openPath?: string
31 /** 0-based page of the open file. */
32 page: number
33 /** Markdown drawn as source rather than rendered. */
34 isRaw: boolean
35 /** Show `git diff HEAD` of the open file instead of its content. */
36 showDiff: boolean
37 /** Open each file Claude edits as it happens. */
38 follow: boolean
39 /** The file the person is editing in the pane, if any: follow never pulls the view away from it. */
40 editing?: string
41 /** Bumped whenever the hooks side moves the cursor (reveal, follow, hotkeys), so the Client adopts it. */
42 cursorAt: number
43 /** The 1-based line the viewer should bring into view (show_file, follow), with its own bump. */
44 line?: number
45 lineAt: number
46 /** The last outcome the Client should show (saved, conflict, error). */
47 notice?: { text: string; tone: 'info' | 'warn' | 'error'; at: number }
48}
49
50// ---------------------------------------------------------------------------
51// The pane's keyboard Client (hooks/nav.tsx): one `Client` keyed 'nav' draws the
52// tree, the file view and the editor, so a click gives it the keys once and the
53// arrows keep working as the person moves between them. Props are plain JSON,
54// at most 100,000 characters in all: hence compact rows and NAV_MAX_TEXT.
55
56/** A tree row as the Client gets it: [path, depth, flags, matches?].
57 * flags: 'd' dir, 'o' expanded, 'c' dir holds changes, 't' touched by Claude,
58 * and at most one git mark letter M A ? D R U. matches: flat [start, end, start, end…] into the shown name. */
59export type NavRow = [path: string, depth: number, flags: string, matches?: number[]]
60
61export type NavTree = {
62 rows: NavRow[]
63 /** Rows left out to keep props small. */
64 more: number
65 /** Filter mode: rows are ranked matches and the shown name is the whole path. */
66 isFiltering: boolean
67 query: string
68}
69
70export type NavFile = {
71 path: string
72 kind: FileDoc['kind']
73 /** The whole text when it is at most NAV_MAX_TEXT characters, else the current page. */
74 text?: string
75 startLine: number
76 page: number
77 pageCount: number
78 lineCount: number
79 size: number
80 mtimeMs: number
81 /** Text or markdown held whole: the Client may edit it. */
82 isEditable: boolean
83 isRaw: boolean
84 showDiff: boolean
85 diff?: { hunks: string; isTruncated: boolean }
86 mark?: GitMark
87 isTouched: boolean
88}
89
90export type NavProps = {
91 mode: 'tree' | 'file'
92 /** The region the Client is given, in cells. */
93 columns: number
94 rows: number
95 surface: 'terminal' | 'desktop'
96 tree?: NavTree
97 file?: NavFile
98 cursor: string
99 cursorAt: number
100 line?: number
101 lineAt: number
102 editing?: string
103 notice?: { text: string; tone: 'info' | 'warn' | 'error'; at: number }
104}
105
106/** What the Client posts to the hooks module (`ui.message` e.data). One post per key press:
107 * a later post in the same frame replaces an undelivered one. */
108export type NavOp =
109 | { op: 'cursor'; path: string }
110 | { op: 'open'; path: string }
111 | { op: 'toggle'; path: string }
112 | { op: 'expand'; path: string }
113 | { op: 'collapse'; path: string }
114 | { op: 'back' }
115 | { op: 'page'; page: number }
116 | { op: 'raw' }
117 | { op: 'diff' }
118 | { op: 'edit'; path: string; isEditing: boolean }
119 | { op: 'save'; path: string; text: string; baseMtimeMs: number; force: boolean }
120 /** A character typed outside the editor: it goes to Claude Code's prompt, and the keys go back there. */
121 | { op: 'type'; text: string }
122 /** Hand the keys back to Claude Code's prompt (← at the tree's top level). */
123 | { op: 'leave' }
124
125
126/** One row the tree draws. */
127export type TreeRow = {
128 path: string
129 /** Last path segment in tree mode; the whole path in filter mode. */
130 name: string
131 depth: number
132 kind: 'file' | 'dir'
133 isExpanded: boolean
134 /** The file's own git mark. */
135 mark?: GitMark
136 /** For a dir: some file beneath it has a git mark. */
137 hasChanges?: boolean
138 /** Claude edited this file (or, for a dir, something beneath it) this session. */
139 isTouched: boolean
140 /** Filter mode: [start, end) character ranges of `name` that matched the query. */
141 matches?: Array<[number, number]>
142}
143
144/** A file loaded for the viewer. */
145export type FileDoc = {
146 path: string
147 kind: 'text' | 'markdown' | 'image' | 'binary' | 'too-large' | 'missing'
148 /** Whole text for text/markdown. */
149 text?: string
150 lineCount: number
151 size: number
152 mtimeMs: number
153 /** Absolute path, for kind 'image' (PNG only; drawn by Image { file, format: 'png' }). */
154 absPath?: string
155 /** Why it could not be read, for 'missing'. */
156 error?: string
157}
158
159/** One page of a text doc, sized for Code/Markdown's 10000-character limit. */
160export type DocPage = { text: string; startLine: number; pageCount: number }
161
162declare module 'claude-code' {
163 interface PluginState {
164 'kesav': {
165 index: RepoIndex | null
166 /** path → mark, from `git status`. */
167 git: Record<string, GitMark>
168 /** path → number of Claude's edits this session. */
169 touched: Record<string, number>
170 view: RepoView
171 /** Bumped whenever a file may have changed on disk (Claude's edits, refresh): the viewer re-reads. */
172 revision: number
173 }
174 }
175}
176