Repository navigation
Use shared Shiki highlighting for chat markdown code fences #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6b55b07
b2edc7e
37f0951
39eea0d
5c16da6
8bfc45d
c832b7c
9e994c1
c0f8c29
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,207 @@ | ||
| import { memo, useMemo } from "react"; | ||
| import { | ||
| getSharedHighlighter, | ||
| type DiffsHighlighter, | ||
| type SupportedLanguages, | ||
| } from "@pierre/diffs"; | ||
| import { CheckIcon, CopyIcon } from "lucide-react"; | ||
| import { | ||
| Children, | ||
| Suspense, | ||
| isValidElement, | ||
| use, | ||
| useCallback, | ||
| memo, | ||
| useEffect, | ||
| useMemo, | ||
| useRef, | ||
| useState, | ||
| type ReactNode, | ||
| } from "react"; | ||
| import type { Components } from "react-markdown"; | ||
| import ReactMarkdown from "react-markdown"; | ||
| import rehypeHighlight from "rehype-highlight"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import { readNativeApi } from "../nativeApi"; | ||
| import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | ||
| 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 { | ||
| text: string; | ||
| cwd: string | undefined; | ||
| isStreaming?: boolean; | ||
| } | ||
|
|
||
| const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; | ||
| const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; | ||
| const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; | ||
| const highlightedCodeCache = new LRUCache<string>( | ||
| MAX_HIGHLIGHT_CACHE_ENTRIES, | ||
| MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, | ||
| ); | ||
| const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
|
|
||
| function extractFenceLanguage(className: string | undefined): string { | ||
| const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); | ||
| return match?.[1] ?? "text"; | ||
| } | ||
|
|
||
| function nodeToPlainText(node: ReactNode): string { | ||
| if (typeof node === "string" || typeof node === "number") { | ||
| return String(node); | ||
| } | ||
| if (Array.isArray(node)) { | ||
| return node.map((child) => nodeToPlainText(child)).join(""); | ||
| } | ||
| if (isValidElement<{ children?: ReactNode }>(node)) { | ||
| return nodeToPlainText(node.props.children); | ||
| } | ||
| return ""; | ||
| } | ||
|
|
||
| function extractCodeBlock( | ||
| children: ReactNode, | ||
| ): { className: string | undefined; code: string } | null { | ||
| const childNodes = Children.toArray(children); | ||
| if (childNodes.length !== 1) { | ||
| return null; | ||
| } | ||
|
|
||
| const onlyChild = childNodes[0]; | ||
| if ( | ||
| !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || | ||
| onlyChild.type !== "code" | ||
| ) { | ||
| return null; | ||
| } | ||
|
|
||
| return { | ||
| className: onlyChild.props.className, | ||
| code: nodeToPlainText(onlyChild.props.children), | ||
| }; | ||
| } | ||
|
|
||
| function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { | ||
| function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { | ||
| return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; | ||
| } | ||
|
|
||
| function estimateHighlightedSize(html: string, code: string): number { | ||
| return Math.max(html.length * 2, code.length * 3); | ||
| } | ||
|
|
||
| function getHighlighterPromise(language: string): Promise<DiffsHighlighter> { | ||
| const cached = highlighterPromiseCache.get(language); | ||
| if (cached) return cached; | ||
|
|
||
| const promise = getSharedHighlighter({ | ||
| themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], | ||
| langs: [language as SupportedLanguages], | ||
| preferredHighlighter: "shiki-js", | ||
| }); | ||
| highlighterPromiseCache.set(language, promise); | ||
| return promise; | ||
| } | ||
|
|
||
| function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { | ||
| const [copied, setCopied] = useState(false); | ||
| const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| const handleCopy = useCallback(() => { | ||
| if (typeof navigator === "undefined" || navigator.clipboard == null) { | ||
| return; | ||
| } | ||
| void navigator.clipboard | ||
| .writeText(code) | ||
| .then(() => { | ||
| if (copiedTimerRef.current != null) { | ||
| clearTimeout(copiedTimerRef.current); | ||
| } | ||
| setCopied(true); | ||
| copiedTimerRef.current = setTimeout(() => { | ||
| setCopied(false); | ||
| copiedTimerRef.current = null; | ||
| }, 1200); | ||
| }) | ||
| .catch(() => undefined); | ||
| }, [code]); | ||
|
|
||
| useEffect( | ||
| () => () => { | ||
| if (copiedTimerRef.current != null) { | ||
| clearTimeout(copiedTimerRef.current); | ||
| copiedTimerRef.current = null; | ||
| } | ||
| }, | ||
| [], | ||
| ); | ||
|
|
||
| return ( | ||
| <div className="chat-markdown-codeblock"> | ||
| <button | ||
| type="button" | ||
| className="chat-markdown-copy-button" | ||
| onClick={handleCopy} | ||
| title={copied ? "Copied" : "Copy code"} | ||
| aria-label={copied ? "Copied" : "Copy code"} | ||
| > | ||
| {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />} | ||
| </button> | ||
| {children} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| interface SuspenseShikiCodeBlockProps { | ||
| className: string | undefined; | ||
| code: string; | ||
| themeName: DiffThemeName; | ||
| isStreaming: boolean; | ||
| } | ||
|
|
||
| function SuspenseShikiCodeBlock({ | ||
| className, | ||
| code, | ||
| themeName, | ||
| isStreaming, | ||
| }: SuspenseShikiCodeBlockProps) { | ||
| const language = extractFenceLanguage(className); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High Calling 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| const cacheKey = createHighlightCacheKey(code, language, themeName); | ||
| const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; | ||
|
|
||
| if (cachedHighlightedHtml != null) { | ||
| return ( | ||
| <div | ||
| className="chat-markdown-shiki" | ||
| dangerouslySetInnerHTML={{ __html: cachedHighlightedHtml }} | ||
| /> | ||
| ); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Conditional return before hooks violates Rules of HooksHigh Severity
Additional Locations (1) |
||
|
|
||
| const highlighter = use(getHighlighterPromise(language)); | ||
| const highlightedHtml = useMemo( | ||
| () => highlighter.codeToHtml(code, { lang: language, theme: themeName }), | ||
| [code, highlighter, language, themeName], | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| if (!isStreaming) { | ||
| highlightedCodeCache.set( | ||
| cacheKey, | ||
| highlightedHtml, | ||
| estimateHighlightedSize(highlightedHtml, code), | ||
| ); | ||
| } | ||
| }, [cacheKey, code, highlightedHtml, isStreaming]); | ||
|
|
||
| return ( | ||
| <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlightedHtml }} /> | ||
| ); | ||
| } | ||
|
|
||
| function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | ||
| const { resolvedTheme } = useTheme(); | ||
| const diffThemeName = resolveDiffThemeName(resolvedTheme); | ||
| const markdownComponents = useMemo<Components>( | ||
| () => ({ | ||
| a({ node: _node, href, ...props }) { | ||
|
|
@@ -38,17 +227,32 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { | |
| /> | ||
| ); | ||
| }, | ||
| pre({ node: _node, children, ...props }) { | ||
| const codeBlock = extractCodeBlock(children); | ||
| if (!codeBlock) { | ||
| return <pre {...props}>{children}</pre>; | ||
| } | ||
|
|
||
| return ( | ||
| <MarkdownCodeBlock code={codeBlock.code}> | ||
| <Suspense fallback={<pre {...props}>{children}</pre>}> | ||
| <SuspenseShikiCodeBlock | ||
| className={codeBlock.className} | ||
| code={codeBlock.code} | ||
| themeName={diffThemeName} | ||
| isStreaming={isStreaming} | ||
| /> | ||
| </Suspense> | ||
| </MarkdownCodeBlock> | ||
| ); | ||
| }, | ||
| }), | ||
| [cwd], | ||
| [cwd, diffThemeName, isStreaming], | ||
| ); | ||
|
|
||
| return ( | ||
| <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80"> | ||
| <ReactMarkdown | ||
| remarkPlugins={[remarkGfm]} | ||
| rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]} | ||
| components={markdownComponents} | ||
| > | ||
| <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> | ||
| {text} | ||
| </ReactMarkdown> | ||
| </div> | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
components/ChatMarkdown.tsx:44Consider handling rejected promises in
getHighlighterPromise— ifgetSharedHighlighterfails, the rejected promise stays cached forever, breaking that language until page reload. Adding a.catch()to delete the entry on failure would fix this.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: