Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7603b739cb | |||
| ff8efdd887 | |||
| b289edf5a9 | |||
| 75b67f2a6f | |||
| bcc7486b59 | |||
| 29e765b2be | |||
| d14cdc1151 | |||
| c80dbf9737 |
@@ -4,6 +4,7 @@ import {
|
|||||||
getTerminalScrollbackLimit,
|
getTerminalScrollbackLimit,
|
||||||
isCurrentWebSocket,
|
isCurrentWebSocket,
|
||||||
shouldRetryWebSocketClose,
|
shouldRetryWebSocketClose,
|
||||||
|
shouldShowTerminalCopyMenu,
|
||||||
} from "./terminal.tsx";
|
} from "./terminal.tsx";
|
||||||
|
|
||||||
describe("getTerminalScrollbackLimit", () => {
|
describe("getTerminalScrollbackLimit", () => {
|
||||||
@@ -30,3 +31,13 @@ describe("getTerminalScrollbackLimit", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldShowTerminalCopyMenu", () => {
|
||||||
|
it("shows Copy for selected terminal output", () => {
|
||||||
|
expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the native context menu when no output is selected", () => {
|
||||||
|
expect(shouldShowTerminalCopyMenu("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,22 @@ 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 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(
|
function matchesByteSequence(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
start: number,
|
start: number,
|
||||||
@@ -107,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(() => {
|
||||||
@@ -462,8 +483,25 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
term.paste(text);
|
term.paste(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBrowserCopy = (event: ClipboardEvent) => {
|
||||||
|
const selection = term.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.clipboardData?.setData("text/plain", selection);
|
||||||
|
};
|
||||||
|
const handleTerminalContextMenu = (event: MouseEvent) => {
|
||||||
|
const selection = term.getSelection();
|
||||||
|
if (!shouldShowTerminalCopyMenu(selection)) {
|
||||||
|
setCopyMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
|
||||||
|
};
|
||||||
|
|
||||||
const handleBrowserPaste = (event: ClipboardEvent) => {
|
const handleBrowserPaste = (event: ClipboardEvent) => {
|
||||||
if (!bracketedPasteEnabledRef.current) return;
|
|
||||||
const text = event.clipboardData?.getData("text/plain");
|
const text = event.clipboardData?.getData("text/plain");
|
||||||
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
|
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
@@ -473,6 +511,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
pasteTextRef.current(text);
|
pasteTextRef.current(text);
|
||||||
};
|
};
|
||||||
|
container.addEventListener("copy", handleBrowserCopy, true);
|
||||||
|
container.addEventListener("contextmenu", handleTerminalContextMenu, true);
|
||||||
container.addEventListener("paste", handleBrowserPaste, true);
|
container.addEventListener("paste", handleBrowserPaste, true);
|
||||||
|
|
||||||
// Mobile touch scroll.
|
// Mobile touch scroll.
|
||||||
@@ -720,6 +760,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
handleVisibilityChange,
|
handleVisibilityChange,
|
||||||
);
|
);
|
||||||
if (touchCleanup) touchCleanup();
|
if (touchCleanup) touchCleanup();
|
||||||
|
container.removeEventListener("copy", handleBrowserCopy, true);
|
||||||
|
container.removeEventListener(
|
||||||
|
"contextmenu",
|
||||||
|
handleTerminalContextMenu,
|
||||||
|
true,
|
||||||
|
);
|
||||||
container.removeEventListener("paste", handleBrowserPaste, true);
|
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||||
pasteTextRef.current = () => {};
|
pasteTextRef.current = () => {};
|
||||||
bracketedPasteEnabledRef.current = false;
|
bracketedPasteEnabledRef.current = false;
|
||||||
@@ -854,6 +900,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();
|
||||||
}
|
}
|
||||||
@@ -968,6 +1015,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}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Fix Web Terminal Clipboard
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users cannot reliably copy terminal output from the browser terminal. Browser copy shortcuts may be forwarded to the terminal as input instead of copying the xterm selection, and paste behavior differs by bracketed-paste mode.
|
||||||
|
|
||||||
|
## Required behavior
|
||||||
|
|
||||||
|
- Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard.
|
||||||
|
- 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.
|
||||||
|
- Pasting plain text is handled once and follows bracketed-paste mode when enabled.
|
||||||
|
- Normal terminal interrupts still work when there is no active selection.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Web Terminal Clipboard Tasks
|
||||||
|
|
||||||
|
- [x] Add selected-text copy interception without suppressing unselected terminal interrupts.
|
||||||
|
- [x] Normalize browser paste handling through the existing paste transport.
|
||||||
|
- [x] Add focused frontend coverage for copy shortcut decisions.
|
||||||
|
- [x] Run frontend tests, typecheck/build, lint, and diagnostics.
|
||||||
Reference in New Issue
Block a user