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 : }
|