Преглед изворни кода

feat: add button to open markdown in VSCode preview (#10773)

Co-authored-by: Roo Code <[email protected]>
Bruno Bergher пре 4 недеља
родитељ
комит
9bf7173725

+ 1 - 0
packages/types/src/vscode-extension-host.ts

@@ -504,6 +504,7 @@ export interface WebviewMessage {
 		| "editQueuedMessage"
 		| "dismissUpsell"
 		| "getDismissedUpsells"
+		| "openMarkdownPreview"
 		| "updateSettings"
 		| "allowedCommands"
 		| "getTaskWithAggregatedCosts"

+ 21 - 0
src/core/webview/webviewMessageHandler.ts

@@ -3233,6 +3233,27 @@ export const webviewMessageHandler = async (
 			break
 		}
 
+		case "openMarkdownPreview": {
+			if (message.text) {
+				try {
+					const tmpDir = os.tmpdir()
+					const timestamp = Date.now()
+					const tempFileName = `roo-preview-${timestamp}.md`
+					const tempFilePath = path.join(tmpDir, tempFileName)
+
+					await fs.writeFile(tempFilePath, message.text, "utf8")
+
+					const doc = await vscode.workspace.openTextDocument(tempFilePath)
+					await vscode.commands.executeCommand("markdown.showPreview", doc.uri)
+				} catch (error) {
+					const errorMessage = error instanceof Error ? error.message : String(error)
+					provider.log(`Error opening markdown preview: ${errorMessage}`)
+					vscode.window.showErrorMessage(`Failed to open markdown preview: ${errorMessage}`)
+				}
+			}
+			break
+		}
+
 		case "requestClaudeCodeRateLimits": {
 			try {
 				const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")

+ 11 - 4
webview-ui/src/components/chat/ChatRow.tsx

@@ -70,6 +70,7 @@ import {
 } from "lucide-react"
 import { cn } from "@/lib/utils"
 import { PathTooltip } from "../ui/PathTooltip"
+import { OpenMarkdownPreviewButton } from "./OpenMarkdownPreviewButton"
 
 // Helper function to get previous todos before a specific message
 function getPreviousTodos(messages: ClineMessage[], currentMessageTs: number): any[] {
@@ -1205,10 +1206,12 @@ export const ChatRowContent = ({
 					return null // we should never see this message type
 				case "text":
 					return (
-						<div>
+						<div className="group">
 							<div style={headerStyle}>
 								<MessageCircle className="w-4 shrink-0" aria-label="Speech bubble icon" />
 								<span style={{ fontWeight: "bold" }}>{t("chat:text.rooSaid")}</span>
+								<div style={{ flexGrow: 1 }} />
+								<OpenMarkdownPreviewButton markdown={message.text} />
 							</div>
 							<div className="pl-6">
 								<Markdown markdown={message.text} partial={message.partial} />
@@ -1343,15 +1346,17 @@ export const ChatRowContent = ({
 					)
 				case "completion_result":
 					return (
-						<>
+						<div className="group">
 							<div style={headerStyle}>
 								{icon}
 								{title}
+								<div style={{ flexGrow: 1 }} />
+								<OpenMarkdownPreviewButton markdown={message.text} />
 							</div>
 							<div className="border-l border-green-600/30 ml-2 pl-4 pb-1">
 								<Markdown markdown={message.text} />
 							</div>
-						</>
+						</div>
 					)
 				case "shell_integration_warning":
 					return <CommandExecutionError />
@@ -1602,10 +1607,12 @@ export const ChatRowContent = ({
 				case "completion_result":
 					if (message.text) {
 						return (
-							<div>
+							<div className="group">
 								<div style={headerStyle}>
 									{icon}
 									{title}
+									<div style={{ flexGrow: 1 }} />
+									<OpenMarkdownPreviewButton markdown={message.text} />
 								</div>
 								<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
 									<Markdown markdown={message.text} partial={message.partial} />

+ 38 - 0
webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx

@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+import { SquareArrowOutUpRight } from "lucide-react"
+
+import { vscode } from "@src/utils/vscode"
+import { hasComplexMarkdown } from "@src/utils/markdown"
+import { StandardTooltip } from "@src/components/ui"
+
+interface OpenMarkdownPreviewButtonProps {
+	markdown: string | undefined
+	className?: string
+}
+
+export const OpenMarkdownPreviewButton = memo(({ markdown, className }: OpenMarkdownPreviewButtonProps) => {
+	if (!hasComplexMarkdown(markdown)) {
+		return null
+	}
+
+	const handleClick = (e: React.MouseEvent) => {
+		e.stopPropagation()
+		if (markdown) {
+			vscode.postMessage({
+				type: "openMarkdownPreview",
+				text: markdown,
+			})
+		}
+	}
+
+	return (
+		<StandardTooltip content="Open in preview">
+			<button
+				onClick={handleClick}
+				className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${className ?? ""}`}
+				aria-label="Open markdown in preview">
+				<SquareArrowOutUpRight className="w-4 h-4" />
+			</button>
+		</StandardTooltip>
+	)
+})

+ 53 - 0
webview-ui/src/components/chat/__tests__/OpenMarkdownPreviewButton.spec.tsx

@@ -0,0 +1,53 @@
+import React from "react"
+import { describe, expect, it, vi, beforeEach } from "vitest"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { TooltipProvider } from "@radix-ui/react-tooltip"
+
+import { OpenMarkdownPreviewButton } from "../OpenMarkdownPreviewButton"
+
+const { postMessageMock } = vi.hoisted(() => ({
+	postMessageMock: vi.fn(),
+}))
+
+vi.mock("@src/utils/vscode", () => ({
+	vscode: {
+		postMessage: postMessageMock,
+	},
+}))
+
+describe("OpenMarkdownPreviewButton", () => {
+	const complex = "# One\n## Two"
+	const simple = "Just text"
+
+	beforeEach(() => {
+		postMessageMock.mockClear()
+	})
+
+	it("does not render when markdown has fewer than 2 headings", () => {
+		render(
+			<TooltipProvider>
+				<OpenMarkdownPreviewButton markdown={simple} />
+			</TooltipProvider>,
+		)
+		expect(screen.queryByLabelText("Open markdown in preview")).toBeNull()
+	})
+
+	it("renders when markdown has 2+ headings", () => {
+		render(
+			<TooltipProvider>
+				<OpenMarkdownPreviewButton markdown={complex} />
+			</TooltipProvider>,
+		)
+		expect(screen.getByLabelText("Open markdown in preview")).toBeInTheDocument()
+	})
+
+	it("posts message on click", () => {
+		render(
+			<TooltipProvider>
+				<OpenMarkdownPreviewButton markdown={complex} />
+			</TooltipProvider>,
+		)
+		fireEvent.click(screen.getByLabelText("Open markdown in preview"))
+		expect(postMessageMock).toHaveBeenCalledWith({ type: "openMarkdownPreview", text: complex })
+	})
+})

+ 32 - 0
webview-ui/src/utils/__tests__/markdown.spec.ts

@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest"
+
+import { countMarkdownHeadings, hasComplexMarkdown } from "../markdown"
+
+describe("markdown heading helpers", () => {
+	it("returns 0 for empty or undefined", () => {
+		expect(countMarkdownHeadings(undefined)).toBe(0)
+		expect(countMarkdownHeadings("")).toBe(0)
+	})
+
+	it("counts single and multiple headings", () => {
+		expect(countMarkdownHeadings("# One")).toBe(1)
+		expect(countMarkdownHeadings("# One\nContent")).toBe(1)
+		expect(countMarkdownHeadings("# One\n## Two")).toBe(2)
+		expect(countMarkdownHeadings("# One\n## Two\n### Three")).toBe(3)
+	})
+
+	it("handles all heading levels", () => {
+		const md = `# h1\n## h2\n### h3\n#### h4\n##### h5\n###### h6`
+		expect(countMarkdownHeadings(md)).toBe(6)
+	})
+
+	it("ignores headings inside code fences", () => {
+		const md = "# real\n```\n# not a heading\n```\n## real"
+		expect(countMarkdownHeadings(md)).toBe(2)
+	})
+
+	it("hasComplexMarkdown requires at least two headings", () => {
+		expect(hasComplexMarkdown("# One")).toBe(false)
+		expect(hasComplexMarkdown("# One\n## Two")).toBe(true)
+	})
+})

+ 23 - 0
webview-ui/src/utils/markdown.ts

@@ -0,0 +1,23 @@
+/**
+ * Counts the number of markdown headings in the given text.
+ * Matches headings from level 1 to 6 (e.g. #, ##, ###, etc.).
+ * Code fences are stripped before matching to avoid false positives.
+ */
+export function countMarkdownHeadings(text: string | undefined): number {
+	if (!text) return 0
+
+	// Remove fenced code blocks to avoid counting headings inside code
+	const withoutCodeBlocks = text.replace(/```[\s\S]*?```/g, "")
+
+	// Up to 3 leading spaces are allowed before the hashes per the markdown spec
+	const headingRegex = /^\s{0,3}#{1,6}\s+.+$/gm
+	const matches = withoutCodeBlocks.match(headingRegex)
+	return matches ? matches.length : 0
+}
+
+/**
+ * Returns true if the markdown contains at least two headings.
+ */
+export function hasComplexMarkdown(text: string | undefined): boolean {
+	return countMarkdownHeadings(text) >= 2
+}