Merge branch 'master' into extension-auto-update

This commit is contained in:
Lauri Nevala authored and GitHub committed 2022-01-20 13:35:13 +02:00
commit d26322df1a
69 files changed
+1318 -806

No files matched your search

-154
View File
@@ -5,7 +5,6 @@
import fs from "fs";
import mockFs from "mock-fs";
import yaml from "js-yaml";
import path from "path";
import fse from "fs-extra";
import type { Cluster } from "../cluster/cluster";
@@ -334,159 +333,6 @@ users:
});
});
describe("pre 2.0 config with an existing cluster", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
"some-directory-for-user-data": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
migrations: {
version: "1.0.0",
},
},
cluster1: minimalValidKubeConfig,
}),
},
};
mockFs(mockOpts);
clusterStore = mainDi.inject(clusterStoreInjectable);
});
afterEach(() => {
mockFs.restore();
});
it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[`);
});
});
describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
"some-directory-for-user-data": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
migrations: {
version: "2.4.1",
},
},
cluster1: {
kubeConfig: JSON.stringify({
apiVersion: "v1",
clusters: [
{
cluster: {
server: "https://10.211.55.6:8443",
},
name: "minikube",
},
],
contexts: [
{
context: {
cluster: "minikube",
user: "minikube",
name: "minikube",
},
name: "minikube",
},
],
"current-context": "minikube",
kind: "Config",
preferences: {},
users: [
{
name: "minikube",
user: {
"client-certificate": "/Users/foo/.minikube/client.crt",
"client-key": "/Users/foo/.minikube/client.key",
"auth-provider": {
config: {
"access-token": ["should be string"],
expiry: ["should be string"],
},
},
},
},
],
}),
},
}),
},
};
mockFs(mockOpts);
clusterStore = mainDi.inject(clusterStoreInjectable);
});
afterEach(() => {
mockFs.restore();
});
it("replaces array format access token and expiry into string", async () => {
const file = clusterStore.clustersList[0].kubeConfigPath;
const config = fs.readFileSync(file, "utf8");
const kc = yaml.load(config) as Record<string, any>;
expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe(
"should be string",
);
expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe(
"should be string",
);
});
});
describe("pre 2.6.0 config with a cluster icon", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
"some-directory-for-user-data": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
migrations: {
version: "2.4.1",
},
},
cluster1: {
kubeConfig: minimalValidKubeConfig,
icon: "icon_path",
preferences: {
terminalCWD: "/some-directory-for-user-data",
},
},
}),
icon_path: testDataIcon,
},
};
mockFs(mockOpts);
clusterStore = mainDi.inject(clusterStoreInjectable);
});
afterEach(() => {
mockFs.restore();
});
it("moves the icon into preferences", async () => {
const storedClusterData = clusterStore.clustersList[0];
expect(Object.prototype.hasOwnProperty.call(storedClusterData, "icon")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(storedClusterData.preferences, "icon")).toBe(true);
expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true);
});
});
describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
beforeEach(() => {
ClusterStore.resetInstance();
@@ -59,8 +59,8 @@ export interface KubernetesClusterStatus extends CatalogEntityStatus {
}
export class KubernetesCluster extends CatalogEntity<KubernetesClusterMetadata, KubernetesClusterStatus, KubernetesClusterSpec> {
public static readonly apiVersion = "entity.k8slens.dev/v1alpha1";
public static readonly kind = "KubernetesCluster";
public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
public static readonly kind: string = "KubernetesCluster";
public readonly apiVersion = KubernetesCluster.apiVersion;
public readonly kind = KubernetesCluster.kind;
+78 -3
View File
@@ -8,6 +8,7 @@ import type TypedEmitter from "typed-emitter";
import { observable, makeObservable } from "mobx";
import { once } from "lodash";
import { iter, Disposer } from "../utils";
import type { CategoryColumnRegistration } from "../../renderer/components/+catalog/custom-category-columns";
type ExtractEntityMetadataType<Entity> = Entity extends CatalogEntity<infer Metadata> ? Metadata : never;
type ExtractEntityStatusType<Entity> = Entity extends CatalogEntity<any, infer Status> ? Status : never;
@@ -46,6 +47,7 @@ export interface CatalogCategorySpec {
* The grouping for for the category. This MUST be a DNS label.
*/
group: string;
/**
* The specific versions of the constructors.
*
@@ -54,6 +56,10 @@ export interface CatalogCategorySpec {
* `name = "v1alpha1"` then the resulting `.apiVersion` MUST be `entity.k8slens.dev/v1alpha1`
*/
versions: CatalogCategoryVersion<CatalogEntity>[];
/**
* This is the concerning the category
*/
names: {
/**
* The kind of entity that this category is for. This value MUST be a DNS
@@ -62,38 +68,107 @@ export interface CatalogCategorySpec {
*/
kind: string;
};
/**
* These are the columns used for displaying entities when in the catalog.
*
* If this is not provided then some default columns will be used, similar in
* scope to the columns in the "Browse" view.
*
* Even if you provide columns, a "Name" column will be provided as well with
* `priority: 0`.
*
* These columns will not be used in the "Browse" view.
*/
displayColumns?: CategoryColumnRegistration[];
}
/**
* If the filter returns true, the menu item is displayed
* If the filter return a thruthy value, the menu item is displayed
*/
export type AddMenuFilter = (menu: CatalogEntityAddMenu) => any;
export interface CatalogCategoryEvents {
/**
* This event will be emitted when the category is loaded in the catalog
* view.
*/
load: () => void;
/**
* This event will be emitted when the catalog add menu is opened and is the
* way to added entries to that menu.
*/
catalogAddMenu: (context: CatalogEntityAddMenuContext) => void;
/**
* This event will be emitted when the context menu for an entity is declared
* by this category is opened.
*/
contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void;
}
export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter<CatalogCategoryEvents>) {
/**
* The version of category that you are wanting to declare.
*
* Currently supported values:
*
* - `"catalog.k8slens.dev/v1alpha1"`
*/
abstract readonly apiVersion: string;
/**
* The kind of item you wish to declare.
*
* Currently supported values:
*
* - `"CatalogCategory"`
*/
abstract readonly kind: string;
abstract metadata: {
/**
* The data about the category itself
*/
abstract readonly metadata: {
/**
* The name of your category. The category can be searched for by this
* value. This will also be used for the catalog menu.
*/
name: string;
/**
* Either an `<svg>` or the name of an icon from {@link IconProps}
*/
icon: string;
};
/**
* The most important part of a category, as it is where entity versions are declared.
*/
abstract spec: CatalogCategorySpec;
/**
* @internal
*/
protected filters = observable.set<AddMenuFilter>([], {
deep: false,
});
static parseId(id = ""): { group?: string, kind?: string } {
/**
* Parse a category ID into parts.
* @param id The id of a category is parse
* @returns The group and kind parts of the ID
*/
public static parseId(id: string): { group?: string, kind?: string } {
const [group, kind] = id.split("/") ?? [];
return { group, kind };
}
/**
* Get the ID of this category
*/
public getId(): string {
return `${this.spec.group}/${this.spec.names.kind}`;
}
@@ -67,6 +67,15 @@ const colorTheme: PreferenceDescription<string> = {
},
};
const terminalTheme: PreferenceDescription<string | undefined> = {
fromStore(val) {
return val || "";
},
toStore(val) {
return val || undefined;
},
};
const localeTimezone: PreferenceDescription<string> = {
fromStore(val) {
return val || moment.tz.guess(true) || "UTC";
@@ -335,6 +344,7 @@ export const DESCRIPTORS = {
httpsProxy,
shell,
colorTheme,
terminalTheme,
localeTimezone,
allowUntrustedCAs,
allowTelemetry,
+3
View File
@@ -49,6 +49,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
@observable allowErrorReporting: boolean;
@observable allowUntrustedCAs: boolean;
@observable colorTheme: string;
@observable terminalTheme: string;
@observable localeTimezone: string;
@observable downloadMirror: string;
@observable httpsProxy?: string;
@@ -170,6 +171,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
this.httpsProxy = DESCRIPTORS.httpsProxy.fromStore(preferences?.httpsProxy);
this.shell = DESCRIPTORS.shell.fromStore(preferences?.shell);
this.colorTheme = DESCRIPTORS.colorTheme.fromStore(preferences?.colorTheme);
this.terminalTheme = DESCRIPTORS.terminalTheme.fromStore(preferences?.terminalTheme);
this.localeTimezone = DESCRIPTORS.localeTimezone.fromStore(preferences?.localeTimezone);
this.allowUntrustedCAs = DESCRIPTORS.allowUntrustedCAs.fromStore(preferences?.allowUntrustedCAs);
this.allowTelemetry = DESCRIPTORS.allowTelemetry.fromStore(preferences?.allowTelemetry);
@@ -194,6 +196,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
httpsProxy: DESCRIPTORS.httpsProxy.toStore(this.httpsProxy),
shell: DESCRIPTORS.shell.toStore(this.shell),
colorTheme: DESCRIPTORS.colorTheme.toStore(this.colorTheme),
terminalTheme: DESCRIPTORS.terminalTheme.toStore(this.terminalTheme),
localeTimezone: DESCRIPTORS.localeTimezone.toStore(this.localeTimezone),
allowUntrustedCAs: DESCRIPTORS.allowUntrustedCAs.toStore(this.allowUntrustedCAs),
allowTelemetry: DESCRIPTORS.allowTelemetry.toStore(this.allowTelemetry),
+19
View File
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { bind } from "../index";
describe("bind", () => {
it("should work correctly", () => {
function foobar(bound: number, nonBound: number): number {
expect(typeof bound).toBe("number");
expect(typeof nonBound).toBe("number");
return bound + nonBound;
}
const foobarBound = bind(foobar, null, 5);
expect(foobarBound(10)).toBe(15);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* Get the value behind `key`. If it was not present, first insert `value`
* @param map The map to interact with
* @param key The key to insert into the map with
* @param value The value to optional add to the map
* @returns The value in the map
*/
export function getOrInsert<K, V>(map: Map<K, V>, key: K, value: V): V {
if (!map.has(key)) {
map.set(key, value);
}
return map.get(key);
}
+8
View File
@@ -10,11 +10,19 @@ export function noop<T extends any[]>(...args: T): void {
return void args;
}
/**
* A typecorrect version of <function>.bind()
*/
export function bind<BoundArgs extends any[], NonBoundArgs extends any[], ReturnType>(fn: (...args: [...BoundArgs, ...NonBoundArgs]) => ReturnType, thisArg: any, ...boundArgs: BoundArgs): (...args: NonBoundArgs) => ReturnType {
return fn.bind(thisArg, ...boundArgs);
}
export * from "./app-version";
export * from "./autobind";
export * from "./camelCase";
export * from "./cloneJson";
export * from "./cluster-id-url-parsing";
export * from "./collection-functions";
export * from "./convertCpu";
export * from "./convertMemory";
export * from "./debouncePromise";
+1 -1
View File
@@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export type { AppPreferenceRegistration, AppPreferenceComponents } from "../registries/app-preference-registry";
export type { AppPreferenceRegistration, AppPreferenceComponents } from "../../renderer/components/+preferences/app-preferences/app-preference-registration";
export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry";
export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry";
export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry";
@@ -269,7 +269,6 @@ export class ExtensionLoader {
return this.autoInitExtensions(async (extension: LensRendererExtension) => {
const removeItems = [
registries.GlobalPageRegistry.getInstance().add(extension.globalPages, extension),
registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences),
registries.EntitySettingRegistry.getInstance().add(extension.entitySettings),
registries.StatusBarRegistry.getInstance().add(extension.statusBarItems),
registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems),
@@ -291,11 +290,12 @@ export class ExtensionLoader {
});
};
loadOnClusterRenderer = (entity: KubernetesCluster) => {
loadOnClusterRenderer = (getCluster: () => KubernetesCluster) => {
logger.debug(`${logModule}: load on cluster renderer (dashboard)`);
this.autoInitExtensions(async (extension: LensRendererExtension) => {
if ((await extension.isEnabledForCluster(entity)) === false) {
// getCluster must be a callback, as the entity might be available only after an extension has been loaded
if ((await extension.isEnabledForCluster(getCluster())) === false) {
return [];
}
@@ -324,11 +324,15 @@ export class ExtensionLoader {
this.extensions.get(extension.id).availableUpdate = await extension.checkForUpdate();
}
protected autoInitExtensions(register: (ext: LensExtension) => Promise<Disposer[]>) {
const loadingExtensions: ExtensionLoading[] = [];
protected async loadExtensions(installedExtensions: Map<string, InstalledExtension>, register: (ext: LensExtension) => Promise<Disposer[]>) {
// Steps of the function:
// 1. require and call .activate for each Extension
// 2. Wait until every extension's onActivate has been resolved
// 3. Call .enable for each extension
// 4. Return ExtensionLoading[]
reaction(() => this.toJSON(), async installedExtensions => {
for (const [extId, extension] of installedExtensions) {
const extensions = [...installedExtensions.entries()]
.map(([extId, extension]) => {
const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name);
if (extension.isCompatible && extension.isEnabled && !alreadyInit) {
@@ -337,7 +341,8 @@ export class ExtensionLoader {
if (!LensExtensionClass) {
this.nonInstancesByName.add(extension.manifest.name);
continue;
return null;
}
// const instance = new LensExtensionClass(extension, this.extensionUpdateChecker);
@@ -347,27 +352,49 @@ export class ExtensionLoader {
this.extensionUpdateChecker,
);
const loaded = instance.enable(register).catch((err) => {
logger.error(`${logModule}: failed to enable`, { ext: extension, err });
});
loadingExtensions.push({
isBundled: extension.isBundled,
loaded,
});
this.instances.set(extId, instance);
return {
extId,
instance,
isBundled: extension.isBundled,
activated: instance.activate(),
};
} catch (err) {
logger.error(`${logModule}: activation extension error`, { ext: extension, err });
}
} else if (!extension.isEnabled && alreadyInit) {
this.removeInstance(extId);
}
}
}, {
fireImmediately: true,
});
return loadingExtensions;
return null;
})
// Remove null values
.filter(extension => Boolean(extension));
// We first need to wait until each extension's `onActivate` is resolved,
// as this might register new catalog categories. Afterwards we can safely .enable the extension.
await Promise.all(extensions.map(extension => extension.activated));
// Return ExtensionLoading[]
return extensions.map(extension => {
const loaded = extension.instance.enable(register).catch((err) => {
logger.error(`${logModule}: failed to enable`, { ext: extension, err });
});
return {
isBundled: extension.isBundled,
loaded,
};
});
}
protected autoInitExtensions(register: (ext: LensExtension) => Promise<Disposer[]>) {
// Setup reaction to load extensions on JSON changes
reaction(() => this.toJSON(), installedExtensions => this.loadExtensions(installedExtensions, register));
// Load initial extensions
return this.loadExtensions(this.toJSON(), register);
}
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null {
+6 -1
View File
@@ -91,7 +91,6 @@ export class LensExtension {
}
try {
await this.onActivate();
this._isEnabled = true;
this[Disposers].push(...await register(this));
@@ -118,6 +117,12 @@ export class LensExtension {
}
}
@action
activate() {
return this.onActivate();
}
public async checkForUpdate() {
return this.updateChecker?.run(this.manifest);
}
+4 -1
View File
@@ -15,13 +15,15 @@ import type { KubernetesCluster } from "../common/catalog-entities";
import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration";
import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration";
import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands";
import type { AppPreferenceRegistration } from "../renderer/components/+preferences/app-preferences/app-preference-registration";
import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns";
export class LensRendererExtension extends LensExtension {
globalPages: registries.PageRegistration[] = [];
clusterPages: registries.PageRegistration[] = [];
clusterPageMenus: registries.ClusterPageMenuRegistration[] = [];
kubeObjectStatusTexts: registries.KubeObjectStatusRegistration[] = [];
appPreferences: registries.AppPreferenceRegistration[] = [];
appPreferences: AppPreferenceRegistration[] = [];
entitySettings: registries.EntitySettingRegistration[] = [];
statusBarItems: registries.StatusBarRegistration[] = [];
kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = [];
@@ -32,6 +34,7 @@ export class LensRendererExtension extends LensExtension {
welcomeBanners: WelcomeBannerRegistration[] = [];
catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = [];
topBarItems: TopBarRegistration[] = [];
additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation");
-1
View File
@@ -7,7 +7,6 @@
export * from "./page-registry";
export * from "./page-menu-registry";
export * from "./app-preference-registry";
export * from "./status-bar-registry";
export * from "./kube-object-detail-registry";
export * from "./kube-object-menu-registry";
@@ -31,6 +31,11 @@ export * from "../../renderer/components/input/input";
// command-overlay
export const CommandOverlay = asLegacyGlobalObjectForExtensionApi(commandOverlayInjectable);
export type {
CategoryColumnRegistration,
AdditionalCategoryColumnRegistration,
} from "../../renderer/components/+catalog/custom-category-columns";
// other components
export * from "../../renderer/components/icon";
export * from "../../renderer/components/tooltip";
@@ -8,10 +8,8 @@ import extensionsInjectable from "./extensions.injectable";
import type { LensRendererExtension } from "./lens-renderer-extension";
const rendererExtensionsInjectable = getInjectable({
instantiate: (di) => di.inject(extensionsInjectable) as IComputedValue<LensRendererExtension[]>,
lifecycle: lifecycleEnum.singleton,
instantiate: (di) =>
di.inject(extensionsInjectable) as IComputedValue<LensRendererExtension[]>,
});
export default rendererExtensionsInjectable;
+3 -3
View File
@@ -4,7 +4,7 @@
*/
import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx";
import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor, CatalogEntityKindData } from "../../common/catalog";
import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor } from "../../common/catalog";
import { iter } from "../../common/utils";
export class CatalogEntityRegistry {
@@ -43,8 +43,8 @@ export class CatalogEntityRegistry {
return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[];
}
getItemsByEntityClass<T extends CatalogEntity>({ apiVersion, kind }: CatalogEntityKindData & CatalogEntityConstructor<T>): T[] {
return this.getItemsForApiKind(apiVersion, kind);
getItemsByEntityClass<T extends CatalogEntity>(constructor: CatalogEntityConstructor<T>): T[] {
return this.items.filter((item) => item instanceof constructor) as T[];
}
}
@@ -1,24 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { MigrationDeclaration } from "../helpers";
/**
* Early store format had the kubeconfig directly under context name, this moves
* it under the kubeConfig key
*/
export default {
version: "2.0.0-beta.2",
run(store) {
for (const value of store) {
const contextName = value[0];
// Looping all the keys gives out the store internal stuff too...
if (contextName === "__internal__" || Object.prototype.hasOwnProperty.call(value[1], "kubeConfig")) continue;
store.set(contextName, { kubeConfig: value[1] });
}
},
} as MigrationDeclaration;
-22
View File
@@ -1,22 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { MigrationDeclaration } from "../helpers";
// Cleans up a store that had the state related data stored
export default {
version: "2.4.1",
run(store) {
for (const value of store) {
const contextName = value[0];
if (contextName === "__internal__") continue;
const cluster = value[1];
store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {}});
}
},
} as MigrationDeclaration;
@@ -1,27 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Move cluster icon from root to preferences
import type { MigrationDeclaration } from "../helpers";
export default {
version: "2.6.0-beta.2",
run(store) {
for (const value of store) {
const clusterKey = value[0];
if (clusterKey === "__internal__") continue;
const cluster = value[1];
if (!cluster.preferences) cluster.preferences = {};
if (cluster.icon) {
cluster.preferences.icon = cluster.icon;
delete (cluster["icon"]);
}
store.set(clusterKey, { contextName: clusterKey, kubeConfig: value[1].kubeConfig, preferences: value[1].preferences });
}
},
} as MigrationDeclaration;
@@ -1,53 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import yaml from "js-yaml";
import { MigrationDeclaration, migrationLog } from "../helpers";
export default {
version: "2.6.0-beta.3",
run(store) {
for (const value of store) {
const clusterKey = value[0];
if (clusterKey === "__internal__") continue;
const cluster = value[1];
if (!cluster.kubeConfig) continue;
const config = yaml.load(cluster.kubeConfig);
if (!config || typeof config !== "object" || !Object.prototype.hasOwnProperty.call(config, "users")) {
continue;
}
const kubeConfig = config as Record<string, any>;
const userObj = kubeConfig.users[0];
if (userObj) {
const user = userObj.user;
if (user["auth-provider"] && user["auth-provider"].config) {
const authConfig = user["auth-provider"].config;
if (authConfig["access-token"]) {
authConfig["access-token"] = `${authConfig["access-token"]}`;
}
if (authConfig.expiry) {
authConfig.expiry = `${authConfig.expiry}`;
}
migrationLog(authConfig);
user["auth-provider"].config = authConfig;
kubeConfig.users = [{
name: userObj.name,
user,
}];
cluster.kubeConfig = yaml.dump(kubeConfig);
store.set(clusterKey, cluster);
}
}
}
},
} as MigrationDeclaration;
@@ -1,22 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Add existing clusters to "default" workspace
import type { MigrationDeclaration } from "../helpers";
export default {
version: "2.7.0-beta.0",
run(store) {
for (const value of store) {
const clusterKey = value[0];
if (clusterKey === "__internal__") continue;
const cluster = value[1];
cluster.workspace = "default";
store.set(clusterKey, cluster);
}
},
} as MigrationDeclaration;
@@ -1,35 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Add id for clusters and store them to array
import { v4 as uuid } from "uuid";
import type { MigrationDeclaration } from "../helpers";
export default {
version: "2.7.0-beta.1",
run(store) {
const clusters: any[] = [];
for (const value of store) {
const clusterKey = value[0];
if (clusterKey === "__internal__") continue;
if (clusterKey === "clusters") continue;
const cluster = value[1];
cluster.id = uuid();
if (!cluster.preferences.clusterName) {
cluster.preferences.clusterName = clusterKey;
}
clusters.push(cluster);
store.delete(clusterKey);
}
if (clusters.length > 0) {
store.set("clusters", clusters);
}
},
} as MigrationDeclaration;
-12
View File
@@ -7,24 +7,12 @@
import { joinMigrations } from "../helpers";
import version200Beta2 from "./2.0.0-beta.2";
import version241 from "./2.4.1";
import version260Beta2 from "./2.6.0-beta.2";
import version260Beta3 from "./2.6.0-beta.3";
import version270Beta0 from "./2.7.0-beta.0";
import version270Beta1 from "./2.7.0-beta.1";
import version360Beta1 from "./3.6.0-beta.1";
import version500Beta10 from "./5.0.0-beta.10";
import version500Beta13 from "./5.0.0-beta.13";
import snap from "./snap";
export default joinMigrations(
version200Beta2,
version241,
version260Beta2,
version260Beta3,
version270Beta0,
version270Beta1,
version360Beta1,
version500Beta10,
version500Beta13,
+16 -1
View File
@@ -47,10 +47,25 @@ export class CatalogEntityRegistry {
makeObservable(this);
}
get activeEntity(): CatalogEntity | null {
protected getActiveEntityById() {
return this._entities.get(this.activeEntityId) || null;
}
get activeEntity(): CatalogEntity | null {
const entity = this.getActiveEntityById();
// If the entity was not found but there are rawEntities to be processed,
// try to process them and return the entity.
// This might happen if an extension registered a new Catalog category.
if (this.activeEntityId && !entity && this.rawEntities.length > 0) {
this.processRawEntities();
return this.getActiveEntityById();
}
return entity;
}
set activeEntity(raw: CatalogEntity | string | null) {
if (raw) {
const id = typeof raw === "string"
-3
View File
@@ -98,9 +98,6 @@ export async function bootstrap(di: DependencyInjectionContainer) {
logger.info(`${logPrefix} initializing IpcRendererListeners`);
initializers.initIpcRendererListeners(extensionLoader);
logger.info(`${logPrefix} initializing StatusBarRegistry`);
initializers.initStatusBarRegistry();
extensionLoader.init();
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
@@ -0,0 +1,123 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
import { computed } from "mobx";
import type { CatalogCategorySpec } from "../../../../common/catalog";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import { CatalogCategory } from "../../../api/catalog-entity";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns";
import getCategoryColumnsInjectable, { CategoryColumns, GetCategoryColumnsParams } from "../get-category-columns.injectable";
class TestCategory extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
kind = "CatalogCategory";
metadata: {
name: "Test";
icon: "question_mark";
};
spec: CatalogCategorySpec = {
group: "foo.bar.bat",
names: {
kind: "Test",
},
versions: [],
};
constructor(columns?: CategoryColumnRegistration[]) {
super();
this.spec.displayColumns = columns;
}
}
describe("Custom Category Columns", () => {
let di: ConfigurableDependencyInjectionContainer;
beforeEach(() => {
di = getDiForUnitTesting();
});
describe("without extensions", () => {
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
beforeEach(() => {
di.override(rendererExtensionsInjectable, () => computed(() => [] as LensRendererExtension[]));
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
});
it("should contain a kind column if activeCategory is falsy", () => {
expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem.title === "Kind")).toBeTruthy();
});
it("should not contain a kind column if activeCategory is truthy", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "Kind")).toBeFalsy();
});
it("should include the default columns if the provided category doesn't provide any", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "Source")).toBeTruthy();
});
it("should not include the default columns if the provided category provides any", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem.title === "Source")).toBeFalsy();
});
it("should include the displayColumns from the provided category", () => {
const columns: CategoryColumnRegistration[] = [
{
id: "foo",
renderCell: () => null,
titleProps: {
title: "Foo",
},
},
];
expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem.title === "Foo")).toBeTruthy();
});
});
describe("with extensions", () => {
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
beforeEach(() => {
di.override(rendererExtensionsInjectable, () => computed(() => [
{
name: "test-extension",
additionalCategoryColumns: [
{
group: "foo.bar.bat",
id: "high",
kind: "Test",
renderCell: () => "",
titleProps: {
title: "High",
},
} as AdditionalCategoryColumnRegistration,
{
group: "foo.bar",
id: "high",
kind: "Test",
renderCell: () => "",
titleProps: {
title: "High2",
},
} as AdditionalCategoryColumnRegistration,
],
} as LensRendererExtension,
]));
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
});
it("should include columns from extensions that match", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High")).toBeTruthy();
});
it("should not include columns from extensions that don't match", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High2")).toBeFalsy();
});
});
});
+21 -57
View File
@@ -28,25 +28,18 @@ import { RenderDelay } from "../render-delay/render-delay";
import { Icon } from "../icon";
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
import { Avatar } from "../avatar";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import { getLabelBadges } from "./helpers";
import { withInjectables } from "@ogre-tools/injectable-react";
import catalogPreviousActiveTabStorageInjectable
from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
enum sortBy {
name = "name",
kind = "kind",
source = "source",
status = "status",
}
import type { GetCategoryColumnsParams, CategoryColumns } from "./get-category-columns.injectable";
import getCategoryColumnsInjectable from "./get-category-columns.injectable";
interface Props extends RouteComponentProps<CatalogViewRouteParam> {}
interface Dependencies {
catalogPreviousActiveTabStorage: { set: (value: string ) => void }
catalogEntityStore: CatalogEntityStore
catalogPreviousActiveTabStorage: { set: (value: string ) => void };
catalogEntityStore: CatalogEntityStore;
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
}
@observer
@@ -228,46 +221,23 @@ class NonInjectedCatalog extends React.Component<Props & Dependencies> {
return null;
}
const { sortingCallbacks, searchFilters, renderTableContents, renderTableHeader } = this.props.getCategoryColumns({ activeCategory });
return (
<ItemListLayout
className={styles.Catalog}
tableId={tableId}
renderHeaderTitle={activeCategory?.metadata.name || "Browse All"}
renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"}
isSelectable={false}
isConfigurable={true}
store={this.props.catalogEntityStore}
sortingCallbacks={{
[sortBy.name]: entity => entity.getName(),
[sortBy.source]: entity => entity.getSource(),
[sortBy.status]: entity => entity.status.phase,
[sortBy.kind]: entity => entity.kind,
}}
searchFilters={[
entity => [
entity.getName(),
entity.getId(),
entity.status.phase,
`source=${entity.getSource()}`,
...KubeObject.stringifyLabels(entity.metadata.labels),
],
]}
renderTableHeader={[
{ title: "Name", className: styles.entityName, sortBy: sortBy.name, id: "name" },
!activeCategory && { title: "Kind", sortBy: sortBy.kind, id: "kind" },
{ title: "Source", className: styles.sourceCell, sortBy: sortBy.source, id: "source" },
{ title: "Labels", className: `${styles.labelsCell} scrollable`, id: "labels" },
{ title: "Status", className: styles.statusCell, sortBy: sortBy.status, id: "status" },
].filter(Boolean)}
sortingCallbacks={sortingCallbacks}
searchFilters={searchFilters}
renderTableHeader={renderTableHeader}
customizeTableRowProps={entity => ({
disabled: !entity.isEnabled(),
})}
renderTableContents={entity => [
this.renderName(entity),
!activeCategory && entity.kind,
entity.getSource(),
getLabelBadges(entity),
<span key="phase" className={entity.status.phase}>{entity.status.phase}</span>,
].filter(Boolean)}
renderTableContents={renderTableContents}
onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu}
/>
@@ -306,17 +276,11 @@ class NonInjectedCatalog extends React.Component<Props & Dependencies> {
}
}
export const Catalog = withInjectables<Dependencies, Props>(
NonInjectedCatalog,
{
getProps: (di, props) => ({
catalogEntityStore: di.inject(catalogEntityStoreInjectable),
catalogPreviousActiveTabStorage: di.inject(
catalogPreviousActiveTabStorageInjectable,
),
...props,
}),
},
);
export const Catalog = withInjectables<Dependencies, Props>( NonInjectedCatalog, {
getProps: (di, props) => ({
catalogEntityStore: di.inject(catalogEntityStoreInjectable),
catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
getCategoryColumns: di.inject(getCategoryColumnsInjectable),
...props,
}),
});
@@ -0,0 +1,54 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed, IComputedValue } from "mobx";
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
import { getOrInsert } from "../../utils";
import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns";
interface Dependencies {
extensions: IComputedValue<LensRendererExtension[]>;
}
function getAdditionCategoryColumns({ extensions }: Dependencies): IComputedValue<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>> {
return computed(() => {
const res = new Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>();
for (const ext of extensions.get()) {
for (const { renderCell, titleProps, priority = 50, searchFilter, sortCallback, ...registration } of ext.additionalCategoryColumns) {
const byGroup = getOrInsert(res, registration.group, new Map<string, RegisteredAdditionalCategoryColumn[]>());
const byKind = getOrInsert(byGroup, registration.kind, []);
const id = `${ext.name}:${registration.id}`;
byKind.push({
renderCell,
priority,
id,
titleProps: {
id,
...titleProps,
sortBy: sortCallback
? id
: undefined,
},
searchFilter,
sortCallback,
});
}
}
return res;
});
}
const categoryColumnsInjectable = getInjectable({
instantiate: (di) => getAdditionCategoryColumns({
extensions: di.inject(rendererExtensionsInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default categoryColumnsInjectable;
@@ -0,0 +1,85 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type React from "react";
import type { CatalogEntity } from "../../../common/catalog";
import type { TableCellProps } from "../table";
/**
* These are the supported props for the title cell
*/
export interface TitleCellProps {
className?: string;
title: React.ReactNode;
}
export interface CategoryColumnRegistration {
/**
* The sorting order value.
*
* @default 50
*/
priority?: number;
/**
* This value MUST to be unique to your extension
*/
id: string;
/**
* This function will be called to generate the cells (on demand) for the column
*/
renderCell: (entity: CatalogEntity) => React.ReactNode;
/**
* This function will be used to generate the columns title cell.
*/
titleProps: TitleCellProps;
/**
* If provided then the column will support sorting and this function will be called to
* determine a row's ordering.
*
* strings are sorted ahead of numbers, and arrays determine ordering between equal
* elements of the previous index.
*/
sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[];
/**
* If provided then searching is supported on this column and this function will be called
* to determine if the current search string matches for this row.
*/
searchFilter?: (entity: CatalogEntity) => string | string[];
}
/**
* This is the type used to declare new catalog category columns
*/
export interface AdditionalCategoryColumnRegistration extends CategoryColumnRegistration {
/**
* The catalog entity kind that is declared by the category for this registration
*
* e.g.
* - `"KubernetesCluster"`
*/
kind: string;
/**
* The catalog entity group that is declared by the category for this registration
*
* e.g.
* - `"entity.k8slens.dev"`
*/
group: string;
}
export interface RegisteredAdditionalCategoryColumn {
id: string;
priority: number;
renderCell: (entity: CatalogEntity) => React.ReactNode;
titleProps: TableCellProps;
sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[];
searchFilter?: (entity: CatalogEntity) => string | string[];
}
@@ -0,0 +1,95 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { orderBy } from "lodash";
import type { IComputedValue } from "mobx";
import type { CatalogCategory, CatalogEntity } from "../../../common/catalog";
import { bind } from "../../utils";
import type { ItemListLayoutProps } from "../item-object-list";
import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns";
import categoryColumnsInjectable from "./custom-category-columns.injectable";
import { defaultCategoryColumns, browseAllColumns, nameCategoryColumn } from "./internal-category-columns";
interface Dependencies {
extensionColumns: IComputedValue<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>>;
}
export interface GetCategoryColumnsParams {
activeCategory: CatalogCategory | null | undefined;
}
export type CategoryColumns = Required<Pick<ItemListLayoutProps<CatalogEntity>, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>;
function getSpecificCategoryColumns(activeCategory: CatalogCategory, extensionColumns: IComputedValue<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>>): RegisteredAdditionalCategoryColumn[] {
const fromExtensions = (
extensionColumns
.get()
.get(activeCategory.spec.group)
?.get(activeCategory.spec.names.kind)
?? []
);
const fromCategory = activeCategory.spec.displayColumns?.map(({ priority = 50, ...column }) => ({
priority,
...column,
})) ?? defaultCategoryColumns;
return [
nameCategoryColumn,
...fromExtensions,
...fromCategory,
];
}
function getBrowseAllColumns(): RegisteredAdditionalCategoryColumn[] {
return [
...browseAllColumns,
nameCategoryColumn,
...defaultCategoryColumns,
];
}
function getCategoryColumns({ extensionColumns }: Dependencies, { activeCategory }: GetCategoryColumnsParams): CategoryColumns {
const allRegistrations = orderBy(
activeCategory
? getSpecificCategoryColumns(activeCategory, extensionColumns)
: getBrowseAllColumns(),
"priority",
"asc",
);
const sortingCallbacks: CategoryColumns["sortingCallbacks"] = {};
const searchFilters: CategoryColumns["searchFilters"] = [];
const renderTableHeader: CategoryColumns["renderTableHeader"] = [];
const tableRowRenderers: ((entity: CatalogEntity) => React.ReactNode)[] = [];
for (const registration of allRegistrations) {
if (registration.sortCallback) {
sortingCallbacks[registration.id] = registration.sortCallback;
}
if (registration.searchFilter) {
searchFilters.push(registration.searchFilter);
}
tableRowRenderers.push(registration.renderCell);
renderTableHeader.push(registration.titleProps);
}
return {
sortingCallbacks,
renderTableHeader,
renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)),
searchFilters,
};
}
const getCategoryColumnsInjectable = getInjectable({
instantiate: (di) => bind(getCategoryColumns, null, {
extensionColumns: di.inject(categoryColumnsInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default getCategoryColumnsInjectable;
@@ -0,0 +1,121 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./catalog.module.scss";
import React from "react";
import { HotbarStore } from "../../../common/hotbar-store";
import type { CatalogEntity } from "../../api/catalog-entity";
import { Avatar } from "../avatar";
import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns";
import { Icon } from "../icon";
import { prevDefault } from "../../utils";
import { getLabelBadges } from "./helpers";
import { KubeObject } from "../../../common/k8s-api/kube-object";
function renderEntityName(entity: CatalogEntity) {
const hotbarStore = HotbarStore.getInstance();
const isItemInHotbar = hotbarStore.isAddedToActive(entity);
const onClick = prevDefault(
isItemInHotbar
? () => hotbarStore.removeFromHotbar(entity.getId())
: () => hotbarStore.addToHotbar(entity),
);
return (
<>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
src={entity.spec.icon?.src}
background={entity.spec.icon?.background}
className={styles.catalogAvatar}
size={24}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material} small/>}
</Avatar>
<span>{entity.getName()}</span>
<Icon
small
className={styles.pinIcon}
material={!isItemInHotbar && "push_pin"}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={onClick}
/>
</>
);
}
export const browseAllColumns: RegisteredAdditionalCategoryColumn[] = [
{
id: "kind",
priority: 5,
renderCell: entity => entity.kind,
titleProps: {
id: "kind",
sortBy: "kind",
title: "Kind",
},
sortCallback: entity => entity.kind,
},
];
export const nameCategoryColumn: RegisteredAdditionalCategoryColumn = {
id: "name",
priority: 0,
renderCell: renderEntityName,
titleProps: {
title: "Name",
className: styles.entityName,
id: "name",
sortBy: "name",
},
searchFilter: entity => entity.getName(),
sortCallback: entity => `name=${entity.getName()}`,
};
export const defaultCategoryColumns: RegisteredAdditionalCategoryColumn[] = [
{
id: "source",
priority: 10,
renderCell: entity => entity.getSource(),
titleProps: {
title: "Source",
className: styles.sourceCell,
id: "source",
sortBy: "source",
},
sortCallback: entity => entity.getSource(),
searchFilter: entity => `source=${entity.getSource()}`,
},
{
id: "labels",
priority: 20,
renderCell: getLabelBadges,
titleProps: {
id: "labels",
title: "Labels",
className: `${styles.labelsCell} scrollable`,
},
searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels),
},
{
id: "status",
priority: 30,
renderCell: entity => (
<span key="phase" className={entity.status.phase}>
{entity.status.phase}
</span>
),
titleProps: {
title: "Status",
className: styles.statusCell,
id: "status",
sortBy: "status",
},
searchFilter: entity => entity.status.phase,
},
];
@@ -4,7 +4,6 @@
*/
import type React from "react";
import { BaseRegistry } from "./base-registry";
export interface AppPreferenceComponents {
Hint: React.ComponentType<any>;
@@ -22,11 +21,3 @@ export interface RegisteredAppPreference extends AppPreferenceRegistration {
id: string;
}
export class AppPreferenceRegistry extends BaseRegistry<AppPreferenceRegistration, RegisteredAppPreference> {
getRegisteredItem(item: AppPreferenceRegistration): RegisteredAppPreference {
return {
id: item.id || item.title.toLowerCase().replace(/[^0-9a-zA-Z]+/g, "-"),
...item,
};
}
}
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import { getAppPreferences } from "./get-app-preferences";
const appPreferencesInjectable = getInjectable({
instantiate: (di) =>
getAppPreferences({
extensions: di.inject(rendererExtensionsInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default appPreferencesInjectable;
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { computed, IComputedValue } from "mobx";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import type { AppPreferenceRegistration, RegisteredAppPreference } from "./app-preference-registration";
interface Dependencies {
extensions: IComputedValue<LensRendererExtension[]>;
}
function getRegisteredItem(item: AppPreferenceRegistration): RegisteredAppPreference {
return {
id: item.id || item.title.toLowerCase().replace(/[^0-9a-zA-Z]+/g, "-"),
...item,
};
}
export const getAppPreferences = ({ extensions }: Dependencies) => {
return computed(() => (
extensions.get()
.flatMap((extension) => extension.appPreferences)
.map(getRegisteredItem)
));
};
@@ -14,10 +14,12 @@ import { isWindows } from "../../../common/vars";
import { Switch } from "../switch";
import moment from "moment-timezone";
import { CONSTANTS, defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../common/user-store/preferences-helpers";
import { action } from "mobx";
import { action, IComputedValue } from "mobx";
import { isUrl } from "../input/input_validators";
import { AppPreferenceRegistry } from "../../../extensions/registries";
import { ExtensionSettings } from "./extension-settings";
import type { RegisteredAppPreference } from "./app-preferences/app-preference-registration";
import { withInjectables } from "@ogre-tools/injectable-react";
import appPreferencesInjectable from "./app-preferences/app-preferences.injectable";
const timezoneOptions: SelectOption<string>[] = moment.tz.names().map(zone => ({
label: zone,
@@ -28,7 +30,11 @@ const updateChannelOptions: SelectOption<string>[] = Array.from(
([value, { label }]) => ({ value, label }),
);
export const Application = observer(() => {
interface Dependencies {
appPreferenceItems: IComputedValue<RegisteredAppPreference[]>
}
const NonInjectedApplication: React.FC<Dependencies> = ({ appPreferenceItems }) => {
const userStore = UserStore.getInstance();
const defaultShell = process.env.SHELL
|| process.env.PTYSHELL
@@ -40,25 +46,39 @@ export const Application = observer(() => {
const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || "");
const [shell, setShell] = React.useState(userStore.shell || "");
const extensionSettings = AppPreferenceRegistry.getInstance().getItems().filter((preference) => preference.showInPreferencesTab === "application");
const extensionSettings = appPreferenceItems.get().filter((preference) => preference.showInPreferencesTab === "application");
const themeStore = ThemeStore.getInstance();
return (
<section id="application">
<h2 data-testid="application-header">Application</h2>
<section id="appearance">
<SubTitle title="Theme"/>
<SubTitle title="Theme" />
<Select
options={ThemeStore.getInstance().themeOptions}
options={themeStore.themeOptions}
value={userStore.colorTheme}
onChange={({ value }) => userStore.colorTheme = value}
themeName="lens"
/>
</section>
<hr/>
<hr />
<section id="terminalTheme">
<SubTitle title="Terminal theme" />
<Select
themeName="lens"
options={[
{ label: "Match theme", value: "" },
...themeStore.themeOptions,
]}
value={userStore.terminalTheme}
onChange={({ value }) => userStore.terminalTheme = value}
/>
</section>
<section id="shell">
<SubTitle title="Terminal Shell Path"/>
<SubTitle title="Terminal Shell Path" />
<Input
theme="round-black"
placeholder={defaultShell}
@@ -78,7 +98,7 @@ export const Application = observer(() => {
</Switch>
</section>
<hr/>
<hr />
<section id="extensionRegistryUrl">
<SubTitle title="Extension Install Registry" />
@@ -111,10 +131,10 @@ export const Application = observer(() => {
/>
</section>
<hr/>
<hr />
<section id="other">
<SubTitle title="Start-up"/>
<SubTitle title="Start-up" />
<Switch checked={userStore.openAtLogin} onChange={() => userStore.openAtLogin = !userStore.openAtLogin}>
Automatically start Lens on login
</Switch>
@@ -127,7 +147,7 @@ export const Application = observer(() => {
))}
<section id="update-channel">
<SubTitle title="Update Channel"/>
<SubTitle title="Update Channel" />
<Select
options={updateChannelOptions}
value={userStore.updateChannel}
@@ -149,4 +169,14 @@ export const Application = observer(() => {
</section>
</section>
);
});
};
export const Application = withInjectables<Dependencies>(
observer(NonInjectedApplication),
{
getProps: (di) => ({
appPreferenceItems: di.inject(appPreferencesInjectable),
}),
},
);
@@ -3,12 +3,12 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { SubTitle } from "../layout/sub-title";
import type { RegisteredAppPreference } from "../../../extensions/registries/app-preference-registry";
import type { AppPreferenceRegistration } from "./app-preferences/app-preference-registration";
import React from "react";
import { cssNames } from "../../../renderer/utils";
interface ExtensionSettingsProps {
setting: RegisteredAppPreference;
setting: AppPreferenceRegistration;
size: "small" | "normal"
}
@@ -3,13 +3,21 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { withInjectables } from "@ogre-tools/injectable-react";
import type { IComputedValue } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import { AppPreferenceRegistry } from "../../../extensions/registries";
import type { RegisteredAppPreference } from "./app-preferences/app-preference-registration";
import appPreferencesInjectable from "./app-preferences/app-preferences.injectable";
import { ExtensionSettings } from "./extension-settings";
export const Extensions = observer(() => {
const settings = AppPreferenceRegistry.getInstance().getItems();
interface Dependencies {
appPreferenceItems: IComputedValue<RegisteredAppPreference[]>
}
const NonInjectedExtensions: React.FC<Dependencies> = ({ appPreferenceItems }) => {
const settings = appPreferenceItems.get();
return (
<section id="extensions">
@@ -19,4 +27,14 @@ export const Extensions = observer(() => {
)}
</section>
);
});
};
export const Extensions = withInjectables<Dependencies>(
observer(NonInjectedExtensions),
{
getProps: (di) => ({
appPreferenceItems: di.inject(appPreferencesInjectable),
}),
},
);
@@ -4,7 +4,7 @@
*/
import "./preferences.scss";
import { makeObservable, observable } from "mobx";
import type { IComputedValue } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import { matchPath, Redirect, Route, RouteProps, Switch } from "react-router";
@@ -23,7 +23,6 @@ import {
telemetryRoute,
telemetryURL,
} from "../../../common/routes";
import { AppPreferenceRegistry } from "../../../extensions/registries/app-preference-registry";
import { navigateWithoutHistoryChange, navigation } from "../../navigation";
import { SettingLayout } from "../layout/setting-layout";
import { Tab, Tabs } from "../tabs";
@@ -34,18 +33,18 @@ import { LensProxy } from "./proxy";
import { Telemetry } from "./telemetry";
import { Extensions } from "./extensions";
import { sentryDsn } from "../../../common/vars";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { RegisteredAppPreference } from "./app-preferences/app-preference-registration";
import appPreferencesInjectable from "./app-preferences/app-preferences.injectable";
@observer
export class Preferences extends React.Component {
@observable historyLength: number | undefined;
interface Dependencies {
appPreferenceItems: IComputedValue<RegisteredAppPreference[]>
}
constructor(props: {}) {
super(props);
makeObservable(this);
}
const NonInjectedPreferences: React.FC<Dependencies> = ({ appPreferenceItems }) => {
renderNavigation() {
const extensions = AppPreferenceRegistry.getInstance().getItems();
function renderNavigation() {
const extensions = appPreferenceItems.get();
const telemetryExtensions = extensions.filter(e => e.showInPreferencesTab == "telemetry");
const currentLocation = navigation.location.pathname;
const isActive = (route: RouteProps) => !!matchPath(currentLocation, { path: route.path, exact: route.exact });
@@ -67,23 +66,31 @@ export class Preferences extends React.Component {
);
}
render() {
return (
<SettingLayout
navigation={this.renderNavigation()}
className="Preferences"
contentGaps={false}
>
<Switch>
<Route path={appURL()} component={Application}/>
<Route path={proxyURL()} component={LensProxy}/>
<Route path={kubernetesURL()} component={Kubernetes}/>
<Route path={editorURL()} component={Editor}/>
<Route path={telemetryURL()} component={Telemetry}/>
<Route path={extensionURL()} component={Extensions}/>
<Redirect exact from={`${preferencesURL()}/`} to={appURL()}/>
</Switch>
</SettingLayout>
);
}
}
return (
<SettingLayout
navigation={renderNavigation()}
className="Preferences"
contentGaps={false}
>
<Switch>
<Route path={appURL()} component={Application}/>
<Route path={proxyURL()} component={LensProxy}/>
<Route path={kubernetesURL()} component={Kubernetes}/>
<Route path={editorURL()} component={Editor}/>
<Route path={telemetryURL()} component={Telemetry}/>
<Route path={extensionURL()} component={Extensions}/>
<Redirect exact from={`${preferencesURL()}/`} to={appURL()}/>
</Switch>
</SettingLayout>
);
};
export const Preferences = withInjectables<Dependencies>(
observer(NonInjectedPreferences),
{
getProps: (di) => ({
appPreferenceItems: di.inject(appPreferencesInjectable),
}),
},
);
@@ -6,13 +6,20 @@ import { observer } from "mobx-react";
import React from "react";
import { UserStore } from "../../../common/user-store";
import { sentryDsn } from "../../../common/vars";
import { AppPreferenceRegistry } from "../../../extensions/registries";
import { Checkbox } from "../checkbox";
import { SubTitle } from "../layout/sub-title";
import { ExtensionSettings } from "./extension-settings";
import type { RegisteredAppPreference } from "./app-preferences/app-preference-registration";
import appPreferencesInjectable from "./app-preferences/app-preferences.injectable";
import type { IComputedValue } from "mobx";
import { withInjectables } from "@ogre-tools/injectable-react";
export const Telemetry = observer(() => {
const extensions = AppPreferenceRegistry.getInstance().getItems();
interface Dependencies {
appPreferenceItems: IComputedValue<RegisteredAppPreference[]>
}
const NonInjectedTelemetry: React.FC<Dependencies> = ({ appPreferenceItems }) => {
const extensions = appPreferenceItems.get();
const telemetryExtensions = extensions.filter(e => e.showInPreferencesTab == "telemetry");
return (
@@ -44,4 +51,14 @@ export const Telemetry = observer(() => {
}
</section>
);
});
};
export const Telemetry = withInjectables<Dependencies>(
observer(NonInjectedTelemetry),
{
getProps: (di) => ({
appPreferenceItems: di.inject(appPreferencesInjectable),
}),
},
);
@@ -1,36 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { observer } from "mobx-react";
import { Icon } from "../icon";
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command";
import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
interface Dependencies {
openCommandOverlay: (component: React.ReactElement) => void;
activeHotbarName: () => string | undefined;
}
const NonInjectedActiveHotbarName = observer(({ openCommandOverlay, activeHotbarName }: Dependencies) => (
<div
className="flex items-center"
data-testid="current-hotbar-name"
onClick={() => openCommandOverlay(<HotbarSwitchCommand />)}
>
<Icon material="bookmarks" className="mr-2" size={14} />
{activeHotbarName()}
</div>
));
export const ActiveHotbarName = withInjectables<Dependencies>(NonInjectedActiveHotbarName, {
getProps: (di, props) => ({
activeHotbarName: () => di.inject(hotbarManagerInjectable).getActive()?.name,
openCommandOverlay: di.inject(commandOverlayInjectable).open,
...props,
}),
});
@@ -4,68 +4,24 @@
*/
import React from "react";
import mockFs from "mock-fs";
import { fireEvent } from "@testing-library/react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { BottomBar } from "./bottom-bar";
import { StatusBarRegistry } from "../../../extensions/registries";
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command";
import { ActiveHotbarName } from "./active-hotbar-name";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import { DiRender, renderFor } from "../test-utils/renderFor";
import type { DependencyInjectionContainer } from "@ogre-tools/injectable";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import { getEmptyHotbar } from "../../../common/hotbar-types";
jest.mock("electron", () => ({
app: {
getName: () => "lens",
setName: jest.fn(),
setPath: jest.fn(),
getPath: () => "tmp",
},
ipcMain: {
handle: jest.fn(),
on: jest.fn(),
removeAllListeners: jest.fn(),
off: jest.fn(),
send: jest.fn(),
getPath: () => "/foo",
},
}));
const foobarHotbar = getEmptyHotbar("foobar");
describe("<BottomBar />", () => {
let di: DependencyInjectionContainer;
let render: DiRender;
beforeEach(async () => {
const mockOpts = {
"tmp": {
"test-store.json": JSON.stringify({}),
},
};
di = getDiForUnitTesting({ doGeneralOverrides: true });
mockFs(mockOpts);
render = renderFor(di);
di.override(hotbarManagerInjectable, () => ({
getActive: () => foobarHotbar,
} as any));
await di.runSetups();
beforeEach(() => {
StatusBarRegistry.createInstance();
});
afterEach(() => {
StatusBarRegistry.resetInstance();
mockFs.restore();
});
it("renders w/o errors", () => {
@@ -111,33 +67,6 @@ describe("<BottomBar />", () => {
expect(getByTestId(testId)).toHaveTextContent(text);
});
it("shows active hotbar name", () => {
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
{ item: () => <ActiveHotbarName/> },
]);
const { getByTestId } = render(<BottomBar />);
expect(getByTestId("current-hotbar-name")).toHaveTextContent("foobar");
});
it("opens command palette on click", () => {
const mockOpen = jest.fn();
di.override(commandOverlayInjectable, () => ({
open: mockOpen,
}) as any);
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
{ item: () => <ActiveHotbarName/> },
]);
const { getByTestId } = render(<BottomBar />);
const activeHotbar = getByTestId("current-hotbar-name");
fireEvent.click(activeHotbar);
expect(mockOpen).toHaveBeenCalledWith(<HotbarSwitchCommand />);
});
it("sort positioned items properly", () => {
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
+4 -1
View File
@@ -60,11 +60,14 @@
.tab-content {
position: relative;
background: var(--terminalBackground);
flex: 1;
overflow: hidden;
transition: flex-basis 25ms ease-in;
&.terminal {
background: var(--terminalBackground);
}
> *:not(.Spinner) {
position: absolute;
left: 0;
+1 -1
View File
@@ -97,7 +97,7 @@ class NonInjectedDock extends React.Component<Props & Dependencies> {
if (!isOpen || !selectedTab) return null;
return (
<div className="tab-content" style={{ flexBasis: height }}>
<div className={`tab-content ${selectedTab.kind}`} style={{ flexBasis: height }}>
{this.renderTab(selectedTab)}
</div>
);
@@ -10,9 +10,9 @@ import { FitAddon } from "xterm-addon-fit";
import type { DockStore, TabId } from "../dock-store/dock.store";
import { TerminalApi, TerminalChannels } from "../../../api/terminal-api";
import { ThemeStore } from "../../../theme.store";
import { boundMethod, disposer } from "../../../utils";
import { disposer } from "../../../utils";
import { isMac } from "../../../../common/vars";
import { camelCase, once } from "lodash";
import { once } from "lodash";
import { UserStore } from "../../../../common/user-store";
import { clipboard } from "electron";
import logger from "../../../../common/logger";
@@ -44,23 +44,6 @@ export class Terminal {
private scrollPos = 0;
private disposer = disposer();
@boundMethod
protected setTheme(colors: Record<string, string>) {
if (!this.xterm) {
return;
}
// Replacing keys stored in styles to format accepted by terminal
// E.g. terminalBrightBlack -> brightBlack
const colorPrefix = "terminal";
const terminalColorEntries = Object.entries(colors)
.filter(([name]) => name.startsWith(colorPrefix))
.map(([name, color]) => [camelCase(name.slice(colorPrefix.length)), color]);
const terminalColors = Object.fromEntries(terminalColorEntries);
this.xterm.setOption("theme", terminalColors);
}
get elem() {
return this.xterm?.element;
}
@@ -109,7 +92,9 @@ export class Terminal {
window.addEventListener("resize", this.onResize);
this.disposer.push(
reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, {
reaction(() => ThemeStore.getInstance().xtermColors, colors => {
this.xterm?.setOption("theme", colors);
}, {
fireImmediately: true,
}),
dependencies.dockStore.onResize(this.onResize),
@@ -54,6 +54,7 @@ export class EditableList<T> extends React.Component<Props<T>> {
onSubmit={this.onSubmit}
validators={validators}
placeholder={placeholder}
blurOnEnter={false}
iconRight={({ isDirty }) => isDirty ? <Icon material="keyboard_return" size={16} /> : null}
/>
</div>
@@ -4,6 +4,8 @@
*/
.HotbarSelector {
display: flex;
align-items: center;
height: 26px;
background-color: var(--layoutBackground);
position: relative;
@@ -17,7 +19,13 @@
top: -20px;
}
.SelectorIndex {
.HotbarIndex {
display: flex;
flex-grow: 1;
align-items: center;
}
.Badge {
cursor: pointer;
background: var(--secondaryBackground);
width: 100%;
@@ -3,21 +3,18 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./hotbar-selector.scss";
import React from "react";
import styles from "./hotbar-selector.module.scss";
import React, { useRef, useState } from "react";
import { Icon } from "../icon";
import { Badge } from "../badge";
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { HotbarSwitchCommand } from "./hotbar-switch-command";
import { TooltipPosition } from "../tooltip";
import { Tooltip, TooltipPosition } from "../tooltip";
import { observer } from "mobx-react";
import type { Hotbar } from "../../../common/hotbar-types";
import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
export interface HotbarSelectorProps {
hotbar: Hotbar;
}
import { cssNames } from "../../utils";
interface Dependencies {
hotbarManager: {
@@ -29,25 +26,63 @@ interface Dependencies {
openCommandOverlay: (component: React.ReactElement) => void;
}
const NonInjectedHotbarSelector = observer(({ hotbar, hotbarManager, openCommandOverlay }: HotbarSelectorProps & Dependencies) => (
<div className="HotbarSelector flex align-center">
<Icon material="play_arrow" className="previous box" onClick={() => hotbarManager.switchToPrevious()} />
<div className="box grow flex align-center">
<Badge
id="hotbarIndex"
small
label={hotbarManager.getDisplayIndex(hotbarManager.getActive())}
onClick={() => openCommandOverlay(<HotbarSwitchCommand />)}
tooltip={{
preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT],
children: hotbar.name,
}}
className="SelectorIndex"
export interface HotbarSelectorProps extends Partial<Dependencies> {
hotbar: Hotbar;
}
const NonInjectedHotbarSelector = observer(({ hotbar, hotbarManager, openCommandOverlay }: HotbarSelectorProps & Dependencies) => {
const [tooltipVisible, setTooltipVisible] = useState(false);
const tooltipTimeout = useRef<NodeJS.Timeout>();
function clearTimer() {
clearTimeout(tooltipTimeout.current);
}
function onTooltipShow() {
setTooltipVisible(true);
clearTimer();
tooltipTimeout.current = setTimeout(() => setTooltipVisible(false), 1500);
}
function onArrowClick(switchTo: () => void) {
onTooltipShow();
switchTo();
}
function onMouseEvent(event: React.MouseEvent) {
clearTimer();
setTooltipVisible(event.type == "mouseenter");
}
return (
<div className={styles.HotbarSelector}>
<Icon
material="play_arrow"
className={cssNames(styles.Icon, styles.previous)}
onClick={() => onArrowClick(hotbarManager.switchToPrevious)}
/>
<div className={styles.HotbarIndex}>
<Badge
id="hotbarIndex"
small
label={hotbarManager.getDisplayIndex(hotbarManager.getActive())}
onClick={() => openCommandOverlay(<HotbarSwitchCommand />)}
className={styles.Badge}
onMouseEnter={onMouseEvent}
onMouseLeave={onMouseEvent}
/>
<Tooltip
visible={tooltipVisible}
targetId="hotbarIndex"
preferredPositions={[TooltipPosition.TOP, TooltipPosition.TOP_LEFT]}
>
{hotbar.name}
</Tooltip>
</div>
<Icon material="play_arrow" className={styles.Icon} onClick={() => onArrowClick(hotbarManager.switchToNext)} />
</div>
<Icon material="play_arrow" className="next box" onClick={() => hotbarManager.switchToNext()} />
</div>
));
);
});
export const HotbarSelector = withInjectables<Dependencies, HotbarSelectorProps>(NonInjectedHotbarSelector, {
getProps: (di, props) => ({
+7
View File
@@ -52,6 +52,7 @@ export type InputProps = Omit<InputElementProps, "onChange" | "onSubmit"> & {
iconRight?: IconData;
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator | InputValidator[];
blurOnEnter?: boolean;
onChange?(value: string, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: string, evt: React.KeyboardEvent<InputElement>): void;
};
@@ -70,6 +71,7 @@ const defaultProps: Partial<InputProps> = {
maxRows: 10000,
showValidationLine: true,
validators: [],
blurOnEnter: true,
};
export class Input extends React.Component<InputProps, State> {
@@ -267,6 +269,11 @@ export class Input extends React.Component<InputProps, State> {
} else {
this.setDirty();
}
if(this.props.blurOnEnter){
//pressing enter indicates that the edit is complete, we can unfocus now
this.blur();
}
}
}
+58 -10
View File
@@ -14,18 +14,66 @@ import { Checkbox } from "../checkbox";
export type TableCellElem = React.ReactElement<TableCellProps>;
export interface TableCellProps extends React.DOMAttributes<HTMLDivElement> {
id?: string; // used for configuration visibility of columns
/**
* used for configuration visibility of columns
*/
id?: string;
/**
* Any css class names for this table cell. Only used if `title` is a "simple" react node
*/
className?: string;
/**
* The actual value of the cell
*/
title?: ReactNode;
scrollable?: boolean; // content inside could be scrolled
checkbox?: boolean; // render cell with a checkbox
isChecked?: boolean; // mark checkbox as checked or not
renderBoolean?: boolean; // show "true" or "false" for all of the children elements are "typeof boolean"
sortBy?: TableSortBy; // column name, must be same as key in sortable object <Table sortable={}/>
showWithColumn?: string // id of the column which follow same visibility rules
_sorting?: Partial<TableSortParams>; // <Table> sorting state, don't use this prop outside (!)
_sort?(sortBy: TableSortBy): void; // <Table> sort function, don't use this prop outside (!)
_nowrap?: boolean; // indicator, might come from parent <TableHead>, don't use this prop outside (!)
/**
* content inside could be scrolled
*/
scrollable?: boolean;
/**
* render cell with a checkbox
*/
checkbox?: boolean;
/**
* mark checkbox as checked or not
*/
isChecked?: boolean;
/**
* show "true" or "false" for all of the children elements are "typeof boolean"
*/
renderBoolean?: boolean;
/**
* column name, must be same as key in sortable object <Table sortable={}/>
*/
sortBy?: TableSortBy;
/**
* id of the column which follow same visibility rules
*/
showWithColumn?: string
/**
* @internal
*/
_sorting?: Partial<TableSortParams>;
/**
* @internal
*/
_sort?(sortBy: TableSortBy): void;
/**
* @internal
* indicator, might come from parent <TableHead>, don't use this prop outside (!)
*/
_nowrap?: boolean;
}
export class TableCell extends React.Component<TableCellProps> {
+7 -6
View File
@@ -25,14 +25,15 @@
pointer-events: none;
transition: opacity 150ms 150ms ease-in-out;
z-index: 100000;
opacity: 1;
box-shadow: 0 8px 16px rgba(0,0,0,0.24);
left: 0;
top: 0;
opacity: 0;
visibility: hidden;
&.invisible {
left: 0;
top: 0;
opacity: 0;
visibility: hidden;
&.visible {
opacity: 1;
visibility: visible;
}
&:empty {
+7 -3
View File
@@ -54,7 +54,7 @@ export class Tooltip extends React.Component<TooltipProps> {
@observable.ref elem: HTMLElement;
@observable activePosition: TooltipPosition;
@observable isVisible = !!this.props.visible;
@observable isVisible = false;
constructor(props: TooltipProps) {
super(props);
@@ -78,6 +78,10 @@ export class Tooltip extends React.Component<TooltipProps> {
this.hoverTarget.addEventListener("mouseleave", this.onLeaveTarget);
}
componentDidUpdate() {
this.refreshPosition();
}
componentWillUnmount() {
this.hoverTarget.removeEventListener("mouseenter", this.onEnterTarget);
this.hoverTarget.removeEventListener("mouseleave", this.onLeaveTarget);
@@ -210,9 +214,9 @@ export class Tooltip extends React.Component<TooltipProps> {
}
render() {
const { style, formatters, usePortal, children } = this.props;
const { style, formatters, usePortal, children, visible } = this.props;
const className = cssNames("Tooltip", this.props.className, formatters, this.activePosition, {
invisible: !this.isVisible,
visible: visible ?? this.isVisible,
formatter: !!formatters,
});
const tooltip = (
+17 -2
View File
@@ -10,6 +10,7 @@ import { Button } from "../button";
import { Stepper } from "../stepper";
import { SubTitle } from "../layout/sub-title";
import { Spinner } from "../spinner";
import { debounce } from "lodash";
interface WizardCommonProps<D = any> {
data?: Partial<D>;
@@ -179,14 +180,16 @@ export class WizardStep extends React.Component<WizardStepProps, WizardStepState
}
};
submit = () => {
//because submit MIGHT be called through pressing enter, it might be fired twice.
//we'll debounce it to ensure it isn't
submit = debounce(() => {
if (!this.form.noValidate) {
const valid = this.form.checkValidity();
if (!valid) return;
}
this.next();
};
}, 100);
renderLoading() {
return (
@@ -196,6 +199,17 @@ export class WizardStep extends React.Component<WizardStepProps, WizardStepState
);
}
//make sure we call submit if the "enter" keypress doesn't trigger the events
keyDown(evt: React.KeyboardEvent<HTMLElement>) {
if (evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey || evt.repeat) {
return;
}
if(evt.key === "Enter"){
this.submit();
}
}
render() {
const {
step, isFirst, isLast, children,
@@ -216,6 +230,7 @@ export class WizardStep extends React.Component<WizardStepProps, WizardStepState
return (
<form className={className}
onSubmit={prevDefault(this.submit)} noValidate={noValidate}
onKeyDown={(evt) => this.keyDown(evt)}
ref={e => this.form = e}>
{beforeContent}
<div className={contentClass}>
@@ -19,7 +19,7 @@ import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store";
interface Dependencies {
hostedCluster: Cluster;
loadExtensions: (entity: CatalogEntity) => void;
loadExtensions: (getCluster: () => CatalogEntity) => void;
catalogEntityRegistry: CatalogEntityRegistry;
frameRoutingId: number;
emitEvent: (event: AppEvent) => void;
@@ -47,11 +47,12 @@ export const initClusterFrame =
catalogEntityRegistry.activeEntity = hostedCluster.id;
// Only load the extensions once the catalog has been populated
// Only load the extensions once the catalog has been populated.
// Note that the Catalog might still have unprocessed entities until the extensions are fully loaded.
when(
() => Boolean(catalogEntityRegistry.activeEntity),
() => catalogEntityRegistry.items.length > 0,
() =>
loadExtensions(catalogEntityRegistry.activeEntity as KubernetesCluster),
loadExtensions(() => catalogEntityRegistry.activeEntity as KubernetesCluster),
{
timeout: 15_000,
onError: (error) => {
@@ -11,15 +11,15 @@ import type { ExtensionLoading } from "../../../../extensions/extension-loader";
import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry";
interface Dependencies {
loadExtensions: () => ExtensionLoading[]
loadExtensions: () => Promise<ExtensionLoading[]>;
// TODO: Move usages of third party library behind abstraction
ipcRenderer: { send: (name: string) => void }
ipcRenderer: { send: (name: string) => void };
// TODO: Remove dependencies being here only for correct timing of initialization
bindProtocolAddRouteHandlers: () => void;
lensProtocolRouterRenderer: { init: () => void };
catalogEntityRegistry: CatalogEntityRegistry
catalogEntityRegistry: CatalogEntityRegistry;
}
const logPrefix = "[ROOT-FRAME]:";
@@ -40,7 +40,7 @@ export const initRootFrame =
// maximum time to let bundled extensions finish loading
const timeout = delay(10000);
const loadingExtensions = loadExtensions();
const loadingExtensions = await loadExtensions();
const loadingBundledExtensions = loadingExtensions
.filter((e) => e.isBundled)
-1
View File
@@ -12,4 +12,3 @@ export * from "./kube-object-menu-registry";
export * from "./registries";
export * from "./workloads-overview-detail-registry";
export * from "./catalog-category-registry";
export * from "./status-bar-registry";
-1
View File
@@ -6,7 +6,6 @@
import * as registries from "../../extensions/registries";
export function initRegistries() {
registries.AppPreferenceRegistry.createInstance();
registries.CatalogEntityDetailRegistry.createInstance();
registries.ClusterPageMenuRegistry.createInstance();
registries.ClusterPageRegistry.createInstance();
@@ -1,19 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { StatusBarRegistry } from "../../extensions/registries";
import { ActiveHotbarName } from "../components/cluster-manager/active-hotbar-name";
export function initStatusBarRegistry() {
StatusBarRegistry.getInstance().add([
{
components: {
Item: () => <ActiveHotbarName/>,
position: "left",
},
},
]);
}
+43 -17
View File
@@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { computed, makeObservable, observable, reaction } from "mobx";
import { comparer, computed, makeObservable, observable, reaction } from "mobx";
import { autoBind, Singleton } from "./utils";
import { UserStore } from "../common/user-store";
import logger from "../main/logger";
@@ -12,6 +12,7 @@ import lensLightThemeJson from "./themes/lens-light.json";
import type { SelectOption } from "./components/select";
import type { MonacoEditorProps } from "./components/monaco-editor";
import { defaultTheme } from "../common/vars";
import { camelCase } from "lodash";
export type ThemeId = string;
@@ -25,7 +26,7 @@ export interface Theme {
}
export class ThemeStore extends Singleton {
protected styles: HTMLStyleElement;
private terminalColorPrefix = "terminal";
// bundled themes from `themes/${themeId}.json`
private themes = observable.map<ThemeId, Theme>({
@@ -33,14 +34,37 @@ export class ThemeStore extends Singleton {
"lens-light": lensLightThemeJson as Theme,
});
@computed get activeThemeId(): string {
@computed get activeThemeId(): ThemeId {
return UserStore.getInstance().colorTheme;
}
@computed get terminalThemeId(): ThemeId {
return UserStore.getInstance().terminalTheme;
}
@computed get activeTheme(): Theme {
return this.themes.get(this.activeThemeId) ?? this.themes.get(defaultTheme);
}
@computed get terminalColors(): [string, string][] {
const theme = this.themes.get(this.terminalThemeId) ?? this.activeTheme;
return Object
.entries(theme.colors)
.filter(([name]) => name.startsWith(this.terminalColorPrefix));
}
// Replacing keys stored in styles to format accepted by terminal
// E.g. terminalBrightBlack -> brightBlack
@computed get xtermColors(): Record<string, string> {
return Object.fromEntries(
this.terminalColors.map(([name, color]) => [
camelCase(name.replace(this.terminalColorPrefix, "")),
color,
]),
);
}
@computed get themeOptions(): SelectOption<string>[] {
return Array.from(this.themes).map(([themeId, theme]) => ({
label: theme.name,
@@ -55,15 +79,19 @@ export class ThemeStore extends Singleton {
autoBind(this);
// auto-apply active theme
reaction(() => this.activeThemeId, themeId => {
reaction(() => ({
themeId: this.activeThemeId,
terminalThemeId: this.terminalThemeId,
}), ({ themeId }) => {
try {
this.applyTheme(this.getThemeById(themeId));
this.applyTheme(themeId);
} catch (err) {
logger.error(err);
UserStore.getInstance().resetTheme();
}
}, {
fireImmediately: true,
equals: comparer.shallow,
});
}
@@ -71,20 +99,18 @@ export class ThemeStore extends Singleton {
return this.themes.get(themeId);
}
protected applyTheme(theme: Theme) {
if (!this.styles) {
this.styles = document.createElement("style");
this.styles.id = "lens-theme";
document.head.append(this.styles);
}
const cssVars = Object.entries(theme.colors).map(([cssName, color]) => {
return `--${cssName}: ${color};`;
protected applyTheme(themeId: ThemeId) {
const theme = this.getThemeById(themeId);
const colors = Object.entries({
...theme.colors,
...Object.fromEntries(this.terminalColors),
});
this.styles.textContent = `:root {\n${cssVars.join("\n")}}`;
// Adding universal theme flag which can be used in component styles
const body = document.querySelector("body");
colors.forEach(([name, value]) => {
document.documentElement.style.setProperty(`--${name}`, value);
});
body.classList.toggle("theme-light", theme.type === "light");
// Adding universal theme flag which can be used in component styles
document.body.classList.toggle("theme-light", theme.type === "light");
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "Dark (Lens)",
"name": "Dark",
"type": "dark",
"description": "Original Lens dark theme",
"author": "Mirantis",
+20 -20
View File
@@ -1,5 +1,5 @@
{
"name": "Light (Lens)",
"name": "Light",
"type": "light",
"description": "Original Lens light theme",
"author": "Mirantis",
@@ -76,26 +76,26 @@
"logsBackground": "#24292e",
"logsForeground": "#ffffff",
"logRowHoverBackground": "#35373a",
"terminalBackground": "#24292e",
"terminalForeground": "#ffffff",
"terminalCursor": "#ffffff",
"terminalCursorAccent": "#000000",
"terminalSelection": "#ffffff77",
"terminalBlack": "#2e3436",
"terminalRed": "#cc0000",
"terminalGreen": "#4e9a06",
"terminalYellow": "#c4a000",
"terminalBlue": "#3465a4",
"terminalMagenta": "#75507b",
"terminalCyan": "#06989a",
"terminalBackground": "#ffffff",
"terminalForeground": "#2d2d2d",
"terminalCursor": "#2d2d2d",
"terminalCursorAccent": "#ffffff",
"terminalSelection": "#bfbfbf",
"terminalBlack": "#2d2d2d",
"terminalRed": "#cd3734 ",
"terminalGreen": "#18cf12",
"terminalYellow": "#acb300",
"terminalBlue": "#3d90ce",
"terminalMagenta": "#c100cd",
"terminalCyan": "#07c4b9",
"terminalWhite": "#d3d7cf",
"terminalBrightBlack": "#555753",
"terminalBrightRed": "#ef2929",
"terminalBrightGreen": "#8ae234",
"terminalBrightYellow": "#fce94f",
"terminalBrightBlue": "#729fcf",
"terminalBrightMagenta": "#ad7fa8",
"terminalBrightCyan": "#34e2e2",
"terminalBrightBlack": "#a8a8a8",
"terminalBrightRed": "#ff6259",
"terminalBrightGreen": "#5cdb59",
"terminalBrightYellow": "#f8c000",
"terminalBrightBlue": "#008db6",
"terminalBrightMagenta": "#ee55f8",
"terminalBrightCyan": "#50e8df",
"terminalBrightWhite": "#eeeeec",
"dialogTextColor": "#87909c",
"dialogBackground": "#ffffff",