diff --git a/backend/ipc.js b/backend/ipc.js index 8bace22..ab06335 100644 --- a/backend/ipc.js +++ b/backend/ipc.js @@ -1,4 +1,7 @@ const fs = require('fs') +const registerMenu = require('./menu.js') +const serial = require('./serial/serial.js').sharedInstance + const { openFolderDialog, listFolder, @@ -7,6 +10,8 @@ const { } = require('./helpers.js') module.exports = function registerIPCHandlers(win, ipcMain, app, dialog) { + serial.win = win // Required to send callback messages to renderer + ipcMain.handle('open-folder', async (event) => { console.log('ipcMain', 'open-folder') const folder = await openFolderDialog(win) @@ -129,9 +134,18 @@ module.exports = function registerIPCHandlers(win, ipcMain, app, dialog) { return response != opt.cancelId }) + ipcMain.handle('update-menu-state', (event, state) => { + registerMenu(win, state) + }) + win.on('close', (event) => { console.log('BrowserWindow', 'close') event.preventDefault() win.webContents.send('check-before-close') }) + + ipcMain.handle('serial', (event, command, ...args) => { + console.debug('Handling IPC serial command:', command, ...args) + return serial[command](...args) + }) } diff --git a/backend/menu.js b/backend/menu.js index 6b62cdf..6db8023 100644 --- a/backend/menu.js +++ b/backend/menu.js @@ -1,8 +1,11 @@ const { app, Menu } = require('electron') const path = require('path') +const serial = require('./serial/serial.js').sharedInstance const openAboutWindow = require('about-window').default +const shortcuts = require('./shortcuts.js') +const { type } = require('os') -module.exports = function registerMenu(win) { +module.exports = function registerMenu(win, state = {}) { const isMac = process.platform === 'darwin' const template = [ ...(isMac ? [{ @@ -10,9 +13,8 @@ module.exports = function registerMenu(win) { submenu: [ { role: 'about'}, { type: 'separator' }, - { role: 'services' }, { type: 'separator' }, - { role: 'hide' }, + { role: 'hide', accelerator: 'CmdOrCtrl+Shift+H' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, @@ -35,7 +37,6 @@ module.exports = function registerMenu(win) { { role: 'copy' }, { role: 'paste' }, ...(isMac ? [ - { role: 'pasteAndMatchStyle' }, { role: 'selectAll' }, { type: 'separator' }, { @@ -51,11 +52,66 @@ module.exports = function registerMenu(win) { ]) ] }, + { + label: 'Board', + submenu: [ + { + label: 'Connect', + accelerator: shortcuts.menu.CONNECT, + click: () => win.webContents.send('shortcut-cmd', shortcuts.global.CONNECT) + }, + { + label: 'Disconnect', + accelerator: shortcuts.menu.DISCONNECT, + click: () => win.webContents.send('shortcut-cmd', shortcuts.global.DISCONNECT) + }, + { type: 'separator' }, + { + label: 'Run', + accelerator: shortcuts.menu.RUN, + enabled: state.isConnected && state.view === 'editor', + click: () => win.webContents.send('shortcut-cmd', shortcuts.global.RUN) + }, + { + label: 'Run selection', + accelerator: isMac ? shortcuts.menu.RUN_SELECTION : shortcuts.menu.RUN_SELECTION_WL, + enabled: state.isConnected && state.view === 'editor', + click: () => win.webContents.send('shortcut-cmd', (isMac ? shortcuts.global.RUN_SELECTION : shortcuts.global.RUN_SELECTION_WL)) + }, + { + label: 'Stop', + accelerator: shortcuts.menu.STOP, + enabled: state.isConnected && state.view === 'editor', + click: () => win.webContents.send('shortcut-cmd', shortcuts.global.STOP) + }, + { + label: 'Reset', + accelerator: shortcuts.menu.RESET, + enabled: state.isConnected && state.view === 'editor', + click: () => win.webContents.send('shortcut-cmd', shortcuts.global.RESET) + }, + { type: 'separator' } + ] + }, { label: 'View', submenu: [ - { role: 'reload' }, - { role: 'toggleDevTools' }, + { + label: 'Editor', + accelerator: shortcuts.menu.EDITOR_VIEW, + click: () => win.webContents.send('shortcut-cmd', shortcuts.global.EDITOR_VIEW,) + }, + { + label: 'Files', + accelerator: shortcuts.menu.FILES_VIEW, + click: () => win.webContents.send('shortcut-cmd', shortcuts.global.FILES_VIEW) + }, + { + label: 'Clear terminal', + accelerator: shortcuts.menu.CLEAR_TERMINAL, + enabled: state.isConnected && state.view === 'editor', + click: () => win.webContents.send('shortcut-cmd', shortcuts.global.CLEAR_TERMINAL) + }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, @@ -67,6 +123,20 @@ module.exports = function registerMenu(win) { { label: 'Window', submenu: [ + { + label: 'Reload', + accelerator: '', + click: async () => { + try { + await serial.disconnect() + win.reload() + } catch(e) { + console.error('Reload from menu failed:', e) + } + } + }, + { role: 'toggleDevTools'}, + { type: 'separator' }, { role: 'minimize' }, { role: 'zoom' }, ...(isMac ? [ @@ -75,7 +145,7 @@ module.exports = function registerMenu(win) { { type: 'separator' }, { role: 'window' } ] : [ - { role: 'close' } + ]) ] }, @@ -102,7 +172,6 @@ module.exports = function registerMenu(win) { openAboutWindow({ icon_path: path.resolve(__dirname, '../ui/arduino/media/about_image.png'), css_path: path.resolve(__dirname, '../ui/arduino/views/about.css'), - // about_page_dir: path.resolve(__dirname, '../ui/arduino/views/'), copyright: '© Arduino SA 2022', package_json_dir: path.resolve(__dirname, '..'), bug_report_url: "https://github.com/arduino/lab-micropython-editor/issues", diff --git a/backend/serial/serial-bridge.js b/backend/serial/serial-bridge.js new file mode 100644 index 0000000..715d21a --- /dev/null +++ b/backend/serial/serial-bridge.js @@ -0,0 +1,97 @@ +const { ipcRenderer } = require('electron') +const path = require('path') + +const SerialBridge = { + loadPorts: async () => { + return await ipcRenderer.invoke('serial', 'loadPorts') + }, + connect: async (path) => { + return await ipcRenderer.invoke('serial', 'connect', path) + }, + disconnect: async () => { + return await ipcRenderer.invoke('serial', 'disconnect') + }, + run: async (code) => { + return await ipcRenderer.invoke('serial', 'run', code) + }, + execFile: async (path) => { + return await ipcRenderer.invoke('serial', 'execFile', path) + }, + getPrompt: async () => { + return await ipcRenderer.invoke('serial', 'getPrompt') + }, + keyboardInterrupt: async () => { + await ipcRenderer.invoke('serial', 'keyboardInterrupt') + return Promise.resolve() + }, + reset: async () => { + await ipcRenderer.invoke('serial', 'reset') + return Promise.resolve() + }, + eval: (d) => { + return ipcRenderer.invoke('serial', 'eval', d) + }, + onData: (callback) => { + // Remove all previous listeners + if (ipcRenderer.listeners("serial-on-data").length > 0) { + ipcRenderer.removeAllListeners("serial-on-data") + } + ipcRenderer.on('serial-on-data', (event, data) => { + callback(data) + }) + }, + listFiles: async (folder) => { + return await ipcRenderer.invoke('serial', 'listFiles', folder) + }, + ilistFiles: async (folder) => { + return await ipcRenderer.invoke('serial', 'ilistFiles', folder) + }, + loadFile: async (file) => { + return await ipcRenderer.invoke('serial', 'loadFile', file) + }, + removeFile: async (file) => { + return await ipcRenderer.invoke('serial', 'removeFile', file) + }, + saveFileContent: async (filename, content, dataConsumer) => { + return await ipcRenderer.invoke('serial', 'saveFileContent', filename, content, dataConsumer) + }, + uploadFile: async (src, dest, dataConsumer) => { + return await ipcRenderer.invoke('serial', 'uploadFile', src, dest, dataConsumer) + }, + downloadFile: async (src, dest) => { + let contents = await ipcRenderer.invoke('serial', 'loadFile', src) + return ipcRenderer.invoke('save-file', dest, contents) + }, + renameFile: async (oldName, newName) => { + return await ipcRenderer.invoke('serial', 'renameFile', oldName, newName) + }, + onConnectionClosed: async (callback) => { + // Remove all previous listeners + if (ipcRenderer.listeners("serial-on-connection-closed").length > 0) { + ipcRenderer.removeAllListeners("serial-on-connection-closed") + } + ipcRenderer.on('serial-on-connection-closed', (event) => { + callback() + }) + }, + createFolder: async (folder) => { + return await ipcRenderer.invoke('serial', 'createFolder', folder) + }, + removeFolder: async (folder) => { + return await ipcRenderer.invoke('serial', 'removeFolder', folder) + }, + getNavigationPath: (navigation, target) => { + return path.posix.join(navigation, target) + }, + getFullPath: (root, navigation, file) => { + return path.posix.join(root, navigation, file) + }, + getParentPath: (navigation) => { + return path.posix.dirname(navigation) + }, + fileExists: async (filePath) => { + return await ipcRenderer.invoke('serial', 'fileExists', filePath) + } +} + +module.exports = SerialBridge \ No newline at end of file diff --git a/backend/serial/serial.js b/backend/serial/serial.js new file mode 100644 index 0000000..e702b17 --- /dev/null +++ b/backend/serial/serial.js @@ -0,0 +1,117 @@ +const MicroPython = require('micropython.js') + +class Serial { + constructor(win = null) { + this.win = win + this.board = new MicroPython() + this.board.chunk_size = 192 + this.board.chunk_sleep = 200 + } + + async loadPorts() { + let ports = await this.board.list_ports() + return ports.filter(p => p.vendorId && p.productId) + } + + async connect(path) { + await this.board.open(path) + this.registerCallbacks() + } + + async disconnect() { + return await this.board.close() + } + + async run(code) { + return await this.board.run(code) + } + + async execFile(path) { + return await this.board.execfile(path) + } + + async getPrompt() { + return await this.board.get_prompt() + } + + async keyboardInterrupt() { + await this.board.stop() + return Promise.resolve() + } + + async reset() { + await this.board.stop() + await this.board.exit_raw_repl() + await this.board.reset() + return Promise.resolve() + } + + async eval(d) { + return await this.board.eval(d) + } + + registerCallbacks() { + this.board.serial.on('data', (data) => { + this.win.webContents.send('serial-on-data', data) + }) + + this.board.serial.on('close', () => { + this.board.serial.removeAllListeners("data") + this.board.serial.removeAllListeners("close") + this.win.webContents.send('serial-on-connection-closed') + }) + } + + async listFiles(folder) { + return await this.board.fs_ls(folder) + } + + async ilistFiles(folder) { + return await this.board.fs_ils(folder) + } + + async loadFile(file) { + const output = await this.board.fs_cat_binary(file) + return output || '' + } + + async removeFile(file) { + return await this.board.fs_rm(file) + } + + async saveFileContent(filename, content, dataConsumer) { + return await this.board.fs_save(content || ' ', filename, dataConsumer) + } + + async uploadFile(src, dest, dataConsumer) { + return await this.board.fs_put(src, dest.replaceAll(path.win32.sep, path.posix.sep), dataConsumer) + } + + async renameFile(oldName, newName) { + return await this.board.fs_rename(oldName, newName) + } + + async createFolder(folder) { + return await this.board.fs_mkdir(folder) + } + + async removeFolder(folder) { + return await this.board.fs_rmdir(folder) + } + + async fileExists(filePath) { + const output = await this.board.run(` +import os +try: + os.stat("${filePath}") + print(0) +except OSError: + print(1) +`) + return output[2] === '0' + } +} + +const sharedInstance = new Serial() + +module.exports = {sharedInstance, Serial} \ No newline at end of file diff --git a/backend/shortcuts.js b/backend/shortcuts.js new file mode 100644 index 0000000..e6b7159 --- /dev/null +++ b/backend/shortcuts.js @@ -0,0 +1,29 @@ +module.exports = { + global: { + CONNECT: 'CommandOrControl+Shift+C', + DISCONNECT: 'CommandOrControl+Shift+D', + SAVE: 'CommandOrControl+S', + RUN: 'CommandOrControl+R', + RUN_SELECTION: 'CommandOrControl+Alt+R', + RUN_SELECTION_WL: 'CommandOrControl+Alt+S', + STOP: 'CommandOrControl+H', + RESET: 'CommandOrControl+Shift+R', + CLEAR_TERMINAL: 'CommandOrControl+L', + EDITOR_VIEW: 'CommandOrControl+Alt+1', + FILES_VIEW: 'CommandOrControl+Alt+2', + ESC: 'Escape' + }, + menu: { + CONNECT: 'CmdOrCtrl+Shift+C', + DISCONNECT: 'CmdOrCtrl+Shift+D', + SAVE: 'CmdOrCtrl+S', + RUN: 'CmdOrCtrl+R', + RUN_SELECTION: 'CmdOrCtrl+Alt+R', + RUN_SELECTION_WL: 'CmdOrCtrl+Alt+S', + STOP: 'CmdOrCtrl+H', + RESET: 'CmdOrCtrl+Shift+R', + CLEAR_TERMINAL: 'CmdOrCtrl+L', + EDITOR_VIEW: 'CmdOrCtrl+Alt+1', + FILES_VIEW: 'CmdOrCtrl+Alt+2' + } +} diff --git a/index.js b/index.js index 57eba4c..a6fcc04 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ -const { app, BrowserWindow, ipcMain, dialog } = require('electron') +const { app, BrowserWindow, ipcMain, dialog, globalShortcut } = require('electron') const path = require('path') const fs = require('fs') +const shortcuts = require('./backend/shortcuts.js').global const registerIPCHandlers = require('./backend/ipc.js') const registerMenu = require('./backend/menu.js') @@ -49,12 +50,41 @@ function createWindow () { win.show() }) + const initialMenuState = { + isConnected: false, + view: 'editor' + } + registerIPCHandlers(win, ipcMain, app, dialog) - registerMenu(win) + registerMenu(win, initialMenuState) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) } -app.on('ready', createWindow) +function shortcutAction(key) { + win.webContents.send('shortcut-cmd', key); +} + +// Shortcuts +function registerShortcuts() { + Object.entries(shortcuts).forEach(([command, shortcut]) => { + globalShortcut.register(shortcut, () => { + shortcutAction(shortcut) + }); + }) +} + +app.on('ready', () => { + createWindow() + registerShortcuts() + + win.on('focus', () => { + registerShortcuts() + }) + win.on('blur', () => { + globalShortcut.unregisterAll() + }) + +}) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d6726c0..42738a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "arduino-lab-micropython-ide", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arduino-lab-micropython-ide", - "version": "0.10.0", + "version": "0.11.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "about-window": "^1.15.2", - "micropython.js": "github:arduino/micropython.js#v1.5.0" + "micropython.js": "github:arduino/micropython.js#v1.5.1" }, "devDependencies": { "electron": "^19.0.10", diff --git a/package.json b/package.json index 0c4d3a5..e86c5ba 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "license": "MIT", "dependencies": { "about-window": "^1.15.2", - "micropython.js": "github:arduino/micropython.js#v1.5.0" + "micropython.js": "github:arduino/micropython.js#v1.5.1" }, "engines": { "node": "18" diff --git a/preload.js b/preload.js index ddcb8aa..f67d43c 100644 --- a/preload.js +++ b/preload.js @@ -1,106 +1,9 @@ console.log('preload') const { contextBridge, ipcRenderer } = require('electron') const path = require('path') - -const MicroPython = require('micropython.js') -const board = new MicroPython() -board.chunk_size = 192 -board.chunk_sleep = 200 - -const Serial = { - loadPorts: async () => { - let ports = await board.list_ports() - return ports.filter(p => p.vendorId && p.productId) - }, - connect: async (path) => { - return board.open(path) - }, - disconnect: async () => { - return board.close() - }, - run: async (code) => { - return board.run(code) - }, - execFile: async (path) => { - return board.execfile(path) - }, - getPrompt: async () => { - return board.get_prompt() - }, - keyboardInterrupt: async () => { - await board.stop() - return Promise.resolve() - }, - reset: async () => { - await board.stop() - await board.exit_raw_repl() - await board.reset() - return Promise.resolve() - }, - eval: (d) => { - return board.eval(d) - }, - onData: (fn) => { - board.serial.on('data', fn) - }, - listFiles: async (folder) => { - return board.fs_ls(folder) - }, - ilistFiles: async (folder) => { - return board.fs_ils(folder) - }, - loadFile: async (file) => { - const output = await board.fs_cat_binary(file) - return output || '' - }, - removeFile: async (file) => { - return board.fs_rm(file) - }, - saveFileContent: async (filename, content, dataConsumer) => { - return board.fs_save(content || ' ', filename, dataConsumer) - }, - uploadFile: async (src, dest, dataConsumer) => { - return board.fs_put(src, dest.replaceAll(path.win32.sep, path.posix.sep), dataConsumer) - }, - downloadFile: async (src, dest) => { - let contents = await Serial.loadFile(src) - return ipcRenderer.invoke('save-file', dest, contents) - }, - renameFile: async (oldName, newName) => { - return board.fs_rename(oldName, newName) - }, - onDisconnect: async (fn) => { - board.serial.on('close', fn) - }, - createFolder: async (folder) => { - return await board.fs_mkdir(folder) - }, - removeFolder: async (folder) => { - return await board.fs_rmdir(folder) - }, - getNavigationPath: (navigation, target) => { - return path.posix.join(navigation, target) - }, - getFullPath: (root, navigation, file) => { - return path.posix.join(root, navigation, file) - }, - getParentPath: (navigation) => { - return path.posix.dirname(navigation) - }, - fileExists: async (filePath) => { - // !!!: Fix this on micropython.js level - // ???: Check if file exists is not part of mpremote specs - const output = await board.run(` -import os -try: - os.stat("${filePath}") - print(0) -except OSError: - print(1) -`) - return output[2] === '0' - } -} +const shortcuts = require('./backend/shortcuts.js').global +const { emit, platform } = require('process') +const SerialBridge = require('./backend/serial/serial-bridge.js') const Disk = { openFolder: async () => { @@ -155,13 +58,28 @@ const Window = { setWindowSize: (minWidth, minHeight) => { ipcRenderer.invoke('set-window-size', minWidth, minHeight) }, + onKeyboardShortcut: (callback, key) => { + ipcRenderer.on('shortcut-cmd', (event, k) => { + callback(k); + }) + }, + beforeClose: (callback) => ipcRenderer.on('check-before-close', callback), confirmClose: () => ipcRenderer.invoke('confirm-close'), isPackaged: () => ipcRenderer.invoke('is-packaged'), - openDialog: (opt) => ipcRenderer.invoke('open-dialog', opt) -} + openDialog: (opt) => ipcRenderer.invoke('open-dialog', opt), + + getOS: () => platform, + isWindows: () => platform === 'win32', + isMac: () => platform === 'darwin', + isLinux: () => platform === 'linux', + updateMenuState: (state) => { + return ipcRenderer.invoke('update-menu-state', state) + }, + getShortcuts: () => shortcuts +} -contextBridge.exposeInMainWorld('BridgeSerial', Serial) +contextBridge.exposeInMainWorld('BridgeSerial', SerialBridge) contextBridge.exposeInMainWorld('BridgeDisk', Disk) -contextBridge.exposeInMainWorld('BridgeWindow', Window) +contextBridge.exposeInMainWorld('BridgeWindow', Window) \ No newline at end of file diff --git a/ui/arduino/main.js b/ui/arduino/main.js index bf693df..ce52be1 100644 --- a/ui/arduino/main.js +++ b/ui/arduino/main.js @@ -46,11 +46,9 @@ window.addEventListener('load', () => { app.use(store); app.route('*', App) app.mount('#app') - app.emitter.on('DOMContentLoaded', () => { if (app.state.diskNavigationRoot) { app.emitter.emit('refresh-files') } }) - }) diff --git a/ui/arduino/media/board.svg b/ui/arduino/media/board.svg new file mode 100644 index 0000000..0977345 --- /dev/null +++ b/ui/arduino/media/board.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/arduino/media/code.svg b/ui/arduino/media/code.svg new file mode 100644 index 0000000..3b4303f --- /dev/null +++ b/ui/arduino/media/code.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/arduino/media/computer.svg b/ui/arduino/media/computer.svg index 9d4cb1e..f0f8efb 100644 --- a/ui/arduino/media/computer.svg +++ b/ui/arduino/media/computer.svg @@ -1,3 +1,26 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/arduino/media/editor.svg b/ui/arduino/media/editor.svg new file mode 100644 index 0000000..327fc19 --- /dev/null +++ b/ui/arduino/media/editor.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/arduino/media/files.svg b/ui/arduino/media/files.svg index 36d96a0..59ffe3f 100644 --- a/ui/arduino/media/files.svg +++ b/ui/arduino/media/files.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/ui/arduino/media/folder.svg b/ui/arduino/media/folder.svg index 87e8555..68843f7 100644 --- a/ui/arduino/media/folder.svg +++ b/ui/arduino/media/folder.svg @@ -1,3 +1,15 @@ - - + + + + + + + + + + + + + + diff --git a/ui/arduino/store.js b/ui/arduino/store.js index c748905..09a373e 100644 --- a/ui/arduino/store.js +++ b/ui/arduino/store.js @@ -1,8 +1,10 @@ const log = console.log -const serial = window.BridgeSerial +const serialBridge = window.BridgeSerial const disk = window.BridgeDisk const win = window.BridgeWindow +const shortcuts = window.BridgeWindow.getShortcuts() + const newFileContent = `# This program was created in Arduino Lab for MicroPython print('Hello, MicroPython!') @@ -24,6 +26,7 @@ async function confirm(msg, cancelMsg, confirmMsg) { async function store(state, emitter) { win.setWindowSize(720, 640) + state.platform = window.BridgeWindow.getOS() state.view = 'editor' state.diskNavigationPath = '/' state.diskNavigationRoot = getDiskNavigationRootFromStorage() @@ -80,6 +83,14 @@ async function store(state, emitter) { emitter.emit('render') } + // Menu management + const updateMenu = () => { + window.BridgeWindow.updateMenuState({ + isConnected: state.isConnected, + view: state.view + }) + } + // START AND BASIC ROUTING emitter.on('select-disk-navigation-root', async () => { const folder = await selectDiskFolder() @@ -97,12 +108,14 @@ async function store(state, emitter) { emitter.emit('refresh-files') } emitter.emit('render') + updateMenu() }) // CONNECTION DIALOG emitter.on('open-connection-dialog', async () => { log('open-connection-dialog') - emitter.emit('disconnect') + // UI should be in disconnected state, no need to update + await serialBridge.disconnect() state.availablePorts = await getAvailablePorts() state.isConnectionDialogOpen = true emitter.emit('render') @@ -136,17 +149,19 @@ async function store(state, emitter) { emitter.emit('connection-timeout') }, 3500) try { - await serial.connect(path) + await serialBridge.connect(path) } catch(e) { console.error(e) } // Stop whatever is going on // Recover from getting stuck in raw repl - await serial.getPrompt() + + await serialBridge.getPrompt() clearTimeout(timeout_id) // Connected and ready state.isConnecting = false state.isConnected = true + updateMenu() if (state.view === 'editor' && state.panelHeight <= PANEL_CLOSED) { state.panelHeight = state.savedPanelHeight } @@ -157,29 +172,35 @@ async function store(state, emitter) { if (!state.isTerminalBound) { state.isTerminalBound = true term.onData((data) => { - serial.eval(data) + serialBridge.eval(data) term.scrollToBottom() }) - serial.eval('\x02') + serialBridge.eval('\x02') // Send Ctrl+B to enter normal repl mode } - serial.onData((data) => { + serialBridge.onData((data) => { term.write(data) term.scrollToBottom() }) - serial.onDisconnect(() => emitter.emit('disconnect')) + + // Update the UI when the conncetion is closed + // This may happen when unplugging the board + serialBridge.onConnectionClosed(() => emitter.emit('disconnected')) emitter.emit('close-connection-dialog') emitter.emit('refresh-files') emitter.emit('render') }) - emitter.on('disconnect', async () => { - await serial.disconnect() + emitter.on('disconnected', () => { state.isConnected = false state.panelHeight = PANEL_CLOSED state.boardFiles = [] state.boardNavigationPath = '/' emitter.emit('refresh-files') emitter.emit('render') + updateMenu() + }) + emitter.on('disconnect', async () => { + await serialBridge.disconnect() }) emitter.on('connection-timeout', async () => { state.isConnected = false @@ -190,15 +211,31 @@ async function store(state, emitter) { }) // CODE EXECUTION - emitter.on('run', async () => { + emitter.on('run', async (onlySelected = false) => { log('run') const openFile = state.openFiles.find(f => f.id == state.editingFile) - const code = openFile.editor.editor.state.doc.toString() + let code = openFile.editor.editor.state.doc.toString() + + // If there is a selection, run only the selected code + const startIndex = openFile.editor.editor.state.selection.ranges[0].from + const endIndex = openFile.editor.editor.state.selection.ranges[0].to + if (endIndex - startIndex > 0 && onlySelected) { + selectedCode = openFile.editor.editor.state.doc.toString().substring(startIndex, endIndex) + // Checking to see if the user accidentally double-clicked some whitespace + // While a random selection would yield an error when executed, + // selecting only whitespace would not and the user would have no feedback. + // This check only replaces the full content of the currently selected tab + // with a text selection if the selection is not empty and contains only whitespace. + if (selectedCode.trim().length > 0) { + code = selectedCode + } + } + emitter.emit('open-panel') emitter.emit('render') try { - await serial.getPrompt() - await serial.run(code) + await serialBridge.getPrompt() + await serialBridge.run(code) } catch(e) { log('error', e) } @@ -210,7 +247,7 @@ async function store(state, emitter) { } emitter.emit('open-panel') emitter.emit('render') - await serial.getPrompt() + await serialBridge.getPrompt() }) emitter.on('reset', async () => { log('reset') @@ -219,7 +256,7 @@ async function store(state, emitter) { } emitter.emit('open-panel') emitter.emit('render') - await serial.reset() + await serialBridge.reset() emitter.emit('update-files') emitter.emit('render') }) @@ -295,9 +332,9 @@ async function store(state, emitter) { // Check if the current full path exists let fullPathExists = false if (openFile.source == 'board') { - await serial.getPrompt() - fullPathExists = await serial.fileExists( - serial.getFullPath( + await serialBridge.getPrompt() + fullPathExists = await serialBridge.fileExists( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, openFile.fileName @@ -318,9 +355,9 @@ async function store(state, emitter) { if (openFile.source == 'board') { openFile.parentFolder = state.boardNavigationPath // Check for overwrite - await serial.getPrompt() - willOverwrite = await serial.fileExists( - serial.getFullPath( + await serialBridge.getPrompt() + willOverwrite = await serialBridge.fileExists( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, openFile.fileName @@ -353,9 +390,9 @@ async function store(state, emitter) { const contents = openFile.editor.editor.state.doc.toString() try { if (openFile.source == 'board') { - await serial.getPrompt() - await serial.saveFileContent( - serial.getFullPath( + await serialBridge.getPrompt() + await serialBridge.saveFileContent( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, openFile.fileName @@ -431,7 +468,7 @@ async function store(state, emitter) { if (state.isConnected) { try { state.boardFiles = await getBoardFiles( - serial.getFullPath( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, '' @@ -509,8 +546,8 @@ async function store(state, emitter) { } // TODO: Remove existing file } - await serial.saveFileContent( - serial.getFullPath( + await serialBridge.saveFileContent( + serialBridge.getFullPath( '/', state.boardNavigationPath, value @@ -580,15 +617,15 @@ async function store(state, emitter) { } // Remove existing folder await removeBoardFolder( - serial.getFullPath( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, value ) ) } - await serial.createFolder( - serial.getFullPath( + await serialBridge.createFolder( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, value @@ -670,7 +707,7 @@ async function store(state, emitter) { if (file.type == 'folder') { if (file.source === 'board') { await removeBoardFolder( - serial.getFullPath( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, file.fileName @@ -687,8 +724,8 @@ async function store(state, emitter) { } } else { if (file.source === 'board') { - await serial.removeFile( - serial.getFullPath( + await serialBridge.removeFile( + serialBridge.getFullPath( '/', state.boardNavigationPath, file.fileName @@ -756,15 +793,15 @@ async function store(state, emitter) { if (file.type == 'folder') { await removeBoardFolder( - serial.getFullPath( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, value ) ) } else if (file.type == 'file') { - await serial.removeFile( - serial.getFullPath( + await serialBridge.removeFile( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, value @@ -816,13 +853,13 @@ async function store(state, emitter) { try { if (state.renamingFile == 'board') { - await serial.renameFile( - serial.getFullPath( + await serialBridge.renameFile( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, file.fileName ), - serial.getFullPath( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, value @@ -893,8 +930,8 @@ async function store(state, emitter) { if (!isNewFile) { // Check if full path exists if (openFile.source == 'board') { - fullPathExists = await serial.fileExists( - serial.getFullPath( + fullPathExists = await serialBridge.fileExists( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, oldName @@ -922,8 +959,8 @@ async function store(state, emitter) { // Check if it will overwrite let willOverwrite = false if (openFile.source == 'board') { - willOverwrite = await serial.fileExists( - serial.getFullPath( + willOverwrite = await serialBridge.fileExists( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, openFile.fileName @@ -955,9 +992,9 @@ async function store(state, emitter) { const contents = openFile.editor.editor.state.doc.toString() try { if (openFile.source == 'board') { - await serial.getPrompt() - await serial.saveFileContent( - serial.getFullPath( + await serialBridge.getPrompt() + await serialBridge.saveFileContent( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, oldName @@ -984,13 +1021,13 @@ async function store(state, emitter) { // RENAME FILE try { if (openFile.source == 'board') { - await serial.renameFile( - serial.getFullPath( + await serialBridge.renameFile( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, oldName ), - serial.getFullPath( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, openFile.fileName @@ -1018,9 +1055,9 @@ async function store(state, emitter) { const contents = openFile.editor.editor.state.doc.toString() try { if (openFile.source == 'board') { - await serial.getPrompt() - await serial.saveFileContent( - serial.getFullPath( + await serialBridge.getPrompt() + await serialBridge.saveFileContent( + serialBridge.getFullPath( state.boardNavigationRoot, openFile.parentFolder, openFile.fileName @@ -1110,8 +1147,8 @@ async function store(state, emitter) { // load content and append it to the list of files to open let file = null if (selectedFile.source == 'board') { - const fileContent = await serial.loadFile( - serial.getFullPath( + const fileContent = await serialBridge.loadFile( + serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, selectedFile.fileName @@ -1167,6 +1204,7 @@ async function store(state, emitter) { state.openFiles = state.openFiles.concat(filesToOpen) state.view = 'editor' + updateMenu() emitter.emit('render') }) emitter.on('open-file', (source, file) => { @@ -1190,7 +1228,7 @@ async function store(state, emitter) { const willOverwrite = await checkOverwrite({ source: 'board', fileNames: state.selectedFiles.map(f => f.fileName), - parentPath: serial.getFullPath( + parentPath: serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, '' @@ -1217,7 +1255,7 @@ async function store(state, emitter) { state.diskNavigationPath, file.fileName ) - const destPath = serial.getFullPath( + const destPath = serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, file.fileName @@ -1231,7 +1269,7 @@ async function store(state, emitter) { } ) } else { - await serial.uploadFile( + await serialBridge.uploadFile( srcPath, destPath, (progress) => { state.transferringProgress = `${file.fileName}: ${progress}` @@ -1277,7 +1315,7 @@ async function store(state, emitter) { for (let i in state.selectedFiles) { const file = state.selectedFiles[i] - const srcPath = serial.getFullPath( + const srcPath = serialBridge.getFullPath( state.boardNavigationRoot, state.boardNavigationPath, file.fileName @@ -1296,7 +1334,7 @@ async function store(state, emitter) { } ) } else { - await serial.downloadFile( + await serialBridge.downloadFile( srcPath, destPath, (e) => { state.transferringProgress = e @@ -1315,7 +1353,7 @@ async function store(state, emitter) { // NAVIGATION emitter.on('navigate-board-folder', (folder) => { log('navigate-board-folder', folder) - state.boardNavigationPath = serial.getNavigationPath( + state.boardNavigationPath = serialBridge.getNavigationPath( state.boardNavigationPath, folder ) @@ -1324,7 +1362,7 @@ async function store(state, emitter) { }) emitter.on('navigate-board-parent', () => { log('navigate-board-parent') - state.boardNavigationPath = serial.getNavigationPath( + state.boardNavigationPath = serialBridge.getNavigationPath( state.boardNavigationPath, '..' ) @@ -1360,6 +1398,78 @@ async function store(state, emitter) { await win.confirmClose() }) + // win.shortcutCmdR(() => { + // // Only run if we can execute + + // }) + + win.onKeyboardShortcut((key) => { + if (key === shortcuts.CONNECT) { + emitter.emit('open-connection-dialog') + } + if (key === shortcuts.DISCONNECT) { + emitter.emit('disconnect') + } + if (key === shortcuts.RESET) { + if (state.view != 'editor') return + emitter.emit('reset') + } + if (key === shortcuts.CLEAR_TERMINAL) { + if (state.view != 'editor') return + emitter.emit('clear-terminal') + } + // Future: Toggle REPL panel + // if (key === 'T') { + // if (state.view != 'editor') return + // emitter.emit('clear-terminal') + // } + if (key === shortcuts.RUN) { + if (state.view != 'editor') return + runCode() + } + if (key === shortcuts.RUN_SELECTION || key === shortcuts.RUN_SELECTION_WL) { + if (state.view != 'editor') return + runCodeSelection() + } + if (key === shortcuts.STOP) { + if (state.view != 'editor') return + stopCode() + } + if (key === shortcuts.SAVE) { + if (state.view != 'editor') return + emitter.emit('save') + } + if (key === shortcuts.EDITOR_VIEW) { + if (state.view != 'file-manager') return + emitter.emit('change-view', 'editor') + } + if (key === shortcuts.FILES_VIEW) { + if (state.view != 'editor') return + emitter.emit('change-view', 'file-manager') + } + if (key === shortcuts.ESC) { + if (state.isConnectionDialogOpen) { + emitter.emit('close-connection-dialog') + } + } + + }) + + function runCode() { + if (canExecute({ view: state.view, isConnected: state.isConnected })) { + emitter.emit('run') + } + } + function runCodeSelection() { + if (canExecute({ view: state.view, isConnected: state.isConnected })) { + emitter.emit('run', true) + } + } + function stopCode() { + if (canExecute({ view: state.view, isConnected: state.isConnected })) { + emitter.emit('stop') + } + } function createFile(args) { const { source, @@ -1437,12 +1547,12 @@ function generateHash() { } async function getAvailablePorts() { - return await serial.loadPorts() + return await serialBridge.loadPorts() } async function getBoardFiles(path) { - await serial.getPrompt() - let files = await serial.ilistFiles(path) + await serialBridge.getPrompt() + let files = await serialBridge.ilistFiles(path) files = files.map(f => ({ fileName: f[0], type: f[1] === 0x4000 ? 'folder' : 'file' @@ -1460,9 +1570,9 @@ function checkDiskFile({ root, parentFolder, fileName }) { async function checkBoardFile({ root, parentFolder, fileName }) { if (root == null || parentFolder == null || fileName == null) return false - await serial.getPrompt() - return serial.fileExists( - serial.getFullPath(root, parentFolder, fileName) + await serialBridge.getPrompt() + return serialBridge.fileExists( + serialBridge.getFullPath(root, parentFolder, fileName) ) } @@ -1492,6 +1602,7 @@ function pickRandom(array) { function canSave({ view, isConnected, openFiles, editingFile }) { const isEditor = view === 'editor' const file = openFiles.find(f => f.id === editingFile) + if (!file.hasChanges) return false // Can only save on editor if (!isEditor) return false // Can always save disk files @@ -1526,29 +1637,29 @@ function canEdit({ selectedFiles }) { async function removeBoardFolder(fullPath) { // TODO: Replace with getting the file tree from the board and deleting one by one - let output = await serial.execFile(await getHelperFullPath()) - await serial.run(`delete_folder('${fullPath}')`) + let output = await serialBridge.execFile(await getHelperFullPath()) + await serialBridge.run(`delete_folder('${fullPath}')`) } async function uploadFolder(srcPath, destPath, dataConsumer) { dataConsumer = dataConsumer || function() {} - await serial.createFolder(destPath) + await serialBridge.createFolder(destPath) let allFiles = await disk.ilistAllFiles(srcPath) for (let i in allFiles) { const file = allFiles[i] const relativePath = file.path.substring(srcPath.length) if (file.type === 'folder') { - await serial.createFolder( - serial.getFullPath( + await serialBridge.createFolder( + serialBridge.getFullPath( destPath, relativePath, '' ) ) } else { - await serial.uploadFile( + await serialBridge.uploadFile( disk.getFullPath(srcPath, relativePath, ''), - serial.getFullPath(destPath, relativePath, ''), + serialBridge.getFullPath(destPath, relativePath, ''), (progress) => { dataConsumer(progress, relativePath) } @@ -1560,8 +1671,8 @@ async function uploadFolder(srcPath, destPath, dataConsumer) { async function downloadFolder(srcPath, destPath, dataConsumer) { dataConsumer = dataConsumer || function() {} await disk.createFolder(destPath) - let output = await serial.execFile(await getHelperFullPath()) - output = await serial.run(`ilist_all('${srcPath}')`) + let output = await serialBridge.execFile(await getHelperFullPath()) + output = await serialBridge.run(`ilist_all('${srcPath}')`) let files = [] try { // Extracting the json output from serial response @@ -1581,9 +1692,9 @@ async function downloadFolder(srcPath, destPath, dataConsumer) { disk.getFullPath( destPath, relativePath, '') ) } else { - await serial.downloadFile( - serial.getFullPath(srcPath, relativePath, ''), - serial.getFullPath(destPath, relativePath, '') + await serialBridge.downloadFile( + serialBridge.getFullPath(srcPath, relativePath, ''), + serialBridge.getFullPath(destPath, relativePath, '') ) } } @@ -1604,4 +1715,5 @@ async function getHelperFullPath() { '' ) } + } diff --git a/ui/arduino/views/components/elements/button.js b/ui/arduino/views/components/elements/button.js index 7030d49..3d888dd 100644 --- a/ui/arduino/views/components/elements/button.js +++ b/ui/arduino/views/components/elements/button.js @@ -2,7 +2,7 @@ function Button(args) { const { size = '', icon = 'connect.svg', - onClick = () => false, + onClick = (e) => false, disabled = false, active = false, tooltip, diff --git a/ui/arduino/views/components/file-list.js b/ui/arduino/views/components/file-list.js index 2767478..83e7d59 100644 --- a/ui/arduino/views/components/file-list.js +++ b/ui/arduino/views/components/file-list.js @@ -104,15 +104,18 @@ function generateFileList(source) { } return 0 }) + const parentNavigationDots = html`
emit(`navigate-${source}-parent`)} + style="cursor: pointer" + > + .. +
` + const list = html`
-
emit(`navigate-${source}-parent`)} - style="cursor: pointer" - > - .. -
+ ${source === 'disk' && state.diskNavigationPath != '/' ? parentNavigationDots : ''} + ${source === 'board' && state.boardNavigationPath != '/' ? parentNavigationDots : ''} ${state.creatingFile == source ? newFileItem : null} ${state.creatingFolder == source ? newFolderItem : null} ${files.map(FileItem)} diff --git a/ui/arduino/views/components/repl-panel.js b/ui/arduino/views/components/repl-panel.js index ac1760c..3974d50 100644 --- a/ui/arduino/views/components/repl-panel.js +++ b/ui/arduino/views/components/repl-panel.js @@ -50,7 +50,7 @@ function ReplOperations(state, emit) { Button({ icon: 'delete.svg', size: 'small', - tooltip: 'Clean', + tooltip: `Clean (${state.platform === 'darwin' ? 'Cmd' : 'Ctrl'}+L)`, onClick: () => emit('clear-terminal') }) ] diff --git a/ui/arduino/views/components/tabs.js b/ui/arduino/views/components/tabs.js index f8d72a5..750f8f8 100644 --- a/ui/arduino/views/components/tabs.js +++ b/ui/arduino/views/components/tabs.js @@ -4,7 +4,7 @@ function Tabs(state, emit) { ${state.openFiles.map((file) => { return Tab({ text: file.fileName, - icon: file.source === 'board'? 'connect.svg': 'computer.svg', + icon: file.source === 'board'? 'board.svg': 'computer.svg', active: file.id === state.editingFile, renaming: file.id === state.renamingTab, hasChanges: file.hasChanges, diff --git a/ui/arduino/views/components/toolbar.js b/ui/arduino/views/components/toolbar.js index 3512ef9..70982b0 100644 --- a/ui/arduino/views/components/toolbar.js +++ b/ui/arduino/views/components/toolbar.js @@ -9,13 +9,14 @@ function Toolbar(state, emit) { view: state.view, isConnected: state.isConnected }) - + const metaKeyString = state.platform === 'darwin' ? 'Cmd' : 'Ctrl' + return html`
${Button({ icon: state.isConnected ? 'connect.svg' : 'disconnect.svg', - tooltip: state.isConnected ? 'Disconnect' : 'Connect', - onClick: () => emit('open-connection-dialog'), + tooltip: state.isConnected ? `Disconnect (${metaKeyString}+Shift+D)` : `Connect (${metaKeyString}+Shift+C)`, + onClick: () => state.isConnected ? emit('disconnect') : emit('open-connection-dialog'), active: state.isConnected })} @@ -23,19 +24,25 @@ function Toolbar(state, emit) { ${Button({ icon: 'run.svg', - tooltip: 'Run', + tooltip: `Run (${metaKeyString}+R)`, disabled: !_canExecute, - onClick: () => emit('run') + onClick: (e) => { + if (e.altKey) { + emit('run', true) + }else{ + emit('run') + } + } })} ${Button({ icon: 'stop.svg', - tooltip: 'Stop', + tooltip: `Stop (${metaKeyString}+H)`, disabled: !_canExecute, onClick: () => emit('stop') })} ${Button({ icon: 'reboot.svg', - tooltip: 'Reset', + tooltip: `Reset (${metaKeyString}+Shift+R)`, disabled: !_canExecute, onClick: () => emit('reset') })} @@ -44,7 +51,7 @@ function Toolbar(state, emit) { ${Button({ icon: 'save.svg', - tooltip: 'Save', + tooltip: `Save (${metaKeyString}+S)`, disabled: !_canSave, onClick: () => emit('save') })} @@ -52,14 +59,14 @@ function Toolbar(state, emit) {
${Button({ - icon: 'console.svg', - tooltip: 'Editor and REPL', + icon: 'code.svg', + tooltip: `Editor (${metaKeyString}+Alt+1)`, active: state.view === 'editor', onClick: () => emit('change-view', 'editor') })} ${Button({ icon: 'files.svg', - tooltip: 'File Manager', + tooltip: `Files (${metaKeyString}+Alt+2)`, active: state.view === 'file-manager', onClick: () => emit('change-view', 'file-manager') })} diff --git a/ui/arduino/views/file-manager.js b/ui/arduino/views/file-manager.js index 04626ec..eafdf65 100644 --- a/ui/arduino/views/file-manager.js +++ b/ui/arduino/views/file-manager.js @@ -12,7 +12,7 @@ function FileManagerView(state, emit) {
- +
emit('open-connection-dialog')} class="text"> ${boardFullPath}