bitburner-src/src/ScriptEditor/ui/Root.tsx

346 lines
12 KiB
TypeScript
Raw Normal View History

2021-09-05 01:09:30 +02:00
import React, { useState, useEffect, useRef } from "react";
2021-08-20 07:21:37 +02:00
import { StdButton } from "../../ui/React/StdButton";
import Editor from "@monaco-editor/react";
2021-08-20 07:57:32 +02:00
import * as monaco from "monaco-editor";
2021-08-27 01:14:56 +02:00
type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor;
2021-08-20 07:21:37 +02:00
import { createPopup } from "../../ui/React/createPopup";
2021-09-25 05:36:28 +02:00
import { OptionsModal } from "./OptionsModal";
import { Options } from "./Options";
2021-09-05 01:09:30 +02:00
import { js_beautify as beautifyCode } from "js-beautify";
2021-08-20 07:21:37 +02:00
import { isValidFilePath } from "../../Terminal/DirectoryHelpers";
import { IPlayer } from "../../PersonObjects/IPlayer";
2021-09-17 08:58:02 +02:00
import { IRouter } from "../../ui/Router";
2021-08-20 07:21:37 +02:00
import { dialogBoxCreate } from "../../../utils/DialogBox";
2021-09-24 22:34:21 +02:00
import { isScriptFilename } from "../../Script/isScriptFilename";
2021-08-20 07:21:37 +02:00
import { Script } from "../../Script/Script";
import { TextFile } from "../../TextFile";
import { calculateRamUsage } from "../../Script/RamCalculations";
import { RamCalculationErrorCode } from "../../Script/RamCalculationErrorCodes";
import { numeralWrapper } from "../../ui/numeralFormat";
2021-08-20 07:57:32 +02:00
import { CursorPositions } from "../../ScriptEditor/CursorPositions";
2021-08-20 22:11:49 +02:00
import { libSource } from "../NetscriptDefinitions";
2021-08-21 06:17:26 +02:00
import { NetscriptFunctions } from "../../NetscriptFunctions";
import { WorkerScript } from "../../Netscript/WorkerScript";
import { Settings } from "../../Settings/Settings";
2021-09-09 05:47:34 +02:00
import { iTutorialNextStep, ITutorial, iTutorialSteps } from "../../InteractiveTutorial";
2021-08-21 06:17:26 +02:00
2021-09-25 05:36:28 +02:00
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Link from "@mui/material/Link";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton";
import SettingsIcon from "@mui/icons-material/Settings";
2021-09-22 18:56:55 +02:00
let loaded = false;
2021-08-21 06:17:26 +02:00
let symbols: string[] = [];
2021-09-05 01:09:30 +02:00
(function () {
const ns = NetscriptFunctions({} as WorkerScript);
2021-08-21 06:17:26 +02:00
2021-09-05 01:09:30 +02:00
function populate(ns: any): string[] {
let symbols: string[] = [];
const keys = Object.keys(ns);
for (const key of keys) {
if (typeof ns[key] === "object") {
symbols.push(key);
symbols = symbols.concat(populate(ns[key]));
}
if (typeof ns[key] === "function") {
symbols.push(key);
}
2021-08-21 06:17:26 +02:00
}
2021-09-05 01:09:30 +02:00
return symbols;
}
symbols = populate(ns);
2021-09-10 08:17:55 +02:00
const exclude = ["heart", "break", "exploit", "bypass", "corporation"];
symbols = symbols.filter((symbol: string) => !exclude.includes(symbol));
2021-08-21 06:17:26 +02:00
})();
2021-08-20 07:21:37 +02:00
interface IProps {
2021-09-05 01:09:30 +02:00
filename: string;
code: string;
player: IPlayer;
2021-09-17 08:58:02 +02:00
router: IRouter;
2021-08-27 01:14:56 +02:00
}
2021-08-20 07:21:37 +02:00
2021-08-21 07:54:39 +02:00
/*
*/
2021-08-20 07:21:37 +02:00
// How to load function definition in monaco
// https://github.com/Microsoft/monaco-editor/issues/1415
// https://microsoft.github.io/monaco-editor/api/modules/monaco.languages.html
// https://www.npmjs.com/package/@monaco-editor/react#development-playground
2021-08-20 10:03:00 +02:00
// https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-custom-languages
2021-08-20 19:57:32 +02:00
// https://github.com/threehams/typescript-error-guide/blob/master/stories/components/Editor.tsx#L11-L39
2021-08-20 07:21:37 +02:00
2021-08-23 08:09:49 +02:00
// These variables are used to reload a script when it's clicked on. Because we
// won't have references to the old script.
let lastFilename = "";
let lastCode = "";
let lastPosition: monaco.Position | null = null;
2021-08-20 07:21:37 +02:00
export function Root(props: IProps): React.ReactElement {
2021-09-05 01:09:30 +02:00
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
2021-09-09 05:47:34 +02:00
const [filename, setFilename] = useState(props.filename ? props.filename : lastFilename);
2021-09-22 07:10:29 +02:00
const [code, setCode] = useState<string>(props.filename ? props.code : lastCode);
2021-09-05 01:09:30 +02:00
const [ram, setRAM] = useState("RAM: ???");
2021-09-25 05:36:28 +02:00
const [optionsOpen, setOptionsOpen] = useState(false);
2021-09-05 01:09:30 +02:00
const [options, setOptions] = useState<Options>({
theme: Settings.MonacoTheme,
insertSpaces: Settings.MonacoInsertSpaces,
});
// store the last known state in case we need to restart without nano.
useEffect(() => {
2021-09-20 23:26:47 +02:00
if (props.filename === undefined) return;
2021-09-05 01:09:30 +02:00
lastFilename = props.filename;
lastCode = props.code;
lastPosition = null;
}, []);
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
function save(): void {
if (editorRef.current !== null) {
const position = editorRef.current.getPosition();
if (position !== null) {
CursorPositions.saveCursor(filename, {
row: position.lineNumber,
column: position.column,
});
}
}
lastPosition = null;
2021-08-23 08:09:49 +02:00
2021-09-19 06:46:39 +02:00
// this is duplicate code with saving later.
2021-09-09 05:47:34 +02:00
if (ITutorial.isRunning && ITutorial.currStep === iTutorialSteps.TerminalTypeScript) {
2021-09-05 01:09:30 +02:00
//Make sure filename + code properly follow tutorial
if (filename !== "n00dles.script") {
dialogBoxCreate("Leave the script name as 'n00dles'!");
return;
}
2021-09-09 05:47:34 +02:00
if (code.replace(/\s/g, "").indexOf("while(true){hack('n00dles');}") == -1) {
2021-09-05 01:09:30 +02:00
dialogBoxCreate("Please copy and paste the code from the tutorial!");
return;
}
//Save the script
const server = props.player.getCurrentServer();
2021-09-09 05:47:34 +02:00
if (server === null) throw new Error("Server should not be null but it is.");
2021-09-19 06:46:39 +02:00
let found = false;
2021-09-05 01:09:30 +02:00
for (let i = 0; i < server.scripts.length; i++) {
if (filename == server.scripts[i].filename) {
2021-09-09 05:47:34 +02:00
server.scripts[i].saveScript(code, props.player.currentServer, server.scripts);
2021-09-19 06:46:39 +02:00
found = true;
2021-08-20 07:57:32 +02:00
}
2021-09-05 01:09:30 +02:00
}
2021-08-20 07:21:37 +02:00
2021-09-19 06:46:39 +02:00
if (!found) {
const script = new Script();
script.saveScript(code, props.player.currentServer, server.scripts);
server.scripts.push(script);
}
iTutorialNextStep();
2021-08-20 07:21:37 +02:00
2021-09-19 06:46:39 +02:00
props.router.toTerminal();
return;
2021-09-05 01:09:30 +02:00
}
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
if (filename == "") {
dialogBoxCreate("You must specify a filename!");
return;
}
2021-08-20 07:21:37 +02:00
2021-09-24 00:47:43 +02:00
if (!isValidFilePath(filename)) {
2021-09-05 01:09:30 +02:00
dialogBoxCreate(
"Script filename can contain only alphanumerics, hyphens, and underscores, and must end with an extension.",
);
return;
}
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
const server = props.player.getCurrentServer();
2021-09-09 05:47:34 +02:00
if (server === null) throw new Error("Server should not be null but it is.");
2021-09-24 00:47:43 +02:00
if (isScriptFilename(filename)) {
2021-09-05 01:09:30 +02:00
//If the current script already exists on the server, overwrite it
for (let i = 0; i < server.scripts.length; i++) {
if (filename == server.scripts[i].filename) {
2021-09-09 05:47:34 +02:00
server.scripts[i].saveScript(code, props.player.currentServer, server.scripts);
2021-09-17 08:58:02 +02:00
props.router.toTerminal();
2021-09-05 01:09:30 +02:00
return;
2021-08-20 07:21:37 +02:00
}
2021-09-05 01:09:30 +02:00
}
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
//If the current script does NOT exist, create a new one
const script = new Script();
script.saveScript(code, props.player.currentServer, server.scripts);
server.scripts.push(script);
} else if (filename.endsWith(".txt")) {
for (let i = 0; i < server.textFiles.length; ++i) {
if (server.textFiles[i].fn === filename) {
server.textFiles[i].write(code);
2021-09-17 08:58:02 +02:00
props.router.toTerminal();
2021-09-05 01:09:30 +02:00
return;
2021-08-20 07:21:37 +02:00
}
2021-09-05 01:09:30 +02:00
}
const textFile = new TextFile(filename, code);
server.textFiles.push(textFile);
} else {
2021-09-09 05:47:34 +02:00
dialogBoxCreate("Invalid filename. Must be either a script (.script, .js, or .ns) or " + " or text file (.txt)");
2021-09-05 01:09:30 +02:00
return;
}
2021-09-17 08:58:02 +02:00
props.router.toTerminal();
2021-09-05 01:09:30 +02:00
}
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
function beautify(): void {
if (editorRef.current === null) return;
const pretty = beautifyCode(code, {
indent_with_tabs: !options.insertSpaces,
indent_size: 4,
brace_style: "preserve-inline",
});
editorRef.current.setValue(pretty);
}
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
function onFilenameChange(event: React.ChangeEvent<HTMLInputElement>): void {
lastFilename = filename;
setFilename(event.target.value);
}
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
function updateCode(newCode?: string): void {
if (newCode === undefined) return;
lastCode = newCode;
if (editorRef.current !== null) {
lastPosition = editorRef.current.getPosition();
2021-08-20 07:21:37 +02:00
}
2021-09-05 01:09:30 +02:00
setCode(newCode);
}
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
async function updateRAM(): Promise<void> {
const codeCopy = code + "";
2021-09-09 05:47:34 +02:00
const ramUsage = await calculateRamUsage(codeCopy, props.player.getCurrentServer().scripts);
2021-09-05 01:09:30 +02:00
if (ramUsage > 0) {
setRAM("RAM: " + numeralWrapper.formatRAM(ramUsage));
return;
2021-08-20 07:21:37 +02:00
}
2021-09-05 01:09:30 +02:00
switch (ramUsage) {
case RamCalculationErrorCode.ImportError: {
setRAM("RAM: Import Error");
break;
}
case RamCalculationErrorCode.URLImportError: {
setRAM("RAM: HTTP Import Error");
break;
}
case RamCalculationErrorCode.SyntaxError:
default: {
setRAM("RAM: Syntax Error");
break;
}
2021-08-20 07:21:37 +02:00
}
2021-09-05 01:09:30 +02:00
return new Promise<void>(() => undefined);
}
2021-08-20 07:21:37 +02:00
2021-09-05 01:09:30 +02:00
useEffect(() => {
const id = setInterval(updateRAM, 1000);
return () => clearInterval(id);
}, [code]);
useEffect(() => {
2021-09-06 21:06:08 +02:00
function maybeSave(event: KeyboardEvent): void {
2021-09-05 01:09:30 +02:00
if (Settings.DisableHotkeys) return;
//Ctrl + b
if (event.keyCode == 66 && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
save();
}
2021-08-20 07:57:32 +02:00
}
2021-09-05 01:09:30 +02:00
document.addEventListener("keydown", maybeSave);
return () => document.removeEventListener("keydown", maybeSave);
});
2021-08-20 07:57:32 +02:00
2021-09-05 01:09:30 +02:00
function onMount(editor: IStandaloneCodeEditor): void {
editorRef.current = editor;
if (editorRef.current === null) return;
const position = CursorPositions.getCursor(filename);
if (position.row !== -1)
editorRef.current.setPosition({
lineNumber: position.row,
column: position.column,
});
else if (lastPosition !== null)
editorRef.current.setPosition({
lineNumber: lastPosition.lineNumber,
column: lastPosition.column + 1,
});
editorRef.current.focus();
}
2021-08-20 07:57:32 +02:00
2021-09-05 01:09:30 +02:00
function beforeMount(monaco: any): void {
monaco.languages.registerCompletionItemProvider("javascript", {
provideCompletionItems: () => {
const suggestions = [];
for (const symbol of symbols) {
suggestions.push({
label: symbol,
kind: monaco.languages.CompletionItemKind.Function,
insertText: symbol,
2021-09-09 05:47:34 +02:00
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
2021-09-05 01:09:30 +02:00
});
2021-08-21 07:54:39 +02:00
}
2021-09-05 01:09:30 +02:00
return { suggestions: suggestions };
},
});
2021-09-09 05:47:34 +02:00
monaco.languages.typescript.javascriptDefaults.addExtraLib(libSource, "netscript.d.ts");
monaco.languages.typescript.typescriptDefaults.addExtraLib(libSource, "netscript.d.ts");
2021-09-22 18:56:55 +02:00
loaded = true;
2021-09-05 01:09:30 +02:00
}
2021-09-05 01:09:30 +02:00
return (
2021-09-17 08:04:44 +02:00
<>
2021-09-25 05:36:28 +02:00
<Box display="flex" flexDirection="row" alignItems="center">
<Typography>Script name: </Typography>
<TextField variant="standard" type="text" tabIndex={1} value={filename} onChange={onFilenameChange} />
<IconButton onClick={() => setOptionsOpen(true)}>
<>
<SettingsIcon />
options
</>
</IconButton>
</Box>
2021-09-05 01:09:30 +02:00
<Editor
beforeMount={beforeMount}
onMount={onMount}
loading={<p>Loading script editor!</p>}
2021-09-17 08:04:44 +02:00
height="90%"
2021-09-05 01:09:30 +02:00
defaultLanguage="javascript"
defaultValue={code}
onChange={updateCode}
theme={options.theme}
options={options}
/>
2021-09-25 05:36:28 +02:00
<Box display="flex" flexDirection="row" sx={{ m: 1 }} alignItems="center">
<Button onClick={beautify}>Beautify</Button>
<Typography sx={{ mx: 1 }}>{ram}</Typography>
<Button onClick={save}>Save & Close (Ctrl/Cmd + b)</Button>
<Link sx={{ mx: 1 }} target="_blank" href="https://bitburner.readthedocs.io/en/latest/index.html">
2021-09-05 01:09:30 +02:00
Netscript Documentation
2021-09-25 05:36:28 +02:00
</Link>
</Box>
<OptionsModal
open={optionsOpen}
onClose={() => setOptionsOpen(false)}
options={{
theme: Settings.MonacoTheme,
insertSpaces: Settings.MonacoInsertSpaces,
}}
save={(options: Options) => {
setOptions(options);
Settings.MonacoTheme = options.theme;
Settings.MonacoInsertSpaces = options.insertSpaces;
}}
/>
2021-09-17 08:04:44 +02:00
</>
2021-09-05 01:09:30 +02:00
);
}