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:
parent
c94c599cdd
commit
6624287626
@ -2,7 +2,7 @@
|
|||||||
--width: 100%;
|
--width: 100%;
|
||||||
--max-width: auto;
|
--max-width: auto;
|
||||||
|
|
||||||
.extension-list {
|
.extensions-list {
|
||||||
.extension {
|
.extension {
|
||||||
--flex-gap: $padding / 3;
|
--flex-gap: $padding / 3;
|
||||||
padding: $padding $padding * 2;
|
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 {
|
.extensions-path {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "./extensions.scss";
|
import "./extensions.scss";
|
||||||
import { shell } from "electron";
|
import { remote, shell } from "electron";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { computed, observable } from "mobx";
|
import { computed, observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
@ -7,15 +7,18 @@ import { t, Trans } from "@lingui/macro";
|
|||||||
import { _i18n } from "../../i18n";
|
import { _i18n } from "../../i18n";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { WizardLayout } from "../layout/wizard-layout";
|
import { WizardLayout } from "../layout/wizard-layout";
|
||||||
import { Input } from "../input";
|
import { Input, InputValidators } from "../input";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { PageLayout } from "../layout/page-layout";
|
import { PageLayout } from "../layout/page-layout";
|
||||||
|
import { CopyToClick } from "../copy-to-click/copy-to-click";
|
||||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||||
import { extensionManager } from "../../../extensions/extension-manager";
|
import { extensionManager } from "../../../extensions/extension-manager";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class Extensions extends React.Component {
|
export class Extensions extends React.Component {
|
||||||
|
@observable.ref input: Input;
|
||||||
@observable search = "";
|
@observable search = "";
|
||||||
|
@observable downloadUrl = "";
|
||||||
|
|
||||||
@computed get extensions() {
|
@computed get extensions() {
|
||||||
const searchText = this.search.toLowerCase();
|
const searchText = this.search.toLowerCase();
|
||||||
@ -32,16 +35,43 @@ export class Extensions extends React.Component {
|
|||||||
return extensionManager.localFolderPath;
|
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() {
|
renderInfo() {
|
||||||
return (
|
return (
|
||||||
<div className="flex column gaps">
|
<div className="extensions-info flex column gaps">
|
||||||
<h2>Lens Extension API</h2>
|
<h2>Lens Extension API</h2>
|
||||||
<div>
|
<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
|
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.
|
features of Lens are built as extensions and use the same Extension API.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Extensions loaded from:
|
<em>Extensions installed and loaded from</em>
|
||||||
<div className="extensions-path flex inline">
|
<div className="extensions-path flex inline">
|
||||||
<code>{this.extensionsPath}</code>
|
<code>{this.extensionsPath}</code>
|
||||||
<Icon
|
<Icon
|
||||||
@ -51,8 +81,46 @@ export class Extensions extends React.Component {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="install-extension flex column gaps">
|
||||||
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -104,7 +172,7 @@ export class Extensions extends React.Component {
|
|||||||
value={this.search}
|
value={this.search}
|
||||||
onChange={(value) => this.search = value}
|
onChange={(value) => this.search = value}
|
||||||
/>
|
/>
|
||||||
<div className="extension-list">
|
<div className="extensions-list">
|
||||||
{this.renderExtensions()}
|
{this.renderExtensions()}
|
||||||
</div>
|
</div>
|
||||||
</WizardLayout>
|
</WizardLayout>
|
||||||
|
|||||||
58
src/renderer/components/copy-to-click/copy-to-click.tsx
Normal file
58
src/renderer/components/copy-to-click/copy-to-click.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/renderer/components/copy-to-click/index.ts
Normal file
1
src/renderer/components/copy-to-click/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./copy-to-click"
|
||||||
@ -32,7 +32,7 @@ export class Icon extends React.PureComponent<IconProps> {
|
|||||||
|
|
||||||
get isInteractive() {
|
get isInteractive() {
|
||||||
const { interactive, onClick, href, link } = this.props;
|
const { interactive, onClick, href, link } = this.props;
|
||||||
return interactive || !!(onClick || href || link);
|
return interactive ?? !!(onClick || href || link);
|
||||||
}
|
}
|
||||||
|
|
||||||
@autobind()
|
@autobind()
|
||||||
|
|||||||
@ -107,3 +107,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Tooltip.InputTooltipError {
|
||||||
|
--bgc: #{$colorError};
|
||||||
|
--color: white;
|
||||||
|
--border: 1px solid currentColor;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import "./input.scss";
|
import "./input.scss";
|
||||||
|
|
||||||
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
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 { Icon } from "../icon";
|
||||||
import * as Validators from "./input_validators";
|
import * as Validators from "./input_validators";
|
||||||
import { InputValidator } from "./input_validators";
|
import { InputValidator } from "./input_validators";
|
||||||
@ -9,6 +9,7 @@ import isString from "lodash/isString";
|
|||||||
import isFunction from "lodash/isFunction";
|
import isFunction from "lodash/isFunction";
|
||||||
import isBoolean from "lodash/isBoolean";
|
import isBoolean from "lodash/isBoolean";
|
||||||
import uniqueId from "lodash/uniqueId";
|
import uniqueId from "lodash/uniqueId";
|
||||||
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
const { conditionalValidators, ...InputValidators } = Validators;
|
const { conditionalValidators, ...InputValidators } = Validators;
|
||||||
export { InputValidators, InputValidator };
|
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
|
maxRows?: number; // when multiLine={true} define max rows size
|
||||||
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
||||||
showValidationLine?: boolean; // show animated validation line for async validators
|
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
|
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
|
||||||
iconRight?: string | React.ReactNode;
|
iconRight?: string | React.ReactNode;
|
||||||
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
||||||
@ -265,7 +267,7 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
multiLine, showValidationLine, validators, theme, maxRows, children,
|
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
|
||||||
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight,
|
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight,
|
||||||
...inputProps
|
...inputProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
@ -292,21 +294,31 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
ref: this.bindRef,
|
ref: this.bindRef,
|
||||||
spellCheck: "false",
|
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 (
|
return (
|
||||||
<div className={className}>
|
<div id={tooltipId} className={className}>
|
||||||
<label className="input-area flex gaps align-center">
|
<label className="input-area flex gaps align-center" id="">
|
||||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||||
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
|
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||||
{contentRight}
|
{contentRight}
|
||||||
</label>
|
</label>
|
||||||
<div className="input-info flex gaps">
|
{showErrorsAsTooltip && showErrors && (
|
||||||
{!valid && dirty && (
|
<Tooltip targetId={tooltipId} className="InputTooltipError">
|
||||||
<div className="errors box grow">
|
<div className="flex gaps align-center">
|
||||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
<Icon material="error_outline"/>
|
||||||
|
{errorsInfo}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<div className="input-info flex gaps">
|
||||||
|
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
||||||
{this.showMaxLenIndicator && (
|
{this.showMaxLenIndicator && (
|
||||||
<div className="maxLengthIndicator box right">
|
<div className="maxLengthIndicator box right">
|
||||||
{this.getValue().length} / {maxLength}
|
{this.getValue().length} / {maxLength}
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
|
|
||||||
.Tooltip {
|
.Tooltip {
|
||||||
|
--bgc: #{$mainBackground};
|
||||||
|
--radius: #{$radius};
|
||||||
|
--color: #{$textColorSecondary};
|
||||||
|
--border: 1px solid #{$borderColor};
|
||||||
|
|
||||||
// use positioning relative to viewport (window)
|
// use positioning relative to viewport (window)
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/position
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/position
|
||||||
position: fixed;
|
position: fixed;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
background: $mainBackground;
|
background: var(--bgc);
|
||||||
font-size: small;
|
font-size: small;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
border: 1px solid $borderColor;
|
border: var(--border);
|
||||||
border-radius: $radius;
|
border-radius: var(--radius);
|
||||||
color: $textColorSecondary;
|
color: var(--color);
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -1,19 +1,25 @@
|
|||||||
// Helper for selecting element's text content and copy in clipboard
|
// 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;
|
let clearSelection: () => void;
|
||||||
if (isSelectable(elem)) {
|
if (isSelectable(elem)) {
|
||||||
elem.select();
|
elem.select();
|
||||||
clearSelection = () => elem.setSelectionRange(0, 0);
|
clearSelection = () => elem.setSelectionRange(0, 0);
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
selection.selectAllChildren(elem);
|
selection.selectAllChildren(elem);
|
||||||
clearSelection = () => selection.removeAllRanges();
|
clearSelection = () => selection.removeAllRanges();
|
||||||
}
|
}
|
||||||
const copyResult = document.execCommand("copy");
|
const selectedText = document.getSelection().toString();
|
||||||
if (resetSelection) clearSelection();
|
const isCopied = document.execCommand("copy");
|
||||||
return copyResult;
|
if (resetSelection) {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
copied: isCopied,
|
||||||
|
copiedText: selectedText,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelectable(elem: HTMLElement): elem is HTMLInputElement {
|
function isSelectable(elem: HTMLElement): elem is HTMLInputElement {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user