Skip to content

Commit 42f6237

Browse files
author
Akos Kitta
committed
feat: icon for cloud sketch in File > Open Recent
Ref: #1826 Signed-off-by: Akos Kitta <[email protected]>
1 parent 9687fc6 commit 42f6237

File tree

7 files changed

+238
-38
lines changed

7 files changed

+238
-38
lines changed

arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts

+5
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ import { ConfigServiceClient } from './config/config-service-client';
347347
import { ValidateSketch } from './contributions/validate-sketch';
348348
import { RenameCloudSketch } from './contributions/rename-cloud-sketch';
349349
import { CreateFeatures } from './create/create-features';
350+
import { NativeImageCache } from './native-image-cache';
350351

351352
export default new ContainerModule((bind, unbind, isBound, rebind) => {
352353
// Commands and toolbar items
@@ -1014,4 +1015,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
10141015
},
10151016
}))
10161017
.inSingletonScope();
1018+
1019+
// manages native images for the electron menu icons
1020+
bind(NativeImageCache).toSelf().inSingletonScope();
1021+
bind(FrontendApplicationContribution).toService(NativeImageCache);
10171022
});
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,49 @@
1-
import { inject, injectable } from '@theia/core/shared/inversify';
2-
import { WorkspaceServer } from '@theia/workspace/lib/common/workspace-protocol';
1+
import { NativeImage } from '@theia/core/electron-shared/electron';
32
import {
43
Disposable,
54
DisposableCollection,
65
} from '@theia/core/lib/common/disposable';
7-
import {
8-
SketchContribution,
9-
CommandRegistry,
10-
MenuModelRegistry,
11-
Sketch,
12-
} from './contribution';
6+
import { MenuAction } from '@theia/core/lib/common/menu';
7+
import { nls } from '@theia/core/lib/common/nls';
8+
import { inject, injectable } from '@theia/core/shared/inversify';
9+
import { SketchesError } from '../../common/protocol';
10+
import { ConfigServiceClient } from '../config/config-service-client';
1311
import { ArduinoMenus } from '../menu/arduino-menus';
14-
import { MainMenuManager } from '../../common/main-menu-manager';
15-
import { OpenSketch } from './open-sketch';
12+
import { NativeImageCache } from '../native-image-cache';
1613
import { NotificationCenter } from '../notification-center';
17-
import { nls } from '@theia/core/lib/common';
18-
import { SketchesError } from '../../common/protocol';
14+
import { CloudSketchContribution } from './cloud-contribution';
15+
import { CommandRegistry, MenuModelRegistry, Sketch } from './contribution';
16+
import { OpenSketch } from './open-sketch';
1917

