diff --git a/.eslintrc.js b/.eslintrc.js index 1415136093..514ab56bd7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -54,6 +54,7 @@ module.exports = { "react-hooks", ], rules: { + "no-constant-condition": ["error", { "checkLoops": false }], "header/header": [2, "./license-header"], "comma-dangle": ["error", "always-multiline"], "comma-spacing": "error", @@ -107,7 +108,10 @@ module.exports = { ], parser: "@typescript-eslint/parser", extends: [ + "eslint:recommended", "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", ], plugins: [ "header", @@ -118,7 +122,7 @@ module.exports = { sourceType: "module", }, rules: { - "no-irregular-whitespace": "error", + "no-constant-condition": ["error", { "checkLoops": false }], "header/header": [2, "./license-header"], "no-invalid-this": "off", "@typescript-eslint/no-invalid-this": ["error"], @@ -191,8 +195,11 @@ module.exports = { "unused-imports", ], extends: [ + "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", + "plugin:import/recommended", + "plugin:import/typescript", ], parserOptions: { ecmaVersion: 2018, @@ -200,8 +207,9 @@ module.exports = { jsx: true, }, rules: { - "no-irregular-whitespace": "error", + "no-constant-condition": ["error", { "checkLoops": false }], "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 +254,6 @@ module.exports = { "objectsInObjects": false, "arraysInObjects": true, }], - "react/prop-types": "off", "semi": "off", "@typescript-eslint/semi": ["error"], "linebreak-style": ["error", "unix"], 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/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 b3fa600b11..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", @@ -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", @@ -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", @@ -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__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 1452dfadd5..81923d93ea 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -472,8 +472,8 @@ describe("pre 2.6.0 config with a cluster icon", () => { it("moves the icon into preferences", async () => { const storedClusterData = ClusterStore.getInstance().clustersList[0]; - expect(storedClusterData.hasOwnProperty("icon")).toBe(false); - expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true); + expect(Object.prototype.hasOwnProperty.call(storedClusterData, "icon")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(storedClusterData.preferences, "icon")).toBe(true); expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true); }); }); diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index ffc4e361da..95a244f3c1 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -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/__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/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/renderer/components/+404/not-found.tsx b/src/common/hotbar-store.injectable.ts similarity index 78% rename from src/renderer/components/+404/not-found.tsx rename to src/common/hotbar-store.injectable.ts index 25f8756afd..4d01f3bada 100644 --- a/src/renderer/components/+404/not-found.tsx +++ b/src/common/hotbar-store.injectable.ts @@ -18,18 +18,12 @@ * 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 { HotbarStore } from "./hotbar-store"; -import React from "react"; -import { TabLayout } from "../layout/tab-layout"; +const hotbarManagerInjectable = getInjectable({ + instantiate: () => HotbarStore.getInstance(), + lifecycle: lifecycleEnum.singleton, +}); -export class NotFound extends React.Component { - render() { - return ( - -

- Page not found -

-
- ); - } -} +export default hotbarManagerInjectable; diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index 4a101e6013..504ba55cda 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.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,7 @@ 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"; export interface HotbarStoreModel { hotbars: Hotbar[]; @@ -52,22 +52,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 +105,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 +134,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 +142,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 +278,7 @@ export class HotbarStore extends BaseStore { index = hotbarStore.hotbars.length - 1; } - hotbarStore.activeHotbarId = hotbarStore.hotbars[index].id; + hotbarStore.setActiveHotbar(index); } switchToNext() { @@ -274,7 +289,7 @@ export class HotbarStore extends BaseStore { index = 0; } - hotbarStore.activeHotbarId = hotbarStore.hotbars[index].id; + hotbarStore.setActiveHotbar(index); } /** @@ -284,6 +299,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 +321,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); } 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/ipc/ipc.ts b/src/common/ipc/ipc.ts index b0eaa01e2a..95b7c672a4 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -30,7 +30,17 @@ 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 { + // ignore temp + } + } + + return null; +})(); const subFramesChannel = "ipc:get-sub-frames"; 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/__tests__/kube-object.test.ts b/src/common/k8s-api/__tests__/kube-object.test.ts index cb2a412a5e..edc7a52505 100644 --- a/src/common/k8s-api/__tests__/kube-object.test.ts +++ b/src/common/k8s-api/__tests__/kube-object.test.ts @@ -164,14 +164,14 @@ describe("KubeObject", () => { describe("isJsonApiDataList", () => { function isAny(val: unknown): val is any { - return !Boolean(void val); + return true; } function isNotAny(val: unknown): val is any { - return Boolean(void val); + return false; } - function isBoolean(val: unknown): val is Boolean { + function isBoolean(val: unknown): val is boolean { return typeof val === "boolean"; } diff --git a/src/common/k8s-api/endpoints/crd.api.ts b/src/common/k8s-api/endpoints/crd.api.ts index 4e2244d8a8..4892a8a262 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,32 @@ 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 +204,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() { diff --git a/src/common/k8s-api/endpoints/ingress.api.ts b/src/common/k8s-api/endpoints/ingress.api.ts index 7b80dca29e..8e46021eae 100644 --- a/src/common/k8s-api/endpoints/ingress.api.ts +++ b/src/common/k8s-api/endpoints/ingress.api.ts @@ -187,7 +187,7 @@ export class Ingress extends KubeObject { const servicePort = defaultBackend?.service.port.number ?? backend?.servicePort; if (rules && rules.length > 0) { - if (rules.some(rule => rule.hasOwnProperty("http"))) { + if (rules.some(rule => Object.prototype.hasOwnProperty.call(rule, "http"))) { ports.push(httpPort); } } else if (servicePort !== undefined) { diff --git a/src/common/k8s-api/endpoints/metrics.api.ts b/src/common/k8s-api/endpoints/metrics.api.ts index bd6bc4709c..0fdadbe0dc 100644 --- a/src/common/k8s-api/endpoints/metrics.api.ts +++ b/src/common/k8s-api/endpoints/metrics.api.ts @@ -184,7 +184,8 @@ export function getMetricLastPoints(metrics: Record) { if (metric.data.result.length) { result[metricName] = +metric.data.result[0].values.slice(-1)[0][1]; } - } catch (e) { + } catch { + // ignore error } return result; diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index 0f31758ab2..b6a823935a 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"; @@ -698,21 +701,16 @@ export class KubeApi { } protected modifyWatchEvent(event: IKubeWatchEvent) { + if (event.type === "ERROR") { + return; - switch (event.type) { - case "ADDED": - case "DELETED": - - case "MODIFIED": { - ensureObjectSelfLink(this, event.object); - - const { namespace, resourceVersion } = event.object.metadata; - - this.setResourceVersion(namespace, resourceVersion); - this.setResourceVersion("", resourceVersion); - - break; - } } + + ensureObjectSelfLink(this, event.object); + + const { namespace, resourceVersion } = event.object.metadata; + + this.setResourceVersion(namespace, resourceVersion); + this.setResourceVersion("", resourceVersion); } } diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index 1832cdfa10..8f82b9e92a 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"; @@ -235,8 +238,9 @@ export abstract class KubeObjectStore extends ItemStore } @action - async loadAll({ namespaces = this.context.contextNamespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { + async loadAll({ namespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { await this.contextReady; + namespaces ??= this.context.contextNamespaces; this.isLoading = true; try { @@ -469,7 +473,9 @@ export abstract class KubeObjectStore extends ItemStore switch (type) { case "ADDED": - case "MODIFIED": + + // falls through + case "MODIFIED": { const newItem = new this.api.objectConstructor(object); if (!item) { @@ -477,7 +483,9 @@ export abstract class KubeObjectStore extends ItemStore } else { items[index] = newItem; } + break; + } case "DELETED": if (item) { items.splice(index, 1); diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index afb0f4c2af..b51e687677 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -88,7 +88,7 @@ export abstract class LensProtocolRouter { public static readonly LoggingPrefix = "[PROTOCOL ROUTER]"; - static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; + static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; constructor(protected dependencies: Dependencies) {} diff --git a/src/common/search-store.ts b/src/common/search-store.ts index ac77e0ec94..6827548bda 100644 --- a/src/common/search-store.ts +++ b/src/common/search-store.ts @@ -29,7 +29,7 @@ export class SearchStore { * @param value Unescaped string */ public static escapeRegex(value?: string): string { - return value ? value.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&") : ""; + return value ? value.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") : ""; } /** 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/defineGlobal.ts b/src/common/utils/defineGlobal.ts index eeaea8baa5..1c205a0632 100755 --- a/src/common/utils/defineGlobal.ts +++ b/src/common/utils/defineGlobal.ts @@ -26,7 +26,7 @@ export function defineGlobal(propName: string, descriptor: PropertyDescriptor) { const scope = typeof global !== "undefined" ? global : window; - if (scope.hasOwnProperty(propName)) { + if (Object.prototype.hasOwnProperty.call(scope, propName)) { return; } diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index 6271a05969..9d185ab5d7 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -25,6 +25,7 @@ export type Falsey = false | 0 | "" | null | undefined; * Create a new type safe empty Iterable * @returns An `Iterable` that yields 0 items */ +// eslint-disable-next-line require-yield export function* newEmpty(): IterableIterator { return; } diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts index d0c67976e9..b3ef949173 100644 --- a/src/common/utils/tar.ts +++ b/src/common/utils/tar.ts @@ -31,12 +31,13 @@ export interface ReadFileFromTarOpts { } export function readFileFromTar({ tarPath, filePath, parseJson }: ReadFileFromTarOpts): Promise { - return new Promise(async (resolve, reject) => { + return new Promise((resolve, reject) => { const fileChunks: Buffer[] = []; - await tar.list({ + tar.list({ file: tarPath, filter: entryPath => path.normalize(entryPath) === filePath, + sync: true, onentry(entry: FileStat) { entry.on("data", chunk => { fileChunks.push(chunk); 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/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index bac6f59cc7..b6466d0a7f 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,63 +79,60 @@ 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"), - availableUpdate: null, - }); - 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"), + availableUpdate: null, + }); + done(); + }); + + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); }); it("doesn't emit add for added file under extension", async done => { @@ -150,7 +149,7 @@ describe("ExtensionDiscovery", () => { }; mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, + (mockWatchInstance) as any, ); const extensionDiscovery = ExtensionDiscovery.createInstance( extensionLoader, @@ -173,3 +172,4 @@ describe("ExtensionDiscovery", () => { }, 10); }); }); + diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts new file mode 100644 index 0000000000..8552cb5ec8 --- /dev/null +++ b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api.ts @@ -0,0 +1,48 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; + +import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; + +type TentativeTuple = T extends object ? [T] : [undefined?]; + +type FactoryType = < + TInjectable extends Injectable, + TInstantiationParameter, + TInstance extends (...args: unknown[]) => any, + TFunction extends (...args: unknown[]) => any = Awaited< + ReturnType + >, +>( + injectableKey: TInjectable, + ...instantiationParameter: TentativeTuple +) => (...args: Parameters) => ReturnType; + +export const asLegacyGlobalFunctionForExtensionApi: FactoryType = + (injectableKey, ...instantiationParameter) => + (...args) => { + const injected = getLegacyGlobalDiForExtensionApi().inject( + injectableKey, + ...instantiationParameter, + ); + + return injected(...args); + }; diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts new file mode 100644 index 0000000000..9c95472258 --- /dev/null +++ b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api.ts @@ -0,0 +1,57 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; + +type TentativeTuple = T extends object ? [T] : [undefined?]; + +export const asLegacyGlobalObjectForExtensionApi = < + TInjectable extends Injectable, + TInstantiationParameter, +>( + injectableKey: TInjectable, + ...instantiationParameter: TentativeTuple + ) => + new Proxy( + {}, + { + get(target, propertyName) { + if (propertyName === "$$typeof") { + return undefined; + } + + const instance: any = getLegacyGlobalDiForExtensionApi().inject( + injectableKey, + ...instantiationParameter, + ); + + const propertyValue = instance[propertyName]; + + if (typeof propertyValue === "function") { + return function (...args: any[]) { + return propertyValue.apply(instance, args); + }; + } + + return propertyValue; + }, + }, + ) as ReturnType; diff --git a/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-for-extension-api.ts new file mode 100644 index 0000000000..a470c6914b --- /dev/null +++ b/src/extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-for-extension-api.ts @@ -0,0 +1,59 @@ +/** + * 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 { Injectable } from "@ogre-tools/injectable"; +import { getLegacyGlobalDiForExtensionApi } from "./legacy-global-di-for-extension-api"; + +type TentativeTuple = T extends object ? [T] : [undefined?]; + +export const asLegacyGlobalSingletonForExtensionApi = < + TClass extends abstract new (...args: any[]) => any, + TInjectable extends Injectable, + TInstantiationParameter, +>( + Class: TClass, + injectableKey: TInjectable, + ...instantiationParameter: TentativeTuple + ) => + new Proxy(Class, { + construct: () => { + throw new Error("A legacy singleton class must be created by createInstance()"); + }, + + get: (target: any, propertyName) => { + if (propertyName === "getInstance" || propertyName === "createInstance") { + return () => + getLegacyGlobalDiForExtensionApi().inject( + injectableKey, + ...instantiationParameter, + ); + } + + if (propertyName === "resetInstance") { + return () => getLegacyGlobalDiForExtensionApi().purge(injectableKey); + } + + return target[propertyName]; + }, + }) as InstanceType & { + getInstance: () => InstanceType; + createInstance: () => InstanceType; + resetInstance: () => void; + }; diff --git a/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts b/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts new file mode 100644 index 0000000000..0d9bc728a5 --- /dev/null +++ b/src/extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api.ts @@ -0,0 +1,29 @@ +/** + * 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 { DependencyInjectionContainer } from "@ogre-tools/injectable"; + +let legacyGlobalDi: DependencyInjectionContainer; + +export const setLegacyGlobalDiForExtensionApi = (di: DependencyInjectionContainer) => { + legacyGlobalDi = di; +}; + +export const getLegacyGlobalDiForExtensionApi = () => legacyGlobalDi; 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/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index 329020f5fa..e4a86ce82b 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -279,11 +279,7 @@ 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.WelcomeMenuRegistry.getInstance().add(extension.welcomeMenus), - registries.WelcomeBannerRegistry.getInstance().add(extension.welcomeBanners), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), - registries.TopBarRegistry.getInstance().add(extension.topBarItems), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { @@ -315,7 +311,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/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/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/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 67567969ee..718f25f7c5 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -26,7 +26,11 @@ 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"; +import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -39,11 +43,11 @@ export class LensRendererExtension extends LensExtension { kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = []; kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; - commands: registries.CommandRegistration[] = []; - welcomeMenus: registries.WelcomeMenuRegistration[] = []; - welcomeBanners: registries.WelcomeBannerRegistration[] = []; + commands: CommandRegistration[] = []; + 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/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/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/extensions/registries/index.ts b/src/extensions/registries/index.ts index 4dd64a9c82..e8c9930176 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -28,11 +28,7 @@ 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 "./welcome-menu-registry"; -export * from "./welcome-banner-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/extensions/registries/page-registry.ts b/src/extensions/registries/page-registry.ts index aa788681d5..5604538a91 100644 --- a/src/extensions/registries/page-registry.ts +++ b/src/extensions/registries/page-registry.ts @@ -135,10 +135,7 @@ class PageRegistry extends BaseRegistry { ); if (notAStringValue && !(parse || stringify)) { - throw new Error( - `PageRegistry: param's "${paramName}" initialization has failed: - paramInit.parse() and paramInit.stringify() are required for non string | string[] "defaultValue"`, - ); + throw new Error(`PageRegistry: param's "${paramName}" initialization has failed: paramInit.parse() and paramInit.stringify() are required for non string | string[] "defaultValue"`); } paramInit.defaultValue = value; diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 7173b592b3..ad31884330 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -19,6 +19,9 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import commandOverlayInjectable from "../../renderer/components/command-palette/command-overlay.injectable"; +import { asLegacyGlobalObjectForExtensionApi } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; + // layouts export * from "../../renderer/components/layout/main-layout"; export * from "../../renderer/components/layout/setting-layout"; @@ -36,7 +39,7 @@ export * from "../../renderer/components/switch"; export * from "../../renderer/components/input/input"; // command-overlay -export { CommandOverlay } from "../../renderer/components/command-palette"; +export const CommandOverlay = asLegacyGlobalObjectForExtensionApi(commandOverlayInjectable); // other components export * from "../../renderer/components/icon"; 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/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/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/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/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/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index f8edc12e00..8c8c0c6996 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -119,7 +119,9 @@ export class HelmRepoManager extends Singleton { if (typeof parsedConfig === "object" && parsedConfig) { return parsedConfig as HelmRepoConfig; } - } catch { } + } catch { + // ignore error + } return { repositories: [], 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/initializers/ipc.ts b/src/main/initializers/ipc.ts index e3d4553935..0ad881f36e 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -97,7 +97,9 @@ export function initIpcMainHandlers(electronMenuItems: IComputedValue { diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index 2ac1831c41..88daa10b80 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -27,12 +27,15 @@ import { ensureDir, pathExists } from "fs-extra"; import * as lockFile from "proper-lockfile"; import { helmCli } from "./helm/helm-cli"; import { UserStore } from "../common/user-store"; -import { customRequest } from "../common/request"; import { getBundledKubectlVersion } from "../common/utils/app-version"; import { isDevelopment, isWindows, isTestEnv } from "../common/vars"; import { SemVer } from "semver"; import { defaultPackageMirror, packageMirrors } from "../common/user-store/preferences-helpers"; import { AppPaths } from "../common/app-paths"; +import got from "got/dist/source"; +import { promisify } from "util"; +import stream from "stream"; +import { noop } from "../renderer/utils"; const bundledVersion = getBundledKubectlVersion(); const kubectlMap: Map = new Map([ @@ -53,7 +56,7 @@ const kubectlMap: Map = new Map([ ["1.21", bundledVersion], ]); let bundledPath: string; -const initScriptVersionString = "# lens-initscript v3\n"; +const initScriptVersionString = "# lens-initscript v3"; export function bundledKubectlPath(): string { if (bundledPath) { return bundledPath; } @@ -309,99 +312,92 @@ export class Kubectl { logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`); - return new Promise((resolve, reject) => { - const stream = customRequest({ - url: this.url, - gzip: true, - }); - const file = fs.createWriteStream(this.path); + const downloadStream = got.stream({ url: this.url, decompress: true }); + const fileWriteStream = fs.createWriteStream(this.path, { mode: 0o755 }); + const pipeline = promisify(stream.pipeline); - stream.on("complete", () => { - logger.debug("kubectl binary download finished"); - file.end(); - }); - stream.on("error", (error) => { - logger.error(error); - fs.unlink(this.path, () => { - // do nothing - }); - reject(error); - }); - file.on("close", () => { - logger.debug("kubectl binary download closed"); - fs.chmod(this.path, 0o755, (err) => { - if (err) reject(err); - }); - resolve(); - }); - stream.pipe(file); - }); + try { + await pipeline(downloadStream, fileWriteStream); + await fs.promises.chmod(this.path, 0o755); + logger.debug("kubectl binary download finished"); + } catch (error) { + await fs.promises.unlink(this.path).catch(noop); + throw error; + } } protected async writeInitScripts() { - const kubectlPath = UserStore.getInstance().downloadKubectlBinaries ? this.dirname : path.dirname(this.getPathFromPreferences()); + const kubectlPath = UserStore.getInstance().downloadKubectlBinaries + ? this.dirname + : path.dirname(this.getPathFromPreferences()); const helmPath = helmCli.getBinaryDir(); - const fsPromises = fs.promises; + const bashScriptPath = path.join(this.dirname, ".bash_set_path"); - let bashScript = `${initScriptVersionString}`; - - bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"; - bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"; - bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"; - bashScript += " . \"$HOME/.bash_profile\"\n"; - bashScript += "elif test -f \"$HOME/.bash_login\"; then\n"; - bashScript += " . \"$HOME/.bash_login\"\n"; - bashScript += "elif test -f \"$HOME/.profile\"; then\n"; - bashScript += " . \"$HOME/.profile\"\n"; - bashScript += "fi\n"; - bashScript += `export PATH="${helmPath}:${kubectlPath}:$PATH"\n`; - bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"; - - bashScript += `NO_PROXY=\",\${NO_PROXY:-localhost},\"\n`; - bashScript += `NO_PROXY=\"\${NO_PROXY//,localhost,/,}\"\n`; - bashScript += `NO_PROXY=\"\${NO_PROXY//,127.0.0.1,/,}\"\n`; - bashScript += `NO_PROXY=\"localhost,127.0.0.1\${NO_PROXY%,}\"\n`; - bashScript += "export NO_PROXY\n"; - bashScript += "unset tempkubeconfig\n"; - await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 }); + const bashScript = [ + initScriptVersionString, + "tempkubeconfig=\"$KUBECONFIG\"", + "test -f \"/etc/profile\" && . \"/etc/profile\"", + "if test -f \"$HOME/.bash_profile\"; then", + " . \"$HOME/.bash_profile\"", + "elif test -f \"$HOME/.bash_login\"; then", + " . \"$HOME/.bash_login\"", + "elif test -f \"$HOME/.profile\"; then", + " . \"$HOME/.profile\"", + "fi", + `export PATH="${helmPath}:${kubectlPath}:$PATH"`, + 'export KUBECONFIG="$tempkubeconfig"', + `NO_PROXY=",\${NO_PROXY:-localhost},"`, + `NO_PROXY="\${NO_PROXY//,localhost,/,}"`, + `NO_PROXY="\${NO_PROXY//,127.0.0.1,/,}"`, + `NO_PROXY="localhost,127.0.0.1\${NO_PROXY%,}"`, + "export NO_PROXY", + "unset tempkubeconfig", + ].join("\n"); const zshScriptPath = path.join(this.dirname, ".zlogin"); - let zshScript = `${initScriptVersionString}`; + const zshScript = [ + initScriptVersionString, + "tempkubeconfig=\"$KUBECONFIG\"", - zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"; - // restore previous ZDOTDIR - zshScript += "export ZDOTDIR=\"$OLD_ZDOTDIR\"\n"; - // source all the files - zshScript += "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"\n"; - zshScript += "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"\n"; - zshScript += "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"\n"; - zshScript += "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"\n"; + // restore previous ZDOTDIR + "export ZDOTDIR=\"$OLD_ZDOTDIR\"", - // voodoo to replace any previous occurrences of kubectl path in the PATH - zshScript += `kubectlpath=\"${kubectlPath}"\n`; - zshScript += `helmpath=\"${helmPath}"\n`; - zshScript += "p=\":$kubectlpath:\"\n"; - zshScript += "d=\":$PATH:\"\n"; - zshScript += `d=\${d//$p/:}\n`; - zshScript += `d=\${d/#:/}\n`; - zshScript += `export PATH=\"$helmpath:$kubectlpath:\${d/%:/}\"\n`; - zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"; - zshScript += `NO_PROXY=\",\${NO_PROXY:-localhost},\"\n`; - zshScript += `NO_PROXY=\"\${NO_PROXY//,localhost,/,}\"\n`; - zshScript += `NO_PROXY=\"\${NO_PROXY//,127.0.0.1,/,}\"\n`; - zshScript += `NO_PROXY=\"localhost,127.0.0.1\${NO_PROXY%,}\"\n`; - zshScript += "export NO_PROXY\n"; - zshScript += "unset tempkubeconfig\n"; - zshScript += "unset OLD_ZDOTDIR\n"; - await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 }); + // source all the files + "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"", + "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"", + "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"", + "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"", + + // voodoo to replace any previous occurrences of kubectl path in the PATH + `kubectlpath="${kubectlPath}"`, + `helmpath="${helmPath}"`, + "p=\":$kubectlpath:\"", + "d=\":$PATH:\"", + `d=\${d//$p/:}`, + `d=\${d/#:/}`, + `export PATH="$helmpath:$kubectlpath:\${d/%:/}"`, + "export KUBECONFIG=\"$tempkubeconfig\"", + `NO_PROXY=",\${NO_PROXY:-localhost},"`, + `NO_PROXY="\${NO_PROXY//,localhost,/,}"`, + `NO_PROXY="\${NO_PROXY//,127.0.0.1,/,}"`, + `NO_PROXY="localhost,127.0.0.1\${NO_PROXY%,}"`, + "export NO_PROXY", + "unset tempkubeconfig", + "unset OLD_ZDOTDIR", + ].join("\n"); + + await Promise.all([ + fs.promises.writeFile(bashScriptPath, bashScript, { mode: 0o644 }), + fs.promises.writeFile(zshScriptPath, zshScript, { mode: 0o644 }), + ]); } protected getDownloadMirror(): string { // MacOS packages are only available from default - const mirror = packageMirrors.get(UserStore.getInstance().downloadMirror) + const { url } = packageMirrors.get(UserStore.getInstance().downloadMirror) ?? packageMirrors.get(defaultPackageMirror); - return mirror.url; + return url; } } 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/menu/menu.ts b/src/main/menu/menu.ts index 294c31e0cc..13cca14ac7 100644 --- a/src/main/menu/menu.ts +++ b/src/main/menu/menu.ts @@ -216,8 +216,16 @@ 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 + * + * NOTE: this `?` is required because of a bug in playwright. https://github.com/microsoft/playwright/issues/10554 + */ + if (!event?.triggeredByAccelerator) { + broadcastMessage("command-palette:open"); + } }, }, { type: "separator" }, 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": 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/main/router.ts b/src/main/router.ts index 96ed7cdd02..082d710f68 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -182,7 +182,6 @@ export class Router { // Port-forward API (the container port and local forwarding port are obtained from the query parameters) this.router.add({ method: "post", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routePortForward); this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForward); - this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forwards` }, PortForwardRoute.routeAllPortForwards); this.router.add({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForwardStop); // Helm API diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index 6392c70ed9..030ec8ff07 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -188,31 +188,6 @@ export class PortForwardRoute { respondJson(response, { port: portForward?.forwardPort ?? null }); } - static async routeAllPortForwards(request: LensApiRequest) { - const { query, response } = request; - const clusterId = query.get("clusterId"); - - let portForwards: PortForwardArgs[] = PortForward.portForwards.map(f => ( - { - clusterId: f.clusterId, - kind: f.kind, - namespace: f.namespace, - name: f.name, - port: f.port, - forwardPort: f.forwardPort, - protocol: f.protocol, - }), - ); - - if (clusterId) { - // filter out any not for this cluster - portForwards = portForwards.filter(pf => pf.clusterId == clusterId); - - } - - respondJson(response, { portForwards }); - } - static async routeCurrentPortForwardStop(request: LensApiRequest) { const { params, query, response, cluster } = request; const { namespace, resourceType, resourceName } = params; diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts index b865fc411d..0b7d673de9 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -72,6 +72,7 @@ export class NodeShellSession extends ShellSession { switch (nodeOs) { default: logger.warn(`[NODE-SHELL-SESSION]: could not determine node OS, falling back with assumption of linux`); + // fallthrough case "linux": args.push("sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"); break; diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 677a637cc0..b17ef22689 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -134,7 +134,9 @@ export abstract class ShellSession { for (const shellProcess of this.processes.values()) { try { process.kill(shellProcess.pid); - } catch {} + } catch { + // ignore error + } } this.processes.clear(); @@ -214,7 +216,9 @@ export abstract class ShellSession { if (stats.isDirectory()) { return potentialCwd; } - } catch {} + } catch { + // ignore error + } } return "."; // Always valid diff --git a/src/renderer/components/hotbar/hotbar-display-label.ts b/src/main/tray/tray-menu-items.injectable.ts similarity index 69% rename from src/renderer/components/hotbar/hotbar-display-label.ts rename to src/main/tray/tray-menu-items.injectable.ts index 0489e2600f..8a31cc6af5 100644 --- a/src/renderer/components/hotbar/hotbar-display-label.ts +++ b/src/main/tray/tray-menu-items.injectable.ts @@ -18,19 +18,19 @@ * 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"; -import { HotbarStore } from "../../../common/hotbar-store"; +const trayItemsInjectable = getInjectable({ + lifecycle: lifecycleEnum.singleton, -function hotbarIndex(id: string) { - return HotbarStore.getInstance().hotbarIndex(id) + 1; -} + instantiate: (di) => { + const extensions = di.inject(mainExtensionsInjectable); -export function hotbarDisplayLabel(id: string) : string { - const hotbar = HotbarStore.getInstance().getById(id); + return computed(() => + extensions.get().flatMap(extension => extension.trayMenus)); + }, +}); - return `${hotbarIndex(id)}: ${hotbar.name}`; -} - -export function hotbarDisplayIndex(id: string) : string { - return hotbarIndex(id).toString(); -} +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/extensions/registries/topbar-registry.ts b/src/main/tray/tray-menu-registration.d.ts similarity index 79% rename from src/extensions/registries/topbar-registry.ts rename to src/main/tray/tray-menu-registration.d.ts index 37b55faaaa..0cf7fe611b 100644 --- a/src/extensions/registries/topbar-registry.ts +++ b/src/main/tray/tray-menu-registration.d.ts @@ -19,16 +19,12 @@ * 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; -} - -export interface TopBarRegistration { - components: TopBarComponents; -} - -export class TopBarRegistry extends BaseRegistry { +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}`, diff --git a/src/migrations/cluster-store/2.0.0-beta.2.ts b/src/migrations/cluster-store/2.0.0-beta.2.ts index e3a89b37d3..8d49aa3ab0 100644 --- a/src/migrations/cluster-store/2.0.0-beta.2.ts +++ b/src/migrations/cluster-store/2.0.0-beta.2.ts @@ -33,7 +33,7 @@ export default { const contextName = value[0]; // Looping all the keys gives out the store internal stuff too... - if (contextName === "__internal__" || value[1].hasOwnProperty("kubeConfig")) continue; + if (contextName === "__internal__" || Object.prototype.hasOwnProperty.call(value[1], "kubeConfig")) continue; store.set(contextName, { kubeConfig: value[1] }); } }, diff --git a/src/migrations/cluster-store/2.6.0-beta.3.ts b/src/migrations/cluster-store/2.6.0-beta.3.ts index a33dd7523f..82f1ac0f88 100644 --- a/src/migrations/cluster-store/2.6.0-beta.3.ts +++ b/src/migrations/cluster-store/2.6.0-beta.3.ts @@ -34,7 +34,7 @@ export default { if (!cluster.kubeConfig) continue; const config = yaml.load(cluster.kubeConfig); - if (!config || typeof config !== "object" || !config.hasOwnProperty("users")) { + if (!config || typeof config !== "object" || !Object.prototype.hasOwnProperty.call(config, "users")) { continue; } 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..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"; @@ -28,14 +28,22 @@ 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"; +import { isMainFrame } from "process"; 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 }); @@ -78,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(catalogEntityRunListener, (event, id: string) => { + const entity = this.getById(id); + + if (entity) { + this.onRun(entity); + } + }); + } } @action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) { 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/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 1de360d18a..b9f48b2b8c 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"; @@ -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(); @@ -102,9 +103,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(); @@ -114,9 +112,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(); @@ -127,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/cluster-frame.tsx b/src/renderer/cluster-frame.tsx index 5113377116..bcade8cf5d 100755 --- a/src/renderer/cluster-frame.tsx +++ b/src/renderer/cluster-frame.tsx @@ -23,7 +23,6 @@ import { observable, makeObservable, when } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Redirect, Route, Router, Switch } from "react-router"; import { history } from "./navigation"; -import { NotFound } from "./components/+404"; import { UserManagement } from "./components/+user-management/user-management"; import { ConfirmDialog } from "./components/confirm-dialog"; import { ClusterOverview } from "./components/+cluster/cluster-overview"; @@ -230,7 +229,11 @@ export class ClusterFrame extends React.Component { {this.renderExtensionTabLayoutRoutes()} {this.renderExtensionRoutes()} - + { + Notifications.error(`Unknown location ${location.pathname}, redirecting to main page.`); + + return ; + }} /> diff --git a/src/renderer/components/+config-autoscalers/hpa-details.tsx b/src/renderer/components/+config-autoscalers/hpa-details.tsx index 3e6c74b9bb..78a0457293 100644 --- a/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ b/src/renderer/components/+config-autoscalers/hpa-details.tsx @@ -45,15 +45,17 @@ export class HpaDetails extends React.Component { const renderName = (metric: IHpaMetric) => { switch (metric.type) { - case HpaMetricType.Resource: - const addition = metric.resource.targetAverageUtilization ? <>(as a percentage of request) : ""; + case HpaMetricType.Resource: { + const addition = metric.resource.targetAverageUtilization + ? "(as a percentage of request)" + : ""; return <>Resource {metric.resource.name} on Pods {addition}; - + } case HpaMetricType.Pods: return <>{metric.pods.metricName} on Pods; - case HpaMetricType.Object: + case HpaMetricType.Object: { const { target } = metric.object; const { kind, name } = target; const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa)); @@ -64,6 +66,7 @@ export class HpaDetails extends React.Component { {kind}/{name} ); + } case HpaMetricType.External: return ( <> 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/+custom-resources/custom-resources.injectable.ts b/src/renderer/components/+custom-resources/custom-resources.injectable.ts new file mode 100644 index 0000000000..99a532f69f --- /dev/null +++ b/src/renderer/components/+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 "./crd.store"; + +const customResourceDefinitionsInjectable = getInjectable({ + instantiate: () => computed(() => [...crdStore.items]), + + lifecycle: lifecycleEnum.singleton, +}); + +export default customResourceDefinitionsInjectable; 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/+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/+extensions/get-base-registry-url/get-base-registry-url.tsx b/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx index c811a21319..1e9c582d39 100644 --- a/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx +++ b/src/renderer/components/+extensions/get-base-registry-url/get-base-registry-url.tsx @@ -47,8 +47,8 @@ export const getBaseRegistryUrl = ({ getRegistryUrlPreference }: Dependencies) = } catch (error) { Notifications.error(

