Added the ability to open multiple script files at a time

Fixed a bug where ram would stay the same when switching between scripts

Fixed bug where updating ram could cause the GUI to crash

The editor will now go to the last edited line when switching tabs.

Tidied up the script editor UI
This commit is contained in:
BuildTools 2021-12-17 07:36:33 -06:00
parent eb002d655a
commit 2265f797c4

@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import Editor from "@monaco-editor/react"; import Editor, { Monaco } from "@monaco-editor/react";
import * as monaco from "monaco-editor"; import * as monaco from "monaco-editor";
type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor;
type ITextModel = monaco.editor.ITextModel;
import { OptionsModal } from "./OptionsModal"; import { OptionsModal } from "./OptionsModal";
import { Options } from "./Options"; import { Options } from "./Options";
import { isValidFilePath } from "../../Terminal/DirectoryHelpers"; import { isValidFilePath } from "../../Terminal/DirectoryHelpers";
@ -34,6 +35,10 @@ import IconButton from "@mui/material/IconButton";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import libSource from "!!raw-loader!../NetscriptDefinitions.d.ts"; import libSource from "!!raw-loader!../NetscriptDefinitions.d.ts";
import { cssNumber } from "cypress/types/jquery";
import { buttonBaseClasses } from "@mui/material";
import { fromPairs } from "cypress/types/lodash";
import { StringMatcher } from "cypress/types/net-stubbing";
let symbolsLoaded = false; let symbolsLoaded = false;
let symbols: string[] = []; let symbols: string[] = [];
@ -80,22 +85,32 @@ interface IProps {
// https://github.com/threehams/typescript-error-guide/blob/master/stories/components/Editor.tsx#L11-L39 // https://github.com/threehams/typescript-error-guide/blob/master/stories/components/Editor.tsx#L11-L39
// https://blog.checklyhq.com/customizing-monaco/ // https://blog.checklyhq.com/customizing-monaco/
// These variables are used to reload a script when it's clicked on. Because we // Holds all the data for a open script
// won't have references to the old script. class openScript {
let lastFilename = ""; fileName: string;
let lastCode = ""; code: string;
let hostname = ""; hostname: string;
let lastPosition: monaco.Position | null = null; lastPosition: monaco.Position;
model: ITextModel;
constructor(fileName: string, code: string, hostname: string, lastPosition: monaco.Position, model: ITextModel) {
this.fileName = fileName;
this.code = code;
this.hostname = hostname;
this.lastPosition = lastPosition;
this.model = model;
}
}
const openScripts = new Array<openScript>(); // Holds all open scripts
let currentScript = {} as openScript; // Script currently being viewed
export function Root(props: IProps): React.ReactElement { export function Root(props: IProps): React.ReactElement {
const editorRef = useRef<IStandaloneCodeEditor | null>(null); const editorRef = useRef<IStandaloneCodeEditor | null>(null);
const [filename, setFilename] = useState(props.filename ? props.filename : lastFilename); const monacoRef = useRef<Monaco | null>(null);
const [code, setCode] = useState<string>(props.filename ? props.code : lastCode); const [filename, setFilename] = useState(props.filename);
const [code, setCode] = useState<string>(props.code);
const [decorations, setDecorations] = useState<string[]>([]); const [decorations, setDecorations] = useState<string[]>([]);
hostname = props.filename ? props.hostname : hostname;
if (hostname === "") {
hostname = props.player.getCurrentServer().hostname;
}
const [ram, setRAM] = useState("RAM: ???"); const [ram, setRAM] = useState("RAM: ???");
const [updatingRam, setUpdatingRam] = useState(false); const [updatingRam, setUpdatingRam] = useState(false);
const [optionsOpen, setOptionsOpen] = useState(false); const [optionsOpen, setOptionsOpen] = useState(false);
@ -105,6 +120,8 @@ export function Root(props: IProps): React.ReactElement {
fontSize: Settings.MonacoFontSize, fontSize: Settings.MonacoFontSize,
}); });
const debouncedSetRAM = useMemo( const debouncedSetRAM = useMemo(
() => () =>
debounce((s) => { debounce((s) => {
@ -114,54 +131,21 @@ export function Root(props: IProps): React.ReactElement {
[], [],
); );
// store the last known state in case we need to restart without nano.
useEffect(() => {
if (props.filename === undefined) return;
lastFilename = props.filename;
lastCode = props.code;
lastPosition = null;
}, []);
function save(): void { 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;
// this is duplicate code with saving later. // this is duplicate code with saving later.
if (ITutorial.isRunning && ITutorial.currStep === iTutorialSteps.TerminalTypeScript) { if (ITutorial.isRunning && ITutorial.currStep === iTutorialSteps.TerminalTypeScript) {
//Make sure filename + code properly follow tutorial //Make sure filename + code properly follow tutorial
if (filename !== "n00dles.script") { if (currentScript.fileName !== "n00dles.script") {
dialogBoxCreate("Leave the script name as 'n00dles.script'!"); dialogBoxCreate("Leave the script name as 'n00dles.script'!");
return; return;
} }
if (code.replace(/\s/g, "").indexOf("while(true){hack('n00dles');}") == -1) { if (currentScript.code.replace(/\s/g, "").indexOf("while(true){hack('n00dles');}") == -1) {
dialogBoxCreate("Please copy and paste the code from the tutorial!"); dialogBoxCreate("Please copy and paste the code from the tutorial!");
return; return;
} }
//Save the script //Save the script
const server = GetServer(hostname); saveScript(currentScript);
if (server === null) throw new Error("Server should not be null but it is.");
let found = false;
for (let i = 0; i < server.scripts.length; i++) {
if (filename == server.scripts[i].filename) {
server.scripts[i].saveScript(filename, code, hostname, server.scripts);
found = true;
}
}
if (!found) {
const script = new Script();
script.saveScript(filename, code, hostname, server.scripts);
server.scripts.push(script);
}
iTutorialNextStep(); iTutorialNextStep();
@ -169,45 +153,43 @@ export function Root(props: IProps): React.ReactElement {
return; return;
} }
if (filename == "") { if (currentScript.fileName == "") {
dialogBoxCreate("You must specify a filename!"); dialogBoxCreate("You must specify a filename!");
return; return;
} }
if (!isValidFilePath(filename)) { if (!isValidFilePath(currentScript.fileName)) {
dialogBoxCreate( dialogBoxCreate(
"Script filename can contain only alphanumerics, hyphens, and underscores, and must end with an extension.", "Script filename can contain only alphanumerics, hyphens, and underscores, and must end with an extension.",
); );
return; return;
} }
const server = GetServer(hostname); const server = GetServer(currentScript.hostname);
if (server === null) throw new Error("Server should not be null but it is."); if (server === null) throw new Error("Server should not be null but it is.");
if (isScriptFilename(filename)) { if (isScriptFilename(currentScript.fileName)) {
//If the current script already exists on the server, overwrite it //If the current script already exists on the server, overwrite it
for (let i = 0; i < server.scripts.length; i++) { for (let i = 0; i < server.scripts.length; i++) {
if (filename == server.scripts[i].filename) { if (currentScript.fileName == server.scripts[i].filename) {
server.scripts[i].saveScript(filename, code, props.player.currentServer, server.scripts); server.scripts[i].saveScript(currentScript.fileName, currentScript.code, props.player.currentServer, server.scripts);
if (Settings.SaveGameOnFileSave) saveObject.saveGame(); if (Settings.SaveGameOnFileSave) saveObject.saveGame();
props.router.toTerminal();
return; return;
} }
} }
//If the current script does NOT exist, create a new one //If the current script does NOT exist, create a new one
const script = new Script(); const script = new Script();
script.saveScript(filename, code, props.player.currentServer, server.scripts); script.saveScript(currentScript.fileName, currentScript.code, props.player.currentServer, server.scripts);
server.scripts.push(script); server.scripts.push(script);
} else if (filename.endsWith(".txt")) { } else if (currentScript.fileName.endsWith(".txt")) {
for (let i = 0; i < server.textFiles.length; ++i) { for (let i = 0; i < server.textFiles.length; ++i) {
if (server.textFiles[i].fn === filename) { if (server.textFiles[i].fn === currentScript.fileName) {
server.textFiles[i].write(code); server.textFiles[i].write(currentScript.code);
if (Settings.SaveGameOnFileSave) saveObject.saveGame(); if (Settings.SaveGameOnFileSave) saveObject.saveGame();
props.router.toTerminal();
return; return;
} }
} }
const textFile = new TextFile(filename, code); const textFile = new TextFile(currentScript.fileName, currentScript.code);
server.textFiles.push(textFile); server.textFiles.push(textFile);
} else { } else {
dialogBoxCreate("Invalid filename. Must be either a script (.script, .js, or .ns) or " + " or text file (.txt)"); dialogBoxCreate("Invalid filename. Must be either a script (.script, .js, or .ns) or " + " or text file (.txt)");
@ -215,7 +197,6 @@ export function Root(props: IProps): React.ReactElement {
} }
if (Settings.SaveGameOnFileSave) saveObject.saveGame(); if (Settings.SaveGameOnFileSave) saveObject.saveGame();
props.router.toTerminal();
} }
function beautify(): void { function beautify(): void {
@ -223,14 +204,9 @@ export function Root(props: IProps): React.ReactElement {
editorRef.current.getAction("editor.action.formatDocument").run(); editorRef.current.getAction("editor.action.formatDocument").run();
} }
function onFilenameChange(event: React.ChangeEvent<HTMLInputElement>): void {
lastFilename = event.target.value;
setFilename(event.target.value);
}
function infLoop(newCode: string): void { function infLoop(newCode: string): void {
if (editorRef.current === null) return; if (editorRef.current === null) return;
if (!filename.endsWith(".ns") && !filename.endsWith(".js")) return; if (!currentScript.fileName.endsWith(".ns") && !currentScript.fileName.endsWith(".js")) return;
const awaitWarning = checkInfiniteLoop(newCode); const awaitWarning = checkInfiniteLoop(newCode);
if (awaitWarning !== -1) { if (awaitWarning !== -1) {
const newDecorations = editorRef.current.deltaDecorations(decorations, [ const newDecorations = editorRef.current.deltaDecorations(decorations, [
@ -259,20 +235,18 @@ export function Root(props: IProps): React.ReactElement {
function updateCode(newCode?: string): void { function updateCode(newCode?: string): void {
if (newCode === undefined) return; if (newCode === undefined) return;
lastCode = newCode;
setCode(newCode);
updateRAM(newCode); updateRAM(newCode);
currentScript.code = newCode;
try { try {
if (editorRef.current !== null) { if (editorRef.current !== null) {
lastPosition = editorRef.current.getPosition();
infLoop(newCode); infLoop(newCode);
} }
} catch (err) {} } catch (err) { }
} }
// calculate it once the first time the file is loaded. // calculate it once the first time the file is loaded.
useEffect(() => { useEffect(() => {
updateRAM(code); updateRAM(currentScript.code);
}, []); }, []);
async function updateRAM(newCode: string): Promise<void> { async function updateRAM(newCode: string): Promise<void> {
@ -314,8 +288,64 @@ export function Root(props: IProps): React.ReactElement {
return () => document.removeEventListener("keydown", maybeSave); return () => document.removeEventListener("keydown", maybeSave);
}); });
function onMount(editor: IStandaloneCodeEditor): void { // Generates a new model for the script
function regenerateModel(script: openScript) {
if (monacoRef.current !== null) {
script.model = monacoRef.current.editor.createModel(script.code, 'javascript');
}
}
// Sets the currently viewed script
function setCurrentScript(script: openScript) {
// Update last position
if (editorRef.current !== null) {
if (currentScript !== null) {
var currentPosition = editorRef.current.getPosition();
if (currentPosition !== null) {
currentScript.lastPosition = currentPosition;
}
}
editorRef.current.setModel(script.model);
currentScript = script;
editorRef.current.setPosition(currentScript.lastPosition);
editorRef.current.revealLine(currentScript.lastPosition.lineNumber);
updateRAM(currentScript.code);
}
}
// Gets a currently opened script
function getOpenedScript(fileName: string, hostname: string) {
for (const script of openScripts) {
if (script.fileName === fileName && script.hostname === hostname) {
return script;
}
}
return null;
}
function saveScript(script: openScript) {
const server = GetServer(script.hostname);
if (server === null) throw new Error("Server should not be null but it is.");
let found = false;
for (let i = 0; i < server.scripts.length; i++) {
if (script.fileName == server.scripts[i].filename) {
server.scripts[i].saveScript(script.fileName, script.code, script.hostname, server.scripts);
found = true;
}
}
if (!found) {
const newScript = new Script();
newScript.saveScript(script.fileName, script.code, script.hostname, server.scripts);
server.scripts.push(newScript);
}
}
function onMount(editor: IStandaloneCodeEditor, monaco: Monaco): void {
editorRef.current = editor; editorRef.current = editor;
monacoRef.current = monaco;
if (editorRef.current === null) return; if (editorRef.current === null) return;
const position = CursorPositions.getCursor(filename); const position = CursorPositions.getCursor(filename);
if (position.row !== -1) if (position.row !== -1)
@ -323,12 +353,44 @@ export function Root(props: IProps): React.ReactElement {
lineNumber: position.row, lineNumber: position.row,
column: position.column, column: position.column,
}); });
else if (lastPosition !== null)
editorRef.current.setPosition({
lineNumber: lastPosition.lineNumber,
column: lastPosition.column + 1,
});
editorRef.current.focus(); editorRef.current.focus();
const script = getOpenedScript(filename, props.player.getCurrentServer().hostname);
// Check if script is already opened, if so switch to that model
if (script !== null) {
if (script.model.isDisposed()) {
regenerateModel(script);
}
setCurrentScript(script);
} else {
if (filename !== undefined) {
// Create new model
if (monacoRef.current !== null) {
var newScript = new openScript(filename, code, props.player.getCurrentServer().hostname, new monaco.Position(0, 0), monacoRef.current.editor.createModel(code, 'javascript'));
setCurrentScript(newScript);
openScripts.push(newScript);
}
} else {
// Script Editor was opened by the sidebar button
if (currentScript.model !== undefined) {
if (currentScript.model.isDisposed()) {
// Create new model, old one was disposed of
regenerateModel(currentScript);
}
setCurrentScript(currentScript);
} else {
// Create a new temporary file
if (monacoRef.current !== null) {
var newScript = new openScript('NewFile.ns', '', props.player.getCurrentServer().hostname, new monaco.Position(0, 0), monacoRef.current.editor.createModel('', 'javascript'));
setCurrentScript(newScript);
openScripts.push(newScript);
}
}
}
}
} }
function beforeMount(monaco: any): void { function beforeMount(monaco: any): void {
@ -368,26 +430,119 @@ export function Root(props: IProps): React.ReactElement {
monaco.languages.typescript.typescriptDefaults.addExtraLib(source, "netscript.d.ts"); monaco.languages.typescript.typescriptDefaults.addExtraLib(source, "netscript.d.ts");
loadThemes(monaco); loadThemes(monaco);
} }
// Change tab highlight from old tab to new tab
function changeTabButtonColor(oldButtonFileName: string, oldButtonHostname: string, newButtonFileName: string, newButtonHostname: string) {
const oldTabButton = document.getElementById('tabButton' + oldButtonFileName + oldButtonHostname);
if (oldTabButton !== null) {
oldTabButton.style.backgroundColor = '';
}
const oldTabCloseButton = document.getElementById('tabCloseButton' + oldButtonFileName + oldButtonHostname);
if (oldTabCloseButton !== null) {
oldTabCloseButton.style.backgroundColor = '';
}
const newTabButton = document.getElementById('tabButton' + newButtonFileName + newButtonHostname);
if (newTabButton !== null) {
newTabButton.style.backgroundColor = '#173b2d';
}
const newTabCloseButton = document.getElementById('tabCloseButton' + newButtonFileName + newButtonHostname);
if (newTabCloseButton !== null) {
newTabCloseButton.style.backgroundColor = '#173b2d';
}
}
// Called when a script tab was clicked
function onTabButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
const valSplit = e.currentTarget.value.split(':');
const fileName = valSplit[0];
const hostname = valSplit[1];
// Change tab highlight from old tab to new tab
changeTabButtonColor(currentScript.fileName, currentScript.hostname, fileName, hostname)
// Update current script
const clickedScript = getOpenedScript(fileName, hostname);
if (clickedScript !== null) {
if (clickedScript.model.isDisposed()) {
regenerateModel(clickedScript);
}
setCurrentScript(clickedScript);
}
}
// Called when a script tab close button was clicked
function onCloseButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
const valSplit = e.currentTarget.value.split(':');
const fileName = valSplit[0];
const hostname = valSplit[1];
const scriptToClose = getOpenedScript(fileName, hostname);
// Save and remove script from openScripts
if (scriptToClose !== null) {
saveScript(scriptToClose);
openScripts.splice(openScripts.indexOf(scriptToClose), 1);
}
if (openScripts.length === 0) {
// No other scripts are open, create a new temporary file
if (monacoRef.current !== null) {
const newScript = new openScript("NewFile.ns", '', props.player.getCurrentServer().hostname, new monacoRef.current.Position(0, 0), monacoRef.current.editor.createModel('', 'javascript'));
setCurrentScript(newScript)
openScripts.push(newScript);
// Create new tab button for temporary file
const element = (<div style={{ paddingRight: '5px' }}><Button style={{ backgroundColor: '#173b2d' }} value={newScript.fileName + ':' + newScript.hostname} onClick={onTabButtonClick}>{newScript.fileName}</Button><Button value={newScript.fileName + ':' + newScript.hostname} onClick={onCloseButtonClick} style={{ maxWidth: '20px', minWidth: '20px', backgroundColor: '#173b2d' }}>x</Button></div>)
// Modify button for temp file
var parent = e.currentTarget.parentElement;
if (parent !== null) {
(parent.children[0] as HTMLButtonElement).value = 'NewFile.ns:home';
(parent.children[0] as HTMLButtonElement).textContent = 'NewFile.ns';
e.currentTarget.value = 'NewFile.ns:home';
}
}
} else {
if (openScripts[0].model.isDisposed()) {
regenerateModel(openScripts[0]);
}
changeTabButtonColor(currentScript.fileName, currentScript.hostname, openScripts[0].fileName, openScripts[0].hostname);
setCurrentScript(openScripts[0]);
}
}
// Generate a button for each open script
const scriptButtons = [];
for (let i = 0; i < openScripts.length; i++) {
if (openScripts[i].fileName !== '') {
const fileName2 = openScripts[i].fileName;
const hostname = openScripts[i].hostname;
if (openScripts[i].fileName === currentScript.fileName && openScripts[i].hostname === currentScript.hostname) {
// Set special background color for current script tab button
scriptButtons.push(<div id={'scriptEditorTab' + fileName2 + hostname} key={'tabButton' + i} style={{ paddingRight: '5px' }}><Button id={'tabButton' + openScripts[i].fileName + openScripts[i].hostname} style={{ backgroundColor: '#173b2d' }} value={fileName2 + ':' + hostname} onClick={onTabButtonClick}>{openScripts[i].fileName}</Button><Button id={'tabCloseButton' + openScripts[i].fileName + openScripts[i].hostname} value={fileName2 + ':' + hostname} onClick={onCloseButtonClick} style={{ maxWidth: '20px', minWidth: '20px', backgroundColor: '#173b2d' }}>x</Button></div>)
} else {
scriptButtons.push(<div id={'scriptEditorTab' + fileName2 + hostname} key={'tabButton' + i} style={{ paddingRight: '5px' }}><Button id={'tabButton' + openScripts[i].fileName + openScripts[i].hostname} value={fileName2 + ':' + hostname} onClick={onTabButtonClick}>{openScripts[i].fileName}</Button><Button id={'tabCloseButton' + openScripts[i].fileName + openScripts[i].hostname} value={fileName2 + ':' + hostname} onClick={onCloseButtonClick} style={{ maxWidth: '20px', minWidth: '20px' }}>x</Button></div>)
}
}
}
// 370px 71%, 725px 85.1%, 1085px 90%, 1300px 91.7% // 370px 71%, 725px 85.1%, 1085px 90%, 1300px 91.7%
// fuck around in desmos until you find a function // fuck around in desmos until you find a function
const p = 11000 / -window.innerHeight + 100; const p = 11000 / -window.innerHeight + 100;
return ( return (
<> <>
<Box display="flex" flexDirection="row" alignItems="center"> <Box display="flex" flexDirection="row" alignItems="center">
<TextField {scriptButtons}
placeholder="filename"
type="text"
tabIndex={1}
value={filename}
onChange={onFilenameChange}
InputProps={{ startAdornment: <Typography>{hostname}:~/</Typography> }}
/>
<IconButton onClick={() => setOptionsOpen(true)}>
<>
<SettingsIcon />
options
</>
</IconButton>
</Box> </Box>
<Editor <Editor
beforeMount={beforeMount} beforeMount={beforeMount}
@ -402,11 +557,11 @@ export function Root(props: IProps): React.ReactElement {
/> />
<Box display="flex" flexDirection="row" sx={{ m: 1 }} alignItems="center"> <Box display="flex" flexDirection="row" sx={{ m: 1 }} alignItems="center">
<Button onClick={beautify}>Beautify</Button> <Button onClick={beautify}>Beautify</Button>
<Typography color={updatingRam ? "secondary" : "primary"} sx={{ mx: 1 }}> <Typography color={updatingRam ? "secondary" : "primary"} sx={{ mx: 1 }}>
{ram} {ram}
</Typography> </Typography>
<Button onClick={save}>Save & Close (Ctrl/Cmd + b)</Button> <Button onClick={save}>Save (Ctrl/Cmd + b)</Button>
<Typography sx={{ mx: 1 }}> <Typography style={{ marginLeft: '50%' }} sx={{ mx: 1 }}>
{" "} {" "}
Documentation:{" "} Documentation:{" "}
<Link target="_blank" href="https://bitburner.readthedocs.io/en/latest/index.html"> <Link target="_blank" href="https://bitburner.readthedocs.io/en/latest/index.html">
@ -417,6 +572,12 @@ export function Root(props: IProps): React.ReactElement {
Full Full
</Link> </Link>
</Typography> </Typography>
<IconButton style={{ marginLeft: 'auto' }} onClick={() => setOptionsOpen(true)}>
<>
<SettingsIcon />
options
</>
</IconButton>
</Box> </Box>
<OptionsModal <OptionsModal
open={optionsOpen} open={optionsOpen}