diff --git a/README.md b/README.md index 3af44b6f..757fd401 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ Then you can start the browser example again: yarn --cwd browser-app start ``` -## Arduino-PoC Electron Application +## Arduino Pro IDE Electron Application The project is built on [Azure DevOps](https://dev.azure.com/typefox/Arduino). -Currently, we provide the Arduino-PoC for the following platforms: +Currently, we provide the Arduino Pro IDE for the following platforms: - Windows, - macOS, and - Linux. diff --git a/arduino-ide-extension/package.json b/arduino-ide-extension/package.json index 50d34ff6..3294eeb4 100644 --- a/arduino-ide-extension/package.json +++ b/arduino-ide-extension/package.json @@ -1,6 +1,6 @@ { "name": "arduino-ide-extension", - "version": "0.0.1", + "version": "0.0.2", "description": "An extension for Theia building the Arduino IDE", "license": "MIT", "engines": { @@ -30,6 +30,7 @@ "react-select": "^3.0.4", "p-queue": "^5.0.0", "ps-tree": "^1.2.0", + "string-natural-compare": "^2.0.3", "tree-kill": "^1.2.1", "upath": "^1.1.2", "which": "^1.3.1" diff --git a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx index 416e5d9c..a0329334 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx +++ b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx @@ -137,7 +137,7 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C @inject(QuickOpenService) protected readonly quickOpenService: QuickOpenService; - @inject(ArduinoWorkspaceService) + @inject(ArduinoWorkspaceService) protected readonly workspaceService: ArduinoWorkspaceService; @inject(ConfigService) @@ -164,6 +164,11 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C updateStatusBar(this.boardsServiceClient.boardsConfig); this.registerSketchesInMenu(this.menuRegistry); + + Promise.all([ + this.boardsService.getAttachedBoards(), + this.boardsService.getAvailablePorts() + ]).then(([{ boards }, { ports }]) => this.boardsServiceClient.tryReconnect(boards, ports)); } registerToolbarItems(registry: TabBarToolbarRegistry): void { @@ -268,7 +273,7 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C if (!selectedPort) { throw new Error('No ports selected. Please select a port.'); } - await this.coreService.upload({ uri: uri.toString(), board: boardsConfig.selectedBoard, port: selectedPort }); + await this.coreService.upload({ uri: uri.toString(), board: boardsConfig.selectedBoard, port: selectedPort.address }); } catch (e) { await this.messageService.error(e.toString()); } finally { @@ -302,7 +307,7 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C registry.registerCommand(ArduinoCommands.OPEN_SKETCH, { isEnabled: () => true, execute: async (sketch: Sketch) => { - this.workspaceService.openSketchFilesInNewWindow(sketch.uri); + this.workspaceService.open(new URI(sketch.uri)); } }) registry.registerCommand(ArduinoCommands.SAVE_SKETCH, { @@ -321,7 +326,7 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C } const sketch = await this.sketchService.createNewSketch(uri.toString()); - this.workspaceService.openSketchFilesInNewWindow(sketch.uri); + this.workspaceService.open(new URI(sketch.uri)); } catch (e) { await this.messageService.error(e.toString()); } @@ -461,7 +466,7 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C if (destinationFile && !destinationFile.isDirectory) { const message = await this.validate(destinationFile); if (!message) { - await this.workspaceService.openSketchFilesInNewWindow(destinationFileUri.toString()); + await this.workspaceService.open(destinationFileUri); return destinationFileUri; } else { this.messageService.warn(message); diff --git a/arduino-ide-extension/src/browser/arduino-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-frontend-module.ts index 75814c07..fb2d1cf3 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-frontend-module.ts @@ -67,6 +67,9 @@ import { TabBarDecoratorService } from '@theia/core/lib/browser/shell/tab-bar-de import { ArduinoTabBarDecoratorService } from './shell/arduino-tab-bar-decorator'; import { ProblemManager } from '@theia/markers/lib/browser'; import { ArduinoProblemManager } from './markers/arduino-problem-manager'; +import { BoardsAutoInstaller } from './boards/boards-auto-installer'; +import { AboutDialog } from '@theia/core/lib/browser/about-dialog'; +import { ArduinoAboutDialog } from './customization/arduino-about-dialog'; const ElementQueries = require('css-element-queries/src/ElementQueries'); export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => { @@ -120,6 +123,10 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un return client; }).inSingletonScope(); + // boards auto-installer + bind(BoardsAutoInstaller).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(BoardsAutoInstaller); + // Boards list widget bind(BoardsListWidget).toSelf(); bindViewContribution(bind, BoardsListWidgetFrontendContribution); @@ -170,13 +177,12 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un id: MonitorWidget.ID, createWidget: () => context.container.get(MonitorWidget) })); - // Frontend binding for the monitor service. + // Frontend binding for the monitor service bind(MonitorService).toDynamicValue(context => { const connection = context.container.get(WebSocketConnectionProvider); const client = context.container.get(MonitorServiceClientImpl); return connection.createProxy(MonitorServicePath, client); }).inSingletonScope(); - // MonitorConnection bind(MonitorConnection).toSelf().inSingletonScope(); // Monitor service client to receive and delegate notifications from the backend. bind(MonitorServiceClientImpl).toSelf().inSingletonScope(); @@ -192,7 +198,7 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un const themeService = ThemeService.get(); themeService.register(...ArduinoTheme.themes); - // customizing default theia + // Customizing default Theia layout if (!ArduinoAdvancedMode.TOGGLED) { unbind(OutlineViewContribution); bind(OutlineViewContribution).to(SilentOutlineViewContribution).inSingletonScope(); @@ -213,24 +219,29 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un unbind(SearchInWorkspaceFrontendContribution); bind(SearchInWorkspaceFrontendContribution).to(SilentSearchInWorkspaceContribution).inSingletonScope(); } else { - // We use this CSS class on the body to modify the visibbility of the close button for the editors and views. + // We use this CSS class on the body to modify the visibility of the close button for the editors and views. document.body.classList.add(ArduinoAdvancedMode.LS_ID); } unbind(FrontendApplication); bind(FrontendApplication).to(ArduinoFrontendApplication).inSingletonScope(); - // monaco customizations + // Monaco customizations unbind(MonacoEditorProvider); bind(ArduinoMonacoEditorProvider).toSelf().inSingletonScope(); bind(MonacoEditorProvider).toService(ArduinoMonacoEditorProvider); - // decorator customizations + // Decorator customizations unbind(TabBarDecoratorService); bind(ArduinoTabBarDecoratorService).toSelf().inSingletonScope(); bind(TabBarDecoratorService).toService(ArduinoTabBarDecoratorService); - // problem markers + // Problem markers unbind(ProblemManager); bind(ArduinoProblemManager).toSelf().inSingletonScope(); bind(ProblemManager).toService(ArduinoProblemManager); + + // About dialog to show the CLI version + unbind(AboutDialog); + bind(ArduinoAboutDialog).toSelf().inSingletonScope(); + bind(AboutDialog).toService(ArduinoAboutDialog); }); diff --git a/arduino-ide-extension/src/browser/arduino-workspace-resolver.ts b/arduino-ide-extension/src/browser/arduino-workspace-resolver.ts new file mode 100644 index 00000000..3ee087c5 --- /dev/null +++ b/arduino-ide-extension/src/browser/arduino-workspace-resolver.ts @@ -0,0 +1,68 @@ +import { toUnix } from 'upath'; +import URI from '@theia/core/lib/common/uri'; +import { isWindows } from '@theia/core/lib/common/os'; +import { notEmpty } from '@theia/core/lib/common/objects'; +import { MaybePromise } from '@theia/core/lib/common/types'; + +/** + * Class for determining the default workspace location from the + * `location.hash`, the historical workspace locations, and recent sketch files. + * + * The following logic is used for determining the default workspace location: + * - `hash` points to an exists in location? + * - Yes + * - `validate location`. Is valid sketch location? + * - Yes + * - Done. + * - No + * - `try open recent workspace roots`, then `try open last modified sketches`, finally `create new sketch`. + * - No + * - `try open recent workspace roots`, then `try open last modified sketches`, finally `create new sketch`. + */ +namespace ArduinoWorkspaceRootResolver { + export interface InitOptions { + readonly isValid: (uri: string) => MaybePromise; + } + export interface ResolveOptions { + readonly hash?: string + readonly recentWorkspaces: string[]; + // Gathered from the default sketch folder. The default sketch folder is defined by the CLI. + readonly recentSketches: string[]; + } +} +export class ArduinoWorkspaceRootResolver { + + constructor(protected options: ArduinoWorkspaceRootResolver.InitOptions) { + } + + async resolve(options: ArduinoWorkspaceRootResolver.ResolveOptions): Promise<{ uri: string } | undefined> { + const { hash, recentWorkspaces, recentSketches } = options; + for (const uri of [this.hashToUri(hash), ...recentWorkspaces, ...recentSketches].filter(notEmpty)) { + const valid = await this.isValid(uri); + if (valid) { + return { uri }; + } + } + return undefined; + } + + protected isValid(uri: string): MaybePromise { + return this.options.isValid.bind(this)(uri); + } + + // Note: here, the `hash` was defined as new `URI(yourValidFsPath).path` so we have to map it to a valid FS path first. + // This is important for Windows only and a NOOP on POSIX. + // Note: we set the `new URI(myValidUri).path.toString()` as the `hash`. See: + // - https://github.com/eclipse-theia/theia/blob/8196e9dcf9c8de8ea0910efeb5334a974f426966/packages/workspace/src/browser/workspace-service.ts#L143 and + // - https://github.com/eclipse-theia/theia/blob/8196e9dcf9c8de8ea0910efeb5334a974f426966/packages/workspace/src/browser/workspace-service.ts#L423 + protected hashToUri(hash: string | undefined): string | undefined { + if (hash + && hash.length > 1 + && hash.startsWith('#')) { + const path = hash.slice(1); // Trim the leading `#`. + return new URI(toUnix(path.slice(isWindows && hash.startsWith('/') ? 1 : 0))).withScheme('file').toString(); + } + return undefined; + } + +} \ No newline at end of file diff --git a/arduino-ide-extension/src/browser/arduino-workspace-service.ts b/arduino-ide-extension/src/browser/arduino-workspace-service.ts index 4e7d8d52..9b3a6f8d 100644 --- a/arduino-ide-extension/src/browser/arduino-workspace-service.ts +++ b/arduino-ide-extension/src/browser/arduino-workspace-service.ts @@ -1,17 +1,11 @@ import { injectable, inject } from 'inversify'; -import { toUnix } from 'upath'; -import URI from '@theia/core/lib/common/uri'; -import { isWindows } from '@theia/core/lib/common/os'; import { LabelProvider } from '@theia/core/lib/browser'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { ConfigService } from '../common/protocol/config-service'; import { SketchesService } from '../common/protocol/sketches-service'; +import { ArduinoWorkspaceRootResolver } from './arduino-workspace-resolver'; import { ArduinoAdvancedMode } from './arduino-frontend-contribution'; -/** - * This is workaround to have custom frontend binding for the default workspace, although we - * already have a custom binding for the backend. - */ @injectable() export class ArduinoWorkspaceService extends WorkspaceService { @@ -25,105 +19,38 @@ export class ArduinoWorkspaceService extends WorkspaceService { protected readonly labelProvider: LabelProvider; async getDefaultWorkspacePath(): Promise { - const url = new URL(window.location.href); - // If `sketch` is set and valid, we use it as is. - // `sketch` is set as an encoded URI string. - const sketch = url.searchParams.get('sketch'); - if (sketch) { - const sketchDirUri = new URI(sketch).toString(); - if (await this.sketchService.isSketchFolder(sketchDirUri)) { - if (await this.configService.isInSketchDir(sketchDirUri)) { - if (ArduinoAdvancedMode.TOGGLED) { - return (await this.configService.getConfiguration()).sketchDirUri - } else { - return sketchDirUri; - } - } - return (await this.configService.getConfiguration()).sketchDirUri - } + const [hash, recentWorkspaces, recentSketches] = await Promise.all([ + window.location.hash, + this.sketchService.getSketches().then(sketches => sketches.map(({ uri }) => uri)), + this.server.getRecentWorkspaces() + ]); + const toOpen = await new ArduinoWorkspaceRootResolver({ + isValid: this.isValid.bind(this) + }).resolve({ + hash, + recentWorkspaces, + recentSketches + }); + if (toOpen) { + const { uri } = toOpen; + await this.server.setMostRecentlyUsedWorkspace(uri); + return toOpen.uri; } - - const { hash } = window.location; - // Note: here, the `uriPath` was defined as new `URI(yourValidFsPath).path` so we have to map it to a valid FS path first. - // This is important for Windows only and a NOOP on UNIX. - if (hash.length > 1 && hash.startsWith('#')) { - let uri = this.toUri(hash.slice(1)); - if (uri && await this.sketchService.isSketchFolder(uri)) { - return this.openSketchFilesInNewWindow(uri); - } - } - - // If we cannot acquire the FS path from the `location.hash` we try to get the most recently used workspace that was a valid sketch folder. - // XXX: Check if `WorkspaceServer#getRecentWorkspaces()` returns with inverse-chrolonolgical order. - const candidateUris = await this.server.getRecentWorkspaces(); - for (const uri of candidateUris) { - if (await this.sketchService.isSketchFolder(uri)) { - return this.openSketchFilesInNewWindow(uri); - } - } - - const config = await this.configService.getConfiguration(); - const { sketchDirUri } = config; - const stat = await this.fileSystem.getFileStat(sketchDirUri); - if (!stat) { - // The folder for the workspace root does not exist yet, create it. - await this.fileSystem.createFolder(sketchDirUri); - await this.sketchService.createNewSketch(sketchDirUri); - } - - const sketches = await this.sketchService.getSketches(sketchDirUri); - if (!sketches.length) { - const sketch = await this.sketchService.createNewSketch(sketchDirUri); - sketches.unshift(sketch); - } - - const uri = sketches[0].uri; - this.server.setMostRecentlyUsedWorkspace(uri); - this.openSketchFilesInNewWindow(uri); - if (ArduinoAdvancedMode.TOGGLED && await this.configService.isInSketchDir(uri)) { - return (await this.configService.getConfiguration()).sketchDirUri; - } - return uri; + return (await this.sketchService.createNewSketch()).uri; } - private toUri(uriPath: string | undefined): string | undefined { - if (uriPath) { - return new URI(toUnix(uriPath.slice(isWindows && uriPath.startsWith('/') ? 1 : 0))).withScheme('file').toString(); + private async isValid(uri: string): Promise { + const exists = await this.fileSystem.exists(uri); + if (!exists) { + return false; } - return undefined; - } - - async openSketchFilesInNewWindow(uri: string): Promise { - const url = new URL(window.location.href); - const currentSketch = url.searchParams.get('sketch'); - // Nothing to do if we want to open the same sketch which is already opened. - const sketchUri = new URI(uri); - if (!!currentSketch && new URI(currentSketch).toString() === sketchUri.toString()) { - return uri; + // The workspace root location must exist. However, when opening a workspace root in pro-mode, + // the workspace root must not be a sketch folder. It can be the default sketch directory, or any other directories, for instance. + if (!ArduinoAdvancedMode.TOGGLED) { + return true; } - - url.searchParams.set('sketch', uri); - // If in advanced mode, we root folder of all sketch folders as the hash, so the default workspace will be opened on the root - // Note: we set the `new URI(myValidUri).path.toString()` as the `hash`. See: - // - https://github.com/eclipse-theia/theia/blob/8196e9dcf9c8de8ea0910efeb5334a974f426966/packages/workspace/src/browser/workspace-service.ts#L143 and - // - https://github.com/eclipse-theia/theia/blob/8196e9dcf9c8de8ea0910efeb5334a974f426966/packages/workspace/src/browser/workspace-service.ts#L423 - if (ArduinoAdvancedMode.TOGGLED && await this.configService.isInSketchDir(uri)) { - url.hash = new URI((await this.configService.getConfiguration()).sketchDirUri).path.toString(); - } else { - // Otherwise, we set the hash as is - const hash = await this.fileSystem.getFsPath(sketchUri.toString()); - if (hash) { - url.hash = sketchUri.path.toString() - } - } - - // Preserve the current window if the `sketch` is not in the `searchParams`. - if (!currentSketch) { - setTimeout(() => window.location.href = url.toString(), 100); - return uri; - } - this.windowService.openNewWindow(url.toString()); - return uri; + const sketchFolder = await this.sketchService.isSketchFolder(uri); + return sketchFolder; } } diff --git a/arduino-ide-extension/src/browser/boards/boards-auto-installer.ts b/arduino-ide-extension/src/browser/boards/boards-auto-installer.ts new file mode 100644 index 00000000..2ee282d6 --- /dev/null +++ b/arduino-ide-extension/src/browser/boards/boards-auto-installer.ts @@ -0,0 +1,63 @@ +import { injectable, inject } from 'inversify'; +import { MessageService } from '@theia/core/lib/common/message-service'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { BoardsService, Board } from '../../common/protocol/boards-service'; +import { BoardsServiceClientImpl } from './boards-service-client-impl'; +import { BoardsListWidgetFrontendContribution } from './boards-widget-frontend-contribution'; +import { InstallationProgressDialog } from '../components/installation-progress-dialog'; +import { BoardsConfig } from './boards-config'; + + +/** + * Listens on `BoardsConfig.Config` changes, if a board is selected which does not + * have the corresponding core installed, it proposes the user to install the core. + */ +@injectable() +export class BoardsAutoInstaller implements FrontendApplicationContribution { + + @inject(MessageService) + protected readonly messageService: MessageService; + + @inject(BoardsService) + protected readonly boardsService: BoardsService; + + @inject(BoardsServiceClientImpl) + protected readonly boardsServiceClient: BoardsServiceClientImpl; + + @inject(BoardsListWidgetFrontendContribution) + protected readonly boardsManagerFrontendContribution: BoardsListWidgetFrontendContribution; + + onStart(): void { + this.boardsServiceClient.onBoardsConfigChanged(this.ensureCoreExists.bind(this)); + this.ensureCoreExists(this.boardsServiceClient.boardsConfig); + } + + protected ensureCoreExists(config: BoardsConfig.Config): void { + const { selectedBoard } = config; + if (selectedBoard) { + this.boardsService.search({}).then(({ items }) => { + const candidates = items + .filter(item => item.boards.some(board => Board.sameAs(board, selectedBoard))) + .filter(({ installable, installedVersion }) => installable && !installedVersion); + for (const candidate of candidates) { + // tslint:disable-next-line:max-line-length + this.messageService.info(`The \`"${candidate.name}"\` core has to be installed for the currently selected \`"${selectedBoard.name}"\` board. Do you want to install it now?`, 'Yes', 'Install Manually').then(async answer => { + if (answer === 'Yes') { + const dialog = new InstallationProgressDialog(candidate.name); + dialog.open(); + try { + await this.boardsService.install(candidate); + } finally { + dialog.close(); + } + } + if (answer) { + this.boardsManagerFrontendContribution.openView({ reveal: true }).then(widget => widget.refresh(candidate.name.toLocaleLowerCase())); + } + }); + } + }) + } + } + +} \ No newline at end of file diff --git a/arduino-ide-extension/src/browser/boards/boards-config.tsx b/arduino-ide-extension/src/browser/boards/boards-config.tsx index 3e294eb0..fef02883 100644 --- a/arduino-ide-extension/src/browser/boards/boards-config.tsx +++ b/arduino-ide-extension/src/browser/boards/boards-config.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; import { DisposableCollection } from '@theia/core'; -import { BoardsService, Board, AttachedSerialBoard, AttachedBoardsChangeEvent } from '../../common/protocol/boards-service'; +import { BoardsService, Board, Port, AttachedSerialBoard, AttachedBoardsChangeEvent } from '../../common/protocol/boards-service'; import { BoardsServiceClientImpl } from './boards-service-client-impl'; export namespace BoardsConfig { export interface Config { selectedBoard?: Board; - selectedPort?: string; + selectedPort?: Port; } export interface Props { @@ -19,7 +19,8 @@ export namespace BoardsConfig { export interface State extends Config { searchResults: Array; - knownPorts: string[]; + knownPorts: Port[]; + showAllPorts: boolean; } } @@ -47,7 +48,7 @@ export abstract class Item extends React.Component<{ {label} {!detail ? '' :
{detail}
} - {!selected ? '' :
} + {!selected ? '' :
} ; } @@ -68,16 +69,17 @@ export class BoardsConfig extends React.Component this.updatePorts(boards)); + this.props.boardsService.getAvailablePorts().then(({ ports }) => this.updatePorts(ports)); const { boardsServiceClient: client } = this.props; this.toDispose.pushAll([ - client.onBoardsChanged(event => this.updatePorts(event.newState.boards, AttachedBoardsChangeEvent.diff(event).detached)), + client.onBoardsChanged(event => this.updatePorts(event.newState.ports, AttachedBoardsChangeEvent.diff(event).detached.ports)), client.onBoardsConfigChanged(({ selectedBoard, selectedPort }) => { this.setState({ selectedBoard, selectedPort }, () => this.fireConfigChanged()); }) @@ -101,11 +103,11 @@ export class BoardsConfig extends React.Component this.setState({ searchResults })); } - protected updatePorts = (boards: Board[] = [], detachedBoards: Board[] = []) => { - this.queryPorts(Promise.resolve({ boards })).then(({ knownPorts }) => { + protected updatePorts = (ports: Port[] = [], removedPorts: Port[] = []) => { + this.queryPorts(Promise.resolve({ ports })).then(({ knownPorts }) => { let { selectedPort } = this.state; - const removedPorts = detachedBoards.filter(AttachedSerialBoard.is).map(({ port }) => port); - if (!!selectedPort && removedPorts.indexOf(selectedPort) !== -1) { + // If the currently selected port is not available anymore, unset the selected port. + if (removedPorts.some(port => Port.equals(port, selectedPort))) { selectedPort = undefined; } this.setState({ knownPorts, selectedPort }, () => this.fireConfigChanged()); @@ -130,18 +132,24 @@ export class BoardsConfig extends React.Component = this.attachedBoards) => { - return new Promise<{ knownPorts: string[] }>(resolve => { - attachedBoards - .then(({ boards }) => boards - .filter(AttachedSerialBoard.is) - .map(({ port }) => port) - .sort()) + protected get availablePorts(): Promise<{ ports: Port[] }> { + return this.props.boardsService.getAvailablePorts(); + } + + protected queryPorts = (availablePorts: Promise<{ ports: Port[] }> = this.availablePorts) => { + return new Promise<{ knownPorts: Port[] }>(resolve => { + availablePorts + .then(({ ports }) => ports + .sort(Port.compare)) .then(knownPorts => resolve({ knownPorts })); }); } - protected selectPort = (selectedPort: string | undefined) => { + protected toggleFilterPorts = () => { + this.setState({ showAllPorts: !this.state.showAllPorts }); + } + + protected selectPort = (selectedPort: Port | undefined) => { this.setState({ selectedPort }, () => this.fireConfigChanged()); } @@ -156,17 +164,20 @@ export class BoardsConfig extends React.Component {this.renderContainer('boards', this.renderBoards.bind(this))} - {this.renderContainer('ports', this.renderPorts.bind(this))} + {this.renderContainer('ports', this.renderPorts.bind(this), this.renderPortsFooter.bind(this))} ; } - protected renderContainer(title: string, contentRenderer: () => React.ReactNode): React.ReactNode { + protected renderContainer(title: string, contentRenderer: () => React.ReactNode, footerRenderer?: () => React.ReactNode): React.ReactNode { return
{title}
{contentRenderer()} +
+ {(footerRenderer ? footerRenderer() : '')} +
; } @@ -214,7 +225,9 @@ export class BoardsConfig extends React.Component true : Port.isBoardPort; + const ports = this.state.knownPorts.filter(filter); + return !ports.length ? (
No ports discovered @@ -222,17 +235,31 @@ export class BoardsConfig extends React.Component - {this.state.knownPorts.map(port => - key={port} + {ports.map(port => + key={Port.toString(port)} item={port} - label={port} - selected={this.state.selectedPort === port} + label={Port.toString(port)} + selected={Port.equals(this.state.selectedPort, port)} onClick={this.selectPort} />)}
); } + protected renderPortsFooter(): React.ReactNode { + return
+ +
; + } + } export namespace BoardsConfig { @@ -244,7 +271,7 @@ export namespace BoardsConfig { if (AttachedSerialBoard.is(other)) { return !!selectedBoard && Board.equals(other, selectedBoard) - && selectedPort === other.port; + && Port.sameAs(selectedPort, other.port); } return sameAs(config, other); } @@ -260,7 +287,7 @@ export namespace BoardsConfig { return options.default; } const { name } = selectedBoard; - return `${name}${port ? ' at ' + port : ''}`; + return `${name}${port ? ' at ' + Port.toString(port) : ''}`; } } diff --git a/arduino-ide-extension/src/browser/boards/boards-service-client-impl.ts b/arduino-ide-extension/src/browser/boards/boards-service-client-impl.ts index 3ceb5d8f..e2ac6a80 100644 --- a/arduino-ide-extension/src/browser/boards/boards-service-client-impl.ts +++ b/arduino-ide-extension/src/browser/boards/boards-service-client-impl.ts @@ -1,8 +1,10 @@ import { injectable, inject, postConstruct } from 'inversify'; -import { Emitter, ILogger } from '@theia/core'; -import { BoardsServiceClient, AttachedBoardsChangeEvent, BoardInstalledEvent, AttachedSerialBoard } from '../../common/protocol/boards-service'; +import { Emitter } from '@theia/core/lib/common/event'; +import { ILogger } from '@theia/core/lib/common/logger'; +import { LocalStorageService } from '@theia/core/lib/browser/storage-service'; +import { RecursiveRequired } from '../../common/types'; +import { BoardsServiceClient, AttachedBoardsChangeEvent, BoardInstalledEvent, AttachedSerialBoard, Board, Port } from '../../common/protocol/boards-service'; import { BoardsConfig } from './boards-config'; -import { LocalStorageService } from '@theia/core/lib/browser'; @injectable() export class BoardsServiceClientImpl implements BoardsServiceClient { @@ -13,10 +15,18 @@ export class BoardsServiceClientImpl implements BoardsServiceClient { @inject(LocalStorageService) protected storageService: LocalStorageService; - protected readonly onAttachedBoardsChangedEmitter = new Emitter(); protected readonly onBoardInstalledEmitter = new Emitter(); + protected readonly onAttachedBoardsChangedEmitter = new Emitter(); protected readonly onSelectedBoardsConfigChangedEmitter = new Emitter(); + /** + * Used for the auto-reconnecting. Sometimes, the attached board gets disconnected after uploading something to it. + * It happens with certain boards on Windows. For example, the `MKR1000` boards is selected on post `COM5` on Windows, + * perform an upload, the board automatically disconnects and reconnects, but on another port, `COM10`. + * We have to listen on such changes and auto-reconnect the same board on another port. + * See: https://arduino.slack.com/archives/CJJHJCJSJ/p1568645417013000?thread_ts=1568640504.009400&cid=CJJHJCJSJ + */ + protected latestValidBoardsConfig: RecursiveRequired | undefined = undefined; protected _boardsConfig: BoardsConfig.Config = {}; readonly onBoardsChanged = this.onAttachedBoardsChangedEmitter.event; @@ -29,17 +39,47 @@ export class BoardsServiceClientImpl implements BoardsServiceClient { } notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void { - this.logger.info('Attached boards changed: ', JSON.stringify(event)); - const detachedBoards = AttachedBoardsChangeEvent.diff(event).detached.filter(AttachedSerialBoard.is).map(({ port }) => port); + this.logger.info('Attached boards and available ports changed: ', JSON.stringify(event)); + const { detached, attached } = AttachedBoardsChangeEvent.diff(event); const { selectedPort, selectedBoard } = this.boardsConfig; this.onAttachedBoardsChangedEmitter.fire(event); - // Dynamically unset the port if the selected board was an attached one and we detached it. - if (!!selectedPort && detachedBoards.indexOf(selectedPort) !== -1) { + // Dynamically unset the port if is not available anymore. A port can be "detached" when removing a board. + if (detached.ports.some(port => Port.equals(selectedPort, port))) { this.boardsConfig = { selectedBoard, selectedPort: undefined }; } + // Try to reconnect. + this.tryReconnect(attached.boards, attached.ports); + } + + async tryReconnect(attachedBoards: Board[], availablePorts: Port[]): Promise { + if (this.latestValidBoardsConfig && !this.canUploadTo(this.boardsConfig)) { + for (const board of attachedBoards.filter(AttachedSerialBoard.is)) { + if (this.latestValidBoardsConfig.selectedBoard.fqbn === board.fqbn + && this.latestValidBoardsConfig.selectedBoard.name === board.name + && Port.sameAs(this.latestValidBoardsConfig.selectedPort, board.port)) { + + this.boardsConfig = this.latestValidBoardsConfig; + return true; + } + } + // If we could not find an exact match, we compare the board FQBN-name pairs and ignore the port, as it might have changed. + // See documentation on `latestValidBoardsConfig`. + for (const board of attachedBoards.filter(AttachedSerialBoard.is)) { + if (this.latestValidBoardsConfig.selectedBoard.fqbn === board.fqbn + && this.latestValidBoardsConfig.selectedBoard.name === board.name) { + + this.boardsConfig = { + ...this.latestValidBoardsConfig, + selectedPort: availablePorts.find(port => Port.sameAs(port, board.port)) + }; + return true; + } + } + } + return false; } notifyBoardInstalled(event: BoardInstalledEvent): void { @@ -50,6 +90,9 @@ export class BoardsServiceClientImpl implements BoardsServiceClient { set boardsConfig(config: BoardsConfig.Config) { this.logger.info('Board config changed: ', JSON.stringify(config)); this._boardsConfig = config; + if (this.canUploadTo(this._boardsConfig)) { + this.latestValidBoardsConfig = this._boardsConfig; + } this.saveState().then(() => this.onSelectedBoardsConfigChangedEmitter.fire(this._boardsConfig)); } @@ -58,14 +101,22 @@ export class BoardsServiceClientImpl implements BoardsServiceClient { } protected saveState(): Promise { - return this.storageService.setData('boards-config', this.boardsConfig); + return this.storageService.setData('latest-valid-boards-config', this.latestValidBoardsConfig); } protected async loadState(): Promise { - const boardsConfig = await this.storageService.getData('boards-config'); - if (boardsConfig) { - this.boardsConfig = boardsConfig; + const storedValidBoardsConfig = await this.storageService.getData>('latest-valid-boards-config'); + if (storedValidBoardsConfig) { + this.latestValidBoardsConfig = storedValidBoardsConfig; } } + protected canVerify(config: BoardsConfig.Config | undefined): config is BoardsConfig.Config & { selectedBoard: Board } { + return !!config && !!config.selectedBoard; + } + + protected canUploadTo(config: BoardsConfig.Config | undefined): config is RecursiveRequired { + return this.canVerify(config) && !!config.selectedPort && !!config.selectedBoard.fqbn; + } + } diff --git a/arduino-ide-extension/src/browser/boards/boards-toolbar-item.tsx b/arduino-ide-extension/src/browser/boards/boards-toolbar-item.tsx index aca66750..7afea70e 100644 --- a/arduino-ide-extension/src/browser/boards/boards-toolbar-item.tsx +++ b/arduino-ide-extension/src/browser/boards/boards-toolbar-item.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { CommandRegistry, DisposableCollection } from '@theia/core'; -import { BoardsService, Board, AttachedSerialBoard } from '../../common/protocol/boards-service'; +import { BoardsService, Board, AttachedSerialBoard, Port } from '../../common/protocol/boards-service'; import { ArduinoCommands } from '../arduino-commands'; import { BoardsServiceClientImpl } from './boards-service-client-impl'; import { BoardsConfig } from './boards-config'; @@ -88,6 +88,7 @@ export namespace BoardsToolBarItem { export interface State { boardsConfig: BoardsConfig.Config; attachedBoards: Board[]; + availablePorts: Port[]; coords: BoardsDropDownListCoords | 'hidden'; } } @@ -104,6 +105,7 @@ export class BoardsToolBarItem extends React.Component this.setState({ boardsConfig })), - client.onBoardsChanged(({ newState }) => this.setState({ attachedBoards: newState.boards })) + client.onBoardsChanged(({ newState }) => this.setState({ attachedBoards: newState.boards, availablePorts: newState.ports })) ]); - boardService.getAttachedBoards().then(({ boards: attachedBoards }) => { - this.setState({ attachedBoards }) + Promise.all([ + boardService.getAttachedBoards(), + boardService.getAvailablePorts() + ]).then(([{boards: attachedBoards}, { ports: availablePorts }]) => { + this.setState({ attachedBoards, availablePorts }) }); } @@ -149,29 +154,32 @@ export class BoardsToolBarItem extends React.Component availablePorts.some(port => Port.sameAs(port, board.port))) .filter(board => BoardsConfig.Config.sameAs(boardsConfig, board)).shift(); const items = attachedBoards.filter(AttachedSerialBoard.is).map(board => ({ label: `${board.name} at ${board.port}`, selected: configuredBoard === board, - onClick: () => this.props.boardsServiceClient.boardsConfig = { - selectedBoard: board, - selectedPort: board.port + onClick: () => { + this.props.boardsServiceClient.boardsConfig = { + selectedBoard: board, + selectedPort: availablePorts.find(port => Port.sameAs(port, board.port)) + } } })); return
-
+
- {boardsConfigText} + {title}
diff --git a/arduino-ide-extension/src/browser/components/component-list/filterable-list-container.tsx b/arduino-ide-extension/src/browser/components/component-list/filterable-list-container.tsx index db93c9dd..bb7ea3aa 100644 --- a/arduino-ide-extension/src/browser/components/component-list/filterable-list-container.tsx +++ b/arduino-ide-extension/src/browser/components/component-list/filterable-list-container.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import debounce = require('lodash.debounce'); +import { Event } from '@theia/core/lib/common/event'; import { Searchable } from '../../../common/protocol/searchable'; import { Installable } from '../../../common/protocol/installable'; import { InstallationProgressDialog } from '../installation-progress-dialog'; @@ -20,6 +21,7 @@ export class FilterableListContainer extends React.Component extends React.Component { const { items } = result; @@ -97,6 +99,7 @@ export namespace FilterableListContainer { readonly itemRenderer: ListItemRenderer; readonly resolveContainer: (element: HTMLElement) => void; readonly resolveFocus: (element: HTMLElement | undefined) => void; + readonly filterTextChangeEvent: Event; } export interface State { diff --git a/arduino-ide-extension/src/browser/components/component-list/list-widget.tsx b/arduino-ide-extension/src/browser/components/component-list/list-widget.tsx index 897a76ff..376c4de2 100644 --- a/arduino-ide-extension/src/browser/components/component-list/list-widget.tsx +++ b/arduino-ide-extension/src/browser/components/component-list/list-widget.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { injectable, postConstruct } from 'inversify'; import { Message } from '@phosphor/messaging'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { Emitter } from '@theia/core/lib/common/event'; import { MaybePromise } from '@theia/core/lib/common/types'; import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget'; import { Installable } from '../../../common/protocol/installable'; @@ -17,6 +18,7 @@ export abstract class ListWidget extends ReactWidget { */ protected focusNode: HTMLElement | undefined; protected readonly deferredContainer = new Deferred(); + protected readonly filterTextChangeEmitter = new Emitter(); constructor(protected options: ListWidget.Options) { super(); @@ -31,6 +33,7 @@ export abstract class ListWidget extends ReactWidget { this.scrollOptions = { suppressScrollX: true } + this.toDispose.push(this.filterTextChangeEmitter); } @postConstruct() @@ -63,7 +66,12 @@ export abstract class ListWidget extends ReactWidget { searchable={this.options.searchable} installable={this.options.installable} itemLabel={this.options.itemLabel} - itemRenderer={this.options.itemRenderer} />; + itemRenderer={this.options.itemRenderer} + filterTextChangeEvent={this.filterTextChangeEmitter.event}/>; + } + + refresh(filterText: string): void { + this.deferredContainer.promise.then(() => this.filterTextChangeEmitter.fire(filterText)); } } diff --git a/arduino-ide-extension/src/browser/customization/arduino-about-dialog.ts b/arduino-ide-extension/src/browser/customization/arduino-about-dialog.ts new file mode 100644 index 00000000..e9f4ae3f --- /dev/null +++ b/arduino-ide-extension/src/browser/customization/arduino-about-dialog.ts @@ -0,0 +1,25 @@ +import { injectable, inject, postConstruct } from 'inversify'; +import { AboutDialog, ABOUT_CONTENT_CLASS } from '@theia/core/lib/browser/about-dialog'; +import { ConfigService } from '../../common/protocol/config-service'; + +@injectable() +export class ArduinoAboutDialog extends AboutDialog { + + @inject(ConfigService) + protected readonly configService: ConfigService; + + @postConstruct() + protected async init(): Promise { + const [, version] = await Promise.all([super.init(), this.configService.getVersion()]); + if (version) { + const { firstChild } = this.contentNode; + if (firstChild instanceof HTMLElement && firstChild.classList.contains(ABOUT_CONTENT_CLASS)) { + const cliVersion = document.createElement('div'); + cliVersion.textContent = version; + firstChild.appendChild(cliVersion); + // TODO: anchor to the commit in the `arduino-cli` repository. + } + } + } + +} diff --git a/arduino-ide-extension/src/browser/customization/arduino-frontend-application.ts b/arduino-ide-extension/src/browser/customization/arduino-frontend-application.ts index 58dba3b1..600b4943 100644 --- a/arduino-ide-extension/src/browser/customization/arduino-frontend-application.ts +++ b/arduino-ide-extension/src/browser/customization/arduino-frontend-application.ts @@ -1,24 +1,38 @@ import { injectable, inject } from 'inversify'; -import { FileSystem } from '@theia/filesystem/lib/common'; -import { FrontendApplication } from '@theia/core/lib/browser'; -import { ArduinoFrontendContribution } from '../arduino-frontend-contribution'; +import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { FrontendApplication } from '@theia/core/lib/browser/frontend-application'; +import { ArduinoFrontendContribution, ArduinoAdvancedMode } from '../arduino-frontend-contribution'; @injectable() export class ArduinoFrontendApplication extends FrontendApplication { - @inject(ArduinoFrontendContribution) - protected readonly frontendContribution: ArduinoFrontendContribution; - @inject(FileSystem) protected readonly fileSystem: FileSystem; + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + @inject(ArduinoFrontendContribution) + protected readonly frontendContribution: ArduinoFrontendContribution; + protected async initializeLayout(): Promise { - await super.initializeLayout(); - const location = new URL(window.location.href); - const sketchPath = location.searchParams.get('sketch'); - if (sketchPath && await this.fileSystem.exists(sketchPath)) { - this.frontendContribution.openSketchFiles(decodeURIComponent(sketchPath)); - } + super.initializeLayout().then(() => { + // If not in PRO mode, we open the sketch file with all the related files. + // Otherwise, we reuse the workbench's restore functionality and we do not open anything at all. + // TODO: check `otherwise`. Also, what if we check for opened editors, instead of blindly opening them? + if (!ArduinoAdvancedMode.TOGGLED) { + this.workspaceService.roots.then(roots => { + for (const root of roots) { + this.fileSystem.exists(root.uri).then(exists => { + if (exists) { + this.frontendContribution.openSketchFiles(root.uri); + } + }); + } + }); + } + }); } } diff --git a/arduino-ide-extension/src/browser/monitor/monitor-widget.tsx b/arduino-ide-extension/src/browser/monitor/monitor-widget.tsx index 84425bff..f9e5e128 100644 --- a/arduino-ide-extension/src/browser/monitor/monitor-widget.tsx +++ b/arduino-ide-extension/src/browser/monitor/monitor-widget.tsx @@ -268,7 +268,7 @@ export class MonitorWidget extends ReactWidget implements StatefulWidget { return { baudRate, board: selectedBoard, - port: selectedPort + port: selectedPort.address } } diff --git a/arduino-ide-extension/src/browser/style/board-select-dialog.css b/arduino-ide-extension/src/browser/style/board-select-dialog.css index ba87b580..76422226 100644 --- a/arduino-ide-extension/src/browser/style/board-select-dialog.css +++ b/arduino-ide-extension/src/browser/style/board-select-dialog.css @@ -73,11 +73,18 @@ div#select-board-dialog .selectBoardContainer .body .list .item.selected i{ text-transform: uppercase; } +#select-board-dialog .selectBoardContainer .body .container .content .footer { + padding: 10px 5px 10px 0px; +} + #select-board-dialog .selectBoardContainer .body .container .content .loading { font-size: var(--theia-ui-font-size1); color: #7f8c8d; padding: 10px 5px 10px 10px; text-transform: uppercase; + /* The max, min-height comes from `.body .list` 265px + 47px top padding - 2 * 10px top padding */ + max-height: 292px; + min-height: 292px; } #select-board-dialog .selectBoardContainer .body .list .item { @@ -209,6 +216,7 @@ button.theia-button.main { .arduino-boards-dropdown-item .fa-check { color: var(--theia-accent-color1); + align-self: center; } .arduino-boards-dropdown-item.selected, diff --git a/arduino-ide-extension/src/common/protocol/boards-service.ts b/arduino-ide-extension/src/common/protocol/boards-service.ts index e59a00b4..5c0a662c 100644 --- a/arduino-ide-extension/src/common/protocol/boards-service.ts +++ b/arduino-ide-extension/src/common/protocol/boards-service.ts @@ -1,23 +1,42 @@ -import { JsonRpcServer } from '@theia/core'; +import { isWindows, isOSX } from '@theia/core/lib/common/os'; +import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; import { Searchable } from './searchable'; import { Installable } from './installable'; import { ArduinoComponent } from './arduino-component'; +const naturalCompare: (left: string, right: string) => number = require('string-natural-compare').caseInsensitive; export interface AttachedBoardsChangeEvent { - readonly oldState: Readonly<{ boards: Board[] }>; - readonly newState: Readonly<{ boards: Board[] }>; + readonly oldState: Readonly<{ boards: Board[], ports: Port[] }>; + readonly newState: Readonly<{ boards: Board[], ports: Port[] }>; } export namespace AttachedBoardsChangeEvent { - export function diff(event: AttachedBoardsChangeEvent): Readonly<{ attached: Board[], detached: Board[] }> { + export function diff(event: AttachedBoardsChangeEvent): Readonly<{ + attached: { + boards: Board[], + ports: Port[] + }, + detached: { + boards: Board[], + ports: Port[] + } + }> { const diff = (left: T[], right: T[]) => { return left.filter(item => right.indexOf(item) === -1); } const { boards: newBoards } = event.newState; const { boards: oldBoards } = event.oldState; + const { ports: newPorts } = event.newState; + const { ports: oldPorts } = event.oldState; return { - detached: diff(oldBoards, newBoards), - attached: diff(newBoards, oldBoards) + detached: { + boards: diff(oldBoards, newBoards), + ports: diff(oldPorts, newPorts) + }, + attached: { + boards: diff(newBoards, oldBoards), + ports: diff(newPorts, oldPorts) + } }; } @@ -37,6 +56,114 @@ export const BoardsServicePath = '/services/boards-service'; export const BoardsService = Symbol('BoardsService'); export interface BoardsService extends Installable, Searchable, JsonRpcServer { getAttachedBoards(): Promise<{ boards: Board[] }>; + getAvailablePorts(): Promise<{ ports: Port[] }>; +} + +export interface Port { + readonly address: string; + readonly protocol: Port.Protocol; + /** + * Optional label for the protocol. For example: `Serial Port (USB)`. + */ + readonly label?: string; +} +export namespace Port { + + export type Protocol = 'serial' | 'network' | 'unknown'; + export namespace Protocol { + export function toProtocol(protocol: string | undefined): Protocol { + if (protocol === 'serial') { + return 'serial'; + } else if (protocol === 'network') { + return 'network'; + } else { + return 'unknown'; + } + } + } + + export function toString(port: Port, options: { useLabel: boolean } = { useLabel: false }): string { + if (options.useLabel && port.label) { + return `${port.address} ${port.label}` + } + return port.address; + } + + export function compare(left: Port, right: Port): number { + // Board ports have higher priorities, they come first. + if (isBoardPort(left) && !isBoardPort(right)) { + return -1; + } + if (!isBoardPort(left) && isBoardPort(right)) { + return 1; + } + let result = left.protocol.toLocaleLowerCase().localeCompare(right.protocol.toLocaleLowerCase()); + if (result !== 0) { + return result; + } + result = naturalCompare(left.address, right.address); + if (result !== 0) { + return result; + } + return (left.label || '').localeCompare(right.label || ''); + } + + export function equals(left: Port | undefined, right: Port | undefined): boolean { + if (left && right) { + return left.address === right.address + && left.protocol === right.protocol + && (left.label || '') === (right.label || ''); + } + return left === right; + } + + // Based on: https://github.com/arduino/Arduino/blob/93581b03d723e55c60caedb4729ffc6ea808fe78/arduino-core/src/processing/app/SerialPortList.java#L48-L74 + export function isBoardPort(port: Port): boolean { + const address = port.address.toLocaleLowerCase(); + if (isWindows) { + // `COM1` seems to be the default serial port on Windows. + return address !== 'COM1'.toLocaleLowerCase(); + } + // On macOS and Linux, the port should start with `/dev/`. + if (!address.startsWith('/dev/')) { + return false + } + if (isOSX) { + // Example: `/dev/cu.usbmodem14401` + if (/(tty|cu)\..*/.test(address.substring('/dev/'.length))) { + return [ + '/dev/cu.MALS', + '/dev/cu.SOC', + '/dev/cu.Bluetooth-Incoming-Port' + ].map(a => a.toLocaleLowerCase()).every(a => a !== address); + } + } + + // Example: `/dev/ttyACM0` + if (/(ttyS|ttyUSB|ttyACM|ttyAMA|rfcomm|ttyO)[0-9]{1,3}/.test(address.substring('/dev/'.length))) { + // Default ports were `/dev/ttyS0` -> `/dev/ttyS31` on Ubuntu 16.04.2. + if (address.startsWith('/dev/ttyS')) { + const index = Number.parseInt(address.substring('/dev/ttyS'.length), 10); + if (!Number.isNaN(index) && 0 <= index && 31 >= index) { + return false; + } + } + return true; + } + + return false; + } + + export function sameAs(left: Port | undefined, right: string | undefined) { + if (left && right) { + if (left.protocol !== 'serial') { + console.log(`Unexpected protocol for port: ${JSON.stringify(left)}. Ignoring protocol, comparing addresses with ${right}.`); + } + return left.address === right; + } + return false; + } + } export interface BoardPackage extends ArduinoComponent { @@ -59,6 +186,17 @@ export namespace Board { return left.name === right.name && left.fqbn === right.fqbn; } + export function sameAs(left: Board, right: string | Board): boolean { + // How to associate a selected board with one of the available cores: https://typefox.slack.com/archives/CJJHJCJSJ/p1571142327059200 + // 1. How to use the FQBN if any and infer the package ID from it: https://typefox.slack.com/archives/CJJHJCJSJ/p1571147549069100 + // 2. How to trim the `/Genuino` from the name: https://arduino.slack.com/archives/CJJHJCJSJ/p1571146951066800?thread_ts=1571142327.059200&cid=CJJHJCJSJ + const other = typeof right === 'string' ? { name: right } : right; + if (left.fqbn && other.fqbn) { + return left.fqbn === other.fqbn; + } + return left.name.replace('/Genuino', '') === other.name.replace('/Genuino', ''); + } + export function compare(left: Board, right: Board): number { let result = left.name.localeCompare(right.name); if (result === 0) { diff --git a/arduino-ide-extension/src/common/protocol/config-service.ts b/arduino-ide-extension/src/common/protocol/config-service.ts index aaddf2c6..10cc977d 100644 --- a/arduino-ide-extension/src/common/protocol/config-service.ts +++ b/arduino-ide-extension/src/common/protocol/config-service.ts @@ -2,6 +2,7 @@ export const ConfigServicePath = '/services/config-service'; export const ConfigService = Symbol('ConfigService'); export interface ConfigService { + getVersion(): Promise; getConfiguration(): Promise; isInDataDir(uri: string): Promise; isInSketchDir(uri: string): Promise; diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index 4b07712d..eeba1683 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -3,11 +3,15 @@ export const SketchesService = Symbol('SketchesService'); export interface SketchesService { /** * Returns with the direct sketch folders from the location of the `fileStat`. - * The sketches returns with inverchronological order, the first item is the most recent one. + * The sketches returns with inverse-chronological order, the first item is the most recent one. */ getSketches(uri?: string): Promise getSketchFiles(uri: string): Promise - createNewSketch(parentUri: string): Promise + /** + * Creates a new sketch folder in the `parentUri` location. If `parentUri` is not specified, + * it falls back to the default `sketchDirUri` from the CLI. + */ + createNewSketch(parentUri?: string): Promise isSketchFolder(uri: string): Promise } diff --git a/arduino-ide-extension/src/common/types.ts b/arduino-ide-extension/src/common/types.ts new file mode 100644 index 00000000..100be9f1 --- /dev/null +++ b/arduino-ide-extension/src/common/types.ts @@ -0,0 +1,3 @@ +export type RecursiveRequired = { + [P in keyof T]-?: RecursiveRequired; +}; diff --git a/arduino-ide-extension/src/node/arduino-backend-module.ts b/arduino-ide-extension/src/node/arduino-backend-module.ts index 45dadb82..cd9cfc49 100644 --- a/arduino-ide-extension/src/node/arduino-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-backend-module.ts @@ -133,7 +133,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { return parentLogger.child('discovery'); }).inSingletonScope().whenTargetNamed('discovery'); - // Default workspace server extension to initialize and use a fallback workspace (`~/Arduino-PoC/workspace/`) + // Default workspace server extension to initialize and use a fallback workspace. // If nothing was set previously. bind(DefaultWorkspaceServerExt).toSelf().inSingletonScope(); rebind(WorkspaceServer).toService(DefaultWorkspaceServerExt); diff --git a/arduino-ide-extension/src/node/arduino-cli.ts b/arduino-ide-extension/src/node/arduino-cli.ts index 5168c8cc..f170be38 100644 --- a/arduino-ide-extension/src/node/arduino-cli.ts +++ b/arduino-ide-extension/src/node/arduino-cli.ts @@ -1,6 +1,6 @@ import * as os from 'os'; import * as which from 'which'; -import * as cp from 'child_process'; +import { spawn } from 'child_process'; import { join, delimiter } from 'path'; import { injectable, inject } from 'inversify'; import { ILogger } from '@theia/core'; @@ -26,40 +26,74 @@ export class ArduinoCli { }); } + async getVersion(): Promise { + const execPath = await this.getExecPath(); + return this.spawn(`"${execPath}"`, ['version']); + return new Promise((resolve, reject) => { + const buffers: Buffer[] = []; + const cp = spawn(`"${execPath}"`, ['version'], { windowsHide: true, shell: true }); + cp.stdout.on('data', (b: Buffer) => buffers.push(b)); + cp.on('error', error => reject(error)); + cp.on('exit', (code, signal) => { + if (code === 0) { + const result = Buffer.concat(buffers).toString('utf8').trim() + resolve(result); + return; + } + if (signal) { + reject(new Error(`Process exited with signal: ${signal}`)); + return; + } + if (code) { + reject(new Error(`Process exited with exit code: ${code}`)); + return; + } + }); + }); + } + async getDefaultConfig(): Promise { - const command = await this.getExecPath(); - return new Promise((resolve, reject) => { - cp.execFile( - command, - ['config', 'dump', '--format', 'json'], - { encoding: 'utf8' }, - (error, stdout, stderr) => { + const execPath = await this.getExecPath(); + const result = await this.spawn(`"${execPath}"`, ['config', 'dump', '--format', 'json']); + const { sketchbook_path, arduino_data } = JSON.parse(result); + if (!sketchbook_path) { + throw new Error(`Could not parse config. 'sketchbook_path' was missing from: ${result}`); + } + if (!arduino_data) { + throw new Error(`Could not parse config. 'arduino_data' was missing from: ${result}`); + } + return { + sketchDirUri: FileUri.create(sketchbook_path).toString(), + dataDirUri: FileUri.create(arduino_data).toString() + }; + } - if (error) { - throw error; - } - - if (stderr) { - throw new Error(stderr); - } - - const { sketchbook_path, arduino_data } = JSON.parse(stdout.trim()); - - if (!sketchbook_path) { - reject(new Error(`Could not parse config. 'sketchbook_path' was missing from: ${stdout}`)); - return; - } - - if (!arduino_data) { - reject(new Error(`Could not parse config. 'arduino_data' was missing from: ${stdout}`)); - return; - } - - resolve({ - sketchDirUri: FileUri.create(sketchbook_path).toString(), - dataDirUri: FileUri.create(arduino_data).toString() - }); - }); + private spawn(command: string, args?: string[]): Promise { + return new Promise((resolve, reject) => { + const buffers: Buffer[] = []; + const cp = spawn(command, args, { windowsHide: true, shell: true }); + cp.stdout.on('data', (b: Buffer) => buffers.push(b)); + cp.on('error', error => { + this.logger.error(`Error executing ${command} with args: ${JSON.stringify(args)}.`, error); + reject(error); + }); + cp.on('exit', (code, signal) => { + if (code === 0) { + const result = Buffer.concat(buffers).toString('utf8').trim() + resolve(result); + return; + } + if (signal) { + this.logger.error(`Unexpected signal '${signal}' when executing ${command} with args: ${JSON.stringify(args)}.`); + reject(new Error(`Process exited with signal: ${signal}`)); + return; + } + if (code) { + this.logger.error(`Unexpected exit code '${code}' when executing ${command} with args: ${JSON.stringify(args)}.`); + reject(new Error(`Process exited with exit code: ${code}`)); + return; + } + }); }); } diff --git a/arduino-ide-extension/src/node/arduino-daemon.ts b/arduino-ide-extension/src/node/arduino-daemon.ts index f17718d6..63047d16 100644 --- a/arduino-ide-extension/src/node/arduino-daemon.ts +++ b/arduino-ide-extension/src/node/arduino-daemon.ts @@ -32,8 +32,9 @@ export class ArduinoDaemon implements BackendApplicationContribution { try { if (!this.cliContribution.debugCli) { const executable = await this.cli.getExecPath(); - this.logger.info(`>>> Starting 'arduino-cli' daemon... [${executable}]`); - const daemon = exec(`${executable} daemon -v --log-level info --format json --log-format json`, + const version = await this.cli.getVersion(); + this.logger.info(`>>> Starting ${version.toLocaleLowerCase()} daemon from ${executable}...`); + const daemon = exec(`"${executable}" daemon -v --log-level info --format json --log-format json`, { encoding: 'utf8', maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => { if (err || stderr) { console.log(err || new Error(stderr)); diff --git a/arduino-ide-extension/src/node/boards-service-impl.ts b/arduino-ide-extension/src/node/boards-service-impl.ts index 737b09eb..4b14a11c 100644 --- a/arduino-ide-extension/src/node/boards-service-impl.ts +++ b/arduino-ide-extension/src/node/boards-service-impl.ts @@ -1,7 +1,7 @@ import * as PQueue from 'p-queue'; import { injectable, inject, postConstruct, named } from 'inversify'; import { ILogger } from '@theia/core/lib/common/logger'; -import { BoardsService, AttachedSerialBoard, BoardPackage, Board, AttachedNetworkBoard, BoardsServiceClient } from '../common/protocol/boards-service'; +import { BoardsService, AttachedSerialBoard, BoardPackage, Board, AttachedNetworkBoard, BoardsServiceClient, Port } from '../common/protocol/boards-service'; import { PlatformSearchReq, PlatformSearchResp, PlatformInstallReq, PlatformInstallResp, PlatformListReq, PlatformListResp } from './cli-protocol/commands/core_pb'; import { CoreClientProvider } from './core-client-provider'; import { BoardListReq, BoardListResp } from './cli-protocol/commands/board_pb'; @@ -20,7 +20,6 @@ export class BoardsServiceImpl implements BoardsService { @inject(ToolOutputServiceServer) protected readonly toolOutputService: ToolOutputServiceServer; - protected selectedBoard: Board | undefined; protected discoveryInitialized = false; protected discoveryTimer: NodeJS.Timeout | undefined; /** @@ -29,44 +28,58 @@ export class BoardsServiceImpl implements BoardsService { * This state is updated via periodical polls. */ protected _attachedBoards: { boards: Board[] } = { boards: [] }; + protected _availablePorts: { ports: Port[] } = { ports: [] }; protected client: BoardsServiceClient | undefined; protected readonly queue = new PQueue({ autoStart: true, concurrency: 1 }); @postConstruct() protected async init(): Promise { this.discoveryTimer = setInterval(() => { - this.discoveryLogger.trace('Discovering attached boards...'); - this.doGetAttachedBoards().then(({ boards }) => { - const update = (oldState: Board[], newState: Board[], message: string) => { - this._attachedBoards = { boards: newState }; - this.discoveryLogger.info(`${message} - Discovered boards: ${JSON.stringify(newState)}`); + this.discoveryLogger.trace('Discovering attached boards and available ports...'); + this.doGetAttachedBoardsAndAvailablePorts().then(({ boards, ports }) => { + const update = (oldBoards: Board[], newBoards: Board[], oldPorts: Port[], newPorts: Port[], message: string) => { + this._attachedBoards = { boards: newBoards }; + this._availablePorts = { ports: newPorts }; + this.discoveryLogger.info(`${message} - Discovered boards: ${JSON.stringify(newBoards)} and available ports: ${JSON.stringify(newPorts)}`); if (this.client) { this.client.notifyAttachedBoardsChanged({ oldState: { - boards: oldState + boards: oldBoards, + ports: oldPorts }, newState: { - boards: newState + boards: newBoards, + ports: newPorts } }); } } const sortedBoards = boards.sort(Board.compare); - this.discoveryLogger.trace(`Discovery done. ${JSON.stringify(sortedBoards)}`); + const sortedPorts = ports.sort(Port.compare); + this.discoveryLogger.trace(`Discovery done. Boards: ${JSON.stringify(sortedBoards)}. Ports: ${sortedPorts}`); if (!this.discoveryInitialized) { - update([], sortedBoards, 'Initialized attached boards.'); + update([], sortedBoards, [], sortedPorts, 'Initialized attached boards and available ports.'); this.discoveryInitialized = true; } else { - this.getAttachedBoards().then(({ boards: currentBoards }) => { + Promise.all([ + this.getAttachedBoards(), + this.getAvailablePorts() + ]).then(([{ boards: currentBoards }, { ports: currentPorts }]) => { this.discoveryLogger.trace(`Updating discovered boards... ${JSON.stringify(currentBoards)}`); - if (currentBoards.length !== sortedBoards.length) { - update(currentBoards, sortedBoards, 'Updated discovered boards.'); + if (currentBoards.length !== sortedBoards.length || currentPorts.length !== sortedPorts.length) { + update(currentBoards, sortedBoards, currentPorts, sortedPorts, 'Updated discovered boards and available ports.'); return; } // `currentBoards` is already sorted. for (let i = 0; i < sortedBoards.length; i++) { if (Board.compare(sortedBoards[i], currentBoards[i]) !== 0) { - update(currentBoards, sortedBoards, 'Updated discovered boards.'); + update(currentBoards, sortedBoards, currentPorts, sortedPorts, 'Updated discovered boards.'); + return; + } + } + for (let i = 0; i < sortedPorts.length; i++) { + if (Port.compare(sortedPorts[i], currentPorts[i]) !== 0) { + update(currentBoards, sortedBoards, currentPorts, sortedPorts, 'Updated discovered boards.'); return; } } @@ -91,13 +104,18 @@ export class BoardsServiceImpl implements BoardsService { return this._attachedBoards; } - private async doGetAttachedBoards(): Promise<{ boards: Board[] }> { + async getAvailablePorts(): Promise<{ ports: Port[] }> { + return this._availablePorts; + } + + private async doGetAttachedBoardsAndAvailablePorts(): Promise<{ boards: Board[], ports: Port[] }> { return this.queue.add(() => { - return new Promise<{ boards: Board[] }>(async resolve => { + return new Promise<{ boards: Board[], ports: Port[] }>(async resolve => { const coreClient = await this.coreClientProvider.getClient(); const boards: Board[] = []; + const ports: Port[] = []; if (!coreClient) { - resolve({ boards }); + resolve({ boards, ports }); return; } @@ -105,10 +123,43 @@ export class BoardsServiceImpl implements BoardsService { const req = new BoardListReq(); req.setInstance(instance); const resp = await new Promise((resolve, reject) => client.boardList(req, (err, resp) => (!!err ? reject : resolve)(!!err ? err : resp))); - for (const portsList of resp.getPortsList()) { - const protocol = portsList.getProtocol(); - const address = portsList.getAddress(); - for (const board of portsList.getBoardsList()) { + const portsList = resp.getPortsList(); + // TODO: remove unknown board mocking! + // You also have to manually import `DetectedPort`. + // const unknownPortList = new DetectedPort(); + // unknownPortList.setAddress(platform() === 'win32' ? 'COM3' : platform() === 'darwin' ? '/dev/cu.usbmodem94401' : '/dev/ttyACM0'); + // unknownPortList.setProtocol('serial'); + // unknownPortList.setProtocolLabel('Serial Port (USB)'); + // portsList.push(unknownPortList); + + for (const portList of portsList) { + const protocol = Port.Protocol.toProtocol(portList.getProtocol()); + const address = portList.getAddress(); + // Available ports can exist with unknown attached boards. + // The `BoardListResp` looks like this for a known attached board: + // [ + // { + // "address": "COM10", + // "protocol": "serial", + // "protocol_label": "Serial Port (USB)", + // "boards": [ + // { + // "name": "Arduino MKR1000", + // "FQBN": "arduino:samd:mkr1000" + // } + // ] + // } + // ] + // And the `BoardListResp` looks like this for an unknown board: + // [ + // { + // "address": "COM9", + // "protocol": "serial", + // "protocol_label": "Serial Port (USB)", + // } + // ] + ports.push({ protocol, address }); + for (const board of portList.getBoardsList()) { const name = board.getName() || 'unknown'; const fqbn = board.getFqbn(); const port = address; @@ -118,13 +169,15 @@ export class BoardsServiceImpl implements BoardsService { fqbn, port }); - } else { // We assume, it is a `network` board. + } else if (protocol === 'network') { // We assume, it is a `network` board. boards.push({ name, fqbn, address, port }); + } else { + console.warn(`Unknown protocol for port: ${address}.`); } } } @@ -133,7 +186,7 @@ export class BoardsServiceImpl implements BoardsService { // { name: 'Arduino/Genuino Uno', fqbn: 'arduino:avr:uno', port: '/dev/cu.usbmodem14201' }, // { name: 'Arduino/Genuino Uno', fqbn: 'arduino:avr:uno', port: '/dev/cu.usbmodem142xx' }, // ]); - resolve({ boards }); + resolve({ boards, ports }); }) }); } diff --git a/arduino-ide-extension/src/node/config-service-impl.ts b/arduino-ide-extension/src/node/config-service-impl.ts index e864a846..5f16d264 100644 --- a/arduino-ide-extension/src/node/config-service-impl.ts +++ b/arduino-ide-extension/src/node/config-service-impl.ts @@ -1,5 +1,8 @@ -import { injectable, inject } from 'inversify'; +import { mkdirpSync, existsSync } from 'fs-extra'; +import { injectable, inject, postConstruct } from 'inversify'; import URI from '@theia/core/lib/common/uri'; +import { FileUri } from '@theia/core/lib/node/file-uri'; +import { Deferred } from '@theia/core/lib/common/promise-util'; import { ConfigService, Config } from '../common/protocol/config-service'; import { ArduinoCli } from './arduino-cli'; @@ -8,9 +11,28 @@ export class ConfigServiceImpl implements ConfigService { @inject(ArduinoCli) protected readonly cli: ArduinoCli; + protected readonly config: Deferred = new Deferred(); + + @postConstruct() + protected init(): void { + this.cli.getDefaultConfig().then(config => { + const { dataDirUri, sketchDirUri } = config; + for (const uri of [dataDirUri, sketchDirUri]) { + const path = FileUri.fsPath(uri); + if (!existsSync(path)) { + mkdirpSync(path); + } + } + this.config.resolve(config); + }); + } async getConfiguration(): Promise { - return this.cli.getDefaultConfig(); + return this.config.promise; + } + + async getVersion(): Promise { + return this.cli.getVersion(); } async isInDataDir(uri: string): Promise { diff --git a/arduino-ide-extension/src/node/core-client-provider-impl.ts b/arduino-ide-extension/src/node/core-client-provider-impl.ts index a4dba8b9..65147596 100644 --- a/arduino-ide-extension/src/node/core-client-provider-impl.ts +++ b/arduino-ide-extension/src/node/core-client-provider-impl.ts @@ -108,14 +108,8 @@ export class CoreClientProviderImpl implements CoreClientProvider { const initResp = await new Promise(resolve => { let resp: InitResp | undefined = undefined; const stream = client.init(initReq); - stream.on('data', (data: InitResp) => { - if (!resp) { - resp = data; - } - }) - stream.on('end', () => { - resolve(resp); - }) + stream.on('data', (data: InitResp) => resp = data); + stream.on('end', () => resolve(resp)); }); const instance = initResp.getInstance(); diff --git a/arduino-ide-extension/src/node/daemon-log.ts b/arduino-ide-extension/src/node/daemon-log.ts index e8ddd9ab..872058a6 100644 --- a/arduino-ide-extension/src/node/daemon-log.ts +++ b/arduino-ide-extension/src/node/daemon-log.ts @@ -83,7 +83,9 @@ export namespace DaemonLog { export function log(logger: ILogger, logMessages: string): void { const parsed = parse(logMessages); for (const log of parsed) { - logger.log(toLogLevel(log), toMessage(log)); + const logLevel = toLogLevel(log); + const message = toMessage(log, { omitLogLevel: true }); + logger.log(logLevel, message); } } @@ -109,12 +111,13 @@ export namespace DaemonLog { export function toPrettyString(logMessages: string): string { const parsed = parse(logMessages); - return parsed.map(toMessage).join('\n') + '\n'; + return parsed.map(log => toMessage(log)).join('\n') + '\n'; } - function toMessage(log: DaemonLog): string { + function toMessage(log: DaemonLog, options: { omitLogLevel: boolean } = { omitLogLevel: false }): string { const details = Object.keys(log).filter(key => key !== 'msg' && key !== 'level' && key !== 'time').map(key => toDetails(log, key)).join(', '); - return `[${log.level.toUpperCase()}] ${log.msg}${!!details ? ` [${details}]` : ''}` + const logLevel = options.omitLogLevel ? '' : `[${log.level.toUpperCase()}] `; + return `${logLevel}${log.msg}${!!details ? ` [${details}]` : ''}` } function toDetails(log: DaemonLog, key: string): string { diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 73c2590e..2a5ae859 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -16,7 +16,19 @@ export class SketchesServiceImpl implements SketchesService { async getSketches(uri?: string): Promise { const sketches: Array = []; - const fsPath = FileUri.fsPath(uri ? uri : (await this.configService.getConfiguration()).sketchDirUri); + let fsPath: undefined | string; + if (!uri) { + const { sketchDirUri } = (await this.configService.getConfiguration()); + fsPath = FileUri.fsPath(sketchDirUri); + if (!fs.existsSync(fsPath)) { + fs.mkdirpSync(fsPath); + } + } else { + fsPath = FileUri.fsPath(uri); + } + if (!fs.existsSync(fsPath)) { + return []; + } const fileNames = fs.readdirSync(fsPath); for (const fileName of fileNames) { const filePath = path.join(fsPath, fileName); @@ -56,12 +68,13 @@ export class SketchesServiceImpl implements SketchesService { return this.getSketchFiles(FileUri.create(sketchDir).toString()); } - async createNewSketch(parentUri: string): Promise { + async createNewSketch(parentUri?: string): Promise { const monthNames = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december' ]; const today = new Date(); - const parent = FileUri.fsPath(parentUri); + const uri = !!parentUri ? parentUri : (await this.configService.getConfiguration()).sketchDirUri; + const parent = FileUri.fsPath(uri); const sketchBaseName = `sketch_${monthNames[today.getMonth()]}${today.getDate()}`; let sketchName: string | undefined; @@ -81,7 +94,7 @@ export class SketchesServiceImpl implements SketchesService { const sketchDir = path.join(parent, sketchName) const sketchFile = path.join(sketchDir, `${sketchName}.ino`); - fs.mkdirSync(sketchDir); + fs.mkdirpSync(sketchDir); fs.writeFileSync(sketchFile, ` void setup() { // put your setup code here, to run once: diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 6326f312..b7ac3238 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -52,7 +52,7 @@ jobs: - task: PublishBuildArtifacts@1 inputs: pathtoPublish: electron/build/dist/$(ArduinoPoC.AppName) - artifactName: 'Arduino-PoC - Applications' + artifactName: 'Arduino Pro IDE - Applications' condition: or(in(variables['Agent.OS'], 'Windows_NT'), in(variables['Build.Reason'], 'Manual', 'Schedule')) displayName: Publish - job: Release @@ -65,16 +65,16 @@ jobs: - task: DownloadBuildArtifacts@0 displayName: Download inputs: - artifactName: 'Arduino-PoC - Applications' + artifactName: 'Arduino Pro IDE - Applications' downloadPath: 'gh-release' - task: GithubRelease@0 inputs: gitHubConnection: typefox-service-account1 repositoryName: bcmi-labs/arduino-editor assets: | - gh-release/Arduino-PoC - Applications/*.zip - gh-release/Arduino-PoC - Applications/*.dmg - gh-release/Arduino-PoC - Applications/*.tar.xz + gh-release/Arduino Pro IDE - Applications/*.zip + gh-release/Arduino Pro IDE - Applications/*.dmg + gh-release/Arduino Pro IDE - Applications/*.tar.xz target: $(Build.SourceVersion) action: Edit tagSource: auto diff --git a/browser-app/package.json b/browser-app/package.json index 9bd2ec75..04005803 100644 --- a/browser-app/package.json +++ b/browser-app/package.json @@ -1,10 +1,11 @@ { "private": true, "name": "browser-app", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "dependencies": { "@theia/core": "next", + "@theia/cpp": "next", "@theia/editor": "next", "@theia/file-search": "next", "@theia/filesystem": "next", @@ -16,22 +17,21 @@ "@theia/process": "next", "@theia/terminal": "next", "@theia/workspace": "next", - "@theia/cpp": "next", "@theia/textmate-grammars": "next", - "arduino-ide-extension": "0.0.1" + "arduino-ide-extension": "0.0.2" }, "devDependencies": { "@theia/cli": "next" }, "scripts": { "prepare": "theia build --mode development", - "start": "theia start --root-dir=../workspace", + "start": "theia start", "watch": "theia build --watch --mode development" }, "theia": { "frontend": { "config": { - "applicationName": "Arduino Editor", + "applicationName": "Arduino Pro IDE", "defaultTheme": "arduino-theme", "preferences": { "editor.autoSave": "on" diff --git a/electron-app/package.json b/electron-app/package.json index 1b039aa7..cb35a263 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,10 +1,11 @@ { "private": true, "name": "electron-app", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "dependencies": { "@theia/core": "next", + "@theia/cpp": "next", "@theia/editor": "next", "@theia/electron": "next", "@theia/file-search": "next", @@ -17,9 +18,8 @@ "@theia/process": "next", "@theia/terminal": "next", "@theia/workspace": "next", - "@theia/cpp": "next", "@theia/textmate-grammars": "next", - "arduino-ide-extension": "0.0.1" + "arduino-ide-extension": "0.0.2" }, "devDependencies": { "@theia/cli": "next", @@ -27,14 +27,14 @@ }, "scripts": { "prepare": "theia build --mode development", - "start": "theia start --root-dir=../workspace", + "start": "theia start", "watch": "theia build --watch --mode development" }, "theia": { "target": "electron", "frontend": { "config": { - "applicationName": "Arduino Editor", + "applicationName": "Arduino Pro IDE", "defaultTheme": "arduino-theme", "preferences": { "editor.autoSave": "on" diff --git a/electron/README.md b/electron/README.md index fa598e43..65d5bc67 100644 --- a/electron/README.md +++ b/electron/README.md @@ -1,13 +1,13 @@ ## Electron -All-in-one packager producing the `Arduino-PoC` Electron-based application. +All-in-one packager producing the `Arduino Pro IDE` Electron-based application. ## Prerequisites The prerequisites are defined [here](https://github.com/theia-ide/theia/blob/master/doc/Developing.md#prerequisites). ### Build: -To build the Arduino-PoC Electron-based Theia application you have to do the followings: +To build the Arduino Pro IDE application you have to do the followings: ```bash yarn --cwd ./electron/packager/ && yarn --cwd ./electron/packager/ package ``` diff --git a/electron/build/template-package.json b/electron/build/template-package.json index fd9d32fd..8d84556f 100644 --- a/electron/build/template-package.json +++ b/electron/build/template-package.json @@ -1,18 +1,21 @@ { - "name": "arduino-electron", - "description": "Arduino-PoC Electron", + "name": "arduino.Pro.IDE", + "description": "Arduino Pro IDE", "main": "src-gen/frontend/electron-main.js", "author": "TypeFox", "dependencies": { "google-protobuf": "^3.5.0", "arduino-ide-extension": "file:../working-copy/arduino-ide-extension" }, + "resolutions": { + "**/fs-extra": "^4.0.2" + }, "devDependencies": { - "electron-builder": "^20.36.2" + "electron-builder": "^21.2.0" }, "scripts": { "build": "theia build --mode development", - "build:release": "theia build --mode production", + "build:release": "theia build --mode development", "package": "electron-builder --publish=never", "package:preview": "electron-builder --dir" }, @@ -21,16 +24,17 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/TypeFox/arduino-poc.git" + "url": "git+https://github.com/arduino/arduino-pro-ide.git" }, - "// Notes:" : [ + "// Notes:": [ "The `electronVersion` version was pinned for `@grpc/grpc-js` -> Node.js version constraints.", - "`google-protobuf` was declared as it is not picked up by the `electron-builder` as a runtime dependency." + "`google-protobuf` was declared as it is not picked up by the `electron-builder` as a runtime dependency.", + "The resolution for `fs-extra` was required due to this: https://spectrum.chat/theia/general/our-theia-electron-builder-app-no-longer-starts~f5cf09a0-6d88-448b-8818-24ad0ec2ee7c" ], "build": { - "productName": "Arduino-PoC", - "appId": "arduino.PoC", - "electronVersion": "4.0.0", + "productName": "Arduino Pro IDE", + "appId": "arduino.Pro.IDE", + "electronVersion": "4.2.0", "asar": false, "directories": { "buildResources": "resources" @@ -49,40 +53,40 @@ "!node_modules/onigasm/*" ], "win": { - "target": [ - "zip" - ], - "artifactName": "${productName}-${env.ARDUINO_VERSION}-${os}.${ext}" + "target": [ + "zip" + ], + "artifactName": "${productName}-${env.ARDUINO_VERSION}-${os}.${ext}" }, "mac": { - "target": [ - "dmg" - ], - "artifactName": "${productName}-${env.ARDUINO_VERSION}-${os}.${ext}", - "darkModeSupport": true + "target": [ + "dmg" + ], + "artifactName": "${productName}-${env.ARDUINO_VERSION}-${os}.${ext}", + "darkModeSupport": true }, "linux": { "target": [ - "tar.xz" + "tar.xz" ], "artifactName": "${productName}-${env.ARDUINO_VERSION}-${os}.${ext}" }, "dmg": { - "icon": "resources/icon.icns", - "iconSize": 128, - "contents": [ - { - "x": 380, - "y": 240, - "type": "link", - "path": "/Applications" - }, - { - "x": 122, - "y": 240, - "type": "file" - } - ] + "icon": "resources/icon.icns", + "iconSize": 128, + "contents": [ + { + "x": 380, + "y": 240, + "type": "link", + "path": "/Applications" + }, + { + "x": 122, + "y": 240, + "type": "file" + } + ] } } -} \ No newline at end of file +} diff --git a/electron/packager/cli b/electron/packager/cli index 203ca8db..a92a122e 100755 --- a/electron/packager/cli +++ b/electron/packager/cli @@ -25,7 +25,7 @@ const yargs = require('yargs'); process.stderr.write(`Unexpected platform: ${platform}.`); process.exit(1); } - process.stdout.write(`Arduino-PoC-${versionInfo().version}-${os}.${ext}`); + process.stdout.write(`Arduino Pro IDE-${versionInfo().version}-${os}.${ext}`); process.exit(0); } }) diff --git a/electron/packager/index.js b/electron/packager/index.js index a7cb4af5..6daf4644 100644 --- a/electron/packager/index.js +++ b/electron/packager/index.js @@ -5,7 +5,8 @@ const fs = require('fs'); const join = require('path').join; const shell = require('shelljs'); - shell.env.THEIA_ELECTRON_SKIP_REPLACE_FFMPEG = '1'; + shell.env.THEIA_ELECTRON_SKIP_REPLACE_FFMPEG = '1'; // Do not run the ffmpeg validation for the packager. + shell.env.NODE_OPTIONS = '--max_old_space_size=4096'; // Increase heap size for the CI const utils = require('./utils'); const { version, release } = utils.versionInfo(); @@ -64,7 +65,7 @@ //-------------------------------------------------------------------------------------------------+ // Rebuild the extension with the copied `yarn.lock`. It is a must to use the same Theia versions. | //-------------------------------------------------------------------------------------------------+ - exec(`yarn --network-timeout 1000000 --cwd ${path('..', workingCopy)}`, 'Building the Arduino Theia extensions'); + exec(`yarn --network-timeout 1000000 --cwd ${path('..', workingCopy)}`, 'Building the Arduino Pro IDE extensions'); // Collect all unused dependencies by the backend. We have to remove them from the electron app. // The `bundle.js` already contains everything we need for the frontend. // We have to do it before changing the dependencies to `local-path`. @@ -87,7 +88,7 @@ devDependencies: pkg.devDependencies }, null, 2)); - echo(`📜 Effective 'package.json' for the Arduino-PoC application is: + echo(`📜 Effective 'package.json' for the Arduino Pro IDE application is: ----------------------- ${fs.readFileSync(path('..', 'build', 'package.json')).toString()} ----------------------- @@ -108,7 +109,7 @@ ${fs.readFileSync(path('..', 'build', 'package.json')).toString()} // Install all private and public dependencies for the electron application and build Theia. | //-------------------------------------------------------------------------------------------+ exec(`yarn --network-timeout 1000000 --cwd ${path('..', 'build')}`, 'Installing dependencies'); - exec(`yarn --network-timeout 1000000 --cwd ${path('..', 'build')} build${release ? ':release' : ''}`, 'Building the Arduino-PoC application'); + exec(`yarn --network-timeout 1000000 --cwd ${path('..', 'build')} build${release ? ':release' : ''}`, 'Building the Arduino Pro IDE application'); //------------------------------------------------------------------------------+ // Create a throw away dotenv file which we use to feed the builder with input. | @@ -124,7 +125,7 @@ ${fs.readFileSync(path('..', 'build', 'package.json')).toString()} //-----------------------------------+ // Package the electron application. | //-----------------------------------+ - exec(`yarn --network-timeout 1000000 --cwd ${path('..', 'build')} package`, `Packaging your Arduino-PoC application`); + exec(`yarn --network-timeout 1000000 --cwd ${path('..', 'build')} package`, `Packaging your Arduino Pro IDE application`); echo(`🎉 Success. Your application is at: ${path('..', 'build', 'dist')}`); restore(); diff --git a/electron/packager/package.json b/electron/packager/package.json index a83ab6a0..b6bd619c 100644 --- a/electron/packager/package.json +++ b/electron/packager/package.json @@ -2,7 +2,7 @@ "private": true, "name": "packager", "version": "1.0.0", - "description": "Packager for the Arduino-PoC electron application", + "description": "Packager for the Arduino Pro IDE electron application", "main": "index.js", "scripts": { "package": "node index.js", diff --git a/yarn.lock b/yarn.lock index a1a9a497..a87e9e0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11924,6 +11924,11 @@ string-argv@^0.1.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.1.2.tgz#c5b7bc03fb2b11983ba3a72333dd0559e77e4738" integrity sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA== +string-natural-compare@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-2.0.3.tgz#9dbe1dd65490a5fe14f7a5c9bc686fc67cb9c6e4" + integrity sha512-4Kcl12rNjc+6EKhY8QyDVuQTAlMWwRiNbsxnVwBUKFr7dYPQuXVrtNU4sEkjF9LHY0AY6uVbB3ktbkIH4LC+BQ== + string-template@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"