mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): add a custom copy/paste menu to the web terminal (#26015)
## Summary Fixes [CODAGT-415](https://linear.app/codercom/issue/CODAGT-415). Right-clicking selected text in the web terminal on Windows (and Linux) showed the browser's image actions ("Copy image", "Save image as") instead of copy/paste. The terminal uses xterm.js with the canvas/WebGL renderer, so the underlying element is a `<canvas>`, which Chromium and Firefox treat as an image. xterm.js tries to retarget the menu by moving a hidden textarea under the cursor, but on Windows and Linux the browser's own non-native context menu locks onto the canvas before that workaround lands. ## Change Wrap the terminal in the shared Radix `ContextMenu` so right-click shows a custom **Copy** / **Paste** menu instead of the browser default: - **Copy** reuses the existing copy-on-select clipboard path (`getSelection()` + `copyToClipboard`). It is disabled when there is no selection. - **Paste** reads the clipboard and uses xterm's `paste()`, which respects bracketed-paste mode. - The menu is gated to non-macOS (`disabled={isMac()}` on the trigger). macOS renders native context menus that already expose working copy/paste across Chrome, Firefox, and Safari, so its default is left untouched. ## Platform scope | Platform | Behavior | | --- | --- | | Windows (Chromium / Firefox) | Custom Copy/Paste menu (fixes the bug) | | Linux (Chromium / Firefox) | Custom Copy/Paste menu | | macOS (Chrome / Firefox / Safari) | Native menu preserved (already works) | ## Testing - `TerminalPage.test.tsx`: on non-macOS, right-click suppresses the native menu and shows the Copy/Paste menu; on macOS the native menu is preserved. - `TerminalPage.stories.tsx`: new `RightClickMenu` story opens the menu via a `play` function for real-browser and visual coverage. - `tsc`, `biome`, and `make pre-commit` (gen/fmt/lint/build) pass locally. <details> <summary>Decision log</summary> - The issue was originally reported as Windows-only. Hands-on testing confirmed macOS is not affected: Chrome, Firefox, and Safari on macOS all show a working copy/paste menu. The difference is the menu implementation: macOS uses native OS context menus (which pick up xterm's repositioned textarea), while Chromium/Firefox on Windows and Linux draw their own menu that targets the `<canvas>` directly. - Root cause is the canvas/WebGL renderer plus the unreliability of xterm's textarea-repositioning workaround on non-native menus, not the operating system itself. - A custom menu (rather than just `preventDefault`) was chosen so users keep an explicit copy/paste affordance on the affected platforms. A bare `preventDefault` removes the menu entirely. - Scope is gated to non-macOS to avoid regressing the working native menu on macOS. Rejected alternatives: suppressing/replacing on all platforms (regresses macOS), and Windows-only (misses Linux, which shares the same non-native menu). </details> --- Generated by Coder Agents on behalf of @jaaydenh.
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -13,6 +13,13 @@ export type UseClipboardInput = Readonly<{
|
||||
|
||||
export type UseClipboardResult = Readonly<{
|
||||
copyToClipboard: (textToCopy: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Reads text from the clipboard. When the asynchronous Clipboard API is
|
||||
* available (secure contexts), it returns the live clipboard contents.
|
||||
*/
|
||||
readFromClipboard: () => Promise<string>;
|
||||
|
||||
error: Error | undefined;
|
||||
|
||||
/**
|
||||
@@ -44,6 +51,7 @@ export const useClipboard = (
|
||||
const [showCopiedSuccess, setShowCopiedSuccess] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const timeoutIdRef = useRef<number | undefined>(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<string> => {
|
||||
// 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 };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Terminal>();
|
||||
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));
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className={cn(
|
||||
"workspace-terminal h-full w-full flex-1 min-h-0 overflow-hidden bg-surface-tertiary",
|
||||
className,
|
||||
)}
|
||||
ref={terminalWrapperRef}
|
||||
data-terminal-scope={scopeId}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
|
||||
<ContextMenuTrigger asChild disabled={isMac()}>
|
||||
<div
|
||||
className={cn(
|
||||
"workspace-terminal h-full w-full flex-1 min-h-0 overflow-hidden bg-surface-tertiary",
|
||||
className,
|
||||
)}
|
||||
ref={terminalWrapperRef}
|
||||
data-terminal-scope={scopeId}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
terminal?.focus();
|
||||
}}
|
||||
>
|
||||
<ContextMenuItem
|
||||
disabled={!hasSelection}
|
||||
onSelect={copyTerminalSelection}
|
||||
>
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => void pasteIntoTerminal()}>
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<HTMLElement>(".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: {
|
||||
|
||||
Reference in New Issue
Block a user