diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 137a1ec..9f35452 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,8 @@ jobs: config: - os: windows-2019 - os: ubuntu-latest - - os: macos-latest + - os: macos-13 + - os: macos-14 runs-on: ${{ matrix.config.os }} timeout-minutes: 90 @@ -99,6 +100,8 @@ jobs: name: Arduino-Lab-for-MicroPython_Linux_X86-64 - path: "*-mac_x64.zip" name: Arduino-Lab-for-MicroPython_macOS_X86-64 + - path: "*-mac_arm64.zip" + name: Arduino-Lab-for-MicroPython_macOS_arm-64 # - path: "*Windows_64bit.exe" # name: Windows_X86-64_interactive_installer # - path: "*Windows_64bit.msi" diff --git a/backend/helpers.js b/backend/helpers.js index 427d360..0976293 100644 --- a/backend/helpers.js +++ b/backend/helpers.js @@ -4,12 +4,12 @@ const path = require('path') async function openFolderDialog(win) { // https://stackoverflow.com/questions/46027287/electron-open-folder-dialog - let dir = await dialog.showOpenDialog(win, { properties: [ 'openDirectory' ] }) + const dir = await dialog.showOpenDialog(win, { properties: [ 'openDirectory' ] }) return dir.filePaths[0] || null } function listFolder(folder) { - files = fs.readdirSync(path.resolve(folder)) + let files = fs.readdirSync(path.resolve(folder)) // Filter out directories files = files.filter(f => { let filePath = path.resolve(folder, f) @@ -38,7 +38,7 @@ function ilistFolder(folder) { function getAllFiles(dirPath, arrayOfFiles) { // https://coderrocketfuel.com/article/recursively-list-all-the-files-in-a-directory-using-node-js - files = ilistFolder(dirPath) + let files = ilistFolder(dirPath) arrayOfFiles = arrayOfFiles || [] files.forEach(function(file) { const p = path.join(dirPath, file.path) diff --git a/backend/ipc.js b/backend/ipc.js index c97daba..d4ddc74 100644 --- a/backend/ipc.js +++ b/backend/ipc.js @@ -6,7 +6,7 @@ const { getAllFiles } = require('./helpers.js') -module.exports = function registerIPCHandlers(win, ipcMain) { +module.exports = function registerIPCHandlers(win, ipcMain, app) { ipcMain.handle('open-folder', async (event) => { console.log('ipcMain', 'open-folder') const folder = await openFolderDialog(win) @@ -107,4 +107,24 @@ module.exports = function registerIPCHandlers(win, ipcMain) { win.setMinimumSize(minWidth, minHeight) }) + + ipcMain.handle('confirm-close', () => { + console.log('ipcMain', 'confirm-close') + app.exit() + }) + + ipcMain.handle('is-packaged', () => { + return app.isPackaged + }) + + ipcMain.handle('get-app-path', () => { + console.log('ipcMain', 'get-app-path') + return app.getAppPath() + }) + + win.on('close', (event) => { + console.log('BrowserWindow', 'close') + event.preventDefault() + win.webContents.send('check-before-close') + }) } diff --git a/backend/menu.js b/backend/menu.js index 3ee40a6..6b62cdf 100644 --- a/backend/menu.js +++ b/backend/menu.js @@ -4,7 +4,6 @@ const openAboutWindow = require('about-window').default module.exports = function registerMenu(win) { const isMac = process.platform === 'darwin' - const isDev = !app.isPackaged const template = [ ...(isMac ? [{ label: app.name, @@ -56,17 +55,13 @@ module.exports = function registerMenu(win) { label: 'View', submenu: [ { role: 'reload' }, + { role: 'toggleDevTools' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, - ...(isDev ? [ - { type: 'separator' }, - { role: 'toggleDevTools' }, - ]:[ - ]) ] }, { diff --git a/index.js b/index.js index 2992967..b38f989 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,8 @@ const registerIPCHandlers = require('./backend/ipc.js') const registerMenu = require('./backend/menu.js') let win = null // main window +let splash = null +let splashTimestamp = null // START APP function createWindow () { @@ -17,17 +19,42 @@ function createWindow () { nodeIntegration: false, webSecurity: true, enableRemoteModule: false, - preload: path.join(__dirname, "preload.js") + preload: path.join(__dirname, "preload.js"), + show: false } }) // and load the index.html of the app. win.loadFile('ui/arduino/index.html') - registerIPCHandlers(win, ipcMain) - registerMenu(win) -} + // If the app takes a while to open, show splash screen + // Create the splash screen + splash = new BrowserWindow({ + width: 450, + height: 140, + transparent: true, + frame: false, + alwaysOnTop: true + }); + splash.loadFile('ui/arduino/splash.html') + splashTimestamp = Date.now() + + win.once('ready-to-show', () => { + if (Date.now()-splashTimestamp > 1000) { + splash.destroy() + } else { + setTimeout(() => { + splash.destroy() + }, 500) + } + win.show() + }) + registerIPCHandlers(win, ipcMain, app) + registerMenu(win) -// TODO: Loading splash screen + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +} -app.whenReady().then(createWindow) +app.on('ready', createWindow) diff --git a/package-lock.json b/package-lock.json index 61b8548..883d73e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arduino-lab-micropython-ide", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arduino-lab-micropython-ide", - "version": "0.9.0", + "version": "0.10.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ad5c2cd..2d3f773 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "arduino-lab-micropython-ide", "productName": "Arduino Lab for MicroPython", - "version": "0.9.1", + "version": "0.10.0", "description": "Arduino Lab for MicroPython is a project sponsored by Arduino, based on original work by Murilo Polese.\nThis is an experimental pre-release software, please direct any questions exclusively to Github issues.", "main": "index.js", "scripts": { "post-set-shell": "npm config set script-shell bash", "rebuild": "electron-rebuild", "dev": "electron --inspect ./", - "build": "npm run post-set-shell && electron-builder $(if [ $(uname -m) = arm64 ]; then echo --mac --x64; fi)", + "build": "npm run post-set-shell && electron-builder", "postinstall": "npm run post-set-shell && npm run rebuild" }, "devDependencies": { @@ -21,6 +21,7 @@ "build": { "appId": "cc.arduino.micropython-lab", "artifactName": "${productName}-${os}_${arch}.${ext}", + "extraResources": "./ui/arduino/helpers.py", "mac": { "target": "zip", "icon": "build_resources/icon.icns" diff --git a/preload.js b/preload.js index d7b24e8..3388904 100644 --- a/preload.js +++ b/preload.js @@ -13,10 +13,10 @@ const Serial = { return ports.filter(p => p.vendorId && p.productId) }, connect: async (path) => { - return await board.open(path) + return board.open(path) }, disconnect: async () => { - return await board.close() + return board.close() }, run: async (code) => { return board.run(code) @@ -145,15 +145,22 @@ const Disk = { }, fileExists: async (filePath) => { return ipcRenderer.invoke('file-exists', filePath) + }, + getAppPath: () => { + return ipcRenderer.invoke('get-app-path') } } const Window = { setWindowSize: (minWidth, minHeight) => { ipcRenderer.invoke('set-window-size', minWidth, minHeight) - } + }, + beforeClose: (callback) => ipcRenderer.on('check-before-close', callback), + confirmClose: () => ipcRenderer.invoke('confirm-close'), + isPackaged: () => ipcRenderer.invoke('is-packaged') } + contextBridge.exposeInMainWorld('BridgeSerial', Serial) contextBridge.exposeInMainWorld('BridgeDisk', Disk) contextBridge.exposeInMainWorld('BridgeWindow', Window) diff --git a/ui/arduino/index.html b/ui/arduino/index.html index 25f1967..8478cc7 100644 --- a/ui/arduino/index.html +++ b/ui/arduino/index.html @@ -30,6 +30,7 @@ + diff --git a/ui/arduino/main.js b/ui/arduino/main.js index f7d1ef7..bf693df 100644 --- a/ui/arduino/main.js +++ b/ui/arduino/main.js @@ -19,27 +19,26 @@ function App(state, emit) { ` } - let overlay = html`
` - - if (state.diskFiles == null) { - emit('load-disk-files') - overlay = html`

