mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-17 14:59:16 +02:00
UI: Add option to autosave scripts on focus change (#2565)
This commit is contained in:
9
src/ScriptEditor/EditorData.ts
Normal file
9
src/ScriptEditor/EditorData.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// To avoid cyclic dependencies, this file should have as few imports as possible.
|
||||
|
||||
import type { ContentFilePath } from "../Paths/ContentFile";
|
||||
import { EventEmitter } from "../utils/EventEmitter";
|
||||
import type { OpenScript } from "./ui/OpenScript";
|
||||
|
||||
export const openScripts: OpenScript[] = [];
|
||||
|
||||
export const EditorEvents = new EventEmitter<[hostname: string, filePath: ContentFilePath]>();
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { ContentFilePath } from "../Paths/ContentFile";
|
||||
|
||||
import { EventEmitter } from "../utils/EventEmitter";
|
||||
import * as monaco from "monaco-editor";
|
||||
import { loadThemes, makeTheme } from "./ui/themes";
|
||||
import { Settings } from "../Settings/Settings";
|
||||
@@ -10,14 +7,7 @@ import { ns } from "../NetscriptFunctions";
|
||||
import { isLegacyScript } from "../Paths/ScriptFilePath";
|
||||
import { exceptionAlert } from "../utils/helpers/exceptionAlert";
|
||||
|
||||
/** Event emitter used for tracking when changes have been made to a content file. */
|
||||
export const fileEditEvents = new EventEmitter<[hostname: string, filename: ContentFilePath]>();
|
||||
|
||||
export class ScriptEditor {
|
||||
// TODO: This will store info about currently open scripts.
|
||||
// Among other things, this will allow informing the script editor of changes made elsewhere, even if the script editor is not being rendered.
|
||||
// openScripts: OpenScript[] = [];
|
||||
|
||||
// Currently, this object is only used for initialization.
|
||||
isInitialized = false;
|
||||
initialize() {
|
||||
|
||||
@@ -13,6 +13,9 @@ import reactDomTypes from "../../../node_modules/@types/react-dom/index.d.ts?raw
|
||||
|
||||
import { useScriptEditorContext } from "./ScriptEditorContext";
|
||||
import { scriptEditor } from "../ScriptEditor";
|
||||
import { Settings } from "../../Settings/Settings";
|
||||
import { openScripts } from "../EditorData";
|
||||
import { isUnsavedFile, saveScript } from "./utils";
|
||||
|
||||
interface EditorProps {
|
||||
/** Function to be ran after mounting editor */
|
||||
@@ -63,6 +66,17 @@ export function Editor({ onMount, onChange, onUnmount }: EditorProps) {
|
||||
subscription.current = editorRef.current.onDidChangeModelContent(() => {
|
||||
onChange(editorRef.current?.getValue());
|
||||
});
|
||||
editorRef.current.onDidBlurEditorWidget(() => {
|
||||
if (!Settings.MonacoAutoSaveOnFocusChange) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < openScripts.length; ++i) {
|
||||
if (!isUnsavedFile(openScripts, i)) {
|
||||
continue;
|
||||
}
|
||||
saveScript(openScripts[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// Unmounting
|
||||
return () => {
|
||||
|
||||
@@ -21,4 +21,5 @@ export interface Options {
|
||||
beautifyOnSave: boolean;
|
||||
stickyScroll: StickyScroll;
|
||||
minimap: Minimap;
|
||||
autoSaveOnFocusChange: boolean;
|
||||
}
|
||||
|
||||
@@ -166,6 +166,14 @@ export function OptionsModal(props: OptionsModalProps): ReactElement {
|
||||
checked={props.options.minimap?.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<Typography marginRight={"auto"}>Autosave on focus change: </Typography>
|
||||
<Switch
|
||||
onChange={(e) => props.onOptionChange("autoSaveOnFocusChange", e.target.checked)}
|
||||
checked={props.options.autoSaveOnFocusChange}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export function ScriptEditorContextProvider({ children }: { children: React.Reac
|
||||
beautifyOnSave: Settings.MonacoBeautifyOnSave,
|
||||
stickyScroll: Settings.MonacoStickyScroll,
|
||||
minimap: Settings.MonacoMinimap,
|
||||
autoSaveOnFocusChange: Settings.MonacoAutoSaveOnFocusChange,
|
||||
});
|
||||
|
||||
function saveOptions(options: Options) {
|
||||
@@ -111,6 +112,7 @@ export function ScriptEditorContextProvider({ children }: { children: React.Reac
|
||||
Settings.MonacoBeautifyOnSave = options.beautifyOnSave;
|
||||
Settings.MonacoStickyScroll = options.stickyScroll;
|
||||
Settings.MonacoMinimap = options.minimap;
|
||||
Settings.MonacoAutoSaveOnFocusChange = options.autoSaveOnFocusChange;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,14 +18,13 @@ import { checkInfiniteLoop } from "../../Script/RamCalculations";
|
||||
import { Settings } from "../../Settings/Settings";
|
||||
import { iTutorialNextStep, ITutorial, iTutorialSteps } from "../../InteractiveTutorial";
|
||||
import { debounce } from "lodash";
|
||||
import { saveObject } from "../../SaveObject";
|
||||
import { GetServer } from "../../Server/AllServers";
|
||||
|
||||
import { PromptEvent } from "../../ui/React/PromptManager";
|
||||
|
||||
import { useRerender } from "../../ui/React/hooks";
|
||||
|
||||
import { isUnsavedFile, getServerCode, makeModel } from "./utils";
|
||||
import { isUnsavedFile, getServerCode, makeModel, saveScript } from "./utils";
|
||||
import { OpenScript } from "./OpenScript";
|
||||
import { Tabs } from "./Tabs";
|
||||
import { Toolbar } from "./Toolbar";
|
||||
@@ -36,7 +35,6 @@ import { useCallback } from "react";
|
||||
import { type AST, getFileType, getModuleScript, parseAST } from "../../utils/ScriptTransformer";
|
||||
import { RamCalculationErrorCode } from "../../Script/RamCalculationErrorCodes";
|
||||
import { hasScriptExtension, isLegacyScript, type ScriptFilePath } from "../../Paths/ScriptFilePath";
|
||||
import { exceptionAlert } from "../../utils/helpers/exceptionAlert";
|
||||
import type { BaseServer } from "../../Server/BaseServer";
|
||||
import {
|
||||
convertKeyboardEventToKeyCombination,
|
||||
@@ -44,11 +42,9 @@ import {
|
||||
determineKeyBindingTypes,
|
||||
ScriptEditorAction,
|
||||
} from "../../utils/KeyBindingUtils";
|
||||
import { SpecialServers } from "../../Server/data/SpecialServers";
|
||||
import { SnackbarEvents } from "../../ui/React/Snackbar";
|
||||
import { ToastVariant } from "@enums";
|
||||
import { createRunningScriptInstance, startWorkerScript } from "../../NetscriptWorker";
|
||||
import type { PositiveInteger } from "../../types";
|
||||
import { openScripts } from "../EditorData";
|
||||
|
||||
// Extend acorn-walk to support TypeScript nodes.
|
||||
extendAcornWalkForTypeScriptNodes(walk.base);
|
||||
@@ -64,7 +60,7 @@ interface IProps {
|
||||
hostname: string;
|
||||
vim: boolean;
|
||||
}
|
||||
const openScripts: OpenScript[] = [];
|
||||
|
||||
let currentScript: OpenScript | null = null;
|
||||
|
||||
function Root(props: IProps): React.ReactElement {
|
||||
@@ -415,23 +411,6 @@ function Root(props: IProps): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
function saveScript(scriptToSave: OpenScript): void {
|
||||
const server = GetServer(scriptToSave.hostname);
|
||||
if (!server) {
|
||||
dialogBoxCreate(`Server ${scriptToSave.hostname} does not exist.`);
|
||||
return;
|
||||
}
|
||||
// Show a warning message if the file is on a non-home server.
|
||||
if (scriptToSave.hostname !== SpecialServers.Home) {
|
||||
SnackbarEvents.emit("You saved a file on a non-home server!", ToastVariant.WARNING, 3000);
|
||||
}
|
||||
// This server helper already handles overwriting, etc.
|
||||
server.writeToContentFile(scriptToSave.path, scriptToSave.code);
|
||||
if (Settings.SaveGameOnFileSave) {
|
||||
saveObject.saveGame().catch((error) => exceptionAlert(error));
|
||||
}
|
||||
}
|
||||
|
||||
function currentTabIndex(): number | undefined {
|
||||
if (currentScript) return openScripts.findIndex((openScript) => currentScript === openScript);
|
||||
return undefined;
|
||||
|
||||
@@ -9,14 +9,18 @@ import SyncIcon from "@mui/icons-material/Sync";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import { Settings } from "../../Settings/Settings";
|
||||
import { EditorEvents } from "../EditorData";
|
||||
import { useRerender } from "../../ui/React/hooks";
|
||||
import { getTabId } from "./utils";
|
||||
import type { ContentFilePath } from "../../Paths/ContentFile";
|
||||
|
||||
interface IProps {
|
||||
provided: DraggableProvided;
|
||||
fullPath: string;
|
||||
tabId: string;
|
||||
isActive: boolean;
|
||||
isExternal: boolean;
|
||||
isUnsaved: boolean;
|
||||
|
||||
isUnsaved: () => boolean;
|
||||
onClick: () => void;
|
||||
onClose: () => void;
|
||||
onUpdate: () => void;
|
||||
@@ -26,7 +30,8 @@ const tabMargin = 5;
|
||||
const tabIconWidth = 25;
|
||||
const tabIconHeight = 38.5;
|
||||
|
||||
export function Tab({ provided, fullPath, isActive, isExternal, isUnsaved, onClick, onClose, onUpdate }: IProps) {
|
||||
export function Tab({ provided, tabId, isActive, isExternal, isUnsaved, onClick, onClose, onUpdate }: IProps) {
|
||||
const rerender = useRerender();
|
||||
const colorProps = isActive
|
||||
? {
|
||||
background: Settings.theme.button,
|
||||
@@ -41,18 +46,18 @@ export function Tab({ provided, fullPath, isActive, isExternal, isUnsaved, onCli
|
||||
|
||||
let tabTitle;
|
||||
let tooltipTitle;
|
||||
if (isUnsaved) {
|
||||
// Show a blinking "*" character to notify the player that this file is dirtied.
|
||||
if (isUnsaved()) {
|
||||
// Show a "*" character to notify the player that this file is dirtied.
|
||||
tabTitle = (
|
||||
<>
|
||||
<Typography component="span" color={Settings.theme.warning}>
|
||||
*{" "}
|
||||
</Typography>
|
||||
{fullPath}
|
||||
{tabId}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
tabTitle = fullPath;
|
||||
tabTitle = tabId;
|
||||
}
|
||||
|
||||
if (isExternal) {
|
||||
@@ -85,6 +90,17 @@ export function Tab({ provided, fullPath, isActive, isExternal, isUnsaved, onCli
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
EditorEvents.subscribe((hostname: string, filePath: ContentFilePath) => {
|
||||
if (tabId !== getTabId(hostname, filePath)) {
|
||||
return;
|
||||
}
|
||||
rerender();
|
||||
}),
|
||||
[rerender, tabId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(element) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import SearchIcon from "@mui/icons-material/Search";
|
||||
import { useBoolean, useRerender } from "../../ui/React/hooks";
|
||||
import { Settings } from "../../Settings/Settings";
|
||||
|
||||
import { isUnsavedFile, reorder } from "./utils";
|
||||
import { getTabId, isUnsavedFile, reorder } from "./utils";
|
||||
import { OpenScript } from "./OpenScript";
|
||||
import { Tab } from "./Tab";
|
||||
import { SpecialServers } from "../../Server/data/SpecialServers";
|
||||
@@ -118,18 +118,18 @@ export function Tabs({ scripts, currentScript, onTabClick, onTabClose, onTabUpda
|
||||
onWheel={handleScroll}
|
||||
>
|
||||
{filteredScripts.map(({ script, originalIndex }, index) => {
|
||||
const { path: fileName, hostname } = script;
|
||||
const { path, hostname } = script;
|
||||
const isActive = currentScript?.path === script.path && currentScript.hostname === script.hostname;
|
||||
const fullPath = `${hostname}:/${fileName}`;
|
||||
const tabId = getTabId(hostname, path);
|
||||
return (
|
||||
<Draggable key={fullPath} draggableId={fullPath} index={index} disableInteractiveElementBlocking>
|
||||
<Draggable key={tabId} draggableId={tabId} index={index} disableInteractiveElementBlocking>
|
||||
{(provided) => (
|
||||
<Tab
|
||||
provided={provided}
|
||||
fullPath={fullPath}
|
||||
tabId={tabId}
|
||||
isActive={isActive}
|
||||
isExternal={hostname !== SpecialServers.Home}
|
||||
isUnsaved={isUnsavedFile(scripts, index)}
|
||||
isUnsaved={() => isUnsavedFile(scripts, index)}
|
||||
onClick={() => onTabClick(originalIndex)}
|
||||
onClose={() => onTabClose(originalIndex)}
|
||||
onUpdate={() => onTabUpdate(originalIndex)}
|
||||
|
||||
@@ -3,8 +3,16 @@ import { editor, Uri } from "monaco-editor";
|
||||
import { OpenScript } from "./OpenScript";
|
||||
import { getFileType, FileType } from "../../utils/ScriptTransformer";
|
||||
import { throwIfReachable } from "../../utils/helpers/throwIfReachable";
|
||||
import { dialogBoxCreate } from "../../ui/React/DialogBox";
|
||||
import { SpecialServers } from "../../Server/data/SpecialServers";
|
||||
import { SnackbarEvents } from "../../ui/React/Snackbar";
|
||||
import { ToastVariant } from "../../Enums";
|
||||
import { EditorEvents } from "../EditorData";
|
||||
import { Settings } from "../../Settings/Settings";
|
||||
import { saveObject } from "../../SaveObject";
|
||||
import { exceptionAlert } from "../../utils/helpers/exceptionAlert";
|
||||
|
||||
function getServerCode(scripts: OpenScript[], index: number): string | null {
|
||||
export function getServerCode(scripts: OpenScript[], index: number): string | null {
|
||||
const openScript = scripts[index];
|
||||
const server = GetServer(openScript.hostname);
|
||||
if (server === null) {
|
||||
@@ -14,7 +22,7 @@ function getServerCode(scripts: OpenScript[], index: number): string | null {
|
||||
return data;
|
||||
}
|
||||
|
||||
function isUnsavedFile(scripts: OpenScript[], index: number): boolean {
|
||||
export function isUnsavedFile(scripts: OpenScript[], index: number): boolean {
|
||||
const openScript = scripts[index];
|
||||
const serverData = getServerCode(scripts, index);
|
||||
if (serverData === null) {
|
||||
@@ -23,12 +31,12 @@ function isUnsavedFile(scripts: OpenScript[], index: number): boolean {
|
||||
return serverData !== openScript.code;
|
||||
}
|
||||
|
||||
function reorder(list: unknown[], startIndex: number, endIndex: number): void {
|
||||
export function reorder(list: unknown[], startIndex: number, endIndex: number): void {
|
||||
const [removed] = list.splice(startIndex, 1);
|
||||
list.splice(endIndex, 0, removed);
|
||||
}
|
||||
|
||||
function makeModel(hostname: string, filename: string, code: string): editor.ITextModel {
|
||||
export function makeModel(hostname: string, filename: string, code: string): editor.ITextModel {
|
||||
const uri = Uri.from({
|
||||
scheme: "memory",
|
||||
path: `${hostname}/${filename}`,
|
||||
@@ -67,4 +75,26 @@ function makeModel(hostname: string, filename: string, code: string): editor.ITe
|
||||
return editor.createModel(code, language, uri);
|
||||
}
|
||||
|
||||
export { getServerCode, isUnsavedFile, reorder, makeModel };
|
||||
export function getTabId(hostname: string, filePath: string): string {
|
||||
return `${hostname}:/${filePath}`;
|
||||
}
|
||||
|
||||
export function saveScript(scriptToSave: OpenScript): void {
|
||||
const server = GetServer(scriptToSave.hostname);
|
||||
if (!server) {
|
||||
dialogBoxCreate(`Server ${scriptToSave.hostname} does not exist.`);
|
||||
return;
|
||||
}
|
||||
// Show a warning message if the file is on a non-home server.
|
||||
if (scriptToSave.hostname !== SpecialServers.Home) {
|
||||
SnackbarEvents.emit("You saved a file on a non-home server!", ToastVariant.WARNING, 3000);
|
||||
}
|
||||
// This server helper already handles overwriting, etc.
|
||||
server.writeToContentFile(scriptToSave.path, scriptToSave.code);
|
||||
|
||||
EditorEvents.emit(scriptToSave.hostname, scriptToSave.path);
|
||||
|
||||
if (Settings.SaveGameOnFileSave) {
|
||||
saveObject.saveGame().catch((error) => exceptionAlert(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,8 @@ export const Settings = {
|
||||
MonacoStickyScroll: { enabled: false } as StickyScroll,
|
||||
/** Whether to show minimap in the script editor */
|
||||
MonacoMinimap: { enabled: true } as Minimap,
|
||||
/** Whether to autosave on focus change */
|
||||
MonacoAutoSaveOnFocusChange: true,
|
||||
/** Whether to hide trailing zeroes on fractional part of decimal */
|
||||
hideTrailingDecimalZeros: false,
|
||||
/** Whether to hide thousands separators. */
|
||||
@@ -273,5 +275,10 @@ export const Settings = {
|
||||
|
||||
// Set up initial state for error modal suppression
|
||||
toggleSuppressErrorModals(Settings.SuppressErrorModals, true);
|
||||
|
||||
// Disable this feature for existing save files.
|
||||
if (save.MonacoAutoSaveOnFocusChange === undefined) {
|
||||
Settings.MonacoAutoSaveOnFocusChange = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user