LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-components-terminal.tsx Coverage Total Hit
Test: cockpit Lines: 86.9 % 297 258
Test Date: 2026-07-02 14:11:36

            Line data    Source code
       1            6 : /*
       2              :  * Copyright (C) 2016 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6            6 : import React from "react";
       7              : import {
       8              :     Modal, ModalBody, ModalFooter, ModalHeader
       9              : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
      10              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      11              : import { MenuList, MenuItem } from "@patternfly/react-core/dist/esm/components/Menu";
      12              : import { WebglAddon } from '@xterm/addon-webgl';
      13              : import { Terminal as Term } from "@xterm/xterm";
      14              : 
      15              : import { ContextMenu } from "cockpit-components-context-menu.jsx";
      16            6 : import cockpit from "cockpit";
      17              : 
      18              : import "console.css";
      19              : 
      20            6 : const _ = cockpit.gettext;
      21              : 
      22            6 : const theme_core = {
      23            6 :     yellow: "#b58900",
      24            6 :     brightRed: "#cb4b16",
      25            6 :     red: "#dc322f",
      26            6 :     magenta: "#d33682",
      27            6 :     brightMagenta: "#6c71c4",
      28            6 :     blue: "#268bd2",
      29            6 :     cyan: "#2aa198",
      30            6 :     green: "#859900"
      31            6 : };
      32              : 
      33            6 : const themes = {
      34            6 :     "black-theme": {
      35            6 :         background: "#000000",
      36            6 :         foreground: "#ffffff"
      37            6 :     },
      38            6 :     "dark-theme": Object.assign({}, theme_core, {
      39            6 :         background: "#002b36",
      40            6 :         foreground: "#fdf6e3",
      41            6 :         cursor: "#eee8d5",
      42            6 :         selection: "#ffffff77",
      43            6 :         brightBlack: "#002b36",
      44            6 :         black: "#073642",
      45            6 :         brightGreen: "#586e75",
      46            6 :         brightYellow: "#657b83",
      47            6 :         brightBlue: "#839496",
      48            6 :         brightCyan: "#93a1a1",
      49            6 :         white: "#eee8d5",
      50            6 :         brightWhite: "#fdf6e3"
      51            6 :     }),
      52            6 :     "light-theme": Object.assign({}, theme_core, {
      53            6 :         background: "#fdf6e3",
      54            6 :         foreground: "#002b36",
      55            6 :         cursor: "#073642",
      56            6 :         selection: "#00000044",
      57            6 :         brightWhite: "#002b36",
      58            6 :         white: "#073642",
      59            6 :         brightCyan: "#586e75",
      60            6 :         brightBlue: "#657b83",
      61            6 :         brightYellow: "#839496",
      62            6 :         brightGreen: "#93a1a1",
      63            6 :         black: "#eee8d5",
      64            6 :         brightBlack: "#fdf6e3"
      65            6 :     }),
      66            6 :     "white-theme": {
      67            6 :         background: "#ffffff",
      68            6 :         foreground: "#000000",
      69            6 :         selection: "#00000044",
      70            6 :         cursor: "#000000",
      71            6 :     },
      72            6 : };
      73              : 
      74              : export type TerminalTheme = keyof typeof themes;
      75              : 
      76              : /*
      77              :  * A terminal component that communicates over a cockpit channel.
      78              :  *
      79              :  * The state of a terminal component can be managed separately from
      80              :  * it. This allows a terminal to stay alive and keep its content while
      81              :  * it is not actually part of the DOM.
      82              :  *
      83              :  * This is done by creating a TerminalState object for the channel,
      84              :  * and then passing this object into a Terminal component via the
      85              :  * "state" property.  You can dispose of the TerminalState object by
      86              :  * calling its close() method. This will also close the channel.
      87              :  *
      88              :  * (So instead of managing the lifetime of a Cockpit channel, you
      89              :  * manage the lifetime of a TerminalState wrapper for the channel, in
      90              :  * exactly the same way.)
      91              :  *
      92              :  * If you don't need to keep a terminal alive while it is not part of
      93              :  * the DOM, you can pass the channel directly into the Terminal
      94              :  * component via the "channel" property.  The Terminal component will
      95              :  * then maintain a internal TerminalState wrapper for the channel.
      96              :  *
      97              :  * The "state" and "channel" properties are of course mutually
      98              :  * exclusive: You can only use one of them for a given Terminal
      99              :  * component.  Also, switching from one to the other over the lifetime
     100              :  * of a Terminal component is not supported.
     101              :  *
     102              :  * The size of the terminal can be set with the 'rows' and 'cols'
     103              :  * properties. If those properties are not given, the terminal will
     104              :  * fill its container.
     105              :  *
     106              :  * If the 'onTitleChanged' callback property is set, it will be called
     107              :  * whenever the title of the terminal changes.
     108              :  *
     109              :  * Call focus() on the Terminal component to set the input focus on
     110              :  * the terminal, or reset() to clear it.
     111              :  *
     112              :  * Also it is possible to set up theme by property 'theme'.
     113              :  */
     114              : 
     115            6 : export class TerminalState {
     116              :     terminal: Term;
     117              :     wrapper_element: HTMLDivElement;
     118              :     channel: cockpit.Channel<string>;
     119              : 
     120            6 :     constructor(channel: cockpit.Channel<string>) {
     121            6 :         this.terminal = new Term({
     122            6 :             cols: 80,
     123            6 :             rows: 1,
     124            6 :             cursorBlink: true,
     125            6 :             fontSize: 16,
     126            6 :             fontFamily: 'Menlo, Monaco, Consolas, monospace',
     127            6 :             screenReaderMode: true,
     128            6 :         });
     129            6 :         this.terminal.loadAddon(new WebglAddon());
     130            6 :         this.wrapper_element = document.createElement("div");
     131            6 :         this.channel = channel;
     132            6 :         this.#connectChannel(channel);
     133            6 :     }
     134              : 
     135            6 :     #connectChannel(channel: cockpit.Channel<string>) {
     136            6 :         channel.addEventListener('message', (_event, data) => {
     137            6 :             this.terminal.write(data);
     138            6 :         });
     139              : 
     140            3 :         this.terminal.onData(data => {
     141            3 :             if (channel.valid) {
     142              :                 /* HACK: Ctrl+Space (and possibly other
     143              :                  * characters) is a disaster: While it is U+00A0
     144              :                  * in unicode, with an UTF-8 representation of
     145              :                  * 0xC2A0, the "visible" string in JS is 0x00
     146              :                  * (with TextEncoder, btoa(), and string
     147              :                  * comparison). The internal representation
     148              :                  * retains half of it, and trying to send it to
     149              :                  * the websocket would result in a single 0xA0,
     150              :                  * which is invalid UTF-8 (and causes the session
     151              :                  * to crash). So intercept and ignore such broken
     152              :                  * chars.  See
     153              :                  * https://github.com/cockpit-project/cockpit/issues/21213 */
     154            0 :                 if (data === '\x00') {
     155            0 :                     console.log("terminal: ignoring invalid input", data);
     156            0 :                     return;
     157            0 :                 }
     158            3 :                 channel.send(data);
     159            3 :             }
     160            3 :         });
     161              : 
     162            2 :         channel.addEventListener('close', (_event, options) => {
     163            2 :             const term = this.terminal;
     164            2 :             term.write('\x1b[31m' + (options.problem || 'disconnected') + '\x1b[m\r\n');
     165            2 :             term.refresh(term.rows, term.rows);
     166            2 :         });
     167            6 :     }
     168              : 
     169            2 :     resetChannel(channel: cockpit.Channel<string>) {
     170            2 :         this.channel.close();
     171            2 :         this.terminal.reset();
     172            2 :         this.channel = channel;
     173            2 :         this.#connectChannel(channel);
     174            2 :     }
     175              : 
     176            1 :     close() {
     177            1 :         this.channel.close();
     178            1 :         this.terminal.dispose();
     179            1 :     }
     180            6 : }
     181              : 
     182              : interface TerminalComponentProps {
     183              :     parentId: string;
     184              :     state?: TerminalState;
     185              :     channel?: cockpit.Channel<string>;
     186              :     onTitleChanged?: (title: string) => void;
     187              :     fontSize?: number;
     188              :     rows?: number;
     189              :     cols?: number;
     190              :     theme?: TerminalTheme;
     191              : }
     192              : 
     193              : interface TerminalComponentState {
     194              :     showPastingModal: boolean,
     195              :     cols: number,
     196              :     rows: number
     197              : }
     198              : 
     199            6 : export class Terminal extends React.Component<TerminalComponentProps, TerminalComponentState> {
     200              :     terminal_state: TerminalState;
     201              :     terminalRef: React.RefObject<HTMLDivElement>;
     202              :     terminal: Term;
     203              : 
     204            6 :     constructor(props: TerminalComponentProps) {
     205            6 :         super(props);
     206            6 :         this.reset = this.reset.bind(this);
     207            6 :         this.focus = this.focus.bind(this);
     208            6 :         this.onWindowResize = this.onWindowResize.bind(this);
     209            6 :         this.resizeTerminal = this.resizeTerminal.bind(this);
     210            6 :         this.onFocusIn = this.onFocusIn.bind(this);
     211            6 :         this.onFocusOut = this.onFocusOut.bind(this);
     212            6 :         this.setText = this.setText.bind(this);
     213            6 :         this.getText = this.getText.bind(this);
     214            6 :         this.setTerminalTheme = this.setTerminalTheme.bind(this);
     215              : 
     216            2 :         if (this.props.state) {
     217            2 :             cockpit.assert(!this.props.channel);
     218            2 :             this.terminal_state = this.props.state;
     219            1 :         } else {
     220            5 :             cockpit.assert(this.props.channel);
     221            5 :             this.terminal_state = new TerminalState(this.props.channel);
     222            5 :         }
     223              : 
     224            6 :         const term = this.terminal_state.terminal;
     225              : 
     226            6 :         this.terminalRef = React.createRef<HTMLDivElement>();
     227              : 
     228            6 :         if (props.onTitleChanged)
     229            5 :             term.onTitleChange(props.onTitleChanged);
     230              : 
     231            6 :         this.terminal = term;
     232            6 :         this.state = {
     233            6 :             showPastingModal: false,
     234            6 :             cols: term.cols,
     235            6 :             rows: term.rows
     236            6 :         };
     237            6 :     }
     238              : 
     239            6 :     mountTerminal(state: TerminalState) {
     240            6 :         this.terminal = state.terminal;
     241            6 :         this.terminalRef.current?.appendChild(state.wrapper_element);
     242            6 :         this.terminal.open(state.wrapper_element);
     243              : 
     244            6 :         if (this.props.fontSize)
     245            5 :             this.terminal.options.fontSize = this.props.fontSize;
     246              : 
     247            2 :         if (this.props.cols && this.props.rows) {
     248            2 :             this.resizeTerminal(this.props.cols, this.props.rows);
     249            2 :         }
     250              : 
     251            2 :         this.setTerminalTheme(this.props.theme || 'black-theme');
     252            6 :         this.terminal.focus();
     253            6 :     }
     254              : 
     255            1 :     unmountTerminal(state: TerminalState) {
     256            1 :         this.terminalRef.current?.removeChild(state.wrapper_element);
     257              :         // This makes sure that the terminal will not cause its new
     258              :         // container to grow when it is reattached later.
     259            1 :         if (!this.props.rows)
     260            0 :             this.resizeTerminal(80, 1);
     261            1 :     }
     262              : 
     263            6 :     componentDidMount() {
     264            6 :         this.mountTerminal(this.terminal_state);
     265            5 :         if (!this.props.rows) {
     266            5 :             window.addEventListener('resize', this.onWindowResize);
     267            5 :             this.onWindowResize();
     268            5 :         }
     269            6 :     }
     270              : 
     271            6 :     resizeTerminal(cols: number, rows: number) {
     272            6 :         this.terminal.resize(cols, rows);
     273            6 :         if (this.terminal_state.channel) {
     274            6 :             this.terminal_state.channel.control({
     275            6 :                 window: {
     276            6 :                     rows,
     277            6 :                     cols
     278            6 :                 }
     279            6 :             } as cockpit.JsonObject as cockpit.ControlMessage);
     280            6 :         }
     281            6 :     }
     282              : 
     283            6 :     componentDidUpdate(prevProps: TerminalComponentProps, prevState: TerminalComponentState) {
     284            2 :         if (this.props.state && prevProps.state !== this.props.state) {
     285            2 :             cockpit.assert(!this.props.channel);
     286            2 :             cockpit.assert(prevProps.state);
     287            2 :             this.unmountTerminal(prevProps.state);
     288            2 :             this.terminal_state = this.props.state;
     289            2 :             this.mountTerminal(this.terminal_state);
     290            2 :             if (!this.props.cols || !this.props.rows)
     291            1 :                 this.resizeTerminal(this.state.cols, this.state.rows);
     292            2 :         }
     293              : 
     294            2 :         if (this.props.channel && prevProps.channel !== this.props.channel) {
     295            2 :             cockpit.assert(!this.props.state);
     296            2 :             this.terminal_state.resetChannel(this.props.channel);
     297            1 :             if (!this.props.cols || !this.props.rows)
     298            2 :                 this.resizeTerminal(this.state.cols, this.state.rows);
     299            2 :         }
     300              : 
     301            1 :         if (this.props.fontSize && prevProps.fontSize !== this.props.fontSize) {
     302            1 :             this.terminal.options.fontSize = this.props.fontSize;
     303              : 
     304              :             // After font size is changed, resize needs to be triggered
     305            1 :             const dimensions = this.calculateDimensions();
     306            1 :             if (dimensions.cols !== this.state.cols || dimensions.rows !== this.state.rows) {
     307            1 :                 this.onWindowResize();
     308            1 :             } else {
     309              :                 // When font size changes but dimensions are the same, we need to force `resize`
     310            1 :                 this.resizeTerminal(dimensions.cols - 1, dimensions.rows);
     311            1 :             }
     312            1 :         }
     313              : 
     314            6 :         if (prevState.cols !== this.state.cols || prevState.rows !== this.state.rows)
     315            5 :             this.resizeTerminal(this.state.cols, this.state.rows);
     316              : 
     317            5 :         if (this.props.theme && prevProps.theme !== this.props.theme)
     318            1 :             this.setTerminalTheme(this.props.theme);
     319              : 
     320            6 :         this.terminal.focus();
     321            6 :     }
     322              : 
     323            6 :     render() {
     324            6 :         const contextMenuList = (
     325            6 :             <MenuList>
     326            6 :                 <MenuItem className="contextMenuOption" onClick={this.getText}>
     327            6 :                     <div className="contextMenuName"> { _("Copy") } </div>
     328            6 :                     <div className="contextMenuShortcut">{ _("Ctrl+Insert") }</div>
     329            6 :                 </MenuItem>
     330            6 :                 <MenuItem className="contextMenuOption" onClick={this.setText}>
     331            6 :                     <div className="contextMenuName"> { _("Paste") } </div>
     332            6 :                     <div className="contextMenuShortcut">{ _("Shift+Insert") }</div>
     333            6 :                 </MenuItem>
     334            6 :             </MenuList>
     335              :         );
     336              : 
     337            6 :         return (
     338            6 :             <>
     339            6 :                 <Modal position="top"
     340            6 :                        variant="small"
     341            6 :                        isOpen={this.state.showPastingModal}
     342            0 :                        onClose={() => this.setState({ showPastingModal: false })}>
     343            6 :                     <ModalHeader title={_("Paste error")} />
     344            6 :                     <ModalBody>
     345            6 :                         {_("Your browser does not allow paste from the context menu. You can use Shift+Insert.")}
     346            6 :                     </ModalBody>
     347            6 :                     <ModalFooter>
     348            0 :                         <Button key="cancel" variant="secondary" onClick={() => this.setState({ showPastingModal: false })}>
     349            6 :                             {_("Close")}
     350            6 :                         </Button>
     351            6 :                     </ModalFooter>
     352            6 :                 </Modal>
     353            6 :                 <div ref={this.terminalRef}
     354            6 :                      className="console-ct"
     355            6 :                      onFocus={this.onFocusIn}
     356            6 :                      onBlur={this.onFocusOut} />
     357            6 :                 <ContextMenu parentId={this.props.parentId}>
     358            6 :                     {contextMenuList}
     359            6 :                 </ContextMenu>
     360            6 :             </>
     361              :         );
     362            6 :     }
     363              : 
     364            1 :     componentWillUnmount() {
     365            1 :         window.removeEventListener('resize', this.onWindowResize);
     366            1 :         this.onFocusOut();
     367            1 :         this.unmountTerminal(this.terminal_state);
     368            1 :         if (!this.props.state)
     369            0 :             this.terminal_state.close();
     370            1 :     }
     371              : 
     372            0 :     setText() {
     373            0 :         try {
     374            0 :             navigator.clipboard.readText()
     375            0 :                     .then(text => this.terminal_state.channel?.send(text))
     376            0 :                     .catch(() => this.setState({ showPastingModal: true }))
     377            0 :                     .finally(() => this.terminal.focus());
     378            0 :         } catch {
     379            0 :             this.setState({ showPastingModal: true });
     380            0 :         }
     381            0 :     }
     382              : 
     383            0 :     getText() {
     384            0 :         try {
     385            0 :             navigator.clipboard.writeText(this.terminal.getSelection())
     386            0 :                     .catch(e => console.error('Text could not be copied, use Ctrl+Insert ', e ? e.toString() : ""))
     387            0 :                     .finally(() => this.terminal.focus());
     388            0 :         } catch (error) {
     389            0 :             console.error('Text could not be copied, use Ctrl+Insert:', String(error));
     390            0 :         }
     391            0 :     }
     392              : 
     393            0 :     reset() {
     394            0 :         this.terminal.reset();
     395            0 :         this.terminal_state.channel?.send(String.fromCharCode(12)); // Send SIGWINCH to show prompt on attaching
     396            0 :     }
     397              : 
     398            0 :     focus() {
     399            0 :         if (this.terminal)
     400            0 :             this.terminal.focus();
     401            0 :     }
     402              : 
     403            5 :     calculateDimensions() {
     404            5 :         const padding = 10; // Leave a bit of space around terminal
     405              :         // @ts-expect-error: we are accessing internals here...
     406            5 :         const core = this.terminal._core;
     407            5 :         const realHeight = core._renderService.dimensions.css.cell.height;
     408            5 :         const realWidth = core._renderService.dimensions.css.cell.width;
     409            5 :         const parentHeight = this.terminalRef.current?.parentElement?.clientHeight;
     410            5 :         const parentWidth = this.terminalRef.current?.parentElement?.clientWidth;
     411            5 :         if (realHeight && realWidth && realWidth !== 0 && realHeight !== 0 && parentHeight && parentWidth)
     412            5 :             return {
     413              :                 // it can happen that parent{Width,Height} are not yet initialized (0), avoid negative values
     414            5 :                 rows: Math.max(Math.floor((parentHeight - padding) / realHeight), 1),
     415            5 :                 cols: Math.max(Math.floor((parentWidth - padding - 12) / realWidth), 1) // Remove 12px for scrollbar
     416            5 :             };
     417              : 
     418            1 :         return { rows: this.state.rows, cols: this.state.cols };
     419            5 :     }
     420              : 
     421            5 :     onWindowResize() {
     422            5 :         this.setState(this.calculateDimensions());
     423            5 :     }
     424              : 
     425            6 :     setTerminalTheme(theme: TerminalTheme) {
     426            6 :         this.terminal.options.theme = themes[theme];
     427            6 :     }
     428              : 
     429            0 :     onBeforeUnload(event: Event) {
     430              :         // Firefox requires this when the page is in an iframe
     431            0 :         event.preventDefault();
     432              : 
     433              :         // Included for legacy support, e.g. Chrome/Edge < 119
     434            0 :         event.returnValue = true;
     435            0 :     }
     436              : 
     437            6 :     onFocusIn() {
     438            6 :         window.addEventListener('beforeunload', this.onBeforeUnload);
     439            6 :     }
     440              : 
     441            5 :     onFocusOut() {
     442            5 :         window.removeEventListener('beforeunload', this.onBeforeUnload);
     443            5 :     }
     444            6 : }
        

Generated by: LCOV version 2.0-1