Skip to content

Commit cbaa97c

Browse files
authored
feat: add copy on ctrl/command+shift+c and selection to web terminal (coder#20129)
Closes coder#20044
1 parent 141ef23 commit cbaa97c

File tree

1 file changed

+44
-4
lines changed

1 file changed

+44
-4
lines changed

site/src/pages/TerminalPage/TerminalPage.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "api/queries/workspaces";
1515
import { useProxy } from "contexts/ProxyContext";
1616
import { ThemeOverride } from "contexts/ThemeProvider";
17+
import { useClipboard } from "hooks/useClipboard";
1718
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
1819
import { type FC, useCallback, useEffect, useRef, useState } from "react";
1920
import { useQuery } from "react-query";
@@ -80,6 +81,8 @@ const TerminalPage: FC = () => {
8081
const config = useQuery(deploymentConfig());
8182
const renderer = config.data?.config.web_terminal_renderer;
8283

84+
const { copyToClipboard } = useClipboard();
85+
8386
// Periodically report workspace usage.
8487
useQuery(
8588
workspaceUsage({
@@ -147,12 +150,21 @@ const TerminalPage: FC = () => {
147150
}),
148151
);
149152

150-
// Make shift+enter send ^[^M (escaped carriage return). Applications
151-
// typically take this to mean to insert a literal newline. There is no way
152-
// to remove this handler, so we must attach it once and rely on a ref to
153-
// send it to the current socket.
153+
const isMac = navigator.platform.match("Mac");
154+
155+
const copySelection = () => {
156+
const selection = terminal.getSelection();
157+
if (selection) {
158+
copyToClipboard(selection);
159+
}
160+
};
161+
162+
// There is no way to remove this handler, so we must attach it once and
163+
// rely on a ref to send it to the current socket.
154164
const escapedCarriageReturn = "\x1b\r";
155165
terminal.attachCustomKeyEventHandler((ev) => {
166+
// Make shift+enter send ^[^M (escaped carriage return). Applications
167+
// typically take this to mean to insert a literal newline.
156168
if (ev.shiftKey && ev.key === "Enter") {
157169
if (ev.type === "keydown") {
158170
websocketRef.current?.send(
@@ -163,9 +175,36 @@ const TerminalPage: FC = () => {
163175
}
164176
return false;
165177
}
178+
// Make ctrl+shift+c (command+shift+c on macOS) copy the selected text.
179+
// By default this usually launches the browser dev tools, but users
180+
// expect this keybinding to copy when in the context of the web terminal.
181+
if ((isMac ? ev.metaKey : ev.ctrlKey) && ev.shiftKey && ev.key === "C") {
182+
ev.preventDefault();
183+
if (ev.type === "keydown") {
184+
copySelection();
185+
}
186+
return false;
187+
}
166188
return true;
167189
});
168190

191+
// Copy using the clipboard API on selection. This selected text will go
192+
// into the clipboard, not the primary selection, as the browser does not
193+
// give us an API to set the primary selection (only relevant to systems
194+
// that have this distinction, like X11).
195+
//
196+
// We could bind the middle mouse button to paste from the clipboard to
197+
// compensate, but then we would break pasting selections from external
198+
// applications into the web terminal. Not sure which tradeoff is worse; it
199+
// probably varies between users.
200+
//
201+
// In other words, this copied text can be pasted with a keybinding
202+
// (typically ctrl+v, ctrl+shift+v, or shift+insert), but *not* with the
203+
// middle mouse button.
204+
terminal.onSelectionChange(() => {
205+
copySelection();
206+
});
207+
169208
terminal.open(terminalWrapperRef.current);
170209

171210
// We have to fit twice here. It's unknown why, but the first fit will
@@ -189,6 +228,7 @@ const TerminalPage: FC = () => {
189228
renderer,
190229
theme.palette.background.default,
191230
currentTerminalFont,
231+
copyToClipboard,
192232
]);
193233

194234
// Updates the reconnection token into the URL if necessary.

0 commit comments

Comments
 (0)