Julius Marmingeeven better206eb6a8 months agoHistory
262 lines · 7.5 KB · TypeScript
RawDownload
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 remarkGfm from "remark-gfm";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>>(); 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 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);  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 }}      />    );  }   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 }) {        const targetPath = resolveMarkdownFileLinkTarget(href, cwd);        if (!targetPath) {          return <a {...props} href={href} target="_blank" rel="noreferrer" />;        }         return (          <a            {...props}            href={href}            onClick={(event) => {              event.preventDefault();              event.stopPropagation();              const api = readNativeApi();              if (api) {                void api.shell.openInEditor(targetPath, preferredTerminalEditor());              } else {                console.warn("Native API not found. Unable to open file in editor.");              }            }}          />        );      },      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, diffThemeName, isStreaming],  );   return (    <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">      <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>        {text}      </ReactMarkdown>    </div>  );} export default memo(ChatMarkdown);