2026-02-23 17:05:53 +07:00
|
|
|
/**
|
|
|
|
|
* VirtualizedCodeBlock — PERF-007
|
|
|
|
|
*
|
2026-06-15 23:37:24 +03:00
|
|
|
* Renders large code/read outputs without mounting one highlighter per line:
|
|
|
|
|
* 1. ONE worker tokenization of the whole block (off the main thread)
|
2026-07-03 18:44:10 +03:00
|
|
|
* 2. @tanstack/react-virtual to only render visible rows
|
2026-02-23 17:05:53 +07:00
|
|
|
*
|
2026-06-15 23:37:24 +03:00
|
|
|
* Tokenizing the whole block at once also preserves cross-line syntax context
|
|
|
|
|
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
|
|
|
|
|
* through the `--md-syntax-*` CSS variables on the container.
|
2026-02-23 17:05:53 +07:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import React from 'react';
|
2026-07-03 18:44:10 +03:00
|
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
2026-06-15 23:37:24 +03:00
|
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
2026-08-07 09:01:31 +03:00
|
|
|
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
|
2026-06-15 23:37:24 +03:00
|
|
|
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
2026-02-23 17:05:53 +07:00
|
|
|
|
|
|
|
|
// ── Threshold: files smaller than this render without virtualization ──
|
|
|
|
|
const VIRTUALIZE_THRESHOLD = 80;
|
|
|
|
|
const ROW_HEIGHT = 20; // px — matches typography-code line-height
|
|
|
|
|
|
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────
|
|
|
|
|
export interface CodeLine {
|
|
|
|
|
text: string;
|
|
|
|
|
lineNumber?: number | null;
|
|
|
|
|
isInfo?: boolean;
|
|
|
|
|
/** For diff lines */
|
|
|
|
|
type?: 'context' | 'added' | 'removed';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VirtualizedCodeBlockProps {
|
|
|
|
|
lines: CodeLine[];
|
|
|
|
|
language: string;
|
|
|
|
|
/** Max visible height in CSS (default: 60vh) */
|
|
|
|
|
maxHeight?: string;
|
|
|
|
|
/** Show line numbers (default: true) */
|
|
|
|
|
showLineNumbers?: boolean;
|
|
|
|
|
/** Styles per line type (for diffs) */
|
|
|
|
|
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Component ────────────────────────────────────────────────────────
|
|
|
|
|
export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.memo((props) => {
|
|
|
|
|
const {
|
|
|
|
|
lines,
|
|
|
|
|
language,
|
|
|
|
|
maxHeight = '60vh',
|
|
|
|
|
showLineNumbers = true,
|
|
|
|
|
lineStyles,
|
|
|
|
|
} = props;
|
2026-06-15 23:37:24 +03:00
|
|
|
|
|
|
|
|
const { currentTheme } = useThemeSystem();
|
|
|
|
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
|
|
|
|
// Tokenize the whole block in one worker call; rows index into the result.
|
|
|
|
|
const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]);
|
2026-08-22 02:10:03 +03:00
|
|
|
const highlightResult = useWorkerHighlightedLines(fullText, language);
|
|
|
|
|
const highlighted = highlightResult.lines;
|
2026-02-23 17:05:53 +07:00
|
|
|
|
|
|
|
|
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
|
|
|
|
|
|
|
|
|
|
// ── Small file: render directly (no virtualizer overhead) ──
|
|
|
|
|
if (!shouldVirtualize) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2026-06-15 23:37:24 +03:00
|
|
|
className="typography-code font-mono w-full min-w-0"
|
|
|
|
|
style={{ ...(syntaxVars as React.CSSProperties), maxHeight, overflow: 'auto' }}
|
2026-02-23 17:05:53 +07:00
|
|
|
>
|
|
|
|
|
{lines.map((line, idx) => (
|
|
|
|
|
<Row
|
|
|
|
|
key={idx}
|
|
|
|
|
line={line}
|
2026-06-15 23:37:24 +03:00
|
|
|
html={highlighted?.[idx]}
|
2026-02-23 17:05:53 +07:00
|
|
|
showLineNumbers={showLineNumbers}
|
|
|
|
|
style={lineStyles?.(line)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Large file: virtualise ──
|
|
|
|
|
return (
|
|
|
|
|
<VirtualizedRows
|
|
|
|
|
lines={lines}
|
2026-06-15 23:37:24 +03:00
|
|
|
highlighted={highlighted}
|
|
|
|
|
syntaxVars={syntaxVars}
|
2026-02-23 17:05:53 +07:00
|
|
|
maxHeight={maxHeight}
|
|
|
|
|
showLineNumbers={showLineNumbers}
|
|
|
|
|
lineStyles={lineStyles}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
VirtualizedCodeBlock.displayName = 'VirtualizedCodeBlock';
|
|
|
|
|
|
|
|
|
|
// ── Virtualised container (extracted so the hook is top-level) ────────
|
|
|
|
|
interface VirtualizedRowsProps {
|
|
|
|
|
lines: CodeLine[];
|
2026-06-15 23:37:24 +03:00
|
|
|
highlighted: string[] | null;
|
|
|
|
|
syntaxVars: Record<string, string>;
|
2026-02-23 17:05:53 +07:00
|
|
|
maxHeight: string;
|
|
|
|
|
showLineNumbers: boolean;
|
|
|
|
|
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
|
|
|
|
lines,
|
2026-06-15 23:37:24 +03:00
|
|
|
highlighted,
|
|
|
|
|
syntaxVars,
|
2026-02-23 17:05:53 +07:00
|
|
|
maxHeight,
|
|
|
|
|
showLineNumbers,
|
|
|
|
|
lineStyles,
|
|
|
|
|
}) => {
|
|
|
|
|
const parentRef = React.useRef<HTMLDivElement>(null);
|
2026-06-15 03:29:40 +03:00
|
|
|
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
|
2026-02-23 17:05:53 +07:00
|
|
|
|
2026-07-03 18:44:10 +03:00
|
|
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
|
|
|
|
count: lines.length,
|
|
|
|
|
getScrollElement: () => parentRef.current,
|
|
|
|
|
estimateSize: () => ROW_HEIGHT,
|
|
|
|
|
overscan: 20,
|
|
|
|
|
});
|
|
|
|
|
const virtualItems = virtualizer.getVirtualItems();
|
|
|
|
|
|
2026-02-23 17:05:53 +07:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={parentRef}
|
2026-06-15 23:37:24 +03:00
|
|
|
className="typography-code font-mono w-full min-w-0"
|
|
|
|
|
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
|
2026-02-23 17:05:53 +07:00
|
|
|
>
|
2026-07-03 18:44:10 +03:00
|
|
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
|
|
|
|
{virtualItems.map((item) => {
|
|
|
|
|
const line = lines[item.index];
|
|
|
|
|
if (!line) return null;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={item.index}
|
|
|
|
|
data-index={item.index}
|
|
|
|
|
ref={virtualizer.measureElement}
|
|
|
|
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
|
|
|
|
|
>
|
|
|
|
|
<Row
|
|
|
|
|
line={line}
|
|
|
|
|
html={highlighted?.[item.index]}
|
|
|
|
|
showLineNumbers={showLineNumbers}
|
|
|
|
|
style={lineStyles?.(line)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2026-02-23 17:05:53 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
VirtualizedRows.displayName = 'VirtualizedRows';
|
|
|
|
|
|
|
|
|
|
// ── Single row ───────────────────────────────────────────────────────
|
|
|
|
|
interface RowProps {
|
|
|
|
|
line: CodeLine;
|
2026-06-15 23:37:24 +03:00
|
|
|
html: string | undefined;
|
2026-02-23 17:05:53 +07:00
|
|
|
showLineNumbers: boolean;
|
|
|
|
|
style?: React.CSSProperties;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 23:37:24 +03:00
|
|
|
const Row: React.FC<RowProps> = React.memo(({ line, html, showLineNumbers, style }) => {
|
2026-02-23 17:05:53 +07:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="typography-code font-mono flex w-full min-w-0"
|
|
|
|
|
style={style}
|
|
|
|
|
>
|
|
|
|
|
{showLineNumbers && (
|
|
|
|
|
<span
|
|
|
|
|
className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5"
|
|
|
|
|
style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}
|
|
|
|
|
>
|
|
|
|
|
{!line.isInfo && line.lineNumber != null ? line.lineNumber : ''}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
{line.isInfo ? (
|
|
|
|
|
<div className="whitespace-pre-wrap break-words text-muted-foreground/70 italic">
|
|
|
|
|
{line.text}
|
|
|
|
|
</div>
|
2026-06-15 23:37:24 +03:00
|
|
|
) : html !== undefined ? (
|
|
|
|
|
<div className="whitespace-pre" dangerouslySetInnerHTML={{ __html: html }} />
|
2026-02-23 17:05:53 +07:00
|
|
|
) : (
|
2026-06-15 23:37:24 +03:00
|
|
|
<div className="whitespace-pre">{line.text}</div>
|
2026-02-23 17:05:53 +07:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Row.displayName = 'VirtualizedCodeBlock.Row';
|