Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 137 additions & 162 deletions webview-ui/src/components/common/MarkdownBlock.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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);
Expand Down Expand Up @@ -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,
})
}

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} />
},
},
vscode.postMessage({
type: "openFile",
text: filePath,
values,
})
}

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>
)
})

Expand Down