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