Loading files...

` + if (state.view == 'file-manager') { + return html` +
+ ${FileManagerView(state, emit)} + ${Overlay(state, emit)} +
+ ` + } else { + return html` +
+ ${EditorView(state, emit)} + ${Overlay(state, emit)} +
+ ` } - - if (state.isRemoving) overlay = html`

Removing...

` - if (state.isConnecting) overlay = html`

Connecting...

` - if (state.isLoadingFiles) overlay = html`

Loading files...

` - if (state.isSaving) overlay = html`

Saving file... ${state.savingProgress}

` - if (state.isTransferring) overlay = html`

Transferring file... ${state.transferringProgress}

` - - const view = state.view == 'editor' ? EditorView(state, emit) : FileManagerView(state, emit) return html`
- ${view} - ${overlay} + ${Overlay(state, emit)}
` - } window.addEventListener('load', () => { @@ -49,7 +48,9 @@ window.addEventListener('load', () => { app.mount('#app') app.emitter.on('DOMContentLoaded', () => { - app.emitter.emit('refresh-files') + if (app.state.diskNavigationRoot) { + app.emitter.emit('refresh-files') + } }) }) diff --git a/ui/arduino/splash.html b/ui/arduino/splash.html new file mode 100644 index 0000000..15ae0b4 --- /dev/null +++ b/ui/arduino/splash.html @@ -0,0 +1,21 @@ + + + + + Arduino Lab for MicroPython + + + + Arduino Lab For MicroPython Logo + + diff --git a/ui/arduino/store.js b/ui/arduino/store.js index 80656a5..f008c4b 100644 --- a/ui/arduino/store.js +++ b/ui/arduino/store.js @@ -1021,17 +1021,19 @@ async function store(state, emitter) { emitter.on('toggle-file-selection', (file, source, event) => { log('toggle-file-selection', file, source, event) + let parentFolder = source == 'board' ? state.boardNavigationPath : state.diskNavigationPath // Single file selection unless holding keyboard key if (event && !event.ctrlKey && !event.metaKey) { state.selectedFiles = [{ fileName: file.fileName, type: file.type, source: source, - parentFolder: file.parentFolder + parentFolder: parentFolder }] emitter.emit('render') return } + const isSelected = state.selectedFiles.find((f) => { return f.fileName === file.fileName && f.source === source }) @@ -1044,79 +1046,89 @@ async function store(state, emitter) { fileName: file.fileName, type: file.type, source: source, - parentFolder: file.parentFolder + parentFolder: parentFolder }) } emitter.emit('render') }) emitter.on('open-selected-files', async () => { log('open-selected-files') - let files = [] + let filesToOpen = [] + let filesAlreadyOpen = [] for (let i in state.selectedFiles) { let selectedFile = state.selectedFiles[i] - let openFile = null if (selectedFile.type == 'folder') { // Don't open folders continue } - if (selectedFile.source == 'board') { - const fileContent = await serial.loadFile( - serial.getFullPath( - '/', - state.boardNavigationPath, - selectedFile.fileName + // ALl good until here + + const alreadyOpen = state.openFiles.find((f) => { + return f.fileName == selectedFile.fileName + && f.source == selectedFile.source + && f.parentFolder == selectedFile.parentFolder + }) + console.log('already open', alreadyOpen) + + if (!alreadyOpen) { + // This file is not open yet, + // 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( + state.boardNavigationRoot, + state.boardNavigationPath, + selectedFile.fileName + ) ) - ) - openFile = createFile({ - parentFolder: state.boardNavigationPath, - fileName: selectedFile.fileName, - source: selectedFile.source, - content: fileContent - }) - openFile.editor.onChange = function() { - openFile.hasChanges = true - emitter.emit('render') - } - } else if (selectedFile.source == 'disk') { - const fileContent = await disk.loadFile( - disk.getFullPath( - state.diskNavigationRoot, - state.diskNavigationPath, - selectedFile.fileName + file = createFile({ + parentFolder: state.boardNavigationPath, + fileName: selectedFile.fileName, + source: selectedFile.source, + content: fileContent + }) + file.editor.onChange = function() { + file.hasChanges = true + emitter.emit('render') + } + } else if (selectedFile.source == 'disk') { + const fileContent = await disk.loadFile( + disk.getFullPath( + state.diskNavigationRoot, + state.diskNavigationPath, + selectedFile.fileName + ) ) - ) - openFile = createFile({ - parentFolder: state.diskNavigationPath, - fileName: selectedFile.fileName, - source: selectedFile.source, - content: fileContent - }) - openFile.editor.onChange = function() { - openFile.hasChanges = true - emitter.emit('render') + file = createFile({ + parentFolder: state.diskNavigationPath, + fileName: selectedFile.fileName, + source: selectedFile.source, + content: fileContent + }) + file.editor.onChange = function() { + file.hasChanges = true + emitter.emit('render') + } } + filesToOpen.push(file) + } else { + // This file is already open, + // append it to the list of files that are already open + filesAlreadyOpen.push(alreadyOpen) } - files.push(openFile) } - files = files.filter((f) => { // find files to open - let isAlready = false - state.openFiles.forEach((g) => { // check if file is already open - if ( - g.fileName == f.fileName - && g.source == f.source - && g.parentFolder == f.parentFolder - ) { - isAlready = true - } - }) - return !isAlready - }) - - if (files.length > 0) { - state.openFiles = state.openFiles.concat(files) - state.editingFile = files[0].id + // If opening an already open file, switch to its tab + if (filesAlreadyOpen.length > 0) { + state.editingFile = filesAlreadyOpen[0].id } + // If there are new files to open, they take priority + if (filesToOpen.length > 0) { + state.editingFile = filesToOpen[0].id + } + + state.openFiles = state.openFiles.concat(filesToOpen) state.view = 'editor' emitter.emit('render') @@ -1303,6 +1315,15 @@ async function store(state, emitter) { emitter.emit('render') }) + win.beforeClose(async () => { + const hasChanges = !!state.openFiles.find(f => f.parentFolder && f.hasChanges) + if (hasChanges) { + const response = await confirm('You may have unsaved changes. Are you sure you want to proceed?', 'Yes', 'Cancel') + if (!response) return false + } + await win.confirmClose() + }) + function createFile(args) { const { source, @@ -1467,26 +1488,9 @@ function canEdit({ selectedFiles }) { return files.length != 0 } -function toggleFileSelection({ fileName, source, selectedFiles }) { - let result = [] - let file = selectedFiles.find((f) => { - return f.fileName === fileName && f.source === source - }) - if (file) { - // filter file out - result = selectedFiles.filter((f) => { - return f.fileName !== fileName && f.source !== source - }) - } else { - // push file - selectedFiles.push({ fileName, source }) - } - return result -} - async function removeBoardFolder(fullPath) { // TODO: Replace with getting the file tree from the board and deleting one by one - let output = await serial.execFile('./ui/arduino/helpers.py') + let output = await serial.execFile(await getHelperFullPath()) await serial.run(`delete_folder('${fullPath}')`) } @@ -1518,7 +1522,7 @@ async function uploadFolder(srcPath, destPath, dataConsumer) { async function downloadFolder(srcPath, destPath, dataConsumer) { dataConsumer = dataConsumer || function() {} await disk.createFolder(destPath) - let output = await serial.execFile('./ui/arduino/helpers.py') + let output = await serial.execFile(await getHelperFullPath()) output = await serial.run(`ilist_all('${srcPath}')`) let files = [] try { @@ -1546,3 +1550,20 @@ async function downloadFolder(srcPath, destPath, dataConsumer) { } } } + +async function getHelperFullPath() { + const appPath = await disk.getAppPath() + if (await win.isPackaged()) { + return disk.getFullPath( + appPath, + '..', + 'ui/arduino/helpers.py' + ) + } else { + return disk.getFullPath( + appPath, + 'ui/arduino/helpers.py', + '' + ) + } +} diff --git a/ui/arduino/views/components/elements/editor.js b/ui/arduino/views/components/elements/editor.js index 81d6fcb..091c6fa 100644 --- a/ui/arduino/views/components/elements/editor.js +++ b/ui/arduino/views/components/elements/editor.js @@ -3,26 +3,43 @@ class CodeMirrorEditor extends Component { super() this.editor = null this.content = '# empty file' + this.scrollTop = 0 } + createElement(content) { + if (content) this.content = content + return html`
` + } + + load(el) { const onCodeChange = (update) => { - // console.log('code change', this.content) this.content = update.state.doc.toString() this.onChange() } this.editor = createEditor(this.content, el, onCodeChange) - } - createElement(content) { - if (content) this.content = content - return html`
` + setTimeout(() => { + this.editor.scrollDOM.addEventListener('scroll', this.updateScrollPosition.bind(this)) + this.editor.scrollDOM.scrollTo({ + top: this.scrollTop, + left: 0 + }) + }, 10) } update() { return false } + unload() { + this.editor.scrollDOM.removeEventListener('scroll', this.updateScrollPosition) + } + + updateScrollPosition(e) { + this.scrollTop = e.target.scrollTop + } + onChange() { return false } diff --git a/ui/arduino/views/components/elements/tab.js b/ui/arduino/views/components/elements/tab.js index f0070f3..6036d6b 100644 --- a/ui/arduino/views/components/elements/tab.js +++ b/ui/arduino/views/components/elements/tab.js @@ -57,7 +57,7 @@ function Tab(args) { } function selectTab(e) { - if(e.target.tagName === 'BUTTON' || e.target.tagName === 'IMG') return + if(e.target.classList.contains('close-tab')) return onSelectTab(e) } @@ -71,9 +71,9 @@ function Tab(args) {
${hasChanges ? '*' : ''} ${text}
-
-
diff --git a/ui/arduino/views/components/overlay.js b/ui/arduino/views/components/overlay.js new file mode 100644 index 0000000..1b9389c --- /dev/null +++ b/ui/arduino/views/components/overlay.js @@ -0,0 +1,16 @@ +function Overlay(state, emit) { + let overlay = html`
` + + if (state.diskFiles == null) { + emit('load-disk-files') + overlay = html`

Loading files...

` + } + + if (state.isRemoving) overlay = html`

Removing...

` + if (state.isConnecting) overlay = html`

Connecting...

` + if (state.isLoadingFiles) overlay = html`

Loading files...

` + if (state.isSaving) overlay = html`

Saving file... ${state.savingProgress}

` + if (state.isTransferring) overlay = html`

Transferring file... ${state.transferringProgress}

` + + return overlay +}