SLOPSHOPPER

repoviewer

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

newpaneguardcommandtoasttool
v1.0.5MITupdated 2026-10-03kesavreddy-commits/repoviewer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kesav
│ ┃ app ✕ › fix the failing auth test and add an audit log call │ ┃ app ⎇ feat/auth-refresh close │ ┃ 0 files ⏺ Read(src/auth.ts) │ ┃ ╭────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ ⌕ Filter files… │ ⏺ Update(src/auth.ts) │ ┃ ╰────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ refresh collapse all follow on ⏺ Bash(bun test) │ ┃ ▣ client module ./nav.tsx ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /files │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · app
app ⎇ feat/auth-refresh close 0 files ╭────────────────────────────────────────────────────────╮ │ ⌕ Filter files… │ ╰────────────────────────────────────────────────────────╯ refresh collapse all follow on ▣ client module ./nav.tsx
README

repoviewer

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

repoviewer: the tree, a file, rendered markdown and the editor, docked beside Claude Code

The Claude desktop app has a file pane; the CLI didn't. repoviewer is a Claude Code mod that docks one on the right:

  • Tree of the repo (.gitignore respected) with git marks (M A ? D R U) and a magenta ● on every file Claude edited this session.
  • Arrow keys: ↑ ↓ move, → opens a folder or file, ← steps back out, and ← at the top level hands the keys back to Claude's prompt.
  • Viewer: syntax highlighting with line numbers, rendered markdown, git diff per file, PNGs (kitty / Ghostty).
  • Editor: → 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.
  • Follows Claude: files open as Claude edits them, and Claude can show you a file ("show me the auth middleware") with its show_file tool.
  • Fuzzy finder: 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.

Install

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

Use

/files (or /repo)toggle the pane
/files <path>open a file, or reveal a folder
/files <text>fuzzy-find
click the treegives 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.

Examples

  1. Browse: type /files, click the tree, then use → to open hooks/ and a file inside it, and ← to come back out.
  2. Jump to a file: /files README.md opens it rendered; /files hooks reveals the folder; /files regtsx filters the tree down to hooks/register.tsx.
  3. Ask Claude to show you something: "Show me the file that handles the pane's keys." Claude opens it in the pane with the show_file tool.
  4. Watch Claude work: with 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.
  5. Edit it yourself: open a file, press → 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.

Troubleshooting

  • The pane sits above the prompt, not on the right: it docks only in fullscreen mode. Run /tui fullscreen.
  • The pane doesn't open when a session starts: a terminal narrower than about 144 columns waits for you to ask, and if you closed the pane last time, it stays closed. /files opens it either way.
  • The arrow keys go to Claude's prompt: click the tree first; it then has the arrow keys until you press Esc or ← at the top level.
  • A file is missing from the tree: it's ignored by .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.
  • A save is refused with "changed on disk": something else changed the file since you opened it. ctrl+s again overwrites it; ctrl+q leaves without saving.
  • Nothing happens at all: check 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.

Privacy and safety

repoviewer reads your repo and shows it to you, and changes nothing unless you save a file. In detail:

  • Nothing leaves your machine. No network calls, no telemetry. Nothing it reads (your files, git output, the conversation) is sent anywhere. When Claude uses the show_file tool, all it gets back is one line, like "Showing hooks/register.tsx in the repo pane."
  • It only runs 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.

  • It only writes the file you save. It's an editor, so it can save any file inside your repo that you open and edit, including build, start-up, settings or instruction files like 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 doesn't read the conversation. It uses the end of each turn only as a signal to refresh the tree, and looks at Claude's Edit, Write and NotebookEdit calls only for the path of the file changed.
  • Typing in the pane goes to Claude's prompt, just as if you'd typed it there.

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.

Develop

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

Contributors

License

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.

Source 8 files
hooks/register.tsx 687 lines
1import { 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}
687
hooks/repo.ts 719 lines
1// 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}
719
hooks/tree.tsx 497 lines
1// 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}
497
hooks/viewer.tsx 310 lines
1import 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}
310
hooks/nav.tsx 569 lines
1// 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}
569
hooks/navfile.tsx 1100 lines
1// 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}
1100
hooks/editor.ts 541 lines
1// 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}
541
types/index.d.ts 176 lines
1// 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