mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Formalize the to/from Store rules for UserStore (#2708)
- Explicitly don't save to disk if the value in the store is the same as the default. That way we can change the defaults between versions - Calculate the PreferencesModel on demand - In the future, when we turn on strict null checking, we can enforce that the UserStore expects the correct types too Signed-off-by: Sebastian Malton <sebastian@malton.name> simplify default check, remove defaults from UserStore Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
baef6944aa
commit
c4623c424d
@ -41,6 +41,7 @@ import { SemVer } from "semver";
|
||||
import electron from "electron";
|
||||
import { stdout, stderr } from "process";
|
||||
import { beforeEachWrapped } from "../../../integration/helpers/utils";
|
||||
import { ThemeStore } from "../../renderer/theme.store";
|
||||
|
||||
console = new Console(stdout, stderr);
|
||||
|
||||
@ -72,7 +73,7 @@ describe("user store tests", () => {
|
||||
us.httpsProxy = "abcd://defg";
|
||||
|
||||
expect(us.httpsProxy).toBe("abcd://defg");
|
||||
expect(us.colorTheme).toBe(UserStore.defaultTheme);
|
||||
expect(us.colorTheme).toBe(ThemeStore.defaultTheme);
|
||||
|
||||
us.colorTheme = "light";
|
||||
expect(us.colorTheme).toBe("light");
|
||||
@ -83,7 +84,7 @@ describe("user store tests", () => {
|
||||
|
||||
us.colorTheme = "some other theme";
|
||||
us.resetTheme();
|
||||
expect(us.colorTheme).toBe(UserStore.defaultTheme);
|
||||
expect(us.colorTheme).toBe(ThemeStore.defaultTheme);
|
||||
});
|
||||
|
||||
it("correctly calculates if the last seen version is an old release", () => {
|
||||
|
||||
23
src/common/user-store/index.ts
Normal file
23
src/common/user-store/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 * from "./user-store";
|
||||
export type { KubeconfigSyncEntry, KubeconfigSyncValue } from "./preferences-helpers";
|
||||
237
src/common/user-store/preferences-helpers.ts
Normal file
237
src/common/user-store/preferences-helpers.ts
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 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 moment from "moment-timezone";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { ThemeStore } from "../../renderer/theme.store";
|
||||
import { ObservableToggleSet } from "../utils";
|
||||
|
||||
export interface KubeconfigSyncEntry extends KubeconfigSyncValue {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface KubeconfigSyncValue { }
|
||||
|
||||
interface PreferenceDescription<T, R = T> {
|
||||
fromStore(val: T | undefined): R;
|
||||
toStore(val: R): T | undefined;
|
||||
}
|
||||
|
||||
const httpsProxy: PreferenceDescription<string | undefined> = {
|
||||
fromStore(val) {
|
||||
return val;
|
||||
},
|
||||
toStore(val) {
|
||||
return val || undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const shell: PreferenceDescription<string | undefined> = {
|
||||
fromStore(val) {
|
||||
return val;
|
||||
},
|
||||
toStore(val) {
|
||||
return val || undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const colorTheme: PreferenceDescription<string> = {
|
||||
fromStore(val) {
|
||||
return val || ThemeStore.defaultTheme;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val || val === ThemeStore.defaultTheme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const localeTimezone: PreferenceDescription<string> = {
|
||||
fromStore(val) {
|
||||
return val || moment.tz.guess(true) || "UTC";
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val || val === moment.tz.guess(true) || val === "UTC") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const allowUntrustedCAs: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? false;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const allowTelemetry: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? true;
|
||||
},
|
||||
toStore(val) {
|
||||
if (val === true) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const downloadMirror: PreferenceDescription<string> = {
|
||||
fromStore(val) {
|
||||
return val ?? "default";
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val || val === "default") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const downloadKubectlBinaries: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? true;
|
||||
},
|
||||
toStore(val) {
|
||||
if (val === true) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const downloadBinariesPath: PreferenceDescription<string | undefined> = {
|
||||
fromStore(val) {
|
||||
return val;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const kubectlBinariesPath: PreferenceDescription<string | undefined> = {
|
||||
fromStore(val) {
|
||||
return val;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const openAtLogin: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? false;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map<string, ObservableToggleSet<string>>> = {
|
||||
fromStore(val) {
|
||||
return new Map(
|
||||
(val ?? []).map(([tableId, columnIds]) => [tableId, new ObservableToggleSet(columnIds)])
|
||||
);
|
||||
},
|
||||
toStore(val) {
|
||||
const res: [string, string[]][] = [];
|
||||
|
||||
for (const [table, columnes] of val) {
|
||||
if (columnes.size) {
|
||||
res.push([table, Array.from(columnes)]);
|
||||
}
|
||||
}
|
||||
|
||||
return res.length ? res : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const mainKubeFolder = path.join(os.homedir(), ".kube");
|
||||
|
||||
const syncKubeconfigEntries: PreferenceDescription<KubeconfigSyncEntry[], Map<string, KubeconfigSyncValue>> = {
|
||||
fromStore(val) {
|
||||
return new Map(
|
||||
val
|
||||
?.map(({ filePath, ...rest }) => [filePath, rest])
|
||||
?? [[mainKubeFolder, {}]]
|
||||
);
|
||||
},
|
||||
toStore(val) {
|
||||
if (val.size === 1 && val.has(mainKubeFolder)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Array.from(val, ([filePath, rest]) => ({ filePath, ...rest }));
|
||||
},
|
||||
};
|
||||
|
||||
type PreferencesModelType<field extends keyof typeof DESCRIPTORS> = typeof DESCRIPTORS[field] extends PreferenceDescription<infer T, any> ? T : never;
|
||||
type UserStoreModelType<field extends keyof typeof DESCRIPTORS> = typeof DESCRIPTORS[field] extends PreferenceDescription<any, infer T> ? T : never;
|
||||
|
||||
export type UserStoreFlatModel = {
|
||||
[field in keyof typeof DESCRIPTORS]: UserStoreModelType<field>;
|
||||
};
|
||||
|
||||
export type UserPreferencesModel = {
|
||||
[field in keyof typeof DESCRIPTORS]: PreferencesModelType<field>;
|
||||
};
|
||||
|
||||
export const DESCRIPTORS = {
|
||||
httpsProxy,
|
||||
shell,
|
||||
colorTheme,
|
||||
localeTimezone,
|
||||
allowUntrustedCAs,
|
||||
allowTelemetry,
|
||||
downloadMirror,
|
||||
downloadKubectlBinaries,
|
||||
downloadBinariesPath,
|
||||
kubectlBinariesPath,
|
||||
openAtLogin,
|
||||
hiddenTableColumns,
|
||||
syncKubeconfigEntries,
|
||||
};
|
||||
@ -19,50 +19,25 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import type { ThemeId } from "../renderer/theme.store";
|
||||
import { app, remote } from "electron";
|
||||
import semver from "semver";
|
||||
import { action, computed, observable, reaction, makeObservable } 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 { appEventBus } from "./event-bus";
|
||||
import { BaseStore } from "../base-store";
|
||||
import migrations from "../../migrations/user-store";
|
||||
import { getAppVersion } from "../utils/app-version";
|
||||
import { kubeConfigDefaultPath } from "../kube-helpers";
|
||||
import { appEventBus } from "../event-bus";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { fileNameMigration } from "../migrations/user-store";
|
||||
import { ObservableToggleSet, toJS } from "../renderer/utils";
|
||||
import { fileNameMigration } from "../../migrations/user-store";
|
||||
import { ObservableToggleSet, toJS } from "../../renderer/utils";
|
||||
import { DESCRIPTORS, KubeconfigSyncValue, UserPreferencesModel } from "./preferences-helpers";
|
||||
|
||||
export interface UserStoreModel {
|
||||
lastSeenAppVersion: string;
|
||||
preferences: UserPreferencesModel;
|
||||
}
|
||||
|
||||
export interface KubeconfigSyncEntry extends KubeconfigSyncValue {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface KubeconfigSyncValue { }
|
||||
|
||||
export interface UserPreferencesModel {
|
||||
httpsProxy?: string;
|
||||
shell?: string;
|
||||
colorTheme?: string;
|
||||
localeTimezone?: string;
|
||||
allowUntrustedCAs?: boolean;
|
||||
allowTelemetry?: boolean;
|
||||
downloadMirror?: string | "default";
|
||||
downloadKubectlBinaries?: boolean;
|
||||
downloadBinariesPath?: string;
|
||||
kubectlBinariesPath?: string;
|
||||
openAtLogin?: boolean;
|
||||
hiddenTableColumns?: [string, string[]][];
|
||||
syncKubeconfigEntries?: KubeconfigSyncEntry[];
|
||||
}
|
||||
|
||||
export class UserStore extends BaseStore<UserStoreModel> {
|
||||
static readonly defaultTheme: ThemeId = "lens-dark";
|
||||
|
||||
export class UserStore extends BaseStore<UserStoreModel> /* implements UserStoreFlatModel (when strict null is enabled) */ {
|
||||
constructor() {
|
||||
super({
|
||||
configName: "lens-user-store",
|
||||
@ -75,11 +50,18 @@ export class UserStore extends BaseStore<UserStoreModel> {
|
||||
}
|
||||
|
||||
@observable lastSeenAppVersion = "0.0.0";
|
||||
@observable allowTelemetry = true;
|
||||
@observable allowUntrustedCAs = false;
|
||||
@observable colorTheme = UserStore.defaultTheme;
|
||||
@observable localeTimezone = moment.tz.guess(true) || "UTC";
|
||||
@observable downloadMirror = "default";
|
||||
|
||||
/**
|
||||
* used in add-cluster page for providing context
|
||||
*/
|
||||
@observable kubeConfigPath = kubeConfigDefaultPath;
|
||||
@observable seenContexts = observable.set<string>();
|
||||
@observable newContexts = observable.set<string>();
|
||||
@observable allowTelemetry: boolean;
|
||||
@observable allowUntrustedCAs: boolean;
|
||||
@observable colorTheme: string;
|
||||
@observable localeTimezone: string;
|
||||
@observable downloadMirror: string;
|
||||
@observable httpsProxy?: string;
|
||||
@observable shell?: string;
|
||||
@observable downloadBinariesPath?: string;
|
||||
@ -88,8 +70,8 @@ export class UserStore extends BaseStore<UserStoreModel> {
|
||||
/**
|
||||
* Download kubectl binaries matching cluster version
|
||||
*/
|
||||
@observable downloadKubectlBinaries = true;
|
||||
@observable openAtLogin = false;
|
||||
@observable downloadKubectlBinaries: boolean;
|
||||
@observable openAtLogin: boolean;
|
||||
|
||||
/**
|
||||
* The column IDs under each configurable table ID that have been configured
|
||||
@ -100,9 +82,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
|
||||
/**
|
||||
* The set of file/folder paths to be synced
|
||||
*/
|
||||
syncKubeconfigEntries = observable.map<string, KubeconfigSyncValue>([
|
||||
[path.join(os.homedir(), ".kube"), {}]
|
||||
]);
|
||||
syncKubeconfigEntries = observable.map<string, KubeconfigSyncValue>();
|
||||
|
||||
@computed get isNewVersion() {
|
||||
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
|
||||
@ -160,7 +140,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
|
||||
|
||||
@action
|
||||
resetTheme() {
|
||||
this.colorTheme = UserStore.defaultTheme;
|
||||
this.colorTheme = DESCRIPTORS.colorTheme.fromStore(undefined);
|
||||
}
|
||||
|
||||
@action
|
||||
@ -182,64 +162,38 @@ export class UserStore extends BaseStore<UserStoreModel> {
|
||||
this.lastSeenAppVersion = lastSeenAppVersion;
|
||||
}
|
||||
|
||||
if (!preferences) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.httpsProxy = preferences.httpsProxy;
|
||||
this.shell = preferences.shell;
|
||||
this.colorTheme = preferences.colorTheme;
|
||||
this.localeTimezone = preferences.localeTimezone;
|
||||
this.allowUntrustedCAs = preferences.allowUntrustedCAs;
|
||||
this.allowTelemetry = preferences.allowTelemetry;
|
||||
this.downloadMirror = preferences.downloadMirror;
|
||||
this.downloadKubectlBinaries = preferences.downloadKubectlBinaries;
|
||||
this.downloadBinariesPath = preferences.downloadBinariesPath;
|
||||
this.kubectlBinariesPath = preferences.kubectlBinariesPath;
|
||||
this.openAtLogin = preferences.openAtLogin;
|
||||
|
||||
if (preferences.hiddenTableColumns) {
|
||||
this.hiddenTableColumns.replace(
|
||||
preferences.hiddenTableColumns
|
||||
.map(([tableId, columnIds]) => [tableId, new ObservableToggleSet(columnIds)])
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences.syncKubeconfigEntries) {
|
||||
this.syncKubeconfigEntries.replace(
|
||||
preferences.syncKubeconfigEntries.map(({ filePath, ...rest }) => [filePath, rest])
|
||||
);
|
||||
}
|
||||
this.httpsProxy = DESCRIPTORS.httpsProxy.fromStore(preferences?.httpsProxy);
|
||||
this.shell = DESCRIPTORS.shell.fromStore(preferences?.shell);
|
||||
this.colorTheme = DESCRIPTORS.colorTheme.fromStore(preferences?.colorTheme);
|
||||
this.localeTimezone = DESCRIPTORS.localeTimezone.fromStore(preferences?.localeTimezone);
|
||||
this.allowUntrustedCAs = DESCRIPTORS.allowUntrustedCAs.fromStore(preferences?.allowUntrustedCAs);
|
||||
this.allowTelemetry = DESCRIPTORS.allowTelemetry.fromStore(preferences?.allowTelemetry);
|
||||
this.downloadMirror = DESCRIPTORS.downloadMirror.fromStore(preferences?.downloadMirror);
|
||||
this.downloadKubectlBinaries = DESCRIPTORS.downloadKubectlBinaries.fromStore(preferences?.downloadKubectlBinaries);
|
||||
this.downloadBinariesPath = DESCRIPTORS.downloadBinariesPath.fromStore(preferences?.downloadBinariesPath);
|
||||
this.kubectlBinariesPath = DESCRIPTORS.kubectlBinariesPath.fromStore(preferences?.kubectlBinariesPath);
|
||||
this.openAtLogin = DESCRIPTORS.openAtLogin.fromStore(preferences?.openAtLogin);
|
||||
this.hiddenTableColumns.replace(DESCRIPTORS.hiddenTableColumns.fromStore(preferences?.hiddenTableColumns));
|
||||
this.syncKubeconfigEntries.replace(DESCRIPTORS.syncKubeconfigEntries.fromStore(preferences?.syncKubeconfigEntries));
|
||||
}
|
||||
|
||||
toJSON(): UserStoreModel {
|
||||
const hiddenTableColumns: [string, string[]][] = [];
|
||||
const syncKubeconfigEntries: KubeconfigSyncEntry[] = [];
|
||||
|
||||
for (const [key, values] of this.hiddenTableColumns.entries()) {
|
||||
hiddenTableColumns.push([key, Array.from(values)]);
|
||||
}
|
||||
|
||||
for (const [filePath, rest] of this.syncKubeconfigEntries) {
|
||||
syncKubeconfigEntries.push({ filePath, ...rest });
|
||||
}
|
||||
|
||||
const model: UserStoreModel = {
|
||||
lastSeenAppVersion: this.lastSeenAppVersion,
|
||||
preferences: {
|
||||
httpsProxy: toJS(this.httpsProxy),
|
||||
shell: toJS(this.shell),
|
||||
colorTheme: toJS(this.colorTheme),
|
||||
localeTimezone: toJS(this.localeTimezone),
|
||||
allowUntrustedCAs: toJS(this.allowUntrustedCAs),
|
||||
allowTelemetry: toJS(this.allowTelemetry),
|
||||
downloadMirror: toJS(this.downloadMirror),
|
||||
downloadKubectlBinaries: toJS(this.downloadKubectlBinaries),
|
||||
downloadBinariesPath: toJS(this.downloadBinariesPath),
|
||||
kubectlBinariesPath: toJS(this.kubectlBinariesPath),
|
||||
openAtLogin: toJS(this.openAtLogin),
|
||||
hiddenTableColumns,
|
||||
syncKubeconfigEntries,
|
||||
httpsProxy: DESCRIPTORS.httpsProxy.toStore(this.httpsProxy),
|
||||
shell: DESCRIPTORS.shell.toStore(this.shell),
|
||||
colorTheme: DESCRIPTORS.colorTheme.toStore(this.colorTheme),
|
||||
localeTimezone: DESCRIPTORS.localeTimezone.toStore(this.localeTimezone),
|
||||
allowUntrustedCAs: DESCRIPTORS.allowUntrustedCAs.toStore(this.allowUntrustedCAs),
|
||||
allowTelemetry: DESCRIPTORS.allowTelemetry.toStore(this.allowTelemetry),
|
||||
downloadMirror: DESCRIPTORS.downloadMirror.toStore(this.downloadMirror),
|
||||
downloadKubectlBinaries: DESCRIPTORS.downloadKubectlBinaries.toStore(this.downloadKubectlBinaries),
|
||||
downloadBinariesPath: DESCRIPTORS.downloadBinariesPath.toStore(this.downloadBinariesPath),
|
||||
kubectlBinariesPath: DESCRIPTORS.kubectlBinariesPath.toStore(this.kubectlBinariesPath),
|
||||
openAtLogin: DESCRIPTORS.openAtLogin.toStore(this.openAtLogin),
|
||||
hiddenTableColumns: DESCRIPTORS.hiddenTableColumns.toStore(this.hiddenTableColumns),
|
||||
syncKubeconfigEntries: DESCRIPTORS.syncKubeconfigEntries.toStore(this.syncKubeconfigEntries),
|
||||
},
|
||||
};
|
||||
|
||||
@ -46,6 +46,7 @@ export interface ThemeItems extends Theme {
|
||||
}
|
||||
|
||||
export class ThemeStore extends Singleton {
|
||||
static readonly defaultTheme = "lens-dark";
|
||||
protected styles: HTMLStyleElement;
|
||||
|
||||
// bundled themes from `themes/${themeId}.json`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user