1baea21cfb
The chunk split only helps if nothing on the entry graph statically imports the heavy libs. Three seams did: - react-shiki: now behind one lazy() boundary (shiki-block.tsx is its only static importer; LazyShiki wraps it with a PlainCode fallback). - diff-lines.tsx: useShikiHighlighter hook extracted to a lazily-loaded syntax-diff.tsx (plain DiffBody fallback — same output Shiki's own pre-resolve state showed); codeToTokens becomes a dynamic import that runs only once a highlightable diff is on screen. - @streamdown/code: statically ran createJavaScriptRegexEngine() and built full language registries at module scope on every launch. It now loads via useCodePlugin() on first markdown mount and swaps into the plugin table when it lands; fenced code renders plain until then, identical to the delay fallback users already see during streaming. Interleaved A/B vs main (prod build, warm V8 cache, median of 4, two alternating rounds): FCP 1100->940ms, DOMContentLoaded 618->542ms, nav-to-interactive 1312->1123ms on the quiet round; same direction with larger gaps under load. ~160-190ms off every renderer boot metric.
678 lines
21 KiB
TypeScript
678 lines
21 KiB
TypeScript
'use client'
|
||
|
||
import * as React from 'react'
|
||
import type { BundledLanguage, ShikiTransformer, 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<DiffKind, string> = {
|
||
add: 'border-(--ui-diff-add-border) bg-(--ui-diff-add-background)',
|
||
context: 'border-transparent',
|
||
remove: 'border-(--ui-diff-remove-border) bg-(--ui-diff-remove-background)'
|
||
}
|
||
|
||
const DIFF_KIND_TEXT: Record<DiffKind, string> = {
|
||
add: 'text-(--ui-diff-add-foreground)',
|
||
context: '',
|
||
remove: 'text-(--ui-diff-remove-foreground)'
|
||
}
|
||
|
||
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<number>()
|
||
const oldNoByNewNo = new Map<number, number>()
|
||
const removalsByNewNo = new Map<number, DiffLine[]>()
|
||
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
|
||
}
|
||
|
||
/** Exported for the lazily-loaded SyntaxDiff (syntax-diff.tsx). */
|
||
export function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
|
||
return (
|
||
<>
|
||
{lines.map((line, index) => (
|
||
<span
|
||
className={cn(DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind], !syntax && DIFF_KIND_TEXT[line.kind])}
|
||
key={`${index}-${line.text}`}
|
||
>
|
||
{line.text || ' '}
|
||
</span>
|
||
))}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// 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<LineChunk<DiffLine>>
|
||
tokens?: ThemedToken[][] | null
|
||
}) {
|
||
return (
|
||
<>
|
||
{beforeLines > 0 && <div aria-hidden style={{ height: beforeLines * PREVIEW_LINE_PX }} />}
|
||
{chunks.map(chunk => (
|
||
<div className="block" key={chunk.start}>
|
||
{chunk.lines.map((line, offset) => {
|
||
const index = chunk.start + offset
|
||
const rowTokens = tokens?.[index] ?? []
|
||
|
||
return (
|
||
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
|
||
{rowTokens.length > 0
|
||
? rowTokens.map((token, tokenIndex) => (
|
||
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
|
||
{token.content}
|
||
</span>
|
||
))
|
||
: line.text || ' '}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
{afterLines > 0 && <div aria-hidden style={{ height: afterLines * PREVIEW_LINE_PX }} />}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function TokenizedDiffBody({
|
||
afterLines,
|
||
beforeLines,
|
||
chunked = false,
|
||
chunks,
|
||
language,
|
||
lines
|
||
}: {
|
||
afterLines?: number
|
||
beforeLines?: number
|
||
chunked?: boolean
|
||
chunks?: Array<LineChunk<DiffLine>>
|
||
language: string
|
||
lines: DiffLine[]
|
||
}) {
|
||
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||
const theme = useThemeName()
|
||
const [tokens, setTokens] = React.useState<ThemedToken[][] | null>(null)
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false
|
||
|
||
setTokens(null)
|
||
// Dynamic import so the multi-MB shiki chunk stays off the cold-start
|
||
// path — this effect only runs once a highlightable diff is on screen.
|
||
void import('shiki')
|
||
.then(({ codeToTokens }) => 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 ? (
|
||
<PreviewDiffRows
|
||
afterLines={afterLines}
|
||
beforeLines={beforeLines}
|
||
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
|
||
/>
|
||
) : (
|
||
<DiffBody lines={lines} />
|
||
)
|
||
}
|
||
|
||
if (chunked) {
|
||
return (
|
||
<PreviewDiffRows
|
||
afterLines={afterLines}
|
||
beforeLines={beforeLines}
|
||
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
|
||
tokens={tokens}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{lines.map((line, index) => {
|
||
const rowTokens = tokens[index] ?? []
|
||
|
||
return (
|
||
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
|
||
{rowTokens.length > 0
|
||
? rowTokens.map((token, tokenIndex) => (
|
||
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
|
||
{token.content}
|
||
</span>
|
||
))
|
||
: line.text || ' '}
|
||
</span>
|
||
)
|
||
})}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// Shiki transformer: tag each `.line` with the diff tint for its kind, so the
|
||
// syntax-highlighted output keeps add/remove backgrounds + the gutter accent.
|
||
// Exported for the lazily-loaded SyntaxDiff (syntax-diff.tsx).
|
||
export 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[] }) {
|
||
// The Shiki hook lives in a lazily-loaded module (syntax-diff.tsx) so the
|
||
// multi-MB shiki chunk stays off the cold-start path. Until it (and the
|
||
// highlight itself) resolves, show the plain colored diff — no flash.
|
||
return (
|
||
<React.Suspense fallback={<DiffBody lines={lines} />}>
|
||
<LazySyntaxDiff language={language} lines={lines} />
|
||
</React.Suspense>
|
||
)
|
||
}
|
||
|
||
const LazySyntaxDiff = React.lazy(() => import('./syntax-diff'))
|
||
|
||
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
|
||
text: string
|
||
}
|
||
|
||
export function DiffLines({ className, text, ...props }: DiffLinesProps) {
|
||
const lines = React.useMemo(() => parseDiff(text), [text])
|
||
|
||
return (
|
||
<pre className={cn(DIFF_BOX_CLASS, className)} data-slot="diff-lines" {...props}>
|
||
<DiffBody lines={lines} />
|
||
</pre>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div aria-hidden className="pointer-events-none absolute top-0 right-0 bottom-0 w-1.5 opacity-80">
|
||
{/* 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. */}
|
||
<div className="relative w-full" style={{ height: `min(100%, ${lines.length * PREVIEW_LINE_PX}px)` }}>
|
||
{runs.map((run, index) => (
|
||
<div
|
||
className={cn(
|
||
'absolute inset-x-0',
|
||
run.kind === 'add' ? 'bg-(--ui-diff-add-border)' : 'bg-(--ui-diff-remove-border)'
|
||
)}
|
||
key={index}
|
||
style={{ height: `max(0.125rem, ${run.sizePct}%)`, top: `${run.startPct}%` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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
|
||
/** Window the rows (fixed-row virtualization) WITHOUT a gutter — for a large
|
||
* diff in a scrolling pane (the review panel), so only visible rows mount
|
||
* instead of highlighting every line. `showLineNumbers` implies windowing. */
|
||
virtualized?: boolean
|
||
}
|
||
|
||
export function FileDiffPanel({
|
||
className,
|
||
diff,
|
||
fullText,
|
||
path,
|
||
showLineNumbers = false,
|
||
virtualized = 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)
|
||
const windowed = showLineNumbers || virtualized
|
||
|
||
// Windowed: we own fixed-height rows and render only the visible chunks, so a
|
||
// large diff never mounts (or Shiki-highlights) every line. Compact tool cards
|
||
// are small/clamped, so they let Shiki own the rows (SyntaxDiff).
|
||
const windowedBody = canHighlight ? (
|
||
<TokenizedDiffBody
|
||
afterLines={afterRows}
|
||
beforeLines={beforeRows}
|
||
chunked
|
||
chunks={visibleLineChunks}
|
||
language={language}
|
||
lines={lines}
|
||
/>
|
||
) : (
|
||
<PreviewDiffRows afterLines={afterRows} beforeLines={beforeRows} chunks={visibleLineChunks} />
|
||
)
|
||
|
||
const compactBody = !canHighlight ? (
|
||
<DiffBody lines={lines} />
|
||
) : fullText != null ? (
|
||
<TokenizedDiffBody language={language} lines={lines} />
|
||
) : (
|
||
<SyntaxDiff language={language} lines={lines} />
|
||
)
|
||
|
||
if (!windowed) {
|
||
return (
|
||
<div className={cn(DIFF_BOX_CLASS, className)} data-slot="file-diff-panel">
|
||
{compactBody}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Windowed: a fixed-row scroller renders only the visible rows (killing the
|
||
// full-Shiki-of-every-line freeze on large diffs). With `showLineNumbers` a
|
||
// VS Code-style gutter (new number for context/adds, old for removals) sits in
|
||
// a left column; the scroller owns scroll so the overview ruler (an absolute
|
||
// sibling) stays viewport-fixed.
|
||
return (
|
||
<div className={cn(DIFF_BOX_CLASS, 'relative overflow-hidden', className)} data-slot="file-diff-panel">
|
||
<div
|
||
className={cn('absolute inset-0 overflow-auto', showLineNumbers && 'pr-2.5')}
|
||
onScroll={onScroll}
|
||
ref={scrollerRef}
|
||
>
|
||
{showLineNumbers ? (
|
||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)]">
|
||
<div className="sticky left-0 z-1 select-none bg-(--ui-editor-surface-background) py-3 text-muted-foreground/55">
|
||
{beforeRows > 0 && <div aria-hidden style={{ height: beforeRows * PREVIEW_LINE_PX }} />}
|
||
{visibleLineChunks.map(chunk => (
|
||
<div className="block" key={chunk.start}>
|
||
{chunk.lines.map((line, offset) => {
|
||
const index = chunk.start + offset
|
||
|
||
return (
|
||
<div
|
||
className="h-5 w-9 pr-2 text-right leading-5 tabular-nums"
|
||
key={`${index}-${line.oldNo}-${line.newNo}`}
|
||
>
|
||
{line.newNo ?? ''}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
{afterRows > 0 && <div aria-hidden style={{ height: afterRows * PREVIEW_LINE_PX }} />}
|
||
</div>
|
||
<div className="min-w-0">{windowedBody}</div>
|
||
</div>
|
||
) : (
|
||
<div className="min-w-0">{windowedBody}</div>
|
||
)}
|
||
</div>
|
||
<DiffOverviewRuler lines={lines} />
|
||
</div>
|
||
)
|
||
}
|