diff --git a/docs/extensions/guides/stores.md b/docs/extensions/guides/stores.md index 704ca8ee18..8a8a460fc3 100644 --- a/docs/extensions/guides/stores.md +++ b/docs/extensions/guides/stores.md @@ -49,9 +49,9 @@ export class ExamplePreferencesStore extends Store.ExtensionStore(apiVersion: string, kind: string): T[] { diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 431505006f..36fa6a7fe7 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -1,7 +1,7 @@ import path from "path"; import { app, ipcRenderer, remote, webFrame } from "electron"; import { unlink } from "fs-extra"; -import { action, comparer, computed, observable, reaction, toJS, makeObservable } from "mobx"; +import { action, comparer, computed, makeObservable, observable, reaction, toJS } from "mobx"; import { BaseStore } from "./base-store"; import { Cluster, ClusterState } from "../main/cluster"; import migrations from "../migrations/cluster-store"; @@ -12,6 +12,7 @@ import { saveToAppFiles } from "./utils/saveToAppFiles"; import { KubeConfig } from "@kubernetes/client-node"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import { ResourceType } from "../renderer/components/cluster-settings/components/cluster-metrics-setting"; +import { cloneJson } from "./utils"; export interface ClusterIconUpload { clusterId: string; @@ -47,7 +48,7 @@ export interface ClusterModel { * Workspace id * * @deprecated - */ + */ workspace?: string; /** User context in kubeconfig */ @@ -330,7 +331,7 @@ export class ClusterStore extends BaseStore { } toJSON(): ClusterStoreModel { - return toJS({ + return cloneJson({ activeCluster: this.activeCluster, clusters: this.clustersList.map(cluster => cluster.toJSON()), }); diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index a3957bbd5c..a6855f255b 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -1,7 +1,8 @@ -import { action, comparer, observable, toJS, makeObservable } from "mobx"; +import { action, comparer, makeObservable, observable } from "mobx"; import { BaseStore } from "./base-store"; import migrations from "../migrations/hotbar-store"; import * as uuid from "uuid"; +import { cloneJson } from "./utils"; export interface HotbarItem { entity: { @@ -59,7 +60,8 @@ export class HotbarStore extends BaseStore { return this.hotbars.findIndex((hotbar) => hotbar.id === this.activeHotbarId); } - @action protected async fromStore(data: Partial = {}) { + @action + protected async fromStore(data: Partial = {}) { if (data.hotbars?.length === 0) { this.hotbars = [{ id: uuid.v4(), @@ -139,11 +141,9 @@ export class HotbarStore extends BaseStore { } toJSON(): HotbarStoreModel { - const model: HotbarStoreModel = { + return cloneJson({ hotbars: this.hotbars, activeHotbarId: this.activeHotbarId - }; - - return toJS(model); + }); } } diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index 78a5233dd0..5d97b11546 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -2,10 +2,10 @@ // https://www.electronjs.org/docs/api/ipc-main // https://www.electronjs.org/docs/api/ipc-renderer -import { ipcMain, ipcRenderer, webContents, remote } from "electron"; +import { ipcMain, ipcRenderer, remote, webContents } from "electron"; import { toJS } from "mobx"; import logger from "../../main/logger"; -import { ClusterFrameInfo, clusterFrameMap } from "../cluster-frames"; +import { ClusterFrameInfo, clusterFrameMap } from "../cluster-frames"; const subFramesChannel = "ipc:get-sub-frames"; @@ -18,7 +18,7 @@ export async function requestMain(channel: string, ...args: any[]) { } function getSubFrames(): ClusterFrameInfo[] { - return toJS(Array.from(clusterFrameMap.values())); + return Array.from(toJS(clusterFrameMap).values()); } export async function broadcastMessage(channel: string, ...args: any[]) { diff --git a/src/common/libs-config.ts b/src/common/libs-config.ts index 1d7a728606..cbca218d1b 100644 --- a/src/common/libs-config.ts +++ b/src/common/libs-config.ts @@ -1,15 +1,19 @@ // Global configuration setup for external packages. // Should be imported at the top of app's entry points. -import { configure } from "mobx"; -import { enableMapSet, setAutoFreeze } from "immer"; +import * as Mobx from "mobx"; +import * as Immer from "immer"; -// Mobx // Docs: https://mobx.js.org/configuration.html -configure({ - isolateGlobalState: true, +Mobx.configure({ enforceActions: "never", + isolateGlobalState: true, + + // TODO: enable later (read more: https://mobx.js.org/migrating-from-4-or-5.html) + // computedRequiresReaction: true, + // reactionRequiresObservable: true, + // observableRequiresReaction: true, }); -// Immer -setAutoFreeze(false); // allow to merge mobx observables, docs: https://immerjs.github.io/immer/freezing -enableMapSet(); // allow to merge maps and sets, docs: https://immerjs.github.io/immer/map-set +// Docs: https://immerjs.github.io/immer/ +Immer.setAutoFreeze(false); // allow to merge mobx observables +Immer.enableMapSet(); // allow to merge maps and sets diff --git a/src/common/user-store.ts b/src/common/user-store.ts index b89affad90..ec08d69167 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -2,16 +2,15 @@ import type { ThemeId } from "../renderer/theme.store"; import { app, remote } from "electron"; import semver from "semver"; import { readFile } from "fs-extra"; -import { action, computed, observable, reaction, toJS, makeObservable } from "mobx"; +import { action, computed, makeObservable, observable, reaction } from "mobx"; import moment from "moment-timezone"; import { BaseStore } from "./base-store"; -import migrations from "../migrations/user-store"; -import { getAppVersion } from "./utils/app-version"; +import migrations, { fileNameMigration } from "../migrations/user-store"; +import { cloneJson, getAppVersion } from "./utils"; import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers"; import { appEventBus } from "./event-bus"; import logger from "../main/logger"; import path from "path"; -import { fileNameMigration } from "../migrations/user-store"; export interface UserStoreModel { kubeConfigPath: string; @@ -181,7 +180,7 @@ export class UserStore extends BaseStore { } toJSON(): UserStoreModel { - return toJS({ + return cloneJson({ kubeConfigPath: this.kubeConfigPath, lastSeenAppVersion: this.lastSeenAppVersion, seenContexts: Array.from(this.seenContexts), diff --git a/src/common/utils/cloneJson.ts b/src/common/utils/cloneJson.ts index d44f9c7898..41048bdb53 100644 --- a/src/common/utils/cloneJson.ts +++ b/src/common/utils/cloneJson.ts @@ -1,5 +1,5 @@ // Clone json-serializable object -export function cloneJsonObject(obj: T): T { - return JSON.parse(JSON.stringify(obj)); +export function cloneJson(data: T): T { + return JSON.parse(JSON.stringify(data)); } diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index d9b6181206..7fba0b2b2b 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -2,30 +2,29 @@ import { watch } from "chokidar"; import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; import fs from "fs-extra"; -import { observable, reaction, toJS, when, makeObservable } from "mobx"; +import { makeObservable, observable, reaction, when } from "mobx"; import os from "os"; import path from "path"; import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; -import { Singleton } from "../common/utils"; +import { cloneJson, Singleton } from "../common/utils"; import logger from "../main/logger"; import { extensionInstaller, PackageJson } from "./extension-installer"; import { ExtensionsStore } from "./extensions-store"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; export interface InstalledExtension { - id: LensExtensionId; + id: LensExtensionId; + readonly manifest: LensExtensionManifest; - readonly manifest: LensExtensionManifest; + // Absolute path to the non-symlinked source folder, + // e.g. "/Users/user/.k8slens/extensions/helloworld" + readonly absolutePath: string; - // Absolute path to the non-symlinked source folder, - // e.g. "/Users/user/.k8slens/extensions/helloworld" - readonly absolutePath: string; - - // Absolute to the symlinked package.json file - readonly manifestPath: string; - readonly isBundled: boolean; // defined in project root's package.json - isEnabled: boolean; - } + // Absolute to the symlinked package.json file + readonly manifestPath: string; + readonly isBundled: boolean; // defined in project root's package.json + isEnabled: boolean; +} const logModule = "[EXTENSION-DISCOVERY]"; @@ -151,7 +150,7 @@ export class ExtensionDiscovery extends Singleton { .on("unlinkDir", this.handleWatchUnlinkDir); } - handleWatchFileAdd = async (manifestPath: string) => { + handleWatchFileAdd = async (manifestPath: string) => { // e.g. "foo/package.json" const relativePath = path.relative(this.localFolderPath, manifestPath); @@ -262,7 +261,6 @@ export class ExtensionDiscovery extends Singleton { // fs.remove won't throw if path is missing await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); - try { // Verify write access to static/extensions, which is needed for symlinking await fs.access(this.inTreeFolderPath, fs.constants.W_OK); @@ -447,7 +445,7 @@ export class ExtensionDiscovery extends Singleton { } toJSON(): ExtensionDiscoveryChannelMessage { - return toJS({ + return cloneJson({ isLoaded: this.isLoaded }); } diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 6069617a08..10a98c776e 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -1,6 +1,7 @@ import type { LensExtensionId } from "./lens-extension"; import { BaseStore } from "../common/base-store"; -import { action, computed, observable, toJS, makeObservable } from "mobx"; +import { action, computed, makeObservable, observable } from "mobx"; +import { cloneJson } from "../common/utils"; export interface LensExtensionsStoreModel { extensions: Record; @@ -22,8 +23,8 @@ export class ExtensionsStore extends BaseStore { @computed get enabledExtensions() { return Array.from(this.state.values()) - .filter(({enabled}) => enabled) - .map(({name}) => name); + .filter(({ enabled }) => enabled) + .map(({ name }) => name); } protected state = observable.map(); @@ -47,7 +48,7 @@ export class ExtensionsStore extends BaseStore { } toJSON(): LensExtensionsStoreModel { - return toJS({ + return cloneJson({ extensions: Object.fromEntries(this.state), }); } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 6c0a2be11c..da8817a3b4 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -1,13 +1,13 @@ import "../common/cluster-ipc"; import type http from "http"; import { ipcMain } from "electron"; -import { action, autorun, observable, reaction, toJS, makeObservable } from "mobx"; +import { action, autorun, makeObservable, observable, reaction, toJS } from "mobx"; import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; import { Cluster } from "./cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; -import { Singleton } from "../common/utils"; -import { CatalogEntity } from "../common/catalog-entity"; +import { cloneJson, Singleton } from "../common/utils"; +import { CatalogEntity, CatalogEntityData } from "../common/catalog-entity"; import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster"; import { catalogEntityRegistry } from "../common/catalog-entity-registry"; @@ -41,7 +41,6 @@ export class ClusterManager extends Singleton { this.syncClustersFromCatalog(entities); }); - // auto-stop removed clusters autorun(() => { const removedClusters = Array.from(ClusterStore.getInstance().removedClusters.values()); @@ -57,11 +56,16 @@ export class ClusterManager extends Singleton { delay: 250 }); - ipcMain.on("network:offline", () => { this.onNetworkOffline(); }); - ipcMain.on("network:online", () => { this.onNetworkOnline(); }); + ipcMain.on("network:offline", () => { + this.onNetworkOffline(); + }); + ipcMain.on("network:online", () => { + this.onNetworkOnline(); + }); } - @action protected updateCatalogSource(clusters: Cluster[]) { + @action + protected updateCatalogSource(clusters: Cluster[]) { this.catalogSource.forEach((entity, index) => { const clusterIndex = clusters.findIndex((cluster) => entity.metadata.uid === cluster.id); @@ -121,7 +125,7 @@ export class ClusterManager extends Singleton { } protected catalogEntityFromCluster(cluster: Cluster) { - return new KubernetesCluster(toJS({ + const data: CatalogEntityData = cloneJson({ apiVersion: "entity.k8slens.dev/v1alpha1", kind: "KubernetesCluster", metadata: { @@ -142,7 +146,9 @@ export class ClusterManager extends Singleton { message: "", active: !cluster.disconnected } - })); + }); + + return new KubernetesCluster(data); } protected onNetworkOffline() { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index ee8f076091..3e70258170 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,7 +1,7 @@ import { ipcMain } from "electron"; import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store"; import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; -import { action, comparer, computed, makeObservable, observable, reaction, toJS, when } from "mobx"; +import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx"; import { apiKubePrefix } from "../common/vars"; import { broadcastMessage, ClusterListNamespaceForbiddenChannel, InvalidKubeconfigChannel } from "../common/ipc"; import { ContextHandler } from "./context-handler"; @@ -15,6 +15,7 @@ import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; import { detectorRegistry } from "./cluster-detectors/detector-registry"; import plimit from "p-limit"; +import { cloneJson } from "../common/utils"; export enum ClusterStatus { AccessGranted = 2, @@ -240,7 +241,7 @@ export class Cluster implements ClusterModel, ClusterState { @computed get prometheusPreferences(): ClusterPrometheusPreferences { const { prometheus, prometheusProvider } = this.preferences; - return toJS({ prometheus, prometheusProvider }); + return cloneJson({ prometheus, prometheusProvider }); } /** @@ -604,7 +605,7 @@ export class Cluster implements ClusterModel, ClusterState { } toJSON(): ClusterModel { - const model: ClusterModel = { + return cloneJson({ id: this.id, contextName: this.contextName, kubeConfigPath: this.kubeConfigPath, @@ -613,16 +614,14 @@ export class Cluster implements ClusterModel, ClusterState { metadata: this.metadata, ownerRef: this.ownerRef, accessibleNamespaces: this.accessibleNamespaces, - }; - - return toJS(model); + }); } /** * Serializable cluster-state used for sync btw main <-> renderer */ getState(): ClusterState { - const state: ClusterState = { + return cloneJson({ initialized: this.initialized, enabled: this.enabled, apiUrl: this.apiUrl, @@ -635,9 +634,7 @@ export class Cluster implements ClusterModel, ClusterState { allowedNamespaces: this.allowedNamespaces, allowedResources: this.allowedResources, isGlobalWatchEnabled: this.isGlobalWatchEnabled, - }; - - return toJS(state); + }); } /** @@ -680,7 +677,7 @@ export class Cluster implements ClusterModel, ClusterState { const api = (await this.getProxyKubeconfig()).makeApiClient(CoreV1Api); try { - const { body: { items }} = await api.listNamespace(); + const { body: { items } } = await api.listNamespace(); const namespaces = items.map(ns => ns.metadata.name); this.getAllowedNamespacesErrorCount = 0; // reset on success diff --git a/src/main/extension-filesystem.ts b/src/main/extension-filesystem.ts index 0d0d1a672a..3fa1003917 100644 --- a/src/main/extension-filesystem.ts +++ b/src/main/extension-filesystem.ts @@ -2,17 +2,18 @@ import { randomBytes } from "crypto"; import { SHA256 } from "crypto-js"; import { app, remote } from "electron"; import fse from "fs-extra"; -import { action, observable, toJS, makeObservable } from "mobx"; +import { action, makeObservable, observable } from "mobx"; import path from "path"; import { BaseStore } from "../common/base-store"; import { LensExtensionId } from "../extensions/lens-extension"; +import { cloneJson } from "../common/utils"; interface FSProvisionModel { extensions: Record; // extension names to paths } export class FilesystemProvisionerStore extends BaseStore { - @observable registeredExtensions = observable.map(); + registeredExtensions = observable.map(); constructor() { super({ @@ -50,8 +51,8 @@ export class FilesystemProvisionerStore extends BaseStore { } toJSON(): FSProvisionModel { - return toJS({ - extensions: Object.fromEntries(this.registeredExtensions.toJSON()), + return cloneJson({ + extensions: Object.fromEntries(this.registeredExtensions), }); } } diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 6f1b0a8e0f..68253eb5e7 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -7,7 +7,7 @@ import path from "path"; import * as tempy from "tempy"; import logger from "./logger"; import { appEventBus } from "../common/event-bus"; -import { cloneJsonObject } from "../common/utils"; +import { cloneJson } from "../common/utils"; export class ResourceApplier { constructor(protected cluster: Cluster) { @@ -83,7 +83,7 @@ export class ResourceApplier { } protected sanitizeObject(resource: KubernetesObject | any) { - resource = cloneJsonObject(resource); + resource = cloneJson(resource); delete resource.status; delete resource.metadata?.resourceVersion; const annotations = resource.metadata?.annotations; diff --git a/src/renderer/components/command-palette/command-dialog.tsx b/src/renderer/components/command-palette/command-dialog.tsx index 3b0ba45f1a..7942826e5f 100644 --- a/src/renderer/components/command-palette/command-dialog.tsx +++ b/src/renderer/components/command-palette/command-dialog.tsx @@ -1,6 +1,6 @@ import { Select } from "../select"; -import { computed, observable, toJS, makeObservable } from "mobx"; +import { computed, observable, makeObservable } from "mobx"; import { observer } from "mobx-react"; import React from "react"; import { commandRegistry } from "../../../extensions/registries/command-registry"; @@ -52,13 +52,11 @@ export class CommandDialog extends React.Component { return; } - const action = toJS(command.action); - try { CommandOverlay.close(); if (command.scope === "global") { - action({ + command.action({ entity: commandRegistry.activeEntity }); } else if(commandRegistry.activeEntity) { diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index 1eb53b6929..29a810cf17 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -1,13 +1,12 @@ -import debounce from "lodash/debounce"; -import { reaction, toJS } from "mobx"; +import { reaction } from "mobx"; import { Terminal as XTerm } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import { dockStore, TabId } from "./dock.store"; import { TerminalApi } from "../../api/terminal-api"; import { ThemeStore } from "../../theme.store"; -import { autobind } from "../../utils"; +import { autobind, cloneJson } from "../../utils"; import { isMac } from "../../../common/vars"; -import { camelCase } from "lodash"; +import { camelCase, debounce } from "lodash"; export class Terminal { static spawningPool: HTMLElement; @@ -104,7 +103,7 @@ export class Terminal { window.addEventListener("resize", this.onResize); this.disposers.push( - reaction(() => toJS(ThemeStore.getInstance().activeTheme.colors), this.setTheme, { + reaction(() => cloneJson(ThemeStore.getInstance().activeTheme.colors), this.setTheme, { fireImmediately: true }), dockStore.onResize(this.onResize), @@ -132,7 +131,7 @@ export class Terminal { const { cols, rows } = this.xterm; this.api.sendTerminalSize(cols, rows); - } catch(error) { + } catch (error) { console.error(error); return; // see https://github.com/lensapp/lens/issues/1891 @@ -183,12 +182,12 @@ export class Terminal { // Handle custom hotkey bindings if (ctrlKey) { switch (code) { - // Ctrl+C: prevent terminal exit on windows / linux (?) + // Ctrl+C: prevent terminal exit on windows / linux (?) case "KeyC": if (this.xterm.hasSelection()) return false; break; - // Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim + // Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim case "KeyW": evt.preventDefault(); break; diff --git a/src/renderer/utils/__tests__/storageHelper.test.ts b/src/renderer/utils/__tests__/storageHelper.test.ts index e0854be11c..53684dd156 100644 --- a/src/renderer/utils/__tests__/storageHelper.test.ts +++ b/src/renderer/utils/__tests__/storageHelper.test.ts @@ -175,7 +175,7 @@ describe("renderer/utils/StorageHelper", () => { it("storage.get() is observable", () => { expect(storageHelper.get()).toEqual(defaultValue); - reaction(() => toJS(storageHelper), change => { + reaction(() => toJS(storageHelper.toJS()), change => { observedChanges.push(change); }); diff --git a/src/renderer/utils/storageHelper.ts b/src/renderer/utils/storageHelper.ts index d93894a491..d66efce1ac 100755 --- a/src/renderer/utils/storageHelper.ts +++ b/src/renderer/utils/storageHelper.ts @@ -1,6 +1,6 @@ // Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.) -import { action, comparer, CreateObservableOptions, IObservableValue, makeObservable, observable, reaction, toJS, when } from "mobx"; +import { action, comparer, CreateObservableOptions, IObservableValue, IReactionDisposer, makeObservable, observable, reaction, toJS, when } from "mobx"; import produce, { Draft } from "immer"; import { isEqual, isFunction, isPlainObject, merge } from "lodash"; import logger from "../../main/logger"; @@ -30,19 +30,24 @@ export class StorageHelper { }; private data: IObservableValue; - @observable initialized = false; - whenReady = when(() => this.initialized); - + protected unwatchChanges: IReactionDisposer; public readonly storage: StorageAdapter; public readonly defaultValue: T; + @observable initialized = false; + whenReady = when(() => this.initialized); + constructor(readonly key: string, private options: StorageHelperOptions) { makeObservable(this); this.options = merge({}, StorageHelper.defaultOptions, options); this.storage = options.storage; this.defaultValue = options.defaultValue; - this.observeData(); + this.data = observable.box(this.defaultValue, this.options.observable); + + this.unwatchChanges = reaction(() => toJS(this.data.get()), (newValue, oldValue) => { + this.onChange(newValue, oldValue); + }, this.options.observable); if (this.options.autoInit) { this.init(); @@ -93,19 +98,6 @@ export class StorageHelper { return isEqual(value, this.defaultValue); } - private observeData(value = this.options.defaultValue) { - const observableOptions: CreateObservableOptions = { - ...StorageHelper.defaultOptions.observable, // inherit default observability options - ...this.options.observable, - }; - - this.data = observable.box(value, observableOptions); - - return reaction(() => toJS(this.data.get()), (newValue, oldValue) => { - this.onChange(newValue, oldValue); - }, observableOptions); - } - protected onChange(value: T, oldValue?: T) { if (!this.initialized) return; @@ -151,6 +143,10 @@ export class StorageHelper { this.set(nextValue as T); } + destroy() { + this.unwatchChanges(); + } + toJS() { return toJS(this.get()); }