mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
UI for enabling/disabling extensions (#1208)
* Extensions page and menu item Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Basic extension list view Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Adding get userExtensions filter Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Using WizardLayout at extension page Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Adding search to extension page Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * Few style fixes Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com> * clean up Signed-off-by: Roman <ixrock@gmail.com> * added folder-icon to open extensions in finder, refactoring Signed-off-by: Roman <ixrock@gmail.com> * remove export warnings in dev:main, tooltip.getPosition() fix Signed-off-by: Roman <ixrock@gmail.com> * refactoring base lens-extension.ts, added `isBundled` flag Signed-off-by: Roman <ixrock@gmail.com> * added enabled/disable buttons Signed-off-by: Roman <ixrock@gmail.com> * auto enable/disable extensions -- part 1 Signed-off-by: Roman <ixrock@gmail.com> * auto enable/disable extensions -- part 2 Signed-off-by: Roman <ixrock@gmail.com> * auto enable/disable extensions -- part 3 Signed-off-by: Roman <ixrock@gmail.com> * auto enable/disable extensions -- part 4 Signed-off-by: Roman <ixrock@gmail.com> * refactoring & fixes Signed-off-by: Roman <ixrock@gmail.com> * fix: use page-layout with fullsize viewport Signed-off-by: Roman <ixrock@gmail.com> Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
parent
d5214e47c1
commit
f9578ba407
@ -2,7 +2,7 @@ import path from "path"
|
||||
import Config from "conf"
|
||||
import { Options as ConfOptions } from "conf/dist/source/types"
|
||||
import { app, ipcMain, IpcMainEvent, ipcRenderer, IpcRendererEvent, remote } from "electron"
|
||||
import { action, observable, reaction, runInAction, toJS, when } from "mobx";
|
||||
import { action, IReactionOptions, observable, reaction, runInAction, toJS, when } from "mobx";
|
||||
import Singleton from "./utils/singleton";
|
||||
import { getAppVersion } from "./utils/app-version";
|
||||
import logger from "../main/logger";
|
||||
@ -12,6 +12,7 @@ import isEqual from "lodash/isEqual";
|
||||
export interface BaseStoreParams<T = any> extends ConfOptions<T> {
|
||||
autoLoad?: boolean;
|
||||
syncEnabled?: boolean;
|
||||
syncOptions?: IReactionOptions;
|
||||
}
|
||||
|
||||
export class BaseStore<T = any> extends Singleton {
|
||||
@ -20,7 +21,7 @@ export class BaseStore<T = any> extends Singleton {
|
||||
|
||||
whenLoaded = when(() => this.isLoaded);
|
||||
@observable isLoaded = false;
|
||||
@observable protected data: T;
|
||||
@observable data = {} as T;
|
||||
|
||||
protected constructor(protected params: BaseStoreParams) {
|
||||
super();
|
||||
@ -36,8 +37,12 @@ export class BaseStore<T = any> extends Singleton {
|
||||
return path.basename(this.storeConfig.path);
|
||||
}
|
||||
|
||||
get path() {
|
||||
return this.storeConfig.path;
|
||||
}
|
||||
|
||||
get syncChannel() {
|
||||
return `store-sync:${this.name}`
|
||||
return `STORE-SYNC:${this.path}`
|
||||
}
|
||||
|
||||
protected async init() {
|
||||
@ -56,19 +61,19 @@ export class BaseStore<T = any> extends Singleton {
|
||||
...confOptions,
|
||||
projectName: "lens",
|
||||
projectVersion: getAppVersion(),
|
||||
cwd: this.storePath(),
|
||||
cwd: this.cwd(),
|
||||
});
|
||||
logger.info(`[STORE]: LOADED from ${this.storeConfig.path}`);
|
||||
logger.info(`[STORE]: LOADED from ${this.path}`);
|
||||
this.fromStore(this.storeConfig.store);
|
||||
this.isLoaded = true;
|
||||
}
|
||||
|
||||
protected storePath() {
|
||||
protected cwd() {
|
||||
return (app || remote.app).getPath("userData")
|
||||
}
|
||||
|
||||
protected async saveToFile(model: T) {
|
||||
logger.info(`[STORE]: SAVING ${this.name}`);
|
||||
logger.info(`[STORE]: SAVING ${this.path}`);
|
||||
// todo: update when fixed https://github.com/sindresorhus/conf/issues/114
|
||||
Object.entries(model).forEach(([key, value]) => {
|
||||
this.storeConfig.set(key, value);
|
||||
@ -77,7 +82,7 @@ export class BaseStore<T = any> extends Singleton {
|
||||
|
||||
enableSync() {
|
||||
this.syncDisposers.push(
|
||||
reaction(() => this.toJSON(), model => this.onModelChange(model)),
|
||||
reaction(() => this.toJSON(), model => this.onModelChange(model), this.params.syncOptions),
|
||||
);
|
||||
if (ipcMain) {
|
||||
const callback = (event: IpcMainEvent, model: T) => {
|
||||
@ -169,6 +174,7 @@ export class BaseStore<T = any> extends Singleton {
|
||||
|
||||
@action
|
||||
protected fromStore(data: T) {
|
||||
if (!data) return;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { ClusterFeature as Feature, ClusterFeatureStatus as FeatureStatus } from "../cluster-feature"
|
||||
export { ClusterFeature as Feature } from "../cluster-feature"
|
||||
export type { ClusterFeatureStatus as FeatureStatus } from "../cluster-feature"
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export { ExtensionStore } from "../extension-store"
|
||||
export { clusterStore, ClusterModel } from "../../common/cluster-store"
|
||||
export { Cluster } from "../../main/cluster"
|
||||
export { workspaceStore, Workspace, WorkspaceModel } from "../../common/workspace-store"
|
||||
export { clusterStore } from "../../common/cluster-store"
|
||||
export type { ClusterModel } from "../../common/cluster-store"
|
||||
export { Cluster } from "../../main/cluster"
|
||||
export { workspaceStore, Workspace } from "../../common/workspace-store"
|
||||
export type { WorkspaceModel } from "../../common/workspace-store"
|
||||
|
||||
@ -1,20 +1,13 @@
|
||||
import type { ExtensionId, ExtensionManifest, ExtensionModel, LensExtension } from "./lens-extension"
|
||||
import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "./lens-extension"
|
||||
import type { LensMainExtension } from "./lens-main-extension"
|
||||
import type { LensRendererExtension } from "./lens-renderer-extension"
|
||||
import type { InstalledExtension } from "./extension-manager";
|
||||
import path from "path"
|
||||
import { broadcastIpc } from "../common/ipc"
|
||||
import { observable, reaction, toJS, } from "mobx"
|
||||
import { computed, observable, reaction, when } from "mobx"
|
||||
import logger from "../main/logger"
|
||||
import { app, ipcRenderer, remote } from "electron"
|
||||
import {
|
||||
appPreferenceRegistry, clusterFeatureRegistry, clusterPageRegistry, globalPageRegistry,
|
||||
kubeObjectDetailRegistry, kubeObjectMenuRegistry, menuRegistry, statusBarRegistry
|
||||
} from "./registries";
|
||||
|
||||
export interface InstalledExtension extends ExtensionModel {
|
||||
manifestPath: string;
|
||||
manifest: ExtensionManifest;
|
||||
}
|
||||
import * as registries from "./registries";
|
||||
|
||||
// lazy load so that we get correct userData
|
||||
export function extensionPackagesRoot() {
|
||||
@ -22,69 +15,82 @@ export function extensionPackagesRoot() {
|
||||
}
|
||||
|
||||
export class ExtensionLoader {
|
||||
@observable extensions = observable.map<ExtensionId, InstalledExtension>([], { deep: false });
|
||||
@observable instances = observable.map<ExtensionId, LensExtension>([], { deep: false })
|
||||
@observable isLoaded = false;
|
||||
protected extensions = observable.map<LensExtensionId, InstalledExtension>([], { deep: false });
|
||||
protected instances = observable.map<LensExtensionId, LensExtension>([], { deep: false })
|
||||
|
||||
constructor() {
|
||||
if (ipcRenderer) {
|
||||
ipcRenderer.on("extensions:loaded", (event, extensions: InstalledExtension[]) => {
|
||||
this.isLoaded = true;
|
||||
extensions.forEach((ext) => {
|
||||
if (!this.getById(ext.manifestPath)) {
|
||||
if (!this.extensions.has(ext.manifestPath)) {
|
||||
this.extensions.set(ext.manifestPath, ext)
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@computed get userExtensions(): LensExtension[] {
|
||||
return [...this.instances.values()].filter(ext => !ext.isBundled)
|
||||
}
|
||||
|
||||
async init() {
|
||||
const { extensionManager } = await import("./extension-manager");
|
||||
const installedExtensions = await extensionManager.load();
|
||||
this.extensions.replace(installedExtensions);
|
||||
this.isLoaded = true;
|
||||
this.loadOnMain();
|
||||
}
|
||||
|
||||
loadOnMain() {
|
||||
logger.info('[EXTENSIONS-LOADER]: load on main')
|
||||
this.autoloadExtensions((extension: LensMainExtension) => {
|
||||
extension.registerTo(menuRegistry, extension.appMenus)
|
||||
})
|
||||
this.autoInitExtensions((extension: LensMainExtension) => [
|
||||
registries.menuRegistry.add(...extension.appMenus)
|
||||
]);
|
||||
}
|
||||
|
||||
loadOnClusterManagerRenderer() {
|
||||
logger.info('[EXTENSIONS-LOADER]: load on main renderer (cluster manager)')
|
||||
this.autoloadExtensions((extension: LensRendererExtension) => {
|
||||
extension.registerTo(globalPageRegistry, extension.globalPages)
|
||||
extension.registerTo(appPreferenceRegistry, extension.appPreferences)
|
||||
extension.registerTo(clusterFeatureRegistry, extension.clusterFeatures)
|
||||
extension.registerTo(statusBarRegistry, extension.statusBarItems)
|
||||
})
|
||||
this.autoInitExtensions((extension: LensRendererExtension) => [
|
||||
registries.globalPageRegistry.add(...extension.globalPages),
|
||||
registries.appPreferenceRegistry.add(...extension.appPreferences),
|
||||
registries.clusterFeatureRegistry.add(...extension.clusterFeatures),
|
||||
registries.statusBarRegistry.add(...extension.statusBarItems),
|
||||
]);
|
||||
}
|
||||
|
||||
loadOnClusterRenderer() {
|
||||
logger.info('[EXTENSIONS-LOADER]: load on cluster renderer (dashboard)')
|
||||
this.autoloadExtensions((extension: LensRendererExtension) => {
|
||||
extension.registerTo(clusterPageRegistry, extension.clusterPages)
|
||||
extension.registerTo(kubeObjectMenuRegistry, extension.kubeObjectMenuItems)
|
||||
extension.registerTo(kubeObjectDetailRegistry, extension.kubeObjectDetailItems)
|
||||
})
|
||||
this.autoInitExtensions((extension: LensRendererExtension) => [
|
||||
registries.clusterPageRegistry.add(...extension.clusterPages),
|
||||
registries.kubeObjectMenuRegistry.add(...extension.kubeObjectMenuItems),
|
||||
registries.kubeObjectDetailRegistry.add(...extension.kubeObjectDetailItems),
|
||||
]);
|
||||
}
|
||||
|
||||
protected autoloadExtensions(callback: (instance: LensExtension) => void) {
|
||||
protected autoInitExtensions(register: (ext: LensExtension) => Function[]) {
|
||||
return reaction(() => this.extensions.toJS(), (installedExtensions) => {
|
||||
for(const [id, ext] of installedExtensions) {
|
||||
let instance = this.instances.get(ext.id)
|
||||
for (const [id, ext] of installedExtensions) {
|
||||
let instance = this.instances.get(ext.manifestPath)
|
||||
if (!instance) {
|
||||
const extensionModule = this.requireExtension(ext)
|
||||
if (!extensionModule) {
|
||||
continue
|
||||
}
|
||||
const LensExtensionClass = extensionModule.default;
|
||||
instance = new LensExtensionClass({ ...ext.manifest, manifestPath: ext.manifestPath, id: ext.manifestPath }, ext.manifest);
|
||||
try {
|
||||
instance.enable()
|
||||
callback(instance)
|
||||
} finally {
|
||||
this.instances.set(ext.id, instance)
|
||||
const LensExtensionClass: LensExtensionConstructor = extensionModule.default;
|
||||
instance = new LensExtensionClass(ext);
|
||||
instance.whenEnabled(() => register(instance));
|
||||
this.instances.set(ext.manifestPath, instance);
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSIONS-LOADER]: init extension instance error`, { ext, err })
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
fireImmediately: true,
|
||||
delay: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@ -105,37 +111,17 @@ export class ExtensionLoader {
|
||||
}
|
||||
}
|
||||
|
||||
getById(id: ExtensionId): InstalledExtension {
|
||||
return this.extensions.get(id);
|
||||
}
|
||||
|
||||
async removeById(id: ExtensionId) {
|
||||
const extension = this.getById(id);
|
||||
if (extension) {
|
||||
const instance = this.instances.get(extension.id)
|
||||
if (instance) {
|
||||
await instance.disable()
|
||||
}
|
||||
this.extensions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
broadcastExtensions(frameId?: number) {
|
||||
async broadcastExtensions(frameId?: number) {
|
||||
await when(() => this.isLoaded);
|
||||
broadcastIpc({
|
||||
channel: "extensions:loaded",
|
||||
frameId: frameId,
|
||||
frameOnly: !!frameId,
|
||||
args: [this.toJSON().extensions],
|
||||
})
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return toJS({
|
||||
extensions: Array.from(this.extensions).map(([id, instance]) => instance),
|
||||
}, {
|
||||
recurseEverything: true,
|
||||
args: [
|
||||
Array.from(this.extensions.toJS().values())
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionLoader = new ExtensionLoader()
|
||||
export const extensionLoader = new ExtensionLoader();
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import type { ExtensionManifest } from "./lens-extension"
|
||||
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import fs from "fs-extra"
|
||||
import child_process from "child_process";
|
||||
import logger from "../main/logger"
|
||||
import { extensionPackagesRoot, InstalledExtension } from "./extension-loader"
|
||||
import * as child_process from 'child_process';
|
||||
import { extensionPackagesRoot } from "./extension-loader"
|
||||
import { getBundledExtensions } from "../common/utils/app-version"
|
||||
|
||||
export interface InstalledExtension {
|
||||
manifest: LensExtensionManifest;
|
||||
manifestPath: string;
|
||||
isBundled?: boolean; // defined in package.json
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
[name: string]: string;
|
||||
}
|
||||
@ -51,7 +57,7 @@ export class ExtensionManager {
|
||||
return path.join(this.extensionPackagesRoot, "package.json")
|
||||
}
|
||||
|
||||
async load() {
|
||||
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
logger.info("[EXTENSION-MANAGER] loading extensions from " + this.extensionPackagesRoot)
|
||||
if (fs.existsSync(path.join(this.extensionPackagesRoot, "package-lock.json"))) {
|
||||
await fs.remove(path.join(this.extensionPackagesRoot, "package-lock.json"))
|
||||
@ -71,8 +77,8 @@ export class ExtensionManager {
|
||||
return await this.loadExtensions();
|
||||
}
|
||||
|
||||
async getExtensionByManifest(manifestPath: string): Promise<InstalledExtension> {
|
||||
let manifestJson: ExtensionManifest;
|
||||
protected async getByManifest(manifestPath: string): Promise<InstalledExtension> {
|
||||
let manifestJson: LensExtensionManifest;
|
||||
try {
|
||||
fs.accessSync(manifestPath, fs.constants.F_OK); // check manifest file for existence
|
||||
manifestJson = __non_webpack_require__(manifestPath)
|
||||
@ -80,11 +86,8 @@ export class ExtensionManager {
|
||||
|
||||
logger.info("[EXTENSION-MANAGER] installed extension " + manifestJson.name)
|
||||
return {
|
||||
id: manifestJson.name,
|
||||
version: manifestJson.version,
|
||||
name: manifestJson.name,
|
||||
manifestPath: path.join(this.nodeModulesPath, manifestJson.name, "package.json"),
|
||||
manifest: manifestJson
|
||||
manifest: manifestJson,
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSION-MANAGER]: can't install extension at ${manifestPath}: ${err}`, { manifestJson });
|
||||
@ -109,10 +112,10 @@ export class ExtensionManager {
|
||||
async loadExtensions() {
|
||||
const bundledExtensions = await this.loadBundledExtensions()
|
||||
const localExtensions = await this.loadFromFolder(this.localFolderPath)
|
||||
await fs.writeFile(path.join(this.packageJsonPath), JSON.stringify(this.packagesJson, null, 2), {mode: 0o600})
|
||||
await fs.writeFile(path.join(this.packageJsonPath), JSON.stringify(this.packagesJson, null, 2), { mode: 0o600 })
|
||||
await this.installPackages()
|
||||
const extensions = bundledExtensions.concat(localExtensions)
|
||||
return new Map(extensions.map(ext => [ext.id, ext]));
|
||||
return new Map(extensions.map(ext => [ext.manifestPath, ext]));
|
||||
}
|
||||
|
||||
async loadBundledExtensions() {
|
||||
@ -126,8 +129,9 @@ export class ExtensionManager {
|
||||
}
|
||||
const absPath = path.resolve(folderPath, fileName);
|
||||
const manifestPath = path.resolve(absPath, "package.json");
|
||||
const ext = await this.getExtensionByManifest(manifestPath).catch(() => null)
|
||||
const ext = await this.getByManifest(manifestPath).catch(() => null)
|
||||
if (ext) {
|
||||
ext.isBundled = true;
|
||||
extensions.push(ext)
|
||||
}
|
||||
}
|
||||
@ -152,7 +156,7 @@ export class ExtensionManager {
|
||||
continue
|
||||
}
|
||||
const manifestPath = path.resolve(absPath, "package.json");
|
||||
const ext = await this.getExtensionByManifest(manifestPath).catch(() => null)
|
||||
const ext = await this.getByManifest(manifestPath).catch(() => null)
|
||||
if (ext) {
|
||||
extensions.push(ext)
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ export class ExtensionStore<T = any> extends BaseStore<T> {
|
||||
await super.load()
|
||||
}
|
||||
|
||||
protected storePath() {
|
||||
return path.join(super.storePath(), "extension-store", this.extension.name)
|
||||
protected cwd() {
|
||||
return path.join(super.cwd(), "extension-store", this.extension.name)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,75 +1,111 @@
|
||||
import { readJsonSync } from "fs-extra";
|
||||
import { action, observable, toJS } from "mobx";
|
||||
import type { InstalledExtension } from "./extension-manager";
|
||||
import { action, reaction } from "mobx";
|
||||
import logger from "../main/logger";
|
||||
import { BaseRegistry } from "./registries/base-registry";
|
||||
import { ExtensionStore } from "./extension-store";
|
||||
|
||||
export type ExtensionId = string | ExtensionPackageJsonPath;
|
||||
export type ExtensionPackageJsonPath = string;
|
||||
export type ExtensionVersion = string | number;
|
||||
export type LensExtensionId = string; // path to manifest (package.json)
|
||||
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
|
||||
|
||||
export interface ExtensionModel {
|
||||
id: ExtensionId;
|
||||
version: ExtensionVersion;
|
||||
export interface LensExtensionManifest {
|
||||
name: string;
|
||||
manifestPath: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
updateUrl?: string;
|
||||
main?: string; // path to %ext/dist/main.js
|
||||
renderer?: string; // path to %ext/dist/renderer.js
|
||||
}
|
||||
|
||||
export interface ExtensionManifest extends ExtensionModel {
|
||||
main?: string;
|
||||
renderer?: string;
|
||||
description?: string; // todo: add more fields similar to package.json + some extra
|
||||
export interface LensExtensionStoreModel {
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export class LensExtension implements ExtensionModel {
|
||||
public id: ExtensionId;
|
||||
public updateUrl: string;
|
||||
protected disposers: (() => void)[] = [];
|
||||
export class LensExtension<S extends ExtensionStore<LensExtensionStoreModel> = any> {
|
||||
protected store: S;
|
||||
readonly manifest: LensExtensionManifest;
|
||||
readonly manifestPath: string;
|
||||
readonly isBundled: boolean;
|
||||
|
||||
@observable name = "";
|
||||
@observable description = "";
|
||||
@observable version: ExtensionVersion = "0.0.0";
|
||||
@observable manifest: ExtensionManifest;
|
||||
@observable manifestPath: string;
|
||||
@observable isEnabled = false;
|
||||
constructor({ manifest, manifestPath, isBundled }: InstalledExtension) {
|
||||
this.manifest = manifest
|
||||
this.manifestPath = manifestPath
|
||||
this.isBundled = !!isBundled
|
||||
this.init();
|
||||
}
|
||||
|
||||
constructor(model: ExtensionModel, manifest: ExtensionManifest) {
|
||||
this.importModel(model, manifest);
|
||||
protected async init(store: S = createBaseStore().getInstance()) {
|
||||
this.store = store;
|
||||
await this.store.loadExtension(this);
|
||||
reaction(() => this.store.data.isEnabled, (isEnabled = true) => {
|
||||
this.toggle(isEnabled); // handle activation & deactivation
|
||||
}, {
|
||||
fireImmediately: true
|
||||
});
|
||||
}
|
||||
|
||||
get isEnabled() {
|
||||
return !!this.store.data.isEnabled;
|
||||
}
|
||||
|
||||
get id(): LensExtensionId {
|
||||
return this.manifestPath;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.manifest.name
|
||||
}
|
||||
|
||||
get version() {
|
||||
return this.manifest.version
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.manifest.description
|
||||
}
|
||||
|
||||
@action
|
||||
async importModel({ enabled, manifestPath, ...model }: ExtensionModel, manifest?: ExtensionManifest) {
|
||||
try {
|
||||
this.manifest = manifest || await readJsonSync(manifestPath, { throws: true })
|
||||
this.manifestPath = manifestPath;
|
||||
Object.assign(this, model);
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSION]: cannot read manifest at ${manifestPath}`, { ...model, err: String(err) })
|
||||
this.disable();
|
||||
}
|
||||
}
|
||||
|
||||
async migrate(appVersion: string) {
|
||||
// mock
|
||||
}
|
||||
|
||||
async enable() {
|
||||
this.isEnabled = true;
|
||||
logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`);
|
||||
if (this.isEnabled) return;
|
||||
this.store.data.isEnabled = true;
|
||||
this.onActivate();
|
||||
logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`);
|
||||
}
|
||||
|
||||
@action
|
||||
async disable() {
|
||||
if (!this.isEnabled) return;
|
||||
this.store.data.isEnabled = false;
|
||||
this.onDeactivate();
|
||||
this.isEnabled = false;
|
||||
this.disposers.forEach(cleanUp => cleanUp());
|
||||
this.disposers.length = 0;
|
||||
logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`);
|
||||
}
|
||||
|
||||
// todo: add more hooks
|
||||
toggle(enable?: boolean) {
|
||||
if (typeof enable === "boolean") {
|
||||
enable ? this.enable() : this.disable()
|
||||
} else {
|
||||
this.isEnabled ? this.disable() : this.enable()
|
||||
}
|
||||
}
|
||||
|
||||
async whenEnabled(handlers: () => Function[]) {
|
||||
const disposers: Function[] = [];
|
||||
const unregisterHandlers = () => {
|
||||
disposers.forEach(unregister => unregister())
|
||||
disposers.length = 0;
|
||||
}
|
||||
const cancelReaction = reaction(() => this.isEnabled, isEnabled => {
|
||||
if (isEnabled) {
|
||||
disposers.push(...handlers());
|
||||
} else {
|
||||
unregisterHandlers();
|
||||
}
|
||||
}, {
|
||||
fireImmediately: true
|
||||
})
|
||||
return () => {
|
||||
unregisterHandlers();
|
||||
cancelReaction();
|
||||
}
|
||||
}
|
||||
|
||||
protected onActivate() {
|
||||
// mock
|
||||
}
|
||||
@ -77,37 +113,14 @@ export class LensExtension implements ExtensionModel {
|
||||
protected onDeactivate() {
|
||||
// mock
|
||||
}
|
||||
}
|
||||
|
||||
registerTo<T = any>(registry: BaseRegistry<T>, items: T[] = []) {
|
||||
const disposers = items.map(item => registry.add(item));
|
||||
this.disposers.push(...disposers);
|
||||
return () => {
|
||||
this.disposers = this.disposers.filter(disposer => !disposers.includes(disposer))
|
||||
};
|
||||
}
|
||||
|
||||
getMeta() {
|
||||
return toJS({
|
||||
id: this.id,
|
||||
manifest: this.manifest,
|
||||
manifestPath: this.manifestPath,
|
||||
enabled: this.isEnabled
|
||||
}, {
|
||||
recurseEverything: true
|
||||
})
|
||||
}
|
||||
|
||||
toJSON(): ExtensionModel {
|
||||
return toJS({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
version: this.version,
|
||||
description: this.description,
|
||||
manifestPath: this.manifestPath,
|
||||
enabled: this.isEnabled,
|
||||
updateUrl: this.updateUrl,
|
||||
}, {
|
||||
recurseEverything: true,
|
||||
})
|
||||
function createBaseStore() {
|
||||
return class extends ExtensionStore<LensExtensionStoreModel> {
|
||||
constructor() {
|
||||
super({
|
||||
configName: "state"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// Base class for extensions-api registries
|
||||
import { observable } from "mobx";
|
||||
import { action, observable } from "mobx";
|
||||
|
||||
export class BaseRegistry<T = any> {
|
||||
protected items = observable<T>([], { deep: false });
|
||||
@ -8,10 +8,16 @@ export class BaseRegistry<T = any> {
|
||||
return this.items.toJS();
|
||||
}
|
||||
|
||||
add(item: T) {
|
||||
this.items.push(item);
|
||||
return () => {
|
||||
@action
|
||||
add(...items: T[]) {
|
||||
this.items.push(...items);
|
||||
return () => this.remove(...items);
|
||||
}
|
||||
|
||||
@action
|
||||
remove(...items: T[]) {
|
||||
items.forEach(item => {
|
||||
this.items.remove(item); // works because of {deep: false};
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,13 +15,12 @@ import { shellSync } from "./shell-sync"
|
||||
import { getFreePort } from "./port"
|
||||
import { mangleProxyEnv } from "./proxy-env"
|
||||
import { registerFileProtocol } from "../common/register-protocol";
|
||||
import logger from "./logger"
|
||||
import { clusterStore } from "../common/cluster-store"
|
||||
import { userStore } from "../common/user-store";
|
||||
import { workspaceStore } from "../common/workspace-store";
|
||||
import { appEventBus } from "../common/event-bus"
|
||||
import { extensionManager } from "../extensions/extension-manager";
|
||||
import { extensionLoader } from "../extensions/extension-loader";
|
||||
import logger from "./logger"
|
||||
|
||||
const workingDir = path.join(app.getPath("appData"), appName);
|
||||
let proxyPort: number;
|
||||
@ -48,7 +47,7 @@ app.on("ready", async () => {
|
||||
|
||||
registerFileProtocol("static", __static);
|
||||
|
||||
// preload isomorphic stores
|
||||
// preload
|
||||
await Promise.all([
|
||||
userStore.load(),
|
||||
clusterStore.load(),
|
||||
@ -76,12 +75,8 @@ app.on("ready", async () => {
|
||||
app.exit();
|
||||
}
|
||||
|
||||
windowManager = new WindowManager(proxyPort);
|
||||
|
||||
LensExtensionsApi.windowManager = windowManager; // expose to extensions
|
||||
extensionLoader.loadOnMain()
|
||||
extensionLoader.extensions.replace(await extensionManager.load())
|
||||
extensionLoader.broadcastExtensions()
|
||||
LensExtensionsApi.windowManager = windowManager = new WindowManager(proxyPort);
|
||||
extensionLoader.init(); // call after windowManager to see splash earlier
|
||||
|
||||
setTimeout(() => {
|
||||
appEventBus.emit({ name: "app", action: "start" })
|
||||
|
||||
@ -6,6 +6,7 @@ import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.r
|
||||
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
|
||||
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
|
||||
import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route";
|
||||
import { extensionsURL } from "../renderer/components/+extensions/extensions.route";
|
||||
import { menuRegistry } from "../extensions/registries/menu-registry";
|
||||
import logger from "./logger";
|
||||
|
||||
@ -70,6 +71,13 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
navigate(preferencesURL())
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Extensions',
|
||||
accelerator: 'CmdOrCtrl+Shift+E',
|
||||
click() {
|
||||
navigate(extensionsURL())
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
|
||||
8
src/renderer/components/+extensions/extensions.route.ts
Normal file
8
src/renderer/components/+extensions/extensions.route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../../common/utils/buildUrl";
|
||||
|
||||
export const extensionsRoute: RouteProps = {
|
||||
path: "/extensions"
|
||||
}
|
||||
|
||||
export const extensionsURL = buildURL(extensionsRoute.path)
|
||||
35
src/renderer/components/+extensions/extensions.scss
Normal file
35
src/renderer/components/+extensions/extensions.scss
Normal file
@ -0,0 +1,35 @@
|
||||
.Extensions {
|
||||
--width: 100%;
|
||||
--max-width: auto;
|
||||
|
||||
.extension {
|
||||
--flex-gap: $padding / 3;
|
||||
padding: $padding $padding * 2;
|
||||
background: $colorVague;
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.extensions-path {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.WizardLayout {
|
||||
padding: 0;
|
||||
|
||||
.info-col {
|
||||
flex: 0.6;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
margin-top: $margin / 2;
|
||||
margin-bottom: $margin * 2;
|
||||
max-width: none;
|
||||
|
||||
> label {
|
||||
padding: $padding $padding * 2;
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/renderer/components/+extensions/extensions.tsx
Normal file
112
src/renderer/components/+extensions/extensions.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import "./extensions.scss";
|
||||
import { shell } from "electron";
|
||||
import React from "react";
|
||||
import { computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Button } from "../button";
|
||||
import { WizardLayout } from "../layout/wizard-layout";
|
||||
import { Input } from "../input";
|
||||
import { Icon } from "../icon";
|
||||
import { PageLayout } from "../layout/page-layout";
|
||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||
import { extensionManager } from "../../../extensions/extension-manager";
|
||||
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
@observable search = ""
|
||||
|
||||
@computed get extensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
return extensionLoader.userExtensions.filter(({ name, description }) => {
|
||||
return [
|
||||
name.toLowerCase().includes(searchText),
|
||||
description.toLowerCase().includes(searchText),
|
||||
].some(v => v)
|
||||
})
|
||||
}
|
||||
|
||||
get extensionsPath() {
|
||||
return extensionManager.localFolderPath;
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
return (
|
||||
<div className="flex column gaps">
|
||||
<h2>Lens Extension API</h2>
|
||||
<div>
|
||||
The Extensions API in Lens allows users to customize and enhance the Lens experience by creating their own menus or page content that is extended from the existing pages. Many of the core
|
||||
features of Lens are built as extensions and use the same Extension API.
|
||||
</div>
|
||||
<div>
|
||||
Extensions loaded from:
|
||||
<div className="extensions-path flex inline">
|
||||
<code>{this.extensionsPath}</code>
|
||||
<Icon
|
||||
material="folder"
|
||||
tooltip="Open folder"
|
||||
onClick={() => shell.openPath(this.extensionsPath)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderExtensions() {
|
||||
const { extensions, extensionsPath, search } = this;
|
||||
if (!extensions.length) {
|
||||
return (
|
||||
<div className="flex align-center box grow justify-center gaps">
|
||||
{search && <Trans>No search results found</Trans>}
|
||||
{!search && <p><Trans>There are no extensions in</Trans> <code>{extensionsPath}</code></p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return extensions.map(ext => {
|
||||
const { id, name, description, isEnabled } = ext;
|
||||
return (
|
||||
<div key={id} className="extension flex gaps align-center">
|
||||
<div className="box grow flex column gaps">
|
||||
<div className="package">
|
||||
Name: <code className="name">{name}</code>
|
||||
</div>
|
||||
<div>
|
||||
Description: <span className="text-secondary">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isEnabled && (
|
||||
<Button plain active onClick={() => ext.enable()}>Enable</Button>
|
||||
)}
|
||||
{isEnabled && (
|
||||
<Button accent onClick={() => ext.disable()}>Disable</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<Input
|
||||
autoFocus
|
||||
theme="round-black"
|
||||
className="SearchInput"
|
||||
placeholder={_i18n._(t`Search extensions`)}
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extension-list flex column gaps">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
2
src/renderer/components/+extensions/index.ts
Normal file
2
src/renderer/components/+extensions/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./extensions.route"
|
||||
export * from "./extensions"
|
||||
@ -16,6 +16,7 @@ import { clusterViewRoute, clusterViewURL } from "./cluster-view.route";
|
||||
import { clusterStore } from "../../../common/cluster-store";
|
||||
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
|
||||
import { globalPageRegistry } from "../../../extensions/registries/page-registry";
|
||||
import { Extensions, extensionsRoute } from "../+extensions";
|
||||
import { getMatchedClusterId } from "../../navigation";
|
||||
|
||||
@observer
|
||||
@ -63,6 +64,7 @@ export class ClusterManager extends React.Component {
|
||||
<Switch>
|
||||
<Route component={LandingPage} {...landingRoute} />
|
||||
<Route component={Preferences} {...preferencesRoute} />
|
||||
<Route component={Extensions} {...extensionsRoute} />
|
||||
<Route component={Workspaces} {...workspacesRoute} />
|
||||
<Route component={AddCluster} {...addClusterRoute} />
|
||||
<Route component={ClusterView} {...clusterViewRoute} />
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
.PageLayout {
|
||||
$spacing: $padding * 2;
|
||||
--width: 60%;
|
||||
--max-width: 1000px;
|
||||
--min-width: 570px;
|
||||
|
||||
position: relative;
|
||||
height: 100%;
|
||||
@ -26,12 +29,15 @@
|
||||
> .content-wrapper {
|
||||
@include custom-scrollbar-themed;
|
||||
padding: $spacing * 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .content {
|
||||
flex: 1;
|
||||
margin: 0 auto;
|
||||
width: 60%;
|
||||
min-width: 570px;
|
||||
max-width: 1000px;
|
||||
width: var(--width);
|
||||
min-width: var(--min-width);
|
||||
max-width: var(--max-width);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -167,7 +167,6 @@ export class Tooltip extends React.Component<TooltipProps> {
|
||||
top = topCenter;
|
||||
break;
|
||||
case "top_right":
|
||||
default:
|
||||
left = targetBounds.right - tooltipBounds.width;
|
||||
top = topCenter;
|
||||
break;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user