import React, { useState, useEffect, useRef } from "react"; 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"; import makeStyles from "@mui/styles/makeStyles"; import createStyles from "@mui/styles/createStyles"; import Box from "@mui/material/Box"; import { ITerminal, Output, Link, RawOutput } from "../ITerminal"; import { IRouter } from "../../ui/Router"; import { IPlayer } from "../../PersonObjects/IPlayer"; import { TerminalInput } from "./TerminalInput"; import { TerminalEvents, TerminalClearEvents } from "../TerminalEvents"; import { BitFlumeModal } from "../../BitNode/ui/BitFlumeModal"; import { CodingContractModal } from "../../ui/React/CodingContractModal"; import _ from "lodash"; interface IActionTimerProps { terminal: ITerminal; } function ActionTimer({ terminal }: IActionTimerProps): React.ReactElement { return ( {terminal.getProgressText()} ); } const useStyles = makeStyles((theme: Theme) => createStyles({ nopadding: { padding: theme.spacing(0), }, preformatted: { whiteSpace: "pre-wrap", overflowWrap: "anywhere", margin: theme.spacing(0), }, list: { padding: theme.spacing(0), height: "100%", }, 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, }, }), ); interface IProps { terminal: ITerminal; router: IRouter; player: IPlayer; } export function TerminalRoot({ terminal, router, player }: IProps): React.ReactElement { const scrollHook = useRef(null); const setRerender = useState(0)[1]; const [key, setKey] = useState(0); function rerender(): void { setRerender((old) => old + 1); } function clear(): void { setKey((key) => key + 1); } useEffect(() => { const debounced = _.debounce(async () => rerender(), 25, { maxWait: 50 }); const unsubscribe = TerminalEvents.subscribe(debounced); return () => { debounced.cancel(); unsubscribe(); }; }, []); useEffect(() => { const debounced = _.debounce(async () => clear(), 25, { maxWait: 50 }); const unsubscribe = TerminalClearEvents.subscribe(debounced); return () => { debounced.cancel(); unsubscribe(); }; }, []); function doScroll(): number | undefined { const hook = scrollHook.current; if (hook !== null) { return window.setTimeout(() => hook.scrollIntoView(true), 50); } } doScroll(); useEffect(() => { let scrollId: number; const id = setTimeout(() => { scrollId = doScroll() ?? 0; }, 50); return () => { clearTimeout(id); clearTimeout(scrollId); }; }, []); function lineClass(s: string): string { const lineClassMap: Record = { error: classes.error, success: classes.success, info: classes.info, warn: classes.warning, }; return lineClassMap[s] || classes.primary; } function ansiCodeStyle(code: string | null): Record { // 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 = { 0: "#404040", 1: "#ff0000", 2: "#00ff00", 3: "#ffff00", 4: "#0000ff", 5: "#ff00ff", 6: "#00ffff", 7: "#ffffff", }; const COLOR_MAP_DARK: Record = { 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; } 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"; } 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}\\[(?.*?)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 }); } }); } } 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 }); } return ( {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 ( {part.text} ); } else { // ... otherwise yield the unmodified, unwrapped part. return part.text; } })} ); } const classes = useStyles(); return ( <> {terminal.outputHistory.map((item, i) => { if (item instanceof Output) return ( {parseOutputText(item)} ); if (item instanceof RawOutput) return ( {item.raw} ); if (item instanceof Link) return ( {item.dashes}>  terminal.connectToServer(player, item.hostname)} > {item.hostname} ); })} {terminal.action !== null && ( {" "} )}
); }