diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 105b8e2041..ab52027f15 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -1,8 +1,11 @@ import chokidar from "chokidar"; +import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; import fs from "fs-extra"; +import { observable, reaction, toJS, when } from "mobx"; import os from "os"; import path from "path"; +import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; import { getBundledExtensions } from "../common/utils/app-version"; import logger from "../main/logger"; import { extensionInstaller, PackageJson } from "./extension-installer"; @@ -28,6 +31,10 @@ const logModule = "[EXTENSION-DISCOVERY]"; 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) * @param lstat the stats to compare @@ -49,22 +56,16 @@ export class ExtensionDiscovery { private loadStarted = false; - // This promise is resolved when .load() is finished. - // This allows operations to be added after .load() success. - private loaded: Promise; + // True if extensions have been loaded from the disk after app startup + @observable isLoaded = false; + whenLoaded = when(() => this.isLoaded); - // These are called to either resolve or reject this.loaded promise - private resolveLoaded: () => void; - private rejectLoaded: (error: any) => void; + // IPC channel to broadcast changes to extension-discovery from main + protected static readonly extensionDiscoveryChannel = "extension-discovery:main"; public events: EventEmitter; constructor() { - this.loaded = new Promise((resolve, reject) => { - this.resolveLoaded = resolve; - this.rejectLoaded = reject; - }); - this.events = new EventEmitter(); } @@ -98,8 +99,32 @@ export class ExtensionDiscovery { /** * 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(); + 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}`); // Wait until .load() has been called and has been resolved - await this.loaded; + await this.whenLoaded; // chokidar works better than fs.watch chokidar.watch(this.localFolderPath, { @@ -208,36 +233,31 @@ export class ExtensionDiscovery { 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"))) { - await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); - } - - try { - await fs.access(this.inTreeFolderPath, fs.constants.W_OK); - this.bundledFolderPath = this.inTreeFolderPath; - } catch { - // we need to copy in-tree extensions so that we can symlink them properly on "npm install" - await fs.remove(this.inTreeTargetPath); - await fs.ensureDir(this.inTreeTargetPath); - await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath); - this.bundledFolderPath = this.inTreeTargetPath; - } - - await fs.ensureDir(this.nodeModulesPath); - await fs.ensureDir(this.localFolderPath); - - const extensions = await this.loadExtensions(); - - // resolve the loaded promise - this.resolveLoaded(); - - return extensions; - } catch (error) { - this.rejectLoaded(error); + if (fs.existsSync(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"))) { + await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); } + + try { + await fs.access(this.inTreeFolderPath, fs.constants.W_OK); + this.bundledFolderPath = this.inTreeFolderPath; + } catch { + // we need to copy in-tree extensions so that we can symlink them properly on "npm install" + await fs.remove(this.inTreeTargetPath); + await fs.ensureDir(this.inTreeTargetPath); + await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath); + this.bundledFolderPath = this.inTreeTargetPath; + } + + await fs.ensureDir(this.nodeModulesPath); + await fs.ensureDir(this.localFolderPath); + + const extensions = await this.loadExtensions(); + + this.isLoaded = true; + + return extensions; } protected async getByManifest(manifestPath: string, { isBundled = false }: { @@ -356,6 +376,18 @@ export class ExtensionDiscovery { 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(); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index f2369df0fd..f15625a881 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -3,19 +3,20 @@ import "./components/app.scss"; import React from "react"; import * as Mobx from "mobx"; 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 { isMac } from "../common/vars"; -import { userStore } from "../common/user-store"; -import { workspaceStore } from "../common/workspace-store"; import { clusterStore } from "../common/cluster-store"; -import { i18nStore } from "./i18n"; -import { themeStore } from "./theme.store"; -import { extensionsStore } from "../extensions/extensions-store"; +import { userStore } from "../common/user-store"; +import { isMac } from "../common/vars"; +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 { extensionsStore } from "../extensions/extensions-store"; 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 & { init?(): Promise; @@ -34,6 +35,7 @@ export async function bootstrap(App: AppComponent) { rootElem.classList.toggle("is-mac", isMac); extensionLoader.init(); + extensionDiscovery.init(); // preload common stores await Promise.all([ diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 186f7297b6..cb4db0fece 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -22,7 +22,8 @@ jest.mock("../../../../extensions/extension-discovery", () => ({ ...jest.requireActual("../../../../extensions/extension-discovery"), extensionDiscovery: { 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(); }); }); + + it("displays spinner while extensions are loading", () => { + extensionDiscovery.isLoaded = false; + const { container } = render(); + + expect(container.querySelector(".Spinner")).toBeInTheDocument(); + + extensionDiscovery.isLoaded = true; + + waitFor(() => + expect(container.querySelector(".Spinner")).not.toBeInTheDocument() + ); + }); }); diff --git a/src/renderer/components/+extensions/extensions.scss b/src/renderer/components/+extensions/extensions.scss index 6060fee61e..7352080a45 100644 --- a/src/renderer/components/+extensions/extensions.scss +++ b/src/renderer/components/+extensions/extensions.scss @@ -37,6 +37,11 @@ font-weight: bold; } } + + > .spinner-wrapper { + display: flex; + justify-content: center; + } } .SearchInput { diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 5e89d9facd..6a94b49480 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -21,6 +21,7 @@ import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } fr import { PageLayout } from "../layout/page-layout"; import { SubTitle } from "../layout/sub-title"; import { Notifications } from "../notifications"; +import { Spinner } from "../spinner/spinner"; import { TooltipPosition } from "../tooltip"; import { ExtensionStateStore } from "./extension-install.store"; import "./extensions.scss"; @@ -541,7 +542,7 @@ export class Extensions extends React.Component { value={this.search} onChange={(value) => this.search = value} /> - {this.renderExtensions()} + {extensionDiscovery.isLoaded ? this.renderExtensions() :
}