blob: b7d15f5c4e033e55b272780ea2f640e2f61b77c6 [file]
import { useInsertionEffect, useMemo, type ReactNode } from 'react';
import { parseUnifiedDiffRows } from '@maka/core';
import { ensureHighlightStyles, type TokenLine } from '@astryxdesign/core';
import { previewVariants } from '../primitives/chat.js';
import { diffSyntaxTokens } from './diff-syntax.js';
type DiffPreviewRow = {
kind: 'add' | 'del' | 'ctx' | 'meta';
marker: string;
code: string;
lineNumber?: number;
};
/**
* Shared structural unified-diff body. Callers own trust boundaries and
* display budgets; this component owns parsing, gutters, line numbers, and
* syntax presentation so transcript results and generated files cannot drift.
*/
export function DiffCodePreview(props: {
diff: string;
paths: readonly string[];
className?: string;
}) {
useInsertionEffect(() => {
ensureHighlightStyles();
}, []);
const rows = useMemo(() => {
const lines = props.diff.split('\n');
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
return diffPreviewRows(lines);
}, [props.diff]);
const tokenLines = useMemo(
() => diffSyntaxTokens(props.paths, rows.map((row) => row.code)),
[props.paths, rows],
);
const numbered = rows.some((row) => row.lineNumber !== undefined);
const digits = numbered
? String(rows.reduce((max, row) => Math.max(max, row.lineNumber ?? 0), 0)).length
: 0;
return (
<pre className={`${previewVariants({ part: 'diff-body' })}${props.className ? ` ${props.className}` : ''}`}>
{rows.map((row, index) => (
<span
key={index}
className={previewVariants({ part: 'diff-line' })}
data-line={row.kind}
>
{numbered && (
<span className="maka-tool-diff-gutter" style={{ minWidth: `${digits}ch` }}>
{row.lineNumber ?? ''}
</span>
)}
<span className="maka-tool-diff-marker">{row.marker}</span>
<span className="maka-tool-diff-code">
{row.kind === 'meta' ? row.code : highlightedCode(row.code, tokenLines[index])}
</span>
{'\n'}
</span>
))}
</pre>
);
}
function diffPreviewRows(lines: string[]): DiffPreviewRow[] {
return parseUnifiedDiffRows(lines.join('\n')).flatMap((row): DiffPreviewRow[] => {
if (row.kind === 'hunk') return [];
if (row.kind === 'meta') return [{ kind: 'meta', marker: '', code: row.text }];
const lineNumber = row.kind === 'del' ? row.oldLine : row.newLine;
const marker = row.kind === 'ctx' ? '' : row.text.slice(0, 1);
const code = row.kind === 'ctx' ? row.text.replace(/^ /, '') : row.text.slice(1);
return [{
kind: row.kind,
marker,
code,
...(lineNumber !== undefined ? { lineNumber } : {}),
}];
});
}
function highlightedCode(code: string, tokens: TokenLine | undefined): ReactNode {
if (!tokens || tokens.length === 0) return code;
const parts: ReactNode[] = [];
let cursor = 0;
for (const token of tokens) {
const end = Math.min(token.end, code.length);
if (token.start < cursor || end <= token.start) continue;
if (token.start > cursor) parts.push(code.slice(cursor, token.start));
parts.push(
<span key={token.start} className={`astryx-token-${token.type}`}>
{code.slice(token.start, end)}
</span>,
);
cursor = end;
}
if (cursor < code.length) parts.push(code.slice(cursor));
return parts;
}