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

Merge branch 'master' into extension-auto-update

This commit is contained in:
Lauri Nevala 2022-01-20 13:35:13 +02:00 committed by GitHub
commit d26322df1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 1318 additions and 806 deletions

View File

@ -16,6 +16,12 @@ module.exports = {
react: { react: {
version: packageJson.devDependencies.react || "detect", version: packageJson.devDependencies.react || "detect",
}, },
// the package eslint-import-resolver-typescript is required for this line which fixes errors when using .d.ts files
"import/resolver": {
"typescript": {
"alwaysTryTypes": true,
},
},
}, },
overrides: [ overrides: [
{ {

View File

@ -103,8 +103,12 @@ publish-npm: node_modules build-npm
cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG) cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG)
git restore src/extensions/npm/extensions/package.json git restore src/extensions/npm/extensions/package.json
.PHONY: build-docs
build-docs:
yarn typedocs-extensions-api
.PHONY: docs .PHONY: docs
docs: docs: build-docs
yarn mkdocs-serve-local yarn mkdocs-serve-local
.PHONY: clean-extensions .PHONY: clean-extensions

View File

@ -15,24 +15,26 @@ Each guide or code sample includes the following:
## Guides ## Guides
| Guide | APIs | | Guide | APIs |
| ----- | ----- | | --------------------------------------------------------------- | ---------------------- |
| [Generate new extension project](generator.md) | | | [Generate new extension project](generator.md) | |
| [Main process extension](main-extension.md) | Main.LensExtension | | [Main process extension](main-extension.md) | Main.LensExtension |
| [Renderer process extension](renderer-extension.md) | Renderer.LensExtension | | [Renderer process extension](renderer-extension.md) | Renderer.LensExtension |
| [Resource stack (cluster feature)](resource-stack.md) | | | [Resource stack (cluster feature)](resource-stack.md) | |
| [Extending KubernetesCluster)](extending-kubernetes-cluster.md) | |
| [Stores](stores.md) | | | [Stores](stores.md) | |
| [Components](components.md) | | | [Components](components.md) | |
| [KubeObjectListLayout](kube-object-list-layout.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | |
| [Working with mobx](working-with-mobx.md) | | | [Working with mobx](working-with-mobx.md) | |
| [Protocol Handlers](protocol-handlers.md) | | | [Protocol Handlers](protocol-handlers.md) | |
| [Sending Data between main and renderer](ipc.md) | | | [Sending Data between main and renderer](ipc.md) | |
| [Catalog Entities and Categories](catalog.md) | |
## Samples ## Samples
| Sample | APIs | | Sample | APIs |
| ----- | ----- | | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps | | [hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps | | [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps | | [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps | | [styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension <br> Renderer.K8sApi.KubeApi <br> Renderer.K8sApi.KubeObjectStore <br> Renderer.Component.KubeObjectListLayout <br> Renderer.Component.KubeObjectDetailsProps <br> Renderer.Component.IconProps | | [custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension <br> Renderer.K8sApi.KubeApi <br> Renderer.K8sApi.KubeObjectStore <br> Renderer.Component.KubeObjectListLayout <br> Renderer.Component.KubeObjectDetailsProps <br> Renderer.Component.IconProps |

View File

@ -1,5 +1,27 @@
# Catalog (WIP) # Catalog (WIP)
## CatalogCategoryRegistry This guide is a brief overview about how the catalog works within Lens.
The catalog should be thought of as the single source of truth about data within Lens.
## CatalogEntityRegistry The data flow is unidirectional, it only flows from the main side to the renderer side.
All data is public within the catalog.
## Categories
A category is the declaration to the catalog of a specific kind of entity.
It declares the currently supported versions of that kind of entity but providing the constructors for the entity classes.
To declare a new category class you must create a new class that extends [Common.Catalog.CatalogCategory](../api/classes/Common.Catalog.CatalogCategory.md) and implement all of the abstract fields.
The categories provided by Lens itself have the following names:
- `KubernetesClusters`
- `WebLinks`
- `General`
To register a category, call the `Main.Catalog.catalogCategories.add()` and `Renderer.Catalog.catalogCategories.add()` with instances of your class.
## Entities
An entity is the data within the catalog.
All entities are typed and the class instances will be recreated on the renderer side by the catalog and the category registrations.

View File

@ -0,0 +1,69 @@
# Extending KubernetesCluster
Extension can specify it's own subclass of Common.Catalog.KubernetesCluster. Extension can also specify a new Category for it in the Catalog.
## Extending Common.Catalog.KubernetesCluster
``` typescript
import { Common } from "@k8slens/extensions";
// The kind must be different from KubernetesCluster's kind
export const kind = "ManagedDevCluster";
export class ManagedDevCluster extends Common.Catalog.KubernetesCluster {
public static readonly kind = kind;
public readonly kind = kind;
}
```
## Extending Common.Catalog.CatalogCategory
These custom Catalog entities can be added a new Category in the Catalog.
``` typescript
import { Common } from "@k8slens/extensions";
import { kind, ManagedDevCluster } from "../entities/ManagedDevCluster";
class ManagedDevClusterCategory extends Common.Catalog.CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "Managed Dev Clusters",
icon: ""
};
public spec: Common.Catalog.CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [
{
name: "v1alpha1",
entityClass: ManagedDevCluster as any,
},
],
names: {
kind
},
};
}
export { ManagedDevClusterCategory };
export type { ManagedDevClusterCategory as ManagedDevClusterCategoryType };
```
The category needs to be registered in the `onActivate()` method both in main and renderer
``` typescript
// in main's on onActivate
Main.Catalog.catalogCategories.add(new ManagedDevClusterCategory());
```
``` typescript
// in renderer's on onActivate
Renderer.Catalog.catalogCategories.add(new ManagedDevClusterCategory());
```
You can then add the entities to the Catalog as a new source:
``` typescript
this.addCatalogSource("managedDevClusters", this.managedDevClusters);
```

View File

@ -16,11 +16,11 @@ import { Main } from "@k8slens/extensions";
export default class ExampleExtensionMain extends Main.LensExtension { export default class ExampleExtensionMain extends Main.LensExtension {
onActivate() { onActivate() {
console.log('custom main process extension code started'); console.log("custom main process extension code started");
} }
onDeactivate() { onDeactivate() {
console.log('custom main process extension de-activated'); console.log("custom main process extension de-activated");
} }
} }
``` ```
@ -40,7 +40,7 @@ The example above logs messages when the extension is enabled and disabled.
To see standard output from the main process there must be a console connected to it. To see standard output from the main process there must be a console connected to it.
Achieve this by starting Lens from the command prompt. Achieve this by starting Lens from the command prompt.
For more details on accessing Lens state data, please see the [Stores](../stores) guide. For more details on accessing Lens state data, please see the [Stores](stores.md) guide.
### `appMenus` ### `appMenus`
@ -57,9 +57,9 @@ export default class SamplePageMainExtension extends Main.LensExtension {
label: "Sample", label: "Sample",
click() { click() {
console.log("Sample clicked"); console.log("Sample clicked");
} },
} },
] ];
} }
``` ```
@ -67,11 +67,11 @@ export default class SamplePageMainExtension extends Main.LensExtension {
`MenuRegistration` extends Electron's `MenuItemConstructorOptions` interface. `MenuRegistration` extends Electron's `MenuItemConstructorOptions` interface.
The properties of the appMenus array objects are defined as follows: The properties of the appMenus array objects are defined as follows:
* `parentId` is the name of the menu where your new menu item will be listed. - `parentId` is the name of the menu where your new menu item will be listed.
Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`. Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`.
`"lens"` is valid on Mac only. `"lens"` is valid on Mac only.
* `label` is the name of your menu item. - `label` is the name of your menu item.
* `click()` is called when the menu item is selected. - `click()` is called when the menu item is selected.
In this example, we simply log a message. In this example, we simply log a message.
However, you would typically have this navigate to a specific page or perform another operation. However, you would typically have this navigate to a specific page or perform another operation.
Note that pages are associated with the [`Renderer.LensExtension`](renderer-extension.md) class and can be defined in the process of extending it. Note that pages are associated with the [`Renderer.LensExtension`](renderer-extension.md) class and can be defined in the process of extending it.
@ -86,9 +86,9 @@ export default class SamplePageMainExtension extends Main.LensExtension {
{ {
parentId: "help", parentId: "help",
label: "Sample", label: "Sample",
click: () => this.navigate("myGlobalPage") click: () => this.navigate("myGlobalPage"),
} },
] ];
} }
``` ```
@ -104,10 +104,10 @@ interface TrayMenuRegistration {
label?: string; label?: string;
click?: (menuItem: TrayMenuRegistration) => void; click?: (menuItem: TrayMenuRegistration) => void;
id?: string; id?: string;
type?: "normal" | "separator" | "submenu" type?: "normal" | "separator" | "submenu";
toolTip?: string; toolTip?: string;
enabled?: boolean; enabled?: boolean;
submenu?: TrayMenuRegistration[] submenu?: TrayMenuRegistration[];
} }
``` ```
@ -117,10 +117,14 @@ The following example demonstrates how tray menus can be added from extension:
import { Main } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
export default class SampleTrayMenuMainExtension extends Main.LensExtension { export default class SampleTrayMenuMainExtension extends Main.LensExtension {
trayMenus = [{ trayMenus = [
{
label: "menu from the extension", label: "menu from the extension",
click: () => { console.log("tray menu clicked!") } click: () => {
}] console.log("tray menu clicked!");
},
},
];
} }
``` ```

