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

Visualize extension loading (#1635)

* Visualize extension loading

Signed-off-by: Panu Horsmalahti <phorsmalahti@mirantis.com>
This commit is contained in:
Panu Horsmalahti 2020-12-04 16:51:28 +02:00 committed by GitHub
parent b342ea0bd6
commit ea98d68e08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 106 additions and 52 deletions

View File

@ -1,8 +1,11 @@
import chokidar from "chokidar"; import chokidar from "chokidar";
import { ipcRenderer } from "electron";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import fs from "fs-extra"; import fs from "fs-extra";
import { observable, reaction, toJS, when } from "mobx";
import os from "os"; import os from "os";
import path from "path"; import path from "path";
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
import { getBundledExtensions } from "../common/utils/app-version"; import { getBundledExtensions } from "../common/utils/app-version";
import logger from "../main/logger"; import logger from "../main/logger";
import { extensionInstaller, PackageJson } from "./extension-installer"; import { extensionInstaller, PackageJson } from "./extension-installer";
@ -28,6 +31,10 @@ const logModule = "[EXTENSION-DISCOVERY]";
export const manifestFilename = "package.json"; export const manifestFilename = "package.json";
interface ExtensionDiscoveryChannelMessage {
isLoaded: boolean;
}
/** /**
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
* @param lstat the stats to compare * @param lstat the stats to compare
@ -49,22 +56,16 @@ export class ExtensionDiscovery {
private loadStarted = false; private loadStarted = false;
// This promise is resolved when .load() is finished. // True if extensions have been loaded from the disk after app startup
// This allows operations to be added after .load() success. @observable isLoaded = false;
private loaded: Promise<void>; whenLoaded = when(() => this.isLoaded);
// These are called to either resolve or reject this.loaded promise // IPC channel to broadcast changes to extension-discovery from main
private resolveLoaded: () => void; protected static readonly extensionDiscoveryChannel = "extension-discovery:main";
private rejectLoaded: (error: any) => void;
public events: EventEmitter; public events: EventEmitter;
constructor() { constructor() {
this.loaded = new Promise((resolve, reject) => {
this.resolveLoaded = resolve;
this.rejectLoaded = reject;
});
this.events = new EventEmitter(); this.events = new EventEmitter();
} }
@ -98,8 +99,32 @@ export class ExtensionDiscovery {
/** /**
* Initializes the class and setups the file watcher for added/removed local extensions. * Initializes the class and setups the file watcher for added/removed local extensions.
*/ */
init() { async init() {
if (ipcRenderer) {
await this.initRenderer();
} else {
await this.initMain();
}
}
async initRenderer() {
const onMessage = ({ isLoaded }: ExtensionDiscoveryChannelMessage) => {
this.isLoaded = isLoaded;
};
requestMain(ExtensionDiscovery.extensionDiscoveryChannel).then(onMessage);
subscribeToBroadcast(ExtensionDiscovery.extensionDiscoveryChannel, (_event, message: ExtensionDiscoveryChannelMessage) => {
onMessage(message);
});
}
async initMain() {
this.watchExtensions(); this.watchExtensions();
handleRequest(ExtensionDiscovery.extensionDiscoveryChannel, () => this.toJSON());
reaction(() => this.toJSON(), () => {
this.broadcast();
});
} }
/** /**
@ -110,7 +135,7 @@ export class ExtensionDiscovery {
logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`); logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`);
// Wait until .load() has been called and has been resolved // Wait until .load() has been called and has been resolved
await this.loaded; await this.whenLoaded;
// chokidar works better than fs.watch // chokidar works better than fs.watch
chokidar.watch(this.localFolderPath, { chokidar.watch(this.localFolderPath, {
@ -208,7 +233,6 @@ export class ExtensionDiscovery {
this.loadStarted = true; this.loadStarted = true;
try {
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
if (fs.existsSync(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"))) { if (fs.existsSync(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"))) {
@ -231,13 +255,9 @@ export class ExtensionDiscovery {
const extensions = await this.loadExtensions(); const extensions = await this.loadExtensions();
// resolve the loaded promise this.isLoaded = true;
this.resolveLoaded();
return extensions; return extensions;
} catch (error) {
this.rejectLoaded(error);
}
} }
protected async getByManifest(manifestPath: string, { isBundled = false }: { protected async getByManifest(manifestPath: string, { isBundled = false }: {
@ -356,6 +376,18 @@ export class ExtensionDiscovery {
return this.getByManifest(manifestPath, { isBundled }); return this.getByManifest(manifestPath, { isBundled });
} }
toJSON(): ExtensionDiscoveryChannelMessage {
return toJS({
isLoaded: this.isLoaded
}, {
recurseEverything: true
});
}
broadcast() {
broadcastMessage(ExtensionDiscovery.extensionDiscoveryChannel, this.toJSON());
}
} }
export const extensionDiscovery = new ExtensionDiscovery(); export const extensionDiscovery = new ExtensionDiscovery();

View File

@ -3,19 +3,20 @@ import "./components/app.scss";
import React from "react"; import React from "react";
import * as Mobx from "mobx"; import * as Mobx from "mobx";
import * as MobxReact from "mobx-react"; import * as MobxReact from "mobx-react";
import * as LensExtensions from "../extensions/extension-api";
import { App } from "./components/app";
import { LensApp } from "./lens-app";
import { render, unmountComponentAtNode } from "react-dom"; import { render, unmountComponentAtNode } from "react-dom";
import { isMac } from "../common/vars";
import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { clusterStore } from "../common/cluster-store"; import { clusterStore } from "../common/cluster-store";
import { i18nStore } from "./i18n"; import { userStore } from "../common/user-store";
import { themeStore } from "./theme.store"; import { isMac } from "../common/vars";
import { extensionsStore } from "../extensions/extensions-store"; import { workspaceStore } from "../common/workspace-store";
import * as LensExtensions from "../extensions/extension-api";
import { extensionDiscovery } from "../extensions/extension-discovery";
import { extensionLoader } from "../extensions/extension-loader"; import { extensionLoader } from "../extensions/extension-loader";
import { extensionsStore } from "../extensions/extensions-store";
import { filesystemProvisionerStore } from "../main/extension-filesystem"; import { filesystemProvisionerStore } from "../main/extension-filesystem";
import { App } from "./components/app";
import { i18nStore } from "./i18n";
import { LensApp } from "./lens-app";
import { themeStore } from "./theme.store";
type AppComponent = React.ComponentType & { type AppComponent = React.ComponentType & {
init?(): Promise<void>; init?(): Promise<void>;
@ -34,6 +35,7 @@ export async function bootstrap(App: AppComponent) {
rootElem.classList.toggle("is-mac", isMac); rootElem.classList.toggle("is-mac", isMac);
extensionLoader.init(); extensionLoader.init();
extensionDiscovery.init();
// preload common stores // preload common stores
await Promise.all([ await Promise.all([

View File

@ -22,7 +22,8 @@ jest.mock("../../../../extensions/extension-discovery", () => ({
...jest.requireActual("../../../../extensions/extension-discovery"), ...jest.requireActual("../../../../extensions/extension-discovery"),
extensionDiscovery: { extensionDiscovery: {
localFolderPath: "/fake/path", localFolderPath: "/fake/path",
uninstallExtension: jest.fn(() => Promise.resolve()) uninstallExtension: jest.fn(() => Promise.resolve()),
isLoaded: true
} }
})); }));
@ -112,4 +113,17 @@ describe("Extensions", () => {
expect(Notifications.error).not.toHaveBeenCalled(); expect(Notifications.error).not.toHaveBeenCalled();
}); });
}); });
it("displays spinner while extensions are loading", () => {
extensionDiscovery.isLoaded = false;
const { container } = render(<Extensions />);
expect(container.querySelector(".Spinner")).toBeInTheDocument();
extensionDiscovery.isLoaded = true;
waitFor(() =>
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
);
});
}); });

View File

@ -37,6 +37,11 @@
font-weight: bold; font-weight: bold;
} }
} }
> .spinner-wrapper {
display: flex;
justify-content: center;
}
} }
.SearchInput { .SearchInput {

View File

@ -21,6 +21,7 @@ import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } fr
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Spinner } from "../spinner/spinner";
import { TooltipPosition } from "../tooltip"; import { TooltipPosition } from "../tooltip";
import { ExtensionStateStore } from "./extension-install.store"; import { ExtensionStateStore } from "./extension-install.store";
import "./extensions.scss"; import "./extensions.scss";
@ -541,7 +542,7 @@ export class Extensions extends React.Component {
value={this.search} value={this.search}
onChange={(value) => this.search = value} onChange={(value) => this.search = value}
/> />
{this.renderExtensions()} {extensionDiscovery.isLoaded ? this.renderExtensions() : <div className="spinner-wrapper"><Spinner/></div>}
</div> </div>
</PageLayout> </PageLayout>
</DropFileInput> </DropFileInput>