mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-12-16 20:17:44 +00:00
feat: simplify board and port handling (#2165)
Use Arduino CLI revision `38479dc`
Closes #43
Closes #82
Closes #1319
Closes #1366
Closes #2143
Closes #2158
Ref: 38479dc706
Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
@@ -1,281 +1,229 @@
|
||||
import { injectable, inject } from '@theia/core/shared/inversify';
|
||||
import { MessageService } from '@theia/core/lib/common/message-service';
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
|
||||
import {
|
||||
BoardsService,
|
||||
BoardsPackage,
|
||||
Board,
|
||||
Port,
|
||||
} from '../../common/protocol/boards-service';
|
||||
import { BoardsServiceProvider } from './boards-service-provider';
|
||||
import { Installable, ResponseServiceClient } from '../../common/protocol';
|
||||
import { BoardsListWidgetFrontendContribution } from './boards-widget-frontend-contribution';
|
||||
import { nls } from '@theia/core/lib/common';
|
||||
import { NotificationCenter } from '../notification-center';
|
||||
import { DisposableCollection } from '@theia/core/lib/common/disposable';
|
||||
import { MessageService } from '@theia/core/lib/common/message-service';
|
||||
import { MessageType } from '@theia/core/lib/common/message-service-protocol';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { notEmpty } from '@theia/core/lib/common/objects';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { NotificationManager } from '@theia/messages/lib/browser/notifications-manager';
|
||||
import { InstallManually } from '../../common/nls';
|
||||
|
||||
interface AutoInstallPromptAction {
|
||||
// isAcceptance, whether or not the action indicates acceptance of auto-install proposal
|
||||
isAcceptance?: boolean;
|
||||
key: string;
|
||||
handler: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
type AutoInstallPromptActions = AutoInstallPromptAction[];
|
||||
import { Installable, ResponseServiceClient } from '../../common/protocol';
|
||||
import {
|
||||
BoardIdentifier,
|
||||
BoardsPackage,
|
||||
BoardsService,
|
||||
createPlatformIdentifier,
|
||||
isBoardIdentifierChangeEvent,
|
||||
PlatformIdentifier,
|
||||
platformIdentifierEquals,
|
||||
serializePlatformIdentifier,
|
||||
} from '../../common/protocol/boards-service';
|
||||
import { NotificationCenter } from '../notification-center';
|
||||
import { BoardsServiceProvider } from './boards-service-provider';
|
||||
import { BoardsListWidgetFrontendContribution } from './boards-widget-frontend-contribution';
|
||||
|
||||
/**
|
||||
* Listens on `BoardsConfig.Config` changes, if a board is selected which does not
|
||||
* Listens on `BoardsConfigChangeEvent`s, if a board is selected which does not
|
||||
* have the corresponding core installed, it proposes the user to install the core.
|
||||
*/
|
||||
|
||||
// * Cases in which we do not show the auto-install prompt:
|
||||
// 1. When a related platform is already installed
|
||||
// 2. When a prompt is already showing in the UI
|
||||
// 3. When a board is unplugged
|
||||
@injectable()
|
||||
export class BoardsAutoInstaller implements FrontendApplicationContribution {
|
||||
@inject(NotificationCenter)
|
||||
private readonly notificationCenter: NotificationCenter;
|
||||
|
||||
@inject(MessageService)
|
||||
protected readonly messageService: MessageService;
|
||||
|
||||
private readonly messageService: MessageService;
|
||||
@inject(NotificationManager)
|
||||
private readonly notificationManager: NotificationManager;
|
||||
@inject(BoardsService)
|
||||
protected readonly boardsService: BoardsService;
|
||||
|
||||
private readonly boardsService: BoardsService;
|
||||
@inject(BoardsServiceProvider)
|
||||
protected readonly boardsServiceClient: BoardsServiceProvider;
|
||||
|
||||
private readonly boardsServiceProvider: BoardsServiceProvider;
|
||||
@inject(ResponseServiceClient)
|
||||
protected readonly responseService: ResponseServiceClient;
|
||||
|
||||
private readonly responseService: ResponseServiceClient;
|
||||
@inject(BoardsListWidgetFrontendContribution)
|
||||
protected readonly boardsManagerFrontendContribution: BoardsListWidgetFrontendContribution;
|
||||
private readonly boardsManagerWidgetContribution: BoardsListWidgetFrontendContribution;
|
||||
|
||||
// Workaround for https://github.com/eclipse-theia/theia/issues/9349
|
||||
protected notifications: Board[] = [];
|
||||
|
||||
// * "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 lastRefusedPackageId: string | undefined;
|
||||
private readonly installNotificationInfos: Readonly<{
|
||||
boardName: string;
|
||||
platformId: string;
|
||||
notificationId: string;
|
||||
}>[] = [];
|
||||
private readonly toDispose = new DisposableCollection();
|
||||
|
||||
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)
|
||||
) {
|
||||
return;
|
||||
this.toDispose.pushAll([
|
||||
this.boardsServiceProvider.onBoardsConfigDidChange((event) => {
|
||||
if (isBoardIdentifierChangeEvent(event)) {
|
||||
this.ensureCoreExists(event.selectedBoard);
|
||||
}
|
||||
|
||||
this.ensureCoreExists(selectedBoard, selectedPort);
|
||||
});
|
||||
|
||||
// we "clearRefusedPackageInfo" if a "refused" package is eventually
|
||||
// installed, though this is not strictly necessary. It's more of a
|
||||
// cleanup, to ensure the related variables are representative of
|
||||
// current state.
|
||||
this.notificationCenter.onPlatformDidInstall((installed) => {
|
||||
if (this.lastRefusedPackageId === installed.item.id) {
|
||||
this.clearLastRefusedPromptInfo();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 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();
|
||||
}),
|
||||
this.notificationCenter.onPlatformDidInstall((event) =>
|
||||
this.clearAllNotificationForPlatform(event.item.id)
|
||||
),
|
||||
]);
|
||||
this.boardsServiceProvider.ready.then(() => {
|
||||
const { selectedBoard } = this.boardsServiceProvider.boardsConfig;
|
||||
this.ensureCoreExists(selectedBoard);
|
||||
});
|
||||
}
|
||||
|
||||
private removeNotificationByBoard(selectedBoard: Board): void {
|
||||
const index = this.notifications.findIndex((notification) =>
|
||||
Board.sameAs(notification, selectedBoard)
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.notifications.splice(index, 1);
|
||||
private async findPlatformToInstall(
|
||||
selectedBoard: BoardIdentifier
|
||||
): Promise<BoardsPackage | undefined> {
|
||||
const platformId = await this.findPlatformIdToInstall(selectedBoard);
|
||||
if (!platformId) {
|
||||
return undefined;
|
||||
}
|
||||
const id = serializePlatformIdentifier(platformId);
|
||||
const platform = await this.boardsService.getBoardPackage({ id });
|
||||
if (!platform) {
|
||||
console.warn(`Could not resolve platform for ID: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
if (platform.installedVersion) {
|
||||
return undefined;
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
|
||||
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 {
|
||||
this.notifications.push(selectedBoard);
|
||||
this.boardsService.search({}).then((packages) => {
|
||||
const candidate = this.getInstallCandidate(packages, selectedBoard);
|
||||
|
||||
if (candidate) {
|
||||
this.showAutoInstallPrompt(candidate, selectedBoard, selectedPort);
|
||||
} else {
|
||||
this.removeNotificationByBoard(selectedBoard);
|
||||
private async findPlatformIdToInstall(
|
||||
selectedBoard: BoardIdentifier
|
||||
): Promise<PlatformIdentifier | undefined> {
|
||||
const selectedBoardPlatformId = createPlatformIdentifier(selectedBoard);
|
||||
// The board is installed or the FQBN is available from the `board list watch` for Arduino boards. The latter might change!
|
||||
if (selectedBoardPlatformId) {
|
||||
const installedPlatforms =
|
||||
await this.boardsService.getInstalledPlatforms();
|
||||
const installedPlatformIds = installedPlatforms
|
||||
.map((platform) => createPlatformIdentifier(platform.id))
|
||||
.filter(notEmpty);
|
||||
if (
|
||||
installedPlatformIds.every(
|
||||
(installedPlatformId) =>
|
||||
!platformIdentifierEquals(
|
||||
installedPlatformId,
|
||||
selectedBoardPlatformId
|
||||
)
|
||||
)
|
||||
) {
|
||||
return selectedBoardPlatformId;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// IDE2 knows that selected board is not installed. Look for board `name` match in not yet installed platforms.
|
||||
// The order should be correct when there is a board name collision (e.g. Arduino Nano RP2040 from Arduino Mbed OS Nano Boards, [DEPRECATED] Arduino Mbed OS Nano Boards). The CLI boosts the platforms, so picking the first name match should be fine.
|
||||
const platforms = await this.boardsService.search({});
|
||||
for (const platform of platforms) {
|
||||
// Ignore installed platforms
|
||||
if (platform.installedVersion) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
platform.boards.some((board) => board.name === selectedBoard.name)
|
||||
) {
|
||||
const platformId = createPlatformIdentifier(platform.id);
|
||||
if (platformId) {
|
||||
return platformId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getInstallCandidate(
|
||||
packages: BoardsPackage[],
|
||||
selectedBoard: Board
|
||||
): BoardsPackage | undefined {
|
||||
// filter packagesForBoard selecting matches from the cli (installed packages)
|
||||
// and matches based on the board name
|
||||
// NOTE: this ensures the Deprecated & new packages are all in the array
|
||||
// so that we can check if any of the valid packages is already installed
|
||||
const packagesForBoard = packages.filter(
|
||||
(pkg) =>
|
||||
BoardsPackage.contains(selectedBoard, pkg) ||
|
||||
pkg.boards.some((board) => board.name === selectedBoard.name)
|
||||
);
|
||||
|
||||
// check if one of the packages for the board is already installed. if so, no hint
|
||||
if (packagesForBoard.some(({ installedVersion }) => !!installedVersion)) {
|
||||
private async ensureCoreExists(
|
||||
selectedBoard: BoardIdentifier | undefined
|
||||
): Promise<void> {
|
||||
if (!selectedBoard) {
|
||||
return;
|
||||
}
|
||||
const candidate = await this.findPlatformToInstall(selectedBoard);
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
const platformIdToInstall = candidate.id;
|
||||
const selectedBoardName = selectedBoard.name;
|
||||
if (
|
||||
this.installNotificationInfos.some(
|
||||
({ boardName, platformId }) =>
|
||||
platformIdToInstall === platformId && selectedBoardName === boardName
|
||||
)
|
||||
) {
|
||||
// Already has a notification for the board with the same platform. Nothing to do.
|
||||
return;
|
||||
}
|
||||
this.clearAllNotificationForPlatform(platformIdToInstall);
|
||||
|
||||
// filter the installable (not installed) packages,
|
||||
// CLI returns the packages already sorted with the deprecated ones at the end of the list
|
||||
// in order to ensure the new ones are preferred
|
||||
const candidates = packagesForBoard.filter(
|
||||
({ installedVersion }) => !installedVersion
|
||||
);
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
private showAutoInstallPrompt(
|
||||
candidate: BoardsPackage,
|
||||
selectedBoard: Board,
|
||||
selectedPort?: Port
|
||||
): void {
|
||||
const candidateName = candidate.name;
|
||||
const version = candidate.availableVersions[0]
|
||||
? `[v ${candidate.availableVersions[0]}]`
|
||||
: '';
|
||||
|
||||
const info = this.generatePromptInfoText(
|
||||
candidateName,
|
||||
const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes');
|
||||
const message = nls.localize(
|
||||
'arduino/board/installNow',
|
||||
'The "{0} {1}" core has to be installed for the currently selected "{2}" board. Do you want to install it now?',
|
||||
candidate.name,
|
||||
version,
|
||||
selectedBoard.name
|
||||
);
|
||||
|
||||
const actions = this.createPromptActions(candidate);
|
||||
|
||||
const onRefuse = () => {
|
||||
this.setLastRefusedPromptInfo(candidate.id, selectedPort);
|
||||
};
|
||||
const handleAction = this.createOnAnswerHandler(actions, onRefuse);
|
||||
|
||||
const onAnswer = (answer: string) => {
|
||||
this.removeNotificationByBoard(selectedBoard);
|
||||
|
||||
handleAction(answer);
|
||||
};
|
||||
|
||||
this.messageService
|
||||
.info(info, ...actions.map((action) => action.key))
|
||||
.then(onAnswer);
|
||||
}
|
||||
|
||||
private generatePromptInfoText(
|
||||
candidateName: string,
|
||||
version: string,
|
||||
boardName: string
|
||||
): string {
|
||||
return nls.localize(
|
||||
'arduino/board/installNow',
|
||||
'The "{0} {1}" core has to be installed for the currently selected "{2}" board. Do you want to install it now?',
|
||||
candidateName,
|
||||
version,
|
||||
boardName
|
||||
const notificationId = this.notificationId(message, InstallManually, yes);
|
||||
this.installNotificationInfos.push({
|
||||
boardName: selectedBoardName,
|
||||
platformId: platformIdToInstall,
|
||||
notificationId,
|
||||
});
|
||||
const answer = await this.messageService.info(
|
||||
message,
|
||||
InstallManually,
|
||||
yes
|
||||
);
|
||||
}
|
||||
|
||||
private createPromptActions(
|
||||
candidate: BoardsPackage
|
||||
): AutoInstallPromptActions {
|
||||
const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes');
|
||||
|
||||
const actions: AutoInstallPromptActions = [
|
||||
{
|
||||
key: InstallManually,
|
||||
handler: () => {
|
||||
this.boardsManagerFrontendContribution
|
||||
.openView({ reveal: true })
|
||||
.then((widget) =>
|
||||
widget.refresh({
|
||||
query: candidate.name.toLocaleLowerCase(),
|
||||
type: 'All',
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
isAcceptance: true,
|
||||
key: yes,
|
||||
handler: () => {
|
||||
return Installable.installWithProgress({
|
||||
installable: this.boardsService,
|
||||
item: candidate,
|
||||
messageService: this.messageService,
|
||||
responseService: this.responseService,
|
||||
version: candidate.availableVersions[0],
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private createOnAnswerHandler(
|
||||
actions: AutoInstallPromptActions,
|
||||
onRefuse?: () => void
|
||||
): (answer: string) => void {
|
||||
return (answer) => {
|
||||
const actionToHandle = actions.find((action) => action.key === answer);
|
||||
actionToHandle?.handler();
|
||||
|
||||
if (!actionToHandle?.isAcceptance && onRefuse) {
|
||||
onRefuse();
|
||||
if (answer) {
|
||||
const index = this.installNotificationInfos.findIndex(
|
||||
({ boardName, platformId }) =>
|
||||
platformIdToInstall === platformId && selectedBoardName === boardName
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.installNotificationInfos.splice(index, 1);
|
||||
}
|
||||
};
|
||||
if (answer === yes) {
|
||||
await Installable.installWithProgress({
|
||||
installable: this.boardsService,
|
||||
item: candidate,
|
||||
messageService: this.messageService,
|
||||
responseService: this.responseService,
|
||||
version: candidate.availableVersions[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (answer === InstallManually) {
|
||||
this.boardsManagerWidgetContribution
|
||||
.openView({ reveal: true })
|
||||
.then((widget) =>
|
||||
widget.refresh({
|
||||
query: candidate.name.toLocaleLowerCase(),
|
||||
type: 'All',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearAllNotificationForPlatform(predicatePlatformId: string): void {
|
||||
// Discard all install notifications for the same platform.
|
||||
const notificationsLength = this.installNotificationInfos.length;
|
||||
for (let i = notificationsLength - 1; i >= 0; i--) {
|
||||
const { notificationId, platformId } = this.installNotificationInfos[i];
|
||||
if (platformId === predicatePlatformId) {
|
||||
this.installNotificationInfos.splice(i, 1);
|
||||
this.notificationManager.clear(notificationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private notificationId(message: string, ...actions: string[]): string {
|
||||
return this.notificationManager['getMessageId']({
|
||||
text: message,
|
||||
actions,
|
||||
type: MessageType.Info,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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 React from '@theia/core/shared/react';
|
||||
import { EditBoardsConfigActionParams } from '../../common/protocol/board-list';
|
||||
import {
|
||||
Board,
|
||||
BoardIdentifier,
|
||||
BoardWithPackage,
|
||||
DetectedPort,
|
||||
findMatchingPortIndex,
|
||||
Port,
|
||||
PortIdentifier,
|
||||
} from '../../common/protocol/boards-service';
|
||||
import type { Defined } from '../../common/types';
|
||||
import { NotificationCenter } from '../notification-center';
|
||||
import { BoardsConfigDialogState } from './boards-config-dialog';
|
||||
|
||||
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: BoardsConfigDialogState;
|
||||
readonly searchSet: BoardIdentifier[] | undefined;
|
||||
readonly notificationCenter: NotificationCenter;
|
||||
readonly appState: FrontendApplicationState;
|
||||
readonly onFocusNodeSet: (element: HTMLElement | undefined) => void;
|
||||
readonly onFilteredTextDidChangeEvent: Event<
|
||||
Defined<EditBoardsConfigActionParams['query']>
|
||||
>;
|
||||
readonly onAppStateDidChange: Event<FrontendApplicationState>;
|
||||
readonly onBoardSelected: (board: BoardIdentifier) => void;
|
||||
readonly onPortSelected: (port: PortIdentifier) => void;
|
||||
readonly searchBoards: (query?: {
|
||||
query?: string;
|
||||
}) => Promise<BoardWithPackage[]>;
|
||||
readonly ports: (
|
||||
predicate?: (port: DetectedPort) => boolean
|
||||
) => readonly DetectedPort[];
|
||||
}
|
||||
|
||||
export interface State {
|
||||
searchResults: Array<BoardWithPackage>;
|
||||
showAllPorts: boolean;
|
||||
query: string;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Item<T> 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 (
|
||||
<div
|
||||
onClick={this.onClick}
|
||||
className={classNames.join(' ')}
|
||||
title={`${label}${!details ? '' : details}`}
|
||||
>
|
||||
<div className="label">{label}</div>
|
||||
{!details ? '' : <div className="details">{details}</div>}
|
||||
{!selected ? (
|
||||
''
|
||||
) : (
|
||||
<div className="selected-icon">
|
||||
<i className="fa fa-check" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.onAppStateDidChange(async (state) => {
|
||||
if (state === 'ready') {
|
||||
const searchResults = await this.queryBoards({});
|
||||
this.setState({ searchResults });
|
||||
}
|
||||
}),
|
||||
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));
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
private readonly updateBoards = (
|
||||
eventOrQuery: React.ChangeEvent<HTMLInputElement> | 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 = async (
|
||||
options: { query?: string } = {}
|
||||
): Promise<Array<BoardWithPackage>> => {
|
||||
const result = await this.props.searchBoards(options);
|
||||
const { searchSet } = this.props;
|
||||
if (searchSet) {
|
||||
return result.filter((board) =>
|
||||
searchSet.some(
|
||||
(restriction) =>
|
||||
restriction.fqbn === board.fqbn || restriction.name === board.fqbn
|
||||
)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
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(selectedBoard);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="container">
|
||||
<div className="content">
|
||||
<div className="title">{title}</div>
|
||||
{contentRenderer()}
|
||||
<div className="footer">{footerRenderer ? footerRenderer() : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, Board.Detailed>();
|
||||
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) => (
|
||||
<Item<BoardWithPackage>
|
||||
key={toKey(board)}
|
||||
item={board}
|
||||
label={board.name}
|
||||
details={board.details}
|
||||
selected={board.selected}
|
||||
onClick={this.selectBoard}
|
||||
missing={board.missing}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="search">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
className="theia-input"
|
||||
placeholder={nls.localize(
|
||||
'arduino/board/searchBoard',
|
||||
'Search board'
|
||||
)}
|
||||
onChange={this.updateBoards}
|
||||
ref={this.focusNodeSet}
|
||||
/>
|
||||
<i className="fa fa-search"></i>
|
||||
</div>
|
||||
{boardsList.length > 0 ? (
|
||||
<div className="boards list">{boardsList}</div>
|
||||
) : (
|
||||
<div className="no-result">
|
||||
{nls.localize(
|
||||
'arduino/board/noBoardsFound',
|
||||
'No boards found for "{0}"',
|
||||
query
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<div className="no-result">
|
||||
{nls.localize('arduino/board/noPortsDiscovered', 'No ports discovered')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ports list">
|
||||
{detectedPorts.map((detectedPort, index) => (
|
||||
<Item<Port>
|
||||
key={`${Port.keyOf(detectedPort.port)}`}
|
||||
item={detectedPort.port}
|
||||
label={Port.toString(detectedPort.port)}
|
||||
selected={index === matchingIndex}
|
||||
onClick={this.selectPort}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderPortsFooter(): React.ReactNode {
|
||||
return (
|
||||
<div className="noselect">
|
||||
<label
|
||||
title={nls.localize(
|
||||
'arduino/board/showAllAvailablePorts',
|
||||
'Shows all available ports when enabled'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={this.state.showAllPorts}
|
||||
onChange={this.toggleFilterPorts}
|
||||
/>
|
||||
<span>
|
||||
{nls.localize('arduino/board/showAllPorts', 'Show all ports')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 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<string>();
|
||||
protected readonly onBoardConfigChangedEmitter =
|
||||
new Emitter<BoardsConfig.Config>();
|
||||
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 (
|
||||
<div className="selectBoardContainer">
|
||||
<BoardsConfig
|
||||
boardsServiceProvider={this.boardsServiceClient}
|
||||
notificationCenter={this.notificationCenter}
|
||||
onConfigChange={this.fireConfigChanged}
|
||||
onFocusNodeSet={this.setFocusNode}
|
||||
onFilteredTextDidChangeEvent={this.onFilterTextDidChangeEmitter.event}
|
||||
onAppStateDidChange={this.notificationCenter.onAppStateDidChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected override onActivateRequest(msg: Message): void {
|
||||
super.onActivateRequest(msg);
|
||||
if (this.focusNode instanceof HTMLInputElement) {
|
||||
this.focusNode.select();
|
||||
}
|
||||
(this.focusNode || this.node).focus();
|
||||
}
|
||||
}
|
||||
@@ -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<BoardsConfig.Config> {
|
||||
@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<BoardsConfig.Config | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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 { EditBoardsConfigActionParams } from '../../common/protocol/board-list';
|
||||
import {
|
||||
BoardIdentifier,
|
||||
BoardsConfig,
|
||||
BoardWithPackage,
|
||||
DetectedPort,
|
||||
emptyBoardsConfig,
|
||||
PortIdentifier,
|
||||
} from '../../common/protocol/boards-service';
|
||||
import type { Defined } from '../../common/types';
|
||||
import { NotificationCenter } from '../notification-center';
|
||||
import { ReactDialog } from '../theia/dialogs/dialogs';
|
||||
import { BoardsConfigComponent } from './boards-config-component';
|
||||
import { BoardsServiceProvider } from './boards-service-provider';
|
||||
|
||||
@injectable()
|
||||
export class BoardsConfigDialogProps extends DialogProps {}
|
||||
|
||||
export type BoardsConfigDialogState = Omit<BoardsConfig, 'selectedBoard'> & {
|
||||
selectedBoard: BoardsConfig['selectedBoard'] | BoardWithPackage;
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class BoardsConfigDialog extends ReactDialog<BoardsConfigDialogState> {
|
||||
@inject(BoardsServiceProvider)
|
||||
private readonly boardsServiceProvider: BoardsServiceProvider;
|
||||
@inject(NotificationCenter)
|
||||
private readonly notificationCenter: NotificationCenter;
|
||||
@inject(FrontendApplicationStateService)
|
||||
private readonly appStateService: FrontendApplicationStateService;
|
||||
|
||||
private readonly onFilterTextDidChangeEmitter: Emitter<
|
||||
Defined<EditBoardsConfigActionParams['query']>
|
||||
>;
|
||||
private readonly onBoardSelected = (board: BoardWithPackage): 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<BoardWithPackage[]> => {
|
||||
return this.boardsServiceProvider.searchBoards(options);
|
||||
};
|
||||
private readonly ports = (
|
||||
predicate?: (port: DetectedPort) => boolean
|
||||
): readonly DetectedPort[] => {
|
||||
return this.boardsServiceProvider.boardList.ports(predicate);
|
||||
};
|
||||
private _boardsConfig: BoardsConfigDialogState;
|
||||
/**
|
||||
* When the dialog's boards result set is limited to a subset of boards when searching, this field is set.
|
||||
*/
|
||||
private _searchSet: BoardIdentifier[] | undefined;
|
||||
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();
|
||||
});
|
||||
this._boardsConfig = deepClone(this.boardsServiceProvider.boardsConfig);
|
||||
}
|
||||
|
||||
override async open(
|
||||
params?: EditBoardsConfigActionParams
|
||||
): Promise<BoardsConfig | undefined> {
|
||||
this._searchSet = undefined;
|
||||
this._boardsConfig.selectedBoard =
|
||||
this.boardsServiceProvider.boardsConfig.selectedBoard;
|
||||
this._boardsConfig.selectedPort =
|
||||
this.boardsServiceProvider.boardsConfig.selectedPort;
|
||||
if (params) {
|
||||
if (typeof params.query === 'string') {
|
||||
this.onFilterTextDidChangeEmitter.fire(params.query);
|
||||
}
|
||||
if (params.portToSelect) {
|
||||
this._boardsConfig.selectedPort = params.portToSelect;
|
||||
}
|
||||
if (params.boardToSelect) {
|
||||
this._boardsConfig.selectedBoard = params.boardToSelect;
|
||||
}
|
||||
if (params.searchSet) {
|
||||
this._searchSet = params.searchSet.slice();
|
||||
}
|
||||
}
|
||||
return super.open();
|
||||
}
|
||||
|
||||
protected override onAfterAttach(msg: Message): void {
|
||||
super.onAfterAttach(msg);
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected override render(): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<div className="head">
|
||||
<div className="text">
|
||||
<div>
|
||||
{nls.localize(
|
||||
'arduino/board/configDialog1',
|
||||
'Select both a Board and a Port if you want to upload a sketch.'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{nls.localize(
|
||||
'arduino/board/configDialog2',
|
||||
'If you only select a Board you will be able to compile, but not to upload your sketch.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="select-board-dialog" className="p-Widget ps">
|
||||
<div className="selectBoardContainer">
|
||||
<BoardsConfigComponent
|
||||
boardsConfig={this._boardsConfig}
|
||||
searchSet={this._searchSet}
|
||||
onBoardSelected={this.onBoardSelected}
|
||||
onPortSelected={this.onPortSelected}
|
||||
notificationCenter={this.notificationCenter}
|
||||
onFocusNodeSet={this.setFocusNode}
|
||||
onFilteredTextDidChangeEvent={
|
||||
this.onFilterTextDidChangeEmitter.event
|
||||
}
|
||||
appState={this.appStateService.state}
|
||||
onAppStateDidChange={this.notificationCenter.onAppStateDidChange}
|
||||
searchBoards={this.searchBoards}
|
||||
ports={this.ports}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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(): BoardsConfigDialogState {
|
||||
return this._boardsConfig;
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
import 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<string>;
|
||||
readonly onAppStateDidChange: Event<FrontendApplicationState>;
|
||||
}
|
||||
|
||||
export interface State extends Config {
|
||||
searchResults: Array<BoardWithPackage>;
|
||||
knownPorts: Port[];
|
||||
showAllPorts: boolean;
|
||||
query: string;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Item<T> 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 (
|
||||
<div
|
||||
onClick={this.onClick}
|
||||
className={classNames.join(' ')}
|
||||
title={`${label}${!details ? '' : details}`}
|
||||
>
|
||||
<div className="label">{label}</div>
|
||||
{!details ? '' : <div className="details">{details}</div>}
|
||||
{!selected ? (
|
||||
''
|
||||
) : (
|
||||
<div className="selected-icon">
|
||||
<i className="fa fa-check" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLInputElement> | 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<Array<BoardWithPackage>> => {
|
||||
return this.props.boardsServiceProvider.searchBoards(options);
|
||||
};
|
||||
|
||||
protected get availablePorts(): MaybePromise<Port[]> {
|
||||
return this.props.boardsServiceProvider.availableBoards
|
||||
.map(({ port }) => port)
|
||||
.filter(notEmpty);
|
||||
}
|
||||
|
||||
protected get availableBoards(): AvailableBoard[] {
|
||||
return this.props.boardsServiceProvider.availableBoards;
|
||||
}
|
||||
|
||||
protected queryPorts = async (
|
||||
availablePorts: MaybePromise<Port[]> = 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 (
|
||||
<div className="container">
|
||||
<div className="content">
|
||||
<div className="title">{title}</div>
|
||||
{contentRenderer()}
|
||||
<div className="footer">{footerRenderer ? footerRenderer() : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, Board.Detailed>();
|
||||
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) => (
|
||||
<Item<BoardWithPackage>
|
||||
key={toKey(board)}
|
||||
item={board}
|
||||
label={board.name}
|
||||
details={board.details}
|
||||
selected={board.selected}
|
||||
onClick={this.selectBoard}
|
||||
missing={board.missing}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="search">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
className="theia-input"
|
||||
placeholder={nls.localize(
|
||||
'arduino/board/searchBoard',
|
||||
'Search board'
|
||||
)}
|
||||
onChange={this.updateBoards}
|
||||
ref={this.focusNodeSet}
|
||||
/>
|
||||
<i className="fa fa-search"></i>
|
||||
</div>
|
||||
{boardsList.length > 0 ? (
|
||||
<div className="boards list">{boardsList}</div>
|
||||
) : (
|
||||
<div className="no-result">
|
||||
{nls.localize(
|
||||
'arduino/board/noBoardsFound',
|
||||
'No boards found for "{0}"',
|
||||
query
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<div className="no-result">
|
||||
{nls.localize('arduino/board/noPortsDiscovered', 'No ports discovered')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ports list">
|
||||
{ports.map((port) => (
|
||||
<Item<Port>
|
||||
key={`${Port.keyOf(port)}`}
|
||||
item={port}
|
||||
label={Port.toString(port)}
|
||||
selected={Port.sameAs(this.state.selectedPort, port)}
|
||||
onClick={this.selectPort}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected renderPortsFooter(): React.ReactNode {
|
||||
return (
|
||||
<div className="noselect">
|
||||
<label
|
||||
title={nls.localize(
|
||||
'arduino/board/showAllAvailablePorts',
|
||||
'Shows all available ports when enabled'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={this.state.showAllPorts}
|
||||
onChange={this.toggleFilterPorts}
|
||||
/>
|
||||
<span>
|
||||
{nls.localize('arduino/board/showAllPorts', 'Show all ports')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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}` : ''}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import PQueue from 'p-queue';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { CommandRegistry } from '@theia/core/lib/common/command';
|
||||
import { MenuModelRegistry } from '@theia/core/lib/common/menu';
|
||||
import {
|
||||
Disposable,
|
||||
DisposableCollection,
|
||||
} from '@theia/core/lib/common/disposable';
|
||||
import { BoardsServiceProvider } from './boards-service-provider';
|
||||
import { Board, ConfigOption, Programmer } from '../../common/protocol';
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { BoardsDataStore } from './boards-data-store';
|
||||
import { MainMenuManager } from '../../common/main-menu-manager';
|
||||
import { ArduinoMenus, unregisterSubmenu } from '../menu/arduino-menus';
|
||||
import { nls } from '@theia/core/lib/common';
|
||||
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
|
||||
|
||||
@injectable()
|
||||
export class BoardsDataMenuUpdater implements FrontendApplicationContribution {
|
||||
@inject(CommandRegistry)
|
||||
protected readonly commandRegistry: CommandRegistry;
|
||||
|
||||
@inject(MenuModelRegistry)
|
||||
protected readonly menuRegistry: MenuModelRegistry;
|
||||
|
||||
@inject(MainMenuManager)
|
||||
protected readonly mainMenuManager: MainMenuManager;
|
||||
|
||||
@inject(BoardsDataStore)
|
||||
protected readonly boardsDataStore: BoardsDataStore;
|
||||
|
||||
@inject(BoardsServiceProvider)
|
||||
protected readonly boardsServiceClient: BoardsServiceProvider;
|
||||
|
||||
@inject(FrontendApplicationStateService)
|
||||
private readonly appStateService: FrontendApplicationStateService;
|
||||
|
||||
protected readonly queue = new PQueue({ autoStart: true, concurrency: 1 });
|
||||
protected readonly toDisposeOnBoardChange = new DisposableCollection();
|
||||
|
||||
async onStart(): Promise<void> {
|
||||
this.appStateService
|
||||
.reachedState('ready')
|
||||
.then(() =>
|
||||
this.updateMenuActions(
|
||||
this.boardsServiceClient.boardsConfig.selectedBoard
|
||||
)
|
||||
);
|
||||
this.boardsDataStore.onChanged(() =>
|
||||
this.updateMenuActions(
|
||||
this.boardsServiceClient.boardsConfig.selectedBoard
|
||||
)
|
||||
);
|
||||
this.boardsServiceClient.onBoardsConfigChanged(({ selectedBoard }) =>
|
||||
this.updateMenuActions(selectedBoard)
|
||||
);
|
||||
}
|
||||
|
||||
protected async updateMenuActions(
|
||||
selectedBoard: Board | undefined
|
||||
): Promise<void> {
|
||||
return this.queue.add(async () => {
|
||||
this.toDisposeOnBoardChange.dispose();
|
||||
this.mainMenuManager.update();
|
||||
if (selectedBoard) {
|
||||
const { fqbn } = selectedBoard;
|
||||
if (fqbn) {
|
||||
const { configOptions, programmers, selectedProgrammer } =
|
||||
await this.boardsDataStore.getData(fqbn);
|
||||
if (configOptions.length) {
|
||||
const boardsConfigMenuPath = [
|
||||
...ArduinoMenus.TOOLS__BOARD_SETTINGS_GROUP,
|
||||
'z01_boardsConfig',
|
||||
]; // `z_` is for ordering.
|
||||
for (const { label, option, values } of configOptions.sort(
|
||||
ConfigOption.LABEL_COMPARATOR
|
||||
)) {
|
||||
const menuPath = [...boardsConfigMenuPath, `${option}`];
|
||||
const commands = new Map<
|
||||
string,
|
||||
Disposable & { label: string }
|
||||
>();
|
||||
let selectedValue = '';
|
||||
for (const value of values) {
|
||||
const id = `${fqbn}-${option}--${value.value}`;
|
||||
const command = { id };
|
||||
const handler = {
|
||||
execute: () =>
|
||||
this.boardsDataStore.selectConfigOption({
|
||||
fqbn,
|
||||
option,
|
||||
selectedValue: value.value,
|
||||
}),
|
||||
isToggled: () => value.selected,
|
||||
};
|
||||
commands.set(
|
||||
id,
|
||||
Object.assign(
|
||||
this.commandRegistry.registerCommand(command, handler),
|
||||
{ label: value.label }
|
||||
)
|
||||
);
|
||||
if (value.selected) {
|
||||
selectedValue = value.label;
|
||||
}
|
||||
}
|
||||
this.menuRegistry.registerSubmenu(
|
||||
menuPath,
|
||||
`${label}${selectedValue ? `: "${selectedValue}"` : ''}`
|
||||
);
|
||||
this.toDisposeOnBoardChange.pushAll([
|
||||
...commands.values(),
|
||||
Disposable.create(() =>
|
||||
unregisterSubmenu(menuPath, this.menuRegistry)
|
||||
),
|
||||
...Array.from(commands.keys()).map((commandId, i) => {
|
||||
const { label } = commands.get(commandId)!;
|
||||
this.menuRegistry.registerMenuAction(menuPath, {
|
||||
commandId,
|
||||
order: String(i).padStart(4),
|
||||
label,
|
||||
});
|
||||
return Disposable.create(() =>
|
||||
this.menuRegistry.unregisterMenuAction(commandId)
|
||||
);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (programmers.length) {
|
||||
const programmersMenuPath = [
|
||||
...ArduinoMenus.TOOLS__BOARD_SETTINGS_GROUP,
|
||||
'z02_programmers',
|
||||
];
|
||||
const programmerNls = nls.localize(
|
||||
'arduino/board/programmer',
|
||||
'Programmer'
|
||||
);
|
||||
const label = selectedProgrammer
|
||||
? `${programmerNls}: "${selectedProgrammer.name}"`
|
||||
: programmerNls;
|
||||
this.menuRegistry.registerSubmenu(programmersMenuPath, label);
|
||||
this.toDisposeOnBoardChange.push(
|
||||
Disposable.create(() =>
|
||||
unregisterSubmenu(programmersMenuPath, this.menuRegistry)
|
||||
)
|
||||
);
|
||||
for (const programmer of programmers) {
|
||||
const { id, name } = programmer;
|
||||
const command = { id: `${fqbn}-programmer--${id}` };
|
||||
const handler = {
|
||||
execute: () =>
|
||||
this.boardsDataStore.selectProgrammer({
|
||||
fqbn,
|
||||
selectedProgrammer: programmer,
|
||||
}),
|
||||
isToggled: () =>
|
||||
Programmer.equals(programmer, selectedProgrammer),
|
||||
};
|
||||
this.menuRegistry.registerMenuAction(programmersMenuPath, {
|
||||
commandId: command.id,
|
||||
label: name,
|
||||
});
|
||||
this.commandRegistry.registerCommand(command, handler);
|
||||
this.toDisposeOnBoardChange.pushAll([
|
||||
Disposable.create(() =>
|
||||
this.commandRegistry.unregisterCommand(command)
|
||||
),
|
||||
Disposable.create(() =>
|
||||
this.menuRegistry.unregisterMenuAction(command.id)
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
this.mainMenuManager.update();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string[]>();
|
||||
private readonly onChangedEmitter = new Emitter<string[]>();
|
||||
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<ConfigOption[]>(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<string[]> {
|
||||
@@ -65,7 +66,7 @@ export class BoardsDataStore implements FrontendApplicationContribution {
|
||||
}
|
||||
|
||||
async appendConfigToFqbn(
|
||||
fqbn: string | undefined,
|
||||
fqbn: string | undefined
|
||||
): Promise<string | undefined> {
|
||||
if (!fqbn) {
|
||||
return undefined;
|
||||
@@ -100,12 +101,13 @@ export class BoardsDataStore implements FrontendApplicationContribution {
|
||||
return data;
|
||||
}
|
||||
|
||||
async selectProgrammer(
|
||||
{
|
||||
fqbn,
|
||||
selectedProgrammer,
|
||||
}: { fqbn: string; selectedProgrammer: Programmer },
|
||||
): Promise<boolean> {
|
||||
async selectProgrammer({
|
||||
fqbn,
|
||||
selectedProgrammer,
|
||||
}: {
|
||||
fqbn: string;
|
||||
selectedProgrammer: Programmer;
|
||||
}): Promise<boolean> {
|
||||
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<boolean> {
|
||||
async selectConfigOption({
|
||||
fqbn,
|
||||
option,
|
||||
selectedValue,
|
||||
}: {
|
||||
fqbn: string;
|
||||
option: string;
|
||||
selectedValue: string;
|
||||
}): Promise<boolean> {
|
||||
const data = deepClone(await this.getData(fqbn));
|
||||
const { configOptions } = data;
|
||||
const configOption = configOptions.find((c) => c.option === option);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,21 @@
|
||||
import React from '@theia/core/shared/react';
|
||||
import * as ReactDOM from '@theia/core/shared/react-dom';
|
||||
import { TabBarToolbar } from '@theia/core/lib/browser/shell/tab-bar-toolbar/tab-bar-toolbar';
|
||||
import { codicon } from '@theia/core/lib/browser/widgets/widget';
|
||||
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 {
|
||||
BoardsServiceProvider,
|
||||
AvailableBoard,
|
||||
} from './boards-service-provider';
|
||||
import { nls } from '@theia/core/lib/common';
|
||||
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 { BoardsConfig } from './boards-config';
|
||||
import { boardIdentifierLabel, Port } from '../../common/protocol';
|
||||
import { BoardListItemUI } from '../../common/protocol/board-list';
|
||||
import { assertUnreachable } from '../../common/utils';
|
||||
import type {
|
||||
BoardListUI,
|
||||
BoardsServiceProvider,
|
||||
} from './boards-service-provider';
|
||||
|
||||
export interface BoardsDropDownListCoords {
|
||||
readonly top: number;
|
||||
@@ -22,18 +27,18 @@ export interface BoardsDropDownListCoords {
|
||||
export namespace BoardsDropDown {
|
||||
export interface Props {
|
||||
readonly coords: BoardsDropDownListCoords | 'hidden';
|
||||
readonly items: Array<AvailableBoard & { onClick: () => void; port: Port }>;
|
||||
readonly boardList: BoardListUI;
|
||||
readonly openBoardsConfig: () => void;
|
||||
readonly hide: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
export class BoardsDropDown extends React.Component<BoardsDropDown.Props> {
|
||||
protected dropdownElement: HTMLElement;
|
||||
export class BoardListDropDown extends React.Component<BoardsDropDown.Props> {
|
||||
private dropdownElement: HTMLElement;
|
||||
private listRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props: BoardsDropDown.Props) {
|
||||
super(props);
|
||||
|
||||
this.listRef = React.createRef();
|
||||
let list = document.getElementById('boards-dropdown-container');
|
||||
if (!list) {
|
||||
@@ -51,11 +56,14 @@ export class BoardsDropDown extends React.Component<BoardsDropDown.Props> {
|
||||
}
|
||||
|
||||
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 +82,12 @@ export class BoardsDropDown extends React.Component<BoardsDropDown.Props> {
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="arduino-boards-dropdown-list--items-container">
|
||||
{items
|
||||
.map(({ name, port, selected, onClick }) => ({
|
||||
boardLabel: name,
|
||||
port,
|
||||
selected,
|
||||
onClick,
|
||||
}))
|
||||
.map(this.renderItem)}
|
||||
{boardList.items.map((item, index) =>
|
||||
this.renderBoardListItem({
|
||||
item,
|
||||
selected: index === boardList.selectedIndex,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
key={footerLabel}
|
||||
@@ -95,31 +101,43 @@ export class BoardsDropDown extends React.Component<BoardsDropDown.Props> {
|
||||
);
|
||||
}
|
||||
|
||||
protected renderItem({
|
||||
boardLabel,
|
||||
port,
|
||||
private readonly onDefaultAction = (item: BoardListItemUI): unknown => {
|
||||
const { boardList, hide } = this.props;
|
||||
const { type, params } = item.defaultAction;
|
||||
hide();
|
||||
switch (type) {
|
||||
case 'select-boards-config': {
|
||||
return boardList.select(params);
|
||||
}
|
||||
case 'edit-boards-config': {
|
||||
return boardList.edit(params);
|
||||
}
|
||||
default:
|
||||
return assertUnreachable(type);
|
||||
}
|
||||
};
|
||||
|
||||
private renderBoardListItem({
|
||||
item,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
boardLabel: string;
|
||||
port: Port;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
item: BoardListItemUI;
|
||||
selected: boolean;
|
||||
}): React.ReactNode {
|
||||
const protocolIcon = iconNameFromProtocol(port.protocol);
|
||||
const { boardLabel, portLabel, portProtocol, tooltip } = item.labels;
|
||||
const port = item.port;
|
||||
const onKeyUp = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onClick();
|
||||
this.onDefaultAction(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`board-item--${boardLabel}-${port.address}`}
|
||||
key={`board-item--${Port.keyOf(port)}`}
|
||||
className={classNames('arduino-boards-dropdown-item', {
|
||||
'arduino-boards-dropdown-item--selected': selected,
|
||||
})}
|
||||
onClick={onClick}
|
||||
onClick={() => this.onDefaultAction(item)}
|
||||
onKeyUp={onKeyUp}
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -127,21 +145,81 @@ export class BoardsDropDown extends React.Component<BoardsDropDown.Props> {
|
||||
className={classNames(
|
||||
'arduino-boards-dropdown-item--protocol',
|
||||
'fa',
|
||||
protocolIcon
|
||||
iconNameFromProtocol(portProtocol)
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className="arduino-boards-dropdown-item--label"
|
||||
title={`${boardLabel}\n${port.address}`}
|
||||
>
|
||||
<div className="arduino-boards-dropdown-item--board-label noWrapInfo noselect">
|
||||
{boardLabel}
|
||||
<div className="arduino-boards-dropdown-item--label" title={tooltip}>
|
||||
<div className="arduino-boards-dropdown-item--board-header">
|
||||
<div className="arduino-boards-dropdown-item--board-label noWrapInfo noselect">
|
||||
{boardLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="arduino-boards-dropdown-item--port-label noWrapInfo noselect">
|
||||
{port.addressLabel}
|
||||
{portLabel}
|
||||
</div>
|
||||
</div>
|
||||
{selected ? <div className="fa fa-check" /> : ''}
|
||||
{this.renderActions(item)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderActions(item: BoardListItemUI): React.ReactNode {
|
||||
const { boardList, hide } = this.props;
|
||||
const { revert, edit } = item.otherActions;
|
||||
if (!edit && !revert) {
|
||||
return undefined;
|
||||
}
|
||||
const handleOnClick = (
|
||||
event: React.MouseEvent<HTMLElement, MouseEvent>,
|
||||
callback: () => void
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
hide();
|
||||
callback();
|
||||
};
|
||||
return (
|
||||
<div className={TabBarToolbar.Styles.TAB_BAR_TOOLBAR}>
|
||||
{edit && (
|
||||
<div
|
||||
className={`${TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM} enabled`}
|
||||
>
|
||||
{
|
||||
<div
|
||||
id="edit"
|
||||
className={codicon('pencil', true)}
|
||||
title={nls.localize(
|
||||
'arduino/board/editBoardsConfig',
|
||||
'Edit Board and Port...'
|
||||
)}
|
||||
onClick={(event) =>
|
||||
handleOnClick(event, () => boardList.edit(edit.params))
|
||||
}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{revert && (
|
||||
<div
|
||||
className={`${TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM} enabled`}
|
||||
>
|
||||
{
|
||||
<div
|
||||
id="revert"
|
||||
className={codicon('discard', true)}
|
||||
title={nls.localize(
|
||||
'arduino/board/revertBoardsConfig',
|
||||
"Use '{0}' discovered on '{1}'",
|
||||
boardIdentifierLabel(revert.params.selectedBoard),
|
||||
item.labels.portLabel
|
||||
)}
|
||||
onClick={(event) =>
|
||||
handleOnClick(event, () => boardList.select(revert.params))
|
||||
}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -153,26 +231,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 +259,7 @@ export class BoardsToolBarItem extends React.Component<
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
protected readonly show = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
private readonly show = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
const { currentTarget: element } = event;
|
||||
if (element instanceof HTMLElement) {
|
||||
if (this.state.coords === 'hidden') {
|
||||
@@ -201,31 +280,26 @@ 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 boardLabel =
|
||||
selectedBoard?.name ||
|
||||
nls.localize('arduino/board/selectBoard', 'Select Board');
|
||||
const selectedPortLabel = portLabel(selectedPort?.address);
|
||||
|
||||
const isConnected = Boolean(selectedBoard && selectedPort);
|
||||
const protocolIcon = isConnected
|
||||
? iconNameFromProtocol(selectedPort?.protocol || '')
|
||||
const { coords, boardList } = this.state;
|
||||
const { boardLabel, selected, portProtocol, tooltip } = boardList.labels;
|
||||
const protocolIcon = portProtocol
|
||||
? iconNameFromProtocol(portProtocol)
|
||||
: null;
|
||||
const protocolIconClassNames = classNames(
|
||||
'arduino-boards-toolbar-item--protocol',
|
||||
'fa',
|
||||
protocolIcon
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div
|
||||
className="arduino-boards-toolbar-item-container"
|
||||
title={selectedPortLabel}
|
||||
title={tooltip}
|
||||
onClick={this.show}
|
||||
>
|
||||
{protocolIcon && <div className={protocolIconClassNames} />}
|
||||
@@ -234,57 +308,22 @@ export class BoardsToolBarItem extends React.Component<
|
||||
'arduino-boards-toolbar-item--label',
|
||||
'noWrapInfo',
|
||||
'noselect',
|
||||
{ 'arduino-boards-toolbar-item--label-connected': isConnected }
|
||||
{ 'arduino-boards-toolbar-item--label-connected': selected }
|
||||
)}
|
||||
>
|
||||
{boardLabel}
|
||||
</div>
|
||||
<div className="fa fa-caret-down caret" />
|
||||
</div>
|
||||
<BoardsDropDown
|
||||
<BoardListDropDown
|
||||
coords={coords}
|
||||
items={availableBoards
|
||||
.filter(AvailableBoard.hasPort)
|
||||
.map((board) => ({
|
||||
...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}
|
||||
></BoardsDropDown>
|
||||
boardList={boardList}
|
||||
openBoardsConfig={() => boardList.edit({ query: '' })}
|
||||
hide={this.hide}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
protected openDialog = async (
|
||||
previousBoardConfig?: BoardsConfig.Config
|
||||
): Promise<void> => {
|
||||
const selectedBoardConfig =
|
||||
await this.props.commands.executeCommand<BoardsConfig.Config>(
|
||||
OpenBoardsConfig.Commands.OPEN_DIALOG.id
|
||||
);
|
||||
if (
|
||||
previousBoardConfig &&
|
||||
(!selectedBoardConfig?.selectedPort ||
|
||||
!selectedBoardConfig?.selectedBoard)
|
||||
) {
|
||||
this.props.boardsServiceProvider.boardsConfig = previousBoardConfig;
|
||||
}
|
||||
};
|
||||
}
|
||||
export namespace BoardsToolBarItem {
|
||||
export interface Props {
|
||||
@@ -293,7 +332,7 @@ export namespace BoardsToolBarItem {
|
||||
}
|
||||
|
||||
export interface State {
|
||||
availableBoards: AvailableBoard[];
|
||||
boardList: BoardListUI;
|
||||
coords: BoardsDropDownListCoords | 'hidden';
|
||||
}
|
||||
}
|
||||
@@ -304,19 +343,10 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
function portLabel(portName?: string): string {
|
||||
return portName
|
||||
? nls.localize('arduino/board/portLabel', 'Port: {0}', portName)
|
||||
: nls.localize('arduino/board/disconnected', 'Disconnected');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user