diff --git a/site/src/hooks/useClipboard.test.tsx b/site/src/hooks/useClipboard.test.tsx index dde538614c..a2a4969c91 100644 --- a/site/src/hooks/useClipboard.test.tsx +++ b/site/src/hooks/useClipboard.test.tsx @@ -157,6 +157,8 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { clipboard: mockClipboard, })); + vi.stubGlobal("isSecureContext", isSecure); + vi.spyOn(console, "error").mockImplementation((errorValue, ...rest) => { const canIgnore = errorValue instanceof Error && @@ -172,6 +174,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { vi.runAllTimers(); vi.useRealTimers(); vi.resetAllMocks(); + vi.unstubAllGlobals(); global.document.execCommand = originalExecCommand; // Still have to reset the mock clipboard state because the same mock values @@ -299,6 +302,43 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { expect(result.current.copyToClipboard).toBe(initialCopy); }); + it("Reads back text that was copied through the hook", async () => { + const textToCopy = "wolves"; + const { result } = renderUseClipboard(); + await assertClipboardUpdateLifecycle(result, textToCopy); + + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(textToCopy); + }); + + it("Surfaces read failures in secure contexts and falls back to the cached value otherwise", async () => { + const textToCopy = "otters"; + const { result } = renderUseClipboard(); + await assertClipboardUpdateLifecycle(result, textToCopy); + + if (isSecure) { + // A failed or denied read must surface instead of silently pasting a + // stale cached selection. + setSimulateFailure(true); + await expect( + act(async () => { + await result.current.readFromClipboard(); + }), + ).rejects.toThrow(); + } else { + // Insecure contexts cannot read the system clipboard, so paste falls + // back to the last value copied within Coder. + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(textToCopy); + } + }); + + it("Returns an empty string when nothing has been copied yet", async () => { + const { result } = renderUseClipboard(); + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(""); + }); + it("Always uses the most up-to-date onError prop", async () => { const initialOnError = vi.fn(); const { result, rerender } = renderUseClipboard({ diff --git a/site/src/hooks/useClipboard.ts b/site/src/hooks/useClipboard.ts index 11c2518ba8..fdbb2d9235 100644 --- a/site/src/hooks/useClipboard.ts +++ b/site/src/hooks/useClipboard.ts @@ -13,6 +13,13 @@ export type UseClipboardInput = Readonly<{ export type UseClipboardResult = Readonly<{ copyToClipboard: (textToCopy: string) => Promise; + + /** + * Reads text from the clipboard. When the asynchronous Clipboard API is + * available (secure contexts), it returns the live clipboard contents. + */ + readFromClipboard: () => Promise; + error: Error | undefined; /** @@ -44,6 +51,7 @@ export const useClipboard = ( const [showCopiedSuccess, setShowCopiedSuccess] = useState(false); const [error, setError] = useState(); const timeoutIdRef = useRef(undefined); + const lastCopiedTextRef = useRef(""); useEffect(() => { return () => window.clearTimeout(timeoutIdRef.current); @@ -52,6 +60,7 @@ export const useClipboard = ( const copyToClipboard = useCallback( async (textToCopy: string) => { const markSuccess = () => { + lastCopiedTextRef.current = textToCopy; setShowCopiedSuccess(true); if (clearErrorOnSuccess) { setError(undefined); @@ -84,7 +93,22 @@ export const useClipboard = ( [onError, clearErrorOnSuccess], ); - return { showCopiedSuccess, error, copyToClipboard }; + const readFromClipboard = useCallback(async (): Promise => { + // Insecure (HTTP) contexts and older browsers cannot read the system + // clipboard, so fall back to the last value copied within Coder. In a + // secure context, surface read failures (such as a denied permission) + // instead of silently pasting a stale cached selection. + if ( + window.isSecureContext && + typeof navigator.clipboard?.readText === "function" + ) { + return await navigator.clipboard.readText(); + } + + return lastCopiedTextRef.current; + }, []); + + return { showCopiedSuccess, error, copyToClipboard, readFromClipboard }; }; /** diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index 7e4f824c19..058fdd7ff7 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -15,14 +15,22 @@ import { useRef, useState, } from "react"; +import { toast } from "sonner"; import { ExponentialBackoff, type Websocket, WebsocketBuilder, WebsocketEvent, } from "websocket-ts"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "#/components/ContextMenu/ContextMenu"; import { useClipboard } from "#/hooks/useClipboard"; import { cn } from "#/utils/cn"; +import { isMac } from "#/utils/platform"; import { terminalWebsocketUrl } from "#/utils/terminal"; import type { ConnectionStatus } from "./types"; @@ -98,7 +106,36 @@ export const WorkspaceTerminal = ({ onContentReady?.(); }); const [terminal, setTerminal] = useState(); - const { copyToClipboard } = useClipboard(); + const { copyToClipboard, readFromClipboard } = useClipboard(); + + const [hasSelection, setHasSelection] = useState(false); + const handleContextMenuOpenChange = (open: boolean) => { + if (open) { + setHasSelection(Boolean(terminal?.hasSelection())); + } + }; + const copyTerminalSelection = () => { + const selection = terminal?.getSelection(); + if (selection) { + void copyToClipboard(selection); + } + }; + const pasteIntoTerminal = async () => { + if (!terminal) { + return; + } + try { + const text = await readFromClipboard(); + if (text) { + terminal.paste(text); + } + } catch (error) { + toast.error("Failed to paste from clipboard"); + console.error(error); + } finally { + terminal.focus(); + } + }; const reportTerminalError = useEffectEvent((error: Error) => { console.error(error); @@ -203,7 +240,6 @@ export const WorkspaceTerminal = ({ }), ); - const isMac = navigator.platform.match("Mac"); const copySelection = () => { const selection = nextTerminal.getSelection(); if (selection) { @@ -229,7 +265,7 @@ export const WorkspaceTerminal = ({ // By default this usually launches the browser dev tools, but users // expect this keybinding to copy when in the context of the web terminal. if ( - (isMac ? event.metaKey : event.ctrlKey) && + (isMac() ? event.metaKey : event.ctrlKey) && event.shiftKey && event.key === "C" ) { @@ -550,15 +586,35 @@ export const WorkspaceTerminal = ({ background-color: hsl(var(--surface-quaternary)); } `} -
+ + +
+ + { + event.preventDefault(); + terminal?.focus(); + }} + > + + Copy + + void pasteIntoTerminal()}> + Paste + + + ); }; diff --git a/site/src/pages/TerminalPage/TerminalPage.stories.tsx b/site/src/pages/TerminalPage/TerminalPage.stories.tsx index 947ad72872..e15d192f8a 100644 --- a/site/src/pages/TerminalPage/TerminalPage.stories.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, waitFor, within } from "storybook/test"; import { reactRouterOutlet, reactRouterParameters, @@ -23,6 +24,7 @@ import { MockWorkspaceAgent, } from "#/testHelpers/entities"; import { withWebSocket } from "#/testHelpers/storybook"; +import { isMac } from "#/utils/platform"; import TerminalPage from "./TerminalPage"; const createWorkspaceWithAgent = (lifecycle: WorkspaceAgentLifecycle) => { @@ -170,6 +172,51 @@ export const Ready: Story = { }, }; +export const RightClickMenu: Story = { + decorators: [withWebSocket], + parameters: { + ...meta.parameters, + webSocket: [ + { + event: "message", + data: "$ echo hello", + }, + ], + queries: [...meta.parameters.queries, createWorkspaceWithAgent("ready")], + }, + play: async ({ canvasElement }) => { + if (isMac()) { + return; + } + + const terminal = await waitFor(() => { + const element = canvasElement.querySelector(".xterm"); + if (!element) { + throw new Error("terminal has not rendered yet"); + } + return element; + }); + + const rect = terminal.getBoundingClientRect(); + terminal.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }), + ); + + const body = within(document.body); + await expect( + await body.findByRole("menuitem", { name: "Copy" }), + ).toBeInTheDocument(); + await expect( + body.getByRole("menuitem", { name: "Paste" }), + ).toBeInTheDocument(); + }, +}; + export const StartError: Story = { decorators: [withWebSocket], parameters: {