diff --git a/webview-ui/jest.config.cjs b/webview-ui/jest.config.cjs index 2d683ebab3e..dffd5737a58 100644 --- a/webview-ui/jest.config.cjs +++ b/webview-ui/jest.config.cjs @@ -13,13 +13,16 @@ module.exports = { "^@vscode/webview-ui-toolkit/react$": "/src/__mocks__/@vscode/webview-ui-toolkit/react.ts", "^@/(.*)$": "/src/$1", '^@roo/(.*)$': '/../src/$1', - '^@src/(.*)$': '/src/$1', + '^@src/(.*)$': '/src/$1', "^src/i18n/setup$": "/src/__mocks__/i18n/setup.ts", "^\\.\\./setup$": "/src/__mocks__/i18n/setup.ts", "^\\./setup$": "/src/__mocks__/i18n/setup.ts", "^src/i18n/TranslationContext$": "/src/__mocks__/i18n/TranslationContext.tsx", "^\\.\\./TranslationContext$": "/src/__mocks__/i18n/TranslationContext.tsx", - "^\\./TranslationContext$": "/src/__mocks__/i18n/TranslationContext.tsx" + "^\\./TranslationContext$": "/src/__mocks__/i18n/TranslationContext.tsx", + "^unist-util-visit$": "/src/__mocks__/unist-util-visit.ts", + "^shiki$": "/src/__mocks__/shiki.ts", + "^mermaid$": "/src/__mocks__/mermaid.ts" }, reporters: [["jest-simple-dot-reporter", {}]], transformIgnorePatterns: [ diff --git a/webview-ui/src/__mocks__/mermaid.ts b/webview-ui/src/__mocks__/mermaid.ts new file mode 100644 index 00000000000..ec96adb5ac4 --- /dev/null +++ b/webview-ui/src/__mocks__/mermaid.ts @@ -0,0 +1,28 @@ +const mermaid = { + initialize: jest.fn(), + render: jest.fn().mockResolvedValue({ svg: "" }), + parse: jest.fn(), + parseDirective: jest.fn(), + registerExternalDiagrams: jest.fn(), + contentLoaded: jest.fn(), + setParseErrorHandler: jest.fn(), + getDiagramFromText: jest.fn(), + getConfig: jest.fn().mockReturnValue({}), + setConfig: jest.fn(), + getSiteConfig: jest.fn(), + updateSiteConfig: jest.fn(), + reset: jest.fn(), + startOnLoad: true, + mermaidAPI: { + render: jest.fn().mockResolvedValue({ svg: "" }), + parse: jest.fn(), + initialize: jest.fn(), + getConfig: jest.fn().mockReturnValue({}), + setConfig: jest.fn(), + getSiteConfig: jest.fn(), + updateSiteConfig: jest.fn(), + reset: jest.fn(), + }, +} + +export default mermaid diff --git a/webview-ui/src/__mocks__/shiki.ts b/webview-ui/src/__mocks__/shiki.ts new file mode 100644 index 00000000000..0b127bbd7ca --- /dev/null +++ b/webview-ui/src/__mocks__/shiki.ts @@ -0,0 +1,22 @@ +export const bundledLanguages = { + javascript: {}, + typescript: {}, + html: {}, + css: {}, + json: {}, + markdown: {}, +} + +export type ShikiTransformer = { + name: string + transform: (hast: any, options: any) => any +} + +export const codeToHast = jest.fn() +export const codeToHtml = jest.fn() +export const codeToTokens = jest.fn() +export const codeToTokensBase = jest.fn() +export const codeToTokensWithThemes = jest.fn() +export const createHighlighter = jest.fn() +export const getLastGrammarState = jest.fn() +export const getSingletonHighlighter = jest.fn() diff --git a/webview-ui/src/__mocks__/unist-util-visit.ts b/webview-ui/src/__mocks__/unist-util-visit.ts new file mode 100644 index 00000000000..0a58a43914b --- /dev/null +++ b/webview-ui/src/__mocks__/unist-util-visit.ts @@ -0,0 +1,5 @@ +export const visit = jest.fn((tree, _nodeType, _visitor) => tree) + +export const CONTINUE = Symbol("continue") +export const EXIT = Symbol("exit") +export const SKIP = Symbol("skip") diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0b39f62edcc..3ffb2a5a97c 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -8,10 +8,11 @@ import { ClineMessage } from "@roo/shared/ExtensionMessage" import { getMaxTokensForModel } from "@src/utils/model-utils" import { formatLargeNumber } from "@src/utils/format" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import { cn } from "@src/lib/utils" import { Button } from "@src/components/ui" -import { useExtensionState } from "@src/context/ExtensionStateContext" -import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" +import { MarkdownBlock } from "@src/components/common/MarkdownBlock" import Thumbnails from "../common/Thumbnails" @@ -115,7 +116,7 @@ const TaskHeader = ({ WebkitLineClamp: "unset", WebkitBoxOrient: "vertical", }}> - + {task.images && task.images.length > 0 && } diff --git a/webview-ui/src/components/common/CodeBlock.styles.ts b/webview-ui/src/components/common/CodeBlock.styles.ts new file mode 100644 index 00000000000..b208fa2a32e --- /dev/null +++ b/webview-ui/src/components/common/CodeBlock.styles.ts @@ -0,0 +1,178 @@ +import styled from "styled-components" + +export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))" + +const WRAPPER_ALPHA = "cc" + +export const ButtonIcon = styled.span` + display: inline-block; + width: 1.2em; + text-align: center; + vertical-align: middle; +` + +export const CodeBlockButton = styled.button` + background: transparent; + border: none; + color: var(--vscode-foreground); + cursor: var(--copy-button-cursor, default); + padding: 4px; + margin: 0 0px; + display: flex; + align-items: center; + opacity: 0.4; + border-radius: 3px; + pointer-events: var(--copy-button-events, none); + margin-left: 4px; + height: 24px; + + &:hover { + background: var(--vscode-toolbar-hoverBackground); + opacity: 1; + } +` + +export const CodeBlockButtonWrapper = styled.div` + position: fixed; + top: var(--copy-button-top); + right: var(--copy-button-right, 8px); + height: auto; + z-index: 100; + background: ${CODE_BLOCK_BG_COLOR}${WRAPPER_ALPHA}; + overflow: visible; + pointer-events: none; + opacity: var(--copy-button-opacity, 0); + padding: 4px 6px; + border-radius: 3px; + display: inline-flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--vscode-editor-background); + opacity: 1 !important; + } + + ${CodeBlockButton} { + position: relative; + top: 0; + right: 0; + } +` + +export const CodeBlockContainer = styled.div` + position: relative; + overflow: hidden; + border-bottom: 4px solid var(--vscode-sideBar-background); + background-color: ${CODE_BLOCK_BG_COLOR}; + + ${CodeBlockButtonWrapper} { + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; /* Keep opacity transition for buttons */ + } + + &[data-partially-visible="true"]:hover ${CodeBlockButtonWrapper} { + opacity: 1; + pointer-events: all; + cursor: pointer; + } +` + +export const StyledPre = styled.div<{ + preStyle?: React.CSSProperties + wordwrap?: "true" | "false" | undefined + windowshade?: "true" | "false" + collapsedHeight?: number +}>` + background-color: ${CODE_BLOCK_BG_COLOR}; + max-height: ${({ windowshade, collapsedHeight = 500 }) => + windowshade === "true" ? `${collapsedHeight}px` : "none"}; + overflow-y: auto; + padding: 10px; + border-radius: 5px; + ${({ preStyle }) => preStyle && { ...preStyle }} + + pre { + background-color: ${CODE_BLOCK_BG_COLOR}; + border-radius: 5px; + margin: 0; + padding: 10px; + width: 100%; + box-sizing: border-box; + } + + pre, + code { + /* Undefined wordwrap defaults to true (pre-wrap) behavior */ + white-space: ${({ wordwrap }) => (wordwrap === "false" ? "pre" : "pre-wrap")}; + word-break: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "normal")}; + overflow-wrap: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "break-word")}; + font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px)); + font-family: var(--vscode-editor-font-family); + } + + pre > code { + .hljs-deletion { + background-color: var(--vscode-diffEditor-removedTextBackground); + display: inline-block; + width: 100%; + } + .hljs-addition { + background-color: var(--vscode-diffEditor-insertedTextBackground); + display: inline-block; + width: 100%; + } + } + + .hljs { + color: var(--vscode-editor-foreground, #fff); + background-color: ${CODE_BLOCK_BG_COLOR}; + } +` + +export const LanguageSelect = styled.select` + font-size: 12px; + color: var(--vscode-foreground); + opacity: 0.4; + font-family: monospace; + appearance: none; + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + margin: 0; + vertical-align: middle; + height: 24px; + + & option { + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + padding: 0; + margin: 0; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background); + } + + &::-webkit-scrollbar-track { + background: var(--vscode-editor-background); + } + + &:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground); + border-radius: 3px; + } + + &:focus { + opacity: 1; + outline: none; + border-radius: 3px; + } +` diff --git a/webview-ui/src/components/common/CodeBlock.tsx b/webview-ui/src/components/common/CodeBlock.tsx index 4752ccc95e8..67a99237a4a 100644 --- a/webview-ui/src/components/common/CodeBlock.tsx +++ b/webview-ui/src/components/common/CodeBlock.tsx @@ -1,27 +1,39 @@ import { memo, useEffect, useRef, useCallback, useState } from "react" -import styled from "styled-components" +import { type ShikiTransformer, bundledLanguages } from "shiki" + import { useCopyToClipboard } from "@src/utils/clipboard" import { getHighlighter, isLanguageLoaded, normalizeLanguage, ExtendedLanguage } from "@src/utils/highlighter" -import { bundledLanguages } from "shiki" -import type { ShikiTransformer } from "shiki" -export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))" -export const WRAPPER_ALPHA = "cc" // 80% opacity -// Configuration constants -export const WINDOW_SHADE_SETTINGS = { - transitionDelayS: 0.2, - collapsedHeight: 500, // Default collapsed height in pixels -} - -// Tolerance in pixels for determining when a container is considered "at the bottom" -export const SCROLL_SNAP_TOLERANCE = 20 -/* -overflowX: auto + inner div with padding results in an issue where the top/left/bottom padding renders but the right padding inside does not count as overflow as the width of the element is not exceeded. Once the inner div is outside the boundaries of the parent it counts as overflow. -https://stackoverflow.com/questions/60778406/why-is-padding-right-clipped-with-overflowscroll/77292459#77292459 -this fixes the issue of right padding clipped off -“ideal” size in a given axis when given infinite available space--allows the syntax highlighter to grow to largest possible width including its padding -minWidth: "max-content", -*/ +import { + CodeBlockContainer, + StyledPre, + LanguageSelect, + CodeBlockButtonWrapper, + CodeBlockButton, + ButtonIcon, +} from "./CodeBlock.styles" + +export { CODE_BLOCK_BG_COLOR } from "./CodeBlock.styles" + +const WINDOW_SHADE_SETTINGS = { transitionDelayS: 0.2, collapsedHeight: 500 } + +// Tolerance in pixels for determining when a container is considered "at the bottom". +const SCROLL_SNAP_TOLERANCE = 20 + +/** + * CSS Overflow Handling: + * When using overflowX: auto with an inner div that has padding, an issue occurs where + * the top, left, and bottom padding render correctly, but the right padding gets clipped. + * This happens because the right padding doesn't count as overflow until the inner div's + * content exceeds the parent's width boundaries. + * + * Solution: + * Setting minWidth: "max-content" fixes this issue by allowing the syntax highlighter + * to grow to its largest possible width (including padding) when given infinite available space. + * This ensures the right padding is properly displayed and not clipped off. + * + * Reference: https://stackoverflow.com/questions/60778406/why-is-padding-right-clipped-with-overflowscroll/77292459#77292459 + */ interface CodeBlockProps { source?: string @@ -34,181 +46,7 @@ interface CodeBlockProps { onLanguageChange?: (language: string) => void } -const ButtonIcon = styled.span` - display: inline-block; - width: 1.2em; - text-align: center; - vertical-align: middle; -` - -const CodeBlockButton = styled.button` - background: transparent; - border: none; - color: var(--vscode-foreground); - cursor: var(--copy-button-cursor, default); - padding: 4px; - margin: 0 0px; - display: flex; - align-items: center; - opacity: 0.4; - border-radius: 3px; - pointer-events: var(--copy-button-events, none); - margin-left: 4px; - height: 24px; - - &:hover { - background: var(--vscode-toolbar-hoverBackground); - opacity: 1; - } -` - -const CodeBlockButtonWrapper = styled.div` - position: fixed; - top: var(--copy-button-top); - right: var(--copy-button-right, 8px); - height: auto; - z-index: 100; - background: ${CODE_BLOCK_BG_COLOR}${WRAPPER_ALPHA}; - overflow: visible; - pointer-events: none; - opacity: var(--copy-button-opacity, 0); - padding: 4px 6px; - border-radius: 3px; - display: inline-flex; - align-items: center; - justify-content: center; - - &:hover { - background: var(--vscode-editor-background); - opacity: 1 !important; - } - - ${CodeBlockButton} { - position: relative; - top: 0; - right: 0; - } -` - -const CodeBlockContainer = styled.div` - position: relative; - overflow: hidden; - border-bottom: 4px solid var(--vscode-sideBar-background); - background-color: ${CODE_BLOCK_BG_COLOR}; - - ${CodeBlockButtonWrapper} { - opacity: 0; - pointer-events: none; - transition: opacity 0.2s; /* Keep opacity transition for buttons */ - } - - &[data-partially-visible="true"]:hover ${CodeBlockButtonWrapper} { - opacity: 1; - pointer-events: all; - cursor: pointer; - } -` - -export const StyledPre = styled.div<{ - preStyle?: React.CSSProperties - wordwrap?: "true" | "false" | undefined - windowshade?: "true" | "false" - collapsedHeight?: number -}>` - background-color: ${CODE_BLOCK_BG_COLOR}; - max-height: ${({ windowshade, collapsedHeight }) => - windowshade === "true" ? `${collapsedHeight || WINDOW_SHADE_SETTINGS.collapsedHeight}px` : "none"}; - overflow-y: auto; - padding: 10px; - // transition: max-height ${WINDOW_SHADE_SETTINGS.transitionDelayS} ease-out; - border-radius: 5px; - ${({ preStyle }) => preStyle && { ...preStyle }} - - pre { - background-color: ${CODE_BLOCK_BG_COLOR}; - border-radius: 5px; - margin: 0; - padding: 10px; - width: 100%; - box-sizing: border-box; - } - - pre, - code { - /* Undefined wordwrap defaults to true (pre-wrap) behavior */ - white-space: ${({ wordwrap }) => (wordwrap === "false" ? "pre" : "pre-wrap")}; - word-break: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "normal")}; - overflow-wrap: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "break-word")}; - font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px)); - font-family: var(--vscode-editor-font-family); - } - - pre > code { - .hljs-deletion { - background-color: var(--vscode-diffEditor-removedTextBackground); - display: inline-block; - width: 100%; - } - .hljs-addition { - background-color: var(--vscode-diffEditor-insertedTextBackground); - display: inline-block; - width: 100%; - } - } - - .hljs { - color: var(--vscode-editor-foreground, #fff); - background-color: ${CODE_BLOCK_BG_COLOR}; - } -` - -const LanguageSelect = styled.select` - font-size: 12px; - color: var(--vscode-foreground); - opacity: 0.4; - font-family: monospace; - appearance: none; - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - margin: 0; - vertical-align: middle; - height: 24px; - - & option { - background: var(--vscode-editor-background); - color: var(--vscode-foreground); - padding: 0; - margin: 0; - } - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-thumb { - background: var(--vscode-scrollbarSlider-background); - } - - &::-webkit-scrollbar-track { - background: var(--vscode-editor-background); - } - - &:hover { - opacity: 1; - background: var(--vscode-toolbar-hoverBackground); - border-radius: 3px; - } - - &:focus { - opacity: 1; - outline: none; - border-radius: 3px; - } -` - -const CodeBlock = memo( +export const CodeBlock = memo( ({ source, rawSource, @@ -233,6 +71,7 @@ const CodeBlock = memo( // Update current language when prop changes, but only if user hasn't made a selection useEffect(() => { const normalizedLang = normalizeLanguage(language) + if (normalizedLang !== currentLanguage && !userChangedLanguageRef.current) { setCurrentLanguage(normalizedLang) } @@ -241,6 +80,7 @@ const CodeBlock = memo( // Syntax highlighting with cached Shiki instance useEffect(() => { const fallback = `
${source || ""}
` + const highlight = async () => { // Show plain text if language needs to be loaded if (currentLanguage && !isLanguageLoaded(currentLanguage)) { @@ -281,10 +121,8 @@ const CodeBlock = memo( // Check if content height exceeds collapsed height whenever content changes useEffect(() => { - const codeBlock = codeBlockRef.current - if (codeBlock) { - const actualHeight = codeBlock.scrollHeight - setShowCollapseButton(actualHeight >= WINDOW_SHADE_SETTINGS.collapsedHeight) + if (codeBlockRef.current) { + setShowCollapseButton(codeBlockRef.current.scrollHeight >= WINDOW_SHADE_SETTINGS.collapsedHeight) } }, [highlightedCode]) @@ -297,44 +135,50 @@ const CodeBlock = memo( // Effect to listen to scroll events and update the ref useEffect(() => { const preElement = preRef.current - if (!preElement) return + + if (!preElement) { + return + } const handleScroll = () => { const isAtBottom = Math.abs(preElement.scrollHeight - preElement.scrollTop - preElement.clientHeight) < SCROLL_SNAP_TOLERANCE + wasScrolledUpRef.current = !isAtBottom } preElement.addEventListener("scroll", handleScroll, { passive: true }) + // Initial check in case it starts scrolled up handleScroll() - return () => { - preElement.removeEventListener("scroll", handleScroll) - } + return () => preElement.removeEventListener("scroll", handleScroll) }, []) // Empty dependency array: runs once on mount // Effect to track outer container scroll position useEffect(() => { const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]') - if (!scrollContainer) return + + if (!scrollContainer) { + return + } const handleOuterScroll = () => { const isAtBottom = Math.abs(scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight) < SCROLL_SNAP_TOLERANCE + outerContainerNearBottomRef.current = isAtBottom } scrollContainer.addEventListener("scroll", handleOuterScroll, { passive: true }) + // Initial check handleOuterScroll() - return () => { - scrollContainer.removeEventListener("scroll", handleOuterScroll) - } - }, []) // Empty dependency array: runs once on mount + return () => scrollContainer.removeEventListener("scroll", handleOuterScroll) + }, []) // Store whether we should scroll after highlighting completes const shouldScrollAfterHighlightRef = useRef(false) @@ -352,16 +196,24 @@ const CodeBlock = memo( const updateCodeBlockButtonPosition = useCallback((forceHide = false) => { const codeBlock = codeBlockRef.current const copyWrapper = copyButtonWrapperRef.current - if (!codeBlock) return + + if (!codeBlock) { + return + } const rectCodeBlock = codeBlock.getBoundingClientRect() const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]') - if (!scrollContainer) return + + if (!scrollContainer) { + return + } // Get wrapper height dynamically let wrapperHeight + if (copyWrapper) { const copyRect = copyWrapper.getBoundingClientRect() + // If height is 0 due to styling, estimate from children if (copyRect.height > 0) { wrapperHeight = copyRect.height @@ -407,6 +259,7 @@ const CodeBlock = memo( scrollRect.top + margin, Math.min(rectCodeBlock.bottom - wrapperHeight - margin, rectCodeBlock.top + margin), ) + const rightPosition = Math.max(margin, scrollRect.right - rectCodeBlock.right + margin) codeBlock.style.setProperty("--copy-button-top", `${topPosition}px`) @@ -626,13 +479,12 @@ const CodeBlock = memo( textAlign: "right", marginRight: 0, }} - onClick={(e) => { - e.currentTarget.focus() - }} + onClick={(e) => e.currentTarget.focus()} onChange={(e) => { const newLang = normalizeLanguage(e.target.value) userChangedLanguageRef.current = true setCurrentLanguage(newLang) + if (onLanguageChange) { onLanguageChange(newLang) } @@ -677,7 +529,10 @@ const CodeBlock = memo( // Get the current code block element and scrollable container const codeBlock = codeBlockRef.current const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]') - if (!codeBlock || !scrollContainer) return + + if (!codeBlock || !scrollContainer) { + return + } // Toggle window shade state setWindowShade(!windowShade) @@ -685,15 +540,9 @@ const CodeBlock = memo( // After UI updates, ensure code block is visible and update button position setTimeout( () => { - codeBlock.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }) - + codeBlock.scrollIntoView({ behavior: "smooth", block: "nearest" }) // Wait for scroll to complete before updating button position - setTimeout(() => { - updateCodeBlockButtonPosition() - }, 50) + setTimeout(() => updateCodeBlockButtonPosition(), 50) }, WINDOW_SHADE_SETTINGS.transitionDelayS * 1000 + 50, ) diff --git a/webview-ui/src/components/common/MarkdownBlock.styles.ts b/webview-ui/src/components/common/MarkdownBlock.styles.ts new file mode 100644 index 00000000000..c0a366514ed --- /dev/null +++ b/webview-ui/src/components/common/MarkdownBlock.styles.ts @@ -0,0 +1,65 @@ +import styled from "styled-components" + +export const StyledMarkdown = styled.div` + code:not(pre > code) { + font-family: var(--vscode-editor-font-family, monospace); + color: var(--vscode-textPreformat-foreground, #f78383); + background-color: var(--vscode-textCodeBlock-background, #1e1e1e); + padding: 0px 2px; + border-radius: 3px; + border: 1px solid var(--vscode-textSeparator-foreground, #424242); + white-space: pre-line; + word-break: break-word; + overflow-wrap: anywhere; + } + + /* Target only Dark High Contrast theme using the data attribute VS Code adds to the body */ + body[data-vscode-theme-kind="vscode-high-contrast"] & code:not(pre > code) { + color: var( + --vscode-editorInlayHint-foreground, + var(--vscode-symbolIcon-stringForeground, var(--vscode-charts-orange, #e9a700)) + ); + } + + font-family: + var(--vscode-font-family), + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + + font-size: var(--vscode-font-size, 13px); + + p, + li, + ol, + ul { + line-height: 1.25; + } + + ol, + ul { + padding-left: 2.5em; + margin-left: 0; + } + + p { + white-space: pre-wrap; + margin-top: 0; + margin-bottom: 0.5em; + } + + a { + text-decoration: none; + &:hover { + text-decoration: underline; + } + } +` diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index ba8eab1eb8e..92217e1fe70 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -1,192 +1,50 @@ import React, { memo, useEffect } from "react" import { useRemark } from "react-remark" -import styled from "styled-components" -import { visit } from "unist-util-visit" +import { ComponentOptions } from "rehype-react" +import { remarkUrls, remarkCodeLang } from "@src/utils/remark" +import { rehypeMentions, rehypeCode, rehypeMermaid } from "@src/utils/rehype" import { useExtensionState } from "@src/context/ExtensionStateContext" -import CodeBlock from "./CodeBlock" -import MermaidBlock from "./MermaidBlock" +import { StyledMarkdown } from "./MarkdownBlock.styles" + +export type RehypePlugin = "mentions" | "code" | "mermaid" interface MarkdownBlockProps { markdown?: string + renderMentions?: boolean + renderCode?: boolean + renderMermaid?: boolean } -/** - * Custom remark plugin that converts plain URLs in text into clickable links - * - * The original bug: We were converting text nodes into paragraph nodes, - * which broke the markdown structure because text nodes should remain as text nodes - * within their parent elements (like paragraphs, list items, etc.). - * This caused the entire content to disappear because the structure became invalid. - */ -const remarkUrlToLink = () => { - return (tree: any) => { - // Visit all "text" nodes in the markdown AST (Abstract Syntax Tree) - visit(tree, "text", (node: any, index, parent) => { - const urlRegex = /https?:\/\/[^\s<>)"]+/g - const matches = node.value.match(urlRegex) - - if (!matches) { - return - } - - const parts = node.value.split(urlRegex) - const children: any[] = [] - - parts.forEach((part: string, i: number) => { - if (part) { - children.push({ type: "text", value: part }) - } - - if (matches[i]) { - children.push({ type: "link", url: matches[i], children: [{ type: "text", value: matches[i] }] }) - } - }) - - // Fix: Instead of converting the node to a paragraph (which broke things), - // we replace the original text node with our new nodes in the parent's children array. - // This preserves the document structure while adding our links. - if (parent) { - parent.children.splice(index, 1, ...children) - } - }) - } -} - -const StyledMarkdown = styled.div` - code:not(pre > code) { - font-family: var(--vscode-editor-font-family, monospace); - color: var(--vscode-textPreformat-foreground, #f78383); - background-color: var(--vscode-textCodeBlock-background, #1e1e1e); - padding: 0px 2px; - border-radius: 3px; - border: 1px solid var(--vscode-textSeparator-foreground, #424242); - white-space: pre-line; - word-break: break-word; - overflow-wrap: anywhere; - } - - /* Target only Dark High Contrast theme using the data attribute VS Code adds to the body */ - body[data-vscode-theme-kind="vscode-high-contrast"] & code:not(pre > code) { - color: var( - --vscode-editorInlayHint-foreground, - var(--vscode-symbolIcon-stringForeground, var(--vscode-charts-orange, #e9a700)) - ); - } - - font-family: - var(--vscode-font-family), - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - Oxygen, - Ubuntu, - Cantarell, - "Open Sans", - "Helvetica Neue", - sans-serif; +export const MarkdownBlock = memo( + ({ markdown, renderMentions = false, renderCode = true, renderMermaid = true }: MarkdownBlockProps) => { + const { theme } = useExtensionState() - font-size: var(--vscode-font-size, 13px); + const components: ComponentOptions["components"] = {} - p, - li, - ol, - ul { - line-height: 1.25; - } - - ol, - ul { - padding-left: 2.5em; - margin-left: 0; - } - - p { - white-space: pre-wrap; - } - - a { - text-decoration: none; - } - a { - &:hover { - text-decoration: underline; + if (renderMentions) { + components.p = rehypeMentions } - } -` - -const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { - const { theme } = useExtensionState() - const [reactContent, setMarkdown] = useRemark({ - remarkPlugins: [ - remarkUrlToLink, - () => { - return (tree) => { - visit(tree, "code", (node: any) => { - if (!node.lang) { - node.lang = "text" - } else if (node.lang.includes(".")) { - node.lang = node.lang.split(".").slice(-1)[0] - } - }) - } - }, - ], - rehypePlugins: [], - rehypeReactOptions: { - components: { - pre: ({ node: _, children }: any) => { - // Check for Mermaid diagrams first - if (Array.isArray(children) && children.length === 1 && React.isValidElement(children[0])) { - const child = children[0] as React.ReactElement<{ className?: string }> - - if (child.props?.className?.includes("language-mermaid")) { - return child - } - } - - // For all other code blocks, use CodeBlock with copy button - const codeNode = children?.[0] - if (!codeNode?.props?.children) { - return null - } - - const language = - (Array.isArray(codeNode.props?.className) - ? codeNode.props.className - : [codeNode.props?.className] - ).map((c: string) => c?.replace("language-", ""))[0] || "javascript" - - const rawText = codeNode.props.children[0] || "" - return - }, - code: (props: any) => { - const className = props.className || "" + if (renderCode) { + components.pre = rehypeCode + } - if (className.includes("language-mermaid")) { - const codeText = String(props.children || "") - return - } + if (renderMermaid) { + components.code = rehypeMermaid + } - return - }, - }, - }, - }) + const [reactContent, setMarkdown] = useRemark({ + remarkPlugins: [remarkUrls, remarkCodeLang], + rehypePlugins: [], + rehypeReactOptions: { components }, + }) - useEffect(() => { - setMarkdown(markdown || "") - }, [markdown, setMarkdown, theme]) + useEffect(() => setMarkdown(markdown || ""), [markdown, setMarkdown, theme]) - return ( -
- {reactContent} -
- ) -}) + return {reactContent} + }, +) export default MarkdownBlock diff --git a/webview-ui/src/components/common/MermaidBlock.styles.ts b/webview-ui/src/components/common/MermaidBlock.styles.ts new file mode 100644 index 00000000000..ef0940fb392 --- /dev/null +++ b/webview-ui/src/components/common/MermaidBlock.styles.ts @@ -0,0 +1,24 @@ +import styled from "styled-components" + +export const MermaidBlockContainer = styled.div` + position: relative; + margin: 8px 0; +` + +export const LoadingMessage = styled.div` + padding: 8px 0; + color: var(--vscode-descriptionForeground); + font-style: italic; + font-size: 0.9em; +` + +export const SvgContainer = styled.div<{ + $isLoading: boolean +}>` + opacity: ${(props) => (props.$isLoading ? 0.3 : 1)}; + min-height: 20px; + transition: opacity 0.2s ease; + cursor: pointer; + display: flex; + justify-content: center; +` diff --git a/webview-ui/src/components/common/MermaidBlock.tsx b/webview-ui/src/components/common/MermaidBlock.tsx index 59132e42390..3baeb087cb8 100644 --- a/webview-ui/src/components/common/MermaidBlock.tsx +++ b/webview-ui/src/components/common/MermaidBlock.tsx @@ -1,8 +1,11 @@ import { useEffect, useRef, useState } from "react" import mermaid from "mermaid" + import { useDebounceEffect } from "@src/utils/useDebounceEffect" -import styled from "styled-components" import { vscode } from "@src/utils/vscode" +import { svgToPng } from "@src/utils/svgToPng" + +import { MermaidBlockContainer, LoadingMessage, SvgContainer } from "./MermaidBlock.styles" const MERMAID_THEME = { background: "#1e1e1e", // VS Code dark theme background @@ -124,16 +127,19 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { * Converts the to a PNG and sends it to the extension. */ const handleClick = async () => { - if (!containerRef.current) return + if (!containerRef.current) { + return + } + const svgEl = containerRef.current.querySelector("svg") - if (!svgEl) return + + if (!svgEl) { + return + } try { - const pngDataUrl = await svgToPng(svgEl) - vscode.postMessage({ - type: "openImage", - text: pngDataUrl, - }) + const pngDataUrl = await svgToPng(svgEl, MERMAID_THEME) + vscode.postMessage({ type: "openImage", text: pngDataUrl }) } catch (err) { console.error("Error converting SVG to PNG:", err) } @@ -142,85 +148,7 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { return ( {isLoading && Generating mermaid diagram...} - - {/* The container for the final or raw code. */} ) } - -async function svgToPng(svgEl: SVGElement): Promise { - // Clone the SVG to avoid modifying the original - const svgClone = svgEl.cloneNode(true) as SVGElement - - // Get the original viewBox - const viewBox = svgClone.getAttribute("viewBox")?.split(" ").map(Number) || [] - const originalWidth = viewBox[2] || svgClone.clientWidth - const originalHeight = viewBox[3] || svgClone.clientHeight - - // Calculate the scale factor to fit editor width while maintaining aspect ratio - - // Unless we can find a way to get the actual editor window dimensions through the VS Code API (which might be possible but would require changes to the extension side), - // the fixed width seems like a reliable approach. - const editorWidth = 3_600 - - const scale = editorWidth / originalWidth - const scaledHeight = originalHeight * scale - - // Update SVG dimensions - svgClone.setAttribute("width", `${editorWidth}`) - svgClone.setAttribute("height", `${scaledHeight}`) - - const serializer = new XMLSerializer() - const svgString = serializer.serializeToString(svgClone) - const svgDataUrl = "data:image/svg+xml;base64," + btoa(decodeURIComponent(encodeURIComponent(svgString))) - - return new Promise((resolve, reject) => { - const img = new Image() - img.onload = () => { - const canvas = document.createElement("canvas") - canvas.width = editorWidth - canvas.height = scaledHeight - - const ctx = canvas.getContext("2d") - if (!ctx) return reject("Canvas context not available") - - // Fill background with Mermaid's dark theme background color - ctx.fillStyle = MERMAID_THEME.background - ctx.fillRect(0, 0, canvas.width, canvas.height) - - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = "high" - - ctx.drawImage(img, 0, 0, editorWidth, scaledHeight) - resolve(canvas.toDataURL("image/png", 1.0)) - } - img.onerror = reject - img.src = svgDataUrl - }) -} - -const MermaidBlockContainer = styled.div` - position: relative; - margin: 8px 0; -` - -const LoadingMessage = styled.div` - padding: 8px 0; - color: var(--vscode-descriptionForeground); - font-style: italic; - font-size: 0.9em; -` - -interface SvgContainerProps { - $isLoading: boolean -} - -const SvgContainer = styled.div` - opacity: ${(props) => (props.$isLoading ? 0.3 : 1)}; - min-height: 20px; - transition: opacity 0.2s ease; - cursor: pointer; - display: flex; - justify-content: center; -` diff --git a/webview-ui/src/utils/rehype.tsx b/webview-ui/src/utils/rehype.tsx new file mode 100644 index 00000000000..e5d116a1125 --- /dev/null +++ b/webview-ui/src/utils/rehype.tsx @@ -0,0 +1,59 @@ +import React from "react" + +import { Mention } from "@src/components/chat/Mention" +import { CodeBlock } from "@src/components/common/CodeBlock" +import MermaidBlock from "@src/components/common/MermaidBlock" + +export const rehypeMentions = (props: any) => { + const { children, ...rest } = props + return

{children.map((node: any) => (typeof node === "string" ? : node))}

+} + +export const rehypeCode = (props: any) => { + const { children } = props + + // Check for Mermaid diagrams first. + if (Array.isArray(children) && children.length === 1 && React.isValidElement(children[0])) { + const child = children[0] as React.ReactElement<{ className?: string }> + + if (child.props?.className?.includes("language-mermaid")) { + return child + } + } + + // For all other code blocks, use CodeBlock with copy button. + const codeNode = children?.[0] + + if (!codeNode?.props?.children) { + return null + } + + const language = + (Array.isArray(codeNode.props?.className) ? codeNode.props.className : [codeNode.props?.className]).map( + (c: string) => c?.replace("language-", ""), + )[0] || "javascript" + + const source = codeNode.props.children[0] || "" + + if (!source) { + return null + } + + return +} + +export const rehypeMermaid = (props: any) => { + const className = props.className || "" + + if (className.includes("language-mermaid")) { + const code = String(props.children || "") + + if (!code) { + return null + } + + return + } + + return +} diff --git a/webview-ui/src/utils/remark.ts b/webview-ui/src/utils/remark.ts new file mode 100644 index 00000000000..28477f07d72 --- /dev/null +++ b/webview-ui/src/utils/remark.ts @@ -0,0 +1,52 @@ +import { visit } from "unist-util-visit" + +/** + * Custom remark plugin that converts plain URLs in text into clickable links + * + * The original bug: We were converting text nodes into paragraph nodes, + * which broke the markdown structure because text nodes should remain as text nodes + * within their parent elements (like paragraphs, list items, etc.). + * This caused the entire content to disappear because the structure became invalid. + */ + +export const remarkUrls = () => (tree: any) => { + // Visit all "text" nodes in the markdown AST + visit(tree, "text", (node: any, index, parent) => { + const urlRegex = /https?:\/\/[^\s<>)"]+/g + const matches = node.value.match(urlRegex) + + if (!matches) { + return + } + + const parts = node.value.split(urlRegex) + const children: any[] = [] + + parts.forEach((part: string, i: number) => { + if (part) { + children.push({ type: "text", value: part }) + } + + if (matches[i]) { + children.push({ type: "link", url: matches[i], children: [{ type: "text", value: matches[i] }] }) + } + }) + + // Fix: Instead of converting the node to a paragraph (which broke things), + // we replace the original text node with our new nodes in the parent's children array. + // This preserves the document structure while adding our links. + if (parent) { + parent.children.splice(index, 1, ...children) + } + }) +} + +export const remarkCodeLang = () => (tree: any) => { + visit(tree, "code", (node: any) => { + if (!node.lang) { + node.lang = "text" + } else if (node.lang.includes(".")) { + node.lang = node.lang.split(".").slice(-1)[0] + } + }) +} diff --git a/webview-ui/src/utils/svgToPng.ts b/webview-ui/src/utils/svgToPng.ts new file mode 100644 index 00000000000..8ad5fa05186 --- /dev/null +++ b/webview-ui/src/utils/svgToPng.ts @@ -0,0 +1,54 @@ +export async function svgToPng(svgEl: SVGElement, theme: Record): Promise { + // Clone the SVG to avoid modifying the original + const svgClone = svgEl.cloneNode(true) as SVGElement + + // Get the original viewBox + const viewBox = svgClone.getAttribute("viewBox")?.split(" ").map(Number) || [] + const originalWidth = viewBox[2] || svgClone.clientWidth + const originalHeight = viewBox[3] || svgClone.clientHeight + + // Calculate the scale factor to fit editor width while maintaining aspect ratio + + // Unless we can find a way to get the actual editor window dimensions through the VS Code API (which might be possible but would require changes to the extension side), + // the fixed width seems like a reliable approach. + const editorWidth = 3_600 + + const scale = editorWidth / originalWidth + const scaledHeight = originalHeight * scale + + // Update SVG dimensions + svgClone.setAttribute("width", `${editorWidth}`) + svgClone.setAttribute("height", `${scaledHeight}`) + + const serializer = new XMLSerializer() + const svgString = serializer.serializeToString(svgClone) + const svgDataUrl = "data:image/svg+xml;base64," + btoa(decodeURIComponent(encodeURIComponent(svgString))) + + return new Promise((resolve, reject) => { + const img = new Image() + + img.onload = () => { + const canvas = document.createElement("canvas") + canvas.width = editorWidth + canvas.height = scaledHeight + + const ctx = canvas.getContext("2d") + + if (!ctx) { + return reject("Canvas context not available") + } + + // Fill background with Mermaid's dark theme background color + ctx.fillStyle = theme.background + ctx.fillRect(0, 0, canvas.width, canvas.height) + + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = "high" + + ctx.drawImage(img, 0, 0, editorWidth, scaledHeight) + resolve(canvas.toDataURL("image/png", 1.0)) + } + img.onerror = reject + img.src = svgDataUrl + }) +}