import cockpit from "cockpit";
import 'cockpit-dark-theme';
import '../lib/patternfly/patternfly-6-cockpit.scss';
import React from "react";
import { createRoot } from "react-dom/client";
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
import { NumberInput } from "@patternfly/react-core/dist/esm/components/NumberInput/index.js";
import { Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js";
import { Alert, AlertActionCloseButton, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
import { fsinfo } from "cockpit/fsinfo";
import "./terminal.scss";
import { Terminal } from "cockpit-components-terminal.jsx";
import { EmptyStatePanel } from "cockpit-components-empty-state";
const _ = cockpit.gettext;
(function() {
cockpit.translate();
* A terminal component for the cockpit user.
*
* Uses the Terminal component from base1 internally, but adds a header
* with title and Reset button.
*
* Spawns the user's shell in the user's home directory.
*/
class UserTerminal extends React.Component {
createChannel(user, dir) {
const ch = cockpit.channel({
payload: "stream",
spawn: [user.shell || "/bin/bash"],
environ: [
"TERM=xterm-256color",
],
directory: dir || user.home || "/",
pty: true,
binary: true,
});
ch.addEventListener("ready", (_, msg) => this.setState({ pid: msg.pid }));
ch.addEventListener("close", () => this.setState({ pid: null }));
return ch;
}
constructor(props) {
super(props);
const theme = localStorage.getItem('terminal:theme');
const size = localStorage.getItem('terminal:font-size');
this.webglAvailable = !!document.createElement("canvas").getContext("webgl2");
this.state = {
title: 'Terminal',
theme: theme || "black-theme",
size: parseInt(size) || 16,
changePathBusy: false,
pathError: null,
pid: null,
user: null,
};
this.onTitleChanged = this.onTitleChanged.bind(this);
this.onResetClick = this.onResetClick.bind(this);
this.onThemeChanged = this.onThemeChanged.bind(this);
this.onPlus = this.onPlus.bind(this);
this.onMinus = this.onMinus.bind(this);
this.forceChangeDirectory = this.forceChangeDirectory.bind(this);
this.onNavigate = this.onNavigate.bind(this);
this.dismiss = this.dismiss.bind(this);
this.terminalRef = React.createRef();
this.resetButtonRef = React.createRef();
this.minSize = 6;
this.maxSize = 40;
}
async componentDidMount() {
cockpit.addEventListener("locationchanged", this.onNavigate);
let dir;
if (cockpit.location.options.path) {
const validPath = await this.readyPath();
if (validPath) {
dir = cockpit.location.options.path;
}
}
const user = await cockpit.user();
this.setState({ user, channel: this.createChannel(user, dir) });
}
componentWillUnmount() {
cockpit.removeEventListener("locationchanged", this.onNavigate);
}
onTitleChanged(title) {
this.setState({ title });
}
forceChangeDirectory() {
this.setState(prevState => ({
channel: this.createChannel(prevState.user, cockpit.location.options.path),
changePathBusy: false,
}));
this.terminalRef.current.focus();
}
dismiss() {
this.setState({
pathError: null,
changePathBusy: false,
});
cockpit.location.replace("");
this.terminalRef.current.focus();
}
async onNavigate() {
this.setState({
pathError: null,
changePathBusy: false,
});
if (!cockpit.location.options.path) {
return;
}
const changeNow = await this.readyPath();
if (changeNow) {
const user = await cockpit.user();
this.setState({ channel: this.createChannel(user, cockpit.location.options.path) });
this.terminalRef.current.focus();
}
}
async readyPath() {
try {
const info = await fsinfo(String(cockpit.location.options.path), ['type']);
if (info.type !== "dir") {
this.setState({ pathError: cockpit.format(_("$0 is not a directory"), cockpit.location.options.path) });
return false;
}
} catch (err) {
this.setState({ pathError: cockpit.format(_("$0 does not exist"), cockpit.location.options.path) });
return false;
}
if (this.state.pid !== null) {
const command = "grep -qr '^PPid:[[:space:]]*" + this.state.pid + "$' /proc/*/status";
try {
await cockpit.script(command, [], { err: "message" });
this.setState({ changePathBusy: true });
return false;
} catch {
return true;
}
}
return true;
}
onPlus() {
this.setState((state, _) => {
localStorage.setItem('terminal:font-size', state.size + 1);
return { size: state.size + 1 };
});
this.terminalRef.current.focus();
}
onMinus() {
this.setState((state, _) => {
localStorage.setItem('terminal:font-size', state.size - 1);
return { size: state.size - 1 };
});
this.terminalRef.current.focus();
}
onThemeChanged(_, value) {
this.setState({ theme: value });
localStorage.setItem('terminal:theme', value);
this.terminalRef.current.focus();
}
onResetClick(event) {
if (event.button !== 0)
return;
if (!this.state.channel.valid && this.state.user)
this.setState(prevState => ({ channel: this.createChannel(prevState.user) }));
else
this.terminalRef.current.reset();
this.resetButtonRef.current.blur();
this.terminalRef.current.focus();
}
render() {
const terminal = this.state.channel
? <Terminal ref={this.terminalRef}
channel={this.state.channel}
theme={this.state.theme}
fontSize={this.state.size}
parentId="cockpit-terminal"
onTitleChanged={this.onTitleChanged} />
: <span>Loading...</span>;
return (
<Page className="pf-m-no-sidebar">
<PageSection hasShadowBottom>
<div className="terminal-group">
<code className="terminal-title">{this.state.title}</code>
<Toolbar id="toolbar">
<ToolbarContent>
<ToolbarGroup>
<ToolbarItem variant="label" id="size-select">
{_("Font size")}
</ToolbarItem>
<ToolbarItem>
<NumberInput
className="font-size"
value={this.state.size}
min={this.minSize}
max={this.maxSize}
onMinus={this.onMinus}
onPlus={this.onPlus}
inputAriaLabel={_("Font size")}
minusBtnAriaLabel={_("Decrease by one")}
plusBtnAriaLabel={_("Increase by one")}
widthChars={2}
/>
</ToolbarItem>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarItem variant="label" id="theme-select">
{_("Appearance")}
</ToolbarItem>
<ToolbarItem>
<FormSelect onChange={this.onThemeChanged}
aria-labelledby="theme-select"
value={this.state.theme}>
<FormSelectOption value='black-theme' label={_("Black")} />
<FormSelectOption value='dark-theme' label={_("Dark")} />
<FormSelectOption value='light-theme' label={_("Light")} />
<FormSelectOption value='white-theme' label={_("White")} />
</FormSelect>
</ToolbarItem>
</ToolbarGroup>
<ToolbarItem>
<button ref={this.resetButtonRef}
disabled={!this.webglAvailable}
className="pf-v6-c-button pf-m-secondary terminal-reset"
onClick={this.onResetClick}>{_("Reset")}</button>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
</div>
</PageSection>
<PageSection className="console-ct-container" isFilled hasBodyWrapper={false} padding={{ default: 'noPadding' }}>
<div className="ct-terminal-dir-alert">
{this.state.pathError && <Alert isInline
title={_("Unable to open directory")}
variant="warning"
actionClose={<AlertActionCloseButton onClose={this.dismiss} />}>
<p>{_(this.state.pathError)}</p>
</Alert>
}
{this.state.changePathBusy && <Alert isInline
title={_("Running process prevents directory change")}
variant="danger"
actionClose={<AlertActionCloseButton onClose={() =>
this.setState({ changePathBusy: false })} />}
actionLinks={
<>
<Button variant="danger" size="sm" onClick={this.forceChangeDirectory}>{_("Change directory")}</Button>
<AlertActionLink onClick={this.dismiss}>{_("Cancel")}</AlertActionLink>
</>
}>
{_("Changing the directory will forcefully stop the currently running process. The process can also be stopped manually in the terminal before continuing.")}
</Alert>
}
</div>
{this.webglAvailable
? (<div className={"terminal-body " + this.state.theme} id="cockpit-terminal">
{terminal}
</div>)
: <EmptyStatePanel title={_("Terminal not available")} paragraph={_("This browser does not support WebGL2.")} />}
</PageSection>
</Page>
);
}
}
UserTerminal.displayName = "UserTerminal";
const root = createRoot(document.getElementById('terminal'));
root.render(<UserTerminal />);
document.body.removeAttribute("hidden");
}());