Read the files of your project in a calm pane beside the chat.

A Claude Code mod. /files opens a pane beside the chat with the files of your project. Markdown shows as a document, with headings, tables and paragraphs that wrap.

The document is next to the chat. The file tree is at the right edge. The prompt stays where it is, at the bottom.
In a Claude Code terminal session:
/plugin install folio --marketplace krishna-goutham-tls/cc-mods
Answer y to add the marketplace, then pick a scope. The user scope loads folio in every session. Then type /files.
folio is built and tested on Claude Code 2.1.296. Mods are early access, so a later release can change the API and break it.
| You do | The pane does |
|---|---|
/files | Opens, and shows the README of the project |
/files docs/guide.md | Opens that file and its folder |
| Click a folder | Opens or closes it |
| Click a file | Shows it |
| Scroll over the document or the tree | Moves that part only |
Click Read or Source | Shows the formatted document or the text as written |
| Click a link to a file of the project | Opens that file |
Click Copy | Puts the text of the open file on the clipboard |
Click Mention | Puts @path in the prompt |
Click Open | Opens the file in its Mac app |
Click ⇄ | Moves the tree to the other side |
ctrl+x tab gives the pane the keyboard. The arrows and the page keys move the document, Tab walks the rows, and Enter opens one. Esc returns to the prompt.
| File | How |
|---|---|
| Markdown | Headings, paragraphs that wrap, lists, task lists, quotes, tables, code blocks with colours, front matter |
| Code and text | Claude Code's syntax colours, with line numbers |
| CSV and TSV | A table |
| PNG, JPG, GIF, HEIC | The picture, in a terminal with the kitty graphics protocol such as Ghostty or kitty |
| PDF, Office files | The first page as a picture, made by macOS Quick Look |
| HTML, SVG | Read shows the top of the page as a picture, made by macOS Quick Look. Source shows the text |
.env, keys, credentials | A notice. The text stays hidden until you click Reveal, so a shared screen or a recording is safe |
| Other files | The name and size. Open shows the file in its own app |
A file that Claude writes gets a ● in the tree. The open document reads the disk again when a turn ends.
The pane follows your light or dark theme.

