mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
feat: add markdown table rendering support (#6252)
This commit is contained in:
parent
62160758fc
commit
478869e37a
1 changed files with 136 additions and 161 deletions
|
|
@ -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"
|
||||
|
|
@ -15,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);
|
||||
|
|
@ -191,117 +130,153 @@ 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<HTMLAnchorElement>) => {
|
||||
// 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<HTMLAnchorElement>) => {
|
||||
// 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 (
|
||||
<a {...props} href={href} onClick={handleClick}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// For all other code blocks, use CodeBlock with copy button
|
||||
const codeNode = children?.[0]
|
||||
|
||||
if (!codeNode?.props?.children) {
|
||||
return null
|
||||
}
|
||||
|
||||
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 <CodeBlock source={rawText} language={language} />
|
||||
},
|
||||
code: (props: any) => {
|
||||
const className = props.className || ""
|
||||
|
||||
if (className.includes("language-mermaid")) {
|
||||
const codeText = String(props.children || "")
|
||||
return <MermaidBlock code={codeText} />
|
||||
}
|
||||
|
||||
return <code {...props} />
|
||||
},
|
||||
},
|
||||
return (
|
||||
<a {...props} href={href} onClick={handleClick}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
})
|
||||
pre: ({ children, ..._props }: any) => {
|
||||
// The structure from react-markdown v9 is: pre > code > text
|
||||
const codeEl = children as React.ReactElement
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdown(markdown || "")
|
||||
}, [markdown, setMarkdown, theme])
|
||||
if (!codeEl || !codeEl.props) {
|
||||
return <pre>{children}</pre>
|
||||
}
|
||||
|
||||
const { className = "", children: codeChildren } = codeEl.props
|
||||
|
||||
// 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("")
|
||||
}
|
||||
|
||||
// Handle mermaid diagrams
|
||||
if (className.includes("language-mermaid")) {
|
||||
return (
|
||||
<div style={{ margin: "1em 0" }}>
|
||||
<MermaidBlock code={codeString} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{ margin: "1em 0" }}>
|
||||
<CodeBlock source={codeString} language={language} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
code: ({ children, className, ...props }: any) => {
|
||||
// This handles inline code
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{}}>
|
||||
<StyledMarkdown>{reactContent}</StyledMarkdown>
|
||||
</div>
|
||||
<StyledMarkdown>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
remarkMath,
|
||||
() => {
|
||||
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 || ""}
|
||||
</ReactMarkdown>
|
||||
</StyledMarkdown>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue