// 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 (
  • {githubUser && ( )} {!githubUser && (
    )}
    {entry.blockedExtensionsEntry && ( window.open( 'https://jupyterlab.readthedocs.io/en/stable/user/extensions.html' ) } /> )} {!entry.allowedExtensionsEntry && viewType === 'installed' && listMode === 'allow' && ( window.open( 'https://jupyterlab.readthedocs.io/en/stable/user/extensions.html' ) } /> )} {entryIsJupyterOrg && ( )}
    {entry.description}
    {!entry.installed && !entry.blockedExtensionsEntry && !(!entry.allowedExtensionsEntry && listMode === 'allow') && ListModel.isDisclaimed() && ( )} {ListModel.entryHasUpdate(entry) && !entry.blockedExtensionsEntry && !(!entry.allowedExtensionsEntry && listMode === 'allow') && ListModel.isDisclaimed() && ( )} {entry.installed && ( )} {entry.enabled && ( )} {entry.installed && !entry.enabled && ( )}
  • ); } /** * The namespace for extension entry statics. */ export namespace ListEntry { export interface IProperties { /** * The entry to visualize. */ entry: IEntry; /** * The list mode to apply. */ listMode: 'block' | 'allow' | 'default' | 'invalid'; /** * The requested view type. */ viewType: 'installed' | 'searchResult'; /** * Callback to use for performing an action on the entry. */ performAction: (action: Action, entry: IEntry) => void; } } /** * List view widget for extensions */ export function ListView(props: ListView.IProperties): React.ReactElement { const entryViews = []; for (const entry of props.entries) { entryViews.push( ); } let pagination; if (props.numPages > 1) { pagination = (
    '} breakLabel={...} breakClassName={'break-me'} pageCount={props.numPages} marginPagesDisplayed={2} pageRangeDisplayed={5} onPageChange={(data: { selected: number }) => props.onPage(data.selected) } containerClassName={'pagination'} activeClassName={'active'} />
    ); } const listview = (
      {entryViews}
    ); return (
    {entryViews.length > 0 ? ( listview ) : (
    No entries
    )} {pagination}
    ); } /** * 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.
    ]; } const pages = Math.ceil(model.totalEntries / model.pagination); const elements = [ ]; if (model.promptBuild) { elements.push( { model.performBuild(); }} ignoreBuild={() => { model.ignoreBuildRecommendation(); }} /> ); } // Indicator element for pending actions: elements.push(
    ); const content = []; content.push(
    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.
    {ListModel.isDisclaimed() && ( )} {!ListModel.isDisclaimed() && ( )}
    ); if (!model.initialized) { content.push(
    Updating extensions list
    ); } else if (model.serverConnectionError !== null) { content.push(

    Error communicating with server extension. Consult the documentation for how to ensure that it is enabled.

    Reason given:

    {model.serverConnectionError}
    ); } else if (model.serverRequirementsError !== null) { content.push(

    The server has some missing requirements for installing extensions.

    Details:

    {model.serverRequirementsError}
    ); } else { // List installed and discovery sections const installedContent = []; if (model.installedError !== null) { installedContent.push( {`Error querying installed extensions${ model.installedError ? `: ${model.installedError}` : '.' }`} ); } else { installedContent.push( { /* no-op */ }} performAction={this.onAction.bind(this)} /> ); } content.push( { model.refreshInstalled(); }} tooltip="Refresh extension list" /> } > {installedContent} ); const searchContent = []; if (model.searchError !== null) { searchContent.push( {`Error searching for extensions${ model.searchError ? `: ${model.searchError}` : '.' }`} ); } else { searchContent.push( model.installed.indexOf(entry) === -1 )} numPages={pages} onPage={value => { this.onPage(value); }} performAction={this.onAction.bind(this)} /> ); } content.push( { if (isOpen && model.query === null) { model.query = ''; } }} > {searchContent} ); } elements.push(
    {content}
    ); // 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); } }