Skip to content
Closed
Show file tree
Hide file tree
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
57 changes: 55 additions & 2 deletions webview-ui/src/components/chat/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,55 @@
import { memo, useState } from "react"
import React, { memo, useState } from "react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"

import { useCopyToClipboard } from "@src/utils/clipboard"
import { StandardTooltip } from "@src/components/ui"

import MarkdownBlock from "../common/MarkdownBlock"
import { parseTable } from "../common/TableParser"

const splitMarkdownAndTables = (markdownText: string) => {
const segments: { type: 'text' | 'table'; content: string | React.ReactNode }[] = [];
const lines = markdownText.split(/\r?\n/);
let currentLineIndex = 0;
let currentTextBuffer: string[] = [];

while (currentLineIndex < lines.length) {
const line = lines[currentLineIndex];
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
const potentialTableLines: string[] = [];
let tempIndex = currentLineIndex;
potentialTableLines.push(lines[tempIndex]);
tempIndex++;
if (tempIndex < lines.length && lines[tempIndex].trim().match(/^\|(?:\s*[-:]+\s*\|)+\s*$/)) {
potentialTableLines.push(lines[tempIndex]);
tempIndex++;
while (tempIndex < lines.length && lines[tempIndex].trim().startsWith('|') && lines[tempIndex].trim().endsWith('|')) {
potentialTableLines.push(lines[tempIndex]);
tempIndex++;
}
const tableString = potentialTableLines.join('\n');
const parsedTableContent = parseTable(tableString, `chat-table-${Date.now()}-${segments.length}`);

if (parsedTableContent) {
if (currentTextBuffer.length > 0) {
segments.push({ type: 'text', content: currentTextBuffer.join('\n') });
currentTextBuffer = [];
}
segments.push({ type: 'table', content: parsedTableContent });
currentLineIndex = tempIndex;
continue;
}
}
}
currentTextBuffer.push(line);
currentLineIndex++;
}
if (currentTextBuffer.length > 0) {
segments.push({ type: 'text', content: currentTextBuffer.join('\n') });
}

return segments;
};

