diff --git a/.eslintrc.js b/.eslintrc.js index da16cbb76..4bb67da9e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,7 +18,7 @@ module.exports = { 'electron-app/src-gen/*', 'electron-app/gen-webpack*.js', '!electron-app/webpack.config.js', - 'plugins/*', + 'electron-app/plugins/*', 'arduino-ide-extension/src/node/cli-protocol', '**/lib/*', ], diff --git a/arduino-ide-extension/package.json b/arduino-ide-extension/package.json index 866844ab8..2042054a0 100644 --- a/arduino-ide-extension/package.json +++ b/arduino-ide-extension/package.json @@ -69,6 +69,7 @@ "deepmerge": "^4.2.2", "drivelist": "^9.2.4", "electron-updater": "^4.6.5", + "fast-deep-equal": "^3.1.3", "fast-json-stable-stringify": "^2.1.0", "fast-safe-stringify": "^2.1.1", "filename-reserved-regex": "^2.0.0", @@ -169,13 +170,13 @@ ], "arduino": { "arduino-cli": { - "version": "0.34.0" + "version": "0.35.0-rc.7" }, "arduino-fwuploader": { "version": "2.4.1" }, "arduino-language-server": { - "version": "0.7.4" + "version": "0.7.5" }, "clangd": { "version": "14.0.0" diff --git a/arduino-ide-extension/scripts/generate-protocol.js b/arduino-ide-extension/scripts/generate-protocol.js index 5b5301575..ec15850b3 100644 --- a/arduino-ide-extension/scripts/generate-protocol.js +++ b/arduino-ide-extension/scripts/generate-protocol.js @@ -6,6 +6,7 @@ const { mkdirSync, promises: fs } = require('node:fs'); const { exec } = require('./utils'); const glob = require('glob'); + const { SemVer, gte, valid: validSemVer } = require('semver'); const protoc = path.dirname(require('protoc/protoc')); const repository = await fs.mkdtemp(path.join(os.tmpdir(), 'arduino-cli-')); @@ -94,13 +95,12 @@ } */ const versionObject = JSON.parse(versionJson); - const version = versionObject.VersionString; - if ( - version && - !version.startsWith('nightly-') && - version !== '0.0.0-git' && - version !== 'git-snapshot' - ) { + let version = versionObject.VersionString; + if (validSemVer(version)) { + // https://github.com/arduino/arduino-cli/pull/2374 + if (gte(new SemVer(version, { loose: true }), new SemVer('0.35.0-rc.1'))) { + version = `v${version}`; + } console.log(`>>> Checking out tagged version: '${version}'...`); exec('git', ['-C', repository, 'fetch', '--all', '--tags'], { logStdout: true, diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index 436dd8e86..f481dfc21 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -1,5 +1,5 @@ import '../../src/browser/style/index.css'; -import { ContainerModule } from '@theia/core/shared/inversify'; +import { Container, ContainerModule } from '@theia/core/shared/inversify'; import { WidgetFactory } from '@theia/core/lib/browser/widget-manager'; import { CommandContribution } from '@theia/core/lib/common/command'; import { bindViewContribution } from '@theia/core/lib/browser/shell/view-contribution'; @@ -271,8 +271,8 @@ import { MonitorModel } from './monitor-model'; import { MonitorManagerProxyClientImpl } from './monitor-manager-proxy-client-impl'; import { EditorManager as TheiaEditorManager } from '@theia/editor/lib/browser/editor-manager'; import { EditorManager } from './theia/editor/editor-manager'; -import { HostedPluginEvents } from './hosted-plugin-events'; -import { HostedPluginSupport } from './theia/plugin-ext/hosted-plugin'; +import { HostedPluginEvents } from './hosted/hosted-plugin-events'; +import { HostedPluginSupportImpl } from './theia/plugin-ext/hosted-plugin'; import { HostedPluginSupport as TheiaHostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin'; import { Formatter, FormatterPath } from '../common/protocol/formatter'; import { Format } from './contributions/format'; @@ -361,6 +361,16 @@ import { TerminalFrontendContribution as TheiaTerminalFrontendContribution } fro import { SelectionService } from '@theia/core/lib/common/selection-service'; import { CommandService } from '@theia/core/lib/common/command'; import { CorePreferences } from '@theia/core/lib/browser/core-preferences'; +import { AutoSelectProgrammer } from './contributions/auto-select-programmer'; +import { HostedPluginSupport } from './hosted/hosted-plugin-support'; +import { DebugSessionManager as TheiaDebugSessionManager } from '@theia/debug/lib/browser/debug-session-manager'; +import { DebugSessionManager } from './theia/debug/debug-session-manager'; +import { DebugWidget } from '@theia/debug/lib/browser/view/debug-widget'; +import { DebugViewModel } from '@theia/debug/lib/browser/view/debug-view-model'; +import { DebugSessionWidget } from '@theia/debug/lib/browser/view/debug-session-widget'; +import { DebugConfigurationWidget } from './theia/debug/debug-configuration-widget'; +import { DebugConfigurationWidget as TheiaDebugConfigurationWidget } from '@theia/debug/lib/browser/view/debug-configuration-widget'; +import { DebugToolBar } from '@theia/debug/lib/browser/view/debug-toolbar-widget'; // Hack to fix copy/cut/paste issue after electron version update in Theia. // https://github.com/eclipse-theia/theia/issues/12487 @@ -454,6 +464,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { // To be able to track, and update the menu based on the core settings (aka. board details) of the currently selected board. bind(BoardsDataStore).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(BoardsDataStore); + bind(CommandContribution).toService(BoardsDataStore); + bind(StartupTaskProvider).toService(BoardsDataStore); // to inherit the boards config options, programmer, etc in a new window + // Logger for the Arduino daemon bind(ILogger) .toDynamicValue((ctx) => { @@ -753,6 +766,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, CreateCloudCopy); Contribution.configure(bind, UpdateArduinoState); Contribution.configure(bind, BoardsDataMenuUpdater); + Contribution.configure(bind, AutoSelectProgrammer); bindContributionProvider(bind, StartupTaskProvider); bind(StartupTaskProvider).toService(BoardsServiceProvider); // to reuse the boards config in another window @@ -854,6 +868,28 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { // To be able to use a `launch.json` from outside of the workspace. bind(DebugConfigurationManager).toSelf().inSingletonScope(); rebind(TheiaDebugConfigurationManager).toService(DebugConfigurationManager); + // To update the currently selected debug config to update it programmatically. + bind(WidgetFactory) + .toDynamicValue(({ container }) => ({ + id: DebugWidget.ID, + createWidget: () => { + const child = new Container({ defaultScope: 'Singleton' }); + child.parent = container; + child.bind(DebugViewModel).toSelf(); + child.bind(DebugToolBar).toSelf(); + child.bind(DebugSessionWidget).toSelf(); + child.bind(DebugConfigurationWidget).toSelf(); // with the patched select + child // use the customized one in the Theia DI + .bind(TheiaDebugConfigurationWidget) + .toService(DebugConfigurationWidget); + child.bind(DebugWidget).toSelf(); + return child.get(DebugWidget); + }, + })) + .inSingletonScope(); // To avoid duplicate tabs use deepEqual instead of string equal: https://github.com/eclipse-theia/theia/issues/11309 bind(WidgetManager).toSelf().inSingletonScope(); @@ -982,8 +1018,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { }) .inSingletonScope(); - bind(HostedPluginSupport).toSelf().inSingletonScope(); - rebind(TheiaHostedPluginSupport).toService(HostedPluginSupport); + bind(HostedPluginSupportImpl).toSelf().inSingletonScope(); + bind(HostedPluginSupport).toService(HostedPluginSupportImpl); + rebind(TheiaHostedPluginSupport).toService(HostedPluginSupportImpl); bind(HostedPluginEvents).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(HostedPluginEvents); diff --git a/arduino-ide-extension/src/browser/boards/boards-config-component.tsx b/arduino-ide-extension/src/browser/boards/boards-config-component.tsx index acf2c7d4e..f14b8f390 100644 --- a/arduino-ide-extension/src/browser/boards/boards-config-component.tsx +++ b/arduino-ide-extension/src/browser/boards/boards-config-component.tsx @@ -48,16 +48,17 @@ namespace BoardsConfigComponent { } } -export abstract class Item extends React.Component<{ +class Item extends React.Component<{ item: T; label: string; selected: boolean; onClick: (item: T) => void; missing?: boolean; details?: string; + title?: string | ((item: T) => string); }> { override render(): React.ReactNode { - const { selected, label, missing, details } = this.props; + const { selected, label, missing, details, item } = this.props; const classNames = ['item']; if (selected) { classNames.push('selected'); @@ -65,11 +66,15 @@ export abstract class Item extends React.Component<{ if (missing === true) { classNames.push('missing'); } + let title = this.props.title ?? `${label}${!details ? '' : details}`; + if (typeof title === 'function') { + title = title(item); + } return (
{label}
{!details ? '' :
{details}
} @@ -234,9 +239,20 @@ export class BoardsConfigComponent extends React.Component< distinctBoards.set(key, board); } } + const title = (board: Board.Detailed): string => { + const { details, manuallyInstalled } = board; + let label = board.name; + if (details) { + label += details; + } + if (manuallyInstalled) { + label += nls.localize('arduino/board/inSketchbook', ' (in Sketchbook)'); + } + return label; + }; const boardsList = Array.from(distinctBoards.values()).map((board) => ( - + key={toKey(board)} item={board} label={board.name} @@ -244,6 +260,7 @@ export class BoardsConfigComponent extends React.Component< selected={board.selected} onClick={this.selectBoard} missing={board.missing} + title={title} /> )); diff --git a/arduino-ide-extension/src/browser/boards/boards-data-store.ts b/arduino-ide-extension/src/browser/boards/boards-data-store.ts index 579f30b7a..e78f5b74b 100644 --- a/arduino-ide-extension/src/browser/boards/boards-data-store.ts +++ b/arduino-ide-extension/src/browser/boards/boards-data-store.ts @@ -1,21 +1,41 @@ import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { StorageService } from '@theia/core/lib/browser/storage-service'; +import type { + Command, + CommandContribution, + CommandRegistry, +} from '@theia/core/lib/common/command'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { ILogger } from '@theia/core/lib/common/logger'; -import { deepClone } from '@theia/core/lib/common/objects'; +import { deepClone, deepFreeze } from '@theia/core/lib/common/objects'; +import type { Mutable } from '@theia/core/lib/common/types'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import { BoardDetails, BoardsService, ConfigOption, + ConfigValue, Programmer, + isBoardIdentifierChangeEvent, + isProgrammer, } from '../../common/protocol'; import { notEmpty } from '../../common/utils'; +import type { + StartupTask, + StartupTaskProvider, +} from '../../electron-common/startup-task'; import { NotificationCenter } from '../notification-center'; +import { BoardsServiceProvider } from './boards-service-provider'; @injectable() -export class BoardsDataStore implements FrontendApplicationContribution { +export class BoardsDataStore + implements + FrontendApplicationContribution, + StartupTaskProvider, + CommandContribution +{ @inject(ILogger) @named('store') private readonly logger: ILogger; @@ -28,44 +48,117 @@ export class BoardsDataStore implements FrontendApplicationContribution { // In other words, store the data (such as the board configs) per sketch, not per IDE2 installation. https://github.com/arduino/arduino-ide/issues/2240 @inject(StorageService) private readonly storageService: StorageService; + @inject(BoardsServiceProvider) + private readonly boardsServiceProvider: BoardsServiceProvider; + @inject(FrontendApplicationStateService) + private readonly appStateService: FrontendApplicationStateService; - private readonly onChangedEmitter = new Emitter(); - private readonly toDispose = new DisposableCollection(this.onChangedEmitter); + private readonly onDidChangeEmitter = + new Emitter(); + private readonly toDispose = new DisposableCollection( + this.onDidChangeEmitter + ); + private _selectedBoardData: BoardsDataStoreChange | undefined; onStart(): void { - this.toDispose.push( + this.toDispose.pushAll([ + this.boardsServiceProvider.onBoardsConfigDidChange((event) => { + if (isBoardIdentifierChangeEvent(event)) { + this.updateSelectedBoardData(event.selectedBoard?.fqbn); + } + }), this.notificationCenter.onPlatformDidInstall(async ({ item }) => { - const dataDidChangePerFqbn: string[] = []; - for (const fqbn of item.boards + const boardsWithFqbn = item.boards .map(({ fqbn }) => fqbn) - .filter(notEmpty) - .filter((fqbn) => !!fqbn)) { + .filter(notEmpty); + const changes: BoardsDataStoreChange[] = []; + for (const fqbn of boardsWithFqbn) { const key = this.getStorageKey(fqbn); - let data = await this.storageService.getData(key); - if (!data || !data.length) { - const details = await this.getBoardDetailsSafe(fqbn); - if (details) { - data = details.configOptions; - if (data.length) { - await this.storageService.setData(key, data); - dataDidChangePerFqbn.push(fqbn); - } - } + const storedData = + await this.storageService.getData(key); + if (!storedData) { + // if no previously value is available for the board, do not update the cache + continue; + } + const details = await this.loadBoardDetails(fqbn); + if (details) { + const data = createDataStoreEntry(details); + await this.storageService.setData(key, data); + changes.push({ fqbn, data }); } } - if (dataDidChangePerFqbn.length) { - this.fireChanged(...dataDidChangePerFqbn); + if (changes.length) { + this.fireChanged(...changes); + } + }), + this.onDidChange((event) => { + const selectedFqbn = + this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn; + if (event.changes.find((change) => change.fqbn === selectedFqbn)) { + this.updateSelectedBoardData(selectedFqbn); } - }) + }), + ]); + + Promise.all([ + this.boardsServiceProvider.ready, + this.appStateService.reachedState('ready'), + ]).then(() => + this.updateSelectedBoardData( + this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn + ) ); } + private async getSelectedBoardData( + fqbn: string | undefined + ): Promise { + if (!fqbn) { + return undefined; + } else { + const data = await this.getData(fqbn); + if (data === BoardsDataStore.Data.EMPTY) { + return undefined; + } + return { fqbn, data }; + } + } + + private async updateSelectedBoardData( + fqbn: string | undefined + ): Promise { + this._selectedBoardData = await this.getSelectedBoardData(fqbn); + } + onStop(): void { this.toDispose.dispose(); } - get onChanged(): Event { - return this.onChangedEmitter.event; + registerCommands(registry: CommandRegistry): void { + registry.registerCommand(USE_INHERITED_DATA, { + execute: async (arg: unknown) => { + if (isBoardsDataStoreChange(arg)) { + await this.setData(arg); + this.fireChanged(arg); + } + }, + }); + } + + tasks(): StartupTask[] { + if (!this._selectedBoardData) { + return []; + } + return [ + { + command: USE_INHERITED_DATA.id, + args: [this._selectedBoardData], + }, + ]; + } + + get onDidChange(): Event { + return this.onDidChangeEmitter.event; } async appendConfigToFqbn( @@ -84,22 +177,19 @@ export class BoardsDataStore implements FrontendApplicationContribution { } const key = this.getStorageKey(fqbn); - let data = await this.storageService.getData< + const storedData = await this.storageService.getData< BoardsDataStore.Data | undefined >(key, undefined); - if (BoardsDataStore.Data.is(data)) { - return data; + if (BoardsDataStore.Data.is(storedData)) { + return storedData; } - const boardDetails = await this.getBoardDetailsSafe(fqbn); + const boardDetails = await this.loadBoardDetails(fqbn); if (!boardDetails) { return BoardsDataStore.Data.EMPTY; } - data = { - configOptions: boardDetails.configOptions, - programmers: boardDetails.programmers, - }; + const data = createDataStoreEntry(boardDetails); await this.storageService.setData(key, data); return data; } @@ -111,17 +201,15 @@ export class BoardsDataStore implements FrontendApplicationContribution { fqbn: string; selectedProgrammer: Programmer; }): Promise { - const data = deepClone(await this.getData(fqbn)); - const { programmers } = data; + const storedData = deepClone(await this.getData(fqbn)); + const { programmers } = storedData; if (!programmers.find((p) => Programmer.equals(selectedProgrammer, p))) { return false; } - await this.setData({ - fqbn, - data: { ...data, selectedProgrammer }, - }); - this.fireChanged(fqbn); + const data = { ...storedData, selectedProgrammer }; + await this.setData({ fqbn, data }); + this.fireChanged({ fqbn, data }); return true; } @@ -142,28 +230,24 @@ export class BoardsDataStore implements FrontendApplicationContribution { } let updated = false; for (const value of configOption.values) { - if (value.value === selectedValue) { - (value as any).selected = true; + const mutable: Mutable = value; + if (mutable.value === selectedValue) { + mutable.selected = true; updated = true; } else { - (value as any).selected = false; + mutable.selected = false; } } if (!updated) { return false; } await this.setData({ fqbn, data }); - this.fireChanged(fqbn); + this.fireChanged({ fqbn, data }); return true; } - protected async setData({ - fqbn, - data, - }: { - fqbn: string; - data: BoardsDataStore.Data; - }): Promise { + protected async setData(change: BoardsDataStoreChange): Promise { + const { fqbn, data } = change; const key = this.getStorageKey(fqbn); return this.storageService.setData(key, data); } @@ -172,11 +256,9 @@ export class BoardsDataStore implements FrontendApplicationContribution { return `.arduinoIDE-configOptions-${fqbn}`; } - protected async getBoardDetailsSafe( - fqbn: string - ): Promise { + async loadBoardDetails(fqbn: string): Promise { try { - const details = this.boardsService.getBoardDetails({ fqbn }); + const details = await this.boardsService.getBoardDetails({ fqbn }); return details; } catch (err) { if ( @@ -197,8 +279,8 @@ export class BoardsDataStore implements FrontendApplicationContribution { } } - protected fireChanged(...fqbn: string[]): void { - this.onChangedEmitter.fire(fqbn); + protected fireChanged(...changes: BoardsDataStoreChange[]): void { + this.onDidChangeEmitter.fire({ changes }); } } @@ -207,20 +289,86 @@ export namespace BoardsDataStore { readonly configOptions: ConfigOption[]; readonly programmers: Programmer[]; readonly selectedProgrammer?: Programmer; + readonly defaultProgrammerId?: string; } export namespace Data { - export const EMPTY: Data = { + export const EMPTY: Data = deepFreeze({ configOptions: [], programmers: [], - }; - export function is(arg: any): arg is Data { + }); + + export function is(arg: unknown): arg is Data { return ( - !!arg && - 'configOptions' in arg && - Array.isArray(arg['configOptions']) && - 'programmers' in arg && - Array.isArray(arg['programmers']) + typeof arg === 'object' && + arg !== null && + Array.isArray((arg).configOptions) && + Array.isArray((arg).programmers) && + ((arg).selectedProgrammer === undefined || + isProgrammer((arg).selectedProgrammer)) && + ((arg).defaultProgrammerId === undefined || + typeof (arg).defaultProgrammerId === 'string') ); } } } + +export function isEmptyData(data: BoardsDataStore.Data): boolean { + return ( + Boolean(!data.configOptions.length) && + Boolean(!data.programmers.length) && + Boolean(!data.selectedProgrammer) && + Boolean(!data.defaultProgrammerId) + ); +} + +export function findDefaultProgrammer( + programmers: readonly Programmer[], + defaultProgrammerId: string | undefined | BoardsDataStore.Data +): Programmer | undefined { + if (!defaultProgrammerId) { + return undefined; + } + const id = + typeof defaultProgrammerId === 'string' + ? defaultProgrammerId + : defaultProgrammerId.defaultProgrammerId; + return programmers.find((p) => p.id === id); +} +function createDataStoreEntry(details: BoardDetails): BoardsDataStore.Data { + const configOptions = details.configOptions.slice(); + const programmers = details.programmers.slice(); + const { defaultProgrammerId } = details; + const selectedProgrammer = findDefaultProgrammer( + programmers, + defaultProgrammerId + ); + const data = { + configOptions, + programmers, + ...(selectedProgrammer ? { selectedProgrammer } : {}), + ...(defaultProgrammerId ? { defaultProgrammerId } : {}), + }; + return data; +} + +export interface BoardsDataStoreChange { + readonly fqbn: string; + readonly data: BoardsDataStore.Data; +} + +function isBoardsDataStoreChange(arg: unknown): arg is BoardsDataStoreChange { + return ( + typeof arg === 'object' && + arg !== null && + typeof (arg).fqbn === 'string' && + BoardsDataStore.Data.is((arg).data) + ); +} + +export interface BoardsDataStoreChangeEvent { + readonly changes: readonly BoardsDataStoreChange[]; +} + +const USE_INHERITED_DATA: Command = { + id: 'arduino-use-inherited-boards-data', +}; 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/boards-data-menu-updater.ts b/arduino-ide-extension/src/browser/contributions/boards-data-menu-updater.ts index d9fe0ae7e..ea085f5ba 100644 --- a/arduino-ide-extension/src/browser/contributions/boards-data-menu-updater.ts +++ b/arduino-ide-extension/src/browser/contributions/boards-data-menu-updater.ts @@ -35,7 +35,7 @@ export class BoardsDataMenuUpdater extends Contribution { private readonly toDisposeOnBoardChange = new DisposableCollection(); override onStart(): void { - this.boardsDataStore.onChanged(() => + this.boardsDataStore.onDidChange(() => this.updateMenuActions( this.boardsServiceProvider.boardsConfig.selectedBoard ) diff --git a/arduino-ide-extension/src/browser/contributions/debug.ts b/arduino-ide-extension/src/browser/contributions/debug.ts index 5dec4655c..d1f205051 100644 --- a/arduino-ide-extension/src/browser/contributions/debug.ts +++ b/arduino-ide-extension/src/browser/contributions/debug.ts @@ -1,106 +1,155 @@ +import { Emitter, Event } from '@theia/core/lib/common/event'; +import { MenuModelRegistry } from '@theia/core/lib/common/menu/menu-model-registry'; +import { nls } from '@theia/core/lib/common/nls'; +import { MaybePromise } from '@theia/core/lib/common/types'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { Event, Emitter } from '@theia/core/lib/common/event'; -import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin'; -import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; -import { NotificationCenter } from '../notification-center'; +import { noBoardSelected } from '../../common/nls'; import { - Board, + BoardDetails, BoardIdentifier, BoardsService, + CheckDebugEnabledParams, ExecutableService, + SketchRef, isBoardIdentifierChangeEvent, - Sketch, + isCompileSummary, } from '../../common/protocol'; +import { BoardsDataStore } from '../boards/boards-data-store'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; +import { HostedPluginSupport } from '../hosted/hosted-plugin-support'; +import { ArduinoMenus } from '../menu/arduino-menus'; +import { NotificationCenter } from '../notification-center'; +import { CurrentSketch } from '../sketches-service-client-impl'; +import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { - URI, Command, CommandRegistry, SketchContribution, TabBarToolbarRegistry, + URI, } from './contribution'; -import { MenuModelRegistry, nls } from '@theia/core/lib/common'; -import { CurrentSketch } from '../sketches-service-client-impl'; -import { ArduinoMenus } from '../menu/arduino-menus'; const COMPILE_FOR_DEBUG_KEY = 'arduino-compile-for-debug'; +interface StartDebugParams { + /** + * Absolute filesystem path to the Arduino CLI executable. + */ + readonly cliPath: string; + /** + * The the board to debug. + */ + readonly board: Readonly<{ fqbn: string; name?: string }>; + /** + * Absolute filesystem path of the sketch to debug. + */ + readonly sketchPath: string; + /** + * Location where the `launch.json` will be created on the fly before starting every debug session. + * If not defined, it falls back to `sketchPath/.vscode/launch.json`. + */ + readonly launchConfigsDirPath?: string; + /** + * Absolute path to the `arduino-cli.yaml` file. If not specified, it falls back to `~/.arduinoIDE/arduino-cli.yaml`. + */ + readonly cliConfigPath?: string; + /** + * Programmer for the debugging. + */ + readonly programmer?: string; + /** + * Custom progress title to use when getting the debug information from the CLI. + */ + readonly title?: string; +} +type StartDebugResult = boolean; + @injectable() export class Debug extends SketchContribution { @inject(HostedPluginSupport) private readonly hostedPluginSupport: HostedPluginSupport; - @inject(NotificationCenter) private readonly notificationCenter: NotificationCenter; - @inject(ExecutableService) private readonly executableService: ExecutableService; - @inject(BoardsService) private readonly boardService: BoardsService; - @inject(BoardsServiceProvider) private readonly boardsServiceProvider: BoardsServiceProvider; + @inject(BoardsDataStore) + private readonly boardsDataStore: BoardsDataStore; /** - * If `undefined`, debugging is enabled. Otherwise, the reason why it's disabled. + * If `undefined`, debugging is enabled. Otherwise, the human-readable reason why it's disabled. */ - private _disabledMessages?: string = nls.localize( - 'arduino/common/noBoardSelected', - 'No board selected' - ); // Initial pessimism. - private disabledMessageDidChangeEmitter = new Emitter(); - private onDisabledMessageDidChange = - this.disabledMessageDidChangeEmitter.event; + private _message?: string = noBoardSelected; // Initial pessimism. + private didChangeMessageEmitter = new Emitter(); + private onDidChangeMessage = this.didChangeMessageEmitter.event; - private get disabledMessage(): string | undefined { - return this._disabledMessages; + private get message(): string | undefined { + return this._message; } - private set disabledMessage(message: string | undefined) { - this._disabledMessages = message; - this.disabledMessageDidChangeEmitter.fire(this._disabledMessages); + private set message(message: string | undefined) { + this._message = message; + this.didChangeMessageEmitter.fire(this._message); } private readonly debugToolbarItem = { id: Debug.Commands.START_DEBUGGING.id, command: Debug.Commands.START_DEBUGGING.id, tooltip: `${ - this.disabledMessage + this.message ? nls.localize( 'arduino/debug/debugWithMessage', 'Debug - {0}', - this.disabledMessage + this.message ) : Debug.Commands.START_DEBUGGING.label }`, priority: 3, - onDidChange: this.onDisabledMessageDidChange as Event, + onDidChange: this.onDidChangeMessage as Event, }; override onStart(): void { - this.onDisabledMessageDidChange( + this.onDidChangeMessage( () => (this.debugToolbarItem.tooltip = `${ - this.disabledMessage + this.message ? nls.localize( 'arduino/debug/debugWithMessage', 'Debug - {0}', - this.disabledMessage + this.message ) : Debug.Commands.START_DEBUGGING.label }`) ); this.boardsServiceProvider.onBoardsConfigDidChange((event) => { if (isBoardIdentifierChangeEvent(event)) { - this.refreshState(event.selectedBoard); + this.updateMessage(); + } + }); + this.notificationCenter.onPlatformDidInstall(() => this.updateMessage()); + this.notificationCenter.onPlatformDidUninstall(() => this.updateMessage()); + this.boardsDataStore.onDidChange((event) => { + const selectedFqbn = + this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn; + if (event.changes.find((change) => change.fqbn === selectedFqbn)) { + this.updateMessage(); + } + }); + this.commandService.onDidExecuteCommand((event) => { + const { commandId, args } = event; + if ( + commandId === 'arduino.languageserver.notifyBuildDidComplete' && + isCompileSummary(args[0]) + ) { + this.updateMessage(); } }); - this.notificationCenter.onPlatformDidInstall(() => this.refreshState()); - this.notificationCenter.onPlatformDidUninstall(() => this.refreshState()); } override onReady(): void { - this.boardsServiceProvider.ready.then(() => this.refreshState()); + this.boardsServiceProvider.ready.then(() => this.updateMessage()); } override registerCommands(registry: CommandRegistry): void { @@ -108,7 +157,7 @@ export class Debug extends SketchContribution { execute: () => this.startDebug(), isVisible: (widget) => ArduinoToolbar.is(widget) && widget.side === 'left', - isEnabled: () => !this.disabledMessage, + isEnabled: () => !this.message, }); registry.registerCommand(Debug.Commands.TOGGLE_OPTIMIZE_FOR_DEBUG, { execute: () => this.toggleCompileForDebug(), @@ -131,94 +180,56 @@ export class Debug extends SketchContribution { }); } - private async refreshState( - board: Board | undefined = this.boardsServiceProvider.boardsConfig - .selectedBoard - ): Promise { - if (!board) { - this.disabledMessage = nls.localize( - 'arduino/common/noBoardSelected', - 'No board selected' - ); - return; - } - const fqbn = board.fqbn; - if (!fqbn) { - this.disabledMessage = nls.localize( - 'arduino/debug/noPlatformInstalledFor', - "Platform is not installed for '{0}'", - board.name - ); - return; - } - const details = await this.boardService.getBoardDetails({ fqbn }); - if (!details) { - this.disabledMessage = nls.localize( - 'arduino/debug/noPlatformInstalledFor', - "Platform is not installed for '{0}'", - board.name - ); - return; - } - const { debuggingSupported } = details; - if (!debuggingSupported) { - this.disabledMessage = nls.localize( - 'arduino/debug/debuggingNotSupported', - "Debugging is not supported by '{0}'", - board.name - ); - } else { - this.disabledMessage = undefined; + private async updateMessage(): Promise { + try { + await this.isDebugEnabled(); + this.message = undefined; + } catch (err) { + let message = String(err); + if (err instanceof Error) { + message = err.message; + } + this.message = message; } } - private async startDebug( + private async isDebugEnabled( board: BoardIdentifier | undefined = this.boardsServiceProvider.boardsConfig .selectedBoard - ): Promise { - if (!board) { - return; + ): Promise { + const debugFqbn = await isDebugEnabled( + board, + (fqbn) => this.boardService.getBoardDetails({ fqbn }), + (fqbn) => this.boardsDataStore.getData(fqbn), + (fqbn) => this.boardsDataStore.appendConfigToFqbn(fqbn), + (params) => this.boardService.checkDebugEnabled(params) + ); + return debugFqbn; + } + + private async startDebug( + board: BoardIdentifier | undefined = this.boardsServiceProvider.boardsConfig + .selectedBoard, + sketch: + | CurrentSketch + | undefined = this.sketchServiceClient.tryGetCurrentSketch() + ): Promise { + if (!CurrentSketch.isValid(sketch)) { + return false; } - const { name, fqbn } = board; - if (!fqbn) { - return; + const params = await this.createStartDebugParams(board); + if (!params) { + return false; } await this.hostedPluginSupport.didStart; - const [sketch, executables] = await Promise.all([ - this.sketchServiceClient.currentSketch(), - this.executableService.list(), - ]); - if (!CurrentSketch.isValid(sketch)) { - return; - } - const ideTempFolderUri = await this.sketchesService.getIdeTempFolderUri( - sketch - ); - const [cliPath, sketchPath, configPath] = await Promise.all([ - this.fileService.fsPath(new URI(executables.cliUri)), - this.fileService.fsPath(new URI(sketch.uri)), - this.fileService.fsPath(new URI(ideTempFolderUri)), - ]); - const config = { - cliPath, - board: { - fqbn, - name, - }, - sketchPath, - configPath, - }; try { - await this.commandService.executeCommand('arduino.debug.start', config); + const result = await this.debug(params); + return Boolean(result); } catch (err) { if (await this.isSketchNotVerifiedError(err, sketch)) { const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes'); const answer = await this.messageService.error( - nls.localize( - 'arduino/debug/sketchIsNotCompiled', - "Sketch '{0}' must be verified before starting a debug session. Please verify the sketch and start debugging again. Do you want to verify the sketch now?", - sketch.name - ), + sketchIsNotCompiled(sketch.name), yes ); if (answer === yes) { @@ -230,6 +241,16 @@ export class Debug extends SketchContribution { ); } } + return false; + } + + private async debug( + params: StartDebugParams + ): Promise { + return this.commandService.executeCommand( + 'arduino.debug.start', + params + ); } get compileForDebug(): boolean { @@ -237,7 +258,7 @@ export class Debug extends SketchContribution { return value === 'true'; } - async toggleCompileForDebug(): Promise { + private toggleCompileForDebug(): void { const oldState = this.compileForDebug; const newState = !oldState; window.localStorage.setItem(COMPILE_FOR_DEBUG_KEY, String(newState)); @@ -246,7 +267,7 @@ export class Debug extends SketchContribution { private async isSketchNotVerifiedError( err: unknown, - sketch: Sketch + sketch: SketchRef ): Promise { if (err instanceof Error) { try { @@ -260,6 +281,48 @@ export class Debug extends SketchContribution { } return false; } + + private async createStartDebugParams( + board: BoardIdentifier | undefined + ): Promise { + if (!board || !board.fqbn) { + return undefined; + } + let debugFqbn: string | undefined = undefined; + try { + debugFqbn = await this.isDebugEnabled(board); + } catch {} + if (!debugFqbn) { + return undefined; + } + const [sketch, executables, boardsData] = await Promise.all([ + this.sketchServiceClient.currentSketch(), + this.executableService.list(), + this.boardsDataStore.getData(board.fqbn), + ]); + if (!CurrentSketch.isValid(sketch)) { + return undefined; + } + const ideTempFolderUri = await this.sketchesService.getIdeTempFolderUri( + sketch + ); + const [cliPath, sketchPath, launchConfigsDirPath] = await Promise.all([ + this.fileService.fsPath(new URI(executables.cliUri)), + this.fileService.fsPath(new URI(sketch.uri)), + this.fileService.fsPath(new URI(ideTempFolderUri)), + ]); + return { + board: { fqbn: debugFqbn, name: board.name }, + cliPath, + sketchPath, + launchConfigsDirPath, + programmer: boardsData.selectedProgrammer?.id, + title: nls.localize( + 'arduino/debug/getDebugInfo', + 'Getting debug info...' + ), + }; + } } export namespace Debug { export namespace Commands { @@ -284,3 +347,89 @@ export namespace Debug { }; } } + +/** + * (non-API) + */ +export async function isDebugEnabled( + board: BoardIdentifier | undefined, + getDetails: (fqbn: string) => MaybePromise, + getData: (fqbn: string) => MaybePromise, + appendConfigToFqbn: (fqbn: string) => MaybePromise, + checkDebugEnabled: (params: CheckDebugEnabledParams) => MaybePromise +): Promise { + if (!board) { + throw new Error(noBoardSelected); + } + const { fqbn } = board; + if (!fqbn) { + throw new Error(noPlatformInstalledFor(board.name)); + } + const [details, data, fqbnWithConfig] = await Promise.all([ + getDetails(fqbn), + getData(fqbn), + appendConfigToFqbn(fqbn), + ]); + if (!details) { + throw new Error(noPlatformInstalledFor(board.name)); + } + if (!fqbnWithConfig) { + throw new Error( + `Failed to append boards config to the FQBN. Original FQBN was: ${fqbn}` + ); + } + if (!data.selectedProgrammer) { + throw new Error(noProgrammerSelectedFor(board.name)); + } + const params = { + fqbn: fqbnWithConfig, + programmer: data.selectedProgrammer.id, + }; + try { + const debugFqbn = await checkDebugEnabled(params); + return debugFqbn; + } catch (err) { + throw new Error(debuggingNotSupported(board.name)); + } +} + +/** + * (non-API) + */ +export function sketchIsNotCompiled(sketchName: string): string { + return nls.localize( + 'arduino/debug/sketchIsNotCompiled', + "Sketch '{0}' must be verified before starting a debug session. Please verify the sketch and start debugging again. Do you want to verify the sketch now?", + sketchName + ); +} +/** + * (non-API) + */ +export function noPlatformInstalledFor(boardName: string): string { + return nls.localize( + 'arduino/debug/noPlatformInstalledFor', + "Platform is not installed for '{0}'", + boardName + ); +} +/** + * (non-API) + */ +export function debuggingNotSupported(boardName: string): string { + return nls.localize( + 'arduino/debug/debuggingNotSupported', + "Debugging is not supported by '{0}'", + boardName + ); +} +/** + * (non-API) + */ +export function noProgrammerSelectedFor(boardName: string): string { + return nls.localize( + 'arduino/debug/noProgrammerSelectedFor', + "No programmer selected for '{0}'", + boardName + ); +} diff --git a/arduino-ide-extension/src/browser/contributions/ino-language.ts b/arduino-ide-extension/src/browser/contributions/ino-language.ts index 096c27ed8..5d1fe4638 100644 --- a/arduino-ide-extension/src/browser/contributions/ino-language.ts +++ b/arduino-ide-extension/src/browser/contributions/ino-language.ts @@ -15,31 +15,88 @@ import { } from '../../common/protocol'; import { CurrentSketch } from '../sketches-service-client-impl'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; -import { HostedPluginEvents } from '../hosted-plugin-events'; +import { HostedPluginEvents } from '../hosted/hosted-plugin-events'; import { NotificationCenter } from '../notification-center'; import { SketchContribution, URI } from './contribution'; import { BoardsDataStore } from '../boards/boards-data-store'; +interface DaemonAddress { + /** + * The host where the Arduino CLI daemon is available. + */ + readonly hostname: string; + /** + * The port where the Arduino CLI daemon is listening. + */ + readonly port: number; + /** + * The [id](https://arduino.github.io/arduino-cli/latest/rpc/commands/#instance) of the initialized core Arduino client instance. + */ + readonly instance: number; +} + +interface StartLanguageServerParams { + /** + * Absolute filesystem path to the Arduino Language Server executable. + */ + readonly lsPath: string; + /** + * The hostname and the port for the gRPC channel connecting to the Arduino CLI daemon. + * The `instance` number is for the initialized core Arduino client. + */ + readonly daemonAddress: DaemonAddress; + /** + * Absolute filesystem path to [`clangd`](https://clangd.llvm.org/). + */ + readonly clangdPath: string; + /** + * The board is relevant to start a specific "flavor" of the language. + */ + readonly board: { fqbn: string; name?: string }; + /** + * `true` if the LS should generate the log files into the default location. The default location is the `cwd` of the process. + * It's very often the same as the workspace root of the IDE, aka the sketch folder. + * When it is a string, it is the absolute filesystem path to the folder to generate the log files. + * If `string`, but the path is inaccessible, the log files will be generated into the default location. + */ + readonly log?: boolean | string; + /** + * Optional `env` for the language server process. + */ + readonly env?: NodeJS.ProcessEnv; + /** + * Additional flags for the Arduino Language server process. + */ + readonly flags?: readonly string[]; + /** + * Set to `true`, to enable `Diagnostics`. + */ + readonly realTimeDiagnostics?: boolean; + /** + * If `true`, the logging is not forwarded to the _Output_ view via the language client. + */ + readonly silentOutput?: boolean; +} + +/** + * The FQBN the language server runs with or `undefined` if it could not start. + */ +type StartLanguageServerResult = string | undefined; + @injectable() export class InoLanguage extends SketchContribution { @inject(HostedPluginEvents) private readonly hostedPluginEvents: HostedPluginEvents; - @inject(ExecutableService) private readonly executableService: ExecutableService; - @inject(ArduinoDaemon) private readonly daemon: ArduinoDaemon; - @inject(BoardsService) private readonly boardsService: BoardsService; - @inject(BoardsServiceProvider) private readonly boardsServiceProvider: BoardsServiceProvider; - @inject(NotificationCenter) private readonly notificationCenter: NotificationCenter; - @inject(BoardsDataStore) private readonly boardDataStore: BoardsDataStore; @@ -90,7 +147,7 @@ export class InoLanguage extends SketchContribution { this.notificationCenter.onPlatformDidInstall(() => forceRestart()), this.notificationCenter.onPlatformDidUninstall(() => forceRestart()), this.notificationCenter.onDidReinitialize(() => forceRestart()), - this.boardDataStore.onChanged((dataChangePerFqbn) => { + this.boardDataStore.onDidChange((event) => { if (this.languageServerFqbn) { const sanitizedFqbn = sanitizeFqbn(this.languageServerFqbn); if (!sanitizeFqbn) { @@ -98,13 +155,13 @@ export class InoLanguage extends SketchContribution { `Failed to sanitize the FQBN of the running language server. FQBN with the board settings was: ${this.languageServerFqbn}` ); } - const matchingFqbn = dataChangePerFqbn.find( - (fqbn) => sanitizedFqbn === fqbn + const matchingChange = event.changes.find( + (change) => change.fqbn === sanitizedFqbn ); const { boardsConfig } = this.boardsServiceProvider; if ( - matchingFqbn && - boardsConfig.selectedBoard?.fqbn === matchingFqbn + matchingChange && + boardsConfig.selectedBoard?.fqbn === matchingChange.fqbn ) { start(boardsConfig.selectedBoard); } @@ -129,6 +186,10 @@ export class InoLanguage extends SketchContribution { if (!port) { return; } + const portNumber = Number.parseInt(port, 10); // TODO: IDE2 APIs should provide a number and not string + if (Number.isNaN(portNumber)) { + return; + } const release = await this.languageServerStartMutex.acquire(); const toDisposeOnRelease = new DisposableCollection(); try { @@ -197,22 +258,22 @@ export class InoLanguage extends SketchContribution { ); toDisposeOnRelease.push(Disposable.create(() => clearTimeout(timer))); }), - this.commandService.executeCommand( - 'arduino.languageserver.start', - { - lsPath, - cliDaemonAddr: `localhost:${port}`, - clangdPath, - log: currentSketchPath ? currentSketchPath : log, - cliDaemonInstance: '1', - board: { - fqbn: fqbnWithConfig, - name: name ? `"${name}"` : undefined, - }, - realTimeDiagnostics, - silentOutput: true, - } - ), + this.start({ + lsPath, + daemonAddress: { + hostname: 'localhost', + port: portNumber, + instance: 1, // TODO: get it from the backend + }, + clangdPath, + log: currentSketchPath ? currentSketchPath : log, + board: { + fqbn: fqbnWithConfig, + name, + }, + realTimeDiagnostics, + silentOutput: true, + }), ]); } catch (e) { console.log(`Failed to start language server. Original FQBN: ${fqbn}`, e); @@ -222,4 +283,13 @@ export class InoLanguage extends SketchContribution { release(); } } + + private async start( + params: StartLanguageServerParams + ): Promise { + return this.commandService.executeCommand( + 'arduino.languageserver.start', + params + ); + } } diff --git a/arduino-ide-extension/src/browser/contributions/update-arduino-state.ts b/arduino-ide-extension/src/browser/contributions/update-arduino-state.ts index e83e46f51..767fbbf8c 100644 --- a/arduino-ide-extension/src/browser/contributions/update-arduino-state.ts +++ b/arduino-ide-extension/src/browser/contributions/update-arduino-state.ts @@ -1,7 +1,7 @@ import { DisposableCollection } from '@theia/core/lib/common/disposable'; import URI from '@theia/core/lib/common/uri'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin'; +import { HostedPluginSupport } from '../hosted/hosted-plugin-support'; import type { ArduinoState } from 'vscode-arduino-api'; import { BoardsService, @@ -21,7 +21,10 @@ import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { CurrentSketch } from '../sketches-service-client-impl'; import { SketchContribution } from './contribution'; -interface UpdateStateParams { +/** + * (non-API) exported for tests + */ +export interface UpdateStateParams { readonly key: keyof T; readonly value: T[keyof T]; } @@ -65,10 +68,13 @@ export class UpdateArduinoState extends SketchContribution { this.updateCompileSummary(args[0]); } }), - this.boardsDataStore.onChanged((fqbn) => { + this.boardsDataStore.onDidChange((event) => { const selectedFqbn = this.boardsServiceProvider.boardsConfig.selectedBoard?.fqbn; - if (selectedFqbn && fqbn.includes(selectedFqbn)) { + if ( + selectedFqbn && + event.changes.find((change) => change.fqbn === selectedFqbn) + ) { this.updateBoardDetails(selectedFqbn); } }), diff --git a/arduino-ide-extension/src/browser/hosted-plugin-events.ts b/arduino-ide-extension/src/browser/hosted/hosted-plugin-events.ts similarity index 97% rename from arduino-ide-extension/src/browser/hosted-plugin-events.ts rename to arduino-ide-extension/src/browser/hosted/hosted-plugin-events.ts index ac7b1fe0d..35dbdbe4c 100644 --- a/arduino-ide-extension/src/browser/hosted-plugin-events.ts +++ b/arduino-ide-extension/src/browser/hosted/hosted-plugin-events.ts @@ -1,7 +1,7 @@ import { DisposableCollection, Emitter, Event } from '@theia/core'; import { FrontendApplicationContribution } from '@theia/core/lib/browser'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { HostedPluginSupport } from './theia/plugin-ext/hosted-plugin'; +import { HostedPluginSupport } from './hosted-plugin-support'; /** * Frontend contribution to watch VS Code extension start/stop events from Theia. diff --git a/arduino-ide-extension/src/browser/hosted/hosted-plugin-support.ts b/arduino-ide-extension/src/browser/hosted/hosted-plugin-support.ts new file mode 100644 index 000000000..b0d11c86a --- /dev/null +++ b/arduino-ide-extension/src/browser/hosted/hosted-plugin-support.ts @@ -0,0 +1,14 @@ +import type { Event } from '@theia/core/lib/common/event'; + +/* +This implementation hides the default HostedPluginSupport implementation from Theia to be able to test it. +Otherwise, the default implementation fails at require time due to the `import.meta` in the Theia plugin worker code. +https://github.com/eclipse-theia/theia/blob/964f69ca3b3a5fb87ffa0177fb300b74ba0ca39f/packages/plugin-ext/src/hosted/browser/plugin-worker.ts#L30-L32 +*/ + +export const HostedPluginSupport = Symbol('HostedPluginSupport'); +export interface HostedPluginSupport { + readonly didStart: Promise; + readonly onDidLoad: Event; + readonly onDidCloseConnection: Event; +} diff --git a/arduino-ide-extension/src/browser/sketches-service-client-impl.ts b/arduino-ide-extension/src/browser/sketches-service-client-impl.ts index f0186454c..9b3cdac94 100644 --- a/arduino-ide-extension/src/browser/sketches-service-client-impl.ts +++ b/arduino-ide-extension/src/browser/sketches-service-client-impl.ts @@ -67,6 +67,7 @@ export class SketchesServiceClientImpl ); private _currentSketch: CurrentSketch | undefined; + private _currentIdeTempFolderUri: URI | undefined; private currentSketchLoaded = new Deferred(); onStart(): void { @@ -74,7 +75,10 @@ export class SketchesServiceClientImpl this.watchSketchbookDir(sketchDirUri); const refreshCurrentSketch = async () => { const currentSketch = await this.loadCurrentSketch(); - this.useCurrentSketch(currentSketch); + const ideTempFolderUri = await this.getIdeTempFolderUriForSketch( + currentSketch + ); + this.useCurrentSketch(currentSketch, ideTempFolderUri); }; this.toDispose.push( this.configService.onDidChangeSketchDirUri((sketchDirUri) => { @@ -141,7 +145,10 @@ export class SketchesServiceClientImpl } if (!Sketch.sameAs(this._currentSketch, reloadedSketch)) { - this.useCurrentSketch(reloadedSketch, true); + const ideTempFolderUri = await this.getIdeTempFolderUriForSketch( + reloadedSketch + ); + this.useCurrentSketch(reloadedSketch, ideTempFolderUri, true); } return; } @@ -179,11 +186,23 @@ export class SketchesServiceClientImpl ]); } + private async getIdeTempFolderUriForSketch( + sketch: CurrentSketch + ): Promise { + if (CurrentSketch.isValid(sketch)) { + const uri = await this.sketchesService.getIdeTempFolderUri(sketch); + return new URI(uri); + } + return undefined; + } + private useCurrentSketch( currentSketch: CurrentSketch, + ideTempFolderUri: URI | undefined, reassignPromise = false ) { this._currentSketch = currentSketch; + this._currentIdeTempFolderUri = ideTempFolderUri; if (reassignPromise) { this.currentSketchLoaded = new Deferred(); } @@ -273,6 +292,14 @@ export class SketchesServiceClientImpl return false; } + if ( + this._currentIdeTempFolderUri && + this._currentIdeTempFolderUri.resolve('launch.json').toString() === + toCheck.toString() + ) { + return false; + } + const isCloudSketch = toCheck .toString() .includes(`${REMOTE_SKETCHBOOK_FOLDER}/${ARDUINO_CLOUD_FOLDER}`); diff --git a/arduino-ide-extension/src/browser/theia/debug/debug-configuration-manager.ts b/arduino-ide-extension/src/browser/theia/debug/debug-configuration-manager.ts index f877e0e12..6e0210a41 100644 --- a/arduino-ide-extension/src/browser/theia/debug/debug-configuration-manager.ts +++ b/arduino-ide-extension/src/browser/theia/debug/debug-configuration-manager.ts @@ -1,44 +1,44 @@ -import debounce from 'p-debounce'; -import { inject, injectable } from '@theia/core/shared/inversify'; -import URI from '@theia/core/lib/common/uri'; -import { Event, Emitter } from '@theia/core/lib/common/event'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; -import { DebugConfiguration } from '@theia/debug/lib/common/debug-common'; -import { DebugConfigurationModel as TheiaDebugConfigurationModel } from '@theia/debug/lib/browser/debug-configuration-model'; +import { Disposable } from '@theia/core/lib/common/disposable'; +import { Emitter, Event } from '@theia/core/lib/common/event'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { DebugConfigurationManager as TheiaDebugConfigurationManager } from '@theia/debug/lib/browser/debug-configuration-manager'; +import { DebugConfigurationModel as TheiaDebugConfigurationModel } from '@theia/debug/lib/browser/debug-configuration-model'; +import { DebugConfiguration } from '@theia/debug/lib/common/debug-common'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { + FileOperationError, + FileOperationResult, +} from '@theia/filesystem/lib/common/files'; +import debounce from 'p-debounce'; import { SketchesService } from '../../../common/protocol'; import { CurrentSketch, SketchesServiceClientImpl, } from '../../sketches-service-client-impl'; +import { maybeUpdateReadOnlyState } from '../monaco/monaco-editor-provider'; import { DebugConfigurationModel } from './debug-configuration-model'; -import { - FileOperationError, - FileOperationResult, -} from '@theia/filesystem/lib/common/files'; -import { FileService } from '@theia/filesystem/lib/browser/file-service'; @injectable() export class DebugConfigurationManager extends TheiaDebugConfigurationManager { @inject(SketchesService) - protected readonly sketchesService: SketchesService; - + private readonly sketchesService: SketchesService; @inject(SketchesServiceClientImpl) - protected readonly sketchesServiceClient: SketchesServiceClientImpl; - + private readonly sketchesServiceClient: SketchesServiceClientImpl; @inject(FrontendApplicationStateService) - protected readonly appStateService: FrontendApplicationStateService; - + private readonly appStateService: FrontendApplicationStateService; @inject(FileService) - protected readonly fileService: FileService; + private readonly fileService: FileService; - protected onTempContentDidChangeEmitter = + private onTempContentDidChangeEmitter = new Emitter(); get onTempContentDidChange(): Event { return this.onTempContentDidChangeEmitter.event; } protected override async doInit(): Promise { + this.watchLaunchConfigEditor(); this.appStateService.reachedState('ready').then(async () => { const tempContent = await this.getTempLaunchJsonContent(); if (!tempContent) { @@ -75,6 +75,19 @@ export class DebugConfigurationManager extends TheiaDebugConfigurationManager { return super.doInit(); } + /** + * Sets a listener on current sketch change, and maybe updates the readonly state of the editor showing the debug configuration. aka the `launch.json`. + */ + private watchLaunchConfigEditor(): Disposable { + return this.sketchesServiceClient.onCurrentSketchDidChange(() => { + for (const widget of this.editorManager.all) { + maybeUpdateReadOnlyState(widget, (uri) => + this.sketchesServiceClient.isReadOnly(uri) + ); + } + }); + } + protected override updateModels = debounce(async () => { await this.appStateService.reachedState('ready'); const roots = await this.workspaceService.roots; @@ -111,7 +124,7 @@ export class DebugConfigurationManager extends TheiaDebugConfigurationManager { this.updateCurrent(); }, 500); - protected async getTempLaunchJsonContent(): Promise< + private async getTempLaunchJsonContent(): Promise< (TheiaDebugConfigurationModel.JsonContent & { uri: URI }) | URI | undefined > { const sketch = await this.sketchesServiceClient.currentSketch(); diff --git a/arduino-ide-extension/src/browser/theia/debug/debug-configuration-widget.tsx b/arduino-ide-extension/src/browser/theia/debug/debug-configuration-widget.tsx new file mode 100644 index 000000000..7d05a4b68 --- /dev/null +++ b/arduino-ide-extension/src/browser/theia/debug/debug-configuration-widget.tsx @@ -0,0 +1,57 @@ +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { nls } from '@theia/core/lib/common/nls'; +import { injectable } from '@theia/core/shared/inversify'; +import React from '@theia/core/shared/react'; +import { DebugAction } from '@theia/debug/lib/browser/view/debug-action'; +import { DebugConfigurationSelect as TheiaDebugConfigurationSelect } from '@theia/debug/lib/browser/view/debug-configuration-select'; +import { DebugConfigurationWidget as TheiaDebugConfigurationWidget } from '@theia/debug/lib/browser/view/debug-configuration-widget'; + +/** + * Patched to programmatically update the debug config