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.
This commit is contained in:
yiliang114
2025-12-04 08:28:42 +08:00
parent 7270983821
commit 86cd06ef43
3 changed files with 349 additions and 263 deletions

View File

@@ -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;
}

View File

@@ -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<MarkdownRendererProps> = ({
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 `<pre class="code-block language-${lang}"><code class="language-${lang}">${md.utils.escapeHtml(content)}</code></pre>`;
};
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 `<pre class="code-block language-${lang}"><code class="language-${lang}">${md.utils.escapeHtml(content)}</code></pre>`;
};
});
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
/**
* 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 `<button class="file-path-link" onclick="window.handleFileClick('${filePath}')" title="Open ${match}">${match}</button>`;
});
// 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 `<button class="file-path-link" onclick="window.handleFileClick('${match}')" title="Open ${match}">${match}</button>`;
});
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 (
<div
className="markdown-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown() }}
style={{
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
}}
/>
);
};

View File

@@ -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<MessageContentProps> = ({
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(
<pre
key={`code-${matchIndex}`}
className="my-2 overflow-x-auto rounded p-3 leading-[1.5]"
style={{
backgroundColor: 'var(--app-code-background, rgba(0, 0, 0, 0.05))',
border: '1px solid var(--app-primary-border-color)',
borderRadius: 'var(--corner-radius-small, 4px)',
fontFamily:
"var(--app-monospace-font-family, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace)",
fontSize: '13px',
}}
>
<code
className={`language-${language || 'plaintext'}`}
style={{
background: 'none',
padding: 0,
fontFamily: 'inherit',
color: 'var(--app-primary-foreground)',
}}
>
{code}
</code>
</pre>,
);
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
key={`inline-${matchIndex}`}
className="rounded px-1.5 py-0.5 whitespace-nowrap text-[0.9em] inline-block max-w-full overflow-hidden text-ellipsis align-baseline"
style={{
backgroundColor: 'var(--app-code-background, rgba(0, 0, 0, 0.05))',
border: '1px solid var(--app-primary-border-color)',
fontFamily:
"var(--app-monospace-font-family, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace)",
color: 'var(--app-primary-foreground)',
}}
>
{code}
</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(
<button
key={`path-${matchIndex}`}
className="bg-transparent border-0 p-0 underline cursor-pointer transition-colors text-[0.95em]"
style={{
fontFamily:
"var(--app-monospace-font-family, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace)",
color: 'var(--app-link-foreground, #007ACC)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color =
'var(--app-link-active-foreground, #005A9E)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--app-link-foreground, #007ACC)';
}}
onClick={() => onFileClick?.(filePath)}
title={`Open ${displayText}`}
>
{displayText}
</button>,
);
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(
<button
key={`path-${matchIndex}`}
className="bg-transparent border-0 p-0 underline cursor-pointer transition-colors text-[0.95em]"
style={{
fontFamily:
"var(--app-monospace-font-family, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace)",
color: 'var(--app-link-foreground, #007ACC)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color =
'var(--app-link-active-foreground, #005A9E)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--app-link-foreground, #007ACC)';
}}
onClick={() => onFileClick?.(fullMatch)}
title={`Open ${fullMatch}`}
>
{fullMatch}
</button>,
);
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()}</>;
};
}) => <MarkdownRenderer content={content} onFileClick={onFileClick} />;