From 86cd06ef43bed39df21d87de2cd3da33f1c919fd Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 4 Dec 2025 08:28:42 +0800 Subject: [PATCH] feat(vscode-ide-companion): add MarkdownRenderer component for rich message formatting - Added MarkdownRenderer component with markdown-it integration - Updated MessageContent to use MarkdownRenderer instead of custom parsing - Added CSS styling for markdown-rendered content This improves message display with proper markdown rendering support. --- .../webview/components/MarkdownRenderer.css | 188 +++++++++++++ .../webview/components/MarkdownRenderer.tsx | 158 +++++++++++ .../src/webview/components/MessageContent.tsx | 266 +----------------- 3 files changed, 349 insertions(+), 263 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/components/MarkdownRenderer.css create mode 100644 packages/vscode-ide-companion/src/webview/components/MarkdownRenderer.tsx diff --git a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer.css b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer.css new file mode 100644 index 00000000..6eedb921 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer.css @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Styles for MarkdownRenderer component + */ + +.markdown-content { + /* Base styles for markdown content */ + line-height: 1.6; + color: var(--app-primary-foreground); +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.markdown-content h1 { + font-size: 1.75em; + border-bottom: 1px solid var(--app-primary-border-color); + padding-bottom: 0.3em; +} + +.markdown-content h2 { + font-size: 1.5em; + border-bottom: 1px solid var(--app-primary-border-color); + padding-bottom: 0.3em; +} + +.markdown-content h3 { + font-size: 1.25em; +} + +.markdown-content h4 { + font-size: 1.1em; +} + +.markdown-content h5, +.markdown-content h6 { + font-size: 1em; +} + +.markdown-content p { + margin-top: 0; + /* margin-bottom: 1em; */ +} + +.markdown-content ul, +.markdown-content ol { + margin-top: 0; + margin-bottom: 1em; + padding-left: 2em; +} + +.markdown-content li { + margin-bottom: 0.25em; +} + +.markdown-content li > p { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.markdown-content blockquote { + margin: 0 0 1em; + padding: 0 1em; + border-left: 0.25em solid var(--app-primary-border-color); + color: var(--app-secondary-foreground); +} + +.markdown-content a { + color: var(--app-link-foreground, #007acc); + text-decoration: none; +} + +.markdown-content a:hover { + color: var(--app-link-active-foreground, #005a9e); + text-decoration: underline; +} + +.markdown-content code { + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.9em; + background-color: var(--app-code-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--app-primary-border-color); + border-radius: var(--corner-radius-small, 4px); + padding: 0.2em 0.4em; + white-space: nowrap; +} + +.markdown-content pre { + margin: 1em 0; + padding: 1em; + overflow-x: auto; + background-color: var(--app-code-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--app-primary-border-color); + border-radius: var(--corner-radius-small, 4px); + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.9em; + line-height: 1.5; +} + +.markdown-content pre code { + background: none; + border: none; + padding: 0; + white-space: pre; +} + +.markdown-content .file-path-link { + background: transparent; + border: none; + padding: 0; + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.95em; + color: var(--app-link-foreground, #007acc); + text-decoration: underline; + cursor: pointer; + transition: color 0.1s ease; +} + +.markdown-content .file-path-link:hover { + color: var(--app-link-active-foreground, #005a9e); +} + +.markdown-content hr { + border: none; + border-top: 1px solid var(--app-primary-border-color); + margin: 1.5em 0; +} + +.markdown-content img { + max-width: 100%; + height: auto; +} + +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; +} + +.markdown-content th, +.markdown-content td { + padding: 0.5em 1em; + border: 1px solid var(--app-primary-border-color); + text-align: left; +} + +.markdown-content th { + background-color: var(--app-secondary-background); + font-weight: 600; +} diff --git a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer.tsx b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer.tsx new file mode 100644 index 00000000..ff3b0beb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer.tsx @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths + */ + +import type React from 'react'; +import MarkdownIt from 'markdown-it'; +import type { Options as MarkdownItOptions } from 'markdown-it'; +import './MarkdownRenderer.css'; + +interface MarkdownRendererProps { + content: string; + onFileClick?: (filePath: string) => void; +} + +/** + * Regular expressions for parsing content + */ +const FILE_PATH_REGEX = + /([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi; +// Match file paths with optional line numbers like: path/file.ts#7-14 or path/file.ts#7 +const FILE_PATH_WITH_LINES_REGEX = + /([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; + +/** + * MarkdownRenderer component - renders markdown content with enhanced features + */ +export const MarkdownRenderer: React.FC = ({ + content, + onFileClick, +}) => { + /** + * Initialize markdown-it with plugins + */ + const getMarkdownInstance = (): MarkdownIt => { + // Create markdown-it instance with options + const md = new MarkdownIt({ + html: false, // Disable HTML for security + xhtmlOut: false, + breaks: true, + linkify: true, + typographer: true, + } as MarkdownItOptions); + + // Add syntax highlighting for code blocks + md.use((md) => { + md.renderer.rules.code_block = function ( + tokens, + idx: number, + _options, + _env, + ) { + const token = tokens[idx]; + const lang = token.info || 'plaintext'; + const content = token.content; + + // Add syntax highlighting classes + return `
${md.utils.escapeHtml(content)}
`; + }; + + md.renderer.rules.fence = function (tokens, idx: number, _options, _env) { + const token = tokens[idx]; + const lang = token.info || 'plaintext'; + const content = token.content; + + // Add syntax highlighting classes + return `
${md.utils.escapeHtml(content)}
`; + }; + }); + + return md; + }; + + /** + * Render markdown content to HTML + */ + const renderMarkdown = (): string => { + try { + const md = getMarkdownInstance(); + + // Process the markdown content + let html = md.render(content); + + // Post-process to add file path click handlers + html = processFilePaths(html); + + return html; + } catch (error) { + console.error('Error rendering markdown:', error); + // Fallback to plain text if markdown rendering fails + return escapeHtml(content); + } + }; + + /** + * Escape HTML characters for security + */ + const escapeHtml = (unsafe: string): string => + unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + /** + * Process file paths in HTML to make them clickable + */ + const processFilePaths = (html: string): string => { + // Process file paths with line numbers + html = html.replace(FILE_PATH_WITH_LINES_REGEX, (match) => { + const filePath = match.split('#')[0]; + return ``; + }); + + // Process regular file paths + html = html.replace(FILE_PATH_REGEX, (match) => { + // Skip if this was already processed as a path with line numbers + if (FILE_PATH_WITH_LINES_REGEX.test(match)) { + return match; + } + return ``; + }); + + return html; + }; + + /** + * Handle file click event + */ + const handleFileClick = (filePath: string) => { + if (onFileClick) { + onFileClick(filePath); + } + }; + + // Attach the handler to window for use in HTML onclick attributes + if (typeof window !== 'undefined') { + ( + window as { handleFileClick?: (filePath: string) => void } + ).handleFileClick = handleFileClick; + } + + return ( +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/MessageContent.tsx b/packages/vscode-ide-companion/src/webview/components/MessageContent.tsx index 2e6fa37d..b25a504b 100644 --- a/packages/vscode-ide-companion/src/webview/components/MessageContent.tsx +++ b/packages/vscode-ide-companion/src/webview/components/MessageContent.tsx @@ -7,6 +7,7 @@ */ import type React from 'react'; +import { MarkdownRenderer } from './MarkdownRenderer.tsx'; interface MessageContentProps { content: string; @@ -14,270 +15,9 @@ interface MessageContentProps { } /** - * Regular expressions for parsing content - */ -const FILE_PATH_REGEX = - /([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi; -// Match file paths with optional line numbers like: path/file.ts#7-14 or path/file.ts#7 -const FILE_PATH_WITH_LINES_REGEX = - /([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; -const CODE_BLOCK_REGEX = /```(\w+)?\n([\s\S]*?)```/g; -const INLINE_CODE_REGEX = /`([^`]+)`/g; - -/** - * Parses message content and renders with syntax highlighting and clickable file paths + * MessageContent component - renders message content with markdown support */ export const MessageContent: React.FC = ({ content, onFileClick, -}) => { - /** - * Parse and render content with special handling for code blocks, inline code, and file paths - */ - const renderContent = () => { - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let matchIndex = 0; - - // First, handle code blocks - const codeBlockMatches = Array.from(content.matchAll(CODE_BLOCK_REGEX)); - - codeBlockMatches.forEach((match) => { - const [fullMatch, language, code] = match; - const startIndex = match.index!; - - // Add text before code block - if (startIndex > lastIndex) { - const textBefore = content.slice(lastIndex, startIndex); - parts.push(...renderTextWithInlineCodeAndPaths(textBefore, matchIndex)); - matchIndex++; - } - - // Add code block with Tailwind CSS - parts.push( -
-          
-            {code}
-          
-        
, - ); - matchIndex++; - - lastIndex = startIndex + fullMatch.length; - }); - - // Add remaining text - if (lastIndex < content.length) { - const remainingText = content.slice(lastIndex); - parts.push( - ...renderTextWithInlineCodeAndPaths(remainingText, matchIndex), - ); - } - - return parts.length > 0 ? parts : content; - }; - - /** - * Render text with inline code and file paths - */ - const renderTextWithInlineCodeAndPaths = ( - text: string, - startIndex: number, - ) => { - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let matchIndex = startIndex; - - // Split by inline code first - const inlineCodeMatches = Array.from(text.matchAll(INLINE_CODE_REGEX)); - - if (inlineCodeMatches.length === 0) { - // No inline code, just check for file paths - return renderTextWithFilePaths(text, matchIndex); - } - - inlineCodeMatches.forEach((match) => { - const [fullMatch, code] = match; - const startIdx = match.index!; - - // Add text before inline code (may contain file paths) - if (startIdx > lastIndex) { - parts.push( - ...renderTextWithFilePaths( - text.slice(lastIndex, startIdx), - matchIndex, - ), - ); - matchIndex++; - } - - // Add inline code with Tailwind CSS - parts.push( - - {code} - , - ); - matchIndex++; - - lastIndex = startIdx + fullMatch.length; - }); - - // Add remaining text - if (lastIndex < text.length) { - parts.push(...renderTextWithFilePaths(text.slice(lastIndex), matchIndex)); - } - - return parts.length > 0 ? parts : [text]; - }; - - /** - * Render text with file paths - */ - const renderTextWithFilePaths = (text: string, startIndex: number) => { - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let matchIndex = startIndex; - - // First, try to match file paths with line numbers - const filePathWithLinesMatches = Array.from( - text.matchAll(FILE_PATH_WITH_LINES_REGEX), - ); - const processedRanges: Array<{ start: number; end: number }> = []; - - filePathWithLinesMatches.forEach((match) => { - const fullMatch = match[0]; - const startIdx = match.index!; - const filePath = fullMatch.split('#')[0]; // Get path without line numbers - const startLine = match[4]; // Capture group 4 is the start line - const endLine = match[5]; // Capture group 5 is the end line (optional) - - processedRanges.push({ - start: startIdx, - end: startIdx + fullMatch.length, - }); - - // Add text before file path - if (startIdx > lastIndex) { - parts.push(text.slice(lastIndex, startIdx)); - } - - // Display text with line numbers - const displayText = endLine - ? `${filePath}#${startLine}-${endLine}` - : `${filePath}#${startLine}`; - - // Add file path link with line numbers - parts.push( - , - ); - - matchIndex++; - lastIndex = startIdx + fullMatch.length; - }); - - // Now match regular file paths (without line numbers) that weren't already matched - const filePathMatches = Array.from(text.matchAll(FILE_PATH_REGEX)); - - filePathMatches.forEach((match) => { - const fullMatch = match[0]; - const startIdx = match.index!; - - // Skip if this range was already processed as a path with line numbers - const isProcessed = processedRanges.some( - (range) => startIdx >= range.start && startIdx < range.end, - ); - if (isProcessed) { - return; - } - - // Add text before file path - if (startIdx > lastIndex) { - parts.push(text.slice(lastIndex, startIdx)); - } - - // Add file path link with Tailwind CSS - parts.push( - , - ); - - matchIndex++; - lastIndex = startIdx + fullMatch.length; - }); - - // Add remaining text - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - - return parts.length > 0 ? parts : [text]; - }; - - return <>{renderContent()}; -}; +}) => ;