Skip to content

Commit 722a1d1

Browse files
authored
Add cached syntax highlighting for chat diffs (#330)
- Factor shared highlighter cache and error boundary into reusable modules - Highlight inline and PR diff blocks using inferred file languages - Add tests for language inference and diff rendering helpers
1 parent b041362 commit 722a1d1

10 files changed

Lines changed: 371 additions & 156 deletions

apps/web/src/components/ChatMarkdown.tsx

Lines changed: 14 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";
21
import { CheckIcon, CopyIcon } from "lucide-react";
32
import React, {
43
Children,
@@ -16,60 +15,29 @@ import React, {
1615
import type { Components } from "react-markdown";
1716
import ReactMarkdown from "react-markdown";
1817
import remarkGfm from "remark-gfm";
18+
import { CodeHighlightErrorBoundary } from "./CodeHighlightErrorBoundary";
1919
import { useAppSettings } from "../appSettings";
2020
import { openFileReference } from "../fileOpen";
2121
import { useFileViewNavigation } from "~/hooks/useFileViewNavigation";
2222
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
23-
import { fnv1a32 } from "../lib/diffRendering";
24-
import { LRUCache } from "../lib/lruCache";
23+
import {
24+
extractFenceLanguage,
25+
getCachedHighlightedHtml,
26+
getHighlighterPromise,
27+
renderHighlightedCodeHtml,
28+
setCachedHighlightedHtml,
29+
} from "../lib/syntaxHighlighting";
2530
import { useTheme } from "../hooks/useTheme";
2631
import { resolveMarkdownFileLinkTarget } from "../markdown-links";
2732
import { readNativeApi } from "../nativeApi";
2833
import { toastManager } from "./ui/toast";
2934

30-
class CodeHighlightErrorBoundary extends React.Component<
31-
{ fallback: ReactNode; children: ReactNode },
32-
{ hasError: boolean }
33-
> {
34-
constructor(props: { fallback: ReactNode; children: ReactNode }) {
35-
super(props);
36-
this.state = { hasError: false };
37-
}
38-
39-
static getDerivedStateFromError() {
40-
return { hasError: true };
41-
}
42-
43-
override render() {
44-
if (this.state.hasError) {
45-
return this.props.fallback;
46-
}
47-
return this.props.children;
48-
}
49-
}
50-
5135
interface ChatMarkdownProps {
5236
text: string;
5337
cwd: string | undefined;
5438
isStreaming?: boolean;
5539
}
5640

57-
const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
58-
const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
59-
const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
60-
const highlightedCodeCache = new LRUCache<string>(
61-
MAX_HIGHLIGHT_CACHE_ENTRIES,
62-
MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
63-
);
64-
const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();
65-
66-
function extractFenceLanguage(className: string | undefined): string {
67-
const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
68-
const raw = match?.[1] ?? "text";
69-
// Shiki doesn't bundle a gitignore grammar; ini is a close match (#685)
70-
return raw === "gitignore" ? "ini" : raw;
71-
}
72-
7341
function nodeToPlainText(node: ReactNode): string {
7442
if (typeof node === "string" || typeof node === "number") {
7543
return String(node);
@@ -105,35 +73,6 @@ function extractCodeBlock(
10573
};
10674
}
10775

108-
function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string {
109-
return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`;
110-
}
111-
112-
function estimateHighlightedSize(html: string, code: string): number {
113-
return Math.max(html.length * 2, code.length * 3);
114-
}
115-
116-
function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
117-
const cached = highlighterPromiseCache.get(language);
118-
if (cached) return cached;
119-
120-
const promise = getSharedHighlighter({
121-
themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],
122-
langs: [language as SupportedLanguages],
123-
preferredHighlighter: "shiki-js",
124-
}).catch((err) => {
125-
highlighterPromiseCache.delete(language);
126-
if (language === "text") {
127-
// "text" itself failed — Shiki cannot initialize at all, surface the error
128-
throw err;
129-
}
130-
// Language not supported by Shiki — fall back to "text"
131-
return getHighlighterPromise("text");
132-
});
133-
highlighterPromiseCache.set(language, promise);
134-
return promise;
135-
}
136-
13776
function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) {
13877
const [copied, setCopied] = useState(false);
13978
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -196,8 +135,9 @@ function SuspenseShikiCodeBlock({
196135
isStreaming,
197136
}: SuspenseShikiCodeBlockProps) {
198137
const language = extractFenceLanguage(className);
199-
const cacheKey = createHighlightCacheKey(code, language, themeName);
200-
const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null;
138+
const cachedHighlightedHtml = !isStreaming
139+
? getCachedHighlightedHtml(code, language, themeName, "chat-markdown")
140+
: null;
201141

202142
if (cachedHighlightedHtml != null) {
203143
return (
@@ -210,28 +150,14 @@ function SuspenseShikiCodeBlock({
210150

211151
const highlighter = use(getHighlighterPromise(language));
212152
const highlightedHtml = useMemo(() => {
213-
try {
214-
return highlighter.codeToHtml(code, { lang: language, theme: themeName });
215-
} catch (error) {
216-
// Log highlighting failures for debugging while falling back to plain text
217-
console.warn(
218-
`Code highlighting failed for language "${language}", falling back to plain text.`,
219-
error instanceof Error ? error.message : error,
220-
);
221-
// If highlighting fails for this language, render as plain text
222-
return highlighter.codeToHtml(code, { lang: "text", theme: themeName });
223-
}
153+
return renderHighlightedCodeHtml(highlighter, code, language, themeName);
224154
}, [code, highlighter, language, themeName]);
225155

226156
useEffect(() => {
227157
if (!isStreaming) {
228-
highlightedCodeCache.set(
229-
cacheKey,
230-
highlightedHtml,
231-
estimateHighlightedSize(highlightedHtml, code),
232-
);
158+
setCachedHighlightedHtml(code, language, themeName, "chat-markdown", highlightedHtml);
233159
}
234-
}, [cacheKey, code, highlightedHtml, isStreaming]);
160+
}, [code, highlightedHtml, isStreaming, language, themeName]);
235161

236162
return (
237163
<div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React, { type ReactNode } from "react";
2+
3+
export class CodeHighlightErrorBoundary extends React.Component<
4+
{ fallback: ReactNode; children: ReactNode },
5+
{ hasError: boolean }
6+
> {
7+
constructor(props: { fallback: ReactNode; children: ReactNode }) {
8+
super(props);
9+
this.state = { hasError: false };
10+
}
11+
12+
static getDerivedStateFromError() {
13+
return { hasError: true };
14+
}
15+
16+
override render() {
17+
if (this.state.hasError) {
18+
return this.props.fallback;
19+
}
20+
return this.props.children;
21+
}
22+
}

apps/web/src/components/DiffPanel.tsx

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import { resolvePathLinkTarget } from "../terminal-links";
2525
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
2626
import { Button } from "./ui/button";
2727
import { Toggle, ToggleGroup } from "./ui/toggle-group";
28+
import {
29+
buildFileDiffRenderKey,
30+
resolveFileDiffPath,
31+
withInferredFileDiffLanguage,
32+
} from "./pr-review/pr-review-utils";
2833

2934
type DiffRenderMode = "stacked" | "split";
3035
type DiffThemeType = "light" | "dark";
@@ -127,18 +132,6 @@ function getRenderablePatch(
127132
}
128133
}
129134

130-
function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
131-
const raw = fileDiff.name ?? fileDiff.prevName ?? "";
132-
if (raw.startsWith("a/") || raw.startsWith("b/")) {
133-
return raw.slice(2);
134-
}
135-
return raw;
136-
}
137-
138-
function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
139-
return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
140-
}
141-
142135
type FileDiffCategory = "all" | "added" | "modified" | "deleted" | "renamed";
143136

144137
const CATEGORY_ORDER: FileDiffCategory[] = ["all", "added", "modified", "deleted", "renamed"];
@@ -301,6 +294,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
301294
if (selectedCategory === "all") return renderableFiles;
302295
return renderableFiles.filter((fileDiff) => categorizeFileDiff(fileDiff) === selectedCategory);
303296
}, [renderableFiles, selectedCategory]);
297+
const renderedFiles = useMemo(
298+
() => filteredFiles.map(withInferredFileDiffLanguage),
299+
[filteredFiles],
300+
);
304301

305302
useEffect(() => {
306303
if (diffOpen && !previousDiffOpenRef.current) {
@@ -540,7 +537,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
540537
intersectionObserverMargin: 1200,
541538
}}
542539
>
543-
{filteredFiles.map((fileDiff) => {
540+
{renderedFiles.map((fileDiff) => {
544541
const filePath = resolveFileDiffPath(fileDiff);
545542
const fileKey = buildFileDiffRenderKey(fileDiff);
546543
const themedFileKey = `${fileKey}:${resolvedTheme}`;

0 commit comments

Comments
 (0)