spacehub

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

mods/diff: hunks draw through the engine's Code element, split at hunk boundaries under its 10000-character source cap; the row renderer and word diff goBrowse files
poteat authored on Sep 10, 2026 parent 1297731 · commit 30a685085957
92 changed files+351 -864compared with 1297731
Mmods/diff/hooks/index.ts
Mmods/diff/hooks/register.ts
Dmods/diff/hooks/views/detail/backgrounds/backgrounds.ts
Dmods/diff/hooks/views/detail/backgrounds/index.ts
Amods/diff/hooks/views/detail/code-blocks-of/code-blocks-of.ts
Amods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/drawn-line-of.ts
Amods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/index.ts
Amods/diff/hooks/views/detail/code-blocks-of/index.ts
Mmods/diff/hooks/views/detail/detail-view.tsx
Dmods/diff/hooks/views/detail/gutter-cells-of/gutter-cells-of.ts
Dmods/diff/hooks/views/detail/gutter-cells-of/index.ts
Dmods/diff/hooks/views/detail/hunk-body/body-cells/body-cells.ts
Dmods/diff/hooks/views/detail/hunk-body/body-cells/index.ts
Dmods/diff/hooks/views/detail/hunk-body/hunk-body.tsx
Dmods/diff/hooks/views/detail/hunk-body/index.ts
Amods/diff/hooks/views/detail/hunk-divider/hunk-divider.ts
Amods/diff/hooks/views/detail/hunk-divider/index.ts
Amods/diff/hooks/views/detail/hunk-divider-nodes/hunk-divider-nodes.ts
Amods/diff/hooks/views/detail/hunk-divider-nodes/index.ts
Amods/diff/hooks/views/detail/hunk-header-of/hunk-header-of.ts
Amods/diff/hooks/views/detail/hunk-header-of/index.ts
Amods/diff/hooks/views/detail/hunk-source-of/hunk-source-of.ts
Amods/diff/hooks/views/detail/hunk-source-of/index.ts
Mmods/diff/hooks/views/detail/index.ts
Amods/diff/hooks/views/detail/leading-hunk-of/index.ts
Amods/diff/hooks/views/detail/leading-hunk-of/leading-hunk-of.ts
Dmods/diff/hooks/views/detail/lines-of/index.ts
Dmods/diff/hooks/views/detail/lines-of/lines-of.ts
Dmods/diff/hooks/views/detail/marker-cells/index.ts
Dmods/diff/hooks/views/detail/marker-cells/marker-cells.ts
Mmods/diff/hooks/views/detail/max-body-chars/max-body-chars.ts
Mmods/diff/hooks/views/detail/max-body-nodes/max-body-nodes.ts
Amods/diff/hooks/views/detail/max-code-chars/index.ts
Amods/diff/hooks/views/detail/max-code-chars/max-code-chars.ts
Dmods/diff/hooks/views/detail/max-rows-of/frame-nodes/frame-nodes.ts
Dmods/diff/hooks/views/detail/max-rows-of/frame-nodes/index.ts
Dmods/diff/hooks/views/detail/max-rows-of/highlight-nodes/highlight-nodes.ts
Dmods/diff/hooks/views/detail/max-rows-of/highlight-nodes/index.ts
Dmods/diff/hooks/views/detail/max-rows-of/index.ts
Dmods/diff/hooks/views/detail/max-rows-of/max-rows-of.ts
Dmods/diff/hooks/views/detail/max-rows-of/run-nodes/index.ts
Dmods/diff/hooks/views/detail/max-rows-of/run-nodes/run-nodes.ts
Dmods/diff/hooks/views/detail/max-run-chars/index.ts
Dmods/diff/hooks/views/detail/max-run-chars/max-run-chars.ts
Dmods/diff/hooks/views/detail/rows-of/index.ts
Dmods/diff/hooks/views/detail/rows-of/max-highlights/index.ts
Dmods/diff/hooks/views/detail/rows-of/max-highlights/max-highlights.ts
Dmods/diff/hooks/views/detail/rows-of/rows-of.ts
Dmods/diff/hooks/views/detail/rows-of/segments-of/index.ts
Dmods/diff/hooks/views/detail/rows-of/segments-of/segments-of.ts
Dmods/diff/hooks/views/detail/runs-of/index.ts
Dmods/diff/hooks/views/detail/runs-of/runs-of.ts
Amods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/first-line-cut.ts
Amods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/index.ts
Amods/diff/hooks/views/detail/sub-hunks-of/index.ts
Amods/diff/hooks/views/detail/sub-hunks-of/sub-hunks-of.ts
Amods/diff/hooks/views/detail/types/code-block/code-block.ts
Amods/diff/hooks/views/detail/types/code-block/index.ts
Amods/diff/hooks/views/detail/types/code-body/code-body.ts
Amods/diff/hooks/views/detail/types/code-body/index.ts
Dmods/diff/hooks/views/detail/types/diff-line/diff-line.ts
Dmods/diff/hooks/views/detail/types/diff-line/index.ts
Mmods/diff/hooks/views/detail/types/index.ts
Dmods/diff/hooks/views/detail/types/line-kind/index.ts
Dmods/diff/hooks/views/detail/types/line-kind/line-kind.ts
Dmods/diff/hooks/views/detail/types/row/index.ts
Dmods/diff/hooks/views/detail/types/row/row.ts
Dmods/diff/hooks/views/detail/types/run.ts
Dmods/diff/hooks/views/detail/word-backgrounds/index.ts
Dmods/diff/hooks/views/detail/word-backgrounds/word-backgrounds.ts
Mmods/diff/hooks/views/kit/ui/ui.ts
Mmods/diff/hooks/views/layout/index.ts
Dmods/diff/hooks/views/layout/segment/index.ts
Dmods/diff/hooks/views/layout/segment/segment.ts
Dmods/diff/hooks/views/layout/slice-cells/cells-split/cells-split.ts
Dmods/diff/hooks/views/layout/slice-cells/cells-split/index.ts
Dmods/diff/hooks/views/layout/slice-cells/index.ts
Dmods/diff/hooks/views/layout/slice-cells/slice-cells.ts
Dmods/diff/hooks/views/layout/wrap-segments/index.ts
Dmods/diff/hooks/views/layout/wrap-segments/wrap-segments.ts
Dmods/diff/hooks/word-diff/change-threshold/change-threshold.ts
Dmods/diff/hooks/word-diff/change-threshold/index.ts
Dmods/diff/hooks/word-diff/diff-parts/diff-parts.ts
Dmods/diff/hooks/word-diff/diff-parts/index.ts
Dmods/diff/hooks/word-diff/index.ts
Dmods/diff/hooks/word-diff/max-lcs-cells/index.ts
Dmods/diff/hooks/word-diff/max-lcs-cells/max-lcs-cells.ts
Dmods/diff/hooks/word-diff/tokens-of/index.ts
Dmods/diff/hooks/word-diff/tokens-of/tokens-of.ts
Dmods/diff/hooks/word-diff/word-diff.ts
Dmods/diff/hooks/word-diff/word-part/index.ts
Dmods/diff/hooks/word-diff/word-part/word-part.ts
@@ -21,6 +21,5 @@
2121 export * from './tools'
2222 export * from './turns'
2323 export * from './views'
24-export * from './word-diff'
2524
2625 export * as default from '.'
@@ -457,14 +457,14 @@
457457 return next(e)
458458 }
459459
460- const { Box, Text, Button, Select } = await $.ui.resolve(e)
460+ const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
461461 wasDrawnSinceProbe = true
462462 columns = e.viewport?.columns ?? columns
463463 model = { ...model, isFocused: e.props.isFocused }
464464
465465 return Views.paneView(
466466 {
467- ui: { Box, Text, Button, Select },
467+ ui: { Box, Text, Button, Select, Code },
468468 actions: actionsOf(host),
469469 columns: e.props.bodyColumns,
470470 },
Dmods/diff/hooks/views/detail/backgrounds/backgrounds.ts+0 -14
@@ -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- }
Dmods/diff/hooks/views/detail/backgrounds/index.ts+0 -3
@@ -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 @@
55
66 import type { Kit } from '../kit'
77 import Layout from '../layout'
8+import { codeBlocksOf } from './code-blocks-of'
89 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'
1412 import { placeholderOf } from './placeholder-of'
15-import { rowsOf } from './rows-of'
1613
1714 /**
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.
2017 *
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.
2421 *
2522 * @param kit the elements and the width
2623 * @param detail the selected file
@@ -32,18 +29,13 @@
3229 detail: DetailModel,
3330 onToggleAsk: () => void,
3431 ): RenderElement {
35- const { Box, Text, Button } = kit.ui
32+ const { Box, Text, Button, Code } = kit.ui
3633 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)
4739 const renamedFrom =
4840 detail.renamedFrom === null ? null : nameOf(detail.renamedFrom)
4941 const asides = [
@@ -52,14 +44,6 @@
5244 isTruncated ? 'truncated' : null,
5345 ].filter(word => word !== null)
5446 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)]
6347 const ask = placeholder
6448 ? []
6549 : [
@@ -74,19 +58,32 @@
7458 </Text>,
7559 ]
7660 : []
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+ ])
7770
78- const header = (
79- <Box flexDirection="row">
71+ return (
72+ <Box flexDirection="column">
8073 {[
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,
8786 ]}
8887 </Box>
8988 )
90-
91- return <Box flexDirection="column">{[header, ...body, ...footer]}</Box>
9289 }
Dmods/diff/hooks/views/detail/gutter-cells-of/gutter-cells-of.ts+0 -12
@@ -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
Dmods/diff/hooks/views/detail/gutter-cells-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './gutter-cells-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/hunk-body/body-cells/body-cells.ts+0 -8
@@ -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-}
Dmods/diff/hooks/views/detail/hunk-body/body-cells/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './body-cells.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/hunk-body/hunk-body.tsx+0 -114
@@ -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-}
Dmods/diff/hooks/views/detail/hunk-body/index.ts+0 -4
@@ -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'
22 export * from './detail-model'
33 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'
89 export * from './max-body-chars'
910 export * from './max-body-nodes'
10-export * from './max-rows-of'
11-export * from './max-run-chars'
11+export * from './max-code-chars'
1212 export * from './placeholder-of'
13-export * from './rows-of'
14-export * from './runs-of'
13+export * from './sub-hunks-of'
1514 export * from './types'
16-export * from './word-backgrounds'
1715
1816 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+}
Dmods/diff/hooks/views/detail/lines-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './lines-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/lines-of/lines-of.ts+0 -74
@@ -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-}
Dmods/diff/hooks/views/detail/marker-cells/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './marker-cells.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/marker-cells/marker-cells.ts+0 -5
@@ -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 @@
11 /**
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.
46 */
57 export const MAX_BODY_CHARS = 80_000
@@ -1,5 +1,7 @@
11 /**
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.
46 */
57 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
Dmods/diff/hooks/views/detail/max-rows-of/frame-nodes/frame-nodes.ts+0 -5
@@ -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
Dmods/diff/hooks/views/detail/max-rows-of/frame-nodes/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './frame-nodes.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/max-rows-of/highlight-nodes/highlight-nodes.ts+0 -5
@@ -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
Dmods/diff/hooks/views/detail/max-rows-of/highlight-nodes/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './highlight-nodes.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/max-rows-of/index.ts+0 -6
@@ -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 '.'
Dmods/diff/hooks/views/detail/max-rows-of/max-rows-of.ts+0 -59
@@ -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-}
Dmods/diff/hooks/views/detail/max-rows-of/run-nodes/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './run-nodes.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/max-rows-of/run-nodes/run-nodes.ts+0 -4
@@ -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
Dmods/diff/hooks/views/detail/max-run-chars/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './max-run-chars.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/max-run-chars/max-run-chars.ts+0 -5
@@ -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
Dmods/diff/hooks/views/detail/rows-of/index.ts+0 -5
@@ -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 '.'
Dmods/diff/hooks/views/detail/rows-of/max-highlights/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './max-highlights.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/rows-of/max-highlights/max-highlights.ts+0 -5
@@ -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
Dmods/diff/hooks/views/detail/rows-of/rows-of.ts+0 -48
@@ -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-}
Dmods/diff/hooks/views/detail/rows-of/segments-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './segments-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/rows-of/segments-of/segments-of.ts+0 -32
@@ -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-}
Dmods/diff/hooks/views/detail/runs-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './runs-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/runs-of/runs-of.ts+0 -23
@@ -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 '.'
Dmods/diff/hooks/views/detail/types/diff-line/diff-line.ts+0 -15
@@ -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-}
Dmods/diff/hooks/views/detail/types/diff-line/index.ts+0 -3
@@ -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'
53
64 export * as default from '.'
Dmods/diff/hooks/views/detail/types/line-kind/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './line-kind.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/types/line-kind/line-kind.ts+0 -4
@@ -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'
Dmods/diff/hooks/views/detail/types/row/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './row.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/types/row/row.ts+0 -13
@@ -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-}
Dmods/diff/hooks/views/detail/types/run.ts+0 -11
@@ -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-}
Dmods/diff/hooks/views/detail/word-backgrounds/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './word-backgrounds.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/word-backgrounds/word-backgrounds.ts+0 -13
@@ -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 @@
44 * The element constructors the pane draws with, destructured from the
55 * table `$.ui.resolve(e)` hands the render hook.
66 *
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
88 * Select, so the pane never draws there.
99 */
1010 export type Ui = Pick<
1111 ElementTable<'terminal' | 'desktop'>,
12- 'Box' | 'Text' | 'Button' | 'Select'
12+ 'Box' | 'Text' | 'Button' | 'Select' | 'Code'
1313 >
@@ -3,10 +3,7 @@
33 export * from './plural'
44 export * from './sanitize-line'
55 export * from './sanitize-name'
6-export * from './segment'
7-export * from './slice-cells'
86 export * from './tab-width'
97 export * from './truncate-start'
10-export * from './wrap-segments'
118
129 export * as default from '.'
Dmods/diff/hooks/views/layout/segment/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './segment.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/layout/segment/segment.ts+0 -8
@@ -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-}
Dmods/diff/hooks/views/layout/slice-cells/cells-split/cells-split.ts+0 -9
@@ -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-}
Dmods/diff/hooks/views/layout/slice-cells/cells-split/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './cells-split.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/layout/slice-cells/index.ts+0 -4
@@ -1,4 +1,0 @@
1-export * from './cells-split'
2-export * from './slice-cells.js'
3-
4-export * as default from '.'
Dmods/diff/hooks/views/layout/slice-cells/slice-cells.ts+0 -37
@@ -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-}
Dmods/diff/hooks/views/layout/wrap-segments/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './wrap-segments.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/layout/wrap-segments/wrap-segments.ts+0 -43
@@ -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-}
Dmods/diff/hooks/word-diff/change-threshold/change-threshold.ts+0 -5
@@ -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
Dmods/diff/hooks/word-diff/change-threshold/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './change-threshold.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/word-diff/diff-parts/diff-parts.ts+0 -76
@@ -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-}
Dmods/diff/hooks/word-diff/diff-parts/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './diff-parts.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/word-diff/index.ts+0 -8
@@ -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 '.'
Dmods/diff/hooks/word-diff/max-lcs-cells/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './max-lcs-cells.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/word-diff/max-lcs-cells/max-lcs-cells.ts+0 -5
@@ -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
Dmods/diff/hooks/word-diff/tokens-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './tokens-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/word-diff/tokens-of/tokens-of.ts+0 -9
@@ -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) ?? []
Dmods/diff/hooks/word-diff/word-diff.ts+0 -30
@@ -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-}
Dmods/diff/hooks/word-diff/word-part/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './word-part.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/word-diff/word-part/word-part.ts+0 -8
@@ -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-}