merge: use ctrl shift c for terminal output copy
This commit is contained in:
@@ -33,25 +33,28 @@ describe("getTerminalScrollbackLimit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("shouldCopyTerminalSelection", () => {
|
describe("shouldCopyTerminalSelection", () => {
|
||||||
it("copies a selected terminal region with Ctrl+C or Cmd+C", () => {
|
it("copies a selected terminal region with Ctrl+Shift+C", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldCopyTerminalSelection(
|
shouldCopyTerminalSelection(
|
||||||
{ ctrlKey: true, metaKey: false, key: "c" },
|
{ ctrlKey: true, shiftKey: true, key: "c" },
|
||||||
true,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
shouldCopyTerminalSelection(
|
|
||||||
{ ctrlKey: false, metaKey: true, key: "C" },
|
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Ctrl+C as a terminal interrupt without a selection", () => {
|
it("keeps Ctrl+C as a terminal interrupt", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldCopyTerminalSelection(
|
shouldCopyTerminalSelection(
|
||||||
{ ctrlKey: true, metaKey: false, key: "c" },
|
{ 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,
|
false,
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|||||||
@@ -69,12 +69,13 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldCopyTerminalSelection(
|
export function shouldCopyTerminalSelection(
|
||||||
event: Pick<KeyboardEvent, "ctrlKey" | "metaKey" | "key">,
|
event: Pick<KeyboardEvent, "ctrlKey" | "shiftKey" | "key">,
|
||||||
hasSelection: boolean,
|
hasSelection: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
hasSelection &&
|
hasSelection &&
|
||||||
(event.ctrlKey || event.metaKey) &&
|
event.ctrlKey &&
|
||||||
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === "c"
|
event.key.toLowerCase() === "c"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -483,24 +484,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
const copySelection = () => {
|
const copySelection = () => {
|
||||||
const selection = term.getSelection();
|
const selection = term.getSelection();
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
const clipboard = navigator.clipboard;
|
|
||||||
if (clipboard) {
|
const textarea = document.createElement("textarea");
|
||||||
void clipboard.writeText(selection).catch(() => {
|
textarea.value = selection;
|
||||||
document.execCommand("copy");
|
textarea.setAttribute("readonly", "");
|
||||||
});
|
textarea.style.position = "fixed";
|
||||||
} else {
|
textarea.style.opacity = "0";
|
||||||
document.execCommand("copy");
|
document.body.appendChild(textarea);
|
||||||
}
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
textarea.remove();
|
||||||
};
|
};
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
const handleBrowserCopyShortcut = (event: KeyboardEvent) => {
|
||||||
if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
|
if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
copySelection();
|
copySelection();
|
||||||
return false;
|
};
|
||||||
});
|
|
||||||
|
|
||||||
const handleBrowserPaste = (event: ClipboardEvent) => {
|
const handleBrowserPaste = (event: ClipboardEvent) => {
|
||||||
const text = event.clipboardData?.getData("text/plain");
|
const text = event.clipboardData?.getData("text/plain");
|
||||||
@@ -513,6 +516,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("paste", handleBrowserPaste, true);
|
container.addEventListener("paste", handleBrowserPaste, true);
|
||||||
|
|
||||||
// Mobile touch scroll.
|
// Mobile touch scroll.
|
||||||
@@ -761,6 +765,11 @@ 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(
|
||||||
|
"keydown",
|
||||||
|
handleBrowserCopyShortcut,
|
||||||
|
true,
|
||||||
|
);
|
||||||
container.removeEventListener("paste", handleBrowserPaste, true);
|
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||||
pasteTextRef.current = () => {};
|
pasteTextRef.current = () => {};
|
||||||
bracketedPasteEnabledRef.current = false;
|
bracketedPasteEnabledRef.current = false;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ Users cannot reliably copy terminal output from the browser terminal. Browser co
|
|||||||
|
|
||||||
## Required behavior
|
## Required behavior
|
||||||
|
|
||||||
- Copying a selected terminal region with standard browser shortcuts (`Ctrl/Cmd+C`) copies text to the system clipboard without sending an interrupt to the terminal.
|
- Copying a selected terminal region with `Ctrl+Shift+C` copies text to the system clipboard without sending input to the terminal.
|
||||||
|
- `Ctrl+C` remains a terminal interrupt, including when output is selected.
|
||||||
- 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