Skip to content

Commit

Permalink
feat: icon for cloud sketch in File > Open Recent
Browse files Browse the repository at this point in the history
Ref: #1826

Signed-off-by: Akos Kitta <[email protected]>
  • Loading branch information
Akos Kitta committed Feb 8, 2023
1 parent 9687fc6 commit 42f6237
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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;
}
}
91 changes: 91 additions & 0 deletions arduino-ide-extension/src/browser/native-image-cache.ts
Original file line number Diff line number Diff line change
@@ -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<NativeImageIdentifier, string> = {
cloud: 'cloud.png',
};

@injectable()
export class NativeImageCache implements FrontendApplicationContribution {
private readonly cache = new Map<NativeImageIdentifier, NativeImage>();
private readonly loading = new Map<
NativeImageIdentifier,
Promise<NativeImage>
>();

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<NativeImage> {
const image = this.cache.get(identifier);
if (image) {
return image;
}
let loading = this.loading.get(identifier);
if (!loading) {
const deferred = new Deferred<NativeImage>();
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<NativeImage> {
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;
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 ((<ActionMenuNodeWithNativeImage>action).nativeImage !== undefined) {
return true;
}
}
return false;
}
5 changes: 5 additions & 0 deletions arduino-ide-extension/src/node/arduino-ide-backend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions arduino-ide-extension/src/node/native-image-data-provider.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<Buffer | undefined>>();

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<Buffer | undefined> {
let data = this.dataCache.get(filename);
if (!data) {
const deferred = new Deferred<Buffer | undefined>();
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;
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 42f6237

Please sign in to comment.