From ab4a8c8a1df9b82ad1a901e113a8db83d9ccdae9 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Tue, 28 Dec 2021 12:35:47 +0200 Subject: [PATCH 01/27] Convert a class component to functional component Co-authored-by: Mikko Aspiala Signed-off-by: Janne Savolainen --- src/renderer/components/+welcome/welcome.tsx | 147 ++++++++++++------- 1 file changed, 90 insertions(+), 57 deletions(-) diff --git a/src/renderer/components/+welcome/welcome.tsx b/src/renderer/components/+welcome/welcome.tsx index 669e768782..52d71bffb0 100644 --- a/src/renderer/components/+welcome/welcome.tsx +++ b/src/renderer/components/+welcome/welcome.tsx @@ -30,70 +30,103 @@ import { WelcomeBannerRegistry } from "../../../extensions/registries"; export const defaultWidth = 320; -@observer -export class Welcome extends React.Component { - render() { - const welcomeBanner = WelcomeBannerRegistry.getInstance().getItems(); +export const Welcome: React.FC = observer(() => { + const welcomeBanner = WelcomeBannerRegistry.getInstance().getItems(); - // if there is banner with specified width, use it to calculate the width of the container - const maxWidth = welcomeBanner.reduce((acc, curr) => { - const currWidth = curr.width ?? 0; + // if there is banner with specified width, use it to calculate the width of the container + const maxWidth = welcomeBanner.reduce((acc, curr) => { + const currWidth = curr.width ?? 0; - if (acc > currWidth) { - return acc; - } + if (acc > currWidth) { + return acc; + } - return currWidth; - }, defaultWidth); + return currWidth; + }, defaultWidth); - return ( -
-
- {welcomeBanner.length > 0 ? ( - 1} - autoPlay={true} - navButtonsAlwaysInvisible={true} - indicatorIconButtonProps={{ - style: { - color: "var(--iconActiveBackground)", - }, - }} - activeIndicatorIconButtonProps={{ - style: { - color: "var(--iconActiveColor)", - }, - }} - interval={8000} + return ( +
+
+ {welcomeBanner.length > 0 ? ( + 1} + autoPlay={true} + navButtonsAlwaysInvisible={true} + indicatorIconButtonProps={{ + style: { + color: "var(--iconActiveBackground)", + }, + }} + activeIndicatorIconButtonProps={{ + style: { + color: "var(--iconActiveColor)", + }, + }} + interval={8000} + > + {welcomeBanner.map((item, index) => ( + + ))} + + ) : ( + + )} + +
+
+

Welcome to {productName} 5!

+ +

+ To get you started we have auto-detected your clusters in your + kubeconfig file and added them to the catalog, your centralized + view for managing all your cloud-native resources. +
+
+ If you have any questions or feedback, please join our{" "} + + Lens Community slack channel + + . +

+ +
- ); - } -} +
+ ); +}); From 04ac2ff3ddc66f9cc5b1fd5478377086b93262e1 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Tue, 28 Dec 2021 12:52:39 +0200 Subject: [PATCH 02/27] Remove WelcomeMenuRegistry in favor of reactive solution Co-authored-by: Mikko Aspiala Signed-off-by: Janne Savolainen --- .../extension-loader/extension-loader.ts | 1 - src/extensions/lens-renderer-extension.ts | 3 +- src/extensions/registries/index.ts | 1 - .../renderer-extensions.injectable.ts | 33 +++++++++++ src/renderer/bootstrap.tsx | 3 - .../+welcome/__test__/welcome.test.tsx | 15 +++-- .../get-welcome-menu-items.ts | 46 ++++++++++++++++ .../welcome-menu-items.injectable.ts} | 25 ++++----- .../welcome-menu-registration.d.ts} | 4 -- src/renderer/components/+welcome/welcome.tsx | 55 ++++++++++++------- src/renderer/initializers/index.ts | 1 - src/renderer/initializers/registries.ts | 1 - 12 files changed, 139 insertions(+), 49 deletions(-) create mode 100644 src/extensions/renderer-extensions.injectable.ts create mode 100644 src/renderer/components/+welcome/welcome-menu-items/get-welcome-menu-items.ts rename src/renderer/{initializers/welcome-menu-registry.ts => components/+welcome/welcome-menu-items/welcome-menu-items.injectable.ts} (69%) rename src/{extensions/registries/welcome-menu-registry.ts => renderer/components/+welcome/welcome-menu-items/welcome-menu-registration.d.ts} (90%) diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index af5f05c100..fb0c42e1ac 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -263,7 +263,6 @@ export class ExtensionLoader { registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), registries.CommandRegistry.getInstance().add(extension.commands), - registries.WelcomeMenuRegistry.getInstance().add(extension.welcomeMenus), registries.WelcomeBannerRegistry.getInstance().add(extension.welcomeBanners), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), registries.TopBarRegistry.getInstance().add(extension.topBarItems), diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 67567969ee..388f6ba1f2 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -27,6 +27,7 @@ import type { Disposer } from "../common/utils"; import { catalogEntityRegistry, EntityFilter } from "../renderer/api/catalog-entity-registry"; import { catalogCategoryRegistry, CategoryFilter } from "../renderer/api/catalog-category-registry"; import type { KubernetesCluster } from "../common/catalog-entities"; +import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -40,7 +41,7 @@ export class LensRendererExtension extends LensExtension { kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = []; kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; commands: registries.CommandRegistration[] = []; - welcomeMenus: registries.WelcomeMenuRegistration[] = []; + welcomeMenus: WelcomeMenuRegistration[] = []; welcomeBanners: registries.WelcomeBannerRegistration[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; topBarItems: registries.TopBarRegistration[] = []; diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 4dd64a9c82..b9c249ce4c 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -30,7 +30,6 @@ export * from "./kube-object-menu-registry"; export * from "./kube-object-status-registry"; export * from "./command-registry"; export * from "./entity-setting-registry"; -export * from "./welcome-menu-registry"; export * from "./welcome-banner-registry"; export * from "./catalog-entity-detail-registry"; export * from "./workloads-overview-detail-registry"; diff --git a/src/extensions/renderer-extensions.injectable.ts b/src/extensions/renderer-extensions.injectable.ts new file mode 100644 index 0000000000..dddcf11e80 --- /dev/null +++ b/src/extensions/renderer-extensions.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import extensionsInjectable from "./extensions.injectable"; +import type { LensRendererExtension } from "./lens-renderer-extension"; + +const rendererExtensionsInjectable = getInjectable({ + lifecycle: lifecycleEnum.singleton, + + instantiate: (di) => + di.inject(extensionsInjectable) as IComputedValue, +}); + +export default rendererExtensionsInjectable; diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 1de360d18a..6e9e9fb471 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -114,9 +114,6 @@ export async function bootstrap(comp: () => Promise, di: Dependenc logger.info(`${logPrefix} initializing KubeObjectDetailRegistry`); initializers.initKubeObjectDetailRegistry(); - logger.info(`${logPrefix} initializing WelcomeMenuRegistry`); - initializers.initWelcomeMenuRegistry(); - logger.info(`${logPrefix} initializing WorkloadsOverviewDetailRegistry`); initializers.initWorkloadsOverviewDetailRegistry(); diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index 2024d69c7e..fa94149caa 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -20,11 +20,14 @@ */ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { Welcome } from "../welcome"; -import { TopBarRegistry, WelcomeMenuRegistry, WelcomeBannerRegistry } from "../../../../extensions/registries"; +import { TopBarRegistry, WelcomeBannerRegistry } from "../../../../extensions/registries"; import { defaultWidth } from "../welcome"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { DiRender } from "../../test-utils/renderFor"; +import { renderFor } from "../../test-utils/renderFor"; jest.mock( "electron", @@ -39,15 +42,19 @@ jest.mock( ); describe("", () => { + let render: DiRender; + beforeEach(() => { + const di = getDiForUnitTesting(); + + render = renderFor(di); + TopBarRegistry.createInstance(); - WelcomeMenuRegistry.createInstance(); WelcomeBannerRegistry.createInstance(); }); afterEach(() => { TopBarRegistry.resetInstance(); - WelcomeMenuRegistry.resetInstance(); WelcomeBannerRegistry.resetInstance(); }); diff --git a/src/renderer/components/+welcome/welcome-menu-items/get-welcome-menu-items.ts b/src/renderer/components/+welcome/welcome-menu-items/get-welcome-menu-items.ts new file mode 100644 index 0000000000..c766704cef --- /dev/null +++ b/src/renderer/components/+welcome/welcome-menu-items/get-welcome-menu-items.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { computed, IComputedValue } from "mobx"; +import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; +import { navigate } from "../../../navigation"; +import { catalogURL } from "../../../../common/routes"; + +interface Dependencies { + extensions: IComputedValue; +} + +export const getWelcomeMenuItems = ({ extensions }: Dependencies) => { + const browseClusters = { + title: "Browse Clusters in Catalog", + icon: "view_list", + click: () => + navigate( + catalogURL({ + params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" }, + }), + ), + }; + + return computed(() => [ + browseClusters, + ...extensions.get().flatMap((extension) => extension.welcomeMenus), + ]); +}; diff --git a/src/renderer/initializers/welcome-menu-registry.ts b/src/renderer/components/+welcome/welcome-menu-items/welcome-menu-items.injectable.ts similarity index 69% rename from src/renderer/initializers/welcome-menu-registry.ts rename to src/renderer/components/+welcome/welcome-menu-items/welcome-menu-items.injectable.ts index f658fe5715..384b0b07bc 100644 --- a/src/renderer/initializers/welcome-menu-registry.ts +++ b/src/renderer/components/+welcome/welcome-menu-items/welcome-menu-items.injectable.ts @@ -18,18 +18,17 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import { getWelcomeMenuItems } from "./get-welcome-menu-items"; -import { catalogURL } from "../../common/routes"; -import { WelcomeMenuRegistry } from "../../extensions/registries"; -import { navigate } from "../navigation"; +const welcomeMenuItemsInjectable = getInjectable({ + instantiate: (di) => + getWelcomeMenuItems({ + extensions: di.inject(rendererExtensionsInjectable), + }), -export function initWelcomeMenuRegistry() { - WelcomeMenuRegistry.getInstance() - .add([ - { - title: "Browse Clusters in Catalog", - icon: "view_list", - click: () => navigate(catalogURL({ params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" }} )), - }, - ]); -} + lifecycle: lifecycleEnum.singleton, +}); + +export default welcomeMenuItemsInjectable; diff --git a/src/extensions/registries/welcome-menu-registry.ts b/src/renderer/components/+welcome/welcome-menu-items/welcome-menu-registration.d.ts similarity index 90% rename from src/extensions/registries/welcome-menu-registry.ts rename to src/renderer/components/+welcome/welcome-menu-items/welcome-menu-registration.d.ts index 7092028459..8f4d9833b3 100644 --- a/src/extensions/registries/welcome-menu-registry.ts +++ b/src/renderer/components/+welcome/welcome-menu-items/welcome-menu-registration.d.ts @@ -19,12 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { BaseRegistry } from "./base-registry"; - export interface WelcomeMenuRegistration { title: string | (() => string); icon: string; click: () => void | Promise; } - -export class WelcomeMenuRegistry extends BaseRegistry {} diff --git a/src/renderer/components/+welcome/welcome.tsx b/src/renderer/components/+welcome/welcome.tsx index 52d71bffb0..f90ede88b8 100644 --- a/src/renderer/components/+welcome/welcome.tsx +++ b/src/renderer/components/+welcome/welcome.tsx @@ -22,15 +22,22 @@ import "./welcome.scss"; import React from "react"; import { observer } from "mobx-react"; +import type { IComputedValue } from "mobx"; import Carousel from "react-material-ui-carousel"; import { Icon } from "../icon"; import { productName, slackUrl } from "../../../common/vars"; -import { WelcomeMenuRegistry } from "../../../extensions/registries"; import { WelcomeBannerRegistry } from "../../../extensions/registries"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import welcomeMenuItemsInjectable from "./welcome-menu-items/welcome-menu-items.injectable"; +import type { WelcomeMenuRegistration } from "./welcome-menu-items/welcome-menu-registration"; export const defaultWidth = 320; -export const Welcome: React.FC = observer(() => { +interface Dependencies { + welcomeMenuItems: IComputedValue +} + +const NonInjectedWelcome: React.FC = ({ welcomeMenuItems }) => { const welcomeBanner = WelcomeBannerRegistry.getInstance().getItems(); // if there is banner with specified width, use it to calculate the width of the container @@ -106,27 +113,35 @@ export const Welcome: React.FC = observer(() => { style={{ width: `${defaultWidth}px` }} data-testid="welcome-menu-container" > - {WelcomeMenuRegistry.getInstance() - .getItems() - .map((item, index) => ( -
  • item.click()} - > - - - {typeof item.title === "string" - ? item.title - : item.title()} - - -
  • - ))} + {welcomeMenuItems.get().map((item, index) => ( +
  • item.click()} + > + + + {typeof item.title === "string" + ? item.title + : item.title()} + + +
  • + ))}
    ); -}); +}; + +export const Welcome = withInjectables( + observer(NonInjectedWelcome), + + { + getProps: (di) => ({ + welcomeMenuItems: di.inject(welcomeMenuItemsInjectable), + }), + }, +); diff --git a/src/renderer/initializers/index.ts b/src/renderer/initializers/index.ts index 55d1bbbb1b..b865368988 100644 --- a/src/renderer/initializers/index.ts +++ b/src/renderer/initializers/index.ts @@ -27,7 +27,6 @@ export * from "./ipc"; export * from "./kube-object-detail-registry"; export * from "./kube-object-menu-registry"; export * from "./registries"; -export * from "./welcome-menu-registry"; export * from "./workloads-overview-detail-registry"; export * from "./catalog-category-registry"; export * from "./status-bar-registry"; diff --git a/src/renderer/initializers/registries.ts b/src/renderer/initializers/registries.ts index 3063213bc8..4fddb35b3d 100644 --- a/src/renderer/initializers/registries.ts +++ b/src/renderer/initializers/registries.ts @@ -33,7 +33,6 @@ export function initRegistries() { registries.KubeObjectMenuRegistry.createInstance(); registries.KubeObjectStatusRegistry.createInstance(); registries.StatusBarRegistry.createInstance(); - registries.WelcomeMenuRegistry.createInstance(); registries.WelcomeBannerRegistry.createInstance(); registries.WorkloadsOverviewDetailRegistry.createInstance(); registries.TopBarRegistry.createInstance(); From c5613a2e6a38a3acf82452c46442c9a2a884ed45 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Tue, 28 Dec 2021 13:13:19 +0200 Subject: [PATCH 03/27] Remove WelcomeBannerRegistry in favor of reactive solution Co-authored-by: Mikko Aspiala Signed-off-by: Janne Savolainen --- .../extension-loader/extension-loader.ts | 1 - src/extensions/lens-renderer-extension.ts | 3 +- src/extensions/registries/index.ts | 1 - .../+welcome/__test__/welcome.test.tsx | 95 ++++++++++++------- .../welcome-banner-items.injectable.ts | 37 ++++++++ .../welcome-banner-registration.d.ts} | 4 - src/renderer/components/+welcome/welcome.tsx | 17 ++-- src/renderer/initializers/registries.ts | 1 - 8 files changed, 112 insertions(+), 47 deletions(-) create mode 100644 src/renderer/components/+welcome/welcome-banner-items/welcome-banner-items.injectable.ts rename src/{extensions/registries/welcome-banner-registry.ts => renderer/components/+welcome/welcome-banner-items/welcome-banner-registration.d.ts} (91%) diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index fb0c42e1ac..db3e924209 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -263,7 +263,6 @@ export class ExtensionLoader { registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), registries.CommandRegistry.getInstance().add(extension.commands), - registries.WelcomeBannerRegistry.getInstance().add(extension.welcomeBanners), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), registries.TopBarRegistry.getInstance().add(extension.topBarItems), ]; diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 388f6ba1f2..2ccb2cfe7a 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -28,6 +28,7 @@ import { catalogEntityRegistry, EntityFilter } from "../renderer/api/catalog-ent import { catalogCategoryRegistry, CategoryFilter } from "../renderer/api/catalog-category-registry"; import type { KubernetesCluster } from "../common/catalog-entities"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; +import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -42,7 +43,7 @@ export class LensRendererExtension extends LensExtension { kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; commands: registries.CommandRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = []; - welcomeBanners: registries.WelcomeBannerRegistration[] = []; + welcomeBanners: WelcomeBannerRegistration[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; topBarItems: registries.TopBarRegistration[] = []; diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index b9c249ce4c..2cd822f77a 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -30,7 +30,6 @@ export * from "./kube-object-menu-registry"; export * from "./kube-object-status-registry"; export * from "./command-registry"; export * from "./entity-setting-registry"; -export * from "./welcome-banner-registry"; export * from "./catalog-entity-detail-registry"; export * from "./workloads-overview-detail-registry"; export * from "./topbar-registry"; diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index fa94149caa..f7ae85bfea 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -22,50 +22,60 @@ import React from "react"; import { screen } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; -import { Welcome } from "../welcome"; -import { TopBarRegistry, WelcomeBannerRegistry } from "../../../../extensions/registries"; -import { defaultWidth } from "../welcome"; +import { defaultWidth, Welcome } from "../welcome"; +import { computed } from "mobx"; +import { TopBarRegistry } from "../../../../extensions/registries"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; +import type { WelcomeBannerRegistration } from "../welcome-banner-items/welcome-banner-registration"; -jest.mock( - "electron", - () => ({ - ipcRenderer: { - on: jest.fn(), - }, - app: { - getPath: () => "tmp", - }, - }), -); +jest.mock("electron", () => ({ + ipcRenderer: { + on: jest.fn(), + }, + app: { + getPath: () => "tmp", + }, +})); describe("", () => { let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; + let welcomeBannersStub: WelcomeBannerRegistration[]; beforeEach(() => { - const di = getDiForUnitTesting(); + di = getDiForUnitTesting(); render = renderFor(di); + welcomeBannersStub = []; + + di.override(rendererExtensionsInjectable, () => + computed(() => [ + new TestExtension({ + id: "some-id", + welcomeBanners: welcomeBannersStub, + }), + ]), + ); + TopBarRegistry.createInstance(); - WelcomeBannerRegistry.createInstance(); }); afterEach(() => { TopBarRegistry.resetInstance(); - WelcomeBannerRegistry.resetInstance(); }); it("renders registered in WelcomeBannerRegistry and hide logo", async () => { const testId = "testId"; - WelcomeBannerRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ - { - Banner: () =>
    , - }, - ]); + welcomeBannersStub.push({ + Banner: () =>
    , + }); const { container } = render(); @@ -74,16 +84,15 @@ describe("", () => { }); it("calculates max width from WelcomeBanner.width registered in WelcomeBannerRegistry", async () => { - WelcomeBannerRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ - { - width: 100, - Banner: () =>
    , - }, - { - width: 800, - Banner: () =>
    , - }, - ]); + welcomeBannersStub.push({ + width: 100, + Banner: () =>
    , + }); + + welcomeBannersStub.push({ + width: 800, + Banner: () =>
    , + }); render(); @@ -99,3 +108,25 @@ describe("", () => { }); }); }); + +class TestExtension extends LensRendererExtension { + constructor({ + id, + welcomeBanners, + }: { + id: string; + welcomeBanners: WelcomeBannerRegistration[]; + }) { + super({ + id, + absolutePath: "irrelevant", + isBundled: false, + isCompatible: false, + isEnabled: false, + manifest: { name: id, version: "some-version" }, + manifestPath: "irrelevant", + }); + + this.welcomeBanners = welcomeBanners; + } +} diff --git a/src/renderer/components/+welcome/welcome-banner-items/welcome-banner-items.injectable.ts b/src/renderer/components/+welcome/welcome-banner-items/welcome-banner-items.injectable.ts new file mode 100644 index 0000000000..288bb540ce --- /dev/null +++ b/src/renderer/components/+welcome/welcome-banner-items/welcome-banner-items.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import { computed } from "mobx"; + +const welcomeBannerItemsInjectable = getInjectable({ + instantiate: (di) => { + const extensions = di.inject(rendererExtensionsInjectable); + + return computed(() => [ + ...extensions.get().flatMap((extension) => extension.welcomeBanners), + ]); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default welcomeBannerItemsInjectable; diff --git a/src/extensions/registries/welcome-banner-registry.ts b/src/renderer/components/+welcome/welcome-banner-items/welcome-banner-registration.d.ts similarity index 91% rename from src/extensions/registries/welcome-banner-registry.ts rename to src/renderer/components/+welcome/welcome-banner-items/welcome-banner-registration.d.ts index 1102dc8a3f..ec7cf3cbdb 100644 --- a/src/extensions/registries/welcome-banner-registry.ts +++ b/src/renderer/components/+welcome/welcome-banner-items/welcome-banner-registration.d.ts @@ -19,8 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { BaseRegistry } from "./base-registry"; - /** * WelcomeBannerRegistration is for an extension to register * Provide a Banner component to be renderered in the welcome screen. @@ -35,5 +33,3 @@ export interface WelcomeBannerRegistration { */ width?: number } - -export class WelcomeBannerRegistry extends BaseRegistry { } diff --git a/src/renderer/components/+welcome/welcome.tsx b/src/renderer/components/+welcome/welcome.tsx index f90ede88b8..56aefbfc3c 100644 --- a/src/renderer/components/+welcome/welcome.tsx +++ b/src/renderer/components/+welcome/welcome.tsx @@ -26,22 +26,24 @@ import type { IComputedValue } from "mobx"; import Carousel from "react-material-ui-carousel"; import { Icon } from "../icon"; import { productName, slackUrl } from "../../../common/vars"; -import { WelcomeBannerRegistry } from "../../../extensions/registries"; import { withInjectables } from "@ogre-tools/injectable-react"; import welcomeMenuItemsInjectable from "./welcome-menu-items/welcome-menu-items.injectable"; import type { WelcomeMenuRegistration } from "./welcome-menu-items/welcome-menu-registration"; +import welcomeBannerItemsInjectable from "./welcome-banner-items/welcome-banner-items.injectable"; +import type { WelcomeBannerRegistration } from "./welcome-banner-items/welcome-banner-registration"; export const defaultWidth = 320; interface Dependencies { welcomeMenuItems: IComputedValue + welcomeBannerItems: IComputedValue } -const NonInjectedWelcome: React.FC = ({ welcomeMenuItems }) => { - const welcomeBanner = WelcomeBannerRegistry.getInstance().getItems(); +const NonInjectedWelcome: React.FC = ({ welcomeMenuItems, welcomeBannerItems }) => { + const welcomeBanners = welcomeBannerItems.get(); // if there is banner with specified width, use it to calculate the width of the container - const maxWidth = welcomeBanner.reduce((acc, curr) => { + const maxWidth = welcomeBanners.reduce((acc, curr) => { const currWidth = curr.width ?? 0; if (acc > currWidth) { @@ -57,10 +59,10 @@ const NonInjectedWelcome: React.FC = ({ welcomeMenuItems }) => { style={{ width: `${maxWidth}px` }} data-testid="welcome-banner-container" > - {welcomeBanner.length > 0 ? ( + {welcomeBanners.length > 0 ? ( 1} + indicators={welcomeBanners.length > 1} autoPlay={true} navButtonsAlwaysInvisible={true} indicatorIconButtonProps={{ @@ -75,7 +77,7 @@ const NonInjectedWelcome: React.FC = ({ welcomeMenuItems }) => { }} interval={8000} > - {welcomeBanner.map((item, index) => ( + {welcomeBanners.map((item, index) => ( ))} @@ -142,6 +144,7 @@ export const Welcome = withInjectables( { getProps: (di) => ({ welcomeMenuItems: di.inject(welcomeMenuItemsInjectable), + welcomeBannerItems: di.inject(welcomeBannerItemsInjectable), }), }, ); diff --git a/src/renderer/initializers/registries.ts b/src/renderer/initializers/registries.ts index 4fddb35b3d..3130fe6984 100644 --- a/src/renderer/initializers/registries.ts +++ b/src/renderer/initializers/registries.ts @@ -33,7 +33,6 @@ export function initRegistries() { registries.KubeObjectMenuRegistry.createInstance(); registries.KubeObjectStatusRegistry.createInstance(); registries.StatusBarRegistry.createInstance(); - registries.WelcomeBannerRegistry.createInstance(); registries.WorkloadsOverviewDetailRegistry.createInstance(); registries.TopBarRegistry.createInstance(); } From 5bdfea6e31a2575c9d3507355ac018f979e966b6 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Thu, 30 Dec 2021 13:58:28 +0300 Subject: [PATCH 04/27] Native switch component (#4610) * Switch component initial draft Signed-off-by: Alex Andreev * Add onClick event Signed-off-by: Alex Andreev * onClick fine-tunings Signed-off-by: Alex Andreev * Fine-tuning styles Signed-off-by: Alex Andreev * Adding tests Signed-off-by: Alex Andreev * Fix light theme thumb color Signed-off-by: Alex Andreev * Using native switch in places Signed-off-by: Alex Andreev * Removing material ui switcher Signed-off-by: Alex Andreev * Revert "Removing material ui switcher" This reverts commit 6b9e0a090c7ff239fd7fbe071a16ff04448ab8c0. * Mark Switcher and FormSwitch as deprecated Signed-off-by: Alex Andreev * Cleaning up Signed-off-by: Alex Andreev * Using theme-light mixin Signed-off-by: Alex Andreev * Fix fetching values from onChange callback Signed-off-by: Alex Andreev * Add custon onChange event with checked prop Signed-off-by: Alex Andreev * Check for onChange() availability Signed-off-by: Alex Andreev * Fix show minimap label Signed-off-by: Alex Andreev --- .../components/+preferences/application.tsx | 31 ++--- .../components/+preferences/editor.tsx | 17 +-- .../+preferences/kubectl-binaries.tsx | 18 +-- .../components/+preferences/proxy.tsx | 16 +-- .../switch/__tests__/switch.test.tsx | 67 ++++++++++ .../components/switch/form-switcher.tsx | 3 + src/renderer/components/switch/index.ts | 1 + .../components/switch/switch.module.scss | 121 ++++++++++++++++++ src/renderer/components/switch/switch.tsx | 38 ++++++ src/renderer/components/switch/switcher.tsx | 3 + 10 files changed, 262 insertions(+), 53 deletions(-) create mode 100644 src/renderer/components/switch/__tests__/switch.test.tsx create mode 100644 src/renderer/components/switch/switch.module.scss create mode 100644 src/renderer/components/switch/switch.tsx diff --git a/src/renderer/components/+preferences/application.tsx b/src/renderer/components/+preferences/application.tsx index 40859cd5e0..1e3c75b68f 100644 --- a/src/renderer/components/+preferences/application.tsx +++ b/src/renderer/components/+preferences/application.tsx @@ -27,7 +27,7 @@ import { ThemeStore } from "../../theme.store"; import { UserStore } from "../../../common/user-store"; import { Input } from "../input"; import { isWindows } from "../../../common/vars"; -import { FormSwitch, Switcher } from "../switch"; +import { Switch } from "../switch"; import moment from "moment-timezone"; import { CONSTANTS, defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../common/user-store/preferences-helpers"; import { action } from "mobx"; @@ -86,16 +86,12 @@ export const Application = observer(() => {
    - userStore.terminalCopyOnSelect = v.target.checked} - name="terminalCopyOnSelect" - /> - } - /> + userStore.terminalCopyOnSelect = !userStore.terminalCopyOnSelect} + > + Copy on select and paste on right-click +

    @@ -135,16 +131,9 @@ export const Application = observer(() => {
    - userStore.openAtLogin = v.target.checked} - name="startup" - /> - } - label="Automatically start Lens on login" - /> + userStore.openAtLogin = !userStore.openAtLogin}> + Automatically start Lens on login +

    diff --git a/src/renderer/components/+preferences/editor.tsx b/src/renderer/components/+preferences/editor.tsx index 6f346eb63d..523f9bd278 100644 --- a/src/renderer/components/+preferences/editor.tsx +++ b/src/renderer/components/+preferences/editor.tsx @@ -21,7 +21,7 @@ import { observer } from "mobx-react"; import React from "react"; import { UserStore } from "../../../common/user-store"; -import { FormSwitch, Switcher } from "../switch"; +import { Switch } from "../switch"; import { Select } from "../select"; import { SubTitle } from "../layout/sub-title"; import { SubHeader } from "../layout/sub-header"; @@ -45,15 +45,12 @@ export const Editor = observer(() => {
    - Show minimap} - control={ - editorConfiguration.minimap.enabled = checked} - /> - } - /> + editorConfiguration.minimap.enabled = !editorConfiguration.minimap.enabled} + > + Show minimap +
    Position diff --git a/src/renderer/components/+preferences/kubectl-binaries.tsx b/src/renderer/components/+preferences/kubectl-binaries.tsx index bffc69e3c0..13a0299ee3 100644 --- a/src/renderer/components/+preferences/kubectl-binaries.tsx +++ b/src/renderer/components/+preferences/kubectl-binaries.tsx @@ -26,7 +26,7 @@ import { getDefaultKubectlDownloadPath, UserStore } from "../../../common/user-s import { observer } from "mobx-react"; import { bundledKubectlPath } from "../../../main/kubectl"; import { SelectOption, Select } from "../select"; -import { FormSwitch, Switcher } from "../switch"; +import { Switch } from "../switch"; import { packageMirrors } from "../../../common/user-store/preferences-helpers"; export const KubectlBinaries = observer(() => { @@ -48,16 +48,12 @@ export const KubectlBinaries = observer(() => { <>
    - userStore.downloadKubectlBinaries = v.target.checked} - name="kubectl-download" - /> - } - label="Download kubectl binaries matching the Kubernetes cluster version" - /> + userStore.downloadKubectlBinaries = !userStore.downloadKubectlBinaries} + > + Download kubectl binaries matching the Kubernetes cluster version +
    diff --git a/src/renderer/components/+preferences/proxy.tsx b/src/renderer/components/+preferences/proxy.tsx index b85bd5e0e1..f25c1fbc9b 100644 --- a/src/renderer/components/+preferences/proxy.tsx +++ b/src/renderer/components/+preferences/proxy.tsx @@ -24,10 +24,11 @@ import React from "react"; import { UserStore } from "../../../common/user-store"; import { Input } from "../input"; import { SubTitle } from "../layout/sub-title"; -import { FormSwitch, Switcher } from "../switch"; +import { Switch } from "../switch"; export const LensProxy = observer(() => { const [proxy, setProxy] = React.useState(UserStore.getInstance().httpsProxy || ""); + const store = UserStore.getInstance(); return (
    @@ -50,16 +51,9 @@ export const LensProxy = observer(() => {
    - UserStore.getInstance().allowUntrustedCAs = v.target.checked} - name="startup" - /> - } - label="Allow untrusted Certificate Authorities" - /> + store.allowUntrustedCAs = !store.allowUntrustedCAs}> + Allow untrusted Certificate Authorities + This will make Lens to trust ANY certificate authority without any validations.{" "} Needed with some corporate proxies that do certificate re-writing.{" "} diff --git a/src/renderer/components/switch/__tests__/switch.test.tsx b/src/renderer/components/switch/__tests__/switch.test.tsx new file mode 100644 index 0000000000..d5187e1417 --- /dev/null +++ b/src/renderer/components/switch/__tests__/switch.test.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import { fireEvent, render } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import { Switch } from ".."; + +describe("", () => { + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("render label text", () => { + const { getByLabelText } = render(Test label); + + expect(getByLabelText("Test label")).toBeTruthy(); + }); + + it("passes disabled and checked attributes to input", () => { + const { container } = render(); + const checkbox = container.querySelector("input[type=checkbox]"); + + expect(checkbox).toHaveAttribute("disabled"); + expect(checkbox).toHaveAttribute("checked"); + }); + + it("onClick event fired", () => { + const onClick = jest.fn(); + const { getByTestId } = render(); + const switcher = getByTestId("switch"); + + fireEvent.click(switcher); + + expect(onClick).toHaveBeenCalled(); + }); + + it("onClick event not fired for disabled item", () => { + const onClick = jest.fn(); + const { getByTestId } = render(); + const switcher = getByTestId("switch"); + + fireEvent.click(switcher); + + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/switch/form-switcher.tsx b/src/renderer/components/switch/form-switcher.tsx index 14df95f676..6499fb0b0f 100644 --- a/src/renderer/components/switch/form-switcher.tsx +++ b/src/renderer/components/switch/form-switcher.tsx @@ -35,6 +35,9 @@ const useStyles = makeStyles({ }, }); +/** + * @deprecated Use instead from "../switch.tsx". + */ export function FormSwitch(props: FormControlLabelProps) { const classes = useStyles(); diff --git a/src/renderer/components/switch/index.ts b/src/renderer/components/switch/index.ts index 60d44f3324..6987ce5b38 100644 --- a/src/renderer/components/switch/index.ts +++ b/src/renderer/components/switch/index.ts @@ -21,3 +21,4 @@ export * from "./switcher"; export * from "./form-switcher"; +export * from "./switch"; diff --git a/src/renderer/components/switch/switch.module.scss b/src/renderer/components/switch/switch.module.scss new file mode 100644 index 0000000000..b890919c61 --- /dev/null +++ b/src/renderer/components/switch/switch.module.scss @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +.Switch { + --thumb-size: 2rem; + --thumb-color: hsl(0 0% 100%); + --thumb-color-highlight: hsl(0 0% 100% / 25%); + + --track-size: calc(var(--thumb-size) * 2); + --track-padding: 2px; + --track-color-inactive: hsl(80 0% 35%); + --track-color-active: hsl(110, 60%, 60%); + + --thumb-position: 0%; + --thumb-transition-duration: .25s; + + --hover-highlight-size: 0; + + display: flex; + align-items: center; + gap: 2ch; + justify-content: space-between; + cursor: pointer; + user-select: none; + color: var(--textColorAccent); + font-weight: 500; + + & > input { + padding: var(--track-padding); + background: var(--track-color-inactive); + inline-size: var(--track-size); + block-size: var(--thumb-size); + border-radius: var(--track-size); + + appearance: none; + pointer-events: none; + border: none; + outline-offset: 5px; + box-sizing: content-box; + + flex-shrink: 0; + display: grid; + align-items: center; + grid: [track] 1fr / [track] 1fr; + + transition: background-color .25s ease; + + &::before { + content: ""; + cursor: pointer; + pointer-events: auto; + grid-area: track; + inline-size: var(--thumb-size); + block-size: var(--thumb-size); + background: var(--thumb-color); + box-shadow: 0 0 0 var(--hover-highlight-size) var(--thumb-color-highlight); + border-radius: 50%; + transform: translateX(var(--thumb-position)); + transition: + transform var(--thumb-transition-duration) ease, + box-shadow .25s ease; + } + + &:not(:disabled):hover::before { + --hover-highlight-size: .5rem; + } + + &:checked { + background: var(--track-color-active); + --thumb-position: 100%; + } + + &:disabled { + --track-color-inactive: hsl(80 0% 30%); + --thumb-color: transparent; + + &::before { + cursor: not-allowed; + box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 40%); + } + } + + &:focus-visible { + box-shadow: 0 0 0 2px var(--blue); + } + } + + &.disabled { + cursor: not-allowed; + } +} + +@include theme-light { + .Switch { + --thumb-color-highlight: hsl(0 0% 0% / 25%); + + & > input { + &:disabled { + --track-color-inactive: hsl(80 0% 80%); + } + } + } +} diff --git a/src/renderer/components/switch/switch.tsx b/src/renderer/components/switch/switch.tsx new file mode 100644 index 0000000000..7bf5f2b3be --- /dev/null +++ b/src/renderer/components/switch/switch.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import styles from "./switch.module.scss"; + +import React, { ChangeEvent, HTMLProps } from "react"; +import { cssNames } from "../../utils"; + +interface Props extends Omit, "onChange"> { + onChange?: (checked: boolean, event: ChangeEvent) => void; +} + +export function Switch({ children, disabled, onChange, ...props }: Props) { + return ( + + ); +} diff --git a/src/renderer/components/switch/switcher.tsx b/src/renderer/components/switch/switcher.tsx index 749e5134c9..136175e394 100644 --- a/src/renderer/components/switch/switcher.tsx +++ b/src/renderer/components/switch/switcher.tsx @@ -31,6 +31,9 @@ interface Props extends SwitchProps { classes: Styles; } +/** + * @deprecated Use instead from "../switch.tsx". + */ export const Switcher = withStyles((theme: Theme) => createStyles({ root: { From 6ca58570b02ab9af804af84ce0763c2b7065c806 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Wed, 8 Dec 2021 16:15:11 +0200 Subject: [PATCH 05/27] Replace TopBarRegistry with reactive solution Co-authored-by: Mikko Aspiala Signed-off-by: Janne Savolainen --- .../extension-loader/extension-loader.ts | 1 - src/extensions/lens-renderer-extension.ts | 3 +- src/extensions/registries/index.ts | 1 - .../+welcome/__test__/welcome.test.tsx | 7 -- .../cluster-manager/cluster-manager.tsx | 2 +- .../top-bar-items/top-bar-items.injectable.ts | 37 +++++++++ .../layout/top-bar/top-bar-registration.d.ts} | 7 -- .../top-bar-win-linux.test.tsx} | 32 +++++--- .../top-bar.module.scss} | 0 .../top-bar.test.tsx} | 30 ++++--- .../{topbar.tsx => top-bar/top-bar.tsx} | 82 ++++++++++--------- src/renderer/initializers/registries.ts | 1 - 12 files changed, 124 insertions(+), 79 deletions(-) create mode 100644 src/renderer/components/layout/top-bar/top-bar-items/top-bar-items.injectable.ts rename src/{extensions/registries/topbar-registry.ts => renderer/components/layout/top-bar/top-bar-registration.d.ts} (88%) rename src/renderer/components/layout/{__tests__/topbar-win-linux.test.tsx => top-bar/top-bar-win-linux.test.tsx} (83%) rename src/renderer/components/layout/{topbar.module.scss => top-bar/top-bar.module.scss} (100%) rename src/renderer/components/layout/{__tests__/topbar.test.tsx => top-bar/top-bar.test.tsx} (84%) rename src/renderer/components/layout/{topbar.tsx => top-bar/top-bar.tsx} (76%) diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index db3e924209..00fb397079 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -264,7 +264,6 @@ export class ExtensionLoader { registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), registries.CommandRegistry.getInstance().add(extension.commands), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), - registries.TopBarRegistry.getInstance().add(extension.topBarItems), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 2ccb2cfe7a..9f7b947d82 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -26,6 +26,7 @@ import type { CatalogEntity } from "../common/catalog"; import type { Disposer } from "../common/utils"; import { catalogEntityRegistry, EntityFilter } from "../renderer/api/catalog-entity-registry"; import { catalogCategoryRegistry, CategoryFilter } from "../renderer/api/catalog-category-registry"; +import type { TopBarRegistration } from "../renderer/components/layout/top-bar/top-bar-registration"; import type { KubernetesCluster } from "../common/catalog-entities"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration"; @@ -45,7 +46,7 @@ export class LensRendererExtension extends LensExtension { welcomeMenus: WelcomeMenuRegistration[] = []; welcomeBanners: WelcomeBannerRegistration[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; - topBarItems: registries.TopBarRegistration[] = []; + topBarItems: TopBarRegistration[] = []; async navigate

    (pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 2cd822f77a..76f6c05d11 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -32,5 +32,4 @@ export * from "./command-registry"; export * from "./entity-setting-registry"; export * from "./catalog-entity-detail-registry"; export * from "./workloads-overview-detail-registry"; -export * from "./topbar-registry"; export * from "./protocol-handler"; diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index f7ae85bfea..74cf65b20d 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -24,7 +24,6 @@ import { screen } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { defaultWidth, Welcome } from "../welcome"; import { computed } from "mobx"; -import { TopBarRegistry } from "../../../../extensions/registries"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; @@ -62,12 +61,6 @@ describe("", () => { }), ]), ); - - TopBarRegistry.createInstance(); - }); - - afterEach(() => { - TopBarRegistry.resetInstance(); }); it("renders registered in WelcomeBannerRegistry and hide logo", async () => { diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 04af444c30..300c9b2eb4 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -39,8 +39,8 @@ import { DeleteClusterDialog } from "../delete-cluster-dialog"; import { reaction } from "mobx"; import { navigation } from "../../navigation"; import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; -import { TopBar } from "../layout/topbar"; import { catalogURL, getPreviousTabUrl } from "../../../common/routes"; +import { TopBar } from "../layout/top-bar/top-bar"; @observer export class ClusterManager extends React.Component { diff --git a/src/renderer/components/layout/top-bar/top-bar-items/top-bar-items.injectable.ts b/src/renderer/components/layout/top-bar/top-bar-items/top-bar-items.injectable.ts new file mode 100644 index 0000000000..9c5bd3d1e8 --- /dev/null +++ b/src/renderer/components/layout/top-bar/top-bar-items/top-bar-items.injectable.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import rendererExtensionsInjectable from "../../../../../extensions/renderer-extensions.injectable"; + +const topBarItemsInjectable = getInjectable({ + instantiate: (di) => { + const extensions = di.inject(rendererExtensionsInjectable); + + return computed(() => + extensions.get().flatMap((extension) => extension.topBarItems), + ); + }, + + lifecycle: lifecycleEnum.singleton, +}); + +export default topBarItemsInjectable; diff --git a/src/extensions/registries/topbar-registry.ts b/src/renderer/components/layout/top-bar/top-bar-registration.d.ts similarity index 88% rename from src/extensions/registries/topbar-registry.ts rename to src/renderer/components/layout/top-bar/top-bar-registration.d.ts index 37b55faaaa..5e470d4d26 100644 --- a/src/extensions/registries/topbar-registry.ts +++ b/src/renderer/components/layout/top-bar/top-bar-registration.d.ts @@ -18,10 +18,6 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import type React from "react"; -import { BaseRegistry } from "./base-registry"; - interface TopBarComponents { Item: React.ComponentType; } @@ -29,6 +25,3 @@ interface TopBarComponents { export interface TopBarRegistration { components: TopBarComponents; } - -export class TopBarRegistry extends BaseRegistry { -} diff --git a/src/renderer/components/layout/__tests__/topbar-win-linux.test.tsx b/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx similarity index 83% rename from src/renderer/components/layout/__tests__/topbar-win-linux.test.tsx rename to src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx index 1307e181cd..6d8014004e 100644 --- a/src/renderer/components/layout/__tests__/topbar-win-linux.test.tsx +++ b/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx @@ -20,23 +20,29 @@ */ import React from "react"; -import { render, fireEvent } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; -import { TopBar } from "../topbar"; -import { TopBarRegistry } from "../../../../extensions/registries"; +import { TopBar } from "./top-bar"; import { IpcMainWindowEvents } from "../../../../main/window-manager"; import { broadcastMessage } from "../../../../common/ipc"; import * as vars from "../../../../common/vars"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../../test-utils/renderFor"; -const mockConfig = vars as { isWindows: boolean, isLinux: boolean }; +const mockConfig = vars as { isWindows: boolean; isLinux: boolean }; jest.mock("../../../../common/ipc"); jest.mock("../../../../common/vars", () => { + const SemVer = require("semver").SemVer; + + const versionStub = new SemVer("1.0.0"); + return { __esModule: true, isWindows: null, isLinux: null, + appSemVer: versionStub, }; }); @@ -57,20 +63,20 @@ jest.mock("@electron/remote", () => { }; }); -describe(" in Windows and Linux", () => { - beforeEach(() => { - TopBarRegistry.createInstance(); - }); +describe(" in Windows and Linux", () => { + let render: DiRender; - afterEach(() => { - TopBarRegistry.resetInstance(); + beforeEach(() => { + const di = getDiForUnitTesting(); + + render = renderFor(di); }); it("shows window controls on Windows", () => { mockConfig.isWindows = true; mockConfig.isLinux = false; - const { getByTestId } = render(); + const { getByTestId } = render(); expect(getByTestId("window-menu")).toBeInTheDocument(); expect(getByTestId("window-minimize")).toBeInTheDocument(); @@ -82,7 +88,7 @@ describe(" in Windows and Linux", () => { mockConfig.isWindows = false; mockConfig.isLinux = true; - const { getByTestId } = render(); + const { getByTestId } = render(); expect(getByTestId("window-menu")).toBeInTheDocument(); expect(getByTestId("window-minimize")).toBeInTheDocument(); @@ -93,7 +99,7 @@ describe(" in Windows and Linux", () => { it("triggers ipc events on click", () => { mockConfig.isWindows = true; - const { getByTestId } = render(); + const { getByTestId } = render(); const menu = getByTestId("window-menu"); const minimize = getByTestId("window-minimize"); diff --git a/src/renderer/components/layout/topbar.module.scss b/src/renderer/components/layout/top-bar/top-bar.module.scss similarity index 100% rename from src/renderer/components/layout/topbar.module.scss rename to src/renderer/components/layout/top-bar/top-bar.module.scss diff --git a/src/renderer/components/layout/__tests__/topbar.test.tsx b/src/renderer/components/layout/top-bar/top-bar.test.tsx similarity index 84% rename from src/renderer/components/layout/__tests__/topbar.test.tsx rename to src/renderer/components/layout/top-bar/top-bar.test.tsx index 110ddef800..a2a93c0c4c 100644 --- a/src/renderer/components/layout/__tests__/topbar.test.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.test.tsx @@ -20,14 +20,23 @@ */ import React from "react"; -import { render, fireEvent } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; -import { TopBar } from "../topbar"; -import { TopBarRegistry } from "../../../../extensions/registries"; +import { TopBar } from "./top-bar"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { DiRender, renderFor } from "../../test-utils/renderFor"; +import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable"; +import { computed } from "mobx"; jest.mock("../../../../common/vars", () => { + const SemVer = require("semver").SemVer; + + const versionStub = new SemVer("1.0.0"); + return { isMac: true, + appSemVer: versionStub, }; }); @@ -76,12 +85,13 @@ jest.mock("@electron/remote", () => { }); describe("", () => { - beforeEach(() => { - TopBarRegistry.createInstance(); - }); + let di: ConfigurableDependencyInjectionContainer; + let render: DiRender; - afterEach(() => { - TopBarRegistry.resetInstance(); + beforeEach(() => { + di = getDiForUnitTesting(); + + render = renderFor(di); }); it("renders w/o errors", () => { @@ -129,13 +139,13 @@ describe("", () => { const testId = "testId"; const text = "an item"; - TopBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ + di.override(topBarItemsInjectable, () => computed(() => [ { components: { Item: () => {text}, }, }, - ]); + ])); const { getByTestId } = render(); diff --git a/src/renderer/components/layout/topbar.tsx b/src/renderer/components/layout/top-bar/top-bar.tsx similarity index 76% rename from src/renderer/components/layout/topbar.tsx rename to src/renderer/components/layout/top-bar/top-bar.tsx index 4705c6993c..d960cbe8f9 100644 --- a/src/renderer/components/layout/topbar.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.tsx @@ -19,22 +19,28 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import styles from "./topbar.module.scss"; +import styles from "./top-bar.module.scss"; import React, { useEffect, useMemo, useRef } from "react"; import { observer } from "mobx-react"; -import { TopBarRegistry } from "../../../extensions/registries"; -import { Icon } from "../icon"; +import type { IComputedValue } from "mobx"; +import { Icon } from "../../icon"; import { webContents, getCurrentWindow } from "@electron/remote"; import { observable } from "mobx"; -import { broadcastMessage, ipcRendererOn } from "../../../common/ipc"; -import { watchHistoryState } from "../../remote-helpers/history-updater"; -import { isActiveRoute, navigate } from "../../navigation"; -import { catalogRoute, catalogURL } from "../../../common/routes"; -import { IpcMainWindowEvents } from "../../../main/window-manager"; -import { isLinux, isWindows } from "../../../common/vars"; -import { cssNames } from "../../utils"; +import { broadcastMessage, ipcRendererOn } from "../../../../common/ipc"; +import { watchHistoryState } from "../../../remote-helpers/history-updater"; +import { isActiveRoute, navigate } from "../../../navigation"; +import { catalogRoute, catalogURL } from "../../../../common/routes"; +import { IpcMainWindowEvents } from "../../../../main/window-manager"; +import { isLinux, isWindows } from "../../../../common/vars"; +import { cssNames } from "../../../utils"; +import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { TopBarRegistration } from "./top-bar-registration"; -interface Props extends React.HTMLAttributes { +interface Props extends React.HTMLAttributes {} + +interface Dependencies { + items: IComputedValue; } const prevEnabled = observable.box(false); @@ -48,34 +54,10 @@ ipcRendererOn("history:can-go-forward", (event, state: boolean) => { nextEnabled.set(state); }); -export const TopBar = observer(({ children, ...rest }: Props) => { +const NonInjectedTopBar: React.FC = (({ items, children, ...rest }) => { const elem = useRef(); const window = useMemo(() => getCurrentWindow(), []); - const renderRegisteredItems = () => { - const items = TopBarRegistry.getInstance().getItems(); - - if (!Array.isArray(items)) { - return null; - } - - return ( -

    - {items.map((registration, index) => { - if (!registration?.components?.Item) { - return null; - } - - return ( -
    - -
    - ); - })} -
    - ); - }; - const openContextMenu = () => { broadcastMessage(IpcMainWindowEvents.OPEN_CONTEXT_MENU); }; @@ -156,7 +138,7 @@ export const TopBar = observer(({ children, ...rest }: Props) => { />
    - {renderRegisteredItems()} + {renderRegisteredItems(items.get())} {children} {(isWindows || isLinux) && (
    @@ -174,3 +156,29 @@ export const TopBar = observer(({ children, ...rest }: Props) => {
    ); }); + +const renderRegisteredItems = (items: TopBarRegistration[]) => ( +
    + {items.map((registration, index) => { + if (!registration?.components?.Item) { + return null; + } + + return ( +
    + +
    + ); + })} +
    +); + + + +export const TopBar = withInjectables(observer(NonInjectedTopBar), { + getProps: (di, props) => ({ + items: di.inject(topBarItemsInjectable), + + ...props, + }), +}); diff --git a/src/renderer/initializers/registries.ts b/src/renderer/initializers/registries.ts index 3130fe6984..a27c47153b 100644 --- a/src/renderer/initializers/registries.ts +++ b/src/renderer/initializers/registries.ts @@ -34,5 +34,4 @@ export function initRegistries() { registries.KubeObjectStatusRegistry.createInstance(); registries.StatusBarRegistry.createInstance(); registries.WorkloadsOverviewDetailRegistry.createInstance(); - registries.TopBarRegistry.createInstance(); } From 71388a0ea32795c067d52f84572eeced0daf4f64 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Fri, 31 Dec 2021 08:47:51 +0200 Subject: [PATCH 06/27] Switch typing of a component to props instead of React.FC Signed-off-by: Janne Savolainen --- src/renderer/components/layout/top-bar/top-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/layout/top-bar/top-bar.tsx b/src/renderer/components/layout/top-bar/top-bar.tsx index d960cbe8f9..a0a785666b 100644 --- a/src/renderer/components/layout/top-bar/top-bar.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.tsx @@ -54,7 +54,7 @@ ipcRendererOn("history:can-go-forward", (event, state: boolean) => { nextEnabled.set(state); }); -const NonInjectedTopBar: React.FC = (({ items, children, ...rest }) => { +const NonInjectedTopBar = (({ items, children, ...rest }: Props & Dependencies) => { const elem = useRef(); const window = useMemo(() => getCurrentWindow(), []); From 1db805b45160a48f7c7b9965e73e44cfb28a57dc Mon Sep 17 00:00:00 2001 From: Juho Heikka Date: Fri, 31 Dec 2021 15:50:01 +0200 Subject: [PATCH 07/27] Extension tray menu items (#4619) * Add extension ability to add tray menu items. Signed-off-by: Juho Heikka * Add tray menu extension documentation Signed-off-by: Juho Heikka * Add tests to tray menu items. Fix autorun infinite loop. Signed-off-by: Juho Heikka * Fix documentation Signed-off-by: Juho Heikka * Remove unnecessary slice() Signed-off-by: Juho Heikka * Define a type for tray menu registration Signed-off-by: Juho Heikka * Change TrayMenuRegistration not to leak or depend on Electron Menu API Signed-off-by: Juho Heikka * Update trayMenus Extension API documentation Signed-off-by: Juho Heikka * Refactor all tests to use runInAction Signed-off-by: Juho Heikka --- .../capabilities/common-capabilities.md | 27 +++- docs/extensions/guides/main-extension.md | 34 ++++- src/extensions/lens-main-extension.ts | 3 +- src/main/index.ts | 6 +- .../menu/electron-menu-items.injectable.ts | 3 +- src/main/tray/tray-menu-items.injectable.ts | 36 +++++ src/main/tray/tray-menu-items.test.ts | 136 ++++++++++++++++++ src/main/tray/tray-menu-registration.d.ts | 30 ++++ src/main/{ => tray}/tray.ts | 46 ++++-- 9 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 src/main/tray/tray-menu-items.injectable.ts create mode 100644 src/main/tray/tray-menu-items.test.ts create mode 100644 src/main/tray/tray-menu-registration.d.ts rename src/main/{ => tray}/tray.ts (70%) diff --git a/docs/extensions/capabilities/common-capabilities.md b/docs/extensions/capabilities/common-capabilities.md index 8ba03253c5..e7b585af66 100644 --- a/docs/extensions/capabilities/common-capabilities.md +++ b/docs/extensions/capabilities/common-capabilities.md @@ -37,9 +37,9 @@ export default class ExampleMainExtension extends Main.LensExtension { } ``` -### App Menus +### Menus -This extension can register custom app menus that will be displayed on OS native menus. +This extension can register custom app and tray menus that will be displayed on OS native menus. Example: @@ -56,6 +56,29 @@ export default class ExampleMainExtension extends Main.LensExtension { } } ] + + trayMenus = [ + { + label: "My links", + submenu: [ + { + label: "Lens", + click() { + Main.Navigation.navigate("https://k8slens.dev"); + } + }, + { + type: "separator" + }, + { + label: "Lens Github", + click() { + Main.Navigation.navigate("https://github.com/lensapp/lens"); + } + } + ] + } + ] } ``` diff --git a/docs/extensions/guides/main-extension.md b/docs/extensions/guides/main-extension.md index fec937db17..a0e20880bf 100644 --- a/docs/extensions/guides/main-extension.md +++ b/docs/extensions/guides/main-extension.md @@ -3,7 +3,7 @@ The Main Extension API is the interface to Lens's main process. Lens runs in both main and renderer processes. The Main Extension API allows you to access, configure, and customize Lens data, add custom application menu items and [protocol handlers](protocol-handlers.md), and run custom code in Lens's main process. -It also provides convenient methods for navigating to built-in Lens pages and extension pages, as well as adding and removing sources of catalog entities. +It also provides convenient methods for navigating to built-in Lens pages and extension pages, as well as adding and removing sources of catalog entities. ## `Main.LensExtension` Class @@ -45,7 +45,6 @@ For more details on accessing Lens state data, please see the [Stores](../stores ### `appMenus` The Main Extension API allows you to customize the UI application menu. -Note that this is the only UI feature that the Main Extension API allows you to customize. The following example demonstrates adding an item to the **Help** menu. ``` typescript @@ -65,7 +64,7 @@ export default class SamplePageMainExtension extends Main.LensExtension { ``` `appMenus` is an array of objects that satisfy the `MenuRegistration` interface. -`MenuRegistration` extends React's `MenuItemConstructorOptions` interface. +`MenuRegistration` extends Electron's `MenuItemConstructorOptions` interface. The properties of the appMenus array objects are defined as follows: * `parentId` is the name of the menu where your new menu item will be listed. @@ -96,6 +95,35 @@ export default class SamplePageMainExtension extends Main.LensExtension { When the menu item is clicked the `navigate()` method looks for and displays a global page with id `"myGlobalPage"`. This page would be defined in your extension's `Renderer.LensExtension` implementation (See [`Renderer.LensExtension`](renderer-extension.md)). +### `trayMenus` + +`trayMenus` is an array of `TrayMenuRegistration` objects. Most importantly you can define a `label` and a `click` handler. Other properties are `submenu`, `enabled`, `toolTip`, `id` and `type`. + +``` typescript +interface TrayMenuRegistration { + label?: string; + click?: (menuItem: TrayMenuRegistration) => void; + id?: string; + type?: "normal" | "separator" | "submenu" + toolTip?: string; + enabled?: boolean; + submenu?: TrayMenuRegistration[] +} +``` + +The following example demonstrates how tray menus can be added from extension: + +``` typescript +import { Main } from "@k8slens/extensions"; + +export default class SampleTrayMenuMainExtension extends Main.LensExtension { + trayMenus = [{ + label: "menu from the extension", + click: () => { console.log("tray menu clicked!") } + }] +} +``` + ### `addCatalogSource()` and `removeCatalogSource()` Methods The `Main.LensExtension` class also provides the `addCatalogSource()` and `removeCatalogSource()` methods, for managing custom catalog items (or entities). diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index c0c0a5674a..00f08ca5e5 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -25,9 +25,10 @@ import { catalogEntityRegistry } from "../main/catalog"; import type { CatalogEntity } from "../common/catalog"; import type { IObservableArray } from "mobx"; import type { MenuRegistration } from "../main/menu/menu-registration"; - +import type { TrayMenuRegistration } from "../main/tray/tray-menu-registration"; export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; + trayMenus: TrayMenuRegistration[] = []; async navigate(pageId?: string, params?: Record, frameId?: number) { return WindowManager.getInstance().navigateExtension(this.id, pageId, params, frameId); diff --git a/src/main/index.ts b/src/main/index.ts index 1f7b26e773..f40b92aa21 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -60,7 +60,7 @@ import { SentryInit } from "../common/sentry"; import { ensureDir } from "fs-extra"; import { Router } from "./router"; import { initMenu } from "./menu/menu"; -import { initTray } from "./tray"; +import { initTray } from "./tray/tray"; import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions"; import { AppPaths } from "../common/app-paths"; import { ShellSession } from "./shell-session/shell-session"; @@ -68,6 +68,7 @@ import { getDi } from "./getDi"; import electronMenuItemsInjectable from "./menu/electron-menu-items.injectable"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; import lensProtocolRouterMainInjectable from "./protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable"; +import trayMenuItemsInjectable from "./tray/tray-menu-items.injectable"; const di = getDi(); @@ -104,6 +105,7 @@ mangleProxyEnv(); logger.debug("[APP-MAIN] initializing ipc main handlers"); const menuItems = di.inject(electronMenuItemsInjectable); +const trayMenuItems = di.inject(trayMenuItemsInjectable); initializers.initIpcMainHandlers(menuItems); @@ -244,7 +246,7 @@ app.on("ready", async () => { onQuitCleanup.push( initMenu(windowManager, menuItems), - initTray(windowManager), + initTray(windowManager, trayMenuItems), () => ShellSession.cleanup(), ); diff --git a/src/main/menu/electron-menu-items.injectable.ts b/src/main/menu/electron-menu-items.injectable.ts index dd70812d89..d0e5a650cf 100644 --- a/src/main/menu/electron-menu-items.injectable.ts +++ b/src/main/menu/electron-menu-items.injectable.ts @@ -29,8 +29,7 @@ const electronMenuItemsInjectable = getInjectable({ const extensions = di.inject(mainExtensionsInjectable); return computed(() => - extensions.get().flatMap((extension) => extension.appMenus), - ); + extensions.get().flatMap((extension) => extension.appMenus)); }, }); diff --git a/src/main/tray/tray-menu-items.injectable.ts b/src/main/tray/tray-menu-items.injectable.ts new file mode 100644 index 0000000000..8a31cc6af5 --- /dev/null +++ b/src/main/tray/tray-menu-items.injectable.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import mainExtensionsInjectable from "../../extensions/main-extensions.injectable"; + +const trayItemsInjectable = getInjectable({ + lifecycle: lifecycleEnum.singleton, + + instantiate: (di) => { + const extensions = di.inject(mainExtensionsInjectable); + + return computed(() => + extensions.get().flatMap(extension => extension.trayMenus)); + }, +}); + +export default trayItemsInjectable; diff --git a/src/main/tray/tray-menu-items.test.ts b/src/main/tray/tray-menu-items.test.ts new file mode 100644 index 0000000000..b46bce3671 --- /dev/null +++ b/src/main/tray/tray-menu-items.test.ts @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { LensMainExtension } from "../../extensions/lens-main-extension"; +import trayItemsInjectable from "./tray-menu-items.injectable"; +import type { IComputedValue } from "mobx"; +import { computed, ObservableMap, runInAction } from "mobx"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import mainExtensionsInjectable from "../../extensions/main-extensions.injectable"; +import type { TrayMenuRegistration } from "./tray-menu-registration"; + +describe("tray-menu-items", () => { + let di: ConfigurableDependencyInjectionContainer; + let trayMenuItems: IComputedValue; + let extensionsStub: ObservableMap; + + beforeEach(() => { + di = getDiForUnitTesting(); + + extensionsStub = new ObservableMap(); + + di.override( + mainExtensionsInjectable, + () => computed(() => [...extensionsStub.values()]), + ); + + trayMenuItems = di.inject(trayItemsInjectable); + }); + + it("does not have any items yet", () => { + expect(trayMenuItems.get()).toHaveLength(0); + }); + + describe("when extension is enabled", () => { + beforeEach(() => { + const someExtension = new SomeTestExtension({ + id: "some-extension-id", + trayMenus: [{ label: "tray-menu-from-some-extension" }], + }); + + runInAction(() => { + extensionsStub.set("some-extension-id", someExtension); + }); + }); + + it("has tray menu items", () => { + expect(trayMenuItems.get()).toEqual([ + { + label: "tray-menu-from-some-extension", + }, + ]); + }); + + it("when disabling extension, does not have tray menu items", () => { + runInAction(() => { + extensionsStub.delete("some-extension-id"); + }); + + expect(trayMenuItems.get()).toHaveLength(0); + }); + + describe("when other extension is enabled", () => { + beforeEach(() => { + const someOtherExtension = new SomeTestExtension({ + id: "some-extension-id", + trayMenus: [{ label: "some-label-from-second-extension" }], + }); + + runInAction(() => { + extensionsStub.set("some-other-extension-id", someOtherExtension); + }); + }); + + it("has tray menu items for both extensions", () => { + expect(trayMenuItems.get()).toEqual([ + { + label: "tray-menu-from-some-extension", + }, + + { + label: "some-label-from-second-extension", + }, + ]); + }); + + it("when extension is disabled, still returns tray menu items for extensions that are enabled", () => { + runInAction(() => { + extensionsStub.delete("some-other-extension-id"); + }); + + expect(trayMenuItems.get()).toEqual([ + { + label: "tray-menu-from-some-extension", + }, + ]); + }); + }); + }); +}); + +class SomeTestExtension extends LensMainExtension { + constructor({ id, trayMenus }: { + id: string; + trayMenus: TrayMenuRegistration[]; + }) { + super({ + id, + absolutePath: "irrelevant", + isBundled: false, + isCompatible: false, + isEnabled: false, + manifest: { name: id, version: "some-version" }, + manifestPath: "irrelevant", + }); + + this.trayMenus = trayMenus; + } +} diff --git a/src/main/tray/tray-menu-registration.d.ts b/src/main/tray/tray-menu-registration.d.ts new file mode 100644 index 0000000000..0cf7fe611b --- /dev/null +++ b/src/main/tray/tray-menu-registration.d.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export interface TrayMenuRegistration { + label?: string; + click?: (menuItem: TrayMenuRegistration) => void; + id?: string; + type?: "normal" | "separator" | "submenu" + toolTip?: string; + enabled?: boolean; + submenu?: TrayMenuRegistration[] +} diff --git a/src/main/tray.ts b/src/main/tray/tray.ts similarity index 70% rename from src/main/tray.ts rename to src/main/tray/tray.ts index 850d19fe21..ac3e140e3b 100644 --- a/src/main/tray.ts +++ b/src/main/tray/tray.ts @@ -20,16 +20,18 @@ */ import path from "path"; -import packageInfo from "../../package.json"; +import packageInfo from "../../../package.json"; import { Menu, Tray } from "electron"; -import { autorun } from "mobx"; -import { showAbout } from "./menu/menu"; -import { checkForUpdates, isAutoUpdateEnabled } from "./app-updater"; -import type { WindowManager } from "./window-manager"; -import logger from "./logger"; -import { isDevelopment, isWindows, productName } from "../common/vars"; -import { exitApp } from "./exit-app"; -import { preferencesURL } from "../common/routes"; +import { autorun, IComputedValue } from "mobx"; +import { showAbout } from "../menu/menu"; +import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater"; +import type { WindowManager } from "../window-manager"; +import logger from "../logger"; +import { isDevelopment, isWindows, productName } from "../../common/vars"; +import { exitApp } from "../exit-app"; +import { preferencesURL } from "../../common/routes"; +import { toJS } from "../../common/utils"; +import type { TrayMenuRegistration } from "./tray-menu-registration"; const TRAY_LOG_PREFIX = "[TRAY]"; @@ -44,7 +46,10 @@ export function getTrayIcon(): string { ); } -export function initTray(windowManager: WindowManager) { +export function initTray( + windowManager: WindowManager, + trayMenuItems: IComputedValue, +) { const icon = getTrayIcon(); tray = new Tray(icon); @@ -62,7 +67,7 @@ export function initTray(windowManager: WindowManager) { const disposers = [ autorun(() => { try { - const menu = createTrayMenu(windowManager); + const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get())); tray.setContextMenu(menu); } catch (error) { @@ -78,8 +83,21 @@ export function initTray(windowManager: WindowManager) { }; } -function createTrayMenu(windowManager: WindowManager): Menu { - const template: Electron.MenuItemConstructorOptions[] = [ +function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { + return { + ...trayItem, + submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, + click: trayItem.click ? () => { + trayItem.click(trayItem); + } : undefined, + }; +} + +function createTrayMenu( + windowManager: WindowManager, + extensionTrayItems: TrayMenuRegistration[], +): Menu { + let template: Electron.MenuItemConstructorOptions[] = [ { label: `Open ${productName}`, click() { @@ -108,6 +126,8 @@ function createTrayMenu(windowManager: WindowManager): Menu { }); } + template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions)); + return Menu.buildFromTemplate(template.concat([ { label: `About ${productName}`, From 02056a6090944caabb8154ba891f84a9a653bd42 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Tue, 4 Jan 2022 15:43:45 +0200 Subject: [PATCH 08/27] Fix prometheus operator metrics work out of the box (#4617) Signed-off-by: Lauri Nevala --- src/main/prometheus/operator.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/prometheus/operator.ts b/src/main/prometheus/operator.ts index 83638e4ec1..140b432baf 100644 --- a/src/main/prometheus/operator.ts +++ b/src/main/prometheus/operator.ts @@ -38,7 +38,7 @@ export class PrometheusOperator extends PrometheusProvider { case "cluster": switch (queryName) { case "memoryUsage": - return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes{node=~"${opts.nodes}"}`); + return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}`); case "workloadMemoryUsage": return `sum(container_memory_working_set_bytes{container!="", instance=~"${opts.nodes}"}) by (component)`; case "memoryRequests": @@ -50,7 +50,7 @@ export class PrometheusOperator extends PrometheusProvider { case "memoryAllocatableCapacity": return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`; case "cpuUsage": - return `sum(rate(node_cpu_seconds_total{node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`; + return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`; case "cpuRequests": return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`; case "cpuLimits": @@ -66,31 +66,31 @@ export class PrometheusOperator extends PrometheusProvider { case "podAllocatableCapacity": return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`; case "fsSize": - return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`; + return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`; case "fsUsage": - return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`; + return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`; } break; case "nodes": switch (queryName) { case "memoryUsage": - return `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (node)`; + return `sum((node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`; case "workloadMemoryUsage": - return `sum(container_memory_working_set_bytes{container!=""}) by (node)`; + return `sum(container_memory_working_set_bytes{container!="POD", container!=""}) by (node)`; case "memoryCapacity": return `sum(kube_node_status_capacity{resource="memory"}) by (node)`; case "memoryAllocatableCapacity": return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`; case "cpuUsage": - return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(node)`; + return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}]) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`; case "cpuCapacity": return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; case "cpuAllocatableCapacity": return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; case "fsSize": - return `sum(node_filesystem_size_bytes{mountpoint="/"}) by (node)`; + return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info) by (node)`; case "fsUsage": - return `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (node)`; + return `sum((node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`; } break; case "pods": From 2a31c5a0d5c65f7b46a94ec6d5c016556a5dbbe9 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 4 Jan 2022 10:20:41 -0500 Subject: [PATCH 09/27] Removing JS adding #terminal-init so that unit tests don't have global state (#4627) --- src/renderer/components/app.scss | 9 +++++++++ src/renderer/components/dock/terminal.ts | 14 +++----------- src/renderer/template.html | 1 + 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index 0482dd0dd8..40ab38ffdb 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -91,6 +91,15 @@ html, body { overflow: hidden; } +#terminal-init { + position: absolute; + top: 0; + left: 0; + height: 0; + visibility: hidden; + overflow: hidden; +} + #app { height: 100%; min-height: 100%; diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index e88bfc067e..383accab20 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -34,17 +34,9 @@ import { clipboard } from "electron"; import logger from "../../../common/logger"; export class Terminal { - public static readonly spawningPool = (() => { - // terminal element must be in DOM before attaching via xterm.open(elem) - // https://xtermjs.org/docs/api/terminal/classes/terminal/#open - const pool = document.createElement("div"); - - pool.className = "terminal-init"; - pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden"; - document.body.appendChild(pool); - - return pool; - })(); + public static get spawningPool() { + return document.getElementById("terminal-init"); + } static async preloadFonts() { const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires diff --git a/src/renderer/template.html b/src/renderer/template.html index c7df1ee507..fd4d35c5a6 100755 --- a/src/renderer/template.html +++ b/src/renderer/template.html @@ -6,6 +6,7 @@
    +
    From 6a97a7b35b89bb605aaf515290091126c5c731e4 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 5 Jan 2022 07:53:28 -0500 Subject: [PATCH 10/27] Fix CRD.getPreferedVersion() to work based on apiVersion (#4553) * Fix CRD.getPreferedVersion() to work based on apiVersion Signed-off-by: Sebastian Malton * Add tests Signed-off-by: Sebastian Malton --- src/common/k8s-api/__tests__/crd.test.ts | 15 +++- src/common/k8s-api/endpoints/crd.api.ts | 97 +++++++++++++----------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/src/common/k8s-api/__tests__/crd.test.ts b/src/common/k8s-api/__tests__/crd.test.ts index 0eba45e2cd..4403a9c7a4 100644 --- a/src/common/k8s-api/__tests__/crd.test.ts +++ b/src/common/k8s-api/__tests__/crd.test.ts @@ -19,10 +19,10 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CustomResourceDefinition } from "../endpoints"; +import { CustomResourceDefinition, CustomResourceDefinitionSpec } from "../endpoints"; describe("Crds", () => { - describe("getVersion", () => { + describe("getVersion()", () => { it("should throw if none of the versions are served", () => { const crd = new CustomResourceDefinition({ apiVersion: "apiextensions.k8s.io/v1", @@ -136,7 +136,7 @@ describe("Crds", () => { expect(crd.getVersion()).toBe("123"); }); - it("should get the version name from the version field", () => { + it("should get the version name from the version field, ignoring versions on v1beta", () => { const crd = new CustomResourceDefinition({ apiVersion: "apiextensions.k8s.io/v1beta1", kind: "CustomResourceDefinition", @@ -147,7 +147,14 @@ describe("Crds", () => { }, spec: { version: "abc", - }, + versions: [ + { + name: "foobar", + served: true, + storage: true, + }, + ], + } as CustomResourceDefinitionSpec, }); expect(crd.getVersion()).toBe("abc"); diff --git a/src/common/k8s-api/endpoints/crd.api.ts b/src/common/k8s-api/endpoints/crd.api.ts index 4e2244d8a8..6b3f198f9d 100644 --- a/src/common/k8s-api/endpoints/crd.api.ts +++ b/src/common/k8s-api/endpoints/crd.api.ts @@ -48,34 +48,36 @@ export interface CRDVersion { additionalPrinterColumns?: AdditionalPrinterColumnsV1[]; } -export interface CustomResourceDefinition { - spec: { - group: string; - /** - * @deprecated for apiextensions.k8s.io/v1 but used previously - */ - version?: string; - names: { - plural: string; - singular: string; - kind: string; - listKind: string; - }; - scope: "Namespaced" | "Cluster" | string; - /** - * @deprecated for apiextensions.k8s.io/v1 but used previously - */ - validation?: object; - versions?: CRDVersion[]; - conversion: { - strategy?: string; - webhook?: any; - }; - /** - * @deprecated for apiextensions.k8s.io/v1 but used previously - */ - additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; +export interface CustomResourceDefinitionSpec { + group: string; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + version?: string; + names: { + plural: string; + singular: string; + kind: string; + listKind: string; }; + scope: "Namespaced" | "Cluster"; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + validation?: object; + versions?: CRDVersion[]; + conversion: { + strategy?: string; + webhook?: any; + }; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; +} + +export interface CustomResourceDefinition { + spec: CustomResourceDefinitionSpec; status: { conditions: { lastTransitionTime: string; @@ -150,27 +152,30 @@ export class CustomResourceDefinition extends KubeObject { } getPreferedVersion(): CRDVersion { - // Prefer the modern `versions` over the legacy `version` - if (this.spec.versions) { - for (const version of this.spec.versions) { - if (version.storage) { - return version; - } - } - } else if (this.spec.version) { - const { additionalPrinterColumns: apc } = this.spec; - const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc }) => ({ ...apc, jsonPath: JSONPath })); + const { apiVersion } = this; - return { - name: this.spec.version, - served: true, - storage: true, - schema: this.spec.validation, - additionalPrinterColumns, - }; + switch (apiVersion) { + case "apiextensions.k8s.io/v1": + for (const version of this.spec.versions) { + if (version.storage) { + return version; + } + } + break; + case "apiextensions.k8s.io/v1beta1": + const { additionalPrinterColumns: apc } = this.spec; + const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc }) => ({ ...apc, jsonPath: JSONPath })); + + return { + name: this.spec.version, + served: true, + storage: true, + schema: this.spec.validation, + additionalPrinterColumns, + }; } - throw new Error(`Failed to find a version for CustomResourceDefinition ${this.metadata.name}`); + throw new Error(`Unknown apiVersion=${apiVersion}: Failed to find a version for CustomResourceDefinition ${this.metadata.name}`); } getVersion() { @@ -197,7 +202,7 @@ export class CustomResourceDefinition extends KubeObject { const columns = this.getPreferedVersion().additionalPrinterColumns ?? []; return columns - .filter(column => column.name != "Age" && (ignorePriority || !column.priority)); + .filter(column => column.name.toLowerCase() != "age" && (ignorePriority || !column.priority)); } getValidation() { From 5591f8dca5afbc060df079abd48f16749f0a29b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Jan 2022 09:37:01 -0500 Subject: [PATCH 11/27] Bump dompurify from 2.3.3 to 2.3.4 (#4568) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b3fa600b11..9e777000ba 100644 --- a/package.json +++ b/package.json @@ -333,7 +333,7 @@ "concurrently": "^5.3.0", "css-loader": "^5.2.7", "deepdash": "^5.3.9", - "dompurify": "^2.3.3", + "dompurify": "^2.3.4", "electron": "^13.6.1", "electron-builder": "^22.14.5", "electron-notarize": "^0.3.0", diff --git a/yarn.lock b/yarn.lock index 76479fa361..4f9a153b6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4853,10 +4853,10 @@ domhandler@^2.3.0: dependencies: domelementtype "1" -dompurify@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.3.tgz#c1af3eb88be47324432964d8abc75cf4b98d634c" - integrity sha512-dqnqRkPMAjOZE0FogZ+ceJNM2dZ3V/yNOuFB7+39qpO93hHhfRpHw3heYQC7DPK9FqbQTfBKUJhiSfz4MvXYwg== +dompurify@^2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.4.tgz#1cf5cf0105ccb4debdf6db162525bd41e6ddacc6" + integrity sha512-6BVcgOAVFXjI0JTjEvZy901Rghm+7fDQOrNIcxB4+gdhj6Kwp6T9VBhBY/AbagKHJocRkDYGd6wvI+p4/10xtQ== domutils@1.5.1: version "1.5.1" From b227a86711dc13d6434b132af56fd75e27e3962f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Jan 2022 09:37:14 -0500 Subject: [PATCH 12/27] Bump got from 11.8.2 to 11.8.3 (#4517) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 9e777000ba..3db8905faf 100644 --- a/package.json +++ b/package.json @@ -214,7 +214,7 @@ "filehound": "^1.17.5", "fs-extra": "^9.0.1", "glob-to-regexp": "^0.4.1", - "got": "^11.8.2", + "got": "^11.8.3", "grapheme-splitter": "^1.0.4", "handlebars": "^4.7.7", "http-proxy": "^1.18.1", diff --git a/yarn.lock b/yarn.lock index 4f9a153b6c..5a6c2c416a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3415,17 +3415,17 @@ cacheable-request@^6.0.0: normalize-url "^4.1.0" responselike "^1.0.2" -cacheable-request@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.1.tgz#062031c2856232782ed694a257fa35da93942a58" - integrity sha512-lt0mJ6YAnsrBErpTMWeu5kl/tg9xMAWjavYTN6VQXM1A/teBITuNcccXsCxF0tDQQJf9DfAaX5O4e0zp0KlfZw== +cacheable-request@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27" + integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew== dependencies: clone-response "^1.0.2" get-stream "^5.1.0" http-cache-semantics "^4.0.0" keyv "^4.0.0" lowercase-keys "^2.0.0" - normalize-url "^4.1.0" + normalize-url "^6.0.1" responselike "^2.0.0" call-bind@^1.0.0: @@ -6593,17 +6593,17 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" -got@^11.8.0, got@^11.8.2: - version "11.8.2" - resolved "https://registry.yarnpkg.com/got/-/got-11.8.2.tgz#7abb3959ea28c31f3576f1576c1effce23f33599" - integrity sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ== +got@^11.8.0, got@^11.8.3: + version "11.8.3" + resolved "https://registry.yarnpkg.com/got/-/got-11.8.3.tgz#f496c8fdda5d729a90b4905d2b07dbd148170770" + integrity sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg== dependencies: "@sindresorhus/is" "^4.0.0" "@szmarczak/http-timer" "^4.0.5" "@types/cacheable-request" "^6.0.1" "@types/responselike" "^1.0.0" cacheable-lookup "^5.0.3" - cacheable-request "^7.0.1" + cacheable-request "^7.0.2" decompress-response "^6.0.0" http2-wrapper "^1.0.0-beta.5.2" lowercase-keys "^2.0.0" @@ -9885,6 +9885,11 @@ normalize-url@^4.1.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + npm-audit-report@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/npm-audit-report/-/npm-audit-report-1.3.3.tgz#8226deeb253b55176ed147592a3995442f2179ed" From 4f75acf2b40b3efdae3c5b49de443dfa08459db0 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 5 Jan 2022 11:35:44 -0500 Subject: [PATCH 13/27] Ban circular dependencies (#3547) --- .eslintrc.js | 6 +- extensions/.eslintrc.js | 36 ++++++ package.json | 5 +- src/common/__tests__/user-store.test.ts | 6 +- src/common/app-paths.ts | 3 +- .../catalog-entities/kubernetes-cluster.ts | 3 +- src/common/ipc/ipc.ts | 10 +- src/common/k8s-api/kube-api.ts | 3 + src/common/k8s-api/kube-object.store.ts | 3 + src/common/user-store/preferences-helpers.ts | 6 +- src/common/utils/tuple.ts | 2 +- src/common/vars.ts | 1 + .../extension-loader.injectable.ts | 3 +- src/extensions/registries/base-registry.ts | 2 +- src/main/app-updater.ts | 3 +- src/main/helm/helm-release-manager.ts | 2 +- src/main/resource-applier.ts | 2 +- .../__tests__/catalog-entity-registry.test.ts | 1 - src/renderer/api/catalog-entity-registry.ts | 9 +- src/renderer/api/catalog-entity.ts | 12 +- .../unpack-extension.injectable.tsx | 3 +- .../port-forward-menu.tsx | 3 +- .../__tests__/delete-cluster-dialog.test.tsx | 2 +- .../__test__/log-resource-selector.test.tsx | 2 +- .../kube-object-details.tsx | 3 +- .../dependencies/hide-details.injectable.ts | 2 +- src/renderer/components/menu/menu-actions.tsx | 2 +- .../monaco-editor/monaco-editor.tsx | 3 +- src/renderer/components/table/react-table.tsx | 3 +- .../port-forward/port-forward-dialog.tsx | 3 +- src/renderer/theme.store.ts | 4 +- src/renderer/utils/storageHelper.ts | 2 +- webpack.main.ts | 7 ++ webpack.renderer.ts | 7 ++ yarn.lock | 104 ++++++++++++++++-- 35 files changed, 209 insertions(+), 59 deletions(-) create mode 100644 extensions/.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js index 1415136093..dba0a666d9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -108,6 +108,8 @@ module.exports = { parser: "@typescript-eslint/parser", extends: [ "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", ], plugins: [ "header", @@ -193,6 +195,8 @@ module.exports = { extends: [ "plugin:@typescript-eslint/recommended", "plugin:react/recommended", + "plugin:import/recommended", + "plugin:import/typescript", ], parserOptions: { ecmaVersion: 2018, @@ -202,6 +206,7 @@ module.exports = { rules: { "no-irregular-whitespace": "error", "header/header": [2, "./license-header"], + "react/prop-types": "off", "no-invalid-this": "off", "@typescript-eslint/no-invalid-this": ["error"], "@typescript-eslint/explicit-function-return-type": "off", @@ -246,7 +251,6 @@ module.exports = { "objectsInObjects": false, "arraysInObjects": true, }], - "react/prop-types": "off", "semi": "off", "@typescript-eslint/semi": ["error"], "linebreak-style": ["error", "unix"], diff --git a/extensions/.eslintrc.js b/extensions/.eslintrc.js new file mode 100644 index 0000000000..d0daadb1aa --- /dev/null +++ b/extensions/.eslintrc.js @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +module.exports = { + "overrides": [ + { + files: [ + "**/*.ts", + "**/*.tsx", + ], + rules: { + "import/no-unresolved": ["error", { + ignore: ["@k8slens/extensions"], + }], + }, + }, + ], +}; diff --git a/package.json b/package.json index 3db8905faf..43497e5edd 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,7 @@ }, "moduleNameMapper": { "\\.(css|scss)$": "/__mocks__/styleMock.ts", - "\\.(svg)$": "/__mocks__/imageMock.ts", - "src/(.*)": "/__mocks__/windowMock.ts" + "\\.(svg)$": "/__mocks__/imageMock.ts" }, "modulePathIgnorePatterns": [ "/dist", @@ -200,6 +199,7 @@ "@ogre-tools/injectable-react": "2.0.0", "@sentry/electron": "^2.5.4", "@sentry/integrations": "^6.15.0", + "@types/circular-dependency-plugin": "5.0.4", "abort-controller": "^3.0.0", "auto-bind": "^4.0.0", "autobind-decorator": "^2.4.0", @@ -341,6 +341,7 @@ "esbuild-loader": "^2.16.0", "eslint": "^7.32.0", "eslint-plugin-header": "^3.1.1", + "eslint-plugin-import": "^2.25.3", "eslint-plugin-react": "^7.27.1", "eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-unused-imports": "^1.1.5", diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index ba7272f4c9..f53e3286c2 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -42,9 +42,9 @@ import { Console } from "console"; import { SemVer } from "semver"; import electron from "electron"; import { stdout, stderr } from "process"; -import { ThemeStore } from "../../renderer/theme.store"; import type { ClusterStoreModel } from "../cluster-store"; import { AppPaths } from "../app-paths"; +import { defaultTheme } from "../vars"; console = new Console(stdout, stderr); AppPaths.init(); @@ -75,7 +75,7 @@ describe("user store tests", () => { us.httpsProxy = "abcd://defg"; expect(us.httpsProxy).toBe("abcd://defg"); - expect(us.colorTheme).toBe(ThemeStore.defaultTheme); + expect(us.colorTheme).toBe(defaultTheme); us.colorTheme = "light"; expect(us.colorTheme).toBe("light"); @@ -86,7 +86,7 @@ describe("user store tests", () => { us.colorTheme = "some other theme"; us.resetTheme(); - expect(us.colorTheme).toBe(ThemeStore.defaultTheme); + expect(us.colorTheme).toBe(defaultTheme); }); it("correctly calculates if the last seen version is an old release", () => { diff --git a/src/common/app-paths.ts b/src/common/app-paths.ts index d23cc8c1a4..6802cd8f88 100644 --- a/src/common/app-paths.ts +++ b/src/common/app-paths.ts @@ -23,7 +23,8 @@ import { app, ipcMain, ipcRenderer } from "electron"; import { observable, when } from "mobx"; import path from "path"; import logger from "./logger"; -import { fromEntries, toJS } from "./utils"; +import { fromEntries } from "./utils/objects"; +import { toJS } from "./utils/toJS"; import { isWindows } from "./vars"; export type PathName = Parameters[0]; diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 1f4f933620..6901a9dad2 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -20,11 +20,10 @@ */ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; -import { CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; +import { CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus, CatalogCategory, CatalogCategorySpec } from "../catalog"; import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; import { ClusterStore } from "../cluster-store"; import { broadcastMessage, requestMain } from "../ipc"; -import { CatalogCategory, CatalogCategorySpec } from "../catalog"; import { app } from "electron"; import type { CatalogEntitySpec } from "../catalog/catalog-entity"; import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index b0eaa01e2a..0442d07630 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -30,7 +30,15 @@ import { ClusterFrameInfo, clusterFrameMap } from "../cluster-frames"; import type { Disposer } from "../utils"; import type remote from "@electron/remote"; -const electronRemote = ipcMain ? null : require("@electron/remote"); +const electronRemote = (() => { + if (ipcRenderer) { + try { + return require("@electron/remote"); + } catch {} + } + + return null; +})(); const subFramesChannel = "ipc:get-sub-frames"; diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 0f31758ab2..a5d9a91347 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -34,6 +34,9 @@ import type { IKubeWatchEvent } from "./kube-watch-api"; import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api"; import { noop } from "../utils"; import type { RequestInit } from "node-fetch"; + +// BUG: https://github.com/mysticatea/abort-controller/pull/22 +// eslint-disable-next-line import/no-named-as-default import AbortController from "abort-controller"; import { Agent, AgentOptions } from "https"; import type { Patch } from "rfc6902"; diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index 1832cdfa10..6858a96907 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -30,6 +30,9 @@ import { ensureObjectSelfLink, IKubeApiQueryParams, KubeApi } from "./kube-api"; import { parseKubeApi } from "./kube-api-parse"; import type { KubeJsonApiData } from "./kube-json-api"; import type { RequestInit } from "node-fetch"; + +// BUG: https://github.com/mysticatea/abort-controller/pull/22 +// eslint-disable-next-line import/no-named-as-default import AbortController from "abort-controller"; import type { Patch } from "rfc6902"; diff --git a/src/common/user-store/preferences-helpers.ts b/src/common/user-store/preferences-helpers.ts index 450c75ae89..1ded307f0d 100644 --- a/src/common/user-store/preferences-helpers.ts +++ b/src/common/user-store/preferences-helpers.ts @@ -22,11 +22,11 @@ import moment from "moment-timezone"; import path from "path"; import os from "os"; -import { ThemeStore } from "../../renderer/theme.store"; import { getAppVersion, ObservableToggleSet } from "../utils"; import type { editor } from "monaco-editor"; import merge from "lodash/merge"; import { SemVer } from "semver"; +import { defaultTheme } from "../vars"; export interface KubeconfigSyncEntry extends KubeconfigSyncValue { filePath: string; @@ -72,10 +72,10 @@ const shell: PreferenceDescription = { const colorTheme: PreferenceDescription = { fromStore(val) { - return val || ThemeStore.defaultTheme; + return val || defaultTheme; }, toStore(val) { - if (!val || val === ThemeStore.defaultTheme) { + if (!val || val === defaultTheme) { return undefined; } diff --git a/src/common/utils/tuple.ts b/src/common/utils/tuple.ts index 5a252cf89c..b7b751bb30 100644 --- a/src/common/utils/tuple.ts +++ b/src/common/utils/tuple.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { array } from "../utils"; +import * as array from "../utils/array"; /** * A strict N-tuple of type T diff --git a/src/common/vars.ts b/src/common/vars.ts index 138626a04b..b3d75f9bf3 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -41,6 +41,7 @@ export const isIntegrationTesting = process.argv.includes(integrationTestingArg) export const productName = packageInfo.productName; export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`; export const publicPath = "/build/" as string; +export const defaultTheme = "lens-dark" as string; // Webpack build paths export const contextDir = process.cwd(); diff --git a/src/extensions/extension-loader/extension-loader.injectable.ts b/src/extensions/extension-loader/extension-loader.injectable.ts index 3bc959bd20..ffb87e3b97 100644 --- a/src/extensions/extension-loader/extension-loader.injectable.ts +++ b/src/extensions/extension-loader/extension-loader.injectable.ts @@ -18,8 +18,7 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { lifecycleEnum } from "@ogre-tools/injectable"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { ExtensionLoader } from "./extension-loader"; const extensionLoaderInjectable = getInjectable({ diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index 9b596172c9..1fe7530779 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -22,7 +22,7 @@ // Base class for extensions-api registries import { action, observable, makeObservable } from "mobx"; import { Singleton } from "../../common/utils"; -import { LensExtension } from "../lens-extension"; +import type { LensExtension } from "../lens-extension"; export class BaseRegistry extends Singleton { private items = observable.map([], { deep: false }); diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 3c358acde6..fb4300b266 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -25,10 +25,9 @@ import { isLinux, isMac, isPublishConfigured, isTestEnv } from "../common/vars"; import { delay } from "../common/utils"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateChecking, AutoUpdateLogPrefix, AutoUpdateNoUpdateAvailable, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; import { once } from "lodash"; -import { ipcMain } from "electron"; +import { ipcMain, autoUpdater as electronAutoUpdater } from "electron"; import { nextUpdateChannel } from "./utils/update-channel"; import { UserStore } from "../common/user-store"; -import { autoUpdater as electronAutoUpdater } from "electron"; let installVersion: null | string = null; diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index 25c8b67737..d7ce9db5c1 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import * as tempy from "tempy"; +import tempy from "tempy"; import fse from "fs-extra"; import * as yaml from "js-yaml"; import { promiseExecFile } from "../../common/utils/promise-exec"; diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 7be1eec94d..61ca1dc1d0 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -25,7 +25,7 @@ import { exec } from "child_process"; import fs from "fs-extra"; import * as yaml from "js-yaml"; import path from "path"; -import * as tempy from "tempy"; +import tempy from "tempy"; import logger from "./logger"; import { appEventBus } from "../common/event-bus"; import { cloneJsonObject } from "../common/utils"; diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts index f4261df995..26a4aaeb03 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -20,7 +20,6 @@ */ import { CatalogEntityRegistry } from "../catalog-entity-registry"; -import "../../../common/catalog-entities"; import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry"; import { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "../catalog-entity"; import { KubernetesCluster, WebLink } from "../../../common/catalog-entities"; diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 4885c8f265..735e66a7a3 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -28,14 +28,21 @@ import { ClusterStore } from "../../common/cluster-store"; import { Disposer, iter } from "../utils"; import { once } from "lodash"; import logger from "../../common/logger"; -import { catalogEntityRunContext } from "./catalog-entity"; import { CatalogRunEvent } from "../../common/catalog/catalog-run-event"; import { ipcRenderer } from "electron"; import { CatalogIpcEvents } from "../../common/ipc/catalog"; +import { navigate } from "../navigation"; export type EntityFilter = (entity: CatalogEntity) => any; export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise; +export const catalogEntityRunContext = { + navigate: (url: string) => navigate(url), + setCommandPaletteContext: (entity?: CatalogEntity) => { + catalogEntityRegistry.activeEntity = entity; + }, +}; + export class CatalogEntityRegistry { @observable protected activeEntityId: string | undefined = undefined; protected _entities = observable.map([], { deep: true }); diff --git a/src/renderer/api/catalog-entity.ts b/src/renderer/api/catalog-entity.ts index 1d07291659..debb235c8f 100644 --- a/src/renderer/api/catalog-entity.ts +++ b/src/renderer/api/catalog-entity.ts @@ -19,10 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { navigate } from "../navigation"; -import type { CatalogEntity } from "../../common/catalog"; -import { catalogEntityRegistry } from "./catalog-entity-registry"; - +export { catalogEntityRunContext } from "./catalog-entity-registry"; export { CatalogCategory, CatalogEntity } from "../../common/catalog"; export type { CatalogEntityData, @@ -33,10 +30,3 @@ export type { CatalogEntityContextMenu, CatalogEntityContextMenuContext, } from "../../common/catalog"; - -export const catalogEntityRunContext = { - navigate: (url: string) => navigate(url), - setCommandPaletteContext: (entity?: CatalogEntity) => { - catalogEntityRegistry.activeEntity = entity; - }, -}; diff --git a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx index 05d970c129..054adf45ad 100644 --- a/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx +++ b/src/renderer/components/+extensions/attempt-install/unpack-extension/unpack-extension.injectable.tsx @@ -18,8 +18,7 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { getInjectable } from "@ogre-tools/injectable"; -import { lifecycleEnum } from "@ogre-tools/injectable"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { unpackExtension } from "./unpack-extension"; import extensionLoaderInjectable from "../../../../../extensions/extension-loader/extension-loader.injectable"; diff --git a/src/renderer/components/+network-port-forwards/port-forward-menu.tsx b/src/renderer/components/+network-port-forwards/port-forward-menu.tsx index 5b49c3f85c..864735f3b7 100644 --- a/src/renderer/components/+network-port-forwards/port-forward-menu.tsx +++ b/src/renderer/components/+network-port-forwards/port-forward-menu.tsx @@ -21,11 +21,10 @@ import React from "react"; import { boundMethod, cssNames } from "../../utils"; -import { openPortForward, PortForwardItem, removePortForward } from "../../port-forward"; +import { openPortForward, PortForwardItem, removePortForward, PortForwardDialog } from "../../port-forward"; import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Icon } from "../icon"; -import { PortForwardDialog } from "../../port-forward"; import { Notifications } from "../notifications"; interface Props extends MenuActionsProps { diff --git a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx index 8270e6e31a..36c9ddbc22 100644 --- a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx +++ b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx @@ -23,7 +23,7 @@ import { KubeConfig } from "@kubernetes/client-node"; import { fireEvent, render } from "@testing-library/react"; import mockFs from "mock-fs"; import React from "react"; -import selectEvent from "react-select-event"; +import * as selectEvent from "react-select-event"; import { Cluster } from "../../../../main/cluster"; import { DeleteClusterDialog } from "../delete-cluster-dialog"; diff --git a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx index b87adba0a7..9fe75a9756 100644 --- a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -22,7 +22,7 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; import { render } from "@testing-library/react"; -import selectEvent from "react-select-event"; +import * as selectEvent from "react-select-event"; import { Pod } from "../../../../common/k8s-api/endpoints"; import { LogResourceSelector } from "../log-resource-selector"; diff --git a/src/renderer/components/kube-object-details/kube-object-details.tsx b/src/renderer/components/kube-object-details/kube-object-details.tsx index be888c6505..e01c109fc5 100644 --- a/src/renderer/components/kube-object-details/kube-object-details.tsx +++ b/src/renderer/components/kube-object-details/kube-object-details.tsx @@ -31,7 +31,6 @@ import { apiManager } from "../../../common/k8s-api/api-manager"; import { crdStore } from "../+custom-resources/crd.store"; import { KubeObjectMenu } from "../kube-object-menu"; import { KubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; -import logger from "../../../main/logger"; import { CrdResourceDetails } from "../+custom-resources"; import { KubeObjectMeta } from "../kube-object-meta"; import { hideDetails, kubeDetailsUrlParam } from "../kube-detail-params"; @@ -62,7 +61,7 @@ export class KubeObjectDetails extends React.Component { .getStore(this.path) ?.getByPath(this.path); } catch (error) { - logger.error(`[KUBE-OBJECT-DETAILS]: failed to get store or object: ${error}`, { path: this.path }); + console.error(`[KUBE-OBJECT-DETAILS]: failed to get store or object: ${error}`, { path: this.path }); return undefined; } diff --git a/src/renderer/components/kube-object-menu/dependencies/hide-details.injectable.ts b/src/renderer/components/kube-object-menu/dependencies/hide-details.injectable.ts index 4937f97b26..fab0639190 100644 --- a/src/renderer/components/kube-object-menu/dependencies/hide-details.injectable.ts +++ b/src/renderer/components/kube-object-menu/dependencies/hide-details.injectable.ts @@ -21,7 +21,7 @@ import { hideDetails } from "../../kube-detail-params"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -export const hideDetailsInjectable = getInjectable({ +const hideDetailsInjectable = getInjectable({ instantiate: () => hideDetails, lifecycle: lifecycleEnum.singleton, }); diff --git a/src/renderer/components/menu/menu-actions.tsx b/src/renderer/components/menu/menu-actions.tsx index 7c2ed5b7be..957147cd1b 100644 --- a/src/renderer/components/menu/menu-actions.tsx +++ b/src/renderer/components/menu/menu-actions.tsx @@ -27,7 +27,7 @@ import { observer } from "mobx-react"; import { boundMethod, cssNames } from "../../utils"; import { ConfirmDialog } from "../confirm-dialog"; import { Icon, IconProps } from "../icon"; -import { Menu, MenuItem, MenuProps } from "../menu"; +import { Menu, MenuItem, MenuProps } from "./menu"; import uniqueId from "lodash/uniqueId"; import isString from "lodash/isString"; diff --git a/src/renderer/components/monaco-editor/monaco-editor.tsx b/src/renderer/components/monaco-editor/monaco-editor.tsx index 96301e8a7f..4a6a4320ba 100644 --- a/src/renderer/components/monaco-editor/monaco-editor.tsx +++ b/src/renderer/components/monaco-editor/monaco-editor.tsx @@ -24,7 +24,8 @@ import React from "react"; import { observer } from "mobx-react"; import { action, computed, makeObservable, observable, reaction } from "mobx"; import { editor, Uri } from "monaco-editor"; -import { MonacoTheme, MonacoValidator, monacoValidators } from "./index"; +import type { MonacoTheme } from "./monaco-themes"; +import { MonacoValidator, monacoValidators } from "./monaco-validators"; import { debounce, merge } from "lodash"; import { cssNames, disposer } from "../../utils"; import { UserStore } from "../../../common/user-store"; diff --git a/src/renderer/components/table/react-table.tsx b/src/renderer/components/table/react-table.tsx index de001c2a9c..e5cedff78f 100644 --- a/src/renderer/components/table/react-table.tsx +++ b/src/renderer/components/table/react-table.tsx @@ -20,8 +20,7 @@ */ import styles from "./react-table.module.scss"; -import React from "react"; -import { useCallback, useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { useFlexLayout, useSortBy, useTable, UseTableOptions } from "react-table"; import { Icon } from "../icon"; import { cssNames } from "../../utils"; diff --git a/src/renderer/port-forward/port-forward-dialog.tsx b/src/renderer/port-forward/port-forward-dialog.tsx index c306b0aaf3..b50dbae596 100644 --- a/src/renderer/port-forward/port-forward-dialog.tsx +++ b/src/renderer/port-forward/port-forward-dialog.tsx @@ -31,7 +31,8 @@ import { Notifications } from "../components/notifications"; import { cssNames } from "../utils"; import { addPortForward, getPortForwards, modifyPortForward } from "./port-forward.store"; import type { ForwardedPort } from "./port-forward-item"; -import { aboutPortForwarding, openPortForward } from "."; +import { openPortForward } from "./port-forward-utils"; +import { aboutPortForwarding } from "./port-forward-notify"; import { Checkbox } from "../components/checkbox"; interface Props extends Partial { diff --git a/src/renderer/theme.store.ts b/src/renderer/theme.store.ts index 144ecf7d47..15aa055f92 100644 --- a/src/renderer/theme.store.ts +++ b/src/renderer/theme.store.ts @@ -27,6 +27,7 @@ import lensDarkThemeJson from "./themes/lens-dark.json"; import lensLightThemeJson from "./themes/lens-light.json"; import type { SelectOption } from "./components/select"; import type { MonacoEditorProps } from "./components/monaco-editor"; +import { defaultTheme } from "../common/vars"; export type ThemeId = string; @@ -40,7 +41,6 @@ export interface Theme { } export class ThemeStore extends Singleton { - static readonly defaultTheme = "lens-dark"; protected styles: HTMLStyleElement; // bundled themes from `themes/${themeId}.json` @@ -54,7 +54,7 @@ export class ThemeStore extends Singleton { } @computed get activeTheme(): Theme { - return this.themes.get(this.activeThemeId) ?? this.themes.get(ThemeStore.defaultTheme); + return this.themes.get(this.activeThemeId) ?? this.themes.get(defaultTheme); } @computed get themeOptions(): SelectOption[] { diff --git a/src/renderer/utils/storageHelper.ts b/src/renderer/utils/storageHelper.ts index 805296b8d9..b5d5e6abde 100755 --- a/src/renderer/utils/storageHelper.ts +++ b/src/renderer/utils/storageHelper.ts @@ -21,7 +21,7 @@ // Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.) import { action, comparer, makeObservable, observable, toJS, when } from "mobx"; -import produce, { Draft, isDraft } from "immer"; +import { produce, Draft, isDraft } from "immer"; import { isEqual, isPlainObject } from "lodash"; import logger from "../../main/logger"; import { getHostedClusterId } from "../../common/utils"; diff --git a/webpack.main.ts b/webpack.main.ts index f18dba4c0b..a3967dd071 100755 --- a/webpack.main.ts +++ b/webpack.main.ts @@ -27,6 +27,7 @@ import nodeExternals from "webpack-node-externals"; import ProgressBarPlugin from "progress-bar-webpack-plugin"; import * as vars from "./src/common/vars"; import getTSLoader from "./src/common/getTSLoader"; +import CircularDependencyPlugin from "circular-dependency-plugin"; const configs: { (): webpack.Configuration }[] = []; @@ -64,6 +65,12 @@ configs.push((): webpack.Configuration => { plugins: [ new ProgressBarPlugin(), new ForkTsCheckerPlugin(), + + new CircularDependencyPlugin({ + cwd: __dirname, + exclude: /node_modules/, + failOnError: true, + }), ].filter(Boolean), }; }); diff --git a/webpack.renderer.ts b/webpack.renderer.ts index 4e07d42ffb..8be6eb35dc 100755 --- a/webpack.renderer.ts +++ b/webpack.renderer.ts @@ -30,6 +30,7 @@ import ProgressBarPlugin from "progress-bar-webpack-plugin"; import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"; import MonacoWebpackPlugin from "monaco-editor-webpack-plugin"; import getTSLoader from "./src/common/getTSLoader"; +import CircularDependencyPlugin from "circular-dependency-plugin"; export default [ webpackLensRenderer, @@ -173,6 +174,12 @@ export function webpackLensRenderer({ showVars = true } = {}): webpack.Configura inject: true, }), + new CircularDependencyPlugin({ + cwd: __dirname, + exclude: /node_modules/, + failOnError: true, + }), + new MiniCssExtractPlugin({ filename: "[name].css", }), diff --git a/yarn.lock b/yarn.lock index 5a6c2c416a..70fb22d9fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1358,6 +1358,13 @@ dependencies: moment "^2.10.2" +"@types/circular-dependency-plugin@5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/circular-dependency-plugin/-/circular-dependency-plugin-5.0.4.tgz#c5ccbd1d2bbb39b60e9859b39c6b826f60567ef2" + integrity sha512-J4XkMJfkGv3o3q2Ca821cufIBNBFms45fz+xD9tEESR0YqL5BlwETOwm2desSCdki2zdcPRhG9ZQCm/WITCEPQ== + dependencies: + "@types/webpack" "^4" + "@types/clean-css@*": version "4.2.1" resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.1.tgz#cb0134241ec5e6ede1b5344bc829668fd9871a8d" @@ -2711,6 +2718,15 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= +array.prototype.flat@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz#07e0975d84bbc7c48cd1879d609e682598d33e13" + integrity sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + array.prototype.flatmap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.2.5.tgz#908dc82d8a406930fdf38598d51e7411d18d4446" @@ -4431,21 +4447,28 @@ debug@3.1.0, debug@~3.1.0: dependencies: ms "2.0.0" -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: +debug@4, debug@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== dependencies: ms "2.1.2" -debug@4.3.1: +debug@4.3.1, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: ms "2.1.2" -debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.6: +debug@^3.0.0, debug@^3.1.1, debug@^3.2.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + +debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== @@ -5436,11 +5459,47 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" +eslint-import-resolver-node@^0.3.6: + version "0.3.6" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd" + integrity sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw== + dependencies: + debug "^3.2.7" + resolve "^1.20.0" + +eslint-module-utils@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.1.tgz#b435001c9f8dd4ab7f6d0efcae4b9696d4c24b7c" + integrity sha512-fjoetBXQZq2tSTWZ9yWVl2KuFrTZZH3V+9iD1V1RfpDgxzJR+mPd/KZmMiA8gbPqdBzpNiEHOuT7IYEWxrH0zQ== + dependencies: + debug "^3.2.7" + find-up "^2.1.0" + pkg-dir "^2.0.0" + eslint-plugin-header@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz#6ce512432d57675265fac47292b50d1eff11acd6" integrity sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg== +eslint-plugin-import@^2.25.3: + version "2.25.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.25.3.tgz#a554b5f66e08fb4f6dc99221866e57cfff824766" + integrity sha512-RzAVbby+72IB3iOEL8clzPLzL3wpDrlwjsTBAQXgyp5SeTqqY+0bFubwuo+y/HLhNZcXV4XqTBO4LGsfyHIDXg== + dependencies: + array-includes "^3.1.4" + array.prototype.flat "^1.2.5" + debug "^2.6.9" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.6" + eslint-module-utils "^2.7.1" + has "^1.0.3" + is-core-module "^2.8.0" + is-glob "^4.0.3" + minimatch "^3.0.4" + object.values "^1.1.5" + resolve "^1.20.0" + tsconfig-paths "^3.11.0" + eslint-plugin-react-hooks@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.3.0.tgz#318dbf312e06fab1c835a4abef00121751ac1172" @@ -6039,7 +6098,7 @@ find-root@^1.1.0: resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== -find-up@^2.0.0: +find-up@^2.0.0, find-up@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= @@ -7439,6 +7498,13 @@ is-core-module@^2.2.0: dependencies: has "^1.0.3" +is-core-module@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.0.tgz#0321336c3d0925e497fd97f5d95cb114a5ccd548" + integrity sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -10828,6 +10894,13 @@ pirates@^4.0.1: dependencies: node-modules-regexp "^1.0.0" +pkg-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" + integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= + dependencies: + find-up "^2.1.0" + pkg-dir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" @@ -13532,9 +13605,9 @@ truncate-utf8-bytes@^1.0.0: utf8-byte-length "^1.0.1" ts-essentials@^7.0.2: - version "7.0.3" - resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" - integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ== + version "7.0.2" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.2.tgz#e21142df8034dbd444cb9573ed204d0b85fc64fb" + integrity sha512-qWPVC1xZGdefbsgFP7tPo+bsgSA2ZIXL1XeEe5M2WoMZxIOr/HbsHxP/Iv75IFhiMHMDGL7cOOwi5SXcgx9mHw== ts-jest@26.5.6: version "26.5.6" @@ -13581,6 +13654,16 @@ ts-node@^10.4.0: make-error "^1.1.1" yn "3.1.1" +tsconfig-paths@^3.11.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.12.0.tgz#19769aca6ee8f6a1a341e38c8fa45dd9fb18899b" + integrity sha512-e5adrnOYT6zqVnWqZu7i/BQ3BnhzvGbjEjejFXO20lKIKpwTaupkCPgEfv4GZK1IBciJUEhYs3J3p75FdaTFVg== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.1" + minimist "^1.2.0" + strip-bom "^3.0.0" + tsconfig-paths@^3.9.0: version "3.9.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" @@ -13679,7 +13762,12 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-fest@^1.0.2, type-fest@^1.4.0: +type-fest@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.0.2.tgz#3f9c39982859f385c77c38b7e5f1432b8a3661c6" + integrity sha512-a720oz3Kjbp3ll0zkeN9qjRhO7I34MKMhPGQiQJAmaZQZQ1lo+NWThK322f7sXV+kTg9B1Ybt16KgBXWgteT8w== + +type-fest@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== From abe90ad92afc1c477c1f7439c3f4fdf65b4cdd8b Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 25 Nov 2021 09:05:07 -0500 Subject: [PATCH 14/27] Add CRDs to Command Palette and fix bugs - Deprecate the "scope" on command registrations as it is buggy and generally unfixable - Switch to mounting CommandContainer in each new frame so that there is no need to broadcast anything other than opening palette - Add CRD entries to the command registry within each cluster - Add navigate to CommandActionContext to simplify handling of root frame scoped URLs - Switch to using DI for some of the deps Signed-off-by: Sebastian Malton --- .../custom-resources.injectable.ts | 32 +++ src/common/utils/iter.ts | 15 ++ .../__tests__/extension-discovery.test.ts | 108 ++++----- .../extension-loader/extension-loader.ts | 2 - src/extensions/lens-renderer-extension.ts | 3 +- src/extensions/registries/index.ts | 1 - src/main/__test__/kubeconfig-manager.test.ts | 6 +- src/main/getDi.ts | 4 + src/main/menu/menu.ts | 10 +- src/renderer/api/catalog-entity-registry.ts | 11 + .../api}/helpers/general-active-sync.ts | 7 +- src/renderer/bootstrap.tsx | 5 +- .../components/+custom-resources/crd.store.ts | 17 +- .../activate-entity-command.tsx | 3 +- .../cluster-manager/cluster-manager.tsx | 2 +- .../command-palette/command-container.tsx | 66 ++++-- .../command-palette/command-dialog.tsx | 173 +++++++------- .../registered-commands/commands.d.ts} | 63 +++-- .../registered-commands/internal-commands.tsx | 215 ++++++++++++++++++ .../registered-commands.injectable.ts | 70 ++++++ src/renderer/components/input/input.tsx | 4 + .../kube-object-menu.test.tsx | 2 +- src/renderer/{components => }/getDi.tsx | 8 +- .../initializers/command-registry.tsx | 207 ----------------- src/renderer/initializers/index.ts | 1 - src/renderer/initializers/registries.ts | 1 - src/renderer/navigation/helpers.ts | 2 +- 27 files changed, 607 insertions(+), 431 deletions(-) create mode 100644 src/common/k8s-api/endpoints/custom-resources/custom-resources.injectable.ts rename src/{main/catalog-sources => renderer/api}/helpers/general-active-sync.ts (82%) rename src/{extensions/registries/command-registry.ts => renderer/components/command-palette/registered-commands/commands.d.ts} (54%) create mode 100644 src/renderer/components/command-palette/registered-commands/internal-commands.tsx create mode 100644 src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts rename src/renderer/{components => }/getDi.tsx (83%) delete mode 100644 src/renderer/initializers/command-registry.tsx diff --git a/src/common/k8s-api/endpoints/custom-resources/custom-resources.injectable.ts b/src/common/k8s-api/endpoints/custom-resources/custom-resources.injectable.ts new file mode 100644 index 0000000000..4a83b7de70 --- /dev/null +++ b/src/common/k8s-api/endpoints/custom-resources/custom-resources.injectable.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { crdStore } from "../../../../renderer/components/+custom-resources/crd.store"; + +const customResourceDefinitionsInjectable = getInjectable({ + instantiate: () => computed(() => [...crdStore.items]), + + lifecycle: lifecycleEnum.singleton, +}); + +export default customResourceDefinitionsInjectable; diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index 6271a05969..8ba606a881 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -201,3 +201,18 @@ export function every(src: Iterable, fn: (val: T) => any): boolean { return true; } + +/** + * Produce a new iterator that drains the first and then the second + * @param first The first iterable to iterate through + * @param second The second iterable to iterate through + */ +export function* chain(first: Iterable, second: Iterable): IterableIterator { + for (const t of first) { + yield t; + } + + for (const t of second) { + yield t; + } +} diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index e4ea1c4451..b39a0a0dfa 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -19,7 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import mockFs from "mock-fs"; import { watch } from "chokidar"; import { ExtensionsStore } from "../extensions-store"; import path from "path"; @@ -30,6 +29,7 @@ import { AppPaths } from "../../common/app-paths"; import type { ExtensionLoader } from "../extension-loader"; import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import * as fse from "fs-extra"; jest.setTimeout(60_000); @@ -43,6 +43,7 @@ jest.mock("../extension-installer", () => ({ installPackage: jest.fn(), }, })); +jest.mock("fs-extra"); jest.mock("electron", () => ({ app: { getVersion: () => "99.99.99", @@ -63,6 +64,7 @@ AppPaths.init(); console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; +const mockedFse = fse as jest.Mocked; describe("ExtensionDiscovery", () => { let extensionLoader: ExtensionLoader; @@ -77,62 +79,59 @@ describe("ExtensionDiscovery", () => { extensionLoader = di.inject(extensionLoaderInjectable); }); - describe("with mockFs", () => { - beforeEach(() => { - mockFs({ - [`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({ - name: "my-extension", - }), - }); - }); + it("emits add for added extension", async (done) => { + let addHandler: (filePath: string) => void; - afterEach(() => { - mockFs.restore(); - }); + mockedFse.readJson.mockImplementation((p) => { + expect(p).toBe(path.join(os.homedir(), ".k8slens/extensions/my-extension/package.json")); - it("emits add for added extension", async (done) => { - let addHandler: (filePath: string) => void; - - const mockWatchInstance: any = { - on: jest.fn((event: string, handler: typeof addHandler) => { - if (event === "add") { - addHandler = handler; - } - - return mockWatchInstance; - }), + return { + name: "my-extension", + version: "1.0.0", }; - - mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, - ); - - const extensionDiscovery = ExtensionDiscovery.createInstance( - extensionLoader, - ); - - // Need to force isLoaded to be true so that the file watching is started - extensionDiscovery.isLoaded = true; - - await extensionDiscovery.watchExtensions(); - - extensionDiscovery.events.on("add", extension => { - expect(extension).toEqual({ - absolutePath: expect.any(String), - id: path.normalize("node_modules/my-extension/package.json"), - isBundled: false, - isEnabled: false, - isCompatible: false, - manifest: { - name: "my-extension", - }, - manifestPath: path.normalize("node_modules/my-extension/package.json"), - }); - done(); - }); - - addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); }); + + mockedFse.pathExists.mockImplementation(() => true); + + const mockWatchInstance: any = { + on: jest.fn((event: string, handler: typeof addHandler) => { + if (event === "add") { + addHandler = handler; + } + + return mockWatchInstance; + }), + }; + + mockedWatch.mockImplementationOnce(() => + (mockWatchInstance) as any, + ); + const extensionDiscovery = ExtensionDiscovery.createInstance( + extensionLoader, + ); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.watchExtensions(); + + extensionDiscovery.events.on("add", extension => { + expect(extension).toEqual({ + absolutePath: expect.any(String), + id: path.normalize("node_modules/my-extension/package.json"), + isBundled: false, + isEnabled: false, + isCompatible: false, + manifest: { + name: "my-extension", + version: "1.0.0", + }, + manifestPath: path.normalize("node_modules/my-extension/package.json"), + }); + done(); + }); + + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); }); it("doesn't emit add for added file under extension", async done => { @@ -149,7 +148,7 @@ describe("ExtensionDiscovery", () => { }; mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, + (mockWatchInstance) as any, ); const extensionDiscovery = ExtensionDiscovery.createInstance( extensionLoader, @@ -172,3 +171,4 @@ describe("ExtensionDiscovery", () => { }, 10); }); }); + diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index 00fb397079..33a716e03a 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -262,7 +262,6 @@ export class ExtensionLoader { registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), - registries.CommandRegistry.getInstance().add(extension.commands), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), ]; @@ -293,7 +292,6 @@ export class ExtensionLoader { registries.KubeObjectDetailRegistry.getInstance().add(extension.kubeObjectDetailItems), registries.KubeObjectStatusRegistry.getInstance().add(extension.kubeObjectStatusTexts), registries.WorkloadsOverviewDetailRegistry.getInstance().add(extension.kubeWorkloadsOverviewItems), - registries.CommandRegistry.getInstance().add(extension.commands), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 9f7b947d82..718f25f7c5 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -30,6 +30,7 @@ import type { TopBarRegistration } from "../renderer/components/layout/top-bar/t import type { KubernetesCluster } from "../common/catalog-entities"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration"; +import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -42,7 +43,7 @@ export class LensRendererExtension extends LensExtension { kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = []; kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; - commands: registries.CommandRegistration[] = []; + commands: CommandRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = []; welcomeBanners: WelcomeBannerRegistration[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 76f6c05d11..e8c9930176 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -28,7 +28,6 @@ export * from "./status-bar-registry"; export * from "./kube-object-detail-registry"; export * from "./kube-object-menu-registry"; export * from "./kube-object-status-registry"; -export * from "./command-registry"; export * from "./entity-setting-registry"; export * from "./catalog-entity-detail-registry"; export * from "./workloads-overview-detail-registry"; diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 081beed63e..641881c38b 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -81,7 +81,7 @@ describe("kubeconfig manager tests", () => { let contextHandler: ContextHandler; beforeEach(() => { - const mockOpts = { + mockFs({ "minikube-config.yml": JSON.stringify({ apiVersion: "v1", clusters: [{ @@ -103,9 +103,7 @@ describe("kubeconfig manager tests", () => { kind: "Config", preferences: {}, }), - }; - - mockFs(mockOpts); + }); cluster = new Cluster({ id: "foo", diff --git a/src/main/getDi.ts b/src/main/getDi.ts index 2b59923a6c..c5fad1d470 100644 --- a/src/main/getDi.ts +++ b/src/main/getDi.ts @@ -24,11 +24,15 @@ import { createContainer } from "@ogre-tools/injectable"; export const getDi = () => createContainer( getRequireContextForMainCode, + getRequireContextForCommonCode, getRequireContextForCommonExtensionCode, ); const getRequireContextForMainCode = () => require.context("./", true, /\.injectable\.(ts|tsx)$/); +const getRequireContextForCommonCode = () => + require.context("../common", true, /\.injectable\.(ts|tsx)$/); + const getRequireContextForCommonExtensionCode = () => require.context("../extensions", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/main/menu/menu.ts b/src/main/menu/menu.ts index 294c31e0cc..512d4e916d 100644 --- a/src/main/menu/menu.ts +++ b/src/main/menu/menu.ts @@ -216,8 +216,14 @@ export function getAppMenu( label: "Command Palette...", accelerator: "Shift+CmdOrCtrl+P", id: "command-palette", - click() { - broadcastMessage("command-palette:open"); + click(_m, _b, event) { + /** + * Don't broadcast unless it was triggered by menu iteration so that + * there aren't double events in renderer + */ + if (!event?.triggeredByAccelerator) { + broadcastMessage("command-palette:open"); + } }, }, { type: "separator" }, diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 735e66a7a3..af2309bc4c 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -32,6 +32,7 @@ import { CatalogRunEvent } from "../../common/catalog/catalog-run-event"; import { ipcRenderer } from "electron"; import { CatalogIpcEvents } from "../../common/ipc/catalog"; import { navigate } from "../navigation"; +import { isMainFrame } from "process"; export type EntityFilter = (entity: CatalogEntity) => any; export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise; @@ -85,6 +86,16 @@ export class CatalogEntityRegistry { // Make sure that we get items ASAP and not the next time one of them changes ipcRenderer.send(CatalogIpcEvents.INIT); + + if (isMainFrame) { + ipcRendererOn("catalog-entity:run", (event, id: string) => { + const entity = this.getById(id); + + if (entity) { + this.onRun(entity); + } + }); + } } @action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) { diff --git a/src/main/catalog-sources/helpers/general-active-sync.ts b/src/renderer/api/helpers/general-active-sync.ts similarity index 82% rename from src/main/catalog-sources/helpers/general-active-sync.ts rename to src/renderer/api/helpers/general-active-sync.ts index e46e27f827..25442e60ab 100644 --- a/src/main/catalog-sources/helpers/general-active-sync.ts +++ b/src/renderer/api/helpers/general-active-sync.ts @@ -21,13 +21,14 @@ import { when } from "mobx"; import { catalogCategoryRegistry } from "../../../common/catalog"; -import { catalogEntityRegistry } from "../../../renderer/api/catalog-entity-registry"; -import { isActiveRoute } from "../../../renderer/navigation"; +import { catalogEntityRegistry } from "../catalog-entity-registry"; +import { isActiveRoute } from "../../navigation"; +import type { GeneralEntity } from "../../../common/catalog-entities"; export async function setEntityOnRouteMatch() { await when(() => catalogEntityRegistry.entities.size > 0); - const entities = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); + const entities: GeneralEntity[] = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path)); if (activeEntity) { diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 6e9e9fb471..210d78267b 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -49,7 +49,7 @@ import { SentryInit } from "../common/sentry"; import { TerminalStore } from "./components/dock/terminal.store"; import { AppPaths } from "../common/app-paths"; import { registerCustomThemes } from "./components/monaco-editor"; -import { getDi } from "./components/getDi"; +import { getDi } from "./getDi"; import { DiContextProvider } from "@ogre-tools/injectable-react"; import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; @@ -102,9 +102,6 @@ export async function bootstrap(comp: () => Promise, di: Dependenc logger.info(`${logPrefix} initializing Registries`); initializers.initRegistries(); - logger.info(`${logPrefix} initializing CommandRegistry`); - initializers.initCommandRegistry(); - logger.info(`${logPrefix} initializing EntitySettingsRegistry`); initializers.initEntitySettingsRegistry(); diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resources/crd.store.ts index 2cdcbee55b..71e96afe8a 100644 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ b/src/renderer/components/+custom-resources/crd.store.ts @@ -66,22 +66,15 @@ export class CRDStore extends KubeObjectStore { @computed get groups() { const groups: Record = {}; - return this.items.reduce((groups, crd) => { - const group = crd.getGroup(); + for (const crd of this.items) { + (groups[crd.getGroup()] ??= []).push(crd); + } - if (!groups[group]) groups[group] = []; - groups[group].push(crd); - - return groups; - }, groups); + return groups; } getByGroup(group: string, pluralName: string) { - const crdInGroup = this.groups[group]; - - if (!crdInGroup) return null; - - return crdInGroup.find(crd => crd.getPluralName() === pluralName); + return this.groups[group]?.find(crd => crd.getPluralName() === pluralName); } getByObject(obj: KubeObject) { diff --git a/src/renderer/components/activate-entity-command/activate-entity-command.tsx b/src/renderer/components/activate-entity-command/activate-entity-command.tsx index f0b06769d8..46d1a8db1f 100644 --- a/src/renderer/components/activate-entity-command/activate-entity-command.tsx +++ b/src/renderer/components/activate-entity-command/activate-entity-command.tsx @@ -22,6 +22,7 @@ import { computed } from "mobx"; import { observer } from "mobx-react"; import React from "react"; +import { broadcastMessage } from "../../../common/ipc"; import type { CatalogEntity } from "../../api/catalog-entity"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { CommandOverlay } from "../command-palette"; @@ -37,7 +38,7 @@ export class ActivateEntityCommand extends React.Component { } onSelect(entity: CatalogEntity): void { - catalogEntityRegistry.onRun(entity); + broadcastMessage("catalog-entity:run", entity.getId()); CommandOverlay.close(); } diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 300c9b2eb4..95a21a747e 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -38,7 +38,7 @@ import * as routes from "../../../common/routes"; import { DeleteClusterDialog } from "../delete-cluster-dialog"; import { reaction } from "mobx"; import { navigation } from "../../navigation"; -import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; +import { setEntityOnRouteMatch } from "../../api/helpers/general-active-sync"; import { catalogURL, getPreviousTabUrl } from "../../../common/routes"; import { TopBar } from "../layout/top-bar/top-bar"; diff --git a/src/renderer/components/command-palette/command-container.tsx b/src/renderer/components/command-palette/command-container.tsx index 2d4a7b6a83..8450129574 100644 --- a/src/renderer/components/command-palette/command-container.tsx +++ b/src/renderer/components/command-palette/command-container.tsx @@ -21,20 +21,30 @@ import "./command-container.scss"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import React from "react"; import { Dialog } from "../dialog"; -import { ipcRendererOn } from "../../../common/ipc"; import { CommandDialog } from "./command-dialog"; import type { ClusterId } from "../../../common/cluster-types"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { CommandRegistration, CommandRegistry } from "../../../extensions/registries/command-registry"; import { CommandOverlay } from "./command-overlay"; +import { isMac } from "../../../common/vars"; +import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { broadcastMessage, ipcRendererOn } from "../../../common/ipc"; +import { getMatchedClusterId } from "../../navigation"; +import type { Disposer } from "../../utils"; export interface CommandContainerProps { clusterId?: ClusterId; } +function addWindowEventListener(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer { + window.addEventListener(type, listener, options); + + return () => { + window.removeEventListener(type, listener); + }; +} + @observer export class CommandContainer extends React.Component { private escHandler(event: KeyboardEvent) { @@ -44,31 +54,39 @@ export class CommandContainer extends React.Component { } } - private findCommandById(commandId: string) { - return CommandRegistry.getInstance().getItems().find((command) => command.id === commandId); - } + handleCommandPalette = () => { + const clusterIsActive = getMatchedClusterId() !== undefined; - private runCommand(command: CommandRegistration) { - command.action({ - entity: catalogEntityRegistry.activeEntity, - }); + if (clusterIsActive) { + broadcastMessage(`command-palette:${catalogEntityRegistry.activeEntity.getId()}:open`); + } else { + CommandOverlay.open(); + } + }; + + onKeyboardShortcut(action: () => void) { + return ({ key, shiftKey, ctrlKey, altKey, metaKey }: KeyboardEvent) => { + const ctrlOrCmd = isMac ? metaKey && !ctrlKey : !metaKey && ctrlKey; + + if (key === "p" && shiftKey && ctrlOrCmd && !altKey) { + action(); + } + }; } componentDidMount() { - if (this.props.clusterId) { - ipcRendererOn(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => { - const command = this.findCommandById(commandId); + const action = this.props.clusterId + ? () => CommandOverlay.open() + : this.handleCommandPalette; + const ipcChannel = this.props.clusterId + ? `command-palette:${this.props.clusterId}:open` + : "command-palette:open"; - if (command) { - this.runCommand(command); - } - }); - } else { - ipcRendererOn("command-palette:open", () => { - CommandOverlay.open(); - }); - } - window.addEventListener("keyup", (e) => this.escHandler(e), true); + disposeOnUnmount(this, [ + ipcRendererOn(ipcChannel, action), + addWindowEventListener("keydown", this.onKeyboardShortcut(action)), + addWindowEventListener("keyup", (e) => this.escHandler(e), true), + ]); } render() { diff --git a/src/renderer/components/command-palette/command-dialog.tsx b/src/renderer/components/command-palette/command-dialog.tsx index 3761068260..4c495ee494 100644 --- a/src/renderer/components/command-palette/command-dialog.tsx +++ b/src/renderer/components/command-palette/command-dialog.tsx @@ -21,61 +21,31 @@ import { Select } from "../select"; -import { computed, makeObservable, observable } from "mobx"; +import type { IComputedValue } from "mobx"; import { observer } from "mobx-react"; -import React from "react"; -import { CommandRegistry } from "../../../extensions/registries/command-registry"; +import React, { useState } from "react"; import { CommandOverlay } from "./command-overlay"; -import { broadcastMessage } from "../../../common/ipc"; -import { navigate } from "../../navigation"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import type { CatalogEntity } from "../../../common/catalog"; -import { clusterViewURL } from "../../../common/routes"; +import { navigate } from "../../navigation"; +import { broadcastMessage } from "../../../common/ipc"; +import { IpcRendererNavigationEvents } from "../../navigation/events"; +import type { RegisteredCommand } from "./registered-commands/commands"; +import { iter } from "../../utils"; +import { orderBy } from "lodash"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import registeredCommandsInjectable from "./registered-commands/registered-commands.injectable"; +import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -@observer -export class CommandDialog extends React.Component { - @observable menuIsOpen = true; - @observable searchValue: any = undefined; +interface Dependencies { + commands: IComputedValue>; + activeEntity?: CatalogEntity; +} - constructor(props: {}) { - super(props); - makeObservable(this); - } +const NonInjectedCommandDialog = observer(({ commands, activeEntity }: Dependencies) => { + const [searchValue, setSearchValue] = useState(""); - @computed get activeEntity(): CatalogEntity | undefined { - return catalogEntityRegistry.activeEntity; - } - - @computed get options() { - const registry = CommandRegistry.getInstance(); - - const context = { - entity: this.activeEntity, - }; - - return registry.getItems().filter((command) => { - if (command.scope === "entity" && !this.activeEntity) { - return false; - } - - try { - return command.isActive?.(context) ?? true; - } catch(e) { - console.error(e); - } - - return false; - }) - .map((command) => ({ - value: command.id, - label: command.title, - })) - .sort((a, b) => a.label > b.label ? 1 : -1); - } - - private onChange(value: string) { - const registry = CommandRegistry.getInstance(); - const command = registry.getItems().find((cmd) => cmd.id === value); + const executeAction = (commandId: string) => { + const command = commands.get().get(commandId); if (!command) { return; @@ -83,46 +53,73 @@ export class CommandDialog extends React.Component { try { CommandOverlay.close(); + command.action({ + entity: activeEntity, + navigate: (url, opts = {}) => { + const { forceRootFrame = false } = opts; - if (command.scope === "global") { - command.action({ - entity: this.activeEntity, - }); - } else if(this.activeEntity) { - navigate(clusterViewURL({ - params: { - clusterId: this.activeEntity.metadata.uid, - }, - })); - broadcastMessage(`command-palette:run-action:${this.activeEntity.metadata.uid}`, command.id); - } - } catch(error) { + if (forceRootFrame) { + broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url); + } else { + navigate(url); + } + }, + }); + } catch (error) { console.error("[COMMAND-DIALOG] failed to execute command", command.id, error); } - } + }; - render() { - return ( - executeAction(v.value)} + components={{ + DropdownIndicator: null, + IndicatorSeparator: null, + }} + menuIsOpen + options={options} + autoFocus={true} + escapeClearsValue={false} + data-test-id="command-palette-search" + placeholder="Type a command or search…" + onInputChange={(newValue, { action }) => { + if (action === "input-change") { + setSearchValue(newValue); + } + }} + inputValue={searchValue} + /> + ); +}); + +export const CommandDialog = withInjectables(NonInjectedCommandDialog, { + getProps: di => ({ + commands: di.inject(registeredCommandsInjectable), + // TODO: replace with injection + activeEntity: catalogEntityRegistry.activeEntity, + }), +}); diff --git a/src/extensions/registries/command-registry.ts b/src/renderer/components/command-palette/registered-commands/commands.d.ts similarity index 54% rename from src/extensions/registries/command-registry.ts rename to src/renderer/components/command-palette/registered-commands/commands.d.ts index 3916b0f721..9b90662255 100644 --- a/src/extensions/registries/command-registry.ts +++ b/src/renderer/components/command-palette/registered-commands/commands.d.ts @@ -19,34 +19,55 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Extensions API -> Commands - -import { BaseRegistry } from "./base-registry"; -import type { LensExtension } from "../lens-extension"; -import type { CatalogEntity } from "../../common/catalog"; +import type { CatalogEntity } from "../../../../common/catalog"; +/** + * The context given to commands when executed + */ export interface CommandContext { entity?: CatalogEntity; } +export interface CommandActionNavigateOptions { + /** + * If `true` then the navigate will only navigate on the root frame and not + * within a cluster + * @default false + */ + forceRootFrame?: boolean; +} + +export interface CommandActionContext extends CommandContext { + navigate: (url: string, opts?: CommandActionNavigateOptions) => void; +} + export interface CommandRegistration { + /** + * The ID of the command, must be globally unique + */ id: string; - title: string; - scope: "entity" | "global"; - action: (context: CommandContext) => void; + + /** + * The display name of the command in the command pallet + */ + title: string | ((context: CommandContext) => string); + + /** + * @deprecated use `isActive` instead since there is always an entity active + */ + scope?: "global" | "entity"; + + /** + * The function to run when this command is selected + */ + action: (context: CommandActionContext) => void; + + /** + * A function that determines if the command is active. + * + * @default () => true + */ isActive?: (context: CommandContext) => boolean; } -export class CommandRegistry extends BaseRegistry { - add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) { - const itemArray = [items].flat(); - - const newIds = itemArray.map((item) => item.id); - const currentIds = this.getItems().map((item) => item.id); - - const filteredIds = newIds.filter((id) => !currentIds.includes(id)); - const filteredItems = itemArray.filter((item) => filteredIds.includes(item.id)); - - return super.add(filteredItems, extension); - } -} +export type RegisteredCommand = Required>; diff --git a/src/renderer/components/command-palette/registered-commands/internal-commands.tsx b/src/renderer/components/command-palette/registered-commands/internal-commands.tsx new file mode 100644 index 0000000000..1b40f20f0e --- /dev/null +++ b/src/renderer/components/command-palette/registered-commands/internal-commands.tsx @@ -0,0 +1,215 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import * as routes from "../../../../common/routes"; +import { EntitySettingRegistry } from "../../../../extensions/registries"; +import { CommandOverlay } from "../../../components/command-palette"; +import { createTerminalTab } from "../../../components/dock/terminal.store"; +import { HotbarAddCommand } from "../../../components/hotbar/hotbar-add-command"; +import { HotbarRemoveCommand } from "../../../components/hotbar/hotbar-remove-command"; +import { HotbarSwitchCommand } from "../../../components/hotbar/hotbar-switch-command"; +import { HotbarRenameCommand } from "../../../components/hotbar/hotbar-rename-command"; +import { ActivateEntityCommand } from "../../../components/activate-entity-command"; +import type { CommandContext, CommandRegistration } from "./commands"; + +export function isKubernetesClusterActive(context: CommandContext): boolean { + return context.entity?.kind === "KubernetesCluster"; +} + +export const internalCommands: CommandRegistration[] = [ + { + id: "app.showPreferences", + title: "Preferences: Open", + action: ({ navigate }) => navigate(routes.preferencesURL(), { + forceRootFrame: true, + }), + }, + { + id: "cluster.viewHelmCharts", + title: "Cluster: View Helm Charts", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.helmChartsURL()), + }, + { + id: "cluster.viewHelmReleases", + title: "Cluster: View Helm Releases", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.releaseURL()), + }, + { + id: "cluster.viewConfigMaps", + title: "Cluster: View ConfigMaps", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.configMapsURL()), + }, + { + id: "cluster.viewSecrets", + title: "Cluster: View Secrets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.secretsURL()), + }, + { + id: "cluster.viewResourceQuotas", + title: "Cluster: View ResourceQuotas", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.resourceQuotaURL()), + }, + { + id: "cluster.viewLimitRanges", + title: "Cluster: View LimitRanges", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.limitRangeURL()), + }, + { + id: "cluster.viewHorizontalPodAutoscalers", + title: "Cluster: View HorizontalPodAutoscalers (HPA)", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.hpaURL()), + }, + { + id: "cluster.viewPodDisruptionBudget", + title: "Cluster: View PodDisruptionBudgets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.pdbURL()), + }, + { + id: "cluster.viewServices", + title: "Cluster: View Services", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.servicesURL()), + }, + { + id: "cluster.viewEndpoints", + title: "Cluster: View Endpoints", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.endpointURL()), + }, + { + id: "cluster.viewIngresses", + title: "Cluster: View Ingresses", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.ingressURL()), + }, + { + id: "cluster.viewNetworkPolicies", + title: "Cluster: View NetworkPolicies", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.networkPoliciesURL()), + }, + { + id: "cluster.viewNodes", + title: "Cluster: View Nodes", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.nodesURL()), + }, + { + id: "cluster.viewPods", + title: "Cluster: View Pods", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.podsURL()), + }, + { + id: "cluster.viewDeployments", + title: "Cluster: View Deployments", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.deploymentsURL()), + }, + { + id: "cluster.viewDaemonSets", + title: "Cluster: View DaemonSets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.daemonSetsURL()), + }, + { + id: "cluster.viewStatefulSets", + title: "Cluster: View StatefulSets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.statefulSetsURL()), + }, + { + id: "cluster.viewJobs", + title: "Cluster: View Jobs", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.jobsURL()), + }, + { + id: "cluster.viewCronJobs", + title: "Cluster: View CronJobs", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.cronJobsURL()), + }, + { + id: "cluster.viewCustomResourceDefinitions", + title: "Cluster: View Custom Resource Definitions", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.crdURL()), + }, + { + id: "entity.viewSettings", + title: ({ entity }) => `${entity.kind}/${entity.getName()}: View Settings`, + action: ({ entity, navigate }) => navigate(`/entity/${entity.getId()}/settings`, { + forceRootFrame: true, + }), + isActive: ({ entity }) => { + if (!entity) { + return false; + } + + // TODO: replace with injection + const entries = EntitySettingRegistry.getInstance() + .getItemsForKind(entity.kind, entity.apiVersion, entity.metadata.source); + + return entries.length > 0; + }, + }, + { + id: "cluster.openTerminal", + title: "Cluster: Open terminal", + action: () => createTerminalTab(), + isActive: isKubernetesClusterActive, + }, + { + id: "hotbar.switchHotbar", + title: "Hotbar: Switch ...", + action: () => CommandOverlay.open(), + }, + { + id: "hotbar.addHotbar", + title: "Hotbar: Add Hotbar ...", + action: () => CommandOverlay.open(), + }, + { + id: "hotbar.removeHotbar", + title: "Hotbar: Remove Hotbar ...", + action: () => CommandOverlay.open(), + }, + { + id: "hotbar.renameHotbar", + title: "Hotbar: Rename Hotbar ...", + action: () => CommandOverlay.open(), + }, + { + id: "catalog.searchEntities", + title: "Catalog: Activate Entity ...", + action: () => CommandOverlay.open(), + }, +]; diff --git a/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts b/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts new file mode 100644 index 0000000000..0f263720d0 --- /dev/null +++ b/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue } from "mobx"; +import type { CustomResourceDefinition } from "../../../../common/k8s-api/endpoints"; +import customResourceDefinitionsInjectable from "../../../../common/k8s-api/endpoints/custom-resources/custom-resources.injectable"; +import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import { iter } from "../../../utils"; +import type { RegisteredCommand } from "./commands"; +import { internalCommands, isKubernetesClusterActive } from "./internal-commands"; + +interface Dependencies { + extensions: IComputedValue; + customResourceDefinitions: IComputedValue; +} + +const instantiateRegisteredCommands = ({ extensions, customResourceDefinitions }: Dependencies) => computed(() => { + const result = new Map(); + const commands = iter.chain( + internalCommands, + iter.chain( + iter.flatMap(extensions.get(), extension => extension.commands), + iter.map(customResourceDefinitions.get(), command => ({ + id: `cluster.view.${command.getResourceKind()}`, + title: `Cluster: View ${command.getResourceKind()}`, + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(command.getResourceUrl()), + })), + ), + ); + + for (const { scope, isActive = () => true, ...command } of commands) { + if (!result.has(command.id)) { + result.set(command.id, { ...command, isActive }); + } + } + + return result; +}); + +const registeredCommandsInjectable = getInjectable({ + instantiate: (di) => instantiateRegisteredCommands({ + extensions: di.inject(rendererExtensionsInjectable), + customResourceDefinitions: di.inject(customResourceDefinitionsInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default registeredCommandsInjectable; diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index f6aea6529e..e9dbc48aad 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -103,6 +103,10 @@ export class Input extends React.Component { submitted: false, }; + componentWillUnmount(): void { + this.setDirtyOnChange.cancel(); + } + setValue(value = "") { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx index 02042850d8..689c368c8d 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx @@ -280,5 +280,5 @@ const addDynamicMenuItem = ({ const kubeObjectMenuRegistry = di.inject(kubeObjectMenuRegistryInjectable); - kubeObjectMenuRegistry.add(dynamicMenuItemStub); + kubeObjectMenuRegistry.add([dynamicMenuItemStub]); }; diff --git a/src/renderer/components/getDi.tsx b/src/renderer/getDi.tsx similarity index 83% rename from src/renderer/components/getDi.tsx rename to src/renderer/getDi.tsx index a0e4615a7c..4c4359fe74 100644 --- a/src/renderer/components/getDi.tsx +++ b/src/renderer/getDi.tsx @@ -24,11 +24,15 @@ import { createContainer } from "@ogre-tools/injectable"; export const getDi = () => createContainer( getRequireContextForRendererCode, + getRequireContextForCommonCode, getRequireContextForCommonExtensionCode, ); const getRequireContextForRendererCode = () => - require.context("../", true, /\.injectable\.(ts|tsx)$/); + require.context("./", true, /\.injectable\.(ts|tsx)$/); + +const getRequireContextForCommonCode = () => + require.context("../common", true, /\.injectable\.(ts|tsx)$/); const getRequireContextForCommonExtensionCode = () => - require.context("../../extensions", true, /\.injectable\.(ts|tsx)$/); + require.context("../extensions", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/renderer/initializers/command-registry.tsx b/src/renderer/initializers/command-registry.tsx deleted file mode 100644 index ff24f72fbf..0000000000 --- a/src/renderer/initializers/command-registry.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React from "react"; -import * as routes from "../../common/routes"; -import { CommandRegistry } from "../../extensions/registries"; -import { getActiveClusterEntity } from "../api/catalog-entity-registry"; -import { CommandOverlay } from "../components/command-palette"; -import { createTerminalTab } from "../components/dock/terminal.store"; -import { HotbarAddCommand } from "../components/hotbar/hotbar-add-command"; -import { HotbarRemoveCommand } from "../components/hotbar/hotbar-remove-command"; -import { HotbarSwitchCommand } from "../components/hotbar/hotbar-switch-command"; -import { navigate } from "../navigation"; -import { HotbarRenameCommand } from "../components/hotbar/hotbar-rename-command"; -import { ActivateEntityCommand } from "../components/activate-entity-command"; - -export function initCommandRegistry() { - CommandRegistry.getInstance() - .add([ - { - id: "app.showPreferences", - title: "Preferences: Open", - scope: "global", - action: () => navigate(routes.preferencesURL()), - }, - { - id: "cluster.viewHelmCharts", - title: "Cluster: View Helm Charts", - scope: "entity", - action: () => navigate(routes.helmChartsURL()), - }, - { - id: "cluster.viewHelmReleases", - title: "Cluster: View Helm Releases", - scope: "entity", - action: () => navigate(routes.releaseURL()), - }, - { - id: "cluster.viewConfigMaps", - title: "Cluster: View ConfigMaps", - scope: "entity", - action: () => navigate(routes.configMapsURL()), - }, - { - id: "cluster.viewSecrets", - title: "Cluster: View Secrets", - scope: "entity", - action: () => navigate(routes.secretsURL()), - }, - { - id: "cluster.viewResourceQuotas", - title: "Cluster: View ResourceQuotas", - scope: "entity", - action: () => navigate(routes.resourceQuotaURL()), - }, - { - id: "cluster.viewLimitRanges", - title: "Cluster: View LimitRanges", - scope: "entity", - action: () => navigate(routes.limitRangeURL()), - }, - { - id: "cluster.viewHorizontalPodAutoscalers", - title: "Cluster: View HorizontalPodAutoscalers (HPA)", - scope: "entity", - action: () => navigate(routes.hpaURL()), - }, - { - id: "cluster.viewPodDisruptionBudget", - title: "Cluster: View PodDisruptionBudgets", - scope: "entity", - action: () => navigate(routes.pdbURL()), - }, - { - id: "cluster.viewServices", - title: "Cluster: View Services", - scope: "entity", - action: () => navigate(routes.servicesURL()), - }, - { - id: "cluster.viewEndpoints", - title: "Cluster: View Endpoints", - scope: "entity", - action: () => navigate(routes.endpointURL()), - }, - { - id: "cluster.viewIngresses", - title: "Cluster: View Ingresses", - scope: "entity", - action: () => navigate(routes.ingressURL()), - }, - { - id: "cluster.viewNetworkPolicies", - title: "Cluster: View NetworkPolicies", - scope: "entity", - action: () => navigate(routes.networkPoliciesURL()), - }, - { - id: "cluster.viewNodes", - title: "Cluster: View Nodes", - scope: "entity", - action: () => navigate(routes.nodesURL()), - }, - { - id: "cluster.viewPods", - title: "Cluster: View Pods", - scope: "entity", - action: () => navigate(routes.podsURL()), - }, - { - id: "cluster.viewDeployments", - title: "Cluster: View Deployments", - scope: "entity", - action: () => navigate(routes.deploymentsURL()), - }, - { - id: "cluster.viewDaemonSets", - title: "Cluster: View DaemonSets", - scope: "entity", - action: () => navigate(routes.daemonSetsURL()), - }, - { - id: "cluster.viewStatefulSets", - title: "Cluster: View StatefulSets", - scope: "entity", - action: () => navigate(routes.statefulSetsURL()), - }, - { - id: "cluster.viewJobs", - title: "Cluster: View Jobs", - scope: "entity", - action: () => navigate(routes.jobsURL()), - }, - { - id: "cluster.viewCronJobs", - title: "Cluster: View CronJobs", - scope: "entity", - action: () => navigate(routes.cronJobsURL()), - }, - { - id: "cluster.viewCurrentClusterSettings", - title: "Cluster: View Settings", - scope: "global", - action: () => navigate(routes.entitySettingsURL({ - params: { - entityId: getActiveClusterEntity()?.id, - }, - })), - isActive: (context) => !!context.entity, - }, - { - id: "cluster.openTerminal", - title: "Cluster: Open terminal", - scope: "entity", - action: () => createTerminalTab(), - isActive: (context) => !!context.entity, - }, - { - id: "hotbar.switchHotbar", - title: "Hotbar: Switch ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.addHotbar", - title: "Hotbar: Add Hotbar ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.removeHotbar", - title: "Hotbar: Remove Hotbar ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.renameHotbar", - title: "Hotbar: Rename Hotbar ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - { - id: "catalog.searchEntities", - title: "Catalog: Activate Entity ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - ]); -} diff --git a/src/renderer/initializers/index.ts b/src/renderer/initializers/index.ts index b865368988..758c9042ad 100644 --- a/src/renderer/initializers/index.ts +++ b/src/renderer/initializers/index.ts @@ -21,7 +21,6 @@ export * from "./catalog-entity-detail-registry"; export * from "./catalog"; -export * from "./command-registry"; export * from "./entity-settings-registry"; export * from "./ipc"; export * from "./kube-object-detail-registry"; diff --git a/src/renderer/initializers/registries.ts b/src/renderer/initializers/registries.ts index a27c47153b..a68b4e0abd 100644 --- a/src/renderer/initializers/registries.ts +++ b/src/renderer/initializers/registries.ts @@ -26,7 +26,6 @@ export function initRegistries() { registries.CatalogEntityDetailRegistry.createInstance(); registries.ClusterPageMenuRegistry.createInstance(); registries.ClusterPageRegistry.createInstance(); - registries.CommandRegistry.createInstance(); registries.EntitySettingRegistry.createInstance(); registries.GlobalPageRegistry.createInstance(); registries.KubeObjectDetailRegistry.createInstance(); diff --git a/src/renderer/navigation/helpers.ts b/src/renderer/navigation/helpers.ts index d6408cb272..1314b0973f 100644 --- a/src/renderer/navigation/helpers.ts +++ b/src/renderer/navigation/helpers.ts @@ -54,7 +54,7 @@ export function isActiveRoute(route: string | string[] | RouteProps): boolean { return !!matchRoute(route); } -export function getMatchedClusterId(): string { +export function getMatchedClusterId(): string | undefined { const matched = matchPath(navigation.location.pathname, { exact: true, path: clusterViewRoute.path, From f8ae1149fbea293625cf97a572cad60d3043e893 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 4 Jan 2022 16:43:29 -0500 Subject: [PATCH 15/27] Make all of Command* injectable, with some others too Signed-off-by: Sebastian Malton --- src/common/__tests__/hotbar-store.test.ts | 4 +- src/common/catalog-entities/web-link.ts | 17 +- ...ar-store.ts => hotbar-store.injectable.ts} | 102 +++++--- src/common/hotbar-types.ts | 8 +- src/common/ipc/index.ts | 1 + src/common/utils/iter.ts | 15 -- src/extensions/getDiForUnitTesting.ts | 1 - .../registries/entity-setting-registry.ts | 4 +- src/main/index.ts | 2 +- src/main/menu/menu.ts | 2 + src/renderer/api/catalog-entity-registry.ts | 4 +- src/renderer/bootstrap.tsx | 7 +- src/renderer/components/+catalog/catalog.tsx | 2 +- .../+catalog/hotbar-toggle-menu-item.tsx | 2 +- .../custom-resources.injectable.ts | 2 +- .../+extensions/__tests__/extensions.test.tsx | 2 +- .../+welcome/__test__/welcome.test.tsx | 2 +- .../activate-entity-command.tsx | 70 +++--- .../catalog-entities/weblink-add-command.tsx | 23 +- .../cluster-manager/active-hotbar-name.tsx | 38 ++- .../cluster-manager/bottom-bar.test.tsx | 89 ++++--- .../command-palette/command-container.tsx | 48 ++-- .../command-palette/command-dialog.tsx | 8 +- ...erlay.ts => command-overlay.injectable.ts} | 30 ++- .../components/command-palette/index.ts | 2 +- .../internal-commands.injectable.tsx | 231 ++++++++++++++++++ .../registered-commands/internal-commands.tsx | 215 ---------------- .../registered-commands.injectable.ts | 33 ++- .../delete-cluster-dialog.tsx | 2 +- .../__tests__/hotbar-remove-command.test.tsx | 68 ++++-- .../components/hotbar/hotbar-add-command.tsx | 66 +++-- .../components/hotbar/hotbar-menu.tsx | 2 +- .../hotbar/hotbar-remove-command.tsx | 92 +++---- .../hotbar/hotbar-rename-command.tsx | 115 ++++----- .../components/hotbar/hotbar-selector.tsx | 62 +++-- .../hotbar/hotbar-switch-command.tsx | 137 ++++++----- .../kube-object-menu.test.tsx | 5 +- .../layout/__tests__/sidebar-cluster.test.tsx | 2 +- .../components/layout/sidebar-cluster.tsx | 2 +- .../layout/top-bar/top-bar-win-linux.test.tsx | 2 +- .../layout/top-bar/top-bar.test.tsx | 2 +- .../{components => }/getDiForUnitTesting.tsx | 30 +-- src/renderer/initializers/catalog.tsx | 48 ++-- .../event-listener.injectable.ts} | 22 +- 44 files changed, 868 insertions(+), 753 deletions(-) rename src/common/{hotbar-store.ts => hotbar-store.injectable.ts} (77%) rename src/{common/k8s-api/endpoints/custom-resources => renderer/components/+custom-resources}/custom-resources.injectable.ts (94%) rename src/renderer/components/command-palette/{command-overlay.ts => command-overlay.injectable.ts} (68%) create mode 100644 src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx delete mode 100644 src/renderer/components/command-palette/registered-commands/internal-commands.tsx rename src/renderer/{components => }/getDiForUnitTesting.tsx (72%) rename src/renderer/{components/hotbar/hotbar-display-label.ts => window/event-listener.injectable.ts} (64%) diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index ffc4e361da..9baa291acd 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -26,7 +26,7 @@ import logger from "../../main/logger"; import { AppPaths } from "../app-paths"; import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; import { ClusterStore } from "../cluster-store"; -import { HotbarStore } from "../hotbar-store"; +import { HotbarStore } from "../hotbar-store.injectable"; jest.mock("../../main/catalog/catalog-entity-registry", () => ({ catalogEntityRegistry: { @@ -251,7 +251,7 @@ describe("HotbarStore", () => { const hotbarStore = HotbarStore.getInstance(); hotbarStore.add({ name: "hottest", id: "hottest" }); - hotbarStore.activeHotbarId = "hottest"; + hotbarStore.setActiveHotbar("hottest"); const { error } = logger; const mocked = jest.fn(); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 870558e0b3..6764ca8bee 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CatalogCategory, CatalogEntity, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; +import { CatalogCategory, CatalogEntity, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { productName } from "../vars"; import { WeblinkStore } from "../weblink-store"; @@ -86,21 +86,6 @@ export class WebLinkCategory extends CatalogCategory { kind: "WebLink", }, }; - public static onAdd?: () => void; - - constructor() { - super(); - - this.on("catalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { - ctx.menuItems.push({ - icon: "public", - title: "Add web link", - onClick: () => { - WebLinkCategory.onAdd(); - }, - }); - }); - } } catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.injectable.ts similarity index 77% rename from src/common/hotbar-store.ts rename to src/common/hotbar-store.injectable.ts index 4a101e6013..daad150c2e 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.injectable.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { action, comparer, observable, makeObservable } from "mobx"; +import { action, comparer, observable, makeObservable, computed } from "mobx"; import { BaseStore } from "./base-store"; import migrations from "../migrations/hotbar-store"; import { toJS } from "./utils"; @@ -27,7 +27,8 @@ import { CatalogEntity } from "./catalog"; import { catalogEntity } from "../main/catalog-sources/general"; import logger from "../main/logger"; import { broadcastMessage, HotbarTooManyItems } from "./ipc"; -import { defaultHotbarCells, getEmptyHotbar, Hotbar, HotbarCreateOptions } from "./hotbar-types"; +import { defaultHotbarCells, getEmptyHotbar, Hotbar, CreateHotbarData, CreateHotbarOptions } from "./hotbar-types"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; export interface HotbarStoreModel { hotbars: Hotbar[]; @@ -52,22 +53,40 @@ export class HotbarStore extends BaseStore { this.load(); } - get activeHotbarId() { + @computed get activeHotbarId() { return this._activeHotbarId; } - set activeHotbarId(id: string) { - if (this.getById(id)) { - this._activeHotbarId = id; + /** + * If `hotbar` points to a known hotbar, make it active. Otherwise, ignore + * @param hotbar The hotbar instance, or the index, or its ID + */ + setActiveHotbar(hotbar: Hotbar | number | string) { + if (typeof hotbar === "number") { + if (hotbar >= 0 && hotbar < this.hotbars.length) { + this._activeHotbarId = this.hotbars[hotbar].id; + } + } else if (typeof hotbar === "string") { + if (this.getById(hotbar)) { + this._activeHotbarId = hotbar; + } + } else { + if (this.hotbars.indexOf(hotbar) >= 0) { + this._activeHotbarId = hotbar.id; + } } } - hotbarIndex(id: string) { + private hotbarIndexById(id: string) { return this.hotbars.findIndex((hotbar) => hotbar.id === id); } - get activeHotbarIndex() { - return this.hotbarIndex(this.activeHotbarId); + private hotbarIndex(hotbar: Hotbar) { + return this.hotbars.indexOf(hotbar); + } + + @computed get activeHotbarIndex() { + return this.hotbarIndexById(this.activeHotbarId); } @action @@ -87,13 +106,11 @@ export class HotbarStore extends BaseStore { this.hotbars.forEach(ensureExactHotbarItemLength); if (data.activeHotbarId) { - if (this.getById(data.activeHotbarId)) { - this.activeHotbarId = data.activeHotbarId; - } + this.setActiveHotbar(data.activeHotbarId); } if (!this.activeHotbarId) { - this.activeHotbarId = this.hotbars[0].id; + this.setActiveHotbar(0); } } @@ -118,8 +135,7 @@ export class HotbarStore extends BaseStore { return this.hotbars.find((hotbar) => hotbar.id === id); } - @action - add(data: HotbarCreateOptions, { setActive = false } = {}) { + add = action((data: CreateHotbarData, { setActive = false }: CreateHotbarOptions = {}) => { const hotbar = getEmptyHotbar(data.name, data.id); this.hotbars.push(hotbar); @@ -127,29 +143,29 @@ export class HotbarStore extends BaseStore { if (setActive) { this._activeHotbarId = hotbar.id; } - } + }); - @action - setHotbarName(id: string, name: string) { + setHotbarName = action((id: string, name: string) => { const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); - if(index < 0) { - console.warn(`[HOTBAR-STORE]: cannot setHotbarName: unknown id`, { id }); - - return; + if (index < 0) { + return void console.warn(`[HOTBAR-STORE]: cannot setHotbarName: unknown id`, { id }); } this.hotbars[index].name = name; - } + }); + + remove = action((hotbar: Hotbar) => { + if (this.hotbars.length <= 1) { + throw new Error("Cannot remove the last hotbar"); + } - @action - remove(hotbar: Hotbar) { this.hotbars = this.hotbars.filter((h) => h !== hotbar); if (this.activeHotbarId === hotbar.id) { - this.activeHotbarId = this.hotbars[0].id; + this.setActiveHotbar(0); } - } + }); @action addToHotbar(item: CatalogEntity, cellIndex?: number) { @@ -263,7 +279,7 @@ export class HotbarStore extends BaseStore { index = hotbarStore.hotbars.length - 1; } - hotbarStore.activeHotbarId = hotbarStore.hotbars[index].id; + hotbarStore.setActiveHotbar(index); } switchToNext() { @@ -274,7 +290,7 @@ export class HotbarStore extends BaseStore { index = 0; } - hotbarStore.activeHotbarId = hotbarStore.hotbars[index].id; + hotbarStore.setActiveHotbar(index); } /** @@ -284,6 +300,20 @@ export class HotbarStore extends BaseStore { isAddedToActive(entity: CatalogEntity) { return !!this.getActive().items.find(item => item?.entity.uid === entity.metadata.uid); } + + getDisplayLabel(hotbar: Hotbar): string { + return `${this.getDisplayIndex(hotbar)}: ${hotbar.name}`; + } + + getDisplayIndex(hotbar: Hotbar): string { + const index = this.hotbarIndex(hotbar); + + if (index < 0) { + return "??"; + } + + return `${index + 1}`; + } } /** @@ -292,12 +322,7 @@ export class HotbarStore extends BaseStore { * @param hotbar The hotbar to modify */ function ensureExactHotbarItemLength(hotbar: Hotbar) { - if (hotbar.items.length === defaultHotbarCells) { - // if we already have `defaultHotbarCells` then we are good to stop - return; - } - - // otherwise, keep adding empty entries until full + // if there are not enough items while (hotbar.items.length < defaultHotbarCells) { hotbar.items.push(null); } @@ -314,3 +339,10 @@ function ensureExactHotbarItemLength(hotbar: Hotbar) { } } } + +const hotbarManagerInjectable = getInjectable({ + instantiate: () => HotbarStore.getInstance(), + lifecycle: lifecycleEnum.singleton, +}); + +export default hotbarManagerInjectable; diff --git a/src/common/hotbar-types.ts b/src/common/hotbar-types.ts index ee65071e0e..7e36a2c37c 100644 --- a/src/common/hotbar-types.ts +++ b/src/common/hotbar-types.ts @@ -33,14 +33,18 @@ export interface HotbarItem { } } -export type Hotbar = Required; +export type Hotbar = Required; -export interface HotbarCreateOptions { +export interface CreateHotbarData { id?: string; name: string; items?: Tuple; } +export interface CreateHotbarOptions { + setActive?: boolean; +} + export const defaultHotbarCells = 12; // Number is chosen to easy hit any item with keyboard export function getEmptyHotbar(name: string, id: string = uuid.v4()): Hotbar { diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts index 774ce589e9..9a64ede7d6 100644 --- a/src/common/ipc/index.ts +++ b/src/common/ipc/index.ts @@ -20,6 +20,7 @@ */ export const dialogShowOpenDialogHandler = "dialog:show-open-dialog"; +export const catalogEntityRunListener = "catalog-entity:run"; export * from "./ipc"; export * from "./invalid-kubeconfig"; diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index 8ba606a881..6271a05969 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -201,18 +201,3 @@ export function every(src: Iterable, fn: (val: T) => any): boolean { return true; } - -/** - * Produce a new iterator that drains the first and then the second - * @param first The first iterable to iterate through - * @param second The second iterable to iterate through - */ -export function* chain(first: Iterable, second: Iterable): IterableIterator { - for (const t of first) { - yield t; - } - - for (const t of second) { - yield t; - } -} diff --git a/src/extensions/getDiForUnitTesting.ts b/src/extensions/getDiForUnitTesting.ts index 5460d8887b..81184da59a 100644 --- a/src/extensions/getDiForUnitTesting.ts +++ b/src/extensions/getDiForUnitTesting.ts @@ -41,7 +41,6 @@ export const getDiForUnitTesting = () => { aliases: [injectable, ...(injectable.aliases || [])], }; }) - .forEach(injectable => di.register(injectable)); di.preventSideEffects(); diff --git a/src/extensions/registries/entity-setting-registry.ts b/src/extensions/registries/entity-setting-registry.ts index 54d85ef57f..1446dff78d 100644 --- a/src/extensions/registries/entity-setting-registry.ts +++ b/src/extensions/registries/entity-setting-registry.ts @@ -54,7 +54,7 @@ export class EntitySettingRegistry extends BaseRegistry { let items = this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion); }); @@ -66,5 +66,5 @@ export class EntitySettingRegistry extends BaseRegistry (b.priority ?? 50) - (a.priority ?? 50)); - } + }; } diff --git a/src/main/index.ts b/src/main/index.ts index f40b92aa21..69a42d8383 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -51,7 +51,7 @@ import configurePackages from "../common/configure-packages"; import { PrometheusProviderRegistry } from "./prometheus"; import * as initializers from "./initializers"; import { ClusterStore } from "../common/cluster-store"; -import { HotbarStore } from "../common/hotbar-store"; +import { HotbarStore } from "../common/hotbar-store.injectable"; import { UserStore } from "../common/user-store"; import { WeblinkStore } from "../common/weblink-store"; import { ExtensionsStore } from "../extensions/extensions-store"; diff --git a/src/main/menu/menu.ts b/src/main/menu/menu.ts index 512d4e916d..13cca14ac7 100644 --- a/src/main/menu/menu.ts +++ b/src/main/menu/menu.ts @@ -220,6 +220,8 @@ export function getAppMenu( /** * Don't broadcast unless it was triggered by menu iteration so that * there aren't double events in renderer + * + * NOTE: this `?` is required because of a bug in playwright. https://github.com/microsoft/playwright/issues/10554 */ if (!event?.triggeredByAccelerator) { broadcastMessage("command-palette:open"); diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index af2309bc4c..10eb124c45 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -20,7 +20,7 @@ */ import { computed, observable, makeObservable, action } from "mobx"; -import { ipcRendererOn } from "../../common/ipc"; +import { catalogEntityRunListener, ipcRendererOn } from "../../common/ipc"; import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; import "../../common/catalog-entities"; import type { Cluster } from "../../main/cluster"; @@ -88,7 +88,7 @@ export class CatalogEntityRegistry { ipcRenderer.send(CatalogIpcEvents.INIT); if (isMainFrame) { - ipcRendererOn("catalog-entity:run", (event, id: string) => { + ipcRendererOn(catalogEntityRunListener, (event, id: string) => { const entity = this.getById(id); if (entity) { diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 210d78267b..9174d30fb2 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -40,7 +40,7 @@ import { DefaultProps } from "./mui-base-theme"; import configurePackages from "../common/configure-packages"; import * as initializers from "./initializers"; import logger from "../common/logger"; -import { HotbarStore } from "../common/hotbar-store"; +import { HotbarStore } from "../common/hotbar-store.injectable"; import { WeblinkStore } from "../common/weblink-store"; import { ExtensionsStore } from "../extensions/extensions-store"; import { FilesystemProvisionerStore } from "../main/extension-filesystem"; @@ -59,6 +59,7 @@ import bindProtocolAddRouteHandlersInjectable import type { LensProtocolRouterRenderer } from "./protocol-handler"; import lensProtocolRouterRendererInjectable from "./protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; +import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable"; if (process.isMainFrame) { SentryInit(); @@ -121,7 +122,9 @@ export async function bootstrap(comp: () => Promise, di: Dependenc initializers.initCatalogCategoryRegistryEntries(); logger.info(`${logPrefix} initializing Catalog`); - initializers.initCatalog(); + initializers.initCatalog({ + openCommandDialog: di.inject(commandOverlayInjectable).open, + }); const extensionLoader = di.inject(extensionLoaderInjectable); diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 595ba6a81c..7774377ccc 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -29,7 +29,7 @@ import { CatalogEntityStore } from "./catalog-entity.store"; import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; -import { HotbarStore } from "../../../common/hotbar-store"; +import { HotbarStore } from "../../../common/hotbar-store.injectable"; import { ConfirmDialog } from "../confirm-dialog"; import { catalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; diff --git a/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx b/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx index da89ff3f26..260b0d5301 100644 --- a/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx +++ b/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx @@ -20,7 +20,7 @@ */ import React, { ReactNode, useState } from "react"; -import { HotbarStore } from "../../../common/hotbar-store"; +import { HotbarStore } from "../../../common/hotbar-store.injectable"; import { MenuItem } from "../menu"; import type { CatalogEntity } from "../../api/catalog-entity"; diff --git a/src/common/k8s-api/endpoints/custom-resources/custom-resources.injectable.ts b/src/renderer/components/+custom-resources/custom-resources.injectable.ts similarity index 94% rename from src/common/k8s-api/endpoints/custom-resources/custom-resources.injectable.ts rename to src/renderer/components/+custom-resources/custom-resources.injectable.ts index 4a83b7de70..99a532f69f 100644 --- a/src/common/k8s-api/endpoints/custom-resources/custom-resources.injectable.ts +++ b/src/renderer/components/+custom-resources/custom-resources.injectable.ts @@ -21,7 +21,7 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import { crdStore } from "../../../../renderer/components/+custom-resources/crd.store"; +import { crdStore } from "./crd.store"; const customResourceDefinitionsInjectable = getInjectable({ instantiate: () => computed(() => [...crdStore.items]), diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 4ebdb6156f..1ad53d2fd6 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -34,7 +34,7 @@ import { mockWindow } from "../../../../../__mocks__/windowMock"; import { AppPaths } from "../../../../common/app-paths"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../test-utils/renderFor"; mockWindow(); diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index 74cf65b20d..3d1ba9b7ce 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -24,7 +24,7 @@ import { screen } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { defaultWidth, Welcome } from "../welcome"; import { computed } from "mobx"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; diff --git a/src/renderer/components/activate-entity-command/activate-entity-command.tsx b/src/renderer/components/activate-entity-command/activate-entity-command.tsx index 46d1a8db1f..57b1cb968a 100644 --- a/src/renderer/components/activate-entity-command/activate-entity-command.tsx +++ b/src/renderer/components/activate-entity-command/activate-entity-command.tsx @@ -19,41 +19,49 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { computed } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { computed, IComputedValue } from "mobx"; import { observer } from "mobx-react"; import React from "react"; -import { broadcastMessage } from "../../../common/ipc"; +import { broadcastMessage, catalogEntityRunListener } from "../../../common/ipc"; import type { CatalogEntity } from "../../api/catalog-entity"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { CommandOverlay } from "../command-palette"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import { Select } from "../select"; -@observer -export class ActivateEntityCommand extends React.Component { - @computed get options() { - return catalogEntityRegistry.items.map(entity => ({ - label: `${entity.kind}: ${entity.getName()}`, - value: entity, - })); - } - - onSelect(entity: CatalogEntity): void { - broadcastMessage("catalog-entity:run", entity.getId()); - CommandOverlay.close(); - } - - render() { - return ( - onSelect(v.value)} + components={{ DropdownIndicator: null, IndicatorSeparator: null }} + menuIsOpen={true} + options={options} + autoFocus={true} + escapeClearsValue={false} + placeholder="Activate entity ..." + /> + ); +}); + +export const ActivateEntityCommand = withInjectables(NonInjectedActivateEntityCommand, { + getProps: di => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + entities: computed(() => [...catalogEntityRegistry.items]), + }), +}); diff --git a/src/renderer/components/catalog-entities/weblink-add-command.tsx b/src/renderer/components/catalog-entities/weblink-add-command.tsx index 7d48622658..230c66987a 100644 --- a/src/renderer/components/catalog-entities/weblink-add-command.tsx +++ b/src/renderer/components/catalog-entities/weblink-add-command.tsx @@ -21,21 +21,26 @@ import React from "react"; import { observer } from "mobx-react"; -import { CommandOverlay } from "../command-palette"; import { Input } from "../input"; import { isUrl } from "../input/input_validators"; import { WeblinkStore } from "../../../common/weblink-store"; import { computed, makeObservable, observable } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; + +interface Dependencies { + closeCommandOverlay: () => void; +} + @observer -export class WeblinkAddCommand extends React.Component { +class NonInjectedWeblinkAddCommand extends React.Component { @observable url = ""; @observable nameHidden = true; @observable dirty = false; - constructor(props: {}) { + constructor(props: Dependencies) { super(props); - makeObservable(this); } @@ -55,8 +60,7 @@ export class WeblinkAddCommand extends React.Component { name: name || this.url, url: this.url, }); - - CommandOverlay.close(); + this.props.closeCommandOverlay(); } @computed get showValidation() { @@ -100,3 +104,10 @@ export class WeblinkAddCommand extends React.Component { ); } } + +export const WeblinkAddCommand = withInjectables(NonInjectedWeblinkAddCommand, { + getProps: (di, props) => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + ...props, + }), +}); diff --git a/src/renderer/components/cluster-manager/active-hotbar-name.tsx b/src/renderer/components/cluster-manager/active-hotbar-name.tsx index 63f8b583b9..344299c110 100644 --- a/src/renderer/components/cluster-manager/active-hotbar-name.tsx +++ b/src/renderer/components/cluster-manager/active-hotbar-name.tsx @@ -22,19 +22,31 @@ import React from "react"; import { observer } from "mobx-react"; import { Icon } from "../icon"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -export const ActiveHotbarName = observer(() => { - return ( -
    CommandOverlay.open()} - > - - {HotbarStore.getInstance().getActive()?.name} -
    - ); +interface Dependencies { + openCommandOverlay: (component: React.ReactElement) => void; + activeHotbarName: () => string | undefined; +} + +const NonInjectedActiveHotbarName = observer(({ openCommandOverlay, activeHotbarName }: Dependencies) => ( +
    openCommandOverlay()} + > + + {activeHotbarName()} +
    +)); + +export const ActiveHotbarName = withInjectables(NonInjectedActiveHotbarName, { + getProps: (di, props) => ({ + activeHotbarName: () => di.inject(hotbarManagerInjectable).getActive()?.name, + openCommandOverlay: di.inject(commandOverlayInjectable).open, + ...props, + }), }); diff --git a/src/renderer/components/cluster-manager/bottom-bar.test.tsx b/src/renderer/components/cluster-manager/bottom-bar.test.tsx index 4390175889..0733747a97 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.test.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.test.tsx @@ -21,21 +21,19 @@ import React from "react"; import mockFs from "mock-fs"; -import { render, fireEvent } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { BottomBar } from "./bottom-bar"; import { StatusBarRegistry } from "../../../extensions/registries"; -import { HotbarStore } from "../../../common/hotbar-store"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { AppPaths } from "../../../common/app-paths"; -import { CommandOverlay } from "../command-palette"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; import { ActiveHotbarName } from "./active-hotbar-name"; - -jest.mock("../command-palette", () => ({ - CommandOverlay: { - open: jest.fn(), - }, -})); +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../test-utils/renderFor"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import { getEmptyHotbar } from "../../../common/hotbar-types"; AppPaths.init(); @@ -55,7 +53,12 @@ jest.mock("electron", () => ({ }, })); +const foobarHotbar = getEmptyHotbar("foobar"); + describe("", () => { + let di: ConfigurableDependencyInjectionContainer; + let render: DiRender; + beforeEach(() => { const mockOpts = { "tmp": { @@ -63,14 +66,19 @@ describe("", () => { }, }; + di = getDiForUnitTesting(); + render = renderFor(di); + mockFs(mockOpts); StatusBarRegistry.createInstance(); - HotbarStore.createInstance(); + + di.override(hotbarManagerInjectable, () => ({ + getActive: () => foobarHotbar, + } as any)); }); afterEach(() => { StatusBarRegistry.resetInstance(); - HotbarStore.resetInstance(); mockFs.restore(); }); @@ -80,24 +88,20 @@ describe("", () => { expect(container).toBeInstanceOf(HTMLElement); }); - it("renders w/o errors when .getItems() returns unexpected (not type compliant) data", async () => { - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => undefined); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => "hello"); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => 6); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => null); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => []); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [{}]); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => { return {};}); + it.each([ + undefined, + "hello", + 6, + null, + [], + [{}], + {}, + ])("renders w/o errors when .getItems() returns not type compliant (%p)", val => { + StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => val); expect(() => render()).not.toThrow(); }); - it("renders items [{item: React.ReactNode}] (4.0.0-rc.1)", async () => { + it("renders items [{item: React.ReactNode}] (4.0.0-rc.1)", () => { const testId = "testId"; const text = "heee"; @@ -106,10 +110,10 @@ describe("", () => { ]); const { getByTestId } = render(); - expect(await getByTestId(testId)).toHaveTextContent(text); + expect(getByTestId(testId)).toHaveTextContent(text); }); - it("renders items [{item: () => React.ReactNode}] (4.0.0-rc.1+)", async () => { + it("renders items [{item: () => React.ReactNode}] (4.0.0-rc.1+)", () => { const testId = "testId"; const text = "heee"; @@ -118,33 +122,25 @@ describe("", () => { ]); const { getByTestId } = render(); - expect(await getByTestId(testId)).toHaveTextContent(text); + expect(getByTestId(testId)).toHaveTextContent(text); }); - it("show default hotbar name", () => { + it("shows active hotbar name", () => { StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ { item: () => }, ]); const { getByTestId } = render(); - expect(getByTestId("current-hotbar-name")).toHaveTextContent("default"); - }); - - it("show active hotbar name", () => { - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ - { item: () => }, - ]); - const { getByTestId } = render(); - - HotbarStore.getInstance().add({ - id: "new", - name: "new", - }, { setActive: true }); - - expect(getByTestId("current-hotbar-name")).toHaveTextContent("new"); + expect(getByTestId("current-hotbar-name")).toHaveTextContent("foobar"); }); it("opens command palette on click", () => { + const mockOpen = jest.fn(); + + di.override(commandOverlayInjectable, () => ({ + open: mockOpen, + }) as any); + StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ { item: () => }, ]); @@ -153,7 +149,8 @@ describe("", () => { fireEvent.click(activeHotbar); - expect(CommandOverlay.open).toHaveBeenCalledWith(); + + expect(mockOpen).toHaveBeenCalledWith(); }); it("sort positioned items properly", () => { diff --git a/src/renderer/components/command-palette/command-container.tsx b/src/renderer/components/command-palette/command-container.tsx index 8450129574..711b16b142 100644 --- a/src/renderer/components/command-palette/command-container.tsx +++ b/src/renderer/components/command-palette/command-container.tsx @@ -26,41 +26,43 @@ import React from "react"; import { Dialog } from "../dialog"; import { CommandDialog } from "./command-dialog"; import type { ClusterId } from "../../../common/cluster-types"; -import { CommandOverlay } from "./command-overlay"; +import commandOverlayInjectable, { CommandOverlay } from "./command-overlay.injectable"; import { isMac } from "../../../common/vars"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { broadcastMessage, ipcRendererOn } from "../../../common/ipc"; import { getMatchedClusterId } from "../../navigation"; import type { Disposer } from "../../utils"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import windowAddEventListenerInjectable from "../../window/event-listener.injectable"; export interface CommandContainerProps { clusterId?: ClusterId; } -function addWindowEventListener(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer { - window.addEventListener(type, listener, options); - - return () => { - window.removeEventListener(type, listener); - }; +interface Dependencies { + addWindowEventListener: (type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) => Disposer; + commandOverlay: CommandOverlay, } @observer -export class CommandContainer extends React.Component { +class NonInjectedCommandContainer extends React.Component { private escHandler(event: KeyboardEvent) { + const { commandOverlay } = this.props; + if (event.key === "Escape") { event.stopPropagation(); - CommandOverlay.close(); + commandOverlay.close(); } } handleCommandPalette = () => { + const { commandOverlay } = this.props; const clusterIsActive = getMatchedClusterId() !== undefined; if (clusterIsActive) { broadcastMessage(`command-palette:${catalogEntityRegistry.activeEntity.getId()}:open`); } else { - CommandOverlay.open(); + commandOverlay.open(); } }; @@ -75,11 +77,13 @@ export class CommandContainer extends React.Component { } componentDidMount() { - const action = this.props.clusterId - ? () => CommandOverlay.open() + const { clusterId, addWindowEventListener, commandOverlay } = this.props; + + const action = clusterId + ? () => commandOverlay.open() : this.handleCommandPalette; - const ipcChannel = this.props.clusterId - ? `command-palette:${this.props.clusterId}:open` + const ipcChannel = clusterId + ? `command-palette:${clusterId}:open` : "command-palette:open"; disposeOnUnmount(this, [ @@ -90,17 +94,27 @@ export class CommandContainer extends React.Component { } render() { + const { commandOverlay } = this.props; + return (
    - {CommandOverlay.component} + {commandOverlay.component}
    ); } } + +export const CommandContainer = withInjectables(NonInjectedCommandContainer, { + getProps: (di, props) => ({ + addWindowEventListener: di.inject(windowAddEventListenerInjectable), + commandOverlay: di.inject(commandOverlayInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/command-palette/command-dialog.tsx b/src/renderer/components/command-palette/command-dialog.tsx index 4c495ee494..01f095ec16 100644 --- a/src/renderer/components/command-palette/command-dialog.tsx +++ b/src/renderer/components/command-palette/command-dialog.tsx @@ -24,7 +24,7 @@ import { Select } from "../select"; import type { IComputedValue } from "mobx"; import { observer } from "mobx-react"; import React, { useState } from "react"; -import { CommandOverlay } from "./command-overlay"; +import commandOverlayInjectable from "./command-overlay.injectable"; import type { CatalogEntity } from "../../../common/catalog"; import { navigate } from "../../navigation"; import { broadcastMessage } from "../../../common/ipc"; @@ -39,9 +39,10 @@ import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; interface Dependencies { commands: IComputedValue>; activeEntity?: CatalogEntity; + closeCommandOverlay: () => void; } -const NonInjectedCommandDialog = observer(({ commands, activeEntity }: Dependencies) => { +const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeCommandOverlay }: Dependencies) => { const [searchValue, setSearchValue] = useState(""); const executeAction = (commandId: string) => { @@ -52,7 +53,7 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity }: Dependenc } try { - CommandOverlay.close(); + closeCommandOverlay(); command.action({ entity: activeEntity, navigate: (url, opts = {}) => { @@ -121,5 +122,6 @@ export const CommandDialog = withInjectables(NonInjectedCommandDia commands: di.inject(registeredCommandsInjectable), // TODO: replace with injection activeEntity: catalogEntityRegistry.activeEntity, + closeCommandOverlay: di.inject(commandOverlayInjectable).close, }), }); diff --git a/src/renderer/components/command-palette/command-overlay.ts b/src/renderer/components/command-palette/command-overlay.injectable.ts similarity index 68% rename from src/renderer/components/command-palette/command-overlay.ts rename to src/renderer/components/command-palette/command-overlay.injectable.ts index 1058b2f9d3..643e81ac90 100644 --- a/src/renderer/components/command-palette/command-overlay.ts +++ b/src/renderer/components/command-palette/command-overlay.injectable.ts @@ -19,29 +19,37 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { observable } from "mobx"; import React from "react"; export class CommandOverlay { - static #component = observable.box(null, { deep: false }); + #component = observable.box(null, { deep: false }); - static get isOpen(): boolean { - return Boolean(CommandOverlay.#component.get()); + get isOpen(): boolean { + return Boolean(this.#component.get()); } - static open(component: React.ReactElement) { + open = (component: React.ReactElement) => { if (!React.isValidElement(component)) { throw new TypeError("CommandOverlay.open must be passed a valid ReactElement"); } - CommandOverlay.#component.set(component); - } + this.#component.set(component); + }; - static close() { - CommandOverlay.#component.set(null); - } + close = () => { + this.#component.set(null); + }; - static get component(): React.ReactElement | null { - return CommandOverlay.#component.get(); + get component(): React.ReactElement | null { + return this.#component.get(); } } + +const commandOverlayInjectable = getInjectable({ + instantiate: () => new CommandOverlay(), + lifecycle: lifecycleEnum.singleton, +}); + +export default commandOverlayInjectable; diff --git a/src/renderer/components/command-palette/index.ts b/src/renderer/components/command-palette/index.ts index 27169ddae2..8aa1da106c 100644 --- a/src/renderer/components/command-palette/index.ts +++ b/src/renderer/components/command-palette/index.ts @@ -21,4 +21,4 @@ export * from "./command-container"; export * from "./command-dialog"; -export * from "./command-overlay"; +export * from "./command-overlay.injectable"; diff --git a/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx b/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx new file mode 100644 index 0000000000..4fc37a902e --- /dev/null +++ b/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import * as routes from "../../../../common/routes"; +import { EntitySettingRegistry, RegisteredEntitySetting } from "../../../../extensions/registries"; +import { createTerminalTab } from "../../dock/terminal.store"; +import { HotbarAddCommand } from "../../hotbar/hotbar-add-command"; +import { HotbarRemoveCommand } from "../../hotbar/hotbar-remove-command"; +import { HotbarSwitchCommand } from "../../hotbar/hotbar-switch-command"; +import { HotbarRenameCommand } from "../../hotbar/hotbar-rename-command"; +import { ActivateEntityCommand } from "../../activate-entity-command"; +import type { CommandContext, CommandRegistration } from "./commands"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import commandOverlayInjectable from "../command-overlay.injectable"; + +export function isKubernetesClusterActive(context: CommandContext): boolean { + return context.entity?.kind === "KubernetesCluster"; +} + +interface Dependencies { + openCommandDialog: (component: React.ReactElement) => void; + getEntitySettingItems: (kind: string, apiVersion: string, source?: string) => RegisteredEntitySetting[]; +} + +function getInternalCommands({ openCommandDialog, getEntitySettingItems }: Dependencies): CommandRegistration[] { + return [ + { + id: "app.showPreferences", + title: "Preferences: Open", + action: ({ navigate }) => navigate(routes.preferencesURL(), { + forceRootFrame: true, + }), + }, + { + id: "cluster.viewHelmCharts", + title: "Cluster: View Helm Charts", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.helmChartsURL()), + }, + { + id: "cluster.viewHelmReleases", + title: "Cluster: View Helm Releases", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.releaseURL()), + }, + { + id: "cluster.viewConfigMaps", + title: "Cluster: View ConfigMaps", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.configMapsURL()), + }, + { + id: "cluster.viewSecrets", + title: "Cluster: View Secrets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.secretsURL()), + }, + { + id: "cluster.viewResourceQuotas", + title: "Cluster: View ResourceQuotas", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.resourceQuotaURL()), + }, + { + id: "cluster.viewLimitRanges", + title: "Cluster: View LimitRanges", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.limitRangeURL()), + }, + { + id: "cluster.viewHorizontalPodAutoscalers", + title: "Cluster: View HorizontalPodAutoscalers (HPA)", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.hpaURL()), + }, + { + id: "cluster.viewPodDisruptionBudget", + title: "Cluster: View PodDisruptionBudgets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.pdbURL()), + }, + { + id: "cluster.viewServices", + title: "Cluster: View Services", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.servicesURL()), + }, + { + id: "cluster.viewEndpoints", + title: "Cluster: View Endpoints", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.endpointURL()), + }, + { + id: "cluster.viewIngresses", + title: "Cluster: View Ingresses", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.ingressURL()), + }, + { + id: "cluster.viewNetworkPolicies", + title: "Cluster: View NetworkPolicies", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.networkPoliciesURL()), + }, + { + id: "cluster.viewNodes", + title: "Cluster: View Nodes", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.nodesURL()), + }, + { + id: "cluster.viewPods", + title: "Cluster: View Pods", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.podsURL()), + }, + { + id: "cluster.viewDeployments", + title: "Cluster: View Deployments", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.deploymentsURL()), + }, + { + id: "cluster.viewDaemonSets", + title: "Cluster: View DaemonSets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.daemonSetsURL()), + }, + { + id: "cluster.viewStatefulSets", + title: "Cluster: View StatefulSets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.statefulSetsURL()), + }, + { + id: "cluster.viewJobs", + title: "Cluster: View Jobs", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.jobsURL()), + }, + { + id: "cluster.viewCronJobs", + title: "Cluster: View CronJobs", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.cronJobsURL()), + }, + { + id: "cluster.viewCustomResourceDefinitions", + title: "Cluster: View Custom Resource Definitions", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.crdURL()), + }, + { + id: "entity.viewSettings", + title: ({ entity }) => `${entity.kind}/${entity.getName()}: View Settings`, + action: ({ entity, navigate }) => navigate(`/entity/${entity.getId()}/settings`, { + forceRootFrame: true, + }), + isActive: ({ entity }) => { + if (!entity) { + return false; + } + + return getEntitySettingItems(entity.kind, entity.apiVersion, entity.metadata.source).length > 0; + }, + }, + { + id: "cluster.openTerminal", + title: "Cluster: Open terminal", + action: () => createTerminalTab(), + isActive: isKubernetesClusterActive, + }, + { + id: "hotbar.switchHotbar", + title: "Hotbar: Switch ...", + action: () => openCommandDialog(), + }, + { + id: "hotbar.addHotbar", + title: "Hotbar: Add Hotbar ...", + action: () => openCommandDialog(), + }, + { + id: "hotbar.removeHotbar", + title: "Hotbar: Remove Hotbar ...", + action: () => openCommandDialog(), + }, + { + id: "hotbar.renameHotbar", + title: "Hotbar: Rename Hotbar ...", + action: () => openCommandDialog(), + }, + { + id: "catalog.searchEntities", + title: "Catalog: Activate Entity ...", + action: () => openCommandDialog(), + }, + ]; +} + +const internalCommandsInjectable = getInjectable({ + instantiate: (di) => getInternalCommands({ + openCommandDialog: di.inject(commandOverlayInjectable).open, + getEntitySettingItems: EntitySettingRegistry + .getInstance() + .getItemsForKind, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default internalCommandsInjectable; diff --git a/src/renderer/components/command-palette/registered-commands/internal-commands.tsx b/src/renderer/components/command-palette/registered-commands/internal-commands.tsx deleted file mode 100644 index 1b40f20f0e..0000000000 --- a/src/renderer/components/command-palette/registered-commands/internal-commands.tsx +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import React from "react"; -import * as routes from "../../../../common/routes"; -import { EntitySettingRegistry } from "../../../../extensions/registries"; -import { CommandOverlay } from "../../../components/command-palette"; -import { createTerminalTab } from "../../../components/dock/terminal.store"; -import { HotbarAddCommand } from "../../../components/hotbar/hotbar-add-command"; -import { HotbarRemoveCommand } from "../../../components/hotbar/hotbar-remove-command"; -import { HotbarSwitchCommand } from "../../../components/hotbar/hotbar-switch-command"; -import { HotbarRenameCommand } from "../../../components/hotbar/hotbar-rename-command"; -import { ActivateEntityCommand } from "../../../components/activate-entity-command"; -import type { CommandContext, CommandRegistration } from "./commands"; - -export function isKubernetesClusterActive(context: CommandContext): boolean { - return context.entity?.kind === "KubernetesCluster"; -} - -export const internalCommands: CommandRegistration[] = [ - { - id: "app.showPreferences", - title: "Preferences: Open", - action: ({ navigate }) => navigate(routes.preferencesURL(), { - forceRootFrame: true, - }), - }, - { - id: "cluster.viewHelmCharts", - title: "Cluster: View Helm Charts", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.helmChartsURL()), - }, - { - id: "cluster.viewHelmReleases", - title: "Cluster: View Helm Releases", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.releaseURL()), - }, - { - id: "cluster.viewConfigMaps", - title: "Cluster: View ConfigMaps", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.configMapsURL()), - }, - { - id: "cluster.viewSecrets", - title: "Cluster: View Secrets", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.secretsURL()), - }, - { - id: "cluster.viewResourceQuotas", - title: "Cluster: View ResourceQuotas", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.resourceQuotaURL()), - }, - { - id: "cluster.viewLimitRanges", - title: "Cluster: View LimitRanges", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.limitRangeURL()), - }, - { - id: "cluster.viewHorizontalPodAutoscalers", - title: "Cluster: View HorizontalPodAutoscalers (HPA)", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.hpaURL()), - }, - { - id: "cluster.viewPodDisruptionBudget", - title: "Cluster: View PodDisruptionBudgets", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.pdbURL()), - }, - { - id: "cluster.viewServices", - title: "Cluster: View Services", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.servicesURL()), - }, - { - id: "cluster.viewEndpoints", - title: "Cluster: View Endpoints", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.endpointURL()), - }, - { - id: "cluster.viewIngresses", - title: "Cluster: View Ingresses", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.ingressURL()), - }, - { - id: "cluster.viewNetworkPolicies", - title: "Cluster: View NetworkPolicies", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.networkPoliciesURL()), - }, - { - id: "cluster.viewNodes", - title: "Cluster: View Nodes", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.nodesURL()), - }, - { - id: "cluster.viewPods", - title: "Cluster: View Pods", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.podsURL()), - }, - { - id: "cluster.viewDeployments", - title: "Cluster: View Deployments", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.deploymentsURL()), - }, - { - id: "cluster.viewDaemonSets", - title: "Cluster: View DaemonSets", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.daemonSetsURL()), - }, - { - id: "cluster.viewStatefulSets", - title: "Cluster: View StatefulSets", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.statefulSetsURL()), - }, - { - id: "cluster.viewJobs", - title: "Cluster: View Jobs", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.jobsURL()), - }, - { - id: "cluster.viewCronJobs", - title: "Cluster: View CronJobs", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.cronJobsURL()), - }, - { - id: "cluster.viewCustomResourceDefinitions", - title: "Cluster: View Custom Resource Definitions", - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(routes.crdURL()), - }, - { - id: "entity.viewSettings", - title: ({ entity }) => `${entity.kind}/${entity.getName()}: View Settings`, - action: ({ entity, navigate }) => navigate(`/entity/${entity.getId()}/settings`, { - forceRootFrame: true, - }), - isActive: ({ entity }) => { - if (!entity) { - return false; - } - - // TODO: replace with injection - const entries = EntitySettingRegistry.getInstance() - .getItemsForKind(entity.kind, entity.apiVersion, entity.metadata.source); - - return entries.length > 0; - }, - }, - { - id: "cluster.openTerminal", - title: "Cluster: Open terminal", - action: () => createTerminalTab(), - isActive: isKubernetesClusterActive, - }, - { - id: "hotbar.switchHotbar", - title: "Hotbar: Switch ...", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.addHotbar", - title: "Hotbar: Add Hotbar ...", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.removeHotbar", - title: "Hotbar: Remove Hotbar ...", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.renameHotbar", - title: "Hotbar: Rename Hotbar ...", - action: () => CommandOverlay.open(), - }, - { - id: "catalog.searchEntities", - title: "Catalog: Activate Entity ...", - action: () => CommandOverlay.open(), - }, -]; diff --git a/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts b/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts index 0f263720d0..a9bbd6bc57 100644 --- a/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts +++ b/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts @@ -22,32 +22,30 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { computed, IComputedValue } from "mobx"; import type { CustomResourceDefinition } from "../../../../common/k8s-api/endpoints"; -import customResourceDefinitionsInjectable from "../../../../common/k8s-api/endpoints/custom-resources/custom-resources.injectable"; +import customResourceDefinitionsInjectable from "../../+custom-resources/custom-resources.injectable"; import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; -import { iter } from "../../../utils"; -import type { RegisteredCommand } from "./commands"; -import { internalCommands, isKubernetesClusterActive } from "./internal-commands"; +import type { CommandRegistration, RegisteredCommand } from "./commands"; +import internalCommandsInjectable, { isKubernetesClusterActive } from "./internal-commands.injectable"; interface Dependencies { extensions: IComputedValue; customResourceDefinitions: IComputedValue; + internalCommands: CommandRegistration[]; } -const instantiateRegisteredCommands = ({ extensions, customResourceDefinitions }: Dependencies) => computed(() => { +const instantiateRegisteredCommands = ({ extensions, customResourceDefinitions, internalCommands }: Dependencies) => computed(() => { const result = new Map(); - const commands = iter.chain( - internalCommands, - iter.chain( - iter.flatMap(extensions.get(), extension => extension.commands), - iter.map(customResourceDefinitions.get(), command => ({ - id: `cluster.view.${command.getResourceKind()}`, - title: `Cluster: View ${command.getResourceKind()}`, - isActive: isKubernetesClusterActive, - action: ({ navigate }) => navigate(command.getResourceUrl()), - })), - ), - ); + const commands = [ + ...internalCommands, + ...extensions.get().flatMap(e => e.commands), + ...customResourceDefinitions.get().map((command): CommandRegistration => ({ + id: `cluster.view.${command.getResourceKind()}`, + title: `Cluster: View ${command.getResourceKind()}`, + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(command.getResourceUrl()), + })), + ]; for (const { scope, isActive = () => true, ...command } of commands) { if (!result.has(command.id)) { @@ -62,6 +60,7 @@ const registeredCommandsInjectable = getInjectable({ instantiate: (di) => instantiateRegisteredCommands({ extensions: di.inject(rendererExtensionsInjectable), customResourceDefinitions: di.inject(customResourceDefinitionsInjectable), + internalCommands: di.inject(internalCommandsInjectable), }), lifecycle: lifecycleEnum.singleton, diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx index e6a03444b6..41ac450aae 100644 --- a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx @@ -31,7 +31,7 @@ import { saveKubeconfig } from "./save-config"; import { requestMain } from "../../../common/ipc"; import { clusterClearDeletingHandler, clusterDeleteHandler, clusterSetDeletingHandler } from "../../../common/cluster-ipc"; import { Notifications } from "../notifications"; -import { HotbarStore } from "../../../common/hotbar-store"; +import { HotbarStore } from "../../../common/hotbar-store.injectable"; import { boundMethod } from "autobind-decorator"; import { Dialog } from "../dialog"; import { Icon } from "../icon"; diff --git a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx index 7a292e7e5f..330bc2930d 100644 --- a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -21,13 +21,16 @@ import "@testing-library/jest-dom/extend-expect"; import { HotbarRemoveCommand } from "../hotbar-remove-command"; -import { render, fireEvent } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import React from "react"; -import { ThemeStore } from "../../../theme.store"; -import { UserStore } from "../../../../common/user-store"; -import { Notifications } from "../../notifications"; -import mockFs from "mock-fs"; import { AppPaths } from "../../../../common/app-paths"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import hotbarManagerInjectable, { HotbarStore } from "../../../../common/hotbar-store.injectable"; +import { UserStore } from "../../../../common/user-store"; +import { ThemeStore } from "../../../theme.store"; +import { ConfirmDialog } from "../../confirm-dialog"; jest.mock("electron", () => ({ app: { @@ -55,45 +58,58 @@ const mockHotbars: { [id: string]: any } = { }, }; -jest.mock("../../../../common/hotbar-store", () => ({ - HotbarStore: { - getInstance: () => ({ - hotbars: [mockHotbars["1"]], - getById: (id: string) => mockHotbars[id], - remove: () => {}, - hotbarIndex: () => 0, - }), - }, -})); - describe("", () => { + let di: ConfigurableDependencyInjectionContainer; + let render: DiRender; + beforeEach(() => { - mockFs({ - "tmp": {}, - }); + di = getDiForUnitTesting(); + render = renderFor(di); + UserStore.createInstance(); ThemeStore.createInstance(); }); afterEach(() => { - UserStore.resetInstance(); ThemeStore.resetInstance(); - mockFs.restore(); + UserStore.resetInstance(); }); it("renders w/o errors", () => { + di.override(hotbarManagerInjectable, () => ({ + hotbars: [mockHotbars["1"]], + getById: (id: string) => mockHotbars[id], + remove: () => { }, + hotbarIndex: () => 0, + getDisplayLabel: () => "1: Default", + }) as any as HotbarStore); + const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); - it("displays error notification if user tries to remove last hotbar", () => { - const spy = jest.spyOn(Notifications, "error"); - const { getByText } = render(); + it("calls remove if you click on the entry", () => { + const removeMock = jest.fn(); + + di.override(hotbarManagerInjectable, () => ({ + hotbars: [mockHotbars["1"]], + getById: (id: string) => mockHotbars[id], + remove: removeMock, + hotbarIndex: () => 0, + getDisplayLabel: () => "1: Default", + }) as any as HotbarStore); + + const { getByText } = render( + <> + + + , + ); fireEvent.click(getByText("1: Default")); + fireEvent.click(getByText("Remove Hotbar")); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); + expect(removeMock).toHaveBeenCalled(); }); }); diff --git a/src/renderer/components/hotbar/hotbar-add-command.tsx b/src/renderer/components/hotbar/hotbar-add-command.tsx index 20c532e252..2380a76f10 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -21,9 +21,11 @@ import React from "react"; import { observer } from "mobx-react"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable, { HotbarStore } from "../../../common/hotbar-store.injectable"; import { Input, InputValidator } from "../input"; +import type { CreateHotbarData, CreateHotbarOptions } from "../../../common/hotbar-types"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; export const uniqueHotbarName: InputValidator = { condition: ({ required }) => required, @@ -31,34 +33,44 @@ export const uniqueHotbarName: InputValidator = { validate: value => !HotbarStore.getInstance().getByName(value), }; -@observer -export class HotbarAddCommand extends React.Component { - onSubmit = (name: string) => { +interface Dependencies { + closeCommandOverlay: () => void; + addHotbar: (data: CreateHotbarData, { setActive }?: CreateHotbarOptions) => void; +} + +const NonInjectedHotbarAddCommand = observer(({ closeCommandOverlay, addHotbar }: Dependencies) => { + const onSubmit = (name: string) => { if (!name.trim()) { return; } - HotbarStore.getInstance().add({ name }, { setActive: true }); - CommandOverlay.close(); + addHotbar({ name }, { setActive: true }); + closeCommandOverlay(); }; - render() { - return ( - <> - - - Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel) - - - ); - } -} + return ( + <> + + + Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel) + + + ); +}); + +export const HotbarAddCommand = withInjectables(NonInjectedHotbarAddCommand, { + getProps: (di, props) => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + addHotbar: di.inject(hotbarManagerInjectable).add, + ...props, + }), +}); diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index 897d4c0360..c55aff4f19 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -26,7 +26,7 @@ import { observer } from "mobx-react"; import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { HotbarStore } from "../../../common/hotbar-store"; +import { HotbarStore } from "../../../common/hotbar-store.injectable"; import type { CatalogEntity } from "../../api/catalog-entity"; import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; import { HotbarSelector } from "./hotbar-selector"; diff --git a/src/renderer/components/hotbar/hotbar-remove-command.tsx b/src/renderer/components/hotbar/hotbar-remove-command.tsx index 58aa1d849d..04dfe273d1 100644 --- a/src/renderer/components/hotbar/hotbar-remove-command.tsx +++ b/src/renderer/components/hotbar/hotbar-remove-command.tsx @@ -22,51 +22,44 @@ import React from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import { computed, makeObservable } from "mobx"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { hotbarDisplayLabel } from "./hotbar-display-label"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { ConfirmDialog } from "../confirm-dialog"; -import { Notifications } from "../notifications"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import type { Hotbar } from "../../../common/hotbar-types"; -@observer -export class HotbarRemoveCommand extends React.Component { - constructor(props: {}) { - super(props); - makeObservable(this); - } +interface Dependencies { + closeCommandOverlay: () => void; + hotbarManager: { + hotbars: Hotbar[]; + getById: (id: string) => Hotbar | undefined; + remove: (hotbar: Hotbar) => void; + getDisplayLabel: (hotbar: Hotbar) => string; + }; +} - @computed get options() { - return HotbarStore.getInstance().hotbars.map((hotbar) => { - return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) }; - }); - } +const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarManager }: Dependencies) => { + const options = hotbarManager.hotbars.map(hotbar => ({ + value: hotbar.id, + label: hotbarManager.getDisplayLabel(hotbar), + })); - onChange(id: string): void { - const hotbarStore = HotbarStore.getInstance(); - const hotbar = hotbarStore.getById(id); - - CommandOverlay.close(); + const onChange = (id: string): void => { + const hotbar = hotbarManager.getById(id); if (!hotbar) { return; } - if (hotbarStore.hotbars.length === 1) { - Notifications.error("Can't remove the last hotbar"); - - return; - } - + closeCommandOverlay(); + // TODO: make confirm dialog injectable ConfirmDialog.open({ okButtonProps: { - label: `Remove Hotbar`, + label: "Remove Hotbar", primary: false, accent: true, }, - ok: () => { - hotbarStore.remove(hotbar); - }, + ok: () => hotbarManager.remove(hotbar), message: (

    @@ -75,19 +68,26 @@ export class HotbarRemoveCommand extends React.Component {

    ), }); - } + }; - render() { - return ( - onChange(v.value)} + components={{ DropdownIndicator: null, IndicatorSeparator: null }} + menuIsOpen={true} + options={options} + autoFocus={true} + escapeClearsValue={false} + placeholder="Remove hotbar" + /> + ); +}); + +export const HotbarRemoveCommand = withInjectables(NonInjectedHotbarRemoveCommand, { + getProps: (di, props) => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + hotbarManager: di.inject(hotbarManagerInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/hotbar/hotbar-rename-command.tsx b/src/renderer/components/hotbar/hotbar-rename-command.tsx index 6d8784a941..6ca702bf5b 100644 --- a/src/renderer/components/hotbar/hotbar-rename-command.tsx +++ b/src/renderer/components/hotbar/hotbar-rename-command.tsx @@ -19,81 +19,60 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import React from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import { action, computed, makeObservable, observable } from "mobx"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { hotbarDisplayLabel } from "./hotbar-display-label"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { Input } from "../input"; import { uniqueHotbarName } from "./hotbar-add-command"; -import { CommandOverlay } from "../command-palette"; +import type { Hotbar } from "../../../common/hotbar-types"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -@observer -export class HotbarRenameCommand extends React.Component { - @observable hotbarId = ""; - @observable hotbarName = ""; - - constructor(props: {}) { - super(props); - makeObservable(this); - } - - @computed get options() { - return HotbarStore.getInstance().hotbars.map((hotbar) => { - return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) }; - }); - } - - @action onSelect = (id: string) => { - this.hotbarId = id; - this.hotbarName = HotbarStore.getInstance().getById(this.hotbarId).name; +interface Dependencies { + closeCommandOverlay: () => void; + hotbarManager: { + hotbars: Hotbar[]; + getById: (id: string) => Hotbar | undefined; + setHotbarName: (id: string, name: string) => void; + getDisplayLabel: (hotbar: Hotbar) => string; }; +} - onSubmit = (name: string) => { +const NonInjectedHotbarRenameCommand = observer(({ closeCommandOverlay, hotbarManager }: Dependencies) => { + const [hotbarId, setHotbarId] = useState(""); + const [hotbarName, setHotbarName] = useState(""); + + const options = hotbarManager.hotbars.map(hotbar => ({ + value: hotbar.id, + label: hotbarManager.getDisplayLabel(hotbar), + })); + + const onSelect = (id: string) => { + setHotbarId(id); + setHotbarName(hotbarManager.getById(id).name); + }; + const onSubmit = (name: string) => { if (!name.trim()) { return; } - const hotbarStore = HotbarStore.getInstance(); - const hotbar = HotbarStore.getInstance().getById(this.hotbarId); - - if (!hotbar) { - return; - } - - hotbarStore.setHotbarName(this.hotbarId, name); - CommandOverlay.close(); + hotbarManager.setHotbarName(hotbarId, name); + closeCommandOverlay(); }; - renderHotbarList() { - return ( - <> - this.hotbarName = v} + value={hotbarName} + onChange={setHotbarName} placeholder="New hotbar name" autoFocus={true} theme="round-black" validators={uniqueHotbarName} - onSubmit={this.onSubmit} + onSubmit={onSubmit} showValidationLine={true} /> @@ -103,12 +82,24 @@ export class HotbarRenameCommand extends React.Component { ); } - render() { + return ( + this.onChange(v.value)} - components={{ DropdownIndicator: null, IndicatorSeparator: null }} - menuIsOpen={true} - options={this.options} - autoFocus={true} - escapeClearsValue={false} - placeholder="Switch to hotbar" /> - ); - } +interface HotbarManager { + hotbars: Hotbar[]; + setActiveHotbar: (id: string) => void; + getDisplayLabel: (hotbar: Hotbar) => string; } + +interface Dependencies { + hotbarManager: HotbarManager + commandOverlay: CommandOverlay; +} + +function getHotbarSwitchOptions(hotbarManager: HotbarManager) { + const options = hotbarManager.hotbars.map(hotbar => ({ + value: hotbar.id, + label: hotbarManager.getDisplayLabel(hotbar), + })); + + options.push({ value: addActionId, label: "Add hotbar ..." }); + + if (hotbarManager.hotbars.length > 1) { + options.push({ value: removeActionId, label: "Remove hotbar ..." }); + } + + options.push({ value: renameActionId, label: "Rename hotbar ..." }); + + return options; +} + +const NonInjectedHotbarSwitchCommand = observer(({ hotbarManager, commandOverlay }: Dependencies) => { + const options = getHotbarSwitchOptions(hotbarManager); + + const onChange = (idOrAction: string): void => { + switch (idOrAction) { + case addActionId: + return commandOverlay.open(); + case removeActionId: + return commandOverlay.open(); + case renameActionId: + return commandOverlay.open(); + default: + hotbarManager.setActiveHotbar(idOrAction); + commandOverlay.close(); + } + }; + + return ( + userStore.colorTheme = value} themeName="lens" />
    -
    +
    + +
    + + {
    -
    +
    diff --git a/src/renderer/components/dock/dock.scss b/src/renderer/components/dock/dock.scss index d473a089fa..49bbcdf6b3 100644 --- a/src/renderer/components/dock/dock.scss +++ b/src/renderer/components/dock/dock.scss @@ -76,11 +76,14 @@ .tab-content { position: relative; - background: var(--terminalBackground); flex: 1; overflow: hidden; transition: flex-basis 25ms ease-in; + &.terminal { + background: var(--terminalBackground); + } + > *:not(.Spinner) { position: absolute; left: 0; diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index 747fd37a5c..90dc49c478 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -105,7 +105,7 @@ export class Dock extends React.Component { if (!isOpen || !selectedTab) return null; return ( -
    +
    {this.renderTab(selectedTab)}
    ); diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index 383accab20..7028081784 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -26,9 +26,9 @@ import { FitAddon } from "xterm-addon-fit"; import { dockStore, TabId } from "./dock.store"; import { TerminalApi, TerminalChannels } from "../../api/terminal-api"; import { ThemeStore } from "../../theme.store"; -import { boundMethod, disposer } from "../../utils"; +import { disposer } from "../../utils"; import { isMac } from "../../../common/vars"; -import { camelCase, once } from "lodash"; +import { once } from "lodash"; import { UserStore } from "../../../common/user-store"; import { clipboard } from "electron"; import logger from "../../../common/logger"; @@ -56,23 +56,6 @@ export class Terminal { private scrollPos = 0; private disposer = disposer(); - @boundMethod - protected setTheme(colors: Record) { - if (!this.xterm) { - return; - } - - // Replacing keys stored in styles to format accepted by terminal - // E.g. terminalBrightBlack -> brightBlack - const colorPrefix = "terminal"; - const terminalColorEntries = Object.entries(colors) - .filter(([name]) => name.startsWith(colorPrefix)) - .map(([name, color]) => [camelCase(name.slice(colorPrefix.length)), color]); - const terminalColors = Object.fromEntries(terminalColorEntries); - - this.xterm.setOption("theme", terminalColors); - } - get elem() { return this.xterm?.element; } @@ -121,7 +104,9 @@ export class Terminal { window.addEventListener("resize", this.onResize); this.disposer.push( - reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, { + reaction(() => ThemeStore.getInstance().xtermColors, colors => { + this.xterm?.setOption("theme", colors); + }, { fireImmediately: true, }), dockStore.onResize(this.onResize), diff --git a/src/renderer/theme.store.ts b/src/renderer/theme.store.ts index 15aa055f92..f39a500c71 100644 --- a/src/renderer/theme.store.ts +++ b/src/renderer/theme.store.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { computed, makeObservable, observable, reaction } from "mobx"; +import { comparer, computed, makeObservable, observable, reaction } from "mobx"; import { autoBind, Singleton } from "./utils"; import { UserStore } from "../common/user-store"; import logger from "../main/logger"; @@ -28,6 +28,7 @@ import lensLightThemeJson from "./themes/lens-light.json"; import type { SelectOption } from "./components/select"; import type { MonacoEditorProps } from "./components/monaco-editor"; import { defaultTheme } from "../common/vars"; +import { camelCase } from "lodash"; export type ThemeId = string; @@ -41,7 +42,7 @@ export interface Theme { } export class ThemeStore extends Singleton { - protected styles: HTMLStyleElement; + private terminalColorPrefix = "terminal"; // bundled themes from `themes/${themeId}.json` private themes = observable.map({ @@ -49,14 +50,37 @@ export class ThemeStore extends Singleton { "lens-light": lensLightThemeJson as Theme, }); - @computed get activeThemeId(): string { + @computed get activeThemeId(): ThemeId { return UserStore.getInstance().colorTheme; } + @computed get terminalThemeId(): ThemeId { + return UserStore.getInstance().terminalTheme; + } + @computed get activeTheme(): Theme { return this.themes.get(this.activeThemeId) ?? this.themes.get(defaultTheme); } + @computed get terminalColors(): [string, string][] { + const theme = this.themes.get(this.terminalThemeId) ?? this.activeTheme; + + return Object + .entries(theme.colors) + .filter(([name]) => name.startsWith(this.terminalColorPrefix)); + } + + // Replacing keys stored in styles to format accepted by terminal + // E.g. terminalBrightBlack -> brightBlack + @computed get xtermColors(): Record { + return Object.fromEntries( + this.terminalColors.map(([name, color]) => [ + camelCase(name.replace(this.terminalColorPrefix, "")), + color, + ]), + ); + } + @computed get themeOptions(): SelectOption[] { return Array.from(this.themes).map(([themeId, theme]) => ({ label: theme.name, @@ -71,15 +95,19 @@ export class ThemeStore extends Singleton { autoBind(this); // auto-apply active theme - reaction(() => this.activeThemeId, themeId => { + reaction(() => ({ + themeId: this.activeThemeId, + terminalThemeId: this.terminalThemeId, + }), ({ themeId }) => { try { - this.applyTheme(this.getThemeById(themeId)); + this.applyTheme(themeId); } catch (err) { logger.error(err); UserStore.getInstance().resetTheme(); } }, { fireImmediately: true, + equals: comparer.shallow, }); } @@ -87,20 +115,18 @@ export class ThemeStore extends Singleton { return this.themes.get(themeId); } - protected applyTheme(theme: Theme) { - if (!this.styles) { - this.styles = document.createElement("style"); - this.styles.id = "lens-theme"; - document.head.append(this.styles); - } - const cssVars = Object.entries(theme.colors).map(([cssName, color]) => { - return `--${cssName}: ${color};`; + protected applyTheme(themeId: ThemeId) { + const theme = this.getThemeById(themeId); + const colors = Object.entries({ + ...theme.colors, + ...Object.fromEntries(this.terminalColors), }); - this.styles.textContent = `:root {\n${cssVars.join("\n")}}`; - // Adding universal theme flag which can be used in component styles - const body = document.querySelector("body"); + colors.forEach(([name, value]) => { + document.documentElement.style.setProperty(`--${name}`, value); + }); - body.classList.toggle("theme-light", theme.type === "light"); + // Adding universal theme flag which can be used in component styles + document.body.classList.toggle("theme-light", theme.type === "light"); } } diff --git a/src/renderer/themes/lens-dark.json b/src/renderer/themes/lens-dark.json index 023f9f6ccf..b48ed84cc7 100644 --- a/src/renderer/themes/lens-dark.json +++ b/src/renderer/themes/lens-dark.json @@ -1,5 +1,5 @@ { - "name": "Dark (Lens)", + "name": "Dark", "type": "dark", "description": "Original Lens dark theme", "author": "Mirantis", diff --git a/src/renderer/themes/lens-light.json b/src/renderer/themes/lens-light.json index 8525d31a56..7891f53321 100644 --- a/src/renderer/themes/lens-light.json +++ b/src/renderer/themes/lens-light.json @@ -1,5 +1,5 @@ { - "name": "Light (Lens)", + "name": "Light", "type": "light", "description": "Original Lens light theme", "author": "Mirantis", @@ -76,26 +76,26 @@ "logsBackground": "#24292e", "logsForeground": "#ffffff", "logRowHoverBackground": "#35373a", - "terminalBackground": "#24292e", - "terminalForeground": "#ffffff", - "terminalCursor": "#ffffff", - "terminalCursorAccent": "#000000", - "terminalSelection": "#ffffff77", - "terminalBlack": "#2e3436", - "terminalRed": "#cc0000", - "terminalGreen": "#4e9a06", - "terminalYellow": "#c4a000", - "terminalBlue": "#3465a4", - "terminalMagenta": "#75507b", - "terminalCyan": "#06989a", + "terminalBackground": "#ffffff", + "terminalForeground": "#2d2d2d", + "terminalCursor": "#2d2d2d", + "terminalCursorAccent": "#ffffff", + "terminalSelection": "#bfbfbf", + "terminalBlack": "#2d2d2d", + "terminalRed": "#cd3734 ", + "terminalGreen": "#18cf12", + "terminalYellow": "#acb300", + "terminalBlue": "#3d90ce", + "terminalMagenta": "#c100cd", + "terminalCyan": "#07c4b9", "terminalWhite": "#d3d7cf", - "terminalBrightBlack": "#555753", - "terminalBrightRed": "#ef2929", - "terminalBrightGreen": "#8ae234", - "terminalBrightYellow": "#fce94f", - "terminalBrightBlue": "#729fcf", - "terminalBrightMagenta": "#ad7fa8", - "terminalBrightCyan": "#34e2e2", + "terminalBrightBlack": "#a8a8a8", + "terminalBrightRed": "#ff6259", + "terminalBrightGreen": "#5cdb59", + "terminalBrightYellow": "#f8c000", + "terminalBrightBlue": "#008db6", + "terminalBrightMagenta": "#ee55f8", + "terminalBrightCyan": "#50e8df", "terminalBrightWhite": "#eeeeec", "dialogTextColor": "#87909c", "dialogBackground": "#ffffff", From 0be1ab4ce0a15994b343bcb67d050d85016ab26d Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 13 Jan 2022 11:54:54 +0100 Subject: [PATCH 27/27] Revert "Allow to customize terminal theme (#4666)" (#4682) This reverts commit a5e89b79d671968df3e44e47b26178ebb10c83e8. --- src/common/user-store/preferences-helpers.ts | 10 ---- src/common/user-store/user-store.ts | 3 - .../components/+preferences/application.tsx | 24 ++------ src/renderer/components/dock/dock.scss | 5 +- src/renderer/components/dock/dock.tsx | 2 +- src/renderer/components/dock/terminal.ts | 25 ++++++-- src/renderer/theme.store.ts | 60 ++++++------------- src/renderer/themes/lens-dark.json | 2 +- src/renderer/themes/lens-light.json | 40 ++++++------- 9 files changed, 65 insertions(+), 106 deletions(-) diff --git a/src/common/user-store/preferences-helpers.ts b/src/common/user-store/preferences-helpers.ts index e20e259556..1ded307f0d 100644 --- a/src/common/user-store/preferences-helpers.ts +++ b/src/common/user-store/preferences-helpers.ts @@ -83,15 +83,6 @@ const colorTheme: PreferenceDescription = { }, }; -const terminalTheme: PreferenceDescription = { - fromStore(val) { - return val || ""; - }, - toStore(val) { - return val || undefined; - }, -}; - const localeTimezone: PreferenceDescription = { fromStore(val) { return val || moment.tz.guess(true) || "UTC"; @@ -360,7 +351,6 @@ export const DESCRIPTORS = { httpsProxy, shell, colorTheme, - terminalTheme, localeTimezone, allowUntrustedCAs, allowTelemetry, diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index 952ca32ecc..31238f0ded 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -67,7 +67,6 @@ export class UserStore extends BaseStore /* implements UserStore @observable allowErrorReporting: boolean; @observable allowUntrustedCAs: boolean; @observable colorTheme: string; - @observable terminalTheme: string; @observable localeTimezone: string; @observable downloadMirror: string; @observable httpsProxy?: string; @@ -189,7 +188,6 @@ export class UserStore extends BaseStore /* implements UserStore this.httpsProxy = DESCRIPTORS.httpsProxy.fromStore(preferences?.httpsProxy); this.shell = DESCRIPTORS.shell.fromStore(preferences?.shell); this.colorTheme = DESCRIPTORS.colorTheme.fromStore(preferences?.colorTheme); - this.terminalTheme = DESCRIPTORS.terminalTheme.fromStore(preferences?.terminalTheme); this.localeTimezone = DESCRIPTORS.localeTimezone.fromStore(preferences?.localeTimezone); this.allowUntrustedCAs = DESCRIPTORS.allowUntrustedCAs.fromStore(preferences?.allowUntrustedCAs); this.allowTelemetry = DESCRIPTORS.allowTelemetry.fromStore(preferences?.allowTelemetry); @@ -214,7 +212,6 @@ export class UserStore extends BaseStore /* implements UserStore httpsProxy: DESCRIPTORS.httpsProxy.toStore(this.httpsProxy), shell: DESCRIPTORS.shell.toStore(this.shell), colorTheme: DESCRIPTORS.colorTheme.toStore(this.colorTheme), - terminalTheme: DESCRIPTORS.terminalTheme.toStore(this.terminalTheme), localeTimezone: DESCRIPTORS.localeTimezone.toStore(this.localeTimezone), allowUntrustedCAs: DESCRIPTORS.allowUntrustedCAs.toStore(this.allowUntrustedCAs), allowTelemetry: DESCRIPTORS.allowTelemetry.toStore(this.allowTelemetry), diff --git a/src/renderer/components/+preferences/application.tsx b/src/renderer/components/+preferences/application.tsx index dd2d1bee90..1e3c75b68f 100644 --- a/src/renderer/components/+preferences/application.tsx +++ b/src/renderer/components/+preferences/application.tsx @@ -57,38 +57,24 @@ export const Application = observer(() => { const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || ""); const [shell, setShell] = React.useState(userStore.shell || ""); const extensionSettings = AppPreferenceRegistry.getInstance().getItems().filter((preference) => preference.showInPreferencesTab === "application"); - const themeStore = ThemeStore.getInstance(); return (

    Application

    - + userStore.terminalTheme = value} - /> -
    +
    - + {
    -
    +
    diff --git a/src/renderer/components/dock/dock.scss b/src/renderer/components/dock/dock.scss index 49bbcdf6b3..d473a089fa 100644 --- a/src/renderer/components/dock/dock.scss +++ b/src/renderer/components/dock/dock.scss @@ -76,14 +76,11 @@ .tab-content { position: relative; + background: var(--terminalBackground); flex: 1; overflow: hidden; transition: flex-basis 25ms ease-in; - &.terminal { - background: var(--terminalBackground); - } - > *:not(.Spinner) { position: absolute; left: 0; diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index 90dc49c478..747fd37a5c 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -105,7 +105,7 @@ export class Dock extends React.Component { if (!isOpen || !selectedTab) return null; return ( -
    +
    {this.renderTab(selectedTab)}
    ); diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index 7028081784..383accab20 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -26,9 +26,9 @@ import { FitAddon } from "xterm-addon-fit"; import { dockStore, TabId } from "./dock.store"; import { TerminalApi, TerminalChannels } from "../../api/terminal-api"; import { ThemeStore } from "../../theme.store"; -import { disposer } from "../../utils"; +import { boundMethod, disposer } from "../../utils"; import { isMac } from "../../../common/vars"; -import { once } from "lodash"; +import { camelCase, once } from "lodash"; import { UserStore } from "../../../common/user-store"; import { clipboard } from "electron"; import logger from "../../../common/logger"; @@ -56,6 +56,23 @@ export class Terminal { private scrollPos = 0; private disposer = disposer(); + @boundMethod + protected setTheme(colors: Record) { + if (!this.xterm) { + return; + } + + // Replacing keys stored in styles to format accepted by terminal + // E.g. terminalBrightBlack -> brightBlack + const colorPrefix = "terminal"; + const terminalColorEntries = Object.entries(colors) + .filter(([name]) => name.startsWith(colorPrefix)) + .map(([name, color]) => [camelCase(name.slice(colorPrefix.length)), color]); + const terminalColors = Object.fromEntries(terminalColorEntries); + + this.xterm.setOption("theme", terminalColors); + } + get elem() { return this.xterm?.element; } @@ -104,9 +121,7 @@ export class Terminal { window.addEventListener("resize", this.onResize); this.disposer.push( - reaction(() => ThemeStore.getInstance().xtermColors, colors => { - this.xterm?.setOption("theme", colors); - }, { + reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, { fireImmediately: true, }), dockStore.onResize(this.onResize), diff --git a/src/renderer/theme.store.ts b/src/renderer/theme.store.ts index f39a500c71..15aa055f92 100644 --- a/src/renderer/theme.store.ts +++ b/src/renderer/theme.store.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { comparer, computed, makeObservable, observable, reaction } from "mobx"; +import { computed, makeObservable, observable, reaction } from "mobx"; import { autoBind, Singleton } from "./utils"; import { UserStore } from "../common/user-store"; import logger from "../main/logger"; @@ -28,7 +28,6 @@ import lensLightThemeJson from "./themes/lens-light.json"; import type { SelectOption } from "./components/select"; import type { MonacoEditorProps } from "./components/monaco-editor"; import { defaultTheme } from "../common/vars"; -import { camelCase } from "lodash"; export type ThemeId = string; @@ -42,7 +41,7 @@ export interface Theme { } export class ThemeStore extends Singleton { - private terminalColorPrefix = "terminal"; + protected styles: HTMLStyleElement; // bundled themes from `themes/${themeId}.json` private themes = observable.map({ @@ -50,37 +49,14 @@ export class ThemeStore extends Singleton { "lens-light": lensLightThemeJson as Theme, }); - @computed get activeThemeId(): ThemeId { + @computed get activeThemeId(): string { return UserStore.getInstance().colorTheme; } - @computed get terminalThemeId(): ThemeId { - return UserStore.getInstance().terminalTheme; - } - @computed get activeTheme(): Theme { return this.themes.get(this.activeThemeId) ?? this.themes.get(defaultTheme); } - @computed get terminalColors(): [string, string][] { - const theme = this.themes.get(this.terminalThemeId) ?? this.activeTheme; - - return Object - .entries(theme.colors) - .filter(([name]) => name.startsWith(this.terminalColorPrefix)); - } - - // Replacing keys stored in styles to format accepted by terminal - // E.g. terminalBrightBlack -> brightBlack - @computed get xtermColors(): Record { - return Object.fromEntries( - this.terminalColors.map(([name, color]) => [ - camelCase(name.replace(this.terminalColorPrefix, "")), - color, - ]), - ); - } - @computed get themeOptions(): SelectOption[] { return Array.from(this.themes).map(([themeId, theme]) => ({ label: theme.name, @@ -95,19 +71,15 @@ export class ThemeStore extends Singleton { autoBind(this); // auto-apply active theme - reaction(() => ({ - themeId: this.activeThemeId, - terminalThemeId: this.terminalThemeId, - }), ({ themeId }) => { + reaction(() => this.activeThemeId, themeId => { try { - this.applyTheme(themeId); + this.applyTheme(this.getThemeById(themeId)); } catch (err) { logger.error(err); UserStore.getInstance().resetTheme(); } }, { fireImmediately: true, - equals: comparer.shallow, }); } @@ -115,18 +87,20 @@ export class ThemeStore extends Singleton { return this.themes.get(themeId); } - protected applyTheme(themeId: ThemeId) { - const theme = this.getThemeById(themeId); - const colors = Object.entries({ - ...theme.colors, - ...Object.fromEntries(this.terminalColors), - }); - - colors.forEach(([name, value]) => { - document.documentElement.style.setProperty(`--${name}`, value); + protected applyTheme(theme: Theme) { + if (!this.styles) { + this.styles = document.createElement("style"); + this.styles.id = "lens-theme"; + document.head.append(this.styles); + } + const cssVars = Object.entries(theme.colors).map(([cssName, color]) => { + return `--${cssName}: ${color};`; }); + this.styles.textContent = `:root {\n${cssVars.join("\n")}}`; // Adding universal theme flag which can be used in component styles - document.body.classList.toggle("theme-light", theme.type === "light"); + const body = document.querySelector("body"); + + body.classList.toggle("theme-light", theme.type === "light"); } } diff --git a/src/renderer/themes/lens-dark.json b/src/renderer/themes/lens-dark.json index b48ed84cc7..023f9f6ccf 100644 --- a/src/renderer/themes/lens-dark.json +++ b/src/renderer/themes/lens-dark.json @@ -1,5 +1,5 @@ { - "name": "Dark", + "name": "Dark (Lens)", "type": "dark", "description": "Original Lens dark theme", "author": "Mirantis", diff --git a/src/renderer/themes/lens-light.json b/src/renderer/themes/lens-light.json index 7891f53321..8525d31a56 100644 --- a/src/renderer/themes/lens-light.json +++ b/src/renderer/themes/lens-light.json @@ -1,5 +1,5 @@ { - "name": "Light", + "name": "Light (Lens)", "type": "light", "description": "Original Lens light theme", "author": "Mirantis", @@ -76,26 +76,26 @@ "logsBackground": "#24292e", "logsForeground": "#ffffff", "logRowHoverBackground": "#35373a", - "terminalBackground": "#ffffff", - "terminalForeground": "#2d2d2d", - "terminalCursor": "#2d2d2d", - "terminalCursorAccent": "#ffffff", - "terminalSelection": "#bfbfbf", - "terminalBlack": "#2d2d2d", - "terminalRed": "#cd3734 ", - "terminalGreen": "#18cf12", - "terminalYellow": "#acb300", - "terminalBlue": "#3d90ce", - "terminalMagenta": "#c100cd", - "terminalCyan": "#07c4b9", + "terminalBackground": "#24292e", + "terminalForeground": "#ffffff", + "terminalCursor": "#ffffff", + "terminalCursorAccent": "#000000", + "terminalSelection": "#ffffff77", + "terminalBlack": "#2e3436", + "terminalRed": "#cc0000", + "terminalGreen": "#4e9a06", + "terminalYellow": "#c4a000", + "terminalBlue": "#3465a4", + "terminalMagenta": "#75507b", + "terminalCyan": "#06989a", "terminalWhite": "#d3d7cf", - "terminalBrightBlack": "#a8a8a8", - "terminalBrightRed": "#ff6259", - "terminalBrightGreen": "#5cdb59", - "terminalBrightYellow": "#f8c000", - "terminalBrightBlue": "#008db6", - "terminalBrightMagenta": "#ee55f8", - "terminalBrightCyan": "#50e8df", + "terminalBrightBlack": "#555753", + "terminalBrightRed": "#ef2929", + "terminalBrightGreen": "#8ae234", + "terminalBrightYellow": "#fce94f", + "terminalBrightBlue": "#729fcf", + "terminalBrightMagenta": "#ad7fa8", + "terminalBrightCyan": "#34e2e2", "terminalBrightWhite": "#eeeeec", "dialogTextColor": "#87909c", "dialogBackground": "#ffffff",