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-17 12:03:54

            Line data    Source code
       1           12 : /*
       2              :  * Copyright (C) 2016 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           12 : 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           12 : import cockpit from "cockpit";
      17              : 
      18              : import "console.css";
      19              : 
      20           12 : const _ = cockpit.gettext;
      21              : 
      22           12 : const theme_core = {
      23           12 :     yellow: "#b58900",
      24           12 :     brightRed: "#cb4b16",
      25           12 :     red: "#dc322f",
      26           12 :     magenta: "#d33682",
      27           12 :     brightMagenta: "#6c71c4",
      28           12 :     blue: "#268bd2",
      29           12 :     cyan: "#2aa198",
      30           12 :     green: "#859900"
      31           12 : };
      32              : 
      33           12 : const themes = {
      34           12 :     "black-theme": {
      35           12 :         background: "#000000",
      36           12 :         foreground: "#ffffff"
      37           12 :     },
      38           12 :     "dark-theme": Object.assign({}, theme_core, {
      39           12 :         background: "#002b36",
      40           12 :         foreground: "#fdf6e3",
      41           12 :         cursor: "#eee8d5",
      42           12 :         selection: "#ffffff77",
      43           12 :         brightBlack: "#002b36",
      44           12 :         black: "#073642",
      45           12 :         brightGreen: "#586e75",
      46           12 :         brightYellow: "#657b83",
      47           12 :         brightBlue: "#839496",
      48           12 :         brightCyan: "#93a1a1",
      49           12 :         white: "#eee8d5",
      50           12 :         brightWhite: "#fdf6e3"
      51           12 :     }),
      52           12 :     "light-theme": Object.assign({}, theme_core, {
      53           12 :         background: "#fdf6e3",
      54           12 :         foreground: "#002b36",
      55           12 :         cursor: "#073642",
      56           12 :         selection: "#00000044",
      57           12 :         brightWhite: "#002b36",
      58           12 :         white: "#073642",
      59           12 :         brightCyan: "#586e75",
      60           12 :         brightBlue: "#657b83",
      61           12 :         brightYellow: "#839496",
      62           12 :         brightGreen: "#93a1a1",
      63           12 :         black: "#eee8d5",
      64           12 :         brightBlack: "#fdf6e3"
      65           12 :     }),
      66           12 :     "white-theme": {
      67           12 :         background: "#ffffff",
      68           12 :         foreground: "#000000",
      69           12 :         selection: "#00000044",
      70           12 :         cursor: "#000000",
      71           12 :     },
      72           12 : };
      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           12 : export class TerminalState {
     116              :     terminal: Term;
     117              :     wrapper_element: HTMLDivElement;
     118              :     channel: cockpit.Channel<string>;
     119              : 
     120           12 :     constructor(channel: cockpit.Channel<string>) {
     121           12 :         this.terminal = new Term({
     122           12 :             cols: 80,
     123           12 :             rows: 1,
     124           12 :             cursorBlink: true,
     125           12 :             fontSize: 16,
     126           12 :             fontFamily: 'Menlo, Monaco, Consolas, monospace',
     127           12 :             screenReaderMode: true,
     128           12 :         });
     129           12 :         this.terminal.loadAddon(new WebglAddon());
     130           12 :         this.wrapper_element = document.createElement("div");
     131           12 :         this.channel = channel;
     132           12 :         this.#connectChannel(channel);
     133           12 :     }
     134              : 
     135           12 :     #connectChannel(channel: cockpit.Channel<string>) {
     136           10 :         channel.addEventListener('message', (_event, data) => {
     137           10 :             this.terminal.write(data);
     138           10 :         });
     139              : 
     140            4 :         this.terminal.onData(data => {
     141            4 :             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            4 :                 channel.send(data);
     159            4 :             }
     160            4 :         });
     161              : 
     162            3 :         channel.addEventListener('close', (_event, options) => {
     163            3 :             const term = this.terminal;
     164            2 :             term.write('\x1b[31m' + (options.problem || 'disconnected') + '\x1b[m\r\n');
     165            3 :             term.refresh(term.rows, term.rows);
     166            3 :         });
     167           12 :     }
     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           12 : }
     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           12 : export class Terminal extends React.Component<TerminalComponentProps, TerminalComponentState> {
     200              :     terminal_state: TerminalState;
     201              :     terminalRef: React.RefObject<HTMLDivElement>;
     202              :     terminal: Term;
     203              : 
     204           12 :     constructor(props: TerminalComponentProps) {
     205           12 :         super(props);
     206           12 :         this.reset = this.reset.bind(this);
     207           12 :         this.focus = this.focus.bind(this);
     208           12 :         this.onWindowResize = this.onWindowResize.bind(this);
     209           12 :         this.resizeTerminal = this.resizeTerminal.bind(this);
     210           12 :         this.onFocusIn = this.onFocusIn.bind(this);
     211           12 :         this.onFocusOut = this.onFocusOut.bind(this);
     212           12 :         this.setText = this.setText.bind(this);
     213           12 :         this.getText = this.getText.bind(this);
     214           12 :         this.setTerminalTheme = this.setTerminalTheme.bind(this);
     215              : 
     216            3 :         if (this.props.state) {
     217            3 :             cockpit.assert(!this.props.channel);
     218            3 :             this.terminal_state = this.props.state;
     219            2 :         } else {
     220           11 :             cockpit.assert(this.props.channel);
     221           11 :             this.terminal_state = new TerminalState(this.props.channel);
     222           11 :         }
     223              : 
     224           12 :         const term = this.terminal_state.terminal;
     225              : 
     226           12 :         this.terminalRef = React.createRef<HTMLDivElement>();
     227              : 
     228           12 :         if (props.onTitleChanged)
     229           11 :             term.onTitleChange(props.onTitleChanged);
     230              : 
     231           12 :         this.terminal = term;
     232           12 :         this.state = {
     233           12 :             showPastingModal: false,
     234           12 :             cols: term.cols,
     235           12 :             rows: term.rows
     236           12 :         };
     237           12 :     }
     238              : 
     239           12 :     mountTerminal(state: TerminalState) {
     240           12 :         this.terminal = state.terminal;
     241           12 :         this.terminalRef.current?.appendChild(state.wrapper_element);
     242           12 :         this.terminal.open(state.wrapper_element);
     243              : 
     244           12 :         if (this.props.fontSize)
     245           11 :             this.terminal.options.fontSize = this.props.fontSize;
     246              : 
     247            3 :         if (this.props.cols && this.props.rows) {
     248            3 :             this.resizeTerminal(this.props.cols, this.props.rows);
     249            3 :         }
     250              : 
     251            3 :         this.setTerminalTheme(this.props.theme || 'black-theme');
     252           12 :         this.terminal.focus();
     253           12 :     }
     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           12 :     componentDidMount() {
     264           12 :         this.mountTerminal(this.terminal_state);
     265           11 :         if (!this.props.rows) {
     266           11 :             window.addEventListener('resize', this.onWindowResize);
     267           11 :             this.onWindowResize();
     268           11 :         }
     269           12 :     }
     270              : 
     271           12 :     resizeTerminal(cols: number, rows: number) {
     272           12 :         this.terminal.resize(cols, rows);
     273           12 :         if (this.terminal_state.channel) {
     274           12 :             this.terminal_state.channel.control({
     275           12 :                 window: {
     276           12 :                     rows,
     277           12 :                     cols
     278           12 :                 }
     279           12 :             } as cockpit.JsonObject as cockpit.ControlMessage);
     280           12 :         }
     281           12 :     }
     282              : 
     283           12 :     componentDidUpdate(prevProps: TerminalComponentProps, prevState: TerminalComponentState) {
     284            3 :         if (this.props.state && prevProps.state !== this.props.state) {
     285            3 :             cockpit.assert(!this.props.channel);
     286            3 :             cockpit.assert(prevProps.state);
     287            3 :             this.unmountTerminal(prevProps.state);
     288            3 :             this.terminal_state = this.props.state;
     289            3 :             this.mountTerminal(this.terminal_state);
     290            3 :             if (!this.props.cols || !this.props.rows)
     291            2 :                 this.resizeTerminal(this.state.cols, this.state.rows);
     292            3 :         }
     293              : 
     294            3 :         if (this.props.channel && prevProps.channel !== this.props.channel) {
     295            3 :             cockpit.assert(!this.props.state);
     296            3 :             this.terminal_state.resetChannel(this.props.channel);
     297            2 :             if (!this.props.cols || !this.props.rows)
     298            3 :                 this.resizeTerminal(this.state.cols, this.state.rows);
     299            3 :         }
     300              : 
     301            2 :         if (this.props.fontSize && prevProps.fontSize !== this.props.fontSize) {
     302            2 :             this.terminal.options.fontSize = this.props.fontSize;
     303              : 
     304              :             // After font size is changed, resize needs to be triggered
     305            2 :             const dimensions = this.calculateDimensions();
     306            2 :             if (dimensions.cols !== this.state.cols || dimensions.rows !== this.state.rows) {
     307            2 :                 this.onWindowResize();
     308            2 :             } else {
     309              :                 // When font size changes but dimensions are the same, we need to force `resize`
     310            2 :                 this.resizeTerminal(dimensions.cols - 1, dimensions.rows);
     311            2 :             }
     312            2 :         }
     313              : 
     314           12 :         if (prevState.cols !== this.state.cols || prevState.rows !== this.state.rows)
     315           11 :             this.resizeTerminal(this.state.cols, this.state.rows);
     316              : 
     317           11 :         if (this.props.theme && prevProps.theme !== this.props.theme)
     318            2 :             this.setTerminalTheme(this.props.theme);
     319              : 
     320           12 :         this.terminal.focus();
     321           12 :     }
     322              : 
     323           12 :     render() {
     324           12 :         const contextMenuList = (
     325           12 :             <MenuList>
     326           12 :                 <MenuItem className="contextMenuOption" onClick={this.getText}>
     327           12 :                     <div className="contextMenuName"> { _("Copy") } </div>
     328           12 :                     <div className="contextMenuShortcut">{ _("Ctrl+Insert") }</div>
     329           12 :                 </MenuItem>
     330           12 :                 <MenuItem className="contextMenuOption" onClick={this.setText}>
     331           12 :                     <div className="contextMenuName"> { _("Paste") } </div>
     332           12 :                     <div className="contextMenuShortcut">{ _("Shift+Insert") }</div>
     333           12 :                 </MenuItem>
     334           12 :             </MenuList>
     335              :         );
     336              : 
     337           12 :         return (
     338           12 :             <>
     339           12 :                 <Modal position="top"
     340           12 :                        variant="small"
     341           12 :                        isOpen={this.state.showPastingModal}
     342            0 :                        onClose={() => this.setState({ showPastingModal: false })}>
     343           12 :                     <ModalHeader title={_("Paste error")} />
     344           12 :                     <ModalBody>
     345           12 :                         {_("Your browser does not allow paste from the context menu. You can use Shift+Insert.")}
     346           12 :                     </ModalBody>
     347           12 :                     <ModalFooter>
     348            0 :                         <Button key="cancel" variant="secondary" onClick={() => this.setState({ showPastingModal: false })}>
     349           12 :                             {_("Close")}
     350           12 :                         </Button>
     351           12 :                     </ModalFooter>
     352           12 :                 </Modal>
     353           12 :                 <div ref={this.terminalRef}
     354           12 :                      className="console-ct"
     355           12 :                      onFocus={this.onFocusIn}
     356           12 :                      onBlur={this.onFocusOut} />
     357           12 :                 <ContextMenu parentId={this.props.parentId}>
     358           12 :                     {contextMenuList}
     359           12 :                 </ContextMenu>
     360           12 :             </>
     361              :         );
     362           12 :     }
     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           11 :     calculateDimensions() {
     404           11 :         const padding = 10; // Leave a bit of space around terminal
     405              :         // @ts-expect-error: we are accessing internals here...
     406           11 :         const core = this.terminal._core;
     407           11 :         const realHeight = core._renderService.dimensions.css.cell.height;
     408           11 :         const realWidth = core._renderService.dimensions.css.cell.width;
     409           11 :         const parentHeight = this.terminalRef.current?.parentElement?.clientHeight;
     410           11 :         const parentWidth = this.terminalRef.current?.parentElement?.clientWidth;
     411           11 :         if (realHeight && realWidth && realWidth !== 0 && realHeight !== 0 && parentHeight && parentWidth)
     412           11 :             return {
     413              :                 // it can happen that parent{Width,Height} are not yet initialized (0), avoid negative values
     414           11 :                 rows: Math.max(Math.floor((parentHeight - padding) / realHeight), 1),
     415           11 :                 cols: Math.max(Math.floor((parentWidth - padding - 12) / realWidth), 1) // Remove 12px for scrollbar
     416           11 :             };
     417              : 
     418            2 :         return { rows: this.state.rows, cols: this.state.cols };
     419           11 :     }
     420              : 
     421           11 :     onWindowResize() {
     422           11 :         this.setState(this.calculateDimensions());
     423           11 :     }
     424              : 
     425           12 :     setTerminalTheme(theme: TerminalTheme) {
     426           12 :         this.terminal.options.theme = themes[theme];
     427           12 :     }
     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           12 :     onFocusIn() {
     438           12 :         window.addEventListener('beforeunload', this.onBeforeUnload);
     439           12 :     }
     440              : 
     441            8 :     onFocusOut() {
     442            8 :         window.removeEventListener('beforeunload', this.onBeforeUnload);
     443            8 :     }
     444           12 : }
        

Generated by: LCOV version 2.0-1