diff --git a/Makefile b/Makefile index 883968feda..d9a5fe1a83 100644 --- a/Makefile +++ b/Makefile @@ -70,7 +70,7 @@ integration-win: binaries/client build-extension-types build-extensions .PHONY: build build: node_modules binaries/client yarn run npm:fix-build-version - $(MAKE) build-extensions + $(MAKE) build-extensions -B yarn run compile ifeq "$(DETECTED_OS)" "Windows" yarn run electron-builder --publish onTag --x64 --ia32 diff --git a/docs/README.md b/docs/README.md index f9ca9e6779..67369f86fc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,4 +51,4 @@ To provide feedback on the documentation or issues with the Lens Extension API, ## Downloading Lens -[Download Lens](https://github.com/lensapp/lens/releases) for macOS, Windows, or Linux. +[Download Lens](https://k8slens.dev/) for macOS, Windows, or Linux. diff --git a/docs/extensions/get-started/your-first-extension.md b/docs/extensions/get-started/your-first-extension.md index fa570e6932..c8c1167943 100644 --- a/docs/extensions/get-started/your-first-extension.md +++ b/docs/extensions/get-started/your-first-extension.md @@ -78,7 +78,7 @@ npm run dev You must restart Lens for the extension to load. After this initial restart, reload Lens and it will automatically pick up changes any time the extension rebuilds. -With Lens running, either connect to an existing cluster or create a new one - refer to the latest [Lens Documentation](https://docs.k8slens.dev/latest/clusters/adding-clusters) for details on how to add a cluster in Lens IDE. +With Lens running, either connect to an existing cluster or create a new one - refer to the latest [Lens Documentation](https://docs.k8slens.dev/main/catalog/) for details on how to add a cluster in Lens IDE. You will see the "Hello World" page in the left-side cluster menu. ## Develop the Extension diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 1bdd96e31a..27a0b508a8 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -43,10 +43,6 @@ describe("Lens cluster pages", () => { const ready = minikubeReady(TEST_NAMESPACE); let clusterAdded = false; - afterEach(() => { - clusterAdded = false; - }); - utils.describeIf(ready)("test common pages", () => { const addCluster = async () => { await waitForMinikubeDashboard(app); @@ -54,29 +50,29 @@ describe("Lens cluster pages", () => { await app.client.waitUntilTextExists("div.TableCell", "Ready"); }; - describe("cluster add", () => { - utils.beforeAllWrapped(async () => { - app = await utils.appStart(); - }); - - utils.afterAllWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); - - it("allows to add a cluster", async () => { - await addCluster(); - clusterAdded = true; - }); - }); - const appStartAddCluster = async () => { app = await utils.appStart(); await addCluster(); clusterAdded = true; }; + const tearDown = async () => { + await utils.tearDown(app); + clusterAdded = false; + }; + + describe("cluster add", () => { + utils.beforeAllWrapped(async () => { + app = await utils.appStart(); + }); + + utils.afterAllWrapped(tearDown); + + it("allows to add a cluster", async () => { + await addCluster(); + }); + }); + function getSidebarSelectors(itemId: string) { const root = `.SidebarItem[data-test-id="${itemId}"]`; @@ -88,12 +84,7 @@ describe("Lens cluster pages", () => { describe("cluster pages", () => { utils.beforeAllWrapped(appStartAddCluster); - - utils.afterAllWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); + utils.afterAllWrapped(tearDown); const tests: { drawer?: string @@ -378,12 +369,7 @@ describe("Lens cluster pages", () => { describe("viewing pod logs", () => { utils.beforeEachWrapped(appStartAddCluster); - - utils.afterEachWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); + utils.afterEachWrapped(tearDown); it(`shows a log for a pod`, async () => { expect(clusterAdded).toBe(true); @@ -413,10 +399,10 @@ describe("Lens cluster pages", () => { await app.client.waitForVisible(".Drawer"); const logsButton = "ul.KubeObjectMenu li.MenuItem i.Icon span[data-icon-name='subject']"; - + await app.client.waitForVisible(logsButton); await app.client.click(logsButton); - + // Check if controls are available await app.client.waitForVisible(".LogList .VirtualList"); await app.client.waitForVisible(".LogResourceSelector"); @@ -433,12 +419,7 @@ describe("Lens cluster pages", () => { describe("cluster operations", () => { utils.beforeEachWrapped(appStartAddCluster); - - utils.afterEachWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); + utils.afterEachWrapped(tearDown); it("shows default namespace", async () => { expect(clusterAdded).toBe(true); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index b509b3d342..d6f633e416 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -37,27 +37,27 @@ interface DoneCallback { * This is necessary because Jest doesn't do this correctly. * @param fn The function to call */ -export function wrapJestLifecycle(fn: () => Promise): (done: DoneCallback) => void { +export function wrapJestLifecycle(fn: () => Promise | void): (done: DoneCallback) => void { return function (done: DoneCallback) { - fn() + (async () => fn())() .then(() => done()) .catch(error => done.fail(error)); }; } -export function beforeAllWrapped(fn: () => Promise): void { +export function beforeAllWrapped(fn: () => Promise | void): void { beforeAll(wrapJestLifecycle(fn)); } -export function beforeEachWrapped(fn: () => Promise): void { +export function beforeEachWrapped(fn: () => Promise | void): void { beforeEach(wrapJestLifecycle(fn)); } -export function afterAllWrapped(fn: () => Promise): void { +export function afterAllWrapped(fn: () => Promise | void): void { afterAll(wrapJestLifecycle(fn)); } -export function afterEachWrapped(fn: () => Promise): void { +export function afterEachWrapped(fn: () => Promise | void): void { afterEach(wrapJestLifecycle(fn)); } @@ -105,7 +105,11 @@ export async function showCatalog(app: Application) { type AsyncPidGetter = () => Promise; -export async function tearDown(app: Application) { +export async function tearDown(app?: Application) { + if (!app?.isRunning()) { + return; + } + const pid = await (app.mainProcess.pid as any as AsyncPidGetter)(); await app.stop(); diff --git a/package.json b/package.json index c7def4e24d..ebe48438c4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.0.0-beta.10", + "version": "5.0.0-beta.12", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 92839bcb30..6d4198d8d7 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -95,7 +95,7 @@ describe("empty config", () => { mockFs(mockOpts); - await ClusterStore.createInstance().load(); + ClusterStore.createInstance(); }); afterEach(() => { @@ -203,7 +203,7 @@ describe("config with existing clusters", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -285,7 +285,7 @@ users: mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -344,7 +344,7 @@ describe("pre 2.0 config with an existing cluster", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -414,7 +414,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -456,7 +456,7 @@ describe("pre 2.6.0 config with a cluster icon", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -495,7 +495,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -531,7 +531,7 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 9f54e8731d..267fbe3e8e 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -23,7 +23,7 @@ import mockFs from "mock-fs"; import { ClusterStore } from "../cluster-store"; import { HotbarStore } from "../hotbar-store"; -jest.mock("../../renderer/api/catalog-entity-registry", () => ({ +jest.mock("../../main/catalog/catalog-entity-registry", () => ({ catalogEntityRegistry: { items: [ { @@ -39,7 +39,14 @@ jest.mock("../../renderer/api/catalog-entity-registry", () => ({ name: "my_shiny_cluster", source: "remote" } - } + }, + { + metadata: { + uid: "catalog-entity", + name: "Catalog", + source: "app" + }, + }, ] } })); @@ -120,29 +127,31 @@ jest.mock("electron", () => { describe("HotbarStore", () => { beforeEach(() => { - ClusterStore.resetInstance(); + mockFs({ + "tmp": { + "lens-hotbar-store.json": JSON.stringify({}) + } + }); ClusterStore.createInstance(); - - HotbarStore.resetInstance(); - mockFs({ tmp: { "lens-hotbar-store.json": "{}" } }); + HotbarStore.createInstance(); }); afterEach(() => { + ClusterStore.resetInstance(); + HotbarStore.resetInstance(); mockFs.restore(); }); describe("load", () => { it("loads one hotbar by default", () => { - HotbarStore.createInstance().load(); expect(HotbarStore.getInstance().hotbars.length).toEqual(1); }); }); describe("add", () => { it("adds a hotbar", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.add({ name: "hottest" }); expect(hotbarStore.hotbars.length).toEqual(2); }); @@ -150,23 +159,20 @@ describe("HotbarStore", () => { describe("hotbar items", () => { it("initially creates 12 empty cells", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); expect(hotbarStore.getActive().items.length).toEqual(12); }); it("initially adds catalog entity as first item", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); expect(hotbarStore.getActive().items[0].entity.name).toEqual("Catalog"); }); it("adds items", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); const items = hotbarStore.getActive().items.filter(Boolean); @@ -174,9 +180,8 @@ describe("HotbarStore", () => { }); it("removes items", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.removeFromHotbar("test"); hotbarStore.removeFromHotbar("catalog-entity"); @@ -186,9 +191,8 @@ describe("HotbarStore", () => { }); it("does nothing if removing with invalid uid", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.removeFromHotbar("invalid uid"); const items = hotbarStore.getActive().items.filter(Boolean); @@ -197,9 +201,8 @@ describe("HotbarStore", () => { }); it("moves item to empty cell", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); @@ -213,9 +216,8 @@ describe("HotbarStore", () => { }); it("moves items down", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); @@ -229,9 +231,8 @@ describe("HotbarStore", () => { }); it("moves items up", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); @@ -245,9 +246,8 @@ describe("HotbarStore", () => { }); it("does nothing when item moved to same cell", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.restackItems(1, 1); @@ -255,9 +255,8 @@ describe("HotbarStore", () => { }); it("new items takes first empty cell", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(awsCluster); hotbarStore.restackItems(0, 3); @@ -268,13 +267,13 @@ describe("HotbarStore", () => { it("throws if invalid arguments provided", () => { // Prevent writing to stderr during this render. - const err = console.error; + const { error, warn } = console; console.error = jest.fn(); + console.warn = jest.fn(); - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); expect(() => hotbarStore.restackItems(-5, 0)).toThrow(); @@ -283,7 +282,8 @@ describe("HotbarStore", () => { expect(() => hotbarStore.restackItems(11, 112)).toThrow(); // Restore writing to stderr. - console.error = err; + console.error = error; + console.warn = warn; }); }); @@ -354,7 +354,7 @@ describe("HotbarStore", () => { mockFs(mockOpts); - return HotbarStore.createInstance().load(); + HotbarStore.createInstance(); }); afterEach(() => { diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 465c598bb0..b1b3f60ce6 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -52,7 +52,7 @@ describe("user store tests", () => { (UserStore.createInstance() as any).refreshNewContexts = jest.fn(() => Promise.resolve()); - return UserStore.getInstance().load(); + UserStore.getInstance(); }); afterEach(() => { @@ -81,10 +81,8 @@ describe("user store tests", () => { it("correctly resets theme to default value", async () => { const us = UserStore.getInstance(); - us.isLoaded = true; - us.colorTheme = "some other theme"; - await us.resetTheme(); + us.resetTheme(); expect(us.colorTheme).toBe(UserStore.defaultTheme); }); @@ -111,7 +109,7 @@ describe("user store tests", () => { } }); - return UserStore.createInstance().load(); + UserStore.createInstance(); }); afterEach(() => { diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 842e0304b4..d30725d6bb 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -23,41 +23,42 @@ import path from "path"; import Config from "conf"; import type { Options as ConfOptions } from "conf/dist/source/types"; import { app, ipcMain, ipcRenderer, remote } from "electron"; -import { IReactionOptions, makeObservable, observable, reaction, runInAction, when } from "mobx"; +import { IReactionOptions, makeObservable, reaction, runInAction } from "mobx"; import { getAppVersion, Singleton, toJS, Disposer } from "./utils"; import logger from "../main/logger"; import { broadcastMessage, ipcMainOn, ipcRendererOn } from "./ipc"; import isEqual from "lodash/isEqual"; -export interface BaseStoreParams extends ConfOptions { - autoLoad?: boolean; - syncEnabled?: boolean; +export interface BaseStoreParams extends ConfOptions { syncOptions?: IReactionOptions; } /** * Note: T should only contain base JSON serializable types. */ -export abstract class BaseStore extends Singleton { +export abstract class BaseStore extends Singleton { protected storeConfig?: Config; protected syncDisposers: Disposer[] = []; - @observable isLoaded = false; - - get whenLoaded() { - return when(() => this.isLoaded); - } - - protected constructor(protected params: BaseStoreParams) { + protected constructor(protected params: BaseStoreParams) { super(); makeObservable(this); + } - this.params = { - autoLoad: false, - syncEnabled: true, - ...params, - }; - this.init(); + /** + * This must be called after the last child's constructor is finished (or just before it finishes) + */ + load() { + this.storeConfig = new Config({ + ...this.params, + projectName: "lens", + projectVersion: getAppVersion(), + cwd: this.cwd(), + }); + + logger.info(`[STORE]: LOADED from ${this.path}`); + this.fromStore(this.storeConfig.store); + this.enableSync(); } get name() { @@ -76,31 +77,6 @@ export abstract class BaseStore extends Singleton { return this.storeConfig?.path || ""; } - protected async init() { - if (this.params.autoLoad) { - await this.load(); - } - - if (this.params.syncEnabled) { - await this.whenLoaded; - this.enableSync(); - } - } - - async load() { - const { autoLoad, syncEnabled, ...confOptions } = this.params; - - this.storeConfig = new Config({ - ...confOptions, - projectName: "lens", - projectVersion: getAppVersion(), - cwd: this.cwd(), - }); - logger.info(`[STORE]: LOADED from ${this.path}`); - this.fromStore(this.storeConfig.store); - this.isLoaded = true; - } - protected cwd() { return (app || remote.app).getPath("userData"); } @@ -159,10 +135,7 @@ export abstract class BaseStore extends Singleton { protected applyWithoutSync(callback: () => void) { this.disableSync(); runInAction(callback); - - if (this.params.syncEnabled) { - this.enableSync(); - } + this.enableSync(); } protected onSync(model: T) { diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 26f0e24370..baa7fc1409 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -110,6 +110,8 @@ export interface ClusterPrometheusPreferences { }; } +const initialStates = "cluster:states"; + export class ClusterStore extends BaseStore { private static StateChannel = "cluster:state"; @@ -121,8 +123,8 @@ export class ClusterStore extends BaseStore { return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId); } - @observable clusters = observable.map(); - @observable removedClusters = observable.map(); + clusters = observable.map(); + removedClusters = observable.map(); protected disposer = disposer(); @@ -137,31 +139,27 @@ export class ClusterStore extends BaseStore { }); makeObservable(this); - + this.load(); this.pushStateToViewsAutomatically(); } - async load() { - const initialStates = "cluster:states"; + async loadInitialOnRenderer() { + logger.info("[CLUSTER-STORE] requesting initial state sync"); - await super.load(); - - if (ipcRenderer) { - logger.info("[CLUSTER-STORE] requesting initial state sync"); - - for (const { id, state } of await requestMain(initialStates)) { - this.getById(id)?.setState(state); - } - } else if (ipcMain) { - ipcMainHandle(initialStates, () => { - return this.clustersList.map(cluster => ({ - id: cluster.id, - state: cluster.getState(), - })); - }); + for (const { id, state } of await requestMain(initialStates)) { + this.getById(id)?.setState(state); } } + provideInitialFromMain() { + ipcMainHandle(initialStates, () => { + return this.clustersList.map(cluster => ({ + id: cluster.id, + state: cluster.getState(), + })); + }); + } + protected pushStateToViewsAutomatically() { if (ipcMain) { this.disposer.push( diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index ab41583b0a..b90a007b34 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -26,7 +26,6 @@ import * as uuid from "uuid"; import isNull from "lodash/isNull"; import { toJS } from "./utils"; import { CatalogEntity } from "./catalog"; -import { catalogEntity } from "../main/catalog-sources/general"; export interface HotbarItem { entity: { @@ -39,16 +38,12 @@ export interface HotbarItem { } } -export interface Hotbar { - id: string; - name: string; - items: HotbarItem[]; -} +export type Hotbar = Required; export interface HotbarCreateOptions { id?: string; name: string; - items?: HotbarItem[]; + items?: (HotbarItem | null)[]; } export interface HotbarStoreModel { @@ -72,6 +67,7 @@ export class HotbarStore extends BaseStore { migrations, }); makeObservable(this); + this.load(); } get activeHotbarId() { @@ -92,30 +88,13 @@ export class HotbarStore extends BaseStore { return this.hotbarIndex(this.activeHotbarId); } - get defaultHotbarInitialItems() { - const { metadata: { uid, name, source } } = catalogEntity; - const initialItem = { entity: { uid, name, source }}; - - return [ - initialItem, - ...Array.from(Array(defaultHotbarCells - 1).fill(null)) - ]; - } - - get initialItems() { + static getInitialItems() { return [...Array.from(Array(defaultHotbarCells).fill(null))]; } - @action protected async fromStore(data: Partial = {}) { - if (data.hotbars?.length === 0) { - this.hotbars = [{ - id: uuid.v4(), - name: "Default", - items: this.defaultHotbarInitialItems, - }]; - } else { - this.hotbars = data.hotbars; - } + @action + protected async fromStore(data: Partial = {}) { + this.hotbars = data.hotbars; if (data.activeHotbarId) { if (this.getById(data.activeHotbarId)) { @@ -140,18 +119,19 @@ export class HotbarStore extends BaseStore { return this.hotbars.find((hotbar) => hotbar.id === id); } - add(data: HotbarCreateOptions) { + @action + add(data: HotbarCreateOptions, { setActive = false } = {}) { const { id = uuid.v4(), - items = this.initialItems, + items = HotbarStore.getInitialItems(), name, } = data; - const hotbar = { id, name, items }; + this.hotbars.push({ id, name, items }); - this.hotbars.push(hotbar as Hotbar); - - return hotbar as Hotbar; + if (setActive) { + this._activeHotbarId = id; + } } @action diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index fe05783947..c387b18a55 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -31,6 +31,7 @@ import { ExtensionLoader } from "../../extensions/extension-loader"; import type { LensExtension } from "../../extensions/lens-extension"; import type { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler"; import { when } from "mobx"; +import { ipcRenderer } from "electron"; // IPC channel for protocol actions. Main broadcasts the open-url events to this channel. export const ProtocolHandlerIpcPrefix = "protocol-handler"; @@ -182,7 +183,10 @@ export abstract class LensProtocolRouter extends Singleton { const extensionLoader = ExtensionLoader.getInstance(); try { - await when(() => !!extensionLoader.getInstanceByName(name), { timeout: 5_000 }); + /** + * Note, if `getInstanceByName` returns `null` that means we won't be getting an instance + */ + await when(() => extensionLoader.getInstanceByName(name) !== (void 0), { timeout: 5_000 }); } catch(error) { logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed (${error})`); @@ -191,7 +195,13 @@ export abstract class LensProtocolRouter extends Singleton { const extension = extensionLoader.getInstanceByName(name); - if (!ExtensionsStore.getInstance().isEnabled(extension.id)) { + if (!extension) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but does not have a class for ${ipcRenderer ? "renderer" : "main"}`); + + return name; + } + + if (!ExtensionsStore.getInstance().isEnabled(extension)) { logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`); return name; diff --git a/src/common/user-store.ts b/src/common/user-store.ts index f7ab221bb8..c1e956b331 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -68,7 +68,10 @@ export class UserStore extends BaseStore { configName: "lens-user-store", migrations, }); + makeObservable(this); + fileNameMigration(); + this.load(); } @observable lastSeenAppVersion = "0.0.0"; @@ -101,33 +104,6 @@ export class UserStore extends BaseStore { [path.join(os.homedir(), ".kube"), {}] ]); - async load(): Promise { - /** - * This has to be here before the call to `new Config` in `super.load()` - * as we have to make sure that file is in the expected place for that call - */ - await fileNameMigration(); - await super.load(); - - if (app) { - // track telemetry availability - reaction(() => this.allowTelemetry, allowed => { - appEventBus.emit({ name: "telemetry", action: allowed ? "enabled" : "disabled" }); - }); - - // open at system start-up - reaction(() => this.openAtLogin, openAtLogin => { - app.setLoginItemSettings({ - openAtLogin, - openAsHidden: true, - args: ["--hidden"] - }); - }, { - fireImmediately: true, - }); - } - } - @computed get isNewVersion() { return semver.gt(getAppVersion(), this.lastSeenAppVersion); } @@ -136,6 +112,24 @@ export class UserStore extends BaseStore { return this.shell || process.env.SHELL || process.env.PTYSHELL; } + startMainReactions() { + // track telemetry availability + reaction(() => this.allowTelemetry, allowed => { + appEventBus.emit({ name: "telemetry", action: allowed ? "enabled" : "disabled" }); + }); + + // open at system start-up + reaction(() => this.openAtLogin, openAtLogin => { + app.setLoginItemSettings({ + openAtLogin, + openAsHidden: true, + args: ["--hidden"] + }); + }, { + fireImmediately: true, + }); + } + /** * Checks if a column (by ID) for a table (by ID) is configured to be hidden * @param tableId The ID of the table to be checked against @@ -165,8 +159,7 @@ export class UserStore extends BaseStore { } @action - async resetTheme() { - await this.whenLoaded; + resetTheme() { this.colorTheme = UserStore.defaultTheme; } diff --git a/src/common/weblink-store.ts b/src/common/weblink-store.ts index 0f66725545..7b3a62f946 100644 --- a/src/common/weblink-store.ts +++ b/src/common/weblink-store.ts @@ -55,6 +55,7 @@ export class WeblinkStore extends BaseStore { migrations, }); makeObservable(this); + this.load(); } @action protected async fromStore(data: Partial = {}) { diff --git a/src/extensions/__tests__/extension-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index 347487e556..6db33acf19 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -39,6 +39,12 @@ jest.mock("../extension-installer", () => ({ installPackage: jest.fn() } })); +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), + }, +})); console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index ea34798271..9f2fe15067 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -357,8 +357,8 @@ export class ExtensionDiscovery extends Singleton { protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise { try { const manifest = await fse.readJson(manifestPath) as LensExtensionManifest; - const installedManifestPath = this.getInstalledManifestPath(manifest.name); - const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath); + const id = this.getInstalledManifestPath(manifest.name); + const isEnabled = ExtensionsStore.getInstance().isEnabled({ id, isBundled }); const extensionDir = path.dirname(manifestPath); const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`); const absolutePath = (isProduction && await fse.pathExists(npmPackage)) ? npmPackage : extensionDir; @@ -369,9 +369,9 @@ export class ExtensionDiscovery extends Singleton { } return { - id: installedManifestPath, + id, absolutePath, - manifestPath: installedManifestPath, + manifestPath: id, manifest, isBundled, isEnabled, diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 68e54828dc..be7c9e5c17 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -48,6 +48,13 @@ export class ExtensionLoader extends Singleton { protected extensions = observable.map(); protected instances = observable.map(); + /** + * This is the set of extensions that don't come with either + * - Main.LensExtension when running in the main process + * - Renderer.LensExtension when running in the renderer process + */ + protected nonInstancesByName = observable.set(); + /** * This is updated by the `observe` in the constructor. DO NOT write directly to it */ @@ -77,7 +84,7 @@ export class ExtensionLoader extends Singleton { if (this.instancesByName.has(change.newValue.name)) { throw new TypeError("Extension names must be unique"); } - + this.instancesByName.set(change.newValue.name, change.newValue); break; case "delete": @@ -101,7 +108,20 @@ export class ExtensionLoader extends Singleton { return extensions; } - getInstanceByName(name: string): LensExtension | undefined { + /** + * Get the extension instance by its manifest name + * @param name The name of the extension + * @returns one of the following: + * - the instance of `Main.LensExtension` on the main process if created + * - the instance of `Renderer.LensExtension` on the renderer process if created + * - `null` if no class definition is provided for the current process + * - `undefined` if the name is not known about + */ + getInstanceByName(name: string): LensExtension | null | undefined { + if (this.nonInstancesByName.has(name)) { + return null; + } + return this.instancesByName.get(name); } @@ -124,7 +144,7 @@ export class ExtensionLoader extends Singleton { await this.initMain(); } - await Promise.all([this.whenLoaded, ExtensionsStore.getInstance().whenLoaded]); + await Promise.all([this.whenLoaded]); // broadcasting extensions between main/renderer processes reaction(() => this.toJSON(), () => this.broadcastExtensions(), { @@ -145,6 +165,7 @@ export class ExtensionLoader extends Singleton { this.extensions.set(extension.id, extension); } + @action removeInstance(lensExtensionId: LensExtensionId) { logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); const instance = this.instances.get(lensExtensionId); @@ -157,6 +178,7 @@ export class ExtensionLoader extends Singleton { instance.disable(); this.events.emit("remove", instance); this.instances.delete(lensExtensionId); + this.nonInstancesByName.delete(instance.name); } catch (error) { logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); } @@ -302,13 +324,14 @@ export class ExtensionLoader extends Singleton { protected autoInitExtensions(register: (ext: LensExtension) => Promise) { return reaction(() => this.toJSON(), installedExtensions => { for (const [extId, extension] of installedExtensions) { - const alreadyInit = this.instances.has(extId); + const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); if (extension.isCompatible && extension.isEnabled && !alreadyInit) { try { const LensExtensionClass = this.requireExtension(extension); if (!LensExtensionClass) { + this.nonInstancesByName.add(extension.manifest.name); continue; } diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index 2c5eb44262..6b4e9c5489 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -26,13 +26,13 @@ import type { LensExtension } from "./lens-extension"; export abstract class ExtensionStore extends BaseStore { protected extension: LensExtension; - async loadExtension(extension: LensExtension) { + loadExtension(extension: LensExtension) { this.extension = extension; return super.load(); } - async load() { + load() { if (!this.extension) { return; } return super.load(); diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 86a90e0238..be40181242 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -39,6 +39,7 @@ export class ExtensionsStore extends BaseStore { configName: "lens-extensions", }); makeObservable(this); + this.load(); } @computed @@ -50,12 +51,10 @@ export class ExtensionsStore extends BaseStore { protected state = observable.map(); - isEnabled(extId: LensExtensionId): boolean { - const state = this.state.get(extId); - + isEnabled({ id, isBundled }: { id: string, isBundled: boolean }): boolean { // By default false, so that copied extensions are disabled by default. // If user installs the extension from the UI, the Extensions component will specifically enable it. - return Boolean(state?.enabled); + return isBundled || Boolean(this.state.get(id)?.enabled); } @action diff --git a/src/main/__test__/context-handler.test.ts b/src/main/__test__/context-handler.test.ts index ad3b42f5d2..0ba3a2d028 100644 --- a/src/main/__test__/context-handler.test.ts +++ b/src/main/__test__/context-handler.test.ts @@ -22,6 +22,14 @@ import { UserStore } from "../../common/user-store"; import { ContextHandler } from "../context-handler"; import { PrometheusProvider, PrometheusProviderRegistry, PrometheusService } from "../prometheus"; +import mockFs from "mock-fs"; + +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), + }, +})); enum ServiceResult { Success, @@ -70,6 +78,10 @@ function getHandler() { describe("ContextHandler", () => { beforeEach(() => { + mockFs({ + "tmp": {} + }); + PrometheusProviderRegistry.createInstance(); UserStore.createInstance(); }); @@ -77,6 +89,7 @@ describe("ContextHandler", () => { afterEach(() => { PrometheusProviderRegistry.resetInstance(); UserStore.resetInstance(); + mockFs.restore(); }); describe("getPrometheusService", () => { diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index 5006859637..e0dd7f73c6 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -46,7 +46,8 @@ jest.mock("winston", () => ({ jest.mock("electron", () => ({ app: { - getPath: () => "/foo", + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), }, })); @@ -77,8 +78,6 @@ const mockWaitUntilUsed = waitUntilUsed as jest.MockedFunction { beforeEach(() => { jest.clearAllMocks(); - UserStore.resetInstance(); - UserStore.createInstance(); const mockMinikubeConfig = { "minikube-config.yml": JSON.stringify({ @@ -102,13 +101,16 @@ describe("kube auth proxy tests", () => { }], kind: "Config", preferences: {}, - }) + }), + "tmp": {}, }; mockFs(mockMinikubeConfig); + UserStore.createInstance(); }); afterEach(() => { + UserStore.resetInstance(); mockFs.restore(); }); diff --git a/src/main/catalog-sources/general.ts b/src/main/catalog-sources/general.ts index 9f64f46b6d..6b16984d1d 100644 --- a/src/main/catalog-sources/general.ts +++ b/src/main/catalog-sources/general.ts @@ -67,6 +67,6 @@ const generalEntities = observable([ preferencesEntity ]); -export function initializeGeneralEntities() { +export function syncGeneralEntities() { catalogEntityRegistry.addObservableSource("lens:general", generalEntities); } diff --git a/src/main/catalog-sources/index.ts b/src/main/catalog-sources/index.ts index 17f383185b..70c2d073fb 100644 --- a/src/main/catalog-sources/index.ts +++ b/src/main/catalog-sources/index.ts @@ -19,6 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export { initializeWeblinks } from "./weblinks"; +export { syncWeblinks } from "./weblinks"; export { KubeconfigSyncManager } from "./kubeconfig-sync"; -export { initializeGeneralEntities } from "./general"; +export { syncGeneralEntities } from "./general"; diff --git a/src/main/catalog-sources/weblinks.ts b/src/main/catalog-sources/weblinks.ts index b6b1ec5a39..2593fedc7c 100644 --- a/src/main/catalog-sources/weblinks.ts +++ b/src/main/catalog-sources/weblinks.ts @@ -69,7 +69,7 @@ async function validateLink(link: WebLink) { } -export function initializeWeblinks() { +export function syncWeblinks() { const weblinkStore = WeblinkStore.getInstance(); const weblinks = observable.array(defaultLinks); diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index b15cce8ae7..fd7ef779dd 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -104,6 +104,7 @@ export class ClusterManager extends Singleton { this.updateEntityStatus(entity, cluster); entity.metadata.labels = Object.assign({}, cluster.labels, entity.metadata.labels); + entity.metadata.labels.distro = cluster.distribution; if (cluster.preferences?.clusterName) { entity.metadata.name = cluster.preferences.clusterName; diff --git a/src/main/extension-filesystem.ts b/src/main/extension-filesystem.ts index 402619bdc9..46afe45d6e 100644 --- a/src/main/extension-filesystem.ts +++ b/src/main/extension-filesystem.ts @@ -42,6 +42,7 @@ export class FilesystemProvisionerStore extends BaseStore { accessPropertiesByDotNotation: false, // To make dots safe in cluster context names }); makeObservable(this); + this.load(); } /** diff --git a/src/main/index.ts b/src/main/index.ts index f4c4351df9..0599f820cb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -48,11 +48,17 @@ import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { pushCatalogToRenderer } from "./catalog-pusher"; import { catalogEntityRegistry } from "./catalog"; import { HelmRepoManager } from "./helm/helm-repo-manager"; -import { KubeconfigSyncManager } from "./catalog-sources"; +import { syncGeneralEntities, syncWeblinks, KubeconfigSyncManager } from "./catalog-sources"; import { handleWsUpgrade } from "./proxy/ws-upgrade"; import configurePackages from "../common/configure-packages"; import { PrometheusProviderRegistry } from "./prometheus"; import * as initializers from "./initializers"; +import { ClusterStore } from "../common/cluster-store"; +import { HotbarStore } from "../common/hotbar-store"; +import { UserStore } from "../common/user-store"; +import { WeblinkStore } from "../common/weblink-store"; +import { ExtensionsStore } from "../extensions/extensions-store"; +import { FilesystemProvisionerStore } from "./extension-filesystem"; const workingDir = path.join(app.getPath("appData"), appName); const cleanup = disposer(); @@ -123,8 +129,23 @@ app.on("ready", async () => { PrometheusProviderRegistry.createInstance(); initializers.initPrometheusProviderRegistry(); - await initializers.initializeStores(); - initializers.initializeWeblinks(); + /** + * The following sync MUST be done before HotbarStore creation, because that + * store has migrations that will remove items that previous migrations add + * if this is not presant + */ + syncGeneralEntities(); + + logger.info("💾 Loading stores"); + + UserStore.createInstance().startMainReactions(); + ClusterStore.createInstance().provideInitialFromMain(); + HotbarStore.createInstance(); + ExtensionsStore.createInstance(); + FilesystemProvisionerStore.createInstance(); + WeblinkStore.createInstance(); + + syncWeblinks(); HelmRepoManager.createInstance(); // create the instance @@ -182,7 +203,6 @@ app.on("ready", async () => { ipcMainOn(IpcRendererNavigationEvents.LOADED, () => { cleanup.push(pushCatalogToRenderer(catalogEntityRegistry)); KubeconfigSyncManager.getInstance().startSync(); - initializers.initializeGeneralEntities(); startUpdateChecking(); LensProtocolRouterMain.getInstance().rendererLoaded = true; }); diff --git a/src/main/initializers/general-entities.ts b/src/main/initializers/general-entities.ts deleted file mode 100644 index b2e39d8a39..0000000000 --- a/src/main/initializers/general-entities.ts +++ /dev/null @@ -1,22 +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. - */ - -export { initializeGeneralEntities } from "../catalog-sources"; diff --git a/src/main/initializers/index.ts b/src/main/initializers/index.ts index 6cd9330bac..526b61d4ed 100644 --- a/src/main/initializers/index.ts +++ b/src/main/initializers/index.ts @@ -22,6 +22,3 @@ export * from "./registries"; export * from "./metrics-providers"; export * from "./ipc"; -export * from "./weblinks"; -export * from "./stores"; -export * from "./general-entities"; diff --git a/src/main/initializers/stores.ts b/src/main/initializers/stores.ts deleted file mode 100644 index 4ea6322457..0000000000 --- a/src/main/initializers/stores.ts +++ /dev/null @@ -1,48 +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 { HotbarStore } from "../../common/hotbar-store"; -import { ClusterStore } from "../../common/cluster-store"; -import { UserStore } from "../../common/user-store"; -import { ExtensionsStore } from "../../extensions/extensions-store"; -import { FilesystemProvisionerStore } from "../extension-filesystem"; -import { WeblinkStore } from "../../common/weblink-store"; -import logger from "../logger"; - -export async function initializeStores() { - const userStore = UserStore.createInstance(); - const clusterStore = ClusterStore.createInstance(); - const hotbarStore = HotbarStore.createInstance(); - const extensionsStore = ExtensionsStore.createInstance(); - const filesystemStore = FilesystemProvisionerStore.createInstance(); - const weblinkStore = WeblinkStore.createInstance(); - - logger.info("💾 Loading stores"); - // preload - await Promise.all([ - userStore.load(), - clusterStore.load(), - hotbarStore.load(), - extensionsStore.load(), - filesystemStore.load(), - weblinkStore.load() - ]); -} diff --git a/src/main/initializers/weblinks.ts b/src/main/initializers/weblinks.ts deleted file mode 100644 index 2bd65d0894..0000000000 --- a/src/main/initializers/weblinks.ts +++ /dev/null @@ -1,22 +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. - */ - -export { initializeWeblinks } from "../catalog-sources"; diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index 42e9bda3c6..1216403838 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -28,9 +28,17 @@ import { LensExtension } from "../../../extensions/main-api"; import { ExtensionLoader } from "../../../extensions/extension-loader"; import { ExtensionsStore } from "../../../extensions/extensions-store"; import { LensProtocolRouterMain } from "../router"; +import mockFs from "mock-fs"; jest.mock("../../../common/ipc"); +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), + }, +})); + function throwIfDefined(val: any): void { if (val != null) { throw val; @@ -39,6 +47,9 @@ function throwIfDefined(val: any): void { describe("protocol router tests", () => { beforeEach(() => { + mockFs({ + "tmp": {} + }); ExtensionsStore.createInstance(); ExtensionLoader.createInstance(); @@ -53,23 +64,24 @@ describe("protocol router tests", () => { ExtensionsStore.resetInstance(); ExtensionLoader.resetInstance(); LensProtocolRouterMain.resetInstance(); + mockFs.restore(); }); - it("should throw on non-lens URLS", () => { + it("should throw on non-lens URLS", async () => { try { const lpr = LensProtocolRouterMain.getInstance(); - expect(lpr.route("https://google.ca")).toBeUndefined(); + expect(await lpr.route("https://google.ca")).toBeUndefined(); } catch (error) { expect(error).toBeInstanceOf(Error); } }); - it("should throw when host not internal or extension", () => { + it("should throw when host not internal or extension", async () => { try { const lpr = LensProtocolRouterMain.getInstance(); - expect(lpr.route("lens://foobar")).toBeUndefined(); + expect(await lpr.route("lens://foobar")).toBeUndefined(); } catch (error) { expect(error).toBeInstanceOf(Error); } @@ -102,13 +114,13 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/", noop); try { - expect(lpr.route("lens://app")).toBeUndefined(); + expect(await lpr.route("lens://app")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } try { - expect(lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); + expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -118,14 +130,14 @@ describe("protocol router tests", () => { expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched"); }); - it("should call handler if matches", () => { + it("should call handler if matches", async () => { const lpr = LensProtocolRouterMain.getInstance(); let called = false; lpr.addInternalHandler("/page", () => { called = true; }); try { - expect(lpr.route("lens://app/page")).toBeUndefined(); + expect(await lpr.route("lens://app/page")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -134,7 +146,7 @@ describe("protocol router tests", () => { expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page", "matched"); }); - it("should call most exact handler", () => { + it("should call most exact handler", async () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -142,7 +154,7 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; }); try { - expect(lpr.route("lens://app/page/foo")).toBeUndefined(); + expect(await lpr.route("lens://app/page/foo")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -182,7 +194,7 @@ describe("protocol router tests", () => { (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); try { - expect(lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); + expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -250,7 +262,7 @@ describe("protocol router tests", () => { (ExtensionsStore.getInstance() as any).state.set("icecream", { enabled: true, name: "icecream" }); try { - expect(lpr.route("lens://extension/icecream/page")).toBeUndefined(); + expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -267,7 +279,7 @@ describe("protocol router tests", () => { expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError(); }); - it("should call most exact handler with 3 found handlers", () => { + it("should call most exact handler with 3 found handlers", async () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -277,7 +289,7 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/bar", () => { called = 4; }); try { - expect(lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -286,7 +298,7 @@ describe("protocol router tests", () => { expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat", "matched"); }); - it("should call most exact handler with 2 found handlers", () => { + it("should call most exact handler with 2 found handlers", async () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -295,7 +307,7 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/bar", () => { called = 4; }); try { - expect(lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/router.ts index 2dc2665341..fe178705e7 100644 --- a/src/main/protocol-handler/router.ts +++ b/src/main/protocol-handler/router.ts @@ -73,7 +73,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { * This will send an IPC message to the renderer router to do the same * in the renderer. */ - public route(rawUrl: string) { + public async route(rawUrl: string) { try { const url = new URLParse(rawUrl, true); @@ -89,7 +89,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { if (routeInternally) { this._routeToInternal(url); } else { - this._routeToExtension(url); + await this._routeToExtension(url); } } catch (error) { broadcastMessage(ProtocolHandlerInvalid, error.toString(), rawUrl); diff --git a/src/migrations/cluster-store/5.0.0-beta.10.ts b/src/migrations/cluster-store/5.0.0-beta.10.ts index daea58279b..3a2b5bdfe3 100644 --- a/src/migrations/cluster-store/5.0.0-beta.10.ts +++ b/src/migrations/cluster-store/5.0.0-beta.10.ts @@ -23,7 +23,7 @@ import path from "path"; import { app } from "electron"; import fse from "fs-extra"; import type { ClusterModel } from "../../common/cluster-store"; -import { MigrationDeclaration, migrationLog } from "../helpers"; +import type { MigrationDeclaration } from "../helpers"; interface Pre500WorkspaceStoreModel { workspaces: { @@ -36,7 +36,7 @@ export default { version: "5.0.0-beta.10", run(store) { const userDataPath = app.getPath("userData"); - + try { const workspaceData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); const workspaces = new Map(); // mapping from WorkspaceId to name @@ -45,8 +45,6 @@ export default { workspaces.set(id, name); } - migrationLog("workspaces", JSON.stringify([...workspaces.entries()])); - const clusters: ClusterModel[] = store.get("clusters"); for (const cluster of clusters) { @@ -58,8 +56,6 @@ export default { store.set("clusters", clusters); } catch (error) { - migrationLog("error", error.path); - if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { // ignore lens-workspace-store.json being missing throw error; diff --git a/src/migrations/hotbar-store/5.0.0-alpha.0.ts b/src/migrations/hotbar-store/5.0.0-alpha.0.ts index 7cd8011414..1a61ce8ef3 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.0.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.0.ts @@ -20,34 +20,24 @@ */ // Cleans up a store that had the state related data stored -import type { Hotbar } from "../../common/hotbar-store"; -import { ClusterStore } from "../../common/cluster-store"; +import { Hotbar, HotbarStore } from "../../common/hotbar-store"; import * as uuid from "uuid"; import type { MigrationDeclaration } from "../helpers"; +import { catalogEntity } from "../../main/catalog-sources/general"; export default { version: "5.0.0-alpha.0", run(store) { - const hotbars: Hotbar[] = []; + const hotbar: Hotbar = { + id: uuid.v4(), + name: "default", + items: HotbarStore.getInitialItems(), + }; - ClusterStore.getInstance().clustersList.forEach((cluster: any) => { - const name = cluster.workspace; + const { metadata: { uid, name, source } } = catalogEntity; - if (!name) return; + hotbar.items[0] = { entity: { uid, name, source } }; - let hotbar = hotbars.find((h) => h.name === name); - - if (!hotbar) { - hotbar = { id: uuid.v4(), name, items: [] }; - hotbars.push(hotbar); - } - - hotbar.items.push({ - entity: { uid: cluster.id }, - params: {} - }); - }); - - store.set("hotbars", hotbars); + store.set("hotbars", [hotbar]); } } as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-beta.10.ts b/src/migrations/hotbar-store/5.0.0-beta.10.ts index fe1b06f320..1586f5c700 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.10.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.10.ts @@ -19,12 +19,15 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { createHash } from "crypto"; import { app } from "electron"; import fse from "fs-extra"; +import { isNull } from "lodash"; import path from "path"; import * as uuid from "uuid"; import type { ClusterStoreModel } from "../../common/cluster-store"; -import { defaultHotbarCells, Hotbar } from "../../common/hotbar-store"; +import { defaultHotbarCells, Hotbar, HotbarStore } from "../../common/hotbar-store"; +import { catalogEntity } from "../../main/catalog-sources/general"; import type { MigrationDeclaration } from "../helpers"; interface Pre500WorkspaceStoreModel { @@ -49,7 +52,19 @@ export default { workspaceHotbars.set(id, { id: uuid.v4(), // don't use the old IDs as they aren't necessarily UUIDs items: [], + name: `Workspace: ${name}`, + }); + } + + { + // grab the default named hotbar or the first. + const defaultHotbarIndex = Math.max(0, hotbars.findIndex(hotbar => hotbar.name === "default")); + const [{ name, id, items }] = hotbars.splice(defaultHotbarIndex, 1); + + workspaceHotbars.set("default", { name, + id, + items: items.filter(Boolean), }); } @@ -59,7 +74,7 @@ export default { if (workspaceHotbar?.items.length < defaultHotbarCells) { workspaceHotbar.items.push({ entity: { - uid: cluster.id, + uid: createHash("md5").update(`${cluster.kubeConfigPath}:${cluster.contextName}`).digest("hex"), name: cluster.preferences.clusterName || cluster.contextName, } }); @@ -74,6 +89,46 @@ export default { hotbars.push(hotbar); } + /** + * Finally, make sure that the catalog entity hotbar item is in place. + * Just in case something else removed it. + * + * if every hotbar has elements that all not the `catalog-entity` item + */ + if (hotbars.every(hotbar => hotbar.items.every(item => item?.entity?.uid !== "catalog-entity"))) { + // note, we will add a new whole hotbar here called "default" if that was previously removed + const defaultHotbar = hotbars.find(hotbar => hotbar.name === "default"); + const { metadata: { uid, name, source } } = catalogEntity; + + if (defaultHotbar) { + const freeIndex = defaultHotbar.items.findIndex(isNull); + + if (freeIndex === -1) { + // making a new hotbar is less destructive if the first hotbar + // called "default" is full than overriding a hotbar item + const hotbar = { + id: uuid.v4(), + name: "initial", + items: HotbarStore.getInitialItems(), + }; + + hotbar.items[0] = { entity: { uid, name, source } }; + hotbars.unshift(hotbar); + } else { + defaultHotbar.items[freeIndex] = { entity: { uid, name, source } }; + } + } else { + const hotbar = { + id: uuid.v4(), + name: "default", + items: HotbarStore.getInitialItems(), + }; + + hotbar.items[0] = { entity: { uid, name, source } }; + hotbars.unshift(hotbar); + } + } + store.set("hotbars", hotbars); } catch (error) { if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { diff --git a/src/migrations/hotbar-store/5.0.0-beta.5.ts b/src/migrations/hotbar-store/5.0.0-beta.5.ts index 1cc8bc2840..8d589e9543 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.5.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.5.ts @@ -20,7 +20,7 @@ */ import type { Hotbar } from "../../common/hotbar-store"; -import { catalogEntityRegistry } from "../../renderer/api/catalog-entity-registry"; +import { catalogEntityRegistry } from "../../main/catalog"; import type { MigrationDeclaration } from "../helpers"; export default { diff --git a/src/migrations/user-store/file-name-migration.ts b/src/migrations/user-store/file-name-migration.ts index e0c848d3a8..122cd78102 100644 --- a/src/migrations/user-store/file-name-migration.ts +++ b/src/migrations/user-store/file-name-migration.ts @@ -23,18 +23,18 @@ import fse from "fs-extra"; import { app, remote } from "electron"; import path from "path"; -export async function fileNameMigration() { +export function fileNameMigration() { const userDataPath = (app || remote.app).getPath("userData"); const configJsonPath = path.join(userDataPath, "config.json"); const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); try { - await fse.move(configJsonPath, lensUserStoreJsonPath); + fse.moveSync(configJsonPath, lensUserStoreJsonPath); } catch (error) { if (error.code === "ENOENT" && error.path === configJsonPath) { // (No such file or directory) return; // file already moved } else if (error.message === "dest already exists.") { - await fse.remove(configJsonPath); + fse.removeSync(configJsonPath); } else { // pass other errors along throw error; diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index df0bd00feb..7768a9e0eb 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -42,6 +42,11 @@ import { ExtensionInstallationStateStore } from "./components/+extensions/extens import { DefaultProps } from "./mui-base-theme"; import configurePackages from "../common/configure-packages"; import * as initializers from "./initializers"; +import { HotbarStore } from "../common/hotbar-store"; +import { WeblinkStore } from "../common/weblink-store"; +import { ExtensionsStore } from "../extensions/extensions-store"; +import { FilesystemProvisionerStore } from "../main/extension-filesystem"; +import { ThemeStore } from "./theme.store"; configurePackages(); @@ -79,7 +84,13 @@ export async function bootstrap(App: AppComponent) { ExtensionLoader.createInstance().init(); ExtensionDiscovery.createInstance().init(); - await initializers.initStores(); + UserStore.createInstance(); + await ClusterStore.createInstance().loadInitialOnRenderer(); + HotbarStore.createInstance(); + ExtensionsStore.createInstance(); + FilesystemProvisionerStore.createInstance(); + ThemeStore.createInstance(); + WeblinkStore.createInstance(); ExtensionInstallationStateStore.bindIpcListeners(); HelmRepoManager.createInstance(); // initialize the manager diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 164106b517..7ee8529492 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -112,7 +112,7 @@ export class AddCluster extends React.Component {

