'use client' import type { ReactNode } from 'react' import * as React from 'react' import { useShikiHighlighter } from 'react-shiki' import { type BundledLanguage, codeToTokens, type ShikiTransformer, type ThemedToken } from 'shiki' import { chunkLines, type LineChunk, useFixedRowWindow } from '@/components/chat/fixed-row-window' import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter' import { shikiLanguageForFilename } from '@/lib/markdown-code' import { cn } from '@/lib/utils' /** * Renders a unified diff for a tool's file edit. Two paths share one parse: * - `SyntaxDiff` highlights the change *content* in the file's language via * Shiki, then a per-line transformer paints the add/remove tint on top. * - `DiffLines` is the color-only fallback (no language, over budget, or while * Shiki loads). * Both drop git file-headers + `@@` hunk noise and the `+/-` gutter so changes * read by color + a 2px gutter accent, the way Cursor does. */ type DiffKind = 'add' | 'context' | 'remove' export interface DiffLine { kind: DiffKind text: string /** 1-based line number in the old/new file (absent on the "other" side of an * add/remove, and on hunk-separator blanks). Only used when line numbers are * shown (the preview's full diff). */ newNo?: number oldNo?: number } interface ParsedHunk { lines: Array<{ kind: DiffKind; text: string }> newStart: number oldStart: number } // Tint + 2px gutter accent per change kind. Text color is included for the // plain renderer; the Shiki path omits it so syntax colors win, layering only // the background + border. const DIFF_KIND_TINT: Record = { add: 'border-emerald-500 bg-emerald-500/12', context: 'border-transparent', remove: 'border-rose-500 bg-rose-500/12' } const DIFF_KIND_TEXT: Record = { add: 'text-emerald-800 dark:text-emerald-200', context: '', remove: 'text-rose-800 dark:text-rose-200' } const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px' const PREVIEW_DIFF_LINE_BASE = 'block h-5 min-w-max whitespace-pre px-2.5 leading-5' const PREVIEW_CHUNK_LINES = 200 const PREVIEW_LINE_PX = 20 const PREVIEW_OVERSCAN_LINES = 400 // Bleed out of the tool-card body's `p-1.5` so tints/borders run flush to the // card edges (rounded corners clip via the card's overflow); compact height // with internal scroll like a code block. // `overscroll-y-auto` so reaching the box's top/bottom hands the wheel back to // the page (no scroll-trap); `overscroll-x-contain` keeps a trackpad's sideways // overscroll on long code lines from firing browser back/forward navigation. const DIFF_BOX_CLASS = '-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-x-contain overscroll-y-auto font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)' function diffKind(line: string): DiffKind { if (line.startsWith('+') && !line.startsWith('+++')) { return 'add' } if (line.startsWith('-') && !line.startsWith('---')) { return 'remove' } return 'context' } // Drop the leading +/-/space gutter so changes read by color alone, keeping the // rest of the indentation intact. function stripDiffMarker(line: string): string { if (diffKind(line) !== 'context' || line.startsWith(' ')) { return line.slice(1) } return line } // Git-style unified diffs arrive with a file-header preamble — `diff --git`, // `index …`, `--- a/path`, `+++ b/path`, and Hermes' own `a/path → b/path` // arrow line. That preamble just repeats the path (which the tool row already // shows) and reads especially badly for absolute paths (`a//Users/…`). Strip // the leading header zone up to the first hunk. const DIFF_HEADER_PREFIXES = [ 'diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file' ] function isArrowHeaderLine(line: string): boolean { const trimmed = line.trim() return trimmed.includes('→') && /^\S.*→\s*\S+$/.test(trimmed) && !/^[+\-@]/.test(trimmed) } /** Exported for tests. */ export function stripDiffFileHeaders(diff: string): string { const lines = diff.split('\n') let start = 0 for (; start < lines.length; start += 1) { const line = lines[start] if (line.startsWith('@@')) { break } if (line.trim() === '' || isArrowHeaderLine(line) || DIFF_HEADER_PREFIXES.some(prefix => line.startsWith(prefix))) { continue } break } return lines.slice(start).join('\n') } function parseHunks(diff: string): ParsedHunk[] { const hunks: ParsedHunk[] = [] let active: null | ParsedHunk = null for (const line of stripDiffFileHeaders(diff).split('\n')) { if (line.startsWith('@@')) { const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line) if (!match) { active = null continue } active = { oldStart: Number(match[1]), newStart: Number(match[2]), lines: [] } hunks.push(active) continue } if (!active || line.startsWith('\\')) { continue } active.lines.push({ kind: diffKind(line), text: stripDiffMarker(line) }) } return hunks } // Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank // separator kept between hunks), markers stripped, kind recorded. Old/new line // numbers are tracked from each `@@ -a,b +c,d @@` header so a caller that wants // a gutter (the preview) can render them; the blank separator carries none. function parseDiff(diff: string): DiffLine[] { const hunks = parseHunks(diff) if (hunks.length === 0) { // Fallback for unexpected non-hunk payloads. return stripDiffFileHeaders(diff) .split('\n') .map(line => ({ kind: diffKind(line), text: stripDiffMarker(line) })) } const out: DiffLine[] = [] let emitted = false let oldNo = 1 let newNo = 1 for (const hunk of hunks) { oldNo = hunk.oldStart newNo = hunk.newStart if (emitted) { out.push({ kind: 'context', text: '' }) } for (const line of hunk.lines) { const entry: DiffLine = { kind: line.kind, text: line.text } if (line.kind === 'add') { entry.newNo = newNo++ } else if (line.kind === 'remove') { entry.oldNo = oldNo++ } else { entry.oldNo = oldNo++ entry.newNo = newNo++ } out.push(entry) emitted = true } } return out } // Build a full-file diff view anchored to the CURRENT file text. Every current // line is emitted from `fullText` with its real new-file line number; hunks only // mark those rows as added and insert deleted rows between them. That keeps the // preview's SOURCE and DIFF views on the same line map even when git returns // compact hunks or removed-only rows. function parseFullFileDiff(diff: string, fullText: string): DiffLine[] { const hunks = parseHunks(diff) const fullLines = fullText.split('\n') if (hunks.length === 0) { return fullLines.map((text, index) => ({ kind: 'context', newNo: index + 1, oldNo: index + 1, text })) } const added = new Set() const oldNoByNewNo = new Map() const removalsByNewNo = new Map() const out: DiffLine[] = [] for (const hunk of hunks) { let oldNo = hunk.oldStart let newNo = hunk.newStart for (const line of hunk.lines) { if (line.kind === 'add') { added.add(newNo) newNo += 1 } else if (line.kind === 'remove') { const anchor = Math.max(1, Math.min(newNo, fullLines.length + 1)) const bucket = removalsByNewNo.get(anchor) ?? [] bucket.push({ kind: 'remove', oldNo, text: line.text }) removalsByNewNo.set(anchor, bucket) oldNo += 1 } else { oldNoByNewNo.set(newNo, oldNo) oldNo += 1 newNo += 1 } } } for (let index = 0; index < fullLines.length; index += 1) { const newNo = index + 1 const removals = removalsByNewNo.get(newNo) if (removals) { out.push(...removals) } out.push({ kind: added.has(newNo) ? 'add' : 'context', newNo, oldNo: oldNoByNewNo.get(newNo), text: fullLines[index] ?? '' }) } const trailingRemovals = removalsByNewNo.get(fullLines.length + 1) if (trailingRemovals) { out.push(...trailingRemovals) } return out } function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) { return ( <> {lines.map((line, index) => ( {line.text || ' '} ))} ) } // shiki FontStyle is a bitmask: Italic=1, Bold=2, Underline=4. function tokenStyle({ bgColor, color, fontStyle = 0 }: ThemedToken): React.CSSProperties | undefined { if (!color && !bgColor && !fontStyle) { return undefined } return { backgroundColor: bgColor, color, fontStyle: fontStyle & 1 ? 'italic' : undefined, fontWeight: fontStyle & 2 ? 700 : undefined, textDecorationLine: fontStyle & 4 ? 'underline' : undefined } } function useThemeName() { const current = () => (document.documentElement.classList.contains('dark') ? SHIKI_THEME.dark : SHIKI_THEME.light) const [theme, setTheme] = React.useState(current) React.useEffect(() => { const observer = new MutationObserver(() => setTheme(current())) observer.observe(document.documentElement, { attributeFilter: ['class'], attributes: true }) return () => observer.disconnect() }, []) return theme } function PreviewDiffRows({ afterLines = 0, beforeLines = 0, chunks, tokens }: { afterLines?: number beforeLines?: number chunks: Array> tokens?: ThemedToken[][] | null }) { return ( <> {beforeLines > 0 &&
} {chunks.map(chunk => (
{chunk.lines.map((line, offset) => { const index = chunk.start + offset const rowTokens = tokens?.[index] ?? [] return ( {rowTokens.length > 0 ? rowTokens.map((token, tokenIndex) => ( {token.content} )) : line.text || ' '} ) })}
))} {afterLines > 0 &&
} ) } function TokenizedDiffBody({ afterLines, beforeLines, chunked = false, chunks, language, lines }: { afterLines?: number beforeLines?: number chunked?: boolean chunks?: Array> language: string lines: DiffLine[] }) { const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines]) const theme = useThemeName() const [tokens, setTokens] = React.useState(null) React.useEffect(() => { let cancelled = false setTokens(null) void codeToTokens(code, { lang: language as BundledLanguage, theme }) .then(result => { if (!cancelled) { setTokens(result.tokens) } }) .catch(() => { if (!cancelled) { setTokens([]) } }) return () => { cancelled = true } }, [code, language, theme]) if (!tokens) { return chunked ? ( ) : ( ) } if (chunked) { return ( ) } return ( <> {lines.map((line, index) => { const rowTokens = tokens[index] ?? [] return ( {rowTokens.length > 0 ? rowTokens.map((token, tokenIndex) => ( {token.content} )) : line.text || ' '} ) })} ) } // Shiki transformer: tag each `.line` with the diff tint for its kind, so the // syntax-highlighted output keeps add/remove backgrounds + the gutter accent. function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer { return { line(node, line) { const kind = kinds[line - 1] ?? 'context' const existing = Array.isArray(node.properties.className) ? (node.properties.className as string[]) : node.properties.className ? [String(node.properties.className)] : [] node.properties.className = [...existing, DIFF_LINE_BASE, DIFF_KIND_TINT[kind]] } } } function SyntaxDiff({ language, lines }: { language: string; lines: DiffLine[] }) { const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines]) const transformers = React.useMemo(() => [diffLineTransformer(lines.map(line => line.kind))], [lines]) const highlighted = useShikiHighlighter(code, language, SHIKI_THEME, { defaultColor: 'light-dark()', transformers }) // Until Shiki resolves, show the plain colored diff so there's no flash. return (highlighted as ReactNode) ?? } interface DiffLinesProps extends Omit, 'children'> { text: string } export function DiffLines({ className, text, ...props }: DiffLinesProps) { const lines = React.useMemo(() => parseDiff(text), [text]) return (
      
    
) } // Coalesce consecutive same-kind changed rows into runs, each placed by line // fraction (no DOM measurement). Context rows produce no tick. function overviewRuns(lines: DiffLine[]): { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] { const total = lines.length || 1 const runs: { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] = [] for (let i = 0; i < lines.length; ) { const kind = lines[i].kind if (kind === 'context') { i += 1 continue } let j = i + 1 while (j < lines.length && lines[j].kind === kind) { j += 1 } runs.push({ kind, sizePct: ((j - i) / total) * 100, startPct: (i / total) * 100 }) i = j } return runs } // VS Code-style overview ruler: a thin strip pinned to the diff's right edge with // a green/red tick per change, positioned by line fraction. Pinned to the // viewport (not the scrolled content) by living as an absolute sibling of the // scroller inside a relative wrapper — so no scroll listener or measurement. function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) { const runs = React.useMemo(() => overviewRuns(lines), [lines]) if (runs.length === 0) { return null } return (
{/* Cap the tick field to the diff's natural height (rows × line px) so a short diff renders thin, line-aligned ticks instead of stretching a few changes into gross full-height blocks. A long diff hits the 100% cap and compresses into a true overview. */}
{runs.map((run, index) => (
))}
) } interface FileDiffPanelProps { /** Override the default (tool-card) box styling — the full-height preview * cancels the bleed/clamp so the diff fills its pane. */ className?: string diff: string /** Current file text. When provided, the panel expands hunked diffs into a * full-file view so unchanged lines are preserved between hunks. */ fullText?: string path?: string /** Render an old/new line-number gutter (the full preview diff). The compact * tool-card + inline review diff leave this off. */ showLineNumbers?: boolean } export function FileDiffPanel({ className, diff, fullText, path, showLineNumbers = false }: FileDiffPanelProps) { const lines = React.useMemo( () => (fullText != null ? parseFullFileDiff(diff, fullText) : parseDiff(diff)), [diff, fullText] ) const lineChunks = React.useMemo(() => chunkLines(lines, PREVIEW_CHUNK_LINES), [lines]) const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ overscanRows: PREVIEW_OVERSCAN_LINES, rowPx: PREVIEW_LINE_PX, rowsPerChunk: PREVIEW_CHUNK_LINES, totalRows: lines.length }) const visibleLineChunks = lineChunks.slice(startChunk, endChunk + 1) const language = shikiLanguageForFilename(path) const canHighlight = Boolean(language) && !exceedsHighlightBudget(fullText ?? diff) // Full-file preview: we own the rows (tokens rendered inside) so blank lines // can't collapse. Compact tool/review diffs let Shiki own the rows. const body = !canHighlight ? ( showLineNumbers ? ( ) : ( ) ) : fullText != null ? ( ) : ( ) if (!showLineNumbers) { return (
{body}
) } // A single line-number gutter (VS Code's inline-diff style): each row shows its // own file's number — the new number for context/adds, the old number for // removals — with an overview ruler pinned to the right edge. The inner div // owns the scroll so the ruler (an absolute sibling) stays viewport-fixed. return (
{beforeRows > 0 &&
} {visibleLineChunks.map(chunk => (
{chunk.lines.map((line, offset) => { const index = chunk.start + offset return (
{line.newNo ?? ''}
) })}
))} {afterRows > 0 &&
}
{body}
) }