export const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => {
const [isHovering, setIsHovering] = useState(false)
Expand All @@ -16,13 +61,21 @@ export const Markdown = memo(({ markdown, partial }: { markdown?: string; partia
return null
}

const segments = splitMarkdownAndTables(markdown);

return (
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{ position: "relative" }}>
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere", marginBottom: -15, marginTop: -15 }}>
<MarkdownBlock markdown={markdown} />
{segments.map((segment, index) => {
if (segment.type === 'text') {
return <MarkdownBlock key={index} markdown={segment.content as string} />;
} else {
return <React.Fragment key={index}>{segment.content}</React.Fragment>;
}
})}
</div>
{markdown && !partial && isHovering && (
<div
Expand Down
310 changes: 310 additions & 0 deletions webview-ui/src/components/common/InlineParser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
import { ReactNode } from "react";

export const parseInlineMarkdown = (text: string, keyOffset: number): ReactNode | ReactNode[] => {
if (!/[[*_~`|]/.test(text)) {
return text;
}

const processedHtml = preserveHtmlTags(text);

let processed: ReactNode | ReactNode[] = processedHtml;

processed = processLinks(processed, keyOffset);
processed = processBold(processed, keyOffset);
processed = processItalic(processed, keyOffset);
processed = processStrikethrough(processed, keyOffset);
processed = processInlineCode(processed, keyOffset);
processed = processSpoiler(processed, keyOffset);

return processed;
};

const preserveHtmlTags = (text: string): string => {
return text.replace(/<([a-z][a-z0-9]*)\b[^>]*>(.*?)<\/\1>/gi, match => {
return match;
});
};

const processLinks = (
text: string | ReactNode | ReactNode[],
keyOffset: number
): ReactNode | ReactNode[] => {
if (typeof text !== "string") {
if (Array.isArray(text)) {
return text.map((item, index) =>
typeof item === "string" ? processLinks(item, keyOffset + index * 100) : item
);
}
return text;
}

const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
const result: ReactNode[] = [];
let match;
let matchIndex = 0;

while ((match = linkRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push(text.substring(lastIndex, match.index));
}

const linkText = match[1];
const linkUrl = match[2];

const processedLinkText = parseInlineMarkdown(linkText, keyOffset + 1000 + matchIndex);

result.push(
<a
key={`link-${keyOffset}-${matchIndex}`}
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded text-[--blue-11] hover:underline focus:outline-none focus:ring-2 focus:ring-[--blue-9] focus:ring-opacity-50"
>
{processedLinkText}
</a>
);

lastIndex = linkRegex.lastIndex;
matchIndex++;
}

if (lastIndex < text.length) {
result.push(text.substring(lastIndex));
}

return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
};

const processBold = (
text: string | ReactNode | ReactNode[],
keyOffset: number
): ReactNode | ReactNode[] => {
if (typeof text !== "string") {
if (Array.isArray(text)) {
return text.map((item, index) =>
typeof item === "string" ? processBold(item, keyOffset + index * 100) : item
);
}
return text;
}

const boldRegex = /\*\*(.*?)\*\*/g;
let lastIndex = 0;
const result: ReactNode[] = [];
let match;
let matchIndex = 0;

while ((match = boldRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push(text.substring(lastIndex, match.index));
}

const boldContent = match[1];

const processedBoldContent = parseInlineMarkdown(boldContent, keyOffset + 2000 + matchIndex);

result.push(<strong key={`bold-${keyOffset}-${matchIndex}`}>{processedBoldContent}</strong>);

lastIndex = boldRegex.lastIndex;
matchIndex++;
}

if (lastIndex < text.length) {
result.push(text.substring(lastIndex));
}


return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
};

const processItalic = (
text: string | ReactNode | ReactNode[],
keyOffset: number
): ReactNode | ReactNode[] => {
if (typeof text !== "string") {
if (Array.isArray(text)) {
return text.map((item, index) =>
typeof item === "string" ? processItalic(item, keyOffset + index * 100) : item
);
}
return text;
}

const italicRegex = /([*_])(.*?)\1/g;
let lastIndex = 0;
const result: ReactNode[] = [];
let match;
let matchIndex = 0;

while ((match = italicRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push(text.substring(lastIndex, match.index));
}

const italicContent = match[2];

const processedItalicContent = parseInlineMarkdown(
italicContent,
keyOffset + 4000 + matchIndex
);

result.push(<em key={`italic-${keyOffset}-${matchIndex}`}>{processedItalicContent}</em>);

lastIndex = italicRegex.lastIndex;
matchIndex++;
}

if (lastIndex < text.length) {
result.push(text.substring(lastIndex));
}

return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
};

const processStrikethrough = (
text: string | ReactNode | ReactNode[],
keyOffset: number
): ReactNode | ReactNode[] => {
if (typeof text !== "string") {
if (Array.isArray(text)) {
return text.map((item, index) =>
typeof item === "string" ? processStrikethrough(item, keyOffset + index * 100) : item
);
}
return text;
}

const strikeRegex = /~~(.*?)~~/g;
let lastIndex = 0;
const result: ReactNode[] = [];
let match;
let matchIndex = 0;

while ((match = strikeRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push(text.substring(lastIndex, match.index));
}

const strikeContent = match[1];

const processedStrikeContent = parseInlineMarkdown(
strikeContent,
keyOffset + 5000 + matchIndex
);

result.push(
<del key={`strike-${keyOffset}-${matchIndex}`} className="line-through">
{processedStrikeContent}
</del>
);

lastIndex = strikeRegex.lastIndex;
matchIndex++;
}

if (lastIndex < text.length) {
result.push(text.substring(lastIndex));
}

return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
};

const processInlineCode = (
text: string | ReactNode | ReactNode[],
keyOffset: number
): ReactNode | ReactNode[] => {
if (typeof text !== "string") {
if (Array.isArray(text)) {
return text.map((item, index) =>
typeof item === "string" ? processInlineCode(item, keyOffset + index * 100) : item
);
}
return text;
}

const codeRegex = /`([^`]+)`/g;
let lastIndex = 0;
const result: ReactNode[] = [];
let match;
let matchIndex = 0;

while ((match = codeRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push(text.substring(lastIndex, match.index));
}

const codeContent = match[1];

result.push(
<code
key={`code-${keyOffset}-${matchIndex}`}
className="rounded border border-[--gray-3] bg-[--gray-2] px-1.5 py-0.5 font-mono text-sm text-[--gray-12]"
>
{codeContent}
</code>
);

lastIndex = codeRegex.lastIndex;
matchIndex++;
}

if (lastIndex < text.length) {
result.push(text.substring(lastIndex));
}

return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
};

const processSpoiler = (
text: string | ReactNode | ReactNode[],
keyOffset: number
): ReactNode | ReactNode[] => {
if (typeof text !== "string") {
if (Array.isArray(text)) {
return text.map((item, index) =>
typeof item === "string" ? processSpoiler(item, keyOffset + index * 100) : item
);
}
return text;
}

const spoilerRegex = /\|\|(.*?)\|\|/g;
let lastIndex = 0;
const result: ReactNode[] = [];
let match;
let matchIndex = 0;

while ((match = spoilerRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push(text.substring(lastIndex, match.index));
}

const spoilerContent = match[1];

const processedSpoilerContent = parseInlineMarkdown(
spoilerContent,
keyOffset + 6000 + matchIndex
);

result.push(
<span
key={`spoiler-${keyOffset}-${matchIndex}`}
className="cursor-pointer rounded bg-[--gray-3] px-1 text-[--gray-3] transition-colors hover:bg-transparent hover:text-[--gray-12]"
title="Click to reveal spoiler"
>
{processedSpoilerContent}
</span>
);

lastIndex = spoilerRegex.lastIndex;
matchIndex++;
}

if (lastIndex < text.length) {
result.push(text.substring(lastIndex));
}

return result.length === 1 && typeof result[0] === "string" ? result[0] : result;
};
Loading