diff --git a/webview-ui/src/components/chat/Markdown.tsx b/webview-ui/src/components/chat/Markdown.tsx index ba838284d7d..99594016838 100644 --- a/webview-ui/src/components/chat/Markdown.tsx +++ b/webview-ui/src/components/chat/Markdown.tsx @@ -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) @@ -16,13 +61,21 @@ export const Markdown = memo(({ markdown, partial }: { markdown?: string; partia return null } + const segments = splitMarkdownAndTables(markdown); + return (
setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} style={{ position: "relative" }}>
- + {segments.map((segment, index) => { + if (segment.type === 'text') { + return ; + } else { + return {segment.content}; + } + })}
{markdown && !partial && isHovering && (
{ + 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( + + {processedLinkText} + + ); + + 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({processedBoldContent}); + + 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({processedItalicContent}); + + 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( + + {processedStrikeContent} + + ); + + 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( + + {codeContent} + + ); + + 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( + + {processedSpoilerContent} + + ); + + lastIndex = spoilerRegex.lastIndex; + matchIndex++; + } + + if (lastIndex < text.length) { + result.push(text.substring(lastIndex)); + } + + return result.length === 1 && typeof result[0] === "string" ? result[0] : result; +}; \ No newline at end of file diff --git a/webview-ui/src/components/common/TableParser.tsx b/webview-ui/src/components/common/TableParser.tsx new file mode 100644 index 00000000000..c67e2f3a0f4 --- /dev/null +++ b/webview-ui/src/components/common/TableParser.tsx @@ -0,0 +1,123 @@ +import { parseInlineMarkdown } from "./InlineParser"; + +const parseTableHeaderCells = (headerRow: string) => { + return headerRow + .split("|") + .slice(1, -1) + .map(cell => cell.trim().replace(/^:[-]+:$|^:[-]+|[-]+:$/g, '').trim()); +}; + +const parseTableDataRows = (dataRows: string[], maxRows: number = 50) => { + const rows = []; + try { + for (let i = 0; i < dataRows.length; i++) { + const cells = dataRows[i] + .split("|") + .slice(1, -1) + .map(cell => cell.trim()); + + if (cells.length > 0) { + rows.push(cells); + } + + if (rows.length >= maxRows) { + break; + } + } + } catch (_error) { + return []; + } + return rows; +}; + +export const renderTableHeader = (headerCells: string[], keyPrefix: string) => { + return ( + + + {headerCells.map((cell, idx) => ( + + {parseInlineMarkdown(cell, idx)} + + ))} + + + ); +}; + +export const renderTableBody = (rows: string[][], keyPrefix: string) => { + return ( + + {rows.map((row, rowIdx) => ( + + {row.map((cell, cellIdx) => ( + + {parseInlineMarkdown(cell, cellIdx + rowIdx * 100)} + + ))} + + ))} + + ); +}; + +const isValidTable = (separatorRow: string, headerCells: string[], rows: string[][]) => { + if (!separatorRow || !separatorRow.includes("|") || !separatorRow.match(/[-:]/)) { + return false; + } + + if (headerCells.length === 0 || rows.some(row => row.length === 0)) { + return false; + } + + return true; +}; + +export const parseTable = (tableText: string, keyPrefix: string) => { + try { + const lines = tableText.trim().split("\n").filter(line => line.trim() !== ''); + + const tableStartIndex = lines.findIndex(line => line.trim().startsWith('|')); + if (tableStartIndex === -1) { + return null; + } + + const tableLines = lines.slice(tableStartIndex); + + if (tableLines.length < 2) { + return null; + } + + const headerRow = tableLines[0]; + const separatorRow = tableLines[1]; + const dataRows = tableLines.slice(2).filter(line => { + return !line.trim().match(/^[\s|:-]+$/); + }); + + const headerCells = parseTableHeaderCells(headerRow); + const rows = parseTableDataRows(dataRows); + + if (!isValidTable(separatorRow, headerCells, rows)) { + return null; + } + + return ( +
+ + {renderTableHeader(headerCells, keyPrefix)} + {renderTableBody(rows, keyPrefix)} +
+
+ ); + } catch (_error) { + return null; + } +}; diff --git a/webview-ui/src/components/common/__tests__/MarkdownTableIntegration.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownTableIntegration.spec.tsx new file mode 100644 index 00000000000..3027b80c9b7 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/MarkdownTableIntegration.spec.tsx @@ -0,0 +1,140 @@ +import { render } from "@testing-library/react"; +import { Markdown } from "../../chat/Markdown"; +import { vi } from "vitest"; + +vi.mock("../MarkdownBlock", () => ({ + __esModule: true, + default: vi.fn(({ markdown }) => ( +
{markdown}
+ )), +})); + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + theme: "dark", + }), +})); + +describe("Markdown Table Integration", () => { + it("renders table in markdown", () => { + const md = `Text before +| Header | +|--------| +| Cell | +Text after`; + const { container } = render(); + expect(container.querySelector("table")).toBeInTheDocument(); + expect(container.querySelector("th")?.textContent).toBe("Header"); + expect(container.querySelector("td")?.textContent).toBe("Cell"); + const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]'); + expect(markdownBlocks.length).toBe(2); + expect(markdownBlocks[0]?.textContent).toContain("Text before"); + expect(markdownBlocks[1]?.textContent).toContain("Text after"); + }); + + it("handles multiple tables", () => { + const md = `| Table 1 |\n|---------|\n| Cell |\n\n| Table 2 |\n|---------|\n| Cell |`; + const { container } = render(); + expect(container.querySelectorAll("table").length).toBe(2); + expect(container.querySelectorAll("th")[0]?.textContent).toBe("Table 1"); + expect(container.querySelectorAll("th")[1]?.textContent).toBe("Table 2"); + }); + + it("handles markdown text interspersed with tables", () => { + const md = `This is some **bold** text. + +| Header A | Header B | +|----------|----------| +| Value A1 | Value B1 | + +And here is some *italic* text after the table. + +| Header C | +|----------| +| Value C1 |`; + const { container } = render(); + expect(container.querySelectorAll("table").length).toBe(2); + const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]'); + expect(markdownBlocks.length).toBe(2); + expect(markdownBlocks[0]?.textContent).toContain("This is some **bold** text."); + expect(markdownBlocks[1]?.textContent).toContain("And here is some *italic* text after the table."); + }); + + it("handles tables at the beginning of the markdown", () => { + const md = `| Start Header | +|--------------| +| Start Cell | +Text after table.`; + const { container } = render(); + expect(container.querySelector("table")).toBeInTheDocument(); + expect(container.querySelector("th")?.textContent).toBe("Start Header"); + const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]'); + expect(markdownBlocks.length).toBe(1); + expect(markdownBlocks[0]?.textContent).toContain("Text after table."); + }); + + it("handles tables at the end of the markdown", () => { + const md = `Text before table. +| End Header | +|------------| +| End Cell |`; + const { container } = render(); + expect(container.querySelector("table")).toBeInTheDocument(); + expect(container.querySelector("th")?.textContent).toBe("End Header"); + const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]'); + expect(markdownBlocks.length).toBe(1); + expect(markdownBlocks[0]?.textContent).toContain("Text before table."); + }); + + it("handles markdown with no tables", () => { + const md = `Just some plain **markdown** text.`; + const { container } = render(); + expect(container.querySelector("table")).not.toBeInTheDocument(); + const markdownBlocks = container.querySelectorAll('[data-testid="mock-markdown-block"]'); + expect(markdownBlocks.length).toBe(1); + expect(markdownBlocks[0]?.textContent).toContain("Just some plain **markdown** text."); + }); + + it("handles empty markdown string", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("handles markdown with only a table", () => { + const md = `| Only Table | +|------------| +| Cell |`; + const { container } = render(); + expect(container.querySelector("table")).toBeInTheDocument(); + expect(container.querySelector("th")?.textContent).toBe("Only Table"); + }); + + it("handles bold markdown in table cells", () => { + const md = `| Header |\n|--------|\n| Cell with **bold** text |`; + const { container } = render(); + const cellContent = container.querySelector("td"); + expect(cellContent?.innerHTML).toContain("bold"); + }); + + it("handles italic markdown in table cells", () => { + const md = `| Header |\n|--------|\n| Cell with _italic_ text |`; + const { container } = render(); + const cellContent = container.querySelector("td"); + expect(cellContent?.innerHTML).toContain("italic"); + }); + + it("handles code markdown in table cells", () => { + const md = `| Header |\n|--------|\n| Cell with \`code\` snippet |`; + const { container } = render(); + const cellContent = container.querySelector("td"); + expect(cellContent?.querySelector("code")?.textContent).toContain("code"); + }); + + it("handles link markdown in table cells", () => { + const md = `| Header |\n|--------|\n| Cell with a [link](http://example.com) |`; + const { container } = render(); + const cellContent = container.querySelector("td"); + expect(cellContent?.innerHTML).toContain(""); + }); +}); \ No newline at end of file diff --git a/webview-ui/src/components/common/__tests__/TableParser.spec.tsx b/webview-ui/src/components/common/__tests__/TableParser.spec.tsx new file mode 100644 index 00000000000..f6e2ceab159 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/TableParser.spec.tsx @@ -0,0 +1,120 @@ +import { render } from "@testing-library/react"; +import { parseTable } from "../TableParser"; +import { parseInlineMarkdown } from "../InlineParser"; + +describe("TableParser", () => { + it("parses simple table", () => { + const table = `| Header 1 | Header 2 | +|----------|----------| +| Cell 1 | Cell 2 |`; + const { container } = render(parseTable(table, "test-table")!); + expect(container.querySelector("table")).toBeInTheDocument(); + expect(container.querySelectorAll("th").length).toBe(2); + expect(container.querySelector("th")?.textContent).toBe("Header 1"); + expect(container.querySelectorAll("td").length).toBe(2); + expect(container.querySelector("td")?.textContent).toBe("Cell 1"); + }); + + it("handles invalid tables", () => { + expect(parseTable("invalid content", "test")).toBeNull(); + expect(parseTable("| Header |\n", "test")).toBeNull(); + expect(parseTable("| Header |\n|---|", "test")).not.toBeNull(); + }); + + + it("handles markdown in cells", () => { + const table = `| Header | +|--------| +| **Bold** and _italic_ |`; + const { container } = render(parseTable(table, "markdown-table")!); + expect(container.querySelector("strong")).toBeInTheDocument(); + expect(container.querySelector("em")).toBeInTheDocument(); + expect(container.querySelector("strong")?.textContent).toBe("Bold"); + expect(container.querySelector("em")?.textContent).toBe("italic"); + }); + + it("handles empty cells", () => { + const table = `| H1 | H2 | +|----|----| +| C1 | |`; + const { container } = render(parseTable(table, "empty-cell-table")!); + expect(container.querySelectorAll("td")[1].textContent).toBe(""); + }); + + it("handles tables with extra spaces", () => { + const table = `| Header 1 | Header 2 | +|------------|------------| +| Cell 1 | Cell 2 |`; + const { container } = render(parseTable(table, "spaced-table")!); + expect(container.querySelector("th")?.textContent).toBe("Header 1"); + expect(container.querySelector("td")?.textContent).toBe("Cell 1"); + }); + + it("handles tables with alignment markers", () => { + const table = `| Left | Center | Right | +|:-----|:------:|------:| +| L | C | R |`; + const { container } = render(parseTable(table, "alignment-table")!); + expect(container.querySelectorAll("th").length).toBe(3); + expect(container.querySelectorAll("td").length).toBe(3); + }); + + it("returns null for tables with too few lines", () => { + expect(parseTable("| Header |", "short-table")).toBeNull(); + expect(parseTable("", "empty-table")).toBeNull(); + }); + + it("handles multiple data rows", () => { + const table = `| H1 | H2 | +|----|----| +| R1C1 | R1C2 | +| R2C1 | R2C2 |`; + const { container } = render(parseTable(table, "multi-row-table")!); + expect(container.querySelectorAll("tr").length).toBe(3); // 1 header + 2 data rows + expect(container.querySelectorAll("td").length).toBe(4); + }); + + it("handles tables with no data rows", () => { + const table = `| H1 | H2 | +|----|----|`; + const { container } = render(parseTable(table, "no-data-table")!); + expect(container.querySelectorAll("tr").length).toBe(1); // Only header row + expect(container.querySelectorAll("td").length).toBe(0); + }); + + it("parses bold markdown", () => { + const text = "Cell with **bold** text."; + const result = parseInlineMarkdown(text, 0); + const container = render(
{result}
).container; + expect(container.innerHTML).toContain("bold"); + }); + + it("parses italic markdown", () => { + const text = "Cell with _italic_ text."; + const result = parseInlineMarkdown(text, 0); + const container = render(
{result}
).container; + expect(container.innerHTML).toContain("italic"); + }); + + it("parses italic markdown with single asterisks", () => { + const text = "Cell with *italic* text."; + const result = parseInlineMarkdown(text, 0); + const container = render(
{result}
).container; + expect(container.innerHTML).toContain("italic"); + }); + + it("parses code markdown", () => { + const text = "Cell with `code` snippet."; + const result = parseInlineMarkdown(text, 0); + const container = render(
{result}
).container; + expect(container.querySelector("code")?.textContent).toContain("code"); + }); + + it("parses link markdown", () => { + const text = "Cell with a [link](http://example.com)."; + const result = parseInlineMarkdown(text, 0); + const container = render(
{result}
).container; + expect(container.innerHTML).toContain("
"); + }); +}); \ No newline at end of file