From 0eec0ad74a2d695f8348bbd178b5919436acc5f0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 09:18:24 +0800 Subject: [PATCH] feat(sidebar): render mermaid diagrams in desktop PR comments (#5581) Render Mermaid code fences in full desktop PR comment markdown while keeping compact sidebar previews bounded and source-only. --- .../sidebar/CommentMarkdown.test.tsx | 77 +++++++++++++++++++ .../components/sidebar/CommentMarkdown.tsx | 35 ++++++--- .../sidebar/CommentMermaidBlock.tsx | 28 +++++++ .../sidebar/comment-mermaid-fence.tsx | 28 +++++++ 4 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/sidebar/CommentMermaidBlock.tsx create mode 100644 src/renderer/src/components/sidebar/comment-mermaid-fence.tsx diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx index bffde3372..b64d10a04 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx @@ -107,6 +107,83 @@ describe('CommentMarkdown', () => { }) }) + it('strips single-line and multi-line HTML comments', () => { + const markup = renderToStaticMarkup( + after'} + /> + ) + + expect(markup).not.toContain('secret') + expect(markup).not.toContain('multi-line') + expect(markup).toContain('before') + expect(markup).toContain('after') + }) + + it('renders
/ as a disclosure section', () => { + const markup = renderToStaticMarkup( + Show more\n\nhidden body\n\n
'} + /> + ) + + expect(markup).toContain('Show more') + expect(markup).toContain('hidden body') + }) + + it('renders markdown blockquotes', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(' { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(' { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('a') + expect(markup).toContain('1') + }) + + it('renders mermaid code fences as a mermaid container instead of a pre block', () => { + const markup = renderToStaticMarkup( + B;\n```'} /> + ) + + expect(markup).toContain('mermaid-block') + expect(markup).toContain('overflow-x-auto') + expect(markup).toContain('[&_.mermaid-block_pre]:max-h-80') + expect(markup).not.toContain(' { + const markup = renderToStaticMarkup( + B;\n```'} /> + ) + + expect(markup).toContain(' { const markup = renderToStaticMarkup( ['rehypePlugins']> type UrlTransform = NonNullable['urlTransform']> @@ -139,12 +140,14 @@ const compactComponents: Components = { // the pill background/padding when code is inside a
. This is
   // more reliable than checking `className` — which is only set when
   // the fenced block specifies a language (```js), not for bare ```.
+  // Why: compact comment previews live in dense cards; keep diagram fences as
+  // bounded source blocks so async SVG renders do not reshape sidebar lists.
   code: ({ children }) => (
     
       {children}
     
   ),
-  // Compact pre blocks — no syntax highlighting needed for short comments
+  // Compact pre blocks — no syntax highlighting needed for short comments.
   pre: ({ children }) => (
     
       {children}
@@ -242,16 +245,26 @@ const documentComponents: Components = {
         {children}
       
     ),
-  code: ({ children }) => (
-    
-      {children}
-    
-  ),
-  pre: ({ children }) => (
-    
-      {children}
-    
- ), + code: ({ className, children }) => + isMermaidFence(className) ? ( + renderMermaidFence( + children, + 'my-3 min-w-0 max-w-full overflow-x-auto rounded-md border border-border/60 p-3 [&_.mermaid-block]:min-w-0 [&_.mermaid-block_pre]:my-0 [&_.mermaid-block_pre]:max-h-80 [&_.mermaid-block_pre]:max-w-full [&_.mermaid-block_pre]:overflow-x-auto [&_.mermaid-block_pre]:rounded-md [&_.mermaid-block_pre]:bg-accent [&_.mermaid-block_pre]:p-3 [&_.mermaid-block_pre]:font-mono [&_.mermaid-block_pre]:text-[12px]' + ) + ) : ( + + {children} + + ), + // Mermaid fences render a
, which is invalid inside
, so unwrap them.
+  pre: ({ children }) =>
+    isMermaidPre(children) ? (
+      <>{children}
+    ) : (
+      
+        {children}
+      
+ ), ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) => ( diff --git a/src/renderer/src/components/sidebar/CommentMermaidBlock.tsx b/src/renderer/src/components/sidebar/CommentMermaidBlock.tsx new file mode 100644 index 000000000..128444a8d --- /dev/null +++ b/src/renderer/src/components/sidebar/CommentMermaidBlock.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import MermaidBlock from '@/components/editor/MermaidBlock' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' + +// Why: comment markdown components are module-level constants without access to +// the live theme, so this wrapper resolves dark mode from the app store (same +// logic the editor uses) and reuses the editor's MermaidBlock renderer. Mermaid +// HTML labels are disabled because MermaidBlock sanitizes the SVG, and sanitized +// foreignObject labels disappear on some platforms. +export default function CommentMermaidBlock({ + content, + className +}: { + content: string + className?: string +}): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const isDark = + settings?.theme === 'dark' || + (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/sidebar/comment-mermaid-fence.tsx b/src/renderer/src/components/sidebar/comment-mermaid-fence.tsx new file mode 100644 index 000000000..4c6af2847 --- /dev/null +++ b/src/renderer/src/components/sidebar/comment-mermaid-fence.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import CommentMermaidBlock from './CommentMermaidBlock' + +// Why: react-markdown sets className="language-mermaid" on the inside a +// fenced ```mermaid block. Detecting it lets us render a real diagram instead of +// the raw source, matching the editor's markdown preview. +export function isMermaidFence(className: string | undefined): boolean { + return /\blanguage-mermaid\b/.test(className ?? '') +} + +export function renderMermaidFence( + children: React.ReactNode, + className?: string +): React.JSX.Element { + return +} + +// Why: MermaidBlock renders a
via innerHTML, which is invalid inside a +//
. The 
 renderer receives the inner  element (not the rendered
+// diagram), so detect the mermaid fence from that child's className and unwrap.
+export function isMermaidPre(children: React.ReactNode): boolean {
+  const child = React.Children.toArray(children)[0]
+  if (!React.isValidElement(child)) {
+    return false
+  }
+  const className = (child.props as { className?: string } | null)?.className
+  return isMermaidFence(className)
+}