| ... | ... | @@ -4,7 +4,7 @@ import { |
| 4 | 4 | type SupportedLanguages, |
| 5 | 5 | } from "@pierre/diffs"; |
| 6 | 6 | import { CheckIcon, CopyIcon } from "lucide-react"; |
| 7 | | import { |
| 7 | import React, { |
| 8 | 8 | Children, |
| 9 | 9 | Suspense, |
| 10 | 10 | isValidElement, |
| ... | ... | @@ -28,6 +28,27 @@ import { resolveMarkdownFileLinkTarget } from "../markdown-links"; |
| 28 | 28 | import { readNativeApi } from "../nativeApi"; |
| 29 | 29 | import { preferredTerminalEditor } from "../terminal-links"; |
| 30 | 30 | |
| 31 | class CodeHighlightErrorBoundary extends React.Component< |
| 32 | { fallback: ReactNode; children: ReactNode }, |
| 33 | { hasError: boolean } |
| 34 | > { |
| 35 | constructor(props: { fallback: ReactNode; children: ReactNode }) { |
| 36 | super(props); |
| 37 | this.state = { hasError: false }; |
| 38 | } |
| 39 | |
| 40 | static getDerivedStateFromError() { |
| 41 | return { hasError: true }; |
| 42 | } |
| 43 | |
| 44 | override render() { |
| 45 | if (this.state.hasError) { |
| 46 | return this.props.fallback; |
| 47 | } |
| 48 | return this.props.children; |
| 49 | } |
| 50 | } |
| 51 | |
| 31 | 52 | interface ChatMarkdownProps { |
| 32 | 53 | text: string; |
| 33 | 54 | cwd: string | undefined; |
| ... | ... | @@ -99,6 +120,14 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> { |
| 99 | 120 | themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], |
| 100 | 121 | langs: [language as SupportedLanguages], |
| 101 | 122 | preferredHighlighter: "shiki-js", |
| 123 | }).catch((err) => { |
| 124 | highlighterPromiseCache.delete(language); |
| 125 | if (language === "text") { |
| 126 | // "text" itself failed — Shiki cannot initialize at all, surface the error |
| 127 | throw err; |
| 128 | } |
| 129 | // Language not supported by Shiki — fall back to "text" |
| 130 | return getHighlighterPromise("text"); |
| 102 | 131 | }); |
| 103 | 132 | highlighterPromiseCache.set(language, promise); |
| 104 | 133 | return promise; |
| ... | ... | @@ -179,10 +208,14 @@ function SuspenseShikiCodeBlock({ |
| 179 | 208 | } |
| 180 | 209 | |
| 181 | 210 | const highlighter = use(getHighlighterPromise(language)); |
| 182 | | const highlightedHtml = useMemo( |
| 183 | | () => highlighter.codeToHtml(code, { lang: language, theme: themeName }), |
| 184 | | [code, highlighter, language, themeName], |
| 185 | | ); |
| 211 | const highlightedHtml = useMemo(() => { |
| 212 | try { |
| 213 | return highlighter.codeToHtml(code, { lang: language, theme: themeName }); |
| 214 | } catch { |
| 215 | // If highlighting fails for this language, render as plain text |
| 216 | return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); |
| 217 | } |
| 218 | }, [code, highlighter, language, themeName]); |
| 186 | 219 | |
| 187 | 220 | useEffect(() => { |
| 188 | 221 | if (!isStreaming) { |
| ... | ... | @@ -235,14 +268,16 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 235 | 268 | |
| 236 | 269 | return ( |
| 237 | 270 | <MarkdownCodeBlock code={codeBlock.code}> |
| 238 | | <Suspense fallback={<pre {...props}>{children}</pre>}> |
| 239 | | <SuspenseShikiCodeBlock |
| 240 | | className={codeBlock.className} |
| 241 | | code={codeBlock.code} |
| 242 | | themeName={diffThemeName} |
| 243 | | isStreaming={isStreaming} |
| 244 | | /> |
| 245 | | </Suspense> |
| 271 | <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}> |
| 272 | <Suspense fallback={<pre {...props}>{children}</pre>}> |
| 273 | <SuspenseShikiCodeBlock |
| 274 | className={codeBlock.className} |
| 275 | code={codeBlock.code} |
| 276 | themeName={diffThemeName} |
| 277 | isStreaming={isStreaming} |
| 278 | /> |
| 279 | </Suspense> |
| 280 | </CodeHighlightErrorBoundary> |
| 246 | 281 | </MarkdownCodeBlock> |
| 247 | 282 | ); |
| 248 | 283 | }, |