2018
@injectable()
21-
export class OpenRecentSketch extends SketchContribution {
19+
export class OpenRecentSketch extends CloudSketchContribution {
2220
@inject(CommandRegistry)
23-
protected readonly commandRegistry: CommandRegistry;
24-
21+
private readonly commandRegistry: CommandRegistry;
2522
@inject(MenuModelRegistry)
26-
protected readonly menuRegistry: MenuModelRegistry;
27-
28-
@inject(MainMenuManager)
29-
protected readonly mainMenuManager: MainMenuManager;
30-
31-
@inject(WorkspaceServer)
32-
protected readonly workspaceServer: WorkspaceServer;
33-
23+
private readonly menuRegistry: MenuModelRegistry;
3424
@inject(NotificationCenter)
35-
protected readonly notificationCenter: NotificationCenter;
25+
private readonly notificationCenter: NotificationCenter;
26+
@inject(NativeImageCache)
27+
private readonly imageCache: NativeImageCache;
28+
@inject(ConfigServiceClient)
29+
private readonly configServiceClient: ConfigServiceClient;
3630

37-
protected toDispose = new DisposableCollection();
31+
private readonly toDispose = new DisposableCollection();
32+
private cloudImage: NativeImage | undefined;
3833

3934
override onStart(): void {
4035
this.notificationCenter.onRecentSketchesDidChange(({ sketches }) =>
4136
this.refreshMenu(sketches)
4237
);
38+
this.imageCache
39+
.getImage('cloud')
40+
.then((image) => (this.cloudImage = image));
4341
}
4442

4543
override async onReady(): Promise<void> {
4644
this.update();
4745
}
4846

49-
private update(forceUpdate?: boolean): void {
50-
this.sketchesService
51-
.recentlyOpenedSketches(forceUpdate)
52-
.then((sketches) => this.refreshMenu(sketches));
53-
}
54-
5547
override registerMenus(registry: MenuModelRegistry): void {
5648
registry.registerSubmenu(
5749
ArduinoMenus.FILE__OPEN_RECENT_SUBMENU,
@@ -60,12 +52,18 @@ export class OpenRecentSketch extends SketchContribution {
6052
);
6153
}
6254

55+
private update(forceUpdate?: boolean): void {
56+
this.sketchesService
57+
.recentlyOpenedSketches(forceUpdate)
58+
.then((sketches) => this.refreshMenu(sketches));
59+
}
60+
6361
private refreshMenu(sketches: Sketch[]): void {
6462
this.register(sketches);
65-
this.mainMenuManager.update();
63+
this.menuManager.update();
6664
}
6765

68-
protected register(sketches: Sketch[]): void {
66+
private register(sketches: Sketch[]): void {
6967
const order = 0;
7068
this.toDispose.dispose();
7169
for (const sketch of sketches) {
@@ -88,13 +86,14 @@ export class OpenRecentSketch extends SketchContribution {
8886
},
8987
};
9088
this.commandRegistry.registerCommand(command, handler);
89+
const menuAction = this.assignImage(sketch, {
90+
commandId: command.id,
91+
label: sketch.name,
92+
order: String(order),
93+
});
9194
this.menuRegistry.registerMenuAction(
9295
ArduinoMenus.FILE__OPEN_RECENT_SUBMENU,
93-
{
94-
commandId: command.id,
95-
label: sketch.name,
96-
order: String(order),
97-
}
96+
menuAction
9897
);
9998
this.toDispose.pushAll([
10099
new DisposableCollection(
@@ -108,4 +107,15 @@ export class OpenRecentSketch extends SketchContribution {
108107
]);
109108
}
110109
}
110+
111+
private assignImage(sketch: Sketch, menuAction: MenuAction): MenuAction {
112+
if (this.cloudImage) {
113+
const dataDirUri = this.configServiceClient.tryGetDataDirUri();
114+
const isCloud = this.createFeatures.isCloud(sketch, dataDirUri);
115+
if (isCloud) {
116+
Object.assign(menuAction, { nativeImage: this.cloudImage });
117+
}
118+
}
119+
return menuAction;
120+
}
111121
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
NativeImage,
3+
nativeImage,
4+
Size,
5+
} from '@theia/core/electron-shared/electron';
6+
import { Endpoint } from '@theia/core/lib/browser/endpoint';
7+
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
8+
import { Deferred } from '@theia/core/lib/common/promise-util';
9+
import { injectable } from '@theia/core/shared/inversify';
10+
import fetch from 'cross-fetch';
11+
12+
const nativeImageIdentifierLiterals = ['cloud'] as const;
13+
export type NativeImageIdentifier =
14+
typeof nativeImageIdentifierLiterals[number];
15+
export const nativeImages: Record<NativeImageIdentifier, string> = {
16+
cloud: 'cloud.png',
17+
};
18+
19+
@injectable()
20+
export class NativeImageCache implements FrontendApplicationContribution {
21+
private readonly cache = new Map<NativeImageIdentifier, NativeImage>();
22+
private readonly loading = new Map<
23+
NativeImageIdentifier,
24+
Promise<NativeImage>
25+
>();
26+
27+
onStart(): void {
28+
Object.keys(nativeImages).forEach((identifier: NativeImageIdentifier) =>
29+
this.getImage(identifier)
30+
);
31+
}
32+
33+
tryGetImage(identifier: NativeImageIdentifier): NativeImage | undefined {
34+
return this.cache.get(identifier);
35+
}
36+
37+
async getImage(identifier: NativeImageIdentifier): Promise<NativeImage> {
38+
const image = this.cache.get(identifier);
39+
if (image) {
40+
return image;
41+
}
42+
let loading = this.loading.get(identifier);
43+
if (!loading) {
44+
const deferred = new Deferred<NativeImage>();
45+
loading = deferred.promise;
46+
this.loading.set(identifier, loading);
47+
this.fetchIconData(identifier).then(
48+
(image) => {
49+
if (!this.cache.has(identifier)) {
50+
this.cache.set(identifier, image);
51+
}
52+
this.loading.delete(identifier);
53+
deferred.resolve(image);
54+
},
55+
(err) => {
56+
this.loading.delete(identifier);
57+
deferred.reject(err);
58+
}
59+
);
60+
}
61+
return loading;
62+
}
63+
64+
private async fetchIconData(
65+
identifier: NativeImageIdentifier
66+
): Promise<NativeImage> {
67+
const path = `nativeImage/${nativeImages[identifier]}`;
68+
const endpoint = new Endpoint({ path }).getRestUrl().toString();
69+
const response = await fetch(endpoint);
70+
const arrayBuffer = await response.arrayBuffer();
71+
const view = new Uint8Array(arrayBuffer);
72+
const buffer = Buffer.alloc(arrayBuffer.byteLength);
73+
buffer.forEach((_, index) => (buffer[index] = view[index]));
74+
const image = nativeImage.createFromBuffer(buffer);
75+
return this.maybeResize(image);
76+
}
77+
78+
private maybeResize(image: NativeImage): NativeImage {
79+
const currentSize = image.getSize();
80+
if (sizeEquals(currentSize, preferredSize)) {
81+
return image;
82+
}
83+
return image.resize(preferredSize);
84+
}
85+
}
86+
87+
const pixel = 16;
88+
const preferredSize: Size = { height: pixel, width: pixel };
89+
function sizeEquals(left: Size, right: Size): boolean {
90+
return left.height === right.height && left.width === right.width;
91+
}

arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts

+28
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as remote from '@theia/core/electron-shared/@electron/remote';
2+
import { NativeImage } from '@theia/core/electron-shared/electron';
23
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
34
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
45
import {
6+
ActionMenuNode,
57
CommandMenuNode,
68
CompoundMenuNode,
79
CompoundMenuNodeRole,
@@ -278,6 +280,12 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory {
278280
delete menuItem.click;
279281
}
280282
}
283+
284+
// Native image customization for IDE2
285+
if (isMenuNodeWithNativeImage(node)) {
286+
menuItem.icon = node.action.nativeImage;
287+
}
288+
281289
parentItems.push(menuItem);
282290

283291
if (this.commandRegistry.getToggledHandler(commandId, ...args)) {
@@ -314,3 +322,23 @@ const AlwaysVisibleSubmenus: MenuPath[] = [
314322
ArduinoMenus.TOOLS__PORTS_SUBMENU, // #655
315323
ArduinoMenus.FILE__SKETCHBOOK_SUBMENU, // #569
316324
];
325+
326+
// Theia does not support icons for electron menu items.
327+
// This is a hack to show a cloud icon as a native image for the cloud sketches in `File` > `Open Recent` menu.
328+
type MenuNodeWithNativeImage = MenuNode & {
329+
action: ActionMenuNode & { nativeImage: NativeImage };
330+
};
331+
type ActionMenuNodeWithNativeImage = ActionMenuNode & {
332+
nativeImage: NativeImage;
333+
};
334+
function isMenuNodeWithNativeImage(
335+
node: MenuNode
336+
): node is MenuNodeWithNativeImage {
337+
if (node instanceof ActionMenuNode) {
338+
const action: unknown = node['action'];
339+
if ((<ActionMenuNodeWithNativeImage>action).nativeImage !== undefined) {
340+
return true;
341+
}
342+
}
343+
return false;
344+
}

arduino-ide-extension/src/node/arduino-ide-backend-module.ts

+5
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ import {
118118
LocalDirectoryPluginDeployerResolverWithFallback,
119119
PluginDeployer_GH_12064,
120120
} from './theia/plugin-ext/plugin-deployer';
121+
import { NativeImageDataProvider } from './native-image-data-provider';
121122

122123
export default new ContainerModule((bind, unbind, isBound, rebind) => {
123124
bind(BackendApplication).toSelf().inSingletonScope();
@@ -403,6 +404,10 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
403404
.toSelf()
404405
.inSingletonScope();
405406
rebind(PluginDeployer).to(PluginDeployer_GH_12064).inSingletonScope();
407+
408+
// to serve native images for the electron menus
409+
bind(NativeImageDataProvider).toSelf().inSingletonScope();
410+
bind(BackendApplicationContribution).toService(NativeImageDataProvider);
406411
});
407412

408413
function bindChildLogger(bind: interfaces.Bind, name: string): void {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Deferred } from '@theia/core/lib/common/promise-util';
2+
import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
3+
import { Application } from '@theia/core/shared/express';
4+
import { injectable } from '@theia/core/shared/inversify';
5+
import { promises as fs } from 'fs';
6+
import { join } from 'path';
7+
import { ErrnoException } from './utils/errors';
8+
9+
@injectable()
10+
export class NativeImageDataProvider implements BackendApplicationContribution {
11+
private readonly rootPath = join(__dirname, '../../src/node/static/icons');
12+
private readonly dataCache = new Map<string, Promise<Buffer | undefined>>();
13+
14+
onStart(): void {
15+
console.log(`Serving native images from ${this.rootPath}`);
16+
}
17+
18+
configure(app: Application): void {
19+
app.get('/nativeImage/:filename', async (req, resp) => {
20+
const filename = req.params.filename;
21+
if (!filename) {
22+
resp.status(400).send('Bad Request');
23+
return;
24+
}
25+
try {
26+
const data = await this.getOrCreateData(filename);
27+
if (!data) {
28+
resp.status(404).send('Not found');
29+
return;
30+
}
31+
resp.send(data);
32+
} catch (err) {
33+
resp.status(500).send(err instanceof Error ? err.message : String(err));
34+
}
35+
});
36+
}
37+
38+
private async getOrCreateData(filename: string): Promise<Buffer | undefined> {
39+
let data = this.dataCache.get(filename);
40+
if (!data) {
41+
const deferred = new Deferred<Buffer | undefined>();
42+
data = deferred.promise;
43+
this.dataCache.set(filename, data);
44+
const path = join(this.rootPath, filename);
45+
fs.readFile(path).then(
46+
(buffer) => deferred.resolve(buffer),
47+
(err) => {
48+
if (ErrnoException.isENOENT(err)) {
49+
console.error(`File not found: ${path}`);
50+
deferred.resolve(undefined);
51+
} else {
52+
console.error(`Failed to load file: ${path}`, err);
53+
this.dataCache.delete(filename);
54+
deferred.reject(err);
55+
}
56+
}
57+
);
58+
}
59+
return data;
60+
}
61+
}
Loading

0 commit comments

Comments
 (0)