diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts index 34dcaec1fe..90aebfaee9 100644 --- a/src/extensions/__tests__/extension-loader.test.ts +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -18,6 +18,7 @@ jest.mock( name: "TestExtension", version: "1.0.0", }, + absolutePath: "/test/1", manifestPath, isBundled: false, isEnabled: true, @@ -30,6 +31,7 @@ jest.mock( name: "TestExtension2", version: "2.0.0", }, + absolutePath: "/test/2", manifestPath: manifestPath2, isBundled: false, isEnabled: true, @@ -52,6 +54,7 @@ jest.mock( name: "TestExtension", version: "1.0.0", }, + absolutePath: "/test/1", manifestPath, isBundled: false, isEnabled: true, @@ -64,7 +67,8 @@ jest.mock( name: "TestExtension3", version: "3.0.0", }, - manifestPath3, + absolutePath: "/test/3", + manifestPath: manifestPath3, isBundled: false, isEnabled: true, }, @@ -94,6 +98,7 @@ describe("ExtensionLoader", () => { expect(extensionLoader.userExtensions).toMatchInlineSnapshot(` Map { "manifest/path" => Object { + "absolutePath": "/test/1", "isBundled": false, "isEnabled": true, "manifest": Object { @@ -103,13 +108,14 @@ describe("ExtensionLoader", () => { "manifestPath": "manifest/path", }, "manifest/path3" => Object { + "absolutePath": "/test/3", "isBundled": false, "isEnabled": true, "manifest": Object { "name": "TestExtension3", "version": "3.0.0", }, - "manifestPath3": "manifest/path3", + "manifestPath": "manifest/path3", }, } `); diff --git a/src/extensions/__tests__/lens-extension.test.ts b/src/extensions/__tests__/lens-extension.test.ts index d6ba04cbb5..277a76b410 100644 --- a/src/extensions/__tests__/lens-extension.test.ts +++ b/src/extensions/__tests__/lens-extension.test.ts @@ -9,6 +9,7 @@ describe("lens extension", () => { name: "foo-bar", version: "0.1.1" }, + absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, isEnabled: true diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index ab17606e92..452bba4d65 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -11,6 +11,12 @@ import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; export interface InstalledExtension { readonly manifest: LensExtensionManifest; + + // 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; @@ -174,6 +180,24 @@ export class ExtensionDiscovery { } }; + /** + * Uninstalls extension by path. + * The application will detect the folder unlink and remove the extension from the UI automatically. + * @param absolutePath Path to the non-symlinked folder of the extension + */ + async uninstallExtension(absolutePath: string) { + logger.info(`${logModule} Uninstalling ${absolutePath}`); + + const exists = await fs.pathExists(absolutePath); + + if (!exists) { + throw new Error(`Extension path ${absolutePath} doesn't exist`); + } + + // fs.remove does nothing if the path doesn't exist anymore + await fs.remove(absolutePath); + } + async load(): Promise> { if (this.loadStarted) { // The class is simplified by only supporting .load() to be called once @@ -230,6 +254,7 @@ export class ExtensionDiscovery { const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); return { + absolutePath: path.dirname(manifestPath), manifestPath: installedManifestPath, manifest: manifestJson, isBundled, diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 43f080325b..a73f173e7c 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -68,15 +68,18 @@ export class ExtensionLoader { logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); const instance = this.instances.get(lensExtensionId); - if (instance) { - try { - instance.disable(); - this.events.emit("remove", instance); - this.instances.delete(lensExtensionId); - } catch (error) { - logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); - } + if (!instance) { + return; } + + try { + instance.disable(); + this.events.emit("remove", instance); + this.instances.delete(lensExtensionId); + } catch (error) { + logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); + } + } removeExtension(lensExtensionId: LensExtensionId) { @@ -85,7 +88,6 @@ export class ExtensionLoader { if (!this.extensions.delete(lensExtensionId)) { throw new Error(`Can't remove extension ${lensExtensionId}, doesn't exist.`); } - } protected async initMain() { diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 3c9f70eb49..1d16183d75 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -114,3 +114,7 @@ export class LensExtension { export function sanitizeExtensionName(name: string) { return name.replace("@", "").replace("/", "--"); } + +export function extensionDisplayName(name: string, version: string) { + return `${name}@${version}`; +} diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index fafc801cc4..4c94274a37 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -11,6 +11,7 @@ describe("getPageUrl", () => { name: "foo-bar", version: "0.1.1" }, + absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, isEnabled: true @@ -41,6 +42,7 @@ describe("globalPageRegistry", () => { name: "@acme/foo-bar", version: "0.1.1" }, + absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, isEnabled: true diff --git a/src/renderer/components/+extensions/extensions.scss b/src/renderer/components/+extensions/extensions.scss index 79f96c74af..888341728a 100644 --- a/src/renderer/components/+extensions/extensions.scss +++ b/src/renderer/components/+extensions/extensions.scss @@ -26,6 +26,10 @@ padding: $padding $spacing; background: $layoutBackground; border-radius: $radius; + + .actions > button:not(:last-child) { + margin-right: $spacing / 2; + } } } diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 12965feefc..03f34f96ee 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -1,27 +1,27 @@ -import "./extensions.scss"; +import { t, Trans } from "@lingui/macro"; import { remote, shell } from "electron"; -import os from "os"; -import path from "path"; import fse from "fs-extra"; -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 { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input"; -import { Icon } from "../icon"; -import { SubTitle } from "../layout/sub-title"; -import { PageLayout } from "../layout/page-layout"; -import logger from "../../../main/logger"; -import { extensionLoader } from "../../../extensions/extension-loader"; -import { extensionDiscovery, manifestFilename } from "../../../extensions/extension-discovery"; -import { LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; -import { Notifications } from "../notifications"; +import os from "os"; +import path from "path"; +import React from "react"; import { downloadFile, extractTar, listTarEntries, 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 logger from "../../../main/logger"; +import { _i18n } from "../../i18n"; import { prevDefault } from "../../utils"; +import { Button } from "../button"; +import { Icon } from "../icon"; +import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input"; +import { PageLayout } from "../layout/page-layout"; +import { SubTitle } from "../layout/sub-title"; +import { Notifications } from "../notifications"; import { TooltipPosition } from "../tooltip"; +import "./extensions.scss"; interface InstallRequest { fileName: string; @@ -112,9 +112,9 @@ export class Extensions extends React.Component { else if (InputValidators.isPath.validate(installPath)) { this.requestInstall({ fileName, filePath: installPath }); } - } catch (err) { + } catch (error) { Notifications.error( -

Installation has failed: {String(err)}

+

Installation has failed: {String(error)}

); } }; @@ -131,6 +131,7 @@ export class Extensions extends React.Component { async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) { const preloadedRequests = requests.filter(req => req.data); + await Promise.all( requests .filter(req => !req.data && req.filePath) @@ -138,13 +139,14 @@ export class Extensions extends React.Component { return fse.readFile(req.filePath).then(data => { req.data = data; preloadedRequests.push(req); - }).catch(err => { + }).catch(error => { if (showError) { - Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`); + Notifications.error(`Error while reading "${req.filePath}": ${String(error)}`); } }); }) ); + return preloadedRequests as InstallRequestPreloaded[]; } @@ -191,13 +193,13 @@ export class Extensions extends React.Component { manifest, tempFile, }); - } catch (err) { + } catch (error) { fse.unlink(tempFile).catch(() => null); // remove invalid temp package if (showErrors) { Notifications.error(

Installing {req.fileName} has failed, skipping.

-

Reason: {String(err)}

+

Reason: {String(error)}

); } @@ -241,7 +243,7 @@ export class Extensions extends React.Component { } async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { - const extName = `${name}@${version}`; + const extName = extensionDisplayName(name, version); logger.info(`Unpacking extension ${extName}`, { fileName, tempFile }); const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked"); const extensionFolder = this.getExtensionDestFolder(name); @@ -264,9 +266,9 @@ export class Extensions extends React.Component { Notifications.ok(

Extension {extName} successfully installed!

); - } catch (err) { + } catch (error) { Notifications.error( -

Installing extension {extName} has failed: {err}

+

Installing extension {extName} has failed: {error}

); } finally { // clean up @@ -275,6 +277,18 @@ export class Extensions extends React.Component { } } + async uninstallExtension(extension: InstalledExtension) { + const extensionName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + + try { + await extensionDiscovery.uninstallExtension(extension.absolutePath); + } catch (error) { + Notifications.error( +

Uninstalling extension {extensionName} has failed: {error?.message ?? ""}

+ ); + } + } + renderExtensions() { const { extensions, extensionsPath, search } = this; @@ -293,6 +307,7 @@ export class Extensions extends React.Component { return extensions.map(ext => { const { manifestPath: extId, isEnabled, manifest } = ext; const { name, description } = manifest; + return (
@@ -303,12 +318,17 @@ export class Extensions extends React.Component { Description: {description}
- {!isEnabled && ( - - )} - {isEnabled && ( - - )} +
+ {!isEnabled && ( + + )} + {isEnabled && ( + + )} + +
); });