1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Simplify hierarchy of application menu items using "single-root" composite

Also solve composed typing of application menu by using Discriminated Unions of TypeScript, see: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen 2022-10-05 10:48:38 +03:00
parent 159f2bb9c1
commit a8cc1cd17c
No known key found for this signature in database
GPG Key ID: 8C6CFB2FFFE8F68A
41 changed files with 578 additions and 244 deletions

View File

@ -29,7 +29,7 @@ describe("add-cluster - navigation using application menu", () => {
describe("when navigating to add cluster using application menu", () => {
beforeEach(async () => {
await applicationBuilder.applicationMenu.click("file.add-cluster");
await applicationBuilder.applicationMenu.click("root.file.add-cluster");
});
it("renders", () => {

View File

@ -0,0 +1,138 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { action } from "mobx";
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import type { FakeExtensionOptions } from "../../renderer/components/test-utils/get-extension-fake";
import applicationMenuItemInjectionToken from "./main/menu-items/application-menu-item-injection-token";
describe("application-menu-in-legacy-extension-api", () => {
let builder: ApplicationBuilder;
beforeEach(async () => {
builder = getApplicationBuilder();
builder.beforeApplicationStart(
action((mainDi) => {
mainDi.register(
someTopMenuItemInjectable,
someNonExtensionBasedMenuItemInjectable,
);
}),
);
await builder.startHidden();
});
describe("when extension with application menu items is enabled", () => {
let onClickMock: jest.Mock;
let testExtensionOptions: FakeExtensionOptions;
beforeEach(() => {
onClickMock = jest.fn();
testExtensionOptions = {
id: "some-test-extension",
name: "some-extension-name",
mainOptions: {
appMenus: [
{
id: "some-clickable-item",
parentId: "some-top-menu-item",
click: onClickMock,
},
{
parentId: "some-top-menu-item",
type: "separator",
},
],
},
};
builder.extensions.enable(testExtensionOptions);
});
it("menu related items exist", () => {
const menuItemPathsForExtension = builder.applicationMenu.items.filter(
(x) =>
x.startsWith("root.some-top-menu-item.some-extension-name"),
);
expect(menuItemPathsForExtension).toEqual([
"root.some-top-menu-item.some-extension-name/application-menu-item/clickable-menu-item(some-clickable-item)",
"root.some-top-menu-item.some-extension-name/application-menu-item/separator(1)",
]);
});
it("when the extension-based clickable menu item is clicked, does so", () => {
builder.applicationMenu.click(
"root.some-top-menu-item.some-extension-name/application-menu-item/clickable-menu-item(some-clickable-item)",
);
expect(onClickMock).toHaveBeenCalled();
});
describe("when the extension is disabled", () => {
beforeEach(() => {
builder.extensions.disable(testExtensionOptions);
});
it("when related menu items no longer exist", () => {
const menuItemPathsForExtension = builder.applicationMenu.items.filter(
(x) =>
x.startsWith("root.some-top-menu-item.some-extension-name"),
);
expect(menuItemPathsForExtension).toEqual([]);
});
it("when the extension is enabled again, also related menu items exist again", () => {
builder.extensions.enable(testExtensionOptions);
const menuItemPathsForExtension = builder.applicationMenu.items.filter(
(x) =>
x.startsWith("root.some-top-menu-item.some-extension-name"),
);
expect(menuItemPathsForExtension).toEqual([
"root.some-top-menu-item.some-extension-name/application-menu-item/clickable-menu-item(some-clickable-item)",
"root.some-top-menu-item.some-extension-name/application-menu-item/separator(1)",
]);
});
});
});
});
const someTopMenuItemInjectable = getInjectable({
id: "some-top-menu-item",
instantiate: () => ({
id: "some-top-menu-item",
parentId: "root" as const,
kind: "top-level-menu" as const,
label: "Some existing root menu item",
orderNumber: 42,
}),
injectionToken: applicationMenuItemInjectionToken,
});
const someNonExtensionBasedMenuItemInjectable = getInjectable({
id: "some-non-extension-based-menu-item",
instantiate: () => ({
id: "some-non-extension-based-menu-item",
parentId: "some-top-menu-item",
kind: "clickable-menu-item" as const,
label: "Some menu item",
onClick: () => {},
orderNumber: 42,
}),
injectionToken: applicationMenuItemInjectionToken,
});

View File

@ -6,6 +6,7 @@ import type { ApplicationBuilder } from "../../renderer/components/test-utils/ge
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import populateApplicationMenuInjectable from "./main/populate-application-menu.injectable";
import { advanceFakeTime, useFakeTime } from "../../common/test-utils/use-fake-time";
import { getCompositePaths } from "./main/menu-items/get-composite/get-composite-paths/get-composite-paths";
describe("application-menu", () => {
let builder: ApplicationBuilder;
@ -35,18 +36,21 @@ describe("application-menu", () => {
});
describe("given enough time passes", () => {
let applicationMenuPaths: string[];
beforeEach(() => {
advanceFakeTime(100);
});
it("populates application menu", () => {
expect(populateApplicationMenuMock).toHaveBeenCalledWith(
expect.any(Array),
applicationMenuPaths = getCompositePaths(
populateApplicationMenuMock.mock.calls[0][0],
);
});
it("populates application menu lol", () => {
expect(populateApplicationMenuMock.mock.calls).toMatchSnapshot();
it("populates application menu with at least something", () => {
expect(applicationMenuPaths.length).toBeGreaterThan(0);
});
it("populates application menu", () => {
expect(applicationMenuPaths).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import applicationMenuItemsInjectable from "./application-menu-items.injectable";
import type { Composite } from "./menu-items/get-composite/get-composite";
import getComposite from "./menu-items/get-composite/get-composite";
import { computed } from "mobx";
import { pipeline } from "@ogre-tools/fp";
import { get } from "lodash/fp";
import type { ApplicationMenuItemTypes } from "./menu-items/application-menu-item-injection-token";
export interface MenuItemRoot { id: "root"; kind: "root"; orderNumber: 0 }
const applicationMenuItemCompositeInjectable = getInjectable({
id: "application-menu-item-composite",
instantiate: (di) => {
const menuItems = di.inject(applicationMenuItemsInjectable);
return computed((): Composite<ApplicationMenuItemTypes | MenuItemRoot> => {
const items = menuItems.get();
return pipeline(
[{ id: "root" as const, kind: "root" as const, orderNumber: 0 as const }, ...items],
x => getComposite({
source: x,
rootId: "root",
getId: get("id"),
getParentId: get("parentId"),
}),
);
});
},
});
export default applicationMenuItemCompositeInjectable;

View File

@ -0,0 +1,58 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token";
import type { LensExtension } from "../../../extensions/lens-extension";
import type { LensMainExtension } from "../../../extensions/lens-main-extension";
import type { ClickableMenuItem, Separator } from "./menu-items/application-menu-item-injection-token";
import applicationMenuItemInjectionToken from "./menu-items/application-menu-item-injection-token";
const applicationMenuItemRegistratorInjectable = getInjectable({
id: "application-menu-item-registrator",
instantiate: () => (ext: LensExtension) => {
const extension = ext as LensMainExtension;
return extension.appMenus.map((registration, index) => {
const registrationId = registration.id || index;
const applicationMenuId = `${extension.sanitizedExtensionId}/application-menu-item`;
return getInjectable({
id: `${applicationMenuId}/${registrationId}`,
instantiate: () => {
const orderNumber = 1000 + index * 10;
if (registration.type === "separator") {
return {
kind: "separator" as const,
id: `${applicationMenuId}/separator(${registrationId})`,
parentId: registration.parentId,
orderNumber,
} as Separator;
}
return {
kind: "clickable-menu-item" as const,
id: `${applicationMenuId}/clickable-menu-item(${registrationId})`,
parentId: registration.parentId,
// Todo: hide electron evens from this abstraction.
onClick: registration.click,
label: registration.label,
isShown: registration.visible ?? true,
orderNumber,
...(registration.accelerator ? { keyboardShortcut: registration.accelerator as string } : {}),
} as ClickableMenuItem;
},
injectionToken: applicationMenuItemInjectionToken,
});
});
},
injectionToken: extensionRegistratorInjectionToken,
});
export default applicationMenuItemRegistratorInjectable;

View File

@ -5,12 +5,8 @@
import { getInjectable } from "@ogre-tools/injectable";
import type { MenuItemConstructorOptions } from "electron";
import { computed } from "mobx";
import type { ApplicationMenuItemTypes } from "./menu-items/application-menu-item-injection-token";
import applicationMenuItemInjectionToken from "./menu-items/application-menu-item-injection-token";
import { filter, map, sortBy } from "lodash/fp";
import { pipeline } from "@ogre-tools/fp";
import type { Composite } from "./menu-items/get-composite/get-composite";
import getComposite from "./menu-items/get-composite/get-composite";
import applicationMenuItemInjectionToken, { isShown } from "./menu-items/application-menu-item-injection-token";
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
export interface MenuItemOpts extends MenuItemConstructorOptions {
submenu?: MenuItemConstructorOptions[];
@ -20,76 +16,33 @@ const applicationMenuItemsInjectable = getInjectable({
id: "application-menu-items",
instantiate: (di) => {
const allShown = pipeline(
di.injectMany(applicationMenuItemInjectionToken),
filter((x) => x.isShown !== false),
const computedInjectMany = di.inject(computedInjectManyInjectable);
return computed(() =>
computedInjectMany(applicationMenuItemInjectionToken)
.get()
.filter(isShown),
);
const roots = allShown.filter((x) => x.parentId === null);
// Prepare menu items order
const toMenuItemOpt = (
x: Composite<ApplicationMenuItemTypes>,
): MenuItemOpts => ({
// @ts-ignore
label: x.value.label,
id: x.id,
submenu: pipeline(
x.children,
sortBy(x => x.value.orderNumber),
map(toMenuItemOpt),
),
// @ts-ignore
type: x.value.type,
// @ts-ignore
role: x.value.role,
// @ts-ignore
click: x.value.click,
// @ts-ignore
accelerator: x.value.accelerator,
});
const menuItems = pipeline(
roots,
map((root) =>
getComposite({
source: allShown,
// @ts-ignore
rootId: root.id,
// @ts-ignore
getId: (x) => x.id,
// @ts-ignore
getParentId: (x) => x.parentId,
}),
),
map(toMenuItemOpt),
);
return computed((): MenuItemOpts[] => {
// Prepare menu items order
// // Modify menu from extensions-api
// for (const menuItem of electronMenuItems.get()) {
// const parentMenu = appMenu.get(menuItem.parentId);
//
// if (!parentMenu) {
// logger.error(
// `[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`,
// { menuItem },
// );
//
// continue;
// }
//
// // (parentMenu.submenu ??= []).push(menuItem);
// }
return menuItems;
});
// // Modify menu from extensions-api
// for (const menuItem of electronMenuItems.get()) {
// const parentMenu = appMenu.get(menuItem.parentId);
//
// if (!parentMenu) {
// logger.error(
// `[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`,
// { menuItem },
// );
//
// continue;
// }
//
// // (parentMenu.submenu ??= []).push(menuItem);
// }
},
});
export default applicationMenuItemsInjectable;

View File

@ -4,19 +4,19 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import { autorun } from "mobx";
import applicationMenuItemsInjectable from "./application-menu-items.injectable";
import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable";
import populateApplicationMenuInjectable from "./populate-application-menu.injectable";
import applicationMenuItemCompositeInjectable from "./application-menu-item-composite.injectable";
const applicationMenuReactivityInjectable = getInjectable({
id: "application-menu-reactivity",
instantiate: (di) => {
const applicationMenuItems = di.inject(applicationMenuItemsInjectable);
const applicationMenuItemComposite = di.inject(applicationMenuItemCompositeInjectable);
const populateApplicationMenu = di.inject(populateApplicationMenuInjectable);
return getStartableStoppable("application-menu-reactivity", () =>
autorun(() => populateApplicationMenu(applicationMenuItems.get()), {
autorun(() => populateApplicationMenu(applicationMenuItemComposite.get()), {
delay: 100,
}),
);

View File

@ -3,63 +3,104 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { BrowserWindow, MenuItem, KeyboardEvent } from "electron";
import type { BrowserWindow, KeyboardEvent, MenuItemConstructorOptions, MenuItem as ElectronMenuItem } from "electron";
import type { SetOptional } from "type-fest";
interface Shared {
parentId: string | null;
orderNumber: number;
export interface MayHaveKeyboardShortcut {
keyboardShortcut?: string;
}
export interface Showable {
isShown?: boolean;
}
export const isShown = (showable: Showable) => showable.isShown !== false;
export interface ApplicationMenuItem extends Shared {
label: string;
accelerator?: string;
id: string;
export interface Clickable {
// TODO: This leaky abstraction is exposed in Extension API, therefore cannot be updated
click?: (
menuItem: MenuItem,
browserWindow: BrowserWindow | undefined,
event: KeyboardEvent
) => void;
onClick: (menuItem: ElectronMenuItem, browserWindow: (BrowserWindow) | (undefined), event: KeyboardEvent) => void;
}
export interface Separator extends Shared {
type: "separator";
export interface Labeled {
label: string;
}
export interface OperationSystemAction extends Shared {
label?: string;
accelerator?: string;
export interface MaybeLabeled extends SetOptional<Labeled, "label"> {}
role:
| "services"
| "hide"
| "hideOthers"
| "unhide"
| "close"
| "undo"
| "redo"
| "cut"
| "copy"
| "paste"
| "delete"
| "selectAll"
| "toggleDevTools"
| "resetZoom"
| "zoomIn"
| "zoomOut"
| "togglefullscreen";
export interface CanBeChildOfParent {
parentId: string;
}
export interface Orderable {
orderNumber: number;
}
export interface Identifiable {
id: string;
}
type ApplicationMenuItemType<T extends string> =
// Note: "kind" is being used for Discriminated unions of TypeScript to achieve type narrowing.
// See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions
& Kind<T>
& Identifiable
& CanBeChildOfParent
& Showable
& Orderable;
interface Kind<T extends string> { kind: T }
export type TopLevelMenu =
& ApplicationMenuItemType<"top-level-menu">
& { parentId: "root" }
& Labeled
& MayHaveElectronRole;
interface MayHaveElectronRole {
role?: ElectronRoles;
}
type ElectronRoles = Exclude<MenuItemConstructorOptions["role"], undefined>;
export type SubMenu =
& ApplicationMenuItemType<"sub-menu">
& Labeled
& CanBeChildOfParent;
export type ClickableMenuItem =
& ApplicationMenuItemType<"clickable-menu-item">
& MenuItem
& Labeled
& Clickable;
export type OsActionMenuItem =
& ApplicationMenuItemType<"os-action-menu-item">
& MenuItem
& MaybeLabeled
& TriggersElectronAction;
type MenuItem =
& CanBeChildOfParent
& MayHaveKeyboardShortcut;
interface TriggersElectronAction {
actionName: ElectronRoles;
}
// Todo: SeparatorMenuItem
export type Separator =
& ApplicationMenuItemType<"separator">
& CanBeChildOfParent;
export type ApplicationMenuItemTypes =
| ApplicationMenuItem
| TopLevelMenu
| SubMenu
| OsActionMenuItem
| ClickableMenuItem
| Separator
| OperationSystemAction;
;
const applicationMenuItemInjectionToken =
getInjectionToken<ApplicationMenuItemTypes>({
id: "application-menu-item-injection-token",
});
const applicationMenuItemInjectionToken = getInjectionToken<ApplicationMenuItemTypes>({
id: "application-menu-item-injection-token",
});
export default applicationMenuItemInjectionToken;

View File

@ -9,8 +9,9 @@ const editMenuItemInjectable = getInjectable({
id: "edit-application-menu-item",
instantiate: () => ({
parentId: null,
kind: "top-level-menu" as const,
id: "edit",
parentId: "root" as const,
orderNumber: 30,
label: "Edit",
}),

View File

@ -3,25 +3,21 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import {
getApplicationMenuOperationSystemActionInjectable,
} from "../../get-application-menu-operation-system-action-injectable";
import {
getApplicationMenuSeparatorInjectable,
} from "../../get-application-menu-separator-injectable";
import { getApplicationMenuOperationSystemActionInjectable } from "../../get-application-menu-operation-system-action-injectable";
import { getApplicationMenuSeparatorInjectable } from "../../get-application-menu-separator-injectable";
export const actionForUndo = getApplicationMenuOperationSystemActionInjectable({
id: "undo",
parentId: "edit",
orderNumber: 10,
role: "undo",
actionName: "undo",
});
export const actionForRedo = getApplicationMenuOperationSystemActionInjectable({
id: "redo",
parentId: "edit",
orderNumber: 20,
role: "redo",
actionName: "redo",
});
export const separator1 = getApplicationMenuSeparatorInjectable({
@ -34,28 +30,28 @@ export const actionForCut = getApplicationMenuOperationSystemActionInjectable({
id: "cut",
parentId: "edit",
orderNumber: 40,
role: "cut",
actionName: "cut",
});
export const actionForCopy = getApplicationMenuOperationSystemActionInjectable({
id: "copy",
parentId: "edit",
orderNumber: 50,
role: "copy",
actionName: "copy",
});
export const actionForPaste = getApplicationMenuOperationSystemActionInjectable({
id: "paste",
parentId: "edit",
orderNumber: 60,
role: "paste",
actionName: "paste",
});
export const actionForDelete = getApplicationMenuOperationSystemActionInjectable({
id: "delete",
parentId: "edit",
orderNumber: 70,
role: "delete",
actionName: "delete",
});
export const separator2 = getApplicationMenuSeparatorInjectable({
@ -68,6 +64,6 @@ export const actionForSelectAll = getApplicationMenuOperationSystemActionInjecta
id: "selectAll",
parentId: "edit",
orderNumber: 90,
role: "selectAll",
actionName: "selectAll",
});

View File

@ -13,13 +13,14 @@ const addClusterMenuItemInjectable = getInjectable({
const navigateToAddCluster = di.inject(navigateToAddClusterInjectable);
return {
kind: "clickable-menu-item" as const,
parentId: "file",
id: "add-cluster",
orderNumber: 10,
label: "Add Cluster",
accelerator: "CmdOrCtrl+Shift+A",
keyboardShortcut: "CmdOrCtrl+Shift+A",
click: () => {
onClick: () => {
navigateToAddCluster();
},
};

View File

@ -13,11 +13,13 @@ const closeWindowMenuItemInjectable = getInjectable({
const isMac = di.inject(isMacInjectable);
return {
id: "close-window",
kind: "os-action-menu-item" as const,
parentId: "file",
orderNumber: 60,
role: "close" as const,
actionName: "close" as const,
label: "Close Window",
accelerator: "Shift+Cmd+W",
keyboardShortcut: "Shift+Cmd+W",
isShown: isMac,
};
},

View File

@ -9,8 +9,9 @@ const fileMenuItemInjectable = getInjectable({
id: "file-application-menu-item",
instantiate: () => ({
parentId: null,
kind: "top-level-menu" as const,
id: "file",
parentId: "root" as const,
orderNumber: 20,
label: "File",
}),

View File

@ -13,9 +13,3 @@ export const separator1 = getApplicationMenuSeparatorInjectable({
orderNumber: 20,
isShownOnlyOnMac: true,
});
export const separator2 = getApplicationMenuSeparatorInjectable({
id: "separator-2-for-file",
parentId: "file",
orderNumber: 50,
});

View File

@ -3,20 +3,20 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { OperationSystemAction } from "./application-menu-item-injection-token";
import type { OsActionMenuItem } from "./application-menu-item-injection-token";
import applicationMenuItemInjectionToken from "./application-menu-item-injection-token";
const getApplicationMenuOperationSystemActionInjectable = ({
id,
role,
...rest
}: { id: string } & OperationSystemAction) =>
}: Omit<OsActionMenuItem, "kind" >) =>
getInjectable({
id: `application-menu-operation-system-action/${id}`,
instantiate: () => ({
...rest,
role,
id,
kind: "os-action-menu-item" as const,
}),
injectionToken: applicationMenuItemInjectionToken,

View File

@ -11,7 +11,10 @@ const getApplicationMenuSeparatorInjectable = ({
id,
isShownOnlyOnMac = false,
...rest
}: { id: string; isShownOnlyOnMac?: boolean } & Omit<Separator, "type">) =>
}: { isShownOnlyOnMac?: boolean } & Omit<
Separator,
"kind" | "isShown"
>) =>
getInjectable({
id: `application-menu-separator/${id}`,
@ -21,8 +24,9 @@ const getApplicationMenuSeparatorInjectable = ({
return {
...rest,
id,
kind: "separator" as const,
isShown,
type: "separator" as const,
};
},

View File

@ -9,10 +9,12 @@ const helpMenuItemInjectable = getInjectable({
id: "help-application-menu-item",
instantiate: () => ({
parentId: null,
kind: "top-level-menu" as const,
id: "help",
parentId: "root" as const,
orderNumber: 50,
label: "Help",
role: "help" as const,
}),
injectionToken: applicationMenuItemInjectionToken,

View File

@ -13,12 +13,13 @@ const navigateToWelcomeMenuItem = getInjectable({
const navigateToWelcome = di.inject(navigateToWelcomeInjectable);
return {
kind: "clickable-menu-item" as const,
parentId: "help",
id: "navigate-to-welcome",
orderNumber: 10,
label: "Welcome",
click: () => {
onClick: () => {
navigateToWelcome();
},
};

View File

@ -16,13 +16,14 @@ const openDocumentationMenuItemInjectable = getInjectable({
const logger = di.inject(loggerInjectable);
return {
kind: "clickable-menu-item" as const,
parentId: "help",
id: "open-documentation",
orderNumber: 20,
label: "Documentation",
// TODO: Convert to async/await
click: () => {
onClick: () => {
openLinkInBrowser(docsUrl).catch((error) => {
logger.error("[MENU]: failed to open browser", { error });
});

View File

@ -16,13 +16,14 @@ const openSupportItemInjectable = getInjectable({
const logger = di.inject(loggerInjectable);
return {
kind: "clickable-menu-item" as const,
parentId: "help",
id: "open-support",
orderNumber: 30,
label: "Support",
// TODO: Convert to async/await
click: () => {
onClick: () => {
openLinkInBrowser(supportUrl).catch((error) => {
logger.error("[MENU]: failed to open browser", { error });
});

View File

@ -24,13 +24,14 @@ const checkForUpdatesMenuItemInjectable = getInjectable({
const isMac = di.inject(isMacInjectable);
return {
kind: "clickable-menu-item" as const,
id: "check-for-updates",
parentId: isMac ? "primary-for-mac" : "help",
orderNumber: isMac ? 20 : 50,
label: "Check for updates",
isShown: updatingIsEnabled,
click: () => {
onClick: () => {
// Todo: implement using async/await
processCheckingForUpdates("application-menu").then(() =>
showApplicationWindow(),

View File

@ -15,13 +15,14 @@ const navigateToExtensionsMenuItem = getInjectable({
const isMac = di.inject(isMacInjectable);
return {
kind: "clickable-menu-item" as const,
parentId: isMac ? "primary-for-mac" : "file",
id: "navigate-to-extensions",
orderNumber: isMac ? 50 : 40,
label: "Extensions",
accelerator: isMac ? "CmdOrCtrl+Shift+E" : "Ctrl+Shift+E",
keyboardShortcut: isMac ? "CmdOrCtrl+Shift+E" : "Ctrl+Shift+E",
click: () => {
onClick: () => {
navigateToExtensions();
},
};

View File

@ -15,13 +15,14 @@ const navigateToPreferencesMenuItemInjectable = getInjectable({
const isMac = di.inject(isMacInjectable);
return {
kind: "clickable-menu-item" as const,
parentId: isMac ? "primary-for-mac" : "file",
id: "navigate-to-preferences",
orderNumber: isMac ? 40 : 30,
label: "Preferences",
accelerator: isMac ? "CmdOrCtrl+," : "Ctrl+,",
keyboardShortcut: isMac ? "CmdOrCtrl+," : "Ctrl+,",
click: () => {
onClick: () => {
navigateToPreferences();
},
};

View File

@ -11,26 +11,26 @@ export const actionForServices = getApplicationMenuOperationSystemActionInjectab
id: "services",
parentId: "primary-for-mac",
orderNumber: 80,
role: "services",
actionName: "services",
});
export const actionForHide = getApplicationMenuOperationSystemActionInjectable({
id: "hide",
parentId: "primary-for-mac",
orderNumber: 100,
role: "hide",
actionName: "hide",
});
export const actionForHideOthers = getApplicationMenuOperationSystemActionInjectable({
id: "hide-others",
parentId: "primary-for-mac",
orderNumber: 110,
role: "hideOthers",
actionName: "hideOthers",
});
export const actionForUnhide = getApplicationMenuOperationSystemActionInjectable({
id: "unhide",
parentId: "primary-for-mac",
orderNumber: 120,
role: "unhide",
actionName: "unhide",
});

View File

@ -15,7 +15,8 @@ const primaryMenuItemInjectable = getInjectable({
const isMac = di.inject(isMacInjectable);
return {
parentId: null,
kind: "top-level-menu" as const,
parentId: "root" as const,
id: "primary-for-mac",
orderNumber: 10,
label: appName,

View File

@ -15,14 +15,15 @@ const quitApplicationMenuItemInjectable = getInjectable({
const isMac = di.inject(isMacInjectable);
return {
kind: "clickable-menu-item" as const,
id: "quit",
label: "Quit",
parentId: isMac ? "primary-for-mac" : "file",
orderNumber: isMac ? 140 : 70,
accelerator: isMac ? "Cmd+Q" : "Alt+F4",
keyboardShortcut: isMac ? "Cmd+Q" : "Alt+F4",
click: () => {
onClick: () => {
stopServicesAndExitApp();
},
};

View File

@ -17,12 +17,13 @@ const aboutMenuItemInjectable = getInjectable({
const isMac = di.inject(isMacInjectable);
return {
kind: "clickable-menu-item" as const,
id: "about",
parentId: isMac ? "primary-for-mac" : "help",
orderNumber: isMac ? 10 : 40,
label: `About ${productName}`,
click() {
onClick() {
showAbout();
},
};

View File

@ -10,13 +10,14 @@ const goBackMenuItemInjectable = getInjectable({
id: "go-back-menu-item",
instantiate: () => ({
kind: "clickable-menu-item" as const,
parentId: "view",
id: "go-back",
orderNumber: 40,
label: "Back",
accelerator: "CmdOrCtrl+[",
keyboardShortcut: "CmdOrCtrl+[",
click: () => {
onClick: () => {
webContents
.getAllWebContents()
.filter((wc) => wc.getType() === "window")

View File

@ -10,13 +10,14 @@ const goForwardMenuItemInjectable = getInjectable({
id: "go-forward-menu-item",
instantiate: () => ({
kind: "clickable-menu-item" as const,
parentId: "view",
id: "go-forward",
orderNumber: 50,
label: "Forward",
accelerator: "CmdOrCtrl+]",
keyboardShortcut: "CmdOrCtrl+]",
click: () => {
onClick: () => {
webContents
.getAllWebContents()
.filter((wc) => wc.getType() === "window")

View File

@ -13,13 +13,14 @@ const navigateToCatalogMenuItemInjectable = getInjectable({
const navigateToCatalog = di.inject(navigateToCatalogInjectable);
return {
kind: "clickable-menu-item" as const,
parentId: "view",
id: "navigate-to-catalog",
orderNumber: 10,
label: "Catalog",
accelerator: "Shift+CmdOrCtrl+C",
keyboardShortcut: "Shift+CmdOrCtrl+C",
click: () => {
onClick: () => {
navigateToCatalog();
},
};

View File

@ -13,13 +13,14 @@ const openCommandPaletteMenuItemInjectable = getInjectable({
const broadcastMessage = di.inject(broadcastMessageInjectable);
return {
kind: "clickable-menu-item" as const,
parentId: "view",
id: "open-command-palette",
orderNumber: 20,
label: "Command Palette...",
accelerator: "Shift+CmdOrCtrl+P",
keyboardShortcut: "Shift+CmdOrCtrl+P",
click(_m, _b, event) {
onClick(_m, _b, event) {
/**
* Don't broadcast unless it was triggered by menu iteration so that
* there aren't double events in renderer

View File

@ -10,33 +10,33 @@ export const actionForToggleDevTools = getApplicationMenuOperationSystemActionIn
id: "toggle-dev-tools",
parentId: "view",
orderNumber: 70,
role: "toggleDevTools",
actionName: "toggleDevTools",
});
export const actionForResetZoom = getApplicationMenuOperationSystemActionInjectable({
id: "reset-zoom",
parentId: "view",
orderNumber: 90,
role: "resetZoom",
actionName: "resetZoom",
});
export const actionForZoomIn = getApplicationMenuOperationSystemActionInjectable({
id: "zoom-in",
parentId: "view",
orderNumber: 100,
role: "zoomIn",
actionName: "zoomIn",
});
export const actionForZoomOut = getApplicationMenuOperationSystemActionInjectable({
id: "zoom-out",
parentId: "view",
orderNumber: 110,
role: "zoomOut",
actionName: "zoomOut",
});
export const actionForToggleFullScreen = getApplicationMenuOperationSystemActionInjectable({
id: "toggle-full-screen",
parentId: "view",
orderNumber: 130,
role: "togglefullscreen",
actionName: "togglefullscreen",
});

View File

@ -15,13 +15,14 @@ const reloadMenuItemInjectable = getInjectable({
);
return {
kind: "clickable-menu-item" as const,
parentId: "view",
id: "reload",
orderNumber: 60,
label: "Reload",
accelerator: "CmdOrCtrl+R",
keyboardShortcut: "CmdOrCtrl+R",
click: () => {
onClick: () => {
reloadApplicationWindow();
},
};

View File

@ -9,7 +9,8 @@ const viewMenuItemInjectable = getInjectable({
id: "view-application-menu-item",
instantiate: () => ({
parentId: null,
kind: "top-level-menu" as const,
parentId: "root" as const,
id: "view",
orderNumber: 40,
label: "View",

View File

@ -5,15 +5,112 @@
import { getInjectable } from "@ogre-tools/injectable";
import { Menu } from "electron";
import type { MenuItemOpts } from "./application-menu-items.injectable";
import type { Composite } from "./menu-items/get-composite/get-composite";
import type { ApplicationMenuItemTypes } from "./menu-items/application-menu-item-injection-token";
import { pipeline } from "@ogre-tools/fp";
import { map, sortBy } from "lodash/fp";
import type { MenuItemRoot } from "./application-menu-item-composite.injectable";
const populateApplicationMenuInjectable = getInjectable({
id: "populate-application-menu",
instantiate: () => (applicationMenuItems: MenuItemOpts[]) => {
Menu.setApplicationMenu(Menu.buildFromTemplate(applicationMenuItems));
instantiate: () => (composite: Composite<ApplicationMenuItemTypes | MenuItemRoot>) => {
const topLevelMenus = composite.children.filter(
(x): x is Composite<ApplicationMenuItemTypes> => x.value.kind !== "root",
);
const electronTemplate = topLevelMenus.map(toHierarchicalElectronMenuItem);
Menu.setApplicationMenu(Menu.buildFromTemplate(electronTemplate));
},
causesSideEffects: true,
});
export default populateApplicationMenuInjectable;
const toHierarchicalElectronMenuItem = (
composite: Composite<ApplicationMenuItemTypes>,
): MenuItemOpts => {
switch (composite.value.kind) {
case "top-level-menu": {
const {
id,
value: { label, role },
} = composite;
return {
...(id ? { id } : {}),
...(role ? { role } : {}),
label,
submenu: pipeline(
composite.children,
sortBy((childComposite) => childComposite.value.orderNumber),
map(toHierarchicalElectronMenuItem),
),
};
}
case "sub-menu": {
const {
id,
value: { label },
} = composite;
return {
...(id ? { id } : {}),
label,
submenu: pipeline(
composite.children,
sortBy((childComposite) => childComposite.value.orderNumber),
map(toHierarchicalElectronMenuItem),
),
};
}
case "clickable-menu-item": {
const {
id,
value: { label, onClick, keyboardShortcut },
} = composite;
return {
...(id ? { id } : {}),
...(label ? { label } : {}),
...(keyboardShortcut ? { accelerator: keyboardShortcut }: {}),
click: onClick,
};
}
case "os-action-menu-item": {
const {
value: { label, keyboardShortcut, actionName },
} = composite;
return {
...(label ? { label } : {}),
...(keyboardShortcut ? { accelerator: keyboardShortcut } : {}),
role: actionName,
};
}
case "separator": {
return {
type: "separator",
};
}
default: {
// Note: this will fail at transpilation time, if all ApplicationMenuItemTypes
// are not handled in switch/case.
const _exhaustiveCheck: never = composite.value;
// Note: this code is unreachable, it is here to make ts not complain about
// _exhaustiveCheck not being used.
// See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking
throw new Error(`Tried to create application menu, but foreign menu item was encountered: ${_exhaustiveCheck} ${composite.value}`);
}
}
};

View File

@ -142,7 +142,7 @@ describe("analytics for installing update", () => {
it("when checking for updates using application menu, sends event to analytics for being checked from application menu", async () => {
analyticsListenerMock.mockClear();
builder.applicationMenu.click("root.check-for-updates");
builder.applicationMenu.click("root.primary-for-mac.check-for-updates");
expect(analyticsListenerMock.mock.calls).toEqual([
[

View File

@ -48,7 +48,7 @@ describe("extensions - navigation using application menu", () => {
describe("when navigating to extensions using application menu", () => {
beforeEach(() => {
builder.applicationMenu.click("root.extensions");
builder.applicationMenu.click("root.primary-for-mac.navigate-to-extensions");
});
it("focuses the window", () => {

View File

@ -29,7 +29,7 @@ describe("preferences - navigation using application menu", () => {
describe("when navigating to preferences using application menu", () => {
beforeEach(() => {
applicationBuilder.applicationMenu.click("root.preferences");
applicationBuilder.applicationMenu.click("root.primary-for-mac.navigate-to-preferences");
});
it("renders", () => {

View File

@ -45,7 +45,7 @@ describe("quitting the app using application menu", () => {
describe("when application is quit", () => {
beforeEach(() => {
builder.applicationMenu.click("root.quit");
builder.applicationMenu.click("root.primary-for-mac.quit");
});
it("closes all windows", () => {

View File

@ -29,7 +29,7 @@ describe("welcome - navigation using application menu", () => {
describe("when navigated somewhere else", () => {
beforeEach(() => {
applicationBuilder.applicationMenu.click("root.preferences");
applicationBuilder.applicationMenu.click("root.primary-for-mac.navigate-to-preferences");
});
it("renders", () => {
@ -44,7 +44,7 @@ describe("welcome - navigation using application menu", () => {
describe("when navigated to welcome using application menu", () => {
beforeEach(() => {
applicationBuilder.applicationMenu.click("help.welcome");
applicationBuilder.applicationMenu.click("root.help.navigate-to-welcome");
});
it("renders", () => {

View File

@ -17,12 +17,9 @@ import type { DiContainer, Injectable } from "@ogre-tools/injectable";
import { getInjectable } from "@ogre-tools/injectable";
import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable";
import { pipeline } from "@ogre-tools/fp";
import { compact, filter, first, flatMap, get, join, last, map, matches } from "lodash/fp";
import { filter, first, get, join, last, map, matches } from "lodash/fp";
import preferenceNavigationItemsInjectable from "../+preferences/preferences-navigation/preference-navigation-items.injectable";
import navigateToPreferencesInjectable from "../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable";
import type { MenuItemOpts } from "../../../features/application-menu/main/application-menu-items.injectable";
import applicationMenuItemsInjectable from "../../../features/application-menu/main/application-menu-items.injectable";
import type { MenuItem, MenuItemConstructorOptions } from "electron";
import type { NavigateToHelmCharts } from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable";
import navigateToHelmChartsInjectable from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable";
import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable";
@ -68,6 +65,11 @@ import { getExtensionFakeForMain, getExtensionFakeForRenderer } from "./get-exte
import namespaceApiInjectable from "../../../common/k8s-api/endpoints/namespace.api.injectable";
import { Namespace } from "../../../common/k8s-api/endpoints";
import { overrideFsWithFakes } from "../../../test-utils/override-fs-with-fakes";
import applicationMenuItemCompositeInjectable from "../../../features/application-menu/main/application-menu-item-composite.injectable";
import { getCompositePaths } from "../../../features/application-menu/main/menu-items/get-composite/get-composite-paths/get-composite-paths";
import { normalizeComposite } from "../../../features/application-menu/main/menu-items/get-composite/normalize-composite/normalize-composite";
import type { ClickableMenuItem } from "../../../features/application-menu/main/menu-items/application-menu-item-injection-token";
import type { Composite } from "../../../features/application-menu/main/menu-items/get-composite/get-composite";
type Callback = (di: DiContainer) => void | Promise<void>;
@ -127,6 +129,7 @@ export interface ApplicationBuilder {
applicationMenu: {
click: (path: string) => void;
items: string[];
};
preferences: {
close: () => void;
@ -343,37 +346,35 @@ export const getApplicationBuilder = () => {
select: action((namespace) => selectedNamespaces.add(namespace)),
},
applicationMenu: {
get items() {
const composite = mainDi.inject(
applicationMenuItemCompositeInjectable,
).get();
return getCompositePaths(composite);
},
click: (path: string) => {
const applicationMenuItems = mainDi.inject(
applicationMenuItemsInjectable,
);
const composite = mainDi.inject(
applicationMenuItemCompositeInjectable,
).get();
const menuItems = pipeline(
applicationMenuItems.get(),
flatMap(toFlatChildren(null)),
filter((menuItem) => !!menuItem.click),
);
const clickableMenuItems = normalizeComposite(composite).filter(isClickableMenuItem);
const clickableMenuItemMap = new Map(clickableMenuItems);
// TODO: find out why this any!? The typing of above map is strict, so why map.get() isn't?
const clickableMenuItem = clickableMenuItemMap.get(path);
const menuItem = menuItems.find((menuItem) => menuItem.path === path);
if (!menuItem) {
const availableIds = menuItems.map(get("path")).join('", "');
if (clickableMenuItem === undefined) {
const clickableIds = [...clickableMenuItemMap.keys()];
throw new Error(
`Tried to click application menu item with ID "${path}" which does not exist. Available IDs are: "${availableIds}"`,
`Tried to click application menu item with unknown path "${path}". Available clickable paths are: \n"${clickableIds.join('",\n"')}"`,
);
}
menuItem.click?.(
{
menu: null as never,
commandId: 0,
userAccelerator: null,
...menuItem,
} as MenuItem,
undefined,
{},
);
// Todo: prevent leaking of Electron.
// @ts-ignore
clickableMenuItem.value.onClick();
},
},
@ -747,25 +748,6 @@ export const getApplicationBuilder = () => {
return builder;
};
export type ToFlatChildren = (opts: MenuItemConstructorOptions) => (MenuItemOpts & { path: string })[];
function toFlatChildren(parentId: string | null | undefined): ToFlatChildren {
return ({ submenu = [], ...menuItem }) => [
{
...menuItem,
path: pipeline([parentId, menuItem.id], compact, join(".")),
},
...(
Array.isArray(submenu)
? submenu.flatMap(toFlatChildren(menuItem.id))
: [{
...submenu,
path: pipeline([parentId, menuItem.id], compact, join(".")),
}]
),
];
}
export const rendererExtensionsStateInjectable = getInjectable({
id: "renderer-extensions-state",
instantiate: () => observable.map<string, LensRendererExtension>(),
@ -897,3 +879,9 @@ const disableExtensionFor =
});
};
const isClickableMenuItem = (
pathAndComposite: readonly [path: string, composite: any],
): pathAndComposite is [string, Composite<ClickableMenuItem>] =>
pathAndComposite[1].value.kind === "clickable-menu-item";