UI: Add option to autosave scripts on focus change (#2565)

This commit is contained in:
catloversg
2026-03-14 09:37:17 +07:00
committed by GitHub
parent 9f6e2ce2d1
commit ade79c0f65
11 changed files with 108 additions and 52 deletions

View 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]>();

View File

@@ -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() {

View File

@@ -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 () => {

View File

@@ -21,4 +21,5 @@ export interface Options {
beautifyOnSave: boolean;
stickyScroll: StickyScroll;
minimap: Minimap;
autoSaveOnFocusChange: boolean;
}

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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)}

View File

@@ -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));
}
}

View File

@@ -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;
}
},
};