diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 03d9549ea8..15223a3492 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,7 @@ + \ No newline at end of file diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index a70d470a64..f22dc4be50 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -110,6 +110,10 @@ describe("cluster-store", () => { createCluster = mainDi.inject(createClusterInjectionToken); }); + afterEach(() => { + mockFs.restore(); + }); + describe("empty config", () => { let getCustomKubeConfigDirectory: (directoryName: string) => string; diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index ab69e3ccee..e0fbd56833 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -239,7 +239,7 @@ describe("HotbarStore", () => { const hotbarStore = HotbarStore.getInstance(); hotbarStore.add({ name: "hottest", id: "hottest" }); - hotbarStore.activeHotbarId = "hottest"; + hotbarStore.setActiveHotbar("hottest"); const { error } = logger; const mocked = jest.fn(); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 870558e0b3..6764ca8bee 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CatalogCategory, CatalogEntity, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; +import { CatalogCategory, CatalogEntity, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { productName } from "../vars"; import { WeblinkStore } from "../weblink-store"; @@ -86,21 +86,6 @@ export class WebLinkCategory extends CatalogCategory { kind: "WebLink", }, }; - public static onAdd?: () => void; - - constructor() { - super(); - - this.on("catalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { - ctx.menuItems.push({ - icon: "public", - title: "Add web link", - onClick: () => { - WebLinkCategory.onAdd(); - }, - }); - }); - } } catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/renderer/port-forward/port-forward-store/add-port-forward/add-port-forward.injectable.ts b/src/common/hotbar-store.injectable.ts similarity index 84% rename from src/renderer/port-forward/port-forward-store/add-port-forward/add-port-forward.injectable.ts rename to src/common/hotbar-store.injectable.ts index 2064c85788..4d01f3bada 100644 --- a/src/renderer/port-forward/port-forward-store/add-port-forward/add-port-forward.injectable.ts +++ b/src/common/hotbar-store.injectable.ts @@ -19,12 +19,11 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; -import portForwardStoreInjectable from "../port-forward-store.injectable"; - -const addPortForwardInjectable = getInjectable({ - instantiate: (di) => di.inject(portForwardStoreInjectable).add, +import { HotbarStore } from "./hotbar-store"; +const hotbarManagerInjectable = getInjectable({ + instantiate: () => HotbarStore.getInstance(), lifecycle: lifecycleEnum.singleton, }); -export default addPortForwardInjectable; +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 0442d07630..95b7c672a4 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -34,7 +34,9 @@ const electronRemote = (() => { if (ipcRenderer) { try { return require("@electron/remote"); - } catch {} + } catch { + // ignore temp + } } return null; diff --git a/src/extensions/extension-discovery/extension-discovery.test.ts b/src/extensions/extension-discovery/extension-discovery.test.ts index 7eb648e312..8713f56344 100644 --- a/src/extensions/extension-discovery/extension-discovery.test.ts +++ b/src/extensions/extension-discovery/extension-discovery.test.ts @@ -19,16 +19,19 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import mockFs from "mock-fs"; import { watch } from "chokidar"; import path from "path"; -import type { ExtensionDiscovery } from "./extension-discovery"; import os from "os"; import { Console } from "console"; -import extensionDiscoveryInjectable from "./extension-discovery.injectable"; -import extensionPackageRootDirectoryInjectable from "../extension-installer/extension-package-root-directory/extension-package-root-directory.injectable"; -import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable"; +import * as fse from "fs-extra"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; +import extensionDiscoveryInjectable from "../extension-discovery/extension-discovery.injectable"; +import type { ExtensionDiscovery } from "../extension-discovery/extension-discovery"; +import installExtensionInjectable + from "../extension-installer/install-extension/install-extension.injectable"; +import directoryForUserDataInjectable + from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import mockFs from "mock-fs"; jest.setTimeout(60_000); @@ -36,6 +39,8 @@ jest.mock("../../common/ipc"); jest.mock("chokidar", () => ({ watch: jest.fn(), })); + +jest.mock("fs-extra"); jest.mock("electron", () => ({ app: { getVersion: () => "99.99.99", @@ -54,6 +59,7 @@ jest.mock("electron", () => ({ console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; +const mockedFse = fse as jest.Mocked; describe("ExtensionDiscovery", () => { let extensionDiscovery: ExtensionDiscovery; @@ -61,20 +67,10 @@ describe("ExtensionDiscovery", () => { beforeEach(async () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); - mockFs({ - [`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: - JSON.stringify({ - name: "my-extension", - }), - }); - - + di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); di.override(installExtensionInjectable, () => () => Promise.resolve()); - di.override( - extensionPackageRootDirectoryInjectable, - () => "some-extension-packages-root", - ); + mockFs(); await di.runSetups(); @@ -85,10 +81,20 @@ describe("ExtensionDiscovery", () => { mockFs.restore(); }); - it("emits add for added extension", async (done) => { let addHandler: (filePath: string) => void; + mockedFse.readJson.mockImplementation((p) => { + expect(p).toBe(path.join(os.homedir(), ".k8slens/extensions/my-extension/package.json")); + + return { + name: "my-extension", + version: "1.0.0", + }; + }); + + mockedFse.pathExists.mockImplementation(() => true); + const mockWatchInstance: any = { on: jest.fn((event: string, handler: typeof addHandler) => { if (event === "add") { @@ -99,42 +105,35 @@ describe("ExtensionDiscovery", () => { }), }; - mockedWatch.mockImplementationOnce(() => mockWatchInstance as any); + mockedWatch.mockImplementationOnce(() => + (mockWatchInstance) as any, + ); // 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) => { + extensionDiscovery.events.on("add", extension => { expect(extension).toEqual({ absolutePath: expect.any(String), - id: path.normalize( - "some-extension-packages-root/node_modules/my-extension/package.json", - ), + id: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"), isBundled: false, isEnabled: false, isCompatible: false, - manifest: { + manifest: { name: "my-extension", + version: "1.0.0", }, - manifestPath: path.normalize( - "some-extension-packages-root/node_modules/my-extension/package.json", - ), + manifestPath: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"), }); - done(); }); - addHandler( - path.join( - extensionDiscovery.localFolderPath, - "/my-extension/package.json", - ), - ); + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); }); - it("doesn't emit add for added file under extension", async (done) => { + it("doesn't emit add for added file under extension", async done => { let addHandler: (filePath: string) => void; const mockWatchInstance: any = { @@ -147,7 +146,9 @@ describe("ExtensionDiscovery", () => { }), }; - mockedWatch.mockImplementationOnce(() => mockWatchInstance as any); + mockedWatch.mockImplementationOnce(() => + (mockWatchInstance) as any, + ); // Need to force isLoaded to be true so that the file watching is started extensionDiscovery.isLoaded = true; @@ -158,12 +159,7 @@ describe("ExtensionDiscovery", () => { extensionDiscovery.events.on("add", onAdd); - addHandler( - path.join( - extensionDiscovery.localFolderPath, - "/my-extension/node_modules/dep/package.json", - ), - ); + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json")); setTimeout(() => { expect(onAdd).not.toHaveBeenCalled(); @@ -171,3 +167,4 @@ describe("ExtensionDiscovery", () => { }, 10); }); }); + diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index ae49ee03dd..39e97ae307 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -271,7 +271,6 @@ export class ExtensionLoader { registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), - registries.CommandRegistry.getInstance().add(extension.commands), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), ]; @@ -302,7 +301,6 @@ export class ExtensionLoader { registries.KubeObjectDetailRegistry.getInstance().add(extension.kubeObjectDetailItems), registries.KubeObjectStatusRegistry.getInstance().add(extension.kubeObjectStatusTexts), registries.WorkloadsOverviewDetailRegistry.getInstance().add(extension.kubeWorkloadsOverviewItems), - registries.CommandRegistry.getInstance().add(extension.commands), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 9f7b947d82..718f25f7c5 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -30,6 +30,7 @@ import type { TopBarRegistration } from "../renderer/components/layout/top-bar/t import type { KubernetesCluster } from "../common/catalog-entities"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration"; +import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -42,7 +43,7 @@ export class LensRendererExtension extends LensExtension { kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = []; kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; - commands: registries.CommandRegistration[] = []; + commands: CommandRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = []; welcomeBanners: WelcomeBannerRegistration[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; diff --git a/src/extensions/registries/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 76f6c05d11..e8c9930176 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -28,7 +28,6 @@ export * from "./status-bar-registry"; export * from "./kube-object-detail-registry"; export * from "./kube-object-menu-registry"; export * from "./kube-object-status-registry"; -export * from "./command-registry"; export * from "./entity-setting-registry"; export * from "./catalog-entity-detail-registry"; export * from "./workloads-overview-detail-registry"; diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index c0a0086e32..a46e76c797 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -18,8 +18,6 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { getDiForUnitTesting } from "../getDiForUnitTesting"; - const logger = { silly: jest.fn(), debug: jest.fn(), @@ -47,6 +45,7 @@ jest.mock("winston", () => ({ }, })); +import { getDiForUnitTesting } from "../getDiForUnitTesting"; import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; import mockFs from "mock-fs"; import type { Cluster } from "../../common/cluster/cluster"; @@ -69,7 +68,7 @@ describe("kubeconfig manager tests", () => { di.override(directoryForTempInjectable, () => "some-directory-for-temp"); - const mockOpts = { + mockFs({ "minikube-config.yml": JSON.stringify({ apiVersion: "v1", clusters: [{ @@ -91,9 +90,7 @@ describe("kubeconfig manager tests", () => { kind: "Config", preferences: {}, }), - }; - - mockFs(mockOpts); + }); await di.runSetups(); @@ -110,7 +107,7 @@ describe("kubeconfig manager tests", () => { cluster.contextHandler = { ensureServer: () => Promise.resolve(), } as any; - + jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); }); @@ -135,7 +132,7 @@ describe("kubeconfig manager tests", () => { it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => { const kubeConfManager = createKubeconfigManager(cluster); - + const configPath = await kubeConfManager.getPath(); expect(await fse.pathExists(configPath)).toBe(true); diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts index 6ad163fad1..164c2b8020 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts @@ -22,25 +22,33 @@ import electronAppInjectable from "./electron-app/electron-app.injectable"; import getElectronAppPathInjectable from "./get-electron-app-path.injectable"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import type { App } from "electron"; +import registerChannelInjectable from "../register-channel/register-channel.injectable"; describe("get-electron-app-path", () => { let getElectronAppPath: (name: string) => string | null; - beforeEach(() => { - const di = getDiForUnitTesting(); + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: false }); const appStub = { + name: "some-app-name", + getPath: (name: string) => { if (name !== "some-existing-name") { throw new Error("irrelevant"); } return "some-existing-app-path"; - }, + + // eslint-disable-next-line unused-imports/no-unused-vars-ts + setPath: (_, __) => undefined, } as App; di.override(electronAppInjectable, () => appStub); + di.override(registerChannelInjectable, () => () => undefined); + + await di.runSetups(); getElectronAppPath = di.inject(getElectronAppPathInjectable); }); @@ -54,6 +62,6 @@ describe("get-electron-app-path", () => { it("given app path does not exist, when called, returns null", () => { const actual = getElectronAppPath("some-non-existing-name"); - expect(actual).toBe(null); + expect(actual).toBe(""); }); }); diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts index 7fe409378d..beec2ac74f 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.ts @@ -31,6 +31,6 @@ export const getElectronAppPath = try { return app.getPath(name); } catch (e) { - return null; + return ""; } }; diff --git a/src/main/kubectl/kubectl.ts b/src/main/kubectl/kubectl.ts index 48ac269806..2c27787473 100644 --- a/src/main/kubectl/kubectl.ts +++ b/src/main/kubectl/kubectl.ts @@ -26,11 +26,13 @@ import logger from "../logger"; import { ensureDir, pathExists } from "fs-extra"; import * as lockFile from "proper-lockfile"; import { helmCli } from "../helm/helm-cli"; -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 got from "got/dist/source"; +import { promisify } from "util"; +import stream from "stream"; const bundledVersion = getBundledKubectlVersion(); const kubectlMap: Map = new Map([ @@ -51,7 +53,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; } @@ -308,99 +310,87 @@ 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); - }); + await pipeline(downloadStream, fileWriteStream); + logger.debug("kubectl binary download finished"); } protected async writeInitScripts() { - const kubectlPath = this.dependencies.userStore.downloadKubectlBinaries ? this.dirname : path.dirname(this.getPathFromPreferences()); + const kubectlPath = this.dependencies.userStore.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(this.dependencies.userStore.downloadMirror) + const { url } = packageMirrors.get(this.dependencies.userStore.downloadMirror) ?? packageMirrors.get(defaultPackageMirror); - return mirror.url; + return url; } } 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/router.ts b/src/main/router.ts index affe92eecd..991d0e5b30 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -186,7 +186,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}` }, this.dependencies.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 c8a97a0436..7cea1bd7b5 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -22,7 +22,7 @@ import type { LensApiRequest } from "../router"; import logger from "../logger"; import { respondJson } from "../utils/http-responses"; -import { PortForward, PortForwardArgs } from "./port-forward/port-forward"; +import { PortForward } from "./port-forward/port-forward"; export class PortForwardRoute { static async routeCurrentPortForward(request: LensApiRequest) { @@ -40,31 +40,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/tray/tray-menu-items.test.ts b/src/main/tray/tray-menu-items.test.ts index b46bce3671..0cde4e43f2 100644 --- a/src/main/tray/tray-menu-items.test.ts +++ b/src/main/tray/tray-menu-items.test.ts @@ -32,8 +32,10 @@ describe("tray-menu-items", () => { let trayMenuItems: IComputedValue; let extensionsStub: ObservableMap; - beforeEach(() => { - di = getDiForUnitTesting(); + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + await di.runSetups(); extensionsStub = new ObservableMap(); diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 51d38d04da..803fec0ece 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 "../../common/cluster/cluster"; @@ -32,6 +32,7 @@ import { CatalogRunEvent } from "../../common/catalog/catalog-run-event"; import { ipcRenderer } from "electron"; import { CatalogIpcEvents } from "../../common/ipc/catalog"; import { navigate } from "../navigation"; +import { isMainFrame } from "process"; export type EntityFilter = (entity: CatalogEntity) => any; export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise; @@ -85,6 +86,16 @@ export class CatalogEntityRegistry { // Make sure that we get items ASAP and not the next time one of them changes ipcRenderer.send(CatalogIpcEvents.INIT); + + if (isMainFrame) { + ipcRendererOn(catalogEntityRunListener, (event, id: string) => { + const entity = this.getById(id); + + if (entity) { + this.onRun(entity); + } + }); + } } @action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) { diff --git a/src/main/catalog-sources/helpers/general-active-sync.ts b/src/renderer/api/helpers/general-active-sync.ts similarity index 82% rename from src/main/catalog-sources/helpers/general-active-sync.ts rename to src/renderer/api/helpers/general-active-sync.ts index e46e27f827..25442e60ab 100644 --- a/src/main/catalog-sources/helpers/general-active-sync.ts +++ b/src/renderer/api/helpers/general-active-sync.ts @@ -21,13 +21,14 @@ import { when } from "mobx"; import { catalogCategoryRegistry } from "../../../common/catalog"; -import { catalogEntityRegistry } from "../../../renderer/api/catalog-entity-registry"; -import { isActiveRoute } from "../../../renderer/navigation"; +import { catalogEntityRegistry } from "../catalog-entity-registry"; +import { isActiveRoute } from "../../navigation"; +import type { GeneralEntity } from "../../../common/catalog-entities"; export async function setEntityOnRouteMatch() { await when(() => catalogEntityRegistry.entities.size > 0); - const entities = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); + const entities: GeneralEntity[] = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path)); if (activeEntity) { diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 9079f4de6e..a131c4506e 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -41,24 +41,17 @@ import { WeblinkStore } from "../common/weblink-store"; import { ThemeStore } from "./theme.store"; import { SentryInit } from "../common/sentry"; 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"; - - -import extensionDiscoveryInjectable - from "../extensions/extension-discovery/extension-discovery.injectable"; -import extensionInstallationStateStoreInjectable - from "../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import extensionDiscoveryInjectable from "../extensions/extension-discovery/extension-discovery.injectable"; +import extensionInstallationStateStoreInjectable from "../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable"; import userStoreInjectable from "../common/user-store/user-store.injectable"; - import initRootFrameInjectable from "./frames/root-frame/init-root-frame/init-root-frame.injectable"; -import initClusterFrameInjectable - from "./frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable"; -import createTerminalTabInjectable - from "./components/dock/create-terminal-tab/create-terminal-tab.injectable"; +import initClusterFrameInjectable from "./frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable"; +import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable"; if (process.isMainFrame) { SentryInit(); @@ -80,7 +73,7 @@ async function attachChromeDebugger() { export async function bootstrap(di: DependencyInjectionContainer) { await di.runSetups(); - + const rootElem = document.getElementById("app"); const logPrefix = `[BOOTSTRAP-${process.isMainFrame ? "ROOT" : "CLUSTER"}-FRAME]:`; @@ -93,11 +86,6 @@ export async function bootstrap(di: DependencyInjectionContainer) { logger.info(`${logPrefix} initializing Registries`); initializers.initRegistries(); - const createTerminalTab = di.inject(createTerminalTabInjectable); - - logger.info(`${logPrefix} initializing CommandRegistry`); - initializers.initCommandRegistry(createTerminalTab); - logger.info(`${logPrefix} initializing EntitySettingsRegistry`); initializers.initEntitySettingsRegistry(); @@ -117,7 +105,9 @@ export async function bootstrap(di: DependencyInjectionContainer) { initializers.initCatalogCategoryRegistryEntries(); logger.info(`${logPrefix} initializing Catalog`); - initializers.initCatalog(); + initializers.initCatalog({ + openCommandDialog: di.inject(commandOverlayInjectable).open, + }); const extensionLoader = di.inject(extensionLoaderInjectable); diff --git a/src/renderer/components/+catalog/catalog.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx index f7496aa24b..e81bd88afd 100644 --- a/src/renderer/components/+catalog/catalog.test.tsx +++ b/src/renderer/components/+catalog/catalog.test.tsx @@ -29,7 +29,7 @@ import { CatalogCategoryRegistry, CatalogEntity, CatalogEntityActionContext, Cat import { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; -import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; import catalogEntityRegistryInjectable 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/port-forward/port-forward-store/modify-port-forward/modify-port-forward.injectable.ts b/src/renderer/components/+custom-resources/custom-resources.injectable.ts similarity index 83% rename from src/renderer/port-forward/port-forward-store/modify-port-forward/modify-port-forward.injectable.ts rename to src/renderer/components/+custom-resources/custom-resources.injectable.ts index 4754f2608e..99a532f69f 100644 --- a/src/renderer/port-forward/port-forward-store/modify-port-forward/modify-port-forward.injectable.ts +++ b/src/renderer/components/+custom-resources/custom-resources.injectable.ts @@ -18,13 +18,15 @@ * 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 portForwardStoreInjectable from "../port-forward-store.injectable"; -const modifyPortForwardInjectable = getInjectable({ - instantiate: (di) => di.inject(portForwardStoreInjectable).modify, +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 modifyPortForwardInjectable; +export default customResourceDefinitionsInjectable; diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index da5c6e72c2..739935bf37 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -30,13 +30,12 @@ import { ConfirmDialog } from "../../confirm-dialog"; import { Extensions } from "../extensions"; import mockFs from "mock-fs"; import { mockWindow } from "../../../../../__mocks__/windowMock"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../test-utils/renderFor"; import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import directoryForDownloadsInjectable - from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; +import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; mockWindow(); 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 fc24dce416..181949e5c4 100644 --- a/src/renderer/components/+network-port-forwards/port-forward-menu.tsx +++ b/src/renderer/components/+network-port-forwards/port-forward-menu.tsx @@ -21,16 +21,14 @@ import React from "react"; import { boundMethod, cssNames } from "../../utils"; -import { openPortForward, PortForwardItem } from "../../port-forward"; +import { openPortForward, PortForwardItem, PortForwardStore } from "../../port-forward"; import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Icon } from "../icon"; import { Notifications } from "../notifications"; import { withInjectables } from "@ogre-tools/injectable-react"; -import removePortForwardInjectable - from "../../port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable"; -import portForwardDialogModelInjectable - from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; +import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; +import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; interface Props extends MenuActionsProps { portForward: PortForwardItem; @@ -38,7 +36,7 @@ interface Props extends MenuActionsProps { } interface Dependencies { - removePortForward: (item: PortForwardItem) => Promise, + portForwardStore: PortForwardStore, openPortForwardDialog: (item: PortForwardItem) => void } @@ -48,12 +46,48 @@ class NonInjectedPortForwardMenu extends React.Component { const { portForward } = this.props; try { - this.props.removePortForward(portForward); + this.portForwardStore.remove(portForward); } catch (error) { Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}. The port-forward may still be active.`); } } + get portForwardStore() { + return this.props.portForwardStore; + } + + private startPortForwarding = async () => { + const { portForward } = this.props; + + const pf = await this.portForwardStore.start(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 ( + this.portForwardStore.stop(portForward)}> + + Stop + + ); + } + + return ( + + + Start + + ); + } + renderContent() { const { portForward, toolbar } = this.props; @@ -61,14 +95,17 @@ class NonInjectedPortForwardMenu extends React.Component { return ( <> - openPortForward(this.props.portForward)}> - - Open - + { portForward.status === "Active" && + openPortForward(portForward)}> + + Open + + } this.props.openPortForwardDialog(portForward)}> Edit + {this.renderStartStopMenuItem()} > ); } @@ -93,7 +130,7 @@ export const PortForwardMenu = withInjectables( { getProps: (di, props) => ({ - removePortForward: di.inject(removePortForwardInjectable), + portForwardStore: di.inject(portForwardStoreInjectable), openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, ...props, }), diff --git a/src/renderer/components/+network-port-forwards/port-forwards.tsx b/src/renderer/components/+network-port-forwards/port-forwards.tsx index 8adb5af1b3..f458adf5a8 100644 --- a/src/renderer/components/+network-port-forwards/port-forwards.tsx +++ b/src/renderer/components/+network-port-forwards/port-forwards.tsx @@ -76,7 +76,7 @@ class NonInjectedPortForwards 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 e3caf33dea..e670a4d421 100644 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -24,20 +24,22 @@ 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 { action, makeObservable, observable, reaction } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Button } from "../button"; -import { aboutPortForwarding, getPortForward, getPortForwards, openPortForward, PortForwardStore, predictProtocol } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward"; +import { + aboutPortForwarding, + notifyErrorPortForwarding, openPortForward, + PortForwardStore, + predictProtocol, +} from "../../port-forward"; import { Spinner } from "../spinner"; import { withInjectables } from "@ogre-tools/injectable-react"; import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; -import removePortForwardInjectable from "../../port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable"; -import portForwardDialogModelInjectable - from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; -import addPortForwardInjectable - from "../../port-forward/port-forward-store/add-port-forward/add-port-forward.injectable"; +import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; +import logger from "../../../common/logger"; interface Props { service: Service; @@ -46,9 +48,7 @@ interface Props { interface Dependencies { portForwardStore: PortForwardStore - removePortForward: (item: ForwardedPort) => Promise - addPortForward: (item: ForwardedPort) => Promise - openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean }) => void + openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean, onClose: () => void }) => void } @observer @@ -56,6 +56,7 @@ class NonInjectedServicePortComponent extends React.Component [this.props.portForwardStore.portForwards, this.props.service], () => this.checkExistingPortForwarding()), + reaction(() => this.props.service, () => this.checkExistingPortForwarding()), ]); } + get portForwardStore() { + return this.props.portForwardStore; + } + + @action async checkExistingPortForwarding() { const { service, port } = this.props; - const portForward: ForwardedPort = { + let portForward: ForwardedPort = { kind: "service", name: service.getName(), namespace: service.getNs(), @@ -79,57 +85,66 @@ class NonInjectedServicePortComponent extends React.Component { + const portForwardAction = action(async () => { if (this.isPortForwarded) { await this.stopPortForward(); } else { @@ -169,16 +184,16 @@ class NonInjectedServicePortComponent extends React.Component this.checkExistingPortForwarding() }); } - }; + }); return ( this.portForward()}> {port.toString()} - portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} + {this.isPortForwarded ? (this.isActive ? "Stop/Remove" : "Remove") : "Forward..."} {this.waiting && ( )} @@ -193,8 +208,6 @@ export const ServicePortComponent = withInjectables( { getProps: (di, props) => ({ portForwardStore: di.inject(portForwardStoreInjectable), - removePortForward: di.inject(removePortForwardInjectable), - addPortForward: di.inject(addPortForwardInjectable), openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, ...props, }), 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/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx b/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx index f128fd21ba..c6d85552bb 100644 --- a/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx +++ b/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx @@ -24,7 +24,7 @@ import { ClusterRoleBindingDialog } from "../dialog"; import { clusterRolesStore } from "../../+cluster-roles/store"; import { ClusterRole } from "../../../../../common/k8s-api/endpoints"; import userEvent from "@testing-library/user-event"; -import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../../test-utils/renderFor"; jest.mock("../../+cluster-roles/store"); diff --git a/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx b/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx index 766aa1104f..544c353b70 100644 --- a/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx +++ b/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx @@ -24,7 +24,7 @@ import React from "react"; import { clusterRolesStore } from "../../+cluster-roles/store"; import { ClusterRole } from "../../../../../common/k8s-api/endpoints"; import { RoleBindingDialog } from "../dialog"; -import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../../getDiForUnitTesting"; import type { DiRender } from "../../../test-utils/renderFor"; import { renderFor } from "../../../test-utils/renderFor"; import directoryForUserDataInjectable diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index 74cf65b20d..c27a39bd96 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -24,7 +24,7 @@ import { screen } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { defaultWidth, Welcome } from "../welcome"; import { computed } from "mobx"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; @@ -46,8 +46,10 @@ describe("", () => { let di: ConfigurableDependencyInjectionContainer; let welcomeBannersStub: WelcomeBannerRegistration[]; - beforeEach(() => { - di = getDiForUnitTesting(); + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + await di.runSetups(); render = renderFor(di); diff --git a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx index 785655626a..ad5ea3f26e 100644 --- a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx +++ b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx @@ -24,7 +24,7 @@ import "@testing-library/jest-dom/extend-expect"; import { fireEvent } from "@testing-library/react"; import type { IToleration } from "../../../../common/k8s-api/workload-kube-object"; import { PodTolerations } from "../pod-tolerations"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../test-utils/renderFor"; import directoryForLensLocalStorageInjectable from "../../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index c290a94c67..012b125c7d 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -24,20 +24,24 @@ 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, makeObservable, observable, reaction } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Button } from "../button"; -import { aboutPortForwarding, getPortForward, getPortForwards, openPortForward, PortForwardStore, predictProtocol } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward"; +import { + aboutPortForwarding, + notifyErrorPortForwarding, + openPortForward, + PortForwardStore, + predictProtocol, +} from "../../port-forward"; + import { Spinner } from "../spinner"; import { withInjectables } from "@ogre-tools/injectable-react"; import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; -import removePortForwardInjectable from "../../port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable"; -import portForwardDialogModelInjectable - from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; -import addPortForwardInjectable - from "../../port-forward/port-forward-store/add-port-forward/add-port-forward.injectable"; +import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable"; +import logger from "../../../common/logger"; interface Props { pod: Pod; @@ -45,14 +49,12 @@ interface Props { name?: string; containerPort: number; protocol: string; - } + }; } interface Dependencies { portForwardStore: PortForwardStore; - removePortForward: (item: ForwardedPort) => Promise; - addPortForward: (item: ForwardedPort) => Promise; - openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean }) => void; + openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean, onClose: () => void }) => void; } @observer @@ -60,6 +62,7 @@ class NonInjectedPodContainerPort extends React.Component @observable waiting = false; @observable forwardPort = 0; @observable isPortForwarded = false; + @observable isActive = false; constructor(props: Props & Dependencies) { super(props); @@ -69,13 +72,18 @@ class NonInjectedPodContainerPort extends React.Component componentDidMount() { disposeOnUnmount(this, [ - reaction(() => [this.props.portForwardStore.portForwards, this.props.pod], () => this.checkExistingPortForwarding()), + reaction(() => this.props.pod, () => this.checkExistingPortForwarding()), ]); } + get portForwardStore() { + return this.props.portForwardStore; + } + + @action async checkExistingPortForwarding() { const { pod, port } = this.props; - const portForward: ForwardedPort = { + let portForward: ForwardedPort = { kind: "pod", name: pod.getName(), namespace: pod.getNs(), @@ -83,57 +91,64 @@ class NonInjectedPodContainerPort extends React.Component forwardPort: this.forwardPort, }; - let activePort: number; - try { - activePort = await getPortForward(portForward) ?? 0; + portForward = await this.portForwardStore.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 } = this.portForwardStore.getPortForwards(); - this.forwardPort = await this.props.addPortForward(portForward); + if (!this.isPortForwarded) { + portForward = await this.portForwardStore.add(portForward); + } else if (!this.isActive) { + portForward = await this.portForwardStore.start(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 = { @@ -147,12 +162,12 @@ class NonInjectedPodContainerPort extends React.Component this.waiting = true; try { - await this.props.removePortForward(portForward); - this.isPortForwarded = false; + await this.portForwardStore.remove(portForward); } 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; } } @@ -162,7 +177,7 @@ class NonInjectedPodContainerPort 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 { @@ -175,16 +190,16 @@ class NonInjectedPodContainerPort extends React.Component protocol: predictProtocol(port.name), }; - this.props.openPortForwardDialog(portForward, { openInBrowser: true }); + this.props.openPortForwardDialog(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() }); } - }; + }); return ( this.portForward()}> {text} - portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} + {this.isPortForwarded ? (this.isActive ? "Stop/Remove" : "Remove") : "Forward..."} {this.waiting && ( )} @@ -199,8 +214,6 @@ export const PodContainerPort = withInjectables( { getProps: (di, props) => ({ portForwardStore: di.inject(portForwardStoreInjectable), - removePortForward: di.inject(removePortForwardInjectable), - addPortForward: di.inject(addPortForwardInjectable), openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, ...props, }), 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 ( - this.onSelect(v.value)} - components={{ DropdownIndicator: null, IndicatorSeparator: null }} - menuIsOpen={true} - options={this.options} - autoFocus={true} - escapeClearsValue={false} - placeholder="Activate entity ..." - /> - ); - } +interface Dependencies { + closeCommandOverlay: () => void; + entities: IComputedValue; } + +const NonInjectedActivateEntityCommand = observer(({ closeCommandOverlay, entities }: Dependencies) => { + const options = entities.get().map(entity => ({ + label: `${entity.kind}: ${entity.getName()}`, + value: entity, + })); + + const onSelect = (entity: CatalogEntity): void => { + broadcastMessage(catalogEntityRunListener, entity.getId()); + closeCommandOverlay(); + }; + + return ( + onSelect(v.value)} + components={{ DropdownIndicator: null, IndicatorSeparator: null }} + menuIsOpen={true} + options={options} + autoFocus={true} + escapeClearsValue={false} + placeholder="Activate entity ..." + /> + ); +}); + +export const ActivateEntityCommand = withInjectables(NonInjectedActivateEntityCommand, { + getProps: di => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + entities: computed(() => [...catalogEntityRegistry.items]), + }), +}); diff --git a/src/renderer/components/catalog-entities/weblink-add-command.tsx b/src/renderer/components/catalog-entities/weblink-add-command.tsx index 7d48622658..230c66987a 100644 --- a/src/renderer/components/catalog-entities/weblink-add-command.tsx +++ b/src/renderer/components/catalog-entities/weblink-add-command.tsx @@ -21,21 +21,26 @@ import React from "react"; import { observer } from "mobx-react"; -import { CommandOverlay } from "../command-palette"; import { Input } from "../input"; import { isUrl } from "../input/input_validators"; import { WeblinkStore } from "../../../common/weblink-store"; import { computed, makeObservable, observable } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; + +interface Dependencies { + closeCommandOverlay: () => void; +} + @observer -export class WeblinkAddCommand extends React.Component { +class NonInjectedWeblinkAddCommand extends React.Component { @observable url = ""; @observable nameHidden = true; @observable dirty = false; - constructor(props: {}) { + constructor(props: Dependencies) { super(props); - makeObservable(this); } @@ -55,8 +60,7 @@ export class WeblinkAddCommand extends React.Component { name: name || this.url, url: this.url, }); - - CommandOverlay.close(); + this.props.closeCommandOverlay(); } @computed get showValidation() { @@ -100,3 +104,10 @@ export class WeblinkAddCommand extends React.Component { ); } } + +export const WeblinkAddCommand = withInjectables(NonInjectedWeblinkAddCommand, { + getProps: (di, props) => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + ...props, + }), +}); diff --git a/src/renderer/components/cluster-manager/active-hotbar-name.tsx b/src/renderer/components/cluster-manager/active-hotbar-name.tsx index 63f8b583b9..344299c110 100644 --- a/src/renderer/components/cluster-manager/active-hotbar-name.tsx +++ b/src/renderer/components/cluster-manager/active-hotbar-name.tsx @@ -22,19 +22,31 @@ import React from "react"; import { observer } from "mobx-react"; import { Icon } from "../icon"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -export const ActiveHotbarName = observer(() => { - return ( - CommandOverlay.open()} - > - - {HotbarStore.getInstance().getActive()?.name} - - ); +interface Dependencies { + openCommandOverlay: (component: React.ReactElement) => void; + activeHotbarName: () => string | undefined; +} + +const NonInjectedActiveHotbarName = observer(({ openCommandOverlay, activeHotbarName }: Dependencies) => ( + openCommandOverlay()} + > + + {activeHotbarName()} + +)); + +export const ActiveHotbarName = withInjectables(NonInjectedActiveHotbarName, { + getProps: (di, props) => ({ + activeHotbarName: () => di.inject(hotbarManagerInjectable).getActive()?.name, + openCommandOverlay: di.inject(commandOverlayInjectable).open, + ...props, + }), }); diff --git a/src/renderer/components/cluster-manager/bottom-bar.test.tsx b/src/renderer/components/cluster-manager/bottom-bar.test.tsx index 0a2e79d061..1807b60d36 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 { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; import { ActiveHotbarName } from "./active-hotbar-name"; -import { getDisForUnitTesting } from "../../../test-utils/get-dis-for-unit-testing"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../test-utils/renderFor"; +import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import { getEmptyHotbar } from "../../../common/hotbar-types"; -jest.mock("../command-palette", () => ({ - CommandOverlay: { - open: jest.fn(), - }, -})); jest.mock("electron", () => ({ app: { @@ -53,26 +51,36 @@ jest.mock("electron", () => ({ }, })); +const foobarHotbar = getEmptyHotbar("foobar"); + describe("", () => { + let di: DependencyInjectionContainer; + let render: DiRender; + beforeEach(async () => { - const dis = getDisForUnitTesting({ doGeneralOverrides: true }); - - await dis.runSetups(); - const mockOpts = { "tmp": { "test-store.json": JSON.stringify({}), }, }; + di = getDiForUnitTesting({ doGeneralOverrides: true }); + mockFs(mockOpts); + + render = renderFor(di); + + di.override(hotbarManagerInjectable, () => ({ + getActive: () => foobarHotbar, + } as any)); + + await di.runSetups(); + StatusBarRegistry.createInstance(); - HotbarStore.createInstance(); }); afterEach(() => { StatusBarRegistry.resetInstance(); - HotbarStore.resetInstance(); mockFs.restore(); }); @@ -82,24 +90,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"; @@ -108,10 +112,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"; @@ -120,33 +124,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: () => }, ]); @@ -155,7 +151,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 cc8a0ef304..4754c02ba2 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -38,7 +38,7 @@ import * as routes from "../../../common/routes"; import { DeleteClusterDialog } from "../delete-cluster-dialog"; import { reaction } from "mobx"; import { navigation } from "../../navigation"; -import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; +import { setEntityOnRouteMatch } from "../../api/helpers/general-active-sync"; import { catalogURL, getPreviousTabUrl } from "../../../common/routes"; import { withInjectables } from "@ogre-tools/injectable-react"; import { TopBar } from "../layout/top-bar/top-bar"; diff --git a/src/renderer/components/command-palette/command-container.tsx b/src/renderer/components/command-palette/command-container.tsx index 2d4a7b6a83..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 ( - this.onChange(v.value)} - components={{ - DropdownIndicator: null, - IndicatorSeparator: null, - }} - menuIsOpen={this.menuIsOpen} - options={this.options} - autoFocus={true} - escapeClearsValue={false} - data-test-id="command-palette-search" - placeholder="Type a command or search…" - onInputChange={(newValue, { action }) => { - if (action === "input-change") { - this.searchValue = newValue; - } - }} - inputValue={this.searchValue} - /> - ); - } -} + const context = { + entity: activeEntity, + }; + const activeCommands = iter.filter(commands.get().values(), command => { + try { + return command.isActive(context); + } catch (error) { + console.error(`[COMMAND-DIALOG]: isActive for ${command.id} threw an error, defaulting to false`, error); + } + + return false; + }); + const options = Array.from(activeCommands, ({ id, title }) => ({ + value: id, + label: typeof title === "function" + ? title(context) + : title, + })); + + // Make sure the options are in the correct order + orderBy(options, "label", "asc"); + + 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..25220f73a1 --- /dev/null +++ b/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx @@ -0,0 +1,235 @@ +/** + * 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 { 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"; +import createTerminalTabInjectable + from "../../dock/create-terminal-tab/create-terminal-tab.injectable"; +import type { DockTabCreate } from "../../dock/dock-store/dock.store"; + +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[]; + createTerminalTab: () => DockTabCreate +} + +function getInternalCommands({ openCommandDialog, getEntitySettingItems, createTerminalTab }: 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, + createTerminalTab: di.inject(createTerminalTabInjectable), + }), + 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/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index cf654a913e..4b95bc0c40 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -28,7 +28,7 @@ import { DockStore, DockTab, TabKind } from "../dock-store/dock.store"; import { noop } from "../../../utils"; import { ThemeStore } from "../../../theme.store"; import { UserStore } from "../../../../common/user-store"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import dockStoreInjectable from "../dock-store/dock-store.injectable"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; 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 e737dac036..e8e89bf775 100644 --- a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -29,7 +29,7 @@ import { dockerPod, deploymentPod1 } from "./pod.mock"; import { ThemeStore } from "../../../theme.store"; import { UserStore } from "../../../../common/user-store"; import mockFs from "mock-fs"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; diff --git a/src/renderer/components/dock/__test__/log-tab.store.test.ts b/src/renderer/components/dock/__test__/log-tab.store.test.ts index 17dff0eff2..88ad6f344b 100644 --- a/src/renderer/components/dock/__test__/log-tab.store.test.ts +++ b/src/renderer/components/dock/__test__/log-tab.store.test.ts @@ -25,7 +25,7 @@ import { Pod } from "../../../../common/k8s-api/endpoints"; import { ThemeStore } from "../../../theme.store"; import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; import { mockWindow } from "../../../../../__mocks__/windowMock"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import logTabStoreInjectable from "../log-tab-store/log-tab-store.injectable"; import type { LogTabStore } from "../log-tab-store/log-tab.store"; import dockStoreInjectable from "../dock-store/dock-store.injectable"; 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 92fd94ec3e..09b482a329 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 type { DependencyInjectionContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../../test-utils/renderFor"; +import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable"; import { ThemeStore } from "../../../theme.store"; +import { ConfirmDialog } from "../../confirm-dialog"; +import type { HotbarStore } from "../../../../common/hotbar-store"; import { UserStore } from "../../../../common/user-store"; -import { Notifications } from "../../notifications"; import mockFs from "mock-fs"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; const mockHotbars: { [id: string]: any } = { @@ -38,50 +42,69 @@ const mockHotbars: { [id: string]: any } = { }, }; -jest.mock("../../../../common/hotbar-store", () => ({ - HotbarStore: { - getInstance: () => ({ - hotbars: [mockHotbars["1"]], - getById: (id: string) => mockHotbars[id], - remove: () => {}, - hotbarIndex: () => 0, - }), - }, -})); - describe("", () => { - beforeEach(async () => { - const di = getDiForUnitTesting({ doGeneralOverrides: true }); + let di: DependencyInjectionContainer; + let render: DiRender; + + beforeEach(() => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); mockFs(); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - await di.runSetups(); + render = renderFor(di); UserStore.createInstance(); ThemeStore.createInstance(); }); afterEach(() => { - UserStore.resetInstance(); - ThemeStore.resetInstance(); mockFs.restore(); + ThemeStore.resetInstance(); + UserStore.resetInstance(); }); - it("renders w/o errors", () => { - const { container } = render(); + it("renders w/o errors", async () => { + di.override(hotbarManagerInjectable, () => ({ + hotbars: [mockHotbars["1"]], + getById: (id: string) => mockHotbars[id], + remove: () => { + }, + hotbarIndex: () => 0, + getDisplayLabel: () => "1: Default", + }) as any as HotbarStore); + + await di.runSetups(); + + 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", async () => { + 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); + + await di.runSetups(); + + 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 ( - this.onChange(v.value)} - components={{ DropdownIndicator: null, IndicatorSeparator: null }} - menuIsOpen={true} - options={this.options} - autoFocus={true} - escapeClearsValue={false} - placeholder="Remove hotbar" /> - ); - } -} + 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.onSelect(v.value)} - components={{ DropdownIndicator: null, IndicatorSeparator: null }} - menuIsOpen={true} - options={this.options} - autoFocus={true} - escapeClearsValue={false} - placeholder="Rename hotbar"/> - > - ); - } - - renderNameInput() { + if (hotbarId) { 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 ( + onSelect(v.value)} + components={{ DropdownIndicator: null, IndicatorSeparator: null }} + menuIsOpen={true} + options={options} + autoFocus={true} + escapeClearsValue={false} + placeholder="Rename hotbar" + /> + ); +}); - return ( - <> - {!this.hotbarId ? this.renderHotbarList() : this.renderNameInput()} - > - ); - } -} +export const HotbarRenameCommand = withInjectables(NonInjectedHotbarRenameCommand, { + getProps: (di, props) => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + hotbarManager: di.inject(hotbarManagerInjectable), + uniqueHotbarName: di.inject(uniqueHotbarNameInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/hotbar/hotbar-selector.tsx b/src/renderer/components/hotbar/hotbar-selector.tsx index 5127630a8f..34f0ecf847 100644 --- a/src/renderer/components/hotbar/hotbar-selector.tsx +++ b/src/renderer/components/hotbar/hotbar-selector.tsx @@ -23,38 +23,52 @@ import "./hotbar-selector.scss"; import React from "react"; import { Icon } from "../icon"; import { Badge } from "../badge"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { HotbarSwitchCommand } from "./hotbar-switch-command"; -import { hotbarDisplayIndex } from "./hotbar-display-label"; import { TooltipPosition } from "../tooltip"; import { observer } from "mobx-react"; import type { Hotbar } from "../../../common/hotbar-types"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -interface Props { +export interface HotbarSelectorProps { hotbar: Hotbar; } -export const HotbarSelector = observer(({ hotbar }: Props) => { - const store = HotbarStore.getInstance(); +interface Dependencies { + hotbarManager: { + switchToPrevious: () => void; + switchToNext: () => void; + getActive: () => Hotbar; + getDisplayIndex: (hotbar: Hotbar) => string; + }; + openCommandOverlay: (component: React.ReactElement) => void; +} - return ( - - store.switchToPrevious()} /> - - CommandOverlay.open()} - tooltip={{ - preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT], - children: hotbar.name, - }} - className="SelectorIndex" - /> - - store.switchToNext()} /> +const NonInjectedHotbarSelector = observer(({ hotbar, hotbarManager, openCommandOverlay }: HotbarSelectorProps & Dependencies) => ( + + hotbarManager.switchToPrevious()} /> + + openCommandOverlay()} + tooltip={{ + preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT], + children: hotbar.name, + }} + className="SelectorIndex" + /> - ); + hotbarManager.switchToNext()} /> + +)); + +export const HotbarSelector = withInjectables(NonInjectedHotbarSelector, { + getProps: (di, props) => ({ + hotbarManager: di.inject(hotbarManagerInjectable), + openCommandOverlay: di.inject(commandOverlayInjectable).open, + ...props, + }), }); diff --git a/src/renderer/components/hotbar/hotbar-switch-command.tsx b/src/renderer/components/hotbar/hotbar-switch-command.tsx index d79ccfba4b..8dcbfded6e 100644 --- a/src/renderer/components/hotbar/hotbar-switch-command.tsx +++ b/src/renderer/components/hotbar/hotbar-switch-command.tsx @@ -22,73 +22,82 @@ 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 { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; +import type { CommandOverlay } from "../command-palette"; import { HotbarAddCommand } from "./hotbar-add-command"; import { HotbarRemoveCommand } from "./hotbar-remove-command"; -import { hotbarDisplayLabel } from "./hotbar-display-label"; import { HotbarRenameCommand } from "./hotbar-rename-command"; +import type { Hotbar } from "../../../common/hotbar-types"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -@observer -export class HotbarSwitchCommand extends React.Component { - private static addActionId = "__add__"; - private static removeActionId = "__remove__"; - private static renameActionId = "__rename__"; +const addActionId = "__add__"; +const removeActionId = "__remove__"; +const renameActionId = "__rename__"; - constructor(props: {}) { - super(props); - makeObservable(this); - } - - @computed get options() { - const hotbarStore = HotbarStore.getInstance(); - const options = hotbarStore.hotbars.map((hotbar) => { - return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) }; - }); - - options.push({ value: HotbarSwitchCommand.addActionId, label: "Add hotbar ..." }); - - if (hotbarStore.hotbars.length > 1) { - options.push({ value: HotbarSwitchCommand.removeActionId, label: "Remove hotbar ..." }); - } - - options.push({ value: HotbarSwitchCommand.renameActionId, label: "Rename hotbar ..." }); - - return options; - } - - onChange(idOrAction: string): void { - switch (idOrAction) { - case HotbarSwitchCommand.addActionId: - CommandOverlay.open(); - - return; - case HotbarSwitchCommand.removeActionId: - CommandOverlay.open(); - - return; - case HotbarSwitchCommand.renameActionId: - CommandOverlay.open(); - - return; - default: - HotbarStore.getInstance().activeHotbarId = idOrAction; - CommandOverlay.close(); - } - } - - 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(v.value)} + components={{ DropdownIndicator: null, IndicatorSeparator: null }} + menuIsOpen={true} + options={options} + autoFocus={true} + escapeClearsValue={false} + placeholder="Switch to hotbar" + /> + ); +}); + +export const HotbarSwitchCommand = withInjectables(NonInjectedHotbarSwitchCommand, { + getProps: (di, props) => ({ + hotbarManager: di.inject(hotbarManagerInjectable), + commandOverlay: di.inject(commandOverlayInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index f6aea6529e..e9dbc48aad 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -103,6 +103,10 @@ export class Input extends React.Component { submitted: false, }; + componentWillUnmount(): void { + this.setDirtyOnChange.cancel(); + } + setValue(value = "") { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; diff --git a/src/renderer/port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable.ts b/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts similarity index 72% rename from src/renderer/port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable.ts rename to src/renderer/components/input/validators/unique-hotbar-name.injectable.ts index c2e587573e..4f3e3f5900 100644 --- a/src/renderer/port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable.ts +++ b/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts @@ -18,13 +18,18 @@ * 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 portForwardStoreInjectable from "../port-forward-store.injectable"; - -const removePortForwardInjectable = getInjectable({ - instantiate: (di) => di.inject(portForwardStoreInjectable).remove, +import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable"; +import type { InputValidator } from "../input_validators"; +const uniqueHotbarNameInjectable = getInjectable({ + instantiate: di => ({ + condition: ({ required }) => required, + message: () => "Hotbar with this name already exists", + validate: value => !di.inject(hotbarManagerInjectable).getByName(value), + } as InputValidator), lifecycle: lifecycleEnum.singleton, }); -export default removePortForwardInjectable; +export default uniqueHotbarNameInjectable; diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx index 54f20e04ae..1f5d276954 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx @@ -29,7 +29,7 @@ import type { KubeObjectMenuRegistration } from "../../../extensions/registries" import { KubeObjectMenuRegistry } from "../../../extensions/registries"; import { ConfirmDialog } from "../confirm-dialog"; import asyncFn, { AsyncFnMock } from "@async-fn/jest"; -import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import clusterInjectable from "./dependencies/cluster.injectable"; import hideDetailsInjectable from "./dependencies/hide-details.injectable"; @@ -65,8 +65,7 @@ describe("kube-object-menu", () => { }) as Cluster); di.override(apiManagerInjectable, () => ({ - // eslint-disable-next-line unused-imports/no-unused-vars-ts - getStore: api => undefined, + getStore: api => void api, }) as ApiManager); di.override(hideDetailsInjectable, () => () => {}); @@ -285,5 +284,5 @@ const addDynamicMenuItem = ({ const kubeObjectMenuRegistry = di.inject(kubeObjectMenuRegistryInjectable); - kubeObjectMenuRegistry.add(dynamicMenuItemStub); + kubeObjectMenuRegistry.add([dynamicMenuItemStub]); }; diff --git a/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx b/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx index 1c7429de15..2fef7f7ea4 100644 --- a/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx +++ b/src/renderer/components/layout/top-bar/top-bar-win-linux.test.tsx @@ -26,7 +26,7 @@ import { TopBar } from "./top-bar"; import { IpcMainWindowEvents } from "../../../../main/window-manager"; import { broadcastMessage } from "../../../../common/ipc"; import * as vars from "../../../../common/vars"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../test-utils/renderFor"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; diff --git a/src/renderer/components/layout/top-bar/top-bar.test.tsx b/src/renderer/components/layout/top-bar/top-bar.test.tsx index 4a4eec4981..9bfd0e7651 100644 --- a/src/renderer/components/layout/top-bar/top-bar.test.tsx +++ b/src/renderer/components/layout/top-bar/top-bar.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { TopBar } from "./top-bar"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; import { DiRender, renderFor } from "../../test-utils/renderFor"; import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable"; diff --git a/src/renderer/components/getDi.tsx b/src/renderer/getDi.tsx similarity index 82% rename from src/renderer/components/getDi.tsx rename to src/renderer/getDi.tsx index 6c95f8be11..ea2d9044a2 100644 --- a/src/renderer/components/getDi.tsx +++ b/src/renderer/getDi.tsx @@ -20,7 +20,7 @@ */ import { createContainer } from "@ogre-tools/injectable"; -import { setLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; export const getDi = () => { const di = createContainer( @@ -35,10 +35,10 @@ export const getDi = () => { }; const getRequireContextForRendererCode = () => - require.context("../", true, /\.injectable\.(ts|tsx)$/); - -const getRequireContextForCommonExtensionCode = () => - require.context("../../extensions", true, /\.injectable\.(ts|tsx)$/); + require.context("./", true, /\.injectable\.(ts|tsx)$/); const getRequireContextForCommonCode = () => - require.context("../../common", true, /\.injectable\.(ts|tsx)$/); + require.context("../common", true, /\.injectable\.(ts|tsx)$/); + +const getRequireContextForCommonExtensionCode = () => + require.context("../extensions", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/renderer/components/getDiForUnitTesting.tsx b/src/renderer/getDiForUnitTesting.tsx similarity index 61% rename from src/renderer/components/getDiForUnitTesting.tsx rename to src/renderer/getDiForUnitTesting.tsx index 89723cf624..419cf6a557 100644 --- a/src/renderer/components/getDiForUnitTesting.tsx +++ b/src/renderer/getDiForUnitTesting.tsx @@ -21,36 +21,29 @@ import glob from "glob"; import { memoize } from "lodash/fp"; - -import { - createContainer, - ConfigurableDependencyInjectionContainer, -} from "@ogre-tools/injectable"; -import { setLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import getValueFromRegisteredChannelInjectable from "./app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable"; -import writeJsonFileInjectable from "../../common/fs/write-json-file/write-json-file.injectable"; -import readJsonFileInjectable from "../../common/fs/read-json-file/read-json-file.injectable"; +import { createContainer } from "@ogre-tools/injectable"; +import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; +import getValueFromRegisteredChannelInjectable from "./components/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable"; +import writeJsonFileInjectable from "../common/fs/write-json-file/write-json-file.injectable"; +import readJsonFileInjectable from "../common/fs/read-json-file/read-json-file.injectable"; export const getDiForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverrides: false }) => { - const di: ConfigurableDependencyInjectionContainer = createContainer(); + const di = createContainer(); setLegacyGlobalDiForExtensionApi(di); - 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(); - + if (doGeneralOverrides) { di.override(getValueFromRegisteredChannelInjectable, () => () => undefined); @@ -67,7 +60,7 @@ export const getDiForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverride }; 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("./**/*.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 2765d2a6a1..4eb08daf57 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/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,30 @@ 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 3d32ef055a..0000000000 --- a/src/renderer/initializers/command-registry.tsx +++ /dev/null @@ -1,206 +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 { 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(createTerminalTab: () => void) { - CommandRegistry.getInstance() - .add([ - { - id: "app.showPreferences", - title: "Preferences: Open", - scope: "global", - action: () => navigate(routes.preferencesURL()), - }, - { - id: "cluster.viewHelmCharts", - title: "Cluster: View Helm Charts", - scope: "entity", - action: () => navigate(routes.helmChartsURL()), - }, - { - id: "cluster.viewHelmReleases", - title: "Cluster: View Helm Releases", - scope: "entity", - action: () => navigate(routes.releaseURL()), - }, - { - id: "cluster.viewConfigMaps", - title: "Cluster: View ConfigMaps", - scope: "entity", - action: () => navigate(routes.configMapsURL()), - }, - { - id: "cluster.viewSecrets", - title: "Cluster: View Secrets", - scope: "entity", - action: () => navigate(routes.secretsURL()), - }, - { - id: "cluster.viewResourceQuotas", - title: "Cluster: View ResourceQuotas", - scope: "entity", - action: () => navigate(routes.resourceQuotaURL()), - }, - { - id: "cluster.viewLimitRanges", - title: "Cluster: View LimitRanges", - scope: "entity", - action: () => navigate(routes.limitRangeURL()), - }, - { - id: "cluster.viewHorizontalPodAutoscalers", - title: "Cluster: View HorizontalPodAutoscalers (HPA)", - scope: "entity", - action: () => navigate(routes.hpaURL()), - }, - { - id: "cluster.viewPodDisruptionBudget", - title: "Cluster: View PodDisruptionBudgets", - scope: "entity", - action: () => navigate(routes.pdbURL()), - }, - { - id: "cluster.viewServices", - title: "Cluster: View Services", - scope: "entity", - action: () => navigate(routes.servicesURL()), - }, - { - id: "cluster.viewEndpoints", - title: "Cluster: View Endpoints", - scope: "entity", - action: () => navigate(routes.endpointURL()), - }, - { - id: "cluster.viewIngresses", - title: "Cluster: View Ingresses", - scope: "entity", - action: () => navigate(routes.ingressURL()), - }, - { - id: "cluster.viewNetworkPolicies", - title: "Cluster: View NetworkPolicies", - scope: "entity", - action: () => navigate(routes.networkPoliciesURL()), - }, - { - id: "cluster.viewNodes", - title: "Cluster: View Nodes", - scope: "entity", - action: () => navigate(routes.nodesURL()), - }, - { - id: "cluster.viewPods", - title: "Cluster: View Pods", - scope: "entity", - action: () => navigate(routes.podsURL()), - }, - { - id: "cluster.viewDeployments", - title: "Cluster: View Deployments", - scope: "entity", - action: () => navigate(routes.deploymentsURL()), - }, - { - id: "cluster.viewDaemonSets", - title: "Cluster: View DaemonSets", - scope: "entity", - action: () => navigate(routes.daemonSetsURL()), - }, - { - id: "cluster.viewStatefulSets", - title: "Cluster: View StatefulSets", - scope: "entity", - action: () => navigate(routes.statefulSetsURL()), - }, - { - id: "cluster.viewJobs", - title: "Cluster: View Jobs", - scope: "entity", - action: () => navigate(routes.jobsURL()), - }, - { - id: "cluster.viewCronJobs", - title: "Cluster: View CronJobs", - scope: "entity", - action: () => navigate(routes.cronJobsURL()), - }, - { - id: "cluster.viewCurrentClusterSettings", - title: "Cluster: View Settings", - scope: "global", - action: () => navigate(routes.entitySettingsURL({ - params: { - entityId: getActiveClusterEntity()?.id, - }, - })), - isActive: (context) => !!context.entity, - }, - { - id: "cluster.openTerminal", - title: "Cluster: Open terminal", - scope: "entity", - action: () => createTerminalTab(), - isActive: (context) => !!context.entity, - }, - { - id: "hotbar.switchHotbar", - title: "Hotbar: Switch ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.addHotbar", - title: "Hotbar: Add Hotbar ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.removeHotbar", - title: "Hotbar: Remove Hotbar ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - { - id: "hotbar.renameHotbar", - title: "Hotbar: Rename Hotbar ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - { - id: "catalog.searchEntities", - title: "Catalog: Activate Entity ...", - scope: "global", - action: () => CommandOverlay.open(), - }, - ]); -} diff --git a/src/renderer/initializers/index.ts b/src/renderer/initializers/index.ts index b865368988..758c9042ad 100644 --- a/src/renderer/initializers/index.ts +++ b/src/renderer/initializers/index.ts @@ -21,7 +21,6 @@ export * from "./catalog-entity-detail-registry"; export * from "./catalog"; -export * from "./command-registry"; export * from "./entity-settings-registry"; export * from "./ipc"; export * from "./kube-object-detail-registry"; diff --git a/src/renderer/initializers/registries.ts b/src/renderer/initializers/registries.ts index a27c47153b..a68b4e0abd 100644 --- a/src/renderer/initializers/registries.ts +++ b/src/renderer/initializers/registries.ts @@ -26,7 +26,6 @@ export function initRegistries() { registries.CatalogEntityDetailRegistry.createInstance(); registries.ClusterPageMenuRegistry.createInstance(); registries.ClusterPageRegistry.createInstance(); - registries.CommandRegistry.createInstance(); registries.EntitySettingRegistry.createInstance(); registries.GlobalPageRegistry.createInstance(); registries.KubeObjectDetailRegistry.createInstance(); diff --git a/src/renderer/navigation/helpers.ts b/src/renderer/navigation/helpers.ts index d6408cb272..1314b0973f 100644 --- a/src/renderer/navigation/helpers.ts +++ b/src/renderer/navigation/helpers.ts @@ -54,7 +54,7 @@ export function isActiveRoute(route: string | string[] | RouteProps): boolean { return !!matchRoute(route); } -export function getMatchedClusterId(): string { +export function getMatchedClusterId(): string | undefined { const matched = matchPath(navigation.location.pathname, { exact: true, path: clusterViewRoute.path, diff --git a/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.ts b/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.ts index 7d37b49a5c..8dc78dbafb 100644 --- a/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.ts +++ b/src/renderer/port-forward/port-forward-dialog-model/port-forward-dialog-model.ts @@ -18,17 +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 { noop } from "lodash/fp"; import { action, computed, observable, makeObservable } from "mobx"; import type { ForwardedPort } from "../port-forward-item"; interface PortForwardDialogOpenOptions { openInBrowser: boolean + onClose: () => void } export class PortForwardDialogModel { portForward: ForwardedPort = null; useHttps = false; openInBrowser = false; + onClose = noop; constructor() { makeObservable(this, { @@ -46,10 +49,11 @@ export class PortForwardDialogModel { return !!this.portForward; } - open = (portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false }) => { + open = (portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false, onClose: noop }) => { this.portForward = portForward; this.useHttps = portForward.protocol === "https"; this.openInBrowser = options.openInBrowser; + this.onClose = options.onClose; }; close = () => { diff --git a/src/renderer/port-forward/port-forward-dialog.tsx b/src/renderer/port-forward/port-forward-dialog.tsx index 8ea7181967..b5a1a20297 100644 --- a/src/renderer/port-forward/port-forward-dialog.tsx +++ b/src/renderer/port-forward/port-forward-dialog.tsx @@ -27,25 +27,21 @@ 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 { getPortForwards } from "./port-forward-store/port-forward-store"; -import type { ForwardedPort } from "./port-forward-item"; +import type { PortForwardStore } from "./port-forward-store/port-forward-store"; import { openPortForward } from "./port-forward-utils"; -import { aboutPortForwarding } from "./port-forward-notify"; +import { aboutPortForwarding, notifyErrorPortForwarding } from "./port-forward-notify"; import { Checkbox } from "../components/checkbox"; import { withInjectables } from "@ogre-tools/injectable-react"; -import modifyPortForwardInjectable from "./port-forward-store/modify-port-forward/modify-port-forward.injectable"; import type { PortForwardDialogModel } from "./port-forward-dialog-model/port-forward-dialog-model"; import portForwardDialogModelInjectable from "./port-forward-dialog-model/port-forward-dialog-model.injectable"; -import addPortForwardInjectable from "./port-forward-store/add-port-forward/add-port-forward.injectable"; +import logger from "../../common/logger"; +import portForwardStoreInjectable from "./port-forward-store/port-forward-store.injectable"; -interface Props extends Partial { -} +interface Props extends Partial {} interface Dependencies { - modifyPortForward: (item: ForwardedPort, desiredPort: number) => Promise, - addPortForward: (item: ForwardedPort) => Promise, + portForwardStore: PortForwardStore, model: PortForwardDialogModel } @@ -59,50 +55,58 @@ class NonInjectedPortForwardDialog extends Component { makeObservable(this); } + get portForwardStore() { + return this.props.portForwardStore; + } + onOpen = async () => { this.currentPort = +this.props.model.portForward.forwardPort; this.desiredPort = this.currentPort; }; - onClose = () => { - }; - changePort = (value: string) => { this.desiredPort = Number(value); }; startPortForward = async () => { - const portForward = this.props.model.portForward; + let { portForward } = this.props.model; const { currentPort, desiredPort } = 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 } = this.portForwardStore.getPortForwards(); portForward.protocol = this.props.model.useHttps ? "https" : "http"; if (currentPort) { - port = await this.props.modifyPortForward(portForward, desiredPort); + const wasRunning = portForward.status === "Active"; + + portForward = await this.portForwardStore.modify(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 this.props.addPortForward(portForward); + portForward = await this.portForwardStore.add(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 (this.props.model.openInBrowser) { - portForward.forwardPort = port; + if (portForward.status === "Active" && this.props.model.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(); + this.props.model.close(); } }; @@ -141,7 +145,7 @@ class NonInjectedPortForwardDialog extends Component { } render() { - const { className, modifyPortForward, model, ...dialogProps } = this.props; + const { className, portForwardStore, model, ...dialogProps } = this.props; const resourceName = this.props.model.portForward?.name ?? ""; const header = ( @@ -155,14 +159,14 @@ class NonInjectedPortForwardDialog extends Component { isOpen={this.props.model.isOpen} className={cssNames("PortForwardDialog", className)} onOpen={this.onOpen} - onClose={this.onClose} + onClose={model.onClose} close={this.props.model.close} > {this.renderContents()} @@ -177,8 +181,7 @@ export const PortForwardDialog = withInjectables( { getProps: (di, props) => ({ - modifyPortForward: di.inject(modifyPortForwardInjectable), - addPortForward: di.inject(addPortForwardInjectable), + portForwardStore: di.inject(portForwardStoreInjectable), model: di.inject(portForwardDialogModelInjectable), ...props, }), 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} + + + { + navigate(portForwardsURL()); + notificationsStore.remove(notificationId); + }} + /> + + + ), + { + id: notificationId, + timeout: 10_000, + }, + ); +} + diff --git a/src/renderer/port-forward/port-forward-store/port-forward-store.ts b/src/renderer/port-forward/port-forward-store/port-forward-store.ts index aa792635be..0af0bd879d 100644 --- a/src/renderer/port-forward/port-forward-store/port-forward-store.ts +++ b/src/renderer/port-forward/port-forward-store/port-forward-store.ts @@ -19,21 +19,21 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { makeObservable, observable, reaction } from "mobx"; +import { action, makeObservable, observable, reaction } from "mobx"; import { ItemStore } from "../../../common/item.store"; -import { autoBind, disposer, getHostedClusterId, StorageHelper } from "../../utils"; +import { autoBind, disposer, StorageHelper } from "../../utils"; import { ForwardedPort, PortForwardItem } from "../port-forward-item"; +import { notifyErrorPortForwarding } from "../port-forward-notify"; import { apiBase } from "../../api"; -import logger from "../../../common/logger"; import { waitUntilFree } from "tcp-port-used"; +import logger from "../../../common/logger"; interface Dependencies { storage: StorageHelper } export class PortForwardStore extends ItemStore { - @observable portForwards: PortForwardItem[]; + @observable portForwards: PortForwardItem[] = []; constructor(private dependencies: Dependencies) { super(); @@ -50,33 +50,58 @@ export class PortForwardStore extends ItemStore { if (Array.isArray(savedPortForwards)) { logger.info("[PORT-FORWARD-STORE] starting saved port-forwards"); - await Promise.all(savedPortForwards.map(this.add)); + + // add the disabled ones + await Promise.all( + savedPortForwards + .filter((pf) => pf.status === "Disabled") + .map(this.add), + ); + + // add the active ones and check if they started successfully + const results = await Promise.allSettled( + savedPortForwards + .filter((pf) => pf.status === "Active") + .map(this.add), + ); + + 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( + () => this.portForwards.slice(), + () => this.loadAll(), + ), ); } loadAll() { - return this.loadItems(async () => { - const portForwards = await getPortForwards(getHostedClusterId()); + return this.loadItems(() => { + const portForwards = this.getPortForwards(); this.dependencies.storage.set(portForwards); - this.reset(); - portForwards.map(pf => this.portForwards.push(new PortForwardItem(pf))); + 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(this.remove)); } @@ -91,87 +116,285 @@ export class PortForwardStore extends ItemStore { return this.getItems()[index]; } - add = async (portForward: ForwardedPort): Promise => { + /** + * 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 + */ + add = action(async (portForward: ForwardedPort): Promise => { + const pf = this.findPortForward(portForward); + + if (pf) { + return pf; + } + + this.portForwards.push(new PortForwardItem(portForward)); + + if (!portForward.status) { + portForward.status = "Active"; + } + + if (portForward.status === "Active") { + portForward = await this.start(portForward); + } + + return portForward; + }); + + /** + * 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. + */ + modify = action( + async ( + portForward: ForwardedPort, + desiredPort: number, + ): Promise => { + const pf = this.findPortForward(portForward); + + if (!pf) { + throw new Error("port-forward not found"); + } + + if (pf.status === "Active") { + try { + await this.stop(pf); + } catch { + // ignore, assume it is stopped and proceed to restart it + } + + pf.forwardPort = desiredPort; + pf.protocol = portForward.protocol ?? "http"; + this.setPortForward(pf); + + return await this.start(pf); + } + + pf.forwardPort = desiredPort; + this.setPortForward(pf); + + return pf as ForwardedPort; + }, + ); + + /** + * remove and stop an existing port-forward. + * @param portForward the port-forward to remove. + */ + remove = action(async (portForward: ForwardedPort) => { + const pf = this.findPortForward(portForward); + + if (!pf) { + const error = new Error("port-forward not found"); + + logger.warn( + `[PORT-FORWARD-STORE] Error getting port-forward: ${error}`, + portForward, + ); + + return; + } + + try { + await this.stop(portForward); + } catch (error) { + if (pf.status === "Active") { + logger.warn( + `[PORT-FORWARD-STORE] Error removing port-forward: ${error}`, + portForward, + ); + } + } + + const index = this.portForwards.findIndex(portForwardsEqual(portForward)); + + if (index >= 0) { + this.portForwards.splice(index, 1); + } + }); + + /** + * gets the list of port-forwards in the store + * + * @returns the port-forwards + */ + getPortForwards = (): ForwardedPort[] => { + return this.portForwards; + }; + + /** + * 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 + */ + stop = action(async (portForward: ForwardedPort) => { + const pf = this.findPortForward(portForward); + + if (!pf) { + logger.warn( + "[PORT-FORWARD-STORE] Error getting port-forward: port-forward not found", + portForward, + ); + + return; + } + 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 stopping active port-forward: ${error}`, + portForward, + ); + throw error; + } + + pf.status = "Disabled"; + + this.setPortForward(pf); + }); + + private findPortForward = (portForward: ForwardedPort) => { + return this.portForwards.find(portForwardsEqual(portForward)); + }; + + private setPortForward = action((portForward: ForwardedPort) => { + const index = this.portForwards.findIndex(portForwardsEqual(portForward)); + + if (index < 0) { + return; + } + + this.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 + */ + start = action(async (portForward: ForwardedPort): Promise => { + const pf = this.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"; } - this.reset(); + this.setPortForward(pf); - return response?.port; - }; + return pf as ForwardedPort; + }); - remove = async (portForward: ForwardedPort) => { - const { port, forwardPort } = portForward; + /** + * 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 + */ + getPortForward = async ( + portForward: ForwardedPort, + ): Promise => { + if (!this.findPortForward(portForward)) { + throw new Error("port-forward not found"); + } + + let pf: ForwardedPort; try { - await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort }}); - await waitUntilFree(+forwardPort, 200, 1000); + // check if the port-forward is active, and if so check if it has the same local port + pf = await getActivePortForward(portForward); + + if (pf.forwardPort && pf.forwardPort !== portForward.forwardPort) { + logger.warn( + `[PORT-FORWARD-STORE] local port, expected ${pf.forwardPort}, got ${portForward.forwardPort}`, + ); + } } catch (error) { - logger.warn("[PORT-FORWARD-STORE] Error removing port-forward:", error, portForward); - throw (error); + // port is not active } - this.reset(); - }; - - modify = async (portForward: ForwardedPort, desiredPort: number): Promise => { - await this.remove(portForward); - - portForward.forwardPort = desiredPort; - - const port = await this.add(portForward); - - this.reset(); - - return port; + return pf; }; } -export interface PortForwardResult { +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 getPortForward(portForward: ForwardedPort): Promise { +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; -} - - -export async function getPortForwards(clusterId?: string): Promise { - try { - const response = await apiBase.get("/pods/port-forwards", { query: { clusterId }}); - - return response.portForwards; - } catch (error) { - logger.warn("[PORT-FORWARD-STORE] Error getting all port-forwards:", error); - - return []; - } + portForward.status = response?.port ? "Active" : "Disabled"; + portForward.forwardPort = response?.port; + + return portForward; } 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/search-store/search-store.test.ts b/src/renderer/search-store/search-store.test.ts index ec4fc59037..93b3d86244 100644 --- a/src/renderer/search-store/search-store.test.ts +++ b/src/renderer/search-store/search-store.test.ts @@ -22,7 +22,7 @@ import { SearchStore } from "./search-store"; import { Console } from "console"; import { stdout, stderr } from "process"; -import { getDiForUnitTesting } from "../components/getDiForUnitTesting"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; import searchStoreInjectable from "./search-store.injectable"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; diff --git a/src/renderer/components/hotbar/hotbar-display-label.ts b/src/renderer/window/event-listener.injectable.ts similarity index 64% rename from src/renderer/components/hotbar/hotbar-display-label.ts rename to src/renderer/window/event-listener.injectable.ts index 0489e2600f..313b961800 100644 --- a/src/renderer/components/hotbar/hotbar-display-label.ts +++ b/src/renderer/window/event-listener.injectable.ts @@ -19,18 +19,18 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { HotbarStore } from "../../../common/hotbar-store"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Disposer } from "../utils"; -function hotbarIndex(id: string) { - return HotbarStore.getInstance().hotbarIndex(id) + 1; +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); } -export function hotbarDisplayLabel(id: string) : string { - const hotbar = HotbarStore.getInstance().getById(id); +const windowAddEventListenerInjectable = getInjectable({ + instantiate: () => addWindowEventListener, + lifecycle: lifecycleEnum.singleton, +}); - return `${hotbarIndex(id)}: ${hotbar.name}`; -} - -export function hotbarDisplayIndex(id: string) : string { - return hotbarIndex(id).toString(); -} +export default windowAddEventListenerInjectable; diff --git a/src/test-utils/get-dis-for-unit-testing.ts b/src/test-utils/get-dis-for-unit-testing.ts index 545207eaea..dc871b75bc 100644 --- a/src/test-utils/get-dis-for-unit-testing.ts +++ b/src/test-utils/get-dis-for-unit-testing.ts @@ -18,7 +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 { getDiForUnitTesting as getRendererDi } from "../renderer/components/getDiForUnitTesting"; +import { getDiForUnitTesting as getRendererDi } from "../renderer/getDiForUnitTesting"; import { getDiForUnitTesting as getMainDi } from "../main/getDiForUnitTesting"; import { overrideIpcBridge } from "./override-ipc-bridge";
@@ -75,19 +68,26 @@ export class HotbarRemoveCommand extends React.Component {
+ {msg} +