mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2025-04-07 02:41:31 +02:00
983 lines
36 KiB
TypeScript
983 lines
36 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
|
import Editor, { Monaco } from "@monaco-editor/react";
|
|
import * as monaco from "monaco-editor";
|
|
|
|
type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor;
|
|
type ITextModel = monaco.editor.ITextModel;
|
|
import { OptionsModal } from "./OptionsModal";
|
|
import { Options } from "./Options";
|
|
import { isValidFilePath } from "../../Terminal/DirectoryHelpers";
|
|
import { Player } from "@player";
|
|
import { Router } from "../../ui/GameRoot";
|
|
import { Page } from "../../ui/Router";
|
|
import { dialogBoxCreate } from "../../ui/React/DialogBox";
|
|
import { isScriptFilename } from "../../Script/isScriptFilename";
|
|
import { Script } from "../../Script/Script";
|
|
import { TextFile } from "../../TextFile";
|
|
import { calculateRamUsage, checkInfiniteLoop } from "../../Script/RamCalculations";
|
|
import { RamCalculationErrorCode } from "../../Script/RamCalculationErrorCodes";
|
|
import { formatRam } from "../../ui/formatNumber";
|
|
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
|
|
import SearchIcon from "@mui/icons-material/Search";
|
|
|
|
import { NetscriptFunctions } from "../../NetscriptFunctions";
|
|
import { WorkerScript } from "../../Netscript/WorkerScript";
|
|
import { Settings } from "../../Settings/Settings";
|
|
import { iTutorialNextStep, ITutorial, iTutorialSteps } from "../../InteractiveTutorial";
|
|
import { debounce } from "lodash";
|
|
import { saveObject } from "../../SaveObject";
|
|
import { loadThemes, makeTheme, sanitizeTheme } from "./themes";
|
|
import { GetServer } from "../../Server/AllServers";
|
|
|
|
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 SettingsIcon from "@mui/icons-material/Settings";
|
|
import SyncIcon from "@mui/icons-material/Sync";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import Table from "@mui/material/Table";
|
|
import TableCell from "@mui/material/TableCell";
|
|
import TableRow from "@mui/material/TableRow";
|
|
import TableBody from "@mui/material/TableBody";
|
|
import { PromptEvent } from "../../ui/React/PromptManager";
|
|
import { Modal } from "../../ui/React/Modal";
|
|
|
|
import libSource from "!!raw-loader!../NetscriptDefinitions.d.ts";
|
|
import { TextField, Tooltip } from "@mui/material";
|
|
import { useRerender } from "../../ui/React/hooks";
|
|
|
|
interface IProps {
|
|
// Map of filename -> code
|
|
files: Record<string, string>;
|
|
hostname: string;
|
|
vim: boolean;
|
|
}
|
|
|
|
// TODO: try to remove global symbols
|
|
let symbolsLoaded = false;
|
|
let symbols: string[] = [];
|
|
export function SetupTextEditor(): void {
|
|
const ns = NetscriptFunctions({ args: [] } as unknown as WorkerScript);
|
|
|
|
// Populates symbols for text editor
|
|
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);
|
|
}
|
|
}
|
|
|
|
return symbols;
|
|
}
|
|
|
|
symbols = populate(ns);
|
|
|
|
const exclude = ["heart", "break", "exploit", "bypass", "corporation", "alterReality"];
|
|
symbols = symbols.filter((symbol: string) => !exclude.includes(symbol)).sort();
|
|
}
|
|
|
|
// Holds all the data for a open script
|
|
class OpenScript {
|
|
fileName: string;
|
|
code: string;
|
|
hostname: string;
|
|
lastPosition: monaco.Position;
|
|
model: ITextModel;
|
|
isTxt: boolean;
|
|
|
|
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;
|
|
this.isTxt = fileName.endsWith(".txt");
|
|
}
|
|
}
|
|
|
|
const openScripts: OpenScript[] = [];
|
|
let currentScript: OpenScript | null = null;
|
|
|
|
// Called every time script editor is opened
|
|
export function Root(props: IProps): React.ReactElement {
|
|
const rerender = useRerender();
|
|
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
|
|
const monacoRef = useRef<Monaco | null>(null);
|
|
const vimStatusRef = useRef<HTMLElement>(null);
|
|
const [vimEditor, setVimEditor] = useState<any>(null);
|
|
const [editor, setEditor] = useState<IStandaloneCodeEditor | null>(null);
|
|
const [filter, setFilter] = useState("");
|
|
const [searchExpanded, setSearchExpanded] = useState(false);
|
|
|
|
const [ram, setRAM] = useState("RAM: ???");
|
|
const [ramEntries, setRamEntries] = useState<string[][]>([["???", ""]]);
|
|
const [updatingRam, setUpdatingRam] = useState(false);
|
|
const [decorations, setDecorations] = useState<string[]>([]);
|
|
|
|
const [optionsOpen, setOptionsOpen] = useState(false);
|
|
const [options, setOptions] = useState<Options>({
|
|
theme: Settings.MonacoTheme,
|
|
insertSpaces: Settings.MonacoInsertSpaces,
|
|
fontSize: Settings.MonacoFontSize,
|
|
wordWrap: Settings.MonacoWordWrap,
|
|
vim: props.vim || Settings.MonacoVim,
|
|
});
|
|
|
|
const [ramInfoOpen, setRamInfoOpen] = useState(false);
|
|
|
|
// Prevent Crash if script is open on deleted server
|
|
for (let i = openScripts.length - 1; i >= 0; i--) {
|
|
GetServer(openScripts[i].hostname) === null && openScripts.splice(i, 1);
|
|
}
|
|
if (currentScript && GetServer(currentScript.hostname) === null) {
|
|
currentScript = openScripts[0];
|
|
if (currentScript === undefined) currentScript = null;
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (currentScript !== null) {
|
|
updateRAM(currentScript.code);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function keydown(event: KeyboardEvent): void {
|
|
if (Settings.DisableHotkeys) return;
|
|
//Ctrl + b
|
|
if (event.code == "KeyB" && (event.ctrlKey || event.metaKey)) {
|
|
event.preventDefault();
|
|
Router.toPage(Page.Terminal);
|
|
}
|
|
|
|
// CTRL/CMD + S
|
|
if (event.code == "KeyS" && (event.ctrlKey || event.metaKey)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
save();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", keydown);
|
|
return () => document.removeEventListener("keydown", keydown);
|
|
});
|
|
|
|
useEffect(() => {
|
|
// setup monaco-vim
|
|
if (options.vim && editor && !vimEditor) {
|
|
try {
|
|
// This library is not typed
|
|
// @ts-expect-error
|
|
window.require(["monaco-vim"], function (MonacoVim: any) {
|
|
setVimEditor(MonacoVim.initVimMode(editor, vimStatusRef.current));
|
|
MonacoVim.VimMode.Vim.defineEx("write", "w", function () {
|
|
// your own implementation on what you want to do when :w is pressed
|
|
save();
|
|
});
|
|
MonacoVim.VimMode.Vim.defineEx("quit", "q", function () {
|
|
Router.toPage(Page.Terminal);
|
|
});
|
|
|
|
const saveNQuit = (): void => {
|
|
save();
|
|
Router.toPage(Page.Terminal);
|
|
};
|
|
// "wqriteandquit" & "xriteandquit" are not typos, prefix must be found in full string
|
|
MonacoVim.VimMode.Vim.defineEx("wqriteandquit", "wq", saveNQuit);
|
|
MonacoVim.VimMode.Vim.defineEx("xriteandquit", "x", saveNQuit);
|
|
|
|
// Setup "go to next tab" and "go to previous tab". This is a little more involved
|
|
// since these aren't Ex commands (they run in normal mode, not after typing `:`)
|
|
MonacoVim.VimMode.Vim.defineAction("nextTabs", function (_cm: any, args: { repeat?: number }) {
|
|
const nTabs = args.repeat ?? 1;
|
|
// Go to the next tab (to the right). Wraps around when at the rightmost tab
|
|
const currIndex = currentTabIndex();
|
|
if (currIndex !== undefined) {
|
|
const nextIndex = (currIndex + nTabs) % openScripts.length;
|
|
onTabClick(nextIndex);
|
|
}
|
|
});
|
|
MonacoVim.VimMode.Vim.defineAction("prevTabs", function (_cm: any, args: { repeat?: number }) {
|
|
const nTabs = args.repeat ?? 1;
|
|
// Go to the previous tab (to the left). Wraps around when at the leftmost tab
|
|
const currIndex = currentTabIndex();
|
|
if (currIndex !== undefined) {
|
|
let nextIndex = currIndex - nTabs;
|
|
while (nextIndex < 0) {
|
|
nextIndex += openScripts.length;
|
|
}
|
|
onTabClick(nextIndex);
|
|
}
|
|
});
|
|
MonacoVim.VimMode.Vim.mapCommand("gt", "action", "nextTabs", {}, { context: "normal" });
|
|
MonacoVim.VimMode.Vim.mapCommand("gT", "action", "prevTabs", {}, { context: "normal" });
|
|
editor.focus();
|
|
});
|
|
} catch {}
|
|
} else if (!options.vim) {
|
|
// When vim mode is disabled
|
|
vimEditor?.dispose();
|
|
setVimEditor(null);
|
|
}
|
|
|
|
return () => {
|
|
vimEditor?.dispose();
|
|
};
|
|
}, [options, editorRef, editor, vimEditor]);
|
|
|
|
// Generates a new model for the script
|
|
function regenerateModel(script: OpenScript): void {
|
|
if (monacoRef.current !== null) {
|
|
script.model = monacoRef.current.editor.createModel(script.code, script.isTxt ? "plaintext" : "javascript");
|
|
}
|
|
}
|
|
|
|
const debouncedUpdateRAM = debounce((newCode: string) => {
|
|
updateRAM(newCode);
|
|
setUpdatingRam(false);
|
|
}, 300);
|
|
|
|
function updateRAM(newCode: string): void {
|
|
if (currentScript != null && currentScript.isTxt) {
|
|
setRAM("N/A");
|
|
setRamEntries([["N/A", ""]]);
|
|
return;
|
|
}
|
|
const codeCopy = newCode + "";
|
|
const ramUsage = calculateRamUsage(codeCopy, Player.getCurrentServer().scripts);
|
|
if (ramUsage.cost > 0) {
|
|
const entries = ramUsage.entries?.sort((a, b) => b.cost - a.cost) ?? [];
|
|
const entriesDisp = [];
|
|
for (const entry of entries) {
|
|
entriesDisp.push([`${entry.name} (${entry.type})`, formatRam(entry.cost)]);
|
|
}
|
|
|
|
setRAM("RAM: " + formatRam(ramUsage.cost));
|
|
setRamEntries(entriesDisp);
|
|
return;
|
|
}
|
|
let RAM = "";
|
|
const entriesDisp = [];
|
|
switch (ramUsage.cost) {
|
|
case RamCalculationErrorCode.ImportError: {
|
|
RAM = "RAM: Import Error";
|
|
entriesDisp.push(["Import Error", ""]);
|
|
break;
|
|
}
|
|
case RamCalculationErrorCode.SyntaxError:
|
|
default: {
|
|
RAM = "RAM: Syntax Error";
|
|
entriesDisp.push(["Syntax Error", ""]);
|
|
break;
|
|
}
|
|
}
|
|
setRAM(RAM);
|
|
setRamEntries(entriesDisp);
|
|
return;
|
|
}
|
|
|
|
// Formats the code
|
|
function beautify(): void {
|
|
if (editorRef.current === null) return;
|
|
editorRef.current.getAction("editor.action.formatDocument").run();
|
|
}
|
|
|
|
// 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
|
|
// https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-custom-languages
|
|
// https://github.com/threehams/typescript-error-guide/blob/master/stories/components/Editor.tsx#L11-L39
|
|
// https://blog.checklyhq.com/customizing-monaco/
|
|
// Before the editor is mounted
|
|
function beforeMount(monaco: any): void {
|
|
if (symbolsLoaded) return;
|
|
// Setup monaco auto completion
|
|
symbolsLoaded = true;
|
|
monaco.languages.registerCompletionItemProvider("javascript", {
|
|
provideCompletionItems: () => {
|
|
const suggestions = [];
|
|
for (const symbol of symbols) {
|
|
suggestions.push({
|
|
label: symbol,
|
|
kind: monaco.languages.CompletionItemKind.Function,
|
|
insertText: symbol,
|
|
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
});
|
|
}
|
|
return { suggestions: suggestions };
|
|
},
|
|
});
|
|
|
|
(async function () {
|
|
// We have to improve the default js language otherwise theme sucks
|
|
const l = await monaco.languages
|
|
.getLanguages()
|
|
.find((l: any) => l.id === "javascript")
|
|
.loader();
|
|
// replaced the bare tokens with regexes surrounded by \b, e.g. \b{token}\b which matches a word-break on either side
|
|
// this prevents the highlighter from highlighting pieces of variables that start with a reserved token name
|
|
l.language.tokenizer.root.unshift([new RegExp("\\bns\\b"), { token: "ns" }]);
|
|
for (const symbol of symbols)
|
|
l.language.tokenizer.root.unshift([new RegExp(`\\b${symbol}\\b`), { token: "netscriptfunction" }]);
|
|
const otherKeywords = ["let", "const", "var", "function"];
|
|
const otherKeyvars = ["true", "false", "null", "undefined"];
|
|
otherKeywords.forEach((k) =>
|
|
l.language.tokenizer.root.unshift([new RegExp(`\\b${k}\\b`), { token: "otherkeywords" }]),
|
|
);
|
|
otherKeyvars.forEach((k) =>
|
|
l.language.tokenizer.root.unshift([new RegExp(`\\b${k}\\b`), { token: "otherkeyvars" }]),
|
|
);
|
|
l.language.tokenizer.root.unshift([new RegExp("\\bthis\\b"), { token: "this" }]);
|
|
})();
|
|
|
|
const source = (libSource + "").replace(/export /g, "");
|
|
monaco.languages.typescript.javascriptDefaults.addExtraLib(source, "netscript.d.ts");
|
|
monaco.languages.typescript.typescriptDefaults.addExtraLib(source, "netscript.d.ts");
|
|
loadThemes(monaco);
|
|
sanitizeTheme(Settings.EditorTheme);
|
|
monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
|
|
}
|
|
|
|
// When the editor is mounted
|
|
function onMount(editor: IStandaloneCodeEditor, monaco: Monaco): void {
|
|
// Required when switching between site navigation (e.g. from Script Editor -> Terminal and back)
|
|
// the `useEffect()` for vim mode is called before editor is mounted.
|
|
setEditor(editor);
|
|
|
|
editorRef.current = editor;
|
|
monacoRef.current = monaco;
|
|
|
|
if (editorRef.current === null || monacoRef.current === null) return;
|
|
|
|
if (!props.files && currentScript !== null) {
|
|
// Open currentscript
|
|
regenerateModel(currentScript);
|
|
editorRef.current.setModel(currentScript.model);
|
|
editorRef.current.setPosition(currentScript.lastPosition);
|
|
editorRef.current.revealLineInCenter(currentScript.lastPosition.lineNumber);
|
|
updateRAM(currentScript.code);
|
|
editorRef.current.focus();
|
|
return;
|
|
}
|
|
if (props.files) {
|
|
const files = Object.entries(props.files);
|
|
|
|
if (!files.length) {
|
|
editorRef.current.focus();
|
|
return;
|
|
}
|
|
|
|
for (const [filename, code] of files) {
|
|
// Check if file is already opened
|
|
const openScript = openScripts.find(
|
|
(script) => script.fileName === filename && script.hostname === props.hostname,
|
|
);
|
|
if (openScript) {
|
|
// Script is already opened
|
|
if (openScript.model === undefined || openScript.model === null || openScript.model.isDisposed()) {
|
|
regenerateModel(openScript);
|
|
}
|
|
|
|
currentScript = openScript;
|
|
editorRef.current.setModel(openScript.model);
|
|
editorRef.current.setPosition(openScript.lastPosition);
|
|
editorRef.current.revealLineInCenter(openScript.lastPosition.lineNumber);
|
|
updateRAM(openScript.code);
|
|
} else {
|
|
// Open script
|
|
const newScript = new OpenScript(
|
|
filename,
|
|
code,
|
|
props.hostname,
|
|
new monacoRef.current.Position(0, 0),
|
|
monacoRef.current.editor.createModel(code, filename.endsWith(".txt") ? "plaintext" : "javascript"),
|
|
);
|
|
openScripts.push(newScript);
|
|
currentScript = newScript;
|
|
editorRef.current.setModel(newScript.model);
|
|
updateRAM(newScript.code);
|
|
}
|
|
}
|
|
}
|
|
|
|
editorRef.current.focus();
|
|
}
|
|
|
|
function infLoop(newCode: string): void {
|
|
if (editorRef.current === null || currentScript === null) return;
|
|
if (!currentScript.fileName.endsWith(".js")) return;
|
|
const awaitWarning = checkInfiniteLoop(newCode);
|
|
if (awaitWarning !== -1) {
|
|
const newDecorations = editorRef.current.deltaDecorations(decorations, [
|
|
{
|
|
range: {
|
|
startLineNumber: awaitWarning,
|
|
startColumn: 1,
|
|
endLineNumber: awaitWarning,
|
|
endColumn: 10,
|
|
},
|
|
options: {
|
|
isWholeLine: true,
|
|
glyphMarginClassName: "myGlyphMarginClass",
|
|
glyphMarginHoverMessage: {
|
|
value: "Possible infinite loop, await something.",
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
setDecorations(newDecorations);
|
|
} else {
|
|
const newDecorations = editorRef.current.deltaDecorations(decorations, []);
|
|
setDecorations(newDecorations);
|
|
}
|
|
}
|
|
|
|
// When the code is updated within the editor
|
|
function updateCode(newCode?: string): void {
|
|
if (newCode === undefined) return;
|
|
setUpdatingRam(true);
|
|
debouncedUpdateRAM(newCode);
|
|
if (editorRef.current === null) return;
|
|
const newPos = editorRef.current.getPosition();
|
|
if (newPos === null) return;
|
|
if (currentScript !== null) {
|
|
currentScript.code = newCode;
|
|
currentScript.lastPosition = newPos;
|
|
}
|
|
try {
|
|
infLoop(newCode);
|
|
} catch (err) {}
|
|
}
|
|
|
|
function saveScript(scriptToSave: OpenScript): void {
|
|
const server = GetServer(scriptToSave.hostname);
|
|
if (server === null) throw new Error("Server should not be null but it is.");
|
|
if (isScriptFilename(scriptToSave.fileName)) {
|
|
//If the current script already exists on the server, overwrite it
|
|
for (let i = 0; i < server.scripts.length; i++) {
|
|
if (scriptToSave.fileName == server.scripts[i].filename) {
|
|
server.scripts[i].saveScript(scriptToSave.fileName, scriptToSave.code, Player.currentServer, server.scripts);
|
|
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
|
|
Router.toPage(Page.Terminal);
|
|
return;
|
|
}
|
|
}
|
|
|
|
//If the current script does NOT exist, create a new one
|
|
const script = new Script();
|
|
script.saveScript(scriptToSave.fileName, scriptToSave.code, Player.currentServer, server.scripts);
|
|
server.scripts.push(script);
|
|
} else if (scriptToSave.isTxt) {
|
|
for (let i = 0; i < server.textFiles.length; ++i) {
|
|
if (server.textFiles[i].fn === scriptToSave.fileName) {
|
|
server.textFiles[i].write(scriptToSave.code);
|
|
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
|
|
Router.toPage(Page.Terminal);
|
|
return;
|
|
}
|
|
}
|
|
const textFile = new TextFile(scriptToSave.fileName, scriptToSave.code);
|
|
server.textFiles.push(textFile);
|
|
} else {
|
|
dialogBoxCreate("Invalid filename. Must be either a script (.script or .js) or a text file (.txt)");
|
|
return;
|
|
}
|
|
|
|
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
|
|
Router.toPage(Page.Terminal);
|
|
}
|
|
|
|
function save(): void {
|
|
if (currentScript === null) {
|
|
console.error("currentScript is null when it shouldn't be. Unable to save script");
|
|
return;
|
|
}
|
|
// this is duplicate code with saving later.
|
|
if (ITutorial.isRunning && ITutorial.currStep === iTutorialSteps.TerminalTypeScript) {
|
|
//Make sure filename + code properly follow tutorial
|
|
if (currentScript.fileName !== "n00dles.script" && currentScript.fileName !== "n00dles.js") {
|
|
dialogBoxCreate("Don't change the script name for now.");
|
|
return;
|
|
}
|
|
const cleanCode = currentScript.code.replace(/\s/g, "");
|
|
const ns1 = "while(true){hack('n00dles');}";
|
|
const ns2 = `exportasyncfunctionmain(ns){while(true){awaitns.hack('n00dles');}}`;
|
|
if (cleanCode.indexOf(ns1) == -1 && cleanCode.indexOf(ns2) == -1) {
|
|
dialogBoxCreate("Please copy and paste the code from the tutorial!");
|
|
return;
|
|
}
|
|
|
|
//Save the script
|
|
saveScript(currentScript);
|
|
|
|
iTutorialNextStep();
|
|
|
|
return;
|
|
}
|
|
|
|
if (currentScript.fileName == "") {
|
|
dialogBoxCreate("You must specify a filename!");
|
|
return;
|
|
}
|
|
|
|
if (!isValidFilePath(currentScript.fileName)) {
|
|
dialogBoxCreate(
|
|
"Script filename can contain only alphanumerics, hyphens, and underscores, and must end with an extension.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const server = GetServer(currentScript.hostname);
|
|
if (server === null) throw new Error("Server should not be null but it is.");
|
|
if (isScriptFilename(currentScript.fileName)) {
|
|
//If the current script already exists on the server, overwrite it
|
|
for (let i = 0; i < server.scripts.length; i++) {
|
|
if (currentScript.fileName == server.scripts[i].filename) {
|
|
server.scripts[i].saveScript(
|
|
currentScript.fileName,
|
|
currentScript.code,
|
|
Player.currentServer,
|
|
server.scripts,
|
|
);
|
|
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
|
|
rerender();
|
|
return;
|
|
}
|
|
}
|
|
|
|
//If the current script does NOT exist, create a new one
|
|
const script = new Script();
|
|
script.saveScript(currentScript.fileName, currentScript.code, Player.currentServer, server.scripts);
|
|
server.scripts.push(script);
|
|
} else if (currentScript.isTxt) {
|
|
for (let i = 0; i < server.textFiles.length; ++i) {
|
|
if (server.textFiles[i].fn === currentScript.fileName) {
|
|
server.textFiles[i].write(currentScript.code);
|
|
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
|
|
rerender();
|
|
return;
|
|
}
|
|
}
|
|
const textFile = new TextFile(currentScript.fileName, currentScript.code);
|
|
server.textFiles.push(textFile);
|
|
} else {
|
|
dialogBoxCreate("Invalid filename. Must be either a script (.script or .js) or a text file (.txt)");
|
|
return;
|
|
}
|
|
|
|
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
|
|
rerender();
|
|
}
|
|
|
|
function reorder(list: OpenScript[], startIndex: number, endIndex: number): void {
|
|
const [removed] = list.splice(startIndex, 1);
|
|
list.splice(endIndex, 0, removed);
|
|
}
|
|
|
|
function onDragEnd(result: any): void {
|
|
// Dropped outside of the list
|
|
if (!result.destination) return;
|
|
reorder(openScripts, result.source.index, result.destination.index);
|
|
}
|
|
|
|
function currentTabIndex(): number | undefined {
|
|
if (currentScript !== null) {
|
|
return openScripts.findIndex(
|
|
(script) =>
|
|
currentScript !== null &&
|
|
script.fileName === currentScript.fileName &&
|
|
script.hostname === currentScript.hostname,
|
|
);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function onTabClick(index: number): void {
|
|
if (currentScript !== null) {
|
|
// Save currentScript to openScripts
|
|
const curIndex = currentTabIndex();
|
|
if (curIndex !== undefined) {
|
|
openScripts[curIndex] = currentScript;
|
|
}
|
|
}
|
|
|
|
currentScript = openScripts[index];
|
|
|
|
if (editorRef.current !== null && openScripts[index] !== null) {
|
|
if (currentScript.model === undefined || currentScript.model.isDisposed()) {
|
|
regenerateModel(currentScript);
|
|
}
|
|
editorRef.current.setModel(currentScript.model);
|
|
|
|
editorRef.current.setPosition(currentScript.lastPosition);
|
|
editorRef.current.revealLineInCenter(currentScript.lastPosition.lineNumber);
|
|
updateRAM(currentScript.code);
|
|
editorRef.current.focus();
|
|
}
|
|
}
|
|
|
|
function onTabClose(index: number): void {
|
|
// See if the script on the server is up to date
|
|
const closingScript = openScripts[index];
|
|
const savedScriptCode = closingScript.code;
|
|
const wasCurrentScript = openScripts[index] === currentScript;
|
|
|
|
if (dirty(index)) {
|
|
PromptEvent.emit({
|
|
txt: `Do you want to save changes to ${closingScript.fileName} on ${closingScript.hostname}?`,
|
|
resolve: (result: boolean | string) => {
|
|
if (result) {
|
|
// Save changes
|
|
closingScript.code = savedScriptCode;
|
|
saveScript(closingScript);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
openScripts.splice(index, 1);
|
|
if (openScripts.length === 0) {
|
|
currentScript = null;
|
|
Router.toPage(Page.Terminal);
|
|
return;
|
|
}
|
|
|
|
// Change current script if we closed it
|
|
if (wasCurrentScript) {
|
|
//Keep the same index unless we were on the last script
|
|
const indexOffset = openScripts.length === index ? -1 : 0;
|
|
currentScript = openScripts[index + indexOffset];
|
|
if (editorRef.current !== null) {
|
|
if (currentScript.model.isDisposed() || !currentScript.model) {
|
|
regenerateModel(currentScript);
|
|
}
|
|
editorRef.current.setModel(currentScript.model);
|
|
editorRef.current.setPosition(currentScript.lastPosition);
|
|
editorRef.current.revealLineInCenter(currentScript.lastPosition.lineNumber);
|
|
editorRef.current.focus();
|
|
}
|
|
}
|
|
rerender();
|
|
}
|
|
|
|
function onTabUpdate(index: number): void {
|
|
const openScript = openScripts[index];
|
|
const serverScriptCode = getServerCode(index);
|
|
if (serverScriptCode === null) return;
|
|
|
|
if (openScript.code !== serverScriptCode) {
|
|
PromptEvent.emit({
|
|
txt:
|
|
"Do you want to overwrite the current editor content with the contents of " +
|
|
openScript.fileName +
|
|
" on the server? This cannot be undone.",
|
|
resolve: (result: boolean | string) => {
|
|
if (result) {
|
|
// Save changes
|
|
openScript.code = serverScriptCode;
|
|
|
|
// Switch to target tab
|
|
onTabClick(index);
|
|
|
|
if (editorRef.current !== null && openScript !== null) {
|
|
if (openScript.model === undefined || openScript.model.isDisposed()) {
|
|
regenerateModel(openScript);
|
|
}
|
|
editorRef.current.setModel(openScript.model);
|
|
|
|
editorRef.current.setValue(openScript.code);
|
|
updateRAM(openScript.code);
|
|
editorRef.current.focus();
|
|
}
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function dirty(index: number): string {
|
|
const openScript = openScripts[index];
|
|
const serverData = getServerCode(index);
|
|
if (serverData === null) return " *";
|
|
// For scripts, server code is stored with its starting & trailing whitespace removed
|
|
const code = openScript.isTxt ? openScript.code : Script.formatCode(openScript.code);
|
|
return serverData !== code ? " *" : "";
|
|
}
|
|
function getServerCode(index: number): string | null {
|
|
const openScript = openScripts[index];
|
|
const server = GetServer(openScript.hostname);
|
|
if (server === null) throw new Error(`Server '${openScript.hostname}' should not be null, but it is.`);
|
|
const data = openScript.isTxt
|
|
? server.textFiles.find((t) => t.filename === openScript.fileName)?.text
|
|
: server.scripts.find((s) => s.filename === openScript.fileName)?.code;
|
|
return data ?? null;
|
|
}
|
|
function handleFilterChange(event: React.ChangeEvent<HTMLInputElement>): void {
|
|
setFilter(event.target.value);
|
|
}
|
|
function handleExpandSearch(): void {
|
|
setFilter("");
|
|
setSearchExpanded(!searchExpanded);
|
|
}
|
|
const filteredOpenScripts = Object.values(openScripts).filter(
|
|
(script) => script.hostname.includes(filter) || script.fileName.includes(filter),
|
|
);
|
|
|
|
const tabsMaxWidth = 1640;
|
|
const tabMargin = 5;
|
|
const tabMaxWidth = filteredOpenScripts.length ? tabsMaxWidth / filteredOpenScripts.length - tabMargin : 0;
|
|
const tabIconWidth = 25;
|
|
const tabTextWidth = tabMaxWidth - tabIconWidth * 2;
|
|
return (
|
|
<>
|
|
<div style={{ display: currentScript !== null ? "block" : "none", height: "100%", width: "100%" }}>
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<Droppable droppableId="tabs" direction="horizontal">
|
|
{(provided, snapshot) => (
|
|
<Box
|
|
maxWidth={`${tabsMaxWidth}px`}
|
|
display="flex"
|
|
flexDirection="row"
|
|
alignItems="center"
|
|
whiteSpace="nowrap"
|
|
ref={provided.innerRef}
|
|
{...provided.droppableProps}
|
|
style={{
|
|
backgroundColor: snapshot.isDraggingOver
|
|
? Settings.theme.backgroundsecondary
|
|
: Settings.theme.backgroundprimary,
|
|
overflowX: "scroll",
|
|
}}
|
|
>
|
|
<Tooltip title={"Search Open Scripts"}>
|
|
{searchExpanded ? (
|
|
<TextField
|
|
value={filter}
|
|
onChange={handleFilterChange}
|
|
autoFocus
|
|
InputProps={{
|
|
startAdornment: <SearchIcon />,
|
|
spellCheck: false,
|
|
endAdornment: <CloseIcon onClick={handleExpandSearch} />,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Button onClick={handleExpandSearch}>
|
|
<SearchIcon />
|
|
</Button>
|
|
)}
|
|
</Tooltip>
|
|
{filteredOpenScripts.map(({ fileName, hostname }, index) => {
|
|
const editingCurrentScript =
|
|
currentScript?.fileName === filteredOpenScripts[index].fileName &&
|
|
currentScript?.hostname === filteredOpenScripts[index].hostname;
|
|
const externalScript = hostname !== "home";
|
|
const colorProps = editingCurrentScript
|
|
? {
|
|
background: Settings.theme.button,
|
|
borderColor: Settings.theme.button,
|
|
color: Settings.theme.primary,
|
|
}
|
|
: {
|
|
background: Settings.theme.backgroundsecondary,
|
|
borderColor: Settings.theme.backgroundsecondary,
|
|
color: Settings.theme.secondary,
|
|
};
|
|
|
|
if (externalScript) {
|
|
colorProps.color = Settings.theme.info;
|
|
}
|
|
const iconButtonStyle = {
|
|
maxWidth: `${tabIconWidth}px`,
|
|
minWidth: `${tabIconWidth}px`,
|
|
minHeight: "38.5px",
|
|
maxHeight: "38.5px",
|
|
...colorProps,
|
|
};
|
|
|
|
const scriptTabText = `${hostname}:~${fileName.startsWith("/") ? "" : "/"}${fileName} ${dirty(
|
|
index,
|
|
)}`;
|
|
return (
|
|
<Draggable
|
|
key={fileName + hostname}
|
|
draggableId={fileName + hostname}
|
|
index={index}
|
|
disableInteractiveElementBlocking={true}
|
|
>
|
|
{(provided) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
{...provided.dragHandleProps}
|
|
style={{
|
|
...provided.draggableProps.style,
|
|
maxWidth: `${tabMaxWidth}px`,
|
|
marginRight: `${tabMargin}px`,
|
|
flexShrink: 0,
|
|
border: "1px solid " + Settings.theme.well,
|
|
}}
|
|
>
|
|
<Tooltip title={scriptTabText}>
|
|
<Button
|
|
onClick={() => onTabClick(index)}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
if (e.button === 1) onTabClose(index);
|
|
}}
|
|
style={{
|
|
maxWidth: `${tabTextWidth}px`,
|
|
minHeight: "38.5px",
|
|
overflow: "hidden",
|
|
...colorProps,
|
|
}}
|
|
>
|
|
<span style={{ overflow: "hidden", direction: "rtl", textOverflow: "ellipsis" }}>
|
|
{scriptTabText}
|
|
</span>
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip title="Overwrite editor content with saved file content">
|
|
<Button onClick={() => onTabUpdate(index)} style={iconButtonStyle}>
|
|
<SyncIcon fontSize="small" />
|
|
</Button>
|
|
</Tooltip>
|
|
<Button onClick={() => onTabClose(index)} style={iconButtonStyle}>
|
|
<CloseIcon fontSize="small" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
);
|
|
})}
|
|
{provided.placeholder}
|
|
</Box>
|
|
)}
|
|
</Droppable>
|
|
</DragDropContext>
|
|
<div style={{ paddingBottom: "5px" }} />
|
|
<Editor
|
|
beforeMount={beforeMount}
|
|
onMount={onMount}
|
|
loading={<Typography>Loading script editor!</Typography>}
|
|
height={`calc(100vh - ${130 + (options.vim ? 34 : 0)}px)`}
|
|
defaultLanguage="javascript"
|
|
defaultValue={""}
|
|
onChange={updateCode}
|
|
theme={options.theme}
|
|
options={{ ...options, glyphMargin: true }}
|
|
/>
|
|
|
|
<Box
|
|
ref={vimStatusRef}
|
|
className="monaco-editor"
|
|
display="flex"
|
|
flexDirection="row"
|
|
sx={{ p: 1 }}
|
|
alignItems="center"
|
|
></Box>
|
|
|
|
<Box display="flex" flexDirection="row" sx={{ m: 1 }} alignItems="center">
|
|
<Button startIcon={<SettingsIcon />} onClick={() => setOptionsOpen(true)} sx={{ mr: 1 }}>
|
|
Options
|
|
</Button>
|
|
<Button onClick={beautify}>Beautify</Button>
|
|
<Button
|
|
color={updatingRam ? "secondary" : "primary"}
|
|
sx={{ mx: 1 }}
|
|
onClick={() => {
|
|
setRamInfoOpen(true);
|
|
}}
|
|
>
|
|
{ram}
|
|
</Button>
|
|
<Button onClick={save}>Save (Ctrl/Cmd + s)</Button>
|
|
<Button sx={{ mx: 1 }} onClick={() => Router.toPage(Page.Terminal)}>
|
|
Terminal (Ctrl/Cmd + b)
|
|
</Button>
|
|
<Typography>
|
|
{" "}
|
|
<strong>Documentation:</strong>{" "}
|
|
<Link target="_blank" href="https://bitburner-official.readthedocs.io/en/latest/index.html">
|
|
Basic
|
|
</Link>
|
|
{" | "}
|
|
<Link
|
|
target="_blank"
|
|
href="https://github.com/bitburner-official/bitburner-src/blob/dev/markdown/bitburner.ns.md"
|
|
>
|
|
Full
|
|
</Link>
|
|
</Typography>
|
|
</Box>
|
|
<OptionsModal
|
|
open={optionsOpen}
|
|
onClose={() => {
|
|
sanitizeTheme(Settings.EditorTheme);
|
|
monacoRef.current?.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
|
|
setOptionsOpen(false);
|
|
}}
|
|
options={{
|
|
theme: Settings.MonacoTheme,
|
|
insertSpaces: Settings.MonacoInsertSpaces,
|
|
fontSize: Settings.MonacoFontSize,
|
|
wordWrap: Settings.MonacoWordWrap,
|
|
vim: Settings.MonacoVim,
|
|
}}
|
|
save={(options: Options) => {
|
|
sanitizeTheme(Settings.EditorTheme);
|
|
monacoRef.current?.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
|
|
setOptions(options);
|
|
Settings.MonacoTheme = options.theme;
|
|
Settings.MonacoInsertSpaces = options.insertSpaces;
|
|
Settings.MonacoFontSize = options.fontSize;
|
|
Settings.MonacoWordWrap = options.wordWrap;
|
|
Settings.MonacoVim = options.vim;
|
|
}}
|
|
/>
|
|
<Modal open={ramInfoOpen} onClose={() => setRamInfoOpen(false)}>
|
|
<Table>
|
|
<TableBody>
|
|
{ramEntries.map(([n, r]) => (
|
|
<React.Fragment key={n + r}>
|
|
<TableRow>
|
|
<TableCell sx={{ color: Settings.theme.primary }}>{n}</TableCell>
|
|
<TableCell align="right" sx={{ color: Settings.theme.primary }}>
|
|
{r}
|
|
</TableCell>
|
|
</TableRow>
|
|
</React.Fragment>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Modal>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: currentScript !== null ? "none" : "flex",
|
|
height: "100%",
|
|
width: "100%",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<span style={{ color: Settings.theme.primary, fontSize: "20px", textAlign: "center" }}>
|
|
<Typography variant="h4">No open files</Typography>
|
|
<Typography variant="h5">
|
|
Use <code>nano FILENAME</code> in
|
|
<br />
|
|
the terminal to open files
|
|
</Typography>
|
|
</span>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|