diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ae82b805d67c05bd40aa6146184b953e2b997b55..9d39849802f3baa2421d87bd2fcbed4e491f2e61 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,8 +1,14 @@ -import { getSharedHighlighter } from "@pierre/diffs"; +import { + getSharedHighlighter, + type DiffsHighlighter, + type SupportedLanguages, +} from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; import { Children, + Suspense, isValidElement, + use, useCallback, memo, useEffect, @@ -14,12 +20,12 @@ import { import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { LRUCache } from "../lib/lruCache"; -import { readNativeApi } from "../nativeApi"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffThemes"; import { fnv1a32 } from "../lib/diffRendering"; +import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; +import { readNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; interface ChatMarkdownProps { @@ -35,11 +41,7 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); -interface InFlightHighlight { - promise: Promise; - enableCache: () => void; -} -const inFlightHighlights = new Map(); +const highlighterPromiseCache = new Map>(); function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); @@ -81,85 +83,33 @@ function extractCodeBlock( }; } -async function highlightCodeBlock(code: string, language: string, themeName: DiffThemeName) { - try { - const highlighter = await getSharedHighlighter({ - themes: [themeName], - langs: [language], - }); - return highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch { - const highlighter = await getSharedHighlighter({ - themes: [themeName], - langs: ["text"], - }); - return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } -} - function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; } +function createHighlighterPromiseKey(language: string, themeName: DiffThemeName): string { + return `${language}:${themeName}`; +} + function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } -function getCachedOrCreateHighlight({ - cacheKey, - code, - language, - themeName, - useCache, -}: { - cacheKey: string; - code: string; - language: string; - themeName: DiffThemeName; - useCache: boolean; -}): Promise { - if (useCache) { - const cached = highlightedCodeCache.get(cacheKey); - if (cached) { - return Promise.resolve(cached); - } - } +function getHighlighterPromise( + language: string, + themeName: DiffThemeName, +): Promise { + const cacheKey = createHighlighterPromiseKey(language, themeName); + const cached = highlighterPromiseCache.get(cacheKey); + if (cached) return cached; - const existingInFlight = inFlightHighlights.get(cacheKey); - if (existingInFlight) { - if (useCache) { - existingInFlight.enableCache(); - } - return existingInFlight.promise; - } - - let shouldCache = useCache; - const requestPromise = highlightCodeBlock(code, language, themeName) - .then((html) => { - if (shouldCache) { - highlightedCodeCache.set(cacheKey, html, estimateHighlightedSize(html, code)); - } - return html; - }) - .finally(() => { - inFlightHighlights.delete(cacheKey); - }); - - inFlightHighlights.set(cacheKey, { - promise: requestPromise, - enableCache: () => { - shouldCache = true; - }, + const promise = getSharedHighlighter({ + themes: [themeName], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", }); - return requestPromise; -} - -interface ShikiCodeBlockProps { - className: string | undefined; - code: string; - themeName: DiffThemeName; - isStreaming: boolean; - fallback: ReactNode; + highlighterPromiseCache.set(cacheKey, promise); + return promise; } function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { @@ -210,70 +160,47 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo ); } -function ShikiCodeBlock({ className, code, themeName, isStreaming, fallback }: ShikiCodeBlockProps) { +interface SuspenseShikiCodeBlockProps { + className: string | undefined; + code: string; + themeName: DiffThemeName; + isStreaming: boolean; +} + +function SuspenseShikiCodeBlock({ + className, + code, + themeName, + isStreaming, +}: SuspenseShikiCodeBlockProps) { const language = extractFenceLanguage(className); const cacheKey = createHighlightCacheKey(code, language, themeName); - const [highlightState, setHighlightState] = useState<{ - cacheKey: string; - html: string | null; - }>(() => ({ - cacheKey, - html: isStreaming ? null : (highlightedCodeCache.get(cacheKey) ?? null), - })); - const cachedHighlightedHtml = isStreaming ? null : (highlightedCodeCache.get(cacheKey) ?? null); - const highlightedHtml = - cachedHighlightedHtml ?? (highlightState.cacheKey === cacheKey ? highlightState.html : null); + const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; + + if (cachedHighlightedHtml != null) { + return ( +
+ ); + } + + const highlighter = use(getHighlighterPromise(language, themeName)); + const highlightedHtml = useMemo( + () => highlighter.codeToHtml(code, { lang: language, theme: themeName }), + [code, highlighter, language, themeName], + ); useEffect(() => { - let cancelled = false; - - if (cachedHighlightedHtml) { - setHighlightState((current) => { - if (current.cacheKey === cacheKey && current.html === cachedHighlightedHtml) { - return current; - } - return { cacheKey, html: cachedHighlightedHtml }; - }); - return () => { - cancelled = true; - }; + if (!isStreaming) { + highlightedCodeCache.set( + cacheKey, + highlightedHtml, + estimateHighlightedSize(highlightedHtml, code), + ); } - - setHighlightState((current) => { - if (current.cacheKey === cacheKey) { - return current; - } - return { cacheKey, html: null }; - }); - - void getCachedOrCreateHighlight({ - cacheKey, - code, - language, - themeName, - useCache: !isStreaming, - }) - .then((html) => { - if (cancelled) { - return; - } - setHighlightState((current) => { - if (current.cacheKey === cacheKey && current.html === html) { - return current; - } - return { cacheKey, html }; - }); - }) - .catch(() => undefined); - - return () => { - cancelled = true; - }; - }, [cacheKey, cachedHighlightedHtml, code, isStreaming, language, themeName]); - - if (!highlightedHtml) { - return fallback; - } + }, [cacheKey, code, highlightedHtml, isStreaming]); return (
@@ -316,13 +243,14 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return ( - {children}} - /> + {children}}> + + ); },