SLOPSHOPPER

folio

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

newpaneguardcommandtoastprocess
v0.1.0MITupdated 2026-10-09krishna-goutham-tls/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 · 1m… act:mention act:op
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/folio

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.295. 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 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 files, SVGThe first page as a picture, made by macOS Quick Look
.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, and only to show them in the pane. 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 . 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

git clone https://github.com/krishna-goutham-tls/folio
cd 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 .

License

MIT

Source 9 files
hooks/register.tsx 630 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, 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 = made === undefined ? opaqueDoc(request, stat, 'No preview for this file here.') : pictureDoc(request, stat, made)
253    } else if (plan === 'opaque') {
254      doc = opaqueDoc(request, stat, 'This file is not text.')
255    } else if (stat.size > MAX_TEXT_BYTES) {
256      doc = opaqueDoc(request, stat, 'This file is too large to show here.')
257    } else {
258      const text = await $.fs.read(abs).catch(() => undefined)
259
260      doc = text === undefined ? notice(request, stat, baseOf(request.path), ['This file cannot be read.']) : textDoc(request, stat, text)
261    }
262  }
263
264  docs.set(key, doc)
265
266  return doc
267}
268
269/** A file to bring into view in the tree at the next drawing. */
270let reveal = ''
271
272/** Opens a path of the project: a file in the reader, a folder in the tree. */
273const show = async ($: EngineInterface, path: string): Promise<boolean> => {
274  const base = await rootOf($)
275  const stat = await $.fs.stat(`${base}/${path}`).catch(() => undefined)
276
277  if (path === '' || stat === undefined || stat.kind === 'other') {
278    return false
279  }
280
281  const isDir = stat.kind === 'dir'
282  const open = isDir ? [...ancestors(path), path] : ancestors(path)
283
284  reveal = path
285  await update($, ui, state => ({
286    ...state,
287    expanded: [...new Set([...state.expanded, ...open])],
288    ...(isDir ? {} : { selected: path, docTop: 0 }),
289  }))
290  await keep($)
291
292  return true
293}
294
295/** When the turn that runs now began; 0 between turns. */
296let turnBegan = 0
297
298/**
299 * Marks the files that changed on disk while Claude's turn ran: what a shell
300 * command wrote, which no file tool reports. Only the folders open in the tree
301 * are looked at.
302 */
303const noteTurn = async ($: EngineInterface): Promise<void> => {
304  try {
305    const began = turnBegan
306    const { value } = await $.state.get({ plugin: 'folio', key: 'ui' } as const)
307
308    turnBegan = 0
309    forget()
310
311    if (began > 0 && value !== undefined) {
312      await rootOf($)
313
314      const changed: string[] = []
315
316      for (const dir of ['', ...value.expanded].slice(0, 60)) {
317        for (const entry of await list($, dir)) {
318          if (!entry.isDir && entry.mtimeMs >= began - 1000) {
319            changed.push(dir === '' ? entry.name : `${dir}/${entry.name}`)
320          }
321        }
322      }
323
324      if (changed.length > 0) {
325        await update($, touched, known => [...new Set([...known, ...changed])].slice(-300))
326      }
327    }
328
329    await update($, ui, state => ({ ...state, tick: state.tick + 1 }))
330  } catch {
331    // The pane's bookkeeping never stands in a turn's way.
332  }
333}
334
335/** Marks a file Claude wrote, and has the pane read the disk again. */
336const noteWrite = async ($: EngineInterface, file: unknown): Promise<void> => {
337  try {
338    const base = await rootOf($)
339
340    if (typeof file === 'string' && file.startsWith(`${base}/`)) {
341      const path = file.slice(base.length + 1)
342
343      await update($, touched, list => (list.includes(path) ? list : [...list, path].slice(-300)))
344    }
345
346    await update($, ui, state => ({ ...state, tick: state.tick + 1 }))
347  } catch {
348    // The pane's bookkeeping never stands in a tool's way.
349  }
350}
351
352export const register: Register = on => {
353  let seenTick = -1
354  /**
355   * True when another copy of this mod runs in the session (one under
356   * development beside an installed one). That copy does the work; this one
357   * passes every event on.
358   */
359  let isIdle = false
360  /** Where the lists stood at the last drawing; the wheel moves them from here. */
361  const drawn = { isSplit: false, isReading: false, treeFrom: 0, treeTo: 0, treeTop: 0, treeMax: 0, docTop: 0, docMax: 0 }
362
363  on('session.start', async ($, e, next) => {
364    // The first copy to start in a session writes its name here; a second copy reads it and stays idle.
365    const session = await $.session.id().catch(() => '')
366    const mine = `${session}|${$.plugin.root}`
367    const home = (await $.env.get('FOLIO_HOME').catch(() => undefined)) ?? ''
368
369    isIdle = home.startsWith(`${session}|`) && home !== mine
370
371    if (isIdle) {
372      return next(e)
373    }
374
375    await $.env.set('FOLIO_HOME', mine).catch(() => undefined)
376    await $.command.register({
377      name: 'files',
378      description: 'Browse and read the files of this project in a pane',
379      argumentHint: '[path]',
380    })
381
382    const base = await rootOf($)
383    const saved = (await $.store.get(`ui:${base}`).catch(() => undefined)) as Partial<FolioUi> | undefined
384    const side = await $.store.get('side').catch(() => undefined)
385
386    await update($, ui, (state): FolioUi =>
387      state.isRestored
388        ? state
389        : {
390            ...state,
391            isRestored: true,
392            expanded: Array.isArray(saved?.expanded) ? saved.expanded.filter((path): path is string => typeof path === 'string') : state.expanded,
393            selected: typeof saved?.selected === 'string' ? saved.selected : state.selected,
394            mode: saved?.mode === 'source' ? 'source' : 'read',
395            side: side === 'left' ? 'left' : 'right',
396          },
397    )
398
399    // The pane comes back where the person left it open.
400    if ((await $.store.get(`open:${base}`).catch(() => undefined)) === true) {
401      void $.ui.open({ id: PANE, title: TITLE })
402    }
403
404    return next(e)
405  })
406
407  on('command.run', { command: 'files' }, async ($, e, next) => {
408    if (isIdle) {
409      return next(e)
410    }
411
412    const base = await rootOf($)
413    const asked = e.args.trim().replace(/^@/, '')
414    const state = await read($, ui)
415
416    if (asked !== '') {
417      if (!(await show($, resolve('', asked)))) {
418        return { text: `No file or folder at ${asked} in this project.` }
419      }
420    } else if (state.selected === '') {
421      for (const name of FIRST_FILES) {
422        if (await $.fs.exists(`${base}/${name}`)) {
423          await show($, name)
424          break
425        }
426      }
427    }
428
429    const columns = clamp(Math.round(e.presentation.columns * 0.52), 60, 124)
430    const opened = await $.ui.open({ id: PANE, title: TITLE, columns, rows: 28 })
431
432    await $.store.set(`open:${base}`, true)
433
434    return opened.isPlaced ? {} : { text: `The files pane is open but waits for room: ${opened.reason}` }
435  })
436
437  on('ui.close', { id: PANE }, async ($, e, next) => {
438    if (!isIdle && e.origin.kind === 'person') {
439      await $.store.set(`open:${await rootOf($)}`, false)
440    }
441
442    return next(e)
443  })
444
445  // The pane keeps its own windows over the tree and the document, so the
446  // header and the tree stay put while the document moves.
447  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
448    if (isIdle) {
449      return next(e)
450    }
451
452    const column = e.pointer?.column
453    const isOverTree = drawn.isSplit ? column !== undefined && column >= drawn.treeFrom && column <= drawn.treeTo : !drawn.isReading
454
455    await update($, ui, state =>
456      isOverTree
457        ? { ...state, treeTop: clamp(drawn.treeTop + e.by, 0, drawn.treeMax) }
458        : { ...state, docTop: clamp(drawn.docTop + e.by, 0, drawn.docMax) },
459    )
460
461    return { deny: 'folio moves its own rows' }
462  }).catch(() => ({ deny: 'folio moves its own rows' }))
463
464  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
465    const result = await next(e)
466
467    if (!isIdle) {
468      await noteWrite($, e.file_path)
469    }
470
471    return result
472  }).catch(($, e, next) => next(e))
473
474  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
475    const result = await next(e)
476
477    if (!isIdle) {
478      await noteWrite($, e.file_path)
479    }
480
481    return result
482  }).catch(($, e, next) => next(e))
483
484  on('turn.start', async ($, e, next) => {
485    turnBegan = await $.clock.now().catch(() => 0)
486
487    return next(e)
488  })
489
490  on('turn.complete', async ($, e, next) => {
491    if (e.agentId === undefined && !isIdle) {
492      await noteTurn($)
493    }
494
495    return next(e)
496  })
497
498  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
499    if (isIdle) {
500      return next(e)
501    }
502
503    const el = $.ui.resolve(e)
504    const { Box, Text } = el
505
506    try {
507      const base = await rootOf($)
508      const state = await read($, ui)
509      const written = await read($, touched)
510
511      if (state.tick !== seenTick) {
512        forget()
513        seenTick = state.tick
514      }
515
516      const columns = Math.max(24, e.props.bodyColumns)
517      const rows = Math.max(8, e.props.scroll.bodyRows)
518      const isSplit = columns >= SPLIT_FROM
519      const treeWidth = isSplit ? clamp(Math.round(columns * 0.29), 26, 38) : columns
520      const isTreeRight = isSplit && state.side === 'right'
521      const frame: Frame = {
522        columns,
523        rows,
524        isSplit,
525        isTreeRight,
526        treeWidth,
527        // Beside the text: the tree, the line between them and the two thumbs, or one margin and a thumb.
528        textWidth: Math.max(8, isSplit ? columns - treeWidth - 5 : columns - 3),
529        treeRows: rows - 2,
530        docRows: rows - 5,
531      }
532      const nodes = await treeNodes($, new Set(state.expanded))
533      const treeMax = Math.max(0, nodes.length - frame.treeRows)
534      let treeTop = clamp(state.treeTop, 0, treeMax)
535
536      if (reveal !== '') {
537        const at = nodes.findIndex(node => node.rel === reveal)
538
539        if (at !== -1 && (at < treeTop || at >= treeTop + frame.treeRows)) {
540          treeTop = clamp(at - Math.floor(frame.treeRows / 3), 0, treeMax)
541        }
542
543        reveal = ''
544      }
545
546      const isSource = state.mode === 'source' && hasTwoForms(baseOf(state.selected))
547      const doc =
548        state.selected === ''
549          ? undefined
550          : await loadDoc($, {
551              root: base,
552              path: state.selected,
553              isSource,
554              isRevealed: state.revealed.includes(state.selected),
555              columns: frame.textWidth,
556              measure: MEASURE,
557              rows: frame.docRows,
558              now: await $.clock.now(),
559            })
560      const docMax = doc === undefined || doc.picture !== undefined ? 0 : Math.max(0, doc.rows.length - frame.docRows)
561      const docTop = clamp(state.docTop, 0, docMax)
562
563      Object.assign(drawn, {
564        isSplit,
565        isReading: state.selected !== '',
566        treeFrom: isTreeRight ? columns - treeWidth - 1 : 0,
567        treeTo: isTreeRight ? columns : treeWidth,
568        treeTop,
569        treeMax,
570        docTop,
571        docMax,
572      })
573
574      const change = (step: (state: FolioUi) => FolioUi) => {
575        void update($, ui, step).then(() => keep($))
576      }
577      const actions: Actions = {
578        toggle: path =>
579          change(now => ({
580            ...now,
581            expanded: now.expanded.includes(path) ? now.expanded.filter(open => open !== path) : [...now.expanded, path],
582          })),
583        open: path => change(now => ({ ...now, selected: path, docTop: 0 })),
584        back: () => change(now => ({ ...now, selected: '' })),
585        mode: (mode: FolioMode) => change(now => ({ ...now, mode, docTop: 0 })),
586        reveal: () => change(now => ({ ...now, revealed: [...now.revealed, state.selected] })),
587        mention: () => {
588          void $.prompt.fill({ text: `@${state.selected} `, mode: 'insert' })
589        },
590        outside: () => {
591          void $.process.run(['open', `${base}/${state.selected}`], { timeoutMs: 10_000 }).catch(() => undefined)
592        },
593        swap: () => {
594          void update($, ui, (now): FolioUi => ({ ...now, side: now.side === 'right' ? 'left' : 'right' })).then(now => $.store.set('side', now.side))
595        },
596        follow: href => {
597          const path = resolve(dirOf(state.selected), href)
598
599          void show($, path).then(isShown => {
600            if (!isShown) {
601              $.ui.toast(`${path} is not in this project`)
602            }
603          })
604        },
605      }
606
607      return drawPane(e.surface === 'terminal' ? $.ui.resolve(e) : el, {
608        frame,
609        title: baseOf(base) || base,
610        nodes,
611        treeTop,
612        selected: state.selected,
613        touched: new Set(written),
614        touchedFolders: new Set(written.flatMap(path => ancestors(path))),
615        doc,
616        docTop,
617        mode: isSource ? 'source' : 'read',
618        on: actions,
619      })
620    } catch (error) {
621      return (
622        <Box flexDirection="column">
623          <Text bold>The files pane could not draw.</Text>
624          <Text dimColor>{String(error instanceof Error ? error.message : error).slice(0, 300)}</Text>
625        </Box>
626      )
627    }
628  })
629}
630
hooks/files.ts 164 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'])
11const BINARY = new Set([
12  'zip', 'gz', 'tar', 'tgz', 'bz2', 'xz', '7z', 'rar', 'dmg', 'pkg', 'exe', 'dll', 'so', 'dylib', 'o', 'a', 'class', 'jar', 'wasm',
13  'woff', 'woff2', 'ttf', 'otf', 'eot', 'mp3', 'wav', 'm4a', 'flac', 'ogg', 'sqlite', 'db', 'bundle', 'bin', 'icns', 'lockb', 'pyc', 'node',
14])
15
16/** Folders and files that are noise in a project tree. */
17const HIDDEN = new Set(['.git', '.DS_Store', 'node_modules', '__pycache__', '.Trash', 'Thumbs.db'])
18
19const 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]
20
21const LABELS: Record<string, string> = {
22  md: 'Markdown', mdx: 'MDX', ts: 'TypeScript', tsx: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
23  json: 'JSON', jsonl: 'JSON lines', yml: 'YAML', yaml: 'YAML', toml: 'TOML', py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust',
24  sh: 'Shell', zsh: 'Shell', bash: 'Shell', css: 'CSS', scss: 'SCSS', html: 'HTML', xml: 'XML', sql: 'SQL', txt: 'Text', log: 'Log',
25  csv: 'CSV', tsv: 'TSV', pdf: 'PDF', svg: 'SVG', png: 'PNG image', jpg: 'JPEG image', jpeg: 'JPEG image', gif: 'GIF image',
26  webp: 'WebP image', heic: 'HEIC image', docx: 'Word document', xlsx: 'Excel workbook', pptx: 'PowerPoint deck', mp4: 'Video',
27  mov: 'Video', zip: 'Zip archive', swift: 'Swift', kt: 'Kotlin', java: 'Java', c: 'C', h: 'C header', cpp: 'C++', lock: 'Lock file',
28}
29
30export const extOf = (name: string): string => {
31  const dot = name.lastIndexOf('.')
32
33  return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
34}
35
36export const baseOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
37
38export const dirOf = (path: string): string => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')
39
40export const kindOf = (name: string): Kind => {
41  const ext = extOf(name)
42
43  if (MARKDOWN.has(ext)) {
44    return 'markdown'
45  }
46
47  if (TABLE.has(ext)) {
48    return 'table'
49  }
50
51  if (IMAGE.has(ext)) {
52    return 'image'
53  }
54
55  if (LOOK.has(ext)) {
56    return 'look'
57  }
58
59  return BINARY.has(ext) ? 'binary' : 'code'
60}
61
62export const labelOf = (name: string): string => {
63  const ext = extOf(name)
64
65  return LABELS[ext] ?? (ext === '' ? 'File' : ext.toUpperCase())
66}
67
68export const isHidden = (name: string): boolean => HIDDEN.has(name)
69
70/** A file whose text should not show on a shared screen until the person asks. */
71export const isSecret = (name: string): boolean => SECRET.some(pattern => pattern.test(name))
72
73export const sizeText = (bytes: number): string => {
74  if (bytes < 1000) {
75    return `${bytes} B`
76  }
77
78  if (bytes < 1_000_000) {
79    return `${(bytes / 1000).toFixed(bytes < 10_000 ? 1 : 0)} KB`
80  }
81
82  return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`
83}
84
85const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const
86
87export const ageText = (mtimeMs: number, now: number): string => {
88  const minutes = Math.floor((now - mtimeMs) / 60_000)
89
90  if (minutes < 1) {
91    return 'just now'
92  }
93
94  if (minutes < 60) {
95    return `${minutes}m ago`
96  }
97
98  if (minutes < 60 * 24) {
99    return `${Math.floor(minutes / 60)}h ago`
100  }
101
102  if (minutes < 60 * 24 * 7) {
103    return `${Math.floor(minutes / (60 * 24))}d ago`
104  }
105
106  const date = new Date(mtimeMs)
107  const year = date.getFullYear() === new Date(now).getFullYear() ? '' : ` ${date.getFullYear()}`
108
109  return `${date.getDate()} ${MONTHS[date.getMonth()] ?? ''}${year}`
110}
111
112/** Comma or tab separated text to rows of cells. */
113export const parseTable = (text: string, separator: string, limit: number): string[][] => {
114  const rows: string[][] = []
115  let row: string[] = []
116  let cell = ''
117  let isQuoted = false
118
119  for (let i = 0; i < text.length && rows.length < limit; i += 1) {
120    const ch = text[i] ?? ''
121
122    if (isQuoted) {
123      if (ch === '"' && text[i + 1] === '"') {
124        cell += '"'
125        i += 1
126      } else if (ch === '"') {
127        isQuoted = false
128      } else {
129        cell += ch
130      }
131    } else if (ch === '"' && cell === '') {
132      isQuoted = true
133    } else if (ch === separator) {
134      row.push(cell)
135      cell = ''
136    } else if (ch === '\n' || ch === '\r') {
137      if (ch === '\r' && text[i + 1] === '\n') {
138        i += 1
139      }
140
141      row.push(cell)
142      rows.push(row)
143      row = []
144      cell = ''
145    } else {
146      cell += ch
147    }
148  }
149
150  if ((cell !== '' || row.length > 0) && rows.length < limit) {
151    row.push(cell)
152    rows.push(row)
153  }
154
155  return rows.filter(cells => cells.some(value => value.trim() !== ''))
156}
157
158/** True when the file has a formatted form and a source form to switch between. */
159export const hasTwoForms = (name: string): boolean => {
160  const kind = kindOf(name)
161
162  return kind === 'markdown' || kind === 'table' || extOf(name) === 'svg'
163}
164
hooks/load.ts 231 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, 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    rows: card(request.columns, [[{ text: title, bold: true }], [], ...lines.map(text => [{ text, color: C.muted }])]),
74  }
75}
76
77export const goneDoc = (request: DocRequest): Doc => notice(request, undefined, baseOf(request.path), ['This file is not on disk any more.'])
78
79export const opaqueDoc = (request: DocRequest, facts: FileFacts, why: string): Doc =>
80  notice(request, facts, baseOf(request.path), [why, 'Open shows it in its own app.'])
81
82export const lockedDoc = (request: DocRequest, facts: FileFacts): Doc => ({
83  ...notice(request, facts, 'Kept off the screen', ['This file can hold secrets.', 'Its text stays hidden until you ask for it.']),
84  isLocked: true,
85})
86
87/** How the reader shows the file: what the hooks module has to read for it. */
88export const planOf = (request: DocRequest): 'locked' | 'picture' | 'opaque' | 'text' => {
89  const name = baseOf(request.path)
90  const kind = kindOf(name)
91
92  if (isSecret(name) && !request.isRevealed) {
93    return 'locked'
94  }
95
96  if (kind === 'image' || (kind === 'look' && !request.isSource)) {
97    return 'picture'
98  }
99
100  return kind === 'binary' || kind === 'look' ? (name.toLowerCase().endsWith('.svg') ? 'text' : 'opaque') : 'text'
101}
102
103export const hash = (text: string): string => {
104  let h = 0x811c9dc5
105
106  for (let i = 0; i < text.length; i += 1) {
107    h ^= text.charCodeAt(i)
108    h = Math.imul(h, 0x01000193) >>> 0
109  }
110
111  return h.toString(16).padStart(8, '0')
112}
113
114const fitPicture = (width: number, height: number, maxColumns: number, maxRows: number): { columns: number; rows: number } => {
115  let columns = Math.min(maxColumns, Math.max(4, Math.round(width / CELL_PIXELS)))
116  let rows = Math.max(1, Math.round((columns * height) / width / CELL_SHAPE))
117
118  if (rows > maxRows) {
119    rows = Math.max(1, maxRows)
120    columns = Math.max(1, Math.min(maxColumns, Math.round((rows * CELL_SHAPE * width) / height)))
121  }
122
123  return { columns: Math.min(255, columns), rows: Math.min(255, rows) }
124}
125
126export const pictureDoc = (request: DocRequest, facts: FileFacts, made: { file: string; width: number; height: number }): Doc => {
127  const name = baseOf(request.path)
128  const kind = kindOf(name)
129  const fit = fitPicture(made.width, made.height, request.columns, Math.max(1, request.rows - 1))
130  const picture: Picture = { file: made.file, ...fit, generation: Math.round(facts.mtimeMs), alt: `${name} (picture)` }
131
132  return {
133    path: request.path,
134    kind,
135    rows: [],
136    meta: [labelOf(name), ...(kind === 'image' ? [`${made.width} × ${made.height}`] : []), ...about(request, facts)],
137    canToggle: name.toLowerCase().endsWith('.svg'),
138    isCard: false,
139    isLocked: false,
140    picture,
141  }
142}
143
144const looksBinary = (text: string): boolean => {
145  const sample = text.slice(0, 8000)
146
147  if (sample.includes('\u0000')) {
148    return true
149  }
150
151  let bad = 0
152
153  for (const ch of sample) {
154    if (ch === '�') {
155      bad += 1
156    }
157  }
158
159  return bad > 8 && bad > sample.length / 200
160}
161
162const sourceRows = (text: string): { rows: Row[]; lines: number; isCut: boolean } => {
163  const all = clean(text).replace(/\n$/, '').split('\n')
164  const lines = all.slice(0, MAX_LINES)
165
166  return {
167    lines: all.length,
168    isCut: all.length > MAX_LINES,
169    rows: lines.map((line, n) => ({
170      spans: [],
171      code: { line: line.length > MAX_LINE_CHARS ? line.slice(0, MAX_LINE_CHARS) : line, block: 0, number: n + 1 },
172    })),
173  }
174}
175
176/** A text file as a document: markdown set as prose, a table as columns, the rest as code. */
177export const textDoc = (request: DocRequest, facts: FileFacts, text: string): Doc => {
178  const { path, columns } = request
179  const name = baseOf(path)
180  const kind: Kind = kindOf(name)
181
182  if (looksBinary(text)) {
183    return opaqueDoc(request, facts, 'This file is not text.')
184  }
185
186  const source = sourceRows(text)
187  const lines = `${source.lines} ${source.lines === 1 ? 'line' : 'lines'}`
188  const meta = (length: string): string[] => [labelOf(name), length, ...about(request, facts)]
189
190  if (kind === 'markdown' && !request.isSource) {
191    return {
192      path,
193      kind,
194      rows: renderMarkdown(text, Math.min(columns, request.measure)),
195      meta: meta(lines),
196      canToggle: true,
197      isCard: false,
198      isLocked: false,
199    }
200  }
201
202  if (kind === 'table' && !request.isSource) {
203    const cells = parseTable(text, name.toLowerCase().endsWith('.tsv') ? '\t' : ',', MAX_TABLE_ROWS + 1)
204    const toSpans = (value: string, isHead: boolean): Span[] => [
205      { text: clean(value).replace(/\n/g, ' ').trim(), ...(isHead ? { bold: true } : {}) },
206    ]
207    const head = (cells[0] ?? []).map(value => toSpans(value, true))
208    const body = cells.slice(1).map(row => row.map(value => toSpans(value, false)))
209
210    return {
211      path,
212      kind,
213      rows: cells.length === 0 ? card(columns, [[{ text: 'This table is empty.', color: C.muted }]]) : tableRows(head, [], body, columns),
214      meta: meta(`${Math.max(0, cells.length - 1)} rows`),
215      canToggle: true,
216      isCard: cells.length === 0,
217      isLocked: false,
218    }
219  }
220
221  return {
222    path,
223    kind: kind === 'markdown' || kind === 'table' || kind === 'look' ? kind : 'code',
224    rows: source.rows,
225    meta: meta(source.isCut ? `first ${MAX_LINES} of ${lines}` : lines),
226    canToggle: kind === 'markdown' || kind === 'table' || kind === 'look',
227    isCard: false,
228    isLocked: false,
229  }
230}
231
hooks/model.ts 47 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  picture?: Picture
32}
33
34/** The pane's measures for one drawing, in cells. */
35export type Frame = {
36  columns: number
37  rows: number
38  isSplit: boolean
39  /** True when the tree is at the pane's right edge, the document beside the chat. */
40  isTreeRight: boolean
41  treeWidth: number
42  /** Cells across the document's text. */
43  textWidth: number
44  treeRows: number
45  docRows: number
46}
47
hooks/view.tsx 472 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 } 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  mention: () => void
23  outside: () => void
24  follow: (href: string) => void
25  swap: () => void
26}
27
28export type PaneProps = {
29  frame: Frame
30  title: string
31  nodes: readonly Node[]
32  treeTop: number
33  selected: string
34  touched: ReadonlySet<string>
35  touchedFolders: ReadonlySet<string>
36  doc: Doc | undefined
37  docTop: number
38  mode: FolioMode
39  on: Actions
40}
41
42const SOFT = { inverse: false, backgroundColor: C.fill } as const
43const BRIGHT = { inverse: false, dimColor: false } as const
44
45const piece = (el: El, span: Span): RenderElement => {
46  const { Text, Link } = el
47  const style = {
48    ...(span.bold === true ? { bold: true } : {}),
49    ...(span.italic === true ? { italic: true } : {}),
50    ...(span.underline === true ? { underline: true } : {}),
51    ...(span.strike === true ? { strikethrough: true } : {}),
52    ...(span.dim === true ? { dimColor: true } : {}),
53    ...(span.color === undefined ? {} : { color: span.color }),
54    ...(span.bg === undefined ? {} : { backgroundColor: span.bg }),
55  }
56
57  // A bare address is left as text: the terminal finds it, and it never shows twice.
58  if (span.href !== undefined && /^https?:\/\//.test(span.href) && span.href.length < 2000 && span.text !== span.href) {
59    return (
60      <Link href={span.href}>
61        <Text {...style}>{span.text}</Text>
62      </Link>
63    )
64  }
65
66  return <Text {...style}>{span.text}</Text>
67}
68
69/** One row of styled text, cut to `cells`. */
70const line = (el: El, spans: readonly Span[], cells: number): RenderElement => {
71  const { Text } = el
72  const fitted = fitSpans(spans.filter(span => span.text !== ''), cells)
73
74  if (fitted.length === 0) {
75    return <Text> </Text>
76  }
77
78  return <Text wrap="truncate-end">{fitted.map(span => piece(el, span))}</Text>
79}
80
81const empty = (el: El): RenderElement => <el.Text> </el.Text>
82
83// --- the tree -----------------------------------------------------------------
84
85const treeRow = (el: El, props: PaneProps, node: Node, cells: number): RenderElement => {
86  const { Box, Text, Button } = el
87  const indent = '  '.repeat(Math.min(node.depth, 12))
88
89  if (node.isEmpty) {
90    return (
91      <Text color={C.faint} wrap="truncate-end">
92        {`   ${indent}empty`}
93      </Text>
94    )
95  }
96
97  const isOpenFile = !node.isDir && node.rel === props.selected
98  const room = Math.max(4, cells - 4 - indent.length)
99  const name = pad(truncateMiddle(node.name, room), room)
100  const mark = node.isDir ? (node.isOpen ? '▾ ' : '▸ ') : '  '
101  const dot = props.touched.has(node.rel) ? '●' : node.isDir && !node.isOpen && props.touchedFolders.has(node.rel) ? '•' : ' '
102  const isQuiet = node.name.startsWith('.')
103
104  return (
105    <Box key={`row:${node.rel}`} width={cells} {...(isOpenFile ? { backgroundColor: C.fill } : {})}>
106      <Button
107        key={`node:${node.rel}`}
108        plain
109        hover={SOFT}
110        onPress={() => (node.isDir ? props.on.toggle(node.rel) : props.on.open(node.rel))}
111      >
112        {isOpenFile ? <Text color={C.accent}>▎</Text> : ' '}
113        {indent}
114        {node.isDir ? <Text color={C.muted}>{mark}</Text> : mark}
115        <Text {...(isOpenFile ? { bold: true } : {})} {...(isQuiet && !isOpenFile ? { color: C.muted } : {})}>
116          {name}
117        </Text>
118        {dot === ' ' ? ' ' : <Text color={C.touched}>{dot}</Text>}
119      </Button>
120    </Box>
121  )
122}
123
124const tree = (el: El, props: PaneProps): RenderElement => {
125  const { Box, Text, Button } = el
126  const { frame } = props
127  const cells = frame.treeWidth
128  const shown = props.nodes.slice(props.treeTop, props.treeTop + frame.treeRows)
129
130  return (
131    <Box flexDirection="column" width={cells} height={frame.rows} overflow="hidden">
132      <Box key="tree-head" flexDirection="row" width={cells} height={1} overflow="hidden">
133        <Box width={Math.max(1, cells - 3)}>
134          <Text bold wrap="truncate-end">
135            {` ${truncate(props.title, cells - 5)}`}
136          </Text>
137        </Box>
138        {props.frame.isSplit ? (
139          <Button key="swap" plain hover={{ inverse: false, color: C.accent }} onPress={() => props.on.swap()}>
140            <Text color={C.faint}>⇄</Text>
141          </Button>
142        ) : null}
143      </Box>
144      {empty(el)}
145      {shown.map(node => treeRow(el, props, node, cells))}
146      {shown.length === 0 ? <Text color={C.faint}>{'   This folder is empty.'}</Text> : null}
147    </Box>
148  )
149}
150
151/**
152 * A one-cell column the height of the pane. Beside the `view` rows that scroll
153 * (after `lead` rows of header) it shows a thumb where the window stands;
154 * `rest` fills every other row: a divider's line, or nothing.
155 */
156const rail = (el: El, rows: number, lead: number, view: number, total: number, top: number, rest: string): RenderElement => {
157  const { Box, Text } = el
158  const marks: RenderElement[] = []
159  const thumb = total > view ? Math.max(1, Math.round((view * view) / total)) : 0
160  const at = total > view ? lead + Math.round((top / (total - view)) * (view - thumb)) : 0
161
162  for (let r = 0; r < rows; r += 1) {
163    const isThumb = thumb > 0 && r >= at && r < at + thumb
164
165    marks.push(<Text color={C.faint}>{isThumb ? '┃' : rest}</Text>)
166  }
167
168  return (
169    <Box flexDirection="column" width={1} height={rows} overflow="hidden">
170      {marks}
171    </Box>
172  )
173}
174
175// --- the reader ---------------------------------------------------------------
176
177const crumbs = (path: string, cells: number): Span[] => {
178  const parts = path.split('/')
179  const name = parts.pop() ?? ''
180  const tail: Span = { text: truncateMiddle(name, Math.max(4, cells)), bold: true }
181  let left = cells - width(tail.text)
182  const head: string[] = []
183
184  for (let k = parts.length - 1; k >= 0; k -= 1) {
185    const part = `${parts[k] ?? ''} › `
186    const more = k > 0 ? 4 : 0
187
188    if (width(part) + more > left) {
189      if (left >= 4) {
190        head.unshift('… › ')
191      }
192
193      break
194    }
195
196    head.unshift(part)
197    left -= width(part)
198  }
199
200  return [{ text: head.join(''), color: C.muted }, tail]
201}
202
203const header = (el: El, props: PaneProps, cells: number): RenderElement => {
204  const { Box, Text, Button } = el
205  const { doc, frame } = props
206  const hasToggle = doc !== undefined && doc.canToggle
207  const toggleCells = hasToggle ? 'Read   Source'.length + 2 : 0
208  const backCells = frame.isSplit ? 0 : 3
209  const room = Math.max(4, cells - toggleCells - backCells)
210  const label = (text: string, isOn: boolean) => (isOn ? <Text bold color={C.accent}>{text}</Text> : <Text color={C.muted}>{text}</Text>)
211
212  return (
213    <Box key="head" flexDirection="row" width={cells} height={1} overflow="hidden">
214      {frame.isSplit ? null : (
215        <Button key="back" plain hover={BRIGHT} onPress={() => props.on.back()}>
216          <Text color={C.muted}>{'‹  '}</Text>
217        </Button>
218      )}
219      <Box width={room}>{line(el, crumbs(props.selected, room), room)}</Box>
220      {hasToggle ? <Text>{'  '}</Text> : null}
221      {hasToggle ? (
222        <Button key="mode:read" plain hover={{ inverse: false, underline: true }} onPress={() => props.on.mode('read')}>
223          {label('Read', props.mode === 'read')}
224        </Button>
225      ) : null}
226      {hasToggle ? <Text>{'   '}</Text> : null}
227      {hasToggle ? (
228        <Button key="mode:source" plain hover={{ inverse: false, underline: true }} onPress={() => props.on.mode('source')}>
229          {label('Source', props.mode === 'source')}
230        </Button>
231      ) : null}
232    </Box>
233  )
234}
235
236const footer = (el: El, props: PaneProps, cells: number): RenderElement => {
237  const { Box, Text, Button } = el
238  const { doc } = props
239  const actions = doc === undefined ? 0 : doc.isLocked ? 'Reveal   Mention   Open'.length : 'Mention   Open'.length
240  const room = Math.max(0, cells - actions - 2)
241  const meta = doc === undefined ? '' : truncate(doc.meta.join('  ·  '), room)
242  const action = (key: string, text: string, onPress: () => void) => (
243    <Button key={key} plain hover={{ inverse: false, color: C.accent }} onPress={onPress}>
244      <Text color={C.muted}>{text}</Text>
245    </Button>
246  )
247
248  if (doc === undefined) {
249    return empty(el)
250  }
251
252  return (
253    <Box key="foot" flexDirection="row" width={cells} height={1} overflow="hidden">
254      <Box width={room + 2}>
255        <Text color={C.faint} wrap="truncate-end">
256          {meta === '' ? ' ' : meta}
257        </Text>
258      </Box>
259      {doc.isLocked ? action('act:reveal', 'Reveal', props.on.reveal) : null}
260      {doc.isLocked ? <Text>{'   '}</Text> : null}
261      {action('act:mention', 'Mention', props.on.mention)}
262      <Text>{'   '}</Text>
263      {action('act:open', 'Open', props.on.outside)}
264    </Box>
265  )
266}
267
268/** Rows of code that touch are one highlighted block. */
269const codeBlock = (el: El, doc: Doc, run: readonly Row[], cells: number): RenderElement => {
270  const { Box, Code } = el
271  const first = run[0]?.code
272  const gutter = spansWidth(run[0]?.spans ?? [])
273  const source = run.map(row => (row.code === undefined || row.code.line === '' ? ' ' : row.code.line)).join('\n')
274  const language = first?.language
275  const code = (
276    <Code
277      source={source}
278      wrap="truncate-end"
279      {...(language === undefined ? (first?.block === 0 ? { path: baseOf(doc.path) } : {}) : { language })}
280      {...(first?.number === undefined ? {} : { startLine: first.number })}
281    />
282  )
283
284  return (
285    <Box flexDirection="row" width={cells} height={run.length} overflow="hidden">
286      {gutter === 0 ? null : (
287        <Box flexDirection="column" width={gutter}>
288          {run.map(row => line(el, row.spans, gutter))}
289        </Box>
290      )}
291      <Box flexDirection="column" width={Math.max(1, cells - gutter)}>
292        {code}
293      </Box>
294    </Box>
295  )
296}
297
298const textRow = (el: El, props: PaneProps, row: Row, index: number, cells: number): RenderElement => {
299  const { Box, Text, Button } = el
300
301  if (row.link === undefined) {
302    return line(el, row.spans, cells)
303  }
304
305  const href = row.link
306  const fitted = fitSpans(row.spans.filter(span => span.text !== ''), cells)
307
308  return (
309    <Box key={`link:${index}`} width={cells} height={1} overflow="hidden">
310      <Button key={`go:${index}`} plain hover={SOFT} onPress={() => props.on.follow(href)}>
311        {fitted.map(span => (
312          <Text
313            {...(span.bold === true ? { bold: true } : {})}
314            {...(span.italic === true ? { italic: true } : {})}
315            {...(span.underline === true ? { underline: true } : {})}
316            {...(span.dim === true ? { dimColor: true } : {})}
317            {...(span.color === undefined ? {} : { color: span.color })}
318          >
319            {span.text}
320          </Text>
321        ))}
322      </Button>
323    </Box>
324  )
325}
326
327const body = (el: El, props: PaneProps, cells: number, rows: number): RenderElement => {
328  const { Box, Text, Image } = el
329  const { doc } = props
330  const out: RenderElement[] = []
331
332  if (doc === undefined) {
333    const lead = Math.max(0, Math.floor(rows / 3))
334    const say = (text: string, isStrong: boolean) => {
335      const free = ' '.repeat(Math.max(0, Math.floor((cells - width(text)) / 2)))
336
337      return isStrong ? <Text bold>{free + text}</Text> : <Text color={C.muted}>{free + text}</Text>
338    }
339
340    for (let r = 0; r < lead; r += 1) {
341      out.push(empty(el))
342    }
343
344    out.push(say(props.title, true), empty(el), say('Select a file to read it here.', false))
345  } else if (doc.picture !== undefined) {
346    const { picture } = doc
347    const lead = Math.max(0, Math.floor((rows - picture.rows) / 2))
348
349    for (let r = 0; r < lead; r += 1) {
350      out.push(empty(el))
351    }
352
353    out.push(
354      <Box width={cells} height={picture.rows} justifyContent="center">
355        {Image === undefined ? (
356          <Text color={C.muted}>{picture.alt}</Text>
357        ) : (
358          <Image
359            key="picture"
360            source={{ file: picture.file, format: 'png', generation: picture.generation }}
361            columns={picture.columns}
362            rows={picture.rows}
363            alt={picture.alt}
364          />
365        )}
366      </Box>,
367    )
368  } else {
369    const shown = doc.rows.slice(props.docTop, props.docTop + rows)
370    const lead = doc.isCard ? Math.max(0, Math.floor((rows - shown.length) / 3)) : 0
371
372    for (let r = 0; r < lead; r += 1) {
373      out.push(empty(el))
374    }
375
376    for (let r = 0; r < shown.length; r += 1) {
377      const row = shown[r]
378
379      if (row === undefined) {
380        continue
381      }
382
383      if (row.code === undefined) {
384        out.push(textRow(el, props, row, props.docTop + r, cells))
385        continue
386      }
387
388      let end = r + 1
389
390      while (end < shown.length && shown[end]?.code?.block === row.code.block) {
391        end += 1
392      }
393
394      out.push(codeBlock(el, doc, shown.slice(r, end), cells))
395      r = end - 1
396    }
397  }
398
399  return (
400    <Box flexDirection="column" width={cells} height={rows} overflow="hidden">
401      {out}
402    </Box>
403  )
404}
405
406const reader = (el: El, props: PaneProps): RenderElement => {
407  const { Box, Text } = el
408  const { frame, doc } = props
409  const cells = frame.textWidth
410  const isBare = doc === undefined && frame.isSplit
411
412  return (
413    <Box flexDirection="column" width={cells} height={frame.rows} overflow="hidden">
414      {isBare ? empty(el) : header(el, props, cells)}
415      <Text color={C.faint}>{isBare ? ' ' : '─'.repeat(cells)}</Text>
416      {empty(el)}
417      {body(el, props, cells, frame.docRows)}
418      {empty(el)}
419      {footer(el, props, cells)}
420    </Box>
421  )
422}
423
424/** The whole pane: tree and reader side by side when there is room, else one of them. */
425export const drawPane = (el: El, props: PaneProps): RenderElement => {
426  const { Box, Text } = el
427  const { frame, doc } = props
428  const docTotal = doc === undefined || doc.picture !== undefined ? 0 : doc.rows.length
429  const docRail = (rest: string) => rail(el, frame.rows, 3, frame.docRows, docTotal, props.docTop, rest)
430  const treeRail = (rest: string) => rail(el, frame.rows, 2, frame.treeRows, props.nodes.length, props.treeTop, rest)
431
432  if (!frame.isSplit) {
433    if (props.selected === '') {
434      return tree(el, props)
435    }
436
437    return (
438      <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
439        <Text> </Text>
440        {reader(el, props)}
441        <Text> </Text>
442        {docRail(' ')}
443      </Box>
444    )
445  }
446
447  // The line between the two parts carries the thumb of the part on its left.
448  if (frame.isTreeRight) {
449    return (
450      <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
451        <Text> </Text>
452        {reader(el, props)}
453        <Text>{'  '}</Text>
454        {docRail('│')}
455        {tree(el, props)}
456        {treeRail(' ')}
457      </Box>
458    )
459  }
460
461  return (
462    <Box flexDirection="row" width={frame.columns} height={frame.rows} overflow="hidden">
463      {tree(el, props)}
464      {treeRail('│')}
465      <Text>{'  '}</Text>
466      {reader(el, props)}
467      <Text> </Text>
468      {docRail(' ')}
469    </Box>
470  )
471}
472
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 18 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  /** The one accent: the open file's mark, inline code, the active mode. */
5  accent: 'suggestion',
6  /** Inline code and paths in a document. */
7  code: 'professionalBlue',
8  /** Secondary text: markers, meta, breadcrumbs. */
9  muted: 'inactive',
10  /** Rules and guides. */
11  faint: 'subtle',
12  /** The soft fill under a hovered or open row. */
13  fill: 'userMessageBackground',
14  /** A file Claude wrote in this session. */
15  touched: 'claude',
16  done: 'success',
17} as const
18
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