surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| @@ -21,6 +21,5 @@ | ||
| 21 | 21 | export * from './tools' |
| 22 | 22 | export * from './turns' |
| 23 | 23 | export * from './views' |
| 24 | -export * from './word-diff' | |
| 25 | 24 | |
| 26 | 25 | export * as default from '.' |
| @@ -457,14 +457,14 @@ | ||
| 457 | 457 | return next(e) |
| 458 | 458 | } |
| 459 | 459 | |
| 460 | - const { Box, Text, Button, Select } = await $.ui.resolve(e) | |
| 460 | + const { Box, Text, Button, Select, Code } = await $.ui.resolve(e) | |
| 461 | 461 | wasDrawnSinceProbe = true |
| 462 | 462 | columns = e.viewport?.columns ?? columns |
| 463 | 463 | model = { ...model, isFocused: e.props.isFocused } |
| 464 | 464 | |
| 465 | 465 | return Views.paneView( |
| 466 | 466 | { |
| 467 | - ui: { Box, Text, Button, Select }, | |
| 467 | + ui: { Box, Text, Button, Select, Code }, | |
| 468 | 468 | actions: actionsOf(host), |
| 469 | 469 | columns: e.props.bodyColumns, |
| 470 | 470 | }, |
| @@ -1,14 +1,0 @@ | ||
| 1 | -import type Types from '../types' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * The theme key behind a run of each kind: the added and removed bands, and | |
| 5 | - * none behind context, which draws on the terminal's own background. | |
| 6 | - * | |
| 7 | - * The theme's `background` key is the background-task accent, a cyan. | |
| 8 | - */ | |
| 9 | -export const BACKGROUNDS: Readonly<Record<Types.LineKind, string | undefined>> = | |
| 10 | - { | |
| 11 | - added: 'diffAdded', | |
| 12 | - removed: 'diffRemoved', | |
| 13 | - context: undefined, | |
| 14 | - } |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './backgrounds.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,0 +1,76 @@ | ||
| 1 | +import Git from '../../../git' | |
| 2 | +import { HUNK_DIVIDER } from '../hunk-divider' | |
| 3 | +import { HUNK_DIVIDER_NODES } from '../hunk-divider-nodes' | |
| 4 | +import { hunkSourceOf } from '../hunk-source-of' | |
| 5 | +import { leadingHunkOf } from '../leading-hunk-of' | |
| 6 | +import { MAX_BODY_CHARS } from '../max-body-chars' | |
| 7 | +import { MAX_BODY_NODES } from '../max-body-nodes' | |
| 8 | +import { MAX_CODE_CHARS } from '../max-code-chars' | |
| 9 | +import { subHunksOf } from '../sub-hunks-of' | |
| 10 | +import type Types from '../types' | |
| 11 | +import { drawnLineOf } from './drawn-line-of' | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * A file's hunks as the sources of `Code` blocks, each at most | |
| 15 | + * MAX_CODE_CHARS, hunks sharing a block (the engine's ellipsis between). | |
| 16 | + * | |
| 17 | + * A new block at a hunk gets HUNK_DIVIDER; a hunk too long for one is split | |
| 18 | + * (subHunksOf) into blocks stacked with none. The body stops, truncated, at | |
| 19 | + * the whole lines MAX_BODY_CHARS and MAX_BODY_NODES hold, as the host counts. | |
| 20 | + * | |
| 21 | + * @param hunks the file's hunks | |
| 22 | + * @returns the blocks in order, and whether any line was left out or cut | |
| 23 | + */ | |
| 24 | +export function codeBlocksOf(hunks: readonly Git.Hunk[]): Types.CodeBody { | |
| 25 | + const blocks: Types.CodeBlock[] = [] | |
| 26 | + let chars = 0 | |
| 27 | + let nodes = 0 | |
| 28 | + let isTruncated = false | |
| 29 | + | |
| 30 | + for (const hunk of hunks) { | |
| 31 | + const lines = hunk.lines.filter(Git.isBodyLine).map(drawnLineOf) | |
| 32 | + const split = subHunksOf({ ...hunk, lines }, MAX_CODE_CHARS) | |
| 33 | + isTruncated ||= split.isTruncated | |
| 34 | + | |
| 35 | + for (const [at, piece] of split.hunks.entries()) { | |
| 36 | + const last = blocks.at(-1) | |
| 37 | + const source = hunkSourceOf(piece) | |
| 38 | + const isHunkStart = at === 0 | |
| 39 | + const isJoining = | |
| 40 | + isHunkStart && | |
| 41 | + last !== undefined && | |
| 42 | + last.source.length + 1 + source.length <= MAX_CODE_CHARS | |
| 43 | + const hasDivider = isHunkStart && !isJoining && last !== undefined | |
| 44 | + const overhead = isJoining ? 1 : hasDivider ? HUNK_DIVIDER.length : 0 | |
| 45 | + const addedNodes = isJoining | |
| 46 | + ? 0 | |
| 47 | + : 1 + (hasDivider ? HUNK_DIVIDER_NODES : 0) | |
| 48 | + const room = MAX_BODY_CHARS - chars - overhead | |
| 49 | + const fitted = source.length <= room ? piece : leadingHunkOf(piece, room) | |
| 50 | + const isOverNodes = nodes + addedNodes > MAX_BODY_NODES | |
| 51 | + | |
| 52 | + if (isOverNodes || fitted.lines.length === 0) { | |
| 53 | + return { blocks, isTruncated: true } | |
| 54 | + } | |
| 55 | + | |
| 56 | + const text = hunkSourceOf(fitted) | |
| 57 | + | |
| 58 | + if (isJoining) { | |
| 59 | + blocks.pop() | |
| 60 | + } | |
| 61 | + | |
| 62 | + blocks.push({ | |
| 63 | + source: isJoining ? `${last.source}\n${text}` : text, | |
| 64 | + hasDivider: isJoining ? last.hasDivider : hasDivider, | |
| 65 | + }) | |
| 66 | + chars += overhead + text.length | |
| 67 | + nodes += addedNodes | |
| 68 | + | |
| 69 | + if (fitted.lines.length < piece.lines.length) { | |
| 70 | + return { blocks, isTruncated: true } | |
| 71 | + } | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + return { blocks, isTruncated } | |
| 76 | +} |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import Layout from '../../../layout' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A hunk line made safe for a `Code` source: its marker and its tabs kept, | |
| 5 | + * the engine laying tabs out as it does every diff it draws. | |
| 6 | + * | |
| 7 | + * Between the tabs sanitizeLine's rule holds: every control, format and | |
| 8 | + * invisible character dropped, so nothing can reorder or hide the line. | |
| 9 | + * | |
| 10 | + * @param line a body line, its `+`, `-` or space marker first | |
| 11 | + * @returns the drawable line | |
| 12 | + */ | |
| 13 | +export const drawnLineOf = (line: string) => | |
| 14 | + line.slice(0, 1) + | |
| 15 | + line.slice(1).split('\t').map(Layout.sanitizeLine).join('\t') |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './drawn-line-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './code-blocks-of.js' | |
| 2 | +export * from './drawn-line-of' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -5,22 +5,19 @@ | ||
| 5 | 5 | |
| 6 | 6 | import type { Kit } from '../kit' |
| 7 | 7 | import Layout from '../layout' |
| 8 | +import { codeBlocksOf } from './code-blocks-of' | |
| 8 | 9 | import type { DetailModel } from './detail-model' |
| 9 | -import { gutterCellsOf } from './gutter-cells-of' | |
| 10 | -import { hunkBody } from './hunk-body' | |
| 11 | -import { linesOf } from './lines-of' | |
| 12 | -import { MARKER_CELLS } from './marker-cells' | |
| 13 | -import { maxRowsOf } from './max-rows-of' | |
| 10 | +import { HUNK_DIVIDER } from './hunk-divider' | |
| 11 | +import { MAX_CODE_CHARS } from './max-code-chars' | |
| 14 | 12 | import { placeholderOf } from './placeholder-of' |
| 15 | -import { rowsOf } from './rows-of' | |
| 16 | 13 | |
| 17 | 14 | /** |
| 18 | - * The selected file under the list: its bold path and asides (renamed | |
| 19 | - * from, untracked, truncated) cut to the width, the ask Button, the body. | |
| 15 | + * The selected file under the list: its bold path and asides, each name | |
| 16 | + * cut from its start to the width, the ask Button, the body. | |
| 20 | 17 | * |
| 21 | - * The body is a placeholder or the hunks (cut to the rows the body's char | |
| 22 | - * and node budgets hold, maxRowsOf), then the footer; the gutter fits the | |
| 23 | - * largest number. | |
| 18 | + * The body is a placeholder, or the hunks as the engine's diff `Code` blocks | |
| 19 | + * (codeBlocksOf) named by the path, a dim divider where two meet at a hunk; | |
| 20 | + * then the footer when anything was cut. | |
| 24 | 21 | * |
| 25 | 22 | * @param kit the elements and the width |
| 26 | 23 | * @param detail the selected file |
| @@ -32,18 +29,13 @@ | ||
| 32 | 29 | detail: DetailModel, |
| 33 | 30 | onToggleAsk: () => void, |
| 34 | 31 | ): RenderElement { |
| 35 | - const { Box, Text, Button } = kit.ui | |
| 32 | + const { Box, Text, Button, Code } = kit.ui | |
| 36 | 33 | const placeholder = placeholderOf(detail) |
| 37 | - const hunks = placeholder ? [] : (detail.body?.hunks ?? []) | |
| 38 | - const lines = linesOf(hunks) | |
| 39 | - const gutterCells = gutterCellsOf(lines) | |
| 40 | - const contentCells = Math.max(1, kit.columns - gutterCells - MARKER_CELLS) | |
| 41 | - const rows = placeholder ? [] : rowsOf(lines, contentCells) | |
| 42 | - const cells = { gutterCells, contentCells } | |
| 43 | - const maxRows = maxRowsOf(rows, cells) | |
| 44 | - const isTruncated = detail.body?.isTruncated === true || rows.length > maxRows | |
| 45 | - const nameOf = (path: string) => | |
| 46 | - Layout.truncateStart(Layout.sanitizeName(path), kit.columns) | |
| 34 | + const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? [])) | |
| 35 | + const isTruncated = detail.body?.isTruncated === true || code.isTruncated | |
| 36 | + const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS) | |
| 37 | + const nameOf = (name: string) => | |
| 38 | + Layout.truncateStart(Layout.sanitizeName(name), kit.columns) | |
| 47 | 39 | const renamedFrom = |
| 48 | 40 | detail.renamedFrom === null ? null : nameOf(detail.renamedFrom) |
| 49 | 41 | const asides = [ |
| @@ -52,14 +44,6 @@ | ||
| 52 | 44 | isTruncated ? 'truncated' : null, |
| 53 | 45 | ].filter(word => word !== null) |
| 54 | 46 | const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})` |
| 55 | - const notes = (placeholder ?? []).map(line => ( | |
| 56 | - <Text dimColor italic wrap="wrap"> | |
| 57 | - {line} | |
| 58 | - </Text> | |
| 59 | - )) | |
| 60 | - const body = placeholder | |
| 61 | - ? notes | |
| 62 | - : [hunkBody(kit.ui, rows.slice(0, maxRows), cells)] | |
| 63 | 47 | const ask = placeholder |
| 64 | 48 | ? [] |
| 65 | 49 | : [ |
| @@ -74,19 +58,32 @@ | ||
| 74 | 58 | </Text>, |
| 75 | 59 | ] |
| 76 | 60 | : [] |
| 61 | + const notes = (placeholder ?? []).map(line => ( | |
| 62 | + <Text dimColor italic wrap="wrap"> | |
| 63 | + {line} | |
| 64 | + </Text> | |
| 65 | + )) | |
| 66 | + const hunks = code.blocks.flatMap(block => [ | |
| 67 | + ...(block.hasDivider ? [<Text dimColor>{HUNK_DIVIDER}</Text>] : []), | |
| 68 | + <Code source={block.source} format="diff" path={path} />, | |
| 69 | + ]) | |
| 77 | 70 | |
| 78 | - const header = ( | |
| 79 | - <Box flexDirection="row"> | |
| 71 | + return ( | |
| 72 | + <Box flexDirection="column"> | |
| 80 | 73 | {[ |
| 81 | - <Text bold wrap="truncate-start"> | |
| 82 | - {nameOf(detail.path)} | |
| 83 | - </Text>, | |
| 84 | - <Text dimColor>{aside}</Text>, | |
| 85 | - <Box flexGrow={1} />, | |
| 86 | - ...ask, | |
| 74 | + <Box flexDirection="row"> | |
| 75 | + {[ | |
| 76 | + <Text bold wrap="truncate-start"> | |
| 77 | + {nameOf(detail.path)} | |
| 78 | + </Text>, | |
| 79 | + <Text dimColor>{aside}</Text>, | |
| 80 | + <Box flexGrow={1} />, | |
| 81 | + ...ask, | |
| 82 | + ]} | |
| 83 | + </Box>, | |
| 84 | + ...(placeholder ? notes : hunks), | |
| 85 | + ...footer, | |
| 87 | 86 | ]} |
| 88 | 87 | </Box> |
| 89 | 88 | ) |
| 90 | - | |
| 91 | - return <Box flexDirection="column">{[header, ...body, ...footer]}</Box> | |
| 92 | 89 | } |
| @@ -1,12 +1,0 @@ | ||
| 1 | -import type Types from '../types' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * How wide the line-number gutter is for these lines: the longest number's | |
| 5 | - * digits and one leading cell (StructuredDiff's digits + 1). | |
| 6 | - * | |
| 7 | - * @param lines the numbered lines | |
| 8 | - * @returns the gutter's width in cells | |
| 9 | - */ | |
| 10 | -export const gutterCellsOf = (lines: readonly Types.DiffLine[]) => | |
| 11 | - String(lines.reduce((most, line) => Math.max(most, line.lineNumber), 0)) | |
| 12 | - .length + 1 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './gutter-cells-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,8 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The two widths a body is drawn in: the gutter's and the content's, the | |
| 3 | - * marker cells between them. | |
| 4 | - */ | |
| 5 | -export type BodyCells = { | |
| 6 | - gutterCells: number | |
| 7 | - contentCells: number | |
| 8 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './body-cells.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,114 +1,0 @@ | ||
| 1 | -/* @jsxRuntime classic */ | |
| 2 | -/* @jsx h */ | |
| 3 | -/* @jsxFrag Fragment */ | |
| 4 | -import type { RenderElement, RenderNode } from 'claude-code' | |
| 5 | - | |
| 6 | -import type Kit from '../../kit' | |
| 7 | -import Layout from '../../layout' | |
| 8 | -import { BACKGROUNDS } from '../backgrounds' | |
| 9 | -import { MARKER_CELLS } from '../marker-cells' | |
| 10 | -import { MAX_RUN_CHARS } from '../max-run-chars' | |
| 11 | -import { runsOf } from '../runs-of' | |
| 12 | -import type Types from '../types' | |
| 13 | -import { WORD_BACKGROUNDS } from '../word-backgrounds' | |
| 14 | -import type { BodyCells } from './body-cells' | |
| 15 | - | |
| 16 | -/** | |
| 17 | - * A file's rows drawn as StructuredDiff's fallback draws them, in few | |
| 18 | - * nodes: one dim gutter Text beside a column of one Text per run. | |
| 19 | - * | |
| 20 | - * Added and removed runs sit on their theme bands, changed words on the | |
| 21 | - * word bands, every row is padded to the band's width less its text's | |
| 22 | - * chargedWidth; a run's text and the gutter split at MAX_RUN_CHARS a string. | |
| 23 | - * | |
| 24 | - * @param ui the surface's elements | |
| 25 | - * @param rows the laid-out rows (rowsOf), each exactly one terminal row | |
| 26 | - * @param cells the gutter's and the content's widths | |
| 27 | - * @returns the body element | |
| 28 | - */ | |
| 29 | -export function hunkBody( | |
| 30 | - ui: Kit.Ui, | |
| 31 | - rows: readonly Types.Row[], | |
| 32 | - cells: BodyCells, | |
| 33 | -): RenderElement { | |
| 34 | - const { Box, Text } = ui | |
| 35 | - const bandCells = MARKER_CELLS + cells.contentCells | |
| 36 | - | |
| 37 | - function gutterChildren(): string[] { | |
| 38 | - const children: string[] = [] | |
| 39 | - let pending = '' | |
| 40 | - | |
| 41 | - rows.forEach((row, at) => { | |
| 42 | - const label = row.gutter.padStart(cells.gutterCells) | |
| 43 | - const text = `${at === 0 ? '' : '\n'}${label}` | |
| 44 | - | |
| 45 | - if (pending.length + text.length > MAX_RUN_CHARS) { | |
| 46 | - children.push(pending) | |
| 47 | - pending = '' | |
| 48 | - } | |
| 49 | - | |
| 50 | - pending += text | |
| 51 | - }) | |
| 52 | - | |
| 53 | - return pending === '' ? children : [...children, pending] | |
| 54 | - } | |
| 55 | - | |
| 56 | - function runChildren(run: Types.Run): RenderNode[] { | |
| 57 | - const children: RenderNode[] = [] | |
| 58 | - let pending = '' | |
| 59 | - | |
| 60 | - function flush() { | |
| 61 | - if (pending !== '') { | |
| 62 | - children.push(pending) | |
| 63 | - pending = '' | |
| 64 | - } | |
| 65 | - } | |
| 66 | - | |
| 67 | - function plain(text: string) { | |
| 68 | - if (pending.length + text.length > MAX_RUN_CHARS) { | |
| 69 | - flush() | |
| 70 | - } | |
| 71 | - | |
| 72 | - pending += text | |
| 73 | - } | |
| 74 | - | |
| 75 | - function marked(text: string) { | |
| 76 | - flush() | |
| 77 | - children.push( | |
| 78 | - <Text backgroundColor={WORD_BACKGROUNDS[run.kind]}>{text}</Text>, | |
| 79 | - ) | |
| 80 | - } | |
| 81 | - | |
| 82 | - run.rows.forEach((row, at) => { | |
| 83 | - const used = row.segments.reduce( | |
| 84 | - (sum, segment) => sum + Layout.chargedWidth(segment.text), | |
| 85 | - MARKER_CELLS, | |
| 86 | - ) | |
| 87 | - plain(`${at === 0 ? '' : '\n'} ${row.marker} `) | |
| 88 | - | |
| 89 | - for (const segment of row.segments) { | |
| 90 | - ;(segment.isChanged ? marked : plain)(segment.text) | |
| 91 | - } | |
| 92 | - | |
| 93 | - plain(' '.repeat(Math.max(0, bandCells - used))) | |
| 94 | - }) | |
| 95 | - flush() | |
| 96 | - | |
| 97 | - return children | |
| 98 | - } | |
| 99 | - | |
| 100 | - return ( | |
| 101 | - <Box flexDirection="row"> | |
| 102 | - <Text dimColor wrap="truncate-end"> | |
| 103 | - {gutterChildren()} | |
| 104 | - </Text> | |
| 105 | - <Box flexDirection="column" flexShrink={0} width={bandCells}> | |
| 106 | - {runsOf(rows).map(run => ( | |
| 107 | - <Text backgroundColor={BACKGROUNDS[run.kind]} wrap="truncate-end"> | |
| 108 | - {runChildren(run)} | |
| 109 | - </Text> | |
| 110 | - ))} | |
| 111 | - </Box> | |
| 112 | - </Box> | |
| 113 | - ) | |
| 114 | -} |
| @@ -1,4 +1,0 @@ | ||
| 1 | -export * from './body-cells' | |
| 2 | -export * from './hunk-body.jsx' | |
| 3 | - | |
| 4 | -export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The dim text between two `Code` blocks that meet at a hunk boundary: the | |
| 3 | + * ellipsis the engine draws between the hunks of one `Code`. | |
| 4 | + */ | |
| 5 | +export const HUNK_DIVIDER = '...' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-divider.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * The nodes one HUNK_DIVIDER costs the tree: its Text and its string. | |
| 3 | + */ | |
| 4 | +export const HUNK_DIVIDER_NODES = 2 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-divider-nodes.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import { countOf } from '../../../count-of' | |
| 2 | +import type Git from '../../../git' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A hunk's `@@ -a,b +c,d @@` header, its counts read off its lines, so a | |
| 6 | + * cut or split hunk still parses: context counts on both sides. | |
| 7 | + * | |
| 8 | + * A side with no lines names the line before its start, as jsdiff's | |
| 9 | + * formatPatch writes a structured hunk and the engine's parser reads back. | |
| 10 | + * | |
| 11 | + * @param hunk the hunk, its lines body lines only | |
| 12 | + * @returns the header line | |
| 13 | + */ | |
| 14 | +export function hunkHeaderOf(hunk: Git.Hunk) { | |
| 15 | + const oldLines = countOf(hunk.lines, line => !line.startsWith('+')) | |
| 16 | + const newLines = countOf(hunk.lines, line => !line.startsWith('-')) | |
| 17 | + const isOldEmpty = oldLines === 0 | |
| 18 | + const isNewEmpty = newLines === 0 | |
| 19 | + const oldStart = isOldEmpty ? Math.max(0, hunk.oldStart - 1) : hunk.oldStart | |
| 20 | + const newStart = isNewEmpty ? Math.max(0, hunk.newStart - 1) : hunk.newStart | |
| 21 | + | |
| 22 | + return `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@` | |
| 23 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-header-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type Git from '../../../git' | |
| 2 | +import { hunkHeaderOf } from '../hunk-header-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A hunk as unified-diff text: its header (hunkHeaderOf), then its lines. | |
| 6 | + * | |
| 7 | + * @param hunk the hunk, its lines body lines only | |
| 8 | + * @returns the text, no trailing newline | |
| 9 | + */ | |
| 10 | +export const hunkSourceOf = (hunk: Git.Hunk) => | |
| 11 | + [hunkHeaderOf(hunk), ...hunk.lines].join('\n') |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-source-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,18 +1,16 @@ | ||
| 1 | -export * from './backgrounds' | |
| 1 | +export * from './code-blocks-of' | |
| 2 | 2 | export * from './detail-model' |
| 3 | 3 | export * from './detail-view.jsx' |
| 4 | -export * from './gutter-cells-of' | |
| 5 | -export * from './hunk-body' | |
| 6 | -export * from './lines-of' | |
| 7 | -export * from './marker-cells' | |
| 4 | +export * from './hunk-divider' | |
| 5 | +export * from './hunk-divider-nodes' | |
| 6 | +export * from './hunk-header-of' | |
| 7 | +export * from './hunk-source-of' | |
| 8 | +export * from './leading-hunk-of' | |
| 8 | 9 | export * from './max-body-chars' |
| 9 | 10 | export * from './max-body-nodes' |
| 10 | -export * from './max-rows-of' | |
| 11 | -export * from './max-run-chars' | |
| 11 | +export * from './max-code-chars' | |
| 12 | 12 | export * from './placeholder-of' |
| 13 | -export * from './rows-of' | |
| 14 | -export * from './runs-of' | |
| 13 | +export * from './sub-hunks-of' | |
| 15 | 14 | export * from './types' |
| 16 | -export * from './word-backgrounds' | |
| 17 | 15 | |
| 18 | 16 | export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './leading-hunk-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,31 @@ | ||
| 1 | +import type Git from '../../../git' | |
| 2 | +import { hunkHeaderOf } from '../hunk-header-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The most of a hunk's first lines whose text (hunkSourceOf) fits the | |
| 6 | + * room, whole lines only: none when the first line alone does not fit. | |
| 7 | + * | |
| 8 | + * @param hunk the hunk, its lines body lines only | |
| 9 | + * @param maxChars the room, in UTF-16 units | |
| 10 | + * @returns the hunk cut to those lines, its starts unchanged | |
| 11 | + */ | |
| 12 | +export function leadingHunkOf(hunk: Git.Hunk, maxChars: number): Git.Hunk { | |
| 13 | + let bodyChars = 0 | |
| 14 | + let taken = 0 | |
| 15 | + | |
| 16 | + for (const line of hunk.lines) { | |
| 17 | + const header = hunkHeaderOf({ | |
| 18 | + ...hunk, | |
| 19 | + lines: hunk.lines.slice(0, taken + 1), | |
| 20 | + }) | |
| 21 | + bodyChars += 1 + line.length | |
| 22 | + | |
| 23 | + if (header.length + bodyChars > maxChars) { | |
| 24 | + break | |
| 25 | + } | |
| 26 | + | |
| 27 | + taken += 1 | |
| 28 | + } | |
| 29 | + | |
| 30 | + return { ...hunk, lines: hunk.lines.slice(0, taken) } | |
| 31 | +} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './lines-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,74 +1,0 @@ | ||
| 1 | -import type Git from '../../../git' | |
| 2 | -import Layout from '../../layout' | |
| 3 | -import type Types from '../types' | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * A file's hunks as numbered lines, hunk after hunk: context and added | |
| 7 | - * lines count the new file's lines, removed ones the old file's. | |
| 8 | - * | |
| 9 | - * Each run of removed lines is paired index by index with the added run | |
| 10 | - * right after it, for the word diff, as the engine's fallback diff pairs. | |
| 11 | - * | |
| 12 | - * @param hunks the parsed hunks | |
| 13 | - * @returns the lines in drawing order | |
| 14 | - */ | |
| 15 | -export function linesOf(hunks: readonly Git.Hunk[]): Types.DiffLine[] { | |
| 16 | - const lines: Types.DiffLine[] = [] | |
| 17 | - | |
| 18 | - for (const hunk of hunks) { | |
| 19 | - let oldNumber = hunk.oldStart | |
| 20 | - let newNumber = hunk.newStart | |
| 21 | - let removedRun: Types.DiffLine[] = [] | |
| 22 | - let pairing = 0 | |
| 23 | - | |
| 24 | - for (const raw of hunk.lines) { | |
| 25 | - const text = Layout.sanitizeLine(raw.slice(1)) | |
| 26 | - const kind: Types.LineKind = raw.startsWith('+') | |
| 27 | - ? 'added' | |
| 28 | - : raw.startsWith('-') | |
| 29 | - ? 'removed' | |
| 30 | - : 'context' | |
| 31 | - | |
| 32 | - if (kind === 'removed') { | |
| 33 | - const isNewRun = pairing > 0 | |
| 34 | - | |
| 35 | - if (isNewRun) { | |
| 36 | - removedRun = [] | |
| 37 | - pairing = 0 | |
| 38 | - } | |
| 39 | - | |
| 40 | - const line = { kind, lineNumber: oldNumber, text, pairedText: null } | |
| 41 | - removedRun.push(line) | |
| 42 | - lines.push(line) | |
| 43 | - oldNumber += 1 | |
| 44 | - continue | |
| 45 | - } | |
| 46 | - | |
| 47 | - if (kind === 'added') { | |
| 48 | - const partner = removedRun[pairing] | |
| 49 | - | |
| 50 | - if (partner) { | |
| 51 | - partner.pairedText = text | |
| 52 | - pairing += 1 | |
| 53 | - } | |
| 54 | - | |
| 55 | - lines.push({ | |
| 56 | - kind, | |
| 57 | - lineNumber: newNumber, | |
| 58 | - text, | |
| 59 | - pairedText: partner ? partner.text : null, | |
| 60 | - }) | |
| 61 | - newNumber += 1 | |
| 62 | - continue | |
| 63 | - } | |
| 64 | - | |
| 65 | - removedRun = [] | |
| 66 | - pairing = 0 | |
| 67 | - lines.push({ kind, lineNumber: newNumber, text, pairedText: null }) | |
| 68 | - oldNumber += 1 | |
| 69 | - newNumber += 1 | |
| 70 | - } | |
| 71 | - } | |
| 72 | - | |
| 73 | - return lines | |
| 74 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './marker-cells.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The cells between the gutter and a row's content: a space, the `+`, `-` | |
| 3 | - * or space marker, and a space. | |
| 4 | - */ | |
| 5 | -export const MARKER_CELLS = 3 |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | - * The most characters a file's drawn body holds in all, padding included: | |
| 3 | - * under the host's whole-tree cap (100 000) with room for the pane's rest. | |
| 2 | + * The most characters a file's drawn body holds, its `Code` sources and | |
| 3 | + * dividers in all. | |
| 4 | + * | |
| 5 | + * Under the host's whole-tree cap (100 000), with room for the pane's rest. | |
| 4 | 6 | */ |
| 5 | 7 | export const MAX_BODY_CHARS = 80_000 |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | - * The most nodes a file's drawn body holds, strings included: under the | |
| 3 | - * host's whole-tree cap (2 000) with room for the list and the pane's rest. | |
| 2 | + * The most nodes a file's drawn body holds, a `Code` one and a divider two. | |
| 3 | + * | |
| 4 | + * Under the host's whole-tree cap (2 000), with room for the list and the | |
| 5 | + * pane's rest. | |
| 4 | 6 | */ |
| 5 | 7 | export const MAX_BODY_NODES = 1_200 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './max-code-chars.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The most characters one `Code` source (or path) holds: the host's cap on | |
| 3 | + * a leaf's text (10 000), which a hooks module cannot import. | |
| 4 | + */ | |
| 5 | +export const MAX_CODE_CHARS = 10_000 |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The nodes the body costs before any row: its row Box, the gutter Text | |
| 3 | - * beside it, and the column Box that holds the runs. | |
| 4 | - */ | |
| 5 | -export const FRAME_NODES = 3 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './frame-nodes.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The nodes one word highlight costs inside a run: the highlighted Text, | |
| 3 | - * its string, and the string the run resumes with. | |
| 4 | - */ | |
| 5 | -export const HIGHLIGHT_NODES = 3 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './highlight-nodes.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,6 +1,0 @@ | ||
| 1 | -export * from './frame-nodes' | |
| 2 | -export * from './highlight-nodes' | |
| 3 | -export * from './max-rows-of.js' | |
| 4 | -export * from './run-nodes' | |
| 5 | - | |
| 6 | -export * as default from '.' |
| @@ -1,59 +1,0 @@ | ||
| 1 | -import Layout from '../../layout' | |
| 2 | -import type { BodyCells } from '../hunk-body/body-cells' | |
| 3 | -import { MARKER_CELLS } from '../marker-cells' | |
| 4 | -import { MAX_BODY_CHARS } from '../max-body-chars' | |
| 5 | -import { MAX_BODY_NODES } from '../max-body-nodes' | |
| 6 | -import { MAX_RUN_CHARS } from '../max-run-chars' | |
| 7 | -import type Types from '../types' | |
| 8 | -import { FRAME_NODES } from './frame-nodes' | |
| 9 | -import { HIGHLIGHT_NODES } from './highlight-nodes' | |
| 10 | -import { RUN_NODES } from './run-nodes' | |
| 11 | - | |
| 12 | -/** | |
| 13 | - * How many of these rows the body may draw under MAX_BODY_CHARS and | |
| 14 | - * MAX_BODY_NODES, counted as the host counts what hunkBody draws. | |
| 15 | - * | |
| 16 | - * A row costs its gutter, marker, segments and padding in UTF-16 units (an | |
| 17 | - * astral letter costs two); a change of kind opens a run (a Text and its | |
| 18 | - * string), a highlight is three nodes, the gutter and long runs split. | |
| 19 | - * | |
| 20 | - * @param rows the laid-out rows, in order | |
| 21 | - * @param cells the gutter's and the content's widths | |
| 22 | - * @returns the count of leading rows whose drawn tree fits both budgets | |
| 23 | - */ | |
| 24 | -export function maxRowsOf(rows: readonly Types.Row[], cells: BodyCells) { | |
| 25 | - let chars = 0 | |
| 26 | - let nodes = FRAME_NODES | |
| 27 | - let kept = 0 | |
| 28 | - let kind: Types.LineKind | undefined | |
| 29 | - | |
| 30 | - for (const row of rows) { | |
| 31 | - let units = 0 | |
| 32 | - let charged = 0 | |
| 33 | - let highlights = 0 | |
| 34 | - | |
| 35 | - for (const segment of row.segments) { | |
| 36 | - units += segment.text.length | |
| 37 | - charged += Layout.chargedWidth(segment.text) | |
| 38 | - highlights += segment.isChanged ? 1 : 0 | |
| 39 | - } | |
| 40 | - | |
| 41 | - chars += | |
| 42 | - cells.gutterCells + | |
| 43 | - 2 + | |
| 44 | - MARKER_CELLS + | |
| 45 | - units + | |
| 46 | - Math.max(0, cells.contentCells - charged) | |
| 47 | - nodes += (row.kind === kind ? 0 : RUN_NODES) + highlights * HIGHLIGHT_NODES | |
| 48 | - kind = row.kind | |
| 49 | - const splits = Math.ceil((2 * chars) / MAX_RUN_CHARS) | |
| 50 | - | |
| 51 | - if (chars > MAX_BODY_CHARS || nodes + splits > MAX_BODY_NODES) { | |
| 52 | - return kept | |
| 53 | - } | |
| 54 | - | |
| 55 | - kept += 1 | |
| 56 | - } | |
| 57 | - | |
| 58 | - return kept | |
| 59 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './run-nodes.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,4 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The nodes a change of line kind opens: a Text and its string. | |
| 3 | - */ | |
| 4 | -export const RUN_NODES = 2 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './max-run-chars.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The most characters one Text string of a run holds: under the host's | |
| 3 | - * per-string cap (10 000), which a hooks module cannot import. | |
| 4 | - */ | |
| 5 | -export const MAX_RUN_CHARS = 8_000 |
| @@ -1,5 +1,0 @@ | ||
| 1 | -export * from './max-highlights' | |
| 2 | -export * from './rows-of.js' | |
| 3 | -export * from './segments-of' | |
| 4 | - | |
| 5 | -export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './max-highlights.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * How many word-diff highlights one body may draw; past it lines draw plain, | |
| 3 | - * keeping the tree under its node cap (each highlight is two nodes). | |
| 4 | - */ | |
| 5 | -export const MAX_HIGHLIGHTS = 300 |
| @@ -1,48 +1,0 @@ | ||
| 1 | -import { countOf } from '../../../count-of' | |
| 2 | -import Layout from '../../layout' | |
| 3 | -import type Types from '../types' | |
| 4 | -import { MAX_HIGHLIGHTS } from './max-highlights' | |
| 5 | -import { segmentsOf } from './segments-of' | |
| 6 | - | |
| 7 | -/** | |
| 8 | - * Numbered lines laid into terminal rows: a long line wraps onto | |
| 9 | - * continuation rows with an empty gutter. | |
| 10 | - * | |
| 11 | - * Word highlights stop once MAX_HIGHLIGHTS are spent, keeping the tree | |
| 12 | - * under its node cap. | |
| 13 | - * | |
| 14 | - * @param lines the numbered lines | |
| 15 | - * @param cells the content's room per row after gutter and marker, one at | |
| 16 | - * least | |
| 17 | - * @returns the rows in drawing order | |
| 18 | - */ | |
| 19 | -export function rowsOf( | |
| 20 | - lines: readonly Types.DiffLine[], | |
| 21 | - cells: number, | |
| 22 | -): Types.Row[] { | |
| 23 | - const rows: Types.Row[] = [] | |
| 24 | - let highlights = 0 | |
| 25 | - | |
| 26 | - for (const line of lines) { | |
| 27 | - const marked = segmentsOf(line) | |
| 28 | - const wanted = countOf(marked, segment => segment.isChanged) | |
| 29 | - const isAffordable = highlights + wanted <= MAX_HIGHLIGHTS | |
| 30 | - highlights += isAffordable ? wanted : 0 | |
| 31 | - const segments = isAffordable | |
| 32 | - ? marked | |
| 33 | - : [{ text: line.text, isChanged: false }] | |
| 34 | - const marker = | |
| 35 | - line.kind === 'added' ? '+' : line.kind === 'removed' ? '-' : ' ' | |
| 36 | - | |
| 37 | - Layout.wrapSegments(segments, cells).forEach((wrapped, at) => { | |
| 38 | - rows.push({ | |
| 39 | - kind: line.kind, | |
| 40 | - gutter: at === 0 ? String(line.lineNumber) : '', | |
| 41 | - marker: at === 0 ? marker : ' ', | |
| 42 | - segments: wrapped, | |
| 43 | - }) | |
| 44 | - }) | |
| 45 | - } | |
| 46 | - | |
| 47 | - return rows | |
| 48 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './segments-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,32 +1,0 @@ | ||
| 1 | -import { wordDiff } from '../../../../word-diff' | |
| 2 | -import type Layout from '../../../layout' | |
| 3 | -import type Types from '../../types' | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * A line's content as segments: one plain segment, or, for a paired line | |
| 7 | - * whose word diff holds, its own side's parts with the changed ones marked. | |
| 8 | - * | |
| 9 | - * @param line the numbered line | |
| 10 | - * @returns the segments in order | |
| 11 | - */ | |
| 12 | -export function segmentsOf(line: Types.DiffLine): Layout.Segment[] { | |
| 13 | - const plain = [{ text: line.text, isChanged: false }] | |
| 14 | - | |
| 15 | - if (line.pairedText === null || line.kind === 'context') { | |
| 16 | - return plain | |
| 17 | - } | |
| 18 | - | |
| 19 | - const isRemoved = line.kind === 'removed' | |
| 20 | - const parts = isRemoved | |
| 21 | - ? wordDiff(line.text, line.pairedText) | |
| 22 | - : wordDiff(line.pairedText, line.text) | |
| 23 | - const ownSide = isRemoved ? 'removed' : 'added' | |
| 24 | - | |
| 25 | - if (!parts) { | |
| 26 | - return plain | |
| 27 | - } | |
| 28 | - | |
| 29 | - return parts | |
| 30 | - .filter(part => part.kind === 'same' || part.kind === ownSide) | |
| 31 | - .map(part => ({ text: part.text, isChanged: part.kind === ownSide })) | |
| 32 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './runs-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,23 +1,0 @@ | ||
| 1 | -import type Types from '../types' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * Rows grouped into runs of one kind, in order. | |
| 5 | - * | |
| 6 | - * @param rows the laid-out rows | |
| 7 | - * @returns the runs | |
| 8 | - */ | |
| 9 | -export function runsOf(rows: readonly Types.Row[]): Types.Run[] { | |
| 10 | - const runs: Types.Run[] = [] | |
| 11 | - let start = 0 | |
| 12 | - | |
| 13 | - rows.forEach((row, at) => { | |
| 14 | - const isRunEnd = rows[at + 1]?.kind !== row.kind | |
| 15 | - | |
| 16 | - if (isRunEnd) { | |
| 17 | - runs.push({ kind: row.kind, rows: rows.slice(start, at + 1) }) | |
| 18 | - start = at + 1 | |
| 19 | - } | |
| 20 | - }) | |
| 21 | - | |
| 22 | - return runs | |
| 23 | -} |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import type Git from '../../../../git' | |
| 2 | +import { hunkHeaderOf } from '../../hunk-header-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A hunk's first line cut so that it alone, under its header, fits the | |
| 6 | + * room: its marker kept, no surrogate pair split. | |
| 7 | + * | |
| 8 | + * @param hunk the hunk, its first line too long for a sub-hunk of its own | |
| 9 | + * @param maxChars the room the one-line sub-hunk's text fits | |
| 10 | + * @returns the cut line | |
| 11 | + */ | |
| 12 | +export function firstLineCut(hunk: Git.Hunk, maxChars: number) { | |
| 13 | + const [first = ''] = hunk.lines | |
| 14 | + const kept = first.slice( | |
| 15 | + 0, | |
| 16 | + Math.max( | |
| 17 | + 1, | |
| 18 | + maxChars - 1 - hunkHeaderOf({ ...hunk, lines: [first] }).length, | |
| 19 | + ), | |
| 20 | + ) | |
| 21 | + | |
| 22 | + return /[\uD800-\uDBFF]$/u.test(kept) ? kept.slice(0, -1) : kept | |
| 23 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './first-line-cut.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './first-line-cut' | |
| 2 | +export * from './sub-hunks-of.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,43 @@ | ||
| 1 | +import { countOf } from '../../../count-of' | |
| 2 | +import type Git from '../../../git' | |
| 3 | +import { leadingHunkOf } from '../leading-hunk-of' | |
| 4 | +import { firstLineCut } from './first-line-cut' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * A hunk as consecutive sub-hunks whose texts (hunkSourceOf) each fit the | |
| 8 | + * room, each starting where the last left off, so the counts add up. | |
| 9 | + * | |
| 10 | + * A line too long for a sub-hunk of its own is cut to fit, not dropped, and | |
| 11 | + * `isTruncated` says so; a hunk that fits comes back whole. | |
| 12 | + * | |
| 13 | + * @param hunk the hunk, its lines body lines only | |
| 14 | + * @param maxChars the room each sub-hunk's text fits, in UTF-16 units | |
| 15 | + * @returns the sub-hunks in order, and whether a line was cut | |
| 16 | + */ | |
| 17 | +export function subHunksOf( | |
| 18 | + hunk: Git.Hunk, | |
| 19 | + maxChars: number, | |
| 20 | +): Pick<Git.FileHunks, 'hunks' | 'isTruncated'> { | |
| 21 | + const hunks: Git.Hunk[] = [] | |
| 22 | + let rest = hunk | |
| 23 | + let isTruncated = false | |
| 24 | + | |
| 25 | + while (rest.lines.length > 0) { | |
| 26 | + const leading = leadingHunkOf(rest, maxChars) | |
| 27 | + const isLineCut = leading.lines.length === 0 | |
| 28 | + const piece = isLineCut | |
| 29 | + ? { ...rest, lines: [firstLineCut(rest, maxChars)] } | |
| 30 | + : leading | |
| 31 | + const taken = rest.lines.slice(0, piece.lines.length) | |
| 32 | + | |
| 33 | + hunks.push(piece) | |
| 34 | + isTruncated ||= isLineCut | |
| 35 | + rest = { | |
| 36 | + oldStart: rest.oldStart + countOf(taken, line => !line.startsWith('+')), | |
| 37 | + newStart: rest.newStart + countOf(taken, line => !line.startsWith('-')), | |
| 38 | + lines: rest.lines.slice(piece.lines.length), | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + return { hunks, isTruncated } | |
| 43 | +} |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * One `Code` element of a file's body: its unified-diff source, and whether | |
| 3 | + * a divider stands above it (it opens a hunk after another block). | |
| 4 | + */ | |
| 5 | +export type CodeBlock = { | |
| 6 | + source: string | |
| 7 | + hasDivider: boolean | |
| 8 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './code-block.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { CodeBlock } from '../code-block' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A file's hunks as the body draws them: the `Code` blocks in order, and | |
| 5 | + * whether the body's budget or a source's cap left any of a line out. | |
| 6 | + */ | |
| 7 | +export type CodeBody = { | |
| 8 | + blocks: readonly CodeBlock[] | |
| 9 | + isTruncated: boolean | |
| 10 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './code-body.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,15 +1,0 @@ | ||
| 1 | -import type { LineKind } from '../line-kind' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * One hunk line ready to lay out: its side, its gutter's line number, its | |
| 5 | - * sanitized text, and the paired line's text for a word diff, if any. | |
| 6 | - * | |
| 7 | - * The number is the new file's for context and added lines, the old | |
| 8 | - * file's for removed ones; a removed line pairs with its added counterpart. | |
| 9 | - */ | |
| 10 | -export type DiffLine = { | |
| 11 | - kind: LineKind | |
| 12 | - lineNumber: number | |
| 13 | - text: string | |
| 14 | - pairedText: string | null | |
| 15 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './diff-line.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,6 +1,4 @@ | ||
| 1 | -export * from './diff-line' | |
| 2 | -export * from './line-kind' | |
| 3 | -export * from './row' | |
| 4 | -export type * from './run.js' | |
| 1 | +export * from './code-block' | |
| 2 | +export * from './code-body' | |
| 5 | 3 | |
| 6 | 4 | export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './line-kind.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,4 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * Which side of the diff a line is on, read from its marker. | |
| 3 | - */ | |
| 4 | -export type LineKind = 'context' | 'added' | 'removed' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './row.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,13 +1,0 @@ | ||
| 1 | -import type Layout from '../../../layout' | |
| 2 | -import type { LineKind } from '../line-kind' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * One terminal row of a file's body: the gutter's text (the line number, | |
| 6 | - * or nothing on a continuation), the marker, and the content's segments. | |
| 7 | - */ | |
| 8 | -export type Row = { | |
| 9 | - kind: LineKind | |
| 10 | - gutter: string | |
| 11 | - marker: string | |
| 12 | - segments: readonly Layout.Segment[] | |
| 13 | -} |
| @@ -1,11 +1,0 @@ | ||
| 1 | -import type { LineKind } from './line-kind' | |
| 2 | -import type { Row } from './row' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * Consecutive rows of one kind, drawn as one Text so a long file stays far | |
| 6 | - * under the tree's node cap. | |
| 7 | - */ | |
| 8 | -export type Run = { | |
| 9 | - kind: LineKind | |
| 10 | - rows: readonly Row[] | |
| 11 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './word-backgrounds.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,13 +1,0 @@ | ||
| 1 | -import type Types from '../types' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * The theme key behind a changed word of each kind; a context line never | |
| 5 | - * carries one, so it has none. | |
| 6 | - */ | |
| 7 | -export const WORD_BACKGROUNDS: Readonly< | |
| 8 | - Record<Types.LineKind, string | undefined> | |
| 9 | -> = { | |
| 10 | - added: 'diffAddedWord', | |
| 11 | - removed: 'diffRemovedWord', | |
| 12 | - context: undefined, | |
| 13 | -} |
| @@ -4,10 +4,10 @@ | ||
| 4 | 4 | * The element constructors the pane draws with, destructured from the |
| 5 | 5 | * table `$.ui.resolve(e)` hands the render hook. |
| 6 | 6 | * |
| 7 | - * The terminal and the desktop carry all four; the mobile app has no | |
| 7 | + * The terminal and the desktop carry all five; the mobile app has no | |
| 8 | 8 | * Select, so the pane never draws there. |
| 9 | 9 | */ |
| 10 | 10 | export type Ui = Pick< |
| 11 | 11 | ElementTable<'terminal' | 'desktop'>, |
| 12 | - 'Box' | 'Text' | 'Button' | 'Select' | |
| 12 | + 'Box' | 'Text' | 'Button' | 'Select' | 'Code' | |
| 13 | 13 | > |
| @@ -3,10 +3,7 @@ | ||
| 3 | 3 | export * from './plural' |
| 4 | 4 | export * from './sanitize-line' |
| 5 | 5 | export * from './sanitize-name' |
| 6 | -export * from './segment' | |
| 7 | -export * from './slice-cells' | |
| 8 | 6 | export * from './tab-width' |
| 9 | 7 | export * from './truncate-start' |
| 10 | -export * from './wrap-segments' | |
| 11 | 8 | |
| 12 | 9 | export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './segment.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,8 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * One stretch of a drawn diff row: its text and whether the word diff | |
| 3 | - * marks it as changed. | |
| 4 | - */ | |
| 5 | -export type Segment = { | |
| 6 | - text: string | |
| 7 | - isChanged: boolean | |
| 8 | -} |
| @@ -1,9 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * A line cut at a cell budget: the head that fits, the budget it spent | |
| 3 | - * (a code point that draws in no cell still spends one), and the tail. | |
| 4 | - */ | |
| 5 | -export type CellsSplit = { | |
| 6 | - head: string | |
| 7 | - spent: number | |
| 8 | - tail: string | |
| 9 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './cells-split.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,4 +1,0 @@ | ||
| 1 | -export * from './cells-split' | |
| 2 | -export * from './slice-cells.js' | |
| 3 | - | |
| 4 | -export * as default from '.' |
| @@ -1,37 +1,0 @@ | ||
| 1 | -import { chargedWidth } from '../charged-width' | |
| 2 | -import type { CellsSplit } from './cells-split' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * The longest head of a line that fits a cell budget, cut between code | |
| 6 | - * points, and what is left after it. | |
| 7 | - * | |
| 8 | - * The budget is spent by chargedWidth, at least one cell a code point, so | |
| 9 | - * a head never holds more code points than cells: a line of ten thousand | |
| 10 | - * combining marks still wraps. | |
| 11 | - * | |
| 12 | - * @param text a sanitized line | |
| 13 | - * @param cells the room, at least one cell | |
| 14 | - * @returns the head that fits, the budget it spent, and the remaining tail | |
| 15 | - */ | |
| 16 | -export function sliceCells(text: string, cells: number): CellsSplit { | |
| 17 | - let spent = 0 | |
| 18 | - let taken = 0 | |
| 19 | - const characters = [...text] | |
| 20 | - | |
| 21 | - for (const character of characters) { | |
| 22 | - const next = spent + chargedWidth(character) | |
| 23 | - | |
| 24 | - if (next > cells && taken > 0) { | |
| 25 | - break | |
| 26 | - } | |
| 27 | - | |
| 28 | - spent = next | |
| 29 | - taken += 1 | |
| 30 | - } | |
| 31 | - | |
| 32 | - return { | |
| 33 | - head: characters.slice(0, taken).join(''), | |
| 34 | - spent, | |
| 35 | - tail: characters.slice(taken).join(''), | |
| 36 | - } | |
| 37 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './wrap-segments.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,43 +1,0 @@ | ||
| 1 | -import type { Segment } from '../segment' | |
| 2 | -import { sliceCells } from '../slice-cells' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * A line's segments laid into rows of at most a cell budget, a segment | |
| 6 | - * split where a row fills, so each row draws as exactly one terminal row. | |
| 7 | - * | |
| 8 | - * A row holds no more code points than cells (sliceCells' spending), and | |
| 9 | - * a continuation can sit under an empty gutter. | |
| 10 | - * | |
| 11 | - * @param segments the line's segments in order | |
| 12 | - * @param cells the room per row, at least one cell | |
| 13 | - * @returns the rows; one empty row for an empty line | |
| 14 | - */ | |
| 15 | -export function wrapSegments( | |
| 16 | - segments: readonly Segment[], | |
| 17 | - cells: number, | |
| 18 | -): Segment[][] { | |
| 19 | - const rows: Segment[][] = [] | |
| 20 | - let row: Segment[] = [] | |
| 21 | - let used = 0 | |
| 22 | - | |
| 23 | - for (const segment of segments) { | |
| 24 | - let rest = segment.text | |
| 25 | - | |
| 26 | - while (rest !== '') { | |
| 27 | - const split = sliceCells(rest, Math.max(1, cells - used)) | |
| 28 | - row.push({ text: split.head, isChanged: segment.isChanged }) | |
| 29 | - used += split.spent | |
| 30 | - rest = split.tail | |
| 31 | - | |
| 32 | - if (rest !== '' || used >= cells) { | |
| 33 | - rows.push(row) | |
| 34 | - row = [] | |
| 35 | - used = 0 | |
| 36 | - } | |
| 37 | - } | |
| 38 | - } | |
| 39 | - | |
| 40 | - const isRowPending = row.length > 0 || rows.length === 0 | |
| 41 | - | |
| 42 | - return isRowPending ? [...rows, row] : rows | |
| 43 | -} |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * Past this share of changed characters a line pair reads as rewritten, not | |
| 3 | - * edited, and gets no word highlights (the engine's fallback diff rule). | |
| 4 | - */ | |
| 5 | -export const CHANGE_THRESHOLD = 0.4 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './change-threshold.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,76 +1,0 @@ | ||
| 1 | -import { MAX_LCS_CELLS } from '../max-lcs-cells' | |
| 2 | -import type { WordPart } from '../word-part' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * The parts of a longest-common-subsequence diff of two token lists, in | |
| 6 | - * order, adjacent parts of one kind merged. | |
| 7 | - * | |
| 8 | - * Null when the table would pass MAX_LCS_CELLS. | |
| 9 | - * | |
| 10 | - * @param before the removed line's tokens | |
| 11 | - * @param after the added line's tokens | |
| 12 | - * @returns the parts, or null for lines too long to compare | |
| 13 | - */ | |
| 14 | -export function diffParts( | |
| 15 | - before: readonly string[], | |
| 16 | - after: readonly string[], | |
| 17 | -): WordPart[] | null { | |
| 18 | - const columns = after.length + 1 | |
| 19 | - | |
| 20 | - if ((before.length + 1) * columns > MAX_LCS_CELLS) { | |
| 21 | - return null | |
| 22 | - } | |
| 23 | - | |
| 24 | - const lengths = new Uint32Array((before.length + 1) * columns) | |
| 25 | - const at = (i: number, j: number) => lengths[i * columns + j] ?? 0 | |
| 26 | - | |
| 27 | - for (let i = before.length - 1; i >= 0; i--) { | |
| 28 | - for (let j = after.length - 1; j >= 0; j--) { | |
| 29 | - const isMatch = before[i] === after[j] | |
| 30 | - lengths[i * columns + j] = isMatch | |
| 31 | - ? at(i + 1, j + 1) + 1 | |
| 32 | - : Math.max(at(i + 1, j), at(i, j + 1)) | |
| 33 | - } | |
| 34 | - } | |
| 35 | - | |
| 36 | - const parts: WordPart[] = [] | |
| 37 | - | |
| 38 | - function emit(text: string, kind: WordPart['kind']) { | |
| 39 | - const last = parts.at(-1) | |
| 40 | - | |
| 41 | - if (last?.kind === kind) { | |
| 42 | - last.text += text | |
| 43 | - | |
| 44 | - return | |
| 45 | - } | |
| 46 | - | |
| 47 | - parts.push({ text, kind }) | |
| 48 | - } | |
| 49 | - | |
| 50 | - let i = 0 | |
| 51 | - let j = 0 | |
| 52 | - | |
| 53 | - while (i < before.length || j < after.length) { | |
| 54 | - const old = before[i] | |
| 55 | - const fresh = after[j] | |
| 56 | - const isSame = old !== undefined && old === fresh | |
| 57 | - const isRemoved = | |
| 58 | - !isSame && | |
| 59 | - old !== undefined && | |
| 60 | - (fresh === undefined || at(i + 1, j) >= at(i, j + 1)) | |
| 61 | - | |
| 62 | - if (isSame) { | |
| 63 | - emit(old, 'same') | |
| 64 | - i += 1 | |
| 65 | - j += 1 | |
| 66 | - } else if (isRemoved) { | |
| 67 | - emit(old, 'removed') | |
| 68 | - i += 1 | |
| 69 | - } else if (fresh !== undefined) { | |
| 70 | - emit(fresh, 'added') | |
| 71 | - j += 1 | |
| 72 | - } | |
| 73 | - } | |
| 74 | - | |
| 75 | - return parts | |
| 76 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './diff-parts.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,8 +1,0 @@ | ||
| 1 | -export * from './change-threshold' | |
| 2 | -export * from './diff-parts' | |
| 3 | -export * from './max-lcs-cells' | |
| 4 | -export * from './tokens-of' | |
| 5 | -export * from './word-diff.js' | |
| 6 | -export * from './word-part' | |
| 7 | - | |
| 8 | -export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './max-lcs-cells.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The largest token-count product a word diff will fill a table for; two | |
| 3 | - * longer lines are shown without word highlights. | |
| 4 | - */ | |
| 5 | -export const MAX_LCS_CELLS = 250_000 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './tokens-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,9 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * A line as the tokens a word diff compares: runs of word characters, runs | |
| 3 | - * of whitespace (kept, as diffWordsWithSpace does), other characters alone. | |
| 4 | - * | |
| 5 | - * @param text one line without its diff marker | |
| 6 | - * @returns the tokens, which join back to `text` | |
| 7 | - */ | |
| 8 | -export const tokensOf = (text: string): string[] => | |
| 9 | - text.match(/\w+|\s+|[^\w\s]/gu) ?? [] |
| @@ -1,30 +1,0 @@ | ||
| 1 | -import { CHANGE_THRESHOLD } from './change-threshold' | |
| 2 | -import { diffParts } from './diff-parts' | |
| 3 | -import { tokensOf } from './tokens-of' | |
| 4 | -import type { WordPart } from './word-part' | |
| 5 | - | |
| 6 | -/** | |
| 7 | - * The word-level diff of a removed line and the added line paired with it, | |
| 8 | - * or null when the pair reads as a rewrite or is too long to compare. | |
| 9 | - * | |
| 10 | - * A rewrite changed more than CHANGE_THRESHOLD of its characters; the | |
| 11 | - * caller then draws both lines plain (the engine's fallback diff rule). | |
| 12 | - * | |
| 13 | - * @param removed the removed line's text, marker dropped | |
| 14 | - * @param added the added line's text, marker dropped | |
| 15 | - * @returns the parts in order, or null | |
| 16 | - */ | |
| 17 | -export function wordDiff(removed: string, added: string): WordPart[] | null { | |
| 18 | - const parts = diffParts(tokensOf(removed), tokensOf(added)) | |
| 19 | - | |
| 20 | - if (!parts) { | |
| 21 | - return null | |
| 22 | - } | |
| 23 | - | |
| 24 | - const total = removed.length + added.length | |
| 25 | - const changed = parts | |
| 26 | - .filter(part => part.kind !== 'same') | |
| 27 | - .reduce((sum, part) => sum + part.text.length, 0) | |
| 28 | - | |
| 29 | - return total > 0 && changed / total > CHANGE_THRESHOLD ? null : parts | |
| 30 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './word-part.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,8 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * One run of a word-level diff between a removed line and the added line | |
| 3 | - * that replaced it: text both share, or text on one side only. | |
| 4 | - */ | |
| 5 | -export type WordPart = { | |
| 6 | - text: string | |
| 7 | - kind: 'same' | 'added' | 'removed' | |
| 8 | -} |