mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Refactor the Extensions settings page
- Simplify the install logic by refactoring out the multi-install logic - Revamp the ExtensionInstallStateStore to more strictly track the lifetime of an install or uninstall request - Fix the install of an already installed extension just hanging visually - Display a spinner more often for more visual feedback Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
63f6562e0a
commit
ab130b586e
18
src/common/utils/disposer.ts
Normal file
18
src/common/utils/disposer.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export type Disposer = () => void;
|
||||
|
||||
interface Extendable<T> {
|
||||
push(...vals: T[]): void;
|
||||
}
|
||||
|
||||
export function disposer(...args: Disposer[]): Disposer & Extendable<Disposer> {
|
||||
const res = () => {
|
||||
args.forEach(dispose => dispose?.());
|
||||
args.length = 0;
|
||||
};
|
||||
|
||||
res.push = (...vals: Disposer[]) => {
|
||||
args.push(...vals);
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
@ -19,3 +19,4 @@ export * from "./downloadFile";
|
||||
export * from "./escapeRegExp";
|
||||
export * from "./tar";
|
||||
export * from "./type-narrowing";
|
||||
export * from "./disposer";
|
||||
|
||||
@ -9,23 +9,24 @@ import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } fr
|
||||
import { getBundledExtensions } from "../common/utils/app-version";
|
||||
import logger from "../main/logger";
|
||||
import { extensionInstaller, PackageJson } from "./extension-installer";
|
||||
import { extensionLoader } from "./extension-loader";
|
||||
import { extensionsStore } from "./extensions-store";
|
||||
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
||||
|
||||
export interface InstalledExtension {
|
||||
id: LensExtensionId;
|
||||
id: LensExtensionId;
|
||||
|
||||
readonly manifest: LensExtensionManifest;
|
||||
readonly manifest: LensExtensionManifest;
|
||||
|
||||
// Absolute path to the non-symlinked source folder,
|
||||
// e.g. "/Users/user/.k8slens/extensions/helloworld"
|
||||
readonly absolutePath: string;
|
||||
// Absolute path to the non-symlinked source folder,
|
||||
// e.g. "/Users/user/.k8slens/extensions/helloworld"
|
||||
readonly absolutePath: string;
|
||||
|
||||
// Absolute to the symlinked package.json file
|
||||
readonly manifestPath: string;
|
||||
readonly isBundled: boolean; // defined in project root's package.json
|
||||
isEnabled: boolean;
|
||||
}
|
||||
// Absolute to the symlinked package.json file
|
||||
readonly manifestPath: string;
|
||||
readonly isBundled: boolean; // defined in project root's package.json
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
const logModule = "[EXTENSION-DISCOVERY]";
|
||||
|
||||
@ -236,9 +237,11 @@ export class ExtensionDiscovery {
|
||||
/**
|
||||
* Uninstalls extension.
|
||||
* The application will detect the folder unlink and remove the extension from the UI automatically.
|
||||
* @param extension Extension to unistall.
|
||||
* @param extensionId The ID of the extension to uninstall.
|
||||
*/
|
||||
async uninstallExtension({ absolutePath, manifest }: InstalledExtension) {
|
||||
async uninstallExtension(extensionId: LensExtensionId) {
|
||||
const { manifest, absolutePath } = this.extensions.get(extensionId) ?? extensionLoader.getExtension(extensionId);
|
||||
|
||||
logger.info(`${logModule} Uninstalling ${manifest.name}`);
|
||||
|
||||
await this.removeSymlinkByPackageName(manifest.name);
|
||||
|
||||
@ -5,7 +5,7 @@ import React from "react";
|
||||
import { extensionDiscovery } from "../../../../extensions/extension-discovery";
|
||||
import { ConfirmDialog } from "../../confirm-dialog";
|
||||
import { Notifications } from "../../notifications";
|
||||
import { ExtensionStateStore } from "../extension-install.store";
|
||||
import { ExtensionInstallationStateStore } from "../extension-install.store";
|
||||
import { Extensions } from "../extensions";
|
||||
|
||||
jest.mock("fs-extra");
|
||||
@ -54,7 +54,7 @@ jest.mock("../../notifications", () => ({
|
||||
|
||||
describe("Extensions", () => {
|
||||
beforeEach(() => {
|
||||
ExtensionStateStore.resetInstance();
|
||||
ExtensionInstallationStateStore.reset();
|
||||
});
|
||||
|
||||
it("disables uninstall and disable buttons while uninstalling", async () => {
|
||||
@ -122,7 +122,7 @@ describe("Extensions", () => {
|
||||
|
||||
extensionDiscovery.isLoaded = true;
|
||||
|
||||
waitFor(() =>
|
||||
waitFor(() =>
|
||||
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,13 +1,187 @@
|
||||
import { observable } from "mobx";
|
||||
import { autobind, Singleton } from "../../utils";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import logger from "../../../main/logger";
|
||||
import { disposer, Disposer } from "../../utils";
|
||||
import * as uuid from "uuid";
|
||||
|
||||
interface ExtensionState {
|
||||
displayName: string;
|
||||
// Possible states the extension can be
|
||||
state: "installing" | "uninstalling";
|
||||
export enum ExtensionInstallationState {
|
||||
INSTALLING = "installing",
|
||||
UNINSTALLING = "uninstalling",
|
||||
IDLE = "IDLE",
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class ExtensionStateStore extends Singleton {
|
||||
extensionState = observable.map<string, ExtensionState>();
|
||||
const Prefix = "[ExtensionInstallationStore]";
|
||||
const installingExtensions = observable.set<string>();
|
||||
const uninstallingExtensions = observable.set<string>();
|
||||
const preInstallIds = observable.set<string>();
|
||||
|
||||
export class ExtensionInstallationStateStore {
|
||||
@action static reset() {
|
||||
logger.warn(`${Prefix}: resetting, may throw errors`);
|
||||
installingExtensions.clear();
|
||||
uninstallingExtensions.clear();
|
||||
preInstallIds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly transitions an extension from not installing to installing
|
||||
* @param extId the ID of the extension
|
||||
* @throws if state is not IDLE
|
||||
*/
|
||||
@action static setInstalling(extId: string): void {
|
||||
logger.debug(`${Prefix}: trying to set ${extId} as installing`);
|
||||
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||
|
||||
if (curState !== ExtensionInstallationState.IDLE) {
|
||||
throw new Error(`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`);
|
||||
}
|
||||
|
||||
installingExtensions.add(extId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the start of a pre-install phase of an extension installation. The
|
||||
* part of the installation before the tarball has been unpacked and the ID
|
||||
* determined.
|
||||
* @returns a disposer which should be called to mark the end of the install phase
|
||||
*/
|
||||
@action static startPreInstall(): Disposer {
|
||||
const preInstallStepId = uuid.v4();
|
||||
|
||||
logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`);
|
||||
preInstallIds.add(preInstallStepId);
|
||||
|
||||
return disposer(() => {
|
||||
preInstallIds.delete(preInstallStepId);
|
||||
logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly transitions an extension from not uninstalling to uninstalling
|
||||
* @param extId the ID of the extension
|
||||
* @throws if state is not IDLE
|
||||
*/
|
||||
@action static setUninstalling(extId: string): void {
|
||||
logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`);
|
||||
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||
|
||||
if (curState !== ExtensionInstallationState.IDLE) {
|
||||
throw new Error(`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`);
|
||||
}
|
||||
|
||||
uninstallingExtensions.add(extId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly clears the INSTALLING state of an extension
|
||||
* @param extId The ID of the extension
|
||||
* @throws if state is not INSTALLING
|
||||
*/
|
||||
@action static clearInstalling(extId: string): void {
|
||||
logger.debug(`${Prefix}: trying to clear ${extId} as installing`);
|
||||
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||
|
||||
switch (curState) {
|
||||
case ExtensionInstallationState.INSTALLING:
|
||||
return void installingExtensions.delete(extId);
|
||||
default:
|
||||
throw new Error(`${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly clears the UNINSTALLING state of an extension
|
||||
* @param extId The ID of the extension
|
||||
* @throws if state is not UNINSTALLING
|
||||
*/
|
||||
@action static clearUninstalling(extId: string): void {
|
||||
logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`);
|
||||
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||
|
||||
switch (curState) {
|
||||
case ExtensionInstallationState.UNINSTALLING:
|
||||
return void uninstallingExtensions.delete(extId);
|
||||
default:
|
||||
throw new Error(`${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current state of the extension. IDLE is default value.
|
||||
* @param extId The ID of the extension
|
||||
*/
|
||||
static getInstallationState(extId: string): ExtensionInstallationState {
|
||||
if (installingExtensions.has(extId)) {
|
||||
return ExtensionInstallationState.INSTALLING;
|
||||
}
|
||||
|
||||
if (uninstallingExtensions.has(extId)) {
|
||||
return ExtensionInstallationState.UNINSTALLING;
|
||||
}
|
||||
|
||||
return ExtensionInstallationState.IDLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the extension is currently INSTALLING
|
||||
* @param extId The ID of the extension
|
||||
*/
|
||||
static isExtensionInstalling(extId: string): boolean {
|
||||
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.INSTALLING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the extension is currently UNINSTALLING
|
||||
* @param extId The ID of the extension
|
||||
*/
|
||||
static isExtensionUninstalling(extId: string): boolean {
|
||||
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.UNINSTALLING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the extension is currently IDLE
|
||||
* @param extId The ID of the extension
|
||||
*/
|
||||
static isExtensionIdle(extId: string): boolean {
|
||||
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.IDLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current number of extensions installing
|
||||
*/
|
||||
@computed static get installing(): number {
|
||||
return installingExtensions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is at least one extension currently installing
|
||||
*/
|
||||
@computed static get anyInstalling(): boolean {
|
||||
return ExtensionInstallationStateStore.installing > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current number of extensions preinstallig
|
||||
*/
|
||||
@computed static get preinstalling(): number {
|
||||
return preInstallIds.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is at least one extension currently downloading
|
||||
*/
|
||||
@computed static get anyPreinstalling(): boolean {
|
||||
return ExtensionInstallationStateStore.preinstalling > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is at least one installing or preinstalling step taking place
|
||||
*/
|
||||
@computed static get anyPreInstallingOrInstalling(): boolean {
|
||||
return ExtensionInstallationStateStore.anyInstalling || ExtensionInstallationStateStore.anyPreinstalling;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import "./extensions.scss";
|
||||
import { remote, shell } from "electron";
|
||||
import fse from "fs-extra";
|
||||
import { computed, observable, reaction } from "mobx";
|
||||
import { computed, observable, reaction, when } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import React from "react";
|
||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||
import { autobind, disposer, Disposer, downloadFile, extractTar, listTarEntries, noop, readFileFromTar } from "../../../common/utils";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
import { extensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery";
|
||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||
import { extensionDisplayName, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import logger from "../../../main/logger";
|
||||
import { prevDefault } from "../../utils";
|
||||
import { Button } from "../button";
|
||||
@ -21,8 +22,7 @@ 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";
|
||||
import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store";
|
||||
|
||||
interface InstallRequest {
|
||||
fileName: string;
|
||||
@ -35,14 +35,324 @@ interface InstallRequestPreloaded extends InstallRequest {
|
||||
}
|
||||
|
||||
interface InstallRequestValidated extends InstallRequestPreloaded {
|
||||
id: LensExtensionId;
|
||||
manifest: LensExtensionManifest;
|
||||
tempFile: string; // temp system path to packed extension for unpacking
|
||||
}
|
||||
|
||||
async function uninstallExtension(extensionId: LensExtensionId, manifest: LensExtensionManifest): Promise<boolean> {
|
||||
const displayName = extensionDisplayName(manifest.name, manifest.version);
|
||||
|
||||
try {
|
||||
logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`);
|
||||
ExtensionInstallationStateStore.setUninstalling(extensionId);
|
||||
|
||||
await extensionDiscovery.uninstallExtension(extensionId);
|
||||
|
||||
// wait for the extensionLoader to actually uninstall the extension
|
||||
await when(() => !extensionLoader.userExtensions.has(extensionId));
|
||||
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
|
||||
);
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
// Remove uninstall state on uninstall failure
|
||||
ExtensionInstallationStateStore.clearUninstalling(extensionId);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmUninstallExtension(extension: InstalledExtension): void {
|
||||
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||
|
||||
ConfirmDialog.open({
|
||||
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
|
||||
labelOk: "Yes",
|
||||
labelCancel: "No",
|
||||
ok: () => {
|
||||
// Don't want the confirm dialog to stay up longer than the click
|
||||
uninstallExtension(extension.id, extension.manifest);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getExtensionDestFolder(name: string) {
|
||||
return path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
|
||||
}
|
||||
|
||||
function getExtensionPackageTemp(fileName = "") {
|
||||
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
||||
}
|
||||
|
||||
async function preloadExtension({ fileName, data, filePath }: InstallRequest, { showError = true } = {}): Promise<InstallRequestPreloaded | null> {
|
||||
if(data) {
|
||||
return { filePath, data, fileName };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fse.readFile(filePath);
|
||||
|
||||
return { filePath, data, fileName };
|
||||
} catch(error) {
|
||||
if (showError) {
|
||||
Notifications.error(`Error while reading "${filePath}": ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||
const tarFiles = await listTarEntries(filePath);
|
||||
|
||||
// tarball from npm contains single root folder "package/*"
|
||||
const firstFile = tarFiles[0];
|
||||
|
||||
if(!firstFile) {
|
||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||
}
|
||||
|
||||
const rootFolder = path.normalize(firstFile).split(path.sep)[0];
|
||||
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
|
||||
const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename;
|
||||
|
||||
if(!tarFiles.includes(manifestLocation)) {
|
||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||
}
|
||||
|
||||
const manifest = await readFileFromTar<LensExtensionManifest>({
|
||||
tarPath: filePath,
|
||||
filePath: manifestLocation,
|
||||
parseJson: true,
|
||||
});
|
||||
|
||||
if (!manifest.lens && !manifest.renderer) {
|
||||
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function createTempFilesAndValidate(request: InstallRequestPreloaded, { showErrors = true } = {}): Promise<InstallRequestValidated | null> {
|
||||
// copy files to temp
|
||||
await fse.ensureDir(getExtensionPackageTemp());
|
||||
|
||||
// validate packages
|
||||
const tempFile = getExtensionPackageTemp(request.fileName);
|
||||
|
||||
try {
|
||||
await fse.writeFile(tempFile, request.data);
|
||||
const manifest = await validatePackage(tempFile);
|
||||
const id = path.join(extensionDiscovery.nodeModulesPath, manifest.name, "package.json");
|
||||
|
||||
return {
|
||||
...request,
|
||||
manifest,
|
||||
tempFile,
|
||||
id,
|
||||
};
|
||||
} catch (error) {
|
||||
fse.unlink(tempFile).catch(noop); // remove invalid temp package
|
||||
|
||||
if (showErrors) {
|
||||
Notifications.error(
|
||||
<div className="flex column gaps">
|
||||
<p>Installing <em>{request.fileName}</em> has failed, skipping.</p>
|
||||
<p>Reason: <em>{String(error)}</em></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function unpackExtension(request: InstallRequestValidated, disposeDownloading: Disposer) {
|
||||
const { id, fileName, tempFile, manifest: { name, version } } = request;
|
||||
|
||||
ExtensionInstallationStateStore.setInstalling(id);
|
||||
disposeDownloading?.();
|
||||
|
||||
const displayName = extensionDisplayName(name, version);
|
||||
const extensionFolder = getExtensionDestFolder(name);
|
||||
const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`);
|
||||
|
||||
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
||||
|
||||
try {
|
||||
// extract to temp folder first
|
||||
await fse.remove(unpackingTempFolder).catch(noop);
|
||||
await fse.ensureDir(unpackingTempFolder);
|
||||
await extractTar(tempFile, { cwd: unpackingTempFolder });
|
||||
|
||||
// move contents to extensions folder
|
||||
const unpackedFiles = await fse.readdir(unpackingTempFolder);
|
||||
let unpackedRootFolder = unpackingTempFolder;
|
||||
|
||||
if (unpackedFiles.length === 1) {
|
||||
// check if %extension.tgz was packed with single top folder,
|
||||
// e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball
|
||||
unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]);
|
||||
}
|
||||
|
||||
await fse.ensureDir(extensionFolder);
|
||||
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
|
||||
|
||||
// wait for the loader has actually install it
|
||||
await when(() => extensionLoader.userExtensions.has(id));
|
||||
|
||||
// Enable installed extensions by default.
|
||||
extensionLoader.userExtensions.get(id).isEnabled = true;
|
||||
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{displayName}</b> successfully installed!</p>
|
||||
);
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Installing extension <b>{displayName}</b> has failed: <em>{error}</em></p>
|
||||
);
|
||||
} finally {
|
||||
// Remove install state once finished
|
||||
ExtensionInstallationStateStore.clearInstalling(id);
|
||||
|
||||
// clean up
|
||||
fse.remove(unpackingTempFolder).catch(noop);
|
||||
fse.unlink(tempFile).catch(noop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param request The information needed to install the extension
|
||||
* @param fromUrl The optional URL
|
||||
*/
|
||||
async function requestInstall(request: InstallRequest, d?: Disposer): Promise<void> {
|
||||
const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d);
|
||||
const loadedRequest = await preloadExtension(request);
|
||||
|
||||
if (!loadedRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedRequest = await createTempFilesAndValidate(loadedRequest);
|
||||
|
||||
if (!validatedRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, version, description } = validatedRequest.manifest;
|
||||
const curState = ExtensionInstallationStateStore.getInstallationState(validatedRequest.id);
|
||||
|
||||
if (curState !== ExtensionInstallationState.IDLE) {
|
||||
dispose();
|
||||
|
||||
return Notifications.error(
|
||||
<div className="flex column gaps">
|
||||
<b>Extension Install Collision:</b>
|
||||
<p>The <em>{name}</em> extension is currently {curState.toLowerCase()}.</p>
|
||||
<p>Will not procede with this current install request.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const extensionFolder = getExtensionDestFolder(name);
|
||||
const folderExists = await fse.pathExists(extensionFolder);
|
||||
|
||||
if (!folderExists) {
|
||||
// install extension if not yet exists
|
||||
unpackExtension(validatedRequest, dispose);
|
||||
} else {
|
||||
// otherwise confirmation required (re-install / update)
|
||||
const removeNotification = Notifications.info(
|
||||
<div className="InstallingExtensionNotification flex gaps align-center">
|
||||
<div className="flex column gaps">
|
||||
<p>Install extension <b>{name}@{version}</b>?</p>
|
||||
<p>Description: <em>{description}</em></p>
|
||||
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
|
||||
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
|
||||
</div>
|
||||
</div>
|
||||
<Button autoFocus label="Install" onClick={async () => {
|
||||
removeNotification();
|
||||
|
||||
await uninstallExtension(validatedRequest.id, validatedRequest.manifest)
|
||||
&& await unpackExtension(validatedRequest, dispose);
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestInstalls(filePaths: string[]): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
promises.push(requestInstall({
|
||||
fileName: path.basename(filePath),
|
||||
filePath,
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
async function installOnDrop(files: File[]) {
|
||||
logger.info("Install from D&D");
|
||||
await requestInstalls(files.map(({ path }) => path));
|
||||
}
|
||||
|
||||
async function installFromUrlOrPath(installPath: string) {
|
||||
const fileName = path.basename(installPath);
|
||||
|
||||
try {
|
||||
// install via url
|
||||
// fixme: improve error messages for non-tar-file URLs
|
||||
if (InputValidators.isUrl.validate(installPath)) {
|
||||
const disposer = ExtensionInstallationStateStore.startPreInstall();
|
||||
const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
|
||||
const data = await filePromise;
|
||||
|
||||
await requestInstall({ fileName, data }, disposer);
|
||||
}
|
||||
// otherwise installing from system path
|
||||
else if (InputValidators.isPath.validate(installPath)) {
|
||||
await requestInstall({ fileName, filePath: installPath });
|
||||
}
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Installation has failed: <b>{String(error)}</b></p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const supportedFormats = ["tar", "tgz"];
|
||||
|
||||
async function installFromSelectFileDialog() {
|
||||
const { dialog, BrowserWindow, app } = remote;
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
|
||||
defaultPath: app.getPath("downloads"),
|
||||
properties: ["openFile", "multiSelections"],
|
||||
message: `Select extensions to install (formats: ${supportedFormats.join(", ")}), `,
|
||||
buttonLabel: "Use configuration",
|
||||
filters: [
|
||||
{ name: "tarball", extensions: supportedFormats }
|
||||
]
|
||||
});
|
||||
|
||||
if (!canceled) {
|
||||
await requestInstalls(filePaths);
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
private static supportedFormats = ["tar", "tgz"];
|
||||
|
||||
private static installPathValidator: InputValidator = {
|
||||
message: "Invalid URL or absolute path",
|
||||
validate(value: string) {
|
||||
@ -50,71 +360,10 @@ export class Extensions extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
get extensionStateStore() {
|
||||
return ExtensionStateStore.getInstance<ExtensionStateStore>();
|
||||
}
|
||||
|
||||
@observable search = "";
|
||||
@observable installPath = "";
|
||||
|
||||
// True if the preliminary install steps have started, but unpackExtension has not started yet
|
||||
@observable startingInstall = false;
|
||||
|
||||
/**
|
||||
* Extensions that were removed from extensions but are still in "uninstalling" state
|
||||
*/
|
||||
@computed get removedUninstalling() {
|
||||
return Array.from(this.extensionStateStore.extensionState.entries())
|
||||
.filter(([id, extension]) =>
|
||||
extension.state === "uninstalling"
|
||||
&& !this.extensions.find(extension => extension.id === id)
|
||||
)
|
||||
.map(([id, extension]) => ({ ...extension, id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions that were added to extensions but are still in "installing" state
|
||||
*/
|
||||
@computed get addedInstalling() {
|
||||
return Array.from(this.extensionStateStore.extensionState.entries())
|
||||
.filter(([id, extension]) =>
|
||||
extension.state === "installing"
|
||||
&& this.extensions.find(extension => extension.id === id)
|
||||
)
|
||||
.map(([id, extension]) => ({ ...extension, id }));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this,
|
||||
reaction(() => this.extensions, () => {
|
||||
this.removedUninstalling.forEach(({ id, displayName }) => {
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
|
||||
);
|
||||
this.extensionStateStore.extensionState.delete(id);
|
||||
});
|
||||
|
||||
this.addedInstalling.forEach(({ id, displayName }) => {
|
||||
const extension = this.extensions.find(extension => extension.id === id);
|
||||
|
||||
if (!extension) {
|
||||
throw new Error("Extension not found");
|
||||
}
|
||||
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{displayName}</b> successfully installed!</p>
|
||||
);
|
||||
this.extensionStateStore.extensionState.delete(id);
|
||||
this.installPath = "";
|
||||
|
||||
// Enable installed extensions by default.
|
||||
extension.isEnabled = true;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@computed get extensions() {
|
||||
@computed get searchedForExtensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
|
||||
return Array.from(extensionLoader.userExtensions.values())
|
||||
@ -124,361 +373,95 @@ export class Extensions extends React.Component {
|
||||
));
|
||||
}
|
||||
|
||||
get extensionsPath() {
|
||||
return extensionDiscovery.localFolderPath;
|
||||
}
|
||||
|
||||
getExtensionPackageTemp(fileName = "") {
|
||||
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
||||
}
|
||||
|
||||
getExtensionDestFolder(name: string) {
|
||||
return path.join(this.extensionsPath, sanitizeExtensionName(name));
|
||||
}
|
||||
|
||||
installFromSelectFileDialog = async () => {
|
||||
const { dialog, BrowserWindow, app } = remote;
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
|
||||
defaultPath: app.getPath("downloads"),
|
||||
properties: ["openFile", "multiSelections"],
|
||||
message: `Select extensions to install (formats: ${Extensions.supportedFormats.join(", ")}), `,
|
||||
buttonLabel: `Use configuration`,
|
||||
filters: [
|
||||
{ name: "tarball", extensions: Extensions.supportedFormats }
|
||||
]
|
||||
});
|
||||
|
||||
if (!canceled && filePaths.length) {
|
||||
this.requestInstall(
|
||||
filePaths.map(filePath => ({
|
||||
fileName: path.basename(filePath),
|
||||
filePath,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
installFromUrlOrPath = async () => {
|
||||
const { installPath } = this;
|
||||
|
||||
if (!installPath) return;
|
||||
|
||||
this.startingInstall = true;
|
||||
const fileName = path.basename(installPath);
|
||||
|
||||
try {
|
||||
// install via url
|
||||
// fixme: improve error messages for non-tar-file URLs
|
||||
if (InputValidators.isUrl.validate(installPath)) {
|
||||
const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
|
||||
const data = await filePromise;
|
||||
|
||||
await this.requestInstall({ fileName, data });
|
||||
}
|
||||
// otherwise installing from system path
|
||||
else if (InputValidators.isPath.validate(installPath)) {
|
||||
await this.requestInstall({ fileName, filePath: installPath });
|
||||
}
|
||||
} catch (error) {
|
||||
this.startingInstall = false;
|
||||
Notifications.error(
|
||||
<p>Installation has failed: <b>{String(error)}</b></p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
installOnDrop = (files: File[]) => {
|
||||
logger.info("Install from D&D");
|
||||
|
||||
return this.requestInstall(
|
||||
files.map(file => ({
|
||||
fileName: path.basename(file.path),
|
||||
filePath: file.path,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
|
||||
const preloadedRequests = requests.filter(request => request.data);
|
||||
|
||||
await Promise.all(
|
||||
requests
|
||||
.filter(request => !request.data && request.filePath)
|
||||
.map(async request => {
|
||||
try {
|
||||
const data = await fse.readFile(request.filePath);
|
||||
|
||||
request.data = data;
|
||||
preloadedRequests.push(request);
|
||||
|
||||
return request;
|
||||
} catch(error) {
|
||||
if (showError) {
|
||||
Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return preloadedRequests as InstallRequestPreloaded[];
|
||||
}
|
||||
|
||||
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||
const tarFiles = await listTarEntries(filePath);
|
||||
|
||||
// tarball from npm contains single root folder "package/*"
|
||||
const firstFile = tarFiles[0];
|
||||
|
||||
if (!firstFile) {
|
||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||
}
|
||||
|
||||
const rootFolder = path.normalize(firstFile).split(path.sep)[0];
|
||||
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
|
||||
const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename;
|
||||
|
||||
if (!tarFiles.includes(manifestLocation)) {
|
||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||
}
|
||||
|
||||
const manifest = await readFileFromTar<LensExtensionManifest>({
|
||||
tarPath: filePath,
|
||||
filePath: manifestLocation,
|
||||
parseJson: true,
|
||||
});
|
||||
|
||||
if (!manifest.lens && !manifest.renderer) {
|
||||
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) {
|
||||
const validatedRequests: InstallRequestValidated[] = [];
|
||||
|
||||
// copy files to temp
|
||||
await fse.ensureDir(this.getExtensionPackageTemp());
|
||||
|
||||
for (const request of requests) {
|
||||
const tempFile = this.getExtensionPackageTemp(request.fileName);
|
||||
|
||||
await fse.writeFile(tempFile, request.data);
|
||||
}
|
||||
|
||||
// validate packages
|
||||
await Promise.all(
|
||||
requests.map(async req => {
|
||||
const tempFile = this.getExtensionPackageTemp(req.fileName);
|
||||
componentDidMount() {
|
||||
// TODO: change this after upgrading to mobx6 as that versions' reactions have this functionality
|
||||
let prevSize = extensionLoader.userExtensions.size;
|
||||
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => extensionLoader.userExtensions.size, curSize => {
|
||||
try {
|
||||
const manifest = await this.validatePackage(tempFile);
|
||||
|
||||
validatedRequests.push({
|
||||
...req,
|
||||
manifest,
|
||||
tempFile,
|
||||
});
|
||||
} catch (error) {
|
||||
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
|
||||
|
||||
if (showErrors) {
|
||||
Notifications.error(
|
||||
<div className="flex column gaps">
|
||||
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
|
||||
<p>Reason: <em>{String(error)}</em></p>
|
||||
</div>
|
||||
);
|
||||
if (curSize > prevSize) {
|
||||
when(() => !ExtensionInstallationStateStore.anyInstalling)
|
||||
.then(() => this.installPath = "");
|
||||
}
|
||||
} finally {
|
||||
prevSize = curSize;
|
||||
}
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
renderNoExtensionsHelpText() {
|
||||
if (this.search) {
|
||||
return <p>No search results found</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<p>
|
||||
There are no installed extensions.
|
||||
See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
|
||||
</p>
|
||||
);
|
||||
|
||||
return validatedRequests;
|
||||
}
|
||||
|
||||
async requestInstall(init: InstallRequest | InstallRequest[]) {
|
||||
const requests = Array.isArray(init) ? init : [init];
|
||||
const preloadedRequests = await this.preloadExtensions(requests);
|
||||
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
||||
|
||||
// If there are no requests for installing, reset startingInstall state
|
||||
if (validatedRequests.length === 0) {
|
||||
this.startingInstall = false;
|
||||
}
|
||||
|
||||
for (const install of validatedRequests) {
|
||||
const { name, version, description } = install.manifest;
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
const folderExists = await fse.pathExists(extensionFolder);
|
||||
|
||||
if (!folderExists) {
|
||||
// auto-install extension if not yet exists
|
||||
this.unpackExtension(install);
|
||||
} else {
|
||||
// If we show the confirmation dialog, we stop the install spinner until user clicks ok
|
||||
// and the install continues
|
||||
this.startingInstall = false;
|
||||
|
||||
// otherwise confirmation required (re-install / update)
|
||||
const removeNotification = Notifications.info(
|
||||
<div className="InstallingExtensionNotification flex gaps align-center">
|
||||
<div className="flex column gaps">
|
||||
<p>Install extension <b>{name}@{version}</b>?</p>
|
||||
<p>Description: <em>{description}</em></p>
|
||||
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
|
||||
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
|
||||
</div>
|
||||
</div>
|
||||
<Button autoFocus label="Install" onClick={() => {
|
||||
removeNotification();
|
||||
this.unpackExtension(install);
|
||||
}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
renderNoExtensions() {
|
||||
return (
|
||||
<div className="no-extensions flex box gaps justify-center">
|
||||
<Icon material="info" />
|
||||
<div>
|
||||
{this.renderNoExtensionsHelpText()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
|
||||
const displayName = extensionDisplayName(name, version);
|
||||
const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json");
|
||||
@autobind()
|
||||
renderExtension(extension: InstalledExtension) {
|
||||
const { id, isEnabled, manifest } = extension;
|
||||
const { name, description, version } = manifest;
|
||||
const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id);
|
||||
|
||||
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
||||
|
||||
this.extensionStateStore.extensionState.set(extensionId, {
|
||||
state: "installing",
|
||||
displayName
|
||||
});
|
||||
this.startingInstall = false;
|
||||
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`);
|
||||
|
||||
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
||||
|
||||
try {
|
||||
// extract to temp folder first
|
||||
await fse.remove(unpackingTempFolder).catch(Function);
|
||||
await fse.ensureDir(unpackingTempFolder);
|
||||
await extractTar(tempFile, { cwd: unpackingTempFolder });
|
||||
|
||||
// move contents to extensions folder
|
||||
const unpackedFiles = await fse.readdir(unpackingTempFolder);
|
||||
let unpackedRootFolder = unpackingTempFolder;
|
||||
|
||||
if (unpackedFiles.length === 1) {
|
||||
// check if %extension.tgz was packed with single top folder,
|
||||
// e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball
|
||||
unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]);
|
||||
}
|
||||
|
||||
await fse.ensureDir(extensionFolder);
|
||||
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Installing extension <b>{displayName}</b> has failed: <em>{error}</em></p>
|
||||
);
|
||||
|
||||
// Remove install state on install failure
|
||||
if (this.extensionStateStore.extensionState.get(extensionId)?.state === "installing") {
|
||||
this.extensionStateStore.extensionState.delete(extensionId);
|
||||
}
|
||||
} finally {
|
||||
// clean up
|
||||
fse.remove(unpackingTempFolder).catch(Function);
|
||||
fse.unlink(tempFile).catch(Function);
|
||||
}
|
||||
}
|
||||
|
||||
confirmUninstallExtension = (extension: InstalledExtension) => {
|
||||
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||
|
||||
ConfirmDialog.open({
|
||||
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
|
||||
labelOk: "Yes",
|
||||
labelCancel: "No",
|
||||
ok: () => this.uninstallExtension(extension)
|
||||
});
|
||||
};
|
||||
|
||||
async uninstallExtension(extension: InstalledExtension) {
|
||||
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||
|
||||
try {
|
||||
this.extensionStateStore.extensionState.set(extension.id, {
|
||||
state: "uninstalling",
|
||||
displayName
|
||||
});
|
||||
|
||||
await extensionDiscovery.uninstallExtension(extension);
|
||||
} catch (error) {
|
||||
Notifications.error(
|
||||
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
|
||||
);
|
||||
|
||||
// Remove uninstall state on uninstall failure
|
||||
if (this.extensionStateStore.extensionState.get(extension.id)?.state === "uninstalling") {
|
||||
this.extensionStateStore.extensionState.delete(extension.id);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={id} className="extension flex gaps align-center">
|
||||
<div className="box grow">
|
||||
<h5>{name}</h5>
|
||||
<h6>{version}</h6>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{
|
||||
isEnabled
|
||||
? <Button accent disabled={isUninstalling} onClick={() => extension.isEnabled = false}>Disable</Button>
|
||||
: <Button plain active disabled={isUninstalling} onClick={() => extension.isEnabled = true}>Enable</Button>
|
||||
}
|
||||
<Button plain active disabled={isUninstalling} waiting={isUninstalling} onClick={() => {
|
||||
confirmUninstallExtension(extension);
|
||||
}}>Uninstall</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderExtensions() {
|
||||
const { extensions, search } = this;
|
||||
|
||||
if (!extensions.length) {
|
||||
return (
|
||||
<div className="no-extensions flex box gaps justify-center">
|
||||
<Icon material="info"/>
|
||||
<div>
|
||||
{
|
||||
search
|
||||
? <p>No search results found</p>
|
||||
: <p>There are no installed extensions. See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!extensionDiscovery.isLoaded) {
|
||||
return <div className="spinner-wrapper"><Spinner /></div>;
|
||||
}
|
||||
|
||||
return extensions.map(extension => {
|
||||
const { id, isEnabled, manifest } = extension;
|
||||
const { name, description, version } = manifest;
|
||||
const isUninstalling = this.extensionStateStore.extensionState.get(id)?.state === "uninstalling";
|
||||
const { searchedForExtensions } = this;
|
||||
|
||||
return (
|
||||
<div key={id} className="extension flex gaps align-center">
|
||||
<div className="box grow">
|
||||
<h5>{name}</h5>
|
||||
<h6>{version}</h6>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{!isEnabled && (
|
||||
<Button plain active disabled={isUninstalling} onClick={() => {
|
||||
extension.isEnabled = true;
|
||||
}}>Enable</Button>
|
||||
)}
|
||||
{isEnabled && (
|
||||
<Button accent disabled={isUninstalling} onClick={() => {
|
||||
extension.isEnabled = false;
|
||||
}}>Disable</Button>
|
||||
)}
|
||||
<Button plain active disabled={isUninstalling} waiting={isUninstalling} onClick={() => {
|
||||
this.confirmUninstallExtension(extension);
|
||||
}}>Uninstall</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!searchedForExtensions.length) {
|
||||
return this.renderNoExtensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* True if at least one extension is in installing state
|
||||
*/
|
||||
@computed get isInstalling() {
|
||||
return [...this.extensionStateStore.extensionState.values()].some(extension => extension.state === "installing");
|
||||
return (
|
||||
<>
|
||||
{...searchedForExtensions.map(this.renderExtension)}
|
||||
{
|
||||
ExtensionInstallationStateStore.anyPreInstallingOrInstalling
|
||||
&& <div className="spinner-wrapper"><Spinner /></div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -486,7 +469,7 @@ export class Extensions extends React.Component {
|
||||
const { installPath } = this;
|
||||
|
||||
return (
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<DropFileInput onDropFiles={installOnDrop}>
|
||||
<PageLayout showOnTop className="Extensions" header={topHeader} contentGaps={false}>
|
||||
<h2>Lens Extensions</h2>
|
||||
<div>
|
||||
@ -500,19 +483,19 @@ export class Extensions extends React.Component {
|
||||
<Input
|
||||
className="box grow"
|
||||
theme="round-black"
|
||||
disabled={this.isInstalling}
|
||||
placeholder={`Path or URL to an extension package (${Extensions.supportedFormats.join(", ")})`}
|
||||
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
|
||||
placeholder={`Path or URL to an extension package (${supportedFormats.join(", ")})`}
|
||||
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
|
||||
validators={installPath ? Extensions.installPathValidator : undefined}
|
||||
value={installPath}
|
||||
onChange={value => this.installPath = value}
|
||||
onSubmit={this.installFromUrlOrPath}
|
||||
onSubmit={() => installFromUrlOrPath(this.installPath)}
|
||||
iconLeft="link"
|
||||
iconRight={
|
||||
<Icon
|
||||
interactive
|
||||
material="folder"
|
||||
onClick={prevDefault(this.installFromSelectFileDialog)}
|
||||
onClick={prevDefault(installFromSelectFileDialog)}
|
||||
tooltip="Browse"
|
||||
/>
|
||||
}
|
||||
@ -521,9 +504,8 @@ export class Extensions extends React.Component {
|
||||
<Button
|
||||
primary
|
||||
label="Install"
|
||||
disabled={this.isInstalling || !Extensions.installPathValidator.validate(installPath)}
|
||||
waiting={this.isInstalling}
|
||||
onClick={this.installFromUrlOrPath}
|
||||
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling || !Extensions.installPathValidator.validate(installPath)}
|
||||
onClick={() => installFromUrlOrPath(this.installPath)}
|
||||
/>
|
||||
<small className="hint">
|
||||
<b>Pro-Tip</b>: you can also drag-n-drop tarball-file to this area
|
||||
@ -537,7 +519,7 @@ export class Extensions extends React.Component {
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
{extensionDiscovery.isLoaded ? this.renderExtensions() : <div className="spinner-wrapper"><Spinner/></div>}
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</DropFileInput>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user