mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2024-11-18 21:53:50 +01:00
REFACTORING: ScriptEditor (#560)
This commit is contained in:
parent
886f402a43
commit
99954ebd1e
@ -1,10 +1,9 @@
|
|||||||
import * as monaco from "monaco-editor";
|
import * as monaco from "monaco-editor";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useScriptEditorContext } from "./ScriptEditorContext";
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
/** Editor options */
|
|
||||||
options: monaco.editor.IEditorOptions;
|
|
||||||
/** Function to be ran prior to mounting editor */
|
/** Function to be ran prior to mounting editor */
|
||||||
beforeMount: () => void;
|
beforeMount: () => void;
|
||||||
/** Function to be ran after mounting editor */
|
/** Function to be ran after mounting editor */
|
||||||
@ -13,35 +12,38 @@ interface EditorProps {
|
|||||||
onChange: (newCode?: string) => void;
|
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 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 subscription = useRef<monaco.IDisposable | null>(null);
|
||||||
|
|
||||||
|
const { options } = useScriptEditorContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerDiv.current) return;
|
if (!containerDiv.current) return;
|
||||||
// Before initializing monaco editor
|
// Before initializing monaco editor
|
||||||
beforeMount();
|
beforeMount();
|
||||||
|
|
||||||
// Initialize monaco editor
|
// Initialize monaco editor
|
||||||
editor.current = monaco.editor.create(containerDiv.current, {
|
editorRef.current = monaco.editor.create(containerDiv.current, {
|
||||||
value: "",
|
value: "",
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
language: "javascript",
|
language: "javascript",
|
||||||
...options,
|
...options,
|
||||||
|
glyphMargin: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// After initializing monaco editor
|
// After initializing monaco editor
|
||||||
onMount(editor.current);
|
onMount(editorRef.current);
|
||||||
subscription.current = editor.current.onDidChangeModelContent(() => {
|
subscription.current = editorRef.current.onDidChangeModelContent(() => {
|
||||||
onChange(editor.current?.getValue());
|
onChange(editorRef.current?.getValue());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unmounting
|
// Unmounting
|
||||||
return () => {
|
return () => {
|
||||||
subscription.current?.dispose();
|
subscription.current?.dispose();
|
||||||
editor.current?.getModel()?.dispose();
|
editorRef.current?.getModel()?.dispose();
|
||||||
editor.current?.dispose();
|
editorRef.current?.dispose();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
28
src/ScriptEditor/ui/NoOpenScripts.tsx
Normal file
28
src/ScriptEditor/ui/NoOpenScripts.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
24
src/ScriptEditor/ui/OpenScript.ts
Normal file
24
src/ScriptEditor/ui/OpenScript.ts
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
112
src/ScriptEditor/ui/ScriptEditorContext.tsx
Normal file
112
src/ScriptEditor/ui/ScriptEditorContext.tsx
Normal file
@ -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 { Editor } from "./Editor";
|
||||||
import * as monaco from "monaco-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 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 { Router } from "../../ui/GameRoot";
|
||||||
import { Page } from "../../ui/Router";
|
import { Page } from "../../ui/Router";
|
||||||
import { dialogBoxCreate } from "../../ui/React/DialogBox";
|
import { dialogBoxCreate } from "../../ui/React/DialogBox";
|
||||||
import { ScriptFilePath } from "../../Paths/ScriptFilePath";
|
import { ScriptFilePath } from "../../Paths/ScriptFilePath";
|
||||||
import { calculateRamUsage, checkInfiniteLoop } from "../../Script/RamCalculations";
|
import { 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 { ns, enums } from "../../NetscriptFunctions";
|
import { ns, enums } from "../../NetscriptFunctions";
|
||||||
import { Settings } from "../../Settings/Settings";
|
import { Settings } from "../../Settings/Settings";
|
||||||
@ -27,26 +18,20 @@ import { saveObject } from "../../SaveObject";
|
|||||||
import { loadThemes, makeTheme, sanitizeTheme } from "./themes";
|
import { loadThemes, makeTheme, sanitizeTheme } from "./themes";
|
||||||
import { GetServer } from "../../Server/AllServers";
|
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 { PromptEvent } from "../../ui/React/PromptManager";
|
||||||
import { Modal } from "../../ui/React/Modal";
|
|
||||||
|
|
||||||
import libSource from "!!raw-loader!../NetscriptDefinitions.d.ts";
|
import libSource from "!!raw-loader!../NetscriptDefinitions.d.ts";
|
||||||
import { TextField, Tooltip } from "@mui/material";
|
|
||||||
import { useRerender } from "../../ui/React/hooks";
|
import { useRerender } from "../../ui/React/hooks";
|
||||||
import { NetscriptExtra } from "../../NetscriptFunctions/Extra";
|
import { NetscriptExtra } from "../../NetscriptFunctions/Extra";
|
||||||
import { TextFilePath } from "src/Paths/TextFilePath";
|
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 {
|
interface IProps {
|
||||||
// Map of filename -> code
|
// Map of filename -> code
|
||||||
@ -72,56 +57,14 @@ export function SetupTextEditor(): void {
|
|||||||
populate();
|
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[] = [];
|
const openScripts: OpenScript[] = [];
|
||||||
let currentScript: OpenScript | null = null;
|
let currentScript: OpenScript | null = null;
|
||||||
|
|
||||||
// Called every time script editor is opened
|
function Root(props: IProps): React.ReactElement {
|
||||||
export function Root(props: IProps): React.ReactElement {
|
|
||||||
const rerender = useRerender();
|
const rerender = useRerender();
|
||||||
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
|
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 { options, updateRAM, startUpdatingRAM, finishUpdatingRAM } = useScriptEditorContext();
|
||||||
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);
|
|
||||||
|
|
||||||
let decorations: monaco.editor.IEditorDecorationsCollection | undefined;
|
let decorations: monaco.editor.IEditorDecorationsCollection | undefined;
|
||||||
|
|
||||||
@ -161,129 +104,48 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
return () => document.removeEventListener("keydown", keydown);
|
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
|
// Generates a new model for the script
|
||||||
function regenerateModel(script: OpenScript): void {
|
function regenerateModel(script: OpenScript): void {
|
||||||
script.model = monaco.editor.createModel(script.code, script.isTxt ? "plaintext" : "javascript");
|
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) => {
|
const debouncedCodeParsing = debounce((newCode: string) => {
|
||||||
infLoop(newCode);
|
infLoop(newCode);
|
||||||
updateRAM(newCode);
|
updateRAM(!currentScript || currentScript.isTxt ? null : newCode);
|
||||||
setUpdatingRam(false);
|
finishUpdatingRAM();
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
function parseCode(newCode: string) {
|
function parseCode(newCode: string) {
|
||||||
setUpdatingRam(true);
|
startUpdatingRAM();
|
||||||
debouncedCodeParsing(newCode);
|
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
|
// How to load function definition in monaco
|
||||||
// https://github.com/Microsoft/monaco-editor/issues/1415
|
// https://github.com/Microsoft/monaco-editor/issues/1415
|
||||||
// https://microsoft.github.io/monaco-editor/api/modules/monaco.languages.html
|
// 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();
|
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
|
// When the code is updated within the editor
|
||||||
function updateCode(newCode?: string): void {
|
function updateCode(newCode?: string): void {
|
||||||
if (newCode === undefined) return;
|
if (newCode === undefined) return;
|
||||||
@ -429,7 +265,6 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
// This server helper already handles overwriting, etc.
|
// This server helper already handles overwriting, etc.
|
||||||
server.writeToContentFile(scriptToSave.path, scriptToSave.code);
|
server.writeToContentFile(scriptToSave.path, scriptToSave.code);
|
||||||
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
|
if (Settings.SaveGameOnFileSave) saveObject.saveGame();
|
||||||
Router.toPage(Page.Terminal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function save(): void {
|
function save(): void {
|
||||||
@ -454,6 +289,7 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
|
|
||||||
//Save the script
|
//Save the script
|
||||||
saveScript(currentScript);
|
saveScript(currentScript);
|
||||||
|
Router.toPage(Page.Terminal);
|
||||||
|
|
||||||
iTutorialNextStep();
|
iTutorialNextStep();
|
||||||
|
|
||||||
@ -467,17 +303,6 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
rerender();
|
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 {
|
function currentTabIndex(): number | undefined {
|
||||||
if (currentScript) return openScripts.findIndex((openScript) => currentScript === openScript);
|
if (currentScript) return openScripts.findIndex((openScript) => currentScript === openScript);
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -512,7 +337,7 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
const savedScriptCode = closingScript.code;
|
const savedScriptCode = closingScript.code;
|
||||||
const wasCurrentScript = openScripts[index] === currentScript;
|
const wasCurrentScript = openScripts[index] === currentScript;
|
||||||
|
|
||||||
if (dirty(index)) {
|
if (dirty(openScripts, index)) {
|
||||||
PromptEvent.emit({
|
PromptEvent.emit({
|
||||||
txt: `Do you want to save changes to ${closingScript.path} on ${closingScript.hostname}?`,
|
txt: `Do you want to save changes to ${closingScript.path} on ${closingScript.hostname}?`,
|
||||||
resolve: (result: boolean | string) => {
|
resolve: (result: boolean | string) => {
|
||||||
@ -520,6 +345,7 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
// Save changes
|
// Save changes
|
||||||
closingScript.code = savedScriptCode;
|
closingScript.code = savedScriptCode;
|
||||||
saveScript(closingScript);
|
saveScript(closingScript);
|
||||||
|
Router.toPage(Page.Terminal);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -552,7 +378,7 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
|
|
||||||
function onTabUpdate(index: number): void {
|
function onTabUpdate(index: number): void {
|
||||||
const openScript = openScripts[index];
|
const openScript = openScripts[index];
|
||||||
const serverScriptCode = getServerCode(index);
|
const serverScriptCode = getServerCode(openScripts, index);
|
||||||
if (serverScriptCode === null) return;
|
if (serverScriptCode === null) return;
|
||||||
|
|
||||||
if (openScript.code !== serverScriptCode) {
|
if (openScript.code !== serverScriptCode) {
|
||||||
@ -585,35 +411,35 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dirty(index: number): string {
|
function onOpenNextTab(step: number): void {
|
||||||
const openScript = openScripts[index];
|
// Go to the next tab (to the right). Wraps around when at the rightmost tab
|
||||||
const serverData = getServerCode(index);
|
const currIndex = currentTabIndex();
|
||||||
if (serverData === null) return " *";
|
if (currIndex !== undefined) {
|
||||||
return serverData !== openScript.code ? " *" : "";
|
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;
|
function onOpenPreviousTab(step: number): void {
|
||||||
const tabMargin = 5;
|
// Go to the previous tab (to the left). Wraps around when at the leftmost tab
|
||||||
const tabMaxWidth = filteredOpenScripts.length ? tabsMaxWidth / filteredOpenScripts.length - tabMargin : 0;
|
const currIndex = currentTabIndex();
|
||||||
const tabIconWidth = 25;
|
if (currIndex !== undefined) {
|
||||||
const tabTextWidth = tabMaxWidth - tabIconWidth * 2;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -624,241 +450,30 @@ export function Root(props: IProps): React.ReactElement {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<Tabs
|
||||||
<Droppable droppableId="tabs" direction="horizontal">
|
scripts={openScripts}
|
||||||
{(provided, snapshot) => (
|
currentScript={currentScript}
|
||||||
<Box
|
onTabClick={onTabClick}
|
||||||
maxWidth={`${tabsMaxWidth}px`}
|
onTabClose={onTabClose}
|
||||||
display="flex"
|
onTabUpdate={onTabUpdate}
|
||||||
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} />,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<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" }} />
|
<div style={{ flex: "0 0 5px" }} />
|
||||||
<Editor
|
<Editor beforeMount={beforeMount} onMount={onMount} onChange={updateCode} />
|
||||||
beforeMount={beforeMount}
|
|
||||||
onMount={onMount}
|
|
||||||
onChange={updateCode}
|
|
||||||
options={{ ...options, glyphMargin: true }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box
|
{VimStatus}
|
||||||
ref={vimStatusRef}
|
|
||||||
className="vim-display"
|
|
||||||
display="flex"
|
|
||||||
flexGrow="0"
|
|
||||||
flexDirection="row"
|
|
||||||
sx={{ p: 1 }}
|
|
||||||
alignItems="center"
|
|
||||||
></Box>
|
|
||||||
|
|
||||||
<Box display="flex" flexDirection="row" sx={{ m: 1 }} alignItems="center">
|
<Toolbar onSave={save} editor={editorRef.current} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!currentScript && <NoOpenScripts />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called every time script editor is opened
|
||||||
|
export function ScriptEditorRoot(props: IProps) {
|
||||||
|
return (
|
||||||
|
<ScriptEditorContextProvider vim={props.vim}>
|
||||||
|
<Root {...props} />
|
||||||
|
</ScriptEditorContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
183
src/ScriptEditor/ui/Tabs.tsx
Normal file
183
src/ScriptEditor/ui/Tabs.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
110
src/ScriptEditor/ui/Toolbar.tsx
Normal file
110
src/ScriptEditor/ui/Toolbar.tsx
Normal file
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
87
src/ScriptEditor/ui/useVimEditor.tsx
Normal file
87
src/ScriptEditor/ui/useVimEditor.tsx
Normal file
@ -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 };
|
||||||
|
}
|
25
src/ScriptEditor/ui/utils.ts
Normal file
25
src/ScriptEditor/ui/utils.ts
Normal file
@ -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 { GenericLocation } from "../Locations/ui/GenericLocation";
|
||||||
import { LocationCity } from "../Locations/ui/City";
|
import { LocationCity } from "../Locations/ui/City";
|
||||||
import { ProgramsRoot } from "../Programs/ui/ProgramsRoot";
|
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 { MilestonesRoot } from "../Milestones/ui/MilestonesRoot";
|
||||||
import { TerminalRoot } from "../Terminal/ui/TerminalRoot";
|
import { TerminalRoot } from "../Terminal/ui/TerminalRoot";
|
||||||
import { TutorialRoot } from "../Tutorial/ui/TutorialRoot";
|
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.
|
/** 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.
|
* @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;
|
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;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user