From 8b3c7f1ff8360fcdc00ee0d9db78459a250a51f7 Mon Sep 17 00:00:00 2001 From: go165 <196723798+go165@users.noreply.github.com> Date: Sun, 14 Jun 2026 09:29:24 +0800 Subject: [PATCH] fix(client): fall back when config clipboard copy is blocked --- client/src/components/Sidebar.tsx | 111 +++++++++++------- .../src/components/__tests__/Sidebar.test.tsx | 36 ++++++ 2 files changed, 105 insertions(+), 42 deletions(-) diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 762678f22..83321bb6e 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -45,6 +45,43 @@ import { useToast } from "../lib/hooks/useToast"; import IconDisplay, { WithIcons } from "./IconDisplay"; import { validateRedirectUrl } from "@/utils/urlValidation"; +const copyTextToClipboard = async (text: string) => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + } catch (error) { + if (copyTextWithTextarea(text)) { + return; + } + throw error; + } + + if (!copyTextWithTextarea(text)) { + throw new Error("Clipboard API is not available"); + } +}; + +const copyTextWithTextarea = (text: string) => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "0"; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + return document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } +}; + interface SidebarProps { connectionStatus: ConnectionStatus; transportType: "stdio" | "sse" | "streamable-http"; @@ -180,57 +217,47 @@ const Sidebar = ({ }, [generateServerConfig]); // Memoized copy handlers - const handleCopyServerEntry = useCallback(() => { + const handleCopyServerEntry = useCallback(async () => { try { const configJson = generateMCPServerEntry(); - navigator.clipboard - .writeText(configJson) - .then(() => { - setCopiedServerEntry(true); - - toast({ - title: "Config entry copied", - description: - transportType === "stdio" - ? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name." - : transportType === "streamable-http" - ? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client." - : "SSE URL has been copied. Use this URL directly in your MCP Client.", - }); - - setTimeout(() => { - setCopiedServerEntry(false); - }, 2000); - }) - .catch((error) => { - reportError(error); - }); + await copyTextToClipboard(configJson); + + setCopiedServerEntry(true); + + toast({ + title: "Config entry copied", + description: + transportType === "stdio" + ? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name." + : transportType === "streamable-http" + ? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client." + : "SSE URL has been copied. Use this URL directly in your MCP Client.", + }); + + setTimeout(() => { + setCopiedServerEntry(false); + }, 2000); } catch (error) { reportError(error); } }, [generateMCPServerEntry, transportType, toast, reportError]); - const handleCopyServerFile = useCallback(() => { + const handleCopyServerFile = useCallback(async () => { try { const configJson = generateMCPServerFile(); - navigator.clipboard - .writeText(configJson) - .then(() => { - setCopiedServerFile(true); - - toast({ - title: "Servers file copied", - description: - "Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'", - }); - - setTimeout(() => { - setCopiedServerFile(false); - }, 2000); - }) - .catch((error) => { - reportError(error); - }); + await copyTextToClipboard(configJson); + + setCopiedServerFile(true); + + toast({ + title: "Servers file copied", + description: + "Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'", + }); + + setTimeout(() => { + setCopiedServerFile(false); + }, 2000); } catch (error) { reportError(error); } diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 460161e59..28ac03d8c 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -23,10 +23,16 @@ jest.mock("@/lib/hooks/useToast", () => ({ // Mock navigator clipboard const mockClipboardWrite = jest.fn(() => Promise.resolve()); Object.defineProperty(navigator, "clipboard", { + configurable: true, value: { writeText: mockClipboardWrite, }, }); +const mockExecCommand = jest.fn(() => true); +Object.defineProperty(document, "execCommand", { + configurable: true, + value: mockExecCommand, +}); // Setup fake timers jest.useFakeTimers(); @@ -76,6 +82,8 @@ describe("Sidebar", () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); + mockClipboardWrite.mockResolvedValue(undefined); + mockExecCommand.mockReturnValue(true); }); describe("Command and arguments", () => { @@ -492,6 +500,34 @@ describe("Sidebar", () => { expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); }); + it("should fall back when clipboard write is rejected for servers file export", async () => { + mockClipboardWrite.mockRejectedValueOnce(new Error("NotAllowedError")); + const command = "node"; + const args = "server.js"; + + renderSidebar({ + transportType: "stdio", + command, + args, + }); + + await act(async () => { + const { serversFile } = getCopyButtons(); + fireEvent.click(serversFile); + await Promise.resolve(); + jest.runAllTimers(); + }); + + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + expect(mockExecCommand).toHaveBeenCalledWith("copy"); + expect(document.querySelector("textarea")).toBeNull(); + expect(mockToast).toHaveBeenCalledWith({ + title: "Servers file copied", + description: + "Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'", + }); + }); + it("should copy server entry configuration to clipboard for SSE transport", async () => { const sseUrl = "http://localhost:3000/events"; renderSidebar({ transportType: "sse", sseUrl });