Files
bitburner-src/src/ui/React/LogBoxManager.tsx
T

447 lines
14 KiB
TypeScript

import React, { useEffect, useRef, useCallback, useMemo } from "react";
import { EventEmitter } from "../../utils/EventEmitter";
import { RunningScript } from "../../Script/RunningScript";
import { killWorkerScriptByPid } from "../../Netscript/killWorkerScript";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Draggable, { DraggableEvent } from "react-draggable";
import { ResizableBox, ResizeCallbackData } from "react-resizable";
import IconButton from "@mui/material/IconButton";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import CloseIcon from "@mui/icons-material/Close";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import StopCircleIcon from "@mui/icons-material/StopCircle";
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
import { workerScripts } from "../../Netscript/WorkerScripts";
import { startWorkerScript } from "../../NetscriptWorker";
import { GetServer } from "../../Server/AllServers";
import { findRunningScriptByPid } from "../../Script/ScriptHelpers";
import { debounce } from "lodash";
import { Settings } from "../../Settings/Settings";
import { ANSIITypography } from "./ANSIITypography";
import { useRerender } from "./hooks";
import { dialogBoxCreate } from "./DialogBox";
import { makeStyles } from "tss-react/mui";
import { logBoxBaseZIndex } from "./Constants";
import { clampNumber } from "../../utils/helpers/clampNumber";
let layerCounter = 0;
export const LogBoxEvents = new EventEmitter<[RunningScript]>();
export const LogBoxCloserEvents = new EventEmitter<[number]>();
export const LogBoxClearEvents = new EventEmitter<[]>();
// Min width/height of a log window
const minWindowSize: [number, number] = [150, 33];
// Dynamic properties (size, position) bound to a specific rendered instance of a LogBox
export class LogBoxProperties {
x = window.innerWidth * 0.4;
y = window.innerHeight * 0.3;
width = 500;
height = 500;
fontSize: number | undefined = undefined;
minimized = false;
rerender: () => void;
rootRef: React.RefObject<Draggable>;
constructor(rerender: () => void, rootRef: React.RefObject<Draggable>) {
this.rerender = rerender;
this.rootRef = rootRef;
}
updateDOM(): void {
if (!this.rootRef.current) return;
const state = this.rootRef.current.state as { x: number; y: number };
state.x = this.x;
state.y = this.y;
}
setPosition(x: number, y: number): void {
this.x = x;
this.y = y;
this.updateDOM();
this.rerender();
}
setSize(width: number, height: number): void {
this.width = clampNumber(width, minWindowSize[0]);
this.height = clampNumber(height, minWindowSize[1]);
this.rerender();
}
setFontSize(size?: number): void {
this.fontSize = size;
this.rerender();
}
setMinimized(minimized: boolean): void {
this.minimized = minimized;
this.rerender();
}
isVisible(): boolean {
return this.rootRef.current !== null;
}
}
interface Log {
id: number; // The PID of the script *when the window was first opened*
script: RunningScript;
}
let logs: Log[] = [];
export function LogBoxManager({ hidden }: { hidden: boolean }): React.ReactElement {
const rerender = useRerender();
//Close tail windows by their pid.
const closePid = useCallback(
(pid: number) => {
logs = logs.filter((log) => log.script.pid !== pid);
rerender();
},
[rerender],
);
useEffect(
() =>
LogBoxEvents.subscribe((script: RunningScript) => {
if (logs.some((l) => l.script.pid === script.pid)) return;
logs.push({
id: script.pid,
script: script,
});
rerender();
}),
[rerender],
);
//Event used by ns.closeTail to close tail windows
useEffect(
() =>
LogBoxCloserEvents.subscribe((pid: number) => {
closePid(pid);
}),
[closePid],
);
useEffect(
() =>
LogBoxClearEvents.subscribe(() => {
logs = [];
rerender();
}),
[rerender],
);
//Close tail windows by their id
function close(id: number): void {
logs = logs.filter((l) => l.id !== id);
rerender();
}
return (
<>
{logs.map((log) => (
<LogWindow hidden={hidden} key={log.id} script={log.script} onClose={() => close(log.id)} />
))}
</>
);
}
interface LogWindowProps {
script: RunningScript;
onClose: () => void;
hidden: boolean;
}
const useStyles = makeStyles()({
logs: {
overflowY: "scroll",
overflowX: "hidden",
scrollbarWidth: "auto",
flexDirection: "column-reverse",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
},
titleButton: {
borderWidth: "0 0 0 1px",
borderColor: Settings.theme.welllight,
borderStyle: "solid",
borderRadius: "0",
padding: "0",
height: "100%",
},
});
function LogWindow({ hidden, script, onClose }: LogWindowProps): React.ReactElement {
const draggableRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<Draggable>(null);
const { classes } = useStyles();
const container = useRef<HTMLDivElement>(null);
const textArea = useRef<HTMLDivElement>(null);
const rerender = useRerender(Settings.TailRenderInterval);
const propsRef = useRef(new LogBoxProperties(rerender, rootRef));
script.tailProps = propsRef.current;
const textAreaKeyDown = (e: React.KeyboardEvent) => {
if (e.ctrlKey && e.key === "a") {
if (!textArea.current) return; //Should never happen
const r = new Range();
r.setStartBefore(textArea.current);
r.setEndAfter(textArea.current);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(r);
e.preventDefault();
e.stopPropagation();
}
};
const onResize = (_: React.SyntheticEvent, { size }: ResizeCallbackData) => {
propsRef.current.setSize(size.width, size.height);
};
const updateLayer = useCallback(() => {
const c = container.current;
if (c === null) return;
c.style.zIndex = logBoxBaseZIndex + layerCounter + "";
layerCounter++;
rerender();
}, [rerender]);
useEffect(() => {
propsRef.current.updateDOM();
updateLayer();
}, [updateLayer]);
function kill(): void {
killWorkerScriptByPid(script.pid);
rerender();
}
function run(): void {
const server = GetServer(script.server);
if (server === null) return;
const s = findRunningScriptByPid(script.pid);
if (s === null) {
const baseScript = server.scripts.get(script.filename);
if (!baseScript) {
return dialogBoxCreate(
`Could not launch script. The script ${script.filename} no longer exists on the server ${server.hostname}.`,
);
}
const ramUsage = baseScript.getRamUsage(server.scripts);
if (!ramUsage) {
return dialogBoxCreate(`Could not calculate ram usage for ${script.filename} on ${server.hostname}.`);
}
// Reset some things, because we're reusing the RunningScript instance
script.ramUsage = ramUsage;
script.dataMap = new Map();
script.onlineExpGained = 0;
script.onlineMoneyMade = 0;
script.onlineRunningTime = 0.01;
startWorkerScript(script, server);
rerender();
} else {
console.warn(`Tried to rerun pid ${script.pid} that was already running!`);
}
}
function title(): React.ReactElement {
const title_str = script.title === "string" ? script.title : `${script.filename} ${script.args.join(" ")}`;
return (
<Typography
variant="h6"
sx={{ marginRight: "auto", textOverflow: "ellipsis", whiteSpace: "nowrap", overflow: "hidden" }}
title={title_str}
>
{script.title}
</Typography>
);
}
function minimize(): void {
propsRef.current.setMinimized(!propsRef.current.minimized);
}
function lineColor(s: string): "error" | "success" | "warn" | "info" | "primary" {
if (s.match(/(^\[[^\]]+\] )?ERROR/) || s.match(/(^\[[^\]]+\] )?FAIL/)) {
return "error";
}
if (s.match(/(^\[[^\]]+\] )?SUCCESS/)) {
return "success";
}
if (s.match(/(^\[[^\]]+\] )?WARN/)) {
return "warn";
}
if (s.match(/(^\[[^\]]+\] )?INFO/)) {
return "info";
}
return "primary";
}
const onWindowResize = useMemo(
() =>
debounce((): void => {
const node = draggableRef.current;
if (!node) return;
if (!isOnScreen(node)) {
propsRef.current.setPosition(0, 0);
}
}, 100),
[],
);
// And trigger fakeDrag when the window is resized
useEffect(() => {
window.addEventListener("resize", onWindowResize);
return () => {
window.removeEventListener("resize", onWindowResize);
};
}, [onWindowResize]);
const isOnScreen = (node: HTMLDivElement): boolean => {
const bounds = node.getBoundingClientRect();
return !(bounds.right < 0 || bounds.bottom < 0 || bounds.left > innerWidth || bounds.top > outerWidth);
};
/**
* The returned type of onDrag is a bit weird here. The Draggable component expects an onDrag that returns "void | false".
* In that component's internal code, it checks for the explicit "false" value. If onDrag returns false, the component
* cancels the dragging.
*
* That's why they use "void | false" as the returned type. However, in TypeScript, "void" is not supposed to be used
* like that. ESLint will complain "void is not valid as a constituent in a union type". Please check its documentation
* for the reason. In order to solve this problem, I changed the returned type to "undefined | false".
*/
const onDrag = (e: DraggableEvent): undefined | false => {
e.preventDefault();
// bound to body
if (
e instanceof MouseEvent &&
(e.clientX < 0 || e.clientY < 0 || e.clientX > innerWidth || e.clientY > innerHeight)
) {
return false;
}
if (rootRef.current) {
// We can set x,y directly. Calling setPosition will make unnecessary calls of updateDOM and rerender.
const currentState = rootRef.current.state as { x: number; y: number };
propsRef.current.x = currentState.x;
propsRef.current.y = currentState.y;
}
};
return (
<Draggable handle=".drag" onDrag={onDrag} ref={rootRef} onMouseDown={updateLayer}>
<Box
display={hidden ? "none" : "flex"}
sx={{
flexFlow: "column",
position: "fixed",
zIndex: 1400,
minWidth: `${minWindowSize[0]}px`,
minHeight: `${minWindowSize[1]}px`,
...(propsRef.current.minimized
? {
border: "none",
margin: 0,
maxHeight: 0,
padding: 0,
}
: {
border: `1px solid ${Settings.theme.welllight}`,
}),
}}
ref={container}
>
<ResizableBox
width={propsRef.current.width}
height={propsRef.current.height}
onResize={onResize}
minConstraints={minWindowSize}
handle={
<span
style={{
position: "absolute",
right: "-10px",
bottom: "-16px",
cursor: "nw-resize",
display: propsRef.current.minimized ? "none" : "inline-block",
}}
>
<ArrowForwardIosIcon color="primary" style={{ transform: "rotate(45deg)", fontSize: "1.75rem" }} />
</span>
}
>
<>
<Paper className="drag" sx={{ display: "flex", alignItems: "center", cursor: "grab" }} ref={draggableRef}>
{title()}
<span style={{ minWidth: "fit-content", height: `${minWindowSize[1]}px` }}>
{!workerScripts.has(script.pid) ? (
<IconButton title="Re-run script" className={classes.titleButton} onClick={run} onTouchEnd={run}>
<PlayCircleIcon />
</IconButton>
) : (
<IconButton title="Stop script" className={classes.titleButton} onClick={kill} onTouchEnd={kill}>
<StopCircleIcon color="error" />
</IconButton>
)}
<IconButton
title={propsRef.current.minimized ? "Expand" : "Minimize"}
className={classes.titleButton}
onClick={minimize}
onTouchEnd={minimize}
>
{propsRef.current.minimized ? <ExpandMoreIcon /> : <ExpandLessIcon />}
</IconButton>
<IconButton title="Close window" className={classes.titleButton} onClick={onClose} onTouchEnd={onClose}>
<CloseIcon />
</IconButton>
</span>
</Paper>
<Paper
className={classes.logs}
style={{
height: `calc(100% - ${minWindowSize[1]}px)`,
display: propsRef.current.minimized ? "none" : "flex",
}}
tabIndex={-1}
ref={textArea}
onKeyDown={textAreaKeyDown}
>
<div style={{ display: "flex", flexDirection: "column" }}>
{script.logs.map(
(line: React.ReactNode, i: number): React.ReactNode =>
typeof line !== "string" ? (
line
) : (
<ANSIITypography
key={i}
text={line}
color={lineColor(line)}
styles={{
fontSize: propsRef.current.fontSize ?? Settings.styles.tailFontSize,
}}
/>
),
)}
</div>
</Paper>
</>
</ResizableBox>
</Box>
</Draggable>
);
}