mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.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 `<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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
|
||||
Reference in New Issue
Block a user