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