perf(code): replace react-syntax-highlighter and prismjs with the Shiki worker
Route all non-markdown code highlighting through the off-main-thread Shiki worker, removing react-syntax-highlighter and prismjs entirely. - Extend the worker with highlightLines: tokenize a whole block once and return per-line inner HTML, so per-line layouts (diffs, gutters, virtualization) make one worker call instead of one highlighter per line. - Add shared WorkerHighlightedCode (whole-block) and useWorkerHighlightedLines (per-line) primitives. Colors resolve via the --md-syntax-* CSS variables, so theme changes never re-highlight. - Migrate all 12 react-syntax-highlighter call sites: PermissionCard, ToolPart, ContextSidebarTab, ToolOutputDialog (whole block) and DiffPreview/WritePreview (per line). - Migrate VirtualizedCodeBlock off prismjs to the worker, keeping virtua virtualization; whole-block tokenization also restores cross-line syntax context that per-line highlighting lost. - Drop react-syntax-highlighter (+types) from ui and web, prismjs (+types) from ui, and the orphaned create-element type shim.
This commit is contained in:
@@ -149,7 +149,6 @@
|
||||
"@pierre/diffs": "1.3.0-beta.4",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"@zumer/snapdom": "^2.12.0",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
@@ -169,12 +168,10 @@
|
||||
"morphdom": "^2.7.7",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-drawio": "1.0.7",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.2.1",
|
||||
@@ -193,7 +190,6 @@
|
||||
"@remixicon/react": "^4.7.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
@@ -292,7 +288,6 @@
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
@@ -312,7 +307,6 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"strip-json-comments": "^5.0.3",
|
||||
@@ -1316,8 +1310,6 @@
|
||||
|
||||
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
|
||||
|
||||
"@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
|
||||
|
||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"@pierre/diffs": "1.3.0-beta.4",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"@zumer/snapdom": "^2.12.0",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
@@ -64,12 +63,10 @@
|
||||
"morphdom": "^2.7.7",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-drawio": "1.0.7",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.2.1",
|
||||
@@ -87,7 +84,6 @@
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
|
||||
@@ -1,97 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
import { parseDiffToUnified } from './message/toolRenderers';
|
||||
|
||||
const DIFF_CUSTOM_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
};
|
||||
|
||||
const DIFF_CODE_TAG_PROPS = {
|
||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } as React.CSSProperties,
|
||||
};
|
||||
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
|
||||
// back to plain text while loading or on failure.
|
||||
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
|
||||
html !== undefined ? (
|
||||
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap break-all">{content}</span>
|
||||
);
|
||||
|
||||
interface DiffPreviewProps {
|
||||
diff: string;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, filePath }) => (
|
||||
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
|
||||
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
|
||||
{`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]);
|
||||
const language = getLanguageFromExtension(filePath || hunks[0]?.file) || 'text';
|
||||
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIdx) => (
|
||||
<div
|
||||
key={lineIdx}
|
||||
className={cn(
|
||||
'typography-code font-mono px-2 py-0.5 flex -mx-2',
|
||||
line.type === 'context' && 'bg-transparent',
|
||||
line.type === 'removed' && 'bg-transparent',
|
||||
line.type === 'added' && 'bg-transparent'
|
||||
)}
|
||||
style={
|
||||
line.type === 'removed'
|
||||
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
|
||||
: line.type === 'added'
|
||||
? { backgroundColor: 'var(--tools-edit-added-bg)' }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<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.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={getLanguageFromExtension(filePath || hunk.file) || 'text'}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={DIFF_CUSTOM_STYLE}
|
||||
codeTagProps={DIFF_CODE_TAG_PROPS}
|
||||
// Flatten all rendered lines so the whole diff tokenizes in one worker call.
|
||||
const flatContent = React.useMemo(
|
||||
() => hunks.flatMap((hunk) => hunk.lines.map((line) => line.content)).join('\n'),
|
||||
[hunks],
|
||||
);
|
||||
const highlighted = useWorkerHighlightedLines(flatContent, language);
|
||||
|
||||
let lineCursor = 0;
|
||||
return (
|
||||
<div className="typography-code px-1 pb-1 pt-0 space-y-0" style={syntaxVars as React.CSSProperties}>
|
||||
{hunks.map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
|
||||
{`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIdx) => {
|
||||
const html = highlighted?.[lineCursor];
|
||||
lineCursor += 1;
|
||||
return (
|
||||
<div
|
||||
key={lineIdx}
|
||||
className={cn(
|
||||
'typography-code font-mono px-2 py-0.5 flex -mx-2',
|
||||
line.type === 'context' && 'bg-transparent',
|
||||
line.type === 'removed' && 'bg-transparent',
|
||||
line.type === 'added' && 'bg-transparent'
|
||||
)}
|
||||
style={
|
||||
line.type === 'removed'
|
||||
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
|
||||
: line.type === 'added'
|
||||
? { backgroundColor: 'var(--tools-edit-added-bg)' }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<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.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CodeLineContent content={line.content} html={html} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface WritePreviewProps {
|
||||
content: string;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme, filePath }) => {
|
||||
const lines = content.split('\n');
|
||||
export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath }) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const lines = React.useMemo(() => content.split('\n'), [content]);
|
||||
const language = getLanguageFromExtension(filePath ?? '') || 'text';
|
||||
const displayPath = filePath?.split('/').pop() || 'New file';
|
||||
const lineCount = Math.max(lines.length, 1);
|
||||
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
|
||||
const highlighted = useWorkerHighlightedLines(content, language);
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
<div className="w-full min-w-0" style={syntaxVars as React.CSSProperties}>
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border border-border/10 rounded-lg mb-1">
|
||||
{`${displayPath} (${headerLineLabel})`}
|
||||
</div>
|
||||
@@ -102,17 +106,7 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme
|
||||
{lineIdx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={DIFF_CUSTOM_STYLE}
|
||||
codeTagProps={DIFF_CODE_TAG_PROPS}
|
||||
>
|
||||
{line || ' '}
|
||||
</SyntaxHighlighter>
|
||||
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,9 +4,7 @@ import type { PermissionRequest, PermissionResponse } from '@/types/permission';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||
@@ -104,8 +102,6 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
const sourceSession = sessions.find((session) => session.id === permission.sessionID);
|
||||
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
||||
}, [permission.sessionID, currentSessionId, sessions]);
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||
|
||||
const handleResponse = async (response: PermissionResponse) => {
|
||||
setIsResponding(true);
|
||||
@@ -168,16 +164,13 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
{}
|
||||
{command && (
|
||||
<div>
|
||||
<SyntaxHighlighter
|
||||
<WorkerHighlightedCode
|
||||
language="bash"
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
customStyle={PERMISSION_BASH_CUSTOM_STYLE}
|
||||
codeTagProps={PERMISSION_BASH_CODE_TAG_PROPS}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{command}
|
||||
</SyntaxHighlighter>
|
||||
code={command}
|
||||
style={PERMISSION_BASH_CUSTOM_STYLE}
|
||||
codeStyle={PERMISSION_BASH_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -198,7 +191,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
)}
|
||||
{changes && (
|
||||
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
|
||||
<DiffPreview diff={changes} syntaxTheme={syntaxTheme} filePath={filePath} />
|
||||
<DiffPreview diff={changes} filePath={filePath} />
|
||||
</ScrollableOverlay>
|
||||
)}
|
||||
</>
|
||||
@@ -212,7 +205,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
if (content) {
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
|
||||
<WritePreview content={content} syntaxTheme={syntaxTheme} filePath={filePath} />
|
||||
<WritePreview content={content} filePath={filePath} />
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
}
|
||||
@@ -247,14 +240,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.headers')}</div>
|
||||
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
|
||||
<SyntaxHighlighter
|
||||
<WorkerHighlightedCode
|
||||
language="json"
|
||||
style={syntaxTheme}
|
||||
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{JSON.stringify(headers, null, 2)}
|
||||
</SyntaxHighlighter>
|
||||
code={JSON.stringify(headers, null, 2)}
|
||||
style={PERMISSION_JSON_CUSTOM_STYLE}
|
||||
wrap
|
||||
/>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
@@ -262,14 +253,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.body')}</div>
|
||||
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
|
||||
<SyntaxHighlighter
|
||||
<WorkerHighlightedCode
|
||||
language={typeof body === 'object' ? 'json' : 'text'}
|
||||
style={syntaxTheme}
|
||||
customStyle={PERMISSION_JSON_CUSTOM_STYLE}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
||||
</SyntaxHighlighter>
|
||||
code={typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
||||
style={PERMISSION_JSON_CUSTOM_STYLE}
|
||||
wrap
|
||||
/>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { bundledLanguages, createHighlighter, type BundledLanguage } from 'shiki';
|
||||
import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki';
|
||||
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
||||
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
|
||||
// Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import.
|
||||
const FONT_STYLE_ITALIC = 1;
|
||||
const FONT_STYLE_BOLD = 2;
|
||||
const FONT_STYLE_UNDERLINE = 4;
|
||||
|
||||
const escapeHtml = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
const tokenSpan = (token: ThemedToken): string => {
|
||||
const styles: string[] = [];
|
||||
if (token.color) styles.push(`color:${token.color}`);
|
||||
const fontStyle = token.fontStyle ?? 0;
|
||||
if (fontStyle & FONT_STYLE_ITALIC) styles.push('font-style:italic');
|
||||
if (fontStyle & FONT_STYLE_BOLD) styles.push('font-weight:bold');
|
||||
if (fontStyle & FONT_STYLE_UNDERLINE) styles.push('text-decoration:underline');
|
||||
const style = styles.length ? ` style="${styles.join(';')}"` : '';
|
||||
return `<span${style}>${escapeHtml(token.content)}</span>`;
|
||||
};
|
||||
|
||||
// Single shared highlighter for the worker. Languages load lazily on demand.
|
||||
let highlighter: ReturnType<typeof createHighlighter> | undefined;
|
||||
|
||||
@@ -25,20 +47,31 @@ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
|
||||
ensureHighlighter();
|
||||
return;
|
||||
}
|
||||
queue = queue.then(() => highlight(request)).catch(() => {});
|
||||
if (request.type === 'highlight') {
|
||||
queue = queue.then(() => highlight(request)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
queue = queue.then(() => highlightLines(request)).catch(() => {});
|
||||
};
|
||||
|
||||
type Instance = Awaited<ReturnType<typeof createHighlighter>>;
|
||||
|
||||
const resolveLanguage = async (instance: Instance, requested: string): Promise<string> => {
|
||||
let lang = requested in bundledLanguages ? requested : 'text';
|
||||
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
|
||||
try {
|
||||
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
}
|
||||
return lang;
|
||||
};
|
||||
|
||||
async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highlight' }>): Promise<void> {
|
||||
try {
|
||||
const instance = await ensureHighlighter();
|
||||
let lang = request.lang in bundledLanguages ? request.lang : 'text';
|
||||
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
|
||||
try {
|
||||
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
}
|
||||
const lang = await resolveLanguage(instance, request.lang);
|
||||
const html = instance.codeToHtml(request.code, {
|
||||
lang,
|
||||
theme: MARKDOWN_SHIKI_THEME,
|
||||
@@ -50,6 +83,21 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
|
||||
}
|
||||
}
|
||||
|
||||
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
|
||||
try {
|
||||
const instance = await ensureHighlighter();
|
||||
const lang = await resolveLanguage(instance, request.lang);
|
||||
const { tokens } = instance.codeToTokens(request.code, {
|
||||
lang: lang as BundledLanguage,
|
||||
theme: MARKDOWN_SHIKI_THEME,
|
||||
});
|
||||
const lines = tokens.map((line) => line.map(tokenSpan).join(''));
|
||||
post({ type: 'highlightLines', id: request.id, lines });
|
||||
} catch (error) {
|
||||
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function post(response: MarkdownWorkerResponse): void {
|
||||
self.postMessage(response);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
|
||||
export type MarkdownWorkerRequest =
|
||||
| { type: 'init' }
|
||||
| { type: 'highlight'; id: number; code: string; lang: string };
|
||||
// Highlight a whole block to ready-to-splice Shiki `<pre>` HTML.
|
||||
| { type: 'highlight'; id: number; code: string; lang: string }
|
||||
// Highlight a whole block but return per-line inner HTML (one entry per line),
|
||||
// so per-line layouts (diffs, gutters, virtualization) tokenize in ONE call
|
||||
// instead of one worker round-trip per line.
|
||||
| { type: 'highlightLines'; id: number; code: string; lang: string };
|
||||
|
||||
export type MarkdownWorkerResponse =
|
||||
| { type: 'highlight'; id: number; html: string }
|
||||
| { type: 'highlightLines'; id: number; lines: string[] }
|
||||
| { type: 'error'; id: number; message: string };
|
||||
|
||||
@@ -7,9 +7,11 @@ import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-w
|
||||
// tokenization error) the promise resolves to `null` and the caller keeps the
|
||||
// escaped plain-text code — highlighting never falls back onto the main thread.
|
||||
|
||||
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let nextId = 0;
|
||||
const pending = new Map<number, (html: string | null) => void>();
|
||||
const pending = new Map<number, PendingResolver>();
|
||||
|
||||
const failAll = (): void => {
|
||||
pending.forEach((resolve) => resolve(null));
|
||||
@@ -30,7 +32,7 @@ const getWorker = (): Worker | undefined => {
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (!resolve) return;
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data.type === 'highlight' ? event.data.html : null);
|
||||
resolve(event.data);
|
||||
};
|
||||
worker.onerror = failAll;
|
||||
worker.onmessageerror = failAll;
|
||||
@@ -38,16 +40,31 @@ const getWorker = (): Worker | undefined => {
|
||||
return worker;
|
||||
};
|
||||
|
||||
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
|
||||
const instance = getWorker();
|
||||
if (!instance) return Promise.resolve(null);
|
||||
const id = ++nextId;
|
||||
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
instance.postMessage(payload(id));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML,
|
||||
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
|
||||
*/
|
||||
export const highlightCodeInWorker = (code: string, lang: string): Promise<string | null> => {
|
||||
const instance = getWorker();
|
||||
if (!instance) return Promise.resolve(null);
|
||||
const id = ++nextId;
|
||||
return new Promise<string | null>((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
instance.postMessage({ type: 'highlight', id, code, lang } satisfies MarkdownWorkerRequest);
|
||||
});
|
||||
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
|
||||
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
return response?.type === 'highlight' ? response.html : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a whole block and return per-line inner HTML (one entry per source
|
||||
* line). For per-line layouts (diffs, gutters, virtualization) — one worker
|
||||
* round-trip instead of one per line. Resolves to `null` on failure.
|
||||
*/
|
||||
export const highlightLinesInWorker = async (code: string, lang: string): Promise<string[] | null> => {
|
||||
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
return response?.type === 'highlightLines' ? response.lines : null;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -550,9 +550,8 @@ DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
|
||||
|
||||
const DialogReadContent: React.FC<{
|
||||
popup: ToolPopupContent;
|
||||
syntaxTheme: Record<string, React.CSSProperties>;
|
||||
pierreThemeConfig: PierreThemeConfig;
|
||||
}> = React.memo(({ popup, syntaxTheme, pierreThemeConfig }) => {
|
||||
}> = React.memo(({ popup, pierreThemeConfig }) => {
|
||||
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
|
||||
|
||||
const inputMeta = popup.metadata?.input;
|
||||
@@ -627,7 +626,6 @@ const DialogReadContent: React.FC<{
|
||||
<VirtualizedCodeBlock
|
||||
lines={codeLines}
|
||||
language={detectedLanguage}
|
||||
syntaxTheme={syntaxTheme}
|
||||
maxHeight="70vh"
|
||||
/>
|
||||
);
|
||||
@@ -1055,16 +1053,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
</div>
|
||||
{meta.tool === 'bash' && getInputValue('command') ? (
|
||||
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language="bash"
|
||||
PreTag="div"
|
||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
wrapLongLines
|
||||
>
|
||||
{getInputValue('command')!}
|
||||
</SyntaxHighlighter>
|
||||
code={getInputValue('command')!}
|
||||
style={toolDisplayStyles.getPopupStyles()}
|
||||
codeStyle={DIALOG_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
) : meta.tool === 'task' && getInputValue('prompt') ? (
|
||||
<div
|
||||
@@ -1124,16 +1119,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
completed: t('chat.todo.completed'),
|
||||
cancelled: t('chat.todo.cancelled'),
|
||||
}) || (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language="json"
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
code={popup.content}
|
||||
style={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeStyle={DIALOG_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1179,22 +1171,19 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') {
|
||||
return (
|
||||
renderWebSearchOutput(popup.content, syntaxTheme) || (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language="text"
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
code={popup.content}
|
||||
style={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeStyle={DIALOG_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'read') {
|
||||
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
|
||||
return <DialogReadContent popup={popup} pierreThemeConfig={pierreThemeConfig} />;
|
||||
}
|
||||
|
||||
// JSON tree viewer for generic JSON outputs
|
||||
@@ -1210,16 +1199,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language={popup.language || 'text'}
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
code={popup.content}
|
||||
style={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeStyle={DIALOG_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { getToolMetadata } from '@/lib/toolHelpers';
|
||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
|
||||
import { toolDisplayStyles } from '@/lib/typography';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -844,7 +844,7 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
input: Record<string, unknown> | undefined;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
}> = ({ output, part, metadata, input, syntaxTheme }) => {
|
||||
}> = ({ output, part, metadata, input }) => {
|
||||
const { t } = useI18n();
|
||||
const renderedOutput = getToolOutputText(output, part, metadata);
|
||||
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
|
||||
@@ -910,16 +910,13 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
/>
|
||||
) : (
|
||||
<div className="typography-code pr-12 text-muted-foreground/90">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language="json"
|
||||
PreTag="div"
|
||||
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
wrapLongLines
|
||||
>
|
||||
{renderedOutput}
|
||||
</SyntaxHighlighter>
|
||||
code={renderedOutput}
|
||||
style={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeStyle={CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -928,16 +925,13 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
|
||||
return (
|
||||
<div className={part.tool === 'bash' ? 'typography-code text-muted-foreground/90' : undefined}>
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language={outputLanguage}
|
||||
PreTag="div"
|
||||
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
wrapLongLines
|
||||
>
|
||||
{renderedOutput}
|
||||
</SyntaxHighlighter>
|
||||
code={renderedOutput}
|
||||
style={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeStyle={CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,48 +1,20 @@
|
||||
/**
|
||||
* VirtualizedCodeBlock — PERF-007
|
||||
*
|
||||
* Replaces per-line <SyntaxHighlighter> with:
|
||||
* 1. ONE Prism.highlight() call to tokenize all code at once
|
||||
* Renders large code/read outputs without mounting one highlighter per line:
|
||||
* 1. ONE worker tokenization of the whole block (off the main thread)
|
||||
* 2. virtua to only render visible rows
|
||||
*
|
||||
* This drops mount cost from O(N * Prism) to O(1 * Prism) + O(visible_rows).
|
||||
* For a 2000-line file, ~2000 SyntaxHighlighter instances → ~30 plain <div>s.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Virtualizer } from 'virtua';
|
||||
import Prism from 'prismjs';
|
||||
|
||||
// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them,
|
||||
// but we call Prism directly so we need them registered).
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-markup-templating';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-tsx';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-csharp';
|
||||
import 'prismjs/components/prism-ruby';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-toml';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-diff';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-swift';
|
||||
import 'prismjs/components/prism-kotlin';
|
||||
import 'prismjs/components/prism-lua';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-scss';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
|
||||
// ── Threshold: files smaller than this render without virtualization ──
|
||||
const VIRTUALIZE_THRESHOLD = 80;
|
||||
@@ -60,7 +32,6 @@ export interface CodeLine {
|
||||
interface VirtualizedCodeBlockProps {
|
||||
lines: CodeLine[];
|
||||
language: string;
|
||||
syntaxTheme: Record<string, React.CSSProperties>;
|
||||
/** Max visible height in CSS (default: 60vh) */
|
||||
maxHeight?: string;
|
||||
/** Show line numbers (default: true) */
|
||||
@@ -69,123 +40,21 @@ interface VirtualizedCodeBlockProps {
|
||||
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
|
||||
}
|
||||
|
||||
const toKebabCase = (value: string): string => value.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
||||
|
||||
const styleObjectToCss = (style: React.CSSProperties): string => {
|
||||
return Object.entries(style)
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.map(([k, v]) => `${toKebabCase(k)}:${String(v)};`)
|
||||
.join('');
|
||||
};
|
||||
|
||||
const buildSelectorList = (rawKey: string): string[] => {
|
||||
return rawKey
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((selector) => {
|
||||
if (selector.startsWith('.token')) {
|
||||
return [`.oc-virtualized-prism ${selector}`];
|
||||
}
|
||||
if (selector.startsWith('token.')) {
|
||||
return [`.oc-virtualized-prism .${selector}`];
|
||||
}
|
||||
if (/^[a-z0-9_-]+$/i.test(selector)) {
|
||||
return [`.oc-virtualized-prism .token.${selector}`];
|
||||
}
|
||||
if (selector.includes('token')) {
|
||||
return [`.oc-virtualized-prism ${selector}`];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const buildPrismThemeCss = (theme: Record<string, React.CSSProperties>): string => {
|
||||
const rules: string[] = [];
|
||||
Object.entries(theme).forEach(([rawKey, style]) => {
|
||||
const selectors = buildSelectorList(rawKey);
|
||||
if (selectors.length === 0) {
|
||||
return;
|
||||
}
|
||||
const css = styleObjectToCss(style);
|
||||
if (!css) {
|
||||
return;
|
||||
}
|
||||
rules.push(`${selectors.join(',')}{${css}}`);
|
||||
});
|
||||
return rules.join('\n');
|
||||
};
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
text: 'plain',
|
||||
plaintext: 'plain',
|
||||
shell: 'bash',
|
||||
sh: 'bash',
|
||||
zsh: 'bash',
|
||||
patch: 'diff',
|
||||
dockerfile: 'docker',
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
};
|
||||
|
||||
const normalizeLanguage = (language: string): string => {
|
||||
const lower = language.toLowerCase();
|
||||
return LANGUAGE_ALIASES[lower] ?? lower;
|
||||
};
|
||||
|
||||
const HIGHLIGHT_CACHE_MAX = 5000;
|
||||
const highlightCache = new Map<string, string>();
|
||||
|
||||
const highlightLine = (text: string, language: string): string => {
|
||||
const normalizedLanguage = normalizeLanguage(language);
|
||||
const cacheKey = `${normalizedLanguage}\n${text}`;
|
||||
const cached = highlightCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const grammar = Prism.languages[normalizedLanguage] ?? Prism.languages.text;
|
||||
if (!grammar) {
|
||||
const escaped = escapeHtml(text);
|
||||
highlightCache.set(cacheKey, escaped);
|
||||
return escaped;
|
||||
}
|
||||
|
||||
try {
|
||||
const highlighted = Prism.highlight(text, grammar, normalizedLanguage);
|
||||
if (highlightCache.size >= HIGHLIGHT_CACHE_MAX) {
|
||||
const oldestKey = highlightCache.keys().next().value;
|
||||
if (typeof oldestKey === 'string') {
|
||||
highlightCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
return highlighted;
|
||||
} catch {
|
||||
const escaped = escapeHtml(text);
|
||||
highlightCache.set(cacheKey, escaped);
|
||||
return escaped;
|
||||
}
|
||||
};
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.memo((props) => {
|
||||
const {
|
||||
lines,
|
||||
language,
|
||||
syntaxTheme,
|
||||
maxHeight = '60vh',
|
||||
showLineNumbers = true,
|
||||
lineStyles,
|
||||
} = props;
|
||||
const prismThemeCss = React.useMemo(() => buildPrismThemeCss(syntaxTheme), [syntaxTheme]);
|
||||
|
||||
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]);
|
||||
const highlighted = useWorkerHighlightedLines(fullText, language);
|
||||
|
||||
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
|
||||
|
||||
@@ -193,15 +62,14 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
|
||||
if (!shouldVirtualize) {
|
||||
return (
|
||||
<div
|
||||
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
||||
style={{ maxHeight, overflow: 'auto' }}
|
||||
className="typography-code font-mono w-full min-w-0"
|
||||
style={{ ...(syntaxVars as React.CSSProperties), maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
||||
{lines.map((line, idx) => (
|
||||
<Row
|
||||
key={idx}
|
||||
line={line}
|
||||
language={language}
|
||||
html={highlighted?.[idx]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
@@ -214,8 +82,8 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
|
||||
return (
|
||||
<VirtualizedRows
|
||||
lines={lines}
|
||||
language={language}
|
||||
prismThemeCss={prismThemeCss}
|
||||
highlighted={highlighted}
|
||||
syntaxVars={syntaxVars}
|
||||
maxHeight={maxHeight}
|
||||
showLineNumbers={showLineNumbers}
|
||||
lineStyles={lineStyles}
|
||||
@@ -228,8 +96,8 @@ VirtualizedCodeBlock.displayName = 'VirtualizedCodeBlock';
|
||||
// ── Virtualised container (extracted so the hook is top-level) ────────
|
||||
interface VirtualizedRowsProps {
|
||||
lines: CodeLine[];
|
||||
language: string;
|
||||
prismThemeCss: string;
|
||||
highlighted: string[] | null;
|
||||
syntaxVars: Record<string, string>;
|
||||
maxHeight: string;
|
||||
showLineNumbers: boolean;
|
||||
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
|
||||
@@ -237,8 +105,8 @@ interface VirtualizedRowsProps {
|
||||
|
||||
const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
lines,
|
||||
language,
|
||||
prismThemeCss,
|
||||
highlighted,
|
||||
syntaxVars,
|
||||
maxHeight,
|
||||
showLineNumbers,
|
||||
lineStyles,
|
||||
@@ -249,10 +117,9 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
||||
style={{ height: viewportHeight, maxHeight, overflow: 'auto' }}
|
||||
className="typography-code font-mono w-full min-w-0"
|
||||
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
||||
<Virtualizer
|
||||
data={lines}
|
||||
itemSize={ROW_HEIGHT}
|
||||
@@ -263,7 +130,7 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
<Row
|
||||
key={index}
|
||||
line={line}
|
||||
language={language}
|
||||
html={highlighted?.[index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
@@ -278,14 +145,12 @@ VirtualizedRows.displayName = 'VirtualizedRows';
|
||||
// ── Single row ───────────────────────────────────────────────────────
|
||||
interface RowProps {
|
||||
line: CodeLine;
|
||||
language: string;
|
||||
html: string | undefined;
|
||||
showLineNumbers: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, style }) => {
|
||||
const html = React.useMemo(() => highlightLine(line.text, language), [line.text, language]);
|
||||
|
||||
const Row: React.FC<RowProps> = React.memo(({ line, html, showLineNumbers, style }) => {
|
||||
return (
|
||||
<div
|
||||
className="typography-code font-mono flex w-full min-w-0"
|
||||
@@ -304,11 +169,10 @@ const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, s
|
||||
<div className="whitespace-pre-wrap break-words text-muted-foreground/70 italic">
|
||||
{line.text}
|
||||
</div>
|
||||
) : html !== undefined ? (
|
||||
<div className="whitespace-pre" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<div
|
||||
className="whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div className="whitespace-pre">{line.text}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||
import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker';
|
||||
|
||||
// Shared static code highlighter backed by the markdown Shiki Web Worker.
|
||||
//
|
||||
// Replaces `react-syntax-highlighter` for non-streaming code surfaces (tool
|
||||
// output, permission previews, diffs, sidebar file contents). The escaped code
|
||||
// paints synchronously; the worker tokenizes off the main thread and swaps in
|
||||
// the highlighted markup. Colors resolve through the `--md-syntax-*` CSS
|
||||
// variables on the host, so theme changes never require re-highlighting.
|
||||
|
||||
const escapeHtml = (text: string): string =>
|
||||
text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const styleString = (style?: React.CSSProperties): string => {
|
||||
if (!style) return '';
|
||||
return Object.entries(style)
|
||||
.map(([key, value]) => {
|
||||
if (value == null) return '';
|
||||
const prop = key.startsWith('--') ? key : key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
||||
return `${prop}:${typeof value === 'number' ? `${value}px` : value};`;
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
// Normalize a worker/plain `<pre><code>` so it sits flush inside the host: drop
|
||||
// Shiki's own background/margin and apply wrap + caller code styles.
|
||||
const applyPreStyles = (host: HTMLElement, wrap: boolean, codeStyle?: React.CSSProperties): void => {
|
||||
const pre = host.querySelector('pre');
|
||||
if (pre) {
|
||||
pre.style.margin = '0';
|
||||
pre.style.background = 'transparent';
|
||||
pre.style.whiteSpace = wrap ? 'pre-wrap' : 'pre';
|
||||
if (wrap) {
|
||||
pre.style.wordBreak = 'break-word';
|
||||
pre.style.overflowWrap = 'break-word';
|
||||
}
|
||||
}
|
||||
const code = host.querySelector('code');
|
||||
if (code) {
|
||||
const extra = styleString(codeStyle);
|
||||
if (extra) code.setAttribute('style', `${code.getAttribute('style') ?? ''}${extra}`);
|
||||
}
|
||||
};
|
||||
|
||||
const plainHtml = (code: string): string => `<pre><code>${escapeHtml(code)}</code></pre>`;
|
||||
|
||||
export interface WorkerHighlightedCodeProps {
|
||||
code: string;
|
||||
language: string;
|
||||
className?: string;
|
||||
/** Inline styles for the host container (mirrors react-syntax-highlighter `customStyle`). */
|
||||
style?: React.CSSProperties;
|
||||
/** Inline styles applied to the `<code>` element (mirrors `codeTagProps.style`). */
|
||||
codeStyle?: React.CSSProperties;
|
||||
/** Wrap long lines instead of horizontal scroll. */
|
||||
wrap?: boolean;
|
||||
}
|
||||
|
||||
export const WorkerHighlightedCode: React.FC<WorkerHighlightedCodeProps> = ({
|
||||
code,
|
||||
language,
|
||||
className,
|
||||
style,
|
||||
codeStyle,
|
||||
wrap = false,
|
||||
}) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const hostRef = React.useRef<HTMLDivElement>(null);
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
|
||||
// Synchronous escaped first paint — no blank frame before highlighting lands.
|
||||
React.useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
host.innerHTML = plainHtml(code);
|
||||
applyPreStyles(host, wrap, codeStyle);
|
||||
}, [code, wrap, codeStyle]);
|
||||
|
||||
// Highlight off the main thread, then swap in. Guarded against stale results.
|
||||
React.useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let active = true;
|
||||
void highlightCodeInWorker(code, (language || 'text').toLowerCase()).then((html) => {
|
||||
if (!active || !host || !html) return;
|
||||
host.innerHTML = html;
|
||||
applyPreStyles(host, wrap, codeStyle);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [code, language, wrap, codeStyle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={cn('typography-code', className)}
|
||||
style={{ ...(syntaxVars as React.CSSProperties), ...style }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { highlightLinesInWorker } from '@/components/chat/markdown/markdown-worker';
|
||||
|
||||
// Tokenize a whole block ONCE in the Shiki worker and expose per-line inner
|
||||
// HTML. For per-line layouts (diffs, gutters, virtualization) that would
|
||||
// otherwise spawn one highlighter per row. Returns `null` until the first
|
||||
// result lands (or permanently on failure) — callers render plain text then.
|
||||
//
|
||||
// Whole-block tokenization also restores cross-line syntax context (multi-line
|
||||
// strings / comments) that independent per-line highlighting loses.
|
||||
export const useWorkerHighlightedLines = (code: string, language: string): string[] | null => {
|
||||
const [lines, setLines] = React.useState<string[] | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
setLines(null);
|
||||
void highlightLinesInWorker(code, (language || 'text').toLowerCase()).then((result) => {
|
||||
if (active) setLines(result);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
return lines;
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||
|
||||
import { deriveMessageRole } from '@/components/chat/message/messageRole';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -272,9 +270,7 @@ const resolveProviderAndModel = (
|
||||
|
||||
export const ContextPanelContent: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
|
||||
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
|
||||
const copyResetTimeoutRef = React.useRef<number | null>(null);
|
||||
@@ -649,28 +645,18 @@ export const ContextPanelContent: React.FC = () => {
|
||||
{isCopied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
<WorkerHighlightedCode
|
||||
language="json"
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
code={jsonValue}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '0.75rem',
|
||||
background: 'transparent',
|
||||
fontSize: 'var(--text-micro)',
|
||||
lineHeight: '1.35',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
}}
|
||||
wrapLongLines
|
||||
>
|
||||
{jsonValue}
|
||||
</SyntaxHighlighter>
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
declare module 'react-syntax-highlighter/create-element' {
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type CreateElementOptions = {
|
||||
node: unknown;
|
||||
stylesheet: unknown;
|
||||
useInlineStyles: boolean;
|
||||
key?: string | number;
|
||||
};
|
||||
|
||||
const createElement: (options: CreateElementOptions) => ReactNode;
|
||||
|
||||
export default createElement;
|
||||
}
|
||||
@@ -70,7 +70,6 @@
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
@@ -90,7 +89,6 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"strip-json-comments": "^5.0.3",
|
||||
|
||||
Reference in New Issue
Block a user