diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index ffc4e361da..95a244f3c1 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -251,7 +251,7 @@ describe("HotbarStore", () => { const hotbarStore = HotbarStore.getInstance(); hotbarStore.add({ name: "hottest", id: "hottest" }); - hotbarStore.activeHotbarId = "hottest"; + hotbarStore.setActiveHotbar("hottest"); const { error } = logger; const mocked = jest.fn(); diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 870558e0b3..6764ca8bee 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CatalogCategory, CatalogEntity, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; +import { CatalogCategory, CatalogEntity, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { productName } from "../vars"; import { WeblinkStore } from "../weblink-store"; @@ -86,21 +86,6 @@ export class WebLinkCategory extends CatalogCategory { kind: "WebLink", }, }; - public static onAdd?: () => void; - - constructor() { - super(); - - this.on("catalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { - ctx.menuItems.push({ - icon: "public", - title: "Add web link", - onClick: () => { - WebLinkCategory.onAdd(); - }, - }); - }); - } } catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/common/hotbar-store.injectable.ts b/src/common/hotbar-store.injectable.ts new file mode 100644 index 0000000000..4d01f3bada --- /dev/null +++ b/src/common/hotbar-store.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { HotbarStore } from "./hotbar-store"; + +const hotbarManagerInjectable = getInjectable({ + instantiate: () => HotbarStore.getInstance(), + lifecycle: lifecycleEnum.singleton, +}); + +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/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index e4ea1c4451..b39a0a0dfa 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -19,7 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import mockFs from "mock-fs"; import { watch } from "chokidar"; import { ExtensionsStore } from "../extensions-store"; import path from "path"; @@ -30,6 +29,7 @@ import { AppPaths } from "../../common/app-paths"; import type { ExtensionLoader } from "../extension-loader"; import extensionLoaderInjectable from "../extension-loader/extension-loader.injectable"; import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import * as fse from "fs-extra"; jest.setTimeout(60_000); @@ -43,6 +43,7 @@ jest.mock("../extension-installer", () => ({ installPackage: jest.fn(), }, })); +jest.mock("fs-extra"); jest.mock("electron", () => ({ app: { getVersion: () => "99.99.99", @@ -63,6 +64,7 @@ AppPaths.init(); console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; +const mockedFse = fse as jest.Mocked; describe("ExtensionDiscovery", () => { let extensionLoader: ExtensionLoader; @@ -77,62 +79,59 @@ describe("ExtensionDiscovery", () => { extensionLoader = di.inject(extensionLoaderInjectable); }); - describe("with mockFs", () => { - beforeEach(() => { - mockFs({ - [`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({ - name: "my-extension", - }), - }); - }); + it("emits add for added extension", async (done) => { + let addHandler: (filePath: string) => void; - afterEach(() => { - mockFs.restore(); - }); + mockedFse.readJson.mockImplementation((p) => { + expect(p).toBe(path.join(os.homedir(), ".k8slens/extensions/my-extension/package.json")); - it("emits add for added extension", async (done) => { - let addHandler: (filePath: string) => void; - - const mockWatchInstance: any = { - on: jest.fn((event: string, handler: typeof addHandler) => { - if (event === "add") { - addHandler = handler; - } - - return mockWatchInstance; - }), + return { + name: "my-extension", + version: "1.0.0", }; - - mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, - ); - - const extensionDiscovery = ExtensionDiscovery.createInstance( - extensionLoader, - ); - - // Need to force isLoaded to be true so that the file watching is started - extensionDiscovery.isLoaded = true; - - await extensionDiscovery.watchExtensions(); - - extensionDiscovery.events.on("add", extension => { - expect(extension).toEqual({ - absolutePath: expect.any(String), - id: path.normalize("node_modules/my-extension/package.json"), - isBundled: false, - isEnabled: false, - isCompatible: false, - manifest: { - name: "my-extension", - }, - manifestPath: path.normalize("node_modules/my-extension/package.json"), - }); - done(); - }); - - addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); }); + + mockedFse.pathExists.mockImplementation(() => true); + + const mockWatchInstance: any = { + on: jest.fn((event: string, handler: typeof addHandler) => { + if (event === "add") { + addHandler = handler; + } + + return mockWatchInstance; + }), + }; + + mockedWatch.mockImplementationOnce(() => + (mockWatchInstance) as any, + ); + const extensionDiscovery = ExtensionDiscovery.createInstance( + extensionLoader, + ); + + // Need to force isLoaded to be true so that the file watching is started + extensionDiscovery.isLoaded = true; + + await extensionDiscovery.watchExtensions(); + + extensionDiscovery.events.on("add", extension => { + expect(extension).toEqual({ + absolutePath: expect.any(String), + id: path.normalize("node_modules/my-extension/package.json"), + isBundled: false, + isEnabled: false, + isCompatible: false, + manifest: { + name: "my-extension", + version: "1.0.0", + }, + manifestPath: path.normalize("node_modules/my-extension/package.json"), + }); + done(); + }); + + addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json")); }); it("doesn't emit add for added file under extension", async done => { @@ -149,7 +148,7 @@ describe("ExtensionDiscovery", () => { }; mockedWatch.mockImplementationOnce(() => - (mockWatchInstance) as any, + (mockWatchInstance) as any, ); const extensionDiscovery = ExtensionDiscovery.createInstance( extensionLoader, @@ -172,3 +171,4 @@ describe("ExtensionDiscovery", () => { }, 10); }); }); + diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index 00fb397079..33a716e03a 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -262,7 +262,6 @@ export class ExtensionLoader { registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), - registries.CommandRegistry.getInstance().add(extension.commands), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), ]; @@ -293,7 +292,6 @@ export class ExtensionLoader { registries.KubeObjectDetailRegistry.getInstance().add(extension.kubeObjectDetailItems), registries.KubeObjectStatusRegistry.getInstance().add(extension.kubeObjectStatusTexts), registries.WorkloadsOverviewDetailRegistry.getInstance().add(extension.kubeWorkloadsOverviewItems), - registries.CommandRegistry.getInstance().add(extension.commands), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { diff --git a/src/extensions/getDiForUnitTesting.ts b/src/extensions/getDiForUnitTesting.ts index 5460d8887b..81184da59a 100644 --- a/src/extensions/getDiForUnitTesting.ts +++ b/src/extensions/getDiForUnitTesting.ts @@ -41,7 +41,6 @@ export const getDiForUnitTesting = () => { aliases: [injectable, ...(injectable.aliases || [])], }; }) - .forEach(injectable => di.register(injectable)); di.preventSideEffects(); diff --git a/src/extensions/lens-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 081beed63e..641881c38b 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -81,7 +81,7 @@ describe("kubeconfig manager tests", () => { let contextHandler: ContextHandler; beforeEach(() => { - const mockOpts = { + mockFs({ "minikube-config.yml": JSON.stringify({ apiVersion: "v1", clusters: [{ @@ -103,9 +103,7 @@ describe("kubeconfig manager tests", () => { kind: "Config", preferences: {}, }), - }; - - mockFs(mockOpts); + }); cluster = new Cluster({ id: "foo", diff --git a/src/main/getDi.ts b/src/main/getDi.ts index 2b59923a6c..c5fad1d470 100644 --- a/src/main/getDi.ts +++ b/src/main/getDi.ts @@ -24,11 +24,15 @@ import { createContainer } from "@ogre-tools/injectable"; export const getDi = () => createContainer( getRequireContextForMainCode, + getRequireContextForCommonCode, getRequireContextForCommonExtensionCode, ); const getRequireContextForMainCode = () => require.context("./", true, /\.injectable\.(ts|tsx)$/); +const getRequireContextForCommonCode = () => + require.context("../common", true, /\.injectable\.(ts|tsx)$/); + const getRequireContextForCommonExtensionCode = () => require.context("../extensions", true, /\.injectable\.(ts|tsx)$/); diff --git a/src/main/menu/menu.ts b/src/main/menu/menu.ts index 294c31e0cc..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/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 735e66a7a3..10eb124c45 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -20,7 +20,7 @@ */ import { computed, observable, makeObservable, action } from "mobx"; -import { ipcRendererOn } from "../../common/ipc"; +import { catalogEntityRunListener, ipcRendererOn } from "../../common/ipc"; import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; import "../../common/catalog-entities"; import type { Cluster } from "../../main/cluster"; @@ -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 6e9e9fb471..b9f48b2b8c 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -49,7 +49,7 @@ import { SentryInit } from "../common/sentry"; import { TerminalStore } from "./components/dock/terminal.store"; import { AppPaths } from "../common/app-paths"; import { registerCustomThemes } from "./components/monaco-editor"; -import { getDi } from "./components/getDi"; +import { getDi } from "./getDi"; import { DiContextProvider } from "@ogre-tools/injectable-react"; import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; @@ -59,6 +59,7 @@ import bindProtocolAddRouteHandlersInjectable import type { LensProtocolRouterRenderer } from "./protocol-handler"; import lensProtocolRouterRendererInjectable from "./protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; +import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable"; if (process.isMainFrame) { SentryInit(); @@ -102,9 +103,6 @@ export async function bootstrap(comp: () => Promise, di: Dependenc logger.info(`${logPrefix} initializing Registries`); initializers.initRegistries(); - logger.info(`${logPrefix} initializing CommandRegistry`); - initializers.initCommandRegistry(); - logger.info(`${logPrefix} initializing EntitySettingsRegistry`); initializers.initEntitySettingsRegistry(); @@ -124,7 +122,9 @@ export async function bootstrap(comp: () => Promise, di: Dependenc initializers.initCatalogCategoryRegistryEntries(); logger.info(`${logPrefix} initializing Catalog`); - initializers.initCatalog(); + initializers.initCatalog({ + openCommandDialog: di.inject(commandOverlayInjectable).open, + }); const extensionLoader = di.inject(extensionLoaderInjectable); diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resources/crd.store.ts index 2cdcbee55b..71e96afe8a 100644 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ b/src/renderer/components/+custom-resources/crd.store.ts @@ -66,22 +66,15 @@ export class CRDStore extends KubeObjectStore { @computed get groups() { const groups: Record = {}; - return this.items.reduce((groups, crd) => { - const group = crd.getGroup(); + for (const crd of this.items) { + (groups[crd.getGroup()] ??= []).push(crd); + } - if (!groups[group]) groups[group] = []; - groups[group].push(crd); - - return groups; - }, groups); + return groups; } getByGroup(group: string, pluralName: string) { - const crdInGroup = this.groups[group]; - - if (!crdInGroup) return null; - - return crdInGroup.find(crd => crd.getPluralName() === pluralName); + return this.groups[group]?.find(crd => crd.getPluralName() === pluralName); } getByObject(obj: KubeObject) { diff --git a/src/renderer/components/hotbar/hotbar-display-label.ts b/src/renderer/components/+custom-resources/custom-resources.injectable.ts similarity index 73% rename from src/renderer/components/hotbar/hotbar-display-label.ts rename to src/renderer/components/+custom-resources/custom-resources.injectable.ts index 0489e2600f..99a532f69f 100644 --- a/src/renderer/components/hotbar/hotbar-display-label.ts +++ b/src/renderer/components/+custom-resources/custom-resources.injectable.ts @@ -19,18 +19,14 @@ * 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 { computed } from "mobx"; +import { crdStore } from "./crd.store"; -function hotbarIndex(id: string) { - return HotbarStore.getInstance().hotbarIndex(id) + 1; -} +const customResourceDefinitionsInjectable = getInjectable({ + instantiate: () => computed(() => [...crdStore.items]), -export function hotbarDisplayLabel(id: string) : string { - const hotbar = HotbarStore.getInstance().getById(id); + lifecycle: lifecycleEnum.singleton, +}); - return `${hotbarIndex(id)}: ${hotbar.name}`; -} - -export function hotbarDisplayIndex(id: string) : string { - return hotbarIndex(id).toString(); -} +export default customResourceDefinitionsInjectable; diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 4ebdb6156f..1ad53d2fd6 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -34,7 +34,7 @@ import { mockWindow } from "../../../../../__mocks__/windowMock"; import { AppPaths } from "../../../../common/app-paths"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../test-utils/renderFor"; mockWindow(); diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index 74cf65b20d..3d1ba9b7ce 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -24,7 +24,7 @@ import { screen } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { defaultWidth, Welcome } from "../welcome"; import { computed } from "mobx"; -import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; diff --git a/src/renderer/components/activate-entity-command/activate-entity-command.tsx b/src/renderer/components/activate-entity-command/activate-entity-command.tsx index f0b06769d8..57b1cb968a 100644 --- a/src/renderer/components/activate-entity-command/activate-entity-command.tsx +++ b/src/renderer/components/activate-entity-command/activate-entity-command.tsx @@ -19,40 +19,49 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { computed } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { computed, IComputedValue } from "mobx"; import { observer } from "mobx-react"; import React from "react"; +import { broadcastMessage, catalogEntityRunListener } from "../../../common/ipc"; import type { CatalogEntity } from "../../api/catalog-entity"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { CommandOverlay } from "../command-palette"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import { Select } from "../select"; -@observer -export class ActivateEntityCommand extends React.Component { - @computed get options() { - return catalogEntityRegistry.items.map(entity => ({ - label: `${entity.kind}: ${entity.getName()}`, - value: entity, - })); - } - - onSelect(entity: CatalogEntity): void { - catalogEntityRegistry.onRun(entity); - CommandOverlay.close(); - } - - render() { - return ( - onSelect(v.value)} + components={{ DropdownIndicator: null, IndicatorSeparator: null }} + menuIsOpen={true} + options={options} + autoFocus={true} + escapeClearsValue={false} + placeholder="Activate entity ..." + /> + ); +}); + +export const ActivateEntityCommand = withInjectables(NonInjectedActivateEntityCommand, { + getProps: di => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + entities: computed(() => [...catalogEntityRegistry.items]), + }), +}); diff --git a/src/renderer/components/catalog-entities/weblink-add-command.tsx b/src/renderer/components/catalog-entities/weblink-add-command.tsx index 7d48622658..230c66987a 100644 --- a/src/renderer/components/catalog-entities/weblink-add-command.tsx +++ b/src/renderer/components/catalog-entities/weblink-add-command.tsx @@ -21,21 +21,26 @@ import React from "react"; import { observer } from "mobx-react"; -import { CommandOverlay } from "../command-palette"; import { Input } from "../input"; import { isUrl } from "../input/input_validators"; import { WeblinkStore } from "../../../common/weblink-store"; import { computed, makeObservable, observable } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; + +interface Dependencies { + closeCommandOverlay: () => void; +} + @observer -export class WeblinkAddCommand extends React.Component { +class NonInjectedWeblinkAddCommand extends React.Component { @observable url = ""; @observable nameHidden = true; @observable dirty = false; - constructor(props: {}) { + constructor(props: Dependencies) { super(props); - makeObservable(this); } @@ -55,8 +60,7 @@ export class WeblinkAddCommand extends React.Component { name: name || this.url, url: this.url, }); - - CommandOverlay.close(); + this.props.closeCommandOverlay(); } @computed get showValidation() { @@ -100,3 +104,10 @@ export class WeblinkAddCommand extends React.Component { ); } } + +export const WeblinkAddCommand = withInjectables(NonInjectedWeblinkAddCommand, { + getProps: (di, props) => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + ...props, + }), +}); diff --git a/src/renderer/components/cluster-manager/active-hotbar-name.tsx b/src/renderer/components/cluster-manager/active-hotbar-name.tsx index 63f8b583b9..344299c110 100644 --- a/src/renderer/components/cluster-manager/active-hotbar-name.tsx +++ b/src/renderer/components/cluster-manager/active-hotbar-name.tsx @@ -22,19 +22,31 @@ import React from "react"; import { observer } from "mobx-react"; import { Icon } from "../icon"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -export const ActiveHotbarName = observer(() => { - return ( -
CommandOverlay.open()} - > - - {HotbarStore.getInstance().getActive()?.name} -
- ); +interface Dependencies { + openCommandOverlay: (component: React.ReactElement) => void; + activeHotbarName: () => string | undefined; +} + +const NonInjectedActiveHotbarName = observer(({ openCommandOverlay, activeHotbarName }: Dependencies) => ( +
openCommandOverlay()} + > + + {activeHotbarName()} +
+)); + +export const ActiveHotbarName = withInjectables(NonInjectedActiveHotbarName, { + getProps: (di, props) => ({ + activeHotbarName: () => di.inject(hotbarManagerInjectable).getActive()?.name, + openCommandOverlay: di.inject(commandOverlayInjectable).open, + ...props, + }), }); diff --git a/src/renderer/components/cluster-manager/bottom-bar.test.tsx b/src/renderer/components/cluster-manager/bottom-bar.test.tsx index 4390175889..0733747a97 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.test.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.test.tsx @@ -21,21 +21,19 @@ import React from "react"; import mockFs from "mock-fs"; -import { render, fireEvent } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { BottomBar } from "./bottom-bar"; import { StatusBarRegistry } from "../../../extensions/registries"; -import { HotbarStore } from "../../../common/hotbar-store"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { AppPaths } from "../../../common/app-paths"; -import { CommandOverlay } from "../command-palette"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; import { ActiveHotbarName } from "./active-hotbar-name"; - -jest.mock("../command-palette", () => ({ - CommandOverlay: { - open: jest.fn(), - }, -})); +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../test-utils/renderFor"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import { getEmptyHotbar } from "../../../common/hotbar-types"; AppPaths.init(); @@ -55,7 +53,12 @@ jest.mock("electron", () => ({ }, })); +const foobarHotbar = getEmptyHotbar("foobar"); + describe("", () => { + let di: ConfigurableDependencyInjectionContainer; + let render: DiRender; + beforeEach(() => { const mockOpts = { "tmp": { @@ -63,14 +66,19 @@ describe("", () => { }, }; + di = getDiForUnitTesting(); + render = renderFor(di); + mockFs(mockOpts); StatusBarRegistry.createInstance(); - HotbarStore.createInstance(); + + di.override(hotbarManagerInjectable, () => ({ + getActive: () => foobarHotbar, + } as any)); }); afterEach(() => { StatusBarRegistry.resetInstance(); - HotbarStore.resetInstance(); mockFs.restore(); }); @@ -80,24 +88,20 @@ describe("", () => { expect(container).toBeInstanceOf(HTMLElement); }); - it("renders w/o errors when .getItems() returns unexpected (not type compliant) data", async () => { - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => undefined); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => "hello"); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => 6); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => null); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => []); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [{}]); - expect(() => render()).not.toThrow(); - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => { return {};}); + it.each([ + undefined, + "hello", + 6, + null, + [], + [{}], + {}, + ])("renders w/o errors when .getItems() returns not type compliant (%p)", val => { + StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => val); expect(() => render()).not.toThrow(); }); - it("renders items [{item: React.ReactNode}] (4.0.0-rc.1)", async () => { + it("renders items [{item: React.ReactNode}] (4.0.0-rc.1)", () => { const testId = "testId"; const text = "heee"; @@ -106,10 +110,10 @@ describe("", () => { ]); const { getByTestId } = render(); - expect(await getByTestId(testId)).toHaveTextContent(text); + expect(getByTestId(testId)).toHaveTextContent(text); }); - it("renders items [{item: () => React.ReactNode}] (4.0.0-rc.1+)", async () => { + it("renders items [{item: () => React.ReactNode}] (4.0.0-rc.1+)", () => { const testId = "testId"; const text = "heee"; @@ -118,33 +122,25 @@ describe("", () => { ]); const { getByTestId } = render(); - expect(await getByTestId(testId)).toHaveTextContent(text); + expect(getByTestId(testId)).toHaveTextContent(text); }); - it("show default hotbar name", () => { + it("shows active hotbar name", () => { StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ { item: () => }, ]); const { getByTestId } = render(); - expect(getByTestId("current-hotbar-name")).toHaveTextContent("default"); - }); - - it("show active hotbar name", () => { - StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ - { item: () => }, - ]); - const { getByTestId } = render(); - - HotbarStore.getInstance().add({ - id: "new", - name: "new", - }, { setActive: true }); - - expect(getByTestId("current-hotbar-name")).toHaveTextContent("new"); + expect(getByTestId("current-hotbar-name")).toHaveTextContent("foobar"); }); it("opens command palette on click", () => { + const mockOpen = jest.fn(); + + di.override(commandOverlayInjectable, () => ({ + open: mockOpen, + }) as any); + StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ { item: () => }, ]); @@ -153,7 +149,8 @@ describe("", () => { fireEvent.click(activeHotbar); - expect(CommandOverlay.open).toHaveBeenCalledWith(); + + expect(mockOpen).toHaveBeenCalledWith(); }); it("sort positioned items properly", () => { diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 300c9b2eb4..95a21a747e 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -38,7 +38,7 @@ import * as routes from "../../../common/routes"; import { DeleteClusterDialog } from "../delete-cluster-dialog"; import { reaction } from "mobx"; import { navigation } from "../../navigation"; -import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; +import { setEntityOnRouteMatch } from "../../api/helpers/general-active-sync"; import { catalogURL, getPreviousTabUrl } from "../../../common/routes"; import { TopBar } from "../layout/top-bar/top-bar"; diff --git a/src/renderer/components/command-palette/command-container.tsx b/src/renderer/components/command-palette/command-container.tsx index 2d4a7b6a83..711b16b142 100644 --- a/src/renderer/components/command-palette/command-container.tsx +++ b/src/renderer/components/command-palette/command-container.tsx @@ -21,68 +21,100 @@ import "./command-container.scss"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import React from "react"; import { Dialog } from "../dialog"; -import { ipcRendererOn } from "../../../common/ipc"; import { CommandDialog } from "./command-dialog"; import type { ClusterId } from "../../../common/cluster-types"; +import commandOverlayInjectable, { CommandOverlay } from "./command-overlay.injectable"; +import { isMac } from "../../../common/vars"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { CommandRegistration, CommandRegistry } from "../../../extensions/registries/command-registry"; -import { CommandOverlay } from "./command-overlay"; +import { broadcastMessage, ipcRendererOn } from "../../../common/ipc"; +import { getMatchedClusterId } from "../../navigation"; +import type { Disposer } from "../../utils"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import windowAddEventListenerInjectable from "../../window/event-listener.injectable"; export interface CommandContainerProps { clusterId?: ClusterId; } +interface Dependencies { + addWindowEventListener: (type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) => Disposer; + commandOverlay: CommandOverlay, +} + @observer -export class CommandContainer extends React.Component { +class NonInjectedCommandContainer extends React.Component { private escHandler(event: KeyboardEvent) { + const { commandOverlay } = this.props; + if (event.key === "Escape") { event.stopPropagation(); - CommandOverlay.close(); + commandOverlay.close(); } } - private findCommandById(commandId: string) { - return CommandRegistry.getInstance().getItems().find((command) => command.id === commandId); - } + handleCommandPalette = () => { + const { commandOverlay } = this.props; + const clusterIsActive = getMatchedClusterId() !== undefined; - private runCommand(command: CommandRegistration) { - command.action({ - entity: catalogEntityRegistry.activeEntity, - }); + if (clusterIsActive) { + broadcastMessage(`command-palette:${catalogEntityRegistry.activeEntity.getId()}:open`); + } else { + commandOverlay.open(); + } + }; + + onKeyboardShortcut(action: () => void) { + return ({ key, shiftKey, ctrlKey, altKey, metaKey }: KeyboardEvent) => { + const ctrlOrCmd = isMac ? metaKey && !ctrlKey : !metaKey && ctrlKey; + + if (key === "p" && shiftKey && ctrlOrCmd && !altKey) { + action(); + } + }; } componentDidMount() { - if (this.props.clusterId) { - ipcRendererOn(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => { - const command = this.findCommandById(commandId); + const { clusterId, addWindowEventListener, commandOverlay } = this.props; - if (command) { - this.runCommand(command); - } - }); - } else { - ipcRendererOn("command-palette:open", () => { - CommandOverlay.open(); - }); - } - window.addEventListener("keyup", (e) => this.escHandler(e), true); + const action = clusterId + ? () => commandOverlay.open() + : this.handleCommandPalette; + const ipcChannel = clusterId + ? `command-palette:${clusterId}:open` + : "command-palette:open"; + + disposeOnUnmount(this, [ + ipcRendererOn(ipcChannel, action), + addWindowEventListener("keydown", this.onKeyboardShortcut(action)), + addWindowEventListener("keyup", (e) => this.escHandler(e), true), + ]); } render() { + const { commandOverlay } = this.props; + return (
- {CommandOverlay.component} + {commandOverlay.component}
); } } + +export const CommandContainer = withInjectables(NonInjectedCommandContainer, { + getProps: (di, props) => ({ + addWindowEventListener: di.inject(windowAddEventListenerInjectable), + commandOverlay: di.inject(commandOverlayInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/command-palette/command-dialog.tsx b/src/renderer/components/command-palette/command-dialog.tsx index 3761068260..01f095ec16 100644 --- a/src/renderer/components/command-palette/command-dialog.tsx +++ b/src/renderer/components/command-palette/command-dialog.tsx @@ -21,108 +21,107 @@ import { Select } from "../select"; -import { computed, makeObservable, observable } from "mobx"; +import type { IComputedValue } from "mobx"; import { observer } from "mobx-react"; -import React from "react"; -import { CommandRegistry } from "../../../extensions/registries/command-registry"; -import { CommandOverlay } from "./command-overlay"; -import { broadcastMessage } from "../../../common/ipc"; -import { navigate } from "../../navigation"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import React, { useState } from "react"; +import commandOverlayInjectable from "./command-overlay.injectable"; import type { CatalogEntity } from "../../../common/catalog"; -import { clusterViewURL } from "../../../common/routes"; +import { navigate } from "../../navigation"; +import { broadcastMessage } from "../../../common/ipc"; +import { IpcRendererNavigationEvents } from "../../navigation/events"; +import type { RegisteredCommand } from "./registered-commands/commands"; +import { iter } from "../../utils"; +import { orderBy } from "lodash"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import registeredCommandsInjectable from "./registered-commands/registered-commands.injectable"; +import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -@observer -export class CommandDialog extends React.Component { - @observable menuIsOpen = true; - @observable searchValue: any = undefined; +interface Dependencies { + commands: IComputedValue>; + activeEntity?: CatalogEntity; + closeCommandOverlay: () => void; +} - constructor(props: {}) { - super(props); - makeObservable(this); - } +const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeCommandOverlay }: Dependencies) => { + const [searchValue, setSearchValue] = useState(""); - @computed get activeEntity(): CatalogEntity | undefined { - return catalogEntityRegistry.activeEntity; - } - - @computed get options() { - const registry = CommandRegistry.getInstance(); - - const context = { - entity: this.activeEntity, - }; - - return registry.getItems().filter((command) => { - if (command.scope === "entity" && !this.activeEntity) { - return false; - } - - try { - return command.isActive?.(context) ?? true; - } catch(e) { - console.error(e); - } - - return false; - }) - .map((command) => ({ - value: command.id, - label: command.title, - })) - .sort((a, b) => a.label > b.label ? 1 : -1); - } - - private onChange(value: string) { - const registry = CommandRegistry.getInstance(); - const command = registry.getItems().find((cmd) => cmd.id === value); + const executeAction = (commandId: string) => { + const command = commands.get().get(commandId); if (!command) { return; } try { - CommandOverlay.close(); + closeCommandOverlay(); + command.action({ + entity: activeEntity, + navigate: (url, opts = {}) => { + const { forceRootFrame = false } = opts; - if (command.scope === "global") { - command.action({ - entity: this.activeEntity, - }); - } else if(this.activeEntity) { - navigate(clusterViewURL({ - params: { - clusterId: this.activeEntity.metadata.uid, - }, - })); - broadcastMessage(`command-palette:run-action:${this.activeEntity.metadata.uid}`, command.id); - } - } catch(error) { + if (forceRootFrame) { + broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url); + } else { + navigate(url); + } + }, + }); + } catch (error) { console.error("[COMMAND-DIALOG] failed to execute command", command.id, error); } - } + }; - render() { - return ( - executeAction(v.value)} + components={{ + DropdownIndicator: null, + IndicatorSeparator: null, + }} + menuIsOpen + options={options} + autoFocus={true} + escapeClearsValue={false} + data-test-id="command-palette-search" + placeholder="Type a command or search…" + onInputChange={(newValue, { action }) => { + if (action === "input-change") { + setSearchValue(newValue); + } + }} + inputValue={searchValue} + /> + ); +}); + +export const CommandDialog = withInjectables(NonInjectedCommandDialog, { + getProps: di => ({ + commands: di.inject(registeredCommandsInjectable), + // TODO: replace with injection + activeEntity: catalogEntityRegistry.activeEntity, + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + }), +}); diff --git a/src/renderer/components/command-palette/command-overlay.ts b/src/renderer/components/command-palette/command-overlay.injectable.ts similarity index 68% rename from src/renderer/components/command-palette/command-overlay.ts rename to src/renderer/components/command-palette/command-overlay.injectable.ts index 1058b2f9d3..643e81ac90 100644 --- a/src/renderer/components/command-palette/command-overlay.ts +++ b/src/renderer/components/command-palette/command-overlay.injectable.ts @@ -19,29 +19,37 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { observable } from "mobx"; import React from "react"; export class CommandOverlay { - static #component = observable.box(null, { deep: false }); + #component = observable.box(null, { deep: false }); - static get isOpen(): boolean { - return Boolean(CommandOverlay.#component.get()); + get isOpen(): boolean { + return Boolean(this.#component.get()); } - static open(component: React.ReactElement) { + open = (component: React.ReactElement) => { if (!React.isValidElement(component)) { throw new TypeError("CommandOverlay.open must be passed a valid ReactElement"); } - CommandOverlay.#component.set(component); - } + this.#component.set(component); + }; - static close() { - CommandOverlay.#component.set(null); - } + close = () => { + this.#component.set(null); + }; - static get component(): React.ReactElement | null { - return CommandOverlay.#component.get(); + get component(): React.ReactElement | null { + return this.#component.get(); } } + +const commandOverlayInjectable = getInjectable({ + instantiate: () => new CommandOverlay(), + lifecycle: lifecycleEnum.singleton, +}); + +export default commandOverlayInjectable; diff --git a/src/renderer/components/command-palette/index.ts b/src/renderer/components/command-palette/index.ts index 27169ddae2..8aa1da106c 100644 --- a/src/renderer/components/command-palette/index.ts +++ b/src/renderer/components/command-palette/index.ts @@ -21,4 +21,4 @@ export * from "./command-container"; export * from "./command-dialog"; -export * from "./command-overlay"; +export * from "./command-overlay.injectable"; diff --git a/src/extensions/registries/command-registry.ts b/src/renderer/components/command-palette/registered-commands/commands.d.ts similarity index 54% rename from src/extensions/registries/command-registry.ts rename to src/renderer/components/command-palette/registered-commands/commands.d.ts index 3916b0f721..9b90662255 100644 --- a/src/extensions/registries/command-registry.ts +++ b/src/renderer/components/command-palette/registered-commands/commands.d.ts @@ -19,34 +19,55 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Extensions API -> Commands - -import { BaseRegistry } from "./base-registry"; -import type { LensExtension } from "../lens-extension"; -import type { CatalogEntity } from "../../common/catalog"; +import type { CatalogEntity } from "../../../../common/catalog"; +/** + * The context given to commands when executed + */ export interface CommandContext { entity?: CatalogEntity; } +export interface CommandActionNavigateOptions { + /** + * If `true` then the navigate will only navigate on the root frame and not + * within a cluster + * @default false + */ + forceRootFrame?: boolean; +} + +export interface CommandActionContext extends CommandContext { + navigate: (url: string, opts?: CommandActionNavigateOptions) => void; +} + export interface CommandRegistration { + /** + * The ID of the command, must be globally unique + */ id: string; - title: string; - scope: "entity" | "global"; - action: (context: CommandContext) => void; + + /** + * The display name of the command in the command pallet + */ + title: string | ((context: CommandContext) => string); + + /** + * @deprecated use `isActive` instead since there is always an entity active + */ + scope?: "global" | "entity"; + + /** + * The function to run when this command is selected + */ + action: (context: CommandActionContext) => void; + + /** + * A function that determines if the command is active. + * + * @default () => true + */ isActive?: (context: CommandContext) => boolean; } -export class CommandRegistry extends BaseRegistry { - add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) { - const itemArray = [items].flat(); - - const newIds = itemArray.map((item) => item.id); - const currentIds = this.getItems().map((item) => item.id); - - const filteredIds = newIds.filter((id) => !currentIds.includes(id)); - const filteredItems = itemArray.filter((item) => filteredIds.includes(item.id)); - - return super.add(filteredItems, extension); - } -} +export type RegisteredCommand = Required>; diff --git a/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx b/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx new file mode 100644 index 0000000000..4fc37a902e --- /dev/null +++ b/src/renderer/components/command-palette/registered-commands/internal-commands.injectable.tsx @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import * as routes from "../../../../common/routes"; +import { EntitySettingRegistry, RegisteredEntitySetting } from "../../../../extensions/registries"; +import { createTerminalTab } from "../../dock/terminal.store"; +import { HotbarAddCommand } from "../../hotbar/hotbar-add-command"; +import { HotbarRemoveCommand } from "../../hotbar/hotbar-remove-command"; +import { HotbarSwitchCommand } from "../../hotbar/hotbar-switch-command"; +import { HotbarRenameCommand } from "../../hotbar/hotbar-rename-command"; +import { ActivateEntityCommand } from "../../activate-entity-command"; +import type { CommandContext, CommandRegistration } from "./commands"; +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import commandOverlayInjectable from "../command-overlay.injectable"; + +export function isKubernetesClusterActive(context: CommandContext): boolean { + return context.entity?.kind === "KubernetesCluster"; +} + +interface Dependencies { + openCommandDialog: (component: React.ReactElement) => void; + getEntitySettingItems: (kind: string, apiVersion: string, source?: string) => RegisteredEntitySetting[]; +} + +function getInternalCommands({ openCommandDialog, getEntitySettingItems }: Dependencies): CommandRegistration[] { + return [ + { + id: "app.showPreferences", + title: "Preferences: Open", + action: ({ navigate }) => navigate(routes.preferencesURL(), { + forceRootFrame: true, + }), + }, + { + id: "cluster.viewHelmCharts", + title: "Cluster: View Helm Charts", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.helmChartsURL()), + }, + { + id: "cluster.viewHelmReleases", + title: "Cluster: View Helm Releases", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.releaseURL()), + }, + { + id: "cluster.viewConfigMaps", + title: "Cluster: View ConfigMaps", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.configMapsURL()), + }, + { + id: "cluster.viewSecrets", + title: "Cluster: View Secrets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.secretsURL()), + }, + { + id: "cluster.viewResourceQuotas", + title: "Cluster: View ResourceQuotas", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.resourceQuotaURL()), + }, + { + id: "cluster.viewLimitRanges", + title: "Cluster: View LimitRanges", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.limitRangeURL()), + }, + { + id: "cluster.viewHorizontalPodAutoscalers", + title: "Cluster: View HorizontalPodAutoscalers (HPA)", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.hpaURL()), + }, + { + id: "cluster.viewPodDisruptionBudget", + title: "Cluster: View PodDisruptionBudgets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.pdbURL()), + }, + { + id: "cluster.viewServices", + title: "Cluster: View Services", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.servicesURL()), + }, + { + id: "cluster.viewEndpoints", + title: "Cluster: View Endpoints", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.endpointURL()), + }, + { + id: "cluster.viewIngresses", + title: "Cluster: View Ingresses", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.ingressURL()), + }, + { + id: "cluster.viewNetworkPolicies", + title: "Cluster: View NetworkPolicies", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.networkPoliciesURL()), + }, + { + id: "cluster.viewNodes", + title: "Cluster: View Nodes", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.nodesURL()), + }, + { + id: "cluster.viewPods", + title: "Cluster: View Pods", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.podsURL()), + }, + { + id: "cluster.viewDeployments", + title: "Cluster: View Deployments", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.deploymentsURL()), + }, + { + id: "cluster.viewDaemonSets", + title: "Cluster: View DaemonSets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.daemonSetsURL()), + }, + { + id: "cluster.viewStatefulSets", + title: "Cluster: View StatefulSets", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.statefulSetsURL()), + }, + { + id: "cluster.viewJobs", + title: "Cluster: View Jobs", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.jobsURL()), + }, + { + id: "cluster.viewCronJobs", + title: "Cluster: View CronJobs", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.cronJobsURL()), + }, + { + id: "cluster.viewCustomResourceDefinitions", + title: "Cluster: View Custom Resource Definitions", + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(routes.crdURL()), + }, + { + id: "entity.viewSettings", + title: ({ entity }) => `${entity.kind}/${entity.getName()}: View Settings`, + action: ({ entity, navigate }) => navigate(`/entity/${entity.getId()}/settings`, { + forceRootFrame: true, + }), + isActive: ({ entity }) => { + if (!entity) { + return false; + } + + return getEntitySettingItems(entity.kind, entity.apiVersion, entity.metadata.source).length > 0; + }, + }, + { + id: "cluster.openTerminal", + title: "Cluster: Open terminal", + action: () => createTerminalTab(), + isActive: isKubernetesClusterActive, + }, + { + id: "hotbar.switchHotbar", + title: "Hotbar: Switch ...", + action: () => openCommandDialog(), + }, + { + id: "hotbar.addHotbar", + title: "Hotbar: Add Hotbar ...", + action: () => openCommandDialog(), + }, + { + id: "hotbar.removeHotbar", + title: "Hotbar: Remove Hotbar ...", + action: () => openCommandDialog(), + }, + { + id: "hotbar.renameHotbar", + title: "Hotbar: Rename Hotbar ...", + action: () => openCommandDialog(), + }, + { + id: "catalog.searchEntities", + title: "Catalog: Activate Entity ...", + action: () => openCommandDialog(), + }, + ]; +} + +const internalCommandsInjectable = getInjectable({ + instantiate: (di) => getInternalCommands({ + openCommandDialog: di.inject(commandOverlayInjectable).open, + getEntitySettingItems: EntitySettingRegistry + .getInstance() + .getItemsForKind, + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default internalCommandsInjectable; diff --git a/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts b/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts new file mode 100644 index 0000000000..a9bbd6bc57 --- /dev/null +++ b/src/renderer/components/command-palette/registered-commands/registered-commands.injectable.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue } from "mobx"; +import type { CustomResourceDefinition } from "../../../../common/k8s-api/endpoints"; +import customResourceDefinitionsInjectable from "../../+custom-resources/custom-resources.injectable"; +import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import type { CommandRegistration, RegisteredCommand } from "./commands"; +import internalCommandsInjectable, { isKubernetesClusterActive } from "./internal-commands.injectable"; + +interface Dependencies { + extensions: IComputedValue; + customResourceDefinitions: IComputedValue; + internalCommands: CommandRegistration[]; +} + +const instantiateRegisteredCommands = ({ extensions, customResourceDefinitions, internalCommands }: Dependencies) => computed(() => { + const result = new Map(); + const commands = [ + ...internalCommands, + ...extensions.get().flatMap(e => e.commands), + ...customResourceDefinitions.get().map((command): CommandRegistration => ({ + id: `cluster.view.${command.getResourceKind()}`, + title: `Cluster: View ${command.getResourceKind()}`, + isActive: isKubernetesClusterActive, + action: ({ navigate }) => navigate(command.getResourceUrl()), + })), + ]; + + for (const { scope, isActive = () => true, ...command } of commands) { + if (!result.has(command.id)) { + result.set(command.id, { ...command, isActive }); + } + } + + return result; +}); + +const registeredCommandsInjectable = getInjectable({ + instantiate: (di) => instantiateRegisteredCommands({ + extensions: di.inject(rendererExtensionsInjectable), + customResourceDefinitions: di.inject(customResourceDefinitionsInjectable), + internalCommands: di.inject(internalCommandsInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default registeredCommandsInjectable; diff --git a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx index 7a292e7e5f..334d9205ad 100644 --- a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -21,13 +21,17 @@ import "@testing-library/jest-dom/extend-expect"; import { HotbarRemoveCommand } from "../hotbar-remove-command"; -import { render, fireEvent } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import React from "react"; -import { ThemeStore } from "../../../theme.store"; -import { UserStore } from "../../../../common/user-store"; -import { Notifications } from "../../notifications"; -import mockFs from "mock-fs"; import { AppPaths } from "../../../../common/app-paths"; +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { type DiRender, renderFor } from "../../test-utils/renderFor"; +import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable"; +import { UserStore } from "../../../../common/user-store"; +import { ThemeStore } from "../../../theme.store"; +import { ConfirmDialog } from "../../confirm-dialog"; +import type { HotbarStore } from "../../../../common/hotbar-store"; jest.mock("electron", () => ({ app: { @@ -55,45 +59,58 @@ const mockHotbars: { [id: string]: any } = { }, }; -jest.mock("../../../../common/hotbar-store", () => ({ - HotbarStore: { - getInstance: () => ({ - hotbars: [mockHotbars["1"]], - getById: (id: string) => mockHotbars[id], - remove: () => {}, - hotbarIndex: () => 0, - }), - }, -})); - describe("", () => { + let di: ConfigurableDependencyInjectionContainer; + let render: DiRender; + beforeEach(() => { - mockFs({ - "tmp": {}, - }); + di = getDiForUnitTesting(); + render = renderFor(di); + UserStore.createInstance(); ThemeStore.createInstance(); }); afterEach(() => { - UserStore.resetInstance(); ThemeStore.resetInstance(); - mockFs.restore(); + UserStore.resetInstance(); }); it("renders w/o errors", () => { + di.override(hotbarManagerInjectable, () => ({ + hotbars: [mockHotbars["1"]], + getById: (id: string) => mockHotbars[id], + remove: () => { }, + hotbarIndex: () => 0, + getDisplayLabel: () => "1: Default", + }) as any as HotbarStore); + const { container } = render(); expect(container).toBeInstanceOf(HTMLElement); }); - it("displays error notification if user tries to remove last hotbar", () => { - const spy = jest.spyOn(Notifications, "error"); - const { getByText } = render(); + it("calls remove if you click on the entry", () => { + const removeMock = jest.fn(); + + di.override(hotbarManagerInjectable, () => ({ + hotbars: [mockHotbars["1"]], + getById: (id: string) => mockHotbars[id], + remove: removeMock, + hotbarIndex: () => 0, + getDisplayLabel: () => "1: Default", + }) as any as HotbarStore); + + const { getByText } = render( + <> + + + , + ); fireEvent.click(getByText("1: Default")); + fireEvent.click(getByText("Remove Hotbar")); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); + expect(removeMock).toHaveBeenCalled(); }); }); diff --git a/src/renderer/components/hotbar/hotbar-add-command.tsx b/src/renderer/components/hotbar/hotbar-add-command.tsx index 20c532e252..e5faf0cb76 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -21,44 +21,53 @@ import React from "react"; import { observer } from "mobx-react"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { CommandOverlay } from "../command-palette"; import { Input, InputValidator } from "../input"; +import type { CreateHotbarData, CreateHotbarOptions } from "../../../common/hotbar-types"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; +import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable"; -export const uniqueHotbarName: InputValidator = { - condition: ({ required }) => required, - message: () => "Hotbar with this name already exists", - validate: value => !HotbarStore.getInstance().getByName(value), -}; +interface Dependencies { + closeCommandOverlay: () => void; + addHotbar: (data: CreateHotbarData, { setActive }?: CreateHotbarOptions) => void; + uniqueHotbarName: InputValidator; +} -@observer -export class HotbarAddCommand extends React.Component { - onSubmit = (name: string) => { +const NonInjectedHotbarAddCommand = observer(({ closeCommandOverlay, addHotbar, uniqueHotbarName }: Dependencies) => { + const onSubmit = (name: string) => { if (!name.trim()) { return; } - HotbarStore.getInstance().add({ name }, { setActive: true }); - CommandOverlay.close(); + addHotbar({ name }, { setActive: true }); + closeCommandOverlay(); }; - render() { - return ( - <> - - - Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel) - - - ); - } -} + return ( + <> + + + Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel) + + + ); +}); + +export const HotbarAddCommand = withInjectables(NonInjectedHotbarAddCommand, { + getProps: (di, props) => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + addHotbar: di.inject(hotbarManagerInjectable).add, + uniqueHotbarName: di.inject(uniqueHotbarNameInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/hotbar/hotbar-remove-command.tsx b/src/renderer/components/hotbar/hotbar-remove-command.tsx index 58aa1d849d..04dfe273d1 100644 --- a/src/renderer/components/hotbar/hotbar-remove-command.tsx +++ b/src/renderer/components/hotbar/hotbar-remove-command.tsx @@ -22,51 +22,44 @@ import React from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import { computed, makeObservable } from "mobx"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { hotbarDisplayLabel } from "./hotbar-display-label"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import { ConfirmDialog } from "../confirm-dialog"; -import { Notifications } from "../notifications"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import type { Hotbar } from "../../../common/hotbar-types"; -@observer -export class HotbarRemoveCommand extends React.Component { - constructor(props: {}) { - super(props); - makeObservable(this); - } +interface Dependencies { + closeCommandOverlay: () => void; + hotbarManager: { + hotbars: Hotbar[]; + getById: (id: string) => Hotbar | undefined; + remove: (hotbar: Hotbar) => void; + getDisplayLabel: (hotbar: Hotbar) => string; + }; +} - @computed get options() { - return HotbarStore.getInstance().hotbars.map((hotbar) => { - return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) }; - }); - } +const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarManager }: Dependencies) => { + const options = hotbarManager.hotbars.map(hotbar => ({ + value: hotbar.id, + label: hotbarManager.getDisplayLabel(hotbar), + })); - onChange(id: string): void { - const hotbarStore = HotbarStore.getInstance(); - const hotbar = hotbarStore.getById(id); - - CommandOverlay.close(); + const onChange = (id: string): void => { + const hotbar = hotbarManager.getById(id); if (!hotbar) { return; } - if (hotbarStore.hotbars.length === 1) { - Notifications.error("Can't remove the last hotbar"); - - return; - } - + closeCommandOverlay(); + // TODO: make confirm dialog injectable ConfirmDialog.open({ okButtonProps: { - label: `Remove Hotbar`, + label: "Remove Hotbar", primary: false, accent: true, }, - ok: () => { - hotbarStore.remove(hotbar); - }, + ok: () => hotbarManager.remove(hotbar), message: (

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

), }); - } + }; - render() { - return ( - onChange(v.value)} + components={{ DropdownIndicator: null, IndicatorSeparator: null }} + menuIsOpen={true} + options={options} + autoFocus={true} + escapeClearsValue={false} + placeholder="Remove hotbar" + /> + ); +}); + +export const HotbarRemoveCommand = withInjectables(NonInjectedHotbarRemoveCommand, { + getProps: (di, props) => ({ + closeCommandOverlay: di.inject(commandOverlayInjectable).close, + hotbarManager: di.inject(hotbarManagerInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/hotbar/hotbar-rename-command.tsx b/src/renderer/components/hotbar/hotbar-rename-command.tsx index 6d8784a941..d4b576e019 100644 --- a/src/renderer/components/hotbar/hotbar-rename-command.tsx +++ b/src/renderer/components/hotbar/hotbar-rename-command.tsx @@ -19,81 +19,61 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import React from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import { action, computed, makeObservable, observable } from "mobx"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { hotbarDisplayLabel } from "./hotbar-display-label"; -import { Input } from "../input"; -import { uniqueHotbarName } from "./hotbar-add-command"; -import { CommandOverlay } from "../command-palette"; +import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; +import { Input, InputValidator } from "../input"; +import type { Hotbar } from "../../../common/hotbar-types"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable"; -@observer -export class HotbarRenameCommand extends React.Component { - @observable hotbarId = ""; - @observable hotbarName = ""; - - constructor(props: {}) { - super(props); - makeObservable(this); - } - - @computed get options() { - return HotbarStore.getInstance().hotbars.map((hotbar) => { - return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) }; - }); - } - - @action onSelect = (id: string) => { - this.hotbarId = id; - this.hotbarName = HotbarStore.getInstance().getById(this.hotbarId).name; +interface Dependencies { + closeCommandOverlay: () => void; + hotbarManager: { + hotbars: Hotbar[]; + getById: (id: string) => Hotbar | undefined; + setHotbarName: (id: string, name: string) => void; + getDisplayLabel: (hotbar: Hotbar) => string; }; + uniqueHotbarName: InputValidator; +} - onSubmit = (name: string) => { +const NonInjectedHotbarRenameCommand = observer(({ closeCommandOverlay, hotbarManager, uniqueHotbarName }: Dependencies) => { + const [hotbarId, setHotbarId] = useState(""); + const [hotbarName, setHotbarName] = useState(""); + + const options = hotbarManager.hotbars.map(hotbar => ({ + value: hotbar.id, + label: hotbarManager.getDisplayLabel(hotbar), + })); + + const onSelect = (id: string) => { + setHotbarId(id); + setHotbarName(hotbarManager.getById(id).name); + }; + const onSubmit = (name: string) => { if (!name.trim()) { return; } - const hotbarStore = HotbarStore.getInstance(); - const hotbar = HotbarStore.getInstance().getById(this.hotbarId); - - if (!hotbar) { - return; - } - - hotbarStore.setHotbarName(this.hotbarId, name); - CommandOverlay.close(); + hotbarManager.setHotbarName(hotbarId, name); + closeCommandOverlay(); }; - renderHotbarList() { - return ( - <> - this.hotbarName = v} + value={hotbarName} + onChange={setHotbarName} placeholder="New hotbar name" autoFocus={true} theme="round-black" validators={uniqueHotbarName} - onSubmit={this.onSubmit} + onSubmit={onSubmit} showValidationLine={true} /> @@ -103,12 +83,25 @@ export class HotbarRenameCommand extends React.Component { ); } - render() { + return ( + this.onChange(v.value)} - components={{ DropdownIndicator: null, IndicatorSeparator: null }} - menuIsOpen={true} - options={this.options} - autoFocus={true} - escapeClearsValue={false} - placeholder="Switch to hotbar" /> - ); - } +interface HotbarManager { + hotbars: Hotbar[]; + setActiveHotbar: (id: string) => void; + getDisplayLabel: (hotbar: Hotbar) => string; } + +interface Dependencies { + hotbarManager: HotbarManager + commandOverlay: CommandOverlay; +} + +function getHotbarSwitchOptions(hotbarManager: HotbarManager) { + const options = hotbarManager.hotbars.map(hotbar => ({ + value: hotbar.id, + label: hotbarManager.getDisplayLabel(hotbar), + })); + + options.push({ value: addActionId, label: "Add hotbar ..." }); + + if (hotbarManager.hotbars.length > 1) { + options.push({ value: removeActionId, label: "Remove hotbar ..." }); + } + + options.push({ value: renameActionId, label: "Rename hotbar ..." }); + + return options; +} + +const NonInjectedHotbarSwitchCommand = observer(({ hotbarManager, commandOverlay }: Dependencies) => { + const options = getHotbarSwitchOptions(hotbarManager); + + const onChange = (idOrAction: string): void => { + switch (idOrAction) { + case addActionId: + return commandOverlay.open(); + case removeActionId: + return commandOverlay.open(); + case renameActionId: + return commandOverlay.open(); + default: + hotbarManager.setActiveHotbar(idOrAction); + commandOverlay.close(); + } + }; + + return ( +