diff --git a/package.json b/package.json index 26c08a2a85..53d67fb862 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "bundledHelmVersion": "3.3.4" }, "engines": { - "node": ">=12.0 <13.0" + "node": ">=12 <13" }, "lingui": { "locales": [ @@ -214,7 +214,7 @@ "@types/node": "^12.12.45", "@types/proper-lockfile": "^4.1.1", "@types/react-beautiful-dnd": "^13.0.0", - "@types/tar": "^4.0.3", + "@types/tar": "^4.0.4", "array-move": "^3.0.0", "await-lock": "^2.1.0", "chalk": "^4.1.0", @@ -251,7 +251,7 @@ "serializr": "^2.0.3", "shell-env": "^3.0.0", "spdy": "^4.0.2", - "tar": "^6.0.2", + "tar": "^6.0.5", "tcp-port-used": "^1.0.1", "tempy": "^0.5.0", "uuid": "^8.1.0", @@ -314,7 +314,6 @@ "@types/sharp": "^0.26.0", "@types/shelljs": "^0.8.8", "@types/spdy": "^3.4.4", - "@types/tar": "^4.0.3", "@types/tcp-port-used": "^1.0.0", "@types/tempy": "^0.3.0", "@types/terser-webpack-plugin": "^3.0.0", diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts new file mode 100644 index 0000000000..a58e9242b4 --- /dev/null +++ b/src/common/utils/downloadFile.ts @@ -0,0 +1,35 @@ +import request from "request"; + +export interface DownloadFileOptions { + url: string; + gzip?: boolean; +} + +export interface DownloadFileTicket { + url: string; + promise: Promise; + cancel(): void; +} + +export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket { + const fileChunks: Buffer[] = []; + const req = request(url, { gzip }); + const promise: Promise = new Promise((resolve, reject) => { + req.on("data", (chunk: Buffer) => { + fileChunks.push(chunk); + }); + req.once("error", err => { + reject({ url, err }); + }); + req.once("complete", () => { + resolve(Buffer.concat(fileChunks)); + }); + }); + return { + url, + promise, + cancel() { + req.abort(); + } + }; +} diff --git a/src/common/utils/escapeRegExp.ts b/src/common/utils/escapeRegExp.ts new file mode 100644 index 0000000000..dbf10e4bfb --- /dev/null +++ b/src/common/utils/escapeRegExp.ts @@ -0,0 +1,5 @@ +// Helper to sanitize / escape special chars for passing to RegExp-constructor + +export function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 330b98fcf7..b1006b5f58 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -15,3 +15,6 @@ export * from "./saveToAppFiles"; export * from "./singleton"; export * from "./openExternal"; export * from "./rectify-array"; +export * from "./downloadFile"; +export * from "./escapeRegExp"; +export * from "./tar"; diff --git a/src/common/utils/tar.ts b/src/common/utils/tar.ts new file mode 100644 index 0000000000..004fa354dc --- /dev/null +++ b/src/common/utils/tar.ts @@ -0,0 +1,55 @@ +// Helper for working with tarball files (.tar, .tgz) +// Docs: https://github.com/npm/node-tar +import tar, { ExtractOptions, FileStat } from "tar"; +import path from "path"; + +export interface ReadFileFromTarOpts { + tarPath: string; + filePath: string; + parseJson?: boolean; +} + +export function readFileFromTar({ tarPath, filePath, parseJson }: ReadFileFromTarOpts): Promise { + return new Promise(async (resolve, reject) => { + const fileChunks: Buffer[] = []; + + await tar.list({ + file: tarPath, + filter: path => path === filePath, + onentry(entry: FileStat) { + entry.on("data", chunk => { + fileChunks.push(chunk); + }); + entry.once("error", err => { + reject(new Error(`reading file has failed ${entry.path}: ${err}`)); + }); + entry.once("end", () => { + const data = Buffer.concat(fileChunks); + const result = parseJson ? JSON.parse(data.toString("utf8")) : data; + resolve(result); + }); + }, + }); + + if (!fileChunks.length) { + reject(new Error("Not found")); + } + }); +} + +export async function listTarEntries(filePath: string): Promise { + const entries: string[] = []; + await tar.list({ + file: filePath, + onentry: (entry: FileStat) => entries.push(entry.path as any as string), + }); + return entries; +} + +export function extractTar(filePath: string, opts: ExtractOptions & { sync?: boolean } = {}) { + return tar.extract({ + file: filePath, + cwd: path.dirname(filePath), + ...opts, + }); +} diff --git a/src/common/vars.ts b/src/common/vars.ts index ab566bb675..ac9f1336ee 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -41,3 +41,5 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis // Links export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"; export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI"; +export const docsUrl = "https://docs.k8slens.dev/"; +export const supportUrl = "https://docs.k8slens.dev/latest/support/"; diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 991725ee67..895fb272d7 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -17,7 +17,7 @@ export interface InstalledExtension { } const logModule = "[EXTENSION-DISCOVERY]"; -const manifestFilename = "package.json"; +export const manifestFilename = "package.json"; /** * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 1af3300ff0..3c9f70eb49 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -12,6 +12,7 @@ export interface LensExtensionManifest { description?: string; main?: string; // path to %ext/dist/main.js renderer?: string; // path to %ext/dist/renderer.js + lens?: object; // fixme: add more required fields for validation } export class LensExtension { @@ -109,3 +110,7 @@ export class LensExtension { // mock } } + +export function sanitizeExtensionName(name: string) { + return name.replace("@", "").replace("/", "--"); +} diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index 29e60dccc0..fafc801cc4 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -25,8 +25,8 @@ describe("getPageUrl", () => { expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test"); }); - it("removes @", () => { - expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo-bar"); + it("removes @ and replace `/` to `--`", () => { + expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo--bar"); }); it("adds / prefix", () => { diff --git a/src/extensions/registries/page-registry.ts b/src/extensions/registries/page-registry.ts index 0b385c02c4..c346c6a56d 100644 --- a/src/extensions/registries/page-registry.ts +++ b/src/extensions/registries/page-registry.ts @@ -5,7 +5,7 @@ import path from "path"; import { action } from "mobx"; import { compile } from "path-to-regexp"; import { BaseRegistry } from "./base-registry"; -import { LensExtension } from "../lens-extension"; +import { LensExtension, sanitizeExtensionName } from "../lens-extension"; import logger from "../../main/logger"; import { recitfy } from "../../common/utils"; @@ -45,10 +45,6 @@ export interface PageComponents { Page: React.ComponentType; } -export function sanitizeExtensionName(name: string) { - return name.replace("@", "").replace("/", "-"); -} - export function getExtensionPageUrl

({ extensionId, pageId = "", params }: PageMenuTarget

): string { const extensionBaseUrl = compile(`/extension/:name`)({ name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path diff --git a/src/main/menu.ts b/src/main/menu.ts index e7add90e77..06bd9095cb 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow, dialog, ipcMain, IpcMainEvent, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron"; import { autorun } from "mobx"; import { WindowManager } from "./window-manager"; -import { appName, isMac, isWindows, isTestEnv } from "../common/vars"; +import { appName, isMac, isWindows, isTestEnv, docsUrl, supportUrl } from "../common/vars"; import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; @@ -24,6 +24,7 @@ export function showAbout(browserWindow: BrowserWindow) { `${appName}: ${app.getVersion()}`, `Electron: ${process.versions.electron}`, `Chrome: ${process.versions.chrome}`, + `Node: ${process.versions.node}`, `Copyright 2020 Mirantis, Inc.`, ]; dialog.showMessageBoxSync(browserWindow, { @@ -215,13 +216,13 @@ export function buildMenu(windowManager: WindowManager) { { label: "Documentation", click: async () => { - shell.openExternal('https://docs.k8slens.dev/'); + shell.openExternal(docsUrl); }, }, { label: "Support", click: async () => { - shell.openExternal('https://docs.k8slens.dev/latest/support/'); + shell.openExternal(supportUrl); }, }, ...ignoreOnMac([ diff --git a/src/renderer/components/+add-cluster/add-cluster.scss b/src/renderer/components/+add-cluster/add-cluster.scss index 90977fa78b..d80373406d 100644 --- a/src/renderer/components/+add-cluster/add-cluster.scss +++ b/src/renderer/components/+add-cluster/add-cluster.scss @@ -1,12 +1,4 @@ .AddCluster { - .droppable { - box-shadow: 0 0 0 5px inset $primary; - - > * { - pointer-events: none; - } - } - .hint { margin-top: -$padding; color: $textColorSecondary; diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 72b54aabd1..1fae256618 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -8,7 +8,7 @@ import { KubeConfig } from "@kubernetes/client-node"; import { _i18n } from "../../i18n"; import { t, Trans } from "@lingui/macro"; import { Select, SelectOption } from "../select"; -import { Input } from "../input"; +import { DropFileInput, Input } from "../input"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; import { Icon } from "../icon"; @@ -44,7 +44,6 @@ export class AddCluster extends React.Component { @observable proxyServer = ""; @observable isWaiting = false; @observable showSettings = false; - @observable dropAreaActive = false; componentDidMount() { clusterStore.setActive(null); @@ -121,6 +120,11 @@ export class AddCluster extends React.Component { } }; + onDropKubeConfig = (files: File[]) => { + this.sourceTab = KubeConfigSourceTab.FILE; + this.setKubeConfig(files[0].path); + }; + @action addClusters = () => { let newClusters: ClusterModel[] = []; @@ -139,7 +143,7 @@ export class AddCluster extends React.Component { return true; } catch (err) { this.error = String(err.message); - if (err instanceof ExecValidationNotFoundError ) { + if (err instanceof ExecValidationNotFoundError) { Notifications.error(Error while adding cluster(s): {this.error}); return false; } else { @@ -230,7 +234,7 @@ export class AddCluster extends React.Component { Select kubeconfig file} - active={this.sourceTab == KubeConfigSourceTab.FILE} /> + active={this.sourceTab == KubeConfigSourceTab.FILE}/> Paste as text} @@ -344,71 +348,55 @@ export class AddCluster extends React.Component { return (

{context} - {isNew && } - {isSelected && } + {isNew && } + {isSelected && }
); }; render() { const addDisabled = this.selectedContexts.length === 0; - return ( - this.dropAreaActive = true, - onDragLeave: event => this.dropAreaActive = false, - onDragOver: event => { - event.preventDefault(); // enable onDrop()-callback - event.dataTransfer.dropEffect = "move"; - }, - onDrop: event => { - this.sourceTab = KubeConfigSourceTab.FILE; - this.dropAreaActive = false; - this.setKubeConfig(event.dataTransfer.files[0].path); - } - }} - > -

Add Cluster

- {this.renderKubeConfigSource()} - {this.renderContextSelector()} - - {this.showSettings && ( -
-

HTTP Proxy server. Used for communicating with Kubernetes API.

- this.proxyServer = value} - theme="round-black" - /> - - {'A HTTP proxy server URL (format: http://
:).'} - + + +

Add Cluster

+ {this.renderKubeConfigSource()} + {this.renderContextSelector()} + - )} - {this.error && ( -
{this.error}
- )} -
-
-
+ {this.showSettings && ( +
+

HTTP Proxy server. Used for communicating with Kubernetes API.

+ this.proxyServer = value} + theme="round-black" + /> + + {'A HTTP proxy server URL (format: http://
:).'} + +
+ )} + {this.error && ( +
{this.error}
+ )} +
+
+ +
); } } diff --git a/src/renderer/components/+extensions/extensions.scss b/src/renderer/components/+extensions/extensions.scss index 63778d37e9..8350b62b9c 100644 --- a/src/renderer/components/+extensions/extensions.scss +++ b/src/renderer/components/+extensions/extensions.scss @@ -1,22 +1,54 @@ .Extensions { + $spacing: $padding * 2; --width: 100%; --max-width: auto; - .extension-list { + .extensions-list { .extension { --flex-gap: $padding / 3; - padding: $padding $padding * 2; + padding: $padding $spacing; background: $colorVague; border-radius: $radius; &:not(:first-of-type) { - margin-top: $padding * 2; + margin-top: $spacing; } } } + .extensions-info { + --flex-gap: #{$spacing}; + + > .flex.gaps { + --flex-gap: #{$padding}; + } + } + .extensions-path { word-break: break-all; + + &:hover code { + color: $textColorSecondary; + cursor: pointer; + } + } + + .Clipboard { + display: inline; + vertical-align: baseline; + font-size: $font-size-small; + + &:hover { + color: $textColorSecondary; + } + } + + .SearchInput { + --spacing: #{$padding}; + } + + .SubTitle { + font-style: italic; } .WizardLayout { @@ -27,15 +59,28 @@ align-self: flex-start; } } +} - .SearchInput { - margin-top: $margin / 2; - margin-bottom: $margin * 2; - max-width: none; +.InstallingExtensionNotification { + .remove-folder-warning { + font-size: $font-size-small; + font-style: italic; + opacity: .8; + cursor: pointer; - > label { - padding: $padding $padding * 2; - border-radius: $radius; + &:hover { + opacity: 1; + } + + code { + display: inline; + color: inherit; } } + + .Button { + background-color: unset; + border: 1px solid currentColor; + box-shadow: none !important; + } } \ No newline at end of file diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index a8b9c54f51..90577cd3a4 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -1,5 +1,8 @@ import "./extensions.scss"; -import { shell } from "electron"; +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"; @@ -7,15 +10,39 @@ 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 { DropFileInput, Input, InputValidators, SearchInput } from "../input"; import { Icon } from "../icon"; +import { SubTitle } from "../layout/sub-title"; import { PageLayout } from "../layout/page-layout"; +import { Clipboard } from "../clipboard"; +import logger from "../../../main/logger"; import { extensionLoader } from "../../../extensions/extension-loader"; -import { extensionDiscovery } from "../../../extensions/extension-discovery"; +import { extensionDiscovery, manifestFilename } from "../../../extensions/extension-discovery"; +import { LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; +import { Notifications } from "../notifications"; +import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils"; +import { docsUrl } from "../../../common/vars"; + +interface InstallRequest { + fileName: string; + filePath?: string; + data?: Buffer; +} + +interface InstallRequestPreloaded extends InstallRequest { + data: Buffer; +} + +interface InstallRequestValidated extends InstallRequestPreloaded { + manifest: LensExtensionManifest; + tempFile: string; // temp system path to packed extension for unpacking +} @observer export class Extensions extends React.Component { + private supportedFormats = [".tar", ".tgz"]; @observable search = ""; + @observable downloadUrl = ""; @computed get extensions() { const searchText = this.search.toLowerCase(); @@ -32,27 +59,265 @@ export class Extensions extends React.Component { 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: _i18n._(t`Select extensions to install (formats: ${this.supportedFormats.join(", ")}), `), + buttonLabel: _i18n._(t`Use configuration`), + filters: [ + { name: "tarball", extensions: this.supportedFormats } + ] + }); + if (!canceled && filePaths.length) { + this.requestInstall( + filePaths.map(filePath => ({ + fileName: path.basename(filePath), + filePath, + })) + ); + } + }; + + addExtensions = () => { + const { downloadUrl } = this; + if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) { + this.installFromUrl(downloadUrl); + } else { + this.installFromSelectFileDialog(); + } + }; + + installFromUrl = async (url: string) => { + try { + const { promise: filePromise } = downloadFile({ url }); + this.requestInstall([{ + fileName: path.basename(url), + data: await filePromise, + }]); + } catch (err) { + Notifications.error( +

Installation via URL has failed: {String(err)}

+ ); + } + }; + + 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(req => req.data); + await Promise.all( + requests + .filter(req => !req.data && req.filePath) + .map(req => { + return fse.readFile(req.filePath).then(data => { + req.data = data; + preloadedRequests.push(req); + }).catch(err => { + if (showError) { + Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`); + } + }); + }) + ); + return preloadedRequests as InstallRequestPreloaded[]; + } + + async validatePackage(filePath: string): Promise { + const tarFiles = await listTarEntries(filePath); + + // tarball from npm contains single root folder "package/*" + const rootFolder = tarFiles[0].split("/")[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({ + 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()); + requests.forEach(req => { + const tempFile = this.getExtensionPackageTemp(req.fileName); + fse.writeFileSync(tempFile, req.data); + }); + + // validate packages + await Promise.all( + requests.map(async req => { + const tempFile = this.getExtensionPackageTemp(req.fileName); + try { + const manifest = await this.validatePackage(tempFile); + validatedRequests.push({ + ...req, + manifest, + tempFile, + }); + } catch (err) { + fse.unlink(tempFile).catch(() => null); // remove invalid temp package + if (showErrors) { + Notifications.error( +
+

Installing {req.fileName} has failed, skipping.

+

Reason: {String(err)}

+
+ ); + } + } + }) + ); + return validatedRequests; + } + + async requestInstall(requests: InstallRequest[]) { + const preloadedRequests = await this.preloadExtensions(requests); + const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests); + + validatedRequests.forEach(install => { + const { name, version, description } = install.manifest; + const extensionFolder = this.getExtensionDestFolder(name); + const folderExists = fse.existsSync(extensionFolder); + if (!folderExists) { + // auto-install extension if not yet exists + this.unpackExtension(install); + } else { + // otherwise confirmation required (re-install / update) + const removeNotification = Notifications.info( +
+
+

Install extension {name}@{version}?

+

Description: {description}

+
shell.openPath(extensionFolder)}> + Warning: {extensionFolder} will be removed before installation. +
+
+
+ ); + } + }); + } + + async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { + const extName = `${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); + 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 }); + Notifications.ok( +

Extension {extName} successfully installed!

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

Installing extension {extName} has failed: {err}

+ ); + } finally { + // clean up + fse.remove(unpackingTempFolder).catch(Function); + fse.unlink(tempFile).catch(Function); + } + } + renderInfo() { return ( -
+

Lens Extension API

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.
- Extensions loaded from: -
+ +
shell.openPath(this.extensionsPath)}> + {this.extensionsPath} - shell.openPath(this.extensionsPath)} - />
-
- Check out documentation to learn more +
+ + this.downloadUrl = v} + onSubmit={this.addExtensions} + /> +
+
+ +

+ Check out documentation to learn more +

); @@ -95,19 +360,18 @@ export class Extensions extends React.Component { render() { return ( Extensions}> - - this.search = value} - /> -
- {this.renderExtensions()} -
-
+ + + this.search = value} + /> +
+ {this.renderExtensions()} +
+
+
); } diff --git a/src/renderer/components/clipboard/clipboard.scss b/src/renderer/components/clipboard/clipboard.scss new file mode 100644 index 0000000000..1c8e007dba --- /dev/null +++ b/src/renderer/components/clipboard/clipboard.scss @@ -0,0 +1,3 @@ +.Clipboard { + cursor: pointer; +} \ No newline at end of file diff --git a/src/renderer/components/clipboard/clipboard.tsx b/src/renderer/components/clipboard/clipboard.tsx new file mode 100644 index 0000000000..77543ea8f4 --- /dev/null +++ b/src/renderer/components/clipboard/clipboard.tsx @@ -0,0 +1,62 @@ +import "./clipboard.scss"; +import React from "react"; +import { findDOMNode } from "react-dom"; +import { autobind } from "../../../common/utils"; +import { Notifications } from "../notifications"; +import { copyToClipboard } from "../../utils/copyToClipboard"; +import logger from "../../../main/logger"; +import { cssNames } from "../../utils"; + +export interface CopyToClipboardProps { + resetSelection?: boolean; + showNotification?: boolean; + cssSelectorLimit?: string; // allows to copy partial content with css-selector in children-element context + getNotificationMessage?(copiedText: string): React.ReactNode; +} + +export const defaultProps: Partial = { + getNotificationMessage(copiedText: string) { + return

Copied to clipboard: {copiedText}

; + } +}; + +export class Clipboard extends React.Component { + static displayName = "Clipboard"; + static defaultProps = defaultProps as object; + + get rootElem(): HTMLElement { + return findDOMNode(this) as HTMLElement; + } + + get rootReactElem(): React.ReactElement> { + return React.Children.only(this.props.children) as React.ReactElement; + } + + @autobind() + onClick(evt: React.MouseEvent) { + if (this.rootReactElem.props.onClick) { + this.rootReactElem.props.onClick(evt); // pass event to children-root-element if any + } + const { showNotification, resetSelection, getNotificationMessage, cssSelectorLimit } = this.props; + const contentElem = this.rootElem.querySelector(cssSelectorLimit) || this.rootElem; + if (contentElem) { + const { copiedText, copied } = copyToClipboard(contentElem, { resetSelection }); + if (copied && showNotification) { + Notifications.ok(getNotificationMessage(copiedText)); + } + } + } + + render() { + try { + const rootElem = this.rootReactElem; + return React.cloneElement(rootElem, { + className: cssNames(Clipboard.displayName, rootElem.props.className), + onClick: this.onClick, + }); + } catch (err) { + logger.error(`Invalid usage components/CopyToClick usage. Children must contain root html element.`, { err: String(err) }); + return this.rootReactElem; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/clipboard/index.ts b/src/renderer/components/clipboard/index.ts new file mode 100644 index 0000000000..b711992418 --- /dev/null +++ b/src/renderer/components/clipboard/index.ts @@ -0,0 +1 @@ +export * from "./clipboard"; diff --git a/src/renderer/components/dock/pod-log-controls.tsx b/src/renderer/components/dock/pod-log-controls.tsx index 469264210d..17ad8a2ddf 100644 --- a/src/renderer/components/dock/pod-log-controls.tsx +++ b/src/renderer/components/dock/pod-log-controls.tsx @@ -6,7 +6,7 @@ import { Select, SelectOption } from "../select"; import { Badge } from "../badge"; import { Icon } from "../icon"; import { _i18n } from "../../i18n"; -import { cssNames, downloadFile } from "../../utils"; +import { cssNames, saveFileDialog } from "../../utils"; import { Pod } from "../../api/endpoints"; import { PodLogSearch, PodLogSearchProps } from "./pod-log-search"; @@ -39,7 +39,7 @@ export const PodLogControls = observer((props: Props) => { const downloadLogs = () => { const fileName = selectedContainer ? selectedContainer.name : pod.getName(); - downloadFile(fileName + ".log", logs.join("\n"), "text/plain"); + saveFileDialog(fileName + ".log", logs.join("\n"), "text/plain"); }; const onContainerChange = (option: SelectOption) => { diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index 98ac5ff97a..bc7534d167 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -32,7 +32,7 @@ export class Icon extends React.PureComponent { get isInteractive() { const { interactive, onClick, href, link } = this.props; - return interactive || !!(onClick || href || link); + return interactive ?? !!(onClick || href || link); } @autobind() diff --git a/src/renderer/components/input/drop-file-input.scss b/src/renderer/components/input/drop-file-input.scss new file mode 100644 index 0000000000..3675d5b843 --- /dev/null +++ b/src/renderer/components/input/drop-file-input.scss @@ -0,0 +1,9 @@ +.DropFileInput { + &.droppable { + box-shadow: 0 0 0 5px $primary; // fixme: might not work sometimes + + > * { + pointer-events: none; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/input/drop-file-input.tsx b/src/renderer/components/input/drop-file-input.tsx new file mode 100644 index 0000000000..32ace899d6 --- /dev/null +++ b/src/renderer/components/input/drop-file-input.tsx @@ -0,0 +1,76 @@ +import "./drop-file-input.scss"; +import React from "react"; +import { autobind, cssNames, IClassName } from "../../utils"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import logger from "../../../main/logger"; + +export interface DropFileInputProps extends React.DOMAttributes { + className?: IClassName; + disabled?: boolean; + onDropFiles(files: File[], meta: DropFileMeta): void; +} + +export interface DropFileMeta { + evt: React.DragEvent; +} + +@observer +export class DropFileInput extends React.Component { + @observable dropAreaActive = false; + + @autobind() + onDragEnter() { + this.dropAreaActive = true; + } + + @autobind() + onDragLeave() { + this.dropAreaActive = false; + } + + @autobind() + onDragOver(evt: React.DragEvent) { + if (this.props.onDragOver) { + this.props.onDragOver(evt); + } + evt.preventDefault(); // enable onDrop()-callback + evt.dataTransfer.dropEffect = "move"; + } + + @autobind() + onDrop(evt: React.DragEvent) { + if (this.props.onDrop) { + this.props.onDrop(evt); + } + this.dropAreaActive = false; + const files = Array.from(evt.dataTransfer.files); + if (files.length > 0) { + this.props.onDropFiles(files, { evt }); + } + } + + render() { + const { disabled, className } = this.props; + const { onDragEnter, onDragLeave, onDragOver, onDrop } = this; + try { + const contentElem = React.Children.only(this.props.children) as React.ReactElement>; + const isValidContentElem = React.isValidElement(contentElem); + if (!disabled && isValidContentElem) { + const contentElemProps: React.HTMLProps = { + className: cssNames("DropFileInput", className, { + droppable: this.dropAreaActive, + }), + onDragEnter, + onDragLeave, + onDragOver, + onDrop, + }; + return React.cloneElement(contentElem, contentElemProps); + } + } catch (err) { + logger.error("Invalid root content-element for DropFileInput", { err: String(err) }); + return this.props.children; + } + } +} diff --git a/src/renderer/components/input/index.ts b/src/renderer/components/input/index.ts index fc930e45d2..2e807d0be6 100644 --- a/src/renderer/components/input/index.ts +++ b/src/renderer/components/input/index.ts @@ -2,3 +2,4 @@ export * from './input'; export * from './search-input'; export * from './search-input-url'; export * from './file-input'; +export * from './drop-file-input'; diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index 31f3c9c46c..b4c3e21703 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -89,6 +89,10 @@ &.theme { &.round-black { + &.invalid label { + border-color: $colorSoftError !important; + } + label { background: $mainBackground; border: 1px solid $borderFaintColor; @@ -107,3 +111,9 @@ } } } + +.Tooltip.InputTooltipError { + --bgc: #{$colorError}; + --border: none; + --color: white; +} diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index 1b4f575b26..cddfcb92eb 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -1,7 +1,7 @@ import "./input.scss"; import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react"; -import { autobind, cssNames, debouncePromise } from "../../utils"; +import { autobind, cssNames, debouncePromise, getRandId } from "../../utils"; import { Icon } from "../icon"; import * as Validators from "./input_validators"; import { InputValidator } from "./input_validators"; @@ -9,6 +9,7 @@ import isString from "lodash/isString"; import isFunction from "lodash/isFunction"; import isBoolean from "lodash/isBoolean"; import uniqueId from "lodash/uniqueId"; +import { Tooltip } from "../tooltip"; const { conditionalValidators, ...InputValidators } = Validators; export { InputValidators, InputValidator }; @@ -25,6 +26,7 @@ export type InputProps = Omit { render() { const { - multiLine, showValidationLine, validators, theme, maxRows, children, + multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip, maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, ...inputProps } = this.props; @@ -292,21 +294,31 @@ export class Input extends React.Component { ref: this.bindRef, spellCheck: "false", }); - + const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; + const showErrors = errors.length > 0 && !valid && dirty; + const errorsInfo = ( +
+ {errors.map((error, i) =>

{error}

)} +
+ ); return ( -
-