@@ -14,6 +14,7 @@ import {
1414} from "api/queries/workspaces" ;
1515import { useProxy } from "contexts/ProxyContext" ;
1616import { ThemeOverride } from "contexts/ThemeProvider" ;
17+ import { useClipboard } from "hooks/useClipboard" ;
1718import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata" ;
1819import { type FC , useCallback , useEffect , useRef , useState } from "react" ;
1920import { useQuery } from "react-query" ;
@@ -80,6 +81,8 @@ const TerminalPage: FC = () => {
8081const config = useQuery ( deploymentConfig ( ) ) ;
8182const renderer = config . data ?. config . web_terminal_renderer ;
8283
84+ const { copyToClipboard} = useClipboard ( ) ;
85+
8386// Periodically report workspace usage.
8487useQuery (
8588workspaceUsage ( {
@@ -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.
154164const escapedCarriageReturn = "\x1b\r" ;
155165terminal . attachCustomKeyEventHandler ( ( ev ) => {
166+ // Make shift+enter send ^[^M (escaped carriage return). Applications
167+ // typically take this to mean to insert a literal newline.
156168if ( ev . shiftKey && ev . key === "Enter" ) {
157169if ( ev . type === "keydown" ) {
158170websocketRef . current ?. send (
@@ -163,9 +175,36 @@ const TerminalPage: FC = () => {
163175}
164176return 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+ }
166188return 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+
169208terminal . 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 = () => {
189228renderer ,
190229theme . palette . background . default ,
191230currentTerminalFont ,
231+ copyToClipboard ,
192232] ) ;
193233
194234// Updates the reconnection token into the URL if necessary.