1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

refactoring, fixes, removing storage json file on cluster removal

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2021-03-14 16:27:51 +02:00
parent a475313853
commit 669d317475
4 changed files with 208 additions and 202 deletions

View File

@ -1,4 +1,4 @@
import { StorageAdapter, StorageHelper } from "../createStorage"; import { StorageAdapter, StorageHelper } from "../storageHelper";
describe("renderer/utils/StorageHelper", () => { describe("renderer/utils/StorageHelper", () => {
describe("window.localStorage might be used as StorageAdapter", () => { describe("window.localStorage might be used as StorageAdapter", () => {
@ -127,4 +127,6 @@ describe("renderer/utils/StorageHelper", () => {
}); });
}); });
// TODO: test observability options
}); });

View File

@ -1,211 +1,70 @@
// Helper for work with persistent local storage (default: window.localStorage) // Keeps window.localStorage state in external JSON-files.
// Because app creates random port between restarts => storage session wiped out each time.
import { app, remote } from "electron";
import path from "path";
import { ensureDir, readJson, writeJson } from "fs-extra";
import type { CreateObservableOptions } from "mobx/lib/api/observable"; 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 import path from "path";
enableMapSet(); // allow merging maps and sets 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<string/* key */, any /* serializable */>();
export function createStorage<T>(key: string, defaultValue?: T, observableOptions?: CreateObservableOptions) { export function createStorage<T>(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<T>(key, { return new StorageHelper<T>(key, {
autoInit: true, autoInit: true,
storage: jsonFileStorageAdapter as StorageAdapter<T>,
observable: observableOptions, observable: observableOptions,
defaultValue, 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<string, any>; // any json-compatible model
export interface StorageAdapter<T = StorageModel> {
[metadata: string]: any;
getItem(key: string): T | Promise<T>;
setItem(key: string, value: T): void;
removeItem(key: string): void;
onChange?(change: { key: string, value: T, oldValue?: T }): void;
}
export interface StorageHelperOptions<T> {
autoInit?: boolean; // start preloading data immediately, default: true
observable?: CreateObservableOptions;
storage: StorageAdapter<T>;
defaultValue?: T;
}
export class StorageHelper<T = StorageModel> {
static defaultOptions: Partial<StorageHelperOptions<any>> = {
autoInit: true,
observable: {
deep: true,
equals: comparer.shallow,
}
};
@observable private data = observable.box<T>();
@observable initialized = false;
whenReady = when(() => this.initialized);
get storage(): StorageAdapter<T> {
return this.options.storage;
}
get defaultValue(): T {
return this.options.defaultValue;
}
constructor(readonly key: string, private options: StorageHelperOptions<T>) {
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<T>(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<T> {
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<T> | ((draft: Draft<T>) => Partial<T> | void)) {
const updater = isFunction(value) ? value : (state: Draft<T>) => {
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<string, any>([], { 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);
}
};

View File

@ -7,6 +7,7 @@ export * from "./cssNames";
export * from "../../common/event-emitter"; export * from "../../common/event-emitter";
export * from "./saveFile"; export * from "./saveFile";
export * from "./prevDefault"; export * from "./prevDefault";
export * from "./storageHelper";
export * from "./createStorage"; export * from "./createStorage";
export * from "./interval"; export * from "./interval";
export * from "./copyToClipboard"; export * from "./copyToClipboard";

View File

@ -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<T> {
[metadata: string]: any;
getItem(key: string): T | Promise<T>;
setItem(key: string, value: T): void;
removeItem(key: string): void;
onChange?(change: { key: string, value: T, oldValue?: T }): void;
}
export interface StorageHelperOptions<T> {
autoInit?: boolean; // start preloading data immediately, default: true
observable?: CreateObservableOptions;
storage: StorageAdapter<T>;
defaultValue?: T;
}
export class StorageHelper<T> {
static defaultOptions: Partial<StorageHelperOptions<any>> = {
autoInit: true,
observable: {
deep: true,
equals: comparer.shallow,
}
};
@observable private data = observable.box<T>();
@observable initialized = false;
whenReady = when(() => this.initialized);
get storage(): StorageAdapter<T> {
return this.options.storage;
}
get defaultValue(): T {
return this.options.defaultValue;
}
constructor(readonly key: string, private options: StorageHelperOptions<T>) {
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<T>(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<T> {
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<T> | ((draft: Draft<T>) => Partial<T> | void)) {
const updater = isFunction(value) ? value : (state: Draft<T>) => {
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 });
}
}