* Copyright (C) 2016 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import React from "react";
import {
Modal, ModalBody, ModalFooter, ModalHeader
} from '@patternfly/react-core/dist/esm/components/Modal/index.js';
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { MenuList, MenuItem } from "@patternfly/react-core/dist/esm/components/Menu";
import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal as Term } from "@xterm/xterm";
import { ContextMenu } from "cockpit-components-context-menu.jsx";
import cockpit from "cockpit";
import "console.css";
const _ = cockpit.gettext;
const theme_core = {
yellow: "#b58900",
brightRed: "#cb4b16",
red: "#dc322f",
magenta: "#d33682",
brightMagenta: "#6c71c4",
blue: "#268bd2",
cyan: "#2aa198",
green: "#859900"
};
const themes = {
"black-theme": {
background: "#000000",
foreground: "#ffffff",
selectionBackground: "#ffffff77",
},
"dark-theme": Object.assign({}, theme_core, {
background: "#002b36",
foreground: "#fdf6e3",
cursor: "#eee8d5",
selectionBackground: "#ffffff77",
brightBlack: "#002b36",
black: "#073642",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightCyan: "#93a1a1",
white: "#eee8d5",
brightWhite: "#fdf6e3"
}),
"light-theme": Object.assign({}, theme_core, {
background: "#fdf6e3",
foreground: "#002b36",
cursor: "#073642",
selectionBackground: "#00000044",
brightWhite: "#002b36",
white: "#073642",
brightCyan: "#586e75",
brightBlue: "#657b83",
brightYellow: "#839496",
brightGreen: "#93a1a1",
black: "#eee8d5",
brightBlack: "#fdf6e3"
}),
"white-theme": {
background: "#ffffff",
foreground: "#000000",
selectionBackground: "#00000044",
cursor: "#000000",
},
};
export type TerminalTheme = keyof typeof themes;
* A terminal component that communicates over a cockpit channel.
*
* The state of a terminal component can be managed separately from
* it. This allows a terminal to stay alive and keep its content while
* it is not actually part of the DOM.
*
* This is done by creating a TerminalState object for the channel,
* and then passing this object into a Terminal component via the
* "state" property. You can dispose of the TerminalState object by
* calling its close() method. This will also close the channel.
*
* (So instead of managing the lifetime of a Cockpit channel, you
* manage the lifetime of a TerminalState wrapper for the channel, in
* exactly the same way.)
*
* If you don't need to keep a terminal alive while it is not part of
* the DOM, you can pass the channel directly into the Terminal
* component via the "channel" property. The Terminal component will
* then maintain a internal TerminalState wrapper for the channel.
*
* The "state" and "channel" properties are of course mutually
* exclusive: You can only use one of them for a given Terminal
* component. Also, switching from one to the other over the lifetime
* of a Terminal component is not supported.
*
* The size of the terminal can be set with the 'rows' and 'cols'
* properties. If those properties are not given, the terminal will
* fill its container.
*
* If the 'onTitleChanged' callback property is set, it will be called
* whenever the title of the terminal changes.
*
* Call focus() on the Terminal component to set the input focus on
* the terminal, or reset() to clear it.
*
* Also it is possible to set up theme by property 'theme'.
*/
export class TerminalState {
terminal: Term;
wrapper_element: HTMLDivElement;
channel: cockpit.Channel<string>;
constructor(channel: cockpit.Channel<string>) {
this.terminal = new Term({
cols: 80,
rows: 1,
cursorBlink: true,
fontSize: 16,
fontFamily: 'Menlo, Monaco, Consolas, monospace',
screenReaderMode: true,
});
this.terminal.loadAddon(new WebglAddon());
this.wrapper_element = document.createElement("div");
this.channel = channel;
this.#connectChannel(channel);
}
#connectChannel(channel: cockpit.Channel<string>) {
channel.addEventListener('message', (_event, data) => {
this.terminal.write(data);
});
this.terminal.onData(data => {
if (channel.valid) {
* characters) is a disaster: While it is U+00A0
* in unicode, with an UTF-8 representation of
* 0xC2A0, the "visible" string in JS is 0x00
* (with TextEncoder, btoa(), and string
* comparison). The internal representation
* retains half of it, and trying to send it to
* the websocket would result in a single 0xA0,
* which is invalid UTF-8 (and causes the session
* to crash). So intercept and ignore such broken
* chars. See
* https://github.com/cockpit-project/cockpit/issues/21213 */
if (data === '\x00') {
console.log("terminal: ignoring invalid input", data);
return;
}
channel.send(data);
}
});
channel.addEventListener('close', (_event, options) => {
const term = this.terminal;
term.write('\x1b[31m' + (options.problem || 'disconnected') + '\x1b[m\r\n');
term.refresh(term.rows, term.rows);
});
}
resetChannel(channel: cockpit.Channel<string>) {
this.channel.close();
this.terminal.reset();
this.channel = channel;
this.#connectChannel(channel);
}
close() {
this.channel.close();
this.terminal.dispose();
}
}
interface TerminalComponentProps {
parentId: string;
state?: TerminalState;
channel?: cockpit.Channel<string>;
onTitleChanged?: (title: string) => void;
fontSize?: number;
rows?: number;
cols?: number;
theme?: TerminalTheme;
}
interface TerminalComponentState {
showPastingModal: boolean,
cols: number,
rows: number
}
export class Terminal extends React.Component<TerminalComponentProps, TerminalComponentState> {
terminal_state: TerminalState;
terminalRef: React.RefObject<HTMLDivElement>;
terminal: Term;
constructor(props: TerminalComponentProps) {
super(props);
this.reset = this.reset.bind(this);
this.focus = this.focus.bind(this);
this.onWindowResize = this.onWindowResize.bind(this);
this.resizeTerminal = this.resizeTerminal.bind(this);
this.onFocusIn = this.onFocusIn.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
this.setText = this.setText.bind(this);
this.getText = this.getText.bind(this);
this.setTerminalTheme = this.setTerminalTheme.bind(this);
if (this.props.state) {
cockpit.assert(!this.props.channel);
this.terminal_state = this.props.state;
} else {
cockpit.assert(this.props.channel);
this.terminal_state = new TerminalState(this.props.channel);
}
const term = this.terminal_state.terminal;
this.terminalRef = React.createRef<HTMLDivElement>();
if (props.onTitleChanged)
term.onTitleChange(props.onTitleChanged);
this.terminal = term;
this.state = {
showPastingModal: false,
cols: term.cols,
rows: term.rows
};
}
mountTerminal(state: TerminalState) {
this.terminal = state.terminal;
this.terminalRef.current?.appendChild(state.wrapper_element);
this.terminal.open(state.wrapper_element);
if (this.props.fontSize)
this.terminal.options.fontSize = this.props.fontSize;
if (this.props.cols && this.props.rows) {
this.resizeTerminal(this.props.cols, this.props.rows);
}
this.setTerminalTheme(this.props.theme || 'black-theme');
this.terminal.focus();
}
unmountTerminal(state: TerminalState) {
this.terminalRef.current?.removeChild(state.wrapper_element);
if (!this.props.rows)
this.resizeTerminal(80, 1);
}
componentDidMount() {
this.mountTerminal(this.terminal_state);
if (!this.props.rows) {
window.addEventListener('resize', this.onWindowResize);
this.onWindowResize();
}
}
resizeTerminal(cols: number, rows: number) {
this.terminal.resize(cols, rows);
if (this.terminal_state.channel) {
this.terminal_state.channel.control({
window: {
rows,
cols
}
} as cockpit.JsonObject as cockpit.ControlMessage);
}
}
componentDidUpdate(prevProps: TerminalComponentProps, prevState: TerminalComponentState) {
const hadFocus = this.terminal_state.wrapper_element.matches(":focus-within");
if (this.props.state && prevProps.state !== this.props.state) {
cockpit.assert(!this.props.channel);
cockpit.assert(prevProps.state);
this.unmountTerminal(prevProps.state);
this.terminal_state = this.props.state;
this.mountTerminal(this.terminal_state);
if (!this.props.cols || !this.props.rows)
this.resizeTerminal(this.state.cols, this.state.rows);
}
if (this.props.channel && prevProps.channel !== this.props.channel) {
cockpit.assert(!this.props.state);
this.terminal_state.resetChannel(this.props.channel);
if (!this.props.cols || !this.props.rows)
this.resizeTerminal(this.state.cols, this.state.rows);
}
if (this.props.fontSize && prevProps.fontSize !== this.props.fontSize) {
this.terminal.options.fontSize = this.props.fontSize;
const dimensions = this.calculateDimensions();
if (dimensions.cols !== this.state.cols || dimensions.rows !== this.state.rows) {
this.onWindowResize();
} else {
this.resizeTerminal(dimensions.cols - 1, dimensions.rows);
}
}
if (prevState.cols !== this.state.cols || prevState.rows !== this.state.rows)
this.resizeTerminal(this.state.cols, this.state.rows);
if (this.props.theme && prevProps.theme !== this.props.theme)
this.setTerminalTheme(this.props.theme);
if (hadFocus)
this.terminal.focus();
}
render() {
const contextMenuList = (
<MenuList>
<MenuItem className="contextMenuOption" onClick={this.getText}>
<div className="contextMenuName"> { _("Copy") } </div>
<div className="contextMenuShortcut">{ _("Ctrl+Insert") }</div>
</MenuItem>
<MenuItem className="contextMenuOption" onClick={this.setText}>
<div className="contextMenuName"> { _("Paste") } </div>
<div className="contextMenuShortcut">{ _("Shift+Insert") }</div>
</MenuItem>
</MenuList>
);
return (
<>
<Modal position="top"
variant="small"
isOpen={this.state.showPastingModal}
onClose={() => this.setState({ showPastingModal: false })}>
<ModalHeader title={_("Paste error")} />
<ModalBody>
{_("Your browser does not allow paste from the context menu. You can use Shift+Insert.")}
</ModalBody>
<ModalFooter>
<Button key="cancel" variant="secondary" onClick={() => this.setState({ showPastingModal: false })}>
{_("Close")}
</Button>
</ModalFooter>
</Modal>
<div ref={this.terminalRef}
className="console-ct"
onFocus={this.onFocusIn}
onBlur={this.onFocusOut} />
<ContextMenu parentId={this.props.parentId}>
{contextMenuList}
</ContextMenu>
</>
);
}
componentWillUnmount() {
window.removeEventListener('resize', this.onWindowResize);
this.onFocusOut();
this.unmountTerminal(this.terminal_state);
if (!this.props.state)
this.terminal_state.close();
}
setText() {
try {
navigator.clipboard.readText()
.then(text => this.terminal_state.channel?.send(text))
.catch(() => this.setState({ showPastingModal: true }))
.finally(() => this.terminal.focus());
} catch {
this.setState({ showPastingModal: true });
}
}
getText() {
try {
navigator.clipboard.writeText(this.terminal.getSelection())
.catch(e => console.error('Text could not be copied, use Ctrl+Insert ', e ? e.toString() : ""))
.finally(() => this.terminal.focus());
} catch (error) {
console.error('Text could not be copied, use Ctrl+Insert:', String(error));
}
}
reset() {
this.terminal.reset();
this.terminal_state.channel?.send(String.fromCharCode(12));
}
focus() {
if (this.terminal)
this.terminal.focus();
}
calculateDimensions() {
const padding = 10;
const core = this.terminal._core;
const realHeight = core._renderService.dimensions.css.cell.height;
const realWidth = core._renderService.dimensions.css.cell.width;
const parentHeight = this.terminalRef.current?.parentElement?.clientHeight;
const parentWidth = this.terminalRef.current?.parentElement?.clientWidth;
if (realHeight && realWidth && realWidth !== 0 && realHeight !== 0 && parentHeight && parentWidth)
return {
rows: Math.max(Math.floor((parentHeight - padding) / realHeight), 1),
cols: Math.max(Math.floor((parentWidth - padding - 12) / realWidth), 1)
};
return { rows: this.state.rows, cols: this.state.cols };
}
onWindowResize() {
this.setState(this.calculateDimensions());
}
setTerminalTheme(theme: TerminalTheme) {
this.terminal.options.theme = themes[theme];
}
onBeforeUnload(event: Event) {
event.preventDefault();
event.returnValue = true;
}
onFocusIn() {
window.addEventListener('beforeunload', this.onBeforeUnload);
}
onFocusOut() {
window.removeEventListener('beforeunload', this.onBeforeUnload);
}
}