fix(terminal): add right click copy action
Replace the conflicting keyboard copy shortcut with an xterm-aware Copy action shown for selected output.
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
getTerminalScrollbackLimit,
|
||||
isCurrentWebSocket,
|
||||
shouldRetryWebSocketClose,
|
||||
shouldCopyTerminalSelection,
|
||||
shouldShowTerminalCopyMenu,
|
||||
} from "./terminal.tsx";
|
||||
|
||||
describe("getTerminalScrollbackLimit", () => {
|
||||
@@ -32,31 +32,12 @@ describe("getTerminalScrollbackLimit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldCopyTerminalSelection", () => {
|
||||
it("copies a selected terminal region with Ctrl+Shift+C", () => {
|
||||
expect(
|
||||
shouldCopyTerminalSelection(
|
||||
{ ctrlKey: true, shiftKey: true, key: "c" },
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
describe("shouldShowTerminalCopyMenu", () => {
|
||||
it("shows Copy for selected terminal output", () => {
|
||||
expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Ctrl+C as a terminal interrupt", () => {
|
||||
expect(
|
||||
shouldCopyTerminalSelection(
|
||||
{ ctrlKey: true, shiftKey: false, key: "c" },
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not copy without a selection", () => {
|
||||
expect(
|
||||
shouldCopyTerminalSelection(
|
||||
{ ctrlKey: true, shiftKey: true, key: "c" },
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
it("keeps the native context menu when no output is selected", () => {
|
||||
expect(shouldShowTerminalCopyMenu("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,16 +68,20 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
|
||||
return code !== 1000 && !(code === 4000 && reason === "New connection established");
|
||||
}
|
||||
|
||||
export function shouldCopyTerminalSelection(
|
||||
event: Pick<KeyboardEvent, "ctrlKey" | "shiftKey" | "key">,
|
||||
hasSelection: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
hasSelection &&
|
||||
event.ctrlKey &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === "c"
|
||||
);
|
||||
export function shouldShowTerminalCopyMenu(selection: string): boolean {
|
||||
return selection.length > 0;
|
||||
}
|
||||
|
||||
function copyTextWithFallback(text: string): void {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
}
|
||||
|
||||
function matchesByteSequence(
|
||||
@@ -119,6 +123,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [copyMenu, setCopyMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const activeModifierRef = useRef(activeModifier);
|
||||
activeModifierRef.current = activeModifier;
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
@@ -481,28 +490,15 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
event.preventDefault();
|
||||
event.clipboardData?.setData("text/plain", selection);
|
||||
};
|
||||
const copySelection = () => {
|
||||
const handleTerminalContextMenu = (event: MouseEvent) => {
|
||||
const selection = term.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = selection;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
};
|
||||
const handleBrowserCopyShortcut = (event: KeyboardEvent) => {
|
||||
if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
|
||||
if (!shouldShowTerminalCopyMenu(selection)) {
|
||||
setCopyMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copySelection();
|
||||
setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
|
||||
};
|
||||
|
||||
const handleBrowserPaste = (event: ClipboardEvent) => {
|
||||
@@ -516,7 +512,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
pasteTextRef.current(text);
|
||||
};
|
||||
container.addEventListener("copy", handleBrowserCopy, true);
|
||||
container.addEventListener("keydown", handleBrowserCopyShortcut, true);
|
||||
container.addEventListener("contextmenu", handleTerminalContextMenu);
|
||||
container.addEventListener("paste", handleBrowserPaste, true);
|
||||
|
||||
// Mobile touch scroll.
|
||||
@@ -765,11 +761,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
);
|
||||
if (touchCleanup) touchCleanup();
|
||||
container.removeEventListener("copy", handleBrowserCopy, true);
|
||||
container.removeEventListener(
|
||||
"keydown",
|
||||
handleBrowserCopyShortcut,
|
||||
true,
|
||||
);
|
||||
container.removeEventListener("contextmenu", handleTerminalContextMenu);
|
||||
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||
pasteTextRef.current = () => {};
|
||||
bracketedPasteEnabledRef.current = false;
|
||||
@@ -904,6 +896,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
|
||||
// Focus terminal on mobile to keep keyboard open
|
||||
const handleTerminalClick = () => {
|
||||
setCopyMenu(null);
|
||||
if (isMobile && termRef.current) {
|
||||
termRef.current.focus();
|
||||
}
|
||||
@@ -1018,6 +1011,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{copyMenu && (
|
||||
<button
|
||||
aria-label="Copy selected terminal text"
|
||||
className="terminal-context-copy"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
copyTextWithFallback(copyMenu.text);
|
||||
setCopyMenu(null);
|
||||
}}
|
||||
style={{
|
||||
left: copyMenu.x,
|
||||
position: "fixed",
|
||||
top: copyMenu.y,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<div className="terminal-error">
|
||||
{error}
|
||||
|
||||
Reference in New Issue
Block a user