SLOPSHOPPER

folio

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

newpaneguardcommandtoastprocess
v0.1.0MITupdated 2026-10-15krishna-goutham-tls/cc-mods/folio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · folio
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ backREADME.md mo │ ┃ ─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ app ⏺ Update(src/auth.ts) │ ┃ ━━━━ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ An example service. Run bun test to test. ⎿ 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 · Files
backREADME.md mode:read mode:s ───────────────────────────────────────────────────── app ━━━━ An example service. Run bun test to test. Markdown · 3 lines · 51 B… act:copy act:mention ac
README

folio

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 folio pane on a sample project

The document is next to the chat. The file tree is at the right edge. The prompt stays where it is, at the bottom.

Install

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.

Use it

You doThe pane does
/filesOpens, and shows the README of the project
/files docs/guide.mdOpens that file and its folder
Click a folderOpens or closes it
Click a fileShows it
Scroll over the document or the treeMoves that part only
Click Read or SourceShows the formatted document or the text as written
Click a link to a file of the projectOpens that file
Click CopyPuts the text of the open file on the clipboard
Click MentionPuts @path in the prompt
Click OpenOpens 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.

What it shows

FileHow
MarkdownHeadings, paragraphs that wrap, lists, task lists, quotes, tables, code blocks with colours, front matter
Code and textClaude Code's syntax colours, with line numbers
CSV and TSVA table
PNG, JPG, GIF, HEICThe picture, in a terminal with the kitty graphics protocol such as Ghostty or kitty
PDF, Office filesThe first page as a picture, made by macOS Quick Look
HTML, SVGRead shows the top of the page as a picture, made by macOS Quick Look. Source shows the text
.env, keys, credentialsA notice. The text stays hidden until you click Reveal, so a shared screen or a recording is safe
Other filesThe 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.

The same pane in a dark theme

What it can reach

A mod runs inside Claude Code, so it is fair to ask what this one does.

  • It reads files under the project folder, only to show them in the pane, or to put one on the clipboard when you click Copy. The text does not go to the model.
  • It writes nothing in your project.
  • It makes no network call.
  • It runs five macOS programs: sips and qlmanage to make a picture preview, mkdir and mv for that preview in the temp folder, and open when you click Open.
  • It watches the Edit and Write tools to mark the files Claude wrote. It never changes or blocks a tool call.
  • It keeps the open folders and the open file of each project in Claude Code's plugin store, on your machine.

claude plugin validate folio, run from the root of the repo, prints the same list from the source.

Limits

  • It only reads. There is no edit mode yet. Mention puts the path in the prompt so you can ask Claude for the change.
  • It is for the terminal. The desktop app is not done.
  • The pane docks beside the chat in the fullscreen layout, on a terminal 110 columns wide or more. Elsewhere it opens above the prompt.
  • The tree and the document show side by side when the pane is 84 columns wide or more. A narrower pane shows one of them at a time.
  • Pictures are the least tested part. They need macOS and a terminal with the kitty graphics protocol.
  • A file changed by a shell command gets its ● only when its folder is open in the tree.

Open work is in TRACKER.md.

How it works

  • 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.
  • The pane keeps its own scroll position for the tree and for the document. That is how the header and the tree stay in place while the document moves.

Work on it

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.

License

MIT

Source 9 files
hooks/register.tsx 648 lines
1// 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}
648
hooks/files.ts 169 lines
1// 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}
169
hooks/load.ts 236 lines
1// 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}
236
hooks/model.ts 49 lines
1import 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}
49
hooks/view.tsx 475 lines
1// 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}
475
hooks/markdown.ts 979 lines
1// 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}
979
hooks/text.ts 356 lines
1// 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}
356
hooks/theme.ts 20 lines
1// 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
20
types/index.d.ts 27 lines
1export 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