View File

@ -24,6 +24,7 @@ nav:
- Renderer Extension: extensions/guides/renderer-extension.md - Renderer Extension: extensions/guides/renderer-extension.md
- Catalog: extensions/guides/catalog.md - Catalog: extensions/guides/catalog.md
- Resource Stack: extensions/guides/resource-stack.md - Resource Stack: extensions/guides/resource-stack.md
- Extending KubernetesCluster: extensions/guides/extending-kubernetes-cluster.md
- Stores: extensions/guides/stores.md - Stores: extensions/guides/stores.md
- Working with MobX: extensions/guides/working-with-mobx.md - Working with MobX: extensions/guides/working-with-mobx.md
- Protocol Handlers: extensions/guides/protocol-handlers.md - Protocol Handlers: extensions/guides/protocol-handlers.md

View File

@ -340,6 +340,7 @@
"esbuild": "^0.13.15", "esbuild": "^0.13.15",
"esbuild-loader": "^2.16.0", "esbuild-loader": "^2.16.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-header": "^3.1.1", "eslint-plugin-header": "^3.1.1",
"eslint-plugin-import": "^2.25.3", "eslint-plugin-import": "^2.25.3",
"eslint-plugin-react": "^7.27.1", "eslint-plugin-react": "^7.27.1",

View File

