From 49ee9406cd44c867bd8a7df9e4c372397bd642c7 Mon Sep 17 00:00:00 2001 From: Eddy Meals Date: Tue, 9 Jul 2024 18:49:19 -0700 Subject: [PATCH] Add saving/loading from Ace editor Add MainApp class to hold main process code besides setup. Add menu options to open and save Python files in Editor. Add file info bar in Editor displaying file name and status. Add generic confirmation modal. --- src/common/AppConsoleMessage.ts | 10 ++ src/main/MainApp.ts | 164 +++++++++++++++++++++++++++ src/main/main.ts | 8 +- src/main/menu.ts | 28 +++-- src/main/preload.ts | 9 +- src/renderer/App.tsx | 131 +++++++++++++++++++-- src/renderer/AppConsole.tsx | 8 +- src/renderer/editor/Editor.css | 19 +++- src/renderer/editor/Editor.tsx | 40 ++++++- src/renderer/modals/ConfirmModal.tsx | 37 ++++++ 10 files changed, 424 insertions(+), 30 deletions(-) create mode 100644 src/common/AppConsoleMessage.ts create mode 100644 src/main/MainApp.ts create mode 100644 src/renderer/modals/ConfirmModal.tsx diff --git a/src/common/AppConsoleMessage.ts b/src/common/AppConsoleMessage.ts new file mode 100644 index 0000000..f62d59f --- /dev/null +++ b/src/common/AppConsoleMessage.ts @@ -0,0 +1,10 @@ +export default class AppConsoleMessage { + #type: string; + + #text: string; + + constructor(type: string, text: string) { + this.#type = type; + this.#text = text; + } +} diff --git a/src/main/MainApp.ts b/src/main/MainApp.ts new file mode 100644 index 0000000..19a84c9 --- /dev/null +++ b/src/main/MainApp.ts @@ -0,0 +1,164 @@ +import { dialog, ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import fs from 'fs'; +import { version as dawnVersion } from '../../package.json'; +import AppConsoleMessage from '../common/AppConsoleMessage'; + +const WATCH_DEBOUNCE_MS = 3000; +const CODE_FILE_FILTERS = [ + { name: 'Python Files', extensions: ['py'] }, + { name: 'All Files', extensions: ['*'] }, +]; + +export default class MainApp { + readonly #mainWindow: BrowserWindow; + + #savePath: string | null; + + #watcher: fs.FSWatcher | null; + + #watchDebounce: boolean; + + #preventQuit: boolean; + + constructor(mainWindow: BrowserWindow) { + this.#mainWindow = mainWindow; + this.#savePath = null; + this.#watcher = null; + this.#watchDebounce = true; + this.#preventQuit = true; + mainWindow.on('close', (e) => { + if (this.#preventQuit) { + e.preventDefault(); + this.#sendToRenderer('renderer-quit-request'); + } + }); + ipcMain.on('main-file-control', (_event, data) => { + if (data.type === 'save') { + this.#saveCodeFile(data.content as string, data.forceDialog as boolean); + } else if (data.type === 'load') { + this.#openCodeFile(); + } else { + // eslint-disable-next-line no-console + console.error(`Unknown data.type for main-file-control ${data.type}`); + } + }); + ipcMain.on('main-quit', () => { + this.#preventQuit = false; + this.#mainWindow.close(); + }); + } + + #sendToRenderer(...args) { + this.#mainWindow.webContents.send(...args); + } + + onPresent() { + this.#watcher?.close(); + this.#savePath = null; + this.#sendToRenderer('renderer-init', { dawnVersion }); + } + + promptSaveCodeFile(forceDialog: boolean) { + this.#sendToRenderer('renderer-file-control', { + type: 'promptSave', + forceDialog, + }); + } + + promptLoadCodeFile() { + this.#sendToRenderer('renderer-file-control', { type: 'promptLoad' }); + } + + #saveCodeFile(code: string, forceDialog: boolean) { + let success = true; + if (this.#savePath === null || forceDialog) { + success = this.#showCodePathDialog('save'); + } + if (success) { + // Temporarily disable watcher + this.#watcher?.close(); + this.#watcher = null; + fs.writeFile( + this.#savePath, + code, + { encoding: 'utf8', flag: 'w' }, + (err) => { + if (err) { + this.#sendToRenderer( + 'renderer-post-console', + new AppConsoleMessage( + 'dawn-err', + `Failed to save code to ${this.#savePath}. ${err}`, + ), + ); + } else { + this.#sendToRenderer('renderer-file-control', { type: 'didSave' }); + } + this.#watchCodeFile(); + }, + ); + } + } + + #openCodeFile() { + const success = this.#showCodePathDialog('load'); + if (success) { + this.#sendToRenderer('renderer-file-control', { + type: 'didOpen', + content: fs.readFileSync(this.#savePath, { + encoding: 'utf8', + flag: 'r', + }), + }); + } + } + + #showCodePathDialog(mode: 'save' | 'load') { + let result: string | string[] | undefined; + if (mode === 'save') { + result = dialog.showSaveDialogSync(this.#mainWindow, { + filters: CODE_FILE_FILTERS, + ...(this.#savePath === null ? {} : { defaultPath: this.#savePath }), + }); + } else { + result = dialog.showOpenDialogSync(this.#mainWindow, { + filters: CODE_FILE_FILTERS, + properties: ['openFile'], + }); + } + if (result && result.length) { + this.#savePath = typeof result === 'string' ? result : result[0]; + this.#sendToRenderer('renderer-file-control', { + type: 'didChangePath', + path: this.#savePath, + }); + if (mode === 'load') { + this.#watchCodeFile(); + } + return true; + } + return false; + } + + #watchCodeFile() { + this.#watcher?.close(); + this.#watchDebounce = true; + this.#watcher = fs.watch( + this.#savePath, + { persistent: false, encoding: 'utf8' }, + () => { + // Don't care what the event type is + if (this.#watchDebounce) { + this.#watchDebounce = false; + setTimeout(() => { + this.#watchDebounce = true; + }, WATCH_DEBOUNCE_MS); + this.#sendToRenderer('renderer-file-control', { + type: 'didExternalChange', + }); + } + }, + ); + } +} diff --git a/src/main/main.ts b/src/main/main.ts index ec1b868..ac16122 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,7 +14,7 @@ import { autoUpdater } from 'electron-updater'; import log from 'electron-log'; import MenuBuilder from './menu'; import { resolveHtmlPath } from './util'; -import { version as dawnVersion } from '../../package.json'; +import MainApp from './MainApp'; class AppUpdater { constructor() { @@ -75,14 +75,14 @@ const createWindow = async () => { : path.join(__dirname, '../../.erb/dll/preload.js'), }, }); + const mainApp = new MainApp(mainWindow); mainWindow.loadURL(resolveHtmlPath('index.html')); - mainWindow.on('ready-to-show', () => { if (!mainWindow) { throw new Error('"mainWindow" is not defined'); } - mainWindow.webContents.send('renderer-init', { dawnVersion }); + mainApp.onPresent(); if (process.env.START_MINIMIZED) { mainWindow.minimize(); } else { @@ -94,7 +94,7 @@ const createWindow = async () => { mainWindow = null; }); - const menuBuilder = new MenuBuilder(mainWindow); + const menuBuilder = new MenuBuilder(mainApp, mainWindow); menuBuilder.buildMenu(); // Open urls in the user's browser diff --git a/src/main/menu.ts b/src/main/menu.ts index ba0fb77..0ed9d85 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -5,6 +5,7 @@ import { BrowserWindow, MenuItemConstructorOptions, } from 'electron'; +import type MainApp from './MainApp'; interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { selector?: string; @@ -12,9 +13,12 @@ interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { } export default class MenuBuilder { + mainApp: MainApp; + mainWindow: BrowserWindow; - constructor(mainWindow: BrowserWindow) { + constructor(mainApp: MainApp, mainWindow: BrowserWindow) { + this.mainApp = mainApp; this.mainWindow = mainWindow; } @@ -200,12 +204,22 @@ export default class MenuBuilder { { label: '&Open', accelerator: 'Ctrl+O', + click: () => { + this.mainApp.promptLoadCodeFile(); + }, }, { - label: '&Close', - accelerator: 'Ctrl+W', + label: '&Save', + accelerator: 'Ctrl+S', click: () => { - this.mainWindow.close(); + this.mainApp.promptSaveCodeFile(false); + }, + }, + { + label: 'Save &As', + accelerator: 'Ctrl+Shift+S', + click: () => { + this.mainApp.promptSaveCodeFile(true); }, }, ], @@ -234,7 +248,7 @@ export default class MenuBuilder { }, { label: 'Toggle &Developer Tools', - accelerator: 'Alt+Ctrl+I', + accelerator: 'Ctrl+Shift+I', click: () => { this.mainWindow.webContents.toggleDevTools(); }, @@ -252,7 +266,7 @@ export default class MenuBuilder { }, ], }, - { + /* { label: 'Help', submenu: [ { @@ -282,7 +296,7 @@ export default class MenuBuilder { }, }, ], - }, + }, */ ]; return templateDefault; diff --git a/src/main/preload.ts b/src/main/preload.ts index b99154d..40b48b5 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -2,7 +2,14 @@ /* eslint no-unused-vars: off */ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; -export type Channels = 'renderer-init' | 'robot-connection-update'; +export type Channels = + | 'renderer-init' + | 'renderer-robot-update' + | 'renderer-post-console' + | 'renderer-file-control' + | 'renderer-quit-request' + | 'main-file-control' + | 'main-quit'; const electronHandler = { ipcRenderer: { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8bd1c2e..10461b1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -9,6 +9,8 @@ import Topbar from './Topbar'; import Editor from './editor/Editor'; import DeviceInfo from './DeviceInfo'; import AppConsole from './AppConsole'; +import AppConsoleMessage from '../common/AppConsoleMessage'; +import ConfirmModal from './modals/ConfirmModal'; import ConnectionConfigModal from './modals/ConnectionConfigModal'; import GamepadInfoModal from './modals/GamepadInfoModal'; import ResizeBar from './ResizeBar'; @@ -44,7 +46,15 @@ export default function App() { // Robot latency, -1 if disconnected from robot const [robotLatencyMs, setRobotLatencyMs] = useState(-1); // Text content of editor - const [, setEditorContent] = useState(''); + const [editorContent, setEditorContent] = useState(''); + // Clean/dirty/extDirty status of edited file + const [editorStatus, setEditorStatus] = useState( + 'clean' as 'clean' | 'dirty' | 'extDirty', + ); + // Path to file in editor, empty if no save path + const [editorPath, setEditorPath] = useState(''); + // AppConsoleMessages displayed in AppConsole + const [consoleMsgs, setConsoleMsgs] = useState([] as AppConsoleMessage[]); // Most recent window.innerWidth/Height needed to clamp editor and col size const [windowSize, setWindowSize] = useReducer( (oldSize: [number, number], newSize: [number, number]) => { @@ -69,6 +79,7 @@ export default function App() { [-1, -1], ); + // Update windowSize: useLayoutEffect(() => { const onResize = () => setWindowSize([window.innerWidth, window.innerHeight]); @@ -83,10 +94,8 @@ export default function App() { window.electron.ipcRenderer.once('renderer-init', (data) => { setDawnVersion((data as { dawnVersion: string }).dawnVersion); }); - // ipcRenderer.on returns a cleanup function - return window.electron.ipcRenderer.on( - 'robot-connection-update', - (data) => { + const listenerDestructors = [ + window.electron.ipcRenderer.on('renderer-robot-update', (data) => { const { newRuntimeVersion, newRobotBatteryVoltage, @@ -105,12 +114,77 @@ export default function App() { if (newRobotLatencyMs !== undefined) { setRobotLatencyMs(newRobotLatencyMs); } - }, - ); + }), + window.electron.ipcRenderer.on('renderer-post-console', (data) => { + setConsoleMsgs((old) => [...old, data as AppConsoleMessage]); + }), + ]; + return () => listenerDestructors.forEach((destructor) => destructor()); } return () => {}; }, []); + const changeActiveModal = (newModalName: string) => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + setActiveModal(newModalName); + }; + useEffect(() => { + if (window.electron) { + return window.electron.ipcRenderer.on('renderer-file-control', (data) => { + if (!data || typeof data !== 'object' || !('type' in data)) { + throw new Error('Bad data for renderer-file-control.'); + } + if (data.type === 'promptSave') { + window.electron.ipcRenderer.sendMessage('main-file-control', { + type: 'save', + content: editorContent, + forceDialog: (data as { type: 'promptSave'; forceDialog: boolean }) + .forceDialog, + }); + } else if (data.type === 'promptLoad') { + if (editorStatus === 'clean') { + window.electron.ipcRenderer.sendMessage('main-file-control', { + type: 'load', + }); + } else { + changeActiveModal('DirtyLoadConfirm'); + } + } else if (data.type === 'didSave') { + setEditorStatus('clean'); + } else if (data.type === 'didOpen') { + setEditorStatus('clean'); + setEditorContent( + (data as { type: 'didOpen'; content: string }).content, + ); + } else if (data.type === 'didExternalChange') { + setEditorStatus('extDirty'); + } else if (data.type === 'didChangePath') { + setEditorPath((data as { type: 'didChangePath'; path: string }).path); + } else { + throw new Error( + `Unknown data.type for renderer-file-control ${data.type}`, + ); + } + }); + } + return () => {}; + }, [editorContent, editorStatus]); + + useEffect(() => { + if (window.electron) { + return window.electron.ipcRenderer.on('renderer-quit-request', () => { + if (activeModal !== 'DirtyQuitConfirm' && editorStatus !== 'clean') { + changeActiveModal('DirtyQuitConfirm'); + } else { + window.electron.ipcRenderer.sendMessage('main-quit'); + } + }); + } + return () => {}; + }, [activeModal, editorStatus]); + // Editor ResizeBar handlers: const startEditorResize = () => setEditorInitialSize(editorSize); const updateEditorResize = (d: number) => { @@ -147,20 +221,34 @@ export default function App() { }; const endColsResize = () => setColsInitialSize(-1); - const closeModal = () => setActiveModal(''); + const changeEditorContent = (newContent: string) => { + setEditorContent(newContent); + if (editorStatus === 'clean') { + setEditorStatus('dirty'); + } + }; + const closeModal = () => changeActiveModal(''); return (
setActiveModal('ConnectionConfig')} + onConnectionConfigModalOpen={() => + changeActiveModal('ConnectionConfig') + } dawnVersion={dawnVersion} runtimeVersion={runtimeVersion} robotLatencyMs={robotLatencyMs} robotBatteryVoltage={robotBatteryVoltage} />
- + - +
+ + window.electron.ipcRenderer.sendMessage('main-file-control', { + type: 'load', + }) + } + queryText="You have unsaved changes. Really load?" + modalTitle="Confirm load" + /> + + window.electron.ipcRenderer.sendMessage('main-quit') + } + queryText="You have unsaved changes. Really quit?" + modalTitle="Confirm quit" + noAutoClose + />
diff --git a/src/renderer/AppConsole.tsx b/src/renderer/AppConsole.tsx index 3e243bc..8bb260a 100644 --- a/src/renderer/AppConsole.tsx +++ b/src/renderer/AppConsole.tsx @@ -1,9 +1,15 @@ +import AppConsoleMessage from '../common/AppConsoleMessage'; import './AppConsole.css'; /** * Component displaying output and error messages from student code ran on the robot. */ -export default function AppConsole() { +export default function AppConsole({ + // eslint-disable-next-line + messages, +}: { + messages: AppConsoleMessage[]; +}) { return (
Test
diff --git a/src/renderer/editor/Editor.css b/src/renderer/editor/Editor.css index 354e553..f4b7297 100644 --- a/src/renderer/editor/Editor.css +++ b/src/renderer/editor/Editor.css @@ -1,3 +1,20 @@ .Editor { - flex-shrink: 0; + display: flex; + flex-direction: column; +} +.Editor-file-info { + display: flex; + padding-left: 15px; + flex-basis: 50px; + flex-direction: row; + align-items: center; +} +.Editor-file-name { + margin-right: 5px; +} +.Editor-file-status { + font-style: italic; +} +.Editor-ace-wrapper { + flex-grow: 1; } diff --git a/src/renderer/editor/Editor.tsx b/src/renderer/editor/Editor.tsx index 28c27b9..456710d 100644 --- a/src/renderer/editor/Editor.tsx +++ b/src/renderer/editor/Editor.tsx @@ -2,23 +2,53 @@ import AceEditor from 'react-ace'; import 'ace-builds/src-noconflict/mode-python'; import './Editor.css'; +const STATUS_TOOLTIPS = { + clean: '', + dirty: + 'Your changes will not be uploaded to the robot unless you save before running.', + extDirty: + 'The code that will be uploaded to the robot was last changed in an external program.', +}; +const STATUS_TEXT = { + clean: '', + dirty: 'Modified', + extDirty: 'Externally modified', +}; /** * Component holding the Ace editor and editor toolbar. */ export default function Editor({ width, onChange, + fileStatus, + filePath, + content, }: { width: number; onChange: (content: string) => void; + fileStatus: 'clean' | 'dirty' | 'extDirty'; + filePath: string; + content: string; }) { return (
- +
+ {filePath || '[New file]'} + + {STATUS_TEXT[fileStatus]} + +
+
+ +
); } diff --git a/src/renderer/modals/ConfirmModal.tsx b/src/renderer/modals/ConfirmModal.tsx new file mode 100644 index 0000000..4d9a384 --- /dev/null +++ b/src/renderer/modals/ConfirmModal.tsx @@ -0,0 +1,37 @@ +import Modal from './Modal'; + +export default function ConfirmModal({ + onClose, + onConfirm, + isActive, + queryText, + modalTitle, + noAutoClose = false, +}: { + onClose: () => void; + onConfirm: () => void; + isActive: boolean; + queryText: string; + modalTitle: string; + noAutoClose?: boolean; +}) { + const handleConfirm = () => { + onConfirm(); + if (!noAutoClose) { + onClose(); + } + }; + return ( + +

{queryText}

+ +
+ ); +} +// Not sure why we need this if we have the default deconstruction parameter but the linter cries if +// we leave it out +ConfirmModal.defaultProps = { + noAutoClose: false, +};