REFACTORING: ScriptEditor (#560)

This commit is contained in:
Aleksei Bezrodnov 2023-06-03 19:55:25 +02:00 committed by GitHub
parent 886f402a43
commit 99954ebd1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 695 additions and 491 deletions

@ -1,10 +1,9 @@
import * as monaco from "monaco-editor";
import * as React from "react";
import { useEffect, useRef } from "react";
import { useScriptEditorContext } from "./ScriptEditorContext";
interface EditorProps {
/** Editor options */
options: monaco.editor.IEditorOptions;
/** Function to be ran prior to mounting editor */
beforeMount: () => void;
/** Function to be ran after mounting editor */
@ -13,35 +12,38 @@ interface EditorProps {
onChange: (newCode?: string) => void;
}
export function Editor({ options, beforeMount, onMount, onChange }: EditorProps) {
export function Editor({ beforeMount, onMount, onChange }: EditorProps) {
const containerDiv = useRef<HTMLDivElement | null>(null);
const editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const subscription = useRef<monaco.IDisposable | null>(null);
const { options } = useScriptEditorContext();
useEffect(() => {
if (!containerDiv.current) return;
// Before initializing monaco editor
beforeMount();
// Initialize monaco editor
editor.current = monaco.editor.create(containerDiv.current, {
editorRef.current = monaco.editor.create(containerDiv.current, {
value: "",
automaticLayout: true,
language: "javascript",
...options,
glyphMargin: true,
});
// After initializing monaco editor
onMount(editor.current);
subscription.current = editor.current.onDidChangeModelContent(() => {
onChange(editor.current?.getValue());
onMount(editorRef.current);
subscription.current = editorRef.current.onDidChangeModelContent(() => {
onChange(editorRef.current?.getValue());
});
// Unmounting
return () => {
subscription.current?.dispose();
editor.current?.getModel()?.dispose();
editor.current?.dispose();
editorRef.current?.getModel()?.dispose();
editorRef.current?.dispose();
};
}, []);

@ -0,0 +1,28 @@
import React from "react";
import Typography from "@mui/material/Typography";
import { Settings } from "../../Settings/Settings";
export function NoOpenScripts() {
return (
<div
style={{
display: "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>
);
}

@ -0,0 +1,24 @@
import * as monaco from "monaco-editor";
import { ContentFilePath } from "src/Paths/ContentFile";
type ITextModel = monaco.editor.ITextModel;
// Holds all the data for a open script
export class OpenScript {
path: ContentFilePath;
code: string;
hostname: string;
lastPosition: monaco.Position;
model: ITextModel;
isTxt: boolean;
constructor(path: ContentFilePath, code: string, hostname: string, lastPosition: monaco.Position, model: ITextModel) {
this.path = path;
this.code = code;
this.hostname = hostname;
this.lastPosition = lastPosition;
this.model = model;
this.isTxt = path.endsWith(".txt");
}
}

@ -0,0 +1,112 @@
import React, { useContext, useState } from "react";
import { Player } from "@player";
import { Settings } from "../../Settings/Settings";
import { calculateRamUsage } from "../../Script/RamCalculations";
import { RamCalculationErrorCode } from "../../Script/RamCalculationErrorCodes";
import { formatRam } from "../../ui/formatNumber";
import { useBoolean } from "../../ui/React/hooks";
import { Options } from "./Options";
export interface ScriptEditorContextShape {
ram: string;
ramEntries: string[][];
updateRAM: (newCode: string | null) => void;
isUpdatingRAM: boolean;
startUpdatingRAM: () => void;
finishUpdatingRAM: () => void;
options: Options;
saveOptions: (options: Options) => void;
}
const ScriptEditorContext = React.createContext({} as ScriptEditorContextShape);
interface IProps {
children: React.ReactNode;
vim: boolean;
}
export function ScriptEditorContextProvider({ children, vim }: IProps) {
const [ram, setRAM] = useState("RAM: ???");
const [ramEntries, setRamEntries] = useState<string[][]>([["???", ""]]);
function updateRAM(newCode: string | null): void {
if (newCode === null) {
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);
}
const [isUpdatingRAM, { on: startUpdatingRAM, off: finishUpdatingRAM }] = useBoolean(false);
const [options, setOptions] = useState<Options>({
theme: Settings.MonacoTheme,
insertSpaces: Settings.MonacoInsertSpaces,
tabSize: Settings.MonacoTabSize,
detectIndentation: Settings.MonacoDetectIndentation,
fontFamily: Settings.MonacoFontFamily,
fontSize: Settings.MonacoFontSize,
fontLigatures: Settings.MonacoFontLigatures,
wordWrap: Settings.MonacoWordWrap,
vim: vim || Settings.MonacoVim,
});
function saveOptions(options: Options) {
setOptions(options);
Settings.MonacoTheme = options.theme;
Settings.MonacoInsertSpaces = options.insertSpaces;
Settings.MonacoTabSize = options.tabSize;
Settings.MonacoDetectIndentation = options.detectIndentation;
Settings.MonacoFontFamily = options.fontFamily;
Settings.MonacoFontSize = options.fontSize;
Settings.MonacoFontLigatures = options.fontLigatures;
Settings.MonacoWordWrap = options.wordWrap;
Settings.MonacoVim = options.vim;
}
return (
<ScriptEditorContext.Provider
value={{ ram, ramEntries, updateRAM, isUpdatingRAM, startUpdatingRAM, finishUpdatingRAM, options, saveOptions }}
>
{children}
</ScriptEditorContext.Provider>
);
}
export const useScriptEditorContext = () => useContext(ScriptEditorContext);

@ -1,23 +1,14 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useEffect, useRef } from "react";
import { Editor } from "./Editor";
import * as monaco from "monaco-editor";
// @ts-expect-error This library does not have types.
import * as MonacoVim from "monaco-vim";
type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor;
type ITextModel = monaco.editor.ITextModel;
import { OptionsModal } from "./OptionsModal";
import { Options } from "./Options";
import { Player } from "@player";
import { Router } from "../../ui/GameRoot";
import { Page } from "../../ui/Router";
import { dialogBoxCreate } from "../../ui/React/DialogBox";
import { ScriptFilePath } from "../../Paths/ScriptFilePath";
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 { checkInfiniteLoop } from "../../Script/RamCalculations";
import { ns, enums } from "../../NetscriptFunctions";
import { Settings } from "../../Settings/Settings";
@ -27,26 +18,20 @@ 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";
import { NetscriptExtra } from "../../NetscriptFunctions/Extra";
import { TextFilePath } from "src/Paths/TextFilePath";
import { ContentFilePath } from "src/Paths/ContentFile";
import { dirty, getServerCode } from "./utils";
import { OpenScript } from "./OpenScript";
import { Tabs } from "./Tabs";
import { Toolbar } from "./Toolbar";
import { NoOpenScripts } from "./NoOpenScripts";
import { ScriptEditorContextProvider, useScriptEditorContext } from "./ScriptEditorContext";
import { useVimEditor } from "./useVimEditor";
interface IProps {
// Map of filename -> code
@ -72,56 +57,14 @@ export function SetupTextEditor(): void {
populate();
}
// Holds all the data for a open script
class OpenScript {
path: ContentFilePath;
code: string;
hostname: string;
lastPosition: monaco.Position;
model: ITextModel;
isTxt: boolean;
constructor(path: ContentFilePath, code: string, hostname: string, lastPosition: monaco.Position, model: ITextModel) {
this.path = path;
this.code = code;
this.hostname = hostname;
this.lastPosition = lastPosition;
this.model = model;
this.isTxt = path.endsWith(".txt");
}
}
const openScripts: OpenScript[] = [];
let currentScript: OpenScript | null = null;
// Called every time script editor is opened
export function Root(props: IProps): React.ReactElement {
function Root(props: IProps): React.ReactElement {
const rerender = useRerender();
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
const vimStatusRef = useRef<HTMLElement>(null);
// monaco-vim does not have types, so this is an any
const [vimEditor, setVimEditor] = useState<any>(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 [optionsOpen, setOptionsOpen] = useState(false);
const [options, setOptions] = useState<Options>({
theme: Settings.MonacoTheme,
insertSpaces: Settings.MonacoInsertSpaces,
tabSize: Settings.MonacoTabSize,
detectIndentation: Settings.MonacoDetectIndentation,
fontFamily: Settings.MonacoFontFamily,
fontSize: Settings.MonacoFontSize,
fontLigatures: Settings.MonacoFontLigatures,
wordWrap: Settings.MonacoWordWrap,
vim: props.vim || Settings.MonacoVim,
});
const [ramInfoOpen, setRamInfoOpen] = useState(false);
const { options, updateRAM, startUpdatingRAM, finishUpdatingRAM } = useScriptEditorContext();
let decorations: monaco.editor.IEditorDecorationsCollection | undefined;
@ -161,129 +104,48 @@ export function Root(props: IProps): React.ReactElement {
return () => document.removeEventListener("keydown", keydown);
});
useEffect(() => {
// setup monaco-vim
if (options.vim && editorRef.current && !vimEditor) {
// Using try/catch because MonacoVim does not have types.
try {
setVimEditor(MonacoVim.initVimMode(editorRef.current, 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" });
editorRef.current.focus();
} catch (e) {
console.error("An error occurred while loading monaco-vim:");
console.error(e);
}
} else if (!options.vim) {
// When vim mode is disabled
vimEditor?.dispose();
setVimEditor(null);
}
return () => {
vimEditor?.dispose();
};
}, [options, editorRef.current, vimEditor]);
// Generates a new model for the script
function regenerateModel(script: OpenScript): void {
script.model = monaco.editor.createModel(script.code, script.isTxt ? "plaintext" : "javascript");
}
function infLoop(newCode: string): void {
if (editorRef.current === null || currentScript === null) return;
if (!decorations) decorations = editorRef.current.createDecorationsCollection();
if (!currentScript.path.endsWith(".js")) return;
const awaitWarning = checkInfiniteLoop(newCode);
if (awaitWarning !== -1) {
decorations.set([
{
range: {
startLineNumber: awaitWarning,
startColumn: 1,
endLineNumber: awaitWarning,
endColumn: 10,
},
options: {
isWholeLine: true,
glyphMarginClassName: "myGlyphMarginClass",
glyphMarginHoverMessage: {
value: "Possible infinite loop, await something.",
},
},
},
]);
} else decorations.clear();
}
const debouncedCodeParsing = debounce((newCode: string) => {
infLoop(newCode);
updateRAM(newCode);
setUpdatingRam(false);
updateRAM(!currentScript || currentScript.isTxt ? null : newCode);
finishUpdatingRAM();
}, 300);
function parseCode(newCode: string) {
setUpdatingRam(true);
startUpdatingRAM();
debouncedCodeParsing(newCode);
}
function updateRAM(newCode: string): void {
if (!currentScript || 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
@ -383,32 +245,6 @@ export function Root(props: IProps): React.ReactElement {
editorRef.current.focus();
}
function infLoop(newCode: string): void {
if (editorRef.current === null || currentScript === null) return;
if (!decorations) decorations = editorRef.current.createDecorationsCollection();
if (!currentScript.path.endsWith(".js")) return;
const awaitWarning = checkInfiniteLoop(newCode);
if (awaitWarning !== -1) {
decorations.set([
{
range: {
startLineNumber: awaitWarning,
startColumn: 1,
endLineNumber: awaitWarning,
endColumn: 10,
},
options: {
isWholeLine: true,
glyphMarginClassName: "myGlyphMarginClass",
glyphMarginHoverMessage: {
value: "Possible infinite loop, await something.",
},
},
},
]);
} else decorations.clear();
}
// When the code is updated within the editor
function updateCode(newCode?: string): void {
if (newCode === undefined) return;
@ -429,7 +265,6 @@ export function Root(props: IProps): React.ReactElement {
// This server helper already handles overwriting, etc.
server.writeToContentFile(scriptToSave.path, scriptToSave.code);
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
Router.toPage(Page.Terminal);
}
function save(): void {
@ -454,6 +289,7 @@ export function Root(props: IProps): React.ReactElement {
//Save the script
saveScript(currentScript);
Router.toPage(Page.Terminal);
iTutorialNextStep();
@ -467,17 +303,6 @@ export function Root(props: IProps): React.ReactElement {
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) return openScripts.findIndex((openScript) => currentScript === openScript);
return undefined;
@ -512,7 +337,7 @@ export function Root(props: IProps): React.ReactElement {
const savedScriptCode = closingScript.code;
const wasCurrentScript = openScripts[index] === currentScript;
if (dirty(index)) {
if (dirty(openScripts, index)) {
PromptEvent.emit({
txt: `Do you want to save changes to ${closingScript.path} on ${closingScript.hostname}?`,
resolve: (result: boolean | string) => {
@ -520,6 +345,7 @@ export function Root(props: IProps): React.ReactElement {
// Save changes
closingScript.code = savedScriptCode;
saveScript(closingScript);
Router.toPage(Page.Terminal);
}
},
});
@ -552,7 +378,7 @@ export function Root(props: IProps): React.ReactElement {
function onTabUpdate(index: number): void {
const openScript = openScripts[index];
const serverScriptCode = getServerCode(index);
const serverScriptCode = getServerCode(openScripts, index);
if (serverScriptCode === null) return;
if (openScript.code !== serverScriptCode) {
@ -585,35 +411,35 @@ export function Root(props: IProps): React.ReactElement {
}
}
function dirty(index: number): string {
const openScript = openScripts[index];
const serverData = getServerCode(index);
if (serverData === null) return " *";
return serverData !== openScript.code ? " *" : "";
function onOpenNextTab(step: number): void {
// Go to the next tab (to the right). Wraps around when at the rightmost tab
const currIndex = currentTabIndex();
if (currIndex !== undefined) {
const nextIndex = (currIndex + step) % openScripts.length;
onTabClick(nextIndex);
}
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 = server.getContentFile(openScript.path)?.content ?? null;
return data;
}
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.path.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;
function onOpenPreviousTab(step: number): void {
// Go to the previous tab (to the left). Wraps around when at the leftmost tab
const currIndex = currentTabIndex();
if (currIndex !== undefined) {
let nextIndex = currIndex - step;
while (nextIndex < 0) {
nextIndex += openScripts.length;
}
onTabClick(nextIndex);
}
}
const { VimStatus } = useVimEditor({
editor: editorRef.current,
vim: options.vim,
onSave: save,
onOpenNextTab,
onOpenPreviousTab,
});
return (
<>
<div
@ -624,241 +450,30 @@ export function Root(props: IProps): React.ReactElement {
flexDirection: "column",
}}
>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="tabs" direction="horizontal">
{(provided, snapshot) => (
<Box
maxWidth={`${tabsMaxWidth}px`}
display="flex"
flexGrow="0"
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} />,
}}
<Tabs
scripts={openScripts}
currentScript={currentScript}
onTabClick={onTabClick}
onTabClose={onTabClose}
onTabUpdate={onTabUpdate}
/>
) : (
<Button onClick={handleExpandSearch}>
<SearchIcon />
</Button>
)}
</Tooltip>
{filteredOpenScripts.map(({ path: fileName, hostname }, index) => {
const editingCurrentScript =
currentScript?.path === filteredOpenScripts[index].path &&
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={{ flex: "0 0 5px" }} />
<Editor
beforeMount={beforeMount}
onMount={onMount}
onChange={updateCode}
options={{ ...options, glyphMargin: true }}
/>
<Editor beforeMount={beforeMount} onMount={onMount} onChange={updateCode} />
<Box
ref={vimStatusRef}
className="vim-display"
display="flex"
flexGrow="0"
flexDirection="row"
sx={{ p: 1 }}
alignItems="center"
></Box>
{VimStatus}
<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);
monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
setOptionsOpen(false);
}}
options={{ ...options }}
save={(options: Options) => {
sanitizeTheme(Settings.EditorTheme);
monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
editorRef.current?.updateOptions(options);
setOptions(options);
Settings.MonacoTheme = options.theme;
Settings.MonacoInsertSpaces = options.insertSpaces;
Settings.MonacoTabSize = options.tabSize;
Settings.MonacoDetectIndentation = options.detectIndentation;
Settings.MonacoFontFamily = options.fontFamily;
Settings.MonacoFontSize = options.fontSize;
Settings.MonacoFontLigatures = options.fontLigatures;
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>
<Toolbar onSave={save} editor={editorRef.current} />
</div>
{!currentScript && <NoOpenScripts />}
</>
);
}
// Called every time script editor is opened
export function ScriptEditorRoot(props: IProps) {
return (
<ScriptEditorContextProvider vim={props.vim}>
<Root {...props} />
</ScriptEditorContextProvider>
);
}

@ -0,0 +1,183 @@
import React, { useState } from "react";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
import { Box, Button, TextField, Tooltip } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import SyncIcon from "@mui/icons-material/Sync";
import { Settings } from "../../Settings/Settings";
import { dirty, reorder } from "./utils";
import { OpenScript } from "./OpenScript";
const tabsMaxWidth = 1640;
const tabMargin = 5;
const tabIconWidth = 25;
interface IProps {
scripts: OpenScript[];
currentScript: OpenScript | null;
onTabClick: (tabIndex: number) => void;
onTabClose: (tabIndex: number) => void;
onTabUpdate: (tabIndex: number) => void;
}
export function Tabs({ scripts, currentScript, onTabClick, onTabClose, onTabUpdate }: IProps) {
const [filter, setFilter] = useState("");
const [searchExpanded, setSearchExpanded] = useState(false);
function onDragEnd(result: any): void {
// Dropped outside of the list
if (!result.destination) return;
reorder(scripts, result.source.index, result.destination.index);
}
const filteredOpenScripts = Object.values(scripts).filter(
(script) => script.hostname.includes(filter) || script.path.includes(filter),
);
function handleFilterChange(event: React.ChangeEvent<HTMLInputElement>): void {
setFilter(event.target.value);
}
function handleExpandSearch(): void {
setFilter("");
setSearchExpanded(!searchExpanded);
}
const tabMaxWidth = filteredOpenScripts.length ? tabsMaxWidth / filteredOpenScripts.length - tabMargin : 0;
const tabTextWidth = tabMaxWidth - tabIconWidth * 2;
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="tabs" direction="horizontal">
{(provided, snapshot) => (
<Box
maxWidth={`${tabsMaxWidth}px`}
display="flex"
flexGrow="0"
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} />,
// TODO: reapply
// sx: { minWidth: 200 },
}}
/>
) : (
<Button onClick={handleExpandSearch}>
<SearchIcon />
</Button>
)}
</Tooltip>
{filteredOpenScripts.map(({ path: fileName, hostname }, index) => {
const editingCurrentScript =
currentScript?.path === filteredOpenScripts[index].path &&
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(
scripts,
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>
);
}

@ -0,0 +1,110 @@
import React, { useState } from "react";
import * as monaco from "monaco-editor";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
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 Typography from "@mui/material/Typography";
import SettingsIcon from "@mui/icons-material/Settings";
import { makeTheme, sanitizeTheme } from "./themes";
import { Modal } from "../../ui/React/Modal";
import { Page } from "../../ui/Router";
import { Router } from "../../ui/GameRoot";
import { Settings } from "../../Settings/Settings";
import { OptionsModal } from "./OptionsModal";
import { Options } from "./Options";
import { useScriptEditorContext } from "./ScriptEditorContext";
type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor;
interface IProps {
editor: IStandaloneCodeEditor | null;
onSave: () => void;
}
export function Toolbar({ editor, onSave }: IProps) {
const [ramInfoOpen, setRamInfoOpen] = useState(false);
const [optionsOpen, setOptionsOpen] = useState(false);
function beautify(): void {
editor?.getAction("editor.action.formatDocument")?.run();
}
const { ram, ramEntries, isUpdatingRAM, options, saveOptions } = useScriptEditorContext();
return (
<>
<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={isUpdatingRAM ? "secondary" : "primary"}
sx={{ mx: 1 }}
onClick={() => {
setRamInfoOpen(true);
}}
>
{ram}
</Button>
<Button onClick={onSave}>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);
monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
setOptionsOpen(false);
}}
options={{ ...options }}
save={(options: Options) => {
sanitizeTheme(Settings.EditorTheme);
monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
editor?.updateOptions(options);
saveOptions(options);
}}
/>
<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>
</>
);
}

@ -0,0 +1,87 @@
import React, { useEffect, useRef, useState } from "react";
// @ts-expect-error This library does not have types.
import * as MonacoVim from "monaco-vim";
import * as monaco from "monaco-editor";
type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor;
import Box from "@mui/material/Box";
import { Router } from "../../ui/GameRoot";
import { Page } from "../../ui/Router";
interface IProps {
vim: boolean;
editor: IStandaloneCodeEditor | null;
onOpenNextTab: (step: number) => void;
onOpenPreviousTab: (step: number) => void;
onSave: () => void;
}
export function useVimEditor({ editor, vim, onOpenNextTab, onOpenPreviousTab, onSave }: IProps) {
// monaco-vim does not have types, so this is an any
const [vimEditor, setVimEditor] = useState<any>(null);
const vimStatusRef = useRef<HTMLElement>(null);
useEffect(() => {
// setup monaco-vim
if (vim && editor && !vimEditor) {
// Using try/catch because MonacoVim does not have types.
try {
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
onSave();
});
MonacoVim.VimMode.Vim.defineEx("quit", "q", function () {
Router.toPage(Page.Terminal);
});
const saveNQuit = (): void => {
onSave();
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, { repeat = 1 }: { repeat?: number }) {
onOpenNextTab(repeat);
});
MonacoVim.VimMode.Vim.defineAction("prevTabs", function (_cm: any, { repeat = 1 }: { repeat?: number }) {
onOpenPreviousTab(repeat);
});
MonacoVim.VimMode.Vim.mapCommand("gt", "action", "nextTabs", {}, { context: "normal" });
MonacoVim.VimMode.Vim.mapCommand("gT", "action", "prevTabs", {}, { context: "normal" });
editor.focus();
} catch (e) {
console.error("An error occurred while loading monaco-vim:");
console.error(e);
}
} else if (!vim) {
// When vim mode is disabled
vimEditor?.dispose();
setVimEditor(null);
}
return () => {
vimEditor?.dispose();
};
}, [vim, editor, vimEditor]);
const VimStatus = (
<Box
ref={vimStatusRef}
className="vim-display"
display="flex"
flexGrow="0"
flexDirection="row"
sx={{ p: 1 }}
alignItems="center"
/>
);
return { VimStatus };
}

@ -0,0 +1,25 @@
import { GetServer } from "../../Server/AllServers";
import { OpenScript } from "./OpenScript";
function getServerCode(scripts: OpenScript[], index: number): string | null {
const openScript = scripts[index];
const server = GetServer(openScript.hostname);
if (server === null) throw new Error(`Server '${openScript.hostname}' should not be null, but it is.`);
const data = server.getContentFile(openScript.path)?.content ?? null;
return data;
}
function dirty(scripts: OpenScript[], index: number): string {
const openScript = scripts[index];
const serverData = getServerCode(scripts, index);
if (serverData === null) return " *";
return serverData !== openScript.code ? " *" : "";
}
function reorder(list: unknown[], startIndex: number, endIndex: number): void {
const [removed] = list.splice(startIndex, 1);
list.splice(endIndex, 0, removed);
}
export { getServerCode, dirty, reorder };

@ -40,7 +40,7 @@ import { HacknetRoot } from "../Hacknet/ui/HacknetRoot";
import { GenericLocation } from "../Locations/ui/GenericLocation";
import { LocationCity } from "../Locations/ui/City";
import { ProgramsRoot } from "../Programs/ui/ProgramsRoot";
import { Root as ScriptEditorRoot } from "../ScriptEditor/ui/ScriptEditorRoot";
import { ScriptEditorRoot } from "../ScriptEditor/ui/ScriptEditorRoot";
import { MilestonesRoot } from "../Milestones/ui/MilestonesRoot";
import { TerminalRoot } from "../Terminal/ui/TerminalRoot";
import { TutorialRoot } from "../Tutorial/ui/TutorialRoot";

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
/** Hook that returns a function for the component. Optionally set an interval to rerender the component.
* @param autoRerenderTime: Optional. If provided and nonzero, used as the ms interval to automatically call the rerender function.
@ -13,3 +13,21 @@ export function useRerender(autoRerenderTime?: number) {
}, []);
return rerender;
}
export function useBoolean(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue((old) => !old);
}, []);
const on = useCallback(() => {
setValue(true);
}, []);
const off = useCallback(() => {
setValue(false);
}, []);
return [value, { toggle, on, off }] as const;
}