diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index ea568d595..a10e6c1b3 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -27,7 +27,10 @@ import { SketchesServiceClientImpl } from './sketches-service-client-impl'; import { CoreService, CoreServicePath } from '../common/protocol/core-service'; import { BoardsListWidget } from './boards/boards-list-widget'; import { BoardsListWidgetFrontendContribution } from './boards/boards-widget-frontend-contribution'; -import { BoardsServiceProvider } from './boards/boards-service-provider'; +import { + BoardListDumper, + BoardsServiceProvider, +} from './boards/boards-service-provider'; import { WorkspaceService as TheiaWorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { WorkspaceService } from './theia/workspace/workspace-service'; import { OutlineViewContribution as TheiaOutlineViewContribution } from '@theia/outline-view/lib/browser/outline-view-contribution'; @@ -61,7 +64,6 @@ import { BoardsConfigDialog, BoardsConfigDialogProps, } from './boards/boards-config-dialog'; -import { BoardsConfigDialogWidget } from './boards/boards-config-dialog-widget'; import { ScmContribution as TheiaScmContribution } from '@theia/scm/lib/browser/scm-contribution'; import { ScmContribution } from './theia/scm/scm-contribution'; import { SearchInWorkspaceFrontendContribution as TheiaSearchInWorkspaceFrontendContribution } from '@theia/search-in-workspace/lib/browser/search-in-workspace-frontend-contribution'; @@ -358,7 +360,7 @@ import { UpdateArduinoState } from './contributions/update-arduino-state'; import { TerminalWidgetImpl } from './theia/terminal/terminal-widget-impl'; import { TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; import { TerminalFrontendContribution } from './theia/terminal/terminal-frontend-contribution'; -import { TerminalFrontendContribution as TheiaTerminalFrontendContribution } from '@theia/terminal/lib/browser/terminal-frontend-contribution' +import { TerminalFrontendContribution as TheiaTerminalFrontendContribution } from '@theia/terminal/lib/browser/terminal-frontend-contribution'; // Hack to fix copy/cut/paste issue after electron version update in Theia. // https://github.com/eclipse-theia/theia/issues/12487 @@ -447,6 +449,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(BoardsServiceProvider).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(BoardsServiceProvider); bind(CommandContribution).toService(BoardsServiceProvider); + bind(BoardListDumper).toSelf().inSingletonScope(); // To be able to track, and update the menu based on the core settings (aka. board details) of the currently selected board. bind(FrontendApplicationContribution) @@ -480,7 +483,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(OpenHandler).toService(BoardsListWidgetFrontendContribution); // Board select dialog - bind(BoardsConfigDialogWidget).toSelf().inSingletonScope(); bind(BoardsConfigDialog).toSelf().inSingletonScope(); bind(BoardsConfigDialogProps).toConstantValue({ title: nls.localize( @@ -1034,5 +1036,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { // Patch terminal issues. rebind(TerminalWidget).to(TerminalWidgetImpl).inTransientScope(); bind(TerminalFrontendContribution).toSelf().inSingletonScope(); - rebind(TheiaTerminalFrontendContribution).toService(TerminalFrontendContribution); + rebind(TheiaTerminalFrontendContribution).toService( + TerminalFrontendContribution + ); }); diff --git a/arduino-ide-extension/src/browser/boards/boards-auto-installer.ts b/arduino-ide-extension/src/browser/boards/boards-auto-installer.ts index 6d95ea661..07c6a52bb 100644 --- a/arduino-ide-extension/src/browser/boards/boards-auto-installer.ts +++ b/arduino-ide-extension/src/browser/boards/boards-auto-installer.ts @@ -5,7 +5,8 @@ import { BoardsService, BoardsPackage, Board, - Port, + isBoardIdentifierChangeEvent, + BoardIdentifier, } from '../../common/protocol/boards-service'; import { BoardsServiceProvider } from './boards-service-provider'; import { Installable, ResponseServiceClient } from '../../common/protocol'; @@ -24,7 +25,7 @@ interface AutoInstallPromptAction { type AutoInstallPromptActions = AutoInstallPromptAction[]; /** - * Listens on `BoardsConfig.Config` changes, if a board is selected which does not + * Listens on `BoardList` changes, if a board is selected which does not * have the corresponding core installed, it proposes the user to install the core. */ @@ -44,7 +45,7 @@ export class BoardsAutoInstaller implements FrontendApplicationContribution { protected readonly boardsService: BoardsService; @inject(BoardsServiceProvider) - protected readonly boardsServiceClient: BoardsServiceProvider; + protected readonly boardsServiceProvider: BoardsServiceProvider; @inject(ResponseServiceClient) protected readonly responseService: ResponseServiceClient; @@ -53,34 +54,28 @@ export class BoardsAutoInstaller implements FrontendApplicationContribution { protected readonly boardsManagerFrontendContribution: BoardsListWidgetFrontendContribution; // Workaround for https://github.com/eclipse-theia/theia/issues/9349 - protected notifications: Board[] = []; + protected notifications: BoardIdentifier[] = []; // * "refusal" meaning a "prompt action" not accepting the auto-install offer ("X" or "install manually") // we can use "portSelectedOnLastRefusal" to deduce when a board is unplugged after a user has "refused" // an auto-install prompt. Important to know as we do not want "an unplug" to trigger a "refused" prompt // showing again - private portSelectedOnLastRefusal: Port | undefined; + // private portSelectedOnLastRefusal: PortIdentifier | undefined; private lastRefusedPackageId: string | undefined; onStart(): void { const setEventListeners = () => { - this.boardsServiceClient.onBoardsConfigChanged((config) => { - const { selectedBoard, selectedPort } = config; - - const boardWasUnplugged = - !selectedPort && this.portSelectedOnLastRefusal; - - this.clearLastRefusedPromptInfo(); - - if ( - boardWasUnplugged || - !selectedBoard || - this.promptAlreadyShowingForBoard(selectedBoard) - ) { + this.boardsServiceProvider.onBoardsConfigDidChange((config) => { + if (!isBoardIdentifierChangeEvent(config)) { + return; + } + const { selectedBoard } = config; + const fqbn = selectedBoard?.fqbn; + if (!fqbn) { return; } - this.ensureCoreExists(selectedBoard, selectedPort); + this.ensureCoreExists(selectedBoard); }); // we "clearRefusedPackageInfo" if a "refused" package is eventually @@ -94,23 +89,16 @@ export class BoardsAutoInstaller implements FrontendApplicationContribution { }); }; - // we should invoke this.ensureCoreExists only once we're sure - // everything has been reconciled - this.boardsServiceClient.reconciled.then(() => { - const { selectedBoard, selectedPort } = - this.boardsServiceClient.boardsConfig; - - if (selectedBoard) { - this.ensureCoreExists(selectedBoard, selectedPort); - } - - setEventListeners(); - }); + setEventListeners(); // TODO: after onDidStart + // }); } - private removeNotificationByBoard(selectedBoard: Board): void { + private removeNotificationByBoard(selectedBoard: BoardIdentifier): void { const index = this.notifications.findIndex((notification) => - Board.sameAs(notification, selectedBoard) + Board.sameAs( + { name: notification.name, fqbn: notification.fqbn }, + selectedBoard + ) ); if (index !== -1) { this.notifications.splice(index, 1); @@ -119,32 +107,15 @@ export class BoardsAutoInstaller implements FrontendApplicationContribution { private clearLastRefusedPromptInfo(): void { this.lastRefusedPackageId = undefined; - this.portSelectedOnLastRefusal = undefined; - } - - private setLastRefusedPromptInfo( - packageId: string, - selectedPort?: Port - ): void { - this.lastRefusedPackageId = packageId; - this.portSelectedOnLastRefusal = selectedPort; - } - - private promptAlreadyShowingForBoard(board: Board): boolean { - return Boolean( - this.notifications.find((notification) => - Board.sameAs(notification, board) - ) - ); } - protected ensureCoreExists(selectedBoard: Board, selectedPort?: Port): void { + protected ensureCoreExists(selectedBoard: BoardIdentifier): void { this.notifications.push(selectedBoard); this.boardsService.search({}).then((packages) => { const candidate = this.getInstallCandidate(packages, selectedBoard); if (candidate) { - this.showAutoInstallPrompt(candidate, selectedBoard, selectedPort); + this.showAutoInstallPrompt(candidate, selectedBoard); } else { this.removeNotificationByBoard(selectedBoard); } @@ -182,8 +153,7 @@ export class BoardsAutoInstaller implements FrontendApplicationContribution { private showAutoInstallPrompt( candidate: BoardsPackage, - selectedBoard: Board, - selectedPort?: Port + selectedBoard: BoardIdentifier ): void { const candidateName = candidate.name; const version = candidate.availableVersions[0] @@ -199,7 +169,7 @@ export class BoardsAutoInstaller implements FrontendApplicationContribution { const actions = this.createPromptActions(candidate); const onRefuse = () => { - this.setLastRefusedPromptInfo(candidate.id, selectedPort); + // this.setLastRefusedPromptInfo(candidate.id, selectedPort); TODO: probably noop but let's revisit it. }; const handleAction = this.createOnAnswerHandler(actions, onRefuse); diff --git a/arduino-ide-extension/src/browser/boards/boards-config-component.tsx b/arduino-ide-extension/src/browser/boards/boards-config-component.tsx new file mode 100644 index 000000000..da50ad0be --- /dev/null +++ b/arduino-ide-extension/src/browser/boards/boards-config-component.tsx @@ -0,0 +1,340 @@ +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Event } from '@theia/core/lib/common/event'; +import { FrontendApplicationState } from '@theia/core/lib/common/frontend-application-state'; +import { nls } from '@theia/core/lib/common/nls'; +import * as React from '@theia/core/shared/react'; +import { + Board, + BoardIdentifier, + BoardsConfig, + BoardWithPackage, + DetectedPort, + findMatchingPortIndex, + Port, + PortIdentifier, +} from '../../common/protocol/boards-service'; +import { Defined } from '../../common/types'; +import { NotificationCenter } from '../notification-center'; +import { EditBoardsConfigActionParams } from './boards-service-provider'; + +namespace BoardsConfigComponent { + export interface Props { + /** + * This is not the real config, it's only living in the dialog. Users can change it without update and can cancel any modifications. + */ + readonly boardsConfig: BoardsConfig; + readonly notificationCenter: NotificationCenter; + readonly appState: FrontendApplicationState; + readonly onFocusNodeSet: (element: HTMLElement | undefined) => void; + readonly onFilteredTextDidChangeEvent: Event< + Defined + >; + readonly onAppStateDidChange: Event; + readonly onBoardSelected: (board: BoardIdentifier) => void; + readonly onPortSelected: (port: PortIdentifier) => void; + readonly searchBoards: (query?: { + query?: string; + }) => Promise; + readonly ports: ( + predicate?: (port: DetectedPort) => boolean + ) => readonly DetectedPort[]; + } + + export interface State { + searchResults: Array; + showAllPorts: boolean; + query: string; + } +} + +export abstract class Item extends React.Component<{ + item: T; + label: string; + selected: boolean; + onClick: (item: T) => void; + missing?: boolean; + details?: string; +}> { + override render(): React.ReactNode { + const { selected, label, missing, details } = this.props; + const classNames = ['item']; + if (selected) { + classNames.push('selected'); + } + if (missing === true) { + classNames.push('missing'); + } + return ( +
+
{label}
+ {!details ? '' :
{details}
} + {!selected ? ( + '' + ) : ( +
+ +
+ )} +
+ ); + } + + private readonly onClick = () => { + this.props.onClick(this.props.item); + }; +} + +export class BoardsConfigComponent extends React.Component< + BoardsConfigComponent.Props, + BoardsConfigComponent.State +> { + private readonly toDispose: DisposableCollection; + + constructor(props: BoardsConfigComponent.Props) { + super(props); + this.state = { + searchResults: [], + showAllPorts: false, + query: '', + }; + this.toDispose = new DisposableCollection(); + } + + override componentDidMount(): void { + this.toDispose.pushAll([ + this.props.notificationCenter.onPlatformDidInstall(() => + this.updateBoards(this.state.query) + ), + this.props.notificationCenter.onPlatformDidUninstall(() => + this.updateBoards(this.state.query) + ), + this.props.notificationCenter.onIndexUpdateDidComplete(() => + this.updateBoards(this.state.query) + ), + this.props.notificationCenter.onDaemonDidStart(() => + this.updateBoards(this.state.query) + ), + this.props.notificationCenter.onDaemonDidStop(() => + this.setState({ searchResults: [] }) + ), + this.props.onFilteredTextDidChangeEvent((query) => { + if (typeof query === 'string') { + this.setState({ query }, () => this.updateBoards(this.state.query)); + } else { + const action = query?.action; + if (action) { + const currentQuery = this.state.query; + if (!currentQuery && action === 'clear-if-not-empty') { + return; + } + this.setState({ query: '' }, () => + this.updateBoards(this.state.query) + ); + } + } + }), + ]); + const refresh = async () => { + const searchResults = await this.queryBoards({}); + this.setState({ searchResults }); + }; + if (this.props.appState === 'ready') { + refresh(); + } else { + this.toDispose.push( + this.props.onAppStateDidChange((state) => { + if (state === 'ready') { + refresh(); + } + }) + ); + } + } + + override componentWillUnmount(): void { + this.toDispose.dispose(); + } + + private readonly updateBoards = ( + eventOrQuery: React.ChangeEvent | string = '' + ) => { + const query = + typeof eventOrQuery === 'string' + ? eventOrQuery + : eventOrQuery.target.value.toLowerCase(); + this.setState({ query }); + this.queryBoards({ query }).then((searchResults) => + this.setState({ searchResults }) + ); + }; + + private readonly queryBoards = ( + options: { query?: string } = {} + ): Promise> => { + return this.props.searchBoards(options); + }; + + private readonly toggleFilterPorts = () => { + this.setState({ showAllPorts: !this.state.showAllPorts }); + }; + + private readonly selectPort = (selectedPort: PortIdentifier) => { + this.props.onPortSelected(selectedPort); + }; + + private readonly selectBoard = (selectedBoard: BoardWithPackage) => { + this.props.onBoardSelected({ + fqbn: selectedBoard.fqbn, + name: selectedBoard.name, + }); + }; + + private readonly focusNodeSet = (element: HTMLElement | null) => { + this.props.onFocusNodeSet(element || undefined); + }; + + override render(): React.ReactNode { + return ( + <> + {this.renderContainer( + nls.localize('arduino/board/boards', 'boards'), + this.renderBoards.bind(this) + )} + {this.renderContainer( + nls.localize('arduino/board/ports', 'ports'), + this.renderPorts.bind(this), + this.renderPortsFooter.bind(this) + )} + + ); + } + + private renderContainer( + title: string, + contentRenderer: () => React.ReactNode, + footerRenderer?: () => React.ReactNode + ): React.ReactNode { + return ( +
+
+
{title}
+ {contentRenderer()} +
{footerRenderer ? footerRenderer() : ''}
+
+
+ ); + } + + private renderBoards(): React.ReactNode { + const { boardsConfig } = this.props; + const { searchResults, query } = this.state; + // Board names are not unique per core https://github.com/arduino/arduino-pro-ide/issues/262#issuecomment-661019560 + // It is tricky when the core is not yet installed, no FQBNs are available. + const distinctBoards = new Map(); + const toKey = ({ name, packageName, fqbn }: Board.Detailed) => + !!fqbn ? `${name}-${packageName}-${fqbn}` : `${name}-${packageName}`; + for (const board of Board.decorateBoards( + boardsConfig.selectedBoard, + searchResults + )) { + const key = toKey(board); + if (!distinctBoards.has(key)) { + distinctBoards.set(key, board); + } + } + + const boardsList = Array.from(distinctBoards.values()).map((board) => ( + + key={toKey(board)} + item={board} + label={board.name} + details={board.details} + selected={board.selected} + onClick={this.selectBoard} + missing={board.missing} + /> + )); + + return ( + +
+ + +
+ {boardsList.length > 0 ? ( +
{boardsList}
+ ) : ( +
+ {nls.localize( + 'arduino/board/noBoardsFound', + 'No boards found for "{0}"', + query + )} +
+ )} +
+ ); + } + + private renderPorts(): React.ReactNode { + const predicate = this.state.showAllPorts ? undefined : Port.isVisiblePort; + const detectedPorts = this.props.ports(predicate); + const matchingIndex = findMatchingPortIndex( + this.props.boardsConfig.selectedPort, + detectedPorts + ); + return !detectedPorts.length ? ( +
+ {nls.localize('arduino/board/noPortsDiscovered', 'No ports discovered')} +
+ ) : ( +
+ {detectedPorts.map((detectedPort, index) => ( + + key={`${Port.keyOf(detectedPort.port)}`} + item={detectedPort.port} + label={Port.toString(detectedPort.port)} + selected={index === matchingIndex} + onClick={this.selectPort} + /> + ))} +
+ ); + } + + private renderPortsFooter(): React.ReactNode { + return ( +
+ +
+ ); + } +} diff --git a/arduino-ide-extension/src/browser/boards/boards-config-dialog-widget.tsx b/arduino-ide-extension/src/browser/boards/boards-config-dialog-widget.tsx deleted file mode 100644 index 7ad65697a..000000000 --- a/arduino-ide-extension/src/browser/boards/boards-config-dialog-widget.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from '@theia/core/shared/react'; -import { injectable, inject } from '@theia/core/shared/inversify'; -import { Emitter } from '@theia/core/lib/common/event'; -import { ReactWidget, Message } from '@theia/core/lib/browser'; -import { BoardsService } from '../../common/protocol/boards-service'; -import { BoardsConfig } from './boards-config'; -import { BoardsServiceProvider } from './boards-service-provider'; -import { NotificationCenter } from '../notification-center'; - -@injectable() -export class BoardsConfigDialogWidget extends ReactWidget { - @inject(BoardsService) - protected readonly boardsService: BoardsService; - - @inject(BoardsServiceProvider) - protected readonly boardsServiceClient: BoardsServiceProvider; - - @inject(NotificationCenter) - protected readonly notificationCenter: NotificationCenter; - - protected readonly onFilterTextDidChangeEmitter = new Emitter(); - protected readonly onBoardConfigChangedEmitter = - new Emitter(); - readonly onBoardConfigChanged = this.onBoardConfigChangedEmitter.event; - - protected focusNode: HTMLElement | undefined; - - constructor() { - super(); - this.id = 'select-board-dialog'; - this.toDispose.pushAll([ - this.onBoardConfigChangedEmitter, - this.onFilterTextDidChangeEmitter, - ]); - } - - search(query: string): void { - this.onFilterTextDidChangeEmitter.fire(query); - } - - protected fireConfigChanged = (config: BoardsConfig.Config) => { - this.onBoardConfigChangedEmitter.fire(config); - }; - - protected setFocusNode = (element: HTMLElement | undefined) => { - this.focusNode = element; - }; - - protected render(): React.ReactNode { - return ( -
- -
- ); - } - - protected override onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - if (this.focusNode instanceof HTMLInputElement) { - this.focusNode.select(); - } - (this.focusNode || this.node).focus(); - } -} diff --git a/arduino-ide-extension/src/browser/boards/boards-config-dialog.ts b/arduino-ide-extension/src/browser/boards/boards-config-dialog.ts deleted file mode 100644 index b08c6de36..000000000 --- a/arduino-ide-extension/src/browser/boards/boards-config-dialog.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { - injectable, - inject, - postConstruct, -} from '@theia/core/shared/inversify'; -import { Message } from '@theia/core/shared/@phosphor/messaging'; -import { DialogProps, Widget, DialogError } from '@theia/core/lib/browser'; -import { AbstractDialog } from '../theia/dialogs/dialogs'; -import { BoardsConfig } from './boards-config'; -import { BoardsService } from '../../common/protocol/boards-service'; -import { BoardsServiceProvider } from './boards-service-provider'; -import { BoardsConfigDialogWidget } from './boards-config-dialog-widget'; -import { nls } from '@theia/core/lib/common'; - -@injectable() -export class BoardsConfigDialogProps extends DialogProps {} - -@injectable() -export class BoardsConfigDialog extends AbstractDialog { - @inject(BoardsConfigDialogWidget) - protected readonly widget: BoardsConfigDialogWidget; - - @inject(BoardsService) - protected readonly boardService: BoardsService; - - @inject(BoardsServiceProvider) - protected readonly boardsServiceClient: BoardsServiceProvider; - - protected config: BoardsConfig.Config = {}; - - constructor( - @inject(BoardsConfigDialogProps) - protected override readonly props: BoardsConfigDialogProps - ) { - super({ ...props, maxWidth: 500 }); - - this.node.id = 'select-board-dialog-container'; - this.contentNode.classList.add('select-board-dialog'); - this.contentNode.appendChild(this.createDescription()); - - this.appendCloseButton( - nls.localize('vscode/issueMainService/cancel', 'Cancel') - ); - this.appendAcceptButton(nls.localize('vscode/issueMainService/ok', 'OK')); - } - - @postConstruct() - protected init(): void { - this.toDispose.push( - this.boardsServiceClient.onBoardsConfigChanged((config) => { - this.config = config; - this.update(); - }) - ); - } - - /** - * Pass in an empty string if you want to reset the search term. Using `undefined` has no effect. - */ - override async open( - query: string | undefined = undefined - ): Promise { - if (typeof query === 'string') { - this.widget.search(query); - } - return super.open(); - } - - protected createDescription(): HTMLElement { - const head = document.createElement('div'); - head.classList.add('head'); - - const text = document.createElement('div'); - text.classList.add('text'); - head.appendChild(text); - - for (const paragraph of [ - nls.localize( - 'arduino/board/configDialog1', - 'Select both a Board and a Port if you want to upload a sketch.' - ), - nls.localize( - 'arduino/board/configDialog2', - 'If you only select a Board you will be able to compile, but not to upload your sketch.' - ), - ]) { - const p = document.createElement('div'); - p.textContent = paragraph; - text.appendChild(p); - } - - return head; - } - - protected override onAfterAttach(msg: Message): void { - if (this.widget.isAttached) { - Widget.detach(this.widget); - } - Widget.attach(this.widget, this.contentNode); - this.toDisposeOnDetach.push( - this.widget.onBoardConfigChanged((config) => { - this.config = config; - this.update(); - }) - ); - super.onAfterAttach(msg); - this.update(); - } - - protected override onUpdateRequest(msg: Message): void { - super.onUpdateRequest(msg); - this.widget.update(); - } - - protected override onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - this.widget.activate(); - } - - protected override handleEnter(event: KeyboardEvent): boolean | void { - if (event.target instanceof HTMLTextAreaElement) { - return false; - } - } - - protected override isValid(value: BoardsConfig.Config): DialogError { - if (!value.selectedBoard) { - if (value.selectedPort) { - return nls.localize( - 'arduino/board/pleasePickBoard', - 'Please pick a board connected to the port you have selected.' - ); - } - return false; - } - return ''; - } - - get value(): BoardsConfig.Config { - return this.config; - } -} diff --git a/arduino-ide-extension/src/browser/boards/boards-config-dialog.tsx b/arduino-ide-extension/src/browser/boards/boards-config-dialog.tsx new file mode 100644 index 000000000..8e9bdaf60 --- /dev/null +++ b/arduino-ide-extension/src/browser/boards/boards-config-dialog.tsx @@ -0,0 +1,184 @@ +import { DialogError, DialogProps } from '@theia/core/lib/browser/dialogs'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; +import { Emitter } from '@theia/core/lib/common/event'; +import { nls } from '@theia/core/lib/common/nls'; +import { deepClone } from '@theia/core/lib/common/objects'; +import type { Message } from '@theia/core/shared/@phosphor/messaging'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; +import React from '@theia/core/shared/react'; +import type { ReactNode } from '@theia/core/shared/react/index'; +import { + BoardIdentifier, + BoardsConfig, + BoardWithPackage, + DetectedPort, + emptyBoardsConfig, + PortIdentifier, +} from '../../common/protocol/boards-service'; +import { NotificationCenter } from '../notification-center'; +import { ReactDialog } from '../theia/dialogs/dialogs'; +import { BoardsConfigComponent } from './boards-config-component'; +import { + BoardsServiceProvider, + EditBoardsConfigActionParams, +} from './boards-service-provider'; + +@injectable() +export class BoardsConfigDialogProps extends DialogProps {} + +@injectable() +export class BoardsConfigDialog extends ReactDialog { + @inject(BoardsServiceProvider) + private readonly boardsServiceProvider: BoardsServiceProvider; + @inject(NotificationCenter) + private readonly notificationCenter: NotificationCenter; + @inject(FrontendApplicationStateService) + private readonly appStateService: FrontendApplicationStateService; + + private readonly onFilterTextDidChangeEmitter: Emitter; + private readonly onBoardSelected = (board: BoardIdentifier): void => { + this._boardsConfig.selectedBoard = board; + this.update(); + }; + private readonly onPortSelected = (port: PortIdentifier): void => { + this._boardsConfig.selectedPort = port; + this.update(); + }; + private readonly setFocusNode = (element: HTMLElement | undefined): void => { + this.focusNode = element; + }; + private readonly searchBoards = (options: { + query?: string; + }): Promise => { + return this.boardsServiceProvider.searchBoards(options); + }; + private readonly ports = ( + predicate?: (port: DetectedPort) => boolean + ): readonly DetectedPort[] => { + return this.boardsServiceProvider.boardList.ports(predicate); + }; + private _boardsConfig: BoardsConfig; + private focusNode: HTMLElement | undefined; + + constructor( + @inject(BoardsConfigDialogProps) + protected override readonly props: BoardsConfigDialogProps + ) { + super({ ...props, maxWidth: 500 }); + this.node.id = 'select-board-dialog-container'; + this.contentNode.classList.add('select-board-dialog'); + this.appendCloseButton( + nls.localize('vscode/issueMainService/cancel', 'Cancel') + ); + this.appendAcceptButton(nls.localize('vscode/issueMainService/ok', 'OK')); + this._boardsConfig = emptyBoardsConfig(); + this.onFilterTextDidChangeEmitter = new Emitter(); + } + + @postConstruct() + protected init(): void { + this.boardsServiceProvider.onBoardListDidChange(() => { + this._boardsConfig = deepClone(this.boardsServiceProvider.boardsConfig); + this.update(); + }); // Do not add to `toDispose`! + this._boardsConfig = deepClone(this.boardsServiceProvider.boardsConfig); + } + + override async open( + params?: EditBoardsConfigActionParams + ): Promise { + if (params) { + if (typeof params?.query === 'string') { + this.onFilterTextDidChangeEmitter.fire(params?.query); + } + if (params?.selectedPort) { + this._boardsConfig.selectedPort = params.selectedPort; + } + if (params?.selectedBoard) { + this._boardsConfig.selectedBoard = params.selectedBoard; + } + } + return super.open(); + } + + protected override onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + this.update(); + } + + protected override render(): ReactNode { + return ( + <> +
+
+
+ {nls.localize( + 'arduino/board/configDialog1', + 'Select both a Board and a Port if you want to upload a sketch.' + )} +
+
+ {nls.localize( + 'arduino/board/configDialog2', + 'If you only select a Board you will be able to compile, but not to upload your sketch.' + )} +
+
+
+
+
+ +
+
+ + ); + } + + protected override onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + if (this.focusNode instanceof HTMLInputElement) { + this.focusNode.select(); + } + (this.focusNode || this.node).focus(); + } + + protected override handleEnter(event: KeyboardEvent): boolean | void { + if (event.target instanceof HTMLTextAreaElement) { + return false; + } + } + + protected override isValid(value: BoardsConfig): DialogError { + if (!value.selectedBoard) { + if (value.selectedPort) { + return nls.localize( + 'arduino/board/pleasePickBoard', + 'Please pick a board connected to the port you have selected.' + ); + } + return false; + } + return ''; + } + + get value(): BoardsConfig { + return this._boardsConfig; + } +} diff --git a/arduino-ide-extension/src/browser/boards/boards-config.tsx b/arduino-ide-extension/src/browser/boards/boards-config.tsx deleted file mode 100644 index 8781981e6..000000000 --- a/arduino-ide-extension/src/browser/boards/boards-config.tsx +++ /dev/null @@ -1,432 +0,0 @@ -import * as React from '@theia/core/shared/react'; -import { Event } from '@theia/core/lib/common/event'; -import { notEmpty } from '@theia/core/lib/common/objects'; -import { MaybePromise } from '@theia/core/lib/common/types'; -import { DisposableCollection } from '@theia/core/lib/common/disposable'; -import { - Board, - Port, - BoardConfig as ProtocolBoardConfig, - BoardWithPackage, -} from '../../common/protocol/boards-service'; -import { NotificationCenter } from '../notification-center'; -import { - AvailableBoard, - BoardsServiceProvider, -} from './boards-service-provider'; -import { naturalCompare } from '../../common/utils'; -import { nls } from '@theia/core/lib/common'; -import { FrontendApplicationState } from '@theia/core/lib/common/frontend-application-state'; - -export namespace BoardsConfig { - export type Config = ProtocolBoardConfig; - - export interface Props { - readonly boardsServiceProvider: BoardsServiceProvider; - readonly notificationCenter: NotificationCenter; - readonly onConfigChange: (config: Config) => void; - readonly onFocusNodeSet: (element: HTMLElement | undefined) => void; - readonly onFilteredTextDidChangeEvent: Event; - readonly onAppStateDidChange: Event; - } - - export interface State extends Config { - searchResults: Array; - knownPorts: Port[]; - showAllPorts: boolean; - query: string; - } -} - -export abstract class Item extends React.Component<{ - item: T; - label: string; - selected: boolean; - onClick: (item: T) => void; - missing?: boolean; - details?: string; -}> { - override render(): React.ReactNode { - const { selected, label, missing, details } = this.props; - const classNames = ['item']; - if (selected) { - classNames.push('selected'); - } - if (missing === true) { - classNames.push('missing'); - } - return ( -
-
{label}
- {!details ? '' :
{details}
} - {!selected ? ( - '' - ) : ( -
- -
- )} -
- ); - } - - protected onClick = () => { - this.props.onClick(this.props.item); - }; -} - -export class BoardsConfig extends React.Component< - BoardsConfig.Props, - BoardsConfig.State -> { - protected toDispose = new DisposableCollection(); - - constructor(props: BoardsConfig.Props) { - super(props); - - const { boardsConfig } = props.boardsServiceProvider; - this.state = { - searchResults: [], - knownPorts: [], - showAllPorts: false, - query: '', - ...boardsConfig, - }; - } - - override componentDidMount(): void { - this.toDispose.pushAll([ - this.props.onAppStateDidChange((state) => { - if (state === 'ready') { - this.updateBoards(); - this.updatePorts( - this.props.boardsServiceProvider.availableBoards - .map(({ port }) => port) - .filter(notEmpty) - ); - } - }), - this.props.boardsServiceProvider.onAvailablePortsChanged( - ({ newState, oldState }) => { - const removedPorts = oldState.filter( - (oldPort) => - !newState.find((newPort) => Port.sameAs(newPort, oldPort)) - ); - this.updatePorts(newState, removedPorts); - } - ), - this.props.boardsServiceProvider.onBoardsConfigChanged( - ({ selectedBoard, selectedPort }) => { - this.setState({ selectedBoard, selectedPort }, () => - this.fireConfigChanged() - ); - } - ), - this.props.notificationCenter.onPlatformDidInstall(() => - this.updateBoards(this.state.query) - ), - this.props.notificationCenter.onPlatformDidUninstall(() => - this.updateBoards(this.state.query) - ), - this.props.notificationCenter.onIndexUpdateDidComplete(() => - this.updateBoards(this.state.query) - ), - this.props.notificationCenter.onDaemonDidStart(() => - this.updateBoards(this.state.query) - ), - this.props.notificationCenter.onDaemonDidStop(() => - this.setState({ searchResults: [] }) - ), - this.props.onFilteredTextDidChangeEvent((query) => - this.setState({ query }, () => this.updateBoards(this.state.query)) - ), - ]); - } - - override componentWillUnmount(): void { - this.toDispose.dispose(); - } - - protected fireConfigChanged(): void { - const { selectedBoard, selectedPort } = this.state; - this.props.onConfigChange({ selectedBoard, selectedPort }); - } - - protected updateBoards = ( - eventOrQuery: React.ChangeEvent | string = '' - ) => { - const query = - typeof eventOrQuery === 'string' - ? eventOrQuery - : eventOrQuery.target.value.toLowerCase(); - this.setState({ query }); - this.queryBoards({ query }).then((searchResults) => - this.setState({ searchResults }) - ); - }; - - protected updatePorts = (ports: Port[] = [], removedPorts: Port[] = []) => { - this.queryPorts(Promise.resolve(ports)).then(({ knownPorts }) => { - let { selectedPort } = this.state; - // If the currently selected port is not available anymore, unset the selected port. - if (removedPorts.some((port) => Port.sameAs(port, selectedPort))) { - selectedPort = undefined; - } - this.setState({ knownPorts, selectedPort }, () => - this.fireConfigChanged() - ); - }); - }; - - protected queryBoards = ( - options: { query?: string } = {} - ): Promise> => { - return this.props.boardsServiceProvider.searchBoards(options); - }; - - protected get availablePorts(): MaybePromise { - return this.props.boardsServiceProvider.availableBoards - .map(({ port }) => port) - .filter(notEmpty); - } - - protected get availableBoards(): AvailableBoard[] { - return this.props.boardsServiceProvider.availableBoards; - } - - protected queryPorts = async ( - availablePorts: MaybePromise = this.availablePorts - ) => { - // Available ports must be sorted in this order: - // 1. Serial with recognized boards - // 2. Serial with guessed boards - // 3. Serial with incomplete boards - // 4. Network with recognized boards - // 5. Other protocols with recognized boards - const ports = (await availablePorts).sort((left: Port, right: Port) => { - if (left.protocol === 'serial' && right.protocol !== 'serial') { - return -1; - } else if (left.protocol !== 'serial' && right.protocol === 'serial') { - return 1; - } else if (left.protocol === 'network' && right.protocol !== 'network') { - return -1; - } else if (left.protocol !== 'network' && right.protocol === 'network') { - return 1; - } else if (left.protocol === right.protocol) { - // We show ports, including those that have guessed - // or unrecognized boards, so we must sort those too. - const leftBoard = this.availableBoards.find( - (board) => board.port === left - ); - const rightBoard = this.availableBoards.find( - (board) => board.port === right - ); - if (leftBoard && !rightBoard) { - return -1; - } else if (!leftBoard && rightBoard) { - return 1; - } else if (leftBoard?.state! < rightBoard?.state!) { - return -1; - } else if (leftBoard?.state! > rightBoard?.state!) { - return 1; - } - } - return naturalCompare(left.address, right.address); - }); - return { knownPorts: ports }; - }; - - protected toggleFilterPorts = () => { - this.setState({ showAllPorts: !this.state.showAllPorts }); - }; - - protected selectPort = (selectedPort: Port | undefined) => { - this.setState({ selectedPort }, () => this.fireConfigChanged()); - }; - - protected selectBoard = (selectedBoard: BoardWithPackage | undefined) => { - this.setState({ selectedBoard }, () => this.fireConfigChanged()); - }; - - protected focusNodeSet = (element: HTMLElement | null) => { - this.props.onFocusNodeSet(element || undefined); - }; - - override render(): React.ReactNode { - return ( - <> - {this.renderContainer( - nls.localize('arduino/board/boards', 'boards'), - this.renderBoards.bind(this) - )} - {this.renderContainer( - nls.localize('arduino/board/ports', 'ports'), - this.renderPorts.bind(this), - this.renderPortsFooter.bind(this) - )} - - ); - } - - protected renderContainer( - title: string, - contentRenderer: () => React.ReactNode, - footerRenderer?: () => React.ReactNode - ): React.ReactNode { - return ( -
-
-
{title}
- {contentRenderer()} -
{footerRenderer ? footerRenderer() : ''}
-
-
- ); - } - - protected renderBoards(): React.ReactNode { - const { selectedBoard, searchResults, query } = this.state; - // Board names are not unique per core https://github.com/arduino/arduino-pro-ide/issues/262#issuecomment-661019560 - // It is tricky when the core is not yet installed, no FQBNs are available. - const distinctBoards = new Map(); - const toKey = ({ name, packageName, fqbn }: Board.Detailed) => - !!fqbn ? `${name}-${packageName}-${fqbn}` : `${name}-${packageName}`; - for (const board of Board.decorateBoards(selectedBoard, searchResults)) { - const key = toKey(board); - if (!distinctBoards.has(key)) { - distinctBoards.set(key, board); - } - } - - const boardsList = Array.from(distinctBoards.values()).map((board) => ( - - key={toKey(board)} - item={board} - label={board.name} - details={board.details} - selected={board.selected} - onClick={this.selectBoard} - missing={board.missing} - /> - )); - - return ( - -
- - -
- {boardsList.length > 0 ? ( -
{boardsList}
- ) : ( -
- {nls.localize( - 'arduino/board/noBoardsFound', - 'No boards found for "{0}"', - query - )} -
- )} -
- ); - } - - protected renderPorts(): React.ReactNode { - let ports = [] as Port[]; - if (this.state.showAllPorts) { - ports = this.state.knownPorts; - } else { - ports = this.state.knownPorts.filter( - Port.visiblePorts(this.availableBoards) - ); - } - return !ports.length ? ( -
- {nls.localize('arduino/board/noPortsDiscovered', 'No ports discovered')} -
- ) : ( -
- {ports.map((port) => ( - - key={`${Port.keyOf(port)}`} - item={port} - label={Port.toString(port)} - selected={Port.sameAs(this.state.selectedPort, port)} - onClick={this.selectPort} - /> - ))} -
- ); - } - - protected renderPortsFooter(): React.ReactNode { - return ( -
- -
- ); - } -} - -export namespace BoardsConfig { - export namespace Config { - export function sameAs(config: Config, other: Config | Board): boolean { - const { selectedBoard, selectedPort } = config; - if (Board.is(other)) { - return ( - !!selectedBoard && - Board.equals(other, selectedBoard) && - Port.sameAs(selectedPort, other.port) - ); - } - return sameAs(config, other); - } - - export function equals(left: Config, right: Config): boolean { - return ( - left.selectedBoard === right.selectedBoard && - left.selectedPort === right.selectedPort - ); - } - - export function toString( - config: Config, - options: { default: string } = { default: '' } - ): string { - const { selectedBoard, selectedPort: port } = config; - if (!selectedBoard) { - return options.default; - } - const { name } = selectedBoard; - return `${name}${port ? ` at ${port.address}` : ''}`; - } - } -} diff --git a/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts b/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts index f323621d3..067863d31 100644 --- a/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts +++ b/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts @@ -7,7 +7,12 @@ import { DisposableCollection, } from '@theia/core/lib/common/disposable'; import { BoardsServiceProvider } from './boards-service-provider'; -import { Board, ConfigOption, Programmer } from '../../common/protocol'; +import { + BoardIdentifier, + ConfigOption, + isBoardIdentifierChangeEvent, + Programmer, +} from '../../common/protocol'; import { FrontendApplicationContribution } from '@theia/core/lib/browser'; import { BoardsDataStore } from './boards-data-store'; import { MainMenuManager } from '../../common/main-menu-manager'; @@ -30,7 +35,7 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution { protected readonly boardsDataStore: BoardsDataStore; @inject(BoardsServiceProvider) - protected readonly boardsServiceClient: BoardsServiceProvider; + protected readonly boardsServiceProvider: BoardsServiceProvider; @inject(FrontendApplicationStateService) private readonly appStateService: FrontendApplicationStateService; @@ -43,21 +48,21 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution { .reachedState('ready') .then(() => this.updateMenuActions( - this.boardsServiceClient.boardsConfig.selectedBoard + this.boardsServiceProvider.boardsConfig.selectedBoard ) ); this.boardsDataStore.onChanged(() => - this.updateMenuActions( - this.boardsServiceClient.boardsConfig.selectedBoard - ) - ); - this.boardsServiceClient.onBoardsConfigChanged(({ selectedBoard }) => - this.updateMenuActions(selectedBoard) + this.updateMenuActions(this.boardsServiceProvider.boardsConfig.selectedBoard) ); + this.boardsServiceProvider.onBoardsConfigDidChange((event) => { + if (isBoardIdentifierChangeEvent(event)) { + this.updateMenuActions(event.selectedBoard); + } + }); } protected async updateMenuActions( - selectedBoard: Board | undefined + selectedBoard: BoardIdentifier | undefined ): Promise { return this.queue.add(async () => { this.toDisposeOnBoardChange.dispose(); diff --git a/arduino-ide-extension/src/browser/boards/boards-data-store.ts b/arduino-ide-extension/src/browser/boards/boards-data-store.ts index cdf73d18f..52507a577 100644 --- a/arduino-ide-extension/src/browser/boards/boards-data-store.ts +++ b/arduino-ide-extension/src/browser/boards/boards-data-store.ts @@ -1,63 +1,64 @@ -import { injectable, inject, named } from '@theia/core/shared/inversify'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { LocalStorageService } from '@theia/core/lib/browser/storage-service'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Emitter, Event } from '@theia/core/lib/common/event'; import { ILogger } from '@theia/core/lib/common/logger'; import { deepClone } from '@theia/core/lib/common/objects'; -import { Event, Emitter } from '@theia/core/lib/common/event'; -import { - FrontendApplicationContribution, - LocalStorageService, -} from '@theia/core/lib/browser'; -import { notEmpty } from '../../common/utils'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; import { + BoardDetails, BoardsService, ConfigOption, - BoardDetails, Programmer, } from '../../common/protocol'; +import { notEmpty } from '../../common/utils'; import { NotificationCenter } from '../notification-center'; @injectable() export class BoardsDataStore implements FrontendApplicationContribution { @inject(ILogger) @named('store') - protected readonly logger: ILogger; - + private readonly logger: ILogger; @inject(BoardsService) - protected readonly boardsService: BoardsService; - + private readonly boardsService: BoardsService; @inject(NotificationCenter) - protected readonly notificationCenter: NotificationCenter; - + private readonly notificationCenter: NotificationCenter; @inject(LocalStorageService) - protected readonly storageService: LocalStorageService; + private readonly storageService: LocalStorageService; - protected readonly onChangedEmitter = new Emitter(); + private readonly onChangedEmitter = new Emitter(); + private readonly toDispose = new DisposableCollection(this.onChangedEmitter); onStart(): void { - this.notificationCenter.onPlatformDidInstall(async ({ item }) => { - const dataDidChangePerFqbn: string[] = []; - for (const fqbn of item.boards - .map(({ fqbn }) => fqbn) - .filter(notEmpty) - .filter((fqbn) => !!fqbn)) { - const key = this.getStorageKey(fqbn); - let data = await this.storageService.getData< - ConfigOption[] | undefined - >(key); - if (!data || !data.length) { - const details = await this.getBoardDetailsSafe(fqbn); - if (details) { - data = details.configOptions; - if (data.length) { - await this.storageService.setData(key, data); - dataDidChangePerFqbn.push(fqbn); + this.toDispose.push( + this.notificationCenter.onPlatformDidInstall(async ({ item }) => { + const dataDidChangePerFqbn: string[] = []; + for (const fqbn of item.boards + .map(({ fqbn }) => fqbn) + .filter(notEmpty) + .filter((fqbn) => !!fqbn)) { + const key = this.getStorageKey(fqbn); + let data = await this.storageService.getData(key); + if (!data || !data.length) { + const details = await this.getBoardDetailsSafe(fqbn); + if (details) { + data = details.configOptions; + if (data.length) { + await this.storageService.setData(key, data); + dataDidChangePerFqbn.push(fqbn); + } } } } - } - if (dataDidChangePerFqbn.length) { - this.fireChanged(...dataDidChangePerFqbn); - } - }); + if (dataDidChangePerFqbn.length) { + this.fireChanged(...dataDidChangePerFqbn); + } + }) + ); + } + + onStop(): void { + this.toDispose.dispose(); } get onChanged(): Event { @@ -65,7 +66,7 @@ export class BoardsDataStore implements FrontendApplicationContribution { } async appendConfigToFqbn( - fqbn: string | undefined, + fqbn: string | undefined ): Promise { if (!fqbn) { return undefined; @@ -100,12 +101,13 @@ export class BoardsDataStore implements FrontendApplicationContribution { return data; } - async selectProgrammer( - { - fqbn, - selectedProgrammer, - }: { fqbn: string; selectedProgrammer: Programmer }, - ): Promise { + async selectProgrammer({ + fqbn, + selectedProgrammer, + }: { + fqbn: string; + selectedProgrammer: Programmer; + }): Promise { const data = deepClone(await this.getData(fqbn)); const { programmers } = data; if (!programmers.find((p) => Programmer.equals(selectedProgrammer, p))) { @@ -120,13 +122,15 @@ export class BoardsDataStore implements FrontendApplicationContribution { return true; } - async selectConfigOption( - { - fqbn, - option, - selectedValue, - }: { fqbn: string; option: string; selectedValue: string } - ): Promise { + async selectConfigOption({ + fqbn, + option, + selectedValue, + }: { + fqbn: string; + option: string; + selectedValue: string; + }): Promise { const data = deepClone(await this.getData(fqbn)); const { configOptions } = data; const configOption = configOptions.find((c) => c.option === option); diff --git a/arduino-ide-extension/src/browser/boards/boards-service-provider.ts b/arduino-ide-extension/src/browser/boards/boards-service-provider.ts index 279a2e3fa..32e43dc47 100644 --- a/arduino-ide-extension/src/browser/boards/boards-service-provider.ts +++ b/arduino-ide-extension/src/browser/boards/boards-service-provider.ts @@ -1,37 +1,138 @@ -import { injectable, inject } from '@theia/core/shared/inversify'; -import { Emitter } from '@theia/core/lib/common/event'; -import { ILogger } from '@theia/core/lib/common/logger'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { Command, CommandContribution, CommandRegistry, CommandService, } from '@theia/core/lib/common/command'; -import { MessageService } from '@theia/core/lib/common/message-service'; -import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; -import { RecursiveRequired } from '../../common/types'; +import type { Disposable } from '@theia/core/lib/common/disposable'; +import { Emitter } from '@theia/core/lib/common/event'; +import { ILogger } from '@theia/core/lib/common/logger'; +import { nls } from '@theia/core/lib/common/nls'; +import type { Mutable } from '@theia/core/lib/common/types'; import { - Port, - Board, + inject, + injectable, + optional, + postConstruct, +} from '@theia/core/shared/inversify'; +import { + OutputChannel, + OutputChannelManager, +} from '@theia/output/lib/browser/output-channel'; +import { + BoardIdentifier, + boardIdentifierEquals, + BoardsConfig, + BoardsConfigChangeEvent, BoardsService, - BoardsPackage, - AttachedBoardsChangeEvent, - BoardWithPackage, BoardUserField, - AvailablePorts, + BoardWithPackage, + DetectedPorts, + emptyBoardsConfig, + isBoardIdentifier, + isPortIdentifier, + Port, + PortIdentifier, + portIdentifierEquals, } from '../../common/protocol'; -import { BoardsConfig } from './boards-config'; -import { naturalCompare } from '../../common/utils'; -import { NotificationCenter } from '../notification-center'; -import { StorageWrapper } from '../storage-wrapper'; -import { nls } from '@theia/core/lib/common'; -import { Deferred } from '@theia/core/lib/common/promise-util'; -import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; -import { Unknown } from '../../common/nls'; +import { + BoardList, + BoardListHistory, + createBoardList, + isBoardListHistory, +} from '../../common/protocol/board-list'; +import { Defined } from '../../common/types'; import { StartupTask, StartupTaskProvider, } from '../../electron-common/startup-task'; +import { NotificationCenter } from '../notification-center'; +import { StorageWrapper } from '../storage-wrapper'; +import { BoardsDataStore } from './boards-data-store'; + +const boardListHistoryStorageKey = 'arduino-ide:boardListHistory'; +const selectedPortStorageKey = 'arduino-ide:selectedPort'; +const selectedBoardStorageKey = 'arduino-ide:selectedBoard'; + +type UpdateBoardsConfigReason = + /** + * Restore previous state at IDE startup. + */ + | 'restore' + /** + * The board and the optional port were changed from the dialog. + */ + | 'dialog' + /** + * The board and the port were updated from the board select toolbar. + */ + | 'toolbar' + /** + * The board and port configuration was inherited from another window. + */ + | 'inherit'; + +interface RefreshBoardListParams { + detectedPorts?: DetectedPorts; + boardsConfig?: BoardsConfig; + boardListHistory?: BoardListHistory; +} + +export type SelectBoardsConfigActionParams = Defined; +export interface SelectBoardsConfigAction { + (params: SelectBoardsConfigActionParams): void; +} + +export interface EditBoardsConfigActionParams { + readonly selectedPort?: PortIdentifier; + readonly selectedBoard?: BoardIdentifier; + /** + * Query string to search for. Or `'clear-if-not-empty'` action when the `` should be cleared. It's a NOOP, if already empty. Use `'clear'` to force rerun the search. + */ + readonly query?: + | string + | Readonly<{ action: 'clear-if-not-empty' | 'clear' }>; +} +export interface EditBoardsConfigAction { + (params?: EditBoardsConfigActionParams): void; +} + +export interface BoardListUI extends BoardList { + /** + * Sets the frontend's port and board configuration according to the params. + */ + onSelect: SelectBoardsConfigAction; + /** + * Opens up the boards config dialog with the port and (optional) board to select in the dialog. + * Calling this function does not immediately change the frontend's port and board config, but + * preselects items in the dialog. + */ + onEdit: EditBoardsConfigAction; +} + +@injectable() +export class BoardListDumper implements Disposable { + @inject(OutputChannelManager) + private readonly outputChannelManager: OutputChannelManager; + + private outputChannel: OutputChannel | undefined; + + dump(boardList: BoardList): void { + if (!this.outputChannel) { + this.outputChannel = this.outputChannelManager.getChannel( + 'Developer (Arduino)' + ); + } + this.outputChannel.show({ preserveFocus: true }); + this.outputChannel.append(boardList.toString() + '\n'); + } + + dispose(): void { + this.outputChannel?.dispose(); + } +} @injectable() export class BoardsServiceProvider @@ -41,402 +142,281 @@ export class BoardsServiceProvider CommandContribution { @inject(ILogger) - protected logger: ILogger; - - @inject(MessageService) - protected messageService: MessageService; - + private readonly logger: ILogger; @inject(BoardsService) - protected boardsService: BoardsService; - + private readonly boardsService: BoardsService; @inject(CommandService) - protected commandService: CommandService; - + private readonly commandService: CommandService; @inject(NotificationCenter) - protected notificationCenter: NotificationCenter; - + private readonly notificationCenter: NotificationCenter; @inject(FrontendApplicationStateService) private readonly appStateService: FrontendApplicationStateService; - - protected readonly onBoardsConfigChangedEmitter = - new Emitter(); - protected readonly onAvailableBoardsChangedEmitter = new Emitter< - AvailableBoard[] - >(); - protected readonly onAvailablePortsChangedEmitter = new Emitter<{ - newState: Port[]; - oldState: Port[]; - }>(); - private readonly inheritedConfig = new Deferred(); - + @inject(BoardsDataStore) + private readonly boardsDataStore: BoardsDataStore; + @optional() + @inject(BoardListDumper) + private readonly boardListDumper?: BoardListDumper; + + private _boardsConfig = emptyBoardsConfig(); + private _detectedPorts: DetectedPorts = {}; + private _boardList: BoardListUI; + private _boardListHistory: Mutable = {}; + + private readonly boardsConfigDidChangeEmitter = + new Emitter(); + readonly onBoardsConfigDidChange = this.boardsConfigDidChangeEmitter.event; + + private readonly boardListDidChangeEmitter = 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 + * Emits an event on board config (port or board) change, and when the discovery (`board list --watch`) detected any changes. */ - protected latestValidBoardsConfig: - | RecursiveRequired - | undefined = undefined; - protected latestBoardsConfig: BoardsConfig.Config | undefined = undefined; - protected _boardsConfig: BoardsConfig.Config = {}; - protected _attachedBoards: Board[] = []; // This does not contain the `Unknown` boards. They're visible from the available ports only. - protected _availablePorts: Port[] = []; - protected _availableBoards: AvailableBoard[] = []; - - private lastBoardsConfigOnUpload: BoardsConfig.Config | undefined; - private lastAvailablePortsOnUpload: Port[] | undefined; - private boardConfigToAutoSelect: BoardsConfig.Config | undefined; + readonly onBoardListDidChange = this.boardListDidChangeEmitter.event; - /** - * Unlike `onAttachedBoardsChanged` this event fires when the user modifies the selected board in the IDE.\ - * This event also fires, when the boards package was not available for the currently selected board, - * and the user installs the board package. Note: installing a board package will set the `fqbn` of the - * currently selected board. - * - * This event is also emitted when the board package for the currently selected board was uninstalled. - */ - readonly onBoardsConfigChanged = this.onBoardsConfigChangedEmitter.event; - readonly onAvailableBoardsChanged = - this.onAvailableBoardsChangedEmitter.event; - readonly onAvailablePortsChanged = this.onAvailablePortsChangedEmitter.event; - - private readonly _reconciled = new Deferred(); + @postConstruct() + protected init(): void { + const boardList = createBoardList(this._detectedPorts, this._boardsConfig); + this._boardList = this.createBoardListUI(boardList); + } onStart(): void { - this.notificationCenter.onAttachedBoardsDidChange( - this.notifyAttachedBoardsChanged.bind(this) - ); - this.notificationCenter.onPlatformDidInstall( - this.notifyPlatformInstalled.bind(this) - ); - this.notificationCenter.onPlatformDidUninstall( - this.notifyPlatformUninstalled.bind(this) + this.notificationCenter.onDetectedPortsDidChange(({ detectedPorts }) => + this.refreshBoardList({ detectedPorts }) ); - this.appStateService.reachedState('ready').then(async () => { - const [state] = await Promise.all([ - this.boardsService.getState(), - this.loadState(), + const [detectedPorts, storedState] = await Promise.all([ + this.boardsService.getDetectedPorts(), + this.restoreState(), ]); - const { boards: attachedBoards, ports: availablePorts } = - AvailablePorts.split(state); - this._attachedBoards = attachedBoards; - const oldState = this._availablePorts.slice(); - this._availablePorts = availablePorts; - this.onAvailablePortsChangedEmitter.fire({ - newState: this._availablePorts.slice(), - oldState, - }); - - await this.reconcileAvailableBoards(); - - this.tryReconnect(); - this._reconciled.resolve(); + const { selectedBoard, selectedPort, boardListHistory } = storedState; + const options: RefreshBoardListParams = { + boardListHistory, + detectedPorts, + }; + // If either the port or the board is set, restore it. Otherwise, do not restore nothing. + // It might override the inherited boards config from the other window on File > New Sketch + if (selectedBoard || selectedPort) { + options.boardsConfig = { selectedBoard, selectedPort }; + } + this.refreshBoardList(options); }); } + onStop(): void { + this.boardListDumper?.dispose(); + } + registerCommands(registry: CommandRegistry): void { registry.registerCommand(USE_INHERITED_CONFIG, { - execute: (inheritedConfig: BoardsConfig.Config) => - this.inheritedConfig.resolve(inheritedConfig), + execute: (boardsConfig: BoardsConfig) => + // TODO: inherit board config if stored per sketch? + this.update( + boardsConfig.selectedBoard, + boardsConfig.selectedPort, + 'inherit' + ), + }); + if (this.boardListDumper) { + registry.registerCommand(DUMP_BOARD_LIST, { + execute: () => this.boardListDumper?.dump(this._boardList), + }); + } + registry.registerCommand(CLEAR_BOARD_LIST_HISTORY, { + execute: () => { + this.refreshBoardList({ boardListHistory: {} }); + this.setData(boardListHistoryStorageKey, undefined); + }, + }); + registry.registerCommand(CLEAR_BOARDS_CONFIG, { + execute: () => { + this.refreshBoardList({ boardsConfig: emptyBoardsConfig() }); + Promise.all([ + this.setData(selectedPortStorageKey, undefined), + this.setData(selectedBoardStorageKey, undefined), + ]); + }, }); } - get reconciled(): Promise { - return this._reconciled.promise; + tasks(): StartupTask[] { + return [ + { + command: USE_INHERITED_CONFIG.id, + args: [this._boardsConfig], + }, + ]; } - snapshotBoardDiscoveryOnUpload(): void { - this.lastBoardsConfigOnUpload = this._boardsConfig; - this.lastAvailablePortsOnUpload = this._availablePorts; + private refreshBoardList(params?: RefreshBoardListParams): void { + if (params?.detectedPorts) { + this._detectedPorts = params.detectedPorts; + } + if (params?.boardsConfig) { + this._boardsConfig = params.boardsConfig; + } + if (params?.boardListHistory) { + this._boardListHistory = params.boardListHistory; + } + const boardList = createBoardList( + this._detectedPorts, + this._boardsConfig, + this._boardListHistory + ); + this._boardList = this.createBoardListUI(boardList); + this.boardListDidChangeEmitter.fire(this._boardList); } - clearBoardDiscoverySnapshot(): void { - this.lastBoardsConfigOnUpload = undefined; - this.lastAvailablePortsOnUpload = undefined; + private createBoardListUI(boardList: BoardList): BoardListUI { + return Object.assign(boardList, { + onSelect: this.onBoardsConfigSelect.bind(this), + onEdit: this.onBoardsConfigEdit.bind(this), + }); } - attemptPostUploadAutoSelect(): void { - setTimeout(() => { - if (this.lastBoardsConfigOnUpload && this.lastAvailablePortsOnUpload) { - this.attemptAutoSelect({ - ports: this._availablePorts, - boards: this._availableBoards, - }); - } - }, 2000); // 2 second delay same as IDE 1.8 + private onBoardsConfigSelect(params: SelectBoardsConfigActionParams): void { + this.updateConfig(params, 'toolbar'); } - private attemptAutoSelect( - newState: AttachedBoardsChangeEvent['newState'] - ): void { - this.deriveBoardConfigToAutoSelect(newState); - this.tryReconnect(); + private async onBoardsConfigEdit( + params?: EditBoardsConfigActionParams + ): Promise { + const boardsConfig = await this.commandService.executeCommand< + BoardsConfig | undefined + >('arduino-open-boards-dialog', params); + if (boardsConfig) { + this.update( + boardsConfig.selectedBoard, + boardsConfig.selectedPort, + 'dialog' + ); + } } - private deriveBoardConfigToAutoSelect( - newState: AttachedBoardsChangeEvent['newState'] + private update( + selectedBoard: BoardIdentifier | undefined, + selectedPort: PortIdentifier | undefined, + reason?: UpdateBoardsConfigReason ): void { - if (!this.lastBoardsConfigOnUpload || !this.lastAvailablePortsOnUpload) { - this.boardConfigToAutoSelect = undefined; - return; - } - - const oldPorts = this.lastAvailablePortsOnUpload; - const { ports: newPorts, boards: newBoards } = newState; - - const appearedPorts = - oldPorts.length > 0 - ? newPorts.filter((newPort: Port) => - oldPorts.every((oldPort: Port) => !Port.sameAs(newPort, oldPort)) - ) - : newPorts; - - for (const port of appearedPorts) { - const boardOnAppearedPort = newBoards.find((board: Board) => - Port.sameAs(board.port, port) + if (selectedBoard && selectedPort) { + this.updateConfig( + { + selectedBoard, + selectedPort, + }, + reason ); + } else if (selectedBoard) { + this.updateBoard(selectedBoard); + } else if (selectedPort) { + this.updatePort(selectedPort); + } + } - const lastBoardsConfigOnUpload = this.lastBoardsConfigOnUpload; - - if (boardOnAppearedPort && lastBoardsConfigOnUpload.selectedBoard) { - const boardIsSameHardware = Board.hardwareIdEquals( - boardOnAppearedPort, - lastBoardsConfigOnUpload.selectedBoard - ); - - const boardIsSameFqbn = Board.sameAs( - boardOnAppearedPort, - lastBoardsConfigOnUpload.selectedBoard + updateConfig( + boardsConfig: Defined, + reason?: UpdateBoardsConfigReason + ): boolean { + const selectedBoard = boardsConfig.selectedBoard; + const previousSelectedBoard = this._boardsConfig.selectedBoard; + const selectedPort = boardsConfig.selectedPort; + const previousSelectedPort = this._boardsConfig.selectedPort; + + if (selectedBoard.fqbn && (reason === 'toolbar' || reason === 'inherit')) { + const [, , , ...rest] = selectedBoard.fqbn.split(':'); + if (rest && rest.length) { + console.log( + typeof this.boardsDataStore, + 'TODO: save update data store if reason is toolbar and the FQBN has options' ); - - if (!boardIsSameHardware && !boardIsSameFqbn) continue; - - let boardToAutoSelect = boardOnAppearedPort; - if (boardIsSameHardware && !boardIsSameFqbn) { - const { name, fqbn } = lastBoardsConfigOnUpload.selectedBoard; - - boardToAutoSelect = { - ...boardToAutoSelect, - name: - boardToAutoSelect.name === Unknown || !boardToAutoSelect.name - ? name - : boardToAutoSelect.name, - fqbn: boardToAutoSelect.fqbn || fqbn, - }; - } - - this.clearBoardDiscoverySnapshot(); - - this.boardConfigToAutoSelect = { - selectedBoard: boardToAutoSelect, - selectedPort: port, - }; - return; } } - } + if ( + previousSelectedBoard !== undefined && + boardIdentifierEquals(previousSelectedBoard, selectedBoard) + ) { + // the board did not change, fall back to port + return this.updatePort(selectedPort); + } - protected notifyAttachedBoardsChanged( - event: AttachedBoardsChangeEvent - ): void { - if (!AttachedBoardsChangeEvent.isEmpty(event)) { - this.logger.info('Attached boards and available ports changed:'); - this.logger.info(AttachedBoardsChangeEvent.toString(event)); - this.logger.info('------------------------------------------'); + if (selectedPort && selectedBoard) { + this._boardListHistory[Port.keyOf(selectedPort)] = selectedBoard; + } + if ( + previousSelectedPort !== undefined && + portIdentifierEquals(previousSelectedPort, selectedPort) + ) { + // the port did not change, fall back to board + return this.updateBoard(selectedBoard); } - this._attachedBoards = event.newState.boards; - const oldState = this._availablePorts.slice(); - this._availablePorts = event.newState.ports; - this.onAvailablePortsChangedEmitter.fire({ - newState: this._availablePorts.slice(), - oldState, - }); - this.reconcileAvailableBoards().then(() => { - const { uploadInProgress } = event; - // avoid attempting "auto-selection" while an - // upload is in progress - if (!uploadInProgress) { - this.attemptAutoSelect(event.newState); - } + this._boardsConfig.selectedBoard = selectedBoard; + this._boardsConfig.selectedPort = selectedPort; + this.boardsConfigDidChangeEmitter.fire({ + selectedBoard, + previousSelectedBoard, + previousSelectedPort, + selectedPort, }); + this.refreshBoardList(); + this.saveState(); + return true; } - protected notifyPlatformInstalled(event: { item: BoardsPackage }): void { - this.logger.info('Boards package installed: ', JSON.stringify(event)); - const { selectedBoard } = this.boardsConfig; - const { installedVersion, id } = event.item; - if (selectedBoard) { - const installedBoard = event.item.boards.find( - ({ name }) => name === selectedBoard.name - ); - if ( - installedBoard && - (!selectedBoard.fqbn || selectedBoard.fqbn === installedBoard.fqbn) - ) { - this.logger.info( - `Board package ${id}[${installedVersion}] was installed. Updating the FQBN of the currently selected ${selectedBoard.name} board. [FQBN: ${installedBoard.fqbn}]` - ); - this.boardsConfig = { - ...this.boardsConfig, - selectedBoard: installedBoard, - }; - return; - } - // The board name can change after install. - // This logic handles it "gracefully" by unselecting the board, so that we can avoid no FQBN is set error. - // https://github.com/arduino/arduino-cli/issues/620 - // https://github.com/arduino/arduino-pro-ide/issues/374 - if ( - BoardWithPackage.is(selectedBoard) && - selectedBoard.packageId === event.item.id && - !installedBoard - ) { - const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes'); - this.messageService - .warn( - nls.localize( - 'arduino/board/couldNotFindPreviouslySelected', - "Could not find previously selected board '{0}' in installed platform '{1}'. Please manually reselect the board you want to use. Do you want to reselect it now?", - selectedBoard.name, - event.item.name - ), - nls.localize('arduino/board/reselectLater', 'Reselect later'), - yes - ) - .then(async (answer) => { - if (answer === yes) { - this.commandService.executeCommand( - 'arduino-open-boards-dialog', - selectedBoard.name - ); - } - }); - this.boardsConfig = {}; - return; - } - // Trigger a board re-set. See: https://github.com/arduino/arduino-cli/issues/954 - // E.g: install `adafruit:avr`, then select `adafruit:avr:adafruit32u4` board, and finally install the required `arduino:avr` - this.boardsConfig = this.boardsConfig; + updateBoard(selectedBoard: BoardIdentifier): boolean { + const previousSelectedBoard = this._boardsConfig.selectedBoard; + if ( + previousSelectedBoard !== undefined && + boardIdentifierEquals(previousSelectedBoard, selectedBoard) + ) { + // NOOP if they're the same + return false; } + this._boardsConfig.selectedBoard = selectedBoard; + this.boardsConfigDidChangeEmitter.fire({ + previousSelectedBoard, + selectedBoard, + }); + this.refreshBoardList(); + this.saveState(); + return true; } - protected notifyPlatformUninstalled(event: { item: BoardsPackage }): void { - this.logger.info('Boards package uninstalled: ', JSON.stringify(event)); - const { selectedBoard } = this.boardsConfig; - if (selectedBoard && selectedBoard.fqbn) { - const uninstalledBoard = event.item.boards.find( - ({ name }) => name === selectedBoard.name - ); - if (uninstalledBoard && uninstalledBoard.fqbn === selectedBoard.fqbn) { - // We should not unset the FQBN, if the selected board is an attached, recognized board. - // Attach Uno and install AVR, select Uno. Uninstall the AVR core while Uno is selected. We do not want to discard the FQBN of the Uno board. - // Dev note: We cannot assume the `selectedBoard` is a type of `AvailableBoard`. - // When the user selects an `AvailableBoard` it works, but between app start/stops, - // it is just a FQBN, so we need to find the `selected` board among the `AvailableBoards` - const selectedAvailableBoard = AvailableBoard.is(selectedBoard) - ? selectedBoard - : this._availableBoards.find( - (availableBoard) => - Board.hardwareIdEquals(availableBoard, selectedBoard) || - Board.sameAs(availableBoard, selectedBoard) - ); - if ( - selectedAvailableBoard && - selectedAvailableBoard.selected && - selectedAvailableBoard.state === AvailableBoard.State.recognized - ) { - return; - } - this.logger.info( - `Board package ${event.item.id} was uninstalled. Discarding the FQBN of the currently selected ${selectedBoard.name} board.` - ); - const selectedBoardWithoutFqbn = { - name: selectedBoard.name, - // No FQBN - }; - this.boardsConfig = { - ...this.boardsConfig, - selectedBoard: selectedBoardWithoutFqbn, - }; - } + updatePort(selectedPort: PortIdentifier): boolean { + const selectedBoard = this._boardsConfig.selectedBoard; + const previousSelectedPort = this._boardsConfig.selectedPort; + if (selectedPort && selectedBoard) { + this._boardListHistory[Port.keyOf(selectedPort)] = selectedBoard; } - } - - protected tryReconnect(): boolean { - if (this.latestValidBoardsConfig && !this.canUploadTo(this.boardsConfig)) { - // ** Reconnect to a board unplugged from, and plugged back into the same port - for (const board of this.availableBoards.filter( - ({ state }) => state !== AvailableBoard.State.incomplete - )) { - if ( - Board.hardwareIdEquals( - this.latestValidBoardsConfig.selectedBoard, - board - ) - ) { - const { name, fqbn } = this.latestValidBoardsConfig.selectedBoard; - this.boardsConfig = { - selectedBoard: { - name: board.name === Unknown || !board.name ? name : board.name, - fqbn: board.fqbn || fqbn, - port: board.port, - }, - selectedPort: board.port, - }; - return true; - } - - 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; - } - } - // ** - - // ** Reconnect to a board whose port changed due to an upload - if (!this.boardConfigToAutoSelect) return false; - - this.boardsConfig = this.boardConfigToAutoSelect; - this.boardConfigToAutoSelect = undefined; - return true; - // ** + this._boardsConfig.selectedPort = selectedPort; + if ( + previousSelectedPort !== undefined && + portIdentifierEquals(previousSelectedPort, selectedPort) + ) { + // NOOP if they're the same + return false; } - return false; + this.boardsConfigDidChangeEmitter.fire({ + previousSelectedPort, + selectedPort, + }); + this.refreshBoardList(); + this.saveState(); + return true; } - set boardsConfig(config: BoardsConfig.Config) { - this.setBoardsConfig(config); - this.saveState().finally(() => - this.reconcileAvailableBoards().finally(() => - this.onBoardsConfigChangedEmitter.fire(this._boardsConfig) - ) - ); + get boardsConfig(): BoardsConfig { + return this._boardsConfig; } - get boardsConfig(): BoardsConfig.Config { - return this._boardsConfig; + get boardList(): BoardListUI { + return this._boardList; } - protected setBoardsConfig(config: BoardsConfig.Config): void { - this.logger.debug('Board config changed: ', JSON.stringify(config)); - this._boardsConfig = config; - this.latestBoardsConfig = this._boardsConfig; - if (this.canUploadTo(this._boardsConfig)) { - this.latestValidBoardsConfig = this._boardsConfig; - } + get detectedPorts(): DetectedPorts { + return this._detectedPorts; } async searchBoards({ query, - cores, }: { query?: string; cores?: string[]; @@ -459,293 +439,68 @@ export class BoardsServiceProvider return await this.boardsService.getBoardUserFields({ fqbn, protocol }); } - /** - * `true` if the `config.selectedBoard` is defined; hence can compile against the board. Otherwise, `false`. - */ - canVerify( - config: BoardsConfig.Config | undefined = this.boardsConfig, - options: { silent: boolean } = { silent: true } - ): config is BoardsConfig.Config & { selectedBoard: Board } { - if (!config) { - return false; - } - - if (!config.selectedBoard) { - if (!options.silent) { - this.messageService.warn( - nls.localize('arduino/board/noneSelected', 'No boards selected.'), - { - timeout: 3000, - } - ); - } - return false; - } - - return true; - } - - /** - * `true` if `canVerify`, the board has an FQBN and the `config.selectedPort` is also set, hence can upload to board. Otherwise, `false`. - */ - canUploadTo( - config: BoardsConfig.Config | undefined = this.boardsConfig, - options: { silent: boolean } = { silent: true } - ): config is RecursiveRequired { - if (!this.canVerify(config, options)) { - return false; - } - - const { name } = config.selectedBoard; - if (!config.selectedPort) { - if (!options.silent) { - this.messageService.warn( - nls.localize( - 'arduino/board/noPortsSelected', - "No ports selected for board: '{0}'.", - name - ), - { - timeout: 3000, - } - ); - } - return false; - } - - if (!config.selectedBoard.fqbn) { - if (!options.silent) { - this.messageService.warn( - nls.localize( - 'arduino/board/noFQBN', - 'The FQBN is not available for the selected board "{0}". Do you have the corresponding core installed?', - name - ), - { timeout: 3000 } - ); - } - return false; - } - - return true; - } - - get availableBoards(): AvailableBoard[] { - return this._availableBoards; - } - - /** - * @deprecated Do not use this API, it will be removed. This is a hack to be able to set the missing port `properties` before an upload. - * - * See: https://github.com/arduino/arduino-ide/pull/1335#issuecomment-1224355236. - */ - // TODO: remove this API and fix the selected board config store/restore correctly. - get availablePorts(): Port[] { - return this._availablePorts.slice(); - } - - async waitUntilAvailable( - what: Board & { port: Port }, - timeout?: number - ): Promise { - const find = (needle: Board & { port: Port }, haystack: AvailableBoard[]) => - haystack.find( - (board) => - Board.equals(needle, board) && Port.sameAs(needle.port, board.port) - ); - const timeoutTask = - !!timeout && timeout > 0 - ? new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Timeout after ${timeout} ms.`)), - timeout - ) - ) - : new Promise(() => { - /* never */ - }); - const waitUntilTask = new Promise((resolve) => { - let candidate = find(what, this.availableBoards); - if (candidate) { - resolve(); - return; - } - const disposable = this.onAvailableBoardsChanged((availableBoards) => { - candidate = find(what, availableBoards); - if (candidate) { - disposable.dispose(); - resolve(); - } - }); - }); - return await Promise.race([waitUntilTask, timeoutTask]); - } - - protected async reconcileAvailableBoards(): Promise { - const availablePorts = this._availablePorts; - // Unset the port on the user's config, if it is not available anymore. - if ( - this.boardsConfig.selectedPort && - !availablePorts.some((port) => - Port.sameAs(port, this.boardsConfig.selectedPort) - ) - ) { - this.setBoardsConfig({ - selectedBoard: this.boardsConfig.selectedBoard, - selectedPort: undefined, - }); - this.onBoardsConfigChangedEmitter.fire(this._boardsConfig); - } - const boardsConfig = this.boardsConfig; - const currentAvailableBoards = this._availableBoards; - const availableBoards: AvailableBoard[] = []; - const attachedBoards = this._attachedBoards.filter(({ port }) => !!port); - const availableBoardPorts = availablePorts.filter( - Port.visiblePorts(attachedBoards) - ); - - for (const boardPort of availableBoardPorts) { - const board = attachedBoards.find(({ port }) => - Port.sameAs(boardPort, port) - ); - // "board" will always be falsey for - // port that was originally mapped - // to unknown board and then selected - // manually by user - - const lastSelectedBoard = await this.getLastSelectedBoardOnPort( - boardPort - ); - - let availableBoard = {} as AvailableBoard; - if (board) { - availableBoard = { - ...board, - state: AvailableBoard.State.recognized, - selected: BoardsConfig.Config.sameAs(boardsConfig, board), - port: boardPort, - }; - } else if (lastSelectedBoard) { - // If the selected board is not recognized because it is a 3rd party board: https://github.com/arduino/arduino-cli/issues/623 - // We still want to show it without the red X in the boards toolbar: https://github.com/arduino/arduino-pro-ide/issues/198#issuecomment-599355836 - availableBoard = { - ...lastSelectedBoard, - state: AvailableBoard.State.guessed, - selected: - BoardsConfig.Config.sameAs(boardsConfig, lastSelectedBoard) && - Port.sameAs(boardPort, boardsConfig.selectedPort), // to avoid double selection - port: boardPort, - }; - } else { - availableBoard = { - name: Unknown, - port: boardPort, - state: AvailableBoard.State.incomplete, - }; - } - availableBoards.push(availableBoard); - } - - if ( - boardsConfig.selectedBoard && - availableBoards.every(({ selected }) => !selected) - ) { - let port = boardsConfig.selectedPort; - // If the selected board has the same port of an unknown board - // that is already in availableBoards we might get a duplicate port. - // So we remove the one already in the array and add the selected one. - const found = availableBoards.findIndex( - (board) => board.port?.address === boardsConfig.selectedPort?.address - ); - if (found >= 0) { - // get the "Unknown board port" that we will substitute, - // then we can include it in the "availableBoard object" - // pushed below; to ensure addressLabel is included - port = availableBoards[found].port; - availableBoards.splice(found, 1); - } - availableBoards.push({ - ...boardsConfig.selectedBoard, - port, - selected: true, - state: AvailableBoard.State.incomplete, - }); - } - - availableBoards.sort(AvailableBoard.compare); - - let hasChanged = availableBoards.length !== currentAvailableBoards.length; - for (let i = 0; !hasChanged && i < availableBoards.length; i++) { - const [left, right] = [availableBoards[i], currentAvailableBoards[i]]; - hasChanged = - left.fqbn !== right.fqbn || - !!AvailableBoard.compare(left, right) || - left.selected !== right.selected; - } - if (hasChanged) { - this._availableBoards = availableBoards; - this.onAvailableBoardsChangedEmitter.fire(this._availableBoards); - } - } - - protected async getLastSelectedBoardOnPort( - port: Port - ): Promise { - const key = this.getLastSelectedBoardOnPortKey(port); - return this.getData(key); - } - - protected async saveState(): Promise { - // We save the port with the selected board name/FQBN, to be able to guess a better board name. - // Required when the attached board belongs to a 3rd party boards package, and neither the name, nor - // the FQBN can be retrieved with a `board list` command. - // https://github.com/arduino/arduino-cli/issues/623 + private async saveState(): Promise { const { selectedBoard, selectedPort } = this.boardsConfig; - if (selectedBoard && selectedPort) { - const key = this.getLastSelectedBoardOnPortKey(selectedPort); - await this.setData(key, selectedBoard); - } await Promise.all([ - this.setData('latest-valid-boards-config', this.latestValidBoardsConfig), - this.setData('latest-boards-config', this.latestBoardsConfig), + this.setData( + selectedBoardStorageKey, + selectedBoard + ? // to make sure no other properties of the board object are persisted + JSON.stringify({ + name: selectedBoard.name, + fqbn: selectedBoard.fqbn, + }) + : undefined + ), + this.setData( + selectedPortStorageKey, + selectedPort + ? // to make sure no other properties are persisted from the port object + JSON.stringify({ + protocol: selectedPort.protocol, + address: selectedPort.address, + }) + : undefined + ), + this.setData( + boardListHistoryStorageKey, + JSON.stringify(this._boardListHistory) + ), ]); } - protected getLastSelectedBoardOnPortKey(port: Port | string): string { - // TODO: we lose the port's `protocol` info (`serial`, `network`, etc.) here if the `port` is a `string`. - return `last-selected-board-on-port:${ - typeof port === 'string' ? port : port.address - }`; - } - - protected async loadState(): Promise { - const storedLatestValidBoardsConfig = await this.getData< - RecursiveRequired - >('latest-valid-boards-config'); - if (storedLatestValidBoardsConfig) { - this.latestValidBoardsConfig = storedLatestValidBoardsConfig; - if (this.canUploadTo(this.latestValidBoardsConfig)) { - this.boardsConfig = this.latestValidBoardsConfig; - } - } else { - // If we could not restore the latest valid config, try to restore something, the board at least. - let storedLatestBoardsConfig = await this.getData< - BoardsConfig.Config | undefined - >('latest-boards-config'); - // Try to get from the startup task. Wait for it, then timeout. Maybe it never arrives. - if (!storedLatestBoardsConfig) { - storedLatestBoardsConfig = await Promise.race([ - this.inheritedConfig.promise, - new Promise((resolve) => - setTimeout(() => resolve(undefined), 2_000) - ), - ]); - } - if (storedLatestBoardsConfig) { - this.latestBoardsConfig = storedLatestBoardsConfig; - this.boardsConfig = this.latestBoardsConfig; + private async restoreState(): Promise< + Readonly & { boardListHistory: BoardListHistory } + > { + const [maybeSelectedBoard, maybeSelectedPort, maybeBoardHistory] = + await Promise.all([ + this.getData(selectedBoardStorageKey), + this.getData(selectedPortStorageKey), + this.getData(boardListHistoryStorageKey), + ]); + const selectedBoard = this.tryParse(maybeSelectedBoard, isBoardIdentifier); + const selectedPort = this.tryParse(maybeSelectedPort, isPortIdentifier); + const boardListHistory = + this.tryParse(maybeBoardHistory, isBoardListHistory) ?? {}; + return { selectedBoard, selectedPort, boardListHistory }; + } + + private tryParse( + raw: string | undefined, + typeGuard: (object: unknown) => object is T + ): T | undefined { + if (!raw) { + return undefined; + } + try { + const object = JSON.parse(raw); + if (typeGuard(object)) { + return object; } + } catch { + this.logger.error(`Failed to parse raw: '${raw}'`); } + return undefined; } private setData(key: string, value: T): Promise { @@ -762,15 +517,6 @@ export class BoardsServiceProvider key ); } - - tasks(): StartupTask[] { - return [ - { - command: USE_INHERITED_CONFIG.id, - args: [this.boardsConfig], - }, - ]; - } } /** @@ -787,77 +533,26 @@ const USE_INHERITED_CONFIG: Command = { id: 'arduino-use-inherited-boards-config', }; -/** - * Representation of a ready-to-use board, either the user has configured it or was automatically recognized by the CLI. - * An available board was not necessarily recognized by the CLI (e.g.: it is a 3rd party board) or correctly configured but ready for `verify`. - * If it has the selected board and a associated port, it can be used for `upload`. We render an available board for the user - * when it has the `port` set. - */ -export interface AvailableBoard extends Board { - readonly state: AvailableBoard.State; - readonly selected?: boolean; - readonly port?: Port; -} - -export namespace AvailableBoard { - export enum State { - /** - * Retrieved from the CLI via the `board list` command. - */ - 'recognized', - /** - * Guessed the name/FQBN of the board from the available board ports (3rd party). - */ - 'guessed', - /** - * We do not know anything about this board, probably a 3rd party. The user has not selected a board for this port yet. - */ - 'incomplete', - } - - export function is(board: any): board is AvailableBoard { - return Board.is(board) && 'state' in board; - } +const DUMP_BOARD_LIST: Command = { + id: 'arduino-dump-board-list', + label: nls.localize('arduino/developer/dumpBoardList', 'Dump the Board List'), + category: 'Developer (Arduino)', +}; - export function hasPort( - board: AvailableBoard - ): board is AvailableBoard & { port: Port } { - return !!board.port; - } +const CLEAR_BOARD_LIST_HISTORY: Command = { + id: 'arduino-clear-board-list-history', + label: nls.localize( + 'arduino/developer/clearBoardList', + 'Clear the Board List History' + ), + category: 'Developer (Arduino)', +}; - // Available boards must be sorted in this order: - // 1. Serial with recognized boards - // 2. Serial with guessed boards - // 3. Serial with incomplete boards - // 4. Network with recognized boards - // 5. Other protocols with recognized boards - export const compare = (left: AvailableBoard, right: AvailableBoard) => { - if (left.port?.protocol === 'serial' && right.port?.protocol !== 'serial') { - return -1; - } else if ( - left.port?.protocol !== 'serial' && - right.port?.protocol === 'serial' - ) { - return 1; - } else if ( - left.port?.protocol === 'network' && - right.port?.protocol !== 'network' - ) { - return -1; - } else if ( - left.port?.protocol !== 'network' && - right.port?.protocol === 'network' - ) { - return 1; - } else if (left.port?.protocol === right.port?.protocol) { - // We show all ports, including those that have guessed - // or unrecognized boards, so we must sort those too. - if (left.state < right.state) { - return -1; - } else if (left.state > right.state) { - return 1; - } - } - return naturalCompare(left.port?.address!, right.port?.address!); - }; -} +const CLEAR_BOARDS_CONFIG: Command = { + id: 'arduino-clear-boards-config', + label: nls.localize( + 'arduino/developer/clearBoardsConfig', + ' Clear the Board and Port Configuration' + ), + category: 'Developer (Arduino)', +}; 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 93a15a3c5..a995fa075 100644 --- a/arduino-ide-extension/src/browser/boards/boards-toolbar-item.tsx +++ b/arduino-ide-extension/src/browser/boards/boards-toolbar-item.tsx @@ -1,16 +1,27 @@ -import * as React from '@theia/core/shared/react'; -import * as ReactDOM from '@theia/core/shared/react-dom'; -import { CommandRegistry } from '@theia/core/lib/common/command'; -import { DisposableCollection } from '@theia/core/lib/common/disposable'; -import { Port } from '../../common/protocol'; -import { OpenBoardsConfig } from '../contributions/open-boards-config'; +import { TabBarToolbar } from '@theia/core/lib/browser/shell/tab-bar-toolbar/tab-bar-toolbar'; +import { codicon } from '@theia/core/lib/browser/widgets/widget'; +import type { CommandRegistry } from '@theia/core/lib/common/command'; import { + Disposable, + DisposableCollection, +} from '@theia/core/lib/common/disposable'; +import { nls } from '@theia/core/lib/common/nls'; +import React from '@theia/core/shared/react'; +import ReactDOM from '@theia/core/shared/react-dom'; +import classNames from 'classnames'; +import { unknownBoard } from '../../common/protocol'; +import { + BoardListItem, + getInferredBoardOrBoard, + InferredBoardListItem, + isInferredBoardListItem, +} from '../../common/protocol/board-list'; +import { + BoardListUI, BoardsServiceProvider, - AvailableBoard, + EditBoardsConfigAction, + SelectBoardsConfigAction, } from './boards-service-provider'; -import { nls } from '@theia/core/lib/common'; -import classNames from 'classnames'; -import { BoardsConfig } from './boards-config'; export interface BoardsDropDownListCoords { readonly top: number; @@ -22,18 +33,21 @@ export interface BoardsDropDownListCoords { export namespace BoardsDropDown { export interface Props { readonly coords: BoardsDropDownListCoords | 'hidden'; - readonly items: Array void; port: Port }>; + readonly boardList: BoardListUI; readonly openBoardsConfig: () => void; + /** + * Hides the board list dropdown, if it's visible. Otherwise, NOOP. + */ + readonly hide: () => void; } } -export class BoardsDropDown extends React.Component { - protected dropdownElement: HTMLElement; +export class BoardListDropDown extends React.Component { + private dropdownElement: HTMLElement; private listRef: React.RefObject; constructor(props: BoardsDropDown.Props) { super(props); - this.listRef = React.createRef(); let list = document.getElementById('boards-dropdown-container'); if (!list) { @@ -51,11 +65,14 @@ export class BoardsDropDown extends React.Component { } override render(): React.ReactNode { - return ReactDOM.createPortal(this.renderNode(), this.dropdownElement); + return ReactDOM.createPortal( + this.renderBoardListItems(), + this.dropdownElement + ); } - protected renderNode(): React.ReactNode { - const { coords, items } = this.props; + private renderBoardListItems(): React.ReactNode { + const { coords, boardList } = this.props; if (coords === 'hidden') { return ''; } @@ -74,14 +91,14 @@ export class BoardsDropDown extends React.Component { tabIndex={0} >
- {items - .map(({ name, port, selected, onClick }) => ({ - boardLabel: name, - port, - selected, - onClick, - })) - .map(this.renderItem)} + {boardList.map((item, index) => + this.renderBoardListItem({ + item, + selected: index === boardList.selectedIndex, + onSelect: boardList.onSelect, + onEdit: boardList.onEdit, + }) + )}
{ ); } - protected renderItem({ - boardLabel, - port, + private renderBoardListItem({ + item, selected, - onClick, + onSelect, + onEdit, }: { - boardLabel: string; - port: Port; - selected?: boolean; - onClick: () => void; + item: BoardListItem; + selected: boolean; + onSelect: SelectBoardsConfigAction; + onEdit: EditBoardsConfigAction; }): React.ReactNode { - const protocolIcon = iconNameFromProtocol(port.protocol); + const port = item.port; + const board = getInferredBoardOrBoard(item); + const boardLabel = board?.name ?? unknownBoard; + const boardFqbn = board?.fqbn; + const onDefaultAction = () => { + if (board) { + onSelect({ selectedBoard: board, selectedPort: port }); + } else { + onEdit({ selectedPort: port, query: { action: 'clear-if-not-empty' } }); + } + }; const onKeyUp = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - onClick(); + onDefaultAction(); } }; - return (
@@ -127,21 +153,83 @@ export class BoardsDropDown extends React.Component { className={classNames( 'arduino-boards-dropdown-item--protocol', 'fa', - protocolIcon + iconNameFromProtocol(port.protocol) )} />
-
- {boardLabel} +
+
+ {boardLabel} +
{port.addressLabel}
- {selected ?
: ''} + {isInferredBoardListItem(item) && + this.renderActions(item, onSelect, onEdit)} +
+ ); + } + + private renderActions( + inferredItem: InferredBoardListItem, + onRevert: SelectBoardsConfigAction, + onEdit: EditBoardsConfigAction + ): React.ReactNode { + const { port } = inferredItem; + const edit = ( +
{ + event.preventDefault(); + event.stopPropagation(); + onEdit({ + query: inferredItem.inferredBoard.name, + selectedBoard: inferredItem.inferredBoard, + selectedPort: port, + }); + }} + /> + ); + const revert = + inferredItem.type === 'board-overridden' ? ( +
{ + event.preventDefault(); + event.stopPropagation(); + onRevert({ selectedBoard: inferredItem.board, selectedPort: port }); + this.props.hide(); + }} + /> + ) : undefined; + return ( +
+
+ {edit} +
+
+ {revert} +
); } @@ -153,26 +241,27 @@ export class BoardsToolBarItem extends React.Component< > { static TOOLBAR_ID: 'boards-toolbar'; - protected readonly toDispose: DisposableCollection = - new DisposableCollection(); + private readonly toDispose: DisposableCollection; constructor(props: BoardsToolBarItem.Props) { super(props); - - const { availableBoards } = props.boardsServiceProvider; + const { boardList } = props.boardsServiceProvider; this.state = { - availableBoards, + boardList, coords: 'hidden', }; - - document.addEventListener('click', () => { - this.setState({ coords: 'hidden' }); - }); + const listener = () => this.setState({ coords: 'hidden' }); + document.addEventListener('click', listener); + this.toDispose = new DisposableCollection( + Disposable.create(() => document.removeEventListener('click', listener)) + ); } override componentDidMount(): void { - this.props.boardsServiceProvider.onAvailableBoardsChanged( - (availableBoards) => this.setState({ availableBoards }) + this.toDispose.push( + this.props.boardsServiceProvider.onBoardListDidChange((boardList) => + this.setState({ boardList }) + ) ); } @@ -180,7 +269,7 @@ export class BoardsToolBarItem extends React.Component< this.toDispose.dispose(); } - protected readonly show = (event: React.MouseEvent): void => { + private readonly show = (event: React.MouseEvent): void => { const { currentTarget: element } = event; if (element instanceof HTMLElement) { if (this.state.coords === 'hidden') { @@ -201,17 +290,20 @@ export class BoardsToolBarItem extends React.Component< event.nativeEvent.stopImmediatePropagation(); }; + private readonly hide = () => { + this.setState({ coords: 'hidden' }); + }; + override render(): React.ReactNode { - const { coords, availableBoards } = this.state; - const { selectedBoard, selectedPort } = - this.props.boardsServiceProvider.boardsConfig; + const { coords, boardList } = this.state; + const { selectedBoard, selectedPort } = boardList.boardsConfig; const boardLabel = selectedBoard?.name || nls.localize('arduino/board/selectBoard', 'Select Board'); const selectedPortLabel = portLabel(selectedPort?.address); - const isConnected = Boolean(selectedBoard && selectedPort); + const isConnected = boardList.selectedIndex >= 0; const protocolIcon = isConnected ? iconNameFromProtocol(selectedPort?.protocol || '') : null; @@ -220,7 +312,6 @@ export class BoardsToolBarItem extends React.Component< 'fa', protocolIcon ); - return (
- ({ - ...board, - onClick: () => { - if (!board.fqbn) { - const previousBoardConfig = - this.props.boardsServiceProvider.boardsConfig; - this.props.boardsServiceProvider.boardsConfig = { - selectedPort: board.port, - }; - this.openDialog(previousBoardConfig); - } else { - this.props.boardsServiceProvider.boardsConfig = { - selectedBoard: board, - selectedPort: board.port, - }; - } - this.setState({ coords: 'hidden' }); - }, - }))} - openBoardsConfig={this.openDialog} - > + boardList={boardList} + openBoardsConfig={() => + boardList.onEdit({ query: { action: 'clear-if-not-empty' } }) + } + hide={this.hide} + > ); } - - protected openDialog = async ( - previousBoardConfig?: BoardsConfig.Config - ): Promise => { - const selectedBoardConfig = - await this.props.commands.executeCommand( - OpenBoardsConfig.Commands.OPEN_DIALOG.id - ); - if ( - previousBoardConfig && - (!selectedBoardConfig?.selectedPort || - !selectedBoardConfig?.selectedBoard) - ) { - this.props.boardsServiceProvider.boardsConfig = previousBoardConfig; - } - }; } export namespace BoardsToolBarItem { export interface Props { @@ -293,7 +351,7 @@ export namespace BoardsToolBarItem { } export interface State { - availableBoards: AvailableBoard[]; + boardList: BoardListUI; coords: BoardsDropDownListCoords | 'hidden'; } } @@ -304,12 +362,9 @@ function iconNameFromProtocol(protocol: string): string { return 'fa-arduino-technology-usb'; case 'network': return 'fa-arduino-technology-connection'; - /* - Bluetooth ports are not listed yet from the CLI; - Not sure about the naming ('bluetooth'); make sure it's correct before uncommenting the following lines - */ - // case 'bluetooth': - // return 'fa-arduino-technology-bluetooth'; + // it is fine to assign dedicated icons to the protocols used by the official boards, + // but other than that it is best to avoid implementing any special handling + // for specific protocols in the IDE codebase. default: return 'fa-arduino-technology-3dimensionscube'; } diff --git a/arduino-ide-extension/src/browser/contributions/board-selection.ts b/arduino-ide-extension/src/browser/contributions/board-selection.ts index 3e7558c83..258561869 100644 --- a/arduino-ide-extension/src/browser/contributions/board-selection.ts +++ b/arduino-ide-extension/src/browser/contributions/board-selection.ts @@ -1,57 +1,53 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; -import { MenuModelRegistry } from '@theia/core/lib/common/menu'; import { - DisposableCollection, Disposable, + DisposableCollection, } from '@theia/core/lib/common/disposable'; -import { BoardsConfig } from '../boards/boards-config'; +import { MenuModelRegistry } from '@theia/core/lib/common/menu/menu-model-registry'; +import { nls } from '@theia/core/lib/common/nls'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { MainMenuManager } from '../../common/main-menu-manager'; +import { + BoardsService, + getBoardInfo, + InstalledBoardWithPackage, + Port, +} from '../../common/protocol'; +import type { BoardList } from '../../common/protocol/board-list'; import { BoardsListWidget } from '../boards/boards-list-widget'; -import { NotificationCenter } from '../notification-center'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { ArduinoMenus, PlaceholderMenuNode, unregisterSubmenu, } from '../menu/arduino-menus'; -import { - BoardsService, - InstalledBoardWithPackage, - AvailablePorts, - Port, - getBoardInfo, -} from '../../common/protocol'; -import { SketchContribution, Command, CommandRegistry } from './contribution'; -import { nls } from '@theia/core/lib/common'; +import { NotificationCenter } from '../notification-center'; +import { Command, CommandRegistry, SketchContribution } from './contribution'; @injectable() export class BoardSelection extends SketchContribution { @inject(CommandRegistry) - protected readonly commandRegistry: CommandRegistry; - + private readonly commandRegistry: CommandRegistry; @inject(MainMenuManager) - protected readonly mainMenuManager: MainMenuManager; - + private readonly mainMenuManager: MainMenuManager; @inject(MenuModelRegistry) - protected readonly menuModelRegistry: MenuModelRegistry; - + private readonly menuModelRegistry: MenuModelRegistry; @inject(NotificationCenter) - protected readonly notificationCenter: NotificationCenter; - + private readonly notificationCenter: NotificationCenter; @inject(BoardsService) - protected readonly boardsService: BoardsService; - + private readonly boardsService: BoardsService; @inject(BoardsServiceProvider) - protected readonly boardsServiceProvider: BoardsServiceProvider; + private readonly boardsServiceProvider: BoardsServiceProvider; - protected readonly toDisposeBeforeMenuRebuild = new DisposableCollection(); + private readonly toDisposeBeforeMenuRebuild = new DisposableCollection(); + // do not query installed platforms on every change + private _installedBoards: Deferred | undefined; override registerCommands(registry: CommandRegistry): void { registry.registerCommand(BoardSelection.Commands.GET_BOARD_INFO, { execute: async () => { const boardInfo = await getBoardInfo( - this.boardsServiceProvider.boardsConfig.selectedPort, - this.boardsService.getState() + this.boardsServiceProvider.boardList ); if (typeof boardInfo === 'string') { this.messageService.info(boardInfo); @@ -76,34 +72,35 @@ SN: ${SN} } override onStart(): void { - this.notificationCenter.onPlatformDidInstall(() => this.updateMenus()); - this.notificationCenter.onPlatformDidUninstall(() => this.updateMenus()); - this.boardsServiceProvider.onBoardsConfigChanged(() => this.updateMenus()); - this.boardsServiceProvider.onAvailableBoardsChanged(() => - this.updateMenus() - ); - this.boardsServiceProvider.onAvailablePortsChanged(() => - this.updateMenus() + this.notificationCenter.onPlatformDidInstall(() => this.updateMenus(true)); + this.notificationCenter.onPlatformDidUninstall(() => + this.updateMenus(true) ); + this.boardsServiceProvider.onBoardListDidChange(() => this.updateMenus()); } override async onReady(): Promise { this.updateMenus(); } - protected async updateMenus(): Promise { - const [installedBoards, availablePorts, config] = await Promise.all([ - this.installedBoards(), - this.boardsService.getState(), - this.boardsServiceProvider.boardsConfig, - ]); - this.rebuildMenus(installedBoards, availablePorts, config); + private async updateMenus(discardCache = false): Promise { + if (discardCache) { + this._installedBoards?.reject(); + this._installedBoards = undefined; + } + if (!this._installedBoards) { + this._installedBoards = new Deferred(); + this.installedBoards().then((installedBoards) => + this._installedBoards?.resolve(installedBoards) + ); + } + const installedBoards = await this._installedBoards.promise; + this.rebuildMenus(installedBoards, this.boardsServiceProvider.boardList); } - protected rebuildMenus( + private rebuildMenus( installedBoards: InstalledBoardWithPackage[], - availablePorts: AvailablePorts, - config: BoardsConfig.Config + boardList: BoardList ): void { this.toDisposeBeforeMenuRebuild.dispose(); @@ -112,7 +109,7 @@ SN: ${SN} ...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP, '1_boards', ]; - const boardsSubmenuLabel = config.selectedBoard?.name; + const boardsSubmenuLabel = boardList.boardsConfig.selectedBoard?.name; // Note: The submenu order starts from `100` because `Auto Format`, `Serial Monitor`, etc starts from `0` index. // The board specific items, and the rest, have order with `z`. We needed something between `0` and `z` with natural-order. this.menuModelRegistry.registerSubmenu( @@ -132,7 +129,7 @@ SN: ${SN} // Ports submenu const portsSubmenuPath = ArduinoMenus.TOOLS__PORTS_SUBMENU; - const portsSubmenuLabel = config.selectedPort?.address; + const portsSubmenuLabel = boardList.boardsConfig.selectedPort?.address; this.menuModelRegistry.registerSubmenu( portsSubmenuPath, nls.localize( @@ -192,22 +189,8 @@ SN: ${SN} const id = `arduino-select-board--${fqbn}`; const command = { id }; const handler = { - execute: () => { - if ( - fqbn !== this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn - ) { - this.boardsServiceProvider.boardsConfig = { - selectedBoard: { - name, - fqbn, - port: this.boardsServiceProvider.boardsConfig.selectedBoard - ?.port, // TODO: verify! - }, - selectedPort: - this.boardsServiceProvider.boardsConfig.selectedPort, - }; - } - }, + execute: () => + this.boardsServiceProvider.updateBoard({ name: name, fqbn: fqbn }), isToggled: () => fqbn === this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn, }; @@ -226,14 +209,13 @@ SN: ${SN} // Note: we do not dispose the menu actions individually. Calling `unregisterSubmenu` on the parent will wipe the children menu nodes recursively. }); - // Installed ports + // Detected ports const registerPorts = ( protocol: string, - protocolOrder: number, - ports: AvailablePorts + ports: ReturnType, + protocolOrder: number ) => { - const portIDs = Object.keys(ports); - if (!portIDs.length) { + if (!ports.length) { return; } @@ -258,46 +240,26 @@ SN: ${SN} ) ); - // First we show addresses with recognized boards connected, - // then all the rest. - const sortedIDs = Object.keys(ports).sort( - (left: string, right: string): number => { - const [, leftBoards] = ports[left]; - const [, rightBoards] = ports[right]; - return rightBoards.length - leftBoards.length; - } - ); - - for (let i = 0; i < sortedIDs.length; i++) { - const portID = sortedIDs[i]; - const [port, boards] = ports[portID]; + for (let i = 0; i < ports.length; i++) { + const { port, boards } = ports[i]; + const portKey = Port.keyOf(port); let label = `${port.addressLabel}`; - if (boards.length) { + if (boards?.length) { const boardsList = boards.map((board) => board.name).join(', '); label = `${label} (${boardsList})`; } - const id = `arduino-select-port--${portID}`; + const id = `arduino-select-port--${portKey}`; const command = { id }; const handler = { execute: () => { - if ( - !Port.sameAs( - port, - this.boardsServiceProvider.boardsConfig.selectedPort - ) - ) { - this.boardsServiceProvider.boardsConfig = { - selectedBoard: - this.boardsServiceProvider.boardsConfig.selectedBoard, - selectedPort: port, - }; - } + this.boardsServiceProvider.updatePort({ + protocol: port.protocol, + address: port.address, + }); + }, + isToggled: () => { + return i === ports.matchingIndex; }, - isToggled: () => - Port.sameAs( - port, - this.boardsServiceProvider.boardsConfig.selectedPort - ), }; const menuAction = { commandId: id, @@ -314,22 +276,12 @@ SN: ${SN} } }; - const grouped = AvailablePorts.groupByProtocol(availablePorts); + const groupedPorts = boardList.portsGroupedByProtocol(); let protocolOrder = 100; - // We first show serial and network ports, then all the rest - ['serial', 'network'].forEach((protocol) => { - const ports = grouped.get(protocol); - if (ports) { - registerPorts(protocol, protocolOrder, ports); - grouped.delete(protocol); - protocolOrder = protocolOrder + 100; - } + Object.entries(groupedPorts).forEach(([protocol, ports]) => { + registerPorts(protocol, ports, protocolOrder); + protocolOrder += 100; }); - grouped.forEach((ports, protocol) => { - registerPorts(protocol, protocolOrder, ports); - protocolOrder = protocolOrder + 100; - }); - this.mainMenuManager.update(); } diff --git a/arduino-ide-extension/src/browser/contributions/debug.ts b/arduino-ide-extension/src/browser/contributions/debug.ts index f43f00426..ee07d7aa5 100644 --- a/arduino-ide-extension/src/browser/contributions/debug.ts +++ b/arduino-ide-extension/src/browser/contributions/debug.ts @@ -5,8 +5,10 @@ import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { NotificationCenter } from '../notification-center'; import { Board, + BoardIdentifier, BoardsService, ExecutableService, + isBoardIdentifierChangeEvent, Sketch, } from '../../common/protocol'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; @@ -88,9 +90,11 @@ export class Debug extends SketchContribution { : Debug.Commands.START_DEBUGGING.label }`) ); - this.boardsServiceProvider.onBoardsConfigChanged(({ selectedBoard }) => - this.refreshState(selectedBoard) - ); + this.boardsServiceProvider.onBoardsConfigDidChange((event) => { + if (isBoardIdentifierChangeEvent(event)) { + this.refreshState(event.selectedBoard); + } + }); this.notificationCenter.onPlatformDidInstall(() => this.refreshState()); this.notificationCenter.onPlatformDidUninstall(() => this.refreshState()); } @@ -169,7 +173,7 @@ export class Debug extends SketchContribution { } private async startDebug( - board: Board | undefined = this.boardsServiceProvider.boardsConfig + board: BoardIdentifier | undefined = this.boardsServiceProvider.boardsConfig .selectedBoard ): Promise { if (!board) { diff --git a/arduino-ide-extension/src/browser/contributions/examples.ts b/arduino-ide-extension/src/browser/contributions/examples.ts index 16fc6a380..3796b7535 100644 --- a/arduino-ide-extension/src/browser/contributions/examples.ts +++ b/arduino-ide-extension/src/browser/contributions/examples.ts @@ -28,6 +28,8 @@ import { CoreService, SketchesService, Sketch, + isBoardIdentifierChangeEvent, + BoardIdentifier, } from '../../common/protocol'; import { nls } from '@theia/core/lib/common/nls'; import { unregisterSubmenu } from '../menu/arduino-menus'; @@ -108,7 +110,7 @@ export abstract class Examples extends SketchContribution { protected readonly coreService: CoreService; @inject(BoardsServiceProvider) - protected readonly boardsServiceClient: BoardsServiceProvider; + protected readonly boardsServiceProvider: BoardsServiceProvider; @inject(NotificationCenter) protected readonly notificationCenter: NotificationCenter; @@ -117,12 +119,14 @@ export abstract class Examples extends SketchContribution { protected override init(): void { super.init(); - this.boardsServiceClient.onBoardsConfigChanged(({ selectedBoard }) => - this.handleBoardChanged(selectedBoard) - ); + this.boardsServiceProvider.onBoardsConfigDidChange((event) => { + if (isBoardIdentifierChangeEvent(event)) { + this.handleBoardChanged(event.selectedBoard); + } + }); this.notificationCenter.onDidReinitialize(() => this.update({ - board: this.boardsServiceClient.boardsConfig.selectedBoard, + board: this.boardsServiceProvider.boardsConfig.selectedBoard, // No force refresh. The core client was already refreshed. }) ); @@ -134,7 +138,7 @@ export abstract class Examples extends SketchContribution { } protected abstract update(options?: { - board?: Board | undefined; + board?: BoardIdentifier | undefined; forceRefresh?: boolean; }): void; @@ -225,7 +229,7 @@ export abstract class Examples extends SketchContribution { protected createHandler(uri: string): CommandHandler { const forceUpdate = () => this.update({ - board: this.boardsServiceClient.boardsConfig.selectedBoard, + board: this.boardsServiceProvider.boardsConfig.selectedBoard, forceRefresh: true, }); return { @@ -306,7 +310,7 @@ export class LibraryExamples extends Examples { protected override async update( options: { board?: Board; forceRefresh?: boolean } = { - board: this.boardsServiceClient.boardsConfig.selectedBoard, + board: this.boardsServiceProvider.boardsConfig.selectedBoard, } ): Promise { const { board, forceRefresh } = options; diff --git a/arduino-ide-extension/src/browser/contributions/include-library.ts b/arduino-ide-extension/src/browser/contributions/include-library.ts index cb6479f18..5d77e9ec3 100644 --- a/arduino-ide-extension/src/browser/contributions/include-library.ts +++ b/arduino-ide-extension/src/browser/contributions/include-library.ts @@ -37,7 +37,7 @@ export class IncludeLibrary extends SketchContribution { protected readonly notificationCenter: NotificationCenter; @inject(BoardsServiceProvider) - protected readonly boardsServiceClient: BoardsServiceProvider; + protected readonly boardsServiceProvider: BoardsServiceProvider; @inject(LibraryService) protected readonly libraryService: LibraryService; @@ -46,7 +46,7 @@ export class IncludeLibrary extends SketchContribution { protected readonly toDispose = new DisposableCollection(); override onStart(): void { - this.boardsServiceClient.onBoardsConfigChanged(() => + this.boardsServiceProvider.onBoardsConfigDidChange(() => this.updateMenuActions() ); this.notificationCenter.onLibraryDidInstall(() => this.updateMenuActions()); @@ -98,7 +98,7 @@ export class IncludeLibrary extends SketchContribution { this.toDispose.dispose(); this.mainMenuManager.update(); const libraries: LibraryPackage[] = []; - const fqbn = this.boardsServiceClient.boardsConfig.selectedBoard?.fqbn; + const fqbn = this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn; // Show all libraries, when no board is selected. // Otherwise, show libraries only for the selected board. libraries.push(...(await this.libraryService.list({ fqbn }))); diff --git a/arduino-ide-extension/src/browser/contributions/ino-language.ts b/arduino-ide-extension/src/browser/contributions/ino-language.ts index 2577d5a73..6ab5845de 100644 --- a/arduino-ide-extension/src/browser/contributions/ino-language.ts +++ b/arduino-ide-extension/src/browser/contributions/ino-language.ts @@ -7,12 +7,13 @@ import { Mutex } from 'async-mutex'; import { ArduinoDaemon, assertSanitizedFqbn, + BoardIdentifier, BoardsService, ExecutableService, + isBoardIdentifierChangeEvent, sanitizeFqbn, } from '../../common/protocol'; import { CurrentSketch } from '../sketches-service-client-impl'; -import { BoardsConfig } from '../boards/boards-config'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { HostedPluginEvents } from '../hosted-plugin-events'; import { NotificationCenter } from '../notification-center'; @@ -48,7 +49,7 @@ export class InoLanguage extends SketchContribution { override onReady(): void { const start = ( - { selectedBoard }: BoardsConfig.Config, + selectedBoard: BoardIdentifier | undefined, forceStart = false ) => { if (selectedBoard) { @@ -59,12 +60,16 @@ export class InoLanguage extends SketchContribution { } }; const forceRestart = () => { - start(this.boardsServiceProvider.boardsConfig, true); + start(this.boardsServiceProvider.boardsConfig.selectedBoard, true); }; this.toDispose.pushAll([ - this.boardsServiceProvider.onBoardsConfigChanged(start), + this.boardsServiceProvider.onBoardsConfigDidChange((event) => { + if (isBoardIdentifierChangeEvent(event)) { + start(event.selectedBoard); + } + }), this.hostedPluginEvents.onPluginsDidStart(() => - start(this.boardsServiceProvider.boardsConfig) + start(this.boardsServiceProvider.boardsConfig.selectedBoard) ), this.hostedPluginEvents.onPluginsWillUnload( () => (this.languageServerFqbn = undefined) @@ -101,12 +106,12 @@ export class InoLanguage extends SketchContribution { matchingFqbn && boardsConfig.selectedBoard?.fqbn === matchingFqbn ) { - start(boardsConfig); + start(boardsConfig.selectedBoard); } } }), ]); - start(this.boardsServiceProvider.boardsConfig); + start(this.boardsServiceProvider.boardsConfig.selectedBoard); } onStop(): void { diff --git a/arduino-ide-extension/src/browser/contributions/open-boards-config.ts b/arduino-ide-extension/src/browser/contributions/open-boards-config.ts index 8feffc14f..f7bb24c1a 100644 --- a/arduino-ide-extension/src/browser/contributions/open-boards-config.ts +++ b/arduino-ide-extension/src/browser/contributions/open-boards-config.ts @@ -1,25 +1,18 @@ -import { CommandRegistry } from '@theia/core'; +import type { Command, CommandRegistry } from '@theia/core/lib/common/command'; import { inject, injectable } from '@theia/core/shared/inversify'; import { BoardsConfigDialog } from '../boards/boards-config-dialog'; -import { BoardsServiceProvider } from '../boards/boards-service-provider'; -import { Contribution, Command } from './contribution'; +import type { EditBoardsConfigActionParams } from '../boards/boards-service-provider'; +import { Contribution } from './contribution'; @injectable() export class OpenBoardsConfig extends Contribution { - @inject(BoardsServiceProvider) - private readonly boardsServiceProvider: BoardsServiceProvider; - @inject(BoardsConfigDialog) private readonly boardsConfigDialog: BoardsConfigDialog; override registerCommands(registry: CommandRegistry): void { registry.registerCommand(OpenBoardsConfig.Commands.OPEN_DIALOG, { - execute: async (query?: string | undefined) => { - const boardsConfig = await this.boardsConfigDialog.open(query); - if (boardsConfig) { - return (this.boardsServiceProvider.boardsConfig = boardsConfig); - } - }, + execute: async (params?: EditBoardsConfigActionParams) => + this.boardsConfigDialog.open(params), }); } } diff --git a/arduino-ide-extension/src/browser/contributions/selected-board.ts b/arduino-ide-extension/src/browser/contributions/selected-board.ts index bf8a84ae8..d7ebbd7cd 100644 --- a/arduino-ide-extension/src/browser/contributions/selected-board.ts +++ b/arduino-ide-extension/src/browser/contributions/selected-board.ts @@ -4,7 +4,7 @@ import { } from '@theia/core/lib/browser/status-bar/status-bar'; import { nls } from '@theia/core/lib/common/nls'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { BoardsConfig } from '../boards/boards-config'; +import type { BoardList } from '../../common/protocol/board-list'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { Contribution } from './contribution'; @@ -12,21 +12,21 @@ import { Contribution } from './contribution'; export class SelectedBoard extends Contribution { @inject(StatusBar) private readonly statusBar: StatusBar; - @inject(BoardsServiceProvider) private readonly boardsServiceProvider: BoardsServiceProvider; override onStart(): void { - this.boardsServiceProvider.onBoardsConfigChanged((config) => - this.update(config) + this.boardsServiceProvider.onBoardListDidChange(() => + this.update(this.boardsServiceProvider.boardList) ); } override onReady(): void { - this.update(this.boardsServiceProvider.boardsConfig); + this.update(this.boardsServiceProvider.boardList); } - private update({ selectedBoard, selectedPort }: BoardsConfig.Config): void { + private update(boardList: BoardList): void { + const { selectedBoard, selectedPort } = boardList.boardsConfig; this.statusBar.setElement('arduino-selected-board', { alignment: StatusBarAlignment.RIGHT, text: selectedBoard diff --git a/arduino-ide-extension/src/browser/contributions/update-arduino-state.ts b/arduino-ide-extension/src/browser/contributions/update-arduino-state.ts index a227a51a0..35b4c2ab7 100644 --- a/arduino-ide-extension/src/browser/contributions/update-arduino-state.ts +++ b/arduino-ide-extension/src/browser/contributions/update-arduino-state.ts @@ -6,15 +6,16 @@ import type { ArduinoState } from 'vscode-arduino-api'; import { BoardsService, CompileSummary, - Port, isCompileSummary, + BoardsConfig, + PortIdentifier, + resolveDetectedPort, } from '../../common/protocol'; import { toApiBoardDetails, toApiCompileSummary, toApiPort, } from '../../common/protocol/arduino-context-mapper'; -import type { BoardsConfig } from '../boards/boards-config'; import { BoardsDataStore } from '../boards/boards-data-store'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { CurrentSketch } from '../sketches-service-client-impl'; @@ -44,8 +45,8 @@ export class UpdateArduinoState extends SketchContribution { override onStart(): void { this.toDispose.pushAll([ - this.boardsServiceProvider.onBoardsConfigChanged((config) => - this.updateBoardsConfig(config) + this.boardsServiceProvider.onBoardsConfigDidChange(() => + this.updateBoardsConfig(this.boardsServiceProvider.boardsConfig) ), this.sketchServiceClient.onCurrentSketchDidChange((sketch) => this.updateSketchPath(sketch) @@ -75,9 +76,7 @@ export class UpdateArduinoState extends SketchContribution { } override onReady(): void { - this.boardsServiceProvider.reconciled.then(() => { - this.updateBoardsConfig(this.boardsServiceProvider.boardsConfig); - }); + this.updateBoardsConfig(this.boardsServiceProvider.boardsConfig); // TODO: verify! this.updateSketchPath(this.sketchServiceClient.tryGetCurrentSketch()); this.updateUserDirPath(this.configService.tryGetSketchDirUri()); this.updateDataDirPath(this.configService.tryGetDataDirUri()); @@ -106,9 +105,7 @@ export class UpdateArduinoState extends SketchContribution { }); } - private async updateBoardsConfig( - boardsConfig: BoardsConfig.Config - ): Promise { + private async updateBoardsConfig(boardsConfig: BoardsConfig): Promise { const fqbn = boardsConfig.selectedBoard?.fqbn; const port = boardsConfig.selectedPort; await this.updateFqbn(fqbn); @@ -146,8 +143,11 @@ export class UpdateArduinoState extends SketchContribution { }); } - private async updatePort(port: Port | undefined): Promise { - const apiPort = port && toApiPort(port); + private async updatePort(port: PortIdentifier | undefined): Promise { + const resolvedPort = + port && + resolveDetectedPort(port, this.boardsServiceProvider.detectedPorts); + const apiPort = resolvedPort && toApiPort(resolvedPort); return this.updateState({ key: 'port', value: apiPort }); } @@ -171,9 +171,6 @@ export class UpdateArduinoState extends SketchContribution { params: UpdateStateParams ): Promise { await this.hostedPluginSupport.didStart; - return this.commandService.executeCommand( - 'arduinoAPI.updateState', - params - ); + return this.commandService.executeCommand('arduinoAPI.updateState', params); } } diff --git a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts index 034ea87d3..6f6e1efae 100644 --- a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts @@ -1,30 +1,34 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; import { Emitter } from '@theia/core/lib/common/event'; -import { CoreService, Port, sanitizeFqbn } from '../../common/protocol'; +import { nls } from '@theia/core/lib/common/nls'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { + CoreService, + portIdentifierEquals, + sanitizeFqbn, +} from '../../common/protocol'; import { ArduinoMenus } from '../menu/arduino-menus'; +import { CurrentSketch } from '../sketches-service-client-impl'; import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { Command, CommandRegistry, - MenuModelRegistry, + CoreServiceContribution, KeybindingRegistry, + MenuModelRegistry, TabBarToolbarRegistry, - CoreServiceContribution, } from './contribution'; -import { deepClone, nls } from '@theia/core/lib/common'; -import { CurrentSketch } from '../sketches-service-client-impl'; -import type { VerifySketchParams } from './verify-sketch'; import { UserFields } from './user-fields'; +import type { VerifySketchParams } from './verify-sketch'; @injectable() export class UploadSketch extends CoreServiceContribution { + @inject(UserFields) + private readonly userFields: UserFields; + private readonly onDidChangeEmitter = new Emitter(); private readonly onDidChange = this.onDidChangeEmitter.event; private uploadInProgress = false; - @inject(UserFields) - private readonly userFields: UserFields; - override registerCommands(registry: CommandRegistry): void { registry.registerCommand(UploadSketch.Commands.UPLOAD_SKETCH, { execute: async () => { @@ -107,7 +111,6 @@ export class UploadSketch extends CoreServiceContribution { // uploadInProgress will be set to false whether the upload fails or not this.uploadInProgress = true; this.menuManager.update(); - this.boardsServiceProvider.snapshotBoardDiscoveryOnUpload(); this.onDidChangeEmitter.fire(); this.clearVisibleNotification(); @@ -135,13 +138,24 @@ export class UploadSketch extends CoreServiceContribution { return; } - await this.doWithProgress({ + const uploadResponse = await this.doWithProgress({ progressText: nls.localize('arduino/sketch/uploading', 'Uploading...'), task: (progressId, coreService) => coreService.upload({ ...uploadOptions, progressId }), keepOutput: true, }); + if ( + uploadResponse.portBeforeUpload && + !portIdentifierEquals( + uploadResponse.portBeforeUpload, + uploadResponse.portAfterUpload + ) + ) { + // https://github.com/arduino/arduino-cli/issues/2245 + this.boardsServiceProvider.updatePort(uploadResponse.portAfterUpload); + } + this.messageService.info( nls.localize('arduino/sketch/doneUploading', 'Done uploading.'), { timeout: 3000 } @@ -150,9 +164,10 @@ export class UploadSketch extends CoreServiceContribution { this.userFields.notifyFailedWithError(e); this.handleError(e); } finally { + // TODO: here comes the port change if happened during the upload + // https://github.com/arduino/arduino-cli/issues/2245 this.uploadInProgress = false; this.menuManager.update(); - this.boardsServiceProvider.attemptPostUploadAutoSelect(); this.onDidChangeEmitter.fire(); } } @@ -174,7 +189,7 @@ export class UploadSketch extends CoreServiceContribution { this.preferences.get('arduino.upload.verify'), this.preferences.get('arduino.upload.verbose'), ]); - const port = this.maybeUpdatePortProperties(boardsConfig.selectedPort); + const port = boardsConfig.selectedPort; return { sketch, fqbn, @@ -185,28 +200,6 @@ export class UploadSketch extends CoreServiceContribution { userFields, }; } - - /** - * This is a hack to ensure that the port object has the `properties` when uploading.(https://github.com/arduino/arduino-ide/issues/740) - * This method works around a bug when restoring a `port` persisted by an older version of IDE2. See the bug [here](https://github.com/arduino/arduino-ide/pull/1335#issuecomment-1224355236). - * - * Before the upload, this method checks the available ports and makes sure that the `properties` of an available port, and the port selected by the user have the same `properties`. - * This method does not update any state (for example, the `BoardsConfig.Config`) but uses the correct `properties` for the `upload`. - */ - private maybeUpdatePortProperties(port: Port | undefined): Port | undefined { - if (port) { - const key = Port.keyOf(port); - for (const candidate of this.boardsServiceProvider.availablePorts) { - if (key === Port.keyOf(candidate) && candidate.properties) { - return { - ...port, - properties: deepClone(candidate.properties), - }; - } - } - } - return port; - } } export namespace UploadSketch { diff --git a/arduino-ide-extension/src/browser/contributions/user-fields.ts b/arduino-ide-extension/src/browser/contributions/user-fields.ts index 62bef9748..14a4e55a8 100644 --- a/arduino-ide-extension/src/browser/contributions/user-fields.ts +++ b/arduino-ide-extension/src/browser/contributions/user-fields.ts @@ -21,7 +21,7 @@ export class UserFields extends Contribution { protected override init(): void { super.init(); - this.boardsServiceProvider.onBoardsConfigChanged(async () => { + this.boardsServiceProvider.onBoardsConfigDidChange(async () => { const userFields = await this.boardsServiceProvider.selectedBoardUserFields(); this.boardRequiresUserFields = userFields.length > 0; @@ -43,10 +43,7 @@ export class UserFields extends Contribution { if (!fqbn) { return undefined; } - const address = - boardsConfig.selectedBoard?.port?.address || - boardsConfig.selectedPort?.address || - ''; + const address = boardsConfig.selectedPort?.address || ''; return fqbn + '|' + address; } diff --git a/arduino-ide-extension/src/browser/dialogs/certificate-uploader/certificate-uploader-component.tsx b/arduino-ide-extension/src/browser/dialogs/certificate-uploader/certificate-uploader-component.tsx index 528e7ec95..168f66831 100644 --- a/arduino-ide-extension/src/browser/dialogs/certificate-uploader/certificate-uploader-component.tsx +++ b/arduino-ide-extension/src/browser/dialogs/certificate-uploader/certificate-uploader-component.tsx @@ -1,20 +1,23 @@ +import { nls } from '@theia/core/lib/common/nls'; import * as React from '@theia/core/shared/react'; import Tippy from '@tippyjs/react'; -import { AvailableBoard } from '../../boards/boards-service-provider'; +import type { + BoardList, + BoardListItem, +} from '../../../common/protocol/board-list'; +import { CertificateAddComponent } from './certificate-add-new'; import { CertificateListComponent } from './certificate-list'; import { SelectBoardComponent } from './select-board-components'; -import { CertificateAddComponent } from './certificate-add-new'; -import { nls } from '@theia/core/lib/common'; export const CertificateUploaderComponent = ({ - availableBoards, + boardList, certificates, addCertificate, updatableFqbns, uploadCertificates, openContextMenu, }: { - availableBoards: AvailableBoard[]; + boardList: BoardList; certificates: string[]; addCertificate: (cert: string) => void; updatableFqbns: string[]; @@ -33,11 +36,11 @@ export const CertificateUploaderComponent = ({ const [selectedCerts, setSelectedCerts] = React.useState([]); - const [selectedBoard, setSelectedBoard] = - React.useState(null); + const [selectedItem, setSelectedItem] = + React.useState | null>(null); const installCertificates = async () => { - if (!selectedBoard || !selectedBoard.fqbn || !selectedBoard.port) { + if (!selectedItem || !selectedItem.board.fqbn || !selectedItem.port) { return; } @@ -45,8 +48,8 @@ export const CertificateUploaderComponent = ({ try { await uploadCertificates( - selectedBoard.fqbn, - selectedBoard.port.address, + selectedItem.board.fqbn, + selectedItem.port.address, selectedCerts ); setInstallFeedback('ok'); @@ -55,17 +58,17 @@ export const CertificateUploaderComponent = ({ } }; - const onBoardSelect = React.useCallback( - (board: AvailableBoard) => { - const newFqbn = (board && board.fqbn) || null; - const prevFqbn = (selectedBoard && selectedBoard.fqbn) || null; + const onItemSelect = React.useCallback( + (item: Required | null) => { + const newFqbn = (item && item.board.fqbn) || null; + const prevFqbn = (selectedItem && selectedItem.board.fqbn) || null; if (newFqbn !== prevFqbn) { setInstallFeedback(null); - setSelectedBoard(board); + setSelectedItem(item); } }, - [selectedBoard] + [selectedItem] ); return ( @@ -125,10 +128,10 @@ export const CertificateUploaderComponent = ({
@@ -167,7 +170,7 @@ export const CertificateUploaderComponent = ({ type="button" className="theia-button primary install-cert-btn" onClick={installCertificates} - disabled={selectedCerts.length === 0 || !selectedBoard} + disabled={selectedCerts.length === 0 || !selectedItem} > {nls.localize('arduino/certificate/upload', 'Upload')} diff --git a/arduino-ide-extension/src/browser/dialogs/certificate-uploader/certificate-uploader-dialog.tsx b/arduino-ide-extension/src/browser/dialogs/certificate-uploader/certificate-uploader-dialog.tsx index fa18fb6f2..0ade63912 100644 --- a/arduino-ide-extension/src/browser/dialogs/certificate-uploader/certificate-uploader-dialog.tsx +++ b/arduino-ide-extension/src/browser/dialogs/certificate-uploader/certificate-uploader-dialog.tsx @@ -9,18 +9,12 @@ import { AbstractDialog } from '../../theia/dialogs/dialogs'; import { Widget } from '@theia/core/shared/@phosphor/widgets'; import { Message } from '@theia/core/shared/@phosphor/messaging'; import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget'; -import { - AvailableBoard, - BoardsServiceProvider, -} from '../../boards/boards-service-provider'; -import { CertificateUploaderComponent } from './certificate-uploader-component'; +import { BoardsServiceProvider } from '../../boards/boards-service-provider'; +// import { CertificateUploaderComponent } from './certificate-uploader-component'; import { ArduinoPreferences } from '../../arduino-preferences'; -import { - PreferenceScope, - PreferenceService, -} from '@theia/core/lib/browser/preferences/preference-service'; +import { PreferenceService } from '@theia/core/lib/browser/preferences/preference-service'; import { CommandRegistry } from '@theia/core/lib/common/command'; -import { certificateList, sanifyCertString } from './utils'; +import { certificateList } from './utils'; import { ArduinoFirmwareUploader } from '../../../common/protocol/arduino-firmware-uploader'; import { nls } from '@theia/core/lib/common'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; @@ -28,7 +22,7 @@ import { FrontendApplicationStateService } from '@theia/core/lib/browser/fronten @injectable() export class UploadCertificateDialogWidget extends ReactWidget { @inject(BoardsServiceProvider) - protected readonly boardsServiceClient: BoardsServiceProvider; + protected readonly boardsServiceProvider: BoardsServiceProvider; @inject(ArduinoPreferences) protected readonly arduinoPreferences: ArduinoPreferences; @@ -47,7 +41,7 @@ export class UploadCertificateDialogWidget extends ReactWidget { protected certificates: string[] = []; protected updatableFqbns: string[] = []; - protected availableBoards: AvailableBoard[] = []; + // protected availableBoards: AvailableBoard[] = []; public busyCallback = (busy: boolean) => { return; @@ -81,25 +75,25 @@ export class UploadCertificateDialogWidget extends ReactWidget { }) ); - this.boardsServiceClient.onAvailableBoardsChanged((availableBoards) => { - this.availableBoards = availableBoards; - this.update(); - }); + // this.boardsServiceProvider.onAvailableBoardsChanged((availableBoards) => { + // this.availableBoards = availableBoards; + // this.update(); + // }); } - private addCertificate(certificate: string) { - const certString = sanifyCertString(certificate); + // private addCertificate(certificate: string) { + // const certString = sanifyCertString(certificate); - if (certString.length > 0) { - this.certificates.push(sanifyCertString(certificate)); - } + // if (certString.length > 0) { + // this.certificates.push(sanifyCertString(certificate)); + // } - this.preferenceService.set( - 'arduino.board.certificates', - this.certificates.join(','), - PreferenceScope.User - ); - } + // this.preferenceService.set( + // 'arduino.board.certificates', + // this.certificates.join(','), + // PreferenceScope.User + // ); + // } protected openContextMenu(x: number, y: number, cert: string): void { this.commandRegistry.executeCommand( @@ -124,16 +118,17 @@ export class UploadCertificateDialogWidget extends ReactWidget { } protected render(): React.ReactNode { - return ( - - ); + // return ( + // + // ); + return undefined; } } diff --git a/arduino-ide-extension/src/browser/dialogs/certificate-uploader/select-board-components.tsx b/arduino-ide-extension/src/browser/dialogs/certificate-uploader/select-board-components.tsx index 285f136c3..bb2cf16f6 100644 --- a/arduino-ide-extension/src/browser/dialogs/certificate-uploader/select-board-components.tsx +++ b/arduino-ide-extension/src/browser/dialogs/certificate-uploader/select-board-components.tsx @@ -1,37 +1,43 @@ -import { nls } from '@theia/core/lib/common'; +import { nls } from '@theia/core/lib/common/nls'; import * as React from '@theia/core/shared/react'; -import { AvailableBoard } from '../../boards/boards-service-provider'; +import { + BoardList, + BoardListItem, + getInferredBoardOrBoard, +} from '../../../common/protocol/board-list'; +import { boardIdentifierEquals } from '../../../common/protocol/boards-service'; import { ArduinoSelect } from '../../widgets/arduino-select'; type BoardOption = { value: string; label: string }; export const SelectBoardComponent = ({ - availableBoards, + boardList, updatableFqbns, - onBoardSelect, - selectedBoard, + onItemSelect, + selectedItem, busy, }: { - availableBoards: AvailableBoard[]; + boardList: BoardList; updatableFqbns: string[]; - onBoardSelect: (board: AvailableBoard | null) => void; - selectedBoard: AvailableBoard | null; + onItemSelect: (item: Required | null) => void; + selectedItem: Required | null; busy: boolean; }): React.ReactElement => { const [selectOptions, setSelectOptions] = React.useState([]); - const [selectBoardPlaceholder, setSelectBoardPlaceholder] = - React.useState(''); + const [selectItemPlaceholder, setSelectBoardPlaceholder] = React.useState(''); const selectOption = React.useCallback( (boardOpt: BoardOption) => { - onBoardSelect( + onItemSelect( (boardOpt && - availableBoards.find((board) => board.fqbn === boardOpt.value)) || + boardList.boards.find( + (board) => board.board.fqbn === boardOpt.value + )) || null ); }, - [availableBoards, onBoardSelect] + [boardList, onItemSelect] ); React.useEffect(() => { @@ -44,22 +50,27 @@ export const SelectBoardComponent = ({ 'arduino/certificate/selectBoard', 'Select a board...' ); - let selBoard = -1; - const updatableBoards = availableBoards.filter( - (board) => board.port && board.fqbn && updatableFqbns.includes(board.fqbn) + const updatableBoards = boardList.boards.filter( + (item) => item.board.fqbn && updatableFqbns.includes(item.board.fqbn) ); - const boardsList: BoardOption[] = updatableBoards.map((board, i) => { - if (board.selected) { + let selBoard = -1; + const selectedItem: BoardListItem | undefined = + boardList[boardList.selectedIndex]; + const selectedBoard = selectedItem + ? getInferredBoardOrBoard(selectedItem) + : undefined; + const boardsList: BoardOption[] = updatableBoards.map((item, i) => { + if (!!selectedBoard && boardIdentifierEquals(item.board, selectedBoard)) { selBoard = i; } return { label: nls.localize( 'arduino/certificate/boardAtPort', '{0} at {1}', - board.name, - board.port?.address ?? '' + item.board.name, + item.port?.address ?? '' ), - value: board.fqbn || '', + value: item.board.fqbn || '', }; }); @@ -73,30 +84,30 @@ export const SelectBoardComponent = ({ setSelectBoardPlaceholder(placeholderTxt); setSelectOptions(boardsList); - if (selectedBoard) { + if (selectedItem) { selBoard = boardsList .map((boardOpt) => boardOpt.value) - .indexOf(selectedBoard.fqbn || ''); + .indexOf(getInferredBoardOrBoard(selectedItem)?.fqbn || ''); } selectOption(boardsList[selBoard] || null); - }, [busy, availableBoards, selectOption, updatableFqbns, selectedBoard]); + }, [busy, boardList, selectOption, updatableFqbns, selectedItem]); return ( Promise; @@ -31,8 +34,8 @@ export const FirmwareUploaderComponent = ({ 'ok' | 'fail' | 'installing' | null >(null); - const [selectedBoard, setSelectedBoard] = - React.useState(null); + const [selectedItem, setSelectedItem] = + React.useState | null>(null); const [availableFirmwares, setAvailableFirmwares] = React.useState< FirmwareInfo[] @@ -50,13 +53,13 @@ export const FirmwareUploaderComponent = ({ const fetchFirmwares = React.useCallback(async () => { setInstallFeedback(null); setFirmwaresFetching(true); - if (!selectedBoard) { + if (!selectedItem) { return; } // fetch the firmwares for the selected board const firmwaresForFqbn = await firmwareUploader.availableFirmwares( - selectedBoard.fqbn || '' + selectedItem.board.fqbn || '' ); setAvailableFirmwares(firmwaresForFqbn); @@ -69,7 +72,7 @@ export const FirmwareUploaderComponent = ({ if (firmwaresForFqbn.length > 0) setSelectedFirmware(firmwaresOpts[0]); setFirmwaresFetching(false); - }, [firmwareUploader, selectedBoard]); + }, [firmwareUploader, selectedItem]); const installFirmware = React.useCallback(async () => { setInstallFeedback('installing'); @@ -81,27 +84,27 @@ export const FirmwareUploaderComponent = ({ try { const installStatus = !!firmwareToFlash && - !!selectedBoard?.port && - (await flashFirmware(firmwareToFlash, selectedBoard?.port)); + !!selectedItem?.board && + (await flashFirmware(firmwareToFlash, selectedItem?.port)); setInstallFeedback((installStatus && 'ok') || 'fail'); } catch { setInstallFeedback('fail'); } - }, [firmwareUploader, selectedBoard, selectedFirmware, availableFirmwares]); + }, [selectedItem, selectedFirmware, availableFirmwares, flashFirmware]); - const onBoardSelect = React.useCallback( - (board: AvailableBoard) => { - const newFqbn = (board && board.fqbn) || null; - const prevFqbn = (selectedBoard && selectedBoard.fqbn) || null; + const onItemSelect = React.useCallback( + (item: Required | null) => { + const newFqbn = item && item.board.fqbn; + const prevFqbn = (selectedItem && selectedItem.board.fqbn) || null; if (newFqbn !== prevFqbn) { setInstallFeedback(null); setAvailableFirmwares([]); - setSelectedBoard(board); + setSelectedItem(item); } }, - [selectedBoard] + [selectedItem] ); return ( @@ -115,10 +118,10 @@ export const FirmwareUploaderComponent = ({
@@ -126,7 +129,7 @@ export const FirmwareUploaderComponent = ({ type="button" className="theia-button secondary" disabled={ - selectedBoard === null || + selectedItem === null || firmwaresFetching || installFeedback === 'installing' } @@ -150,7 +153,7 @@ export const FirmwareUploaderComponent = ({ id="firmware-select" menuPosition="fixed" isDisabled={ - !selectedBoard || + !selectedItem || firmwaresFetching || installFeedback === 'installing' } diff --git a/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-dialog.tsx b/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-dialog.tsx index 20d97d4b2..40aefc37d 100644 --- a/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-dialog.tsx +++ b/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-dialog.tsx @@ -1,24 +1,21 @@ -import * as React from '@theia/core/shared/react'; +import { DialogProps } from '@theia/core/lib/browser/dialogs'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; +import { Message } from '@theia/core/shared/@phosphor/messaging'; import { inject, injectable, postConstruct, } from '@theia/core/shared/inversify'; -import { DialogProps } from '@theia/core/lib/browser/dialogs'; -import { ReactDialog } from '../../theia/dialogs/dialogs'; -import { Message } from '@theia/core/shared/@phosphor/messaging'; -import { - AvailableBoard, - BoardsServiceProvider, -} from '../../boards/boards-service-provider'; +import * as React from '@theia/core/shared/react'; import { ArduinoFirmwareUploader, FirmwareInfo, } from '../../../common/protocol/arduino-firmware-uploader'; -import { FirmwareUploaderComponent } from './firmware-uploader-component'; +import { Port } from '../../../common/protocol/boards-service'; +import { BoardsServiceProvider } from '../../boards/boards-service-provider'; import { UploadFirmware } from '../../contributions/upload-firmware'; -import { Port } from '../../../common/protocol'; -import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; +import { ReactDialog } from '../../theia/dialogs/dialogs'; +import { FirmwareUploaderComponent } from './firmware-uploader-component'; @injectable() export class UploadFirmwareDialogProps extends DialogProps {} @@ -26,14 +23,13 @@ export class UploadFirmwareDialogProps extends DialogProps {} @injectable() export class UploadFirmwareDialog extends ReactDialog { @inject(BoardsServiceProvider) - private readonly boardsServiceClient: BoardsServiceProvider; + private readonly boardsServiceProvider: BoardsServiceProvider; @inject(ArduinoFirmwareUploader) private readonly arduinoFirmwareUploader: ArduinoFirmwareUploader; @inject(FrontendApplicationStateService) private readonly appStatusService: FrontendApplicationStateService; private updatableFqbns: string[] = []; - private availableBoards: AvailableBoard[] = []; private isOpen = new Object(); private busy = false; @@ -54,11 +50,7 @@ export class UploadFirmwareDialog extends ReactDialog { this.updatableFqbns = fqbns; this.update(); }); - - this.boardsServiceClient.onAvailableBoardsChanged((availableBoards) => { - this.availableBoards = availableBoards; - this.update(); - }); + this.boardsServiceProvider.onBoardListDidChange(() => this.update()); } get value(): void { @@ -70,7 +62,7 @@ export class UploadFirmwareDialog extends ReactDialog {
{ - await this.boardsServiceProvider.reconciled; - this.lastConnectedBoard = { - selectedBoard: this.boardsServiceProvider.boardsConfig.selectedBoard, - selectedPort: this.boardsServiceProvider.boardsConfig.selectedPort, - }; - - if (!this.onBoardsConfigChanged) { - this.onBoardsConfigChanged = - this.boardsServiceProvider.onBoardsConfigChanged( - async ({ selectedBoard, selectedPort }) => { - if ( - typeof selectedBoard === 'undefined' || - typeof selectedPort === 'undefined' - ) + const { boardList } = this.boardsServiceProvider; + this.lastConnectedBoard = boardList[boardList.selectedIndex]; + if (!this.onBoardListDidChange) { + this.onBoardListDidChange = + this.boardsServiceProvider.onBoardListDidChange( + async (newBoardList) => { + const currentConnectedBoard = + newBoardList[newBoardList.selectedIndex]; + if (!currentConnectedBoard) { return; + } - // a board is plugged and it's different from the old connected board if ( - selectedBoard?.fqbn !== - this.lastConnectedBoard?.selectedBoard?.fqbn || - Port.keyOf(selectedPort) !== - (this.lastConnectedBoard.selectedPort - ? Port.keyOf(this.lastConnectedBoard.selectedPort) - : undefined) + !this.lastConnectedBoard || + boardListItemEquals( + currentConnectedBoard, + this.lastConnectedBoard + ) ) { - this.lastConnectedBoard = { - selectedBoard: selectedBoard, - selectedPort: selectedPort, - }; - this.onMonitorShouldResetEmitter.fire(); - } else { // a board is plugged and it's the same as prev, rerun "this.startMonitor" to // recreate the listener callback this.startMonitor(); + } else { + // a board is plugged and it's different from the old connected board + this.lastConnectedBoard = currentConnectedBoard; + this.onMonitorShouldResetEmitter.fire(); } } ); @@ -186,7 +179,10 @@ export class MonitorManagerProxyClientImpl } } - getCurrentSettings(board: Board, port: Port): Promise { + getCurrentSettings( + board: BoardIdentifier, + port: PortIdentifier + ): Promise { return this.server().getCurrentSettings(board, port); } diff --git a/arduino-ide-extension/src/browser/notification-center.ts b/arduino-ide-extension/src/browser/notification-center.ts index 96c938d25..e7d5c6676 100644 --- a/arduino-ide-extension/src/browser/notification-center.ts +++ b/arduino-ide-extension/src/browser/notification-center.ts @@ -14,13 +14,13 @@ import { NotificationServiceClient, NotificationServiceServer, } from '../common/protocol/notification-service'; -import { - AttachedBoardsChangeEvent, +import type { BoardsPackage, LibraryPackage, ConfigState, Sketch, ProgressMessage, + DetectedPorts, } from '../common/protocol'; import { FrontendApplicationStateService, @@ -61,8 +61,9 @@ export class NotificationCenter private readonly libraryDidUninstallEmitter = new Emitter<{ item: LibraryPackage; }>(); - private readonly attachedBoardsDidChangeEmitter = - new Emitter(); + private readonly detectedPortsDidChangeEmitter = new Emitter<{ + detectedPorts: DetectedPorts; + }>(); private readonly recentSketchesChangedEmitter = new Emitter<{ sketches: Sketch[]; }>(); @@ -82,7 +83,7 @@ export class NotificationCenter this.platformDidUninstallEmitter, this.libraryDidInstallEmitter, this.libraryDidUninstallEmitter, - this.attachedBoardsDidChangeEmitter + this.detectedPortsDidChangeEmitter ); readonly onDidReinitialize = this.didReinitializeEmitter.event; @@ -97,8 +98,7 @@ export class NotificationCenter readonly onPlatformDidUninstall = this.platformDidUninstallEmitter.event; readonly onLibraryDidInstall = this.libraryDidInstallEmitter.event; readonly onLibraryDidUninstall = this.libraryDidUninstallEmitter.event; - readonly onAttachedBoardsDidChange = - this.attachedBoardsDidChangeEmitter.event; + readonly onDetectedPortsDidChange = this.detectedPortsDidChangeEmitter.event; readonly onRecentSketchesDidChange = this.recentSketchesChangedEmitter.event; readonly onAppStateDidChange = this.onAppStateDidChangeEmitter.event; @@ -166,8 +166,8 @@ export class NotificationCenter this.libraryDidUninstallEmitter.fire(event); } - notifyAttachedBoardsDidChange(event: AttachedBoardsChangeEvent): void { - this.attachedBoardsDidChangeEmitter.fire(event); + notifyDetectedPortsDidChange(event: { detectedPorts: DetectedPorts }): void { + this.detectedPortsDidChangeEmitter.fire(event); } notifyRecentSketchesDidChange(event: { sketches: Sketch[] }): void { diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx index 7f2e6d02c..2ac378347 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx @@ -173,7 +173,6 @@ export class MonitorWidget extends ReactWidget { private async startMonitor(): Promise { await this.appStateService.reachedState('ready'); - await this.boardsServiceProvider.reconciled; await this.syncSettings(); await this.monitorManagerProxy.startMonitor(); } diff --git a/arduino-ide-extension/src/browser/style/boards-config-dialog.css b/arduino-ide-extension/src/browser/style/boards-config-dialog.css index 90938ca70..c64ab45d3 100644 --- a/arduino-ide-extension/src/browser/style/boards-config-dialog.css +++ b/arduino-ide-extension/src/browser/style/boards-config-dialog.css @@ -16,7 +16,7 @@ div#select-board-dialog .selectBoardContainer { height: 100%; } -.select-board-dialog .head { +#select-board-dialog .head { margin: 5px; } @@ -196,7 +196,10 @@ div#select-board-dialog .selectBoardContainer .list .item.selected i { } .arduino-boards-toolbar-item--label-connected { + font-family: 'Open Sans Bold'; + font-style: normal; font-weight: 700; + font-size: 14px; } .arduino-boards-toolbar-item-container .caret { @@ -208,6 +211,10 @@ div#select-board-dialog .selectBoardContainer .list .item.selected i { margin: -1px; z-index: 1; border: 1px solid var(--theia-arduino-toolbar-dropdown-border); + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-size: 12px; } .arduino-boards-dropdown-list:focus { @@ -230,7 +237,6 @@ div#select-board-dialog .selectBoardContainer .list .item.selected i { cursor: default; display: flex; font-size: var(--theia-ui-font-size1); - gap: 10px; justify-content: space-between; padding: 10px; } @@ -240,10 +246,33 @@ div#select-board-dialog .selectBoardContainer .list .item.selected i { flex: 1; } +/* Redefine default codicon size https://github.com/microsoft/vscode/commit/38cd0a377b7abef34fb07fe770fc633e68819ba6 */ +.arduino-boards-dropdown-item .codicon[class*='codicon-'] { + font-size: 14px; +} + +.arduino-boards-dropdown-item .p-TabBar-toolbar { + padding: 0px; + margin: 0px; + flex-direction: column; +} + +.arduino-boards-dropdown-item .p-TabBar-toolbar .item { + margin: 0px; +} + +.arduino-boards-dropdown-item .p-TabBar-toolbar .item .action-label { + padding: 0px; +} + .arduino-boards-dropdown-item--board-label { font-size: 14px; } +.arduino-boards-dropdown-item .arduino-boards-dropdown-item--protocol { + margin-right: 10px; +} + .arduino-boards-dropdown-item--port-label { font-size: 12px; } @@ -267,10 +296,6 @@ div#select-board-dialog .selectBoardContainer .list .item.selected i { color: var(--theia-arduino-toolbar-dropdown-iconSelected); } -.arduino-boards-dropdown-item .fa-check { - align-self: center; -} - .arduino-board-dropdown-footer { color: var(--theia-secondaryButton-foreground); border-top: 1px solid var(--theia-dropdown-border); diff --git a/arduino-ide-extension/src/browser/theia/dialogs/dialogs.tsx b/arduino-ide-extension/src/browser/theia/dialogs/dialogs.tsx index 57eef5639..20284b413 100644 --- a/arduino-ide-extension/src/browser/theia/dialogs/dialogs.tsx +++ b/arduino-ide-extension/src/browser/theia/dialogs/dialogs.tsx @@ -18,7 +18,6 @@ export abstract class AbstractDialog extends TheiaAbstractDialog { @inject(DialogProps) protected override readonly props: DialogProps ) { super(props); - this.closeCrossNode.classList.remove(...codiconArray('close')); this.closeCrossNode.classList.add('fa', 'fa-close'); } diff --git a/arduino-ide-extension/src/common/nls.ts b/arduino-ide-extension/src/common/nls.ts index 29ab5604f..06c8baee7 100644 --- a/arduino-ide-extension/src/common/nls.ts +++ b/arduino-ide-extension/src/common/nls.ts @@ -1,5 +1,6 @@ import { nls } from '@theia/core/lib/common/nls'; +// TODO: rename constants: `Unknown` should be `unknownLabel`, change `Later` to `laterLabel`, etc. export const Unknown = nls.localize('arduino/common/unknown', 'Unknown'); export const Later = nls.localize('arduino/common/later', 'Later'); export const Updatable = nls.localize('arduino/common/updateable', 'Updatable'); diff --git a/arduino-ide-extension/src/common/protocol/board-list.ts b/arduino-ide-extension/src/common/protocol/board-list.ts new file mode 100644 index 000000000..442a5b9bf --- /dev/null +++ b/arduino-ide-extension/src/common/protocol/board-list.ts @@ -0,0 +1,374 @@ +import { naturalCompare } from '../utils'; +import { + BoardIdentifier, + boardIdentifierComparator, + boardIdentifierEquals, + BoardsConfig, + DetectedPort, + DetectedPorts, + emptyBoardsConfig, + findMatchingPortIndex, + isBoardIdentifier, + isDefinedBoardsConfig, + Port, + portIdentifierEquals, + portProtocolComparator, +} from './boards-service'; + +export interface BoardListItem { + readonly port: Port; + readonly board?: BoardIdentifier; +} + +export function isBoardListItem(arg: unknown): arg is BoardListItem { + return ( + Boolean(arg) && + typeof arg === 'object' && + (arg).port !== undefined && + Port.is((arg).port) && + ((arg).board === undefined || + ((arg).board !== undefined && + isBoardIdentifier((arg).board))) + ); +} + +export function boardListItemEquals( + left: BoardListItem, + right: BoardListItem +): boolean { + if (portIdentifierEquals(left.port, right.port)) { + const leftBoard = getBoardOrInferredBoard(left); + const rightBoard = getBoardOrInferredBoard(right); + if (boardIdentifierEquals(leftBoard, rightBoard)) { + const leftInferredBoard = getInferredBoardOrBoard(left); + const rightInferredBoard = getInferredBoardOrBoard(right); + return boardIdentifierEquals(leftInferredBoard, rightInferredBoard); + } + } + return false; +} + +export function getBoardOrInferredBoard( + item: BoardListItem +): BoardIdentifier | undefined { + let board: BoardIdentifier | undefined = undefined; + board = item.board; + if (!board && isInferredBoardListItem(item)) { + board = item.inferredBoard; + } + return board; +} + +export function getInferredBoardOrBoard( + item: BoardListItem +): BoardIdentifier | undefined { + if (isInferredBoardListItem(item)) { + return item.inferredBoard; + } + return item.board; +} + +interface BoardSelectedBoardListItem extends BoardListItem { + readonly inferredBoard: BoardIdentifier; + readonly type: Extract; +} + +interface BoardOverriddenBoardListItem extends BoardListItem { + readonly board: BoardIdentifier; + readonly inferredBoard: BoardIdentifier; + readonly type: Extract; +} + +export type InferredBoardListItem = + | BoardSelectedBoardListItem + | BoardOverriddenBoardListItem; + +export function isInferredBoardListItem( + arg: unknown +): arg is InferredBoardListItem { + return ( + isBoardListItem(arg) && + (arg).type !== undefined && + isInferenceType((arg).type) && + (arg).inferredBoard !== undefined && + isBoardIdentifier((arg).inferredBoard) + ); +} + +/** + * Stores historical info about boards manually specified for detected boards. The key are generated with `Port#keyOf`. + */ +export type BoardListHistory = Readonly>; + +export function isBoardListHistory(arg: unknown): arg is BoardListHistory { + return ( + Boolean(arg) && + typeof arg === 'object' && + Object.entries(arg).every(([, value]) => isBoardIdentifier(value)) + ); +} + +const inferenceTypeLiterals = [ + /** + * The user has manually selected the board (FQBN) for the detected port of a 3rd party board (no matching boards were detected by the CLI for the port) + */ + 'board-select', + /** + * The user has manually edited the detected FQBN of a recognized board from a detected port (there are matching boards for a detected port, but the user decided to use another FQBN) + */ + 'board-overridden', +] as const; +type InferenceType = (typeof inferenceTypeLiterals)[number]; +function isInferenceType(arg: unknown): arg is InferenceType { + return ( + typeof arg === 'string' && + inferenceTypeLiterals.includes(arg) + ); +} + +/** + * Compare precedence: + * 1. `BoardListItem#port#protocol`: `'serial'`, `'network'`, then natural compare of the `protocol` string. + * 1. `BoardListItem`s with a `board` comes before items without a `board`. + * 1. `BoardListItem#board`: + * 1. Items with `'arduino'` vendor ID in the `fqbn` come before other vendors. + * 1. Natural compare of the `name`. + * 1. If the `BoardListItem`s do not have a `board` property, `BoardListItem#port#address` natural compare is the fallback. + */ +function boardListItemComparator( + left: BoardListItem, + right: BoardListItem +): number { + // sort by port protocol + let result = portProtocolComparator(left.port, right.port); + if (result) { + return result; + } + + // compare by board + result = boardIdentifierComparator( + getBoardOrInferredBoard(left), + getBoardOrInferredBoard(right) + ); + if (result) { + return result; + } + + // fallback compare based on the address + return naturalCompare(left.port.address, right.port.address); +} + +/** + * A list of boards discovered by the Arduino CLI. With the `board list --watch` gRPC equivalent command, + * the CLI provides a `1..*` mapping between a port and the matching boards list. This type inverts the mapping + * and makes a `1..1` association between a board identifier and the port it belongs to. + */ +export type BoardList = + readonly T[] & { + /** + * A snapshot of the board and port configuration this board list has been initialized with. + */ + readonly boardsConfig: Readonly; + + /** + * Index of the board+port item that is currently "selected". A board list item is selected, if matches the board+port combination of `boardsConfig`. + */ + get selectedIndex(): number; + + /** + * Contains all boards recognized from the detected port, and an optional unrecognized one that is derived from the detected port and the `initParam#selectedBoard`. + */ + get boards(): readonly Required[]; + + /** + * If `predicate` is not defined, no ports are filtered. + */ + ports( + predicate?: (detectedPort: DetectedPort) => boolean + ): readonly DetectedPort[] & Readonly<{ matchingIndex: number }>; + + portsGroupedByProtocol(): Readonly< + Record<'serial' | 'network' | string, ReturnType> + >; + + toString(): string; + }; + +export function createBoardList( + detectedPorts: DetectedPorts, + boardsConfig: Readonly = emptyBoardsConfig(), + history: BoardListHistory = {} +): BoardList { + const items: BoardListItem[] = []; + for (const detectedPort of Object.values(detectedPorts)) { + const { port, boards } = detectedPort; + const portKey = Port.keyOf(port); + const inferredBoard = history[portKey]; + if (!boards?.length) { + // Infer unrecognized boards from the history + if (inferredBoard) { + const item: InferredBoardListItem = { + port, + inferredBoard, + type: 'board-select', + }; + items.push(item); + } else { + items.push({ port }); + } + } else { + // Otherwise, include the port for each board. + for (const board of boards) { + const { fqbn, name } = board; + if ( + isBoardIdentifier(board) && + isBoardIdentifier(inferredBoard) && + !boardIdentifierEquals(board, inferredBoard) + ) { + const inferredItem: InferredBoardListItem = { + inferredBoard, + port, + type: 'board-overridden', + board, + }; + items.push(inferredItem); + } else { + items.push({ port, board: { fqbn, name } }); + } + } + } + } + items.sort(boardListItemComparator); + const length = items.length; + const findSelectedIndex = (): number => { + if (!isDefinedBoardsConfig(boardsConfig)) { + return -1; + } + const { selectedPort, selectedBoard } = boardsConfig; + const portKey = Port.keyOf(selectedPort); + // find the exact match of the board and port combination + for (let index = 0; index < length; index++) { + const item = items[index]; + const { board, port } = item; + if (!board) { + continue; + } + if ( + Port.keyOf(port) === portKey && + boardIdentifierEquals(board, selectedBoard) + ) { + return index; + } + } + // find match from inferred board + for (let index = 0; index < length; index++) { + const item = items[index]; + if (!isInferredBoardListItem(item)) { + continue; + } + const { inferredBoard, port } = item; + if ( + Port.keyOf(port) === portKey && + boardIdentifierEquals(inferredBoard, boardsConfig.selectedBoard) + ) { + return index; + } + } + return -1; + }; + + let _selectedIndex: number | undefined; + let _allPorts: DetectedPort[] | undefined; + const ports = ( + predicate: (detectedPort: DetectedPort) => boolean = () => true + ) => { + if (!_allPorts) { + _allPorts = []; + // to keep the order or the detected ports + const visitedPortKeys = new Set(); + for (let i = 0; i < length; i++) { + const { port } = items[i]; + const portKey = Port.keyOf(port); + if (!visitedPortKeys.has(portKey)) { + visitedPortKeys.add(portKey); + const detectedPort = detectedPorts[portKey]; + if (detectedPort) { + _allPorts.push(detectedPort); + } + } + } + } + const ports = _allPorts.filter(predicate); + const matchingIndex = findMatchingPortIndex( + boardsConfig.selectedPort, + ports + ); + return Object.assign(ports, { matchingIndex }); + }; + const selectedIndexMemoized = () => { + if (typeof _selectedIndex !== 'number') { + _selectedIndex = findSelectedIndex(); + } + return _selectedIndex; + }; + + let _boards: Required[] | undefined; + const boardList: BoardList = Object.assign(items, { + boardsConfig, + get selectedIndex() { + return selectedIndexMemoized(); + }, + get boards() { + if (!_boards) { + _boards = []; + for (let i = 0; i < length; i++) { + const item = items[i]; + if (item.board) { + _boards.push(>item); + } + } + } + return _boards; + }, + ports(predicate?: (detectedPort: DetectedPort) => boolean) { + return ports(predicate); + }, + portsGroupedByProtocol() { + const result: Record = + {}; + const allPorts = ports(); + for (const detectedPort of allPorts) { + const protocol = detectedPort.port.protocol; + if (!result[protocol]) { + result[protocol] = Object.assign([], { + matchingIndex: -1, + }); + } + const portsOnProtocol = result[protocol]; + portsOnProtocol.push(detectedPort); + } + const matchItem = allPorts[allPorts.matchingIndex]; + // match index is per all ports, IDE2 needs to adjust it per protocol + if (matchItem) { + const matchProtocol = matchItem.port.protocol; + const matchPorts = result[matchProtocol]; + matchPorts.matchingIndex = matchPorts.indexOf(matchItem); + } + return result; + }, + toString() { + const selectedIndex = selectedIndexMemoized(); + return JSON.stringify( + { + detectedPorts, + boardsConfig, + items, + selectedIndex, + }, + null, + 2 + ); + }, + }); + return boardList; +} diff --git a/arduino-ide-extension/src/common/protocol/boards-service.ts b/arduino-ide-extension/src/common/protocol/boards-service.ts index 7e2f77555..49bb9f3f2 100644 --- a/arduino-ide-extension/src/common/protocol/boards-service.ts +++ b/arduino-ide-extension/src/common/protocol/boards-service.ts @@ -1,8 +1,6 @@ -import { naturalCompare } from './../utils'; -import { Searchable } from './searchable'; -import { Installable } from './installable'; -import { ArduinoComponent } from './arduino-component'; import { nls } from '@theia/core/lib/common/nls'; +import type { MaybePromise } from '@theia/core/lib/common/types'; +import URI from '@theia/core/lib/common/uri'; import { All, Contributed, @@ -10,131 +8,46 @@ import { Type as TypeLabel, Updatable, } from '../nls'; -import URI from '@theia/core/lib/common/uri'; -import { MaybePromise } from '@theia/core/lib/common/types'; - -export type AvailablePorts = Record]>; -export namespace AvailablePorts { - export function groupByProtocol( - availablePorts: AvailablePorts - ): Map { - const grouped = new Map(); - for (const portID of Object.keys(availablePorts)) { - const [port, boards] = availablePorts[portID]; - let ports = grouped.get(port.protocol); - if (!ports) { - ports = {} as AvailablePorts; - } - ports[portID] = [port, boards]; - grouped.set(port.protocol, ports); - } - return grouped; - } - export function split( - state: AvailablePorts - ): Readonly<{ boards: Board[]; ports: Port[] }> { - const availablePorts: Port[] = []; - const attachedBoards: Board[] = []; - for (const key of Object.keys(state)) { - const [port, boards] = state[key]; - availablePorts.push(port); - attachedBoards.push(...boards); - } - return { - boards: attachedBoards, - ports: availablePorts, - }; - } -} +import { Defined } from '../types'; +import { naturalCompare } from './../utils'; +import type { ArduinoComponent } from './arduino-component'; +import type { BoardList } from './board-list'; +import { Installable } from './installable'; +import { Searchable } from './searchable'; -export interface AttachedBoardsChangeEvent { - readonly oldState: Readonly<{ boards: Board[]; ports: Port[] }>; - readonly newState: Readonly<{ boards: Board[]; ports: Port[] }>; - readonly uploadInProgress: boolean; +export interface DetectedPort { + readonly port: Port; + readonly boards?: Pick[]; } -export namespace AttachedBoardsChangeEvent { - export function isEmpty(event: AttachedBoardsChangeEvent): boolean { - const { detached, attached } = diff(event); - return ( - !!detached.boards.length && - !!detached.ports.length && - !!attached.boards.length && - !!attached.ports.length - ); - } - export function toString(event: AttachedBoardsChangeEvent): string { - const rows: string[] = []; - if (!isEmpty(event)) { - const { attached, detached } = diff(event); - const visitedAttachedPorts: Port[] = []; - const visitedDetachedPorts: Port[] = []; - for (const board of attached.boards) { - const port = board.port ? ` on ${Port.toString(board.port)}` : ''; - rows.push(` - Attached board: ${Board.toString(board)}${port}`); - if (board.port) { - visitedAttachedPorts.push(board.port); - } - } - for (const board of detached.boards) { - const port = board.port ? ` from ${Port.toString(board.port)}` : ''; - rows.push(` - Detached board: ${Board.toString(board)}${port}`); - if (board.port) { - visitedDetachedPorts.push(board.port); - } - } - for (const port of attached.ports) { - if (!visitedAttachedPorts.find((p) => Port.sameAs(port, p))) { - rows.push(` - New port is available on ${Port.toString(port)}`); - } - } - for (const port of detached.ports) { - if (!visitedDetachedPorts.find((p) => Port.sameAs(port, p))) { - rows.push(` - Port is no longer available on ${Port.toString(port)}`); - } - } - } - return rows.length ? rows.join('\n') : 'No changes.'; +export function findMatchingPortIndex( + toFind: PortIdentifier | undefined, + ports: readonly DetectedPort[] | readonly Port[] +): number { + if (!toFind) { + return -1; } + const toFindPortKey = Port.keyOf(toFind); + return ports.findIndex((port) => Port.keyOf(port) === toFindPortKey); +} - export function diff(event: AttachedBoardsChangeEvent): Readonly<{ - attached: { - boards: Board[]; - ports: Port[]; - }; - detached: { - boards: Board[]; - ports: Port[]; - }; - }> { - // In `lefts` AND not in `rights`. - const diff = ( - lefts: T[], - rights: T[], - sameAs: (left: T, right: T) => boolean - ) => { - return lefts.filter( - (left) => rights.findIndex((right) => sameAs(left, right)) === -1 - ); - }; - const { boards: newBoards } = event.newState; - const { boards: oldBoards } = event.oldState; - const { ports: newPorts } = event.newState; - const { ports: oldPorts } = event.oldState; - const boardSameAs = (left: Board, right: Board) => - Board.sameAs(left, right); - const portSameAs = (left: Port, right: Port) => Port.sameAs(left, right); - return { - detached: { - boards: diff(oldBoards, newBoards, boardSameAs), - ports: diff(oldPorts, newPorts, portSameAs), - }, - attached: { - boards: diff(newBoards, oldBoards, boardSameAs), - ports: diff(newPorts, oldPorts, portSameAs), - }, - }; +/** + * The closest representation what the Arduino CLI detects with the `board list --watch` gRPC equivalent. + * The keys are unique identifiers generated from the port object (via `Port#keyOf`). + * The values are the detected ports with all their optional `properties` and matching board list. + */ +export type DetectedPorts = Readonly>; + +export function resolveDetectedPort( + port: PortIdentifier, + detectedPorts: DetectedPorts +): Port | undefined { + const portKey = Port.keyOf(port); + const detectedPort = detectedPorts[portKey]; + if (detectedPort) { + return detectedPort.port; } + return undefined; } export const BoardsServicePath = '/services/boards-service'; @@ -152,7 +65,7 @@ export interface BoardsService */ skipPostInstall?: boolean; }): Promise; - getState(): Promise; + getDetectedPorts(): Promise; getBoardDetails(options: { fqbn: string }): Promise; getBoardPackage(options: { id: string }): Promise; getContainerBoardPackage(options: { @@ -180,7 +93,7 @@ export namespace BoardSearch { 'Partner', 'Arduino@Heart', ] as const; - export type Type = typeof TypeLiterals[number]; + export type Type = (typeof TypeLiterals)[number]; export namespace Type { export function is(arg: unknown): arg is Type { return typeof arg === 'string' && TypeLiterals.includes(arg as Type); @@ -252,9 +165,9 @@ export namespace Port { export namespace Properties { export function create( properties: [string, string][] | undefined - ): Properties { - if (!properties) { - return {}; + ): Properties | undefined { + if (!properties || !properties.length) { + return undefined; } return properties.reduce((acc, curr) => { const [key, value] = curr; @@ -282,10 +195,13 @@ export namespace Port { } /** - * Key is the combination of address and protocol formatted like `'${address}|${protocol}'` used to uniquely identify a port. + * Key is the combination of address and protocol formatted like `'arduino+${protocol}://${address}'` used to uniquely identify a port. */ - export function keyOf({ address, protocol }: Port): string { - return `${address}|${protocol}`; + export function keyOf(port: PortIdentifier | Port | DetectedPort): string { + if (isPortIdentifier(port)) { + return `arduino+${port.protocol}://${port.address}`; + } + return keyOf(port.port); } export function toString({ addressLabel, protocolLabel }: Port): string { @@ -297,16 +213,8 @@ export namespace Port { // 1. Serial // 2. Network // 3. Other protocols - if (left.protocol === 'serial' && right.protocol !== 'serial') { - return -1; - } else if (left.protocol !== 'serial' && right.protocol === 'serial') { - return 1; - } else if (left.protocol === 'network' && right.protocol !== 'network') { - return -1; - } else if (left.protocol !== 'network' && right.protocol === 'network') { - return 1; - } - return naturalCompare(left.address!, right.address!); + const priorityResult = portProtocolComparator(left, right); + return priorityResult || naturalCompare(left.address, right.address); } export function sameAs( @@ -324,35 +232,28 @@ export namespace Port { /** * All ports with `'serial'` or `'network'` `protocol`, or any other port `protocol` that has at least one recognized board connected to. */ - export function visiblePorts( - boardsHaystack: ReadonlyArray - ): (port: Port) => boolean { - return (port: Port) => { - if (port.protocol === 'serial' || port.protocol === 'network') { - // Allow all `serial` and `network` boards. - // IDE2 must support better label for unrecognized `network` boards: https://github.com/arduino/arduino-ide/issues/1331 - return true; - } - // All other ports with different protocol are - // only shown if there is a recognized board - // connected - for (const board of boardsHaystack) { - if (board.port?.address === port.address) { - return true; - } - } - return false; - }; + export function isVisiblePort(detectedPort: DetectedPort): boolean { + const protocol = detectedPort.port.protocol; + if (protocol === 'serial' || protocol === 'network') { + // Allow all `serial` and `network` boards. + // IDE2 must support better label for unrecognized `network` boards: https://github.com/arduino/arduino-ide/issues/1331 + return true; + } + // All other ports with different protocol are + // only shown if there is a recognized board + // connected + return Boolean(detectedPort?.boards?.length); } export namespace Protocols { + // IDE2 does not want to handle any other port protocols in a special way export const KnownProtocolLiterals = ['serial', 'network'] as const; - export type KnownProtocol = typeof KnownProtocolLiterals[number]; + export type KnownProtocol = (typeof KnownProtocolLiterals)[number]; export namespace KnownProtocol { export function is(protocol: unknown): protocol is KnownProtocol { return ( typeof protocol === 'string' && - KnownProtocolLiterals.indexOf(protocol as KnownProtocol) >= 0 + KnownProtocolLiterals.includes(protocol as KnownProtocol) ); } } @@ -379,10 +280,17 @@ export namespace BoardsPackage { } export function contains( - selectedBoard: Board, + selectedBoard: Board, // TODO: change to BoardIdentifier? { id, boards }: BoardsPackage ): boolean { - if (boards.some((board) => Board.sameAs(board, selectedBoard))) { + if ( + boards.some(({ name, fqbn }) => + Board.sameAs( + { name, fqbn }, + { name: selectedBoard.name, fqbn: selectedBoard.fqbn } + ) + ) + ) { return true; } if (selectedBoard.fqbn) { @@ -395,11 +303,10 @@ export namespace BoardsPackage { } } -export interface Board { - readonly name: string; - readonly fqbn?: string; - readonly port?: Port; -} +/** + * @deprecated user `BoardIdentifier` instead. + */ +export type Board = BoardIdentifier; export interface BoardUserField { readonly toolId: string; @@ -456,18 +363,6 @@ export interface ConfigOption { readonly values: ConfigValue[]; } export namespace ConfigOption { - export function is(arg: any): arg is ConfigOption { - return ( - !!arg && - 'option' in arg && - 'label' in arg && - 'values' in arg && - typeof arg['option'] === 'string' && - typeof arg['label'] === 'string' && - Array.isArray(arg['values']) - ); - } - /** * Appends the configuration options to the `fqbn` argument. * Throws an error if the `fqbn` does not have the `segment(':'segment)*` format. @@ -555,24 +450,15 @@ export namespace Board { return left.name === right.name && left.fqbn === right.fqbn; } - export function hardwareIdEquals(left: Board, right: Board): boolean { - if (left.port && right.port) { - const { hardwareId: leftHardwareId } = left.port; - const { hardwareId: rightHardwareId } = right.port; - - if (leftHardwareId && rightHardwareId) { - return leftHardwareId === rightHardwareId; - } - } - - return false; - } - - export function sameAs(left: Board, right: string | Board): boolean { + export function sameAs( + left: BoardIdentifier, + right: string | BoardIdentifier + ): 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; + const other: BoardIdentifier = + typeof right === 'string' ? { name: right, fqbn: undefined } : right; if (left.fqbn && other.fqbn) { return left.fqbn === other.fqbn; } @@ -594,7 +480,7 @@ export namespace Board { } export function toString( - board: Board, + board: BoardIdentifier, options: { useFqbn: boolean } = { useFqbn: true } ): string { const fqbn = @@ -612,7 +498,7 @@ export namespace Board { manuallyInstalled: boolean; }>; export function decorateBoards( - selectedBoard: Board | undefined, + selectedBoard: BoardIdentifier | undefined, boards: Array ): Array { // Board names are not unique. We show the corresponding core name as a detail. @@ -622,18 +508,29 @@ export namespace Board { const counter = distinctBoardNames.get(name) || 0; distinctBoardNames.set(name, counter + 1); } - + let selectedBoardPackageId: string | undefined = undefined; + if (selectedBoard?.fqbn) { + selectedBoardPackageId = selectedBoard.fqbn + .split(':') + .slice(0, 2) + .join(':'); + } // Due to the non-unique board names, we have to check the package name as well. const selected = (board: BoardWithPackage) => { if (!!selectedBoard) { - if (Board.equals(board, selectedBoard)) { - if ('packageName' in selectedBoard) { - return board.packageName === (selectedBoard as any).packageName; + if ( + boardIdentifierEquals( + { name: board.name, fqbn: board.fqbn }, + selectedBoard + ) + ) { + if (selectedBoardPackageId) { + return board.packageId === selectedBoardPackageId; } - if ('packageId' in selectedBoard) { - return board.packageId === (selectedBoard as any).packageId; + if (!board.fqbn && board.name === selectedBoard.name) { + return true; } - return true; + return false; } } return false; @@ -674,11 +571,191 @@ export function sanitizeFqbn(fqbn: string | undefined): string | undefined { return `${vendor}:${arch}:${id}`; } -export interface BoardConfig { - selectedBoard?: Board; - selectedPort?: Port; +/** + * Bare minimum information to identify port. + */ +export type PortIdentifier = Readonly>; + +export function portIdentifierEquals( + left: PortIdentifier, + right: PortIdentifier +): boolean { + return left.protocol === right.protocol && left.address === right.address; +} + +export function isPortIdentifier(arg: unknown): arg is PortIdentifier { + return ( + Boolean(arg) && + typeof arg === 'object' && + (arg).protocol !== undefined && + typeof (arg).protocol === 'string' && + (arg).address !== undefined && + typeof (arg).address === 'string' + ); +} + +// the smaller the number, the higher the priority +const portProtocolPriorities: Record = { + serial: 0, + network: 1, +} as const; + +/** + * See `boardListItemComparator`. + */ +export function portProtocolComparator( + left: PortIdentifier, + right: PortIdentifier +): number { + const leftPriority = + portProtocolPriorities[left.protocol] ?? Number.MAX_SAFE_INTEGER; + const rightPriority = + portProtocolPriorities[right.protocol] ?? Number.MAX_SAFE_INTEGER; + return leftPriority - rightPriority; } +/** + * Lightweight information to identify a board.\ + * \ + * Note: the `name` property of the board identifier must never participate in the board's identification. + * Hence, it should only be used as the final fallback for the UI when the board's platform is not installed and only the board's name is available. + */ +export interface BoardIdentifier { + /** + * The name of the board. It's only purpose is to provide a fallback for the UI. Preferably do not use this property for any sophisticated logic. When + */ + readonly name: string; + /** + * The FQBN might contain boards config options if selected from the discovered ports (see [arduino/arduino-ide#1588](https://github.com/arduino/arduino-ide/issues/1588)). + */ + // TODO: decide whether to persist the boards config if any + readonly fqbn: string | undefined; +} + +export function isBoardIdentifier(arg: unknown): arg is BoardIdentifier { + return ( + Boolean(arg) && + typeof arg === 'object' && + (arg).name !== undefined && + typeof (arg).name === 'string' && + ((arg).fqbn === undefined || + ((arg).fqbn !== undefined && + typeof (arg).fqbn === 'string')) + ); +} + +/** + * @param options if `loose` is `true`, FQBN config options are ignored. Hence, `{ name: 'x', fqbn: 'a:b:c:o1=v1 }` equals `{ name: 'y', fqbn: 'a:b:c' }`. It's `true` by default. + */ +export function boardIdentifierEquals( + left: BoardIdentifier | undefined, + right: BoardIdentifier | undefined, + options: { loose: boolean } = { loose: true } +): boolean { + if (!left) { + return !right; + } + if (!right) { + return !left; + } + if (left.fqbn && right.fqbn) { + const leftFqbn = options.loose ? sanitizeFqbn(left.fqbn) : left.fqbn; + const rightFqbn = options.loose ? sanitizeFqbn(right.fqbn) : right.fqbn; + if (leftFqbn === rightFqbn) { + return true; + } + } + // No more Genuino hack. + // https://github.com/arduino/arduino-ide/blob/f6a43254f5c416a2e4fa888875358336b42dd4d5/arduino-ide-extension/src/common/protocol/boards-service.ts#L572-L581 + return left.name === right.name; +} + +/** + * See `boardListItemComparator`. + */ +export function boardIdentifierComparator( + left: BoardIdentifier | undefined, + right: BoardIdentifier | undefined +): number { + if (!left) { + return right ? 1 : 0; + } + if (!right) { + return left ? -1 : 0; + } + let leftVendor: string | undefined = undefined; + let rightVendor: string | undefined = undefined; + if (left.fqbn) { + const [vendor] = left.fqbn.split(':'); + leftVendor = vendor; + } + if (right.fqbn) { + const [vendor] = right.fqbn.split(':'); + rightVendor = vendor; + } + if (leftVendor === 'arduino' && rightVendor !== 'arduino') { + return -1; + } + if (leftVendor !== 'arduino' && rightVendor === 'arduino') { + return 1; + } + return naturalCompare(left.name, right.name); +} + +export interface BoardsConfig { + selectedBoard: BoardIdentifier | undefined; + selectedPort: PortIdentifier | undefined; +} + +/** + * Creates a new board config object with `undefined` properties. + */ +export function emptyBoardsConfig(): BoardsConfig { + return { + selectedBoard: undefined, + selectedPort: undefined, + }; +} + +export function isDefinedBoardsConfig( + boardsConfig: BoardsConfig | undefined +): boardsConfig is Defined { + if (!boardsConfig) { + return false; + } + return ( + boardsConfig.selectedBoard !== undefined && + boardsConfig.selectedPort !== undefined + ); +} + +export interface BoardIdentifierChangeEvent { + readonly previousSelectedBoard: BoardIdentifier | undefined; + readonly selectedBoard: BoardIdentifier | undefined; +} + +export function isBoardIdentifierChangeEvent( + event: BoardsConfigChangeEvent +): event is BoardIdentifierChangeEvent { + return 'previousSelectedBoard' in event && 'selectedBoard' in event; +} + +export interface PortIdentifierChangeEvent { + readonly previousSelectedPort: PortIdentifier | undefined; + readonly selectedPort: PortIdentifier | undefined; +} + +export function isPortIdentifierChangeEvent( + event: BoardsConfigChangeEvent +): event is PortIdentifierChangeEvent { + return 'previousSelectedPort' in event && 'selectedPort' in event; +} + +export type BoardsConfigChangeEvent = + | BoardIdentifierChangeEvent + | PortIdentifierChangeEvent + | (BoardIdentifierChangeEvent & PortIdentifierChangeEvent); + export interface BoardInfo { /** * Board name. Could be `'Unknown board`'. @@ -719,45 +796,40 @@ export const unknownBoard = nls.localize( * The returned promise resolves to a `BoardInfo` if available to show in the UI or an info message explaining why showing the board info is not possible. */ export async function getBoardInfo( - selectedPort: Port | undefined, - availablePorts: MaybePromise + boardListProvider: MaybePromise ): Promise { - if (!selectedPort) { + const boardList = await boardListProvider; + const ports = boardList.ports(); + const detectedPort = ports[ports.matchingIndex]; + if (!detectedPort) { return selectPortForInfo; } + const { port: selectedPort, boards } = detectedPort; // IDE2 must show the board info based on the selected port. // https://github.com/arduino/arduino-ide/issues/1489 // IDE 1.x supports only serial port protocol if (selectedPort.protocol !== 'serial') { return nonSerialPort; } - const selectedPortKey = Port.keyOf(selectedPort); - const state = await availablePorts; - const boardListOnSelectedPort = Object.entries(state).filter( - ([portKey, [port]]) => - portKey === selectedPortKey && isNonNativeSerial(port) - ); - - if (!boardListOnSelectedPort.length) { + if (!isNonNativeSerial(selectedPort)) { return noNativeSerialPort; } - const [, [port, boards]] = boardListOnSelectedPort[0]; - if (boardListOnSelectedPort.length > 1 || boards.length > 1) { + if (boards && boards.length > 1) { console.warn( `Detected more than one available boards on the selected port : ${JSON.stringify( - selectedPort + detectedPort )}. Detected boards were: ${JSON.stringify( - boardListOnSelectedPort - )}. Using the first one: ${JSON.stringify([port, boards])}` + boards + )}. Using the first one: ${JSON.stringify(boards[0])}` ); } - const board = boards[0]; + const board = boards ? boards[0] : undefined; const BN = board?.name ?? unknownBoard; - const VID = readProperty('vid', port); - const PID = readProperty('pid', port); - const SN = readProperty('serialNumber', port); + const VID = readProperty('vid', selectedPort); + const PID = readProperty('pid', selectedPort); + const SN = readProperty('serialNumber', selectedPort); return { VID, PID, SN, BN }; } diff --git a/arduino-ide-extension/src/common/protocol/core-service.ts b/arduino-ide-extension/src/common/protocol/core-service.ts index 2a683370d..b067aa5b1 100644 --- a/arduino-ide-extension/src/common/protocol/core-service.ts +++ b/arduino-ide-extension/src/common/protocol/core-service.ts @@ -1,15 +1,15 @@ -import { nls } from '@theia/core/lib/common/nls'; import { ApplicationError } from '@theia/core/lib/common/application-error'; +import { nls } from '@theia/core/lib/common/nls'; import type { Location, - Range, Position, + Range, } from '@theia/core/shared/vscode-languageserver-protocol'; -import type { BoardUserField, Port, Installable } from '../../common/protocol/'; -import type { Programmer } from './boards-service'; -import type { Sketch } from './sketches-service'; -import { IndexUpdateSummary } from './notification-service'; import type { CompileSummary as ApiCompileSummary } from 'vscode-arduino-api'; +import type { BoardUserField, Installable } from '../../common/protocol/'; +import { isPortIdentifier, PortIdentifier, Programmer } from './boards-service'; +import type { IndexUpdateSummary } from './notification-service'; +import type { Sketch } from './sketches-service'; export const CompilerWarningLiterals = [ 'None', @@ -148,11 +148,25 @@ export function isCompileSummary(arg: unknown): arg is CompileSummary { ); } +export interface UploadResponse { + readonly portBeforeUpload?: PortIdentifier | undefined; + readonly portAfterUpload: PortIdentifier; +} +export function isUploadResponse(arg: unknown): arg is UploadResponse { + return ( + Boolean(arg) && + typeof arg === 'object' && + ((arg).portBeforeUpload === undefined || + isPortIdentifier((arg).portBeforeUpload)) && + isPortIdentifier((arg).portAfterUpload) + ); +} + export const CoreServicePath = '/services/core-service'; export const CoreService = Symbol('CoreService'); export interface CoreService { compile(options: CoreService.Options.Compile): Promise; - upload(options: CoreService.Options.Upload): Promise; + upload(options: CoreService.Options.Upload): Promise; burnBootloader(options: CoreService.Options.Bootloader): Promise; /** * Refreshes the underling core gRPC client for the Arduino CLI. @@ -198,7 +212,7 @@ export namespace CoreService { readonly sketch: Sketch; } export interface BoardBased { - readonly port?: Port; + readonly port?: PortIdentifier; readonly programmer?: Programmer | undefined; /** * For the _Verify after upload_ setting. diff --git a/arduino-ide-extension/src/common/protocol/monitor-service.ts b/arduino-ide-extension/src/common/protocol/monitor-service.ts index 5eb793f5b..92fb7e4a6 100644 --- a/arduino-ide-extension/src/common/protocol/monitor-service.ts +++ b/arduino-ide-extension/src/common/protocol/monitor-service.ts @@ -1,5 +1,8 @@ -import { ApplicationError, Event, JsonRpcServer, nls } from '@theia/core'; -import { Board, Port } from './boards-service'; +import { ApplicationError } from '@theia/core/lib/common/application-error'; +import type { Event } from '@theia/core/lib/common/event'; +import type { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; +import { nls } from '@theia/core/lib/common/nls'; +import type { BoardIdentifier, PortIdentifier } from './boards-service'; export type PluggableMonitorSettings = Record; export interface MonitorSettings { @@ -15,17 +18,20 @@ export const MonitorManagerProxy = Symbol('MonitorManagerProxy'); export interface MonitorManagerProxy extends JsonRpcServer { startMonitor( - board: Board, - port: Port, + board: BoardIdentifier, + port: PortIdentifier, settings?: PluggableMonitorSettings ): Promise; changeMonitorSettings( - board: Board, - port: Port, + board: BoardIdentifier, + port: PortIdentifier, settings: MonitorSettings ): Promise; - stopMonitor(board: Board, port: Port): Promise; - getCurrentSettings(board: Board, port: Port): Promise; + stopMonitor(board: BoardIdentifier, port: PortIdentifier): Promise; + getCurrentSettings( + board: BoardIdentifier, + port: PortIdentifier + ): Promise; } export const MonitorManagerProxyClient = Symbol('MonitorManagerProxyClient'); @@ -38,7 +44,10 @@ export interface MonitorManagerProxyClient { getWebSocketPort(): number | undefined; isWSConnected(): Promise; startMonitor(settings?: PluggableMonitorSettings): Promise; - getCurrentSettings(board: Board, port: Port): Promise; + getCurrentSettings( + board: BoardIdentifier, + port: PortIdentifier + ): Promise; send(message: string): void; changeSettings(settings: MonitorSettings): void; } @@ -95,7 +104,7 @@ export const MissingConfigurationError = declareMonitorError( ); export function createConnectionFailedError( - port: Port, + port: PortIdentifier, details?: string ): ApplicationError { const { protocol, address } = port; @@ -120,7 +129,7 @@ export function createConnectionFailedError( return ConnectionFailedError(message, { protocol, address }); } export function createNotConnectedError( - port: Port + port: PortIdentifier ): ApplicationError { const { protocol, address } = port; return NotConnectedError( @@ -134,7 +143,7 @@ export function createNotConnectedError( ); } export function createAlreadyConnectedError( - port: Port + port: PortIdentifier ): ApplicationError { const { protocol, address } = port; return AlreadyConnectedError( @@ -148,7 +157,7 @@ export function createAlreadyConnectedError( ); } export function createMissingConfigurationError( - port: Port + port: PortIdentifier ): ApplicationError { const { protocol, address } = port; return MissingConfigurationError( diff --git a/arduino-ide-extension/src/common/protocol/notification-service.ts b/arduino-ide-extension/src/common/protocol/notification-service.ts index eba8f798e..9ad5c202d 100644 --- a/arduino-ide-extension/src/common/protocol/notification-service.ts +++ b/arduino-ide-extension/src/common/protocol/notification-service.ts @@ -1,11 +1,11 @@ import type { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; import type { - AttachedBoardsChangeEvent, BoardsPackage, ConfigState, + DetectedPorts, + IndexType, ProgressMessage, Sketch, - IndexType, } from '../protocol'; import type { LibraryPackage } from './library-service'; @@ -68,7 +68,9 @@ export interface NotificationServiceClient { notifyLibraryDidUninstall(event: { item: LibraryPackage }): void; // Boards discovery - notifyAttachedBoardsDidChange(event: AttachedBoardsChangeEvent): void; + notifyDetectedPortsDidChange(event: { detectedPorts: DetectedPorts }): void; + + // Sketches notifyRecentSketchesDidChange(event: { sketches: Sketch[] }): void; } diff --git a/arduino-ide-extension/src/common/types.ts b/arduino-ide-extension/src/common/types.ts index 421a27534..c73987650 100644 --- a/arduino-ide-extension/src/common/types.ts +++ b/arduino-ide-extension/src/common/types.ts @@ -1,3 +1,7 @@ export type RecursiveRequired = { [P in keyof T]-?: RecursiveRequired; }; + +export type Defined = { + [P in keyof T]: NonNullable; +}; diff --git a/arduino-ide-extension/src/node/arduino-firmware-uploader-impl.ts b/arduino-ide-extension/src/node/arduino-firmware-uploader-impl.ts index c8c4b3578..2ec810aca 100644 --- a/arduino-ide-extension/src/node/arduino-firmware-uploader-impl.ts +++ b/arduino-ide-extension/src/node/arduino-firmware-uploader-impl.ts @@ -65,7 +65,7 @@ export class ArduinoFirmwareUploaderImpl implements ArduinoFirmwareUploader { ]); return output; } finally { - await this.monitorManager.notifyUploadFinished(board.fqbn, port); + await this.monitorManager.notifyUploadFinished(board.fqbn, port, port); // here the before and after ports are assumed to be always the same } } diff --git a/arduino-ide-extension/src/node/board-discovery.ts b/arduino-ide-extension/src/node/board-discovery.ts index 8699b7232..851660ba1 100644 --- a/arduino-ide-extension/src/node/board-discovery.ts +++ b/arduino-ide-extension/src/node/board-discovery.ts @@ -1,18 +1,22 @@ -import { ClientDuplexStream } from '@grpc/grpc-js'; -import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import type { ClientDuplexStream } from '@grpc/grpc-js'; +import { + Disposable, + DisposableCollection, +} from '@theia/core/lib/common/disposable'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { ILogger } from '@theia/core/lib/common/logger'; import { deepClone } from '@theia/core/lib/common/objects'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { BackendApplicationContribution } from '@theia/core/lib/node'; +import type { Mutable } from '@theia/core/lib/common/types'; +import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; import { inject, injectable, named } from '@theia/core/shared/inversify'; -import { Disposable } from '@theia/core/lib/common/disposable'; +import { isDeepStrictEqual } from 'util'; import { v4 } from 'uuid'; import { Unknown } from '../common/nls'; import { - AttachedBoardsChangeEvent, - AvailablePorts, Board, + DetectedPort, + DetectedPorts, NotificationServiceServer, Port, } from '../common/protocol'; @@ -22,7 +26,7 @@ import { DetectedPort as RpcDetectedPort, } from './cli-protocol/cc/arduino/cli/commands/v1/board_pb'; import { ArduinoCoreServiceClient } from './cli-protocol/cc/arduino/cli/commands/v1/commands_grpc_pb'; -import { Port as RpcPort } from './cli-protocol/cc/arduino/cli/commands/v1/port_pb'; +import type { Port as RpcPort } from './cli-protocol/cc/arduino/cli/commands/v1/port_pb'; import { CoreClientAware } from './core-client-provider'; import { ServiceError } from './service-error'; @@ -57,23 +61,9 @@ export class BoardDiscovery private readonly onStreamDidCancelEmitter = new Emitter(); // when the watcher is canceled by the IDE2 private readonly toDisposeOnStopWatch = new DisposableCollection(); - private uploadInProgress = false; - - /** - * Keys are the `address` of the ports. - * - * The `protocol` is ignored because the board detach event does not carry the protocol information, - * just the address. - * ```json - * { - * "type": "remove", - * "address": "/dev/cu.usbmodem14101" - * } - * ``` - */ - private _availablePorts: AvailablePorts = {}; - get availablePorts(): AvailablePorts { - return this._availablePorts; + private _detectedPorts: DetectedPorts = {}; + get detectedPorts(): DetectedPorts { + return this._detectedPorts; } onStart(): void { @@ -120,10 +110,6 @@ export class BoardDiscovery }); } - setUploadInProgress(uploadInProgress: boolean): void { - this.uploadInProgress = uploadInProgress; - } - private createTimeout( after: number, onTimeout: (error: Error) => void @@ -202,18 +188,6 @@ export class BoardDiscovery return wrapper; } - private toJson(arg: BoardListWatchRequest | BoardListWatchResponse): string { - let object: Record | undefined = undefined; - if (arg instanceof BoardListWatchRequest) { - object = BoardListWatchRequest.toObject(false, arg); - } else if (arg instanceof BoardListWatchResponse) { - object = BoardListWatchResponse.toObject(false, arg); - } else { - throw new Error(`Unhandled object type: ${arg}`); - } - return JSON.stringify(object); - } - async start(): Promise { this.logger.info('start'); if (this.stopping) { @@ -240,9 +214,8 @@ export class BoardDiscovery this.logger.info('start resolved watching'); } - // XXX: make this `protected` and override for tests if IDE2 wants to mock events from the CLI. - private onBoardListWatchResponse(resp: BoardListWatchResponse): void { - this.logger.info(this.toJson(resp)); + protected onBoardListWatchResponse(resp: BoardListWatchResponse): void { + this.logger.info(JSON.stringify(resp.toObject(false))); const eventType = EventType.parse(resp.getEventType()); if (eventType === EventType.Quit) { @@ -251,69 +224,36 @@ export class BoardDiscovery return; } - const detectedPort = resp.getPort(); - if (detectedPort) { - const { port, boards } = this.fromRpc(detectedPort); - if (!port) { - if (!!boards.length) { - console.warn( - `Could not detect the port, but unexpectedly received discovered boards. This is most likely a bug! Response was: ${this.toJson( - resp - )}` - ); - } - return; - } - const oldState = deepClone(this._availablePorts); - const newState = deepClone(this._availablePorts); - const key = Port.keyOf(port); - - if (eventType === EventType.Add) { - if (newState[key]) { - const [, knownBoards] = newState[key]; - this.logger.warn( - `Port '${Port.toString( - port - )}' was already available. Known boards before override: ${JSON.stringify( - knownBoards - )}` - ); - } - newState[key] = [port, boards]; - } else if (eventType === EventType.Remove) { - if (!newState[key]) { - this.logger.warn( - `Port '${Port.toString(port)}' was not available. Skipping` - ); - return; - } - delete newState[key]; + const rpcDetectedPort = resp.getPort(); + if (rpcDetectedPort) { + const detectedPort = this.fromRpc(rpcDetectedPort); + if (detectedPort) { + this.fireSoon({ detectedPort, eventType }); + } else { + this.logger.warn( + `Could not extract the detected port from ${rpcDetectedPort.toObject( + false + )}` + ); } - - const event: AttachedBoardsChangeEvent = { - oldState: { - ...AvailablePorts.split(oldState), - }, - newState: { - ...AvailablePorts.split(newState), - }, - uploadInProgress: this.uploadInProgress, - }; - - this._availablePorts = newState; - this.notificationService.notifyAttachedBoardsDidChange(event); + } else if (resp.getError()) { + this.logger.error( + `Could not extract any detected 'port' from the board list watch response. An 'error' has occurred: ${resp.getError()}` + ); } } - private fromRpc(detectedPort: RpcDetectedPort): DetectedPort { + private fromRpc(detectedPort: RpcDetectedPort): DetectedPort | undefined { const rpcPort = detectedPort.getPort(); - const port = rpcPort && this.fromRpcPort(rpcPort); + if (!rpcPort) { + return undefined; + } + const port = this.fromRpcPort(rpcPort); const boards = detectedPort.getMatchingBoardsList().map( (board) => ({ - fqbn: board.getFqbn(), + fqbn: board.getFqbn() || undefined, // prefer undefined fqbn over empty string name: board.getName() || Unknown, - port, } as Board) ); return { @@ -329,9 +269,60 @@ export class BoardDiscovery protocol: rpcPort.getProtocol(), protocolLabel: rpcPort.getProtocolLabel(), properties: Port.Properties.create(rpcPort.getPropertiesMap().toObject()), - hardwareId: rpcPort.getHardwareId(), + hardwareId: rpcPort.getHardwareId() || undefined, // prefer undefined over empty string }; } + + private fireSoonHandle: NodeJS.Timeout | undefined; + private readonly bufferedEvents: DetectedPortChangeEvent[] = []; + private fireSoon(event: DetectedPortChangeEvent): void { + this.bufferedEvents.push(event); + clearTimeout(this.fireSoonHandle); + this.fireSoonHandle = setTimeout(() => { + const current = deepClone(this.detectedPorts); + const newState = this.calculateNewState(this.bufferedEvents, current); + if (!isDeepStrictEqual(current, newState)) { + this._detectedPorts = newState; + this.notificationService.notifyDetectedPortsDidChange({ + detectedPorts: this._detectedPorts, + }); + } + this.bufferedEvents.length = 0; + }, 100); + } + + private calculateNewState( + events: DetectedPortChangeEvent[], + prevState: Mutable + ): DetectedPorts { + const newState = deepClone(prevState); + for (const { detectedPort, eventType } of events) { + const { port, boards } = detectedPort; + const key = Port.keyOf(port); + if (eventType === EventType.Add) { + const alreadyDetectedPort = newState[key]; + if (alreadyDetectedPort) { + console.warn( + `Detected a new port that has been already discovered. The old value will be overridden. Old value: ${JSON.stringify( + alreadyDetectedPort + )}, new value: ${JSON.stringify(detectedPort)}` + ); + } + newState[key] = { port, boards }; + } else if (eventType === EventType.Remove) { + const alreadyDetectedPort = newState[key]; + if (!alreadyDetectedPort) { + console.warn( + `Detected a port removal but it has not been discovered. This is most likely a bug! Detected port was: ${JSON.stringify( + detectedPort + )}` + ); + } + delete newState[key]; + } + } + return newState; + } } enum EventType { @@ -357,7 +348,7 @@ namespace EventType { } } -interface DetectedPort { - port: Port | undefined; - boards: Board[]; +interface DetectedPortChangeEvent { + readonly detectedPort: DetectedPort; + readonly eventType: EventType.Add | EventType.Remove; } diff --git a/arduino-ide-extension/src/node/boards-service-impl.ts b/arduino-ide-extension/src/node/boards-service-impl.ts index a2db13193..5c8350322 100644 --- a/arduino-ide-extension/src/node/boards-service-impl.ts +++ b/arduino-ide-extension/src/node/boards-service-impl.ts @@ -12,7 +12,7 @@ import { Programmer, ResponseService, NotificationServiceServer, - AvailablePorts, + DetectedPorts, BoardWithPackage, BoardUserField, BoardSearch, @@ -65,8 +65,8 @@ export class BoardsServiceImpl @inject(BoardDiscovery) protected readonly boardDiscovery: BoardDiscovery; - async getState(): Promise { - return this.boardDiscovery.availablePorts; + async getDetectedPorts(): Promise { + return this.boardDiscovery.detectedPorts; } async getBoardDetails(options: { @@ -165,7 +165,7 @@ export class BoardsServiceImpl debuggingSupported, VID, PID, - buildProperties + buildProperties, }; } diff --git a/arduino-ide-extension/src/node/core-service-impl.ts b/arduino-ide-extension/src/node/core-service-impl.ts index 253dcd383..a81755163 100644 --- a/arduino-ide-extension/src/node/core-service-impl.ts +++ b/arduino-ide-extension/src/node/core-service-impl.ts @@ -3,13 +3,14 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { relative } from 'node:path'; import * as jspb from 'google-protobuf'; import { BoolValue } from 'google-protobuf/google/protobuf/wrappers_pb'; -import { ClientReadableStream } from '@grpc/grpc-js'; +import type { ClientReadableStream } from '@grpc/grpc-js'; import { CompilerWarnings, CoreService, CoreError, CompileSummary, isCompileSummary, + isUploadResponse, } from '../common/protocol/core-service'; import { CompileRequest, @@ -25,7 +26,13 @@ import { UploadUsingProgrammerResponse, } from './cli-protocol/cc/arduino/cli/commands/v1/upload_pb'; import { ResponseService } from '../common/protocol/response-service'; -import { OutputMessage, Port } from '../common/protocol'; +import { + resolveDetectedPort, + OutputMessage, + PortIdentifier, + Port, + UploadResponse as ApiUploadResponse, +} from '../common/protocol'; import { ArduinoCoreServiceClient } from './cli-protocol/cc/arduino/cli/commands/v1/commands_grpc_pb'; import { Port as RpcPort } from './cli-protocol/cc/arduino/cli/commands/v1/port_pb'; import { ApplicationError, CommandService, Disposable, nls } from '@theia/core'; @@ -36,8 +43,8 @@ import { Instance } from './cli-protocol/cc/arduino/cli/commands/v1/common_pb'; import { firstToUpperCase, notEmpty } from '../common/utils'; import { ServiceError } from './service-error'; import { ExecuteWithProgress, ProgressResponse } from './grpc-progressible'; +import type { Mutable } from '@theia/core/lib/common/types'; import { BoardDiscovery } from './board-discovery'; -import { Mutable } from '@theia/core/lib/common/types'; namespace Uploadable { export type Request = UploadRequest | UploadUsingProgrammerRequest; @@ -50,13 +57,10 @@ type CompileSummaryFragment = Partial>; export class CoreServiceImpl extends CoreClientAware implements CoreService { @inject(ResponseService) private readonly responseService: ResponseService; - @inject(MonitorManager) private readonly monitorManager: MonitorManager; - @inject(CommandService) private readonly commandService: CommandService; - @inject(BoardDiscovery) private readonly boardDiscovery: BoardDiscovery; @@ -172,7 +176,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { return request; } - upload(options: CoreService.Options.Upload): Promise { + upload(options: CoreService.Options.Upload): Promise { const { usingProgrammer } = options; return this.doUpload( options, @@ -201,14 +205,25 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { ) => (request: REQ) => ClientReadableStream, errorCtor: ApplicationError.Constructor, task: string - ): Promise { + ): Promise { + const uploadResponseFragment: Mutable> = { + portBeforeUpload: options.port, + }; const coreClient = await this.coreClient; const { client, instance } = coreClient; const progressHandler = this.createProgressHandler(options); - const handler = this.createOnDataHandler(progressHandler); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const updateUploadResponseFragmentHandler = (_: RESP) => { + // TODO: update the upload response fragment with the new port. + // https://github.com/arduino/arduino-cli/issues/2245 + }; + const handler = this.createOnDataHandler( + progressHandler, + updateUploadResponseFragmentHandler + ); const grpcCall = responseFactory(client); return this.notifyUploadWillStart(options).then(() => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { grpcCall(this.initUploadRequest(request, options, instance)) .on('data', handler.onData) .on('error', (error) => { @@ -234,10 +249,29 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { ); } }) - .on('end', resolve); + .on('end', () => { + uploadResponseFragment.portAfterUpload = options.port; // https://github.com/arduino/arduino-cli/issues/2245 + if (isUploadResponse(uploadResponseFragment)) { + resolve(uploadResponseFragment); + } else { + reject( + new Error( + `Could not detect the port after the upload. Upload options were: ${JSON.stringify( + options + )}, upload response was: ${JSON.stringify( + uploadResponseFragment + )}` + ) + ); + } + }); }).finally(async () => { handler.dispose(); - await this.notifyUploadDidFinish(options); + await this.notifyUploadDidFinish( + Object.assign(options, { + afterPort: uploadResponseFragment.portAfterUpload, + }) + ); }) ); } @@ -302,7 +336,9 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { .on('end', resolve); }).finally(async () => { handler.dispose(); - await this.notifyUploadDidFinish(options); + await this.notifyUploadDidFinish( + Object.assign(options, { afterPort: options.port }) + ); }) ); } @@ -379,21 +415,25 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { port, }: { fqbn?: string | undefined; - port?: Port | undefined; + port?: PortIdentifier; }): Promise { - this.boardDiscovery.setUploadInProgress(true); - return this.monitorManager.notifyUploadStarted(fqbn, port); + if (fqbn && port) { + return this.monitorManager.notifyUploadStarted(fqbn, port); + } } private async notifyUploadDidFinish({ fqbn, port, + afterPort, }: { fqbn?: string | undefined; - port?: Port | undefined; + port?: PortIdentifier; + afterPort?: PortIdentifier; }): Promise { - this.boardDiscovery.setUploadInProgress(false); - return this.monitorManager.notifyUploadFinished(fqbn, port); + if (fqbn && port && afterPort) { + return this.monitorManager.notifyUploadFinished(fqbn, port, afterPort); + } } private mergeSourceOverrides( @@ -410,21 +450,28 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { } } - private createPort(port: Port | undefined): RpcPort | undefined { + private createPort( + port: PortIdentifier | undefined, + resolve: (port: PortIdentifier) => Port | undefined = (port) => + resolveDetectedPort(port, this.boardDiscovery.detectedPorts) + ): RpcPort | undefined { if (!port) { return undefined; } + const resolvedPort = resolve(port); const rpcPort = new RpcPort(); - rpcPort.setAddress(port.address); - rpcPort.setLabel(port.addressLabel); rpcPort.setProtocol(port.protocol); - rpcPort.setProtocolLabel(port.protocolLabel); - if (port.hardwareId !== undefined) { - rpcPort.setHardwareId(port.hardwareId); - } - if (port.properties) { - for (const [key, value] of Object.entries(port.properties)) { - rpcPort.getPropertiesMap().set(key, value); + rpcPort.setAddress(port.address); + if (resolvedPort) { + rpcPort.setLabel(resolvedPort.addressLabel); + rpcPort.setProtocolLabel(resolvedPort.protocolLabel); + if (resolvedPort.hardwareId !== undefined) { + rpcPort.setHardwareId(resolvedPort.hardwareId); + } + if (resolvedPort.properties) { + for (const [key, value] of Object.entries(resolvedPort.properties)) { + rpcPort.getPropertiesMap().set(key, value); + } } } return rpcPort; diff --git a/arduino-ide-extension/src/node/monitor-manager.ts b/arduino-ide-extension/src/node/monitor-manager.ts index 4eea5b46a..3a6161e39 100644 --- a/arduino-ide-extension/src/node/monitor-manager.ts +++ b/arduino-ide-extension/src/node/monitor-manager.ts @@ -7,6 +7,7 @@ import { MonitorSettings, PluggableMonitorSettings, Port, + PortIdentifier, } from '../common/protocol'; import { CoreClientAware } from './core-client-provider'; import { MonitorService } from './monitor-service'; @@ -180,13 +181,7 @@ export class MonitorManager extends CoreClientAware { * @param fqbn the FQBN of the board connected to port * @param port port to monitor */ - async notifyUploadStarted(fqbn?: string, port?: Port): Promise { - if (!fqbn || !port) { - // We have no way of knowing which monitor - // to retrieve if we don't have this information. - return; - } - + async notifyUploadStarted(fqbn: string, port: PortIdentifier): Promise { const monitorID = this.monitorID(fqbn, port); this.addToMonitorIDsByUploadState('uploadInProgress', monitorID); @@ -204,41 +199,40 @@ export class MonitorManager extends CoreClientAware { * Notifies the monitor service of that board/port combination * that an upload process started on that exact board/port combination. * @param fqbn the FQBN of the board connected to port - * @param port port to monitor + * @param beforePort port to monitor * @returns a Status object to know if the process has been * started or if there have been errors. */ async notifyUploadFinished( - fqbn?: string | undefined, - port?: Port + fqbn: string | undefined, + beforePort: PortIdentifier, + afterPort: PortIdentifier ): Promise { let portDidChangeOnUpload = false; + const beforeMonitorID = this.monitorID(fqbn, beforePort); + this.removeFromMonitorIDsByUploadState('uploadInProgress', beforeMonitorID); - // We have no way of knowing which monitor - // to retrieve if we don't have this information. - if (fqbn && port) { - const monitorID = this.monitorID(fqbn, port); - this.removeFromMonitorIDsByUploadState('uploadInProgress', monitorID); - - const monitor = this.monitorServices.get(monitorID); - if (monitor) { - await monitor.start(); - } + const monitor = this.monitorServices.get(beforeMonitorID); + if (monitor) { + await monitor.start(); + } - // this monitorID will only be present in "disposedForUpload" - // if the upload changed the board port - portDidChangeOnUpload = this.monitorIDIsInUploadState( + // this monitorID will only be present in "disposedForUpload" + // if the upload changed the board port + portDidChangeOnUpload = this.monitorIDIsInUploadState( + 'disposedForUpload', + beforeMonitorID + ); + if (portDidChangeOnUpload) { + this.removeFromMonitorIDsByUploadState( 'disposedForUpload', - monitorID + beforeMonitorID ); - if (portDidChangeOnUpload) { - this.removeFromMonitorIDsByUploadState('disposedForUpload', monitorID); - } - - // in case a service was paused but not disposed - this.removeFromMonitorIDsByUploadState('pausedForUpload', monitorID); } + // in case a service was paused but not disposed + this.removeFromMonitorIDsByUploadState('pausedForUpload', beforeMonitorID); + await this.startQueuedServices(portDidChangeOnUpload); } @@ -256,7 +250,7 @@ export class MonitorManager extends CoreClientAware { serviceStartParams: [, port], connectToClient, } of queued) { - const boardsState = await this.boardsService.getState(); + const boardsState = await this.boardsService.getDetectedPorts(); const boardIsStillOnPort = Object.keys(boardsState) .map((connection: string) => { const portAddress = connection.split('|')[0]; @@ -355,7 +349,7 @@ export class MonitorManager extends CoreClientAware { * @param port * @returns a unique monitor ID */ - private monitorID(fqbn: string | undefined, port: Port): MonitorID { + private monitorID(fqbn: string | undefined, port: PortIdentifier): MonitorID { const splitFqbn = fqbn?.split(':') || []; const shortenedFqbn = splitFqbn.slice(0, 3).join(':') || ''; return `${shortenedFqbn}-${port.address}-${port.protocol}`; diff --git a/arduino-ide-extension/src/node/notification-service-server.ts b/arduino-ide-extension/src/node/notification-service-server.ts index ce6a96304..cd3cac91e 100644 --- a/arduino-ide-extension/src/node/notification-service-server.ts +++ b/arduino-ide-extension/src/node/notification-service-server.ts @@ -2,7 +2,6 @@ import { injectable } from '@theia/core/shared/inversify'; import type { NotificationServiceServer, NotificationServiceClient, - AttachedBoardsChangeEvent, BoardsPackage, LibraryPackage, ConfigState, @@ -11,6 +10,7 @@ import type { IndexUpdateWillStartParams, IndexUpdateDidCompleteParams, IndexUpdateDidFailParams, + DetectedPorts, } from '../common/protocol'; @injectable() @@ -69,9 +69,9 @@ export class NotificationServiceServerImpl this.clients.forEach((client) => client.notifyLibraryDidUninstall(event)); } - notifyAttachedBoardsDidChange(event: AttachedBoardsChangeEvent): void { + notifyDetectedPortsDidChange(event: { detectedPorts: DetectedPorts }): void { this.clients.forEach((client) => - client.notifyAttachedBoardsDidChange(event) + client.notifyDetectedPortsDidChange(event) ); } diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index d3ad0e58e..ccf78cc41 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import glob from 'glob'; import crypto from 'node:crypto'; import PQueue from 'p-queue'; -import { Mutable } from '@theia/core/lib/common/types'; +import type { Mutable } from '@theia/core/lib/common/types'; import URI from '@theia/core/lib/common/uri'; import { ILogger } from '@theia/core/lib/common/logger'; import { FileUri } from '@theia/core/lib/node/file-uri'; diff --git a/arduino-ide-extension/src/test/browser/boards-auto-installer.test.ts b/arduino-ide-extension/src/test/browser/boards-auto-installer.test.ts deleted file mode 100644 index 00570fff6..000000000 --- a/arduino-ide-extension/src/test/browser/boards-auto-installer.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -// import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; -// const disableJSDOM = enableJSDOM(); - -// import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; -// import { ApplicationProps } from '@theia/application-package/lib/application-props'; -// FrontendApplicationConfigProvider.set({ -// ...ApplicationProps.DEFAULT.frontend.config, -// }); - -// import { MessageService } from '@theia/core'; -// import { BoardsServiceProvider } from '../../browser/boards/boards-service-provider'; -// import { BoardsListWidgetFrontendContribution } from '../../browser/boards/boards-widget-frontend-contribution'; -// import { -// Board, -// BoardsPackage, -// BoardsService, -// Port, -// ResponseServiceArduino, -// } from '../../common/protocol'; -// import { IMock, It, Mock, Times } from 'typemoq'; -// import { Container, ContainerModule } from '@theia/core/shared/inversify'; -// import { BoardsAutoInstaller } from '../../browser/boards/boards-auto-installer'; -// import { BoardsConfig } from '../../browser/boards/boards-config'; -// import { tick } from '../utils'; -// import { ListWidget } from '../../browser/widgets/component-list/list-widget'; - -// disableJSDOM(); - -// const aBoard: Board = { -// fqbn: 'some:board:fqbn', -// name: 'Some Arduino Board', -// port: { address: '/lol/port1234', protocol: 'serial' }, -// }; -// const aPort: Port = { -// address: aBoard.port!.address, -// protocol: aBoard.port!.protocol, -// }; -// const aBoardConfig: BoardsConfig.Config = { -// selectedBoard: aBoard, -// selectedPort: aPort, -// }; -// const aPackage: BoardsPackage = { -// author: 'someAuthor', -// availableVersions: ['some.ver.sion', 'some.other.version'], -// boards: [aBoard], -// deprecated: false, -// description: 'Some Arduino Board, Some Other Arduino Board', -// id: 'some:arduinoCoreId', -// installable: true, -// moreInfoLink: 'http://www.some-url.lol/', -// name: 'Some Arduino Package', -// summary: 'Boards included in this package:', -// }; - -// const anInstalledPackage: BoardsPackage = { -// ...aPackage, -// installedVersion: 'some.ver.sion', -// }; - -// describe('BoardsAutoInstaller', () => { -// let subject: BoardsAutoInstaller; -// let messageService: IMock; -// let boardsService: IMock; -// let boardsServiceClient: IMock; -// let responseService: IMock; -// let boardsManagerFrontendContribution: IMock; -// let boardsManagerWidget: IMock>; - -// let testContainer: Container; - -// beforeEach(() => { -// testContainer = new Container(); -// messageService = Mock.ofType(); -// boardsService = Mock.ofType(); -// boardsServiceClient = Mock.ofType(); -// responseService = Mock.ofType(); -// boardsManagerFrontendContribution = -// Mock.ofType(); -// boardsManagerWidget = Mock.ofType>(); - -// boardsManagerWidget.setup((b) => -// b.refresh(aPackage.name.toLocaleLowerCase()) -// ); - -// boardsManagerFrontendContribution -// .setup((b) => b.openView({ reveal: true })) -// .returns(async () => boardsManagerWidget.object); - -// messageService -// .setup((m) => m.showProgress(It.isAny(), It.isAny())) -// .returns(async () => ({ -// cancel: () => null, -// id: '', -// report: () => null, -// result: Promise.resolve(''), -// })); - -// responseService -// .setup((r) => r.onProgressDidChange(It.isAny())) -// .returns(() => ({ dispose: () => null })); - -// const module = new ContainerModule((bind) => { -// bind(BoardsAutoInstaller).toSelf(); -// bind(MessageService).toConstantValue(messageService.object); -// bind(BoardsService).toConstantValue(boardsService.object); -// bind(BoardsServiceProvider).toConstantValue(boardsServiceClient.object); -// bind(ResponseServiceArduino).toConstantValue(responseService.object); -// bind(BoardsListWidgetFrontendContribution).toConstantValue( -// boardsManagerFrontendContribution.object -// ); -// }); - -// testContainer.load(module); -// subject = testContainer.get(BoardsAutoInstaller); -// }); - -// context('when it starts', () => { -// it('should register to the BoardsServiceClient in order to check the packages every a new board is plugged in', () => { -// subject.onStart(); -// boardsServiceClient.verify( -// (b) => b.onBoardsConfigChanged(It.isAny()), -// Times.once() -// ); -// }); - -// context('and it checks the installable packages', () => { -// context(`and a port and a board a selected`, () => { -// beforeEach(() => { -// boardsServiceClient -// .setup((b) => b.boardsConfig) -// .returns(() => aBoardConfig); -// }); -// context('if no package for the board is already installed', () => { -// context('if a candidate package for the board is found', () => { -// beforeEach(() => { -// boardsService -// .setup((b) => b.search(It.isValue({}))) -// .returns(async () => [aPackage]); -// }); -// it('should show a notification suggesting to install that package', async () => { -// messageService -// .setup((m) => -// m.info(It.isAnyString(), It.isAnyString(), It.isAnyString()) -// ) -// .returns(() => Promise.resolve('Install Manually')); -// subject.onStart(); -// await tick(); -// messageService.verify( -// (m) => -// m.info(It.isAnyString(), It.isAnyString(), It.isAnyString()), -// Times.once() -// ); -// }); -// context(`if the answer to the message is 'Yes'`, () => { -// beforeEach(() => { -// messageService -// .setup((m) => -// m.info(It.isAnyString(), It.isAnyString(), It.isAnyString()) -// ) -// .returns(() => Promise.resolve('Yes')); -// }); -// it('should install the package', async () => { -// subject.onStart(); - -// await tick(); - -// messageService.verify( -// (m) => m.showProgress(It.isAny(), It.isAny()), -// Times.once() -// ); -// }); -// }); -// context( -// `if the answer to the message is 'Install Manually'`, -// () => { -// beforeEach(() => { -// messageService -// .setup((m) => -// m.info( -// It.isAnyString(), -// It.isAnyString(), -// It.isAnyString() -// ) -// ) -// .returns(() => Promise.resolve('Install Manually')); -// }); -// it('should open the boards manager widget', () => { -// subject.onStart(); -// }); -// } -// ); -// }); -// context('if a candidate package for the board is not found', () => { -// beforeEach(() => { -// boardsService -// .setup((b) => b.search(It.isValue({}))) -// .returns(async () => []); -// }); -// it('should do nothing', async () => { -// subject.onStart(); -// await tick(); -// messageService.verify( -// (m) => -// m.info(It.isAnyString(), It.isAnyString(), It.isAnyString()), -// Times.never() -// ); -// }); -// }); -// }); -// context( -// 'if one of the packages for the board is already installed', -// () => { -// beforeEach(() => { -// boardsService -// .setup((b) => b.search(It.isValue({}))) -// .returns(async () => [aPackage, anInstalledPackage]); -// messageService -// .setup((m) => -// m.info(It.isAnyString(), It.isAnyString(), It.isAnyString()) -// ) -// .returns(() => Promise.resolve('Yes')); -// }); -// it('should do nothing', async () => { -// subject.onStart(); -// await tick(); -// messageService.verify( -// (m) => -// m.info(It.isAnyString(), It.isAnyString(), It.isAnyString()), -// Times.never() -// ); -// }); -// } -// ); -// }); -// context('and there is no selected board or port', () => { -// it('should do nothing', async () => { -// subject.onStart(); -// await tick(); -// messageService.verify( -// (m) => m.info(It.isAnyString(), It.isAnyString(), It.isAnyString()), -// Times.never() -// ); -// }); -// }); -// }); -// }); -// }); diff --git a/arduino-ide-extension/src/test/browser/fixtures/boards.ts b/arduino-ide-extension/src/test/browser/fixtures/boards.ts index 16256f3ab..f42f9a757 100644 --- a/arduino-ide-extension/src/test/browser/fixtures/boards.ts +++ b/arduino-ide-extension/src/test/browser/fixtures/boards.ts @@ -1,44 +1,36 @@ -import { BoardsConfig } from '../../../browser/boards/boards-config'; -import { Board, BoardsPackage, Port } from '../../../common/protocol'; +import type { + Board, + BoardsConfig, + BoardsPackage, + Port, +} from '../../../common/protocol'; export const aBoard: Board = { fqbn: 'some:board:fqbn', name: 'Some Arduino Board', - port: { - address: '/lol/port1234', - addressLabel: '/lol/port1234', - protocol: 'serial', - protocolLabel: 'Serial Port (USB)', - }, -}; -export const aPort: Port = { - address: aBoard.port!.address, - addressLabel: aBoard.port!.addressLabel, - protocol: aBoard.port!.protocol, - protocolLabel: aBoard.port!.protocolLabel, -}; -export const aBoardConfig: BoardsConfig.Config = { - selectedBoard: aBoard, +}; +const aPort: Port = { + address: '/lol/port1234', + addressLabel: '/lol/port1234', + protocol: 'serial', + protocolLabel: 'Serial Port (USB)', +}; +export const aBoardsConfig: BoardsConfig = { + selectedBoard: { name: aBoard.name, fqbn: aBoard.fqbn }, selectedPort: aPort, }; export const anotherBoard: Board = { fqbn: 'another:board:fqbn', name: 'Another Arduino Board', - port: { - address: '/kek/port5678', - addressLabel: '/kek/port5678', - protocol: 'serial', - protocolLabel: 'Serial Port (USB)', - }, }; export const anotherPort: Port = { - address: anotherBoard.port!.address, - addressLabel: anotherBoard.port!.addressLabel, - protocol: anotherBoard.port!.protocol, - protocolLabel: anotherBoard.port!.protocolLabel, + address: '/kek/port5678', + addressLabel: '/kek/port5678', + protocol: 'serial', + protocolLabel: 'Serial Port (USB)', }; -export const anotherBoardConfig: BoardsConfig.Config = { - selectedBoard: anotherBoard, +export const anotherBoardsConfig: BoardsConfig = { + selectedBoard: { name: anotherBoard.name, fqbn: anotherBoard.fqbn }, selectedPort: anotherPort, }; diff --git a/arduino-ide-extension/src/test/common/board-list.test.ts b/arduino-ide-extension/src/test/common/board-list.test.ts new file mode 100644 index 000000000..3f7aaa248 --- /dev/null +++ b/arduino-ide-extension/src/test/common/board-list.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import { createBoardList } from '../../common/protocol/board-list'; +import { + BoardIdentifier, + DetectedPort, + DetectedPorts, + Port, +} from '../../common/protocol/boards-service'; + +const mkr1000: BoardIdentifier = { + name: 'Arduino MKR1000', + fqbn: 'arduino:samd:mkr1000', +}; +const uno: BoardIdentifier = { + name: 'Arduino Uno', + fqbn: 'arduino:avr:uno', +}; +const bluetoothSerialPort: Port = { + address: '/dev/cu.Bluetooth-Incoming-Port', + addressLabel: '/dev/cu.Bluetooth-Incoming-Port', + protocol: 'serial', + protocolLabel: 'Serial Port', + properties: {}, + hardwareId: '', +}; +const builtinSerialPort: Port = { + address: '/dev/cu.BLTH', + addressLabel: '/dev/cu.BLTH', + protocol: 'serial', + protocolLabel: 'Serial Port', + properties: {}, + hardwareId: '', +}; +const undiscoveredSerialPort: Port = { + address: '/dev/cu.usbserial-0001', + addressLabel: '/dev/cu.usbserial-0001', + protocol: 'serial', + protocolLabel: 'Serial Port (USB)', + properties: { + pid: '0xEA60', + serialNumber: '0001', + vid: '0x10C4', + }, + hardwareId: '0001', +}; +const mkr1000NetworkPort: Port = { + address: '192.168.0.104', + addressLabel: 'Arduino at 192.168.0.104', + protocol: 'network', + protocolLabel: 'Network Port', + properties: { + '.': 'mkr1000', + auth_upload: 'yes', + board: 'mkr1000', + hostname: 'Arduino.local.', + port: '65280', + ssh_upload: 'no', + tcp_check: 'no', + }, + hardwareId: '', +}; +const undiscoveredUsbToUARTSerialPort: Port = { + address: '/dev/cu.SLAB_USBtoUART', + addressLabel: '/dev/cu.SLAB_USBtoUART', + protocol: 'serial', + protocolLabel: 'Serial Port (USB)', + properties: { + pid: '0xEA60', + serialNumber: '0001', + vid: '0x10C4', + }, + hardwareId: '0001', +}; +const mkr1000SerialPort: Port = { + address: '/dev/cu.usbmodem14301', + addressLabel: '/dev/cu.usbmodem14301', + protocol: 'serial', + protocolLabel: 'Serial Port (USB)', + properties: { + pid: '0x804E', + serialNumber: '94A3397C5150435437202020FF150838', + vid: '0x2341', + }, + hardwareId: '94A3397C5150435437202020FF150838', +}; +const unoSerialPort: Port = { + address: '/dev/cu.usbmodem14201', + addressLabel: '/dev/cu.usbmodem14201', + protocol: 'serial', + protocolLabel: 'Serial Port (USB)', + properties: { + pid: '0x0043', + serialNumber: '75830303934351618212', + vid: '0x2341', + }, + hardwareId: '75830303934351618212', +}; + +function detectedPort( + port: Port, + ...boards: BoardIdentifier[] +): { [portKey: string]: DetectedPort } { + return { [Port.keyOf(port)]: boards.length ? { port, boards } : { port } }; +} + +const detectedPorts: DetectedPorts = { + ...detectedPort(builtinSerialPort), + ...detectedPort(bluetoothSerialPort), + ...detectedPort(unoSerialPort, uno), + ...detectedPort(mkr1000SerialPort, mkr1000), + ...detectedPort(mkr1000NetworkPort, mkr1000), + ...detectedPort(undiscoveredSerialPort), + ...detectedPort(undiscoveredUsbToUARTSerialPort), +}; + +describe('board-list', () => { + describe('createBoardList', () => { + it('should sort items deterministically', () => { + const actual = createBoardList(detectedPorts); + expect(actual[0].board).deep.equal(mkr1000); + expect(actual[1].board).deep.equal(uno); + expect(actual[2].port).deep.equal(builtinSerialPort); + expect(actual[3].port).deep.equal(bluetoothSerialPort); + expect(actual[4].port).deep.equal(undiscoveredUsbToUARTSerialPort); + expect(actual[5].port).deep.equal(undiscoveredSerialPort); + expect(actual[6].port.protocol).equal('network'); + expect(actual[6].board).deep.equal(mkr1000); + }); + }); +}); diff --git a/arduino-ide-extension/src/test/common/boards-service.test.ts b/arduino-ide-extension/src/test/common/boards-service.test.ts index d2cae5a53..693daa8b5 100644 --- a/arduino-ide-extension/src/test/common/boards-service.test.ts +++ b/arduino-ide-extension/src/test/common/boards-service.test.ts @@ -1,8 +1,6 @@ -import { Deferred } from '@theia/core/lib/common/promise-util'; -import { Mutable } from '@theia/core/lib/common/types'; +import type { Mutable } from '@theia/core/lib/common/types'; import { expect } from 'chai'; import { - AttachedBoardsChangeEvent, BoardInfo, getBoardInfo, noNativeSerialPort, @@ -11,88 +9,10 @@ import { selectPortForInfo, unknownBoard, } from '../../common/protocol'; +import { createBoardList } from '../../common/protocol/board-list'; import { firstToUpperCase } from '../../common/utils'; describe('boards-service', () => { - describe('AttachedBoardsChangeEvent', () => { - it('should detect one attached port', () => { - const event = { - oldState: { - boards: [ - { - name: 'Arduino MKR1000', - fqbn: 'arduino:samd:mkr1000', - port: '/dev/cu.usbmodem14601', - }, - { - name: 'Arduino Uno', - fqbn: 'arduino:avr:uno', - port: '/dev/cu.usbmodem14501', - }, - ], - ports: [ - { - protocol: 'serial', - address: '/dev/cu.usbmodem14501', - }, - { - protocol: 'serial', - address: '/dev/cu.usbmodem14601', - }, - { - protocol: 'serial', - address: '/dev/cu.Bluetooth-Incoming-Port', - }, - { protocol: 'serial', address: '/dev/cu.MALS' }, - { protocol: 'serial', address: '/dev/cu.SOC' }, - ], - }, - newState: { - boards: [ - { - name: 'Arduino MKR1000', - fqbn: 'arduino:samd:mkr1000', - port: '/dev/cu.usbmodem1460', - }, - { - name: 'Arduino Uno', - fqbn: 'arduino:avr:uno', - port: '/dev/cu.usbmodem14501', - }, - ], - ports: [ - { - protocol: 'serial', - address: '/dev/cu.SLAB_USBtoUART', - }, - { - protocol: 'serial', - address: '/dev/cu.usbmodem14501', - }, - { - protocol: 'serial', - address: '/dev/cu.usbmodem14601', - }, - { - protocol: 'serial', - address: '/dev/cu.Bluetooth-Incoming-Port', - }, - { protocol: 'serial', address: '/dev/cu.MALS' }, - { protocol: 'serial', address: '/dev/cu.SOC' }, - ], - }, - }; - const diff = AttachedBoardsChangeEvent.diff(event); - expect(diff.attached.boards).to.be.empty; // tslint:disable-line:no-unused-expression - expect(diff.detached.boards).to.be.empty; // tslint:disable-line:no-unused-expression - expect(diff.detached.ports).to.be.empty; // tslint:disable-line:no-unused-expression - expect(diff.attached.ports.length).to.be.equal(1); - expect(diff.attached.ports[0].address).to.be.equal( - '/dev/cu.SLAB_USBtoUART' - ); - }); - }); - describe('getBoardInfo', () => { const vid = '0x0'; const pid = '0x1'; @@ -112,7 +32,7 @@ describe('boards-service', () => { }); it('should handle when no port is selected', async () => { - const info = await getBoardInfo(undefined, never()); + const info = await getBoardInfo(createBoardList({})); expect(info).to.be.equal(selectPortForInfo); }); @@ -125,7 +45,11 @@ describe('boards-service', () => { protocolLabel: firstToUpperCase(protocol), protocol, }; - const info = await getBoardInfo(selectedPort, never()); + const boardList = createBoardList( + { [Port.keyOf(selectedPort)]: { port: selectedPort } }, + { selectedPort, selectedBoard: undefined } + ); + const info = await getBoardInfo(boardList); expect(info).to.be.equal(nonSerialPort); }) ); @@ -140,18 +64,26 @@ describe('boards-service', () => { ]; for (const properties of insufficientProperties) { const port = selectedPort(properties); - const info = await getBoardInfo(port, { - [Port.keyOf(port)]: [port, []], - }); + const boardList = createBoardList( + { + [Port.keyOf(port)]: { port }, + }, + { selectedPort: port, selectedBoard: undefined } + ); + const info = await getBoardInfo(boardList); expect(info).to.be.equal(noNativeSerialPort); } }); it("should detect a port as non-native serial, if protocol is 'serial' and VID/PID are available", async () => { const port = selectedPort({ vid, pid }); - const info = await getBoardInfo(port, { - [Port.keyOf(port)]: [port, []], - }); + const boardList = createBoardList( + { + [Port.keyOf(port)]: { port }, + }, + { selectedPort: port, selectedBoard: undefined } + ); + const info = await getBoardInfo(boardList); expect(typeof info).to.be.equal('object'); const boardInfo = info; expect(boardInfo.VID).to.be.equal(vid); @@ -162,9 +94,13 @@ describe('boards-service', () => { it("should show the 'SN' even if no matching board was detected for the port", async () => { const port = selectedPort({ vid, pid, serialNumber }); - const info = await getBoardInfo(port, { - [Port.keyOf(port)]: [port, []], - }); + const boardList = createBoardList( + { + [Port.keyOf(port)]: { port }, + }, + { selectedPort: port, selectedBoard: undefined } + ); + const info = await getBoardInfo(boardList); expect(typeof info).to.be.equal('object'); const boardInfo = info; expect(boardInfo.VID).to.be.equal(vid); @@ -175,9 +111,13 @@ describe('boards-service', () => { it("should use the name of the matching board as 'BN' if available", async () => { const port = selectedPort({ vid, pid }); - const info = await getBoardInfo(port, { - [Port.keyOf(port)]: [port, [selectedBoard]], - }); + const boardList = createBoardList( + { + [Port.keyOf(port)]: { port, boards: [selectedBoard] }, + }, + { selectedPort: port, selectedBoard: undefined } + ); + const info = await getBoardInfo(boardList); expect(typeof info).to.be.equal('object'); const boardInfo = info; expect(boardInfo.VID).to.be.equal(vid); @@ -187,7 +127,3 @@ describe('boards-service', () => { }); }); }); - -function never(): Promise { - return new Deferred().promise; -} diff --git a/arduino-ide-extension/src/test/node/core-service-impl.test.ts b/arduino-ide-extension/src/test/node/core-service-impl.test.ts index abba88357..ac419dd2d 100644 --- a/arduino-ide-extension/src/test/node/core-service-impl.test.ts +++ b/arduino-ide-extension/src/test/node/core-service-impl.test.ts @@ -1,5 +1,10 @@ import { expect } from 'chai'; -import { Port } from '../../node/cli-protocol/cc/arduino/cli/commands/v1/port_pb'; +import { + PortIdentifier, + portIdentifierEquals, + Port, +} from '../../common/protocol/boards-service'; +import { Port as RpcPort } from '../../node/cli-protocol/cc/arduino/cli/commands/v1/port_pb'; import { CoreServiceImpl } from '../../node/core-service-impl'; describe('core-service-impl', () => { @@ -22,9 +27,18 @@ describe('core-service-impl', () => { protocolLabel: 'serial port', properties, } as const; - const actual = new CoreServiceImpl()['createPort'](port); + const resolve = (toResolve: PortIdentifier): Port | undefined => { + if (portIdentifierEquals(toResolve, port)) { + return port; + } + return undefined; + }; + const actual = new CoreServiceImpl()['createPort']( + { protocol: port.protocol, address: port.address }, + resolve + ); expect(actual).to.be.not.undefined; - const expected = new Port() + const expected = new RpcPort() .setAddress(port.address) .setHardwareId(port.hardwareId) .setLabel(port.addressLabel) @@ -33,7 +47,7 @@ describe('core-service-impl', () => { Object.entries(properties).forEach(([key, value]) => expected.getPropertiesMap().set(key, value) ); - expect((actual).toObject(false)).to.be.deep.equal( + expect((actual).toObject(false)).to.be.deep.equal( expected.toObject(false) ); }); diff --git a/arduino-ide-extension/src/test/node/test-bindings.ts b/arduino-ide-extension/src/test/node/test-bindings.ts index 30d8513a9..ecf07dbc8 100644 --- a/arduino-ide-extension/src/test/node/test-bindings.ts +++ b/arduino-ide-extension/src/test/node/test-bindings.ts @@ -29,13 +29,12 @@ import { join } from 'node:path'; import { path as tempPath, track } from 'temp'; import { ArduinoDaemon, - AttachedBoardsChangeEvent, - AvailablePorts, BoardsPackage, BoardsService, ConfigService, ConfigState, CoreService, + DetectedPorts, IndexUpdateDidCompleteParams, IndexUpdateDidFailParams, IndexUpdateParams, @@ -160,7 +159,7 @@ class SilentArduinoDaemon extends ArduinoDaemonImpl { @injectable() class TestBoardDiscovery extends BoardDiscovery { - mutableAvailablePorts: AvailablePorts = {}; + mutableDetectedPorts: DetectedPorts = {}; override async start(): Promise { // NOOP @@ -168,8 +167,8 @@ class TestBoardDiscovery extends BoardDiscovery { override async stop(): Promise { // NOOP } - override get availablePorts(): AvailablePorts { - return this.mutableAvailablePorts; + override get detectedPorts(): DetectedPorts { + return this.mutableDetectedPorts; } } @@ -221,7 +220,7 @@ class TestNotificationServiceServer implements NotificationServiceServer { notifyLibraryDidUninstall(event: { item: LibraryPackage }): void { this.events.push(`notifyLibraryDidUninstall:${JSON.stringify(event)}`); } - notifyAttachedBoardsDidChange(event: AttachedBoardsChangeEvent): void { + notifyDetectedPortsDidChange(event: { detectedPorts: DetectedPorts }): void { this.events.push(`notifyAttachedBoardsDidChange:${JSON.stringify(event)}`); } notifyRecentSketchesDidChange(event: { sketches: Sketch[] }): void { diff --git a/i18n/en.json b/i18n/en.json index 9a5b38eda..18e10a569 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -17,25 +17,22 @@ "boards": "boards", "configDialog1": "Select both a Board and a Port if you want to upload a sketch.", "configDialog2": "If you only select a Board you will be able to compile, but not to upload your sketch.", - "couldNotFindPreviouslySelected": "Could not find previously selected board '{0}' in installed platform '{1}'. Please manually reselect the board you want to use. Do you want to reselect it now?", "disconnected": "Disconnected", + "editBoardsConfig": "Edit Board and Port...", "getBoardInfo": "Get Board Info", "inSketchbook": " (in Sketchbook)", "installNow": "The \"{0} {1}\" core has to be installed for the currently selected \"{2}\" board. Do you want to install it now?", "noBoardsFound": "No boards found for \"{0}\"", - "noFQBN": "The FQBN is not available for the selected board \"{0}\". Do you have the corresponding core installed?", "noNativeSerialPort": "Native serial port, can't obtain info.", "noPortsDiscovered": "No ports discovered", - "noPortsSelected": "No ports selected for board: '{0}'.", "nonSerialPort": "Non-serial port, can't obtain info.", - "noneSelected": "No boards selected.", "openBoardsConfig": "Select other board and port…", "pleasePickBoard": "Please pick a board connected to the port you have selected.", "port": "Port{0}", "portLabel": "Port: {0}", "ports": "ports", "programmer": "Programmer", - "reselectLater": "Reselect later", + "revertBoardsConfig": "Revert the selected '{0}' board to '{1}' detected on '{2}'", "searchBoard": "Search board", "selectBoard": "Select Board", "selectPortForInfo": "Please select a port to obtain board info.", @@ -215,6 +212,11 @@ "optimizeForDebugging": "Optimize for Debugging", "sketchIsNotCompiled": "Sketch '{0}' must be verified before starting a debug session. Please verify the sketch and start debugging again. Do you want to verify the sketch now?" }, + "developer": { + "clearBoardList": "Clear the Board List History", + "clearBoardsConfig": " Clear the Board and Port Configuration", + "dumpBoardList": "Dump the Board List" + }, "dialog": { "dontAskAgain": "Don't ask again" },