@ -5,7 +5,6 @@
import fs from "fs"; import fs from "fs";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import yaml from "js-yaml";
import path from "path"; import path from "path";
import fse from "fs-extra"; import fse from "fs-extra";
import type { Cluster } from "../cluster/cluster"; 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", () => { describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
beforeEach(() => { beforeEach(() => {
ClusterStore.resetInstance(); ClusterStore.resetInstance();

View File

@ -59,8 +59,8 @@ export interface KubernetesClusterStatus extends CatalogEntityStatus {
} }
export class KubernetesCluster extends CatalogEntity<KubernetesClusterMetadata, KubernetesClusterStatus, KubernetesClusterSpec> { export class KubernetesCluster extends CatalogEntity<KubernetesClusterMetadata, KubernetesClusterStatus, KubernetesClusterSpec> {
public static readonly apiVersion = "entity.k8slens.dev/v1alpha1"; public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
public static readonly kind = "KubernetesCluster"; public static readonly kind: string = "KubernetesCluster";
public readonly apiVersion = KubernetesCluster.apiVersion; public readonly apiVersion = KubernetesCluster.apiVersion;
public readonly kind = KubernetesCluster.kind; public readonly kind = KubernetesCluster.kind;

View File

@ -8,6 +8,7 @@ import type TypedEmitter from "typed-emitter";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable } from "mobx";
import { once } from "lodash"; import { once } from "lodash";
import { iter, Disposer } from "../utils"; 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 ExtractEntityMetadataType<Entity> = Entity extends CatalogEntity<infer Metadata> ? Metadata : never;
type ExtractEntityStatusType<Entity> = Entity extends CatalogEntity<any, infer Status> ? Status : 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. * The grouping for for the category. This MUST be a DNS label.
*/ */
group: string; group: string;
/** /**
* The specific versions of the constructors. * 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` * `name = "v1alpha1"` then the resulting `.apiVersion` MUST be `entity.k8slens.dev/v1alpha1`
*/ */
versions: CatalogCategoryVersion<CatalogEntity>[]; versions: CatalogCategoryVersion<CatalogEntity>[];
/**
* This is the concerning the category
*/
names: { names: {
/** /**
* The kind of entity that this category is for. This value MUST be a DNS * The kind of entity that this category is for. This value MUST be a DNS
@ -62,38 +68,107 @@ export interface CatalogCategorySpec {
*/ */
kind: string; 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 type AddMenuFilter = (menu: CatalogEntityAddMenu) => any;
export interface CatalogCategoryEvents { export interface CatalogCategoryEvents {
/**
* This event will be emitted when the category is loaded in the catalog
* view.
*/
load: () => void; 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; 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; contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void;
} }
export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter<CatalogCategoryEvents>) { 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; abstract readonly apiVersion: string;
/**
* The kind of item you wish to declare.
*
* Currently supported values:
*
* - `"CatalogCategory"`
*/
abstract readonly kind: string; 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; name: string;
/**
* Either an `<svg>` or the name of an icon from {@link IconProps}
*/
icon: string; icon: string;
}; };
/**
* The most important part of a category, as it is where entity versions are declared.
*/
abstract spec: CatalogCategorySpec; abstract spec: CatalogCategorySpec;
/**
* @internal
*/
protected filters = observable.set<AddMenuFilter>([], { protected filters = observable.set<AddMenuFilter>([], {
deep: false, 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("/") ?? []; const [group, kind] = id.split("/") ?? [];
return { group, kind }; return { group, kind };
} }
/**
* Get the ID of this category
*/
public getId(): string { public getId(): string {
return `${this.spec.group}/${this.spec.names.kind}`; return `${this.spec.group}/${this.spec.names.kind}`;
} }

View File

@ -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> = { const localeTimezone: PreferenceDescription<string> = {
fromStore(val) { fromStore(val) {
return val || moment.tz.guess(true) || "UTC"; return val || moment.tz.guess(true) || "UTC";
@ -335,6 +344,7 @@ export const DESCRIPTORS = {
httpsProxy, httpsProxy,
shell, shell,
colorTheme, colorTheme,
terminalTheme,
localeTimezone, localeTimezone,
allowUntrustedCAs, allowUntrustedCAs,
allowTelemetry, allowTelemetry,

View File

@ -49,6 +49,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
@observable allowErrorReporting: boolean; @observable allowErrorReporting: boolean;
@observable allowUntrustedCAs: boolean; @observable allowUntrustedCAs: boolean;
@observable colorTheme: string; @observable colorTheme: string;
@observable terminalTheme: string;
@observable localeTimezone: string; @observable localeTimezone: string;
@observable downloadMirror: string; @observable downloadMirror: string;
@observable httpsProxy?: string; @observable httpsProxy?: string;
@ -170,6 +171,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
this.httpsProxy = DESCRIPTORS.httpsProxy.fromStore(preferences?.httpsProxy); this.httpsProxy = DESCRIPTORS.httpsProxy.fromStore(preferences?.httpsProxy);
this.shell = DESCRIPTORS.shell.fromStore(preferences?.shell); this.shell = DESCRIPTORS.shell.fromStore(preferences?.shell);
this.colorTheme = DESCRIPTORS.colorTheme.fromStore(preferences?.colorTheme); this.colorTheme = DESCRIPTORS.colorTheme.fromStore(preferences?.colorTheme);
this.terminalTheme = DESCRIPTORS.terminalTheme.fromStore(preferences?.terminalTheme);
this.localeTimezone = DESCRIPTORS.localeTimezone.fromStore(preferences?.localeTimezone); this.localeTimezone = DESCRIPTORS.localeTimezone.fromStore(preferences?.localeTimezone);
this.allowUntrustedCAs = DESCRIPTORS.allowUntrustedCAs.fromStore(preferences?.allowUntrustedCAs); this.allowUntrustedCAs = DESCRIPTORS.allowUntrustedCAs.fromStore(preferences?.allowUntrustedCAs);
this.allowTelemetry = DESCRIPTORS.allowTelemetry.fromStore(preferences?.allowTelemetry); 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), httpsProxy: DESCRIPTORS.httpsProxy.toStore(this.httpsProxy),
shell: DESCRIPTORS.shell.toStore(this.shell), shell: DESCRIPTORS.shell.toStore(this.shell),
colorTheme: DESCRIPTORS.colorTheme.toStore(this.colorTheme), colorTheme: DESCRIPTORS.colorTheme.toStore(this.colorTheme),
terminalTheme: DESCRIPTORS.terminalTheme.toStore(this.terminalTheme),
localeTimezone: DESCRIPTORS.localeTimezone.toStore(this.localeTimezone), localeTimezone: DESCRIPTORS.localeTimezone.toStore(this.localeTimezone),
allowUntrustedCAs: DESCRIPTORS.allowUntrustedCAs.toStore(this.allowUntrustedCAs), allowUntrustedCAs: DESCRIPTORS.allowUntrustedCAs.toStore(this.allowUntrustedCAs),
allowTelemetry: DESCRIPTORS.allowTelemetry.toStore(this.allowTelemetry), allowTelemetry: DESCRIPTORS.allowTelemetry.toStore(this.allowTelemetry),

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);
});
});

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);
}

View File

@ -10,11 +10,19 @@ export function noop<T extends any[]>(...args: T): void {
return void args; 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 "./app-version";
export * from "./autobind"; export * from "./autobind";
export * from "./camelCase"; export * from "./camelCase";
export * from "./cloneJson"; export * from "./cloneJson";
export * from "./cluster-id-url-parsing"; export * from "./cluster-id-url-parsing";
export * from "./collection-functions";
export * from "./convertCpu"; export * from "./convertCpu";
export * from "./convertMemory"; export * from "./convertMemory";
export * from "./debouncePromise"; export * from "./debouncePromise";

View File

@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * 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 { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry";
export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry"; export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry";
export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry"; export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry";

View File

@ -269,7 +269,6 @@ export class ExtensionLoader {
return this.autoInitExtensions(async (extension: LensRendererExtension) => { return this.autoInitExtensions(async (extension: LensRendererExtension) => {
const removeItems = [ const removeItems = [
registries.GlobalPageRegistry.getInstance().add(extension.globalPages, extension), registries.GlobalPageRegistry.getInstance().add(extension.globalPages, extension),
registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences),
registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings),
registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems),
registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), 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)`); logger.debug(`${logModule}: load on cluster renderer (dashboard)`);
this.autoInitExtensions(async (extension: LensRendererExtension) => { 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 []; return [];
} }
@ -324,11 +324,15 @@ export class ExtensionLoader {
this.extensions.get(extension.id).availableUpdate = await extension.checkForUpdate(); this.extensions.get(extension.id).availableUpdate = await extension.checkForUpdate();
} }
protected autoInitExtensions(register: (ext: LensExtension) => Promise<Disposer[]>) { protected async loadExtensions(installedExtensions: Map<string, InstalledExtension>, register: (ext: LensExtension) => Promise<Disposer[]>) {
const loadingExtensions: ExtensionLoading[] = []; // 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 => { const extensions = [...installedExtensions.entries()]
for (const [extId, extension] of installedExtensions) { .map(([extId, extension]) => {
const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name);
if (extension.isCompatible && extension.isEnabled && !alreadyInit) { if (extension.isCompatible && extension.isEnabled && !alreadyInit) {
@ -337,7 +341,8 @@ export class ExtensionLoader {
if (!LensExtensionClass) { if (!LensExtensionClass) {
this.nonInstancesByName.add(extension.manifest.name); this.nonInstancesByName.add(extension.manifest.name);
continue;
return null;
} }
// const instance = new LensExtensionClass(extension, this.extensionUpdateChecker); // const instance = new LensExtensionClass(extension, this.extensionUpdateChecker);
@ -347,27 +352,49 @@ export class ExtensionLoader {
this.extensionUpdateChecker, 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); this.instances.set(extId, instance);
return {
extId,
instance,
isBundled: extension.isBundled,
activated: instance.activate(),
};
} catch (err) { } catch (err) {
logger.error(`${logModule}: activation extension error`, { ext: extension, err }); logger.error(`${logModule}: activation extension error`, { ext: extension, err });
} }
} else if (!extension.isEnabled && alreadyInit) { } else if (!extension.isEnabled && alreadyInit) {
this.removeInstance(extId); this.removeInstance(extId);
} }
}
}, { return null;
fireImmediately: true, })
// 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 loadingExtensions; 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 { protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null {

View File

@ -91,7 +91,6 @@ export class LensExtension {
} }
try { try {
await this.onActivate();
this._isEnabled = true; this._isEnabled = true;
this[Disposers].push(...await register(this)); this[Disposers].push(...await register(this));
@ -118,6 +117,12 @@ export class LensExtension {
} }
} }
@action
activate() {
return this.onActivate();
}
public async checkForUpdate() { public async checkForUpdate() {
return this.updateChecker?.run(this.manifest); return this.updateChecker?.run(this.manifest);
} }

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 { 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 { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration";
import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; 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 { export class LensRendererExtension extends LensExtension {
globalPages: registries.PageRegistration[] = []; globalPages: registries.PageRegistration[] = [];
clusterPages: registries.PageRegistration[] = []; clusterPages: registries.PageRegistration[] = [];
clusterPageMenus: registries.ClusterPageMenuRegistration[] = []; clusterPageMenus: registries.ClusterPageMenuRegistration[] = [];
kubeObjectStatusTexts: registries.KubeObjectStatusRegistration[] = []; kubeObjectStatusTexts: registries.KubeObjectStatusRegistration[] = [];
appPreferences: registries.AppPreferenceRegistration[] = []; appPreferences: AppPreferenceRegistration[] = [];
entitySettings: registries.EntitySettingRegistration[] = []; entitySettings: registries.EntitySettingRegistration[] = [];
statusBarItems: registries.StatusBarRegistration[] = []; statusBarItems: registries.StatusBarRegistration[] = [];
kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = [];
@ -32,6 +34,7 @@ export class LensRendererExtension extends LensExtension {
welcomeBanners: WelcomeBannerRegistration[] = []; welcomeBanners: WelcomeBannerRegistration[] = [];
catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = [];
topBarItems: TopBarRegistration[] = []; topBarItems: TopBarRegistration[] = [];
additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) { async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation"); const { navigate } = await import("../renderer/navigation");

View File

@ -7,7 +7,6 @@
export * from "./page-registry"; export * from "./page-registry";
export * from "./page-menu-registry"; export * from "./page-menu-registry";
export * from "./app-preference-registry";
export * from "./status-bar-registry"; export * from "./status-bar-registry";
export * from "./kube-object-detail-registry"; export * from "./kube-object-detail-registry";
export * from "./kube-object-menu-registry"; export * from "./kube-object-menu-registry";

View File

@ -31,6 +31,11 @@ export * from "../../renderer/components/input/input";
// command-overlay // command-overlay
export const CommandOverlay = asLegacyGlobalObjectForExtensionApi(commandOverlayInjectable); export const CommandOverlay = asLegacyGlobalObjectForExtensionApi(commandOverlayInjectable);
export type {
CategoryColumnRegistration,
AdditionalCategoryColumnRegistration,
} from "../../renderer/components/+catalog/custom-category-columns";
// other components // other components
export * from "../../renderer/components/icon"; export * from "../../renderer/components/icon";
export * from "../../renderer/components/tooltip"; export * from "../../renderer/components/tooltip";

View File

@ -8,10 +8,8 @@ import extensionsInjectable from "./extensions.injectable";
import type { LensRendererExtension } from "./lens-renderer-extension"; import type { LensRendererExtension } from "./lens-renderer-extension";
const rendererExtensionsInjectable = getInjectable({ const rendererExtensionsInjectable = getInjectable({
instantiate: (di) => di.inject(extensionsInjectable) as IComputedValue<LensRendererExtension[]>,
lifecycle: lifecycleEnum.singleton, lifecycle: lifecycleEnum.singleton,
instantiate: (di) =>
di.inject(extensionsInjectable) as IComputedValue<LensRendererExtension[]>,
}); });
export default rendererExtensionsInjectable; export default rendererExtensionsInjectable;

View File

@ -4,7 +4,7 @@
*/ */
import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; 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"; import { iter } from "../../common/utils";
export class CatalogEntityRegistry { export class CatalogEntityRegistry {
@ -43,8 +43,8 @@ export class CatalogEntityRegistry {
return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[];
} }
getItemsByEntityClass<T extends CatalogEntity>({ apiVersion, kind }: CatalogEntityKindData & CatalogEntityConstructor<T>): T[] { getItemsByEntityClass<T extends CatalogEntity>(constructor: CatalogEntityConstructor<T>): T[] {
return this.getItemsForApiKind(apiVersion, kind); return this.items.filter((item) => item instanceof constructor) as T[];
} }
} }

View File

@ -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;

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;

View File

@ -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;

View File

@ -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;

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.
*/
// 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;

View File

@ -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;

View File

@ -7,24 +7,12 @@
import { joinMigrations } from "../helpers"; 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 version360Beta1 from "./3.6.0-beta.1";
import version500Beta10 from "./5.0.0-beta.10"; import version500Beta10 from "./5.0.0-beta.10";
import version500Beta13 from "./5.0.0-beta.13"; import version500Beta13 from "./5.0.0-beta.13";
import snap from "./snap"; import snap from "./snap";
export default joinMigrations( export default joinMigrations(
version200Beta2,
version241,
version260Beta2,
version260Beta3,
version270Beta0,
version270Beta1,
version360Beta1, version360Beta1,
version500Beta10, version500Beta10,
version500Beta13, version500Beta13,

View File

@ -47,10 +47,25 @@ export class CatalogEntityRegistry {
makeObservable(this); makeObservable(this);
} }
get activeEntity(): CatalogEntity | null { protected getActiveEntityById() {
return this._entities.get(this.activeEntityId) || null; 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) { set activeEntity(raw: CatalogEntity | string | null) {
if (raw) { if (raw) {
const id = typeof raw === "string" const id = typeof raw === "string"

View File

@ -98,9 +98,6 @@ export async function bootstrap(di: DependencyInjectionContainer) {
logger.info(`${logPrefix} initializing IpcRendererListeners`); logger.info(`${logPrefix} initializing IpcRendererListeners`);
initializers.initIpcRendererListeners(extensionLoader); initializers.initIpcRendererListeners(extensionLoader);
logger.info(`${logPrefix} initializing StatusBarRegistry`);
initializers.initStatusBarRegistry();
extensionLoader.init(); extensionLoader.init();
const extensionDiscovery = di.inject(extensionDiscoveryInjectable); const extensionDiscovery = di.inject(extensionDiscoveryInjectable);

View File

@ -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();
});
});
});

View File

@ -28,25 +28,18 @@ import { RenderDelay } from "../render-delay/render-delay";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
import { Avatar } from "../avatar"; import { Avatar } from "../avatar";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import { getLabelBadges } from "./helpers";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import catalogPreviousActiveTabStorageInjectable import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
import type { GetCategoryColumnsParams, CategoryColumns } from "./get-category-columns.injectable";
enum sortBy { import getCategoryColumnsInjectable from "./get-category-columns.injectable";
name = "name",
kind = "kind",
source = "source",
status = "status",
}
interface Props extends RouteComponentProps<CatalogViewRouteParam> {} interface Props extends RouteComponentProps<CatalogViewRouteParam> {}
interface Dependencies { interface Dependencies {
catalogPreviousActiveTabStorage: { set: (value: string ) => void } catalogPreviousActiveTabStorage: { set: (value: string ) => void };
catalogEntityStore: CatalogEntityStore catalogEntityStore: CatalogEntityStore;
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
} }
@observer @observer
@ -228,46 +221,23 @@ class NonInjectedCatalog extends React.Component<Props & Dependencies> {
return null; return null;
} }
const { sortingCallbacks, searchFilters, renderTableContents, renderTableHeader } = this.props.getCategoryColumns({ activeCategory });
return ( return (
<ItemListLayout <ItemListLayout
className={styles.Catalog} className={styles.Catalog}
tableId={tableId} tableId={tableId}
renderHeaderTitle={activeCategory?.metadata.name || "Browse All"} renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"}
isSelectable={false} isSelectable={false}
isConfigurable={true} isConfigurable={true}
store={this.props.catalogEntityStore} store={this.props.catalogEntityStore}
sortingCallbacks={{ sortingCallbacks={sortingCallbacks}
[sortBy.name]: entity => entity.getName(), searchFilters={searchFilters}
[sortBy.source]: entity => entity.getSource(), renderTableHeader={renderTableHeader}
[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)}
customizeTableRowProps={entity => ({ customizeTableRowProps={entity => ({
disabled: !entity.isEnabled(), disabled: !entity.isEnabled(),
})} })}
renderTableContents={entity => [ renderTableContents={renderTableContents}
this.renderName(entity),
!activeCategory && entity.kind,
entity.getSource(),
getLabelBadges(entity),
<span key="phase" className={entity.status.phase}>{entity.status.phase}</span>,
].filter(Boolean)}
onDetails={this.onDetails} onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu} renderItemMenu={this.renderItemMenu}
/> />
@ -306,17 +276,11 @@ class NonInjectedCatalog extends React.Component<Props & Dependencies> {
} }
} }
export const Catalog = withInjectables<Dependencies, Props>( export const Catalog = withInjectables<Dependencies, Props>( NonInjectedCatalog, {
NonInjectedCatalog,
{
getProps: (di, props) => ({ getProps: (di, props) => ({
catalogEntityStore: di.inject(catalogEntityStoreInjectable), catalogEntityStore: di.inject(catalogEntityStoreInjectable),
catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
catalogPreviousActiveTabStorage: di.inject( getCategoryColumns: di.inject(getCategoryColumnsInjectable),
catalogPreviousActiveTabStorageInjectable,
),
...props, ...props,
}), }),
}, });
);

View File

@ -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;

View File

@ -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[];
}

View File

@ -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;

View File

@ -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,
},
];

View File

@ -4,7 +4,6 @@
*/ */
import type React from "react"; import type React from "react";
import { BaseRegistry } from "./base-registry";
export interface AppPreferenceComponents { export interface AppPreferenceComponents {
Hint: React.ComponentType<any>; Hint: React.ComponentType<any>;
@ -22,11 +21,3 @@ export interface RegisteredAppPreference extends AppPreferenceRegistration {
id: string; 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,
};
}
}

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 { 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;

View File

@ -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)
));
};

View File

@ -14,10 +14,12 @@ import { isWindows } from "../../../common/vars";
import { Switch } from "../switch"; import { Switch } from "../switch";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { CONSTANTS, defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../common/user-store/preferences-helpers"; 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 { isUrl } from "../input/input_validators";
import { AppPreferenceRegistry } from "../../../extensions/registries";
import { ExtensionSettings } from "./extension-settings"; 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 => ({ const timezoneOptions: SelectOption<string>[] = moment.tz.names().map(zone => ({
label: zone, label: zone,
@ -28,7 +30,11 @@ const updateChannelOptions: SelectOption<string>[] = Array.from(
([value, { label }]) => ({ value, label }), ([value, { label }]) => ({ value, label }),
); );
export const Application = observer(() => { interface Dependencies {
appPreferenceItems: IComputedValue<RegisteredAppPreference[]>
}
const NonInjectedApplication: React.FC<Dependencies> = ({ appPreferenceItems }) => {
const userStore = UserStore.getInstance(); const userStore = UserStore.getInstance();
const defaultShell = process.env.SHELL const defaultShell = process.env.SHELL
|| process.env.PTYSHELL || process.env.PTYSHELL
@ -40,7 +46,8 @@ export const Application = observer(() => {
const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || ""); const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || "");
const [shell, setShell] = React.useState(userStore.shell || ""); 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 ( return (
<section id="application"> <section id="application">
@ -48,7 +55,7 @@ export const Application = observer(() => {
<section id="appearance"> <section id="appearance">
<SubTitle title="Theme" /> <SubTitle title="Theme" />
<Select <Select
options={ThemeStore.getInstance().themeOptions} options={themeStore.themeOptions}
value={userStore.colorTheme} value={userStore.colorTheme}
onChange={({ value }) => userStore.colorTheme = value} onChange={({ value }) => userStore.colorTheme = value}
themeName="lens" themeName="lens"
@ -57,6 +64,19 @@ export const Application = observer(() => {
<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"> <section id="shell">
<SubTitle title="Terminal Shell Path" /> <SubTitle title="Terminal Shell Path" />
<Input <Input
@ -149,4 +169,14 @@ export const Application = observer(() => {
</section> </section>
</section> </section>
); );
}); };
export const Application = withInjectables<Dependencies>(
observer(NonInjectedApplication),
{
getProps: (di) => ({
appPreferenceItems: di.inject(appPreferencesInjectable),
}),
},
);

View File

@ -3,12 +3,12 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { SubTitle } from "../layout/sub-title"; 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 React from "react";
import { cssNames } from "../../../renderer/utils"; import { cssNames } from "../../../renderer/utils";
interface ExtensionSettingsProps { interface ExtensionSettingsProps {
setting: RegisteredAppPreference; setting: AppPreferenceRegistration;
size: "small" | "normal" size: "small" | "normal"
} }

View File

@ -3,13 +3,21 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * 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 { observer } from "mobx-react";
import React from "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"; import { ExtensionSettings } from "./extension-settings";
export const Extensions = observer(() => { interface Dependencies {
const settings = AppPreferenceRegistry.getInstance().getItems(); appPreferenceItems: IComputedValue<RegisteredAppPreference[]>
}
const NonInjectedExtensions: React.FC<Dependencies> = ({ appPreferenceItems }) => {
const settings = appPreferenceItems.get();
return ( return (
<section id="extensions"> <section id="extensions">
@ -19,4 +27,14 @@ export const Extensions = observer(() => {
)} )}
</section> </section>
); );
}); };
export const Extensions = withInjectables<Dependencies>(
observer(NonInjectedExtensions),
{
getProps: (di) => ({
appPreferenceItems: di.inject(appPreferencesInjectable),
}),
},
);

View File

@ -4,7 +4,7 @@
*/ */
import "./preferences.scss"; import "./preferences.scss";
import { makeObservable, observable } from "mobx"; import type { IComputedValue } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { matchPath, Redirect, Route, RouteProps, Switch } from "react-router"; import { matchPath, Redirect, Route, RouteProps, Switch } from "react-router";
@ -23,7 +23,6 @@ import {
telemetryRoute, telemetryRoute,
telemetryURL, telemetryURL,
} from "../../../common/routes"; } from "../../../common/routes";
import { AppPreferenceRegistry } from "../../../extensions/registries/app-preference-registry";
import { navigateWithoutHistoryChange, navigation } from "../../navigation"; import { navigateWithoutHistoryChange, navigation } from "../../navigation";
import { SettingLayout } from "../layout/setting-layout"; import { SettingLayout } from "../layout/setting-layout";
import { Tab, Tabs } from "../tabs"; import { Tab, Tabs } from "../tabs";
@ -34,18 +33,18 @@ import { LensProxy } from "./proxy";
import { Telemetry } from "./telemetry"; import { Telemetry } from "./telemetry";
import { Extensions } from "./extensions"; import { Extensions } from "./extensions";
import { sentryDsn } from "../../../common/vars"; 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 interface Dependencies {
export class Preferences extends React.Component { appPreferenceItems: IComputedValue<RegisteredAppPreference[]>
@observable historyLength: number | undefined;
constructor(props: {}) {
super(props);
makeObservable(this);
} }
renderNavigation() { const NonInjectedPreferences: React.FC<Dependencies> = ({ appPreferenceItems }) => {
const extensions = AppPreferenceRegistry.getInstance().getItems();
function renderNavigation() {
const extensions = appPreferenceItems.get();
const telemetryExtensions = extensions.filter(e => e.showInPreferencesTab == "telemetry"); const telemetryExtensions = extensions.filter(e => e.showInPreferencesTab == "telemetry");
const currentLocation = navigation.location.pathname; const currentLocation = navigation.location.pathname;
const isActive = (route: RouteProps) => !!matchPath(currentLocation, { path: route.path, exact: route.exact }); const isActive = (route: RouteProps) => !!matchPath(currentLocation, { path: route.path, exact: route.exact });
@ -67,10 +66,9 @@ export class Preferences extends React.Component {
); );
} }
render() {
return ( return (
<SettingLayout <SettingLayout
navigation={this.renderNavigation()} navigation={renderNavigation()}
className="Preferences" className="Preferences"
contentGaps={false} contentGaps={false}
> >
@ -85,5 +83,14 @@ export class Preferences extends React.Component {
</Switch> </Switch>
</SettingLayout> </SettingLayout>
); );
} };
}
export const Preferences = withInjectables<Dependencies>(
observer(NonInjectedPreferences),
{
getProps: (di) => ({
appPreferenceItems: di.inject(appPreferencesInjectable),
}),
},
);

View File

@ -6,13 +6,20 @@ import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { sentryDsn } from "../../../common/vars"; import { sentryDsn } from "../../../common/vars";
import { AppPreferenceRegistry } from "../../../extensions/registries";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { ExtensionSettings } from "./extension-settings"; 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(() => { interface Dependencies {
const extensions = AppPreferenceRegistry.getInstance().getItems(); appPreferenceItems: IComputedValue<RegisteredAppPreference[]>
}
const NonInjectedTelemetry: React.FC<Dependencies> = ({ appPreferenceItems }) => {
const extensions = appPreferenceItems.get();
const telemetryExtensions = extensions.filter(e => e.showInPreferencesTab == "telemetry"); const telemetryExtensions = extensions.filter(e => e.showInPreferencesTab == "telemetry");
return ( return (
@ -44,4 +51,14 @@ export const Telemetry = observer(() => {
} }
</section> </section>
); );
}); };
export const Telemetry = withInjectables<Dependencies>(
observer(NonInjectedTelemetry),
{
getProps: (di) => ({
appPreferenceItems: di.inject(appPreferencesInjectable),
}),
},
);

View File

@ -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,
}),
});

View File

@ -4,68 +4,24 @@
*/ */
import React from "react"; import React from "react";
import mockFs from "mock-fs"; import { render } from "@testing-library/react";
import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { BottomBar } from "./bottom-bar"; import { BottomBar } from "./bottom-bar";
import { StatusBarRegistry } from "../../../extensions/registries"; 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", () => ({ jest.mock("electron", () => ({
app: { app: {
getName: () => "lens", getPath: () => "/foo",
setName: jest.fn(),
setPath: jest.fn(),
getPath: () => "tmp",
},
ipcMain: {
handle: jest.fn(),
on: jest.fn(),
removeAllListeners: jest.fn(),
off: jest.fn(),
send: jest.fn(),
}, },
})); }));
const foobarHotbar = getEmptyHotbar("foobar");
describe("<BottomBar />", () => { describe("<BottomBar />", () => {
let di: DependencyInjectionContainer; beforeEach(() => {
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();
StatusBarRegistry.createInstance(); StatusBarRegistry.createInstance();
}); });
afterEach(() => { afterEach(() => {
StatusBarRegistry.resetInstance(); StatusBarRegistry.resetInstance();
mockFs.restore();
}); });
it("renders w/o errors", () => { it("renders w/o errors", () => {
@ -111,33 +67,6 @@ describe("<BottomBar />", () => {
expect(getByTestId(testId)).toHaveTextContent(text); 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", () => { it("sort positioned items properly", () => {
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [

View File

@ -60,11 +60,14 @@
.tab-content { .tab-content {
position: relative; position: relative;
background: var(--terminalBackground);
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
transition: flex-basis 25ms ease-in; transition: flex-basis 25ms ease-in;
&.terminal {
background: var(--terminalBackground);
}
> *:not(.Spinner) { > *:not(.Spinner) {
position: absolute; position: absolute;
left: 0; left: 0;

View File

@ -97,7 +97,7 @@ class NonInjectedDock extends React.Component<Props & Dependencies> {
if (!isOpen || !selectedTab) return null; if (!isOpen || !selectedTab) return null;
return ( return (
<div className="tab-content" style={{ flexBasis: height }}> <div className={`tab-content ${selectedTab.kind}`} style={{ flexBasis: height }}>
{this.renderTab(selectedTab)} {this.renderTab(selectedTab)}
</div> </div>
); );

View File

@ -10,9 +10,9 @@ import { FitAddon } from "xterm-addon-fit";
import type { DockStore, TabId } from "../dock-store/dock.store"; import type { DockStore, TabId } from "../dock-store/dock.store";
import { TerminalApi, TerminalChannels } from "../../../api/terminal-api"; import { TerminalApi, TerminalChannels } from "../../../api/terminal-api";
import { ThemeStore } from "../../../theme.store"; import { ThemeStore } from "../../../theme.store";
import { boundMethod, disposer } from "../../../utils"; import { disposer } from "../../../utils";
import { isMac } from "../../../../common/vars"; import { isMac } from "../../../../common/vars";
import { camelCase, once } from "lodash"; import { once } from "lodash";
import { UserStore } from "../../../../common/user-store"; import { UserStore } from "../../../../common/user-store";
import { clipboard } from "electron"; import { clipboard } from "electron";
import logger from "../../../../common/logger"; import logger from "../../../../common/logger";
@ -44,23 +44,6 @@ export class Terminal {
private scrollPos = 0; private scrollPos = 0;
private disposer = disposer(); 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() { get elem() {
return this.xterm?.element; return this.xterm?.element;
} }
@ -109,7 +92,9 @@ export class Terminal {
window.addEventListener("resize", this.onResize); window.addEventListener("resize", this.onResize);
this.disposer.push( this.disposer.push(
reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, { reaction(() => ThemeStore.getInstance().xtermColors, colors => {
this.xterm?.setOption("theme", colors);
}, {
fireImmediately: true, fireImmediately: true,
}), }),
dependencies.dockStore.onResize(this.onResize), dependencies.dockStore.onResize(this.onResize),

View File

@ -54,6 +54,7 @@ export class EditableList<T> extends React.Component<Props<T>> {
onSubmit={this.onSubmit} onSubmit={this.onSubmit}
validators={validators} validators={validators}
placeholder={placeholder} placeholder={placeholder}
blurOnEnter={false}
iconRight={({ isDirty }) => isDirty ? <Icon material="keyboard_return" size={16} /> : null} iconRight={({ isDirty }) => isDirty ? <Icon material="keyboard_return" size={16} /> : null}
/> />
</div> </div>

View File

@ -4,6 +4,8 @@
*/ */
.HotbarSelector { .HotbarSelector {
display: flex;
align-items: center;
height: 26px; height: 26px;
background-color: var(--layoutBackground); background-color: var(--layoutBackground);
position: relative; position: relative;
@ -17,7 +19,13 @@
top: -20px; top: -20px;
} }
.SelectorIndex { .HotbarIndex {
display: flex;
flex-grow: 1;
align-items: center;
}
.Badge {
cursor: pointer; cursor: pointer;
background: var(--secondaryBackground); background: var(--secondaryBackground);
width: 100%; width: 100%;

View File

@ -3,21 +3,18 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import "./hotbar-selector.scss"; import styles from "./hotbar-selector.module.scss";
import React from "react"; import React, { useRef, useState } from "react";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Badge } from "../badge"; import { Badge } from "../badge";
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable"; import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { HotbarSwitchCommand } from "./hotbar-switch-command"; import { HotbarSwitchCommand } from "./hotbar-switch-command";
import { TooltipPosition } from "../tooltip"; import { Tooltip, TooltipPosition } from "../tooltip";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { Hotbar } from "../../../common/hotbar-types"; import type { Hotbar } from "../../../common/hotbar-types";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import { cssNames } from "../../utils";
export interface HotbarSelectorProps {
hotbar: Hotbar;
}
interface Dependencies { interface Dependencies {
hotbarManager: { hotbarManager: {
@ -29,25 +26,63 @@ interface Dependencies {
openCommandOverlay: (component: React.ReactElement) => void; openCommandOverlay: (component: React.ReactElement) => void;
} }
const NonInjectedHotbarSelector = observer(({ hotbar, hotbarManager, openCommandOverlay }: HotbarSelectorProps & Dependencies) => ( export interface HotbarSelectorProps extends Partial<Dependencies> {
<div className="HotbarSelector flex align-center"> hotbar: Hotbar;
<Icon material="play_arrow" className="previous box" onClick={() => hotbarManager.switchToPrevious()} /> }
<div className="box grow flex align-center">
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 <Badge
id="hotbarIndex" id="hotbarIndex"
small small
label={hotbarManager.getDisplayIndex(hotbarManager.getActive())} label={hotbarManager.getDisplayIndex(hotbarManager.getActive())}
onClick={() => openCommandOverlay(<HotbarSwitchCommand />)} onClick={() => openCommandOverlay(<HotbarSwitchCommand />)}
tooltip={{ className={styles.Badge}
preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT], onMouseEnter={onMouseEvent}
children: hotbar.name, onMouseLeave={onMouseEvent}
}}
className="SelectorIndex"
/> />
<Tooltip
visible={tooltipVisible}
targetId="hotbarIndex"
preferredPositions={[TooltipPosition.TOP, TooltipPosition.TOP_LEFT]}
>
{hotbar.name}
</Tooltip>
</div> </div>
<Icon material="play_arrow" className="next box" onClick={() => hotbarManager.switchToNext()} /> <Icon material="play_arrow" className={styles.Icon} onClick={() => onArrowClick(hotbarManager.switchToNext)} />
</div> </div>
)); );
});
export const HotbarSelector = withInjectables<Dependencies, HotbarSelectorProps>(NonInjectedHotbarSelector, { export const HotbarSelector = withInjectables<Dependencies, HotbarSelectorProps>(NonInjectedHotbarSelector, {
getProps: (di, props) => ({ getProps: (di, props) => ({

View File

@ -52,6 +52,7 @@ export type InputProps = Omit<InputElementProps, "onChange" | "onSubmit"> & {
iconRight?: IconData; iconRight?: IconData;
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator | InputValidator[]; validators?: InputValidator | InputValidator[];
blurOnEnter?: boolean;
onChange?(value: string, evt: React.ChangeEvent<InputElement>): void; onChange?(value: string, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: string, evt: React.KeyboardEvent<InputElement>): void; onSubmit?(value: string, evt: React.KeyboardEvent<InputElement>): void;
}; };
@ -70,6 +71,7 @@ const defaultProps: Partial<InputProps> = {
maxRows: 10000, maxRows: 10000,
showValidationLine: true, showValidationLine: true,
validators: [], validators: [],
blurOnEnter: true,
}; };
export class Input extends React.Component<InputProps, State> { export class Input extends React.Component<InputProps, State> {
@ -267,6 +269,11 @@ export class Input extends React.Component<InputProps, State> {
} else { } else {
this.setDirty(); this.setDirty();
} }
if(this.props.blurOnEnter){
//pressing enter indicates that the edit is complete, we can unfocus now
this.blur();
}
} }
} }

View File

@ -14,18 +14,66 @@ import { Checkbox } from "../checkbox";
export type TableCellElem = React.ReactElement<TableCellProps>; export type TableCellElem = React.ReactElement<TableCellProps>;
export interface TableCellProps extends React.DOMAttributes<HTMLDivElement> { 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; className?: string;
/**
* The actual value of the cell
*/
title?: ReactNode; title?: ReactNode;
scrollable?: boolean; // content inside could be scrolled
checkbox?: boolean; // render cell with a checkbox /**
isChecked?: boolean; // mark checkbox as checked or not * content inside could be scrolled
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={}/> scrollable?: boolean;
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 (!) * render cell with a checkbox
_nowrap?: boolean; // indicator, might come from parent <TableHead>, don't use this prop outside (!) */
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> { export class TableCell extends React.Component<TableCellProps> {

View File

@ -25,14 +25,15 @@
pointer-events: none; pointer-events: none;
transition: opacity 150ms 150ms ease-in-out; transition: opacity 150ms 150ms ease-in-out;
z-index: 100000; z-index: 100000;
opacity: 1;
box-shadow: 0 8px 16px rgba(0,0,0,0.24); box-shadow: 0 8px 16px rgba(0,0,0,0.24);
&.invisible {
left: 0; left: 0;
top: 0; top: 0;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
&.visible {
opacity: 1;
visibility: visible;
} }
&:empty { &:empty {

View File

@ -54,7 +54,7 @@ export class Tooltip extends React.Component<TooltipProps> {
@observable.ref elem: HTMLElement; @observable.ref elem: HTMLElement;
@observable activePosition: TooltipPosition; @observable activePosition: TooltipPosition;
@observable isVisible = !!this.props.visible; @observable isVisible = false;
constructor(props: TooltipProps) { constructor(props: TooltipProps) {
super(props); super(props);
@ -78,6 +78,10 @@ export class Tooltip extends React.Component<TooltipProps> {
this.hoverTarget.addEventListener("mouseleave", this.onLeaveTarget); this.hoverTarget.addEventListener("mouseleave", this.onLeaveTarget);
} }
componentDidUpdate() {
this.refreshPosition();
}
componentWillUnmount() { componentWillUnmount() {
this.hoverTarget.removeEventListener("mouseenter", this.onEnterTarget); this.hoverTarget.removeEventListener("mouseenter", this.onEnterTarget);
this.hoverTarget.removeEventListener("mouseleave", this.onLeaveTarget); this.hoverTarget.removeEventListener("mouseleave", this.onLeaveTarget);
@ -210,9 +214,9 @@ export class Tooltip extends React.Component<TooltipProps> {
} }
render() { 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, { const className = cssNames("Tooltip", this.props.className, formatters, this.activePosition, {
invisible: !this.isVisible, visible: visible ?? this.isVisible,
formatter: !!formatters, formatter: !!formatters,
}); });
const tooltip = ( const tooltip = (

View File

@ -10,6 +10,7 @@ import { Button } from "../button";
import { Stepper } from "../stepper"; import { Stepper } from "../stepper";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { debounce } from "lodash";
interface WizardCommonProps<D = any> { interface WizardCommonProps<D = any> {
data?: Partial<D>; 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) { if (!this.form.noValidate) {
const valid = this.form.checkValidity(); const valid = this.form.checkValidity();
if (!valid) return; if (!valid) return;
} }
this.next(); this.next();
}; }, 100);
renderLoading() { renderLoading() {
return ( 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() { render() {
const { const {
step, isFirst, isLast, children, step, isFirst, isLast, children,
@ -216,6 +230,7 @@ export class WizardStep extends React.Component<WizardStepProps, WizardStepState
return ( return (
<form className={className} <form className={className}
onSubmit={prevDefault(this.submit)} noValidate={noValidate} onSubmit={prevDefault(this.submit)} noValidate={noValidate}
onKeyDown={(evt) => this.keyDown(evt)}
ref={e => this.form = e}> ref={e => this.form = e}>
{beforeContent} {beforeContent}
<div className={contentClass}> <div className={contentClass}>

View File

@ -19,7 +19,7 @@ import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store";
interface Dependencies { interface Dependencies {
hostedCluster: Cluster; hostedCluster: Cluster;
loadExtensions: (entity: CatalogEntity) => void; loadExtensions: (getCluster: () => CatalogEntity) => void;
catalogEntityRegistry: CatalogEntityRegistry; catalogEntityRegistry: CatalogEntityRegistry;
frameRoutingId: number; frameRoutingId: number;
emitEvent: (event: AppEvent) => void; emitEvent: (event: AppEvent) => void;
@ -47,11 +47,12 @@ export const initClusterFrame =
catalogEntityRegistry.activeEntity = hostedCluster.id; 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( when(
() => Boolean(catalogEntityRegistry.activeEntity), () => catalogEntityRegistry.items.length > 0,
() => () =>
loadExtensions(catalogEntityRegistry.activeEntity as KubernetesCluster), loadExtensions(() => catalogEntityRegistry.activeEntity as KubernetesCluster),
{ {
timeout: 15_000, timeout: 15_000,
onError: (error) => { onError: (error) => {

View File

@ -11,15 +11,15 @@ import type { ExtensionLoading } from "../../../../extensions/extension-loader";
import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry"; import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry";
interface Dependencies { interface Dependencies {
loadExtensions: () => ExtensionLoading[] loadExtensions: () => Promise<ExtensionLoading[]>;
// TODO: Move usages of third party library behind abstraction // 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 // TODO: Remove dependencies being here only for correct timing of initialization
bindProtocolAddRouteHandlers: () => void; bindProtocolAddRouteHandlers: () => void;
lensProtocolRouterRenderer: { init: () => void }; lensProtocolRouterRenderer: { init: () => void };
catalogEntityRegistry: CatalogEntityRegistry catalogEntityRegistry: CatalogEntityRegistry;
} }
const logPrefix = "[ROOT-FRAME]:"; const logPrefix = "[ROOT-FRAME]:";
@ -40,7 +40,7 @@ export const initRootFrame =
// maximum time to let bundled extensions finish loading // maximum time to let bundled extensions finish loading
const timeout = delay(10000); const timeout = delay(10000);
const loadingExtensions = loadExtensions(); const loadingExtensions = await loadExtensions();
const loadingBundledExtensions = loadingExtensions const loadingBundledExtensions = loadingExtensions
.filter((e) => e.isBundled) .filter((e) => e.isBundled)

View File

@ -12,4 +12,3 @@ export * from "./kube-object-menu-registry";
export * from "./registries"; export * from "./registries";
export * from "./workloads-overview-detail-registry"; export * from "./workloads-overview-detail-registry";
export * from "./catalog-category-registry"; export * from "./catalog-category-registry";
export * from "./status-bar-registry";

View File

@ -6,7 +6,6 @@
import * as registries from "../../extensions/registries"; import * as registries from "../../extensions/registries";
export function initRegistries() { export function initRegistries() {
registries.AppPreferenceRegistry.createInstance();
registries.CatalogEntityDetailRegistry.createInstance(); registries.CatalogEntityDetailRegistry.createInstance();
registries.ClusterPageMenuRegistry.createInstance(); registries.ClusterPageMenuRegistry.createInstance();
registries.ClusterPageRegistry.createInstance(); registries.ClusterPageRegistry.createInstance();

View File

@ -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",
},
},
]);
}

View File

@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * 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 { autoBind, Singleton } from "./utils";
import { UserStore } from "../common/user-store"; import { UserStore } from "../common/user-store";
import logger from "../main/logger"; import logger from "../main/logger";
@ -12,6 +12,7 @@ import lensLightThemeJson from "./themes/lens-light.json";
import type { SelectOption } from "./components/select"; import type { SelectOption } from "./components/select";
import type { MonacoEditorProps } from "./components/monaco-editor"; import type { MonacoEditorProps } from "./components/monaco-editor";
import { defaultTheme } from "../common/vars"; import { defaultTheme } from "../common/vars";
import { camelCase } from "lodash";
export type ThemeId = string; export type ThemeId = string;
@ -25,7 +26,7 @@ export interface Theme {
} }
export class ThemeStore extends Singleton { export class ThemeStore extends Singleton {
protected styles: HTMLStyleElement; private terminalColorPrefix = "terminal";
// bundled themes from `themes/${themeId}.json` // bundled themes from `themes/${themeId}.json`
private themes = observable.map<ThemeId, Theme>({ private themes = observable.map<ThemeId, Theme>({
@ -33,14 +34,37 @@ export class ThemeStore extends Singleton {
"lens-light": lensLightThemeJson as Theme, "lens-light": lensLightThemeJson as Theme,
}); });
@computed get activeThemeId(): string { @computed get activeThemeId(): ThemeId {
return UserStore.getInstance().colorTheme; return UserStore.getInstance().colorTheme;
} }
@computed get terminalThemeId(): ThemeId {
return UserStore.getInstance().terminalTheme;
}
@computed get activeTheme(): Theme { @computed get activeTheme(): Theme {
return this.themes.get(this.activeThemeId) ?? this.themes.get(defaultTheme); 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>[] { @computed get themeOptions(): SelectOption<string>[] {
return Array.from(this.themes).map(([themeId, theme]) => ({ return Array.from(this.themes).map(([themeId, theme]) => ({
label: theme.name, label: theme.name,
@ -55,15 +79,19 @@ export class ThemeStore extends Singleton {
autoBind(this); autoBind(this);
// auto-apply active theme // auto-apply active theme
reaction(() => this.activeThemeId, themeId => { reaction(() => ({
themeId: this.activeThemeId,
terminalThemeId: this.terminalThemeId,
}), ({ themeId }) => {
try { try {
this.applyTheme(this.getThemeById(themeId)); this.applyTheme(themeId);
} catch (err) { } catch (err) {
logger.error(err); logger.error(err);
UserStore.getInstance().resetTheme(); UserStore.getInstance().resetTheme();
} }
}, { }, {
fireImmediately: true, fireImmediately: true,
equals: comparer.shallow,
}); });
} }
@ -71,20 +99,18 @@ export class ThemeStore extends Singleton {
return this.themes.get(themeId); return this.themes.get(themeId);
} }
protected applyTheme(theme: Theme) { protected applyTheme(themeId: ThemeId) {
if (!this.styles) { const theme = this.getThemeById(themeId);
this.styles = document.createElement("style"); const colors = Object.entries({
this.styles.id = "lens-theme"; ...theme.colors,
document.head.append(this.styles); ...Object.fromEntries(this.terminalColors),
}
const cssVars = Object.entries(theme.colors).map(([cssName, color]) => {
return `--${cssName}: ${color};`;
}); });
this.styles.textContent = `:root {\n${cssVars.join("\n")}}`; colors.forEach(([name, value]) => {
// Adding universal theme flag which can be used in component styles document.documentElement.style.setProperty(`--${name}`, value);
const body = document.querySelector("body"); });
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");
} }
} }

View File

@ -1,5 +1,5 @@
{ {
"name": "Dark (Lens)", "name": "Dark",
"type": "dark", "type": "dark",
"description": "Original Lens dark theme", "description": "Original Lens dark theme",
"author": "Mirantis", "author": "Mirantis",

View File

@ -1,5 +1,5 @@
{ {
"name": "Light (Lens)", "name": "Light",
"type": "light", "type": "light",
"description": "Original Lens light theme", "description": "Original Lens light theme",
"author": "Mirantis", "author": "Mirantis",
@ -76,26 +76,26 @@
"logsBackground": "#24292e", "logsBackground": "#24292e",
"logsForeground": "#ffffff", "logsForeground": "#ffffff",
"logRowHoverBackground": "#35373a", "logRowHoverBackground": "#35373a",
"terminalBackground": "#24292e", "terminalBackground": "#ffffff",
"terminalForeground": "#ffffff", "terminalForeground": "#2d2d2d",
"terminalCursor": "#ffffff", "terminalCursor": "#2d2d2d",
"terminalCursorAccent": "#000000", "terminalCursorAccent": "#ffffff",
"terminalSelection": "#ffffff77", "terminalSelection": "#bfbfbf",
"terminalBlack": "#2e3436", "terminalBlack": "#2d2d2d",
"terminalRed": "#cc0000", "terminalRed": "#cd3734 ",
"terminalGreen": "#4e9a06", "terminalGreen": "#18cf12",
"terminalYellow": "#c4a000", "terminalYellow": "#acb300",
"terminalBlue": "#3465a4", "terminalBlue": "#3d90ce",
"terminalMagenta": "#75507b", "terminalMagenta": "#c100cd",
"terminalCyan": "#06989a", "terminalCyan": "#07c4b9",
"terminalWhite": "#d3d7cf", "terminalWhite": "#d3d7cf",
"terminalBrightBlack": "#555753", "terminalBrightBlack": "#a8a8a8",
"terminalBrightRed": "#ef2929", "terminalBrightRed": "#ff6259",
"terminalBrightGreen": "#8ae234", "terminalBrightGreen": "#5cdb59",
"terminalBrightYellow": "#fce94f", "terminalBrightYellow": "#f8c000",
"terminalBrightBlue": "#729fcf", "terminalBrightBlue": "#008db6",
"terminalBrightMagenta": "#ad7fa8", "terminalBrightMagenta": "#ee55f8",
"terminalBrightCyan": "#34e2e2", "terminalBrightCyan": "#50e8df",
"terminalBrightWhite": "#eeeeec", "terminalBrightWhite": "#eeeeec",
"dialogTextColor": "#87909c", "dialogTextColor": "#87909c",
"dialogBackground": "#ffffff", "dialogBackground": "#ffffff",

View File

@ -5460,6 +5460,17 @@ eslint-import-resolver-node@^0.3.6:
debug "^3.2.7" debug "^3.2.7"
resolve "^1.20.0" resolve "^1.20.0"
eslint-import-resolver-typescript@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.5.0.tgz#07661966b272d14ba97f597b51e1a588f9722f0a"
integrity sha512-qZ6e5CFr+I7K4VVhQu3M/9xGv9/YmwsEXrsm3nimw8vWaVHRDrQRp26BgCypTxBp3vUp4o5aVEJRiy0F2DFddQ==
dependencies:
debug "^4.3.1"
glob "^7.1.7"
is-glob "^4.0.1"
resolve "^1.20.0"
tsconfig-paths "^3.9.0"
eslint-module-utils@^2.7.1: eslint-module-utils@^2.7.1:
version "2.7.1" version "2.7.1"
resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.1.tgz#b435001c9f8dd4ab7f6d0efcae4b9696d4c24b7c" resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.1.tgz#b435001c9f8dd4ab7f6d0efcae4b9696d4c24b7c"
@ -6516,7 +6527,7 @@ glob-to-regexp@^0.4.1:
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7, glob@^7.2.0:
version "7.2.0" version "7.2.0"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==