feat(frontend): Add copy button to code blocks (#13458)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Vasco Schiavo
2026-03-20 12:20:25 +01:00
committed by GitHub
parent a75b576f1c
commit fb776ef650
4 changed files with 167 additions and 20 deletions

View File

@@ -0,0 +1,60 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper";
describe("CopyableContentWrapper", () => {
it("should hide the copy button by default", () => {
render(
<CopyableContentWrapper text="hello">
<p>content</p>
</CopyableContentWrapper>,
);
expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
});
it("should show the copy button on hover", async () => {
const user = userEvent.setup();
render(
<CopyableContentWrapper text="hello">
<p>content</p>
</CopyableContentWrapper>,
);
await user.hover(screen.getByText("content"));
expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
});
it("should copy text to clipboard on click", async () => {
const user = userEvent.setup();
render(
<CopyableContentWrapper text="copy me">
<p>content</p>
</CopyableContentWrapper>,
);
await user.click(screen.getByTestId("copy-to-clipboard"));
await waitFor(() =>
expect(navigator.clipboard.readText()).resolves.toBe("copy me"),
);
});
it("should show copied state after clicking", async () => {
const user = userEvent.setup();
render(
<CopyableContentWrapper text="hello">
<p>content</p>
</CopyableContentWrapper>,
);
await user.click(screen.getByTestId("copy-to-clipboard"));
expect(screen.getByTestId("copy-to-clipboard")).toHaveAttribute(
"aria-label",
"BUTTON$COPIED",
);
});
});

View File

@@ -0,0 +1,37 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { code as Code } from "#/components/features/markdown/code";
describe("code (markdown)", () => {
it("should render inline code without a copy button", () => {
render(<Code>inline snippet</Code>);
expect(screen.getByText("inline snippet")).toBeInTheDocument();
expect(screen.queryByTestId("copy-to-clipboard")).not.toBeInTheDocument();
});
it("should render a multiline code block with a copy button", () => {
render(<Code>{"line1\nline2"}</Code>);
expect(screen.getByText("line1 line2")).toBeInTheDocument();
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
});
it("should render a syntax-highlighted block with a copy button", () => {
render(<Code className="language-js">{"console.log('hi')"}</Code>);
expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument();
});
it("should copy code block content to clipboard", async () => {
const user = userEvent.setup();
render(<Code>{"line1\nline2"}</Code>);
await user.click(screen.getByTestId("copy-to-clipboard"));
await waitFor(() =>
expect(navigator.clipboard.readText()).resolves.toBe("line1\nline2"),
);
});
});

View File

@@ -2,6 +2,7 @@ import React from "react";
import { ExtraProps } from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper";
// See https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight
@@ -15,6 +16,7 @@ export function code({
React.HTMLAttributes<HTMLElement> &
ExtraProps) {
const match = /language-(\w+)/.exec(className || ""); // get the language
const codeString = String(children).replace(/\n$/, "");
if (!match) {
const isMultiline = String(children).includes("\n");
@@ -37,29 +39,33 @@ export function code({
}
return (
<pre
style={{
backgroundColor: "#2a3038",
padding: "1em",
borderRadius: "4px",
color: "#e6edf3",
border: "1px solid #30363d",
overflow: "auto",
}}
>
<code className={className}>{String(children).replace(/\n$/, "")}</code>
</pre>
<CopyableContentWrapper text={codeString}>
<pre
style={{
backgroundColor: "#2a3038",
padding: "1em",
borderRadius: "4px",
color: "#e6edf3",
border: "1px solid #30363d",
overflow: "auto",
}}
>
<code className={className}>{codeString}</code>
</pre>
</CopyableContentWrapper>
);
}
return (
<SyntaxHighlighter
className="rounded-lg"
style={vscDarkPlus}
language={match?.[1]}
PreTag="div"
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
<CopyableContentWrapper text={codeString}>
<SyntaxHighlighter
className="rounded-lg"
style={vscDarkPlus}
language={match?.[1]}
PreTag="div"
>
{codeString}
</SyntaxHighlighter>
</CopyableContentWrapper>
);
}

View File

@@ -0,0 +1,44 @@
import React from "react";
import { CopyToClipboardButton } from "./copy-to-clipboard-button";
export function CopyableContentWrapper({
text,
children,
}: {
text: string;
children: React.ReactNode;
}) {
const [isHovering, setIsHovering] = React.useState(false);
const [isCopied, setIsCopied] = React.useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setIsCopied(true);
};
React.useEffect(() => {
let timeout: NodeJS.Timeout;
if (isCopied) {
timeout = setTimeout(() => setIsCopied(false), 2000);
}
return () => clearTimeout(timeout);
}, [isCopied]);
return (
<div
className="relative"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className="absolute top-2 right-2 z-10">
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopied}
onClick={handleCopy}
mode={isCopied ? "copied" : "copy"}
/>
</div>
{children}
</div>
);
}