From 8472a351d3e55679642c795c28a135990be13ffc Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Sat, 26 Jul 2025 14:45:32 -0500 Subject: [PATCH 1/2] feat: add markdown table rendering support - Migrate from react-remark to react-markdown for improved compatibility - Add remark-gfm plugin to enable GitHub Flavored Markdown tables - Add comprehensive table styling for VSCode theme compatibility - Ensure proper code block separation with wrapper divs - Maintain all existing markdown features (links, code blocks, math, etc.) --- .../src/components/common/MarkdownBlock.tsx | 230 ++++++++++-------- 1 file changed, 134 insertions(+), 96 deletions(-) diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 4c958593aaf..c5f7109668f 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -1,9 +1,10 @@ -import React, { memo, useEffect } from "react" -import { useRemark } from "react-remark" +import React, { memo } from "react" +import ReactMarkdown from "react-markdown" import styled from "styled-components" import { visit } from "unist-util-visit" import rehypeKatex from "rehype-katex" import remarkMath from "remark-math" +import remarkGfm from "remark-gfm" import { vscode } from "@src/utils/vscode" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -191,117 +192,154 @@ const StyledMarkdown = styled.div` text-decoration-color: var(--vscode-textLink-activeForeground); } } + + /* Table styles for remark-gfm */ + table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; + overflow-x: auto; + display: block; + } + + th, + td { + border: 1px solid var(--vscode-panel-border); + padding: 8px 12px; + text-align: left; + } + + th { + background-color: var(--vscode-editor-background); + font-weight: 600; + color: var(--vscode-foreground); + } + + tr:nth-child(even) { + background-color: var(--vscode-editor-inactiveSelectionBackground); + } + + tr:hover { + background-color: var(--vscode-list-hoverBackground); + } ` const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { - const { theme } = useExtensionState() - const [reactContent, setMarkdown] = useRemark({ - remarkPlugins: [ - remarkUrlToLink, - remarkMath, - () => { - return (tree) => { - visit(tree, "code", (node: any) => { - if (!node.lang) { - node.lang = "text" - } else if (node.lang.includes(".")) { - node.lang = node.lang.split(".").slice(-1)[0] - } - }) + const { theme: _theme } = useExtensionState() + + const components = { + a: ({ href, children, ...props }: any) => { + const handleClick = (e: React.MouseEvent) => { + // Only process file:// protocol or local file paths + const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://") + + if (!isLocalPath) { + return } - }, - ], - rehypePlugins: [rehypeKatex as any], - rehypeReactOptions: { - components: { - a: ({ href, children, ...props }: any) => { - const handleClick = (e: React.MouseEvent) => { - // Only process file:// protocol or local file paths - const isLocalPath = href.startsWith("file://") || href.startsWith("/") || !href.includes("://") - - if (!isLocalPath) { - return - } - e.preventDefault() + e.preventDefault() - // Handle absolute vs project-relative paths - let filePath = href.replace("file://", "") + // Handle absolute vs project-relative paths + let filePath = href.replace("file://", "") - // Extract line number if present - const match = filePath.match(/(.*):(\d+)(-\d+)?$/) - let values = undefined - if (match) { - filePath = match[1] - values = { line: parseInt(match[2]) } - } + // Extract line number if present + const match = filePath.match(/(.*):(\d+)(-\d+)?$/) + let values = undefined + if (match) { + filePath = match[1] + values = { line: parseInt(match[2]) } + } - // Add ./ prefix if needed - if (!filePath.startsWith("/") && !filePath.startsWith("./")) { - filePath = "./" + filePath - } + // Add ./ prefix if needed + if (!filePath.startsWith("/") && !filePath.startsWith("./")) { + filePath = "./" + filePath + } - vscode.postMessage({ - type: "openFile", - text: filePath, - values, - }) - } + vscode.postMessage({ + type: "openFile", + text: filePath, + values, + }) + } - return ( - - {children} - - ) - }, - pre: ({ node: _, children }: any) => { - // Check for Mermaid diagrams first - if (Array.isArray(children) && children.length === 1 && React.isValidElement(children[0])) { - const child = children[0] as React.ReactElement<{ className?: string }> - - if (child.props?.className?.includes("language-mermaid")) { - return child - } - } + return ( + + {children} + + ) + }, + pre: ({ children, ..._props }: any) => { + // The structure from react-markdown v9 is: pre > code > text + const codeEl = children as React.ReactElement - // For all other code blocks, use CodeBlock with copy button - const codeNode = children?.[0] + if (!codeEl || !codeEl.props) { + return
{children}
+ } - if (!codeNode?.props?.children) { - return null - } + const { className = "", children: codeChildren } = codeEl.props - const language = - (Array.isArray(codeNode.props?.className) - ? codeNode.props.className - : [codeNode.props?.className] - ).map((c: string) => c?.replace("language-", ""))[0] || "javascript" - - const rawText = codeNode.props.children[0] || "" - return - }, - code: (props: any) => { - const className = props.className || "" - - if (className.includes("language-mermaid")) { - const codeText = String(props.children || "") - return - } + // Get the actual code text + let codeString = "" + if (typeof codeChildren === "string") { + codeString = codeChildren + } else if (Array.isArray(codeChildren)) { + codeString = codeChildren.filter((child) => typeof child === "string").join("") + } - return - }, - }, - }, - }) + // Handle mermaid diagrams + if (className.includes("language-mermaid")) { + return ( +
+ +
+ ) + } - useEffect(() => { - setMarkdown(markdown || "") - }, [markdown, setMarkdown, theme]) + // Extract language from className + const match = /language-(\w+)/.exec(className) + const language = match ? match[1] : "text" + + // Wrap CodeBlock in a div to ensure proper separation + return ( +
+ +
+ ) + }, + code: ({ children, className, ...props }: any) => { + // This handles inline code + return ( + + {children} + + ) + }, + } return ( -
- {reactContent} -
+ + { + return (tree: any) => { + visit(tree, "code", (node: any) => { + if (!node.lang) { + node.lang = "text" + } else if (node.lang.includes(".")) { + node.lang = node.lang.split(".").slice(-1)[0] + } + }) + } + }, + ]} + rehypePlugins={[rehypeKatex as any]} + components={components}> + {markdown || ""} + + ) }) From 23aa0229ec73b735fda26cea97527033fad3320c Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Sat, 26 Jul 2025 14:58:27 -0500 Subject: [PATCH 2/2] fix: remove redundant remarkUrlToLink plugin to prevent nested links The remarkGfm plugin already handles URL auto-linking, so using both plugins was causing nested links which broke the test. Removed the unused remarkUrlToLink function entirely. --- .../src/components/common/MarkdownBlock.tsx | 63 ------------------- 1 file changed, 63 deletions(-) diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index c5f7109668f..ca6c9316b10 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -16,68 +16,6 @@ interface MarkdownBlockProps { markdown?: string } -/** - * Custom remark plugin that converts plain URLs in text into clickable links - * - * The original bug: We were converting text nodes into paragraph nodes, - * which broke the markdown structure because text nodes should remain as text nodes - * within their parent elements (like paragraphs, list items, etc.). - * This caused the entire content to disappear because the structure became invalid. - */ -const remarkUrlToLink = () => { - return (tree: any) => { - // Visit all "text" nodes in the markdown AST (Abstract Syntax Tree) - visit(tree, "text", (node: any, index, parent) => { - const urlRegex = /https?:\/\/[^\s<>)"]+/g - const matches = node.value.match(urlRegex) - - if (!matches || !parent) { - return - } - - const parts = node.value.split(urlRegex) - const children: any[] = [] - const cleanedMatches = matches.map((url: string) => url.replace(/[.,;:!?'"]+$/, "")) - - parts.forEach((part: string, i: number) => { - if (part) { - children.push({ type: "text", value: part }) - } - - if (cleanedMatches[i]) { - const originalUrl = matches[i] - const cleanedUrl = cleanedMatches[i] - const removedPunctuation = originalUrl.substring(cleanedUrl.length) - - // Create a proper link node with all required properties - children.push({ - type: "link", - url: cleanedUrl, - title: null, - children: [{ type: "text", value: cleanedUrl }], - data: { - hProperties: { - href: cleanedUrl, - }, - }, - }) - - if (removedPunctuation) { - children.push({ type: "text", value: removedPunctuation }) - } - } - }) - - // Replace the original text node with our new nodes in the parent's children array. - // This preserves the document structure while adding our links. - parent.children.splice(index!, 1, ...children) - - // Return SKIP to prevent visiting the newly created nodes - return ["skip", index! + children.length] - }) - } -} - const StyledMarkdown = styled.div` code:not(pre > code) { font-family: var(--vscode-editor-font-family, monospace); @@ -321,7 +259,6 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { { return (tree: any) => {