| ... | @@ -4,7 +4,7 @@ import { | ... | @@ -4,7 +4,7 @@ import { |
| 4 | type SupportedLanguages, | 4 | type SupportedLanguages, |
| 5 | } from "@pierre/diffs"; | 5 | } from "@pierre/diffs"; |
| 6 | import { CheckIcon, CopyIcon } from "lucide-react"; | 6 | import { CheckIcon, CopyIcon } from "lucide-react"; |
| 7 | import { | 7 | import React, { |
| 8 | Children, | 8 | Children, |
| 9 | Suspense, | 9 | Suspense, |
| 10 | isValidElement, | 10 | isValidElement, |
| ... | @@ -28,6 +28,27 @@ import { resolveMarkdownFileLinkTarget } from "../markdown-links"; | ... | @@ -28,6 +28,27 @@ import { resolveMarkdownFileLinkTarget } from "../markdown-links"; |
| 28 | import { readNativeApi } from "../nativeApi"; | 28 | import { readNativeApi } from "../nativeApi"; |
| 29 | import { preferredTerminalEditor } from "../terminal-links"; | 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 | interface ChatMarkdownProps { | 52 | interface ChatMarkdownProps { |
| 32 | text: string; | 53 | text: string; |
| 33 | cwd: string | undefined; | 54 | cwd: string | undefined; |
| ... | @@ -99,6 +120,14 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> { | ... | @@ -99,6 +120,14 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> { |
| 99 | themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], | 120 | themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], |
| 100 | langs: [language as SupportedLanguages], | 121 | langs: [language as SupportedLanguages], |
| 101 | preferredHighlighter: "shiki-js", | 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 | highlighterPromiseCache.set(language, promise); | 132 | highlighterPromiseCache.set(language, promise); |
| 104 | return promise; | 133 | return promise; |
| ... | @@ -179,10 +208,14 @@ function SuspenseShikiCodeBlock({ | ... | @@ -179,10 +208,14 @@ function SuspenseShikiCodeBlock({ |
| 179 | } | 208 | } |
| 180 | | 209 | |
| 181 | const highlighter = use(getHighlighterPromise(language)); | 210 | const highlighter = use(getHighlighterPromise(language)); |
| 182 | const highlightedHtml = useMemo( | 211 | const highlightedHtml = useMemo(() => { |
| 183 | () => highlighter.codeToHtml(code, { lang: language, theme: themeName }), | 212 | try { |
| 184 | [code, highlighter, language, themeName], | 213 | return highlighter.codeToHtml(code, { lang: language, theme: themeName }); |
| 185 | ); | 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 | useEffect(() => { | 220 | useEffect(() => { |
| 188 | if (!isStreaming) { | 221 | if (!isStreaming) { |
| ... | @@ -235,14 +268,16 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | ... | @@ -235,14 +268,16 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 235 | | 268 | |
| 236 | return ( | 269 | return ( |
| 237 | <MarkdownCodeBlock code={codeBlock.code}> | 270 | <MarkdownCodeBlock code={codeBlock.code}> |
| 238 | <Suspense fallback={<pre {...props}>{children}</pre>}> | 271 | <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}> |
| 239 | <SuspenseShikiCodeBlock | 272 | <Suspense fallback={<pre {...props}>{children}</pre>}> |
| 240 | className={codeBlock.className} | 273 | <SuspenseShikiCodeBlock |
| 241 | code={codeBlock.code} | 274 | className={codeBlock.className} |
| 242 | themeName={diffThemeName} | 275 | code={codeBlock.code} |
| 243 | isStreaming={isStreaming} | 276 | themeName={diffThemeName} |
| 244 | /> | 277 | isStreaming={isStreaming} |
| 245 | </Suspense> | 278 | /> |
| | 279 | </Suspense> |
| | 280 | </CodeHighlightErrorBoundary> |
| 246 | </MarkdownCodeBlock> | 281 | </MarkdownCodeBlock> |
| 247 | ); | 282 | ); |
| 248 | }, | 283 | }, |