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 4743f3f84..284d5be8d 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -347,6 +347,7 @@ import { ConfigServiceClient } from './config/config-service-client'; import { ValidateSketch } from './contributions/validate-sketch'; import { RenameCloudSketch } from './contributions/rename-cloud-sketch'; import { CreateFeatures } from './create/create-features'; +import { NativeImageCache } from './native-image-cache'; export default new ContainerModule((bind, unbind, isBound, rebind) => { // Commands and toolbar items @@ -1014,4 +1015,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { }, })) .inSingletonScope(); + + // manages native images for the electron menu icons + bind(NativeImageCache).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(NativeImageCache); }); 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 a14d6a541..c17fd79a4 100644 --- a/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts @@ -1,57 +1,49 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; -import { WorkspaceServer } from '@theia/workspace/lib/common/workspace-protocol'; +import { NativeImage } from '@theia/core/electron-shared/electron'; import { Disposable, DisposableCollection, } from '@theia/core/lib/common/disposable'; -import { - SketchContribution, - CommandRegistry, - MenuModelRegistry, - Sketch, -} from './contribution'; +import { MenuAction } from '@theia/core/lib/common/menu'; +import { nls } from '@theia/core/lib/common/nls'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { SketchesError } from '../../common/protocol'; +import { ConfigServiceClient } from '../config/config-service-client'; import { ArduinoMenus } from '../menu/arduino-menus'; -import { MainMenuManager } from '../../common/main-menu-manager'; -import { OpenSketch } from './open-sketch'; +import { NativeImageCache } from '../native-image-cache'; import { NotificationCenter } from '../notification-center'; -import { nls } from '@theia/core/lib/common'; -import { SketchesError } from '../../common/protocol'; +import { CloudSketchContribution } from './cloud-contribution'; +import { CommandRegistry, MenuModelRegistry, Sketch } from './contribution'; +import { OpenSketch } from './open-sketch'; @injectable() -export class OpenRecentSketch extends SketchContribution { +export class OpenRecentSketch extends CloudSketchContribution { @inject(CommandRegistry) - protected readonly commandRegistry: CommandRegistry; - + private readonly commandRegistry: CommandRegistry; @inject(MenuModelRegistry) - protected readonly menuRegistry: MenuModelRegistry; - - @inject(MainMenuManager) - protected readonly mainMenuManager: MainMenuManager; - - @inject(WorkspaceServer) - protected readonly workspaceServer: WorkspaceServer; - + private readonly menuRegistry: MenuModelRegistry; @inject(NotificationCenter) - protected readonly notificationCenter: NotificationCenter; + private readonly notificationCenter: NotificationCenter; + @inject(NativeImageCache) + private readonly imageCache: NativeImageCache; + @inject(ConfigServiceClient) + private readonly configServiceClient: ConfigServiceClient; - protected toDispose = new DisposableCollection(); + private readonly toDispose = new DisposableCollection(); + private cloudImage: NativeImage | undefined; override onStart(): void { this.notificationCenter.onRecentSketchesDidChange(({ sketches }) => this.refreshMenu(sketches) ); + this.imageCache + .getImage('cloud') + .then((image) => (this.cloudImage = image)); } override async onReady(): Promise { this.update(); } - private update(forceUpdate?: boolean): void { - this.sketchesService - .recentlyOpenedSketches(forceUpdate) - .then((sketches) => this.refreshMenu(sketches)); - } - override registerMenus(registry: MenuModelRegistry): void { registry.registerSubmenu( ArduinoMenus.FILE__OPEN_RECENT_SUBMENU, @@ -60,12 +52,18 @@ export class OpenRecentSketch extends SketchContribution { ); } + private update(forceUpdate?: boolean): void { + this.sketchesService + .recentlyOpenedSketches(forceUpdate) + .then((sketches) => this.refreshMenu(sketches)); + } + private refreshMenu(sketches: Sketch[]): void { this.register(sketches); - this.mainMenuManager.update(); + this.menuManager.update(); } - protected register(sketches: Sketch[]): void { + private register(sketches: Sketch[]): void { const order = 0; this.toDispose.dispose(); for (const sketch of sketches) { @@ -88,13 +86,14 @@ export class OpenRecentSketch extends SketchContribution { }, }; this.commandRegistry.registerCommand(command, handler); + const menuAction = this.assignImage(sketch, { + commandId: command.id, + label: sketch.name, + order: String(order), + }); this.menuRegistry.registerMenuAction( ArduinoMenus.FILE__OPEN_RECENT_SUBMENU, - { - commandId: command.id, - label: sketch.name, - order: String(order), - } + menuAction ); this.toDispose.pushAll([ new DisposableCollection( @@ -108,4 +107,15 @@ export class OpenRecentSketch extends SketchContribution { ]); } } + + private assignImage(sketch: Sketch, menuAction: MenuAction): MenuAction { + if (this.cloudImage) { + const dataDirUri = this.configServiceClient.tryGetDataDirUri(); + const isCloud = this.createFeatures.isCloud(sketch, dataDirUri); + if (isCloud) { + Object.assign(menuAction, { nativeImage: this.cloudImage }); + } + } + return menuAction; + } } diff --git a/arduino-ide-extension/src/browser/native-image-cache.ts b/arduino-ide-extension/src/browser/native-image-cache.ts new file mode 100644 index 000000000..268a96ae7 --- /dev/null +++ b/arduino-ide-extension/src/browser/native-image-cache.ts @@ -0,0 +1,91 @@ +import { + NativeImage, + nativeImage, + Size, +} from '@theia/core/electron-shared/electron'; +import { Endpoint } from '@theia/core/lib/browser/endpoint'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { injectable } from '@theia/core/shared/inversify'; +import fetch from 'cross-fetch'; + +const nativeImageIdentifierLiterals = ['cloud'] as const; +export type NativeImageIdentifier = + typeof nativeImageIdentifierLiterals[number]; +export const nativeImages: Record = { + cloud: 'cloud.png', +}; + +@injectable() +export class NativeImageCache implements FrontendApplicationContribution { + private readonly cache = new Map(); + private readonly loading = new Map< + NativeImageIdentifier, + Promise + >(); + + onStart(): void { + Object.keys(nativeImages).forEach((identifier: NativeImageIdentifier) => + this.getImage(identifier) + ); + } + + tryGetImage(identifier: NativeImageIdentifier): NativeImage | undefined { + return this.cache.get(identifier); + } + + async getImage(identifier: NativeImageIdentifier): Promise { + const image = this.cache.get(identifier); + if (image) { + return image; + } + let loading = this.loading.get(identifier); + if (!loading) { + const deferred = new Deferred(); + loading = deferred.promise; + this.loading.set(identifier, loading); + this.fetchIconData(identifier).then( + (image) => { + if (!this.cache.has(identifier)) { + this.cache.set(identifier, image); + } + this.loading.delete(identifier); + deferred.resolve(image); + }, + (err) => { + this.loading.delete(identifier); + deferred.reject(err); + } + ); + } + return loading; + } + + private async fetchIconData( + identifier: NativeImageIdentifier + ): Promise { + const path = `nativeImage/${nativeImages[identifier]}`; + const endpoint = new Endpoint({ path }).getRestUrl().toString(); + const response = await fetch(endpoint); + const arrayBuffer = await response.arrayBuffer(); + const view = new Uint8Array(arrayBuffer); + const buffer = Buffer.alloc(arrayBuffer.byteLength); + buffer.forEach((_, index) => (buffer[index] = view[index])); + const image = nativeImage.createFromBuffer(buffer); + return this.maybeResize(image); + } + + private maybeResize(image: NativeImage): NativeImage { + const currentSize = image.getSize(); + if (sizeEquals(currentSize, preferredSize)) { + return image; + } + return image.resize(preferredSize); + } +} + +const pixel = 16; +const preferredSize: Size = { height: pixel, width: pixel }; +function sizeEquals(left: Size, right: Size): boolean { + return left.height === right.height && left.width === right.width; +} diff --git a/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts b/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts index bcb313a6f..4a2f2faf4 100644 --- a/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts +++ b/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts @@ -1,7 +1,9 @@ import * as remote from '@theia/core/electron-shared/@electron/remote'; +import { NativeImage } from '@theia/core/electron-shared/electron'; import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { + ActionMenuNode, CommandMenuNode, CompoundMenuNode, CompoundMenuNodeRole, @@ -278,6 +280,12 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory { delete menuItem.click; } } + + // Native image customization for IDE2 + if (isMenuNodeWithNativeImage(node)) { + menuItem.icon = node.action.nativeImage; + } + parentItems.push(menuItem); if (this.commandRegistry.getToggledHandler(commandId, ...args)) { @@ -314,3 +322,23 @@ const AlwaysVisibleSubmenus: MenuPath[] = [ ArduinoMenus.TOOLS__PORTS_SUBMENU, // #655 ArduinoMenus.FILE__SKETCHBOOK_SUBMENU, // #569 ]; + +// Theia does not support icons for electron menu items. +// This is a hack to show a cloud icon as a native image for the cloud sketches in `File` > `Open Recent` menu. +type MenuNodeWithNativeImage = MenuNode & { + action: ActionMenuNode & { nativeImage: NativeImage }; +}; +type ActionMenuNodeWithNativeImage = ActionMenuNode & { + nativeImage: NativeImage; +}; +function isMenuNodeWithNativeImage( + node: MenuNode +): node is MenuNodeWithNativeImage { + if (node instanceof ActionMenuNode) { + const action: unknown = node['action']; + if ((action).nativeImage !== undefined) { + return true; + } + } + return false; +} 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 812761f77..781633694 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -118,6 +118,7 @@ import { LocalDirectoryPluginDeployerResolverWithFallback, PluginDeployer_GH_12064, } from './theia/plugin-ext/plugin-deployer'; +import { NativeImageDataProvider } from './native-image-data-provider'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(BackendApplication).toSelf().inSingletonScope(); @@ -403,6 +404,10 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { .toSelf() .inSingletonScope(); rebind(PluginDeployer).to(PluginDeployer_GH_12064).inSingletonScope(); + + // to serve native images for the electron menus + bind(NativeImageDataProvider).toSelf().inSingletonScope(); + bind(BackendApplicationContribution).toService(NativeImageDataProvider); }); function bindChildLogger(bind: interfaces.Bind, name: string): void { diff --git a/arduino-ide-extension/src/node/native-image-data-provider.ts b/arduino-ide-extension/src/node/native-image-data-provider.ts new file mode 100644 index 000000000..beb7b265f --- /dev/null +++ b/arduino-ide-extension/src/node/native-image-data-provider.ts @@ -0,0 +1,61 @@ +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; +import { Application } from '@theia/core/shared/express'; +import { injectable } from '@theia/core/shared/inversify'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { ErrnoException } from './utils/errors'; + +@injectable() +export class NativeImageDataProvider implements BackendApplicationContribution { + private readonly rootPath = join(__dirname, '../../src/node/static/icons'); + private readonly dataCache = new Map>(); + + onStart(): void { + console.log(`Serving native images from ${this.rootPath}`); + } + + configure(app: Application): void { + app.get('/nativeImage/:filename', async (req, resp) => { + const filename = req.params.filename; + if (!filename) { + resp.status(400).send('Bad Request'); + return; + } + try { + const data = await this.getOrCreateData(filename); + if (!data) { + resp.status(404).send('Not found'); + return; + } + resp.send(data); + } catch (err) { + resp.status(500).send(err instanceof Error ? err.message : String(err)); + } + }); + } + + private async getOrCreateData(filename: string): Promise { + let data = this.dataCache.get(filename); + if (!data) { + const deferred = new Deferred(); + data = deferred.promise; + this.dataCache.set(filename, data); + const path = join(this.rootPath, filename); + fs.readFile(path).then( + (buffer) => deferred.resolve(buffer), + (err) => { + if (ErrnoException.isENOENT(err)) { + console.error(`File not found: ${path}`); + deferred.resolve(undefined); + } else { + console.error(`Failed to load file: ${path}`, err); + this.dataCache.delete(filename); + deferred.reject(err); + } + } + ); + } + return data; + } +} diff --git a/arduino-ide-extension/src/node/static/icons/cloud.png b/arduino-ide-extension/src/node/static/icons/cloud.png new file mode 100644 index 000000000..fb4426ce8 Binary files /dev/null and b/arduino-ide-extension/src/node/static/icons/cloud.png differ