// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { JupyterFrontEnd } from '@jupyterlab/application';
import { VDomRenderer, ToolbarButtonComponent } from '@jupyterlab/apputils';
import { ServiceManager } from '@jupyterlab/services';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import {
Button,
caretDownIcon,
caretRightIcon,
Collapse,
InputGroup,
jupyterIcon,
listingsInfoIcon,
refreshIcon
} from '@jupyterlab/ui-components';
import { Message } from '@lumino/messaging';
import * as React from 'react';
import ReactPaginate from 'react-paginate';
import { ListModel, IEntry, Action } from './model';
import { isJupyterOrg } from './npm';
// TODO: Replace pagination with lazy loading of lower search results
/**
* Icons with custom styling bound.
*/
const caretDownIconStyled = caretDownIcon.bindprops({
height: 'auto',
width: '20px'
});
const caretRightIconStyled = caretRightIcon.bindprops({
height: 'auto',
width: '20px'
});
const badgeSize = 32;
const badgeQuerySize = Math.floor(devicePixelRatio * badgeSize);
/**
* Search bar VDOM component.
*/
export class SearchBar extends React.Component<
SearchBar.IProperties,
SearchBar.IState
> {
constructor(props: SearchBar.IProperties) {
super(props);
this.state = {
value: ''
};
}
/**
* Render the list view using the virtual DOM.
*/
render(): React.ReactNode {
return (
);
}
/**
* Handler for search input changes.
*/
handleChange = (e: React.FormEvent) => {
const target = e.target as HTMLInputElement;
this.setState({
value: target.value
});
};
}
/**
* The namespace for search bar statics.
*/
export namespace SearchBar {
/**
* React properties for search bar component.
*/
export interface IProperties {
/**
* The placeholder string to use in the search bar input field when empty.
*/
placeholder: string;
disabled: boolean;
settings: ISettingRegistry.ISettings;
}
/**
* React state for search bar component.
*/
export interface IState {
/**
* The value of the search bar input field.
*/
value: string;
}
}
/**
* Create a build prompt as a react element.
*
* @param props Configuration of the build prompt.
*/
function BuildPrompt(props: BuildPrompt.IProperties): React.ReactElement {
return (
A build is needed to include the latest changes
);
}
/**
* The namespace for build prompt statics.
*/
namespace BuildPrompt {
/**
* Properties for build prompt react component.
*/
export interface IProperties {
/**
* Callback for when a build is requested.
*/
performBuild: () => void;
/**
* Callback for when a build notice is dismissed.
*/
ignoreBuild: () => void;
}
}
function getExtensionGitHubUser(entry: IEntry) {
if (entry.url && entry.url.startsWith('https://github.com/')) {
return entry.url.split('/')[3];
}
return null;
}
/**
* VDOM for visualizing an extension entry.
*/
function ListEntry(props: ListEntry.IProperties): React.ReactElement {
const { entry, listMode, viewType } = props;
const flagClasses = [];
if (entry.status && ['ok', 'warning', 'error'].indexOf(entry.status) !== -1) {
flagClasses.push(`jp-extensionmanager-entry-${entry.status}`);
}
let title = entry.name;
const entryIsJupyterOrg = isJupyterOrg(entry.name);
if (entryIsJupyterOrg) {
title = `${entry.name} (Developed by Project Jupyter)`;
}
const githubUser = getExtensionGitHubUser(entry);
if (
listMode === 'block' &&
entry.blockedExtensionsEntry &&
viewType === 'searchResult'
) {
return ;
}
if (
listMode === 'allow' &&
!entry.allowedExtensionsEntry &&
viewType === 'searchResult'
) {
return ;
}
if (listMode === 'block' && entry.blockedExtensionsEntry?.name) {
flagClasses.push(`jp-extensionmanager-entry-should-be-uninstalled`);
}
if (listMode === 'allow' && !entry.allowedExtensionsEntry) {
flagClasses.push(`jp-extensionmanager-entry-should-be-uninstalled`);
}
return (
);
}
/**
* The namespace for list view widget statics.
*/
export namespace ListView {
export interface IProperties {
/**
* The extension entries to display.
*/
entries: ReadonlyArray;
/**
* The number of pages that can be viewed via pagination.
*/
numPages: number;
/**
* The list mode to apply.
*/
listMode: 'block' | 'allow' | 'default' | 'invalid';
/**
* The requested view type.
*/
viewType: 'installed' | 'searchResult';
/**
* The callback to use for changing the page
*/
onPage: (page: number) => void;
/**
* Callback to use for performing an action on an entry.
*/
performAction: (action: Action, entry: IEntry) => void;
}
}
function ErrorMessage(props: ErrorMessage.IProperties) {
return (
{props.children}
);
}
namespace ErrorMessage {
export interface IProperties {
children: React.ReactNode;
}
}
/**
*
*/
export class CollapsibleSection extends React.Component<
CollapsibleSection.IProperties,
CollapsibleSection.IState
> {
constructor(props: CollapsibleSection.IProperties) {
super(props);
this.state = {
isOpen: props.isOpen ? true : false
};
}
/**
* Render the collapsible section using the virtual DOM.
*/
render(): React.ReactNode {
let icon = this.state.isOpen ? caretDownIconStyled : caretRightIconStyled;
let isOpen = this.state.isOpen;
let className = 'jp-extensionmanager-headerText';
if (this.props.disabled) {
icon = caretRightIconStyled;
isOpen = false;
className = 'jp-extensionmanager-headerTextDisabled';
}
return (
<>
{
this.handleCollapse();
}}
/>
{this.props.header}
{!this.props.disabled && this.props.headerElements}
{this.props.children}
>
);
}
/**
* Handler for search input changes.
*/
handleCollapse() {
this.setState(
{
isOpen: !this.state.isOpen
},
() => {
if (this.props.onCollapse) {
this.props.onCollapse(this.state.isOpen);
}
}
);
}
UNSAFE_componentWillReceiveProps(nextProps: CollapsibleSection.IProperties) {
if (nextProps.forceOpen) {
this.setState({
isOpen: true
});
}
}
}
/**
* The namespace for collapsible section statics.
*/
export namespace CollapsibleSection {
/**
* React properties for collapsible section component.
*/
export interface IProperties {
/**
* The header string for section list.
*/
header: string;
/**
* Whether the view will be expanded or collapsed initially, defaults to open.
*/
isOpen?: boolean;
/**
* Handle collapse event.
*/
onCollapse?: (isOpen: boolean) => void;
/**
* Any additional elements to add to the header.
*/
headerElements?: React.ReactNode;
/**
* If given, this will be diplayed instead of the children.
*/
errorMessage?: string | null;
/**
* If true, the section will be collapsed and will not respond
* to open nor close actions.
*/
disabled?: boolean;
/**
* If true, the section will be opened if not disabled.
*/
forceOpen?: boolean;
}
/**
* React state for collapsible section component.
*/
export interface IState {
/**
* Whether the section is expanded or collapsed.
*/
isOpen: boolean;
}
}
/**
* The main view for the discovery extension.
*/
export class ExtensionView extends VDomRenderer {
private _settings: ISettingRegistry.ISettings;
private _forceOpen: boolean;
constructor(
app: JupyterFrontEnd,
serviceManager: ServiceManager,
settings: ISettingRegistry.ISettings
) {
super(new ListModel(app, serviceManager, settings));
this._settings = settings;
this._forceOpen = false;
this.addClass('jp-extensionmanager-view');
}
/**
* The search input node.
*/
get inputNode(): HTMLInputElement {
return this.node.querySelector(
'.jp-extensionmanager-search-wrapper input'
) as HTMLInputElement;
}
/**
* Render the extension view using the virtual DOM.
*/
protected render(): React.ReactElement[] {
const model = this.model!;
if (!model.listMode) {
return [];
}
if (model.listMode === 'invalid') {
return [
The extension manager is disabled. Please contact your system
administrator to verify the listings configuration.
The JupyterLab development team is excited to have a robust
third-party extension community. However, we do not review
third-party extensions, and some extensions may introduce security
risks or contain malicious code that runs on your machine.
);
// Reset the force open for future usage.
this._forceOpen = false;
return elements;
}
/**
* Callback handler for the user specifies a new search query.
*
* @param value The new query.
*/
onSearch(value: string) {
this.model!.query = value;
}
/**
* Callback handler for the user changes the page of the search result pagination.
*
* @param value The pagination page number.
*/
onPage(value: number) {
this.model!.page = value;
}
/**
* Callback handler for when the user wants to perform an action on an extension.
*
* @param action The action to perform.
* @param entry The entry to perform the action on.
*/
onAction(action: Action, entry: IEntry) {
switch (action) {
case 'install':
return this.model!.install(entry);
case 'uninstall':
return this.model!.uninstall(entry);
case 'enable':
return this.model!.enable(entry);
case 'disable':
return this.model!.disable(entry);
default:
throw new Error(`Invalid action: ${action}`);
}
}
/**
* Handle the DOM events for the command palette.
*
* @param event - The DOM event sent to the command palette.
*
* #### Notes
* This method implements the DOM `EventListener` interface and is
* called in response to events on the command palette's DOM node.
* It should not be called directly by user code.
*/
handleEvent(event: Event): void {
switch (event.type) {
case 'input':
this.onSearch(this.inputNode.value);
break;
case 'focus':
case 'blur':
this._toggleFocused();
break;
default:
break;
}
}
/**
* A message handler invoked on a `'before-attach'` message.
*/
protected onBeforeAttach(msg: Message): void {
this.node.addEventListener('input', this);
this.node.addEventListener('focus', this, true);
this.node.addEventListener('blur', this, true);
}
/**
* A message handler invoked on an `'after-detach'` message.
*/
protected onAfterDetach(msg: Message): void {
this.node.removeEventListener('input', this);
this.node.removeEventListener('focus', this, true);
this.node.removeEventListener('blur', this, true);
}
/**
* A message handler invoked on an `'activate-request'` message.
*/
protected onActivateRequest(msg: Message): void {
if (this.isAttached) {
const input = this.inputNode;
if (input) {
input.focus();
input.select();
}
}
}
/**
* Toggle the focused modifier based on the input node focus state.
*/
private _toggleFocused(): void {
const focused = document.activeElement === this.inputNode;
this.toggleClass('lm-mod-focused', focused);
}
}