Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf561186fb | |||
| 0afce741eb | |||
| 7603b739cb | |||
| ff8efdd887 | |||
| b289edf5a9 | |||
| 75b67f2a6f |
@@ -4,7 +4,7 @@ import {
|
|||||||
getTerminalScrollbackLimit,
|
getTerminalScrollbackLimit,
|
||||||
isCurrentWebSocket,
|
isCurrentWebSocket,
|
||||||
shouldRetryWebSocketClose,
|
shouldRetryWebSocketClose,
|
||||||
shouldCopyTerminalSelection,
|
shouldShowTerminalCopyMenu,
|
||||||
} from "./terminal.tsx";
|
} from "./terminal.tsx";
|
||||||
|
|
||||||
describe("getTerminalScrollbackLimit", () => {
|
describe("getTerminalScrollbackLimit", () => {
|
||||||
@@ -32,31 +32,12 @@ describe("getTerminalScrollbackLimit", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shouldCopyTerminalSelection", () => {
|
describe("shouldShowTerminalCopyMenu", () => {
|
||||||
it("copies a selected terminal region with Ctrl+Shift+C", () => {
|
it("shows Copy for selected terminal output", () => {
|
||||||
expect(
|
expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
|
||||||
shouldCopyTerminalSelection(
|
|
||||||
{ ctrlKey: true, shiftKey: true, key: "c" },
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Ctrl+C as a terminal interrupt", () => {
|
it("keeps the native context menu when no output is selected", () => {
|
||||||
expect(
|
expect(shouldShowTerminalCopyMenu("")).toBe(false);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,16 +68,20 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
|
|||||||
return code !== 1000 && !(code === 4000 && reason === "New connection established");
|
return code !== 1000 && !(code === 4000 && reason === "New connection established");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldCopyTerminalSelection(
|
export function shouldShowTerminalCopyMenu(selection: string): boolean {
|
||||||
event: Pick<KeyboardEvent, "ctrlKey" | "shiftKey" | "key">,
|
return selection.length > 0;
|
||||||
hasSelection: boolean,
|
}
|
||||||
): boolean {
|
|
||||||
return (
|
function copyTextWithFallback(text: string): void {
|
||||||
hasSelection &&
|
const textarea = document.createElement("textarea");
|
||||||
event.ctrlKey &&
|
textarea.value = text;
|
||||||
event.shiftKey &&
|
textarea.setAttribute("readonly", "");
|
||||||
event.key.toLowerCase() === "c"
|
textarea.style.position = "fixed";
|
||||||
);
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
textarea.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesByteSequence(
|
function matchesByteSequence(
|
||||||
@@ -119,6 +123,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
>("connecting");
|
>("connecting");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [copyMenu, setCopyMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
const activeModifierRef = useRef(activeModifier);
|
const activeModifierRef = useRef(activeModifier);
|
||||||
activeModifierRef.current = activeModifier;
|
activeModifierRef.current = activeModifier;
|
||||||
const [fontSize, setFontSize] = useState(() => {
|
const [fontSize, setFontSize] = useState(() => {
|
||||||
@@ -481,28 +490,17 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.clipboardData?.setData("text/plain", selection);
|
event.clipboardData?.setData("text/plain", selection);
|
||||||
};
|
};
|
||||||
const copySelection = () => {
|
const handleTerminalContextMenu = (event: MouseEvent) => {
|
||||||
const selection = term.getSelection();
|
const selection = term.getSelection();
|
||||||
if (!selection) return;
|
if (!shouldShowTerminalCopyMenu(selection)) {
|
||||||
|
setCopyMenu(null);
|
||||||
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())) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
copySelection();
|
setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBrowserPaste = (event: ClipboardEvent) => {
|
const handleBrowserPaste = (event: ClipboardEvent) => {
|
||||||
@@ -516,7 +514,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
pasteTextRef.current(text);
|
pasteTextRef.current(text);
|
||||||
};
|
};
|
||||||
container.addEventListener("copy", handleBrowserCopy, true);
|
container.addEventListener("copy", handleBrowserCopy, true);
|
||||||
container.addEventListener("keydown", handleBrowserCopyShortcut, true);
|
container.addEventListener("contextmenu", handleTerminalContextMenu, true);
|
||||||
container.addEventListener("paste", handleBrowserPaste, true);
|
container.addEventListener("paste", handleBrowserPaste, true);
|
||||||
|
|
||||||
// Mobile touch scroll.
|
// Mobile touch scroll.
|
||||||
@@ -766,8 +764,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
if (touchCleanup) touchCleanup();
|
if (touchCleanup) touchCleanup();
|
||||||
container.removeEventListener("copy", handleBrowserCopy, true);
|
container.removeEventListener("copy", handleBrowserCopy, true);
|
||||||
container.removeEventListener(
|
container.removeEventListener(
|
||||||
"keydown",
|
"contextmenu",
|
||||||
handleBrowserCopyShortcut,
|
handleTerminalContextMenu,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
container.removeEventListener("paste", handleBrowserPaste, true);
|
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||||
@@ -904,6 +902,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
|
|
||||||
// Focus terminal on mobile to keep keyboard open
|
// Focus terminal on mobile to keep keyboard open
|
||||||
const handleTerminalClick = () => {
|
const handleTerminalClick = () => {
|
||||||
|
setCopyMenu(null);
|
||||||
if (isMobile && termRef.current) {
|
if (isMobile && termRef.current) {
|
||||||
termRef.current.focus();
|
termRef.current.focus();
|
||||||
}
|
}
|
||||||
@@ -1018,6 +1017,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{error && (
|
||||||
<div className="terminal-error">
|
<div className="terminal-error">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ Users cannot reliably copy terminal output from the browser terminal. Browser co
|
|||||||
|
|
||||||
## Required behavior
|
## Required behavior
|
||||||
|
|
||||||
- Copying a selected terminal region with `Ctrl+Shift+C` copies text to the system clipboard without sending input to the terminal.
|
- Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard.
|
||||||
- `Ctrl+C` remains a terminal interrupt, including when output is selected.
|
- Browser native context menus remain available when no terminal output is selected.
|
||||||
|
- Terminal keyboard input, including `Ctrl+C`, remains unchanged.
|
||||||
- Copy requests expose the xterm selection as plain text.
|
- Copy requests expose the xterm selection as plain text.
|
||||||
- Pasting plain text is handled once and follows bracketed-paste mode when enabled.
|
- Pasting plain text is handled once and follows bracketed-paste mode when enabled.
|
||||||
- Normal terminal interrupts still work when there is no active selection.
|
- Normal terminal interrupts still work when there is no active selection.
|
||||||
|
|||||||
Reference in New Issue
Block a user