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 9b9079617..ba340a0f7 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,7 +170,7 @@ ], "arduino": { "arduino-cli": { - "version": "0.34.0" + "version": "0.35.0-rc.7" }, "arduino-fwuploader": { "version": "2.4.1" 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 fa92aeafe..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'; @@ -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 @@ -756,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 @@ -857,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(); 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 e6e34abf0..e78f5b74b 100644 --- a/arduino-ide-extension/src/browser/boards/boards-data-store.ts +++ b/arduino-ide-extension/src/browser/boards/boards-data-store.ts @@ -10,13 +10,16 @@ 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, 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 { @@ -74,7 +77,7 @@ export class BoardsDataStore const storedData = await this.storageService.getData(key); if (!storedData) { - // if not previously value is available for the board, do not update the cache + // if no previously value is available for the board, do not update the cache continue; } const details = await this.loadBoardDetails(fqbn); @@ -88,6 +91,13 @@ export class BoardsDataStore 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([ @@ -174,7 +184,7 @@ export class BoardsDataStore return storedData; } - const boardDetails = await this.getBoardDetailsSafe(fqbn); + const boardDetails = await this.loadBoardDetails(fqbn); if (!boardDetails) { return BoardsDataStore.Data.EMPTY; } @@ -220,11 +230,12 @@ export class BoardsDataStore } 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) { @@ -245,9 +256,7 @@ export class BoardsDataStore return `.arduinoIDE-configOptions-${fqbn}`; } - protected async getBoardDetailsSafe( - fqbn: string - ): Promise { + async loadBoardDetails(fqbn: string): Promise { try { const details = await this.boardsService.getBoardDetails({ fqbn }); return details; @@ -280,21 +289,24 @@ export namespace BoardsDataStore { readonly configOptions: ConfigOption[]; readonly programmers: Programmer[]; readonly selectedProgrammer?: Programmer; + readonly defaultProgrammerId?: string; } export namespace Data { export const EMPTY: Data = deepFreeze({ configOptions: [], programmers: [], - defaultProgrammerId: undefined, }); 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') ); } } @@ -304,7 +316,8 @@ export function isEmptyData(data: BoardsDataStore.Data): boolean { return ( Boolean(!data.configOptions.length) && Boolean(!data.programmers.length) && - Boolean(!data.selectedProgrammer) + Boolean(!data.selectedProgrammer) && + Boolean(!data.defaultProgrammerId) ); } @@ -324,16 +337,18 @@ export function findDefaultProgrammer( function createDataStoreEntry(details: BoardDetails): BoardsDataStore.Data { const configOptions = details.configOptions.slice(); const programmers = details.programmers.slice(); + const { defaultProgrammerId } = details; const selectedProgrammer = findDefaultProgrammer( programmers, - details.defaultProgrammerId + defaultProgrammerId ); - return { + const data = { configOptions, programmers, - defaultProgrammerId: details.defaultProgrammerId, - selectedProgrammer, + ...(selectedProgrammer ? { selectedProgrammer } : {}), + ...(defaultProgrammerId ? { defaultProgrammerId } : {}), }; + return data; } export interface BoardsDataStoreChange { 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/debug.ts b/arduino-ide-extension/src/browser/contributions/debug.ts index 8aebd0d82..d1f205051 100644 --- a/arduino-ide-extension/src/browser/contributions/debug.ts +++ b/arduino-ide-extension/src/browser/contributions/debug.ts @@ -1,12 +1,20 @@ +import { Emitter, Event } from '@theia/core/lib/common/event'; +import { MenuModelRegistry } from '@theia/core/lib/common/menu/menu-model-registry'; +import { nls } from '@theia/core/lib/common/nls'; +import { MaybePromise } from '@theia/core/lib/common/types'; import { inject, injectable } from '@theia/core/shared/inversify'; +import { noBoardSelected } from '../../common/nls'; import { - 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'; @@ -14,95 +22,119 @@ 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.refreshState()); - this.notificationCenter.onPlatformDidUninstall(() => this.refreshState()); + 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.refreshState(); + this.updateMessage(); } }); this.commandService.onDidExecuteCommand((event) => { @@ -111,13 +143,13 @@ export class Debug extends SketchContribution { commandId === 'arduino.languageserver.notifyBuildDidComplete' && isCompileSummary(args[0]) ) { - this.refreshState(); + this.updateMessage(); } }); } override onReady(): void { - this.boardsServiceProvider.ready.then(() => this.refreshState()); + this.boardsServiceProvider.ready.then(() => this.updateMessage()); } override registerCommands(registry: CommandRegistry): void { @@ -125,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(), @@ -148,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) { @@ -247,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 { @@ -254,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)); @@ -263,7 +267,7 @@ export class Debug extends SketchContribution { private async isSketchNotVerifiedError( err: unknown, - sketch: Sketch + sketch: SketchRef ): Promise { if (err instanceof Error) { try { @@ -277,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 { @@ -301,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 5f9a3f127..5d1fe4638 100644 --- a/arduino-ide-extension/src/browser/contributions/ino-language.ts +++ b/arduino-ide-extension/src/browser/contributions/ino-language.ts @@ -20,26 +20,83 @@ 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; @@ -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/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