+ {
+
+ handleOnClick(event, () => boardList.edit(edit.params))
+ }
+ />
+ }
+
+ )}
+ {revert && (
+
+ {
+
+ handleOnClick(event, () => boardList.select(revert.params))
+ }
+ />
+ }
+
+ )}
);
}
@@ -113,34 +231,35 @@ 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.boardsServiceClient;
+ 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))
+ );
}
- componentDidMount() {
- this.props.boardsServiceClient.onAvailableBoardsChanged((availableBoards) =>
- this.setState({ availableBoards })
+ override componentDidMount(): void {
+ this.toDispose.push(
+ this.props.boardsServiceProvider.onBoardListDidChange((boardList) =>
+ this.setState({ boardList })
+ )
);
}
- componentWillUnmount(): void {
+ override componentWillUnmount(): void {
this.toDispose.dispose();
}
- protected readonly show = (event: React.MouseEvent
) => {
+ private readonly show = (event: React.MouseEvent): void => {
const { currentTarget: element } = event;
if (element instanceof HTMLElement) {
if (this.state.coords === 'hidden') {
@@ -161,78 +280,73 @@ export class BoardsToolBarItem extends React.Component<
event.nativeEvent.stopImmediatePropagation();
};
- render(): React.ReactNode {
- const { coords, availableBoards } = this.state;
- const boardsConfig = this.props.boardsServiceClient.boardsConfig;
- const title = BoardsConfig.Config.toString(boardsConfig, {
- default: nls.localize(
- 'arduino/common/noBoardSelected',
- 'No board selected'
- ),
- });
- const decorator = (() => {
- const selectedBoard = availableBoards.find(({ selected }) => selected);
- if (!selectedBoard || !selectedBoard.port) {
- return 'fa fa-times notAttached';
- }
- if (selectedBoard.state === AvailableBoard.State.guessed) {
- return 'fa fa-exclamation-triangle guessed';
- }
- return '';
- })();
+ private readonly hide = () => {
+ this.setState({ coords: 'hidden' });
+ };
+ override render(): React.ReactNode {
+ 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 (
-
-
-
+
+ {protocolIcon &&
}
+
+ {boardLabel}
+
-
({
- ...board,
- onClick: () => {
- if (board.state === AvailableBoard.State.incomplete) {
- this.props.boardsServiceClient.boardsConfig = {
- selectedPort: board.port,
- };
- this.openDialog();
- } else {
- this.props.boardsServiceClient.boardsConfig = {
- selectedBoard: board,
- selectedPort: board.port,
- };
- }
- },
- }))}
- openBoardsConfig={this.openDialog}
- >
+ boardList={boardList}
+ openBoardsConfig={() => boardList.edit({ query: '' })}
+ hide={this.hide}
+ />
);
}
-
- protected openDialog = () => {
- this.props.commands.executeCommand(ArduinoCommands.OPEN_BOARDS_DIALOG.id);
- this.setState({ coords: 'hidden' });
- };
}
export namespace BoardsToolBarItem {
export interface Props {
- readonly boardsServiceClient: BoardsServiceProvider;
+ readonly boardsServiceProvider: BoardsServiceProvider;
readonly commands: CommandRegistry;
}
export interface State {
- availableBoards: AvailableBoard[];
+ boardList: BoardListUI;
coords: BoardsDropDownListCoords | 'hidden';
}
}
+
+function iconNameFromProtocol(protocol: string): string {
+ switch (protocol) {
+ case 'serial':
+ return 'fa-arduino-technology-usb';
+ case 'network':
+ return 'fa-arduino-technology-connection';
+ // 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/boards/boards-widget-frontend-contribution.ts b/arduino-ide-extension/src/browser/boards/boards-widget-frontend-contribution.ts
index 21aed8310..c64d08690 100644
--- a/arduino-ide-extension/src/browser/boards/boards-widget-frontend-contribution.ts
+++ b/arduino-ide-extension/src/browser/boards/boards-widget-frontend-contribution.ts
@@ -1,10 +1,17 @@
-import { injectable } from 'inversify';
-import { BoardsListWidget } from './boards-list-widget';
-import { BoardsPackage } from '../../common/protocol/boards-service';
+import { injectable } from '@theia/core/shared/inversify';
+import {
+ BoardSearch,
+ BoardsPackage,
+} from '../../common/protocol/boards-service';
+import { URI } from '../contributions/contribution';
import { ListWidgetFrontendContribution } from '../widgets/component-list/list-widget-frontend-contribution';
+import { BoardsListWidget } from './boards-list-widget';
@injectable()
-export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution
{
+export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution<
+ BoardsPackage,
+ BoardSearch
+> {
constructor() {
super({
widgetId: BoardsListWidget.WIDGET_ID,
@@ -18,7 +25,16 @@ export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendCont
});
}
- async initializeLayout(): Promise {
- this.openView();
+ protected canParse(uri: URI): boolean {
+ try {
+ BoardSearch.UriParser.parse(uri);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ protected parse(uri: URI): BoardSearch | undefined {
+ return BoardSearch.UriParser.parse(uri);
}
}
diff --git a/arduino-ide-extension/src/browser/components/ProgressBar.tsx b/arduino-ide-extension/src/browser/components/ProgressBar.tsx
index f91c9f991..c531cde7e 100644
--- a/arduino-ide-extension/src/browser/components/ProgressBar.tsx
+++ b/arduino-ide-extension/src/browser/components/ProgressBar.tsx
@@ -1,4 +1,4 @@
-import * as React from 'react';
+import React from '@theia/core/shared/react';
export type ProgressBarProps = {
percent?: number;
diff --git a/arduino-ide-extension/src/browser/config/config-service-client.ts b/arduino-ide-extension/src/browser/config/config-service-client.ts
new file mode 100644
index 000000000..ff671da20
--- /dev/null
+++ b/arduino-ide-extension/src/browser/config/config-service-client.ts
@@ -0,0 +1,102 @@
+import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
+import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
+import { DisposableCollection } from '@theia/core/lib/common/disposable';
+import { Emitter, Event } from '@theia/core/lib/common/event';
+import { MessageService } from '@theia/core/lib/common/message-service';
+import { deepClone } from '@theia/core/lib/common/objects';
+import URI from '@theia/core/lib/common/uri';
+import {
+ inject,
+ injectable,
+ postConstruct,
+} from '@theia/core/shared/inversify';
+import { ConfigService, ConfigState } from '../../common/protocol';
+import { NotificationCenter } from '../notification-center';
+
+@injectable()
+export class ConfigServiceClient implements FrontendApplicationContribution {
+ @inject(ConfigService)
+ private readonly delegate: ConfigService;
+ @inject(NotificationCenter)
+ private readonly notificationCenter: NotificationCenter;
+ @inject(FrontendApplicationStateService)
+ private readonly appStateService: FrontendApplicationStateService;
+ @inject(MessageService)
+ private readonly messageService: MessageService;
+
+ private readonly didChangeSketchDirUriEmitter = new Emitter<
+ URI | undefined
+ >();
+ private readonly didChangeDataDirUriEmitter = new Emitter();
+ private readonly toDispose = new DisposableCollection(
+ this.didChangeSketchDirUriEmitter,
+ this.didChangeDataDirUriEmitter
+ );
+
+ private config: ConfigState | undefined;
+
+ @postConstruct()
+ protected init(): void {
+ this.appStateService.reachedState('ready').then(async () => {
+ const config = await this.delegate.getConfiguration();
+ this.use(config);
+ });
+ }
+
+ onStart(): void {
+ this.notificationCenter.onConfigDidChange((config) => this.use(config));
+ }
+
+ onStop(): void {
+ this.toDispose.dispose();
+ }
+
+ get onDidChangeSketchDirUri(): Event {
+ return this.didChangeSketchDirUriEmitter.event;
+ }
+
+ get onDidChangeDataDirUri(): Event {
+ return this.didChangeDataDirUriEmitter.event;
+ }
+
+ /**
+ * CLI config related error messages if any.
+ */
+ tryGetMessages(): string[] | undefined {
+ return this.config?.messages;
+ }
+
+ /**
+ * `directories.user`
+ */
+ tryGetSketchDirUri(): URI | undefined {
+ return this.config?.config?.sketchDirUri
+ ? new URI(this.config?.config?.sketchDirUri)
+ : undefined;
+ }
+
+ /**
+ * `directories.data`
+ */
+ tryGetDataDirUri(): URI | undefined {
+ return this.config?.config?.dataDirUri
+ ? new URI(this.config?.config?.dataDirUri)
+ : undefined;
+ }
+
+ private use(config: ConfigState): void {
+ const oldConfig = deepClone(this.config);
+ this.config = config;
+ if (oldConfig?.config?.sketchDirUri !== this.config?.config?.sketchDirUri) {
+ this.didChangeSketchDirUriEmitter.fire(this.tryGetSketchDirUri());
+ }
+ if (oldConfig?.config?.dataDirUri !== this.config?.config?.dataDirUri) {
+ this.didChangeDataDirUriEmitter.fire(this.tryGetDataDirUri());
+ }
+ if (this.config.messages?.length) {
+ const message = this.config.messages.join(' ');
+ // toast the error later otherwise it might not show up in IDE2
+ setTimeout(() => this.messageService.error(message), 1_000);
+ }
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/about.ts b/arduino-ide-extension/src/browser/contributions/about.ts
index 3f93adba2..201d13b41 100644
--- a/arduino-ide-extension/src/browser/contributions/about.ts
+++ b/arduino-ide-extension/src/browser/contributions/about.ts
@@ -1,34 +1,32 @@
-import { inject, injectable } from 'inversify';
-import * as moment from 'moment';
-import * as remote from '@theia/core/electron-shared/@electron/remote';
-import { isOSX, isWindows } from '@theia/core/lib/common/os';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
+import { nls } from '@theia/core/lib/common/nls';
+import { isOSX, isWindows } from '@theia/core/lib/common/os';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import moment from 'moment';
+import { AppService } from '../app-service';
+import { ArduinoMenus } from '../menu/arduino-menus';
import {
- Contribution,
Command,
- MenuModelRegistry,
CommandRegistry,
+ Contribution,
+ MenuModelRegistry,
} from './contribution';
-import { ArduinoMenus } from '../menu/arduino-menus';
-import { ConfigService } from '../../common/protocol';
-import { nls } from '@theia/core/lib/common';
@injectable()
export class About extends Contribution {
@inject(ClipboardService)
- protected readonly clipboardService: ClipboardService;
-
- @inject(ConfigService)
- protected readonly configService: ConfigService;
+ private readonly clipboardService: ClipboardService;
+ @inject(AppService)
+ private readonly appService: AppService;
- registerCommands(registry: CommandRegistry): void {
+ override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(About.Commands.ABOUT_APP, {
execute: () => this.showAbout(),
});
}
- registerMenus(registry: MenuModelRegistry): void {
+ override registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(ArduinoMenus.HELP__ABOUT_GROUP, {
commandId: About.Commands.ABOUT_APP.id,
label: nls.localize(
@@ -40,23 +38,18 @@ export class About extends Contribution {
});
}
- async showAbout(): Promise {
- const {
- version,
- commit,
- status: cliStatus,
- } = await this.configService.getVersion();
- const buildDate = this.buildDate;
+ private async showAbout(): Promise {
+ const appInfo = await this.appService.info();
+ const { appVersion, cliVersion, buildDate } = appInfo;
+
const detail = (showAll: boolean) =>
nls.localize(
'arduino/about/detail',
- 'Version: {0}\nDate: {1}{2}\nCLI Version: {3}{4} [{5}]\n\n{6}',
- remote.app.getVersion(),
+ 'Version: {0}\nDate: {1}{2}\nCLI Version: {3}\n\n{4}',
+ appVersion,
buildDate ? buildDate : nls.localize('', 'dev build'),
buildDate && showAll ? ` (${this.ago(buildDate)})` : '',
- version,
- cliStatus ? ` ${cliStatus}` : '',
- commit,
+ cliVersion,
nls.localize(
'arduino/about/copyright',
'Copyright © {0} Arduino SA',
@@ -66,34 +59,27 @@ export class About extends Contribution {
const ok = nls.localize('vscode/issueMainService/ok', 'OK');
const copy = nls.localize('vscode/textInputActions/copy', 'Copy');
const buttons = !isWindows && !isOSX ? [copy, ok] : [ok, copy];
- const { response } = await remote.dialog.showMessageBox(
- remote.getCurrentWindow(),
- {
- message: `${this.applicationName}`,
- title: `${this.applicationName}`,
- type: 'info',
- detail: detail(true),
- buttons,
- noLink: true,
- defaultId: buttons.indexOf(ok),
- cancelId: buttons.indexOf(ok),
- }
- );
+ const { response } = await this.dialogService.showMessageBox({
+ message: `${this.applicationName}`,
+ title: `${this.applicationName}`,
+ type: 'info',
+ detail: detail(true),
+ buttons,
+ noLink: true,
+ defaultId: buttons.indexOf(ok),
+ cancelId: buttons.indexOf(ok),
+ });
if (buttons[response] === copy) {
await this.clipboardService.writeText(detail(false).trim());
}
}
- protected get applicationName(): string {
+ private get applicationName(): string {
return FrontendApplicationConfigProvider.get().applicationName;
}
- protected get buildDate(): string | undefined {
- return FrontendApplicationConfigProvider.get().buildDate;
- }
-
- protected ago(isoTime: string): string {
+ private ago(isoTime: string): string {
const now = moment(Date.now());
const other = moment(isoTime);
let result = now.diff(other, 'minute');
diff --git a/arduino-ide-extension/src/browser/contributions/account.ts b/arduino-ide-extension/src/browser/contributions/account.ts
new file mode 100644
index 000000000..a8f728de2
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/account.ts
@@ -0,0 +1,155 @@
+import { FrontendApplication } from '@theia/core/lib/browser/frontend-application';
+import { SidebarMenu } from '@theia/core/lib/browser/shell/sidebar-menu-widget';
+import { WindowService } from '@theia/core/lib/browser/window/window-service';
+import { DisposableCollection } from '@theia/core/lib/common/disposable';
+import { MenuPath } from '@theia/core/lib/common/menu';
+import { nls } from '@theia/core/lib/common/nls';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { CloudUserCommands, LEARN_MORE_URL } from '../auth/cloud-user-commands';
+import { CreateFeatures } from '../create/create-features';
+import { ArduinoMenus } from '../menu/arduino-menus';
+import { ApplicationConnectionStatusContribution } from '../theia/core/connection-status-service';
+import {
+ Command,
+ CommandRegistry,
+ Contribution,
+ MenuModelRegistry,
+} from './contribution';
+
+export const accountMenu: SidebarMenu = {
+ id: 'arduino-accounts-menu',
+ iconClass: 'codicon codicon-account',
+ title: nls.localize('arduino/account/menuTitle', 'Arduino Cloud'),
+ menuPath: ArduinoMenus.ARDUINO_ACCOUNT__CONTEXT,
+ order: 0,
+};
+
+@injectable()
+export class Account extends Contribution {
+ @inject(WindowService)
+ private readonly windowService: WindowService;
+ @inject(CreateFeatures)
+ private readonly createFeatures: CreateFeatures;
+ @inject(ApplicationConnectionStatusContribution)
+ private readonly connectionStatus: ApplicationConnectionStatusContribution;
+
+ private readonly toDispose = new DisposableCollection();
+ private app: FrontendApplication;
+
+ override onStart(app: FrontendApplication): void {
+ this.app = app;
+ this.updateSidebarCommand();
+ this.toDispose.push(
+ this.createFeatures.onDidChangeEnabled((enabled) =>
+ this.updateSidebarCommand(enabled)
+ )
+ );
+ }
+
+ onStop(): void {
+ this.toDispose.dispose();
+ }
+
+ override registerCommands(registry: CommandRegistry): void {
+ const openExternal = (url: string) =>
+ this.windowService.openNewWindow(url, { external: true });
+ const loggedIn = () => Boolean(this.createFeatures.session);
+ const loggedInWithInternetConnection = () =>
+ loggedIn() && this.connectionStatus.offlineStatus !== 'internet';
+ registry.registerCommand(Account.Commands.LEARN_MORE, {
+ execute: () => openExternal(LEARN_MORE_URL),
+ isEnabled: () => !loggedIn(),
+ isVisible: () => !loggedIn(),
+ });
+ registry.registerCommand(Account.Commands.GO_TO_PROFILE, {
+ execute: () => openExternal('https://id.arduino.cc/'),
+ isEnabled: () => loggedInWithInternetConnection(),
+ isVisible: () => loggedIn(),
+ });
+ registry.registerCommand(Account.Commands.GO_TO_CLOUD_EDITOR, {
+ execute: () => openExternal('https://create.arduino.cc/editor'),
+ isEnabled: () => loggedInWithInternetConnection(),
+ isVisible: () => loggedIn(),
+ });
+ registry.registerCommand(Account.Commands.GO_TO_IOT_CLOUD, {
+ execute: () => openExternal('https://create.arduino.cc/iot/'),
+ isEnabled: () => loggedInWithInternetConnection(),
+ isVisible: () => loggedIn(),
+ });
+ }
+
+ override registerMenus(registry: MenuModelRegistry): void {
+ const register = (
+ menuPath: MenuPath,
+ ...commands: (Command | [command: Command, menuLabel: string])[]
+ ) =>
+ commands.forEach((command, index) => {
+ const commandId = Array.isArray(command) ? command[0].id : command.id;
+ const label = Array.isArray(command) ? command[1] : command.label;
+ registry.registerMenuAction(menuPath, {
+ label,
+ commandId,
+ order: String(index),
+ });
+ });
+
+ register(ArduinoMenus.ARDUINO_ACCOUNT__CONTEXT__SIGN_IN_GROUP, [
+ CloudUserCommands.LOGIN,
+ nls.localize('arduino/cloud/signInToCloud', 'Sign in to Arduino Cloud'),
+ ]);
+ register(ArduinoMenus.ARDUINO_ACCOUNT__CONTEXT__LEARN_MORE_GROUP, [
+ Account.Commands.LEARN_MORE,
+ nls.localize('arduino/cloud/learnMore', 'Learn more'),
+ ]);
+ register(
+ ArduinoMenus.ARDUINO_ACCOUNT__CONTEXT__GO_TO_GROUP,
+ [
+ Account.Commands.GO_TO_PROFILE,
+ nls.localize('arduino/account/goToProfile', 'Go to Profile'),
+ ],
+ [
+ Account.Commands.GO_TO_CLOUD_EDITOR,
+ nls.localize('arduino/account/goToCloudEditor', 'Go to Cloud Editor'),
+ ],
+ [
+ Account.Commands.GO_TO_IOT_CLOUD,
+ nls.localize('arduino/account/goToIoTCloud', 'Go to IoT Cloud'),
+ ]
+ );
+ register(
+ ArduinoMenus.ARDUINO_ACCOUNT__CONTEXT__SIGN_OUT_GROUP,
+ CloudUserCommands.LOGOUT
+ );
+ }
+
+ private updateSidebarCommand(
+ visible: boolean = this.preferences['arduino.cloud.enabled']
+ ): void {
+ if (!this.app) {
+ return;
+ }
+ const handler = this.app.shell.leftPanelHandler;
+ if (visible) {
+ handler.addBottomMenu(accountMenu);
+ } else {
+ handler.removeBottomMenu(accountMenu.id);
+ }
+ }
+}
+
+export namespace Account {
+ export namespace Commands {
+ export const GO_TO_PROFILE: Command = {
+ id: 'arduino-go-to-profile',
+ };
+ export const GO_TO_CLOUD_EDITOR: Command = {
+ id: 'arduino-go-to-cloud-editor',
+ };
+ export const GO_TO_IOT_CLOUD: Command = {
+ id: 'arduino-go-to-iot-cloud',
+ };
+ export const LEARN_MORE: Command = {
+ id: 'arduino-learn-more',
+ };
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/add-file.ts b/arduino-ide-extension/src/browser/contributions/add-file.ts
index 94316a1f4..da1796048 100644
--- a/arduino-ide-extension/src/browser/contributions/add-file.ts
+++ b/arduino-ide-extension/src/browser/contributions/add-file.ts
@@ -1,28 +1,29 @@
-import { inject, injectable } from 'inversify';
-import * as remote from '@theia/core/electron-shared/@electron/remote';
+import { nls } from '@theia/core/lib/common/nls';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { FileDialogService } from '@theia/filesystem/lib/browser';
import { ArduinoMenus } from '../menu/arduino-menus';
+import { CurrentSketch } from '../sketches-service-client-impl';
import {
- SketchContribution,
Command,
CommandRegistry,
MenuModelRegistry,
+ Sketch,
+ SketchContribution,
URI,
} from './contribution';
-import { FileDialogService } from '@theia/filesystem/lib/browser';
-import { nls } from '@theia/core/lib/common';
@injectable()
export class AddFile extends SketchContribution {
@inject(FileDialogService)
- protected readonly fileDialogService: FileDialogService;
+ private readonly fileDialogService: FileDialogService; // TODO: use dialogService
- registerCommands(registry: CommandRegistry): void {
+ override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(AddFile.Commands.ADD_FILE, {
execute: () => this.addFile(),
});
}
- registerMenus(registry: MenuModelRegistry): void {
+ override registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(ArduinoMenus.SKETCH__UTILS_GROUP, {
commandId: AddFile.Commands.ADD_FILE.id,
label: nls.localize('arduino/contributions/addFile', 'Add File') + '...',
@@ -30,9 +31,9 @@ export class AddFile extends SketchContribution {
});
}
- protected async addFile(): Promise {
+ private async addFile(): Promise {
const sketch = await this.sketchServiceClient.currentSketch();
- if (!sketch) {
+ if (!CurrentSketch.isValid(sketch)) {
return;
}
const toAddUri = await this.fileDialogService.showOpenDialog({
@@ -40,16 +41,15 @@ export class AddFile extends SketchContribution {
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
+ modal: true,
});
if (!toAddUri) {
return;
}
- const sketchUri = new URI(sketch.uri);
- const filename = toAddUri.path.base;
- const targetUri = sketchUri.resolve('data').resolve(filename);
+ const { uri: targetUri, filename } = this.resolveTarget(sketch, toAddUri);
const exists = await this.fileService.exists(targetUri);
if (exists) {
- const { response } = await remote.dialog.showMessageBox({
+ const { response } = await this.dialogService.showMessageBox({
type: 'question',
title: nls.localize('arduino/contributions/replaceTitle', 'Replace'),
buttons: [
@@ -78,6 +78,22 @@ export class AddFile extends SketchContribution {
}
);
}
+
+ // https://github.com/arduino/arduino-ide/issues/284#issuecomment-1364533662
+ // File the file to add has one of the following extension, it goes to the sketch folder root: .ino, .h, .cpp, .c, .S
+ // Otherwise, the files goes to the `data` folder inside the sketch folder root.
+ private resolveTarget(
+ sketch: Sketch,
+ toAddUri: URI
+ ): { uri: URI; filename: string } {
+ const path = toAddUri.path;
+ const filename = path.base;
+ let root = new URI(sketch.uri);
+ if (!Sketch.Extensions.CODE_FILES.includes(path.ext)) {
+ root = root.resolve('data');
+ }
+ return { uri: root.resolve(filename), filename: filename };
+ }
}
export namespace AddFile {
diff --git a/arduino-ide-extension/src/browser/contributions/add-zip-library.ts b/arduino-ide-extension/src/browser/contributions/add-zip-library.ts
index a03d056f2..b765f9681 100644
--- a/arduino-ide-extension/src/browser/contributions/add-zip-library.ts
+++ b/arduino-ide-extension/src/browser/contributions/add-zip-library.ts
@@ -1,14 +1,9 @@
-import { inject, injectable } from 'inversify';
-import * as remote from '@theia/core/electron-shared/@electron/remote';
+import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { ConfirmDialog } from '@theia/core/lib/browser/dialogs';
-import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { ArduinoMenus } from '../menu/arduino-menus';
-import {
- Installable,
- LibraryService,
- ResponseServiceArduino,
-} from '../../common/protocol';
+import { LibraryService, ResponseServiceClient } from '../../common/protocol';
+import { ExecuteWithProgress } from '../../common/protocol/progressible';
import {
SketchContribution,
Command,
@@ -19,30 +14,23 @@ import { nls } from '@theia/core/lib/common';
@injectable()
export class AddZipLibrary extends SketchContribution {
- @inject(EnvVariablesServer)
- protected readonly envVariableServer: EnvVariablesServer;
-
- @inject(ResponseServiceArduino)
- protected readonly responseService: ResponseServiceArduino;
+ @inject(ResponseServiceClient)
+ private readonly responseService: ResponseServiceClient;
@inject(LibraryService)
- protected readonly libraryService: LibraryService;
+ private readonly libraryService: LibraryService;
- registerCommands(registry: CommandRegistry): void {
+ override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(AddZipLibrary.Commands.ADD_ZIP_LIBRARY, {
execute: () => this.addZipLibrary(),
});
}
- registerMenus(registry: MenuModelRegistry): void {
+ override registerMenus(registry: MenuModelRegistry): void {
const includeLibMenuPath = [
...ArduinoMenus.SKETCH__UTILS_GROUP,
'0_include',
];
- // TODO: do we need it? calling `registerSubmenu` multiple times is noop, so it does not hurt.
- registry.registerSubmenu(includeLibMenuPath, 'Include Library', {
- order: '1',
- });
registry.registerMenuAction([...includeLibMenuPath, '1_install'], {
commandId: AddZipLibrary.Commands.ADD_ZIP_LIBRARY.id,
label: nls.localize('arduino/library/addZip', 'Add .ZIP Library...'),
@@ -50,10 +38,10 @@ export class AddZipLibrary extends SketchContribution {
});
}
- async addZipLibrary(): Promise {
+ private async addZipLibrary(): Promise {
const homeUri = await this.envVariableServer.getHomeDirUri();
const defaultPath = await this.fileService.fsPath(new URI(homeUri));
- const { canceled, filePaths } = await remote.dialog.showOpenDialog({
+ const { canceled, filePaths } = await this.dialogService.showOpenDialog({
title: nls.localize(
'arduino/selectZip',
"Select a zip file containing the library you'd like to add"
@@ -92,7 +80,7 @@ export class AddZipLibrary extends SketchContribution {
private async doInstall(zipUri: string, overwrite?: boolean): Promise {
try {
- await Installable.doWithProgress({
+ await ExecuteWithProgress.doWithProgress({
messageService: this.messageService,
progressText:
nls.localize('arduino/common/processing', 'Processing') +
diff --git a/arduino-ide-extension/src/browser/contributions/archive-sketch.ts b/arduino-ide-extension/src/browser/contributions/archive-sketch.ts
index 2ab62dc22..f49f85caf 100644
--- a/arduino-ide-extension/src/browser/contributions/archive-sketch.ts
+++ b/arduino-ide-extension/src/browser/contributions/archive-sketch.ts
@@ -1,7 +1,5 @@
-import { injectable } from 'inversify';
-import * as remote from '@theia/core/electron-shared/@electron/remote';
-import * as dateFormat from 'dateformat';
-import URI from '@theia/core/lib/common/uri';
+import { injectable } from '@theia/core/shared/inversify';
+import dateFormat from 'dateformat';
import { ArduinoMenus } from '../menu/arduino-menus';
import {
SketchContribution,
@@ -10,16 +8,17 @@ import {
MenuModelRegistry,
} from './contribution';
import { nls } from '@theia/core/lib/common';
+import { CurrentSketch } from '../sketches-service-client-impl';
@injectable()
export class ArchiveSketch extends SketchContribution {
- registerCommands(registry: CommandRegistry): void {
+ override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(ArchiveSketch.Commands.ARCHIVE_SKETCH, {
execute: () => this.archiveSketch(),
});
}
- registerMenus(registry: MenuModelRegistry): void {
+ override registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(ArduinoMenus.TOOLS__MAIN_GROUP, {
commandId: ArchiveSketch.Commands.ARCHIVE_SKETCH.id,
label: nls.localize('arduino/sketch/archiveSketch', 'Archive Sketch'),
@@ -27,22 +26,19 @@ export class ArchiveSketch extends SketchContribution {
});
}
- protected async archiveSketch(): Promise {
- const [sketch, config] = await Promise.all([
- this.sketchServiceClient.currentSketch(),
- this.configService.getConfiguration(),
- ]);
- if (!sketch) {
+ private async archiveSketch(): Promise {
+ const sketch = await this.sketchServiceClient.currentSketch();
+ if (!CurrentSketch.isValid(sketch)) {
return;
}
const archiveBasename = `${sketch.name}-${dateFormat(
new Date(),
'yymmdd'
)}a.zip`;
- const defaultPath = await this.fileService.fsPath(
- new URI(config.sketchDirUri).resolve(archiveBasename)
- );
- const { filePath, canceled } = await remote.dialog.showSaveDialog({
+ const defaultContainerUri = await this.defaultUri();
+ const defaultUri = defaultContainerUri.resolve(archiveBasename);
+ const defaultPath = await this.fileService.fsPath(defaultUri);
+ const { filePath, canceled } = await this.dialogService.showSaveDialog({
title: nls.localize(
'arduino/sketch/saveSketchAs',
'Save sketch folder as...'
@@ -56,7 +52,7 @@ export class ArchiveSketch extends SketchContribution {
if (!destinationUri) {
return;
}
- await this.sketchService.archive(sketch, destinationUri.toString());
+ await this.sketchesService.archive(sketch, destinationUri.toString());
this.messageService.info(
nls.localize(
'arduino/sketch/createdArchive',
diff --git a/arduino-ide-extension/src/browser/contributions/auto-select-programmer.ts b/arduino-ide-extension/src/browser/contributions/auto-select-programmer.ts
new file mode 100644
index 000000000..0bf8e277e
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/auto-select-programmer.ts
@@ -0,0 +1,123 @@
+import type { MaybePromise } from '@theia/core/lib/common/types';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import {
+ BoardDetails,
+ Programmer,
+ isBoardIdentifierChangeEvent,
+} from '../../common/protocol';
+import {
+ BoardsDataStore,
+ findDefaultProgrammer,
+ isEmptyData,
+} from '../boards/boards-data-store';
+import { BoardsServiceProvider } from '../boards/boards-service-provider';
+import { Contribution } from './contribution';
+
+/**
+ * Before CLI 0.35.0-rc.3, there was no `programmer#default` property in the `board details` response.
+ * This method does the programmer migration in the data store. If there is a programmer selected, it's a noop.
+ * If no programmer is selected, it forcefully reloads the details from the CLI and updates it in the local storage.
+ */
+@injectable()
+export class AutoSelectProgrammer extends Contribution {
+ @inject(BoardsServiceProvider)
+ private readonly boardsServiceProvider: BoardsServiceProvider;
+ @inject(BoardsDataStore)
+ private readonly boardsDataStore: BoardsDataStore;
+
+ override onStart(): void {
+ this.boardsServiceProvider.onBoardsConfigDidChange((event) => {
+ if (isBoardIdentifierChangeEvent(event)) {
+ this.ensureProgrammerIsSelected();
+ }
+ });
+ }
+
+ override onReady(): void {
+ this.boardsServiceProvider.ready.then(() =>
+ this.ensureProgrammerIsSelected()
+ );
+ }
+
+ private async ensureProgrammerIsSelected(): Promise {
+ return ensureProgrammerIsSelected({
+ fqbn: this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn,
+ getData: (fqbn) => this.boardsDataStore.getData(fqbn),
+ loadBoardDetails: (fqbn) => this.boardsDataStore.loadBoardDetails(fqbn),
+ selectProgrammer: (arg) => this.boardsDataStore.selectProgrammer(arg),
+ });
+ }
+}
+
+interface EnsureProgrammerIsSelectedParams {
+ fqbn: string | undefined;
+ getData: (fqbn: string | undefined) => MaybePromise;
+ loadBoardDetails: (fqbn: string) => MaybePromise;
+ selectProgrammer(options: {
+ fqbn: string;
+ selectedProgrammer: Programmer;
+ }): MaybePromise;
+}
+
+export async function ensureProgrammerIsSelected(
+ params: EnsureProgrammerIsSelectedParams
+): Promise {
+ const { fqbn, getData, loadBoardDetails, selectProgrammer } = params;
+ if (!fqbn) {
+ return false;
+ }
+ console.debug(`Ensuring a programmer is selected for ${fqbn}...`);
+ const data = await getData(fqbn);
+ if (isEmptyData(data)) {
+ // For example, the platform is not installed.
+ console.debug(`Skipping. No boards data is available for ${fqbn}.`);
+ return false;
+ }
+ if (data.selectedProgrammer) {
+ console.debug(
+ `A programmer is already selected for ${fqbn}: '${data.selectedProgrammer.id}'.`
+ );
+ return true;
+ }
+ let programmer = findDefaultProgrammer(data.programmers, data);
+ if (programmer) {
+ // select the programmer if the default info is available
+ const result = await selectProgrammer({
+ fqbn,
+ selectedProgrammer: programmer,
+ });
+ if (result) {
+ console.debug(`Selected '${programmer.id}' programmer for ${fqbn}.`);
+ return result;
+ }
+ }
+ console.debug(`Reloading board details for ${fqbn}...`);
+ const reloadedData = await loadBoardDetails(fqbn);
+ if (!reloadedData) {
+ console.debug(`Skipping. No board details found for ${fqbn}.`);
+ return false;
+ }
+ if (!reloadedData.programmers.length) {
+ console.debug(`Skipping. ${fqbn} does not have programmers.`);
+ return false;
+ }
+ programmer = findDefaultProgrammer(reloadedData.programmers, reloadedData);
+ if (!programmer) {
+ console.debug(
+ `Skipping. Could not find a default programmer for ${fqbn}. Programmers were: `
+ );
+ return false;
+ }
+ const result = await selectProgrammer({
+ fqbn,
+ selectedProgrammer: programmer,
+ });
+ if (result) {
+ console.debug(`Selected '${programmer.id}' programmer for ${fqbn}.`);
+ } else {
+ console.debug(
+ `Could not select '${programmer.id}' programmer for ${fqbn}.`
+ );
+ }
+ return result;
+}
diff --git a/arduino-ide-extension/src/browser/contributions/board-selection.ts b/arduino-ide-extension/src/browser/contributions/board-selection.ts
index 9dc085fbf..f9c2d2b36 100644
--- a/arduino-ide-extension/src/browser/contributions/board-selection.ts
+++ b/arduino-ide-extension/src/browser/contributions/board-selection.ts
@@ -1,133 +1,137 @@
-import { inject, injectable } from 'inversify';
-import * as remote from '@theia/core/electron-shared/@electron/remote';
-import { MenuModelRegistry } from '@theia/core/lib/common/menu';
import {
- DisposableCollection,
Disposable,
+ DisposableCollection,
} from '@theia/core/lib/common/disposable';
-import { firstToUpperCase } from '../../common/utils';
-import { BoardsConfig } from '../boards/boards-config';
+import { MenuModelRegistry } from '@theia/core/lib/common/menu/menu-model-registry';
+import type { MenuPath } from '@theia/core/lib/common/menu/menu-types';
+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,
+ BoardWithPackage,
+ createPlatformIdentifier,
+ getBoardInfo,
+ InstalledBoardWithPackage,
+ platformIdentifierEquals,
+ Port,
+ serializePlatformIdentifier,
+} from '../../common/protocol';
+import type { BoardList } from '../../common/protocol/board-list';
import { BoardsListWidget } from '../boards/boards-list-widget';
-import { NotificationCenter } from '../notification-center';
+import { BoardsDataStore } from '../boards/boards-data-store';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import {
ArduinoMenus,
PlaceholderMenuNode,
unregisterSubmenu,
} from '../menu/arduino-menus';
-import {
- BoardsService,
- InstalledBoardWithPackage,
- AvailablePorts,
- Port,
-} 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(BoardsDataStore)
+ private readonly boardsDataStore: BoardsDataStore;
@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;
- registerCommands(registry: CommandRegistry): void {
+ override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(BoardSelection.Commands.GET_BOARD_INFO, {
execute: async () => {
- const { selectedBoard, selectedPort } =
- this.boardsServiceProvider.boardsConfig;
- if (!selectedBoard) {
- this.messageService.info(
- nls.localize(
- 'arduino/board/selectBoardForInfo',
- 'Please select a board to obtain board info.'
- )
- );
+ const boardInfo = await getBoardInfo(
+ this.boardsServiceProvider.boardList
+ );
+ if (typeof boardInfo === 'string') {
+ this.messageService.info(boardInfo);
return;
}
- if (!selectedBoard.fqbn) {
- this.messageService.info(
- nls.localize(
- 'arduino/board/platformMissing',
- "The platform for the selected '{0}' board is not installed.",
- selectedBoard.name
- )
+ const { BN, VID, PID, SN } = boardInfo;
+ const detail = `
+BN: ${BN}
+VID: ${VID}
+PID: ${PID}
+SN: ${SN}
+`.trim();
+ await this.dialogService.showMessageBox({
+ message: nls.localize('arduino/board/boardInfo', 'Board Info'),
+ title: nls.localize('arduino/board/boardInfo', 'Board Info'),
+ type: 'info',
+ detail,
+ buttons: [nls.localize('vscode/issueMainService/ok', 'OK')],
+ });
+ },
+ });
+
+ registry.registerCommand(BoardSelection.Commands.RELOAD_BOARD_DATA, {
+ execute: async () => {
+ const selectedFqbn =
+ this.boardsServiceProvider.boardList.boardsConfig.selectedBoard?.fqbn;
+ let message: string;
+
+ if (selectedFqbn) {
+ await this.boardsDataStore.reloadBoardData(selectedFqbn);
+ message = nls.localize(
+ 'arduino/board/boardDataReloaded',
+ 'Board data reloaded.'
);
- return;
- }
- if (!selectedPort) {
- this.messageService.info(
- nls.localize(
- 'arduino/board/selectPortForInfo',
- 'Please select a port to obtain board info.'
- )
+ } else {
+ message = nls.localize(
+ 'arduino/board/selectBoardToReload',
+ 'Please select a board first.'
);
- return;
- }
- const boardDetails = await this.boardsService.getBoardDetails({
- fqbn: selectedBoard.fqbn,
- });
- if (boardDetails) {
- const { VID, PID } = boardDetails;
- const detail = `BN: ${selectedBoard.name}
-VID: ${VID}
-PID: ${PID}`;
- await remote.dialog.showMessageBox(remote.getCurrentWindow(), {
- message: nls.localize('arduino/board/boardInfo', 'Board Info'),
- title: nls.localize('arduino/board/boardInfo', 'Board Info'),
- type: 'info',
- detail,
- buttons: [nls.localize('vscode/issueMainService/ok', 'OK')],
- });
}
+
+ this.messageService.info(message, { timeout: 2000 });
},
});
}
- onStart(): void {
- this.updateMenus();
- this.notificationCenter.onPlatformInstalled(this.updateMenus.bind(this));
- this.notificationCenter.onPlatformUninstalled(this.updateMenus.bind(this));
- this.boardsServiceProvider.onBoardsConfigChanged(
- this.updateMenus.bind(this)
- );
- this.boardsServiceProvider.onAvailableBoardsChanged(
- this.updateMenus.bind(this)
- );
- this.boardsServiceProvider.onAvailablePortsChanged(
- this.updateMenus.bind(this)
+ override onStart(): void {
+ 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();
@@ -136,7 +140,8 @@ PID: ${PID}`;
...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP,
'1_boards',
];
- const boardsSubmenuLabel = config.selectedBoard?.name;
+ const { selectedBoard, selectedPort } = boardList.boardsConfig;
+ const boardsSubmenuLabel = 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(
@@ -155,11 +160,8 @@ PID: ${PID}`;
);
// Ports submenu
- const portsSubmenuPath = [
- ...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP,
- '2_ports',
- ];
- const portsSubmenuLabel = config.selectedPort?.address;
+ const portsSubmenuPath = ArduinoMenus.TOOLS__PORTS_SUBMENU;
+ const portsSubmenuLabel = selectedPort?.address;
this.menuModelRegistry.registerSubmenu(
portsSubmenuPath,
nls.localize(
@@ -175,6 +177,21 @@ PID: ${PID}`;
)
);
+ const reloadBoardData = {
+ commandId: BoardSelection.Commands.RELOAD_BOARD_DATA.id,
+ label: nls.localize('arduino/board/reloadBoardData', 'Reload Board Data'),
+ order: '102',
+ };
+ this.menuModelRegistry.registerMenuAction(
+ ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP,
+ reloadBoardData
+ );
+ this.toDisposeBeforeMenuRebuild.push(
+ Disposable.create(() =>
+ this.menuModelRegistry.unregisterMenuAction(reloadBoardData)
+ )
+ );
+
const getBoardInfo = {
commandId: BoardSelection.Commands.GET_BOARD_INFO.id,
label: nls.localize('arduino/board/getBoardInfo', 'Get Board Info'),
@@ -198,64 +215,116 @@ PID: ${PID}`;
label: `${BoardsListWidget.WIDGET_LABEL}...`,
});
- // Installed boards
+ const selectedBoardPlatformId = selectedBoard
+ ? createPlatformIdentifier(selectedBoard)
+ : undefined;
+
+ // Keys are the vendor IDs
+ type BoardsPerVendor = Record;
+ // Group boards by their platform names. The keys are the platform names as menu labels.
+ // If there is a platform name (menu label) collision, refine the menu label with the vendor ID.
+ const groupedBoards = new Map();
for (const board of installedBoards) {
- const { packageId, packageName, fqbn, name, manuallyInstalled } = board;
+ const { packageId, packageName } = board;
+ const { vendorId } = packageId;
+ let boardsPerPackageName = groupedBoards.get(packageName);
+ if (!boardsPerPackageName) {
+ boardsPerPackageName = {} as BoardsPerVendor;
+ groupedBoards.set(packageName, boardsPerPackageName);
+ }
+ let boardPerVendor: BoardWithPackage[] | undefined =
+ boardsPerPackageName[vendorId];
+ if (!boardPerVendor) {
+ boardPerVendor = [];
+ boardsPerPackageName[vendorId] = boardPerVendor;
+ }
+ boardPerVendor.push(board);
+ }
- const packageLabel =
- packageName +
- `${manuallyInstalled
- ? nls.localize('arduino/board/inSketchbook', ' (in Sketchbook)')
- : ''
- }`;
- // Platform submenu
- const platformMenuPath = [...boardsPackagesGroup, packageId];
- // Note: Registering the same submenu twice is a noop. No need to group the boards per platform.
- this.menuModelRegistry.registerSubmenu(platformMenuPath, packageLabel, {
- order: packageName.toLowerCase(),
- });
+ // Installed boards
+ Array.from(groupedBoards.entries()).forEach(
+ ([packageName, boardsPerPackage]) => {
+ const useVendorSuffix = Object.keys(boardsPerPackage).length > 1;
+ Object.entries(boardsPerPackage).forEach(([vendorId, boards]) => {
+ let platformMenuPath: MenuPath | undefined = undefined;
+ boards.forEach((board, index) => {
+ const { packageId, fqbn, name, manuallyInstalled } = board;
+ // create the platform submenu once.
+ // creating and registering the same submenu twice in Theia is a noop, though.
+ if (!platformMenuPath) {
+ let packageLabel =
+ packageName +
+ `${
+ manuallyInstalled
+ ? nls.localize(
+ 'arduino/board/inSketchbook',
+ ' (in Sketchbook)'
+ )
+ : ''
+ }`;
+ if (
+ selectedBoardPlatformId &&
+ platformIdentifierEquals(packageId, selectedBoardPlatformId)
+ ) {
+ packageLabel = `● ${packageLabel}`;
+ }
+ if (useVendorSuffix) {
+ packageLabel += ` (${vendorId})`;
+ }
+ // Platform submenu
+ platformMenuPath = [
+ ...boardsPackagesGroup,
+ serializePlatformIdentifier(packageId),
+ ];
+ this.menuModelRegistry.registerSubmenu(
+ platformMenuPath,
+ packageLabel,
+ {
+ order: packageName.toLowerCase(),
+ }
+ );
+ }
- 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,
+ const id = `arduino-select-board--${fqbn}`;
+ const command = { id };
+ const handler = {
+ execute: () =>
+ this.boardsServiceProvider.updateConfig({
+ name: name,
+ fqbn: fqbn,
+ }),
+ isToggled: () => fqbn === selectedBoard?.fqbn,
};
- }
- },
- isToggled: () =>
- fqbn === this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn,
- };
- // Board menu
- const menuAction = { commandId: id, label: name };
- this.commandRegistry.registerCommand(command, handler);
- this.toDisposeBeforeMenuRebuild.push(
- Disposable.create(() => this.commandRegistry.unregisterCommand(command))
- );
- this.menuModelRegistry.registerMenuAction(platformMenuPath, menuAction);
- // Note: we do not dispose the menu actions individually. Calling `unregisterSubmenu` on the parent will wipe the children menu nodes recursively.
- }
+ // Board menu
+ const menuAction = {
+ commandId: id,
+ label: name,
+ order: String(index).padStart(4), // pads with leading zeros for alphanumeric sort where order is 1, 2, 11, and NOT 1, 11, 2
+ };
+ this.commandRegistry.registerCommand(command, handler);
+ this.toDisposeBeforeMenuRebuild.push(
+ Disposable.create(() =>
+ this.commandRegistry.unregisterCommand(command)
+ )
+ );
+ this.menuModelRegistry.registerMenuAction(
+ platformMenuPath,
+ menuAction
+ );
+ // 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;
}
@@ -266,8 +335,12 @@ PID: ${PID}`;
];
const placeholder = new PlaceholderMenuNode(
menuPath,
- `${firstToUpperCase(protocol)} ports`,
- { order: protocolOrder.toString() }
+ nls.localize(
+ 'arduino/board/typeOfPorts',
+ '{0} ports',
+ Port.Protocols.protocolLabel(protocol)
+ ),
+ { order: protocolOrder.toString().padStart(4) }
);
this.menuModelRegistry.registerMenuNode(menuPath, placeholder);
this.toDisposeBeforeMenuRebuild.push(
@@ -276,49 +349,31 @@ PID: ${PID}`;
)
);
- // 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];
- let label = `${port.address}`;
- if (boards.length) {
+ 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) {
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.updateConfig({
+ protocol: port.protocol,
+ address: port.address,
+ });
+ },
+ isToggled: () => {
+ return i === ports.matchingIndex;
},
- isToggled: () =>
- Port.sameAs(
- port,
- this.boardsServiceProvider.boardsConfig.selectedPort
- ),
};
const menuAction = {
commandId: id,
label,
- order: `${protocolOrder + i + 1}`,
+ order: String(protocolOrder + i + 1).padStart(4),
};
this.commandRegistry.registerCommand(command, handler);
this.toDisposeBeforeMenuRebuild.push(
@@ -330,32 +385,25 @@ PID: ${PID}`;
}
};
- const grouped = AvailablePorts.byProtocol(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;
- }
- });
- grouped.forEach((ports, protocol) => {
- registerPorts(protocol, protocolOrder, ports);
- protocolOrder = protocolOrder + 100;
+ Object.entries(groupedPorts).forEach(([protocol, ports]) => {
+ registerPorts(protocol, ports, protocolOrder);
+ protocolOrder += 100;
});
-
this.mainMenuManager.update();
}
protected async installedBoards(): Promise {
- const allBoards = await this.boardsService.searchBoards({});
+ const allBoards = await this.boardsService.getInstalledBoards();
return allBoards.filter(InstalledBoardWithPackage.is);
}
}
export namespace BoardSelection {
export namespace Commands {
export const GET_BOARD_INFO: Command = { id: 'arduino-get-board-info' };
+ export const RELOAD_BOARD_DATA: Command = {
+ id: 'arduino-reload-board-data',
+ };
}
}
diff --git a/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts b/arduino-ide-extension/src/browser/contributions/boards-data-menu-updater.ts
similarity index 68%
rename from arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts
rename to arduino-ide-extension/src/browser/contributions/boards-data-menu-updater.ts
index 5447427de..382e0f2ef 100644
--- a/arduino-ide-extension/src/browser/boards/boards-data-menu-updater.ts
+++ b/arduino-ide-extension/src/browser/contributions/boards-data-menu-updater.ts
@@ -1,57 +1,66 @@
-import * as PQueue from 'p-queue';
-import { inject, injectable } from '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 { nls } from '@theia/core/lib/common/nls';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import PQueue from 'p-queue';
+import {
+ BoardIdentifier,
+ ConfigOption,
+ isBoardIdentifierChangeEvent,
+ Programmer,
+} from '../../common/protocol';
+import { BoardsDataStore } from '../boards/boards-data-store';
+import { BoardsServiceProvider } from '../boards/boards-service-provider';
import { ArduinoMenus, unregisterSubmenu } from '../menu/arduino-menus';
-import { nls } from '@theia/core/lib/common';
+import {
+ CommandRegistry,
+ Contribution,
+ MenuModelRegistry,
+} from './contribution';
@injectable()
-export class BoardsDataMenuUpdater implements FrontendApplicationContribution {
+export class BoardsDataMenuUpdater extends Contribution {
@inject(CommandRegistry)
- protected readonly commandRegistry: CommandRegistry;
-
+ private readonly commandRegistry: CommandRegistry;
@inject(MenuModelRegistry)
- protected readonly menuRegistry: MenuModelRegistry;
-
- @inject(MainMenuManager)
- protected readonly mainMenuManager: MainMenuManager;
-
+ private readonly menuRegistry: MenuModelRegistry;
@inject(BoardsDataStore)
- protected readonly boardsDataStore: BoardsDataStore;
-
+ private readonly boardsDataStore: BoardsDataStore;
@inject(BoardsServiceProvider)
- protected readonly boardsServiceClient: BoardsServiceProvider;
+ private readonly boardsServiceProvider: BoardsServiceProvider;
- protected readonly queue = new PQueue({ autoStart: true, concurrency: 1 });
- protected readonly toDisposeOnBoardChange = new DisposableCollection();
+ private readonly queue = new PQueue({ autoStart: true, concurrency: 1 });
+ private readonly toDisposeOnBoardChange = new DisposableCollection();
- async onStart(): Promise {
- this.updateMenuActions(this.boardsServiceClient.boardsConfig.selectedBoard);
- this.boardsDataStore.onChanged(() =>
+ override onStart(): void {
+ this.boardsDataStore.onDidChange(() =>
this.updateMenuActions(
- this.boardsServiceClient.boardsConfig.selectedBoard
+ this.boardsServiceProvider.boardsConfig.selectedBoard
)
);
- this.boardsServiceClient.onBoardsConfigChanged(({ selectedBoard }) =>
- this.updateMenuActions(selectedBoard)
+ this.boardsServiceProvider.onBoardsConfigDidChange((event) => {
+ if (isBoardIdentifierChangeEvent(event)) {
+ this.updateMenuActions(event.selectedBoard);
+ }
+ });
+ }
+
+ override onReady(): void {
+ this.boardsServiceProvider.ready.then(() =>
+ this.updateMenuActions(
+ this.boardsServiceProvider.boardsConfig.selectedBoard
+ )
);
}
- protected async updateMenuActions(
- selectedBoard: Board | undefined
+ private async updateMenuActions(
+ selectedBoard: BoardIdentifier | undefined
): Promise {
return this.queue.add(async () => {
this.toDisposeOnBoardChange.dispose();
- this.mainMenuManager.update();
+ this.menuManager.update();
if (selectedBoard) {
const { fqbn } = selectedBoard;
if (fqbn) {
@@ -70,16 +79,15 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution {
string,
Disposable & { label: string }
>();
+ let selectedValue = '';
for (const value of values) {
const id = `${fqbn}-${option}--${value.value}`;
const command = { id };
- const selectedValue = value.value;
const handler = {
execute: () =>
this.boardsDataStore.selectConfigOption({
fqbn,
- option,
- selectedValue,
+ optionsToUpdate: [{ option, selectedValue: value.value }],
}),
isToggled: () => value.selected,
};
@@ -90,8 +98,14 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution {
{ label: value.label }
)
);
+ if (value.selected) {
+ selectedValue = value.label;
+ }
}
- this.menuRegistry.registerSubmenu(menuPath, label);
+ this.menuRegistry.registerSubmenu(
+ menuPath,
+ `${label}${selectedValue ? `: "${selectedValue}"` : ''}`
+ );
this.toDisposeOnBoardChange.pushAll([
...commands.values(),
Disposable.create(() =>
@@ -101,7 +115,7 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution {
const { label } = commands.get(commandId)!;
this.menuRegistry.registerMenuAction(menuPath, {
commandId,
- order: `${i}`,
+ order: String(i).padStart(4),
label,
});
return Disposable.create(() =>
@@ -156,7 +170,7 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution {
]);
}
}
- this.mainMenuManager.update();
+ this.menuManager.update();
}
}
});
diff --git a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts
index 75aaef8fa..e951ac2f9 100644
--- a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts
+++ b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts
@@ -1,42 +1,23 @@
-import { inject, injectable } from 'inversify';
-import { OutputChannelManager } from '@theia/output/lib/browser/output-channel';
+import { nls } from '@theia/core/lib/common';
+import { injectable } from '@theia/core/shared/inversify';
import { CoreService } from '../../common/protocol';
import { ArduinoMenus } from '../menu/arduino-menus';
-import { BoardsDataStore } from '../boards/boards-data-store';
-import { SerialConnectionManager } from '../serial/serial-connection-manager';
-import { BoardsServiceProvider } from '../boards/boards-service-provider';
import {
- SketchContribution,
Command,
CommandRegistry,
+ CoreServiceContribution,
MenuModelRegistry,
} from './contribution';
-import { nls } from '@theia/core/lib/common';
@injectable()
-export class BurnBootloader extends SketchContribution {
- @inject(CoreService)
- protected readonly coreService: CoreService;
-
- @inject(SerialConnectionManager)
- protected readonly serialConnection: SerialConnectionManager;
-
- @inject(BoardsDataStore)
- protected readonly boardsDataStore: BoardsDataStore;
-
- @inject(BoardsServiceProvider)
- protected readonly boardsServiceClientImpl: BoardsServiceProvider;
-
- @inject(OutputChannelManager)
- protected readonly outputChannelManager: OutputChannelManager;
-
- registerCommands(registry: CommandRegistry): void {
+export class BurnBootloader extends CoreServiceContribution {
+ override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(BurnBootloader.Commands.BURN_BOOTLOADER, {
execute: () => this.burnBootloader(),
});
}
- registerMenus(registry: MenuModelRegistry): void {
+ override registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(ArduinoMenus.TOOLS__BOARD_SETTINGS_GROUP, {
commandId: BurnBootloader.Commands.BURN_BOOTLOADER.id,
label: nls.localize(
@@ -47,26 +28,24 @@ export class BurnBootloader extends SketchContribution {
});
}
- async burnBootloader(): Promise {
+ private async burnBootloader(): Promise {
+ this.clearVisibleNotification();
+ const options = await this.options();
try {
- const { boardsConfig } = this.boardsServiceClientImpl;
- const port = boardsConfig.selectedPort;
- const [fqbn, { selectedProgrammer: programmer }, verify, verbose] =
- await Promise.all([
- this.boardsDataStore.appendConfigToFqbn(
- boardsConfig.selectedBoard?.fqbn
+ await this.doWithProgress({
+ progressText: nls.localize(
+ 'arduino/bootloader/burningBootloader',
+ 'Burning bootloader...'
+ ),
+ task: (progressId, coreService, token) =>
+ coreService.burnBootloader(
+ {
+ ...options,
+ progressId,
+ },
+ token
),
- this.boardsDataStore.getData(boardsConfig.selectedBoard?.fqbn),
- this.preferences.get('arduino.upload.verify'),
- this.preferences.get('arduino.upload.verbose'),
- ]);
- this.outputChannelManager.getChannel('Arduino').clear();
- await this.coreService.burnBootloader({
- fqbn,
- programmer,
- port,
- verify,
- verbose,
+ cancelable: true,
});
this.messageService.info(
nls.localize(
@@ -78,17 +57,30 @@ export class BurnBootloader extends SketchContribution {
}
);
} catch (e) {
- let errorMessage = "";
- if (typeof e === "string") {
- errorMessage = e;
- } else {
- errorMessage = e.toString();
- }
- this.messageService.error(errorMessage);
- } finally {
- await this.serialConnection.reconnectAfterUpload();
+ this.handleError(e);
}
}
+
+ private async options(): Promise {
+ const { boardsConfig } = this.boardsServiceProvider;
+ const port = boardsConfig.selectedPort;
+ const [fqbn, { selectedProgrammer: programmer }, verify, verbose] =
+ await Promise.all([
+ this.boardsDataStore.appendConfigToFqbn(
+ boardsConfig.selectedBoard?.fqbn
+ ),
+ this.boardsDataStore.getData(boardsConfig.selectedBoard?.fqbn),
+ this.preferences.get('arduino.upload.verify'),
+ this.preferences.get('arduino.upload.verbose'),
+ ]);
+ return {
+ fqbn,
+ programmer,
+ port,
+ verify,
+ verbose,
+ };
+ }
}
export namespace BurnBootloader {
diff --git a/arduino-ide-extension/src/browser/contributions/check-for-ide-updates.ts b/arduino-ide-extension/src/browser/contributions/check-for-ide-updates.ts
new file mode 100644
index 000000000..a2f76d15f
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/check-for-ide-updates.ts
@@ -0,0 +1,123 @@
+import { nls } from '@theia/core/lib/common/nls';
+import { LocalStorageService } from '@theia/core/lib/browser/storage-service';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import {
+ IDEUpdater,
+ LAST_USED_IDE_VERSION,
+ SKIP_IDE_VERSION,
+} from '../../common/protocol/ide-updater';
+import { IDEUpdaterDialog } from '../dialogs/ide-updater/ide-updater-dialog';
+import { Contribution } from './contribution';
+import { VersionWelcomeDialog } from '../dialogs/version-welcome-dialog';
+import { AppService } from '../app-service';
+import { SemVer } from 'semver';
+
+@injectable()
+export class CheckForIDEUpdates extends Contribution {
+ @inject(IDEUpdater)
+ private readonly updater: IDEUpdater;
+
+ @inject(IDEUpdaterDialog)
+ private readonly updaterDialog: IDEUpdaterDialog;
+
+ @inject(VersionWelcomeDialog)
+ private readonly versionWelcomeDialog: VersionWelcomeDialog;
+
+ @inject(LocalStorageService)
+ private readonly localStorage: LocalStorageService;
+
+ @inject(AppService)
+ private readonly appService: AppService;
+
+ override onStart(): void {
+ this.preferences.onPreferenceChanged(
+ ({ preferenceName, newValue, oldValue }) => {
+ if (newValue !== oldValue) {
+ switch (preferenceName) {
+ case 'arduino.ide.updateChannel':
+ case 'arduino.ide.updateBaseUrl':
+ this.updater.init(
+ this.preferences.get('arduino.ide.updateChannel'),
+ this.preferences.get('arduino.ide.updateBaseUrl')
+ );
+ }
+ }
+ }
+ );
+ }
+
+ override async onReady(): Promise {
+ this.updater
+ .init(
+ this.preferences.get('arduino.ide.updateChannel'),
+ this.preferences.get('arduino.ide.updateBaseUrl')
+ )
+ .then(() => {
+ if (!this.preferences['arduino.checkForUpdates']) {
+ return;
+ }
+ return this.updater.checkForUpdates(true);
+ })
+ .then(async (updateInfo) => {
+ if (!updateInfo) {
+ const isNewVersion = await this.isNewStableVersion();
+ if (isNewVersion) {
+ this.versionWelcomeDialog.open();
+ }
+ return;
+ }
+ const versionToSkip = await this.localStorage.getData(
+ SKIP_IDE_VERSION
+ );
+ if (versionToSkip === updateInfo.version) return;
+ this.updaterDialog.open(true, updateInfo);
+ })
+ .catch((e) => {
+ this.messageService.error(
+ nls.localize(
+ 'arduino/ide-updater/errorCheckingForUpdates',
+ 'Error while checking for Arduino IDE updates.\n{0}',
+ e.message
+ )
+ );
+ })
+ .finally(() => {
+ this.setCurrentIDEVersion();
+ });
+ }
+
+ private async setCurrentIDEVersion(): Promise {
+ try {
+ const { appVersion } = await this.appService.info();
+ const currSemVer = new SemVer(appVersion ?? '');
+ this.localStorage.setData(LAST_USED_IDE_VERSION, currSemVer.format());
+ } catch {
+ // ignore invalid versions
+ }
+ }
+
+ /**
+ * Check if user is running a new IDE version for the first time.
+ * @returns true if the current IDE version is greater than the last used version
+ * and both are non-prerelease versions.
+ */
+ private async isNewStableVersion(): Promise {
+ try {
+ const { appVersion } = await this.appService.info();
+ const prevVersion = await this.localStorage.getData(
+ LAST_USED_IDE_VERSION
+ );
+
+ const prevSemVer = new SemVer(prevVersion ?? '');
+ const currSemVer = new SemVer(appVersion ?? '');
+
+ if (prevSemVer.prerelease.length || currSemVer.prerelease.length) {
+ return false;
+ }
+
+ return currSemVer.compare(prevSemVer) === 1;
+ } catch (e) {
+ return false;
+ }
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/check-for-updates.ts b/arduino-ide-extension/src/browser/contributions/check-for-updates.ts
new file mode 100644
index 000000000..d305f9db2
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/check-for-updates.ts
@@ -0,0 +1,221 @@
+import type { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution';
+import { nls } from '@theia/core/lib/common/nls';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { InstallManually, Later } from '../../common/nls';
+import {
+ ArduinoComponent,
+ BoardsPackage,
+ BoardsService,
+ LibraryPackage,
+ LibraryService,
+ ResponseServiceClient,
+ Searchable,
+} from '../../common/protocol';
+import { Installable } from '../../common/protocol/installable';
+import { ExecuteWithProgress } from '../../common/protocol/progressible';
+import { BoardsListWidgetFrontendContribution } from '../boards/boards-widget-frontend-contribution';
+import { LibraryListWidgetFrontendContribution } from '../library/library-widget-frontend-contribution';
+import { WindowServiceExt } from '../theia/core/window-service-ext';
+import type { ListWidget } from '../widgets/component-list/list-widget';
+import { Command, CommandRegistry, Contribution } from './contribution';
+
+const NoUpdates = nls.localize(
+ 'arduino/checkForUpdates/noUpdates',
+ 'There are no recent updates available.'
+);
+const PromptUpdateBoards = nls.localize(
+ 'arduino/checkForUpdates/promptUpdateBoards',
+ 'Updates are available for some of your boards.'
+);
+const PromptUpdateLibraries = nls.localize(
+ 'arduino/checkForUpdates/promptUpdateLibraries',
+ 'Updates are available for some of your libraries.'
+);
+const UpdatingBoards = nls.localize(
+ 'arduino/checkForUpdates/updatingBoards',
+ 'Updating boards...'
+);
+const UpdatingLibraries = nls.localize(
+ 'arduino/checkForUpdates/updatingLibraries',
+ 'Updating libraries...'
+);
+const InstallAll = nls.localize(
+ 'arduino/checkForUpdates/installAll',
+ 'Install All'
+);
+
+interface Task {
+ readonly run: () => Promise;
+ readonly item: T;
+}
+
+const Updatable = { type: 'Updatable' } as const;
+
+@injectable()
+export class CheckForUpdates extends Contribution {
+ @inject(WindowServiceExt)
+ private readonly windowService: WindowServiceExt;
+ @inject(ResponseServiceClient)
+ private readonly responseService: ResponseServiceClient;
+ @inject(BoardsService)
+ private readonly boardsService: BoardsService;
+ @inject(LibraryService)
+ private readonly libraryService: LibraryService;
+ @inject(BoardsListWidgetFrontendContribution)
+ private readonly boardsContribution: BoardsListWidgetFrontendContribution;
+ @inject(LibraryListWidgetFrontendContribution)
+ private readonly librariesContribution: LibraryListWidgetFrontendContribution;
+
+ override registerCommands(register: CommandRegistry): void {
+ register.registerCommand(CheckForUpdates.Commands.CHECK_FOR_UPDATES, {
+ execute: () => this.checkForUpdates(false),
+ });
+ }
+
+ override async onReady(): Promise {
+ const checkForUpdates = this.preferences['arduino.checkForUpdates'];
+ if (checkForUpdates) {
+ this.windowService.isFirstWindow().then((firstWindow) => {
+ if (firstWindow) {
+ this.checkForUpdates();
+ }
+ });
+ }
+ }
+
+ private async checkForUpdates(silent = true) {
+ const [boardsPackages, libraryPackages] = await Promise.all([
+ this.boardsService.search(Updatable),
+ this.libraryService.search(Updatable),
+ ]);
+ this.promptUpdateBoards(boardsPackages);
+ this.promptUpdateLibraries(libraryPackages);
+ if (!libraryPackages.length && !boardsPackages.length && !silent) {
+ this.messageService.info(NoUpdates);
+ }
+ }
+
+ private promptUpdateBoards(items: BoardsPackage[]): void {
+ this.prompt({
+ items,
+ installable: this.boardsService,
+ viewContribution: this.boardsContribution,
+ viewSearchOptions: { query: '', ...Updatable },
+ promptMessage: PromptUpdateBoards,
+ updatingMessage: UpdatingBoards,
+ });
+ }
+
+ private promptUpdateLibraries(items: LibraryPackage[]): void {
+ this.prompt({
+ items,
+ installable: this.libraryService,
+ viewContribution: this.librariesContribution,
+ viewSearchOptions: { query: '', topic: 'All', ...Updatable },
+ promptMessage: PromptUpdateLibraries,
+ updatingMessage: UpdatingLibraries,
+ });
+ }
+
+ private prompt<
+ T extends ArduinoComponent,
+ S extends Searchable.Options
+ >(options: {
+ items: T[];
+ installable: Installable;
+ viewContribution: AbstractViewContribution>;
+ viewSearchOptions: S;
+ promptMessage: string;
+ updatingMessage: string;
+ }): void {
+ const {
+ items,
+ installable,
+ viewContribution,
+ promptMessage: message,
+ viewSearchOptions,
+ updatingMessage,
+ } = options;
+
+ if (!items.length) {
+ return;
+ }
+ this.messageService
+ .info(message, Later, InstallManually, InstallAll)
+ .then((answer) => {
+ if (answer === InstallAll) {
+ const tasks = items.map((item) =>
+ this.createInstallTask(item, installable)
+ );
+ this.executeTasks(updatingMessage, tasks);
+ } else if (answer === InstallManually) {
+ viewContribution
+ .openView({ reveal: true })
+ .then((widget) => widget.refresh(viewSearchOptions));
+ }
+ });
+ }
+
+ private async executeTasks(
+ message: string,
+ tasks: Task[]
+ ): Promise {
+ if (tasks.length) {
+ return ExecuteWithProgress.withProgress(
+ message,
+ this.messageService,
+ async (progress) => {
+ try {
+ const total = tasks.length;
+ let count = 0;
+ for (const { run, item } of tasks) {
+ try {
+ await run(); // runs update sequentially. // TODO: is parallel update desired?
+ } catch (err) {
+ console.error(err);
+ this.messageService.error(
+ `Failed to update ${item.name}. ${err}`
+ );
+ } finally {
+ progress.report({ work: { total, done: ++count } });
+ }
+ }
+ } finally {
+ progress.cancel();
+ }
+ }
+ );
+ }
+ }
+
+ private createInstallTask(
+ item: T,
+ installable: Installable
+ ): Task {
+ const latestVersion = item.availableVersions[0];
+ return {
+ item,
+ run: () =>
+ Installable.installWithProgress({
+ installable,
+ item,
+ version: latestVersion,
+ messageService: this.messageService,
+ responseService: this.responseService,
+ keepOutput: true,
+ }),
+ };
+ }
+}
+export namespace CheckForUpdates {
+ export namespace Commands {
+ export const CHECK_FOR_UPDATES: Command = Command.toLocalizedCommand(
+ {
+ id: 'arduino-check-for-updates',
+ label: 'Check for Arduino Updates',
+ category: 'Arduino',
+ },
+ 'arduino/checkForUpdates/checkForUpdates'
+ );
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/close.ts b/arduino-ide-extension/src/browser/contributions/close.ts
index 1b335fa82..93b4a62e4 100644
--- a/arduino-ide-extension/src/browser/contributions/close.ts
+++ b/arduino-ide-extension/src/browser/contributions/close.ts
@@ -1,39 +1,43 @@
-import { inject, injectable } from 'inversify';
-import { toArray } from '@phosphor/algorithm';
-import * as remote from '@theia/core/electron-shared/@electron/remote';
-import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
-import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
+import { Dialog } from '@theia/core/lib/browser/dialogs';
+import type { FrontendApplication } from '@theia/core/lib/browser/frontend-application';
+import type { OnWillStopAction } from '@theia/core/lib/browser/frontend-application-contribution';
import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
-import { FrontendApplication } from '@theia/core/lib/browser/frontend-application';
+import { nls } from '@theia/core/lib/common/nls';
+import type { MaybePromise } from '@theia/core/lib/common/types';
+import { toArray } from '@theia/core/shared/@phosphor/algorithm';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { ArduinoMenus } from '../menu/arduino-menus';
-import { SaveAsSketch } from './save-as-sketch';
+import { CurrentSketch } from '../sketches-service-client-impl';
+import { WindowServiceExt } from '../theia/core/window-service-ext';
import {
- SketchContribution,
Command,
CommandRegistry,
- MenuModelRegistry,
KeybindingRegistry,
+ MenuModelRegistry,
+ Sketch,
+ SketchContribution,
URI,
} from './contribution';
-import { nls } from '@theia/core/lib/common';
+import { SaveAsSketch } from './save-as-sketch';
/**
* Closes the `current` closeable editor, or any closeable current widget from the main area, or the current sketch window.
*/
@injectable()
export class Close extends SketchContribution {
- @inject(EditorManager)
- protected readonly editorManager: EditorManager;
+ @inject(WindowServiceExt)
+ private readonly windowServiceExt: WindowServiceExt;
- protected shell: ApplicationShell;
+ private shell: ApplicationShell | undefined;
- onStart(app: FrontendApplication): void {
+ override onStart(app: FrontendApplication): MaybePromise {
this.shell = app.shell;
}
- registerCommands(registry: CommandRegistry): void {
+ override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(Close.Commands.CLOSE, {
- execute: async () => {
+ execute: () => {
// Close current editor if closeable.
const { currentEditor } = this.editorManager;
if (currentEditor && currentEditor.title.closable) {
@@ -41,86 +45,152 @@ export class Close extends SketchContribution {
return;
}
- // Close current widget from the main area if possible.
- const { currentWidget } = this.shell;
- if (currentWidget) {
- const currentWidgetInMain = toArray(
- this.shell.mainPanel.widgets()
- ).find((widget) => widget === currentWidget);
- if (currentWidgetInMain && currentWidgetInMain.title.closable) {
- return currentWidgetInMain.close();
- }
- }
-
- // Close the sketch (window).
- const sketch = await this.sketchServiceClient.currentSketch();
- if (!sketch) {
- return;
- }
- const isTemp = await this.sketchService.isTemp(sketch);
- const uri = await this.sketchServiceClient.currentSketchFile();
- if (!uri) {
- return;
- }
- if (isTemp && (await this.wasTouched(uri))) {
- const { response } = await remote.dialog.showMessageBox({
- type: 'question',
- buttons: [
- nls.localize(
- 'vscode/abstractTaskService/saveBeforeRun.dontSave',
- "Don't Save"
- ),
- nls.localize('vscode/issueMainService/cancel', 'Cancel'),
- nls.localize(
- 'vscode/abstractTaskService/saveBeforeRun.save',
- 'Save'
- ),
- ],
- message: nls.localize(
- 'arduino/common/saveChangesToSketch',
- 'Do you want to save changes to this sketch before closing?'
- ),
- detail: nls.localize(
- 'arduino/common/loseChanges',
- "If you don't save, your changes will be lost."
- ),
- });
- if (response === 1) {
- // Cancel
- return;
- }
- if (response === 2) {
- // Save
- const saved = await this.commandService.executeCommand(
- SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
- { openAfterMove: false, execOnlyIfTemp: true }
- );
- if (!saved) {
- // If it was not saved, do bail the close.
- return;
+ if (this.shell) {
+ // Close current widget from the main area if possible.
+ const { currentWidget } = this.shell;
+ if (currentWidget) {
+ const currentWidgetInMain = toArray(
+ this.shell.mainPanel.widgets()
+ ).find((widget) => widget === currentWidget);
+ if (currentWidgetInMain && currentWidgetInMain.title.closable) {
+ return currentWidgetInMain.close();
}
}
}
- window.close();
+ return this.windowServiceExt.close();
},
});
}
- registerMenus(registry: MenuModelRegistry): void {
+ override registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
commandId: Close.Commands.CLOSE.id,
label: nls.localize('vscode/editor.contribution/close', 'Close'),
- order: '5',
+ order: '6',
});
}
- registerKeybindings(registry: KeybindingRegistry): void {
+ override registerKeybindings(registry: KeybindingRegistry): void {
registry.registerKeybinding({
command: Close.Commands.CLOSE.id,
keybinding: 'CtrlCmd+W',
});
}
+ // `FrontendApplicationContribution#onWillStop`
+ onWillStop(): OnWillStopAction {
+ return {
+ reason: 'save-sketch',
+ action: () => {
+ return this.showSaveSketchDialog();
+ },
+ };
+ }
+
+ /**
+ * If returns with `true`, IDE2 will close. Otherwise, it won't.
+ */
+ private async showSaveSketchDialog(): Promise {
+ const sketch = await this.isCurrentSketchTemp();
+ if (!sketch) {
+ // Normal close workflow: if there are dirty editors prompt the user.
+ if (!this.shell) {
+ console.error(
+ `Could not get the application shell. Something went wrong.`
+ );
+ return true;
+ }
+ if (this.shell.canSaveAll()) {
+ const prompt = await this.prompt(false);
+ switch (prompt) {
+ case Prompt.DoNotSave:
+ return true;
+ case Prompt.Cancel:
+ return false;
+ case Prompt.Save: {
+ await this.shell.saveAll();
+ return true;
+ }
+ default:
+ throw new Error(`Unexpected prompt: ${prompt}`);
+ }
+ }
+ return true;
+ }
+
+ // If non of the sketch files were ever touched, do not prompt the save dialog. (#1274)
+ const wereTouched = await Promise.all(
+ Sketch.uris(sketch).map((uri) => this.wasTouched(uri))
+ );
+ if (wereTouched.every((wasTouched) => !Boolean(wasTouched))) {
+ return true;
+ }
+
+ const prompt = await this.prompt(true);
+ switch (prompt) {
+ case Prompt.DoNotSave:
+ return true;
+ case Prompt.Cancel:
+ return false;
+ case Prompt.Save: {
+ // If `save as` was canceled by user, the result will be `undefined`, otherwise the new URI.
+ const result = await this.commandService.executeCommand(
+ SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
+ {
+ execOnlyIfTemp: false,
+ openAfterMove: false,
+ wipeOriginal: true,
+ markAsRecentlyOpened: true,
+ }
+ );
+ return !!result;
+ }
+ default:
+ throw new Error(`Unexpected prompt: ${prompt}`);
+ }
+ }
+
+ private async prompt(isTemp: boolean): Promise {
+ const { response } = await this.dialogService.showMessageBox({
+ message: nls.localize(
+ 'arduino/sketch/saveSketch',
+ 'Save your sketch to open it again later.'
+ ),
+ title: nls.localize(
+ 'theia/core/quitTitle',
+ 'Are you sure you want to quit?'
+ ),
+ type: 'question',
+ buttons: [
+ nls.localizeByDefault("Don't Save"),
+ Dialog.CANCEL,
+ nls.localizeByDefault(isTemp ? 'Save As...' : 'Save'),
+ ],
+ defaultId: 2, // `Save`/`Save As...` button index is the default.
+ });
+ switch (response) {
+ case 0:
+ return Prompt.DoNotSave;
+ case 1:
+ return Prompt.Cancel;
+ case 2:
+ return Prompt.Save;
+ default:
+ throw new Error(`Unexpected response: ${response}`);
+ }
+ }
+
+ private async isCurrentSketchTemp(): Promise {
+ const currentSketch = await this.sketchServiceClient.currentSketch();
+ if (CurrentSketch.isValid(currentSketch)) {
+ const isTemp = await this.sketchesService.isTemp(currentSketch);
+ if (isTemp) {
+ return currentSketch;
+ }
+ }
+ return false;
+ }
+
/**
* If the file was ever touched/modified. We get this based on the `version` of the monaco model.
*/
@@ -130,13 +200,23 @@ export class Close extends SketchContribution {
const { editor } = editorWidget;
if (editor instanceof MonacoEditor) {
const versionId = editor.getControl().getModel()?.getVersionId();
- if (Number.isInteger(versionId) && versionId! > 1) {
+ if (this.isInteger(versionId) && versionId > 1) {
return true;
}
}
}
return false;
}
+
+ private isInteger(arg: unknown): arg is number {
+ return Number.isInteger(arg);
+ }
+}
+
+enum Prompt {
+ Save,
+ DoNotSave,
+ Cancel,
}
export namespace Close {
diff --git a/arduino-ide-extension/src/browser/contributions/cloud-contribution.ts b/arduino-ide-extension/src/browser/contributions/cloud-contribution.ts
new file mode 100644
index 000000000..47e14210d
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/cloud-contribution.ts
@@ -0,0 +1,121 @@
+import { CompositeTreeNode } from '@theia/core/lib/browser/tree';
+import { nls } from '@theia/core/lib/common/nls';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { CreateApi } from '../create/create-api';
+import { CreateFeatures } from '../create/create-features';
+import { CreateUri } from '../create/create-uri';
+import { Create, isNotFound } from '../create/typings';
+import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree';
+import { CloudSketchbookTreeModel } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-model';
+import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-widget';
+import { SketchbookWidget } from '../widgets/sketchbook/sketchbook-widget';
+import { SketchbookWidgetContribution } from '../widgets/sketchbook/sketchbook-widget-contribution';
+import { SketchContribution } from './contribution';
+
+export function sketchAlreadyExists(input: string): string {
+ return nls.localize(
+ 'arduino/cloudSketch/alreadyExists',
+ "Cloud sketch '{0}' already exists.",
+ input
+ );
+}
+export function sketchNotFound(input: string): string {
+ return nls.localize(
+ 'arduino/cloudSketch/notFound',
+ "Could not pull the cloud sketch '{0}'. It does not exist.",
+ input
+ );
+}
+export const synchronizingSketchbook = nls.localize(
+ 'arduino/cloudSketch/synchronizingSketchbook',
+ 'Synchronizing sketchbook...'
+);
+export function pullingSketch(input: string): string {
+ return nls.localize(
+ 'arduino/cloudSketch/pulling',
+ "Synchronizing sketchbook, pulling '{0}'...",
+ input
+ );
+}
+export function pushingSketch(input: string): string {
+ return nls.localize(
+ 'arduino/cloudSketch/pushing',
+ "Synchronizing sketchbook, pushing '{0}'...",
+ input
+ );
+}
+
+@injectable()
+export abstract class CloudSketchContribution extends SketchContribution {
+ @inject(SketchbookWidgetContribution)
+ private readonly widgetContribution: SketchbookWidgetContribution;
+ @inject(CreateApi)
+ protected readonly createApi: CreateApi;
+ @inject(CreateFeatures)
+ protected readonly createFeatures: CreateFeatures;
+
+ protected async treeModel(): Promise<
+ (CloudSketchbookTreeModel & { root: CompositeTreeNode }) | undefined
+ > {
+ const { enabled, session } = this.createFeatures;
+ if (enabled && session) {
+ const widget = await this.widgetContribution.widget;
+ const treeModel = this.treeModelFrom(widget);
+ if (treeModel) {
+ const root = treeModel.root;
+ if (CompositeTreeNode.is(root)) {
+ return treeModel as CloudSketchbookTreeModel & {
+ root: CompositeTreeNode;
+ };
+ }
+ }
+ }
+ return undefined;
+ }
+
+ protected async pull(
+ sketch: Create.Sketch
+ ): Promise {
+ const treeModel = await this.treeModel();
+ if (!treeModel) {
+ return undefined;
+ }
+ const id = CreateUri.toUri(sketch).path.toString();
+ const node = treeModel.getNode(id);
+ if (!node) {
+ throw new Error(
+ `Could not find cloud sketchbook tree node with ID: ${id}.`
+ );
+ }
+ if (!CloudSketchbookTree.CloudSketchDirNode.is(node)) {
+ throw new Error(
+ `Cloud sketchbook tree node expected to represent a directory but it did not. Tree node ID: ${id}.`
+ );
+ }
+ try {
+ await treeModel.sketchbookTree().pull({ node }, true);
+ return node;
+ } catch (err) {
+ if (isNotFound(err)) {
+ await treeModel.refresh();
+ this.messageService.error(sketchNotFound(sketch.name));
+ return undefined;
+ }
+ throw err;
+ }
+ }
+
+ private treeModelFrom(
+ widget: SketchbookWidget
+ ): CloudSketchbookTreeModel | undefined {
+ for (const treeWidget of widget.getTreeWidgets()) {
+ if (treeWidget instanceof CloudSketchbookTreeWidget) {
+ const model = treeWidget.model;
+ if (model instanceof CloudSketchbookTreeModel) {
+ return model;
+ }
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/compiler-errors.ts b/arduino-ide-extension/src/browser/contributions/compiler-errors.ts
new file mode 100644
index 000000000..19c322d21
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/compiler-errors.ts
@@ -0,0 +1,804 @@
+import {
+ Command,
+ CommandRegistry,
+ Disposable,
+ DisposableCollection,
+ Emitter,
+ MaybeArray,
+ MaybePromise,
+ nls,
+ notEmpty,
+} from '@theia/core';
+import { ApplicationShell, FrontendApplication } from '@theia/core/lib/browser';
+import { ITextModel } from '@theia/monaco-editor-core/esm/vs/editor/common/model';
+import URI from '@theia/core/lib/common/uri';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import {
+ Location,
+ Range,
+} from '@theia/core/shared/vscode-languageserver-protocol';
+import {
+ EditorWidget,
+ TextDocumentChangeEvent,
+} from '@theia/editor/lib/browser';
+import {
+ EditorDecoration,
+ TrackedRangeStickiness,
+} from '@theia/editor/lib/browser/decorations/editor-decoration';
+import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
+import * as monaco from '@theia/monaco-editor-core';
+import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
+import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter';
+import { ProtocolToMonacoConverter } from '@theia/monaco/lib/browser/protocol-to-monaco-converter';
+import { OutputUri } from '@theia/output/lib/common/output-uri';
+import { CoreError } from '../../common/protocol/core-service';
+import { ErrorRevealStrategy } from '../arduino-preferences';
+import { ArduinoOutputSelector, InoSelector } from '../selectors';
+import { Contribution } from './contribution';
+import { CoreErrorHandler } from './core-error-handler';
+import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
+
+interface ErrorDecorationRef {
+ /**
+ * This is the unique ID of the decoration given by `monaco`.
+ */
+ readonly id: string;
+ /**
+ * The resource this decoration belongs to.
+ */
+ readonly uri: string;
+}
+export namespace ErrorDecorationRef {
+ export function is(arg: unknown): arg is ErrorDecorationRef {
+ if (typeof arg === 'object') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const object = arg as any;
+ return (
+ 'uri' in object &&
+ typeof object['uri'] === 'string' &&
+ 'id' in object &&
+ typeof object['id'] === 'string'
+ );
+ }
+ return false;
+ }
+ export function sameAs(
+ left: ErrorDecorationRef,
+ right: ErrorDecorationRef
+ ): boolean {
+ return left.id === right.id && left.uri === right.uri;
+ }
+}
+
+interface ErrorDecoration extends ErrorDecorationRef {
+ /**
+ * The range of the error location the error in the compiler output from the CLI.
+ */
+ readonly rangesInOutput: monaco.Range[];
+}
+namespace ErrorDecoration {
+ export function rangeOf(
+ editorOrModel: MonacoEditor | ITextModel | undefined,
+ decorations: ErrorDecoration
+ ): monaco.Range | undefined;
+ export function rangeOf(
+ editorOrModel: MonacoEditor | ITextModel | undefined,
+ decorations: ErrorDecoration[]
+ ): (monaco.Range | undefined)[];
+ export function rangeOf(
+ editorOrModel: MonacoEditor | ITextModel | undefined,
+ decorations: ErrorDecoration | ErrorDecoration[]
+ ): MaybePromise> {
+ if (editorOrModel) {
+ const allDecorations = getAllDecorations(editorOrModel);
+ if (allDecorations) {
+ if (Array.isArray(decorations)) {
+ return decorations.map(({ id: decorationId }) =>
+ findRangeOf(decorationId, allDecorations)
+ );
+ } else {
+ return findRangeOf(decorations.id, allDecorations);
+ }
+ }
+ }
+ return Array.isArray(decorations)
+ ? decorations.map(() => undefined)
+ : undefined;
+ }
+ function findRangeOf(
+ decorationId: string,
+ allDecorations: { id: string; range?: monaco.Range }[]
+ ): monaco.Range | undefined {
+ return allDecorations.find(
+ ({ id: candidateId }) => candidateId === decorationId
+ )?.range;
+ }
+ function getAllDecorations(
+ editorOrModel: MonacoEditor | ITextModel
+ ): { id: string; range?: monaco.Range }[] {
+ if (editorOrModel instanceof MonacoEditor) {
+ const model = editorOrModel.getControl().getModel();
+ if (!model) {
+ return [];
+ }
+ return model.getAllDecorations();
+ }
+ return editorOrModel.getAllDecorations();
+ }
+}
+
+@injectable()
+export class CompilerErrors
+ extends Contribution
+ implements monaco.languages.CodeLensProvider, monaco.languages.LinkProvider
+{
+ @inject(EditorManager)
+ private readonly editorManager: EditorManager;
+
+ @inject(ProtocolToMonacoConverter)
+ private readonly p2m: ProtocolToMonacoConverter;
+
+ @inject(MonacoToProtocolConverter)
+ private readonly m2p: MonacoToProtocolConverter;
+
+ @inject(CoreErrorHandler)
+ private readonly coreErrorHandler: CoreErrorHandler;
+
+ private revealStrategy = ErrorRevealStrategy.Default;
+ private experimental = false;
+
+ private readonly errors: ErrorDecoration[] = [];
+ private readonly onDidChangeEmitter = new monaco.Emitter();
+ private readonly currentErrorDidChangEmitter = new Emitter();
+ private readonly onCurrentErrorDidChange =
+ this.currentErrorDidChangEmitter.event;
+ private readonly toDisposeOnCompilerErrorDidChange =
+ new DisposableCollection();
+
+ private shell: ApplicationShell | undefined;
+ private currentError: ErrorDecoration | undefined;
+ private get currentErrorIndex(): number {
+ const current = this.currentError;
+ if (!current) {
+ return -1;
+ }
+ return this.errors.findIndex((error) =>
+ ErrorDecorationRef.sameAs(error, current)
+ );
+ }
+
+ override onStart(app: FrontendApplication): void {
+ this.shell = app.shell;
+ monaco.languages.registerCodeLensProvider(InoSelector, this);
+ monaco.languages.registerLinkProvider(ArduinoOutputSelector, this);
+ this.coreErrorHandler.onCompilerErrorsDidChange((errors) =>
+ this.handleCompilerErrorsDidChange(errors)
+ );
+ this.onCurrentErrorDidChange(async (error) => {
+ const monacoEditor = await this.monacoEditor(error.uri);
+ const monacoRange = ErrorDecoration.rangeOf(monacoEditor, error);
+ if (!monacoRange) {
+ console.warn(
+ 'compiler-errors',
+ `Could not find range of decoration: ${error.id}`
+ );
+ return;
+ }
+ const range = this.m2p.asRange(monacoRange);
+ const editor = await this.revealLocationInEditor({
+ uri: error.uri,
+ range,
+ });
+ if (!editor) {
+ console.warn(
+ 'compiler-errors',
+ `Failed to mark error ${error.id} as the current one.`
+ );
+ } else {
+ const monacoEditor = this.monacoEditor(editor);
+ if (monacoEditor) {
+ monacoEditor.cursor = range.start;
+ }
+ }
+ });
+ }
+
+ override onReady(): MaybePromise {
+ this.preferences.ready.then(() => {
+ this.experimental = Boolean(
+ this.preferences['arduino.compile.experimental']
+ );
+ const strategy = this.preferences['arduino.compile.revealRange'];
+ this.revealStrategy = ErrorRevealStrategy.is(strategy)
+ ? strategy
+ : ErrorRevealStrategy.Default;
+ this.preferences.onPreferenceChanged(
+ ({ preferenceName, newValue, oldValue }) => {
+ if (newValue === oldValue) {
+ return;
+ }
+ switch (preferenceName) {
+ case 'arduino.compile.revealRange': {
+ this.revealStrategy = ErrorRevealStrategy.is(newValue)
+ ? newValue
+ : ErrorRevealStrategy.Default;
+ return;
+ }
+ case 'arduino.compile.experimental': {
+ this.experimental = Boolean(newValue);
+ this.onDidChangeEmitter.fire(this);
+ return;
+ }
+ }
+ }
+ );
+ });
+ }
+
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(CompilerErrors.Commands.NEXT_ERROR, {
+ execute: () => {
+ const index = this.currentErrorIndex;
+ if (index < 0) {
+ console.warn(
+ 'compiler-errors',
+ `Could not advance to next error. Unknown current error.`
+ );
+ return;
+ }
+ const nextError =
+ this.errors[index === this.errors.length - 1 ? 0 : index + 1];
+ return this.markAsCurrentError(nextError, {
+ forceReselect: true,
+ reveal: true,
+ });
+ },
+ isEnabled: () =>
+ this.experimental && !!this.currentError && this.errors.length > 1,
+ });
+ registry.registerCommand(CompilerErrors.Commands.PREVIOUS_ERROR, {
+ execute: () => {
+ const index = this.currentErrorIndex;
+ if (index < 0) {
+ console.warn(
+ 'compiler-errors',
+ `Could not advance to previous error. Unknown current error.`
+ );
+ return;
+ }
+ const previousError =
+ this.errors[index === 0 ? this.errors.length - 1 : index - 1];
+ return this.markAsCurrentError(previousError, {
+ forceReselect: true,
+ reveal: true,
+ });
+ },
+ isEnabled: () =>
+ this.experimental && !!this.currentError && this.errors.length > 1,
+ });
+ registry.registerCommand(CompilerErrors.Commands.MARK_AS_CURRENT, {
+ execute: (arg: unknown) => {
+ if (ErrorDecorationRef.is(arg)) {
+ return this.markAsCurrentError(
+ { id: arg.id, uri: new URI(arg.uri).toString() }, // Make sure the URI fragments are encoded. On Windows, `C:` is encoded as `C%3A`.
+ { forceReselect: true, reveal: true }
+ );
+ }
+ },
+ isEnabled: () => !!this.errors.length,
+ });
+ }
+
+ get onDidChange(): monaco.IEvent {
+ return this.onDidChangeEmitter.event;
+ }
+
+ async provideCodeLenses(
+ model: monaco.editor.ITextModel,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _token: monaco.CancellationToken
+ ): Promise {
+ const lenses: monaco.languages.CodeLens[] = [];
+ if (
+ this.experimental &&
+ this.currentError &&
+ this.currentError.uri === model.uri.toString() &&
+ this.errors.length > 1
+ ) {
+ const monacoEditor = await this.monacoEditor(model.uri);
+ const range = ErrorDecoration.rangeOf(monacoEditor, this.currentError);
+ if (range) {
+ lenses.push(
+ {
+ range,
+ command: {
+ id: CompilerErrors.Commands.PREVIOUS_ERROR.id,
+ title: nls.localize(
+ 'arduino/editor/previousError',
+ 'Previous Error'
+ ),
+ arguments: [this.currentError],
+ },
+ },
+ {
+ range,
+ command: {
+ id: CompilerErrors.Commands.NEXT_ERROR.id,
+ title: nls.localize('arduino/editor/nextError', 'Next Error'),
+ arguments: [this.currentError],
+ },
+ }
+ );
+ }
+ }
+ return {
+ lenses,
+ dispose: () => {
+ /* NOOP */
+ },
+ };
+ }
+
+ async provideLinks(
+ model: monaco.editor.ITextModel,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _token: monaco.CancellationToken
+ ): Promise {
+ const links: monaco.languages.ILink[] = [];
+ if (
+ model.uri.scheme === OutputUri.SCHEME &&
+ model.uri.path === '/Arduino'
+ ) {
+ links.push(
+ ...this.errors
+ .filter((decoration) => !!decoration.rangesInOutput.length)
+ .map(({ rangesInOutput, id, uri }) =>
+ rangesInOutput.map(
+ (range) =>
+ {
+ range,
+ url: monaco.Uri.parse(`command://`).with({
+ query: JSON.stringify({ id, uri }),
+ path: CompilerErrors.Commands.MARK_AS_CURRENT.id,
+ }),
+ tooltip: nls.localize(
+ 'arduino/editor/revealError',
+ 'Reveal Error'
+ ),
+ }
+ )
+ )
+ .reduce((acc, curr) => acc.concat(curr), [])
+ );
+ } else {
+ console.warn('unexpected URI: ' + model.uri.toString());
+ }
+ return { links };
+ }
+
+ async resolveLink(
+ link: monaco.languages.ILink,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _token: monaco.CancellationToken
+ ): Promise {
+ if (!this.experimental) {
+ return undefined;
+ }
+ const { url } = link;
+ if (url) {
+ const candidateUri = new URI(
+ typeof url === 'string' ? url : url.toString()
+ );
+ const candidateId = candidateUri.path.toString();
+ const error = this.errors.find((error) => error.id === candidateId);
+ if (error) {
+ const monacoEditor = await this.monacoEditor(error.uri);
+ const range = ErrorDecoration.rangeOf(monacoEditor, error);
+ if (range) {
+ return {
+ range,
+ url: monaco.Uri.parse(error.uri),
+ };
+ }
+ }
+ }
+ return undefined;
+ }
+
+ private async handleCompilerErrorsDidChange(
+ errors: CoreError.ErrorLocation[]
+ ): Promise {
+ this.toDisposeOnCompilerErrorDidChange.dispose();
+ const groupedErrors = this.groupBy(
+ errors,
+ (error: CoreError.ErrorLocation) => error.location.uri
+ );
+ const decorations = await this.decorateEditors(groupedErrors);
+ this.errors.push(...decorations.errors);
+ this.toDisposeOnCompilerErrorDidChange.pushAll([
+ Disposable.create(() => (this.errors.length = 0)),
+ Disposable.create(() => this.onDidChangeEmitter.fire(this)),
+ ...(await Promise.all([
+ decorations.dispose,
+ this.trackEditors(
+ groupedErrors,
+ (editor) =>
+ editor.onSelectionChanged((selection) =>
+ this.handleSelectionChange(editor, selection)
+ ),
+ (editor) =>
+ editor.onDispose(() =>
+ this.handleEditorDidDispose(editor.uri.toString())
+ ),
+ (editor) =>
+ editor.onDocumentContentChanged((event) =>
+ this.handleDocumentContentChange(editor, event)
+ )
+ ),
+ ])),
+ ]);
+ const currentError = this.errors[0];
+ if (currentError) {
+ await this.markAsCurrentError(currentError, {
+ forceReselect: true,
+ reveal: true,
+ });
+ }
+ }
+
+ private async decorateEditors(
+ errors: Map
+ ): Promise<{ dispose: Disposable; errors: ErrorDecoration[] }> {
+ const composite = await Promise.all(
+ [...errors.entries()].map(([uri, errors]) =>
+ this.decorateEditor(uri, errors)
+ )
+ );
+ return {
+ dispose: new DisposableCollection(
+ ...composite.map(({ dispose }) => dispose)
+ ),
+ errors: composite.reduce(
+ (acc, { errors }) => acc.concat(errors),
+ [] as ErrorDecoration[]
+ ),
+ };
+ }
+
+ private async decorateEditor(
+ uri: string,
+ errors: CoreError.ErrorLocation[]
+ ): Promise<{ dispose: Disposable; errors: ErrorDecoration[] }> {
+ const editor = await this.monacoEditor(uri);
+ if (!editor) {
+ return { dispose: Disposable.NULL, errors: [] };
+ }
+ const oldDecorations = editor.deltaDecorations({
+ oldDecorations: [],
+ newDecorations: errors.map((error) =>
+ this.compilerErrorDecoration(error.location.range)
+ ),
+ });
+ return {
+ dispose: Disposable.create(() => {
+ if (editor) {
+ editor.deltaDecorations({
+ oldDecorations,
+ newDecorations: [],
+ });
+ }
+ }),
+ errors: oldDecorations.map((id, index) => ({
+ id,
+ uri,
+ rangesInOutput: errors[index].rangesInOutput.map((range) =>
+ this.p2m.asRange(range)
+ ),
+ })),
+ };
+ }
+
+ private compilerErrorDecoration(range: Range): EditorDecoration {
+ return {
+ range,
+ options: {
+ isWholeLine: true,
+ className: 'compiler-error',
+ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
+ },
+ };
+ }
+
+ /**
+ * Tracks the selection in all editors that have an error. If the editor selection overlaps one of the compiler error's range, mark as current error.
+ */
+ private handleSelectionChange(
+ monacoEditor: MonacoEditor,
+ selection: Range
+ ): void {
+ const uri = monacoEditor.uri.toString();
+ const monacoSelection = this.p2m.asRange(selection);
+ console.log(
+ 'compiler-errors',
+ `Handling selection change in editor ${uri}. New (monaco) selection: ${monacoSelection.toJSON()}`
+ );
+ const calculatePriority = (
+ candidateErrorRange: monaco.Range,
+ currentSelection: monaco.Range
+ ) => {
+ console.trace(
+ 'compiler-errors',
+ `Candidate error range: ${candidateErrorRange.toJSON()}`
+ );
+ console.trace(
+ 'compiler-errors',
+ `Current selection range: ${currentSelection.toJSON()}`
+ );
+ if (candidateErrorRange.intersectRanges(currentSelection)) {
+ console.trace('Intersects.');
+ return { score: 2 };
+ }
+ if (
+ candidateErrorRange.startLineNumber <=
+ currentSelection.startLineNumber &&
+ candidateErrorRange.endLineNumber >= currentSelection.endLineNumber
+ ) {
+ console.trace('Same line.');
+ return { score: 1 };
+ }
+
+ console.trace('No match');
+ return undefined;
+ };
+ const errorsPerResource = this.errors.filter((error) => error.uri === uri);
+ const rangesPerResource = ErrorDecoration.rangeOf(
+ monacoEditor,
+ errorsPerResource
+ );
+ const error = rangesPerResource
+ .map((range, index) => ({ error: errorsPerResource[index], range }))
+ .map(({ error, range }) => {
+ if (range) {
+ const priority = calculatePriority(range, monacoSelection);
+ if (priority) {
+ return { ...priority, error };
+ }
+ }
+ return undefined;
+ })
+ .filter(notEmpty)
+ .sort((left, right) => right.score - left.score) // highest first
+ .map(({ error }) => error)
+ .shift();
+ if (error) {
+ this.markAsCurrentError(error);
+ } else {
+ console.info(
+ 'compiler-errors',
+ `New (monaco) selection ${monacoSelection.toJSON()} does not intersect any error locations. Skipping.`
+ );
+ }
+ }
+
+ /**
+ * This code does not deal with resource deletion, but tracks editor dispose events. It does not matter what was the cause of the editor disposal.
+ * If editor closes, delete the decorators.
+ */
+ private handleEditorDidDispose(uri: string): void {
+ let i = this.errors.length;
+ // `splice` re-indexes the array. It's better to "iterate and modify" from the last element.
+ while (i--) {
+ const error = this.errors[i];
+ if (error.uri === uri) {
+ this.errors.splice(i, 1);
+ }
+ }
+ this.onDidChangeEmitter.fire(this);
+ }
+
+ /**
+ * If the text document changes in the line where compiler errors are, the compiler errors will be removed.
+ */
+ private handleDocumentContentChange(
+ monacoEditor: MonacoEditor,
+ event: TextDocumentChangeEvent
+ ): void {
+ const errorsPerResource = this.errors.filter(
+ (error) => error.uri === event.document.uri
+ );
+ let editorOrModel: MonacoEditor | ITextModel = monacoEditor;
+ const doc = event.document;
+ if (doc instanceof MonacoEditorModel) {
+ editorOrModel = doc.textEditorModel;
+ }
+ const rangesPerResource = ErrorDecoration.rangeOf(
+ editorOrModel,
+ errorsPerResource
+ );
+ const resolvedDecorations = rangesPerResource.map((range, index) => ({
+ error: errorsPerResource[index],
+ range,
+ }));
+ const decoratorsToRemove = event.contentChanges
+ .map(({ range }) => this.p2m.asRange(range))
+ .map((changedRange) =>
+ resolvedDecorations
+ .filter(({ range: decorationRange }) => {
+ if (!decorationRange) {
+ return false;
+ }
+ const affects =
+ changedRange.startLineNumber <= decorationRange.startLineNumber &&
+ changedRange.endLineNumber >= decorationRange.endLineNumber;
+ console.log(
+ 'compiler-errors',
+ `decoration range: ${decorationRange.toString()}, change range: ${changedRange.toString()}, affects: ${affects}`
+ );
+ return affects;
+ })
+ .map(({ error }) => {
+ const index = this.errors.findIndex((candidate) =>
+ ErrorDecorationRef.sameAs(candidate, error)
+ );
+ return index !== -1 ? { error, index } : undefined;
+ })
+ .filter(notEmpty)
+ )
+ .reduce((acc, curr) => acc.concat(curr), [])
+ .sort((left, right) => left.index - right.index); // highest index last
+
+ if (decoratorsToRemove.length) {
+ let i = decoratorsToRemove.length;
+ while (i--) {
+ this.errors.splice(decoratorsToRemove[i].index, 1);
+ }
+ monacoEditor.getControl().deltaDecorations(
+ decoratorsToRemove.map(({ error }) => error.id),
+ []
+ );
+ this.onDidChangeEmitter.fire(this);
+ }
+ }
+
+ private async trackEditors(
+ errors: Map,
+ ...track: ((editor: MonacoEditor) => Disposable)[]
+ ): Promise {
+ return new DisposableCollection(
+ ...(await Promise.all(
+ Array.from(errors.keys()).map(async (uri) => {
+ const editor = await this.monacoEditor(uri);
+ if (!editor) {
+ return Disposable.NULL;
+ }
+ return new DisposableCollection(...track.map((t) => t(editor)));
+ })
+ ))
+ );
+ }
+
+ private async markAsCurrentError(
+ ref: ErrorDecorationRef,
+ options?: { forceReselect?: boolean; reveal?: boolean }
+ ): Promise {
+ const index = this.errors.findIndex((candidate) =>
+ ErrorDecorationRef.sameAs(candidate, ref)
+ );
+ if (index < 0) {
+ console.warn(
+ 'compiler-errors',
+ `Failed to mark error ${
+ ref.id
+ } as the current one. Error is unknown. Known errors are: ${this.errors.map(
+ ({ id }) => id
+ )}`
+ );
+ return;
+ }
+ const newError = this.errors[index];
+ if (
+ options?.forceReselect ||
+ !this.currentError ||
+ !ErrorDecorationRef.sameAs(this.currentError, newError)
+ ) {
+ this.currentError = this.errors[index];
+ console.log(
+ 'compiler-errors',
+ `Current error changed to ${this.currentError.id}`
+ );
+ if (options?.reveal) {
+ this.currentErrorDidChangEmitter.fire(this.currentError);
+ }
+ this.onDidChangeEmitter.fire(this);
+ }
+ }
+
+ // The double editor activation logic is required: https://github.com/eclipse-theia/theia/issues/11284
+ private async revealLocationInEditor(
+ location: Location
+ ): Promise {
+ const { uri, range } = location;
+ const editor = await this.editorManager.getByUri(new URI(uri), {
+ mode: 'activate',
+ });
+ if (editor && this.shell) {
+ // to avoid flickering, reveal the range here and not with `getByUri`, because it uses `at: 'center'` for the reveal option.
+ // TODO: check the community reaction whether it is better to set the focus at the error marker. it might cause flickering even if errors are close to each other
+ editor.editor.revealRange(range, { at: this.revealStrategy });
+ const activeWidget = await this.shell.activateWidget(editor.id);
+ if (!activeWidget) {
+ console.warn(
+ 'compiler-errors',
+ `editor widget activation has failed. editor widget ${editor.id} expected to be the active one.`
+ );
+ return editor;
+ }
+ if (editor !== activeWidget) {
+ console.warn(
+ 'compiler-errors',
+ `active widget was not the same as previously activated editor. editor widget ID ${editor.id}, active widget ID: ${activeWidget.id}`
+ );
+ }
+ return editor;
+ }
+ console.warn(
+ 'compiler-errors',
+ `could not find editor widget for URI: ${uri}`
+ );
+ return undefined;
+ }
+
+ private groupBy(
+ elements: V[],
+ extractKey: (element: V) => K
+ ): Map {
+ return elements.reduce((acc, curr) => {
+ const key = extractKey(curr);
+ let values = acc.get(key);
+ if (!values) {
+ values = [];
+ acc.set(key, values);
+ }
+ values.push(curr);
+ return acc;
+ }, new Map());
+ }
+
+ private monacoEditor(widget: EditorWidget): MonacoEditor | undefined;
+ private monacoEditor(
+ uri: string | monaco.Uri
+ ): Promise;
+ private monacoEditor(
+ uriOrWidget: string | monaco.Uri | EditorWidget
+ ): MaybePromise {
+ if (uriOrWidget instanceof EditorWidget) {
+ const editor = uriOrWidget.editor;
+ if (editor instanceof MonacoEditor) {
+ return editor;
+ }
+ return undefined;
+ } else {
+ return this.editorManager
+ .getByUri(new URI(uriOrWidget.toString()))
+ .then((editor) => {
+ if (editor) {
+ return this.monacoEditor(editor);
+ }
+ return undefined;
+ });
+ }
+ }
+}
+export namespace CompilerErrors {
+ export namespace Commands {
+ export const NEXT_ERROR: Command = {
+ id: 'arduino-editor-next-error',
+ };
+ export const PREVIOUS_ERROR: Command = {
+ id: 'arduino-editor-previous-error',
+ };
+ export const MARK_AS_CURRENT: Command = {
+ id: 'arduino-editor-mark-as-current-error',
+ };
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/contribution.ts b/arduino-ide-extension/src/browser/contributions/contribution.ts
index ee65185c0..781b832fc 100644
--- a/arduino-ide-extension/src/browser/contributions/contribution.ts
+++ b/arduino-ide-extension/src/browser/contributions/contribution.ts
@@ -1,56 +1,87 @@
-import { inject, injectable, interfaces } from 'inversify';
-import URI from '@theia/core/lib/common/uri';
-import { ILogger } from '@theia/core/lib/common/logger';
-import { Saveable } from '@theia/core/lib/browser/saveable';
-import { FileService } from '@theia/filesystem/lib/browser/file-service';
-import { MaybePromise } from '@theia/core/lib/common/types';
-import { LabelProvider } from '@theia/core/lib/browser/label-provider';
-import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
-import { MessageService } from '@theia/core/lib/common/message-service';
-import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
-import { open, OpenerService } from '@theia/core/lib/browser/opener-service';
-import { OutputChannelManager } from '@theia/output/lib/browser/output-channel';
-import {
- MenuModelRegistry,
- MenuContribution,
-} from '@theia/core/lib/common/menu';
+import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
+import { FrontendApplication } from '@theia/core/lib/browser/frontend-application';
+import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application-contribution';
+import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import {
- KeybindingRegistry,
KeybindingContribution,
+ KeybindingRegistry,
} from '@theia/core/lib/browser/keybinding';
+import { LabelProvider } from '@theia/core/lib/browser/label-provider';
+import { OpenerService, open } from '@theia/core/lib/browser/opener-service';
+import { Saveable } from '@theia/core/lib/browser/saveable';
+import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
import {
TabBarToolbarContribution,
TabBarToolbarRegistry,
} from '@theia/core/lib/browser/shell/tab-bar-toolbar';
-import {
- FrontendApplicationContribution,
- FrontendApplication,
-} from '@theia/core/lib/browser/frontend-application';
+import { CancellationToken } from '@theia/core/lib/common/cancellation';
import {
Command,
- CommandRegistry,
CommandContribution,
+ CommandRegistry,
CommandService,
} from '@theia/core/lib/common/command';
-import { EditorMode } from '../editor-mode';
-import { SettingsService } from '../dialogs/settings/settings';
-import { SketchesServiceClientImpl } from '../../common/protocol/sketches-service-client-impl';
import {
- SketchesService,
- ConfigService,
+ Disposable,
+ DisposableCollection,
+} from '@theia/core/lib/common/disposable';
+import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
+import { ILogger } from '@theia/core/lib/common/logger';
+import {
+ MenuContribution,
+ MenuModelRegistry,
+} from '@theia/core/lib/common/menu';
+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 { MaybePromise, isObject } from '@theia/core/lib/common/types';
+import URI from '@theia/core/lib/common/uri';
+import {
+ inject,
+ injectable,
+ interfaces,
+ postConstruct,
+} from '@theia/core/shared/inversify';
+import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
+import { FileService } from '@theia/filesystem/lib/browser/file-service';
+import { NotificationManager } from '@theia/messages/lib/browser/notifications-manager';
+import { OutputChannelSeverity } from '@theia/output/lib/browser/output-channel';
+import { MainMenuManager } from '../../common/main-menu-manager';
+import { userAbort } from '../../common/nls';
+import {
+ CoreError,
+ CoreService,
FileSystemExt,
+ ResponseServiceClient,
Sketch,
+ SketchesService,
} from '../../common/protocol';
+import {
+ ExecuteWithProgress,
+ UserAbortApplicationError,
+} from '../../common/protocol/progressible';
import { ArduinoPreferences } from '../arduino-preferences';
+import { BoardsDataStore } from '../boards/boards-data-store';
+import { BoardsServiceProvider } from '../boards/boards-service-provider';
+import { ConfigServiceClient } from '../config/config-service-client';
+import { DialogService } from '../dialog-service';
+import { SettingsService } from '../dialogs/settings/settings';
+import {
+ CurrentSketch,
+ SketchesServiceClientImpl,
+} from '../sketches-service-client-impl';
+import { ApplicationConnectionStatusContribution } from '../theia/core/connection-status-service';
+import { OutputChannelManager } from '../theia/output/output-channel';
+import { WorkspaceService } from '../theia/workspace/workspace-service';
export {
Command,
CommandRegistry,
- MenuModelRegistry,
KeybindingRegistry,
+ MenuModelRegistry,
+ Sketch,
TabBarToolbarRegistry,
URI,
- Sketch,
open,
};
@@ -75,24 +106,46 @@ export abstract class Contribution
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
- @inject(EditorMode)
- protected readonly editorMode: EditorMode;
-
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(SettingsService)
protected readonly settingsService: SettingsService;
+ @inject(ArduinoPreferences)
+ protected readonly preferences: ArduinoPreferences;
+
+ @inject(FrontendApplicationStateService)
+ protected readonly appStateService: FrontendApplicationStateService;
+
+ @inject(MainMenuManager)
+ protected readonly menuManager: MainMenuManager;
+
+ @inject(DialogService)
+ protected readonly dialogService: DialogService;
+
+ @postConstruct()
+ protected init(): void {
+ this.appStateService.reachedState('ready').then(() => this.onReady());
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function, unused-imports/no-unused-vars
onStart(app: FrontendApplication): MaybePromise {}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function, unused-imports/no-unused-vars
registerCommands(registry: CommandRegistry): void {}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function, unused-imports/no-unused-vars
registerMenus(registry: MenuModelRegistry): void {}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function, unused-imports/no-unused-vars
registerKeybindings(registry: KeybindingRegistry): void {}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function, unused-imports/no-unused-vars
registerToolbarItems(registry: TabBarToolbarRegistry): void {}
+
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ onReady(): MaybePromise {}
}
@injectable()
@@ -103,11 +156,11 @@ export abstract class SketchContribution extends Contribution {
@inject(FileSystemExt)
protected readonly fileSystemExt: FileSystemExt;
- @inject(ConfigService)
- protected readonly configService: ConfigService;
+ @inject(ConfigServiceClient)
+ protected readonly configService: ConfigServiceClient;
@inject(SketchesService)
- protected readonly sketchService: SketchesService;
+ protected readonly sketchesService: SketchesService;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@@ -115,19 +168,22 @@ export abstract class SketchContribution extends Contribution {
@inject(SketchesServiceClientImpl)
protected readonly sketchServiceClient: SketchesServiceClientImpl;
- @inject(ArduinoPreferences)
- protected readonly preferences: ArduinoPreferences;
-
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(OutputChannelManager)
protected readonly outputChannelManager: OutputChannelManager;
+ @inject(EnvVariablesServer)
+ protected readonly envVariableServer: EnvVariablesServer;
+
+ @inject(ApplicationConnectionStatusContribution)
+ protected readonly connectionStatusService: ApplicationConnectionStatusContribution;
+
protected async sourceOverride(): Promise> {
const override: Record = {};
const sketch = await this.sketchServiceClient.currentSketch();
- if (sketch) {
+ if (CurrentSketch.isValid(sketch)) {
for (const editor of this.editorManager.all) {
const uri = editor.editor.uri;
if (Saveable.isDirty(editor) && Sketch.isInSketch(uri, sketch)) {
@@ -137,10 +193,169 @@ export abstract class SketchContribution extends Contribution {
}
return override;
}
+
+ /**
+ * Defaults to `directories.user` if defined and not CLI config errors were detected.
+ * Otherwise, the URI of the user home directory.
+ */
+ protected async defaultUri(): Promise {
+ const errors = this.configService.tryGetMessages();
+ let defaultUri = this.configService.tryGetSketchDirUri();
+ if (!defaultUri || errors?.length) {
+ // Fall back to user home when the `directories.user` is not available or there are known CLI config errors
+ defaultUri = new URI(await this.envVariableServer.getHomeDirUri());
+ }
+ return defaultUri;
+ }
+
+ protected async defaultPath(): Promise {
+ const defaultUri = await this.defaultUri();
+ return this.fileService.fsPath(defaultUri);
+ }
+}
+
+@injectable()
+export abstract class CoreServiceContribution extends SketchContribution {
+ @inject(BoardsDataStore)
+ protected readonly boardsDataStore: BoardsDataStore;
+
+ @inject(BoardsServiceProvider)
+ protected readonly boardsServiceProvider: BoardsServiceProvider;
+
+ @inject(CoreService)
+ private readonly coreService: CoreService;
+
+ @inject(ClipboardService)
+ private readonly clipboardService: ClipboardService;
+
+ @inject(ResponseServiceClient)
+ private readonly responseService: ResponseServiceClient;
+
+ @inject(NotificationManager)
+ private readonly notificationManager: NotificationManager;
+
+ @inject(ApplicationShell)
+ private readonly shell: ApplicationShell;
+
+ /**
+ * This is the internal (Theia) ID of the notification that is currently visible.
+ * It's stored here as a field to be able to close it before executing any new core command (such as verify, upload, etc.)
+ */
+ private visibleNotificationId: string | undefined;
+
+ protected clearVisibleNotification(): void {
+ if (this.visibleNotificationId) {
+ this.notificationManager.clear(this.visibleNotificationId);
+ this.visibleNotificationId = undefined;
+ }
+ }
+
+ protected handleError(error: unknown): void {
+ if (isObject(error) && UserAbortApplicationError.is(error)) {
+ this.outputChannelManager
+ .getChannel('Arduino')
+ .appendLine(userAbort, OutputChannelSeverity.Warning);
+ return;
+ }
+ this.tryToastErrorMessage(error);
+ }
+
+ private tryToastErrorMessage(error: unknown): void {
+ let message: undefined | string = undefined;
+ if (CoreError.is(error)) {
+ message = error.message;
+ } else if (error instanceof Error) {
+ message = error.message;
+ } else if (typeof error === 'string') {
+ message = error;
+ } else {
+ try {
+ message = JSON.stringify(error);
+ } catch {}
+ }
+ if (message) {
+ if (message.includes('Missing FQBN (Fully Qualified Board Name)')) {
+ message = nls.localize(
+ 'arduino/coreContribution/noBoardSelected',
+ 'No board selected. Please select your Arduino board from the Tools > Board menu.'
+ );
+ }
+ const copyAction = nls.localize(
+ 'arduino/coreContribution/copyError',
+ 'Copy error messages'
+ );
+ this.visibleNotificationId = this.notificationId(message, copyAction);
+ this.messageService.error(message, copyAction).then(async (action) => {
+ if (action === copyAction) {
+ const content = await this.outputChannelManager.contentOfChannel(
+ 'Arduino'
+ );
+ if (content) {
+ this.clipboardService.writeText(content);
+ }
+ }
+ });
+ } else {
+ throw error;
+ }
+ }
+
+ protected async doWithProgress(options: {
+ progressText: string;
+ keepOutput?: boolean;
+ task: (
+ progressId: string,
+ coreService: CoreService,
+ cancellationToken?: CancellationToken
+ ) => Promise;
+ // false by default
+ cancelable?: boolean;
+ }): Promise {
+ const toDisposeOnComplete = new DisposableCollection(
+ this.maybeActivateMonitorWidget()
+ );
+ const { progressText, keepOutput, task } = options;
+ this.outputChannelManager
+ .getChannel('Arduino')
+ .show({ preserveFocus: true });
+ const result = await ExecuteWithProgress.doWithProgress({
+ messageService: this.messageService,
+ responseService: this.responseService,
+ progressText,
+ run: ({ progressId, cancellationToken }) =>
+ task(progressId, this.coreService, cancellationToken),
+ keepOutput,
+ cancelable: options.cancelable,
+ });
+ toDisposeOnComplete.dispose();
+ return result;
+ }
+
+ // TODO: cleanup!
+ // this dependency does not belong here
+ // support core command contribution handlers, the monitor-widget should implement it and register itself as a handler
+ // the monitor widget should reveal itself after a successful core command execution
+ private maybeActivateMonitorWidget(): Disposable {
+ const currentWidget = this.shell.bottomPanel.currentTitle?.owner;
+ if (currentWidget?.id === 'serial-monitor') {
+ return Disposable.create(() =>
+ this.shell.bottomPanel.activateWidget(currentWidget)
+ );
+ }
+ return Disposable.NULL;
+ }
+
+ private notificationId(message: string, ...actions: string[]): string {
+ return this.notificationManager['getMessageId']({
+ text: message,
+ actions,
+ type: MessageType.Error,
+ });
+ }
}
export namespace Contribution {
- export function configure(
+ export function configure(
bind: interfaces.Bind,
serviceIdentifier: typeof Contribution
): void {
diff --git a/arduino-ide-extension/src/browser/contributions/core-error-handler.ts b/arduino-ide-extension/src/browser/contributions/core-error-handler.ts
new file mode 100644
index 000000000..82aba4c00
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/core-error-handler.ts
@@ -0,0 +1,32 @@
+import { Emitter, Event } from '@theia/core';
+import { injectable } from '@theia/core/shared/inversify';
+import { CoreError } from '../../common/protocol/core-service';
+
+@injectable()
+export class CoreErrorHandler {
+ private readonly errors: CoreError.ErrorLocation[] = [];
+ private readonly compilerErrorsDidChangeEmitter = new Emitter<
+ CoreError.ErrorLocation[]
+ >();
+
+ tryHandle(error: unknown): void {
+ if (CoreError.is(error)) {
+ this.errors.length = 0;
+ this.errors.push(...error.data);
+ this.fireCompilerErrorsDidChange();
+ }
+ }
+
+ reset(): void {
+ this.errors.length = 0;
+ this.fireCompilerErrorsDidChange();
+ }
+
+ get onCompilerErrorsDidChange(): Event {
+ return this.compilerErrorsDidChangeEmitter.event;
+ }
+
+ private fireCompilerErrorsDidChange(): void {
+ this.compilerErrorsDidChangeEmitter.fire(this.errors.slice());
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/create-cloud-copy.ts b/arduino-ide-extension/src/browser/contributions/create-cloud-copy.ts
new file mode 100644
index 000000000..73b967f0f
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/create-cloud-copy.ts
@@ -0,0 +1,118 @@
+import { FrontendApplication } from '@theia/core/lib/browser/frontend-application';
+import { ApplicationShell } from '@theia/core/lib/browser/shell';
+import type { Command, CommandRegistry } from '@theia/core/lib/common/command';
+import { Progress } from '@theia/core/lib/common/message-service-protocol';
+import { nls } from '@theia/core/lib/common/nls';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { Create } from '../create/typings';
+import { ApplicationConnectionStatusContribution } from '../theia/core/connection-status-service';
+import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree';
+import { SketchbookTree } from '../widgets/sketchbook/sketchbook-tree';
+import { SketchbookTreeModel } from '../widgets/sketchbook/sketchbook-tree-model';
+import { CloudSketchContribution, pushingSketch } from './cloud-contribution';
+import {
+ CreateNewCloudSketchCallback,
+ NewCloudSketch,
+ NewCloudSketchParams,
+} from './new-cloud-sketch';
+import { saveOntoCopiedSketch } from './save-as-sketch';
+
+interface CreateCloudCopyParams {
+ readonly model: SketchbookTreeModel;
+ readonly node: SketchbookTree.SketchDirNode;
+}
+function isCreateCloudCopyParams(arg: unknown): arg is CreateCloudCopyParams {
+ return (
+ typeof arg === 'object' &&
+ (arg).model !== undefined &&
+ (arg).model instanceof SketchbookTreeModel &&
+ (arg).node !== undefined &&
+ SketchbookTree.SketchDirNode.is((arg).node)
+ );
+}
+
+@injectable()
+export class CreateCloudCopy extends CloudSketchContribution {
+ @inject(ApplicationConnectionStatusContribution)
+ private readonly connectionStatus: ApplicationConnectionStatusContribution;
+
+ private shell: ApplicationShell;
+
+ override onStart(app: FrontendApplication): void {
+ this.shell = app.shell;
+ }
+
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(CreateCloudCopy.Commands.CREATE_CLOUD_COPY, {
+ execute: (args: CreateCloudCopyParams) => this.createCloudCopy(args),
+ isEnabled: (args: unknown) =>
+ Boolean(this.createFeatures.session) && isCreateCloudCopyParams(args),
+ isVisible: (args: unknown) =>
+ Boolean(this.createFeatures.enabled) &&
+ Boolean(this.createFeatures.session) &&
+ this.connectionStatus.offlineStatus !== 'internet' &&
+ isCreateCloudCopyParams(args),
+ });
+ }
+
+ /**
+ * - creates new cloud sketch with the name of the params sketch,
+ * - pulls the cloud sketch,
+ * - copies files from params sketch to pulled cloud sketch in the cache folder,
+ * - pushes the cloud sketch, and
+ * - opens in new window.
+ */
+ private async createCloudCopy(params: CreateCloudCopyParams): Promise {
+ const sketch = await this.sketchesService.loadSketch(
+ params.node.fileStat.resource.toString()
+ );
+ const callback: CreateNewCloudSketchCallback = async (
+ newSketch: Create.Sketch,
+ newNode: CloudSketchbookTree.CloudSketchDirNode,
+ progress: Progress
+ ) => {
+ const treeModel = await this.treeModel();
+ if (!treeModel) {
+ throw new Error('Could not retrieve the cloud sketchbook tree model.');
+ }
+
+ progress.report({
+ message: nls.localize(
+ 'arduino/createCloudCopy/copyingSketchFilesMessage',
+ 'Copying local sketch files...'
+ ),
+ });
+ const localCacheFolderUri = newNode.uri.toString();
+ await this.sketchesService.copy(sketch, {
+ destinationUri: localCacheFolderUri,
+ onlySketchFiles: true,
+ });
+ await saveOntoCopiedSketch(
+ sketch,
+ localCacheFolderUri,
+ this.shell,
+ this.editorManager
+ );
+
+ progress.report({ message: pushingSketch(newSketch.name) });
+ await treeModel.sketchbookTree().push(newNode, true, true);
+ };
+ return this.commandService.executeCommand(
+ NewCloudSketch.Commands.NEW_CLOUD_SKETCH.id,
+ {
+ initialValue: params.node.fileStat.name,
+ callback,
+ skipShowErrorMessageOnOpen: false,
+ }
+ );
+ }
+}
+
+export namespace CreateCloudCopy {
+ export namespace Commands {
+ export const CREATE_CLOUD_COPY: Command = {
+ id: 'arduino-create-cloud-copy',
+ iconClass: 'fa fa-arduino-cloud-upload',
+ };
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/daemon.ts b/arduino-ide-extension/src/browser/contributions/daemon.ts
new file mode 100644
index 000000000..740dcccf7
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/daemon.ts
@@ -0,0 +1,41 @@
+import { nls } from '@theia/core';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { ArduinoDaemon } from '../../common/protocol';
+import { Contribution, Command, CommandRegistry } from './contribution';
+
+@injectable()
+export class Daemon extends Contribution {
+ @inject(ArduinoDaemon)
+ private readonly daemon: ArduinoDaemon;
+
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(Daemon.Commands.START_DAEMON, {
+ execute: () => this.daemon.start(),
+ });
+ registry.registerCommand(Daemon.Commands.STOP_DAEMON, {
+ execute: () => this.daemon.stop(),
+ });
+ registry.registerCommand(Daemon.Commands.RESTART_DAEMON, {
+ execute: () => this.daemon.restart(),
+ });
+ }
+}
+export namespace Daemon {
+ export namespace Commands {
+ export const START_DAEMON: Command = {
+ id: 'arduino-start-daemon',
+ label: nls.localize('arduino/daemon/start', 'Start Daemon'),
+ category: 'Arduino',
+ };
+ export const STOP_DAEMON: Command = {
+ id: 'arduino-stop-daemon',
+ label: nls.localize('arduino/daemon/stop', 'Stop Daemon'),
+ category: 'Arduino',
+ };
+ export const RESTART_DAEMON: Command = {
+ id: 'arduino-restart-daemon',
+ label: nls.localize('arduino/daemon/restart', 'Restart Daemon'),
+ category: 'Arduino',
+ };
+ }
+}
diff --git a/arduino-ide-extension/src/browser/contributions/debug.ts b/arduino-ide-extension/src/browser/contributions/debug.ts
index a67f832e9..93dd2aa51 100644
--- a/arduino-ide-extension/src/browser/contributions/debug.ts
+++ b/arduino-ide-extension/src/browser/contributions/debug.ts
@@ -1,185 +1,347 @@
-import { inject, injectable } from 'inversify';
-import { Event, Emitter } from '@theia/core/lib/common/event';
-import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin';
-import { ArduinoToolbar } from '../toolbar/arduino-toolbar';
-import { NotificationCenter } from '../notification-center';
-import { Board, BoardsService, ExecutableService } from '../../common/protocol';
+import { Emitter, Event } from '@theia/core/lib/common/event';
+import { MenuModelRegistry } from '@theia/core/lib/common/menu/menu-model-registry';
+import { nls } from '@theia/core/lib/common/nls';
+import { MaybePromise } from '@theia/core/lib/common/types';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { noBoardSelected } from '../../common/nls';
+import {
+ BoardDetails,
+ BoardIdentifier,
+ BoardsService,
+ CheckDebugEnabledParams,
+ ExecutableService,
+ SketchRef,
+ isBoardIdentifierChangeEvent,
+ isCompileSummary,
+} from '../../common/protocol';
+import { BoardsDataStore } from '../boards/boards-data-store';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
+import { HostedPluginSupport } from '../hosted/hosted-plugin-support';
+import { ArduinoMenus } from '../menu/arduino-menus';
+import { NotificationCenter } from '../notification-center';
+import { CurrentSketch } from '../sketches-service-client-impl';
+import { ArduinoToolbar } from '../toolbar/arduino-toolbar';
import {
- URI,
Command,
CommandRegistry,
SketchContribution,
TabBarToolbarRegistry,
+ URI,
} from './contribution';
-import { nls } from '@theia/core/lib/common';
+
+const COMPILE_FOR_DEBUG_KEY = 'arduino-compile-for-debug';
+
+interface StartDebugParams {
+ /**
+ * Absolute filesystem path to the Arduino CLI executable.
+ */
+ readonly cliPath: string;
+ /**
+ * The the board to debug.
+ */
+ readonly board: Readonly<{ fqbn: string; name?: string }>;
+ /**
+ * Absolute filesystem path of the sketch to debug.
+ */
+ readonly sketchPath: string;
+ /**
+ * Location where the `launch.json` will be created on the fly before starting every debug session.
+ * If not defined, it falls back to `sketchPath/.vscode/launch.json`.
+ */
+ readonly launchConfigsDirPath?: string;
+ /**
+ * Absolute path to the `arduino-cli.yaml` file. If not specified, it falls back to `~/.arduinoIDE/arduino-cli.yaml`.
+ */
+ readonly cliConfigPath?: string;
+ /**
+ * Programmer for the debugging.
+ */
+ readonly programmer?: string;
+ /**
+ * Custom progress title to use when getting the debug information from the CLI.
+ */
+ readonly title?: string;
+}
+type StartDebugResult = boolean;
+
+export const DebugDisabledStatusMessageSource = Symbol(
+ 'DebugDisabledStatusMessageSource'
+);
+export interface DebugDisabledStatusMessageSource {
+ /**
+ * `undefined` if debugging is enabled (for the currently selected board + programmer + config options).
+ * Otherwise, it's the human readable message why it's disabled.
+ */
+ get message(): string | undefined;
+ /**
+ * Emits an event when {@link message} changes.
+ */
+ get onDidChangeMessage(): Event;
+}
@injectable()
-export class Debug extends SketchContribution {
+export class Debug
+ extends SketchContribution
+ implements DebugDisabledStatusMessageSource
+{
@inject(HostedPluginSupport)
- protected hostedPluginSupport: HostedPluginSupport;
-
+ private readonly hostedPluginSupport: HostedPluginSupport;
@inject(NotificationCenter)
- protected readonly notificationCenter: NotificationCenter;
-
+ private readonly notificationCenter: NotificationCenter;
@inject(ExecutableService)
- protected readonly executableService: ExecutableService;
-
+ private readonly executableService: ExecutableService;
@inject(BoardsService)
- protected readonly boardService: BoardsService;
-
+ private readonly boardService: BoardsService;
@inject(BoardsServiceProvider)
- protected readonly boardsServiceProvider: BoardsServiceProvider;
+ private readonly boardsServiceProvider: BoardsServiceProvider;
+ @inject(BoardsDataStore)
+ private readonly boardsDataStore: BoardsDataStore;
/**
- * If `undefined`, debugging is enabled. Otherwise, the reason why it's disabled.
+ * If `undefined`, debugging is enabled. Otherwise, the human-readable reason why it's disabled.
*/
- protected _disabledMessages?: string = nls.localize(
- 'arduino/common/noBoardSelected',
- 'No board selected'
- ); // Initial pessimism.
- protected disabledMessageDidChangeEmitter = new Emitter();
- protected onDisabledMessageDidChange =
- this.disabledMessageDidChangeEmitter.event;
+ private _message?: string = noBoardSelected; // Initial pessimism.
+ private readonly didChangeMessageEmitter = new Emitter();
+ readonly onDidChangeMessage = this.didChangeMessageEmitter.event;
- protected get disabledMessage(): string | undefined {
- return this._disabledMessages;
+ get message(): string | undefined {
+ return this._message;
}
- protected set disabledMessage(message: string | undefined) {
- this._disabledMessages = message;
- this.disabledMessageDidChangeEmitter.fire(this._disabledMessages);
+ private set message(message: string | undefined) {
+ this._message = message;
+ this.didChangeMessageEmitter.fire(this._message);
}
- protected readonly debugToolbarItem = {
+ private readonly debugToolbarItem = {
id: Debug.Commands.START_DEBUGGING.id,
command: Debug.Commands.START_DEBUGGING.id,
tooltip: `${
- this.disabledMessage
+ this.message
? nls.localize(
'arduino/debug/debugWithMessage',
'Debug - {0}',
- this.disabledMessage
+ this.message
)
: Debug.Commands.START_DEBUGGING.label
}`,
priority: 3,
- onDidChange: this.onDisabledMessageDidChange as Event,
+ onDidChange: this.onDidChangeMessage as Event,
};
- onStart(): void {
- this.onDisabledMessageDidChange(
+ override onStart(): void {
+ this.onDidChangeMessage(
() =>
(this.debugToolbarItem.tooltip = `${
- this.disabledMessage
+ this.message
? nls.localize(
'arduino/debug/debugWithMessage',
'Debug - {0}',
- this.disabledMessage
+ this.message
)
: Debug.Commands.START_DEBUGGING.label
}`)
);
- const refreshState = async (
- board: Board | undefined = this.boardsServiceProvider.boardsConfig
- .selectedBoard
- ) => {
- if (!board) {
- this.disabledMessage = nls.localize(
- 'arduino/common/noBoardSelected',
- 'No board selected'
- );
- return;
+ this.boardsServiceProvider.onBoardsConfigDidChange((event) => {
+ if (isBoardIdentifierChangeEvent(event)) {
+ this.updateMessage();
}
- const fqbn = board.fqbn;
- if (!fqbn) {
- this.disabledMessage = nls.localize(
- 'arduino/debug/noPlatformInstalledFor',
- "Platform is not installed for '{0}'",
- board.name
- );
- return;
- }
- const details = await this.boardService.getBoardDetails({ fqbn });
- if (!details) {
- this.disabledMessage = nls.localize(
- 'arduino/debug/noPlatformInstalledFor',
- "Platform is not installed for '{0}'",
- board.name
- );
- return;
+ });
+ this.notificationCenter.onPlatformDidInstall(() => this.updateMessage());
+ this.notificationCenter.onPlatformDidUninstall(() => this.updateMessage());
+ this.boardsDataStore.onDidChange((event) => {
+ const selectedFqbn =
+ this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn;
+ if (event.changes.find((change) => change.fqbn === selectedFqbn)) {
+ this.updateMessage();
}
- const { debuggingSupported } = details;
- if (!debuggingSupported) {
- this.disabledMessage = nls.localize(
- 'arduino/debug/debuggingNotSupported',
- "Debugging is not supported by '{0}'",
- board.name
- );
- } else {
- this.disabledMessage = undefined;
+ });
+ this.commandService.onDidExecuteCommand((event) => {
+ const { commandId, args } = event;
+ if (
+ commandId === 'arduino.languageserver.notifyBuildDidComplete' &&
+ isCompileSummary(args[0])
+ ) {
+ this.updateMessage();
}
- };
- this.boardsServiceProvider.onBoardsConfigChanged(({ selectedBoard }) =>
- refreshState(selectedBoard)
- );
- this.notificationCenter.onPlatformInstalled(() => refreshState());
- this.notificationCenter.onPlatformUninstalled(() => refreshState());
- refreshState();
+ });
+ }
+
+ override onReady(): void {
+ this.boardsServiceProvider.ready.then(() => this.updateMessage());
}
- registerCommands(registry: CommandRegistry): void {
+ override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(Debug.Commands.START_DEBUGGING, {
execute: () => this.startDebug(),
isVisible: (widget) =>
ArduinoToolbar.is(widget) && widget.side === 'left',
- isEnabled: () => !this.disabledMessage,
+ isEnabled: () => !this.message,
+ });
+ registry.registerCommand(Debug.Commands.TOGGLE_OPTIMIZE_FOR_DEBUG, {
+ execute: () => this.toggleCompileForDebug(),
+ isToggled: () => this.compileForDebug,
+ });
+ registry.registerCommand(Debug.Commands.IS_OPTIMIZE_FOR_DEBUG, {
+ execute: () => this.compileForDebug,
});
}
- registerToolbarItems(registry: TabBarToolbarRegistry): void {
+ override registerToolbarItems(registry: TabBarToolbarRegistry): void {
registry.registerItem(this.debugToolbarItem);
}
- protected async startDebug(
- board: Board | undefined = this.boardsServiceProvider.boardsConfig
+ override registerMenus(registry: MenuModelRegistry): void {
+ registry.registerMenuAction(ArduinoMenus.SKETCH__MAIN_GROUP, {
+ commandId: Debug.Commands.TOGGLE_OPTIMIZE_FOR_DEBUG.id,
+ label: Debug.Commands.TOGGLE_OPTIMIZE_FOR_DEBUG.label,
+ order: '5',
+ });
+ }
+
+ private async updateMessage(): Promise {
+ try {
+ await this.isDebugEnabled();
+ this.message = undefined;
+ } catch (err) {
+ let message = String(err);
+ if (err instanceof Error) {
+ message = err.message;
+ }
+ this.message = message;
+ }
+ }
+
+ private async isDebugEnabled(
+ board: BoardIdentifier | undefined = this.boardsServiceProvider.boardsConfig
.selectedBoard
- ): Promise {
- if (!board) {
- return;
+ ): Promise {
+ const debugFqbn = await isDebugEnabled(
+ board,
+ (fqbn) => this.boardService.getBoardDetails({ fqbn }),
+ (fqbn) => this.boardsDataStore.getData(fqbn),
+ (fqbn) => this.boardsDataStore.appendConfigToFqbn(fqbn),
+ (params) => this.boardService.checkDebugEnabled(params)
+ );
+ return debugFqbn;
+ }
+
+ private async startDebug(
+ board: BoardIdentifier | undefined = this.boardsServiceProvider.boardsConfig
+ .selectedBoard,
+ sketch:
+ | CurrentSketch
+ | undefined = this.sketchServiceClient.tryGetCurrentSketch()
+ ): Promise {
+ if (!CurrentSketch.isValid(sketch)) {
+ return false;
}
- const { name, fqbn } = board;
- if (!fqbn) {
- return;
+ const params = await this.createStartDebugParams(board);
+ if (!params) {
+ return false;
}
await this.hostedPluginSupport.didStart;
- const [sketch, executables] = await Promise.all([
+ try {
+ const result = await this.debug(params);
+ return Boolean(result);
+ } catch (err) {
+ if (await this.isSketchNotVerifiedError(err, sketch)) {
+ const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes');
+ const answer = await this.messageService.error(
+ sketchIsNotCompiled(sketch.name),
+ yes
+ );
+ if (answer === yes) {
+ this.commandService.executeCommand('arduino-verify-sketch');
+ }
+ } else {
+ this.messageService.error(
+ err instanceof Error ? err.message : String(err)
+ );
+ }
+ }
+ return false;
+ }
+
+ private async debug(
+ params: StartDebugParams
+ ): Promise {
+ return this.commandService.executeCommand(
+ 'arduino.debug.start',
+ params
+ );
+ }
+
+ get compileForDebug(): boolean {
+ const value = window.localStorage.getItem(COMPILE_FOR_DEBUG_KEY);
+ return value === 'true';
+ }
+
+ private toggleCompileForDebug(): void {
+ const oldState = this.compileForDebug;
+ const newState = !oldState;
+ window.localStorage.setItem(COMPILE_FOR_DEBUG_KEY, String(newState));
+ this.menuManager.update();
+ }
+
+ private async isSketchNotVerifiedError(
+ err: unknown,
+ sketch: SketchRef
+ ): Promise {
+ if (err instanceof Error) {
+ try {
+ const buildPaths = await this.sketchesService.getBuildPath(sketch);
+ return buildPaths.some((tempBuildPath) =>
+ err.message.includes(tempBuildPath)
+ );
+ } catch {
+ return false;
+ }
+ }
+ return false;
+ }
+
+ private async createStartDebugParams(
+ board: BoardIdentifier | undefined
+ ): Promise {
+ if (!board || !board.fqbn) {
+ return undefined;
+ }
+ let debugFqbn: string | undefined = undefined;
+ try {
+ debugFqbn = await this.isDebugEnabled(board);
+ } catch {}
+ if (!debugFqbn) {
+ return undefined;
+ }
+ const [sketch, executables, boardsData] = await Promise.all([
this.sketchServiceClient.currentSketch(),
this.executableService.list(),
+ this.boardsDataStore.getData(board.fqbn),
]);
- if (!sketch) {
- return;
+ if (!CurrentSketch.isValid(sketch)) {
+ return undefined;
}
- const ideTempFolderUri = await this.sketchService.getIdeTempFolderUri(
+ const ideTempFolderUri = await this.sketchesService.getIdeTempFolderUri(
sketch
);
- const [cliPath, sketchPath, configPath] = await Promise.all([
+ const [cliPath, sketchPath, launchConfigsDirPath] = await Promise.all([
this.fileService.fsPath(new URI(executables.cliUri)),
this.fileService.fsPath(new URI(sketch.uri)),
this.fileService.fsPath(new URI(ideTempFolderUri)),
]);
- const config = {
+ return {
+ board: { fqbn: debugFqbn, name: board.name },
cliPath,
- board: {
- fqbn,
- name,
- },
sketchPath,
- configPath,
+ launchConfigsDirPath,
+ programmer: boardsData.selectedProgrammer?.id,
+ title: nls.localize(
+ 'arduino/debug/getDebugInfo',
+ 'Getting debug info...'
+ ),
};
- return this.commandService.executeCommand('arduino.debug.start', config);
}
}
-
export namespace Debug {
export namespace Commands {
export const START_DEBUGGING = Command.toLocalizedCommand(
@@ -190,5 +352,91 @@ export namespace Debug {
},
'vscode/debug.contribution/startDebuggingHelp'
);
+ export const TOGGLE_OPTIMIZE_FOR_DEBUG = Command.toLocalizedCommand(
+ {
+ id: 'arduino-toggle-optimize-for-debug',
+ label: 'Optimize for Debugging',
+ category: 'Arduino',
+ },
+ 'arduino/debug/optimizeForDebugging'
+ );
+ export const IS_OPTIMIZE_FOR_DEBUG: Command = {
+ id: 'arduino-is-optimize-for-debug',
+ };
}
}
+
+/**
+ * Resolves with the FQBN to use for the `debug --info --programmer p --fqbn $FQBN` command. Otherwise, rejects.
+ *
+ * (non-API)
+ */
+export async function isDebugEnabled(
+ board: BoardIdentifier | undefined,
+ getDetails: (fqbn: string) => MaybePromise,
+ getData: (fqbn: string) => MaybePromise,
+ appendConfigToFqbn: (fqbn: string) => MaybePromise,
+ checkDebugEnabled: (params: CheckDebugEnabledParams) => MaybePromise
+): Promise