diff --git a/src/renderer/utils/__tests__/createStorage.test.ts b/src/renderer/utils/__tests__/storageHelper.test.ts
similarity index 97%
rename from src/renderer/utils/__tests__/createStorage.test.ts
rename to src/renderer/utils/__tests__/storageHelper.test.ts
index e79bc149b1..4dfb318105 100644
--- a/src/renderer/utils/__tests__/createStorage.test.ts
+++ b/src/renderer/utils/__tests__/storageHelper.test.ts
@@ -1,4 +1,4 @@
-import { StorageAdapter, StorageHelper } from "../createStorage";
+import { StorageAdapter, StorageHelper } from "../storageHelper";
describe("renderer/utils/StorageHelper", () => {
describe("window.localStorage might be used as StorageAdapter", () => {
@@ -127,4 +127,6 @@ describe("renderer/utils/StorageHelper", () => {
});
});
+ // TODO: test observability options
+
});
diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/createStorage.ts
index b6048df685..0f1d6f55c2 100755
--- a/src/renderer/utils/createStorage.ts
+++ b/src/renderer/utils/createStorage.ts
@@ -1,211 +1,70 @@
-// Helper for work with persistent local storage (default: window.localStorage)
-
-import { app, remote } from "electron";
-import path from "path";
-import { ensureDir, readJson, writeJson } from "fs-extra";
+// Keeps window.localStorage state in external JSON-files.
+// Because app creates random port between restarts => storage session wiped out each time.
import type { CreateObservableOptions } from "mobx/lib/api/observable";
-import { action, comparer, observable, reaction, toJS, when } from "mobx";
-import produce, { Draft, enableMapSet, setAutoFreeze } from "immer";
-import { isEqual, isFunction, isObject, noop } from "lodash";
-setAutoFreeze(false); // allow to merge observables
-enableMapSet(); // allow merging maps and sets
+import path from "path";
+import { app, remote } from "electron";
+import { observable, reaction, when } from "mobx";
+import fse from "fs-extra";
+import { StorageHelper } from "./storageHelper";
+import { clusterStore, getHostedClusterId } from "../../common/cluster-store";
+import logger from "../../main/logger";
+
+let initialized = false;
+const loaded = observable.box(false);
+const storage = observable.map();
export function createStorage(key: string, defaultValue?: T, observableOptions?: CreateObservableOptions) {
+ const clusterId = getHostedClusterId();
+ const savingFolder = path.resolve((app || remote.app).getPath("userData"), "lens-local-storage");
+ const jsonFilePath = path.resolve(savingFolder, `${clusterId ?? "app"}.json`);
+
+ if (!initialized) {
+ initialized = true;
+
+ // read once per cluster domain
+ fse.readJson(jsonFilePath)
+ .then((data = {}) => storage.merge(data))
+ .catch(() => null) // ignore empty / non-existing / invalid json files
+ .finally(() => loaded.set(true));
+
+ // bind auto-saving
+ reaction(() => storage.toJSON(), saveFile, { delay: 250 });
+
+ // remove json-file when cluster deleted
+ when(() => clusterStore.removedClusters.has(clusterId)).then(removeFile);
+ }
+
+ async function saveFile(json = {}) {
+ try {
+ await fse.ensureDir(savingFolder, { mode: 0o755 });
+ await fse.writeJson(jsonFilePath, json, { spaces: 2 });
+ } catch (error) {
+ logger.error(`[save]: ${error}`, { json, jsonFilePath });
+ }
+ }
+
+ function removeFile() {
+ logger.debug("[remove]:", jsonFilePath);
+ fse.unlink(jsonFilePath).catch(Function);
+ }
+
return new StorageHelper(key, {
autoInit: true,
- storage: jsonFileStorageAdapter as StorageAdapter,
observable: observableOptions,
defaultValue,
+ storage: {
+ async getItem(key: string) {
+ await when(() => loaded.get());
+
+ return storage.get(key);
+ },
+ setItem(key: string, value: any) {
+ storage.set(key, value);
+ },
+ removeItem(key: string) {
+ storage.delete(key);
+ }
+ },
});
}
-
-export type StorageModel = Record; // any json-compatible model
-
-export interface StorageAdapter {
- [metadata: string]: any;
- getItem(key: string): T | Promise;
- setItem(key: string, value: T): void;
- removeItem(key: string): void;
- onChange?(change: { key: string, value: T, oldValue?: T }): void;
-}
-
-export interface StorageHelperOptions {
- autoInit?: boolean; // start preloading data immediately, default: true
- observable?: CreateObservableOptions;
- storage: StorageAdapter;
- defaultValue?: T;
-}
-
-export class StorageHelper {
- static defaultOptions: Partial> = {
- autoInit: true,
- observable: {
- deep: true,
- equals: comparer.shallow,
- }
- };
-
- @observable private data = observable.box();
- @observable initialized = false;
- whenReady = when(() => this.initialized);
-
- get storage(): StorageAdapter {
- return this.options.storage;
- }
-
- get defaultValue(): T {
- return this.options.defaultValue;
- }
-
- constructor(readonly key: string, private options: StorageHelperOptions) {
- this.options = { ...StorageHelper.defaultOptions, ...options };
- this.configureObservable();
- this.reset();
-
- if (this.options.autoInit) {
- this.init();
- }
- }
-
- @action
- async init() {
- if (this.initialized) return;
-
- try {
- await this.load();
- this.initialized = true;
- } catch (error) {
- console.error(`[init]: ${error}`, this);
- }
- }
-
- isDefaultValue(value: T): boolean {
- return isEqual(value, this.defaultValue);
- }
-
- @action
- private configureObservable(options = this.options.observable) {
- this.data = observable.box(this.data.get(), {
- ...StorageHelper.defaultOptions.observable, // inherit default observability options
- ...(options ?? {}),
- });
- this.data.observe(change => {
- const { newValue, oldValue } = toJS(change, { recurseEverything: true });
-
- this.onChange(newValue, oldValue);
- });
- }
-
- protected onChange(value: T, oldValue?: T) {
- if (!this.initialized) return;
-
- try {
- if (value == null) {
- this.storage.removeItem(this.key);
- } else {
- this.storage.setItem(this.key, value);
- }
-
- this.storage.onChange?.({ value, oldValue, key: this.key });
- } catch (error) {
- console.error(`[change]: ${error}`, this, { value, oldValue });
- }
- }
-
- async load(): Promise {
- const value = await this.storage.getItem(this.key);
- const notEmpty = value != null;
- const notDefault = !this.isDefaultValue(value);
-
- if (notEmpty && notDefault) {
- this.merge(value);
- }
-
- return value;
- }
-
- get(): T {
- return this.data.get();
- }
-
- set(value: T) {
- this.data.set(value);
- }
-
- reset() {
- this.set(this.defaultValue);
- }
-
- clear() {
- this.data.set(null);
- }
-
- merge(value: Partial | ((draft: Draft) => Partial | void)) {
- const updater = isFunction(value) ? value : (state: Draft) => {
- if (isObject(state)) return Object.assign(state, value);
-
- return value;
- };
- const currentValue = this.toJS();
- const nextValue = produce(currentValue, updater) as T;
-
- this.set(nextValue);
- }
-
- toJS() {
- return toJS(this.get(), { recurseEverything: true });
- }
-}
-
-/**
- * Keep intended window.localStorage state in external JSON-file.
- * Reason: app creates random ports between restarts and as a result storage not persistent.
- */
-export const jsonFileStorageAdapter: StorageAdapter = {
- cwd: path.resolve((app || remote.app).getPath("userData"), "lens-local-storage"),
- data: observable.map([], { deep: false }),
- initialized: false,
-
- get filePath() {
- // TODO: use getHostedClusterId() after moving out from cluster-store.ts
- const clusterId = location.hostname.split(".").slice(-2, -1)[0];
-
- return path.resolve(this.cwd, `${clusterId ?? "app"}.json`);
- },
-
- async init() {
- if (this.initialized) return;
-
- const data = await readJson(this.filePath).catch(noop);
-
- if (data) {
- this.data.replace(data);
- }
- this.bindAutoSave();
- this.initialized = true;
- },
-
- bindAutoSave() {
- return reaction(() => this.data.toJSON(), async (data) => {
- try {
- await ensureDir(this.cwd, { mode: 0o755 });
- await writeJson(this.filePath, data, { spaces: 2 });
- } catch (error) {
- console.error(`[save]: ${this.filePath}: ${error}`, this);
- }
- });
- },
-
- async getItem(key: string) {
- await this.init();
-
- return this.data.get(key);
- },
- setItem(key: string, value: any) {
- this.data.set(key, value);
- },
- removeItem(key: string) {
- this.data.delete(key);
- }
-};
diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts
index e546f94154..cb4ef559e7 100755
--- a/src/renderer/utils/index.ts
+++ b/src/renderer/utils/index.ts
@@ -7,6 +7,7 @@ export * from "./cssNames";
export * from "../../common/event-emitter";
export * from "./saveFile";
export * from "./prevDefault";
+export * from "./storageHelper";
export * from "./createStorage";
export * from "./interval";
export * from "./copyToClipboard";
diff --git a/src/renderer/utils/storageHelper.ts b/src/renderer/utils/storageHelper.ts
new file mode 100755
index 0000000000..9d95a1e1aa
--- /dev/null
+++ b/src/renderer/utils/storageHelper.ts
@@ -0,0 +1,144 @@
+// Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.)
+
+import type { CreateObservableOptions } from "mobx/lib/api/observable";
+import { action, comparer, observable, toJS, when } from "mobx";
+import produce, { Draft, enableMapSet, setAutoFreeze } from "immer";
+import { isEqual, isFunction, isObject } from "lodash";
+
+setAutoFreeze(false); // allow to merge observables
+enableMapSet(); // allow merging maps and sets
+
+export interface StorageAdapter {
+ [metadata: string]: any;
+ getItem(key: string): T | Promise;
+ setItem(key: string, value: T): void;
+ removeItem(key: string): void;
+ onChange?(change: { key: string, value: T, oldValue?: T }): void;
+}
+
+export interface StorageHelperOptions {
+ autoInit?: boolean; // start preloading data immediately, default: true
+ observable?: CreateObservableOptions;
+ storage: StorageAdapter;
+ defaultValue?: T;
+}
+
+export class StorageHelper {
+ static defaultOptions: Partial> = {
+ autoInit: true,
+ observable: {
+ deep: true,
+ equals: comparer.shallow,
+ }
+ };
+
+ @observable private data = observable.box();
+ @observable initialized = false;
+ whenReady = when(() => this.initialized);
+
+ get storage(): StorageAdapter {
+ return this.options.storage;
+ }
+
+ get defaultValue(): T {
+ return this.options.defaultValue;
+ }
+
+ constructor(readonly key: string, private options: StorageHelperOptions) {
+ this.options = { ...StorageHelper.defaultOptions, ...options };
+ this.configureObservable();
+ this.reset();
+
+ if (this.options.autoInit) {
+ this.init();
+ }
+ }
+
+ @action
+ async init() {
+ if (this.initialized) return;
+
+ try {
+ await this.load();
+ this.initialized = true;
+ } catch (error) {
+ console.error(`[init]: ${error}`, this);
+ }
+ }
+
+ isDefaultValue(value: T): boolean {
+ return isEqual(value, this.defaultValue);
+ }
+
+ @action
+ private configureObservable(options = this.options.observable) {
+ this.data = observable.box(this.data.get(), {
+ ...StorageHelper.defaultOptions.observable, // inherit default observability options
+ ...(options ?? {}),
+ });
+ this.data.observe(change => {
+ const { newValue, oldValue } = toJS(change, { recurseEverything: true });
+
+ this.onChange(newValue, oldValue);
+ });
+ }
+
+ protected onChange(value: T, oldValue?: T) {
+ if (!this.initialized) return;
+
+ try {
+ if (value == null) {
+ this.storage.removeItem(this.key);
+ } else {
+ this.storage.setItem(this.key, value);
+ }
+
+ this.storage.onChange?.({ value, oldValue, key: this.key });
+ } catch (error) {
+ console.error(`[change]: ${error}`, this, { value, oldValue });
+ }
+ }
+
+ async load(): Promise {
+ const value = await this.storage.getItem(this.key);
+ const notEmpty = value != null;
+ const notDefault = !this.isDefaultValue(value);
+
+ if (notEmpty && notDefault) {
+ this.merge(value);
+ }
+
+ return value;
+ }
+
+ get(): T {
+ return this.data.get();
+ }
+
+ set(value: T) {
+ this.data.set(value);
+ }
+
+ reset() {
+ this.set(this.defaultValue);
+ }
+
+ clear() {
+ this.data.set(null);
+ }
+
+ merge(value: Partial | ((draft: Draft) => Partial | void)) {
+ const updater = isFunction(value) ? value : (state: Draft) => {
+ if (isObject(state)) return Object.assign(state, value);
+
+ return value;
+ };
+ const nextValue = produce(this.get(), updater) as T;
+
+ this.set(nextValue);
+ }
+
+ toJS() {
+ return toJS(this.get(), { recurseEverything: true });
+ }
+}