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

Option to install an extension from filesystem/url #1227 -- part 1 (UI)

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2020-11-19 19:49:00 +02:00
parent c94c599cdd
commit 6624287626
9 changed files with 200 additions and 30 deletions

View File

@ -2,7 +2,7 @@
--width: 100%;
--max-width: auto;
.extension-list {
.extensions-list {
.extension {
--flex-gap: $padding / 3;
padding: $padding $padding * 2;
@ -15,6 +15,20 @@
}
}
.extensions-info {
.flex.gaps {
--flex-gap: #{$padding};
}
.install-extension {
code {
&:hover {
color: $textColorSecondary;
}
cursor: pointer;
}
}
}
.extensions-path {
word-break: break-all;
}

View File

@ -1,5 +1,5 @@
import "./extensions.scss";
import { shell } from "electron";
import { remote, shell } from "electron";
import React from "react";
import { computed, observable } from "mobx";
import { observer } from "mobx-react";
@ -7,15 +7,18 @@ 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 { Input, InputValidators } from "../input";
import { Icon } from "../icon";
import { PageLayout } from "../layout/page-layout";
import { CopyToClick } from "../copy-to-click/copy-to-click";
import { extensionLoader } from "../../../extensions/extension-loader";
import { extensionManager } from "../../../extensions/extension-manager";
@observer
export class Extensions extends React.Component {
@observable.ref input: Input;
@observable search = "";
@observable downloadUrl = "";
@computed get extensions() {
const searchText = this.search.toLowerCase();
@ -32,16 +35,43 @@ export class Extensions extends React.Component {
return extensionManager.localFolderPath;
}
selectPackedExtensionsDialog = 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 add to Lens (*.tgz`),
buttonLabel: _i18n._(t`Use configuration`),
filters: [
{ name: "tarball", extensions: [".tgz", ".tar.gz"] }
]
});
if (!canceled && filePaths.length) {
this.installFromLocalPath(filePaths);
}
}
installFromUrl = () => {
if (!this.downloadUrl) {
this.input?.focus();
return;
}
}
installFromLocalPath = (filePaths: string[]) => {
}
renderInfo() {
return (
<div className="flex column gaps">
<div className="extensions-info flex column gaps">
<h2>Lens Extension API</h2>
<div>
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.
</div>
<div>
Extensions loaded from:
<em>Extensions installed and loaded from</em>
<div className="extensions-path flex inline">
<code>{this.extensionsPath}</code>
<Icon
@ -51,8 +81,46 @@ export class Extensions extends React.Component {
/>
</div>
</div>
<div>
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
<div className="install-extension flex column gaps">
<em>Install extensions from local file-system or URL:</em>
<div className="install-extension-by-url flex gaps align-center">
<Input
showErrorsAsTooltip={true}
className="box grow"
theme="round-black"
placeholder="URL, e.g. https://registry.npmjs.org/%path-to-ext.tgz"
validators={InputValidators.isUrl}
value={this.downloadUrl} // TODO: in addition we could support npm-package-name (if non-url value)?
onChange={v => this.downloadUrl = v}
ref={e => this.input = e}
/>
<Icon
material="get_app"
tooltip="Download and install"
interactive={this.downloadUrl.length > 0}
onClick={this.installFromUrl}
/>
</div>
<Button
primary
label="Select local extensions"
onClick={this.selectPackedExtensionsDialog}
/>
<small className="hint">
<Trans>Pro-Tip 1: you can download extension archive.tgz via npm by</Trans>{" "}
<CopyToClick showNotification><code>npm pack %name</code></CopyToClick>
<CopyToClick showNotification><code>npm view %name dist.tarball</code></CopyToClick>{" "}
<em className="text-secondary">(click to copy)</em>
</small>
<small className="hint">
<Trans>Pro-Tip 2: you also can drop archive from file-system to this window to install</Trans>
</small>
</div>
<div className="more-info flex inline gaps">
<Icon material="local_fire_department"/>
<p>
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
</p>
</div>
</div>
);
@ -104,7 +172,7 @@ export class Extensions extends React.Component {
value={this.search}
onChange={(value) => this.search = value}
/>
<div className="extension-list">
<div className="extensions-list">
{this.renderExtensions()}
</div>
</WizardLayout>

View File

@ -0,0 +1,58 @@
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";
export interface CopyToClickProps {
resetSelection?: boolean
showNotification?: boolean
getNotificationMessage?(copiedText: string): React.ReactNode;
}
export const defaultProps: Partial<CopyToClickProps> = {
getNotificationMessage(copiedText: string) {
return <p>Copied to clipboard: <em className="contrast">{copiedText}</em></p>
}
}
export class CopyToClick extends React.Component<CopyToClickProps> {
static defaultProps = defaultProps as object;
get rootElem(): HTMLElement {
return findDOMNode(this) as HTMLElement;
}
get rootReactElem(): React.ReactElement<React.DOMAttributes<any>> {
return React.Children.only(this.props.children) as React.ReactElement;
}
@autobind()
onClick(evt: React.MouseEvent<HTMLElement>) {
if (!this.rootElem || !this.rootElem.contains(evt.target as any)) {
return;
}
const { showNotification, resetSelection, getNotificationMessage } = this.props;
const { copiedText, copied } = copyToClipboard(this.rootElem, { resetSelection });
if (copied && showNotification) {
Notifications.ok(getNotificationMessage(copiedText));
}
if (this.rootReactElem.props.onClick) {
this.rootReactElem.props.onClick(evt); // pass event to content element as well when provided
}
}
render() {
try {
const rootElem = this.rootReactElem;
return React.cloneElement(rootElem, {
...(rootElem || {}).props,
onClick: this.onClick,
})
} catch (err) {
logger.error(`Invalid usage components/CopyToClick usage. Children must contain root html element.`, { err: String(err) })
return this.rootReactElem;
}
}
}

View File

@ -0,0 +1 @@
export * from "./copy-to-click"

View File

@ -32,7 +32,7 @@ export class Icon extends React.PureComponent<IconProps> {
get isInteractive() {
const { interactive, onClick, href, link } = this.props;
return interactive || !!(onClick || href || link);
return interactive ?? !!(onClick || href || link);
}
@autobind()

View File

@ -107,3 +107,9 @@
}
}
}
.Tooltip.InputTooltipError {
--bgc: #{$colorError};
--color: white;
--border: 1px solid currentColor;
}

View File

@ -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<T = string> = Omit<InputElementProps, "onChange" | "onSub
maxRows?: number; // when multiLine={true} define max rows size
dirty?: boolean; // show validation errors even if the field wasn't touched yet
showValidationLine?: boolean; // show animated validation line for async validators
showErrorsAsTooltip?: boolean; // show validation errors as a tooltip :hover (instead of block below)
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
iconRight?: string | React.ReactNode;
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
@ -265,7 +267,7 @@ export class Input extends React.Component<InputProps, State> {
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<InputProps, State> {
ref: this.bindRef,
spellCheck: "false",
});
const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
const showErrors = errors.length > 0 && !valid && dirty;
const errorsInfo = (
<div className="errors box grow">
{errors.map((error, i) => <p key={i}>{error}</p>)}
</div>
);
return (
<div className={className}>
<label className="input-area flex gaps align-center">
<div id={tooltipId} className={className}>
<label className="input-area flex gaps align-center" id="">
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
{contentRight}
</label>
<div className="input-info flex gaps">
{!valid && dirty && (
<div className="errors box grow">
{errors.map((error, i) => <p key={i}>{error}</p>)}
{showErrorsAsTooltip && showErrors && (
<Tooltip targetId={tooltipId} className="InputTooltipError">
<div className="flex gaps align-center">
<Icon material="error_outline"/>
{errorsInfo}
</div>
)}
</Tooltip>
)}
<div className="input-info flex gaps">
{!showErrorsAsTooltip && showErrors && errorsInfo}
{this.showMaxLenIndicator && (
<div className="maxLengthIndicator box right">
{this.getValue().length} / {maxLength}

View File

@ -1,15 +1,20 @@
.Tooltip {
--bgc: #{$mainBackground};
--radius: #{$radius};
--color: #{$textColorSecondary};
--border: 1px solid #{$borderColor};
// use positioning relative to viewport (window)
// https://developer.mozilla.org/en-US/docs/Web/CSS/position
position: fixed;
margin: 0 !important;
background: $mainBackground;
background: var(--bgc);
font-size: small;
font-weight: normal;
border: 1px solid $borderColor;
border-radius: $radius;
color: $textColorSecondary;
border: var(--border);
border-radius: var(--radius);
color: var(--color);
white-space: normal;
padding: .5em;
text-align: center;

View File

@ -1,19 +1,25 @@
// Helper for selecting element's text content and copy in clipboard
export function copyToClipboard(elem: HTMLElement, resetSelection = true): boolean {
export function copyToClipboard(elem: HTMLElement, { resetSelection = true } = {}) {
let clearSelection: () => void;
if (isSelectable(elem)) {
elem.select();
clearSelection = () => elem.setSelectionRange(0, 0);
}
else {
} else {
const selection = window.getSelection();
selection.selectAllChildren(elem);
clearSelection = () => selection.removeAllRanges();
}
const copyResult = document.execCommand("copy");
if (resetSelection) clearSelection();
return copyResult;
const selectedText = document.getSelection().toString();
const isCopied = document.execCommand("copy");
if (resetSelection) {
clearSelection();
}
return {
copied: isCopied,
copiedText: selectedText,
clearSelection,
};
}
function isSelectable(elem: HTMLElement): elem is HTMLInputElement {