Failed to get configured registry from .npmrc. Falling back to default registry

); console.warn("[EXTENSIONS]: failed to get configured registry from .npmrc", error); - // fallthrough } + // fallthrough } default: case ExtensionRegistryLocation.DEFAULT: 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..c4e24df266 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, startPortForward, stopPortForward } 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 { @@ -45,6 +44,38 @@ export class PortForwardMenu extends React.Component { } } + private startPortForwarding = async () => { + const { portForward } = this.props; + + const pf = await startPortForward(portForward); + + if (pf.status === "Disabled") { + const { name, kind, forwardPort } = portForward; + + Notifications.error(`Error occurred starting port-forward, the local port ${forwardPort} may not be available or the ${kind} ${name} may not be reachable`); + } + }; + + renderStartStopMenuItem() { + const { portForward, toolbar } = this.props; + + if (portForward.status === "Active") { + return ( + stopPortForward(portForward)}> + + Stop + + ); + } + + return ( + + + Start + + ); + } + renderContent() { const { portForward, toolbar } = this.props; @@ -52,14 +83,17 @@ export class PortForwardMenu extends React.Component { return ( <> - openPortForward(this.props.portForward)}> - - Open - + { portForward.status === "Active" && + openPortForward(portForward)}> + + Open + + } PortForwardDialog.open(portForward)}> Edit + {this.renderStartStopMenuItem()} ); } diff --git a/src/renderer/components/+network-port-forwards/port-forwards.tsx b/src/renderer/components/+network-port-forwards/port-forwards.tsx index a76588619d..b6d4fa5fbf 100644 --- a/src/renderer/components/+network-port-forwards/port-forwards.tsx +++ b/src/renderer/components/+network-port-forwards/port-forwards.tsx @@ -70,7 +70,7 @@ export class PortForwards extends React.Component { showDetails = (item: PortForwardItem) => { navigation.push(portForwardsURL({ params: { - forwardport: String(item.getForwardPort()), + forwardport: item.getId(), }, })); }; diff --git a/src/renderer/components/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx index 95355d0f0d..796c403cbd 100644 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -24,13 +24,14 @@ import "./service-port-component.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import type { Service, ServicePort } from "../../../common/k8s-api/endpoints"; -import { observable, makeObservable, reaction } from "mobx"; +import { observable, makeObservable, reaction, action } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Button } from "../button"; -import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward"; +import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, notifyErrorPortForwarding, openPortForward, PortForwardDialog, predictProtocol, removePortForward, startPortForward } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward"; import { Spinner } from "../spinner"; +import logger from "../../../common/logger"; interface Props { service: Service; @@ -42,6 +43,7 @@ export class ServicePortComponent extends React.Component { @observable waiting = false; @observable forwardPort = 0; @observable isPortForwarded = false; + @observable isActive = false; constructor(props: Props) { super(props); @@ -51,13 +53,14 @@ export class ServicePortComponent extends React.Component { componentDidMount() { disposeOnUnmount(this, [ - reaction(() => [portForwardStore.portForwards, this.props.service], () => this.checkExistingPortForwarding()), + reaction(() => this.props.service, () => this.checkExistingPortForwarding()), ]); } + @action async checkExistingPortForwarding() { const { service, port } = this.props; - const portForward: ForwardedPort = { + let portForward: ForwardedPort = { kind: "service", name: service.getName(), namespace: service.getNs(), @@ -65,57 +68,66 @@ export class ServicePortComponent extends React.Component { forwardPort: this.forwardPort, }; - let activePort: number; - try { - activePort = await getPortForward(portForward) ?? 0; + portForward = await getPortForward(portForward); } catch (error) { this.isPortForwarded = false; + this.isActive = false; return; } - this.forwardPort = activePort; - this.isPortForwarded = activePort ? true : false; + this.forwardPort = portForward.forwardPort; + this.isPortForwarded = true; + this.isActive = portForward.status === "Active"; } + @action async portForward() { const { service, port } = this.props; - const portForward: ForwardedPort = { + let portForward: ForwardedPort = { kind: "service", name: service.getName(), namespace: service.getNs(), port: port.port, forwardPort: this.forwardPort, protocol: predictProtocol(port.name), + status: "Active", }; this.waiting = true; try { - // determine how many port-forwards are already active - const { length } = await getPortForwards(); + // determine how many port-forwards already exist + const { length } = getPortForwards(); - this.forwardPort = await addPortForward(portForward); + if (!this.isPortForwarded) { + portForward = await addPortForward(portForward); + } else if (!this.isActive) { + portForward = await startPortForward(portForward); + } - if (this.forwardPort) { - portForward.forwardPort = this.forwardPort; + this.forwardPort = portForward.forwardPort; + + if (portForward.status === "Active") { openPortForward(portForward); - this.isPortForwarded = true; // if this is the first port-forward show the about notification if (!length) { aboutPortForwarding(); } + } else { + notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); } } catch (error) { - Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); - this.checkExistingPortForwarding(); + logger.error("[SERVICE-PORT-COMPONENT]:", error, portForward); } finally { + this.checkExistingPortForwarding(); this.waiting = false; } } + @action async stopPortForward() { const { service, port } = this.props; const portForward: ForwardedPort = { @@ -130,11 +142,11 @@ export class ServicePortComponent extends React.Component { try { await removePortForward(portForward); - this.isPortForwarded = false; } catch (error) { Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`); - this.checkExistingPortForwarding(); } finally { + this.checkExistingPortForwarding(); + this.forwardPort = 0; this.waiting = false; } } @@ -142,7 +154,7 @@ export class ServicePortComponent extends React.Component { render() { const { port, service } = this.props; - const portForwardAction = async () => { + const portForwardAction = action(async () => { if (this.isPortForwarded) { await this.stopPortForward(); } else { @@ -155,16 +167,16 @@ export class ServicePortComponent extends React.Component { protocol: predictProtocol(port.name), }; - PortForwardDialog.open(portForward, { openInBrowser: true }); + PortForwardDialog.open(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() }); } - }; + }); return (
this.portForward()}> {port.toString()} - + {this.waiting && ( )} diff --git a/src/renderer/components/+network/network-mixins.scss b/src/renderer/components/+network/network-mixins.scss index 3fa05ac072..3b9ca4fbb9 100644 --- a/src/renderer/components/+network/network-mixins.scss +++ b/src/renderer/components/+network/network-mixins.scss @@ -34,6 +34,7 @@ $service-status-color-list: ( $port-forward-status-color-list: ( active: var(--colorOk), + disabled: var(--colorSoftError) ); @mixin port-forward-status-colors { 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/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index 2024d69c7e..3d1ba9b7ce 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -20,45 +20,55 @@ */ 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 { defaultWidth } from "../welcome"; +import { defaultWidth, Welcome } from "../welcome"; +import { computed } from "mobx"; +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("", () => { - beforeEach(() => { - TopBarRegistry.createInstance(); - WelcomeMenuRegistry.createInstance(); - WelcomeBannerRegistry.createInstance(); - }); + let render: DiRender; + let di: ConfigurableDependencyInjectionContainer; + let welcomeBannersStub: WelcomeBannerRegistration[]; - afterEach(() => { - TopBarRegistry.resetInstance(); - WelcomeMenuRegistry.resetInstance(); - WelcomeBannerRegistry.resetInstance(); + beforeEach(() => { + di = getDiForUnitTesting(); + + render = renderFor(di); + + welcomeBannersStub = []; + + di.override(rendererExtensionsInjectable, () => + computed(() => [ + new TestExtension({ + id: "some-id", + welcomeBanners: welcomeBannersStub, + }), + ]), + ); }); 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(); @@ -67,16 +77,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(); @@ -92,3 +101,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/initializers/welcome-menu-registry.ts b/src/renderer/components/+welcome/welcome-banner-items/welcome-banner-items.injectable.ts similarity index 67% rename from src/renderer/initializers/welcome-menu-registry.ts rename to src/renderer/components/+welcome/welcome-banner-items/welcome-banner-items.injectable.ts index f658fe5715..288bb540ce 100644 --- a/src/renderer/initializers/welcome-menu-registry.ts +++ b/src/renderer/components/+welcome/welcome-banner-items/welcome-banner-items.injectable.ts @@ -18,18 +18,20 @@ * 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"; -import { catalogURL } from "../../common/routes"; -import { WelcomeMenuRegistry } from "../../extensions/registries"; -import { navigate } from "../navigation"; +const welcomeBannerItemsInjectable = getInjectable({ + instantiate: (di) => { + const 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" }} )), - }, + 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-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/components/+welcome/welcome-menu-items/welcome-menu-items.injectable.ts b/src/renderer/components/+welcome/welcome-menu-items/welcome-menu-items.injectable.ts new file mode 100644 index 0000000000..384b0b07bc --- /dev/null +++ b/src/renderer/components/+welcome/welcome-menu-items/welcome-menu-items.injectable.ts @@ -0,0 +1,34 @@ +/** + * 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 { getWelcomeMenuItems } from "./get-welcome-menu-items"; + +const welcomeMenuItemsInjectable = getInjectable({ + instantiate: (di) => + getWelcomeMenuItems({ + extensions: di.inject(rendererExtensionsInjectable), + }), + + 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 669e768782..56aefbfc3c 100644 --- a/src/renderer/components/+welcome/welcome.tsx +++ b/src/renderer/components/+welcome/welcome.tsx @@ -22,78 +22,129 @@ 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"; +import welcomeBannerItemsInjectable from "./welcome-banner-items/welcome-banner-items.injectable"; +import type { WelcomeBannerRegistration } from "./welcome-banner-items/welcome-banner-registration"; export const defaultWidth = 320; -@observer -export class Welcome extends React.Component { - render() { - const welcomeBanner = WelcomeBannerRegistry.getInstance().getItems(); +interface Dependencies { + welcomeMenuItems: IComputedValue + welcomeBannerItems: IComputedValue +} - // 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; +const NonInjectedWelcome: React.FC = ({ welcomeMenuItems, welcomeBannerItems }) => { + const welcomeBanners = welcomeBannerItems.get(); - if (acc > currWidth) { - return acc; - } + // if there is banner with specified width, use it to calculate the width of the container + const maxWidth = welcomeBanners.reduce((acc, curr) => { + const currWidth = curr.width ?? 0; - return currWidth; - }, defaultWidth); + if (acc > currWidth) { + return acc; + } - return ( -
-
- {welcomeBanner.length > 0 ? ( - 1} - autoPlay={true} - navButtonsAlwaysInvisible={true} - indicatorIconButtonProps={{ - style: { - color: "var(--iconActiveBackground)", - }, - }} - activeIndicatorIconButtonProps={{ - style: { - color: "var(--iconActiveColor)", - }, - }} - interval={8000} + return currWidth; + }, defaultWidth); + + return ( +
+
+ {welcomeBanners.length > 0 ? ( + 1} + autoPlay={true} + navButtonsAlwaysInvisible={true} + indicatorIconButtonProps={{ + style: { + color: "var(--iconActiveBackground)", + }, + }} + activeIndicatorIconButtonProps={{ + style: { + color: "var(--iconActiveColor)", + }, + }} + interval={8000} + > + {welcomeBanners.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 + + . +

+ +
- ); - } -} +
+ ); +}; + +export const Welcome = withInjectables( + observer(NonInjectedWelcome), + + { + getProps: (di) => ({ + welcomeMenuItems: di.inject(welcomeMenuItemsInjectable), + welcomeBannerItems: di.inject(welcomeBannerItemsInjectable), + }), + }, +); diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index 50417ce9dd..768ea50220 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -24,13 +24,14 @@ import "./pod-container-port.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import type { Pod } from "../../../common/k8s-api/endpoints"; -import { observable, makeObservable, reaction } from "mobx"; +import { action, observable, makeObservable, reaction } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Button } from "../button"; -import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward"; +import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, notifyErrorPortForwarding, openPortForward, PortForwardDialog, predictProtocol, removePortForward, startPortForward } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward"; import { Spinner } from "../spinner"; +import logger from "../../../common/logger"; interface Props { pod: Pod; @@ -46,6 +47,7 @@ export class PodContainerPort extends React.Component { @observable waiting = false; @observable forwardPort = 0; @observable isPortForwarded = false; + @observable isActive = false; constructor(props: Props) { super(props); @@ -55,13 +57,14 @@ export class PodContainerPort extends React.Component { componentDidMount() { disposeOnUnmount(this, [ - reaction(() => [portForwardStore.portForwards, this.props.pod], () => this.checkExistingPortForwarding()), + reaction(() => this.props.pod, () => this.checkExistingPortForwarding()), ]); } + @action async checkExistingPortForwarding() { const { pod, port } = this.props; - const portForward: ForwardedPort = { + let portForward: ForwardedPort = { kind: "pod", name: pod.getName(), namespace: pod.getNs(), @@ -69,57 +72,64 @@ export class PodContainerPort extends React.Component { forwardPort: this.forwardPort, }; - let activePort: number; - try { - activePort = await getPortForward(portForward) ?? 0; + portForward = await getPortForward(portForward); } catch (error) { this.isPortForwarded = false; + this.isActive = false; return; } - this.forwardPort = activePort; - this.isPortForwarded = activePort ? true : false; + this.forwardPort = portForward.forwardPort; + this.isPortForwarded = true; + this.isActive = portForward.status === "Active"; } + @action async portForward() { const { pod, port } = this.props; - const portForward: ForwardedPort = { + let portForward: ForwardedPort = { kind: "pod", name: pod.getName(), namespace: pod.getNs(), port: port.containerPort, forwardPort: this.forwardPort, protocol: predictProtocol(port.name), + status: "Active", }; this.waiting = true; try { - // determine how many port-forwards are already active - const { length } = await getPortForwards(); + // determine how many port-forwards already exist + const { length } = getPortForwards(); - this.forwardPort = await addPortForward(portForward); + if (!this.isPortForwarded) { + portForward = await addPortForward(portForward); + } else if (!this.isActive) { + portForward = await startPortForward(portForward); + } - if (this.forwardPort) { - portForward.forwardPort = this.forwardPort; + if (portForward.status === "Active") { openPortForward(portForward); - this.isPortForwarded = true; // if this is the first port-forward show the about notification if (!length) { aboutPortForwarding(); } + } else { + notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); } } catch (error) { - Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); - this.checkExistingPortForwarding(); + logger.error("[POD-CONTAINER-PORT]:", error, portForward); } finally { + this.checkExistingPortForwarding(); this.waiting = false; } } + @action async stopPortForward() { const { pod, port } = this.props; const portForward: ForwardedPort = { @@ -134,11 +144,11 @@ export class PodContainerPort extends React.Component { try { await removePortForward(portForward); - this.isPortForwarded = false; } catch (error) { Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`); - this.checkExistingPortForwarding(); } finally { + this.checkExistingPortForwarding(); + this.forwardPort = 0; this.waiting = false; } } @@ -148,7 +158,7 @@ export class PodContainerPort extends React.Component { const { name, containerPort, protocol } = port; const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`; - const portForwardAction = async () => { + const portForwardAction = action(async () => { if (this.isPortForwarded) { await this.stopPortForward(); } else { @@ -161,16 +171,16 @@ export class PodContainerPort extends React.Component { protocol: predictProtocol(port.name), }; - PortForwardDialog.open(portForward, { openInBrowser: true }); + PortForwardDialog.open(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() }); } - }; + }); return (
this.portForward()}> {text} - + {this.waiting && ( )} 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..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,40 +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, 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 { - catalogEntityRegistry.onRun(entity); - 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/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/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/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 04af444c30..95a21a747e 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -38,9 +38,9 @@ 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 { TopBar } from "../layout/topbar"; +import { setEntityOnRouteMatch } from "../../api/helpers/general-active-sync"; 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/command-palette/command-container.tsx b/src/renderer/components/command-palette/command-container.tsx index 2d4a7b6a83..711b16b142 100644 --- a/src/renderer/components/command-palette/command-container.tsx +++ b/src/renderer/components/command-palette/command-container.tsx @@ -21,68 +21,100 @@ 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 commandOverlayInjectable, { CommandOverlay } from "./command-overlay.injectable"; +import { isMac } from "../../../common/vars"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { CommandRegistration, CommandRegistry } from "../../../extensions/registries/command-registry"; -import { CommandOverlay } from "./command-overlay"; +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; } +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(); } } - private findCommandById(commandId: string) { - return CommandRegistry.getInstance().getItems().find((command) => command.id === commandId); - } + handleCommandPalette = () => { + const { commandOverlay } = this.props; + 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 { clusterId, addWindowEventListener, commandOverlay } = this.props; - if (command) { - this.runCommand(command); - } - }); - } else { - ipcRendererOn("command-palette:open", () => { - CommandOverlay.open(); - }); - } - window.addEventListener("keyup", (e) => this.escHandler(e), true); + const action = clusterId + ? () => commandOverlay.open() + : this.handleCommandPalette; + const ipcChannel = clusterId + ? `command-palette:${clusterId}:open` + : "command-palette:open"; + + disposeOnUnmount(this, [ + ipcRendererOn(ipcChannel, action), + addWindowEventListener("keydown", this.onKeyboardShortcut(action)), + addWindowEventListener("keyup", (e) => this.escHandler(e), true), + ]); } 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 3761068260..01f095ec16 100644 --- a/src/renderer/components/command-palette/command-dialog.tsx +++ b/src/renderer/components/command-palette/command-dialog.tsx @@ -21,108 +21,107 @@ 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 { CommandOverlay } from "./command-overlay"; -import { broadcastMessage } from "../../../common/ipc"; -import { navigate } from "../../navigation"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import React, { useState } from "react"; +import commandOverlayInjectable from "./command-overlay.injectable"; 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; + closeCommandOverlay: () => void; +} - constructor(props: {}) { - super(props); - makeObservable(this); - } +const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeCommandOverlay }: 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; } try { - CommandOverlay.close(); + closeCommandOverlay(); + 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, + 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/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.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/registered-commands.injectable.ts b/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts new file mode 100644 index 0000000000..a9bbd6bc57 --- /dev/null +++ b/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts @@ -0,0 +1,69 @@ +/** + * 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 "../../+custom-resources/custom-resources.injectable"; +import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +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, internalCommands }: Dependencies) => computed(() => { + const result = new Map(); + 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)) { + result.set(command.id, { ...command, isActive }); + } + } + + return result; +}); + +const registeredCommandsInjectable = getInjectable({ + instantiate: (di) => instantiateRegisteredCommands({ + extensions: di.inject(rendererExtensionsInjectable), + customResourceDefinitions: di.inject(customResourceDefinitionsInjectable), + internalCommands: di.inject(internalCommandsInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default registeredCommandsInjectable; 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/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/components/file-picker/file-picker.tsx b/src/renderer/components/file-picker/file-picker.tsx index 3661bb2cb2..ed955c4a22 100644 --- a/src/renderer/components/file-picker/file-picker.tsx +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -133,7 +133,8 @@ export class FilePicker extends React.Component { switch (onOverSizeLimit) { case OverSizeLimitStyle.FILTER: return files.filter(file => file.size <= maxSize ); - case OverSizeLimitStyle.REJECT: + + case OverSizeLimitStyle.REJECT: { const firstFileToLarge = files.find(file => file.size > maxSize); if (firstFileToLarge) { @@ -141,6 +142,7 @@ export class FilePicker extends React.Component { } return files; + } } } @@ -156,7 +158,9 @@ export class FilePicker extends React.Component { switch (onOverTotalSizeLimit) { case OverTotalSizeLimitStyle.FILTER_LARGEST: files = _.orderBy(files, ["size"]); - case OverTotalSizeLimitStyle.FILTER_LAST: + + // fallthrough + case OverTotalSizeLimitStyle.FILTER_LAST: { let newTotalSize = totalSize; for (;files.length > 0;) { @@ -168,6 +172,7 @@ export class FilePicker extends React.Component { } return files; + } case OverTotalSizeLimitStyle.REJECT: throw `Total file size to upload is too large. Expected at most ${maxTotalSize}. Found ${totalSize}.`; } 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..334d9205ad 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,17 @@ 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 from "../../../../common/hotbar-store.injectable"; +import { UserStore } from "../../../../common/user-store"; +import { ThemeStore } from "../../../theme.store"; +import { ConfirmDialog } from "../../confirm-dialog"; +import type { HotbarStore } from "../../../../common/hotbar-store"; jest.mock("electron", () => ({ app: { @@ -55,45 +59,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..e5faf0cb76 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -21,44 +21,53 @@ import React from "react"; import { observer } from "mobx-react"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { CommandOverlay } from "../command-palette"; 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"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; +import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable"; -export const uniqueHotbarName: InputValidator = { - condition: ({ required }) => required, - message: () => "Hotbar with this name already exists", - validate: value => !HotbarStore.getInstance().getByName(value), -}; +interface Dependencies { + closeCommandOverlay: () => void; + addHotbar: (data: CreateHotbarData, { setActive }?: CreateHotbarOptions) => void; + uniqueHotbarName: InputValidator; +} -@observer -export class HotbarAddCommand extends React.Component { - onSubmit = (name: string) => { +const NonInjectedHotbarAddCommand = observer(({ closeCommandOverlay, addHotbar, uniqueHotbarName }: 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, + uniqueHotbarName: di.inject(uniqueHotbarNameInjectable), + ...props, + }), +}); 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..d4b576e019 100644 --- a/src/renderer/components/hotbar/hotbar-rename-command.tsx +++ b/src/renderer/components/hotbar/hotbar-rename-command.tsx @@ -19,81 +19,61 @@ * 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 { Input } from "../input"; -import { uniqueHotbarName } from "./hotbar-add-command"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; +import { Input, InputValidator } from "../input"; +import type { Hotbar } from "../../../common/hotbar-types"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.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; }; + uniqueHotbarName: InputValidator; +} - onSubmit = (name: string) => { +const NonInjectedHotbarRenameCommand = observer(({ closeCommandOverlay, hotbarManager, uniqueHotbarName }: 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 +83,25 @@ 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 ( + onChange?.(props.checked, event)} {...props}/> + + ); +} 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: { 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/components/getDi.tsx b/src/renderer/getDi.tsx similarity index 72% rename from src/renderer/components/getDi.tsx rename to src/renderer/getDi.tsx index a0e4615a7c..e9126d62a9 100644 --- a/src/renderer/components/getDi.tsx +++ b/src/renderer/getDi.tsx @@ -20,15 +20,25 @@ */ import { createContainer } from "@ogre-tools/injectable"; +import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -export const getDi = () => - createContainer( +export function getDi() { + const di = createContainer( getRequireContextForRendererCode, getRequireContextForCommonExtensionCode, + getRequireContextForCommonCode, ); + setLegacyGlobalDiForExtensionApi(di); + + return di; +} + 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/components/getDiForUnitTesting.tsx b/src/renderer/getDiForUnitTesting.tsx similarity index 72% rename from src/renderer/components/getDiForUnitTesting.tsx rename to src/renderer/getDiForUnitTesting.tsx index fefd120fb4..a5f03165b2 100644 --- a/src/renderer/components/getDiForUnitTesting.tsx +++ b/src/renderer/getDiForUnitTesting.tsx @@ -21,27 +21,20 @@ import glob from "glob"; import { memoize } from "lodash/fp"; - -import { - createContainer, - ConfigurableDependencyInjectionContainer, -} from "@ogre-tools/injectable"; +import { createContainer } from "@ogre-tools/injectable"; export const getDiForUnitTesting = () => { - const di: ConfigurableDependencyInjectionContainer = createContainer(); + const di = createContainer(); - getInjectableFilePaths() - .map(key => { - const injectable = require(key).default; + for (const filePath of getInjectableFilePaths()) { + const injectableInstance = require(filePath).default; - return { - id: key, - ...injectable, - aliases: [injectable, ...(injectable.aliases || [])], - }; - }) - - .forEach(injectable => di.register(injectable)); + di.register({ + id: filePath, + ...injectableInstance, + aliases: [injectableInstance, ...(injectableInstance.aliases || [])], + }); + } di.preventSideEffects(); @@ -50,5 +43,6 @@ export const getDiForUnitTesting = () => { const getInjectableFilePaths = memoize(() => [ ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }), - ...glob.sync("../../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }), + ...glob.sync("../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }), + ...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }), ]); diff --git a/src/renderer/initializers/catalog.tsx b/src/renderer/initializers/catalog.tsx index d02d253353..546c717aa5 100644 --- a/src/renderer/initializers/catalog.tsx +++ b/src/renderer/initializers/catalog.tsx @@ -22,32 +22,12 @@ import React from "react"; import fs from "fs"; import "../../common/catalog-entities/kubernetes-cluster"; -import { WebLinkCategory } from "../../common/catalog-entities"; import { ClusterStore } from "../../common/cluster-store"; import { catalogCategoryRegistry } from "../api/catalog-category-registry"; import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command"; -import { CommandOverlay } from "../components/command-palette"; import { loadConfigFromString } from "../../common/kube-helpers"; import { DeleteClusterDialog } from "../components/delete-cluster-dialog"; -function initWebLinks() { - WebLinkCategory.onAdd = () => CommandOverlay.open(); -} - -function initKubernetesClusters() { - catalogCategoryRegistry - .getForGroupKind("entity.k8slens.dev", "KubernetesCluster") - .on("contextMenuOpen", (entity, context) => { - if (entity.metadata?.source == "local") { - context.menuItems.push({ - title: "Delete", - icon: "delete", - onClick: () => onClusterDelete(entity.metadata.uid), - }); - } - }); -} - async function onClusterDelete(clusterId: string) { const cluster = ClusterStore.getInstance().getById(clusterId); @@ -64,7 +44,29 @@ async function onClusterDelete(clusterId: string) { DeleteClusterDialog.open({ cluster, config }); } -export function initCatalog() { - initWebLinks(); - initKubernetesClusters(); +interface Dependencies { + openCommandDialog: (component: React.ReactElement) => void; +} + +export function initCatalog({ openCommandDialog }: Dependencies) { + catalogCategoryRegistry + .getForGroupKind("entity.k8slens.dev", "WebLink") + .on("catalogAddMenu", ctx => { + ctx.menuItems.push({ + title: "Add web link", + icon: "public", + onClick: () => openCommandDialog(), + }); + }); + catalogCategoryRegistry + .getForGroupKind("entity.k8slens.dev", "KubernetesCluster") + .on("contextMenuOpen", (entity, context) => { + if (entity.metadata?.source == "local") { + context.menuItems.push({ + title: "Delete", + icon: "delete", + onClick: () => onClusterDelete(entity.metadata.uid), + }); + } + }); } 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 55d1bbbb1b..758c9042ad 100644 --- a/src/renderer/initializers/index.ts +++ b/src/renderer/initializers/index.ts @@ -21,13 +21,11 @@ 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"; 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..a68b4e0abd 100644 --- a/src/renderer/initializers/registries.ts +++ b/src/renderer/initializers/registries.ts @@ -26,15 +26,11 @@ export function initRegistries() { registries.CatalogEntityDetailRegistry.createInstance(); registries.ClusterPageMenuRegistry.createInstance(); registries.ClusterPageRegistry.createInstance(); - registries.CommandRegistry.createInstance(); registries.EntitySettingRegistry.createInstance(); registries.GlobalPageRegistry.createInstance(); registries.KubeObjectDetailRegistry.createInstance(); registries.KubeObjectMenuRegistry.createInstance(); registries.KubeObjectStatusRegistry.createInstance(); registries.StatusBarRegistry.createInstance(); - registries.WelcomeMenuRegistry.createInstance(); - registries.WelcomeBannerRegistry.createInstance(); registries.WorkloadsOverviewDetailRegistry.createInstance(); - registries.TopBarRegistry.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, diff --git a/src/renderer/port-forward/port-forward-dialog.tsx b/src/renderer/port-forward/port-forward-dialog.tsx index c306b0aaf3..9db91f8993 100644 --- a/src/renderer/port-forward/port-forward-dialog.tsx +++ b/src/renderer/port-forward/port-forward-dialog.tsx @@ -27,18 +27,20 @@ import { observer } from "mobx-react"; import { Dialog, DialogProps } from "../components/dialog"; import { Wizard, WizardStep } from "../components/wizard"; import { Input } from "../components/input"; -import { Notifications } from "../components/notifications"; -import { cssNames } from "../utils"; +import { cssNames, noop } 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, notifyErrorPortForwarding } from "./port-forward-notify"; import { Checkbox } from "../components/checkbox"; +import logger from "../../common/logger"; interface Props extends Partial { } interface PortForwardDialogOpenOptions { - openInBrowser: boolean + openInBrowser: boolean; + onClose: () => void; } const dialogState = observable.object({ @@ -46,6 +48,7 @@ const dialogState = observable.object({ data: null as ForwardedPort, useHttps: false, openInBrowser: false, + onClose: noop, }); @observer @@ -58,11 +61,12 @@ export class PortForwardDialog extends Component { makeObservable(this); } - static open(portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false }) { + static open(portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false, onClose: noop }) { dialogState.isOpen = true; dialogState.data = portForward; dialogState.useHttps = portForward.protocol === "https"; dialogState.openInBrowser = options.openInBrowser; + dialogState.onClose = options.onClose; } static close() { @@ -84,43 +88,47 @@ export class PortForwardDialog extends Component { this.desiredPort = this.currentPort; }; - onClose = () => { - }; - changePort = (value: string) => { this.desiredPort = Number(value); }; startPortForward = async () => { - const { portForward } = this; + let { portForward } = this; const { currentPort, desiredPort, close } = this; try { - // determine how many port-forwards are already active - const { length } = await getPortForwards(); - - let port: number; + // determine how many port-forwards already exist + const { length } = getPortForwards(); portForward.protocol = dialogState.useHttps ? "https" : "http"; if (currentPort) { - port = await modifyPortForward(portForward, desiredPort); + const wasRunning = portForward.status === "Active"; + + portForward = await modifyPortForward(portForward, desiredPort); + + if (wasRunning && portForward.status === "Disabled") { + notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${portForward.forwardPort} may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); + } } else { portForward.forwardPort = desiredPort; - port = await addPortForward(portForward); + portForward = await addPortForward(portForward); - // if this is the first port-forward show the about notification - if (!length) { - aboutPortForwarding(); + if (portForward.status === "Disabled") { + notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${portForward.forwardPort} may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); + } else { + // if this is the first port-forward show the about notification + if (!length) { + aboutPortForwarding(); + } } } - if (dialogState.openInBrowser) { - portForward.forwardPort = port; + if (portForward.status === "Active" && dialogState.openInBrowser) { openPortForward(portForward); } - } catch (err) { - Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); + } catch (error) { + logger.error(`[PORT-FORWARD-DIALOG]: ${error}`, portForward); } finally { close(); } @@ -175,14 +183,14 @@ export class PortForwardDialog extends Component { isOpen={dialogState.isOpen} className={cssNames("PortForwardDialog", className)} onOpen={this.onOpen} - onClose={this.onClose} + onClose={dialogState.onClose} close={this.close} > {this.renderContents()} diff --git a/src/renderer/port-forward/port-forward-item.ts b/src/renderer/port-forward/port-forward-item.ts index 33126fafa5..90a9c7c218 100644 --- a/src/renderer/port-forward/port-forward-item.ts +++ b/src/renderer/port-forward/port-forward-item.ts @@ -23,33 +23,34 @@ import type { ItemObject } from "../../common/item.store"; import { autoBind } from "../../common/utils"; +export type ForwardedPortStatus = "Active" | "Disabled"; export interface ForwardedPort { - clusterId?: string; kind: string; namespace: string; name: string; port: number; forwardPort: number; protocol?: string; + status?: ForwardedPortStatus; } export class PortForwardItem implements ItemObject { - clusterId: string; kind: string; namespace: string; name: string; port: number; forwardPort: number; protocol: string; + status: ForwardedPortStatus; constructor(pf: ForwardedPort) { - this.clusterId = pf.clusterId; this.kind = pf.kind; this.namespace = pf.namespace; this.name = pf.name; this.port = pf.port; this.forwardPort = pf.forwardPort; this.protocol = pf.protocol ?? "http"; + this.status = pf.status ?? "Active"; autoBind(this); } @@ -62,12 +63,8 @@ export class PortForwardItem implements ItemObject { return this.namespace; } - get id() { - return this.forwardPort; - } - getId() { - return String(this.forwardPort); + return `${this.namespace}-${this.kind}-${this.name}:${this.port}`; } getKind() { @@ -87,16 +84,17 @@ export class PortForwardItem implements ItemObject { } getStatus() { - return "Active"; // to-do allow port-forward-items to be stopped (without removing them) + return this.status; } getSearchFields() { return [ this.name, - this.id, + this.namespace, this.kind, this.port, this.forwardPort, + this.status, ]; } } diff --git a/src/renderer/port-forward/port-forward-notify.tsx b/src/renderer/port-forward/port-forward-notify.tsx index 4b26fb976c..788a5b672d 100644 --- a/src/renderer/port-forward/port-forward-notify.tsx +++ b/src/renderer/port-forward/port-forward-notify.tsx @@ -56,3 +56,34 @@ export function aboutPortForwarding() { }, ); } + +export function notifyErrorPortForwarding(msg: string) { + const notificationId = `port-forward-error-notification-${getHostedClusterId()}`; + + Notifications.error( + ( +
+ Port Forwarding +

+ {msg} +

+
+
+
+ ), + { + id: notificationId, + timeout: 10_000, + }, + ); +} + diff --git a/src/renderer/port-forward/port-forward-utils.ts b/src/renderer/port-forward/port-forward-utils.ts index c88761f344..775a350e2b 100644 --- a/src/renderer/port-forward/port-forward-utils.ts +++ b/src/renderer/port-forward/port-forward-utils.ts @@ -35,7 +35,6 @@ export function openPortForward(portForward: ForwardedPort) { openExternal(browseTo) .catch(error => { logger.error(`failed to open in browser: ${error}`, { - clusterId: portForward.clusterId, port: portForward.port, kind: portForward.kind, namespace: portForward.namespace, diff --git a/src/renderer/port-forward/port-forward.store.ts b/src/renderer/port-forward/port-forward.store.ts index 57c9d8a1c4..e914fff7d5 100644 --- a/src/renderer/port-forward/port-forward.store.ts +++ b/src/renderer/port-forward/port-forward.store.ts @@ -20,10 +20,11 @@ */ -import { makeObservable, observable, reaction } from "mobx"; +import { action, makeObservable, observable, reaction } from "mobx"; import { ItemStore } from "../../common/item.store"; -import { autoBind, createStorage, disposer, getHostedClusterId } from "../utils"; +import { autoBind, createStorage, disposer } from "../utils"; import { ForwardedPort, PortForwardItem } from "./port-forward-item"; +import { notifyErrorPortForwarding } from "./port-forward-notify"; import { apiBase } from "../api"; import { waitUntilFree } from "tcp-port-used"; import logger from "../../common/logger"; @@ -31,7 +32,7 @@ import logger from "../../common/logger"; export class PortForwardStore extends ItemStore { private storage = createStorage("port_forwards", undefined); - @observable portForwards: PortForwardItem[]; + @observable portForwards: PortForwardItem[] = []; constructor() { super(); @@ -46,35 +47,44 @@ export class PortForwardStore extends ItemStore { const savedPortForwards = this.storage.get(); // undefined on first load - if (Array.isArray(savedPortForwards)) { + if (Array.isArray(savedPortForwards) && savedPortForwards.length > 0) { logger.info("[PORT-FORWARD-STORE] starting saved port-forwards"); - await Promise.all(savedPortForwards.map(addPortForward)); + + // add the disabled ones + await Promise.all(savedPortForwards.filter(pf => pf.status === "Disabled").map(addPortForward)); + + // add the active ones (assume active if the status is undefined, for backward compatibilty) and check if they started successfully + const results = await Promise.allSettled(savedPortForwards.filter(pf => !pf.status || pf.status === "Active").map(addPortForward)); + + for (const result of results) { + if (result.status === "rejected" || result.value.status === "Disabled") { + notifyErrorPortForwarding("One or more port-forwards could not be started"); + + return; + } + } } } watch() { return disposer( - reaction(() => this.portForwards, () => this.loadAll()), + reaction(() => portForwardStore.portForwards.slice(), () => portForwardStore.loadAll()), ); } loadAll() { - return this.loadItems(async () => { - const portForwards = await getPortForwards(getHostedClusterId()); + return this.loadItems(() => { + const portForwards = getPortForwards(); this.storage.set(portForwards); - this.reset(); + this.portForwards = []; portForwards.map(pf => this.portForwards.push(new PortForwardItem(pf))); return this.portForwards; }); } - reset() { - this.portForwards = []; - } - async removeSelectedItems() { return Promise.all(this.selectedItems.map(removePortForward)); } @@ -94,82 +104,248 @@ interface PortForwardResult { port: number; } -interface PortForwardsResult { - portForwards: ForwardedPort[]; +function portForwardsEqual(portForward: ForwardedPort) { + return (pf: ForwardedPort) => ( + pf.kind == portForward.kind && + pf.name == portForward.name && + pf.namespace == portForward.namespace && + pf.port == portForward.port + ); } -export async function addPortForward(portForward: ForwardedPort): Promise { - const { port, forwardPort } = portForward; +function findPortForward(portForward: ForwardedPort) { + return portForwardStore.portForwards.find(portForwardsEqual(portForward)); + +} + +const setPortForward = action((portForward: ForwardedPort) => { + const index = portForwardStore.portForwards.findIndex(portForwardsEqual(portForward)); + + if (index < 0 ) { + return; + } + + portForwardStore.portForwards[index] = new PortForwardItem(portForward); +}); + +/** + * start an existing port-forward + * @param portForward the port-forward to start. If the forwardPort field is 0 then an arbitrary port will be + * used + * + * @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and + * forwardPort + * + * @throws if the port-forward does not already exist in the store + */ +export const startPortForward = action( async (portForward: ForwardedPort): Promise => { + const pf = findPortForward(portForward); + + if (!pf) { + throw new Error("cannot start non-existent port-forward"); + } + + const { port, forwardPort } = pf; let response: PortForwardResult; try { - const protocol = portForward.protocol ?? "http"; + const protocol = pf.protocol ?? "http"; - response = await apiBase.post(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }}); + response = await apiBase.post(`/pods/port-forward/${pf.namespace}/${pf.kind}/${pf.name}`, { query: { port, forwardPort, protocol }}); // expecting the received port to be the specified port, unless the specified port is 0, which indicates any available port is suitable - if (portForward.forwardPort && response?.port && response.port != +portForward.forwardPort) { - logger.warn(`[PORT-FORWARD-STORE] specified ${portForward.forwardPort} got ${response.port}`); + if (pf.forwardPort && response?.port && response.port != +pf.forwardPort) { + logger.warn(`[PORT-FORWARD-STORE] specified ${pf.forwardPort}, got ${response.port}`); } + + pf.forwardPort = response.port; + pf.status = "Active"; + } catch (error) { - logger.warn("[PORT-FORWARD-STORE] Error adding port-forward:", error, portForward); - throw (error); + logger.warn(`[PORT-FORWARD-STORE] Error starting port-forward: ${error}`, pf); + pf.status = "Disabled"; } - portForwardStore.reset(); - return response?.port; -} + setPortForward(pf); -export async function getPortForward(portForward: ForwardedPort): Promise { + return pf as ForwardedPort; +}); + +/** + * add a port-forward to the store and optionally start it + * @param portForward the port-forward to add. If the port-forward already exists in the store it will be + * returned with its current state. If the forwardPort field is 0 then an arbitrary port will be + * used. If the status field is "Active" or not present then an attempt is made to start the port-forward. + * + * @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and + * forwardPort + */ +export const addPortForward = action(async (portForward: ForwardedPort): Promise => { + const pf = findPortForward(portForward); + + if (pf) { + return pf; + } + + portForwardStore.portForwards.push(new PortForwardItem(portForward)); + + if (!portForward.status) { + portForward.status = "Active"; + } + + if (portForward.status === "Active") { + portForward = await startPortForward(portForward); + } + + return portForward; +}); + +async function getActivePortForward(portForward: ForwardedPort): Promise { const { port, forwardPort, protocol } = portForward; let response: PortForwardResult; try { response = await apiBase.get(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }}); } catch (error) { - logger.warn("[PORT-FORWARD-STORE] Error getting port-forward:", error, portForward); - throw (error); + logger.warn(`[PORT-FORWARD-STORE] Error getting active port-forward: ${error}`, portForward); } - return response?.port; + portForward.status = response?.port ? "Active" : "Disabled"; + portForward.forwardPort = response?.port; + + return portForward; } -export async function modifyPortForward(portForward: ForwardedPort, desiredPort: number): Promise { - let port = 0; +/** + * get a port-forward from the store, with up-to-date status + * @param portForward the port-forward to get. + * + * @returns the port-forward with updated status ("Active" if running, "Disabled" if not) and + * forwardPort used. + * + * @throws if the port-forward does not exist in the store + */ +export async function getPortForward(portForward: ForwardedPort): Promise { + if (!findPortForward(portForward)) { + throw new Error("port-forward not found"); + } - await removePortForward(portForward); - portForward.forwardPort = desiredPort; - port = await addPortForward(portForward); + let pf: ForwardedPort; - portForwardStore.reset(); + try { + // check if the port-forward is active, and if so check if it has the same local port + pf = await getActivePortForward(portForward); - return port; + if (pf.forwardPort && pf.forwardPort !== portForward.forwardPort) { + logger.warn(`[PORT-FORWARD-STORE] local port, expected ${pf.forwardPort}, got ${portForward.forwardPort}`); + } + } catch (error) { + // port is not active + } + + return pf; } +/** + * modifies a port-forward in the store, including the forwardPort and protocol + * @param portForward the port-forward to modify. + * + * @returns the port-forward after being modified. + */ +export const modifyPortForward = action(async (portForward: ForwardedPort, desiredPort: number): Promise => { + const pf = findPortForward(portForward); + + if (!pf) { + throw new Error("port-forward not found"); + } + + if (pf.status === "Active") { + try { + await stopPortForward(pf); + } catch { + // ignore, assume it is stopped and proceed to restart it + } + + pf.forwardPort = desiredPort; + pf.protocol = portForward.protocol ?? "http"; + setPortForward(pf); + + return await startPortForward(pf); + } + + pf.forwardPort = desiredPort; + setPortForward(pf); + + return pf as ForwardedPort; +}); + + +/** + * stop an existing port-forward. Its status is set to "Disabled" after successfully stopped. + * @param portForward the port-forward to stop. + * + * @throws if the port-forward could not be stopped. Its status is unchanged + */ +export const stopPortForward = action(async (portForward: ForwardedPort) => { + const pf = findPortForward(portForward); + + if (!pf) { + logger.warn("[PORT-FORWARD-STORE] Error getting port-forward: port-forward not found", portForward); + + return; + } -export async function removePortForward(portForward: ForwardedPort) { const { port, forwardPort } = portForward; try { await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort }}); await waitUntilFree(+forwardPort, 200, 1000); } catch (error) { - logger.warn("[PORT-FORWARD-STORE] Error removing port-forward:", error, portForward); + logger.warn(`[PORT-FORWARD-STORE] Error stopping active port-forward: ${error}`, portForward); throw (error); } - portForwardStore.reset(); -} -export async function getPortForwards(clusterId?: string): Promise { - try { - const response = await apiBase.get("/pods/port-forwards", { query: { clusterId }}); + pf.status = "Disabled"; + setPortForward(pf); +}); - return response.portForwards; - } catch (error) { - logger.warn("[PORT-FORWARD-STORE] Error getting all port-forwards:", error); +/** + * remove and stop an existing port-forward. + * @param portForward the port-forward to remove. + */ +export const removePortForward = action(async (portForward: ForwardedPort) => { + const pf = findPortForward(portForward); - return []; + if (!pf) { + const error = new Error("port-forward not found"); + + logger.warn(`[PORT-FORWARD-STORE] Error getting port-forward: ${error}`, portForward); + + return; } + + try { + await stopPortForward(portForward); + } catch (error) { + if (pf.status === "Active") { + logger.warn(`[PORT-FORWARD-STORE] Error removing port-forward: ${error}`, portForward); + } + } + + const index = portForwardStore.portForwards.findIndex(portForwardsEqual(portForward)); + + if (index >= 0 ) { + portForwardStore.portForwards.splice(index, 1); + } +}); + +/** + * gets the list of port-forwards in the store + * + * @returns the port-forwards + */ +export function getPortForwards(): ForwardedPort[] { + return portForwardStore.portForwards; } export const portForwardStore = new PortForwardStore(); 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 @@
+
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/createStorage.ts b/src/renderer/utils/createStorage.ts index 92c92c8f5a..c282994e61 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/createStorage.ts @@ -50,7 +50,9 @@ export function createStorage(key: string, defaultValue: T) { try { storage.data = await fse.readJson(filePath); - } catch {} finally { + } catch { + // ignore error + } finally { if (!isTestEnv) { logger.info(`${logPrefix} loading finished for ${filePath}`); } 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/src/renderer/window/event-listener.injectable.ts b/src/renderer/window/event-listener.injectable.ts new file mode 100644 index 0000000000..313b961800 --- /dev/null +++ b/src/renderer/window/event-listener.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 type { Disposer } from "../utils"; + +function addWindowEventListener(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer { + window.addEventListener(type, listener, options); + + return () => void window.removeEventListener(type, listener); +} + +const windowAddEventListenerInjectable = getInjectable({ + instantiate: () => addWindowEventListener, + lifecycle: lifecycleEnum.singleton, +}); + +export default windowAddEventListenerInjectable; 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 76479fa361..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" @@ -3415,17 +3431,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: @@ -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== @@ -4853,10 +4876,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" @@ -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= @@ -6593,17 +6652,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" @@ -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" @@ -9885,6 +9951,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" @@ -10823,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" @@ -13527,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" @@ -13576,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" @@ -13674,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==