mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add button to open markdown in VSCode preview (#10773)
Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
parent
8b9f02aa0d
commit
9bf7173725
7 changed files with 179 additions and 4 deletions
|
|
@ -504,6 +504,7 @@ export interface WebviewMessage {
|
|||
| "editQueuedMessage"
|
||||
| "dismissUpsell"
|
||||
| "getDismissedUpsells"
|
||||
| "openMarkdownPreview"
|
||||
| "updateSettings"
|
||||
| "allowedCommands"
|
||||
| "getTaskWithAggregatedCosts"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx
Normal file
38
webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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
webview-ui/src/utils/__tests__/markdown.spec.ts
Normal file
32
webview-ui/src/utils/__tests__/markdown.spec.ts
Normal file
|
|
@ -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
webview-ui/src/utils/markdown.ts
Normal file
23
webview-ui/src/utils/markdown.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue