mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +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 type React from 'react';
|
||||||
|
import { MarkdownRenderer } from './MarkdownRenderer.tsx';
|
||||||
|
|
||||||
interface MessageContentProps {
|
interface MessageContentProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -14,270 +15,9 @@ interface MessageContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regular expressions for parsing content
|
* MessageContent component - renders message content with markdown support
|
||||||
*/
|
|
||||||
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
|
|
||||||
*/
|
*/
|
||||||
export const MessageContent: React.FC<MessageContentProps> = ({
|
export const MessageContent: React.FC<MessageContentProps> = ({
|
||||||
content,
|
content,
|
||||||
onFileClick,
|
onFileClick,
|
||||||
}) => {
|
}) => <MarkdownRenderer content={content} onFileClick={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()}</>;
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user