Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: icon for cloud sketch in File > Open Recent #1868

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
124 changes: 81 additions & 43 deletions arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
this.update();
this.imageCache.getImage('cloud').then((image) => {
this.cloudImage = image;
this.update();
});
}

override registerMenus(registry: MenuModelRegistry): void {
Expand All @@ -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}` };
Expand All @@ -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)
Expand All @@ -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;
}
}
116 changes: 116 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,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' &&
(<ThemeNativeImage>arg).light !== undefined &&
(<ThemeNativeImage>arg).dark !== undefined
);
}

type Image = NativeImage | ThemeNativeImage;

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

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<Image> {
const image = this.cache.get(identifier);
if (image) {
return image;
}
let loading = this.loading.get(identifier);
if (!loading) {
const deferred = new Deferred<Image>();
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<Image> {
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<NativeImage> {
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;
}
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;
}
4 changes: 4 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 @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
Loading