/** * Line diff between two artifact versions, used by the artifact panel to show * what an `update` operation changed. * * The whole file is emitted (changed lines marked, unchanged lines as * context), not just hunks: the code view stays a scrollable view of the * artifact, and the panel auto-scrolls to the first change. Rendering keeps * full syntax highlighting: the old and new contents are highlighted * separately, split into lines, and changed lines are tinted via * background-only classes so token colors stay intact (plus a stronger * emphasis chip on the changed segment of replaced lines). */ export type DiffLineType = "context" | "add" | "del"; export interface DiffLine { type: DiffLineType; text: string; } /** * Beyond this many LCS cells, fall back to a plain delete-all/add-all block * for the changed region instead of computing an optimal alignment. */ const MAX_LCS_CELLS = 1_000_000; /** * Diff `oldText` → `newText` line by line. Common prefix/suffix lines are * trimmed first — artifact updates are typically small and local, which keeps * the LCS table tiny even for large artifacts. Within a changed region, * deletions are emitted before additions. */ export function diffLines(oldText: string, newText: string): DiffLine[] { // A trailing newline is presentation, not content — don't diff it const a = oldText.replace(/\n$/, "").split("\n"); const b = newText.replace(/\n$/, "").split("\n"); let start = 0; while (start < a.length && start < b.length && a[start] === b[start]) start += 1; let endA = a.length; let endB = b.length; while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA -= 1; endB -= 1; } const lines: DiffLine[] = []; for (let i = 0; i < start; i += 1) lines.push({ type: "context", text: a[i] }); const n = endA - start; const m = endB - start; if (n > 0 && m > 0 && (n + 1) * (m + 1) <= MAX_LCS_CELLS) { // dp[i][j] = LCS length of a[start+i..endA) and b[start+j..endB), so the // walk below can emit lines forward in display order. const width = m + 1; const dp = new Uint32Array((n + 1) * width); for (let i = n - 1; i >= 0; i -= 1) { for (let j = m - 1; j >= 0; j -= 1) { dp[i * width + j] = a[start + i] === b[start + j] ? dp[(i + 1) * width + j + 1] + 1 : Math.max(dp[(i + 1) * width + j], dp[i * width + j + 1]); } } let i = 0; let j = 0; while (i < n && j < m) { if (a[start + i] === b[start + j]) { lines.push({ type: "context", text: a[start + i] }); i += 1; j += 1; } else if (dp[(i + 1) * width + j] >= dp[i * width + j + 1]) { lines.push({ type: "del", text: a[start + i] }); i += 1; } else { lines.push({ type: "add", text: b[start + j] }); j += 1; } } while (i < n) { lines.push({ type: "del", text: a[start + i] }); i += 1; } while (j < m) { lines.push({ type: "add", text: b[start + j] }); j += 1; } } else { for (let i = start; i < endA; i += 1) lines.push({ type: "del", text: a[i] }); for (let j = start; j < endB; j += 1) lines.push({ type: "add", text: b[j] }); } for (let i = endA; i < a.length; i += 1) lines.push({ type: "context", text: a[i] }); return lines; } export function diffStats(lines: DiffLine[]): { added: number; removed: number } { let added = 0; let removed = 0; for (const line of lines) { if (line.type === "add") added += 1; else if (line.type === "del") removed += 1; } return { added, removed }; } // ---------- rendering ---------- function escapeHtml(text: string): string { return text.replace(/&/g, "&").replace(//g, ">"); } /** * Split highlighter output into one HTML string per line. hljs emits flat * escaped text plus `` tokens that may legally span newlines (template * literals, comments), so open spans are closed at each line break and * reopened on the next line — every returned line is self-contained HTML. */ function splitHighlightedLines(html: string): string[] { const lines: string[] = []; const stack: string[] = []; let current = ""; const tokens = html.match(/<\/?span[^>]*>|\n|[^\n<]+|".repeat(stack.length)); current = stack.join(""); } else if (token.startsWith(" 0 ? (aRange.end - aRange.start) / a.length : 0; const bFraction = b.length > 0 ? (bRange.end - bRange.start) / b.length : 0; if (aFraction > 0.7 && bFraction > 0.7) return null; return { a: aRange, b: bRange }; } /** * Wrap the [start, end) raw-character range of a highlighted line in * ``. Offsets count source characters, so entities * (one escaped character) count as one; the wrapper never crosses token * tags — it closes and reopens around them instead, keeping nesting valid. */ function emphasizeRange(html: string, { start, end }: CharRange): string { if (start >= end) return html; const openTag = ``; let out = ""; let raw = 0; let open = false; const close = () => { if (open) { out += ""; open = false; } }; const tokens = html.match(/<\/?span[^>]*>|&[a-zA-Z][a-zA-Z0-9]*;|&#x?[0-9a-fA-F]+;|[^&<]+|[&<]/g); for (const token of tokens ?? []) { if (token.startsWith("<") && token.length > 1) { close(); out += token; continue; } if (token.startsWith("&") && token.length > 1 && token.endsWith(";")) { const inside = raw >= start && raw < end; if (inside && !open) { out += openTag; open = true; } else if (!inside) { close(); } out += token; raw += 1; continue; } const from = Math.min(Math.max(start - raw, 0), token.length); const to = Math.min(Math.max(end - raw, 0), token.length); const before = token.slice(0, from); const within = token.slice(from, to); const after = token.slice(to); if (before) { close(); out += before; } if (within) { if (!open) { out += openTag; open = true; } out += within; } if (after) { close(); out += after; } raw += token.length; } close(); return out; } /** * Render diff lines as HTML for a `
` block. The old and new
 * contents are highlighted as whole documents (so multi-line constructs keep
 * correct colors), then reassembled per line: changed lines are wrapped in
 * background-tint spans with a colored `+ `/`- ` sign (copied text reads as a
 * diff), and replaced line pairs get a `diff-emph` chip on the changed
 * segment. Context lines stay plain highlighted code.
 */
export function renderDiffHtml(lines: DiffLine[], highlight?: (text: string) => string): string {
	const oldRaw: string[] = [];
	const newRaw: string[] = [];
	for (const line of lines) {
		if (line.type !== "add") oldRaw.push(line.text);
		if (line.type !== "del") newRaw.push(line.text);
	}
	const hl = highlight ?? escapeHtml;
	let oldHtml = splitHighlightedLines(hl(oldRaw.join("\n")));
	let newHtml = splitHighlightedLines(hl(newRaw.join("\n")));
	if (oldHtml.length !== oldRaw.length || newHtml.length !== newRaw.length) {
		// A highlighter that altered line structure would desync the diff; fall
		// back to plain escaped lines rather than mislabel them
		oldHtml = oldRaw.map(escapeHtml);
		newHtml = newRaw.map(escapeHtml);
	}

	// Pair the k-th deleted line with the k-th added line of each changed block
	// (deletions always precede additions, see diffLines) for intra-line emphasis
	const emphOld = new Map();
	const emphNew = new Map();
	{
		let i = 0;
		let oi = 0;
		let ni = 0;
		while (i < lines.length) {
			if (lines[i].type === "context") {
				i += 1;
				oi += 1;
				ni += 1;
				continue;
			}
			if (lines[i].type === "add") {
				i += 1;
				ni += 1;
				continue;
			}
			const delI = i;
			const delOi = oi;
			while (i < lines.length && lines[i].type === "del") {
				i += 1;
				oi += 1;
			}
			const addI = i;
			const addNi = ni;
			while (i < lines.length && lines[i].type === "add") {
				i += 1;
				ni += 1;
			}
			const pairs = Math.min(addI - delI, i - addI);
			for (let k = 0; k < pairs; k += 1) {
				const segments = changedSegments(lines[delI + k].text, lines[addI + k].text);
				if (segments) {
					emphOld.set(delOi + k, segments.a);
					emphNew.set(addNi + k, segments.b);
				}
			}
		}
	}

	const out: string[] = [];
	let oi = 0;
	let ni = 0;
	for (const line of lines) {
		if (line.type === "context") {
			out.push(`  ${newHtml[ni]}`);
			oi += 1;
			ni += 1;
		} else if (line.type === "del") {
			const range = emphOld.get(oi);
			const html = range ? emphasizeRange(oldHtml[oi], range) : oldHtml[oi];
			out.push(`- ${html}`);
			oi += 1;
		} else {
			const range = emphNew.get(ni);
			const html = range ? emphasizeRange(newHtml[ni], range) : newHtml[ni];
			out.push(`+ ${html}`);
			ni += 1;
		}
	}
	return out.join("\n");
}