From bf11467157747002704bb67687bd8d1d77a2f387 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Tue, 16 Aug 2022 13:37:31 +0200 Subject: [PATCH] Refresh menus when opening example/recent fails. Closes #53 Signed-off-by: Akos Kitta --- .../src/browser/contributions/examples.ts | 90 ++++- .../src/browser/contributions/new-sketch.ts | 9 - .../contributions/open-recent-sketch.ts | 40 ++- .../src/browser/contributions/open-sketch.ts | 151 +++----- .../src/browser/contributions/save-sketch.ts | 10 - .../src/browser/contributions/sketchbook.ts | 51 +-- .../src/browser/theia/core/about-dialog.ts | 2 - .../src/common/protocol/core-service.ts | 4 + .../src/common/protocol/sketches-service.ts | 57 ++- .../src/node/arduino-ide-backend-module.ts | 1 + .../src/node/config-service-impl.ts | 2 - .../src/node/core-client-provider.ts | 9 + .../src/node/examples-service-impl.ts | 13 +- .../src/node/sketches-service-impl.ts | 332 ++++++++++-------- 14 files changed, 390 insertions(+), 381 deletions(-) diff --git a/arduino-ide-extension/src/browser/contributions/examples.ts b/arduino-ide-extension/src/browser/contributions/examples.ts index d7185178b..1f818a217 100644 --- a/arduino-ide-extension/src/browser/contributions/examples.ts +++ b/arduino-ide-extension/src/browser/contributions/examples.ts @@ -21,16 +21,23 @@ import { MenuModelRegistry, } from './contribution'; import { NotificationCenter } from '../notification-center'; -import { Board, SketchRef, SketchContainer } from '../../common/protocol'; +import { + Board, + SketchRef, + SketchContainer, + SketchesError, + Sketch, + CoreService, +} from '../../common/protocol'; import { nls } from '@theia/core/lib/common'; @injectable() export abstract class Examples extends SketchContribution { @inject(CommandRegistry) - protected readonly commandRegistry: CommandRegistry; + private readonly commandRegistry: CommandRegistry; @inject(MenuModelRegistry) - protected readonly menuRegistry: MenuModelRegistry; + private readonly menuRegistry: MenuModelRegistry; @inject(MainMenuManager) protected readonly menuManager: MainMenuManager; @@ -38,6 +45,9 @@ export abstract class Examples extends SketchContribution { @inject(ExamplesService) protected readonly examplesService: ExamplesService; + @inject(CoreService) + protected readonly coreService: CoreService; + @inject(BoardsServiceProvider) protected readonly boardsServiceClient: BoardsServiceProvider; @@ -50,10 +60,16 @@ export abstract class Examples extends SketchContribution { ); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars protected handleBoardChanged(board: Board | undefined): void { // NOOP } + protected abstract update(options?: { + board?: Board | undefined; + forceRefresh?: boolean; + }): void; + override registerMenus(registry: MenuModelRegistry): void { try { // This is a hack the ensures the desired menu ordering! We cannot use https://github.com/eclipse-theia/theia/pull/8377 due to ATL-222. @@ -149,23 +165,54 @@ export abstract class Examples extends SketchContribution { protected createHandler(uri: string): CommandHandler { return { execute: async () => { - const sketch = await this.sketchService.cloneExample(uri); - return this.commandService.executeCommand( - OpenSketch.Commands.OPEN_SKETCH.id, - sketch - ); + const sketch = await this.clone(uri); + if (sketch) { + try { + return this.commandService.executeCommand( + OpenSketch.Commands.OPEN_SKETCH.id, + sketch + ); + } catch (err) { + if (SketchesError.NotFound.is(err)) { + // Do not toast the error message. It's handled by the `Open Sketch` command. + this.update({ + board: this.boardsServiceClient.boardsConfig.selectedBoard, + forceRefresh: true, + }); + } else { + throw err; + } + } + } }, }; } + + private async clone(uri: string): Promise { + try { + const sketch = await this.sketchService.cloneExample(uri); + return sketch; + } catch (err) { + if (SketchesError.NotFound.is(err)) { + this.messageService.error(err.message); + this.update({ + board: this.boardsServiceClient.boardsConfig.selectedBoard, + forceRefresh: true, + }); + } else { + throw err; + } + } + } } @injectable() export class BuiltInExamples extends Examples { override async onReady(): Promise { - this.register(); // no `await` + this.update(); // no `await` } - protected async register(): Promise { + protected override async update(): Promise { let sketchContainers: SketchContainer[] | undefined; try { sketchContainers = await this.examplesService.builtIns(); @@ -197,29 +244,34 @@ export class BuiltInExamples extends Examples { @injectable() export class LibraryExamples extends Examples { @inject(NotificationCenter) - protected readonly notificationCenter: NotificationCenter; + private readonly notificationCenter: NotificationCenter; - protected readonly queue = new PQueue({ autoStart: true, concurrency: 1 }); + private readonly queue = new PQueue({ autoStart: true, concurrency: 1 }); override onStart(): void { - this.notificationCenter.onLibraryDidInstall(() => this.register()); - this.notificationCenter.onLibraryDidUninstall(() => this.register()); + this.notificationCenter.onLibraryDidInstall(() => this.update()); + this.notificationCenter.onLibraryDidUninstall(() => this.update()); } override async onReady(): Promise { - this.register(); // no `await` + this.update(); // no `await` } protected override handleBoardChanged(board: Board | undefined): void { - this.register(board); + this.update({ board }); } - protected async register( - board: Board | undefined = this.boardsServiceClient.boardsConfig - .selectedBoard + protected override async update( + options: { board?: Board; forceRefresh?: boolean } = { + board: this.boardsServiceClient.boardsConfig.selectedBoard, + } ): Promise { + const { board, forceRefresh } = options; return this.queue.add(async () => { this.toDispose.dispose(); + if (forceRefresh) { + await this.coreService.refresh(); + } const fqbn = board?.fqbn; const name = board?.name; // Shows all examples when no board is selected, or the platform of the currently selected board is not installed. diff --git a/arduino-ide-extension/src/browser/contributions/new-sketch.ts b/arduino-ide-extension/src/browser/contributions/new-sketch.ts index c43e15505..bebc67767 100644 --- a/arduino-ide-extension/src/browser/contributions/new-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/new-sketch.ts @@ -1,7 +1,6 @@ import { nls } from '@theia/core/lib/common'; import { injectable } from '@theia/core/shared/inversify'; import { ArduinoMenus } from '../menu/arduino-menus'; -import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { SketchContribution, URI, @@ -17,11 +16,6 @@ export class NewSketch extends SketchContribution { registry.registerCommand(NewSketch.Commands.NEW_SKETCH, { execute: () => this.newSketch(), }); - registry.registerCommand(NewSketch.Commands.NEW_SKETCH__TOOLBAR, { - isVisible: (widget) => - ArduinoToolbar.is(widget) && widget.side === 'left', - execute: () => registry.executeCommand(NewSketch.Commands.NEW_SKETCH.id), - }); } override registerMenus(registry: MenuModelRegistry): void { @@ -54,8 +48,5 @@ export namespace NewSketch { export const NEW_SKETCH: Command = { id: 'arduino-new-sketch', }; - export const NEW_SKETCH__TOOLBAR: Command = { - id: 'arduino-new-sketch--toolbar', - }; } } diff --git a/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts b/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts index 1c1f384ac..21232f055 100644 --- a/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts @@ -15,6 +15,7 @@ import { MainMenuManager } from '../../common/main-menu-manager'; import { OpenSketch } from './open-sketch'; import { NotificationCenter } from '../notification-center'; import { nls } from '@theia/core/lib/common'; +import { SketchesError } from '../../common/protocol'; @injectable() export class OpenRecentSketch extends SketchContribution { @@ -33,7 +34,7 @@ export class OpenRecentSketch extends SketchContribution { @inject(NotificationCenter) protected readonly notificationCenter: NotificationCenter; - protected toDisposeBeforeRegister = new Map(); + protected toDispose = new DisposableCollection(); override onStart(): void { this.notificationCenter.onRecentSketchesDidChange(({ sketches }) => @@ -42,8 +43,12 @@ export class OpenRecentSketch extends SketchContribution { } override async onReady(): Promise { + this.update(); + } + + private update(forceUpdate?: boolean): void { this.sketchService - .recentlyOpenedSketches() + .recentlyOpenedSketches(forceUpdate) .then((sketches) => this.refreshMenu(sketches)); } @@ -62,19 +67,25 @@ export class OpenRecentSketch extends SketchContribution { protected register(sketches: Sketch[]): void { const order = 0; + this.toDispose.dispose(); for (const sketch of sketches) { const { uri } = sketch; - const toDispose = this.toDisposeBeforeRegister.get(uri); - if (toDispose) { - toDispose.dispose(); - } const command = { id: `arduino-open-recent--${uri}` }; const handler = { - execute: () => - this.commandRegistry.executeCommand( - OpenSketch.Commands.OPEN_SKETCH.id, - sketch - ), + execute: async () => { + try { + await this.commandRegistry.executeCommand( + OpenSketch.Commands.OPEN_SKETCH.id, + sketch + ); + } catch (err) { + if (SketchesError.NotFound.is(err)) { + this.update(true); + } else { + throw err; + } + } + }, }; this.commandRegistry.registerCommand(command, handler); this.menuRegistry.registerMenuAction( @@ -85,8 +96,7 @@ export class OpenRecentSketch extends SketchContribution { order: String(order), } ); - this.toDisposeBeforeRegister.set( - sketch.uri, + this.toDispose.pushAll([ new DisposableCollection( Disposable.create(() => this.commandRegistry.unregisterCommand(command) @@ -94,8 +104,8 @@ export class OpenRecentSketch extends SketchContribution { Disposable.create(() => this.menuRegistry.unregisterMenuAction(command) ) - ) - ); + ), + ]); } } } diff --git a/arduino-ide-extension/src/browser/contributions/open-sketch.ts b/arduino-ide-extension/src/browser/contributions/open-sketch.ts index 063b86a1e..5f50daae5 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch.ts @@ -1,115 +1,44 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; import * as remote from '@theia/core/electron-shared/@electron/remote'; -import { MaybePromise } from '@theia/core/lib/common/types'; -import { Widget, ContextMenuRenderer } from '@theia/core/lib/browser'; -import { - Disposable, - DisposableCollection, -} from '@theia/core/lib/common/disposable'; +import { nls } from '@theia/core/lib/common/nls'; +import { injectable } from '@theia/core/shared/inversify'; +import { SketchesError, SketchRef } from '../../common/protocol'; import { ArduinoMenus } from '../menu/arduino-menus'; -import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { - SketchContribution, - Sketch, - URI, Command, CommandRegistry, - MenuModelRegistry, KeybindingRegistry, + MenuModelRegistry, + Sketch, + SketchContribution, + URI, } from './contribution'; -import { ExamplesService } from '../../common/protocol/examples-service'; -import { BuiltInExamples } from './examples'; -import { Sketchbook } from './sketchbook'; -import { SketchContainer } from '../../common/protocol'; -import { nls } from '@theia/core/lib/common'; + +export type SketchLocation = string | URI | SketchRef; +export namespace SketchLocation { + export function toUri(location: SketchLocation): URI { + if (typeof location === 'string') { + return new URI(location); + } else if (SketchRef.is(location)) { + return toUri(location.uri); + } else { + return location; + } + } + export function is(arg: unknown): arg is SketchLocation { + return typeof arg === 'string' || arg instanceof URI || SketchRef.is(arg); + } +} @injectable() export class OpenSketch extends SketchContribution { - @inject(MenuModelRegistry) - private readonly menuRegistry: MenuModelRegistry; - - @inject(ContextMenuRenderer) - private readonly contextMenuRenderer: ContextMenuRenderer; - - @inject(BuiltInExamples) - private readonly builtInExamples: BuiltInExamples; - - @inject(ExamplesService) - private readonly examplesService: ExamplesService; - - @inject(Sketchbook) - private readonly sketchbook: Sketchbook; - - private readonly toDispose = new DisposableCollection(); - override registerCommands(registry: CommandRegistry): void { registry.registerCommand(OpenSketch.Commands.OPEN_SKETCH, { - execute: (arg) => - Sketch.is(arg) ? this.openSketch(arg) : this.openSketch(), - }); - registry.registerCommand(OpenSketch.Commands.OPEN_SKETCH__TOOLBAR, { - isVisible: (widget) => - ArduinoToolbar.is(widget) && widget.side === 'left', - execute: async (_: Widget, target: EventTarget) => { - const container = await this.sketchService.getSketches({ - exclude: ['**/hardware/**'], - }); - if (SketchContainer.isEmpty(container)) { - this.openSketch(); - } else { - this.toDispose.dispose(); - if (!(target instanceof HTMLElement)) { - return; - } - const { parentElement } = target; - if (!parentElement) { - return; - } - - this.menuRegistry.registerMenuAction( - ArduinoMenus.OPEN_SKETCH__CONTEXT__OPEN_GROUP, - { - commandId: OpenSketch.Commands.OPEN_SKETCH.id, - label: nls.localize( - 'vscode/workspaceActions/openFileFolder', - 'Open...' - ), - } - ); - this.toDispose.push( - Disposable.create(() => - this.menuRegistry.unregisterMenuAction( - OpenSketch.Commands.OPEN_SKETCH - ) - ) - ); - this.sketchbook.registerRecursively( - [...container.children, ...container.sketches], - ArduinoMenus.OPEN_SKETCH__CONTEXT__RECENT_GROUP, - this.toDispose - ); - try { - const containers = await this.examplesService.builtIns(); - for (const container of containers) { - this.builtInExamples.registerRecursively( - container, - ArduinoMenus.OPEN_SKETCH__CONTEXT__EXAMPLES_GROUP, - this.toDispose - ); - } - } catch (e) { - console.error('Error when collecting built-in examples.', e); - } - const options = { - menuPath: ArduinoMenus.OPEN_SKETCH__CONTEXT, - anchor: { - x: parentElement.getBoundingClientRect().left, - y: - parentElement.getBoundingClientRect().top + - parentElement.offsetHeight, - }, - }; - this.contextMenuRenderer.render(options); + execute: async (arg) => { + const toOpen = !SketchLocation.is(arg) + ? await this.selectSketch() + : arg; + if (toOpen) { + return this.openSketch(toOpen); } }, }); @@ -130,13 +59,20 @@ export class OpenSketch extends SketchContribution { }); } - private async openSketch( - toOpen: MaybePromise = this.selectSketch() - ): Promise { - const sketch = await toOpen; - if (sketch) { - this.workspaceService.open(new URI(sketch.uri)); + private async openSketch(toOpen: SketchLocation | undefined): Promise { + if (!toOpen) { + return; } + const uri = SketchLocation.toUri(toOpen); + try { + await this.sketchService.loadSketch(uri.toString()); + } catch (err) { + if (SketchesError.NotFound.is(err)) { + this.messageService.error(err.message); + } + throw err; + } + this.workspaceService.open(uri); } private async selectSketch(): Promise { @@ -220,8 +156,5 @@ export namespace OpenSketch { export const OPEN_SKETCH: Command = { id: 'arduino-open-sketch', }; - export const OPEN_SKETCH__TOOLBAR: Command = { - id: 'arduino-open-sketch--toolbar', - }; } } diff --git a/arduino-ide-extension/src/browser/contributions/save-sketch.ts b/arduino-ide-extension/src/browser/contributions/save-sketch.ts index dce6405c3..62a6b0f86 100644 --- a/arduino-ide-extension/src/browser/contributions/save-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-sketch.ts @@ -1,7 +1,6 @@ import { injectable } from '@theia/core/shared/inversify'; import { CommonCommands } from '@theia/core/lib/browser/common-frontend-contribution'; import { ArduinoMenus } from '../menu/arduino-menus'; -import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { SaveAsSketch } from './save-as-sketch'; import { SketchContribution, @@ -19,12 +18,6 @@ export class SaveSketch extends SketchContribution { registry.registerCommand(SaveSketch.Commands.SAVE_SKETCH, { execute: () => this.saveSketch(), }); - registry.registerCommand(SaveSketch.Commands.SAVE_SKETCH__TOOLBAR, { - isVisible: (widget) => - ArduinoToolbar.is(widget) && widget.side === 'left', - execute: () => - registry.executeCommand(SaveSketch.Commands.SAVE_SKETCH.id), - }); } override registerMenus(registry: MenuModelRegistry): void { @@ -68,8 +61,5 @@ export namespace SaveSketch { export const SAVE_SKETCH: Command = { id: 'arduino-save-sketch', }; - export const SAVE_SKETCH__TOOLBAR: Command = { - id: 'arduino-save-sketch--toolbar', - }; } } diff --git a/arduino-ide-extension/src/browser/contributions/sketchbook.ts b/arduino-ide-extension/src/browser/contributions/sketchbook.ts index fdac918b7..8c01cd46a 100644 --- a/arduino-ide-extension/src/browser/contributions/sketchbook.ts +++ b/arduino-ide-extension/src/browser/contributions/sketchbook.ts @@ -1,32 +1,14 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; +import { injectable } from '@theia/core/shared/inversify'; import { CommandHandler } from '@theia/core/lib/common/command'; -import { CommandRegistry, MenuModelRegistry } from './contribution'; +import { MenuModelRegistry } from './contribution'; import { ArduinoMenus } from '../menu/arduino-menus'; -import { MainMenuManager } from '../../common/main-menu-manager'; -import { NotificationCenter } from '../notification-center'; import { Examples } from './examples'; -import { - SketchContainer, - SketchesError, - SketchRef, -} from '../../common/protocol'; +import { SketchContainer, SketchesError } from '../../common/protocol'; import { OpenSketch } from './open-sketch'; -import { nls } from '@theia/core/lib/common'; +import { nls } from '@theia/core/lib/common/nls'; @injectable() export class Sketchbook extends Examples { - @inject(CommandRegistry) - protected override readonly commandRegistry: CommandRegistry; - - @inject(MenuModelRegistry) - protected override readonly menuRegistry: MenuModelRegistry; - - @inject(MainMenuManager) - protected readonly mainMenuManager: MainMenuManager; - - @inject(NotificationCenter) - protected readonly notificationCenter: NotificationCenter; - override onStart(): void { this.sketchServiceClient.onSketchbookDidChange(() => this.update()); } @@ -35,10 +17,10 @@ export class Sketchbook extends Examples { this.update(); } - private update() { + protected override update(): void { this.sketchService.getSketches({}).then((container) => { this.register(container); - this.mainMenuManager.update(); + this.menuManager.update(); }); } @@ -50,7 +32,7 @@ export class Sketchbook extends Examples { ); } - protected register(container: SketchContainer): void { + private register(container: SketchContainer): void { this.toDispose.dispose(); this.registerRecursively( [...container.children, ...container.sketches], @@ -62,24 +44,19 @@ export class Sketchbook extends Examples { protected override createHandler(uri: string): CommandHandler { return { execute: async () => { - let sketch: SketchRef | undefined = undefined; try { - sketch = await this.sketchService.loadSketch(uri); + await this.commandService.executeCommand( + OpenSketch.Commands.OPEN_SKETCH.id, + uri + ); } catch (err) { if (SketchesError.NotFound.is(err)) { - // To handle the following: - // Open IDE2, delete a sketch from sketchbook, click on File > Sketchbook > the deleted sketch. - // Filesystem watcher misses out delete events on macOS; hence IDE2 has no chance to update the menu items. - this.messageService.error(err.message); + // Force update the menu items to remove the absent sketch. this.update(); + } else { + throw err; } } - if (sketch) { - await this.commandService.executeCommand( - OpenSketch.Commands.OPEN_SKETCH.id, - sketch - ); - } }, }; } diff --git a/arduino-ide-extension/src/browser/theia/core/about-dialog.ts b/arduino-ide-extension/src/browser/theia/core/about-dialog.ts index 3e89c5105..d2b1ad0a9 100644 --- a/arduino-ide-extension/src/browser/theia/core/about-dialog.ts +++ b/arduino-ide-extension/src/browser/theia/core/about-dialog.ts @@ -1,8 +1,6 @@ import { AboutDialog as TheiaAboutDialog } from '@theia/core/lib/browser/about-dialog'; -import { duration } from '../../../common/decorators'; export class AboutDialog extends TheiaAboutDialog { - @duration({ name: 'theia-about#init' }) protected override async init(): Promise { // NOOP // IDE2 has a custom about dialog, so it does not make sense to collect Theia extensions at startup time. diff --git a/arduino-ide-extension/src/common/protocol/core-service.ts b/arduino-ide-extension/src/common/protocol/core-service.ts index a4b63a604..81e5212a9 100644 --- a/arduino-ide-extension/src/common/protocol/core-service.ts +++ b/arduino-ide-extension/src/common/protocol/core-service.ts @@ -108,6 +108,10 @@ export interface CoreService { compile(options: CoreService.Options.Compile): Promise; upload(options: CoreService.Options.Upload): Promise; burnBootloader(options: CoreService.Options.Bootloader): Promise; + /** + * Refreshes the underling core gRPC client for the Arduino CLI. + */ + refresh(): Promise; } export namespace CoreService { diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index dbb7c2654..f1b8f7675 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -21,16 +21,9 @@ export const SketchesService = Symbol('SketchesService'); export interface SketchesService { /** * Resolves to a sketch container representing the hierarchical structure of the sketches. - * If `uri` is not given, `directories.user` will be user instead. Specify `exclude` global patterns to filter folders from the sketch container. - * If `exclude` is not set `['**\/libraries\/**', '**\/hardware\/**']` will be used instead. + * If `uri` is not given, `directories.user` will be user instead. */ - getSketches({ - uri, - exclude, - }: { - uri?: string; - exclude?: string[]; - }): Promise; + getSketches({ uri }: { uri?: string }): Promise; /** * This is the TS implementation of `SketchLoad` from the CLI and should be replaced with a gRPC call eventually. @@ -71,7 +64,7 @@ export interface SketchesService { copy(sketch: Sketch, options: { destinationUri: string }): Promise; /** - * Returns with the container sketch for the input `uri`. If the `uri` is not in a sketch folder, resolved `undefined`. + * Returns with the container sketch for the input `uri`. If the `uri` is not in a sketch folder, the promise resolves to `undefined`. */ getSketchFolder(uri: string): Promise; @@ -82,8 +75,10 @@ export interface SketchesService { /** * Resolves to an array of sketches in inverse chronological order. The newest is the first. + * If `forceUpdate` is `true`, the array of recently opened sketches will be recalculated. + * Invalid and missing sketches will be removed from the list. It's `false` by default. */ - recentlyOpenedSketches(): Promise; + recentlyOpenedSketches(forceUpdate?: boolean): Promise; /** * Archives the sketch, resolves to the archive URI. @@ -114,6 +109,19 @@ export namespace SketchRef { uri: typeof uriLike === 'string' ? uriLike : uriLike.toString(), }; } + export function is(arg: unknown): arg is SketchRef { + if (typeof arg === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const object = arg as any; + return ( + 'name' in object && + typeof object['name'] === 'string' && + 'uri' in object && + typeof object['name'] === 'string' + ); + } + return false; + } } export interface Sketch extends SketchRef { readonly mainFileUri: string; // `MainFile` @@ -122,14 +130,25 @@ export interface Sketch extends SketchRef { readonly rootFolderFileUris: string[]; // `RootFolderFiles` (does not include the main sketch file) } export namespace Sketch { - export function is(arg: any): arg is Sketch { - return ( - !!arg && - 'name' in arg && - 'uri' in arg && - typeof arg.name === 'string' && - typeof arg.uri === 'string' - ); + export function is(arg: unknown): arg is Sketch { + if (!SketchRef.is(arg)) { + return false; + } + if (typeof arg === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const object = arg as any; + return ( + 'mainFileUri' in object && + typeof object['mainFileUri'] === 'string' && + 'otherSketchFileUris' in object && + Array.isArray(object['otherSketchFileUris']) && + 'additionalFileUris' in object && + Array.isArray(object['additionalFileUris']) && + 'rootFolderFileUris' in object && + Array.isArray(object['rootFolderFileUris']) + ); + } + return false; } export namespace Extensions { export const MAIN = ['.ino', '.pde']; diff --git a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts index b2486e478..33d21c0ff 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -332,6 +332,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { 'fwuploader', // Arduino Firmware uploader 'discovery-log', // Boards discovery 'config', // Logger for the CLI config reading and manipulation + 'sketches-service', // For creating, loading, and cloning sketches MonitorManagerName, // Logger for the monitor manager and its services MonitorServiceName, ].forEach((name) => bindChildLogger(bind, name)); diff --git a/arduino-ide-extension/src/node/config-service-impl.ts b/arduino-ide-extension/src/node/config-service-impl.ts index 904be5882..fa2e259c3 100644 --- a/arduino-ide-extension/src/node/config-service-impl.ts +++ b/arduino-ide-extension/src/node/config-service-impl.ts @@ -26,7 +26,6 @@ import { DefaultCliConfig, CLI_CONFIG } from './cli-config'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { deepClone } from '@theia/core'; -import { duration } from '../common/decorators'; const deepmerge = require('deepmerge'); @@ -129,7 +128,6 @@ export class ConfigServiceImpl return this.daemon.getVersion(); } - @duration() protected async loadCliConfig( initializeIfAbsent = true ): Promise { diff --git a/arduino-ide-extension/src/node/core-client-provider.ts b/arduino-ide-extension/src/node/core-client-provider.ts index 9c2395de9..0bfd9766b 100644 --- a/arduino-ide-extension/src/node/core-client-provider.ts +++ b/arduino-ide-extension/src/node/core-client-provider.ts @@ -94,6 +94,11 @@ export class CoreClientProvider { return this.onClientDidRefreshEmitter.event; } + async refresh(): Promise { + const client = await this.client; + await this.initInstance(client); + } + /** * Encapsulates both the gRPC core client creation (`CreateRequest`) and initialization (`InitRequest`). */ @@ -415,6 +420,10 @@ export abstract class CoreClientAware { protected get onClientDidRefresh(): Event { return this.coreClientProvider.onClientDidRefresh; } + + refresh(): Promise { + return this.coreClientProvider.refresh(); + } } class IndexUpdateRequiredBeforeInitError extends Error { diff --git a/arduino-ide-extension/src/node/examples-service-impl.ts b/arduino-ide-extension/src/node/examples-service-impl.ts index 0028791a6..1bf43c470 100644 --- a/arduino-ide-extension/src/node/examples-service-impl.ts +++ b/arduino-ide-extension/src/node/examples-service-impl.ts @@ -11,14 +11,10 @@ import { SketchContainer, } from '../common/protocol/sketches-service'; import { ExamplesService } from '../common/protocol/examples-service'; -import { - LibraryLocation, - LibraryPackage, - LibraryService, -} from '../common/protocol'; -import { duration } from '../common/decorators'; +import { LibraryLocation, LibraryPackage } from '../common/protocol'; import { URI } from '@theia/core/lib/common/uri'; import { Path } from '@theia/core/lib/common/path'; +import { LibraryServiceImpl } from './library-service-impl'; interface BuiltInSketchRef { readonly name: string; @@ -84,8 +80,8 @@ export class BuiltInExamplesServiceImpl { @injectable() export class ExamplesServiceImpl implements ExamplesService { - @inject(LibraryService) - private readonly libraryService: LibraryService; + @inject(LibraryServiceImpl) + private readonly libraryService: LibraryServiceImpl; @inject(BuiltInExamplesServiceImpl) private readonly builtInExamplesService: BuiltInExamplesServiceImpl; @@ -94,7 +90,6 @@ export class ExamplesServiceImpl implements ExamplesService { return this.builtInExamplesService.builtIns(); } - @duration() async installed({ fqbn }: { fqbn?: string }): Promise<{ user: SketchContainer[]; current: SketchContainer[]; diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 0912d4733..06c7931b6 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -1,14 +1,15 @@ -import { injectable, inject } from '@theia/core/shared/inversify'; -import * as fs from 'fs'; +import { injectable, inject, named } from '@theia/core/shared/inversify'; +import { promises as fs, realpath, lstat, Stats, constants, rm } from 'fs'; import * as os from 'os'; import * as temp from 'temp'; - import * as path from 'path'; +import * as glob from 'glob'; import * as crypto from 'crypto'; +import * as PQueue from 'p-queue'; import { ncp } from 'ncp'; -import { promisify } from 'util'; import URI from '@theia/core/lib/common/uri'; -import { FileUri } from '@theia/core/lib/node'; +import { ILogger } from '@theia/core/lib/common/logger'; +import { FileUri } from '@theia/core/lib/node/file-uri'; import { ConfigServiceImpl } from './config-service-impl'; import { SketchesService, @@ -24,8 +25,6 @@ import { ArchiveSketchRequest, LoadSketchRequest, } from './cli-protocol/cc/arduino/cli/commands/v1/commands_pb'; -import { duration } from '../common/decorators'; -import * as glob from 'glob'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { ServiceError } from './service-error'; import { @@ -34,6 +33,8 @@ import { TempSketchPrefix, } from './is-temp-sketch'; +const RecentSketches = 'recent-sketches.json'; + @injectable() export class SketchesServiceImpl extends CoreClientAware @@ -41,6 +42,15 @@ export class SketchesServiceImpl { private sketchSuffixIndex = 1; private lastSketchBaseName: string; + private recentSketches: SketchWithDetails[] | undefined; + private readonly markAsRecentSketchQueue = new PQueue({ + autoStart: true, + concurrency: 1, + }); + + @inject(ILogger) + @named('sketches-service') + private readonly logger: ILogger; @inject(ConfigServiceImpl) private readonly configService: ConfigServiceImpl; @@ -54,28 +64,7 @@ export class SketchesServiceImpl @inject(IsTempSketch) private readonly isTempSketch: IsTempSketch; - async getSketches({ - uri, - exclude, - }: { - uri?: string; - exclude?: string[]; - }): Promise { - const [/*old,*/ _new] = await Promise.all([ - // this.getSketchesOld({ uri, exclude }), - this.getSketchesNew({ uri, exclude }), - ]); - return _new; - } - - @duration() - async getSketchesNew({ - uri, - exclude, - }: { - uri?: string; - exclude?: string[]; - }): Promise { + async getSketches({ uri }: { uri?: string }): Promise { const root = await this.root(uri); const pathToAllSketchFiles = await new Promise( (resolve, reject) => { @@ -138,7 +127,7 @@ export class SketchesServiceImpl for (const pathToSketchFile of pathToAllSketchFiles) { const relative = path.relative(root, pathToSketchFile); if (!relative) { - console.warn( + this.logger.warn( `Could not determine relative sketch path from the root <${root}> to the sketch <${pathToSketchFile}>. Skipping. Relative path was: ${relative}` ); continue; @@ -146,7 +135,7 @@ export class SketchesServiceImpl const segments = relative.split(path.sep); if (segments.length < 2) { // folder name, and sketch name. - console.warn( + this.logger.warn( `Expected at least one segment relative path from the root <${root}> to the sketch <${pathToSketchFile}>. Skipping. Segments were: ${segments}.` ); continue; @@ -160,7 +149,7 @@ export class SketchesServiceImpl '' ); if (sketchFileExtension !== '.ino' && sketchFileExtension !== '.pde') { - console.warn( + this.logger.warn( `Mismatching sketch file <${sketchFilename}> and sketch folder name <${sketchName}>. Skipping` ); continue; @@ -169,7 +158,7 @@ export class SketchesServiceImpl if (child) { child.sketches.push({ name: sketchName, - uri: FileUri.create(pathToSketchFile).toString(), + uri: FileUri.create(path.dirname(pathToSketchFile)).toString(), }); } } @@ -191,8 +180,8 @@ export class SketchesServiceImpl const requestSketchPath = FileUri.fsPath(uri); req.setSketchPath(requestSketchPath); req.setInstance(instance); - const stat = new Deferred(); - fs.lstat(requestSketchPath, (err, result) => + const stat = new Deferred(); + lstat(requestSketchPath, (err, result) => err ? stat.resolve(err) : stat.resolve(result) ); const sketch = await new Promise((resolve, reject) => { @@ -200,27 +189,20 @@ export class SketchesServiceImpl if (err) { reject( isNotFoundError(err) - ? SketchesError.NotFound( - fixErrorMessage( - err, - requestSketchPath, - this.configService.cliConfiguration?.directories.user - ), - uri - ) + ? SketchesError.NotFound(err.details, uri) : err ); return; } const responseSketchPath = maybeNormalizeDrive(resp.getLocationPath()); if (requestSketchPath !== responseSketchPath) { - console.warn( + this.logger.warn( `Warning! The request sketch path was different than the response sketch path from the CLI. This could be a potential bug. Request: <${requestSketchPath}>, response: <${responseSketchPath}>.` ); } const resolvedStat = await stat.promise; if (resolvedStat instanceof Error) { - console.error( + this.logger.error( `The CLI could load the sketch from ${requestSketchPath}, but stating the folder has failed.` ); reject(resolvedStat); @@ -254,89 +236,160 @@ export class SketchesServiceImpl private get recentSketchesFsPath(): Promise { return this.envVariableServer .getConfigDirUri() - .then((uri) => path.join(FileUri.fsPath(uri), 'recent-sketches.json')); + .then((uri) => path.join(FileUri.fsPath(uri), RecentSketches)); } - private async loadRecentSketches( - fsPath: string - ): Promise> { + private async loadRecentSketches(): Promise> { + this.logger.debug(`>>> Loading recently opened sketches data.`); + const fsPath = await this.recentSketchesFsPath; let data: Record = {}; try { - const raw = await promisify(fs.readFile)(fsPath, { + const raw = await fs.readFile(fsPath, { encoding: 'utf8', }); - data = JSON.parse(raw); - } catch {} + try { + data = JSON.parse(raw); + } catch (err) { + this.logger.error( + `Could not parse recently opened sketches. Raw input was: ${raw}` + ); + } + } catch (err) { + if ('code' in err && err.code === 'ENOENT') { + this.logger.debug( + `<<< '${RecentSketches}' does not exist yet. This is normal behavior. Falling back to empty data.` + ); + return {}; + } + throw err; + } + this.logger.debug( + `<<< Successfully loaded recently opened sketches data: ${JSON.stringify( + data + )}` + ); return data; } + private async saveRecentSketches( + data: Record + ): Promise { + this.logger.debug( + `>>> Saving recently opened sketches data: ${JSON.stringify(data)}` + ); + const fsPath = await this.recentSketchesFsPath; + await fs.writeFile(fsPath, JSON.stringify(data, null, 2)); + this.logger.debug('<<< Successfully saved recently opened sketches data.'); + } + async markAsRecentlyOpened(uri: string): Promise { - let sketch: Sketch | undefined = undefined; - try { - sketch = await this.loadSketch(uri); - } catch { - return; - } - if (await this.isTemp(sketch)) { - return; - } + return this.markAsRecentSketchQueue.add(async () => { + this.logger.debug(`Marking sketch at '${uri}' as recently opened.`); + if (this.isTempSketch.is(FileUri.fsPath(uri))) { + this.logger.debug( + `Sketch at '${uri}' is pointing to a temp location. Not marking as recently opened.` + ); + return; + } - const fsPath = await this.recentSketchesFsPath; - const data = await this.loadRecentSketches(fsPath); - const now = Date.now(); - data[sketch.uri] = now; - - let toDeleteUri: string | undefined = undefined; - if (Object.keys(data).length > 10) { - let min = Number.MAX_SAFE_INTEGER; - for (const uri of Object.keys(data)) { - if (min > data[uri]) { - min = data[uri]; - toDeleteUri = uri; + let sketch: Sketch | undefined = undefined; + try { + sketch = await this.loadSketch(uri); + this.logger.debug( + `Loaded sketch ${JSON.stringify( + sketch + )} before marking it as recently opened.` + ); + } catch (err) { + if (SketchesError.NotFound.is(err)) { + this.logger.debug( + `Could not load sketch from '${uri}'. Not marking as recently opened.` + ); + return; } + this.logger.error( + `Unexpected error occurred while loading sketch from '${uri}'.`, + err + ); + throw err; } - } - if (toDeleteUri) { - delete data[toDeleteUri]; - } + const data = await this.loadRecentSketches(); + const now = Date.now(); + this.logger.debug( + `Marking sketch '${uri}' as recently opened with timestamp: '${now}'.` + ); + data[sketch.uri] = now; + + let toDelete: [string, number] | undefined = undefined; + if (Object.keys(data).length > 10) { + let min = Number.MAX_SAFE_INTEGER; + for (const [uri, timestamp] of Object.entries(data)) { + if (min > timestamp) { + min = data[uri]; + toDelete = [uri, timestamp]; + } + } + } - await promisify(fs.writeFile)(fsPath, JSON.stringify(data, null, 2)); - this.recentlyOpenedSketches().then((sketches) => - this.notificationService.notifyRecentSketchesDidChange({ sketches }) - ); - } + if (toDelete) { + const [toDeleteUri] = toDelete; + delete data[toDeleteUri]; + this.logger.debug( + `Deleted sketch entry ${JSON.stringify( + toDelete + )} from recently opened.` + ); + } - async recentlyOpenedSketches(): Promise { - const configDirUri = await this.envVariableServer.getConfigDirUri(); - const fsPath = path.join( - FileUri.fsPath(configDirUri), - 'recent-sketches.json' - ); - let data: Record = {}; - try { - const raw = await promisify(fs.readFile)(fsPath, { - encoding: 'utf8', - }); - data = JSON.parse(raw); - } catch {} + await this.saveRecentSketches(data); + this.logger.debug(`Marked sketch '${uri}' as recently opened.`); + const sketches = await this.recentlyOpenedSketches(data); + this.notificationService.notifyRecentSketchesDidChange({ sketches }); + }); + } - const sketches: SketchWithDetails[] = []; - for (const uri of Object.keys(data).sort( - (left, right) => data[right] - data[left] - )) { - try { - const sketch = await this.loadSketch(uri); - sketches.push(sketch); - } catch {} + async recentlyOpenedSketches( + forceUpdate?: Record | boolean + ): Promise { + if (!this.recentSketches || forceUpdate) { + const data = + forceUpdate && typeof forceUpdate === 'object' + ? forceUpdate + : await this.loadRecentSketches(); + const sketches: SketchWithDetails[] = []; + let needsUpdate = false; + for (const uri of Object.keys(data).sort( + (left, right) => data[right] - data[left] + )) { + let sketch: SketchWithDetails | undefined = undefined; + try { + sketch = await this.loadSketch(uri); + } catch {} + if (!sketch) { + needsUpdate = true; + } else { + sketches.push(sketch); + } + } + if (needsUpdate) { + const data = sketches.reduce((acc, curr) => { + acc[curr.uri] = curr.mtimeMs; + return acc; + }, {} as Record); + await this.saveRecentSketches(data); + this.notificationService.notifyRecentSketchesDidChange({ sketches }); + } + this.recentSketches = sketches; } - - return sketches; + return this.recentSketches; } async cloneExample(uri: string): Promise { - const sketch = await this.loadSketch(uri); - const parentPath = await this.createTempFolder(); + const [sketch, parentPath] = await Promise.all([ + this.loadSketch(uri), + this.createTempFolder(), + ]); const destinationUri = FileUri.create( path.join(parentPath, sketch.name) ).toString(); @@ -377,7 +430,7 @@ export class SketchesServiceImpl this.sketchSuffixIndex++ )}`; // Note: we check the future destination folder (`directories.user`) for name collision and not the temp folder! - const sketchExists = await promisify(fs.exists)( + const sketchExists = await this.exists( path.join(sketchbookPath, sketchNameCandidate) ); if (!sketchExists) { @@ -393,8 +446,8 @@ export class SketchesServiceImpl const sketchDir = path.join(parentPath, sketchName); const sketchFile = path.join(sketchDir, `${sketchName}.ino`); - await promisify(fs.mkdir)(sketchDir, { recursive: true }); - await promisify(fs.writeFile)( + await fs.mkdir(sketchDir, { recursive: true }); + await fs.writeFile( sketchFile, `void setup() { // put your setup code here, to run once: @@ -424,7 +477,7 @@ void loop() { reject(createError); return; } - fs.realpath.native(dirPath, (resolveError, resolvedDirPath) => { + realpath.native(dirPath, (resolveError, resolvedDirPath) => { if (resolveError) { reject(resolveError); return; @@ -478,7 +531,7 @@ void loop() { { destinationUri }: { destinationUri: string } ): Promise { const source = FileUri.fsPath(sketch.uri); - const exists = await promisify(fs.exists)(source); + const exists = await this.exists(source); if (!exists) { throw new Error(`Sketch does not exist: ${sketch}`); } @@ -503,7 +556,7 @@ void loop() { ); const newPath = path.join(destinationPath, `${newName}.ino`); if (oldPath !== newPath) { - await promisify(fs.rename)(oldPath, newPath); + await fs.rename(oldPath, newPath); } await this.loadSketch(FileUri.create(destinationPath).toString()); // Sanity check. resolve(); @@ -520,7 +573,7 @@ void loop() { const destination = FileUri.fsPath(destinationUri); let tempDestination = await this.createTempFolder(); tempDestination = path.join(tempDestination, sketch.name); - await fs.promises.mkdir(tempDestination, { recursive: true }); + await fs.mkdir(tempDestination, { recursive: true }); await copy(source, tempDestination); await copy(tempDestination, destination); return FileUri.create(destination).toString(); @@ -531,8 +584,8 @@ void loop() { const { client } = await this.coreClient; const archivePath = FileUri.fsPath(destinationUri); // The CLI cannot override existing archives, so we have to wipe it manually: https://github.com/arduino/arduino-cli/issues/1160 - if (await promisify(fs.exists)(archivePath)) { - await promisify(fs.unlink)(archivePath); + if (await this.exists(archivePath)) { + await fs.unlink(archivePath); } const req = new ArchiveSketchRequest(); req.setSketchPath(FileUri.fsPath(sketch.uri)); @@ -556,7 +609,7 @@ void loop() { async getIdeTempFolderPath(sketch: Sketch): Promise { const sketchPath = FileUri.fsPath(sketch.uri); - await fs.promises.readdir(sketchPath); // Validates the sketch folder and rejects if not accessible. + await fs.readdir(sketchPath); // Validates the sketch folder and rejects if not accessible. const suffix = crypto.createHash('md5').update(sketchPath).digest('hex'); return path.join(os.tmpdir(), `arduino-ide2-${suffix}`); } @@ -564,53 +617,32 @@ void loop() { async deleteSketch(sketch: Sketch): Promise { return new Promise((resolve, reject) => { const sketchPath = FileUri.fsPath(sketch.uri); - fs.rm(sketchPath, { recursive: true, maxRetries: 5 }, (error) => { + rm(sketchPath, { recursive: true, maxRetries: 5 }, (error) => { if (error) { - console.error(`Failed to delete sketch at ${sketchPath}.`, error); + this.logger.error(`Failed to delete sketch at ${sketchPath}.`, error); reject(error); } else { - console.log(`Successfully deleted sketch at ${sketchPath}.`); + this.logger.info(`Successfully deleted sketch at ${sketchPath}.`); resolve(); } }); }); } + + private async exists(pathLike: string): Promise { + try { + await fs.access(pathLike, constants.R_OK | constants.W_OK); + return true; + } catch { + return false; + } + } } interface SketchWithDetails extends Sketch { readonly mtimeMs: number; } -// https://github.com/arduino/arduino-cli/issues/1797 -function fixErrorMessage( - err: ServiceError, - sketchPath: string, - sketchbookPath: string | undefined -): string { - if (!sketchbookPath) { - return err.details; // No way to repair the error message. The current sketchbook path is not available. - } - // Original: `Can't open sketch: no valid sketch found in /Users/a.kitta/Documents/Arduino: missing /Users/a.kitta/Documents/Arduino/Arduino.ino` - // Fixed: `Can't open sketch: no valid sketch found in /Users/a.kitta/Documents/Arduino: missing $sketchPath` - const message = err.details; - const incorrectMessageSuffix = path.join(sketchbookPath, 'Arduino.ino'); - if ( - message.startsWith("Can't open sketch: no valid sketch found in") && - message.endsWith(`${incorrectMessageSuffix}`) - ) { - const sketchName = path.basename(sketchPath); - const correctMessagePrefix = message.substring( - 0, - message.length - incorrectMessageSuffix.length - ); - return `${correctMessagePrefix}${path.join( - sketchPath, - `${sketchName}.ino` - )}`; - } - return err.details; -} - function isNotFoundError(err: unknown): err is ServiceError { return ServiceError.is(err) && err.code === 5; // `NOT_FOUND` https://grpc.github.io/grpc/core/md_doc_statuscodes.html }