authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 15:10:49-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-28 15:17:03-08:00
log33e6f35ce9b4c846a64c818430797590fe965fe0
treea99276d0c42f0859c311a043704e8262c3879c9d
parente8896fe02d713d59c73b5212011507ef30dd9737

simp


1 files changed, 71 insertions(+), 143 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+71-143
......@@ -1,8 +1,14 @@
1import { getSharedHighlighter } from "@pierre/diffs";
1import {
2 getSharedHighlighter,
3 type DiffsHighlighter,
4 type SupportedLanguages,
5} from "@pierre/diffs";
26import { CheckIcon, CopyIcon } from "lucide-react";
37import {
48 Children,
9 Suspense,
510 isValidElement,
11 use,
612 useCallback,
713 memo,
814 useEffect,
......@@ -14,12 +20,12 @@ import {
1420import type { Components } from "react-markdown";
1521import ReactMarkdown from "react-markdown";
1622import remarkGfm from "remark-gfm";
17import { LRUCache } from "../lib/lruCache";
18import { readNativeApi } from "../nativeApi";
1923import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes";
2024import { fnv1a32 } from "../lib/diffRendering";
25import { LRUCache } from "../lib/lruCache";
2126import { useTheme } from "../hooks/useTheme";
2227import { resolveMarkdownFileLinkTarget } from "../markdown-links";
28import { readNativeApi } from "../nativeApi";
2329import { preferredTerminalEditor } from "../terminal-links";
2430
2531interface ChatMarkdownProps {
......@@ -35,11 +41,7 @@ const highlightedCodeCache = new LRUCache<string>(
3541 MAX_HIGHLIGHT_CACHE_ENTRIES,
3642 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
3743);
38interface InFlightHighlight {
39 promise: Promise<string>;
40 enableCache: () => void;
41}
42const inFlightHighlights = new Map<string, InFlightHighlight>();
44const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();
4345
4446function extractFenceLanguage(className: string | undefined): string {
4547 const match = className?.match(CODE_FENCE_LANGUAGE_REGEX);
......@@ -81,85 +83,33 @@ function extractCodeBlock(
8183 };
8284}
8385
84async function highlightCodeBlock(code: string, language: string, themeName: DiffThemeName) {
85 try {
86 const highlighter = await getSharedHighlighter({
87 themes: [themeName],
88 langs: [language],
89 });
90 return highlighter.codeToHtml(code, { lang: language, theme: themeName });
91 } catch {
92 const highlighter = await getSharedHighlighter({
93 themes: [themeName],
94 langs: ["text"],
95 });
96 return highlighter.codeToHtml(code, { lang: "text", theme: themeName });
97 }
98}
99
10086function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string {
10187 return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`;
10288}
10389
90function createHighlighterPromiseKey(language: string, themeName: DiffThemeName): string {
91 return `${language}:${themeName}`;
92}
93
10494function estimateHighlightedSize(html: string, code: string): number {
10595 return Math.max(html.length * 2, code.length * 3);
10696}
10797
108function getCachedOrCreateHighlight({
109 cacheKey,
110 code,
111 language,
112 themeName,
113 useCache,
114}: {
115 cacheKey: string;
116 code: string;
117 language: string;
118 themeName: DiffThemeName;
119 useCache: boolean;
120}): Promise<string> {
121 if (useCache) {
122 const cached = highlightedCodeCache.get(cacheKey);
123 if (cached) {
124 return Promise.resolve(cached);
125 }
126 }
127
128 const existingInFlight = inFlightHighlights.get(cacheKey);
129 if (existingInFlight) {
130 if (useCache) {
131 existingInFlight.enableCache();
132 }
133 return existingInFlight.promise;
134 }
98function getHighlighterPromise(
99 language: string,
100 themeName: DiffThemeName,
101): Promise<DiffsHighlighter> {
102 const cacheKey = createHighlighterPromiseKey(language, themeName);
103 const cached = highlighterPromiseCache.get(cacheKey);
104 if (cached) return cached;
135105
136 let shouldCache = useCache;
137 const requestPromise = highlightCodeBlock(code, language, themeName)
138 .then((html) => {
139 if (shouldCache) {
140 highlightedCodeCache.set(cacheKey, html, estimateHighlightedSize(html, code));
141 }
142 return html;
143 })
144 .finally(() => {
145 inFlightHighlights.delete(cacheKey);
146 });
147
148 inFlightHighlights.set(cacheKey, {
149 promise: requestPromise,
150 enableCache: () => {
151 shouldCache = true;
152 },
106 const promise = getSharedHighlighter({
107 themes: [themeName],
108 langs: [language as SupportedLanguages],
109 preferredHighlighter: "shiki-js",
153110 });
154 return requestPromise;
155}
156
157interface ShikiCodeBlockProps {
158 className: string | undefined;
159 code: string;
160 themeName: DiffThemeName;
161 isStreaming: boolean;
162 fallback: ReactNode;
111 highlighterPromiseCache.set(cacheKey, promise);
112 return promise;
163113}
164114
165115function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) {
......@@ -210,70 +160,47 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo
210160 );
211161}
212162
213function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) {
163interface SuspenseShikiCodeBlockProps {
164 className: string | undefined;
165 code: string;
166 themeName: DiffThemeName;
167 isStreaming: boolean;
168}
169
170function SuspenseShikiCodeBlock({
171 className,
172 code,
173 themeName,
174 isStreaming,
175}: SuspenseShikiCodeBlockProps) {
214176 const language = extractFenceLanguage(className);
215177 const cacheKey = createHighlightCacheKey(code, language, themeName);
216 const [highlightState, setHighlightState] = useState<{
217 cacheKey: string;
218 html: string | null;
219 }>(() => ({
220 cacheKey,
221 html: isStreaming ? null : (highlightedCodeCache.get(cacheKey) ?? null),
222 }));
223 const cachedHighlightedHtml = isStreaming ? null : (highlightedCodeCache.get(cacheKey) ?? null);
224 const highlightedHtml =
225 cachedHighlightedHtml ?? (highlightState.cacheKey === cacheKey ? highlightState.html : null);
226
227 useEffect(() => {
228 let cancelled = false;
178 const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null;
229179
230 if (cachedHighlightedHtml) {
231 setHighlightState((current) => {
232 if (current.cacheKey === cacheKey && current.html === cachedHighlightedHtml) {
233 return current;
234 }
235 return { cacheKey, html: cachedHighlightedHtml };
236 });
237 return () => {
238 cancelled = true;
239 };
240 }
241
242 setHighlightState((current) => {
243 if (current.cacheKey === cacheKey) {
244 return current;
245 }
246 return { cacheKey, html: null };
247 });
248
249 void getCachedOrCreateHighlight({
250 cacheKey,
251 code,
252 language,
253 themeName,
254 useCache: !isStreaming,
255 })
256 .then((html) => {
257 if (cancelled) {
258 return;
259 }
260 setHighlightState((current) => {
261 if (current.cacheKey === cacheKey && current.html === html) {
262 return current;
263 }
264 return { cacheKey, html };
265 });
266 })
267 .catch(() => undefined);
180 if (cachedHighlightedHtml != null) {
181 return (
182 <div
183 className="chat-markdown-shiki"
184 dangerouslySetInnerHTML={{ __html: cachedHighlightedHtml }}
185 />
186 );
187 }
268188
269 return () => {
270 cancelled = true;
271 };
272 }, [cacheKey, cachedHighlightedHtml, code, isStreaming, language, themeName]);
189 const highlighter = use(getHighlighterPromise(language, themeName));
190 const highlightedHtml = useMemo(
191 () => highlighter.codeToHtml(code, { lang: language, theme: themeName }),
192 [code, highlighter, language, themeName],
193 );
273194
274 if (!highlightedHtml) {
275 return fallback;
276 }
195 useEffect(() => {
196 if (!isStreaming) {
197 highlightedCodeCache.set(
198 cacheKey,
199 highlightedHtml,
200 estimateHighlightedSize(highlightedHtml, code),
201 );
202 }
203 }, [cacheKey, code, highlightedHtml, isStreaming]);
277204
278205 return (
279206 <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
......@@ -316,13 +243,14 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
316243
317244 return (
318245 <MarkdownCodeBlock code={codeBlock.code}>
319 <ShikiCodeBlock
320 className={codeBlock.className}
321 code={codeBlock.code}
322 themeName={diffThemeName}
323 isStreaming={isStreaming}
324 fallback={<pre {...props}>{children}</pre>}
325 />
246 <Suspense fallback={<pre {...props}>{children}</pre>}>
247 <SuspenseShikiCodeBlock
248 className={codeBlock.className}
249 code={codeBlock.code}
250 themeName={diffThemeName}
251 isStreaming={isStreaming}
252 />
253 </Suspense>
326254 </MarkdownCodeBlock>
327255 );
328256 },