A mod runs inside Claude Code, so it is fair to ask what this one does.
Copy. The text does not go to the model.sips and qlmanage to make a picture preview, mkdir and mv for that preview in the temp folder, and open when you click Open.claude plugin validate folio, run from the root of the repo, prints the same list from the source.
Mention puts the path in the prompt so you can ask Claude for the change.● only when its folder is open in the tree.Open work is in TRACKER.md.
hooks/register.tsx has the hooks and every read of the disk.hooks/markdown.ts turns markdown into rows, one per terminal line. The pane draws only the rows in view, so a long file scrolls like a short one.hooks/view.tsx draws the tree and the reader.folio is one of two mods in the cc-mods repo. Its folder is folio/.
git clone https://github.com/krishna-goutham-tls/cc-mods
cd cc-mods/folio
claude --plugin-dir .
Claude Code loads the mod from the folder and reloads it when you save a file. It also writes the API types to .claude-plugin/types/, which the tsconfig.json here reads.
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .
AGENTS.md has the build rules and the file map.
MIT
hooks/register.tsx 648 lines1// folio: the files of the project in a pane beside the chat. A tree on the
2// left, the open file on the right, markdown set as a document.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { FolioMode, FolioUi } from '../types'
7import { baseOf, dirOf, hasTwoForms, isHidden, isPage, kindOf } from './files'
8import { MAX_TEXT_BYTES, byName, goneDoc, hash, lockedDoc, notice, opaqueDoc, pictureDoc, planOf, textDoc } from './load'
9import type { DocRequest, Entry } from './load'
10import type { Doc, Frame, Node } from './model'
11import { drawPane } from './view'
12import type { Actions } from './view'
13
14const PANE = 'folio'
15const TITLE = 'Files'
16/** The pane shows tree and reader side by side from this many columns. */
17const SPLIT_FROM = 84
18/** The widest a line of prose runs, for a comfortable read. */
19const MEASURE = 88
20const FIRST_FILES = ['README.md', 'AGENTS.md', 'CLAUDE.md', 'readme.md']
21
22const START: FolioUi = { expanded: [], selected: '', mode: 'read', docTop: 0, treeTop: 0, revealed: [], tick: 0, side: 'right', isRestored: false }
23const ui = atom({ plugin: 'folio', key: 'ui' } as const, START)
24const touched = atom({ plugin: 'folio', key: 'touched' } as const, [])
25
26const clamp = (n: number, low: number, high: number): number => Math.max(low, Math.min(high, n))
27
28// What was read from disk, kept until the files change (`forget`).
29let root = ''
30const listings = new Map<string, Entry[]>()
31const docs = new Map<string, Doc>()
32
33const forget = (): void => {
34 listings.clear()
35 docs.clear()
36}
37
38const rootOf = async ($: EngineInterface): Promise<string> => {
39 if (root === '') {
40 root = (await $.session.root()).replace(/\/+$/, '')
41 }
42
43 return root
44}
45
46/** Keeps the open folders and the open file for the next session in this project. */
47const keep = async ($: EngineInterface): Promise<void> => {
48 const { value } = await $.state.get({ plugin: 'folio', key: 'ui' } as const)
49
50 if (value !== undefined && root !== '') {
51 await $.store.set(`ui:${root}`, { expanded: value.expanded.slice(-200), selected: value.selected, mode: value.mode })
52 }
53}
54
55const ancestors = (path: string): string[] => {
56 const parts = path.split('/').slice(0, -1)
57
58 return parts.map((_, n) => parts.slice(0, n + 1).join('/'))
59}
60
61/** A path as a link or a command names it, made relative to the project. */
62const resolve = (from: string, target: string): string => {
63 let clean = target.split('#')[0] ?? ''
64
65 try {
66 clean = decodeURIComponent(clean)
67 } catch {
68 // Kept as written.
69 }
70
71 if (root !== '' && clean.startsWith(`${root}/`)) {
72 clean = clean.slice(root.length)
73 }
74
75 const parts = clean.startsWith('/') ? [] : from.split('/').filter(part => part !== '')
76
77 for (const part of clean.split('/')) {
78 if (part === '..') {
79 parts.pop()
80 } else if (part !== '' && part !== '.') {
81 parts.push(part)
82 }
83 }
84
85 return parts.join('/')
86}
87
88const list = async ($: EngineInterface, dir: string): Promise<Entry[]> => {
89 const known = listings.get(dir)
90
91 if (known !== undefined) {
92 return known
93 }
94
95 const abs = dir === '' ? root : `${root}/${dir}`
96 const entries: Entry[] = []
97 const raw = await $.fs.list(abs).catch(() => [])
98
99 for (const entry of raw) {
100 if (isHidden(entry.name)) {
101 continue
102 }
103
104 if (entry.kind !== 'other') {
105 entries.push({ name: entry.name, isDir: entry.kind === 'dir', mtimeMs: entry.mtimeMs })
106 continue
107 }
108
109 if (entry.isLink) {
110 const target = await $.fs.stat(`${abs}/${entry.name}`).catch(() => undefined)
111
112 if (target !== undefined && target.kind !== 'other') {
113 entries.push({ name: entry.name, isDir: target.kind === 'dir', mtimeMs: target.mtimeMs })
114 }
115 }
116 }
117
118 entries.sort(byName)
119 listings.set(dir, entries)
120
121 return entries
122}
123
124/** The rows of the tree: the project folder's entries and those of each open folder. */
125const treeNodes = async ($: EngineInterface, expanded: ReadonlySet<string>): Promise<Node[]> => {
126 const out: Node[] = []
127 // Folders wait here to be read; the newest is read first, so rows come out in tree order.
128 const waiting: Array<{ dir: string; depth: number }> = [{ dir: '', depth: 0 }]
129 const rowsOf = new Map<string, Node[]>()
130
131 while (waiting.length > 0) {
132 const { dir, depth } = waiting.pop() ?? { dir: '', depth: 0 }
133 const entries = await list($, dir)
134 const rows: Node[] = []
135
136 if (entries.length === 0 && dir !== '') {
137 rows.push({ rel: `${dir}/`, name: '', depth, isDir: false, isOpen: false, isEmpty: true })
138 }
139
140 for (const entry of entries) {
141 const rel = dir === '' ? entry.name : `${dir}/${entry.name}`
142 const isOpen = entry.isDir && expanded.has(rel) && depth < 24
143
144 rows.push({ rel, name: entry.name, depth, isDir: entry.isDir, isOpen, isEmpty: false })
145
146 if (isOpen) {
147 waiting.push({ dir: rel, depth: depth + 1 })
148 }
149 }
150
151 rowsOf.set(dir, rows)
152 }
153
154 const place = (dir: string): void => {
155 for (const row of rowsOf.get(dir) ?? []) {
156 out.push(row)
157
158 if (row.isOpen) {
159 place(row.rel)
160 }
161 }
162 }
163
164 place('')
165
166 return out
167}
168
169const pixels = async ($: EngineInterface, file: string): Promise<{ width: number; height: number } | undefined> => {
170 const info = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeoutMs: 8000 })
171 const width = Number(/pixelWidth:\s*(\d+)/.exec(info.stdout)?.[1])
172 const height = Number(/pixelHeight:\s*(\d+)/.exec(info.stdout)?.[1])
173
174 return width > 0 && height > 0 ? { width, height } : undefined
175}
176
177/**
178 * A PNG of the file in the temp folder, made by macOS: `sips` for a picture,
179 * Quick Look for a document's first page. The terminal reads the PNG itself.
180 */
181const preview = async (
182 $: EngineInterface,
183 abs: string,
184 isImage: boolean,
185 stamp: string,
186): Promise<{ file: string; width: number; height: number } | undefined> => {
187 try {
188 const temp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
189 const dir = `${temp}/folio-${hash(`${abs}|${stamp}`)}`
190 const out = `${dir}/preview.png`
191
192 if (!(await $.fs.exists(out))) {
193 await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5000 })
194
195 let isMade = false
196
197 if (isImage) {
198 const size = await pixels($, abs)
199 const fit = size !== undefined && Math.max(size.width, size.height) > 1600 ? ['-Z', '1600'] : []
200 const made = await $.process.run(['sips', '-s', 'format', 'png', ...fit, abs, '--out', out], { timeoutMs: 20_000 })
201
202 isMade = made.exitCode === 0 && (await $.fs.exists(out))
203 }
204
205 if (!isMade) {
206 await $.process.run(['qlmanage', '-t', '-s', '1600', '-o', dir, abs], { timeoutMs: 30_000 })
207
208 const made = `${dir}/${baseOf(abs)}.png`
209
210 if (await $.fs.exists(made)) {
211 await $.process.run(['mv', made, out], { timeoutMs: 5000 })
212 isMade = true
213 }
214 }
215
216 if (!isMade) {
217 return undefined
218 }
219 }
220
221 const size = await pixels($, out)
222
223 return size === undefined ? undefined : { file: out, ...size }
224 } catch {
225 return undefined
226 }
227}
228
229/** One file as the reader shows it. Kept until `forget`. */
230const loadDoc = async ($: EngineInterface, request: DocRequest): Promise<Doc> => {
231 const key = [request.path, request.isSource, request.isRevealed, request.columns, request.measure, request.rows].join('|')
232 const known = docs.get(key)
233
234 if (known !== undefined) {
235 return known
236 }
237
238 const abs = `${request.root}/${request.path}`
239 const stat = await $.fs.stat(abs).catch(() => undefined)
240 let doc: Doc
241
242 if (stat === undefined || stat.kind !== 'file') {
243 doc = goneDoc(request)
244 } else {
245 const plan = planOf(request)
246
247 if (plan === 'locked') {
248 doc = lockedDoc(request, stat)
249 } else if (plan === 'picture') {
250 const made = await preview($, abs, kindOf(baseOf(request.path)) === 'image', `${stat.mtimeMs}|${stat.size}`)
251
252 doc =
253 made === undefined
254 ? // A page keeps its toggle, so Source still shows the text.
255 { ...opaqueDoc(request, stat, 'No preview for this file here.'), canToggle: isPage(baseOf(request.path)) }
256 : pictureDoc(request, stat, made)
257 } else if (plan === 'opaque') {
258 doc = opaqueDoc(request, stat, 'This file is not text.')
259 } else if (stat.size > MAX_TEXT_BYTES) {
260 doc = opaqueDoc(request, stat, 'This file is too large to show here.')
261 } else {
262 const text = await $.fs.read(abs).catch(() => undefined)
263
264 doc = text === undefined ? notice(request, stat, baseOf(request.path), ['This file cannot be read.']) : textDoc(request, stat, text)
265 }
266 }
267
268 docs.set(key, doc)
269
270 return doc
271}
272
273/** A file to bring into view in the tree at the next drawing. */
274let reveal = ''
275
276/** Opens a path of the project: a file in the reader, a folder in the tree. */
277const show = async ($: EngineInterface, path: string): Promise<boolean> => {
278 const base = await rootOf($)
279 const stat = await $.fs.stat(`${base}/${path}`).catch(() => undefined)
280
281 if (path === '' || stat === undefined || stat.kind === 'other') {
282 return false
283 }
284
285 const isDir = stat.kind === 'dir'
286 const open = isDir ? [...ancestors(path), path] : ancestors(path)
287
288 reveal = path
289 await update($, ui, state => ({
290 ...state,
291 expanded: [...new Set([...state.expanded, ...open])],
292 ...(isDir ? {} : { selected: path, docTop: 0 }),
293 }))
294 await keep($)
295
296 return true
297}
298
299/** When the turn that runs now began; 0 between turns. */
300let turnBegan = 0
301
302/**
303 * Marks the files that changed on disk while Claude's turn ran: what a shell
304 * command wrote, which no file tool reports. Only the folders open in the tree
305 * are looked at.
306 */
307const noteTurn = async ($: EngineInterface): Promise<void> => {
308 try {
309 const began = turnBegan
310 const { value } = await $.state.get({ plugin: 'folio', key: 'ui' } as const)
311
312 turnBegan = 0
313 forget()
314
315 if (began > 0 && value !== undefined) {
316 await rootOf($)
317
318 const changed: string[] = []
319
320 for (const dir of ['', ...value.expanded].slice(0, 60)) {
321 for (const entry of await list($, dir)) {
322 if (!entry.isDir && entry.mtimeMs >= began - 1000) {
323 changed.push(dir === '' ? entry.name : `${dir}/${entry.name}`)
324 }
325 }
326 }
327
328 if (changed.length > 0) {
329 await update($, touched, known => [...new Set([...known, ...changed])].slice(-300))
330 }
331 }
332
333 await update($, ui, state => ({ ...state, tick: state.tick + 1 }))
334 } catch {
335 // The pane's bookkeeping never stands in a turn's way.
336 }
337}
338
339/** Marks a file Claude wrote, and has the pane read the disk again. */
340const noteWrite = async ($: EngineInterface, file: unknown): Promise<void> => {
341 try {
342 const base = await rootOf($)
343
344 if (typeof file === 'string' && file.startsWith(`${base}/`)) {
345 const path = file.slice(base.length + 1)
346
347 await update($, touched, list => (list.includes(path) ? list : [...list, path].slice(-300)))
348 }
349
350 await update($, ui, state => ({ ...state, tick: state.tick + 1 }))
351 } catch {
352 // The pane's bookkeeping never stands in a tool's way.
353 }
354}
355
356export const register: Register = on => {
357 let seenTick = -1
358 /**
359 * True when another copy of this mod runs in the session (one under
360 * development beside an installed one). That copy does the work; this one
361 * passes every event on.
362 */
363 let isIdle = false
364 /** Where the lists stood at the last drawing; the wheel moves them from here. */
365 const drawn = { isSplit: false, isReading: false, treeFrom: 0, treeTo: 0, treeTop: 0, treeMax: 0, docTop: 0, docMax: 0 }
366
367 on('session.start', async ($, e, next) => {
368 // The first copy to start in a session writes its name here; a second copy reads it and stays idle.
369 const session = await $.session.id().catch(() => '')
370 const mine = `${session}|${$.plugin.root}`
371 const home = (await $.env.get('FOLIO_HOME').catch(() => undefined)) ?? ''
372
373 isIdle = home.startsWith(`${session}|`) && home !== mine
374
375 if (isIdle) {
376 return next(e)
377 }
378
379 await $.env.set('FOLIO_HOME', mine).catch(() => undefined)
380 await $.command.register({
381 name: 'files',
382 description: 'Browse and read the files of this project in a pane',
383 argumentHint: '[path]',
384 })
385
386 const base = await rootOf($)
387 const saved = (await $.store.get(`ui:${base}`).catch(() => undefined)) as Partial<FolioUi> | undefined
388 const side = await $.store.get('side').catch(() => undefined)
389
390 await update($, ui, (state): FolioUi =>
391 state.isRestored
392 ? state
393 : {
394 ...state,
395 isRestored: true,
396 expanded: Array.isArray(saved?.expanded) ? saved.expanded.filter((path): path is string => typeof path === 'string') : state.expanded,
397 selected: typeof saved?.selected === 'string' ? saved.selected : state.selected,
398 mode: saved?.mode === 'source' ? 'source' : 'read',
399 side: side === 'left' ? 'left' : 'right',
400 },
401 )
402
403 // The pane comes back where the person left it open.
404 if ((await $.store.get(`open:${base}`).catch(() => undefined)) === true) {
405 const width = await $.store.get(`columns:${base}`).catch(() => undefined)
406
407 void $.ui.open({ id: PANE, title: TITLE, ...(typeof width === 'number' ? { columns: clamp(Math.round(width), 60, 124), rows: 28 } : {}) })
408 }
409
410 return next(e)
411 })
412
413 on('command.run', { command: 'files' }, async ($, e, next) => {
414 if (isIdle) {
415 return next(e)
416 }
417
418 const base = await rootOf($)
419 const asked = e.args.trim().replace(/^@/, '')
420 const state = await read($, ui)
421
422 if (asked !== '') {
423 if (!(await show($, resolve('', asked)))) {
424 return { text: `No file or folder at ${asked} in this project.` }
425 }
426 } else if (state.selected === '') {
427 for (const name of FIRST_FILES) {
428 if (await $.fs.exists(`${base}/${name}`)) {
429 await show($, name)
430 break
431 }
432 }
433 }
434
435 const columns = clamp(Math.round(e.presentation.columns * 0.52), 60, 124)
436 const opened = await $.ui.open({ id: PANE, title: TITLE, columns, rows: 28 })
437
438 await $.store.set(`open:${base}`, true)
439 await $.store.set(`columns:${base}`, columns)
440
441 return opened.isPlaced ? {} : { text: `The files pane is open but waits for room: ${opened.reason}` }
442 })
443
444 on('ui.close', { id: PANE }, async ($, e, next) => {
445 if (!isIdle && e.origin.kind === 'person') {
446 await $.store.set(`open:${await rootOf($)}`, false)
447 }
448
449 return next(e)
450 })
451
452 // The pane keeps its own windows over the tree and the document, so the
453 // header and the tree stay put while the document moves.
454 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
455 if (isIdle) {
456 return next(e)
457 }
458
459 const column = e.pointer?.column
460 const isOverTree = drawn.isSplit ? column !== undefined && column >= drawn.treeFrom && column <= drawn.treeTo : !drawn.isReading
461
462 await update($, ui, state =>
463 isOverTree
464 ? { ...state, treeTop: clamp(drawn.treeTop + e.by, 0, drawn.treeMax) }
465 : { ...state, docTop: clamp(drawn.docTop + e.by, 0, drawn.docMax) },
466 )
467
468 return { deny: 'folio moves its own rows' }
469 }).catch(() => ({ deny: 'folio moves its own rows' }))
470
471 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
472 const result = await next(e)
473
474 // An MCP tool with an input named `tool` adds a variant without `file_path` to the type of `e`.
475 if (!isIdle && 'file_path' in e) {
476 await noteWrite($, e.file_path)
477 }
478
479 return result
480 }).catch(($, e, next) => next(e))
481
482 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
483 const result = await next(e)
484
485 // An MCP tool with an input named `tool` adds a variant without `file_path` to the type of `e`.
486 if (!isIdle && 'file_path' in e) {
487 await noteWrite($, e.file_path)
488 }
489
490 return result
491 }).catch(($, e, next) => next(e))
492
493 on('turn.start', async ($, e, next) => {
494 turnBegan = await $.clock.now().catch(() => 0)
495
496 return next(e)
497 })
498
499 on('turn.complete', async ($, e, next) => {
500 if (e.agentId === undefined && !isIdle) {
501 await noteTurn($)
502 }
503
504 return next(e)
505 })
506
507 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
508 if (isIdle) {
509 return next(e)
510 }
511
512 const el = $.ui.resolve(e)
513 const { Box, Text } = el
514
515 try {
516 const base = await rootOf($)
517 const state = await read($, ui)
518 const written = await read($, touched)
519
520 if (state.tick !== seenTick) {
521 forget()
522 seenTick = state.tick
523 }
524
525 const columns = Math.max(24, e.props.bodyColumns)
526 const rows = Math.max(8, e.props.scroll.bodyRows)
527 const isSplit = columns >= SPLIT_FROM
528 const treeWidth = isSplit ? clamp(Math.round(columns * 0.29), 26, 38) : columns
529 const isTreeRight = isSplit && state.side === 'right'
530 const frame: Frame = {
531 columns,
532 rows,
533 isSplit,
534 isTreeRight,
535 treeWidth,
536 // Beside the text: the tree, the line between them and the two thumbs, or one margin and a thumb.
537 textWidth: Math.max(8, isSplit ? columns - treeWidth - 5 : columns - 3),
538 treeRows: rows - 2,
539 docRows: rows - 5,
540 }
541 const nodes = await treeNodes($, new Set(state.expanded))
542 const treeMax = Math.max(0, nodes.length - frame.treeRows)
543 let treeTop = clamp(state.treeTop, 0, treeMax)
544
545 if (reveal !== '') {
546 const at = nodes.findIndex(node => node.rel === reveal)
547
548 if (at !== -1 && (at < treeTop || at >= treeTop + frame.treeRows)) {
549 treeTop = clamp(at - Math.floor(frame.treeRows / 3), 0, treeMax)
550 }
551
552 reveal = ''
553 }
554
555 const isSource = state.mode === 'source' && hasTwoForms(baseOf(state.selected))
556 const doc =
557 state.selected === ''
558 ? undefined
559 : await loadDoc($, {
560 root: base,
561 path: state.selected,
562 isSource,
563 isRevealed: state.revealed.includes(state.selected),
564 columns: frame.textWidth,
565 measure: MEASURE,
566 rows: frame.docRows,
567 now: await $.clock.now(),
568 })
569 const docMax = doc === undefined || doc.picture !== undefined ? 0 : Math.max(0, doc.rows.length - frame.docRows)
570 const docTop = clamp(state.docTop, 0, docMax)
571
572 Object.assign(drawn, {
573 isSplit,
574 isReading: state.selected !== '',
575 treeFrom: isTreeRight ? columns - treeWidth - 1 : 0,
576 treeTo: isTreeRight ? columns : treeWidth,
577 treeTop,
578 treeMax,
579 docTop,
580 docMax,
581 })
582
583 const change = (step: (state: FolioUi) => FolioUi) => {
584 void update($, ui, step).then(() => keep($))
585 }
586 const actions: Actions = {
587 toggle: path =>
588 change(now => ({
589 ...now,
590 expanded: now.expanded.includes(path) ? now.expanded.filter(open => open !== path) : [...now.expanded, path],
591 })),
592 open: path => change(now => ({ ...now, selected: path, docTop: 0 })),
593 back: () => change(now => ({ ...now, selected: '' })),
594 mode: (mode: FolioMode) => change(now => ({ ...now, mode, docTop: 0 })),
595 reveal: () => change(now => ({ ...now, revealed: [...now.revealed, state.selected] })),
596 copy: press => {
597 const name = baseOf(state.selected)
598
599 void $.fs
600 .read(`${base}/${state.selected}`)
601 .then(text => $.ui.copy({ text, surface: press.surface }))
602 .then(done => $.ui.toast(done.isCopied ? `Copied ${name}` : `${name} was not copied`))
603 .catch(() => $.ui.toast(`${name} could not be read`))
604 },
605 mention: () => {
606 void $.prompt.fill({ text: `@${state.selected} `, mode: 'insert' })
607 },
608 outside: () => {
609 void $.process.run(['open', `${base}/${state.selected}`], { timeoutMs: 10_000 }).catch(() => undefined)
610 },
611 swap: () => {
612 void update($, ui, (now): FolioUi => ({ ...now, side: now.side === 'right' ? 'left' : 'right' })).then(now => $.store.set('side', now.side))
613 },
614 follow: href => {
615 const path = resolve(dirOf(state.selected), href)
616
617 void show($, path).then(isShown => {
618 if (!isShown) {
619 $.ui.toast(`${path} is not in this project`)
620 }
621 })
622 },
623 }
624
625 return drawPane(e.surface === 'terminal' ? $.ui.resolve(e) : el, {
626 frame,
627 title: baseOf(base) || base,
628 nodes,
629 treeTop,
630 selected: state.selected,
631 touched: new Set(written),
632 touchedFolders: new Set(written.flatMap(path => ancestors(path))),
633 doc,
634 docTop,
635 mode: isSource ? 'source' : 'read',
636 on: actions,
637 })
638 } catch (error) {
639 return (
640 <Box flexDirection="column">
641 <Text bold>The files pane could not draw.</Text>
642 <Text dimColor>{String(error instanceof Error ? error.message : error).slice(0, 300)}</Text>
643 </Box>
644 )
645 }
646 })
647}
648hooks/files.ts 169 lines1// What a file is, by its name: how the pane shows it, and the words for its
2// size and age.
3
4export type Kind = 'markdown' | 'table' | 'code' | 'image' | 'look' | 'binary'
5
6const MARKDOWN = new Set(['md', 'mdx', 'markdown'])
7const TABLE = new Set(['csv', 'tsv'])
8const IMAGE = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'heif', 'ico', 'avif'])
9/** Files macOS Quick Look can draw a first page of. */
10const LOOK = new Set(['pdf', 'svg', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt', 'key', 'pages', 'numbers', 'mp4', 'mov', 'm4v', 'psd', 'ai', 'eps'])
11/** Text files that Quick Look can also draw: Read shows the drawing, Source the text. */
12const PAGE = new Set(['svg', 'html', 'htm'])
13const BINARY = new Set([
14 'zip', 'gz', 'tar', 'tgz', 'bz2', 'xz', '7z', 'rar', 'dmg', 'pkg', 'exe', 'dll', 'so', 'dylib', 'o', 'a', 'class', 'jar', 'wasm',
15 'woff', 'woff2', 'ttf', 'otf', 'eot', 'mp3', 'wav', 'm4a', 'flac', 'ogg', 'sqlite', 'db', 'bundle', 'bin', 'icns', 'lockb', 'pyc', 'node',
16])
17
18/** Folders and files that are noise in a project tree. */
19const HIDDEN = new Set(['.git', '.DS_Store', 'node_modules', '__pycache__', '.Trash', 'Thumbs.db'])
20
21const SECRET = [/^\.env(\..+)?$/i, /\.(pem|key|p12|pfx|keystore|jks)$/i, /^id_(rsa|dsa|ecdsa|ed25519)/i, /^\.(npmrc|netrc|pgpass)$/i, /(^|[._-])(secret|secrets|credential|credentials)([._-]|$)/i, /^auth\.json$/i, /recovery[_-]?codes/i]
22
23const LABELS: Record<string, string> = {
24 md: 'Markdown', mdx: 'MDX', ts: 'TypeScript', tsx: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
25 json: 'JSON', jsonl: 'JSON lines', yml: 'YAML', yaml: 'YAML', toml: 'TOML', py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust',
26 sh: 'Shell', zsh: 'Shell', bash: 'Shell', css: 'CSS', scss: 'SCSS', html: 'HTML', htm: 'HTML', xml: 'XML', sql: 'SQL', txt: 'Text', log: 'Log',
27 csv: 'CSV', tsv: 'TSV', pdf: 'PDF', svg: 'SVG', png: 'PNG image', jpg: 'JPEG image', jpeg: 'JPEG image', gif: 'GIF image',
28 webp: 'WebP image', heic: 'HEIC image', docx: 'Word document', xlsx: 'Excel workbook', pptx: 'PowerPoint deck', mp4: 'Video',
29 mov: 'Video', zip: 'Zip archive', swift: 'Swift', kt: 'Kotlin', java: 'Java', c: 'C', h: 'C header', cpp: 'C++', lock: 'Lock file',
30}
31
32export const extOf = (name: string): string => {
33 const dot = name.lastIndexOf('.')
34
35 return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
36}
37
38export const baseOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
39
40export const dirOf = (path: string): string => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')
41
42export const kindOf = (name: string): Kind => {
43 const ext = extOf(name)
44
45 if (MARKDOWN.has(ext)) {
46 return 'markdown'
47 }
48
49 if (TABLE.has(ext)) {
50 return 'table'
51 }
52
53 if (IMAGE.has(ext)) {
54 return 'image'
55 }
56
57 if (LOOK.has(ext)) {
58 return 'look'
59 }
60
61 return BINARY.has(ext) ? 'binary' : 'code'
62}
63
64export const labelOf = (name: string): string => {
65 const ext = extOf(name)
66
67 return LABELS[ext] ?? (ext === '' ? 'File' : ext.toUpperCase())
68}
69
70export const isHidden = (name: string): boolean => HIDDEN.has(name)
71
72/** A file whose text should not show on a shared screen until the person asks. */
73export const isSecret = (name: string): boolean => SECRET.some(pattern => pattern.test(name))
74
75export const sizeText = (bytes: number): string => {
76 if (bytes < 1000) {
77 return `${bytes} B`
78 }
79
80 if (bytes < 1_000_000) {
81 return `${(bytes / 1000).toFixed(bytes < 10_000 ? 1 : 0)} KB`
82 }
83
84 return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`
85}
86
87const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const
88
89export const ageText = (mtimeMs: number, now: number): string => {
90 const minutes = Math.floor((now - mtimeMs) / 60_000)
91
92 if (minutes < 1) {
93 return 'just now'
94 }
95
96 if (minutes < 60) {
97 return `${minutes}m ago`
98 }
99
100 if (minutes < 60 * 24) {
101 return `${Math.floor(minutes / 60)}h ago`
102 }
103
104 if (minutes < 60 * 24 * 7) {
105 return `${Math.floor(minutes / (60 * 24))}d ago`
106 }
107
108 const date = new Date(mtimeMs)
109 const year = date.getFullYear() === new Date(now).getFullYear() ? '' : ` ${date.getFullYear()}`
110
111 return `${date.getDate()} ${MONTHS[date.getMonth()] ?? ''}${year}`
112}
113
114/** Comma or tab separated text to rows of cells. */
115export const parseTable = (text: string, separator: string, limit: number): string[][] => {
116 const rows: string[][] = []
117 let row: string[] = []
118 let cell = ''
119 let isQuoted = false
120
121 for (let i = 0; i < text.length && rows.length < limit; i += 1) {
122 const ch = text[i] ?? ''
123
124 if (isQuoted) {
125 if (ch === '"' && text[i + 1] === '"') {
126 cell += '"'
127 i += 1
128 } else if (ch === '"') {
129 isQuoted = false
130 } else {
131 cell += ch
132 }
133 } else if (ch === '"' && cell === '') {
134 isQuoted = true
135 } else if (ch === separator) {
136 row.push(cell)
137 cell = ''
138 } else if (ch === '\n' || ch === '\r') {
139 if (ch === '\r' && text[i + 1] === '\n') {
140 i += 1
141 }
142
143 row.push(cell)
144 rows.push(row)
145 row = []
146 cell = ''
147 } else {
148 cell += ch
149 }
150 }
151
152 if ((cell !== '' || row.length > 0) && rows.length < limit) {
153 row.push(cell)
154 rows.push(row)
155 }
156
157 return rows.filter(cells => cells.some(value => value.trim() !== ''))
158}
159
160/** True for a text file that Quick Look draws as a picture: an SVG or a web page. */
161export const isPage = (name: string): boolean => PAGE.has(extOf(name))
162
163/** True when the file has a formatted form and a source form to switch between. */
164export const hasTwoForms = (name: string): boolean => {
165 const kind = kindOf(name)
166
167 return kind === 'markdown' || kind === 'table' || isPage(name)
168}
169hooks/load.ts 236 lines1// A file's content as the rows the reader shows. Nothing here reads the disk:
2// the hooks module reads, and hands the text or the picture's size to these.
3import { ageText, baseOf, hasTwoForms, isPage, isSecret, kindOf, labelOf, parseTable, sizeText } from './files'
4import type { Kind } from './files'
5import { renderMarkdown, tableRows } from './markdown'
6import type { Doc, Picture } from './model'
7import { clean, spansWidth } from './text'
8import type { Row, Span } from './text'
9import { C } from './theme'
10
11export type Entry = { name: string; isDir: boolean; mtimeMs: number }
12
13export const MAX_TEXT_BYTES = 3_500_000
14const MAX_LINES = 20_000
15const MAX_LINE_CHARS = 2_000
16const MAX_TABLE_ROWS = 1_000
17/** A terminal cell is about this many times taller than it is wide. */
18const CELL_SHAPE = 2.1
19const CELL_PIXELS = 8
20
21export type DocRequest = {
22 root: string
23 path: string
24 isSource: boolean
25 isRevealed: boolean
26 /** Cells across the reader's text. */
27 columns: number
28 /** The widest a line of prose may run. */
29 measure: number
30 /** Rows the reader shows at once. */
31 rows: number
32 now: number
33}
34
35export type FileFacts = { size: number; mtimeMs: number }
36
37/** Folders first, names that start with a dot last, then by name. */
38export const byName = (a: Entry, b: Entry): number => {
39 if (a.isDir !== b.isDir) {
40 return a.isDir ? -1 : 1
41 }
42
43 const aDot = a.name.startsWith('.')
44 const bDot = b.name.startsWith('.')
45
46 if (aDot !== bDot) {
47 return aDot ? 1 : -1
48 }
49
50 const x = a.name.toLowerCase()
51 const y = b.name.toLowerCase()
52
53 return x < y ? -1 : x > y ? 1 : 0
54}
55
56/** Short lines set in the middle of the reader. */
57const card = (cols: number, lines: readonly Span[][]): Row[] =>
58 lines.map(spans => ({ spans: [{ text: ' '.repeat(Math.max(0, Math.floor((cols - spansWidth(spans)) / 2))) }, ...spans] }))
59
60const about = (request: DocRequest, facts: FileFacts): string[] => [sizeText(facts.size), ageText(facts.mtimeMs, request.now)]
61
62/** A short notice in place of a file's content. */
63export const notice = (request: DocRequest, facts: FileFacts | undefined, title: string, lines: readonly string[]): Doc => {
64 const name = baseOf(request.path)
65
66 return {
67 path: request.path,
68 kind: kindOf(name),
69 meta: facts === undefined ? [] : [labelOf(name), ...about(request, facts)],
70 canToggle: false,
71 isCard: true,
72 isLocked: false,
73 canCopy: false,
74 rows: card(request.columns, [[{ text: title, bold: true }], [], ...lines.map(text => [{ text, color: C.muted }])]),
75 }
76}
77
78export const goneDoc = (request: DocRequest): Doc => notice(request, undefined, baseOf(request.path), ['This file is not on disk any more.'])
79
80export const opaqueDoc = (request: DocRequest, facts: FileFacts, why: string): Doc =>
81 notice(request, facts, baseOf(request.path), [why, 'Open shows it in its own app.'])
82
83export const lockedDoc = (request: DocRequest, facts: FileFacts): Doc => ({
84 ...notice(request, facts, 'Kept off the screen', ['This file can hold secrets.', 'Its text stays hidden until you ask for it.']),
85 isLocked: true,
86})
87
88/** How the reader shows the file: what the hooks module has to read for it. */
89export const planOf = (request: DocRequest): 'locked' | 'picture' | 'opaque' | 'text' => {
90 const name = baseOf(request.path)
91 const kind = kindOf(name)
92
93 if (isSecret(name) && !request.isRevealed) {
94 return 'locked'
95 }
96
97 if (kind === 'image' || ((kind === 'look' || isPage(name)) && !request.isSource)) {
98 return 'picture'
99 }
100
101 return kind === 'binary' || (kind === 'look' && !isPage(name)) ? 'opaque' : 'text'
102}
103
104export const hash = (text: string): string => {
105 let h = 0x811c9dc5
106
107 for (let i = 0; i < text.length; i += 1) {
108 h ^= text.charCodeAt(i)
109 h = Math.imul(h, 0x01000193) >>> 0
110 }
111
112 return h.toString(16).padStart(8, '0')
113}
114
115const fitPicture = (width: number, height: number, maxColumns: number, maxRows: number): { columns: number; rows: number } => {
116 let columns = Math.min(maxColumns, Math.max(4, Math.round(width / CELL_PIXELS)))
117 let rows = Math.max(1, Math.round((columns * height) / width / CELL_SHAPE))
118
119 if (rows > maxRows) {
120 rows = Math.max(1, maxRows)
121 columns = Math.max(1, Math.min(maxColumns, Math.round((rows * CELL_SHAPE * width) / height)))
122 }
123
124 return { columns: Math.min(255, columns), rows: Math.min(255, rows) }
125}
126
127export const pictureDoc = (request: DocRequest, facts: FileFacts, made: { file: string; width: number; height: number }): Doc => {
128 const name = baseOf(request.path)
129 const kind = kindOf(name)
130 const fit = fitPicture(made.width, made.height, request.columns, Math.max(1, request.rows - 1))
131 const picture: Picture = { file: made.file, ...fit, generation: Math.round(facts.mtimeMs), alt: `${name} (picture)` }
132
133 return {
134 path: request.path,
135 kind,
136 rows: [],
137 meta: [labelOf(name), ...(kind === 'image' ? [`${made.width} × ${made.height}`] : []), ...about(request, facts)],
138 canToggle: isPage(name),
139 isCard: false,
140 isLocked: false,
141 canCopy: isPage(name),
142 picture,
143 }
144}
145
146const looksBinary = (text: string): boolean => {
147 const sample = text.slice(0, 8000)
148
149 if (sample.includes('\u0000')) {
150 return true
151 }
152
153 let bad = 0
154
155 for (const ch of sample) {
156 if (ch === '�') {
157 bad += 1
158 }
159 }
160
161 return bad > 8 && bad > sample.length / 200
162}
163
164const sourceRows = (text: string): { rows: Row[]; lines: number; isCut: boolean } => {
165 const all = clean(text).replace(/\n$/, '').split('\n')
166 const lines = all.slice(0, MAX_LINES)
167
168 return {
169 lines: all.length,
170 isCut: all.length > MAX_LINES,
171 rows: lines.map((line, n) => ({
172 spans: [],
173 code: { line: line.length > MAX_LINE_CHARS ? line.slice(0, MAX_LINE_CHARS) : line, block: 0, number: n + 1 },
174 })),
175 }
176}
177
178/** A text file as a document: markdown set as prose, a table as columns, the rest as code. */
179export const textDoc = (request: DocRequest, facts: FileFacts, text: string): Doc => {
180 const { path, columns } = request
181 const name = baseOf(path)
182 const kind: Kind = kindOf(name)
183
184 if (looksBinary(text)) {
185 return opaqueDoc(request, facts, 'This file is not text.')
186 }
187
188 const source = sourceRows(text)
189 const lines = `${source.lines} ${source.lines === 1 ? 'line' : 'lines'}`
190 const meta = (length: string): string[] => [labelOf(name), length, ...about(request, facts)]
191
192 if (kind === 'markdown' && !request.isSource) {
193 return {
194 path,
195 kind,
196 rows: renderMarkdown(text, Math.min(columns, request.measure)),
197 meta: meta(lines),
198 canToggle: true,
199 isCard: false,
200 isLocked: false,
201 canCopy: true,
202 }
203 }
204
205 if (kind === 'table' && !request.isSource) {
206 const cells = parseTable(text, name.toLowerCase().endsWith('.tsv') ? '\t' : ',', MAX_TABLE_ROWS + 1)
207 const toSpans = (value: string, isHead: boolean): Span[] => [
208 { text: clean(value).replace(/\n/g, ' ').trim(), ...(isHead ? { bold: true } : {}) },
209 ]
210 const head = (cells[0] ?? []).map(value => toSpans(value, true))
211 const body = cells.slice(1).map(row => row.map(value => toSpans(value, false)))
212
213 return {
214 path,
215 kind,
216 rows: cells.length === 0 ? card(columns, [[{ text: 'This table is empty.', color: C.muted }]]) : tableRows(head, [], body, columns),
217 meta: meta(`${Math.max(0, cells.length - 1)} rows`),
218 canToggle: true,
219 isCard: cells.length === 0,
220 isLocked: false,
221 canCopy: true,
222 }
223 }
224
225 return {
226 path,
227 kind: kind === 'markdown' || kind === 'table' || kind === 'look' ? kind : 'code',
228 rows: source.rows,
229 meta: meta(source.isCut ? `first ${MAX_LINES} of ${lines}` : lines),
230 canToggle: hasTwoForms(name),
231 isCard: false,
232 isLocked: false,
233 canCopy: true,
234 }
235}
236hooks/model.ts 49 lines1import type { Kind } from './files'
2import type { Row } from './text'
3
4/** One row of the file tree. */
5export type Node = {
6 rel: string
7 name: string
8 depth: number
9 isDir: boolean
10 isOpen: boolean
11 /** A stand-in row under an open folder that holds nothing. */
12 isEmpty: boolean
13}
14
15/** A picture the terminal reads from disk itself. */
16export type Picture = { file: string; columns: number; rows: number; generation: number; alt: string }
17
18/** A file as the reader shows it. */
19export type Doc = {
20 path: string
21 kind: Kind
22 rows: Row[]
23 /** The words of the footer: kind, length, size, age. */
24 meta: string[]
25 /** True when the file has a formatted form and a source form. */
26 canToggle: boolean
27 /** True for a short notice, drawn in the middle of the reader. */
28 isCard: boolean
29 /** True while the file's text is held back. */
30 isLocked: boolean
31 /** True when the file is text that Copy can put on the clipboard. */
32 canCopy: boolean
33 picture?: Picture
34}
35
36/** The pane's measures for one drawing, in cells. */
37export type Frame = {
38 columns: number
39 rows: number
40 isSplit: boolean
41 /** True when the tree is at the pane's right edge, the document beside the chat. */
42 isTreeRight: boolean
43 treeWidth: number
44 /** Cells across the document's text. */
45 textWidth: number
46 treeRows: number
47 docRows: number
48}
49hooks/view.tsx 475 lines1// Draws the pane: the file tree, the reader, and the thin marks between them.
2// Every element here is one terminal row tall unless it says its height.
3import type { Elements, RenderElement, UiPressArgument } from 'claude-code'
4
5import type { FolioMode } from '../types'
6import { baseOf } from './files'
7import type { Doc, Frame, Node } from './model'
8import { fitSpans, pad, spansWidth, truncate, truncateMiddle, width } from './text'
9import type { Row, Span } from './text'
10import { C } from './theme'
11
12type Terminal = Elements['terminal']
13
14export type El = Pick<Terminal, 'Box' | 'Text' | 'Button' | 'Code' | 'Link'> & { Image?: Terminal['Image'] }
15
16export type Actions = {
17 toggle: (path: string) => void
18 open: (path: string) => void
19 back: () => void
20 mode: (mode: FolioMode) => void
21 reveal: () => void
22 copy: (press: UiPressArgument) => void
23 mention: () => void
24 outside: () => void
25 follow: (href: string) => void
26 swap: () => void
27}
28
29export type PaneProps = {
30 frame: Frame
31 title: string
32 nodes: readonly Node[]
33 treeTop: number
34 selected: string
35 touched: ReadonlySet<string>
36 touchedFolders: ReadonlySet<string>
37 doc: Doc | undefined
38 docTop: number
39 mode: FolioMode
40 on: Actions
41}
42
43const SOFT = { inverse: false, backgroundColor: C.fill } as const
44const BRIGHT = { inverse: false, dimColor: false } as const
45
46const piece = (el: El, span: Span): RenderElement => {
47 const { Text, Link } = el
48 const style = {
49 ...(span.bold === true ? { bold: true } : {}),
50 ...(span.italic === true ? { italic: true } : {}),
51 ...(span.underline === true ? { underline: true } : {}),
52 ...(span.strike === true ? { strikethrough: true } : {}),
53 ...(span.dim === true ? { dimColor: true } : {}),
54 color: span.color ?? C.text,
55 ...(span.bg === undefined ? {} : { backgroundColor: span.bg }),
56 }
57
58 // A bare address is left as text: the terminal finds it, and it never shows twice.
59 if (span.href !== undefined && /^https?:\/\//.test(span.href) && span.href.length < 2000 && span.text !== span.href) {
60 return (
61 <Link href={span.href}>
62 <Text {...style}>{span.text}</Text>
63 </Link>
64 )
65 }
66
67 return <Text {...style}>{span.text}</Text>
68}
69
70/** One row of styled text, cut to `cells`. */
71const line = (el: El, spans: readonly Span[], cells: number): RenderElement => {
72 const { Text } = el
73 const fitted = fitSpans(spans.filter(span => span.text !== ''), cells)
74
75 if (fitted.length === 0) {
76 return <Text> </Text>
77 }
78
79 return <Text wrap="truncate-end">{fitted.map(span => piece(el, span))}</Text>
80}
81
82const empty = (el: El): RenderElement => <el.Text> </el.Text>
83
84// --- the tree -----------------------------------------------------------------
85
86const treeRow = (el: El, props: PaneProps, node: Node, cells: number): RenderElement => {
87 const { Box, Text, Button } = el
88 const indent = ' '.repeat(Math.min(node.depth, 12))
89
90 if (node.isEmpty) {
91 return (
92 <Text color={C.faint} wrap="truncate-end">
93 {` ${indent}empty`}
94 </Text>
95 )
96 }
97
98 const isOpenFile = !node.isDir && node.rel === props.selected
99 const room = Math.max(4, cells - 4 - indent.length)
100 const name = pad(truncateMiddle(node.name, room), room)
101 const mark = node.isDir ? (node.isOpen ? '▾ ' : '▸ ') : ' '
102 const dot = props.touched.has(node.rel) ? '●' : node.isDir && !node.isOpen && props.touchedFolders.has(node.rel) ? '•' : ' '
103 const isQuiet = node.name.startsWith('.')
104
105 return (
106 <Box key={`row:${node.rel}`} width={cells} {...(isOpenFile ? { backgroundColor: C.fill } : {})}>
107 <Button
108 key={`node:${node.rel}`}
109 plain
110 hover={SOFT}
111 onPress={() => (node.isDir ? props.on.toggle(node.rel) : props.on.open(node.rel))}
112 >
113 {isOpenFile ? <Text color={C.accent}>▎</Text> : ' '}
114 {indent}
115 {node.isDir ? <Text color={C.muted}>{mark}</Text> : mark}
116 <Text {...(isOpenFile ? { bold: true } : {})} color={isQuiet && !isOpenFile ? C.muted : C.text}>
117 {name}
118 </Text>
119 {dot === ' ' ? ' ' : <Text color={C.touched}>{dot}</Text>}
120 </Button>
121 </Box>
122 )
123}
124
125const tree = (el: El, props: PaneProps): RenderElement => {
126 const { Box, Text, Button } = el
127 const { frame } = props
128 const cells = frame.treeWidth
129 const shown = props.nodes.slice(props.treeTop, props.treeTop + frame.treeRows)
130
131 return (
132 <Box flexDirection="column" width={cells} height={frame.rows} overflow="hidden">
133 <Box key="tree-head" flexDirection="row" width={cells} height={1} overflow="hidden">
134 <Box width={Math.max(1, cells - 3)}>
135 <Text bold color={C.text} wrap="truncate-end">
136 {` ${truncate(props.title, cells - 5)}`}
137 </Text>
138 </Box>
139 {props.frame.isSplit ? (
140 <Button key="swap" plain hover={{ inverse: false, color: C.accent }} onPress={() => props.on.swap()}>
141 <Text color={C.faint}>⇄</Text>
142 </Button>
143 ) : null}
144 </Box>
145 {empty(el)}
146 {shown.map(node => treeRow(el, props, node, cells))}
147 {shown.length === 0 ? <Text color={C.faint}>{' This folder is empty.'}</Text> : null}
148 </Box>
149 )
150}
151
152/**
153 * A one-cell column the height of the pane. Beside the `view` rows that scroll
154 * (after `lead` rows of header) it shows a thumb where the window stands;
155 * `rest` fills every other row: a divider's line, or nothing.
156 */
157const rail = (el: El, rows: number, lead: number, view: number, total: number, top: number, rest: string): RenderElement => {
158 const { Box, Text } = el
159 const marks: RenderElement[] = []
160 const thumb = total > view ? Math.max(1, Math.round((view * view) / total)) : 0
161 const at = total > view ? lead + Math.round((top / (total - view)) * (view - thumb)) : 0
162
163 for (let r = 0; r < rows; r += 1) {
164 const isThumb = thumb > 0 && r >= at && r < at + thumb
165
166 marks.push(<Text color={C.faint}>{isThumb ? '┃' : rest}</Text>)
167 }
168
169 return (
170 <Box flexDirection="column" width={1} height={rows} overflow="hidden">
171 {marks}
172 </Box>
173 )
174}
175
176// --- the reader ---------------------------------------------------------------
177
178const crumbs = (path: string, cells: number): Span[] => {
179 const parts = path.split('/')
180 const name = parts.pop() ?? ''
181 const tail: Span = { text: truncateMiddle(name, Math.max(4, cells)), bold: true }
182 let left = cells - width(tail.text)
183 const head: string[] = []
184
185 for (let k = parts.length - 1; k >= 0; k -= 1) {
186 const part = `${parts[k] ?? ''} › `
187 const more = k > 0 ? 4 : 0
188
189 if (width(part) + more > left) {
190 if (left >= 4) {
191 head.unshift('… › ')
192 }
193
194 break
195 }
196
197 head.unshift(part)
198 left -= width(part)
199 }
200
201 return [{ text: head.join(''), color: C.muted }, tail]
202}
203
204const header = (el: El, props: PaneProps, cells: number): RenderElement => {
205 const { Box, Text, Button } = el
206 const { doc, frame } = props
207 const hasToggle = doc !== undefined && doc.canToggle
208 const toggleCells = hasToggle ? 'Read Source'.length + 2 : 0
209 const backCells = frame.isSplit ? 0 : 3
210 const room = Math.max(4, cells - toggleCells - backCells)
211 const label = (text: string, isOn: boolean) => (isOn ? <Text bold color={C.accent}>{text}</Text> : <Text color={C.muted}>{text}</Text>)
212
213 return (
214 <Box key="head" flexDirection="row" width={cells} height={1} overflow="hidden">
215 {frame.isSplit ? null : (
216 <Button key="back" plain hover={BRIGHT} onPress={() => props.on.back()}>
217 <Text color={C.muted}>{'‹ '}</Text>
218 </Button>
219 )}
220 <Box width={room}>{line(el, crumbs(props.selected, room), room)}</Box>
221 {hasToggle ? <Text>{' '}</Text> : null}
222 {hasToggle ? (
223 <Button key="mode:read" plain hover={{ inverse: false, underline: true }} onPress={() => props.on.mode('read')}>
224 {label('Read', props.mode === 'read')}
225 </Button>
226 ) : null}
227 {hasToggle ? <Text>{' '}</Text> : null}
228 {hasToggle ? (
229 <Button key="mode:source" plain hover={{ inverse: false, underline: true }} onPress={() => props.on.mode('source')}>
230 {label('Source', props.mode === 'source')}
231 </Button>
232 ) : null}
233 </Box>
234 )
235}
236
237const footer = (el: El, props: PaneProps, cells: number): RenderElement => {
238 const { Box, Text, Button } = el
239 const { doc } = props
240 const names = doc === undefined ? [] : [...(doc.isLocked ? ['Reveal'] : []), ...(doc.canCopy ? ['Copy'] : []), 'Mention', 'Open']
241 const room = Math.max(0, cells - names.join(' ').length - 2)
242 const meta = doc === undefined ? '' : truncate(doc.meta.join(' · '), room)
243 const action = (key: string, text: string, onPress: (press: UiPressArgument) => void) => (
244 <Button key={key} plain hover={{ inverse: false, color: C.accent }} onPress={onPress}>
245 <Text color={C.muted}>{text}</Text>
246 </Button>
247 )
248
249 if (doc === undefined) {
250 return empty(el)
251 }
252
253 return (
254 <Box key="foot" flexDirection="row" width={cells} height={1} overflow="hidden">
255 <Box width={room + 2}>
256 <Text color={C.faint} wrap="truncate-end">
257 {meta === '' ? ' ' : meta}
258 </Text>
259 </Box>
260 {doc.isLocked ? action('act:reveal', 'Reveal', props.on.reveal) : null}
261 {doc.isLocked ? <Text>{' '}</Text> : null}
262 {doc.canCopy ? action('act:copy', 'Copy', props.on.copy) : null}
263 {doc.canCopy ? <Text>{' '}</Text> : null}
264 {action('act:mention', 'Mention', props.on.mention)}
265 <Text>{' '}</Text>
266 {action('act:open', 'Open', props.on.outside)}
267 </Box>
268 )
269}
270
271/** Rows of code that touch are one highlighted block. */
272const codeBlock = (el: El, doc: Doc, run: readonly Row[], cells: number): RenderElement => {
273 const { Box, Code } = el
274 const first = run[0]?.code
275 const gutter = spansWidth(run[0]?.spans ?? [])
276 const source = run.map(row => (row.code === undefined || row.code.line === '' ? ' ' : row.code.line)).join('\n')
277 const language = first?.language
278 const code = (
279 <Code
280 source={source}
281 wrap="truncate-end"
282 {...(language === undefined ? (first?.block === 0 ? { path: baseOf(doc.path) } : {}) : { language })}
283 {...(first?.number === undefined ? {} : { startLine: first.number })}
284 />
285 )
286
287 return (
288 <Box flexDirection="row" width={cells} height={run.length} overflow="hidden">
289 {gutter === 0 ? null : (
290 <Box flexDirection="column" width={gutter}>
291 {run.map(row => line(el, row.spans, gutter))}
292 </Box>
293 )}
294 <Box flexDirection="column" width={Math.max(1, cells - gutter)}>
295 {code}
296 </Box>
297 </Box>
298 )
299}
300
301const textRow = (el: El, props: PaneProps, row: Row, index: number, cells: number): RenderElement => {
302 const { Box, Text, Button } = el
303
304 if (row.link === undefined) {
305 return line(el, row.spans, cells)
306 }
307
308 const href = row.link
309 const fitted = fitSpans(row.spans.filter(span => span.text !== ''), cells)
310
311 return (
312 <Box key={`link:${index}`} width={cells} height={1} overflow="hidden">
313 <Button key={`go:${index}`} plain hover={SOFT} onPress={() => props.on.follow(href)}>
314 {fitted.map(span => (
315 <Text
316 {...(span.bold === true ? { bold: true } : {})}
317 {...(span.italic === true ? { italic: true } : {})}
318 {...(span.underline === true ? { underline: true } : {})}
319 {...(span.dim === true ? { dimColor: true } : {})}
320 color={span.color ?? C.text}
321 >
322 {span.text}
323 </Text>
324 ))}
325 </Button>
326 </Box>
327 )
328}
329
330const body = (el: El, props: PaneProps, cells: number, rows: number): RenderElement => {
331 const { Box, Text, Image } = el
332 const { doc } = props
333 const out: RenderElement[] = []
334
335 if (doc === undefined) {
336 const lead = Math.max(0, Math.floor(rows / 3))
337 const say = (text: string, isStrong: boolean) => {
338 const free = ' '.repeat(Math.max(0, Math.floor((cells - width(text)) / 2)))
339
340 return isStrong ? <Text bold color={C.text}>{free + text}</Text> : <Text color={C.muted}>{free + text}</Text>
341 }
342
343 for (let r = 0; r < lead; r += 1) {
344 out.push(empty(el))
345 }
346
347 out.push(say(props.title, true), empty(el), say('Select a file to read it here.', false))
348 } else if (doc.picture !== undefined) {
349 const { picture } = doc
350 const lead = Math.max(0, Math.floor((rows - picture.rows) / 2))
351
352 for (let r = 0; r < lead; r += 1) {
353 out.push(empty(el))
354 }
355
356 out.push(
357 <Box width={cells} height={picture.rows} justifyContent="center">
358 {Image === undefined ? (
359 <Text color={C.muted}>{picture.alt}</Text>
360 ) : (
361 <Image
362 key="picture"
363 source={{ file: picture.file, format: 'png', generation: picture.generation }}
364 columns={picture.columns}
365 rows={picture.rows}
366 alt={picture.alt}
367 />
368 )}
369 </Box>,
370 )
371 } else {
372 const shown = doc.rows.slice(props.docTop, props.docTop + rows)
373 const lead = doc.isCard ? Math.max(0, Math.floor((rows - shown.length) / 3)) : 0
374
375 for (let r = 0; r < lead; r += 1) {
376 out.push(empty(el))
377 }
378
379 for (let r = 0; r < shown.length; r += 1) {
380 const row = shown[r]
381
382 if (row === undefined) {
383 continue
384 }
385
386 if (row.code === undefined) {
387 out.push(textRow(el, props, row, props.docTop + r, cells))
388 continue
389 }
390
391 let end = r + 1
392
393 while (end < shown.length && shown[end]?.code?.block === row.code.block) {
394 end += 1
395 }
396
397 out.push(codeBlock(el, doc, shown.slice(r, end), cells))
398 r = end - 1
399 }
400 }
401
402 return (
403 <Box flexDirection="column" width={cells} height={rows} overflow="hidden">
404 {out}
405 </Box>
406 )
407}
408
409const reader = (el: El, props: PaneProps): RenderElement => {
410 const { Box, Text } = el
411 const { frame, doc } = props
412 const cells = frame.textWidth
413 const isBare = doc === undefined && frame.isSplit
414
415 return (
416 <Box flexDirection="column" width={cells} height={frame.rows} overflow="hidden">
417 {isBare ? empty(el) : header(el, props, cells)}
418 <Text color={C.faint}>{isBare ? ' ' : '─'.repeat(cells)}</Text>
419 {empty(el)}
420 {body(el, props, cells, frame.docRows)}
421 {empty(el)}
422 {footer(el, props, cells)}
423 </Box>
424 )
425}
426
427/** The whole pane: tree and reader side by side when there is room, else one of them. */
428export const drawPane = (el: El, props: PaneProps): RenderElement => {
429 const { Box, Text } = el
430 const { frame, doc } = props
431 const docTotal = doc === undefined || doc.picture !== undefined ? 0 : doc.rows.length
432 const docRail = (rest: string) => rail(el, frame.rows, 3, frame.docRows, docTotal, props.docTop, rest)
433 const treeRail = (rest: string) => rail(el, frame.rows, 2, frame.treeRows, props.nodes.length, props.treeTop, rest)
434
435 if (!frame.isSplit) {
436 if (props.selected === '') {
437 return tree(el, props)
438 }
439
440 return (
441 <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
442 <Text> </Text>
443 {reader(el, props)}
444 <Text> </Text>
445 {docRail(' ')}
446 </Box>
447 )
448 }
449
450 // The line between the two parts carries the thumb of the part on its left.
451 if (frame.isTreeRight) {
452 return (
453 <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
454 <Text> </Text>
455 {reader(el, props)}
456 <Text>{' '}</Text>
457 {docRail('│')}
458 {tree(el, props)}
459 {treeRail(' ')}
460 </Box>
461 )
462 }
463
464 return (
465 <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
466 {tree(el, props)}
467 {treeRail('│')}
468 <Text>{' '}</Text>
469 {reader(el, props)}
470 <Text> </Text>
471 {docRail(' ')}
472 </Box>
473 )
474}
475hooks/markdown.ts 979 lines1// Markdown to terminal rows. Every row is one line of the pane, so the pane
2// can show any window of a document and knows its height.
3import { clean, spansWidth, width, wrapSpans } from './text'
4import type { Row, Span, Style } from './text'
5import { C } from './theme'
6
7type Align = 'left' | 'right' | 'center'
8
9type Ctx = {
10 /** How deep in lists the block is; picks the bullet. */
11 depth: number
12 /** Inside a tight list item: no blank row between its blocks. */
13 isTight: boolean
14 /** Counts fenced blocks, so two that touch stay apart. */
15 blocks: { n: number }
16}
17
18const BULLETS = ['•', '◦', '▪'] as const
19const RULE = '─'
20const ESCAPABLE = /[\\`*_{}[\]()#+\-.!|~<>&"']/
21const ENTITIES: Record<string, string> = {
22 amp: '&',
23 lt: '<',
24 gt: '>',
25 quot: '"',
26 apos: "'",
27 nbsp: ' ',
28 mdash: '—',
29 ndash: '–',
30 hellip: '…',
31 rarr: '→',
32 larr: '←',
33 copy: '©',
34 times: '×',
35 middot: '·',
36}
37
38/** Inline code: a quiet tint, so a page full of paths stays calm. */
39const CODE: Style = { color: C.code }
40
41const blank = (): Row => ({ spans: [] })
42
43const isBlank = (row: Row | undefined): boolean =>
44 row !== undefined && row.code === undefined && row.spans.every(span => span.text.trim() === '')
45
46const indentOf = (line: string): number => line.length - line.trimStart().length
47
48/** A link the pane can follow itself: a path, not a web address or an anchor. */
49export const isLocalLink = (href: string): boolean => href !== '' && !/^([a-z][a-z0-9+.-]*:|#|\/\/)/i.test(href)
50
51// --- inline -----------------------------------------------------------------
52
53const skipCode = (src: string, at: number): number => {
54 let run = 1
55
56 while (src[at + run] === '`') {
57 run += 1
58 }
59
60 let k = at + run
61
62 while (k < src.length) {
63 if (src[k] !== '`') {
64 k += 1
65 continue
66 }
67
68 let close = 1
69
70 while (src[k + close] === '`') {
71 close += 1
72 }
73
74 if (close === run) {
75 return k + close
76 }
77
78 k += close
79 }
80
81 return -1
82}
83
84/** Where the closing run of `len` marks starts, or -1. */
85const findClose = (src: string, from: number, mark: string, len: number): number => {
86 let k = from
87
88 while (k < src.length) {
89 const ch = src[k]
90
91 if (ch === '\\') {
92 k += 2
93 continue
94 }
95
96 if (ch === '`') {
97 const end = skipCode(src, k)
98
99 if (end === -1) {
100 k += 1
101 } else {
102 k = end
103 }
104
105 continue
106 }
107
108 if (ch !== mark) {
109 k += 1
110 continue
111 }
112
113 let run = 1
114
115 while (src[k + run] === mark) {
116 run += 1
117 }
118
119 const before = src[k - 1] ?? ' '
120 const after = src[k + run] ?? ' '
121 const fits = len === 1 ? run % 2 === 1 : run >= len
122 const closes = k > from && !/\s/.test(before) && (mark !== '_' || !/[A-Za-z0-9]/.test(after))
123
124 if (fits && closes) {
125 return k + run - len
126 }
127
128 k += run
129 }
130
131 return -1
132}
133
134const readLink = (src: string, start: number): { text: string; href: string; end: number } | undefined => {
135 let depth = 0
136 let k = start
137
138 for (; k < src.length; k += 1) {
139 const ch = src[k]
140
141 if (ch === '\\') {
142 k += 1
143 } else if (ch === '`') {
144 const end = skipCode(src, k)
145
146 if (end !== -1) {
147 k = end - 1
148 }
149 } else if (ch === '[') {
150 depth += 1
151 } else if (ch === ']') {
152 depth -= 1
153
154 if (depth === 0) {
155 break
156 }
157 }
158 }
159
160 if (k >= src.length || src[k + 1] !== '(') {
161 return undefined
162 }
163
164 let p = k + 2
165 let parens = 1
166
167 for (; p < src.length; p += 1) {
168 const ch = src[p]
169
170 if (ch === '\\') {
171 p += 1
172 } else if (ch === '(') {
173 parens += 1
174 } else if (ch === ')') {
175 parens -= 1
176
177 if (parens === 0) {
178 break
179 }
180 }
181 }
182
183 if (p >= src.length) {
184 return undefined
185 }
186
187 let href = src.slice(k + 2, p).trim()
188 const titled = /^(\S+)\s+["'(].*["')]$/.exec(href)
189
190 if (titled?.[1] !== undefined) {
191 href = titled[1]
192 }
193
194 return { text: src.slice(start + 1, k), href: href.replace(/^<|>$/g, ''), end: p + 1 }
195}
196
197const entity = (name: string): string | undefined => {
198 if (name.startsWith('#x') || name.startsWith('#X')) {
199 const cp = Number.parseInt(name.slice(2), 16)
200
201 return Number.isFinite(cp) && cp > 31 && cp < 0x110000 ? String.fromCodePoint(cp) : undefined
202 }
203
204 if (name.startsWith('#')) {
205 const cp = Number.parseInt(name.slice(1), 10)
206
207 return Number.isFinite(cp) && cp > 31 && cp < 0x110000 ? String.fromCodePoint(cp) : undefined
208 }
209
210 return ENTITIES[name]
211}
212
213/** Inline markdown to styled spans. */
214export const inline = (src: string, base: Style = {}): Span[] => {
215 const out: Span[] = []
216 let buf = ''
217 let i = 0
218
219 const flush = () => {
220 if (buf !== '') {
221 out.push({ ...base, text: buf })
222 buf = ''
223 }
224 }
225
226 while (i < src.length) {
227 const ch = src[i] ?? ''
228 const next = src[i + 1] ?? ''
229
230 if (ch === '\\') {
231 if (next !== '' && ESCAPABLE.test(next)) {
232 buf += next
233 i += 2
234 } else {
235 buf += ch
236 i += 1
237 }
238
239 continue
240 }
241
242 if (ch === '`') {
243 const end = skipCode(src, i)
244
245 if (end !== -1) {
246 let run = 1
247
248 while (src[i + run] === '`') {
249 run += 1
250 }
251
252 let code = src.slice(i + run, end - run).replace(/\n/g, ' ')
253
254 if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') {
255 code = code.slice(1, -1)
256 }
257
258 flush()
259 out.push({ ...base, text: code, ...CODE })
260 i = end
261 continue
262 }
263 }
264
265 if (ch === '!' && next === '[') {
266 const image = readLink(src, i + 1)
267
268 if (image !== undefined) {
269 flush()
270 out.push({ ...base, text: `▣ ${image.text === '' ? 'image' : image.text}`, color: C.muted })
271 i = image.end
272 continue
273 }
274 }
275
276 if (ch === '[') {
277 const link = readLink(src, i)
278
279 if (link !== undefined) {
280 flush()
281 out.push(...inline(link.text, { ...base, underline: true, href: link.href }))
282 i = link.end
283 continue
284 }
285 }
286
287 if (ch === '<') {
288 const rest = src.slice(i, i + 400)
289 const auto = /^<((?:https?:\/\/|mailto:)[^\s<>]+)>/.exec(rest)
290
291 if (auto?.[1] !== undefined) {
292 flush()
293 out.push({ ...base, text: auto[1].replace(/^mailto:/, ''), underline: true, href: auto[1] })
294 i += auto[0].length
295 continue
296 }
297
298 const br = /^<br\s*\/?>/i.exec(rest)
299
300 if (br !== null) {
301 buf += '\n'
302 i += br[0].length
303 continue
304 }
305
306 const tag = /^<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s[^<>]*)?\/?>/.exec(rest)
307
308 if (tag !== null) {
309 i += tag[0].length
310 continue
311 }
312 }
313
314 if (ch === '&') {
315 const found = /^&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});/.exec(src.slice(i, i + 12))
316 const value = found?.[1] === undefined ? undefined : entity(found[1])
317
318 if (found !== null && value !== undefined) {
319 buf += value
320 i += found[0].length
321 continue
322 }
323 }
324
325 if (ch === '*' || ch === '_') {
326 let run = 1
327
328 while (src[i + run] === ch) {
329 run += 1
330 }
331
332 const before = src[i - 1] ?? ' '
333 const after = src[i + run] ?? ' '
334 const opens = !/\s/.test(after) && (ch === '*' || !/[A-Za-z0-9]/.test(before))
335 let isDone = false
336
337 if (opens) {
338 for (let len = Math.min(run, 3); len >= 1 && !isDone; len -= 1) {
339 const close = findClose(src, i + run, ch, len)
340
341 if (close === -1) {
342 continue
343 }
344
345 buf += ch.repeat(run - len)
346 flush()
347 out.push(
348 ...inline(src.slice(i + run, close), {
349 ...base,
350 ...(len >= 2 ? { bold: true } : {}),
351 ...(len !== 2 ? { italic: true } : {}),
352 }),
353 )
354 i = close + len
355 isDone = true
356 }
357 }
358
359 if (!isDone) {
360 buf += ch.repeat(run)
361 i += run
362 }
363
364 continue
365 }
366
367 if (ch === '~' && next === '~' && !/\s/.test(src[i + 2] ?? ' ')) {
368 const close = src.indexOf('~~', i + 2)
369
370 if (close !== -1) {
371 flush()
372 out.push(...inline(src.slice(i + 2, close), { ...base, strike: true }))
373 i = close + 2
374 continue
375 }
376 }
377
378 if (ch === 'h' && (src.startsWith('https://', i) || src.startsWith('http://', i)) && /[\s(>]|^$/.test(src[i - 1] ?? '')) {
379 const found = /^https?:\/\/[^\s<>]+/.exec(src.slice(i, i + 2100))
380
381 if (found !== null) {
382 const url = found[0].replace(/[.,;:!?)\]'"*_]+$/, '')
383
384 flush()
385 out.push({ ...base, text: url, underline: true, href: url })
386 i += url.length
387 continue
388 }
389 }
390
391 buf += ch
392 i += 1
393 }
394
395 flush()
396
397 return out
398}
399
400// --- blocks -----------------------------------------------------------------
401
402const HEADING = /^ {0,3}(#{1,6})(?:[ ]+(.*?))?(?:[ ]+#+)?[ ]*$/
403const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/
404const HR = /^ {0,3}([-*_])(?:[ ]*\1){2,}[ ]*$/
405const QUOTE = /^ {0,3}>/
406const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(?:(\s+)(.*))?$/
407const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
408const COMMENT = /^\s*<!--/
409
410const isTableRule = (line: string | undefined): boolean =>
411 line !== undefined && line.includes('|') && line.includes('-') && TABLE_RULE.test(line)
412
413const startsBlock = (line: string, after: string | undefined): boolean => {
414 if (HEADING.test(line) || FENCE.test(line) || HR.test(line) || QUOTE.test(line) || COMMENT.test(line)) {
415 return true
416 }
417
418 const item = ITEM.exec(line)
419
420 if (item !== null && item[4] !== undefined && item[4] !== '' && (item[2] === '1.' || item[2] === '1)' || !/\d/.test(item[2] ?? ''))) {
421 return true
422 }
423
424 return line.includes('|') && isTableRule(after)
425}
426
427const cellsOf = (line: string): string[] => {
428 const cells: string[] = []
429 let cell = ''
430 let isCode = false
431 const text = line.trim()
432
433 for (let k = 0; k < text.length; k += 1) {
434 const ch = text[k] ?? ''
435
436 if (ch === '\\' && text[k + 1] === '|') {
437 cell += '|'
438 k += 1
439 } else if (ch === '`') {
440 isCode = !isCode
441 cell += ch
442 } else if (ch === '|' && !isCode) {
443 cells.push(cell.trim())
444 cell = ''
445 } else {
446 cell += ch
447 }
448 }
449
450 cells.push(cell.trim())
451
452 if (text.startsWith('|')) {
453 cells.shift()
454 }
455
456 if (text.endsWith('|') && !text.endsWith('\\|')) {
457 cells.pop()
458 }
459
460 return cells
461}
462
463const sum = (list: readonly number[]): number => list.reduce((total, n) => total + n, 0)
464
465/**
466 * Column widths that fit `room`. Each column first gets room for its longest
467 * word, then the columns with more text share what is left.
468 */
469export const fitColumns = (natural: readonly number[], room: number, words: readonly number[] = []): number[] => {
470 if (sum(natural) <= room) {
471 return [...natural]
472 }
473
474 const count = Math.max(1, natural.length)
475 const floor = Math.max(3, Math.min(10, Math.floor(room / count)))
476 const base = natural.map((w, c) => Math.min(w, Math.max(floor, Math.min(words[c] ?? floor, Math.floor(room / 2)))))
477
478 if (sum(base) <= room) {
479 const widths = [...base]
480 let left = room - sum(widths)
481
482 // The column that is shortest of its full width takes the next cell.
483 while (left > 0) {
484 let pick = -1
485 let need = 0
486
487 for (let c = 0; c < widths.length; c += 1) {
488 const short = ((natural[c] ?? 0) - (widths[c] ?? 0)) / Math.max(1, widths[c] ?? 1)
489
490 if (short > need) {
491 need = short
492 pick = c
493 }
494 }
495
496 if (pick === -1) {
497 break
498 }
499
500 widths[pick] = (widths[pick] ?? 0) + 1
501 left -= 1
502 }
503
504 return widths
505 }
506
507 let low = floor
508 let high = Math.max(...natural)
509
510 while (low < high) {
511 const mid = Math.ceil((low + high) / 2)
512
513 if (sum(natural.map(w => Math.min(w, mid))) <= room) {
514 low = mid
515 } else {
516 high = mid - 1
517 }
518 }
519
520 return natural.map(w => Math.max(Math.min(w, low), Math.min(w, floor)))
521}
522
523const longestWord = (spans: readonly Span[]): number =>
524 Math.max(0, ...spans.map(span => span.text).join('').split(/\s+/).map(word => width(word)))
525
526/** A table with a rule under its head and air between its columns, no box. */
527export const tableRows = (head: readonly Span[][], aligns: readonly Align[], body: readonly Span[][][], cols: number): Row[] => {
528 const count = Math.max(head.length, ...body.map(row => row.length), 1)
529 const gap = count > 4 ? 2 : 3
530 const natural = Array.from({ length: count }, (_, c) =>
531 Math.max(1, spansWidth(head[c] ?? []), ...body.map(row => spansWidth(row[c] ?? []))),
532 )
533 const words = Array.from({ length: count }, (_, c) =>
534 Math.min(26, Math.max(longestWord(head[c] ?? []), ...body.map(row => longestWord(row[c] ?? [])))),
535 )
536 const widths = fitColumns(natural, Math.max(count * 3, cols - gap * (count - 1)), words)
537 const out: Row[] = []
538
539 const line = (cells: readonly Span[][]): Row[] => {
540 const wrapped = Array.from({ length: count }, (_, c) => wrapSpans(cells[c] ?? [], widths[c] ?? 1))
541 const height = Math.max(1, ...wrapped.map(lines => lines.length))
542 const rows: Row[] = []
543
544 for (let l = 0; l < height; l += 1) {
545 const spans: Span[] = []
546
547 for (let c = 0; c < count; c += 1) {
548 const part = wrapped[c]?.[l] ?? []
549 const room = widths[c] ?? 1
550 const free = Math.max(0, room - spansWidth(part))
551 const align = aligns[c] ?? 'left'
552 const left = align === 'right' ? free : align === 'center' ? Math.floor(free / 2) : 0
553 const right = free - left + (c < count - 1 ? gap : 0)
554
555 if (left > 0) {
556 spans.push({ text: ' '.repeat(left) })
557 }
558
559 spans.push(...part)
560
561 if (right > 0 && c < count - 1) {
562 spans.push({ text: ' '.repeat(right) })
563 }
564 }
565
566 rows.push({ spans })
567 }
568
569 return rows
570 }
571
572 out.push(...line(head))
573 out.push({
574 spans: widths.flatMap((w, c) => [
575 { text: RULE.repeat(w), color: C.faint },
576 ...(c < count - 1 ? [{ text: ' '.repeat(gap) }] : []),
577 ]),
578 })
579
580 const lines = body.map(line)
581 const isAiry = lines.some(rows => rows.length > 1)
582
583 lines.forEach((rows, r) => {
584 if (isAiry && r > 0) {
585 out.push(blank())
586 }
587
588 out.push(...rows)
589 })
590
591 return out
592}
593
594const headingRows = (level: number, text: string, cols: number): Row[] => {
595 const style: Style =
596 level <= 3 ? { bold: true } : level === 4 ? { bold: true, color: C.muted } : { color: C.muted, italic: true }
597 const rows = wrapSpans(inline(text, style), cols).map(spans => ({ spans }))
598
599 if (level === 1) {
600 rows.push({ spans: [{ text: '━━━━', color: C.accent }] })
601 } else if (level === 2) {
602 rows.push({ spans: [{ text: RULE.repeat(cols), color: C.faint }] })
603 }
604
605 return rows
606}
607
608const blockRows = (lines: readonly string[], cols: number, ctx: Ctx): Row[] => {
609 const out: Row[] = []
610 let i = 0
611
612 const gap = (rows = 1, isForced = false) => {
613 if (out.length === 0 || (ctx.isTight && !isForced)) {
614 return
615 }
616
617 let have = 0
618
619 for (let k = out.length - 1; k >= 0 && isBlank(out[k]); k -= 1) {
620 have += 1
621 }
622
623 for (; have < rows; have += 1) {
624 out.push(blank())
625 }
626 }
627
628 while (i < lines.length) {
629 const line = lines[i] ?? ''
630
631 if (line.trim() === '') {
632 i += 1
633 continue
634 }
635
636 if (COMMENT.test(line)) {
637 while (i < lines.length && !(lines[i] ?? '').includes('-->')) {
638 i += 1
639 }
640
641 i += 1
642 continue
643 }
644
645 const fence = FENCE.exec(line)
646
647 if (fence !== null) {
648 const mark = fence[1] ?? '```'
649 const language = (fence[2] ?? '').trim().split(/\s+/)[0] ?? ''
650 const indent = indentOf(line)
651 const block = ctx.blocks.n
652 const body: string[] = []
653
654 ctx.blocks.n += 1
655 i += 1
656
657 while (i < lines.length) {
658 const inner = lines[i] ?? ''
659 const trimmed = inner.trim()
660
661 i += 1
662
663 if (trimmed.startsWith(mark) && trimmed.replace(new RegExp(`\\${mark[0] ?? '`'}`, 'g'), '') === '') {
664 break
665 }
666
667 body.push(inner.slice(Math.min(indent, indentOf(inner))))
668 }
669
670 while (body.length > 0 && (body[body.length - 1] ?? '').trim() === '') {
671 body.pop()
672 }
673
674 gap(1, true)
675
676 for (const code of body.length === 0 ? [''] : body) {
677 out.push({
678 spans: [{ text: '│ ', color: C.faint }],
679 code: { line: code, block, ...(language === '' ? {} : { language }) },
680 })
681 }
682
683 continue
684 }
685
686 const heading = HEADING.exec(line)
687
688 if (heading !== null) {
689 const level = (heading[1] ?? '#').length
690
691 gap(level <= 2 ? 2 : 1, true)
692 out.push(...headingRows(level, (heading[2] ?? '').trim(), cols))
693 i += 1
694 continue
695 }
696
697 if (HR.test(line)) {
698 gap(1, true)
699 out.push({ spans: [{ text: RULE.repeat(cols), color: C.faint }] })
700 i += 1
701 continue
702 }
703
704 if (line.includes('|') && isTableRule(lines[i + 1])) {
705 const head = cellsOf(line).map(cell => inline(cell, { bold: true }))
706 const aligns = cellsOf(lines[i + 1] ?? '').map((cell): Align =>
707 cell.startsWith(':') && cell.endsWith(':') ? 'center' : cell.endsWith(':') ? 'right' : 'left',
708 )
709 const body: Span[][][] = []
710
711 i += 2
712
713 while (i < lines.length && (lines[i] ?? '').trim() !== '' && (lines[i] ?? '').includes('|')) {
714 body.push(cellsOf(lines[i] ?? '').map(cell => inline(cell)))
715 i += 1
716 }
717
718 gap(1, true)
719 out.push(...tableRows(head, aligns, body, cols))
720 continue
721 }
722
723 if (QUOTE.test(line)) {
724 const inner: string[] = []
725
726 while (i < lines.length) {
727 const quoted = lines[i] ?? ''
728
729 if (QUOTE.test(quoted)) {
730 inner.push(quoted.replace(/^ {0,3}> ?/, ''))
731 } else if (quoted.trim() !== '' && inner.length > 0 && !startsBlock(quoted, lines[i + 1])) {
732 inner.push(quoted)
733 } else {
734 break
735 }
736
737 i += 1
738 }
739
740 gap(1, true)
741
742 for (const row of blockRows(inner, Math.max(8, cols - 2), { ...ctx, isTight: false })) {
743 out.push({
744 ...row,
745 spans: [{ text: '▎ ', color: C.faint }, ...row.spans.map(span => (span.color === undefined ? { ...span, color: C.muted } : span))],
746 })
747 }
748
749 continue
750 }
751
752 const first = ITEM.exec(line)
753
754 if (first !== null) {
755 const base = (first[1] ?? '').length
756 const isOrdered = /\d/.test(first[2] ?? '')
757 const items: string[][] = []
758 const start = isOrdered ? Number.parseInt(first[2] ?? '1', 10) : 1
759 let isLoose = false
760
761 while (i < lines.length) {
762 const item = ITEM.exec(lines[i] ?? '')
763
764 // A list ends where the indent or the kind of marker changes.
765 if (item === null || (item[1] ?? '').length !== base || /\d/.test(item[2] ?? '') !== isOrdered || HR.test(lines[i] ?? '')) {
766 break
767 }
768
769 const offset = base + (item[2] ?? '').length + Math.min((item[3] ?? ' ').length, 4)
770 const body = [item[4] ?? '']
771
772 i += 1
773
774 while (i < lines.length) {
775 const inner = lines[i] ?? ''
776
777 if (inner.trim() === '') {
778 let k = i + 1
779
780 while (k < lines.length && (lines[k] ?? '').trim() === '') {
781 k += 1
782 }
783
784 if (k < lines.length && indentOf(lines[k] ?? '') > base) {
785 body.push('')
786 i += 1
787 continue
788 }
789
790 break
791 }
792
793 const indent = indentOf(inner)
794
795 if (indent > base) {
796 body.push(inner.slice(Math.min(indent, offset)))
797 i += 1
798 continue
799 }
800
801 if (ITEM.test(inner) || startsBlock(inner, lines[i + 1])) {
802 break
803 }
804
805 body.push(inner.trim())
806 i += 1
807 }
808
809 items.push(body)
810
811 let k = i
812
813 while (k < lines.length && (lines[k] ?? '').trim() === '') {
814 k += 1
815 }
816
817 if (k > i && k < lines.length) {
818 const following = ITEM.exec(lines[k] ?? '')
819
820 const isSame = following !== null && (following[1] ?? '').length === base && /\d/.test(following[2] ?? '') === isOrdered
821
822 if (isSame && !HR.test(lines[k] ?? '')) {
823 isLoose = true
824 i = k
825 continue
826 }
827
828 break
829 }
830 }
831
832 const digits = String(start + items.length - 1).length
833 const room = isOrdered ? digits + 2 : 2
834
835 gap()
836
837 items.forEach((body, n) => {
838 const task = /^\[([ xX])\]\s+/.exec(body[0] ?? '')
839 const isChecked = task !== null && task[1] !== ' '
840
841 if (task !== null) {
842 body[0] = (body[0] ?? '').slice(task[0].length)
843 }
844
845 const marker: Span =
846 task !== null
847 ? { text: isChecked ? '✓ ' : '○ ', color: isChecked ? C.done : C.muted }
848 : isOrdered
849 ? { text: `${`${start + n}.`.padStart(digits + 1)} `, color: C.muted }
850 : { text: `${BULLETS[ctx.depth % BULLETS.length] ?? '•'} `, color: C.muted }
851 const inner = blockRows(body, Math.max(8, cols - room), {
852 ...ctx,
853 depth: ctx.depth + 1,
854 isTight: !isLoose && !body.includes(''),
855 })
856 const rows = inner.length === 0 ? [blank()] : inner
857
858 if (isLoose && n > 0) {
859 gap(1, true)
860 }
861
862 rows.forEach((row, r) => {
863 const spans = isChecked ? row.spans.map(span => ({ ...span, dim: true })) : row.spans
864
865 out.push({ ...row, spans: [r === 0 ? marker : { text: ' '.repeat(room) }, ...spans] })
866 })
867 })
868
869 continue
870 }
871
872 // A paragraph, or a heading underlined with = or -.
873 const para: string[] = []
874 let setext = 0
875
876 while (i < lines.length) {
877 const text = lines[i] ?? ''
878
879 if (text.trim() === '') {
880 break
881 }
882
883 if (para.length > 0) {
884 const under = /^ {0,3}(=+|-+)[ ]*$/.exec(text)
885
886 if (under !== null) {
887 setext = (under[1] ?? '=').startsWith('=') ? 1 : 2
888 i += 1
889 break
890 }
891
892 if (startsBlock(text, lines[i + 1])) {
893 break
894 }
895 }
896
897 para.push(text)
898 i += 1
899 }
900
901 const joined = para
902 .map((text, n) => (n === para.length - 1 ? text.trim() : / {2,}$|\\$/.test(text) ? `${text.replace(/( {2,}|\\)$/, '').trim()}\n` : `${text.trim()} `))
903 .join('')
904
905 if (setext > 0) {
906 gap(2, true)
907 out.push(...headingRows(setext, joined, cols))
908 continue
909 }
910
911 gap()
912
913 for (const spans of wrapSpans(inline(joined), cols)) {
914 const link = spans.find(span => span.href !== undefined && isLocalLink(span.href))?.href
915
916 out.push(link === undefined ? { spans } : { spans, link })
917 }
918 }
919
920 return out
921}
922
923const frontMatter = (lines: readonly string[], cols: number): Row[] => {
924 const pairs = lines
925 .map(line => /^\s*([A-Za-z0-9_-]+):\s*(.*)$/.exec(line))
926 .filter((found): found is RegExpExecArray => found !== null && (found[2] ?? '').trim() !== '')
927 const keyWidth = Math.min(16, Math.max(0, ...pairs.map(pair => (pair[1] ?? '').length)))
928 const out: Row[] = []
929
930 for (const pair of pairs) {
931 const value = (pair[2] ?? '').replace(/^["']|["']$/g, '')
932 const rows = wrapSpans([{ text: value === '' ? '—' : value, color: C.muted }], Math.max(8, cols - keyWidth - 2))
933
934 rows.forEach((spans, r) => {
935 out.push({ spans: [{ text: (r === 0 ? (pair[1] ?? '') : '').padEnd(keyWidth + 2), color: C.faint }, ...spans] })
936 })
937 }
938
939 if (out.length > 0) {
940 out.push({ spans: [{ text: RULE.repeat(cols), color: C.faint }] })
941 }
942
943 return out
944}
945
946/** A markdown document as rows `cols` cells wide. */
947export const renderMarkdown = (source: string, cols: number): Row[] => {
948 const room = Math.max(12, cols)
949 let lines = clean(source).split('\n')
950 const out: Row[] = []
951
952 if ((lines[0] ?? '').trim() === '---') {
953 const end = lines.findIndex((line, n) => n > 0 && /^(---|\.\.\.)\s*$/.test(line))
954
955 if (end > 0) {
956 out.push(...frontMatter(lines.slice(1, end), room))
957 lines = lines.slice(end + 1)
958 }
959 }
960
961 const body = blockRows(lines, room, { depth: 0, isTight: false, blocks: { n: 1 } })
962
963 if (out.length > 0 && body.length > 0) {
964 out.push(blank())
965 }
966
967 out.push(...body)
968
969 while (isBlank(out[0])) {
970 out.shift()
971 }
972
973 while (isBlank(out[out.length - 1])) {
974 out.pop()
975 }
976
977 return out
978}
979hooks/text.ts 356 lines1// Text measured in terminal cells: widths, cuts and word wrap over styled spans.
2
3export type Span = {
4 text: string
5 bold?: boolean
6 italic?: boolean
7 underline?: boolean
8 strike?: boolean
9 dim?: boolean
10 color?: string
11 bg?: string
12 href?: string
13}
14
15export type Style = Omit<Span, 'text'>
16
17/** One line of a fenced block or of a source file, drawn by the engine's highlighter. */
18export type CodeLine = { line: string; block: number; language?: string; number?: number }
19
20/** One terminal row. A code row keeps its indent in `spans`. */
21export type Row = { spans: Span[]; code?: CodeLine; link?: string }
22
23const WIDE: ReadonlyArray<readonly [number, number]> = [
24 [0x1100, 0x115f],
25 [0x231a, 0x231b],
26 [0x23e9, 0x23ec],
27 [0x23f0, 0x23f0],
28 [0x23f3, 0x23f3],
29 [0x25fd, 0x25fe],
30 [0x2614, 0x2615],
31 [0x2648, 0x2653],
32 [0x267f, 0x267f],
33 [0x2693, 0x2693],
34 [0x26a1, 0x26a1],
35 [0x26aa, 0x26ab],
36 [0x26bd, 0x26be],
37 [0x26c4, 0x26c5],
38 [0x26ce, 0x26ce],
39 [0x26d4, 0x26d4],
40 [0x26ea, 0x26ea],
41 [0x26f2, 0x26f3],
42 [0x26f5, 0x26f5],
43 [0x26fa, 0x26fa],
44 [0x26fd, 0x26fd],
45 [0x2705, 0x2705],
46 [0x270a, 0x270b],
47 [0x2728, 0x2728],
48 [0x274c, 0x274c],
49 [0x274e, 0x274e],
50 [0x2753, 0x2755],
51 [0x2757, 0x2757],
52 [0x2795, 0x2797],
53 [0x27b0, 0x27b0],
54 [0x27bf, 0x27bf],
55 [0x2b1b, 0x2b1c],
56 [0x2b50, 0x2b50],
57 [0x2b55, 0x2b55],
58 [0x2e80, 0xa4cf],
59 [0xac00, 0xd7a3],
60 [0xf900, 0xfaff],
61 [0xfe30, 0xfe6f],
62 [0xff00, 0xff60],
63 [0xffe0, 0xffe6],
64 [0x1f300, 0x1f64f],
65 [0x1f680, 0x1f6ff],
66 [0x1f900, 0x1f9ff],
67 [0x1fa70, 0x1faff],
68 [0x20000, 0x3fffd],
69]
70
71const ZERO: ReadonlyArray<readonly [number, number]> = [
72 [0x0300, 0x036f],
73 [0x1ab0, 0x1aff],
74 [0x200b, 0x200f],
75 [0x2060, 0x2060],
76 [0x20d0, 0x20ff],
77 [0xfe00, 0xfe0f],
78 [0xfeff, 0xfeff],
79]
80
81const within = (ranges: ReadonlyArray<readonly [number, number]>, cp: number) => {
82 for (const [low, high] of ranges) {
83 if (cp >= low && cp <= high) {
84 return true
85 }
86 }
87
88 return false
89}
90
91export const charWidth = (cp: number): number => {
92 if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) {
93 return 0
94 }
95
96 if (cp < 0x300) {
97 return 1
98 }
99
100 if (within(ZERO, cp)) {
101 return 0
102 }
103
104 return within(WIDE, cp) ? 2 : 1
105}
106
107export const width = (text: string): number => {
108 let total = 0
109 let last = 0
110
111 for (const ch of text) {
112 const cp = ch.codePointAt(0) ?? 0
113
114 // An emoji selector makes a narrow symbol draw wide.
115 if (cp === 0xfe0f && last === 1) {
116 total += 1
117 last = 2
118 continue
119 }
120
121 last = charWidth(cp)
122 total += last
123 }
124
125 return total
126}
127
128/** The longest head of `text` that fits `cells`. */
129export const head = (text: string, cells: number): string => {
130 let total = 0
131 let out = ''
132
133 for (const ch of text) {
134 const w = charWidth(ch.codePointAt(0) ?? 0)
135
136 if (total + w > cells) {
137 break
138 }
139
140 total += w
141 out += ch
142 }
143
144 return out
145}
146
147/** Cuts `text` to `cells`, an ellipsis at the cut. */
148export const truncate = (text: string, cells: number): string => {
149 if (cells <= 0) {
150 return ''
151 }
152
153 return width(text) <= cells ? text : `${head(text, cells - 1)}…`
154}
155
156/** Cuts in the middle, so a file name keeps its ending. */
157export const truncateMiddle = (text: string, cells: number): string => {
158 if (width(text) <= cells) {
159 return text
160 }
161
162 if (cells <= 3) {
163 return truncate(text, cells)
164 }
165
166 const tail = Math.floor((cells - 1) / 3)
167 const chars = [...text]
168
169 return `${head(text, cells - 1 - tail)}…${chars.slice(chars.length - tail).join('')}`
170}
171
172export const pad = (text: string, cells: number): string => text + ' '.repeat(Math.max(0, cells - width(text)))
173
174/** Tabs to spaces, and no control character but a newline: what a Text may hold. */
175export const clean = (text: string): string =>
176 text
177 .replace(/\r\n?/g, '\n')
178 .replace(/\t/g, ' ')
179 // eslint-disable-next-line no-control-regex
180 .replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, '')
181
182export const plain = (spans: readonly Span[]): string => spans.map(span => span.text).join('')
183
184export const spansWidth = (spans: readonly Span[]): number => spans.reduce((sum, span) => sum + width(span.text), 0)
185
186type Piece = { text: string; style: Style; cells: number }
187
188const styleOf = (span: Span): Style => {
189 const { text: _text, ...style } = span
190
191 return style
192}
193
194/**
195 * Word wrap over styled spans. A newline in a span's text is a hard break.
196 * A word wider than the room is cut across rows.
197 */
198export const wrapSpans = (spans: readonly Span[], cells: number): Span[][] => {
199 const room = Math.max(1, cells)
200 const lines: Span[][] = []
201 let line: Span[] = []
202 let used = 0
203 let word: Piece[] = []
204 let wordCells = 0
205 let gap: Piece[] = []
206 let gapCells = 0
207
208 const push = (piece: Piece) => {
209 const lastSpan = line[line.length - 1]
210
211 if (lastSpan !== undefined && sameStyle(lastSpan, piece.style)) {
212 lastSpan.text += piece.text
213 } else {
214 line.push({ ...piece.style, text: piece.text })
215 }
216
217 used += piece.cells
218 }
219
220 const breakLine = () => {
221 lines.push(line)
222 line = []
223 used = 0
224 }
225
226 const placeWord = () => {
227 if (word.length === 0) {
228 return
229 }
230
231 if (used > 0 && used + gapCells + wordCells > room) {
232 breakLine()
233 gap = []
234 gapCells = 0
235 }
236
237 if (used > 0) {
238 for (const piece of gap) {
239 push(piece)
240 }
241 }
242
243 gap = []
244 gapCells = 0
245
246 for (const piece of word) {
247 if (used + piece.cells <= room) {
248 push(piece)
249 continue
250 }
251
252 // The word is wider than a whole row: cut it.
253 let rest = piece.text
254
255 while (rest !== '') {
256 if (used >= room) {
257 breakLine()
258 }
259
260 let part = head(rest, room - used) || [...rest][0] || ''
261 let isSoft = false
262
263 // A path or a long name breaks after a separator when one is near the edge.
264 if (part.length < rest.length) {
265 const least = Math.floor(part.length * 0.4)
266 const slash = part.lastIndexOf('/', part.length - 2)
267 const soft = slash >= least ? slash : Math.max(part.lastIndexOf('-'), part.lastIndexOf('_'), part.lastIndexOf('='))
268
269 if (soft >= least && soft < part.length - 1) {
270 part = part.slice(0, soft + 1)
271 isSoft = true
272 }
273 }
274
275 push({ text: part, style: piece.style, cells: width(part) })
276 rest = rest.slice(part.length)
277
278 if (isSoft) {
279 breakLine()
280 }
281 }
282 }
283
284 word = []
285 wordCells = 0
286 }
287
288 for (const span of spans) {
289 const style = styleOf(span)
290
291 for (const token of span.text.split(/(\n|[ ]+)/)) {
292 if (token === '') {
293 continue
294 }
295
296 if (token === '\n') {
297 placeWord()
298 breakLine()
299 gap = []
300 gapCells = 0
301 } else if (token.startsWith(' ')) {
302 placeWord()
303 gap.push({ text: token, style, cells: token.length })
304 gapCells += token.length
305 } else {
306 const cellsOf = width(token)
307 word.push({ text: token, style, cells: cellsOf })
308 wordCells += cellsOf
309 }
310 }
311 }
312
313 placeWord()
314
315 if (line.length > 0 || lines.length === 0) {
316 lines.push(line)
317 }
318
319 return lines
320}
321
322const sameStyle = (span: Span, style: Style): boolean =>
323 span.bold === style.bold &&
324 span.italic === style.italic &&
325 span.underline === style.underline &&
326 span.strike === style.strike &&
327 span.dim === style.dim &&
328 span.color === style.color &&
329 span.bg === style.bg &&
330 span.href === style.href
331
332/** Cuts a row of spans to `cells`, an ellipsis at the cut. */
333export const fitSpans = (spans: readonly Span[], cells: number): Span[] => {
334 if (spansWidth(spans) <= cells) {
335 return [...spans]
336 }
337
338 const out: Span[] = []
339 let left = Math.max(0, cells - 1)
340
341 for (const span of spans) {
342 const w = width(span.text)
343
344 if (w <= left) {
345 out.push(span)
346 left -= w
347 continue
348 }
349
350 out.push({ ...span, text: `${head(span.text, left)}…` })
351 break
352 }
353
354 return out
355}
356hooks/theme.ts 20 lines1// The few colors the pane uses. Each is a theme key, so the pane follows the
2// person's light or dark theme.
3export const C = {
4 /** Body text. Named, so it follows the theme and not the terminal's own text color. */
5 text: 'text',
6 /** The one accent: the open file's mark, inline code, the active mode. */
7 accent: 'suggestion',
8 /** Inline code and paths in a document. */
9 code: 'professionalBlue',
10 /** Secondary text: markers, meta, breadcrumbs. */
11 muted: 'inactive',
12 /** Rules and guides. */
13 faint: 'subtle',
14 /** The soft fill under a hovered or open row. */
15 fill: 'userMessageBackground',
16 /** A file Claude wrote in this session. */
17 touched: 'claude',
18 done: 'success',
19} as const
20types/index.d.ts 27 lines1export type FolioMode = 'read' | 'source'
2
3/** What the pane shows: the open folders, the open file and where each list stands. */
4export type FolioUi = {
5 expanded: string[]
6 selected: string
7 mode: FolioMode
8 docTop: number
9 treeTop: number
10 /** Files that can hold secrets and that the person asked to see. */
11 revealed: string[]
12 /** Goes up when files change on disk, so the pane reads them again. */
13 tick: number
14 /** Where the tree stands in the pane. */
15 side: 'left' | 'right'
16 isRestored: boolean
17}
18
19/** Paths Claude wrote in this session, relative to the project. */
20export type FolioTouched = string[]
21
22declare module 'claude-code' {
23 interface PluginState {
24 folio: { ui: FolioUi; touched: FolioTouched }
25 }
26}
27