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 6c6b44a63..bd9a8b5b8 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -356,6 +356,7 @@ import { Account } from './contributions/account'; import { SidebarBottomMenuWidget } from './theia/core/sidebar-bottom-menu-widget'; import { SidebarBottomMenuWidget as TheiaSidebarBottomMenuWidget } from '@theia/core/lib/browser/shell/sidebar-bottom-menu-widget'; import { CreateCloudCopy } from './contributions/create-cloud-copy'; +import { NativeImageCache } from './native-image-cache'; export default new ContainerModule((bind, unbind, isBound, rebind) => { // Commands and toolbar items @@ -1034,4 +1035,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(FrontendApplicationContribution).toService(DaemonPort); bind(IsOnline).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(IsOnline); + // 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..dfc02b7a4 100644 --- a/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts @@ -1,55 +1,65 @@ -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 { ThemeService } from '@theia/core/lib/browser/theming'; 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 { + isThemeNativeImage, + NativeImageCache, + ThemeNativeImage, +} 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; + @inject(ThemeService) + private readonly themeService: ThemeService; - protected toDispose = new DisposableCollection(); + private readonly toDisposeBeforeRegister = new DisposableCollection(); + private readonly toDispose = new DisposableCollection( + this.toDisposeBeforeRegister + ); + private cloudImage: NativeImage | ThemeNativeImage; override onStart(): void { - this.notificationCenter.onRecentSketchesDidChange(({ sketches }) => - this.refreshMenu(sketches) - ); + this.toDispose.pushAll([ + this.notificationCenter.onRecentSketchesDidChange(({ sketches }) => + this.refreshMenu(sketches) + ), + this.themeService.onDidColorThemeChange(() => this.update()), + ]); } - override async onReady(): Promise { - this.update(); + onStop(): void { + this.toDispose.dispose(); } - private update(forceUpdate?: boolean): void { - this.sketchesService - .recentlyOpenedSketches(forceUpdate) - .then((sketches) => this.refreshMenu(sketches)); + override async onReady(): Promise { + this.update(); + this.imageCache.getImage('cloud').then((image) => { + this.cloudImage = image; + this.update(); + }); } override registerMenus(registry: MenuModelRegistry): void { @@ -60,14 +70,20 @@ 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(); + this.toDisposeBeforeRegister.dispose(); for (const sketch of sketches) { const { uri } = sketch; const command = { id: `arduino-open-recent--${uri}` }; @@ -88,15 +104,16 @@ 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([ + this.toDisposeBeforeRegister.pushAll([ new DisposableCollection( Disposable.create(() => this.commandRegistry.unregisterCommand(command) @@ -108,4 +125,25 @@ export class OpenRecentSketch extends SketchContribution { ]); } } + + private assignImage(sketch: Sketch, menuAction: MenuAction): MenuAction { + const image = this.nativeImageForTheme(); + if (image) { + const dataDirUri = this.configServiceClient.tryGetDataDirUri(); + const isCloud = this.createFeatures.isCloud(sketch, dataDirUri); + if (isCloud) { + Object.assign(menuAction, { nativeImage: image }); + } + } + return menuAction; + } + + private nativeImageForTheme(): NativeImage | undefined { + const image = this.cloudImage; + if (isThemeNativeImage(image)) { + const themeType = this.themeService.getCurrentTheme().type; + return themeType === 'light' ? image.light : image.dark; + } + return image; + } } 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..13948fee6 --- /dev/null +++ b/arduino-ide-extension/src/browser/native-image-cache.ts @@ -0,0 +1,116 @@ +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< + NativeImageIdentifier, + string | { light: string; dark: string } +> = { + cloud: { light: 'cloud-light.png', dark: 'cloud-dark.png' }, +}; + +export interface ThemeNativeImage { + readonly light: NativeImage; + readonly dark: NativeImage; +} + +export function isThemeNativeImage(arg: unknown): arg is ThemeNativeImage { + return ( + typeof arg === 'object' && + (arg).light !== undefined && + (arg).dark !== undefined + ); +} + +type Image = NativeImage | ThemeNativeImage; + +@injectable() +export class NativeImageCache implements FrontendApplicationContribution { + private readonly cache = new Map(); + private readonly loading = new Map>(); + + onStart(): void { + Object.keys(nativeImages).forEach((identifier: NativeImageIdentifier) => + this.getImage(identifier) + ); + } + + tryGetImage(identifier: NativeImageIdentifier): Image | 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.fetchImage(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 fetchImage(identifier: NativeImageIdentifier): Promise { + const value = nativeImages[identifier]; + if (typeof value === 'string') { + return this.fetchIconData(value); + } + const [light, dark] = await Promise.all([ + this.fetchIconData(value.light), + this.fetchIconData(value.dark), + ]); + return { light, dark }; + } + + private async fetchIconData(filename: string): Promise { + const path = `nativeImage/${filename}`; + 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 5cd81f7c3..349faf482 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -119,6 +119,7 @@ import { PluginDeployer_GH_12064, } from './theia/plugin-ext/plugin-deployer'; import { SettingsReader } from './settings-reader'; +import { NativeImageDataProvider } from './native-image-data-provider'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(BackendApplication).toSelf().inSingletonScope(); @@ -406,6 +407,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { rebind(PluginDeployer).to(PluginDeployer_GH_12064).inSingletonScope(); bind(SettingsReader).toSelf().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-dark.png b/arduino-ide-extension/src/node/static/icons/cloud-dark.png new file mode 100644 index 000000000..464646dd0 Binary files /dev/null and b/arduino-ide-extension/src/node/static/icons/cloud-dark.png differ diff --git a/arduino-ide-extension/src/node/static/icons/cloud-dark@2x.png b/arduino-ide-extension/src/node/static/icons/cloud-dark@2x.png new file mode 100644 index 000000000..14af1ce5e Binary files /dev/null and b/arduino-ide-extension/src/node/static/icons/cloud-dark@2x.png differ diff --git a/arduino-ide-extension/src/node/static/icons/cloud-dark@3x.png b/arduino-ide-extension/src/node/static/icons/cloud-dark@3x.png new file mode 100644 index 000000000..4d857c1d5 Binary files /dev/null and b/arduino-ide-extension/src/node/static/icons/cloud-dark@3x.png differ diff --git a/arduino-ide-extension/src/node/static/icons/cloud-light.png b/arduino-ide-extension/src/node/static/icons/cloud-light.png new file mode 100644 index 000000000..51bf2be84 Binary files /dev/null and b/arduino-ide-extension/src/node/static/icons/cloud-light.png differ diff --git a/arduino-ide-extension/src/node/static/icons/cloud-light@2x.png b/arduino-ide-extension/src/node/static/icons/cloud-light@2x.png new file mode 100644 index 000000000..d42259fb1 Binary files /dev/null and b/arduino-ide-extension/src/node/static/icons/cloud-light@2x.png differ diff --git a/arduino-ide-extension/src/node/static/icons/cloud-light@3x.png b/arduino-ide-extension/src/node/static/icons/cloud-light@3x.png new file mode 100644 index 000000000..777688e04 Binary files /dev/null and b/arduino-ide-extension/src/node/static/icons/cloud-light@3x.png differ