- {this.renderItem({
- label: 'Select Other Board & Port',
- onClick: () => this.props.openBoardsConfig()
- })}
- {items.map(({ name, port, selected, onClick }) => ({ label: `${name} at ${Port.toString(port)}`, selected, onClick })).map(this.renderItem)}
+ override render(): React.ReactNode {
+ return ReactDOM.createPortal(
+ this.renderBoardListItems(),
+ this.dropdownElement
+ );
+ }
+
+ private renderBoardListItems(): React.ReactNode {
+ const { coords, boardList } = this.props;
+ if (coords === 'hidden') {
+ return '';
+ }
+ const footerLabel = nls.localize(
+ 'arduino/board/openBoardsConfig',
+ 'Select other board and port…'
+ );
+ return (
+
+
+ {boardList.items.map((item, index) =>
+ this.renderBoardListItem({
+ item,
+ selected: index === boardList.selectedIndex,
+ })
+ )}
+
this.props.openBoardsConfig()}
+ >
+
{footerLabel}
+
+
+ );
+ }
+
+ private readonly onDefaultAction = (item: BoardListItemUI): unknown => {
+ const { boardList, hide } = this.props;
+ const { type, params } = item.defaultAction;
+ hide();
+ switch (type) {
+ case 'select-boards-config': {
+ return boardList.select(params);
+ }
+ case 'edit-boards-config': {
+ return boardList.edit(params);
+ }
+ default:
+ return assertUnreachable(type);
}
+ };
- protected renderItem({ label, selected, onClick }: { label: string, selected?: boolean, onClick: () => void }): React.ReactNode {
- return
-
- {label}
+ private renderBoardListItem({
+ item,
+ selected,
+ }: {
+ item: BoardListItemUI;
+ selected: boolean;
+ }): React.ReactNode {
+ const { boardLabel, portLabel, portProtocol, tooltip } = item.labels;
+ const port = item.port;
+ const onKeyUp = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ this.onDefaultAction(item);
+ }
+ };
+ return (
+
this.onDefaultAction(item)}
+ onKeyUp={onKeyUp}
+ tabIndex={0}
+ >
+
+
+
+
+ {boardLabel}
- {selected ?
: ''}
+
+
+ {portLabel}
+
- }
+ {this.renderActions(item)}
+
+ );
+ }
+ private renderActions(item: BoardListItemUI): React.ReactNode {
+ const { boardList, hide } = this.props;
+ const { revert, edit } = item.otherActions;
+ if (!edit && !revert) {
+ return undefined;
+ }
+ const handleOnClick = (
+ event: React.MouseEvent
,
+ callback: () => void
+ ) => {
+ event.preventDefault();
+ event.stopPropagation();
+ hide();
+ callback();
+ };
+ return (
+
+ {edit && (
+
+ {
+
+ handleOnClick(event, () => boardList.edit(edit.params))
+ }
+ />
+ }
+
+ )}
+ {revert && (
+
+ {
+
+ handleOnClick(event, () => boardList.select(revert.params))
+ }
+ />
+ }
+
+ )}
+
+ );
+ }
}
-export class BoardsToolBarItem extends React.Component
{
+export class BoardsToolBarItem extends React.Component<
+ BoardsToolBarItem.Props,
+ BoardsToolBarItem.State
+> {
+ static TOOLBAR_ID: 'boards-toolbar';
- static TOOLBAR_ID: 'boards-toolbar';
+ private readonly toDispose: DisposableCollection;
- protected readonly toDispose: DisposableCollection = new DisposableCollection();
+ constructor(props: BoardsToolBarItem.Props) {
+ super(props);
+ const { boardList } = props.boardsServiceProvider;
+ this.state = {
+ boardList,
+ coords: 'hidden',
+ };
+ const listener = () => this.setState({ coords: 'hidden' });
+ document.addEventListener('click', listener);
+ this.toDispose = new DisposableCollection(
+ Disposable.create(() => document.removeEventListener('click', listener))
+ );
+ }
- constructor(props: BoardsToolBarItem.Props) {
- super(props);
+ override componentDidMount(): void {
+ this.toDispose.push(
+ this.props.boardsServiceProvider.onBoardListDidChange((boardList) =>
+ this.setState({ boardList })
+ )
+ );
+ }
- const { availableBoards } = props.boardsServiceClient;
- this.state = {
- availableBoards,
- coords: 'hidden'
- };
+ override componentWillUnmount(): void {
+ this.toDispose.dispose();
+ }
- document.addEventListener('click', () => {
- this.setState({ coords: 'hidden' });
+ private readonly show = (event: React.MouseEvent): void => {
+ const { currentTarget: element } = event;
+ if (element instanceof HTMLElement) {
+ if (this.state.coords === 'hidden') {
+ const rect = element.getBoundingClientRect();
+ this.setState({
+ coords: {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ paddingTop: rect.height,
+ },
});
+ } else {
+ this.setState({ coords: 'hidden' });
+ }
}
+ event.stopPropagation();
+ event.nativeEvent.stopImmediatePropagation();
+ };
- componentDidMount() {
- this.props.boardsServiceClient.onAvailableBoardsChanged(availableBoards => this.setState({ availableBoards }));
- }
-
- componentWillUnmount(): void {
- this.toDispose.dispose();
- }
-
- protected readonly show = (event: React.MouseEvent) => {
- const { currentTarget: element } = event;
- if (element instanceof HTMLElement) {
- if (this.state.coords === 'hidden') {
- const rect = element.getBoundingClientRect();
- this.setState({
- coords: {
- top: rect.top,
- left: rect.left,
- width: rect.width,
- paddingTop: rect.height
- }
- });
- } else {
- this.setState({ coords: 'hidden' });
- }
- }
- event.stopPropagation();
- event.nativeEvent.stopImmediatePropagation();
- };
-
- render(): React.ReactNode {
- const { coords, availableBoards } = this.state;
- const boardsConfig = this.props.boardsServiceClient.boardsConfig;
- const title = BoardsConfig.Config.toString(boardsConfig, { default: '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 ''
- })();
-
- return
-
- ({
- ...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}>
-
- ;
- }
-
- protected openDialog = () => {
- this.props.commands.executeCommand(ArduinoCommands.OPEN_BOARDS_DIALOG.id);
- this.setState({ coords: 'hidden' });
- };
+ 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}
+
+
+
+ boardList.edit({ query: '' })}
+ hide={this.hide}
+ />
+
+ );
+ }
}
export namespace BoardsToolBarItem {
+ export interface Props {
+ readonly boardsServiceProvider: BoardsServiceProvider;
+ readonly commands: CommandRegistry;
+ }
- export interface Props {
- readonly boardsServiceClient: BoardsServiceProvider;
- readonly commands: CommandRegistry;
- }
-
- export interface State {
- availableBoards: AvailableBoard[];
- coords: BoardsDropDownListCoords | 'hidden';
- }
+ export interface State {
+ 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 47403845e..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,26 +1,40 @@
-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 {
-
- constructor() {
- super({
- widgetId: BoardsListWidget.WIDGET_ID,
- widgetName: BoardsListWidget.WIDGET_LABEL,
- defaultWidgetOptions: {
- area: 'left',
- rank: 2
- },
- toggleCommandId: `${BoardsListWidget.WIDGET_ID}:toggle`,
- toggleKeybinding: 'CtrlCmd+Shift+B'
- });
- }
+export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution<
+ BoardsPackage,
+ BoardSearch
+> {
+ constructor() {
+ super({
+ widgetId: BoardsListWidget.WIDGET_ID,
+ widgetName: BoardsListWidget.WIDGET_LABEL,
+ defaultWidgetOptions: {
+ area: 'left',
+ rank: 2,
+ },
+ toggleCommandId: `${BoardsListWidget.WIDGET_ID}:toggle`,
+ toggleKeybinding: 'CtrlCmd+Shift+B',
+ });
+ }
- 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
new file mode 100644
index 000000000..c531cde7e
--- /dev/null
+++ b/arduino-ide-extension/src/browser/components/ProgressBar.tsx
@@ -0,0 +1,28 @@
+import React from '@theia/core/shared/react';
+
+export type ProgressBarProps = {
+ percent?: number;
+ showPercentage?: boolean;
+};
+
+export default function ProgressBar({
+ percent = 0,
+ showPercentage = false,
+}: ProgressBarProps): React.ReactElement {
+ const roundedPercent = Math.round(percent);
+ return (
+
+
+ {showPercentage && (
+
+ )}
+
+ );
+}
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 de9a4733a..201d13b41 100644
--- a/arduino-ide-extension/src/browser/contributions/about.ts
+++ b/arduino-ide-extension/src/browser/contributions/about.ts
@@ -1,105 +1,176 @@
-import { inject, injectable } from 'inversify';
-import * as moment from 'moment';
-import { remote } from 'electron';
-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 { Contribution, Command, MenuModelRegistry, CommandRegistry } from './contribution';
+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 { ConfigService } from '../../common/protocol';
+import {
+ Command,
+ CommandRegistry,
+ Contribution,
+ MenuModelRegistry,
+} from './contribution';
@injectable()
export class About extends Contribution {
+ @inject(ClipboardService)
+ private readonly clipboardService: ClipboardService;
+ @inject(AppService)
+ private readonly appService: AppService;
- @inject(ClipboardService)
- protected readonly clipboardService: ClipboardService;
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(About.Commands.ABOUT_APP, {
+ execute: () => this.showAbout(),
+ });
+ }
- @inject(ConfigService)
- protected readonly configService: ConfigService;
+ override registerMenus(registry: MenuModelRegistry): void {
+ registry.registerMenuAction(ArduinoMenus.HELP__ABOUT_GROUP, {
+ commandId: About.Commands.ABOUT_APP.id,
+ label: nls.localize(
+ 'arduino/about/label',
+ 'About {0}',
+ this.applicationName
+ ),
+ order: '0',
+ });
+ }
- registerCommands(registry: CommandRegistry): void {
- registry.registerCommand(About.Commands.ABOUT_APP, {
- execute: () => this.showAbout()
- });
- }
+ private async showAbout(): Promise {
+ const appInfo = await this.appService.info();
+ const { appVersion, cliVersion, buildDate } = appInfo;
- registerMenus(registry: MenuModelRegistry): void {
- registry.registerMenuAction(ArduinoMenus.HELP__ABOUT_GROUP, {
- commandId: About.Commands.ABOUT_APP.id,
- label: `About ${this.applicationName}`,
- order: '0'
- });
- }
+ const detail = (showAll: boolean) =>
+ nls.localize(
+ 'arduino/about/detail',
+ 'Version: {0}\nDate: {1}{2}\nCLI Version: {3}\n\n{4}',
+ appVersion,
+ buildDate ? buildDate : nls.localize('', 'dev build'),
+ buildDate && showAll ? ` (${this.ago(buildDate)})` : '',
+ cliVersion,
+ nls.localize(
+ 'arduino/about/copyright',
+ 'Copyright © {0} Arduino SA',
+ new Date().getFullYear().toString()
+ )
+ );
+ 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 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),
+ });
- async showAbout(): Promise {
- const { version, commit, status: cliStatus } = await this.configService.getVersion();
- const buildDate = this.buildDate;
- const detail = (showAll: boolean) => `Version: ${remote.app.getVersion()}
-Date: ${buildDate ? buildDate : 'dev build'}${buildDate && showAll ? ` (${this.ago(buildDate)})` : ''}
-CLI Version: ${version}${cliStatus ? ` ${cliStatus}` : ''} [${commit}]
+ if (buttons[response] === copy) {
+ await this.clipboardService.writeText(detail(false).trim());
+ }
+ }
-${showAll ? `Copyright © ${new Date().getFullYear()} Arduino SA` : ''}
-`;
- const ok = 'OK';
- const 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)
- });
+ private get applicationName(): string {
+ return FrontendApplicationConfigProvider.get().applicationName;
+ }
- if (buttons[response] === copy) {
- await this.clipboardService.writeText(detail(false).trim());
- }
+ private ago(isoTime: string): string {
+ const now = moment(Date.now());
+ const other = moment(isoTime);
+ let result = now.diff(other, 'minute');
+ if (result < 60) {
+ return result === 1
+ ? nls.localize(
+ 'vscode/date/date.fromNow.minutes.singular.ago',
+ '{0} minute ago',
+ result.toString()
+ )
+ : nls.localize(
+ 'vscode/date/date.fromNow.minutes.plural.ago',
+ '{0} minutes ago',
+ result.toString()
+ );
}
-
- protected get applicationName(): string {
- return FrontendApplicationConfigProvider.get().applicationName;
+ result = now.diff(other, 'hour');
+ if (result < 25) {
+ return result === 1
+ ? nls.localize(
+ 'vscode/date/date.fromNow.hours.singular.ago',
+ '{0} hour ago',
+ result.toString()
+ )
+ : nls.localize(
+ 'vscode/date/date.fromNow.hours.plural.ago',
+ '{0} hours ago',
+ result.toString()
+ );
}
-
- protected get buildDate(): string | undefined {
- return FrontendApplicationConfigProvider.get().buildDate;
+ result = now.diff(other, 'day');
+ if (result < 8) {
+ return result === 1
+ ? nls.localize(
+ 'vscode/date/date.fromNow.days.singular.ago',
+ '{0} day ago',
+ result.toString()
+ )
+ : nls.localize(
+ 'vscode/date/date.fromNow.days.plural.ago',
+ '{0} days ago',
+ result.toString()
+ );
}
-
- protected ago(isoTime: string): string {
- const now = moment(Date.now());
- const other = moment(isoTime);
- let result = now.diff(other, 'minute');
- if (result < 60) {
- return result === 1 ? `${result} minute ago` : `${result} minute ago`;
- }
- result = now.diff(other, 'hour');
- if (result < 25) {
- return result === 1 ? `${result} hour ago` : `${result} hours ago`;
- }
- result = now.diff(other, 'day');
- if (result < 8) {
- return result === 1 ? `${result} day ago` : `${result} days ago`;
- }
- result = now.diff(other, 'week');
- if (result < 5) {
- return result === 1 ? `${result} week ago` : `${result} weeks ago`;
- }
- result = now.diff(other, 'month');
- if (result < 13) {
- return result === 1 ? `${result} month ago` : `${result} months ago`;
- }
- result = now.diff(other, 'year');
- return result === 1 ? `${result} year ago` : `${result} years ago`;
+ result = now.diff(other, 'week');
+ if (result < 5) {
+ return result === 1
+ ? nls.localize(
+ 'vscode/date/date.fromNow.weeks.singular.ago',
+ '{0} week ago',
+ result.toString()
+ )
+ : nls.localize(
+ 'vscode/date/date.fromNow.weeks.plural.ago',
+ '{0} weeks ago',
+ result.toString()
+ );
}
-
+ result = now.diff(other, 'month');
+ if (result < 13) {
+ return result === 1
+ ? nls.localize(
+ 'vscode/date/date.fromNow.months.singular.ago',
+ '{0} month ago',
+ result.toString()
+ )
+ : nls.localize(
+ 'vscode/date/date.fromNow.months.plural.ago',
+ '{0} months ago',
+ result.toString()
+ );
+ }
+ result = now.diff(other, 'year');
+ return result === 1
+ ? nls.localize(
+ 'vscode/date/date.fromNow.years.singular.ago',
+ '{0} year ago',
+ result.toString()
+ )
+ : nls.localize(
+ 'vscode/date/date.fromNow.years.plural.ago',
+ '{0} years ago',
+ result.toString()
+ );
+ }
}
export namespace About {
- export namespace Commands {
- export const ABOUT_APP: Command = {
- id: 'arduino-about'
- };
- }
+ export namespace Commands {
+ export const ABOUT_APP: Command = {
+ id: 'arduino-about',
+ };
+ }
}
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 f9caf195a..da1796048 100644
--- a/arduino-ide-extension/src/browser/contributions/add-file.ts
+++ b/arduino-ide-extension/src/browser/contributions/add-file.ts
@@ -1,68 +1,105 @@
-import { inject, injectable } from 'inversify';
-import { remote } from 'electron';
-import { ArduinoMenus } from '../menu/arduino-menus';
-import { SketchContribution, Command, CommandRegistry, MenuModelRegistry, URI } from './contribution';
+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 {
+ Command,
+ CommandRegistry,
+ MenuModelRegistry,
+ Sketch,
+ SketchContribution,
+ URI,
+} from './contribution';
@injectable()
export class AddFile extends SketchContribution {
+ @inject(FileDialogService)
+ private readonly fileDialogService: FileDialogService; // TODO: use dialogService
- @inject(FileDialogService)
- protected readonly fileDialogService: FileDialogService;
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(AddFile.Commands.ADD_FILE, {
+ execute: () => this.addFile(),
+ });
+ }
- registerCommands(registry: CommandRegistry): void {
- registry.registerCommand(AddFile.Commands.ADD_FILE, {
- execute: () => this.addFile()
- });
- }
+ 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') + '...',
+ order: '2',
+ });
+ }
- registerMenus(registry: MenuModelRegistry): void {
- registry.registerMenuAction(ArduinoMenus.SKETCH__UTILS_GROUP, {
- commandId: AddFile.Commands.ADD_FILE.id,
- label: 'Add File...',
- order: '2'
- });
+ private async addFile(): Promise {
+ const sketch = await this.sketchServiceClient.currentSketch();
+ if (!CurrentSketch.isValid(sketch)) {
+ return;
}
-
- protected async addFile(): Promise {
- const sketch = await this.sketchServiceClient.currentSketch();
- if (!sketch) {
- return;
- }
- const toAddUri = await this.fileDialogService.showOpenDialog({
- title: 'Add File',
- canSelectFiles: true,
- canSelectFolders: false,
- canSelectMany: false
- });
- if (!toAddUri) {
- return;
- }
- const sketchUri = new URI(sketch.uri);
- const filename = toAddUri.path.base;
- const targetUri = sketchUri.resolve('data').resolve(filename);
- const exists = await this.fileService.exists(targetUri);
- if (exists) {
- const { response } = await remote.dialog.showMessageBox({
- type: 'question',
- title: 'Replace',
- buttons: ['Cancel', 'OK'],
- message: `Replace the existing version of ${filename}?`
- });
- if (response === 0) { // Cancel
- return;
- }
- }
- await this.fileService.copy(toAddUri, targetUri, { overwrite: true });
- this.messageService.info('One file added to the sketch.', { timeout: 2000 });
+ const toAddUri = await this.fileDialogService.showOpenDialog({
+ title: nls.localize('arduino/contributions/addFile', 'Add File'),
+ canSelectFiles: true,
+ canSelectFolders: false,
+ canSelectMany: false,
+ modal: true,
+ });
+ if (!toAddUri) {
+ return;
}
+ const { uri: targetUri, filename } = this.resolveTarget(sketch, toAddUri);
+ const exists = await this.fileService.exists(targetUri);
+ if (exists) {
+ const { response } = await this.dialogService.showMessageBox({
+ type: 'question',
+ title: nls.localize('arduino/contributions/replaceTitle', 'Replace'),
+ buttons: [
+ nls.localize('vscode/issueMainService/cancel', 'Cancel'),
+ nls.localize('vscode/issueMainService/ok', 'OK'),
+ ],
+ message: nls.localize(
+ 'arduino/replaceMsg',
+ 'Replace the existing version of {0}?',
+ filename
+ ),
+ });
+ if (response === 0) {
+ // Cancel
+ return;
+ }
+ }
+ await this.fileService.copy(toAddUri, targetUri, { overwrite: true });
+ this.messageService.info(
+ nls.localize(
+ 'arduino/contributions/fileAdded',
+ 'One file added to the sketch.'
+ ),
+ {
+ timeout: 2000,
+ }
+ );
+ }
+ // 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 {
- export namespace Commands {
- export const ADD_FILE: Command = {
- id: 'arduino-add-file'
- };
- }
+ export namespace Commands {
+ export const ADD_FILE: Command = {
+ id: 'arduino-add-file',
+ };
+ }
}
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 0a12e979d..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,113 +1,142 @@
-import { inject, injectable } from 'inversify';
-import { remote } from 'electron';
-import { ArduinoMenus } from '../menu/arduino-menus';
-import { SketchContribution, Command, CommandRegistry, MenuModelRegistry } from './contribution';
-import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
+import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
-import { InstallationProgressDialog } from '../widgets/progress-dialog';
-import { LibraryService } from '../../common/protocol';
-import { ConfirmDialog } from '@theia/core/lib/browser';
+import { ConfirmDialog } from '@theia/core/lib/browser/dialogs';
+import { ArduinoMenus } from '../menu/arduino-menus';
+import { LibraryService, ResponseServiceClient } from '../../common/protocol';
+import { ExecuteWithProgress } from '../../common/protocol/progressible';
+import {
+ SketchContribution,
+ Command,
+ CommandRegistry,
+ MenuModelRegistry,
+} from './contribution';
+import { nls } from '@theia/core/lib/common';
@injectable()
export class AddZipLibrary extends SketchContribution {
+ @inject(ResponseServiceClient)
+ private readonly responseService: ResponseServiceClient;
- @inject(EnvVariablesServer)
- protected readonly envVariableServer: EnvVariablesServer;
+ @inject(LibraryService)
+ private readonly libraryService: LibraryService;
- @inject(LibraryService)
- protected readonly libraryService: LibraryService;
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(AddZipLibrary.Commands.ADD_ZIP_LIBRARY, {
+ execute: () => this.addZipLibrary(),
+ });
+ }
- registerCommands(registry: CommandRegistry): void {
- registry.registerCommand(AddZipLibrary.Commands.ADD_ZIP_LIBRARY, {
- execute: () => this.addZipLibrary()
- });
- }
-
- 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: 'Add .ZIP Library...',
- order: '1'
- });
- }
+ override registerMenus(registry: MenuModelRegistry): void {
+ const includeLibMenuPath = [
+ ...ArduinoMenus.SKETCH__UTILS_GROUP,
+ '0_include',
+ ];
+ registry.registerMenuAction([...includeLibMenuPath, '1_install'], {
+ commandId: AddZipLibrary.Commands.ADD_ZIP_LIBRARY.id,
+ label: nls.localize('arduino/library/addZip', 'Add .ZIP Library...'),
+ order: '1',
+ });
+ }
- 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({
- title: "Select a zip file containing the library you'd like to add",
- defaultPath,
- properties: ['openFile'],
- filters: [
- {
- name: 'Library',
- extensions: ['zip']
- }
- ]
- });
- if (!canceled && filePaths.length) {
- const zipUri = await this.fileSystemExt.getUri(filePaths[0]);
- try {
- await this.doInstall(zipUri);
- } catch (error) {
- if (error instanceof AlreadyInstalledError) {
- const result = await new ConfirmDialog({
- msg: error.message,
- title: 'Do you want to overwrite the existing library?',
- ok: 'Yes',
- cancel: 'No'
- }).open();
- if (result) {
- await this.doInstall(zipUri, true);
- }
- }
- }
+ private async addZipLibrary(): Promise {
+ const homeUri = await this.envVariableServer.getHomeDirUri();
+ const defaultPath = await this.fileService.fsPath(new URI(homeUri));
+ const { canceled, filePaths } = await this.dialogService.showOpenDialog({
+ title: nls.localize(
+ 'arduino/selectZip',
+ "Select a zip file containing the library you'd like to add"
+ ),
+ defaultPath,
+ properties: ['openFile'],
+ filters: [
+ {
+ name: nls.localize('arduino/library/zipLibrary', 'Library'),
+ extensions: ['zip'],
+ },
+ ],
+ });
+ if (!canceled && filePaths.length) {
+ const zipUri = await this.fileSystemExt.getUri(filePaths[0]);
+ try {
+ await this.doInstall(zipUri);
+ } catch (error) {
+ if (error instanceof AlreadyInstalledError) {
+ const result = await new ConfirmDialog({
+ msg: error.message,
+ title: nls.localize(
+ 'arduino/library/overwriteExistingLibrary',
+ 'Do you want to overwrite the existing library?'
+ ),
+ ok: nls.localize('vscode/extensionsUtils/yes', 'Yes'),
+ cancel: nls.localize('vscode/extensionsUtils/no', 'No'),
+ }).open();
+ if (result) {
+ await this.doInstall(zipUri, true);
+ }
}
+ }
}
+ }
- private async doInstall(zipUri: string, overwrite?: boolean): Promise {
- const dialog = new InstallationProgressDialog('Installing library', 'zip');
- try {
- this.outputChannelManager.getChannel('Arduino').clear();
- dialog.open();
- await this.libraryService.installZip({ zipUri, overwrite });
- } catch (error) {
- if (error instanceof Error) {
- const match = error.message.match(/library (.*?) already installed/);
- if (match && match.length >= 2) {
- const name = match[1].trim();
- if (name) {
- throw new AlreadyInstalledError(`A library folder named ${name} already exists. Do you want to overwrite it?`, name);
- } else {
- throw new AlreadyInstalledError('A library already exists. Do you want to overwrite it?');
- }
- }
- }
- this.messageService.error(error.toString());
- throw error;
- } finally {
- dialog.close();
+ private async doInstall(zipUri: string, overwrite?: boolean): Promise {
+ try {
+ await ExecuteWithProgress.doWithProgress({
+ messageService: this.messageService,
+ progressText:
+ nls.localize('arduino/common/processing', 'Processing') +
+ ` ${new URI(zipUri).path.base}`,
+ responseService: this.responseService,
+ run: () => this.libraryService.installZip({ zipUri, overwrite }),
+ });
+ this.messageService.info(
+ nls.localize(
+ 'arduino/library/successfullyInstalledZipLibrary',
+ 'Successfully installed library from {0} archive',
+ new URI(zipUri).path.base
+ ),
+ { timeout: 3000 }
+ );
+ } catch (error) {
+ if (error instanceof Error) {
+ const match = error.message.match(/library (.*?) already installed/);
+ if (match && match.length >= 2) {
+ const name = match[1].trim();
+ if (name) {
+ throw new AlreadyInstalledError(
+ nls.localize(
+ 'arduino/library/namedLibraryAlreadyExists',
+ 'A library folder named {0} already exists. Do you want to overwrite it?',
+ name
+ ),
+ name
+ );
+ } else {
+ throw new AlreadyInstalledError(
+ nls.localize(
+ 'arduino/library/libraryAlreadyExists',
+ 'A library already exists. Do you want to overwrite it?'
+ )
+ );
+ }
}
+ }
+ this.messageService.error(error.toString());
+ throw error;
}
-
+ }
}
class AlreadyInstalledError extends Error {
-
- constructor(message: string, readonly libraryName?: string) {
- super(message);
- Object.setPrototypeOf(this, AlreadyInstalledError.prototype);
- }
-
+ constructor(message: string, readonly libraryName?: string) {
+ super(message);
+ Object.setPrototypeOf(this, AlreadyInstalledError.prototype);
+ }
}
export namespace AddZipLibrary {
- export namespace Commands {
- export const ADD_ZIP_LIBRARY: Command = {
- id: 'arduino-add-zip-library'
- };
- }
+ export namespace Commands {
+ export const ADD_ZIP_LIBRARY: Command = {
+ id: 'arduino-add-zip-library',
+ };
+ }
}
diff --git a/arduino-ide-extension/src/browser/contributions/archive-sketch.ts b/arduino-ide-extension/src/browser/contributions/archive-sketch.ts
index d4f8da0d7..f49f85caf 100644
--- a/arduino-ide-extension/src/browser/contributions/archive-sketch.ts
+++ b/arduino-ide-extension/src/browser/contributions/archive-sketch.ts
@@ -1,55 +1,75 @@
-import { injectable } from 'inversify';
-import { remote } from 'electron';
-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, Command, CommandRegistry, MenuModelRegistry } from './contribution';
+import {
+ SketchContribution,
+ Command,
+ CommandRegistry,
+ MenuModelRegistry,
+} from './contribution';
+import { nls } from '@theia/core/lib/common';
+import { CurrentSketch } from '../sketches-service-client-impl';
@injectable()
export class ArchiveSketch extends SketchContribution {
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(ArchiveSketch.Commands.ARCHIVE_SKETCH, {
+ execute: () => this.archiveSketch(),
+ });
+ }
- registerCommands(registry: CommandRegistry): void {
- registry.registerCommand(ArchiveSketch.Commands.ARCHIVE_SKETCH, {
- execute: () => this.archiveSketch()
- });
- }
+ 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'),
+ order: '1',
+ });
+ }
- registerMenus(registry: MenuModelRegistry): void {
- registry.registerMenuAction(ArduinoMenus.TOOLS__MAIN_GROUP, {
- commandId: ArchiveSketch.Commands.ARCHIVE_SKETCH.id,
- label: 'Archive Sketch',
- order: '1'
- });
+ private async archiveSketch(): Promise {
+ const sketch = await this.sketchServiceClient.currentSketch();
+ if (!CurrentSketch.isValid(sketch)) {
+ return;
}
-
- protected async archiveSketch(): Promise {
- const [sketch, config] = await Promise.all([
- this.sketchServiceClient.currentSketch(),
- this.configService.getConfiguration()
- ]);
- if (!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({ title: 'Save sketch folder as...', defaultPath });
- if (!filePath || canceled) {
- return;
- }
- const destinationUri = await this.fileSystemExt.getUri(filePath);
- if (!destinationUri) {
- return;
- }
- await this.sketchService.archive(sketch, destinationUri.toString());
- this.messageService.info(`Created archive '${archiveBasename}'.`, { timeout: 2000 });
+ const archiveBasename = `${sketch.name}-${dateFormat(
+ new Date(),
+ 'yymmdd'
+ )}a.zip`;
+ 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...'
+ ),
+ defaultPath,
+ });
+ if (!filePath || canceled) {
+ return;
}
-
+ const destinationUri = await this.fileSystemExt.getUri(filePath);
+ if (!destinationUri) {
+ return;
+ }
+ await this.sketchesService.archive(sketch, destinationUri.toString());
+ this.messageService.info(
+ nls.localize(
+ 'arduino/sketch/createdArchive',
+ "Created archive '{0}'.",
+ archiveBasename
+ ),
+ {
+ timeout: 2000,
+ }
+ );
+ }
}
export namespace ArchiveSketch {
- export namespace Commands {
- export const ARCHIVE_SKETCH: Command = {
- id: 'arduino-archive-sketch'
- };
- }
+ export namespace Commands {
+ export const ARCHIVE_SKETCH: Command = {
+ id: 'arduino-archive-sketch',
+ };
+ }
}
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 3e84a76d0..f9c2d2b36 100644
--- a/arduino-ide-extension/src/browser/contributions/board-selection.ts
+++ b/arduino-ide-extension/src/browser/contributions/board-selection.ts
@@ -1,222 +1,409 @@
-import { inject, injectable } from 'inversify';
-import { remote } from 'electron';
-import { MenuModelRegistry } from '@theia/core/lib/common/menu';
-import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
-import { firstToUpperCase } from '../../common/utils';
-import { BoardsConfig } from '../boards/boards-config';
+import {
+ Disposable,
+ DisposableCollection,
+} from '@theia/core/lib/common/disposable';
+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 {
+ ArduinoMenus,
+ PlaceholderMenuNode,
+ unregisterSubmenu,
+} from '../menu/arduino-menus';
+import { NotificationCenter } from '../notification-center';
+import { Command, CommandRegistry, SketchContribution } from './contribution';
@injectable()
export class BoardSelection extends SketchContribution {
+ @inject(CommandRegistry)
+ private readonly commandRegistry: CommandRegistry;
+ @inject(MainMenuManager)
+ private readonly mainMenuManager: MainMenuManager;
+ @inject(MenuModelRegistry)
+ private readonly menuModelRegistry: MenuModelRegistry;
+ @inject(NotificationCenter)
+ private readonly notificationCenter: NotificationCenter;
+ @inject(BoardsDataStore)
+ private readonly boardsDataStore: BoardsDataStore;
+ @inject(BoardsService)
+ private readonly boardsService: BoardsService;
+ @inject(BoardsServiceProvider)
+ private readonly boardsServiceProvider: BoardsServiceProvider;
- @inject(CommandRegistry)
- protected readonly commandRegistry: CommandRegistry;
+ private readonly toDisposeBeforeMenuRebuild = new DisposableCollection();
+ // do not query installed platforms on every change
+ private _installedBoards: Deferred | undefined;
- @inject(MainMenuManager)
- protected readonly mainMenuManager: MainMenuManager;
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(BoardSelection.Commands.GET_BOARD_INFO, {
+ execute: async () => {
+ const boardInfo = await getBoardInfo(
+ this.boardsServiceProvider.boardList
+ );
+ if (typeof boardInfo === 'string') {
+ this.messageService.info(boardInfo);
+ return;
+ }
+ 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')],
+ });
+ },
+ });
- @inject(MenuModelRegistry)
- protected readonly menuModelRegistry: MenuModelRegistry;
+ registry.registerCommand(BoardSelection.Commands.RELOAD_BOARD_DATA, {
+ execute: async () => {
+ const selectedFqbn =
+ this.boardsServiceProvider.boardList.boardsConfig.selectedBoard?.fqbn;
+ let message: string;
- @inject(NotificationCenter)
- protected readonly notificationCenter: NotificationCenter;
+ if (selectedFqbn) {
+ await this.boardsDataStore.reloadBoardData(selectedFqbn);
+ message = nls.localize(
+ 'arduino/board/boardDataReloaded',
+ 'Board data reloaded.'
+ );
+ } else {
+ message = nls.localize(
+ 'arduino/board/selectBoardToReload',
+ 'Please select a board first.'
+ );
+ }
- @inject(BoardsService)
- protected readonly boardsService: BoardsService;
+ this.messageService.info(message, { timeout: 2000 });
+ },
+ });
+ }
- @inject(BoardsServiceProvider)
- protected readonly boardsServiceProvider: BoardsServiceProvider;
+ override onStart(): void {
+ this.notificationCenter.onPlatformDidInstall(() => this.updateMenus(true));
+ this.notificationCenter.onPlatformDidUninstall(() =>
+ this.updateMenus(true)
+ );
+ this.boardsServiceProvider.onBoardListDidChange(() => this.updateMenus());
+ }
- protected readonly toDisposeBeforeMenuRebuild = new DisposableCollection();
+ override async onReady(): Promise {
+ this.updateMenus();
+ }
- registerCommands(registry: CommandRegistry): void {
- registry.registerCommand(BoardSelection.Commands.GET_BOARD_INFO, {
- execute: async () => {
- const { selectedBoard, selectedPort } = this.boardsServiceProvider.boardsConfig;
- if (!selectedBoard) {
- this.messageService.info('Please select a board to obtain board info.');
- return;
- }
- if (!selectedBoard.fqbn) {
- this.messageService.info(`The platform for the selected '${selectedBoard.name}' board is not installed.`);
- return;
- }
- if (!selectedPort) {
- this.messageService.info('Please select a port to obtain board info.');
- 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: 'Board Info',
- title: 'Board Info',
- type: 'info',
- detail,
- buttons: ['OK']
- });
- }
- }
- });
+ private async updateMenus(discardCache = false): Promise {
+ if (discardCache) {
+ this._installedBoards?.reject();
+ this._installedBoards = undefined;
}
-
- 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));
+ 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 async updateMenus(): Promise {
- const [installedBoards, availablePorts, config] = await Promise.all([
- this.installedBoards(),
- this.boardsService.getState(),
- this.boardsServiceProvider.boardsConfig
- ]);
- this.rebuildMenus(installedBoards, availablePorts, config);
- }
+ private rebuildMenus(
+ installedBoards: InstalledBoardWithPackage[],
+ boardList: BoardList
+ ): void {
+ this.toDisposeBeforeMenuRebuild.dispose();
- protected rebuildMenus(installedBoards: InstalledBoardWithPackage[], availablePorts: AvailablePorts, config: BoardsConfig.Config): void {
- this.toDisposeBeforeMenuRebuild.dispose();
-
- // Boards submenu
- const boardsSubmenuPath = [...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP, '1_boards'];
- const boardsSubmenuLabel = config.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(boardsSubmenuPath, `Board${!!boardsSubmenuLabel ? `: "${boardsSubmenuLabel}"` : ''}`, { order: '100' });
- this.toDisposeBeforeMenuRebuild.push(Disposable.create(() => unregisterSubmenu(boardsSubmenuPath, this.menuModelRegistry)));
-
- // Ports submenu
- const portsSubmenuPath = [...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP, '2_ports'];
- const portsSubmenuLabel = config.selectedPort?.address;
- this.menuModelRegistry.registerSubmenu(portsSubmenuPath, `Port${!!portsSubmenuLabel ? `: "${portsSubmenuLabel}"` : ''}`, { order: '101' });
- this.toDisposeBeforeMenuRebuild.push(Disposable.create(() => unregisterSubmenu(portsSubmenuPath, this.menuModelRegistry)));
-
- const getBoardInfo = { commandId: BoardSelection.Commands.GET_BOARD_INFO.id, label: 'Get Board Info', order: '103' };
- this.menuModelRegistry.registerMenuAction(ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP, getBoardInfo);
- this.toDisposeBeforeMenuRebuild.push(Disposable.create(() => this.menuModelRegistry.unregisterMenuAction(getBoardInfo)));
-
- const boardsManagerGroup = [...boardsSubmenuPath, '0_manager'];
- const boardsPackagesGroup = [...boardsSubmenuPath, '1_packages'];
-
- this.menuModelRegistry.registerMenuAction(boardsManagerGroup, {
- commandId: `${BoardsListWidget.WIDGET_ID}:toggle`,
- label: 'Boards Manager...'
- });
+ // Boards submenu
+ const boardsSubmenuPath = [
+ ...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP,
+ '1_boards',
+ ];
+ 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(
+ boardsSubmenuPath,
+ nls.localize(
+ 'arduino/board/board',
+ 'Board{0}',
+ !!boardsSubmenuLabel ? `: "${boardsSubmenuLabel}"` : ''
+ ),
+ { order: '100' }
+ );
+ this.toDisposeBeforeMenuRebuild.push(
+ Disposable.create(() =>
+ unregisterSubmenu(boardsSubmenuPath, this.menuModelRegistry)
+ )
+ );
+
+ // Ports submenu
+ const portsSubmenuPath = ArduinoMenus.TOOLS__PORTS_SUBMENU;
+ const portsSubmenuLabel = selectedPort?.address;
+ this.menuModelRegistry.registerSubmenu(
+ portsSubmenuPath,
+ nls.localize(
+ 'arduino/board/port',
+ 'Port{0}',
+ portsSubmenuLabel ? `: "${portsSubmenuLabel}"` : ''
+ ),
+ { order: '101' }
+ );
+ this.toDisposeBeforeMenuRebuild.push(
+ Disposable.create(() =>
+ unregisterSubmenu(portsSubmenuPath, this.menuModelRegistry)
+ )
+ );
+
+ 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'),
+ order: '103',
+ };
+ this.menuModelRegistry.registerMenuAction(
+ ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP,
+ getBoardInfo
+ );
+ this.toDisposeBeforeMenuRebuild.push(
+ Disposable.create(() =>
+ this.menuModelRegistry.unregisterMenuAction(getBoardInfo)
+ )
+ );
- // Installed boards
- for (const board of installedBoards) {
- const { packageId, packageName, fqbn, name } = board;
+ const boardsManagerGroup = [...boardsSubmenuPath, '0_manager'];
+ const boardsPackagesGroup = [...boardsSubmenuPath, '1_packages'];
- // 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, packageName);
+ this.menuModelRegistry.registerMenuAction(boardsManagerGroup, {
+ commandId: `${BoardsListWidget.WIDGET_ID}:toggle`,
+ label: `${BoardsListWidget.WIDGET_LABEL}...`,
+ });
+
+ 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 } = 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);
+ }
+
+ // 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
- }
- }
- },
- isToggled: () => fqbn === this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn
+ execute: () =>
+ this.boardsServiceProvider.updateConfig({
+ name: name,
+ fqbn: fqbn,
+ }),
+ isToggled: () => fqbn === selectedBoard?.fqbn,
};
// Board menu
- const menuAction = { commandId: id, label: name };
+ 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);
+ 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
- const registerPorts = (ports: AvailablePorts) => {
- const addresses = Object.keys(ports);
- if (!addresses.length) {
- return;
- }
+ });
+ });
+ }
+ );
- // Register placeholder for protocol
- const [port] = ports[addresses[0]];
- const protocol = port.protocol;
- const menuPath = [...portsSubmenuPath, protocol];
- const placeholder = new PlaceholderMenuNode(menuPath, `${firstToUpperCase(port.protocol)} ports`);
- this.menuModelRegistry.registerMenuNode(menuPath, placeholder);
- this.toDisposeBeforeMenuRebuild.push(Disposable.create(() => this.menuModelRegistry.unregisterMenuNode(placeholder.id)));
-
- for (const address of addresses) {
- if (!!ports[address]) {
- const [port, boards] = ports[address];
- if (!boards.length) {
- boards.push({
- name: ''
- });
- }
- for (const { name, fqbn } of boards) {
- const id = `arduino-select-port--${address}${fqbn ? `--${fqbn}` : ''}`;
- const command = { id };
- const handler = {
- execute: () => {
- if (!Port.equals(port, this.boardsServiceProvider.boardsConfig.selectedPort)) {
- this.boardsServiceProvider.boardsConfig = {
- selectedBoard: this.boardsServiceProvider.boardsConfig.selectedBoard,
- selectedPort: port
- }
- }
- },
- isToggled: () => Port.equals(port, this.boardsServiceProvider.boardsConfig.selectedPort)
- };
- const label = `${address}${name ? ` (${name})` : ''}`;
- const menuAction = {
- commandId: id,
- label,
- order: `1${label}` // `1` comes after the placeholder which has order `0`
- };
- this.commandRegistry.registerCommand(command, handler);
- this.toDisposeBeforeMenuRebuild.push(Disposable.create(() => this.commandRegistry.unregisterCommand(command)));
- this.menuModelRegistry.registerMenuAction(menuPath, menuAction);
- }
- }
- }
- }
+ // Detected ports
+ const registerPorts = (
+ protocol: string,
+ ports: ReturnType,
+ protocolOrder: number
+ ) => {
+ if (!ports.length) {
+ return;
+ }
- const { serial, network, unknown } = AvailablePorts.groupByProtocol(availablePorts);
- registerPorts(serial);
- registerPorts(network);
- registerPorts(unknown);
+ // Register placeholder for protocol
+ const menuPath = [
+ ...portsSubmenuPath,
+ `${protocolOrder.toString()}_${protocol}`,
+ ];
+ const placeholder = new PlaceholderMenuNode(
+ menuPath,
+ nls.localize(
+ 'arduino/board/typeOfPorts',
+ '{0} ports',
+ Port.Protocols.protocolLabel(protocol)
+ ),
+ { order: protocolOrder.toString().padStart(4) }
+ );
+ this.menuModelRegistry.registerMenuNode(menuPath, placeholder);
+ this.toDisposeBeforeMenuRebuild.push(
+ Disposable.create(() =>
+ this.menuModelRegistry.unregisterMenuNode(placeholder.id)
+ )
+ );
- this.mainMenuManager.update();
- }
+ 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--${portKey}`;
+ const command = { id };
+ const handler = {
+ execute: () => {
+ this.boardsServiceProvider.updateConfig({
+ protocol: port.protocol,
+ address: port.address,
+ });
+ },
+ isToggled: () => {
+ return i === ports.matchingIndex;
+ },
+ };
+ const menuAction = {
+ commandId: id,
+ label,
+ order: String(protocolOrder + i + 1).padStart(4),
+ };
+ this.commandRegistry.registerCommand(command, handler);
+ this.toDisposeBeforeMenuRebuild.push(
+ Disposable.create(() =>
+ this.commandRegistry.unregisterCommand(command)
+ )
+ );
+ this.menuModelRegistry.registerMenuAction(menuPath, menuAction);
+ }
+ };
- protected async installedBoards(): Promise {
- const allBoards = await this.boardsService.searchBoards({});
- return allBoards.filter(InstalledBoardWithPackage.is);
- }
+ const groupedPorts = boardList.portsGroupedByProtocol();
+ let 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.getInstalledBoards();
+ return allBoards.filter(InstalledBoardWithPackage.is);
+ }
}
export namespace BoardSelection {
- export namespace Commands {
- export const GET_BOARD_INFO: Command = { id: 'arduino-get-board-info' };
- }
+ 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/contributions/boards-data-menu-updater.ts b/arduino-ide-extension/src/browser/contributions/boards-data-menu-updater.ts
new file mode 100644
index 000000000..382e0f2ef
--- /dev/null
+++ b/arduino-ide-extension/src/browser/contributions/boards-data-menu-updater.ts
@@ -0,0 +1,178 @@
+import {
+ Disposable,
+ DisposableCollection,
+} from '@theia/core/lib/common/disposable';
+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 {
+ CommandRegistry,
+ Contribution,
+ MenuModelRegistry,
+} from './contribution';
+
+@injectable()
+export class BoardsDataMenuUpdater extends Contribution {
+ @inject(CommandRegistry)
+ private readonly commandRegistry: CommandRegistry;
+ @inject(MenuModelRegistry)
+ private readonly menuRegistry: MenuModelRegistry;
+ @inject(BoardsDataStore)
+ private readonly boardsDataStore: BoardsDataStore;
+ @inject(BoardsServiceProvider)
+ private readonly boardsServiceProvider: BoardsServiceProvider;
+
+ private readonly queue = new PQueue({ autoStart: true, concurrency: 1 });
+ private readonly toDisposeOnBoardChange = new DisposableCollection();
+
+ override onStart(): void {
+ this.boardsDataStore.onDidChange(() =>
+ this.updateMenuActions(
+ this.boardsServiceProvider.boardsConfig.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
+ )
+ );
+ }
+
+ private async updateMenuActions(
+ selectedBoard: BoardIdentifier | undefined
+ ): Promise {
+ return this.queue.add(async () => {
+ this.toDisposeOnBoardChange.dispose();
+ this.menuManager.update();
+ if (selectedBoard) {
+ const { fqbn } = selectedBoard;
+ if (fqbn) {
+ const { configOptions, programmers, selectedProgrammer } =
+ await this.boardsDataStore.getData(fqbn);
+ if (configOptions.length) {
+ const boardsConfigMenuPath = [
+ ...ArduinoMenus.TOOLS__BOARD_SETTINGS_GROUP,
+ 'z01_boardsConfig',
+ ]; // `z_` is for ordering.
+ for (const { label, option, values } of configOptions.sort(
+ ConfigOption.LABEL_COMPARATOR
+ )) {
+ const menuPath = [...boardsConfigMenuPath, `${option}`];
+ const commands = new Map<
+ string,
+ Disposable & { label: string }
+ >();
+ let selectedValue = '';
+ for (const value of values) {
+ const id = `${fqbn}-${option}--${value.value}`;
+ const command = { id };
+ const handler = {
+ execute: () =>
+ this.boardsDataStore.selectConfigOption({
+ fqbn,
+ optionsToUpdate: [{ option, selectedValue: value.value }],
+ }),
+ isToggled: () => value.selected,
+ };
+ commands.set(
+ id,
+ Object.assign(
+ this.commandRegistry.registerCommand(command, handler),
+ { label: value.label }
+ )
+ );
+ if (value.selected) {
+ selectedValue = value.label;
+ }
+ }
+ this.menuRegistry.registerSubmenu(
+ menuPath,
+ `${label}${selectedValue ? `: "${selectedValue}"` : ''}`
+ );
+ this.toDisposeOnBoardChange.pushAll([
+ ...commands.values(),
+ Disposable.create(() =>
+ unregisterSubmenu(menuPath, this.menuRegistry)
+ ),
+ ...Array.from(commands.keys()).map((commandId, i) => {
+ const { label } = commands.get(commandId)!;
+ this.menuRegistry.registerMenuAction(menuPath, {
+ commandId,
+ order: String(i).padStart(4),
+ label,
+ });
+ return Disposable.create(() =>
+ this.menuRegistry.unregisterMenuAction(commandId)
+ );
+ }),
+ ]);
+ }
+ }
+ if (programmers.length) {
+ const programmersMenuPath = [
+ ...ArduinoMenus.TOOLS__BOARD_SETTINGS_GROUP,
+ 'z02_programmers',
+ ];
+ const programmerNls = nls.localize(
+ 'arduino/board/programmer',
+ 'Programmer'
+ );
+ const label = selectedProgrammer
+ ? `${programmerNls}: "${selectedProgrammer.name}"`
+ : programmerNls;
+ this.menuRegistry.registerSubmenu(programmersMenuPath, label);
+ this.toDisposeOnBoardChange.push(
+ Disposable.create(() =>
+ unregisterSubmenu(programmersMenuPath, this.menuRegistry)
+ )
+ );
+ for (const programmer of programmers) {
+ const { id, name } = programmer;
+ const command = { id: `${fqbn}-programmer--${id}` };
+ const handler = {
+ execute: () =>
+ this.boardsDataStore.selectProgrammer({
+ fqbn,
+ selectedProgrammer: programmer,
+ }),
+ isToggled: () =>
+ Programmer.equals(programmer, selectedProgrammer),
+ };
+ this.menuRegistry.registerMenuAction(programmersMenuPath, {
+ commandId: command.id,
+ label: name,
+ });
+ this.commandRegistry.registerCommand(command, handler);
+ this.toDisposeOnBoardChange.pushAll([
+ Disposable.create(() =>
+ this.commandRegistry.unregisterCommand(command)
+ ),
+ Disposable.create(() =>
+ this.menuRegistry.unregisterMenuAction(command.id)
+ ),
+ ]);
+ }
+ }
+ this.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 1b19be40c..e951ac2f9 100644
--- a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts
+++ b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts
@@ -1,82 +1,92 @@
-import { inject, injectable } from 'inversify';
-import { OutputChannelManager } from '@theia/output/lib/common/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 { MonitorConnection } from '../monitor/monitor-connection';
-import { BoardsServiceProvider } from '../boards/boards-service-provider';
-import { SketchContribution, Command, CommandRegistry, MenuModelRegistry } from './contribution';
+import {
+ Command,
+ CommandRegistry,
+ CoreServiceContribution,
+ MenuModelRegistry,
+} from './contribution';
@injectable()
-export class BurnBootloader extends SketchContribution {
+export class BurnBootloader extends CoreServiceContribution {
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(BurnBootloader.Commands.BURN_BOOTLOADER, {
+ execute: () => this.burnBootloader(),
+ });
+ }
- @inject(CoreService)
- protected readonly coreService: CoreService;
+ override registerMenus(registry: MenuModelRegistry): void {
+ registry.registerMenuAction(ArduinoMenus.TOOLS__BOARD_SETTINGS_GROUP, {
+ commandId: BurnBootloader.Commands.BURN_BOOTLOADER.id,
+ label: nls.localize(
+ 'arduino/bootloader/burnBootloader',
+ 'Burn Bootloader'
+ ),
+ order: 'z99',
+ });
+ }
- @inject(MonitorConnection)
- protected readonly monitorConnection: MonitorConnection;
-
- @inject(BoardsDataStore)
- protected readonly boardsDataStore: BoardsDataStore;
-
- @inject(BoardsServiceProvider)
- protected readonly boardsServiceClientImpl: BoardsServiceProvider;
-
- @inject(OutputChannelManager)
- protected readonly outputChannelManager: OutputChannelManager;
-
- registerCommands(registry: CommandRegistry): void {
- registry.registerCommand(BurnBootloader.Commands.BURN_BOOTLOADER, {
- execute: () => this.burnBootloader()
- });
- }
-
- registerMenus(registry: MenuModelRegistry): void {
- registry.registerMenuAction(ArduinoMenus.TOOLS__BOARD_SETTINGS_GROUP, {
- commandId: BurnBootloader.Commands.BURN_BOOTLOADER.id,
- label: 'Burn Bootloader',
- order: 'z99'
- });
- }
-
- async burnBootloader(): Promise {
- const monitorConfig = this.monitorConnection.monitorConfig;
- if (monitorConfig) {
- await this.monitorConnection.disconnect();
- }
- try {
- const { boardsConfig } = this.boardsServiceClientImpl;
- const port = boardsConfig.selectedPort?.address;
- 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')
- ]);
- this.outputChannelManager.getChannel('Arduino').clear();
- await this.coreService.burnBootloader({
- fqbn,
- programmer,
- port,
- verify,
- verbose
- });
- this.messageService.info('Done burning bootloader.', { timeout: 1000 });
- } catch (e) {
- this.messageService.error(e.toString());
- } finally {
- if (monitorConfig) {
- await this.monitorConnection.connect(monitorConfig);
- }
+ private async burnBootloader(): Promise {
+ this.clearVisibleNotification();
+ const options = await this.options();
+ try {
+ await this.doWithProgress({
+ progressText: nls.localize(
+ 'arduino/bootloader/burningBootloader',
+ 'Burning bootloader...'
+ ),
+ task: (progressId, coreService, token) =>
+ coreService.burnBootloader(
+ {
+ ...options,
+ progressId,
+ },
+ token
+ ),
+ cancelable: true,
+ });
+ this.messageService.info(
+ nls.localize(
+ 'arduino/bootloader/doneBurningBootloader',
+ 'Done burning bootloader.'
+ ),
+ {
+ timeout: 3000,
}
+ );
+ } catch (e) {
+ 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 {
- export namespace Commands {
- export const BURN_BOOTLOADER: Command = {
- id: 'arduino-burn-bootloader'
- };
- }
+ export namespace Commands {
+ export const BURN_BOOTLOADER: Command = {
+ id: 'arduino-burn-bootloader',
+ };
+ }
}
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 46bd72ceb..93b4a62e4 100644
--- a/arduino-ide-extension/src/browser/contributions/close.ts
+++ b/arduino-ide-extension/src/browser/contributions/close.ts
@@ -1,119 +1,228 @@
-import { inject, injectable } from 'inversify';
-import { toArray } from '@phosphor/algorithm';
-import { remote } from 'electron';
-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 { CurrentSketch } from '../sketches-service-client-impl';
+import { WindowServiceExt } from '../theia/core/window-service-ext';
+import {
+ Command,
+ CommandRegistry,
+ KeybindingRegistry,
+ MenuModelRegistry,
+ Sketch,
+ SketchContribution,
+ URI,
+} from './contribution';
import { SaveAsSketch } from './save-as-sketch';
-import { SketchContribution, Command, CommandRegistry, MenuModelRegistry, KeybindingRegistry, URI } from './contribution';
/**
* 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(WindowServiceExt)
+ private readonly windowServiceExt: WindowServiceExt;
- @inject(EditorManager)
- protected readonly editorManager: EditorManager;
+ private shell: ApplicationShell | undefined;
- protected shell: ApplicationShell;
+ override onStart(app: FrontendApplication): MaybePromise {
+ this.shell = app.shell;
+ }
- onStart(app: FrontendApplication): void {
- this.shell = app.shell;
- }
+ override registerCommands(registry: CommandRegistry): void {
+ registry.registerCommand(Close.Commands.CLOSE, {
+ execute: () => {
+ // Close current editor if closeable.
+ const { currentEditor } = this.editorManager;
+ if (currentEditor && currentEditor.title.closable) {
+ currentEditor.close();
+ return;
+ }
- registerCommands(registry: CommandRegistry): void {
- registry.registerCommand(Close.Commands.CLOSE, {
- execute: async () => {
-
- // Close current editor if closeable.
- const { currentEditor } = this.editorManager;
- if (currentEditor && currentEditor.title.closable) {
- currentEditor.close();
- 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: ["Don't Save", 'Cancel', 'Save'],
- message: 'Do you want to save changes to this sketch before closing?',
- detail: "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;
- }
- }
- }
- window.close();
+ 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();
}
- });
+ }
+ }
+ return this.windowServiceExt.close();
+ },
+ });
+ }
+
+ 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: '6',
+ });
+ }
+
+ 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;
}
- registerMenus(registry: MenuModelRegistry): void {
- registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
- commandId: Close.Commands.CLOSE.id,
- label: 'Close',
- order: '5'
- });
+ // 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;
}
- registerKeybindings(registry: KeybindingRegistry): void {
- registry.registerKeybinding({
- command: Close.Commands.CLOSE.id,
- keybinding: 'CtrlCmd+W'
- });
+ 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}`);
}
+ }
- /**
- * If the file was ever touched/modified. We get this based on the `version` of the monaco model.
- */
- protected async wasTouched(uri: string): Promise {
- const editorWidget = await this.editorManager.getByUri(new URI(uri));
- if (editorWidget) {
- const { editor } = editorWidget;
- if (editor instanceof MonacoEditor) {
- const versionId = editor.getControl().getModel()?.getVersionId();
- if (Number.isInteger(versionId) && versionId! > 1) {
- return true;
- }
- }
+ 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.
+ */
+ protected async wasTouched(uri: string): Promise {
+ const editorWidget = await this.editorManager.getByUri(new URI(uri));
+ if (editorWidget) {
+ const { editor } = editorWidget;
+ if (editor instanceof MonacoEditor) {
+ const versionId = editor.getControl().getModel()?.getVersionId();
+ if (this.isInteger(versionId) && versionId > 1) {
+ return true;
}
- return false;
+ }
}
+ return false;
+ }
+ private isInteger(arg: unknown): arg is number {
+ return Number.isInteger(arg);
+ }
+}
+
+enum Prompt {
+ Save,
+ DoNotSave,
+ Cancel,
}
export namespace Close {
- export namespace Commands {
- export const CLOSE: Command = {
- id: 'arduino-close'
- };
- }
+ export namespace Commands {
+ export const CLOSE: Command = {
+ id: 'arduino-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 af4a5d47c..781b832fc 100644
--- a/arduino-ide-extension/src/browser/contributions/contribution.ts
+++ b/arduino-ide-extension/src/browser/contributions/contribution.ts
@@ -1,122 +1,369 @@
-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 { 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 {
+ KeybindingContribution,
+ KeybindingRegistry,
+} from '@theia/core/lib/browser/keybinding';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
-import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
+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 { CancellationToken } from '@theia/core/lib/common/cancellation';
+import {
+ Command,
+ CommandContribution,
+ CommandRegistry,
+ CommandService,
+} from '@theia/core/lib/common/command';
+import {
+ 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 { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
-import { open, OpenerService } from '@theia/core/lib/browser/opener-service';
-import { OutputChannelManager } from '@theia/output/lib/common/output-channel';
-import { MenuModelRegistry, MenuContribution } from '@theia/core/lib/common/menu';
-import { KeybindingRegistry, KeybindingContribution } from '@theia/core/lib/browser/keybinding';
-import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
-import { FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser/frontend-application';
-import { Command, CommandRegistry, CommandContribution, CommandService } from '@theia/core/lib/common/command';
-import { EditorMode } from '../editor-mode';
-import { SettingsService } from '../settings';
-import { SketchesServiceClientImpl } from '../../common/protocol/sketches-service-client-impl';
-import { SketchesService, ConfigService, FileSystemExt, Sketch } from '../../common/protocol';
+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, TabBarToolbarRegistry, URI, Sketch, open };
+export {
+ Command,
+ CommandRegistry,
+ KeybindingRegistry,
+ MenuModelRegistry,
+ Sketch,
+ TabBarToolbarRegistry,
+ URI,
+ open,
+};
@injectable()
-export abstract class Contribution implements CommandContribution, MenuContribution, KeybindingContribution, TabBarToolbarContribution, FrontendApplicationContribution {
+export abstract class Contribution
+ implements
+ CommandContribution,
+ MenuContribution,
+ KeybindingContribution,
+ TabBarToolbarContribution,
+ FrontendApplicationContribution
+{
+ @inject(ILogger)
+ protected readonly logger: ILogger;
- @inject(ILogger)
- protected readonly logger: ILogger;
+ @inject(MessageService)
+ protected readonly messageService: MessageService;
- @inject(MessageService)
- protected readonly messageService: MessageService;
+ @inject(CommandService)
+ protected readonly commandService: CommandService;
- @inject(CommandService)
- protected readonly commandService: CommandService;
+ @inject(WorkspaceService)
+ protected readonly workspaceService: WorkspaceService;
- @inject(WorkspaceService)
- protected readonly workspaceService: WorkspaceService;
+ @inject(LabelProvider)
+ protected readonly labelProvider: LabelProvider;
- @inject(EditorMode)
- protected readonly editorMode: EditorMode;
+ @inject(SettingsService)
+ protected readonly settingsService: SettingsService;
- @inject(LabelProvider)
- protected readonly labelProvider: LabelProvider;
+ @inject(ArduinoPreferences)
+ protected readonly preferences: ArduinoPreferences;
- @inject(SettingsService)
- protected readonly settingsService: SettingsService;
+ @inject(FrontendApplicationStateService)
+ protected readonly appStateService: FrontendApplicationStateService;
- onStart(app: FrontendApplication): MaybePromise {
- }
+ @inject(MainMenuManager)
+ protected readonly menuManager: MainMenuManager;
- registerCommands(registry: CommandRegistry): void {
- }
+ @inject(DialogService)
+ protected readonly dialogService: DialogService;
- registerMenus(registry: MenuModelRegistry): void {
- }
+ @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 {}
- registerKeybindings(registry: KeybindingRegistry): 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()
+export abstract class SketchContribution extends Contribution {
+ @inject(FileService)
+ protected readonly fileService: FileService;
+
+ @inject(FileSystemExt)
+ protected readonly fileSystemExt: FileSystemExt;
+
+ @inject(ConfigServiceClient)
+ protected readonly configService: ConfigServiceClient;
+
+ @inject(SketchesService)
+ protected readonly sketchesService: SketchesService;
+
+ @inject(OpenerService)
+ protected readonly openerService: OpenerService;
+
+ @inject(SketchesServiceClientImpl)
+ protected readonly sketchServiceClient: SketchesServiceClientImpl;
+
+ @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 (CurrentSketch.isValid(sketch)) {
+ for (const editor of this.editorManager.all) {
+ const uri = editor.editor.uri;
+ if (Saveable.isDirty(editor) && Sketch.isInSketch(uri, sketch)) {
+ override[uri.toString()] = editor.editor.document.getText();
+ }
+ }
}
+ return override;
+ }
- registerToolbarItems(registry: TabBarToolbarRegistry): void {
+ /**
+ * 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 SketchContribution extends Contribution {
+export abstract class CoreServiceContribution extends SketchContribution {
+ @inject(BoardsDataStore)
+ protected readonly boardsDataStore: BoardsDataStore;
- @inject(FileService)
- protected readonly fileService: FileService;
+ @inject(BoardsServiceProvider)
+ protected readonly boardsServiceProvider: BoardsServiceProvider;
- @inject(FileSystemExt)
- protected readonly fileSystemExt: FileSystemExt;
+ @inject(CoreService)
+ private readonly coreService: CoreService;
- @inject(ConfigService)
- protected readonly configService: ConfigService;
+ @inject(ClipboardService)
+ private readonly clipboardService: ClipboardService;
- @inject(SketchesService)
- protected readonly sketchService: SketchesService;
+ @inject(ResponseServiceClient)
+ private readonly responseService: ResponseServiceClient;
- @inject(OpenerService)
- protected readonly openerService: OpenerService;
+ @inject(NotificationManager)
+ private readonly notificationManager: NotificationManager;
- @inject(SketchesServiceClientImpl)
- protected readonly sketchServiceClient: SketchesServiceClientImpl;
+ @inject(ApplicationShell)
+ private readonly shell: ApplicationShell;
- @inject(ArduinoPreferences)
- protected readonly preferences: ArduinoPreferences;
+ /**
+ * 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;
- @inject(EditorManager)
- protected readonly editorManager: EditorManager;
+ protected clearVisibleNotification(): void {
+ if (this.visibleNotificationId) {
+ this.notificationManager.clear(this.visibleNotificationId);
+ this.visibleNotificationId = undefined;
+ }
+ }
- @inject(OutputChannelManager)
- protected readonly outputChannelManager: OutputChannelManager;
+ protected handleError(error: unknown): void {
+ if (isObject(error) && UserAbortApplicationError.is(error)) {
+ this.outputChannelManager
+ .getChannel('Arduino')
+ .appendLine(userAbort, OutputChannelSeverity.Warning);
+ return;
+ }
+ this.tryToastErrorMessage(error);
+ }
- protected async sourceOverride(): Promise> {
- const override: Record = {};
- const sketch = await this.sketchServiceClient.currentSketch();
- if (sketch) {
- for (const editor of this.editorManager.all) {
- const uri = editor.editor.uri;
- if (Saveable.isDirty(editor) && Sketch.isInSketch(uri, sketch)) {
- override[uri.toString()] = editor.editor.document.getText();
- }
- }
+ 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);
+ }
}
- return override;
+ });
+ } 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(bind: interfaces.Bind, serviceIdentifier: typeof Contribution): void {
- bind(serviceIdentifier).toSelf().inSingletonScope();
- bind(CommandContribution).toService(serviceIdentifier);
- bind(MenuContribution).toService(serviceIdentifier);
- bind(KeybindingContribution).toService(serviceIdentifier);
- bind(TabBarToolbarContribution).toService(serviceIdentifier);
- bind(FrontendApplicationContribution).toService(serviceIdentifier);
- }
+ export function configure(
+ bind: interfaces.Bind,
+ serviceIdentifier: typeof Contribution
+ ): void {
+ bind(serviceIdentifier).toSelf().inSingletonScope();
+ bind(CommandContribution).toService(serviceIdentifier);
+ bind(MenuContribution).toService(serviceIdentifier);
+ bind(KeybindingContribution).toService(serviceIdentifier);
+ bind(TabBarToolbarContribution).toService(serviceIdentifier);
+ bind(FrontendApplicationContribution).toService(serviceIdentifier);
+ }
}
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 &&
+ (