/** * The one diff renderer every file-change surface shares (the changes tab's * inline preview pane and the diff tab): fold-capable hunk rows with old/new * line gutters, rewrite (mod) tinting with intra-line character highlights, * lightweight syntax coloring, and long-line folding. Presentational — the * segments arrive precomputed (session ops via buildDiffSegments, git diffs * via unifiedSegments) so both producers render identically; the one * exception is git gap folds, whose rows git never emitted and a caller * supplies on demand through resolveFold (first click fetches, then they * toggle like any fold). */ import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react' import { t } from '../locales.ts' import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type FoldSegment, type InlineDiff } from './rows.ts' import { hasBlockComment, isColored, scanLine, type CodeToken, type TokenType } from './highlight.ts' import css from './diff.module.css' /** Long diff lines fold to one ellipsized row; the threshold is the char count. */ const FOLD_THRESHOLD = 120 /** Rendered hunk rows per file capped at this count; expand reveals the rest. */ const MAX_ROWS = 600 /** Token class -> CSS color class ('' inherits the row's diff color). */ const TOKEN_CLASS: Readonly> = { plain: '', comment: css.tokComment ?? '', string: css.tokString ?? '', keyword: css.tokKeyword ?? '', number: css.tokNumber ?? '', type: css.tokType ?? '', function: css.tokFunction ?? '', macro: css.tokMacro ?? '', } /** One token span's class list: its color class, plus the change tint. */ function tokenSpanClass(type: TokenType, changed: boolean): string { const color = TOKEN_CLASS[type] return changed ? `${color} ${css.inlineChange}` : color } /** Render scanned tokens as colored nodes; uncolored runs stay text. */ function tokensToNodes(tokens: readonly CodeToken[], changed = false): ReactNode[] { const nodes: ReactNode[] = [] for (const token of tokens) { if (!changed && !isColored(token)) nodes.push(token.text) else nodes.push({token.text}) } return nodes } /** * Intra-line diffs for mod-row pairs, keyed by row identity: each mod-run's * first half (old side) pairs with its second half (new side), each pair * diffed by common prefix/suffix so both sides highlight the exact changed * substring. */ function buildInlineMap(segments: readonly DiffSegment[]): Map { const map = new Map() for (const segment of segments) { if (segment.kind !== 'hunk') continue const rows = segment.rows let i = 0 while (i < rows.length) { if (rows[i]!.kind !== 'mod') { i += 1; continue } let j = i while (j < rows.length && rows[j]!.kind === 'mod') j += 1 const block = rows.slice(i, j) const half = Math.floor(block.length / 2) for (let p = 0; p < half; p += 1) { const delRow = block[p]! const addRow = block[p + half]! const inline = diffInline(delRow.text, addRow.text) map.set(delRow, inline) map.set(addRow, inline) } i = j } } return map } /** * Per-row block-comment entry state for a diff: the old side threads along * old-line order and the new side along new-line order (the row order * preserves both), so multi-line comments color correctly on each side. */ function diffBlockEntries(segments: readonly DiffSegment[], lang: string | undefined): Map { const entries = new Map() if (!hasBlockComment(lang)) return entries let oldIn = false let newIn = false for (const segment of segments) { if (segment.kind !== 'hunk') continue for (const row of segment.rows) { const isOld = row.oldLine !== undefined const isNew = row.newLine !== undefined entries.set(row, isOld ? oldIn : newIn) if (isOld) oldIn = scanLine(row.text, lang, oldIn).inBlock if (isNew) newIn = scanLine(row.text, lang, newIn).inBlock } } return entries } export interface DiffRowsProps { /** Precomputed segments (hunks and folds) for one file's diff. */ segments: readonly DiffSegment[] /** Syntax language id (langOfPath); undefined renders plain text. */ lang?: string /** Fetch a git gap fold's hidden rows on demand (both sides' contents * sliced by the fold's line ranges). Absent folds without `rows` — git's * unemitted gaps without a resolver — stay non-expandable markers. */ resolveFold?: (segment: FoldSegment) => Promise } /** One file's diff rows: fold chips between hunks, highlighted code rows. */ export function DiffRows({ segments, lang, resolveFold }: DiffRowsProps) { // Long diff lines fold to one ellipsized row; the set holds expanded row keys. const [expandedLines, setExpandedLines] = useState>(new Set()) // Hunk-fold segments expanded by index; default collapsed. const [expandedFolds, setExpandedFolds] = useState>(new Set()) // Row-count cap expanded: a huge file renders head rows plus this button. const [expandedAll, setExpandedAll] = useState(false) // On-demand fold expansion (git gaps): resolved rows per segment index, // the loading/failed markers, and an epoch that invalidates in-flight // resolves when the segments identity changes. const [foldData, setFoldData] = useState>(new Map()) const [foldLoading, setFoldLoading] = useState>(new Set()) const [foldFailed, setFoldFailed] = useState>(new Set()) const foldEpoch = useRef(0) // Reset all folding when the segments identity changes (new target). useEffect(() => { setExpandedLines(new Set()) setExpandedFolds(new Set()) setExpandedAll(false) setFoldData(new Map()) setFoldLoading(new Set()) setFoldFailed(new Set()) foldEpoch.current += 1 }, [segments]) const inlineMap = useMemo(() => buildInlineMap(segments), [segments]) const blockEntries = useMemo(() => diffBlockEntries(segments, lang), [segments, lang]) /** One diff row: colored sign + syntax-colored text, long-line fold toggle. */ const renderDiffRow = (row: DiffRow, rowKey: string): ReactElement => { if (row.kind === 'meta') { return (
{row.text}
) } const isLong = row.text.length > FOLD_THRESHOLD const isFolded = isLong && !expandedLines.has(rowKey) const blockEntry = blockEntries.get(row) ?? false return (
{ setExpandedLines(prev => { const next = new Set(prev) if (next.has(rowKey)) next.delete(rowKey) else next.add(rowKey) return next }) } : undefined} title={isFolded ? row.text : undefined} > {row.oldLine !== undefined ? String(row.oldLine) : ''} {row.newLine !== undefined ? String(row.newLine) : ''} {row.kind === 'del' ? '-' : row.kind === 'add' ? '+' : row.kind === 'mod' ? '~' : ' '} {row.kind === 'mod' && (() => { const inline = inlineMap.get(row) if (inline === undefined) return tokensToNodes(scanLine(row.text, lang, blockEntry).tokens) // Coalesced change runs, each split into syntax tokens with // block-comment state threaded across the runs of this line. const side = coalesceInline(row.oldLine !== undefined ? inline.old : inline.next) const nodes: ReactNode[] = [] let state = blockEntry for (const seg of side) { const scan = scanLine(seg.text, lang, state) state = scan.inBlock nodes.push(...tokensToNodes(scan.tokens, seg.changed)) } return nodes })()} {row.kind !== 'mod' && tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)}
) } let renderedRows = 0 const renderSegment = (segment: DiffSegment, segIndex: number): ReactNode => { if (segment.kind === 'hunk') { const capped = !expandedAll && renderedRows + segment.rows.length > MAX_ROWS const rows = capped ? segment.rows.slice(0, Math.max(MAX_ROWS - renderedRows, 0)) : segment.rows renderedRows += rows.length return (
{rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))}
) } if (foldFailed.has(segIndex)) { // The on-demand fetch failed: a quiet, non-clickable marker — the // expansion promise is gone, so no interaction is promised either. return (
{t('changesFoldUnavailable')}
) } const resolvedRows = foldData.get(segIndex) const expandable = (segment.rows !== undefined || resolvedRows !== undefined || resolveFold !== undefined) && segment.count >= MIN_FOLD if (!expandable) { // A tiny fold (or a git gap with no rows to reveal): a quiet marker. return (
{t('changesFold', { count: segment.count })}
) } const loading = foldLoading.has(segIndex) const isExpanded = expandedFolds.has(segIndex) const revealed = segment.rows ?? resolvedRows return (
{ if (revealed !== undefined) { setExpandedFolds(prev => { const next = new Set(prev) if (next.has(segIndex)) next.delete(segIndex) else next.add(segIndex) return next }) return } // A git gap fold's first click: fetch its rows, then expand. if (resolveFold === undefined) return const epoch = foldEpoch.current setFoldLoading(prev => new Set(prev).add(segIndex)) resolveFold(segment).then( (rows) => { if (foldEpoch.current !== epoch) return setFoldData(prev => new Map(prev).set(segIndex, rows)) setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next }) setExpandedFolds(prev => new Set(prev).add(segIndex)) }, () => { if (foldEpoch.current !== epoch) return setFoldLoading(prev => { const next = new Set(prev); next.delete(segIndex); return next }) setFoldFailed(prev => new Set(prev).add(segIndex)) }, ) }} > {isExpanded && revealed !== undefined ? revealed.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`)) : ( {loading ? t('changesFoldLoading') : t('changesFold', { count: segment.count })} )}
) } const parts = segments.map(renderSegment) return (
{parts} {renderedRows >= MAX_ROWS && !expandedAll && ( )}
) } export interface ReadRowsProps { /** The file lines with their real line numbers (parseReadLines output). */ lines: ReadonlyArray<{ line: number; text: string }> /** Syntax language id (langOfPath); undefined renders plain text. */ lang?: string } /** The read view: a line-numbered, syntax-colored slice of a read file. */ export function ReadRows({ lines, lang }: ReadRowsProps) { const rows = useMemo(() => { let state = false return lines.map((line) => { const scan = scanLine(line.text, lang, state) state = scan.inBlock return { line: line.line, nodes: tokensToNodes(scan.tokens) } }) }, [lines, lang]) return (
{rows.map((row) => (
{String(row.line)} {row.nodes}
))}
) }