Add Clusters from Kubeconfig

Clusters added here are not merged into the ~/.kube/config file. - Read more about adding clusters here. + Read more about adding clusters here.

{ item: CatalogEntityItem | null | undefined; @@ -91,6 +92,11 @@ export class CatalogEntityDetails extends Component {...item.getLabelBadges(this.props.hideDetails)} + {isDevelopment && ( + + {item.getId()} + + )}
)} diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 17ed88a517..9192364e91 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -173,7 +173,7 @@ export class Catalog extends React.Component { renderIcon(item: CatalogEntityItem) { return ( { ExtensionInstallationStateStore.reset(); UserStore.resetInstance(); - await UserStore.createInstance().load(); + UserStore.createInstance(); ExtensionDiscovery.resetInstance(); ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve()); 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 62566b868a..17ebb92d09 100644 --- a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -30,10 +30,11 @@ import type { LogTabData } from "../log-tab.store"; import { dockerPod, deploymentPod1 } from "./pod.mock"; import { ThemeStore } from "../../../theme.store"; import { UserStore } from "../../../../common/user-store"; +import mockFs from "mock-fs"; jest.mock("electron", () => ({ app: { - getPath: () => "/foo", + getPath: () => "tmp", }, })); @@ -71,6 +72,9 @@ const getFewPodsTabData = (): LogTabData => { describe("", () => { beforeEach(() => { + mockFs({ + "tmp": {} + }); UserStore.createInstance(); ThemeStore.createInstance(); }); @@ -78,6 +82,7 @@ describe("", () => { afterEach(() => { UserStore.resetInstance(); ThemeStore.resetInstance(); + mockFs.restore(); }); it("renders w/o errors", () => { 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 dfb75ffda3..119212753d 100644 --- a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -26,6 +26,14 @@ import React from "react"; import { ThemeStore } from "../../../theme.store"; import { UserStore } from "../../../../common/user-store"; import { Notifications } from "../../notifications"; +import mockFs from "mock-fs"; + +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), + }, +})); const mockHotbars: {[id: string]: any} = { "1": { @@ -48,6 +56,9 @@ jest.mock("../../../../common/hotbar-store", () => ({ describe("", () => { beforeEach(() => { + mockFs({ + "tmp": {} + }); UserStore.createInstance(); ThemeStore.createInstance(); }); @@ -55,11 +66,12 @@ describe("", () => { afterEach(() => { UserStore.resetInstance(); ThemeStore.resetInstance(); + mockFs.restore(); }); it("renders w/o errors", () => { const { container } = render(); - + expect(container).toBeInstanceOf(HTMLElement); }); @@ -73,4 +85,3 @@ describe("", () => { spy.mockRestore(); }); }); - diff --git a/src/renderer/components/hotbar/hotbar-add-command.tsx b/src/renderer/components/hotbar/hotbar-add-command.tsx index 8141594b7c..fde97ef561 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -33,22 +33,14 @@ const uniqueHotbarName: InputValidator = { @observer export class HotbarAddCommand extends React.Component { - - onSubmit(name: string) { + onSubmit = (name: string) => { if (!name.trim()) { return; } - const hotbarStore = HotbarStore.getInstance(); - - const hotbar = hotbarStore.add({ - name - }); - - hotbarStore.activeHotbarId = hotbar.id; - + HotbarStore.getInstance().add({ name }, { setActive: true }); CommandOverlay.close(); - } + }; render() { return ( @@ -58,10 +50,11 @@ export class HotbarAddCommand extends React.Component { autoFocus={true} theme="round-black" data-test-id="command-palette-hotbar-add-name" - validators={[uniqueHotbarName]} - onSubmit={(v) => this.onSubmit(v)} + validators={uniqueHotbarName} + onSubmit={this.onSubmit} dirty={true} - showValidationLine={true} /> + showValidationLine={true} + /> Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel) diff --git a/src/renderer/components/hotbar/hotbar-icon.scss b/src/renderer/components/hotbar/hotbar-icon.scss index 7d2ef34398..c12c68f26f 100644 --- a/src/renderer/components/hotbar/hotbar-icon.scss +++ b/src/renderer/components/hotbar/hotbar-icon.scss @@ -114,7 +114,6 @@ } img { - padding: 3px; border-radius: 6px; &.active { diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index ead860ee6f..5c05244e70 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -160,7 +160,7 @@ export class HotbarMenu extends React.Component { /> ) : ( navigate(catalogURL({ params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" } } )), } diff --git a/src/renderer/theme.store.ts b/src/renderer/theme.store.ts index e3c1fd71ea..6caf9cf78a 100644 --- a/src/renderer/theme.store.ts +++ b/src/renderer/theme.store.ts @@ -20,9 +20,11 @@ */ import { computed, observable, reaction, makeObservable } from "mobx"; -import { autoBind, boundMethod, Singleton } from "./utils"; +import { autoBind, iter, Singleton } from "./utils"; import { UserStore } from "../common/user-store"; import logger from "../main/logger"; +import darkTheme from "./themes/lens-dark.json"; +import lightTheme from "./themes/lens-light.json"; export type ThemeId = string; @@ -32,12 +34,15 @@ export enum ThemeType { } export interface Theme { - id: ThemeId; // filename without .json-extension type: ThemeType; - name?: string; - colors?: Record; - description?: string; - author?: string; + name: string; + colors: Record; + description: string; + author: string; +} + +export interface ThemeItems extends Theme { + id: string; } export class ThemeStore extends Singleton { @@ -45,16 +50,12 @@ export class ThemeStore extends Singleton { // bundled themes from `themes/${themeId}.json` private allThemes = observable.map([ - ["lens-dark", { id: "lens-dark", type: ThemeType.DARK }], - ["lens-light", { id: "lens-light", type: ThemeType.LIGHT }], + ["lens-dark", { ...darkTheme, type: ThemeType.DARK }], + ["lens-light", { ...lightTheme, type: ThemeType.LIGHT }], ]); - @computed get themeIds(): string[] { - return Array.from(this.allThemes.keys()); - } - - @computed get themes(): Theme[] { - return Array.from(this.allThemes.values()); + @computed get themes(): ThemeItems[] { + return Array.from(iter.map(this.allThemes, ([id, theme]) => ({ id, ...theme }))); } @computed get activeThemeId(): string { @@ -62,12 +63,7 @@ export class ThemeStore extends Singleton { } @computed get activeTheme(): Theme { - const activeTheme = this.allThemes.get(this.activeThemeId) ?? this.allThemes.get("lens-dark"); - - return { - colors: {}, - ...activeTheme, - }; + return this.allThemes.get(this.activeThemeId) ?? this.allThemes.get("lens-dark"); } constructor() { @@ -77,9 +73,9 @@ export class ThemeStore extends Singleton { autoBind(this); // auto-apply active theme - reaction(() => this.activeThemeId, async themeId => { + reaction(() => this.activeThemeId, themeId => { try { - this.applyTheme(await this.loadTheme(themeId)); + this.applyTheme(this.getThemeById(themeId)); } catch (err) { logger.error(err); UserStore.getInstance().resetTheme(); @@ -89,38 +85,10 @@ export class ThemeStore extends Singleton { }); } - async init() { - // preload all themes - await Promise.all(this.themeIds.map(this.loadTheme)); - } - getThemeById(themeId: ThemeId): Theme { return this.allThemes.get(themeId); } - @boundMethod - protected async loadTheme(themeId: ThemeId): Promise { - try { - const existingTheme = this.getThemeById(themeId); - - if (existingTheme) { - const theme = await import( - /* webpackChunkName: "themes/[name]" */ - `./themes/${themeId}.json` - ); - - existingTheme.author = theme.author; - existingTheme.colors = theme.colors; - existingTheme.description = theme.description; - existingTheme.name = theme.name; - } - - return existingTheme; - } catch (err) { - throw new Error(`Can't load theme "${themeId}": ${err}`); - } - } - protected applyTheme(theme: Theme) { if (!this.styles) { this.styles = document.createElement("style");