2021-09-17 02:14:09 +02:00
|
|
|
import React, { useState, useEffect, useRef } from "react";
|
2021-09-17 01:23:03 +02:00
|
|
|
import Typography from "@mui/material/Typography";
|
|
|
|
import List from "@mui/material/List";
|
|
|
|
import ListItem from "@mui/material/ListItem";
|
|
|
|
import { Link as MuiLink } from "@mui/material";
|
|
|
|
import { Theme } from "@mui/material/styles";
|
2021-09-17 02:14:09 +02:00
|
|
|
import makeStyles from "@mui/styles/makeStyles";
|
|
|
|
import createStyles from "@mui/styles/createStyles";
|
2021-09-17 01:23:03 +02:00
|
|
|
import Box from "@mui/material/Box";
|
2021-10-27 02:26:05 +02:00
|
|
|
import { ITerminal, Output, Link, RawOutput } from "../ITerminal";
|
2021-09-17 08:58:02 +02:00
|
|
|
import { IRouter } from "../../ui/Router";
|
2021-09-16 08:52:45 +02:00
|
|
|
import { IPlayer } from "../../PersonObjects/IPlayer";
|
2021-09-16 21:37:01 +02:00
|
|
|
import { TerminalInput } from "./TerminalInput";
|
2021-09-20 06:14:30 +02:00
|
|
|
import { TerminalEvents, TerminalClearEvents } from "../TerminalEvents";
|
2021-10-01 19:08:37 +02:00
|
|
|
import { BitFlumeModal } from "../../BitNode/ui/BitFlumeModal";
|
|
|
|
import { CodingContractModal } from "../../ui/React/CodingContractModal";
|
|
|
|
|
2021-09-24 00:47:43 +02:00
|
|
|
import _ from "lodash";
|
2021-09-16 20:43:39 +02:00
|
|
|
|
|
|
|
interface IActionTimerProps {
|
|
|
|
terminal: ITerminal;
|
|
|
|
}
|
|
|
|
|
|
|
|
function ActionTimer({ terminal }: IActionTimerProps): React.ReactElement {
|
|
|
|
return (
|
|
|
|
<Typography color={"primary"} paragraph={false}>
|
|
|
|
{terminal.getProgressText()}
|
|
|
|
</Typography>
|
|
|
|
);
|
|
|
|
}
|
2021-09-16 08:52:45 +02:00
|
|
|
|
|
|
|
const useStyles = makeStyles((theme: Theme) =>
|
|
|
|
createStyles({
|
|
|
|
nopadding: {
|
2021-09-16 21:37:01 +02:00
|
|
|
padding: theme.spacing(0),
|
2021-09-16 08:52:45 +02:00
|
|
|
},
|
|
|
|
preformatted: {
|
2021-09-16 20:55:55 +02:00
|
|
|
whiteSpace: "pre-wrap",
|
2021-09-18 08:21:48 +02:00
|
|
|
overflowWrap: "anywhere",
|
2021-09-16 21:37:01 +02:00
|
|
|
margin: theme.spacing(0),
|
2021-09-16 08:52:45 +02:00
|
|
|
},
|
2021-09-16 21:04:20 +02:00
|
|
|
list: {
|
2021-09-16 21:37:01 +02:00
|
|
|
padding: theme.spacing(0),
|
2021-09-16 21:04:20 +02:00
|
|
|
height: "100%",
|
|
|
|
},
|
2021-11-19 21:44:12 +01:00
|
|
|
|
|
|
|
success: {
|
|
|
|
whiteSpace: "pre-wrap",
|
|
|
|
overflowWrap: "anywhere",
|
|
|
|
margin: theme.spacing(0),
|
|
|
|
color: theme.colors.success,
|
|
|
|
},
|
|
|
|
error: {
|
|
|
|
whiteSpace: "pre-wrap",
|
|
|
|
overflowWrap: "anywhere",
|
|
|
|
margin: theme.spacing(0),
|
|
|
|
color: theme.palette.error.main,
|
|
|
|
},
|
|
|
|
primary: {
|
|
|
|
whiteSpace: "pre-wrap",
|
|
|
|
overflowWrap: "anywhere",
|
|
|
|
margin: theme.spacing(0),
|
|
|
|
color: theme.palette.primary.main,
|
|
|
|
},
|
|
|
|
info: {
|
|
|
|
whiteSpace: "pre-wrap",
|
|
|
|
overflowWrap: "anywhere",
|
|
|
|
margin: theme.spacing(0),
|
|
|
|
color: theme.palette.info.main,
|
|
|
|
},
|
|
|
|
warning: {
|
|
|
|
whiteSpace: "pre-wrap",
|
|
|
|
overflowWrap: "anywhere",
|
|
|
|
margin: theme.spacing(0),
|
|
|
|
color: theme.palette.warning.main,
|
|
|
|
},
|
2021-09-16 08:52:45 +02:00
|
|
|
}),
|
|
|
|
);
|
|
|
|
|
|
|
|
interface IProps {
|
|
|
|
terminal: ITerminal;
|
2021-09-17 08:58:02 +02:00
|
|
|
router: IRouter;
|
2021-09-16 08:52:45 +02:00
|
|
|
player: IPlayer;
|
|
|
|
}
|
|
|
|
|
2021-09-17 08:58:02 +02:00
|
|
|
export function TerminalRoot({ terminal, router, player }: IProps): React.ReactElement {
|
2021-09-17 02:14:09 +02:00
|
|
|
const scrollHook = useRef<HTMLDivElement>(null);
|
2021-09-19 06:46:39 +02:00
|
|
|
const setRerender = useState(0)[1];
|
2021-09-20 06:14:30 +02:00
|
|
|
const [key, setKey] = useState(0);
|
2021-09-16 08:52:45 +02:00
|
|
|
function rerender(): void {
|
2021-09-19 06:46:39 +02:00
|
|
|
setRerender((old) => old + 1);
|
2021-09-16 08:52:45 +02:00
|
|
|
}
|
|
|
|
|
2021-09-20 06:14:30 +02:00
|
|
|
function clear(): void {
|
|
|
|
setKey((key) => key + 1);
|
|
|
|
}
|
|
|
|
|
2022-01-14 15:00:29 +01:00
|
|
|
useEffect(() => {
|
|
|
|
const debounced = _.debounce(async () => rerender(), 25, { maxWait: 50 });
|
|
|
|
const unsubscribe = TerminalEvents.subscribe(debounced);
|
|
|
|
return () => {
|
|
|
|
debounced.cancel();
|
|
|
|
unsubscribe();
|
2022-04-07 01:30:08 +02:00
|
|
|
};
|
2022-01-14 15:00:29 +01:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const debounced = _.debounce(async () => clear(), 25, { maxWait: 50 });
|
|
|
|
const unsubscribe = TerminalClearEvents.subscribe(debounced);
|
|
|
|
return () => {
|
|
|
|
debounced.cancel();
|
|
|
|
unsubscribe();
|
2022-04-07 01:30:08 +02:00
|
|
|
};
|
2022-01-14 15:00:29 +01:00
|
|
|
}, []);
|
2021-09-16 08:52:45 +02:00
|
|
|
|
2022-01-14 15:00:29 +01:00
|
|
|
function doScroll(): number | undefined {
|
2021-09-17 03:49:38 +02:00
|
|
|
const hook = scrollHook.current;
|
|
|
|
if (hook !== null) {
|
2022-01-14 15:00:29 +01:00
|
|
|
return window.setTimeout(() => hook.scrollIntoView(true), 50);
|
2021-09-17 03:49:38 +02:00
|
|
|
}
|
2021-09-17 02:14:09 +02:00
|
|
|
}
|
|
|
|
|
2021-09-17 03:49:38 +02:00
|
|
|
doScroll();
|
|
|
|
|
|
|
|
useEffect(() => {
|
2022-01-14 15:00:29 +01:00
|
|
|
let scrollId: number;
|
|
|
|
const id = setTimeout(() => {
|
|
|
|
scrollId = doScroll() ?? 0;
|
|
|
|
}, 50);
|
|
|
|
return () => {
|
|
|
|
clearTimeout(id);
|
|
|
|
clearTimeout(scrollId);
|
2022-04-07 01:30:08 +02:00
|
|
|
};
|
2021-09-17 03:49:38 +02:00
|
|
|
}, []);
|
|
|
|
|
2021-11-19 21:44:12 +01:00
|
|
|
function lineClass(s: string): string {
|
2022-05-04 18:21:16 +02:00
|
|
|
const lineClassMap: Record<string, string> = {
|
|
|
|
error: classes.error,
|
|
|
|
success: classes.success,
|
|
|
|
info: classes.info,
|
|
|
|
warn: classes.warning,
|
|
|
|
};
|
|
|
|
return lineClassMap[s] || classes.primary;
|
|
|
|
}
|
|
|
|
|
|
|
|
function ansiCodeStyle(code: string | null): Record<string, any> {
|
|
|
|
// The ANSI colors actually have the dark color set as default and require extra work to get
|
|
|
|
// bright colors. But these are rarely used or, if they are, are often re-mapped by the
|
|
|
|
// terminal emulator to brighter colors. So for foreground colors we use the bright color set
|
|
|
|
// and for background colors we use the dark color set. Of course, all colors are available
|
|
|
|
// via the longer ESC[n8;5;c] sequence (n={3,4}, c=color). Ideally, these 8-bit maps could
|
|
|
|
// be managed in the user preferences/theme.
|
|
|
|
const COLOR_MAP_BRIGHT: Record<string | number, string> = {
|
|
|
|
0: "#404040",
|
|
|
|
1: "#ff0000",
|
|
|
|
2: "#00ff00",
|
|
|
|
3: "#ffff00",
|
|
|
|
4: "#0000ff",
|
|
|
|
5: "#ff00ff",
|
|
|
|
6: "#00ffff",
|
|
|
|
7: "#ffffff",
|
|
|
|
};
|
|
|
|
const COLOR_MAP_DARK: Record<string | number, string> = {
|
|
|
|
0: "#000000",
|
|
|
|
1: "#800000",
|
|
|
|
2: "#008000",
|
|
|
|
3: "#808000",
|
|
|
|
4: "#000080",
|
|
|
|
5: "#800080",
|
|
|
|
6: "#008080",
|
|
|
|
7: "#c0c0c0",
|
|
|
|
};
|
|
|
|
|
|
|
|
const ansi2rgb = (code: number): string => {
|
|
|
|
/* eslint-disable yoda */
|
|
|
|
if (0 <= code && code < 8) {
|
|
|
|
// x8 RGB
|
|
|
|
return COLOR_MAP_BRIGHT[code];
|
|
|
|
}
|
|
|
|
if (8 <= code && code < 16) {
|
|
|
|
// x8 RGB - "High Intensity" (but here, actually the dark set)
|
|
|
|
return COLOR_MAP_DARK[code];
|
|
|
|
}
|
|
|
|
if (16 <= code && code < 232) {
|
|
|
|
// x216 RGB
|
|
|
|
const base = code - 16;
|
|
|
|
const ir = Math.floor(base / 36);
|
|
|
|
const ig = Math.floor((base % 36) / 6);
|
|
|
|
const ib = Math.floor((base % 6) / 1);
|
|
|
|
const r = ir <= 0 ? 0 : 55 + ir * 40;
|
|
|
|
const g = ig <= 0 ? 0 : 55 + ig * 40;
|
|
|
|
const b = ib <= 0 ? 0 : 55 + ib * 40;
|
|
|
|
return `rgb(${r}, ${g}, ${b})`;
|
|
|
|
}
|
|
|
|
if (232 <= code && code < 256) {
|
|
|
|
// x32 greyscale
|
|
|
|
const base = code - 232;
|
|
|
|
const grey = base * 10 + 8;
|
|
|
|
return `rgb(${grey}, ${grey}, ${grey})`;
|
|
|
|
}
|
|
|
|
// shouldn't get here (under normal circumstances), but just in case
|
|
|
|
return "initial";
|
|
|
|
};
|
|
|
|
|
|
|
|
type styleKey = "fontWeight" | "textDecoration" | "color" | "backgroundColor" | "display";
|
|
|
|
const style: {
|
|
|
|
fontWeight?: string;
|
|
|
|
textDecoration?: string;
|
|
|
|
color?: string;
|
|
|
|
backgroundColor?: string;
|
|
|
|
display?: string;
|
|
|
|
} = {};
|
|
|
|
|
|
|
|
if (code === null || code === "0") {
|
|
|
|
return style;
|
2021-11-19 21:44:12 +01:00
|
|
|
}
|
2022-05-04 18:21:16 +02:00
|
|
|
|
|
|
|
const codeParts = code
|
|
|
|
.split(";")
|
|
|
|
.map((p) => parseInt(p))
|
|
|
|
.filter(
|
|
|
|
(p, i, arr) =>
|
|
|
|
// If the sequence is 38;5 (x256 foreground color) or 48;5 (x256 background color),
|
|
|
|
// filter out the 5 so the next codePart after {38,48} is the color code.
|
|
|
|
p != 5 || i == 0 || (arr[i - 1] != 38 && arr[i - 1] != 48),
|
|
|
|
);
|
|
|
|
|
|
|
|
let nextStyleKey: styleKey | null = null;
|
|
|
|
codeParts.forEach((codePart) => {
|
|
|
|
/* eslint-disable yoda */
|
|
|
|
if (nextStyleKey !== null) {
|
|
|
|
style[nextStyleKey] = ansi2rgb(codePart);
|
|
|
|
nextStyleKey = null;
|
|
|
|
}
|
|
|
|
// Decorations
|
|
|
|
else if (codePart == 1) {
|
|
|
|
style.fontWeight = "bold";
|
|
|
|
} else if (codePart == 4) {
|
|
|
|
style.textDecoration = "underline";
|
|
|
|
}
|
|
|
|
// Forground Color (x8)
|
|
|
|
else if (30 <= codePart && codePart < 38) {
|
|
|
|
if (COLOR_MAP_BRIGHT[codePart % 10]) {
|
|
|
|
style.color = COLOR_MAP_BRIGHT[codePart % 10];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Background Color (x8)
|
|
|
|
else if (40 <= codePart && codePart < 48) {
|
|
|
|
if (COLOR_MAP_DARK[codePart % 10]) {
|
|
|
|
style.backgroundColor = COLOR_MAP_DARK[codePart % 10];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Forground Color (x256)
|
|
|
|
else if (codePart == 38) {
|
|
|
|
nextStyleKey = "color";
|
|
|
|
}
|
|
|
|
// Background Color (x256)
|
|
|
|
else if (codePart == 48) {
|
|
|
|
nextStyleKey = "backgroundColor";
|
|
|
|
}
|
|
|
|
});
|
|
|
|
// If a background color is set, render this as an inline block element (instead of inline)
|
|
|
|
// so that the background between lines (at least those that don't wrap) is uninterrupted.
|
|
|
|
if (style.backgroundColor !== undefined) {
|
|
|
|
style.display = "inline-block";
|
2021-11-19 21:44:12 +01:00
|
|
|
}
|
2022-05-04 18:21:16 +02:00
|
|
|
return style;
|
|
|
|
}
|
|
|
|
|
|
|
|
function parseOutputText(item: Output): React.ReactElement {
|
|
|
|
const parts = [];
|
|
|
|
// Some things, oddly, can cause item.text to not be a string (e.g. expr from the CLI), which
|
|
|
|
// causes .matchAll to throw. Ensure it's a string immediately
|
|
|
|
if (typeof item.text === "string") {
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
const ANSI_ESCAPE = new RegExp("\u{001b}\\[(?<code>.*?)m", "ug");
|
|
|
|
// Build a look-alike regex match to place at the front of the matches list
|
|
|
|
const INITIAL = {
|
|
|
|
0: "",
|
|
|
|
index: 0,
|
|
|
|
groups: { code: null },
|
|
|
|
};
|
|
|
|
const matches = [INITIAL, ...item.text.matchAll(ANSI_ESCAPE), null];
|
|
|
|
if (matches.length > 2) {
|
|
|
|
matches.slice(0, -1).forEach((m, i) => {
|
|
|
|
const n = matches[i + 1];
|
|
|
|
if (!m || m.index === undefined || m.groups === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const startIndex = m.index + m[0].length;
|
|
|
|
const stopIndex = n ? n.index : item.text.length;
|
|
|
|
const partText = item.text.slice(startIndex, stopIndex);
|
|
|
|
if (startIndex !== stopIndex) {
|
|
|
|
// Don't generate "empty" spans
|
|
|
|
parts.push({ code: m.groups.code, text: partText });
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2021-11-19 21:44:12 +01:00
|
|
|
}
|
2022-05-04 18:21:16 +02:00
|
|
|
if (parts.length === 0) {
|
|
|
|
// For example, if the string was empty or there were no escape sequence matches
|
|
|
|
parts.push({ code: null, text: item.text });
|
2021-11-19 21:44:12 +01:00
|
|
|
}
|
2022-05-04 18:21:16 +02:00
|
|
|
return (
|
|
|
|
<Typography classes={{ root: lineClass(item.color) }} paragraph={false}>
|
|
|
|
{parts.map((part, index) => {
|
|
|
|
const spanStyle = ansiCodeStyle(part.code);
|
|
|
|
if (!_.isEmpty(spanStyle)) {
|
|
|
|
// Only wrap parts with spans if the calculated spanStyle is non-empty...
|
|
|
|
return (
|
|
|
|
<Typography key={index} paragraph={false} component="span" sx={spanStyle}>
|
|
|
|
{part.text}
|
|
|
|
</Typography>
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
// ... otherwise yield the unmodified, unwrapped part.
|
|
|
|
return part.text;
|
|
|
|
}
|
|
|
|
})}
|
|
|
|
</Typography>
|
|
|
|
);
|
2021-11-19 21:44:12 +01:00
|
|
|
}
|
|
|
|
|
2021-09-16 08:52:45 +02:00
|
|
|
const classes = useStyles();
|
|
|
|
return (
|
2021-09-17 02:14:09 +02:00
|
|
|
<>
|
2021-09-17 08:04:44 +02:00
|
|
|
<Box width="100%" minHeight="100vh" display={"flex"} alignItems={"flex-end"}>
|
2021-09-20 06:14:30 +02:00
|
|
|
<List key={key} id="terminal" classes={{ root: classes.list }}>
|
2021-09-17 02:14:09 +02:00
|
|
|
{terminal.outputHistory.map((item, i) => {
|
|
|
|
if (item instanceof Output)
|
|
|
|
return (
|
|
|
|
<ListItem key={i} classes={{ root: classes.nopadding }}>
|
2021-11-19 21:44:12 +01:00
|
|
|
<Typography classes={{ root: lineClass(item.color) }} paragraph={false}>
|
2022-05-04 18:21:16 +02:00
|
|
|
{parseOutputText(item)}
|
2021-09-17 02:14:09 +02:00
|
|
|
</Typography>
|
|
|
|
</ListItem>
|
|
|
|
);
|
2021-10-27 02:26:05 +02:00
|
|
|
if (item instanceof RawOutput)
|
|
|
|
return (
|
|
|
|
<ListItem key={i} classes={{ root: classes.nopadding }}>
|
|
|
|
<Typography classes={{ root: classes.preformatted }} paragraph={false}>
|
|
|
|
{item.raw}
|
|
|
|
</Typography>
|
|
|
|
</ListItem>
|
|
|
|
);
|
2021-09-17 02:14:09 +02:00
|
|
|
if (item instanceof Link)
|
|
|
|
return (
|
|
|
|
<ListItem key={i} classes={{ root: classes.nopadding }}>
|
2021-09-22 18:56:55 +02:00
|
|
|
<Typography>{item.dashes}> </Typography>
|
|
|
|
<MuiLink
|
2021-09-17 02:14:09 +02:00
|
|
|
classes={{ root: classes.preformatted }}
|
|
|
|
color={"secondary"}
|
|
|
|
paragraph={false}
|
|
|
|
onClick={() => terminal.connectToServer(player, item.hostname)}
|
|
|
|
>
|
2021-10-01 22:42:07 +02:00
|
|
|
<Typography>{item.hostname}</Typography>
|
2021-09-17 02:14:09 +02:00
|
|
|
</MuiLink>
|
|
|
|
</ListItem>
|
|
|
|
);
|
|
|
|
})}
|
2021-09-17 09:08:15 +02:00
|
|
|
|
|
|
|
{terminal.action !== null && (
|
|
|
|
<ListItem classes={{ root: classes.nopadding }}>
|
|
|
|
<ActionTimer terminal={terminal} />{" "}
|
|
|
|
</ListItem>
|
|
|
|
)}
|
2021-09-17 02:14:09 +02:00
|
|
|
</List>
|
|
|
|
<div ref={scrollHook}></div>
|
|
|
|
</Box>
|
2021-09-17 08:04:44 +02:00
|
|
|
<Box position="sticky" bottom={0} width="100%" px={0}>
|
2021-09-17 08:58:02 +02:00
|
|
|
<TerminalInput player={player} router={router} terminal={terminal} />
|
2021-09-17 02:14:09 +02:00
|
|
|
</Box>
|
2021-10-01 19:08:37 +02:00
|
|
|
<BitFlumeModal />
|
|
|
|
<CodingContractModal />
|
2021-09-17 02:14:09 +02:00
|
|
|
</>
|
2021-09-16 08:52:45 +02:00
|
|
|
);
|
|
|
|
}
|