import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; import React, { Children, Suspense, type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, memo, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, { hasError: boolean } > { constructor(props: { fallback: ReactNode; children: ReactNode }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } override render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } } 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( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); const highlighterPromiseCache = new Map>(); function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) return raw === "gitignore" ? "ini" : raw; } 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 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 { const cached = highlighterPromiseCache.get(language); if (cached) return cached; const promise = getSharedHighlighter({ themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], langs: [language as SupportedLanguages], preferredHighlighter: "shiki-js", }).catch((err) => { highlighterPromiseCache.delete(language); if (language === "text") { // "text" itself failed — Shiki cannot initialize at all, surface the error throw err; } // Language not supported by Shiki — fall back to "text" return getHighlighterPromise("text"); }); highlighterPromiseCache.set(language, promise); return promise; } function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | 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 (
{children}
); } 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 cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; if (cachedHighlightedHtml != null) { return (
); } const highlighter = use(getHighlighterPromise(language)); const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); } catch (error) { // Log highlighting failures for debugging while falling back to plain text console.warn( `Code highlighting failed for language "${language}", falling back to plain text.`, error instanceof Error ? error.message : error, ); // If highlighting fails for this language, render as plain text return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); } }, [code, highlighter, language, themeName]); useEffect(() => { if (!isStreaming) { highlightedCodeCache.set( cacheKey, highlightedHtml, estimateHighlightedSize(highlightedHtml, code), ); } }, [cacheKey, code, highlightedHtml, isStreaming]); return (
); } interface MarkdownFileLinkProps { href: string; targetPath: string; displayPath: string; filePath: string; label: string; theme: "light" | "dark"; className?: string | undefined; } const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate"; function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); const segments = normalized.split("/").filter((segment) => segment.length > 0); return segments.slice(0, -1); } function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { const groups = new Map>(); for (const filePath of filePaths) { const pathSegments = filePath .replaceAll("\\", "/") .split("/") .filter((segment) => segment.length > 0); const basename = pathSegments[pathSegments.length - 1]; if (!basename) continue; const group = groups.get(basename) ?? new Set(); group.add(filePath); groups.set(basename, group); } const suffixByPath = new Map(); for (const group of groups.values()) { const uniquePaths = [...group]; if (uniquePaths.length < 2) continue; const parentSegmentsByPath = new Map( uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), ); const minUniqueDepthByPath = new Map(); for (const filePath of uniquePaths) { const segments = parentSegmentsByPath.get(filePath) ?? []; let resolvedDepth = segments.length; for (let depth = 1; depth <= segments.length; depth += 1) { const candidate = segments.slice(-depth).join("/"); const collision = uniquePaths.some((otherPath) => { if (otherPath === filePath) return false; const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; return otherSegments.slice(-depth).join("/") === candidate; }); if (!collision) { resolvedDepth = depth; break; } } minUniqueDepthByPath.set(filePath, resolvedDepth); } for (const filePath of uniquePaths) { const segments = parentSegmentsByPath.get(filePath) ?? []; if (segments.length === 0) continue; const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); } } return suffixByPath; } function extractMarkdownLinkHrefs(text: string): string[] { const hrefs: string[] = []; for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { const href = match[1]?.trim(); if (!href) continue; hrefs.push(href); } return hrefs; } function normalizeMarkdownLinkHrefKey(href: string): string { return rewriteMarkdownFileUriHref(href.trim()) ?? href.trim(); } const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, displayPath, filePath, label, theme, className, }: MarkdownFileLinkProps) { const handleOpen = useCallback(() => { const api = readLocalApi(); if (!api) { toastManager.add({ type: "error", title: "Open in editor is unavailable", }); return; } void openInPreferredEditor(api, targetPath).catch((error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open file", description: error instanceof Error ? error.message : "An error occurred.", }), ); }); }, [targetPath]); const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ type: "error", title: `Failed to copy ${title.toLowerCase()}`, description: "Clipboard API unavailable.", }), ); return; } void navigator.clipboard.writeText(value).then( () => { toastManager.add({ type: "success", title: `${title} copied`, description: value, }); }, (error) => { toastManager.add( stackedThreadToast({ type: "error", title: `Failed to copy ${title.toLowerCase()}`, description: error instanceof Error ? error.message : "An error occurred.", }), ); }, ); }, []); const handleContextMenu = useCallback( async (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, { x: event.clientX, y: event.clientY }, ); if (clicked === "open") { handleOpen(); return; } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; } if (clicked === "copy-full") { handleCopy(targetPath, "Full path"); } }, [displayPath, handleCopy, handleOpen, targetPath], ); return ( { event.preventDefault(); event.stopPropagation(); handleOpen(); }} onContextMenu={handleContextMenu} > {label} } />
{displayPath}
); }, areMarkdownFileLinkPropsEqual); function areMarkdownFileLinkPropsEqual( previous: Readonly, next: Readonly, ): boolean { return ( previous.href === next.href && previous.targetPath === next.targetPath && previous.displayPath === next.displayPath && previous.filePath === next.filePath && previous.label === next.label && previous.theme === next.theme && previous.className === next.className ); } function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< string, NonNullable> >(); for (const href of extractMarkdownLinkHrefs(text)) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); if (meta) { metaByHref.set(normalizedHref, meta); } } return metaByHref; }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); return buildFileLinkParentSuffixByPath(filePaths); }, [markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { return ; } const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); const labelParts = [fileLinkMeta.basename]; if (typeof parentSuffix === "string" && parentSuffix.length > 0) { labelParts.push(parentSuffix); } if (fileLinkMeta.line) { labelParts.push( `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, ); } return ( ); }, pre({ node: _node, children, ...props }) { const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
{children}
; } return ( {children}}> {children}}> ); }, }), [ diffThemeName, fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, resolvedTheme, ], ); return (
{text}
); } export default memo(ChatMarkdown);