widget.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import { JupyterFrontEnd } from '@jupyterlab/application';
  4. import { VDomRenderer, ToolbarButtonComponent } from '@jupyterlab/apputils';
  5. import { ServiceManager } from '@jupyterlab/services';
  6. import { ISettingRegistry } from '@jupyterlab/settingregistry';
  7. import {
  8. Button,
  9. caretDownIcon,
  10. caretRightIcon,
  11. Collapse,
  12. InputGroup,
  13. jupyterIcon,
  14. listingsInfoIcon,
  15. refreshIcon
  16. } from '@jupyterlab/ui-components';
  17. import { Message } from '@lumino/messaging';
  18. import * as React from 'react';
  19. import ReactPaginate from 'react-paginate';
  20. import { ListModel, IEntry, Action } from './model';
  21. import { isJupyterOrg } from './npm';
  22. // TODO: Replace pagination with lazy loading of lower search results
  23. /**
  24. * Icons with custom styling bound.
  25. */
  26. const caretDownIconStyled = caretDownIcon.bindprops({
  27. height: 'auto',
  28. width: '20px'
  29. });
  30. const caretRightIconStyled = caretRightIcon.bindprops({
  31. height: 'auto',
  32. width: '20px'
  33. });
  34. const badgeSize = 32;
  35. const badgeQuerySize = Math.floor(devicePixelRatio * badgeSize);
  36. /**
  37. * Search bar VDOM component.
  38. */
  39. export class SearchBar extends React.Component<
  40. SearchBar.IProperties,
  41. SearchBar.IState
  42. > {
  43. constructor(props: SearchBar.IProperties) {
  44. super(props);
  45. this.state = {
  46. value: ''
  47. };
  48. }
  49. /**
  50. * Render the list view using the virtual DOM.
  51. */
  52. render(): React.ReactNode {
  53. return (
  54. <div className="jp-extensionmanager-search-bar">
  55. <InputGroup
  56. className="jp-extensionmanager-search-wrapper"
  57. type="text"
  58. placeholder={this.props.placeholder}
  59. onChange={this.handleChange}
  60. value={this.state.value}
  61. rightIcon="ui-components:search"
  62. disabled={this.props.disabled}
  63. />
  64. </div>
  65. );
  66. }
  67. /**
  68. * Handler for search input changes.
  69. */
  70. handleChange = (e: React.FormEvent<HTMLElement>) => {
  71. const target = e.target as HTMLInputElement;
  72. this.setState({
  73. value: target.value
  74. });
  75. };
  76. }
  77. /**
  78. * The namespace for search bar statics.
  79. */
  80. export namespace SearchBar {
  81. /**
  82. * React properties for search bar component.
  83. */
  84. export interface IProperties {
  85. /**
  86. * The placeholder string to use in the search bar input field when empty.
  87. */
  88. placeholder: string;
  89. disabled: boolean;
  90. settings: ISettingRegistry.ISettings;
  91. }
  92. /**
  93. * React state for search bar component.
  94. */
  95. export interface IState {
  96. /**
  97. * The value of the search bar input field.
  98. */
  99. value: string;
  100. }
  101. }
  102. /**
  103. * Create a build prompt as a react element.
  104. *
  105. * @param props Configuration of the build prompt.
  106. */
  107. function BuildPrompt(props: BuildPrompt.IProperties): React.ReactElement<any> {
  108. return (
  109. <div className="jp-extensionmanager-buildprompt">
  110. <div className="jp-extensionmanager-buildmessage">
  111. A build is needed to include the latest changes
  112. </div>
  113. <Button onClick={props.performBuild} minimal small>
  114. Rebuild
  115. </Button>
  116. <Button onClick={props.ignoreBuild} minimal small>
  117. Ignore
  118. </Button>
  119. </div>
  120. );
  121. }
  122. /**
  123. * The namespace for build prompt statics.
  124. */
  125. namespace BuildPrompt {
  126. /**
  127. * Properties for build prompt react component.
  128. */
  129. export interface IProperties {
  130. /**
  131. * Callback for when a build is requested.
  132. */
  133. performBuild: () => void;
  134. /**
  135. * Callback for when a build notice is dismissed.
  136. */
  137. ignoreBuild: () => void;
  138. }
  139. }
  140. function getExtensionGitHubUser(entry: IEntry) {
  141. if (entry.url && entry.url.startsWith('https://github.com/')) {
  142. return entry.url.split('/')[3];
  143. }
  144. return null;
  145. }
  146. /**
  147. * VDOM for visualizing an extension entry.
  148. */
  149. function ListEntry(props: ListEntry.IProperties): React.ReactElement<any> {
  150. const { entry, listMode, viewType } = props;
  151. const flagClasses = [];
  152. if (entry.status && ['ok', 'warning', 'error'].indexOf(entry.status) !== -1) {
  153. flagClasses.push(`jp-extensionmanager-entry-${entry.status}`);
  154. }
  155. let title = entry.name;
  156. const entryIsJupyterOrg = isJupyterOrg(entry.name);
  157. if (entryIsJupyterOrg) {
  158. title = `${entry.name} (Developed by Project Jupyter)`;
  159. }
  160. const githubUser = getExtensionGitHubUser(entry);
  161. if (
  162. listMode === 'block' &&
  163. entry.blockedExtensionsEntry &&
  164. viewType === 'searchResult'
  165. ) {
  166. return <li></li>;
  167. }
  168. if (
  169. listMode === 'allow' &&
  170. !entry.allowedExtensionsEntry &&
  171. viewType === 'searchResult'
  172. ) {
  173. return <li></li>;
  174. }
  175. if (listMode === 'block' && entry.blockedExtensionsEntry?.name) {
  176. flagClasses.push(`jp-extensionmanager-entry-should-be-uninstalled`);
  177. }
  178. if (listMode === 'allow' && !entry.allowedExtensionsEntry) {
  179. flagClasses.push(`jp-extensionmanager-entry-should-be-uninstalled`);
  180. }
  181. return (
  182. <li
  183. className={`jp-extensionmanager-entry ${flagClasses.join(' ')}`}
  184. title={title}
  185. style={{ display: 'flex' }}
  186. >
  187. <div style={{ marginRight: '8px' }}>
  188. {githubUser && (
  189. <img
  190. src={`https://github.com/${githubUser}.png?size=${badgeQuerySize}`}
  191. style={{ width: '32px', height: '32px' }}
  192. />
  193. )}
  194. {!githubUser && (
  195. <div style={{ width: `${badgeSize}px`, height: `${badgeSize}px` }} />
  196. )}
  197. </div>
  198. <div className="jp-extensionmanager-entry-description">
  199. <div className="jp-extensionmanager-entry-title">
  200. <div className="jp-extensionmanager-entry-name">
  201. <a href={entry.url} target="_blank" rel="noopener noreferrer">
  202. {entry.name}
  203. </a>
  204. </div>
  205. {entry.blockedExtensionsEntry && (
  206. <ToolbarButtonComponent
  207. icon={listingsInfoIcon}
  208. iconLabel={`${entry.name} extension has been blockedExtensionsed since install. Please uninstall immediately and contact your blockedExtensions administrator.`}
  209. onClick={() =>
  210. window.open(
  211. 'https://jupyterlab.readthedocs.io/en/stable/user/extensions.html'
  212. )
  213. }
  214. />
  215. )}
  216. {!entry.allowedExtensionsEntry &&
  217. viewType === 'installed' &&
  218. listMode === 'allow' && (
  219. <ToolbarButtonComponent
  220. icon={listingsInfoIcon}
  221. iconLabel={`${entry.name} extension has been removed from the allowedExtensions since installation. Please uninstall immediately and contact your allowedExtensions administrator.`}
  222. onClick={() =>
  223. window.open(
  224. 'https://jupyterlab.readthedocs.io/en/stable/user/extensions.html'
  225. )
  226. }
  227. />
  228. )}
  229. {entryIsJupyterOrg && (
  230. <jupyterIcon.react
  231. className="jp-extensionmanager-is-jupyter-org"
  232. top="1px"
  233. height="auto"
  234. width="1em"
  235. />
  236. )}
  237. </div>
  238. <div className="jp-extensionmanager-entry-content">
  239. <div className="jp-extensionmanager-entry-description">
  240. {entry.description}
  241. </div>
  242. <div className="jp-extensionmanager-entry-buttons">
  243. {!entry.installed &&
  244. !entry.blockedExtensionsEntry &&
  245. !(!entry.allowedExtensionsEntry && listMode === 'allow') &&
  246. ListModel.isDisclaimed() && (
  247. <Button
  248. onClick={() => props.performAction('install', entry)}
  249. minimal
  250. small
  251. >
  252. Install
  253. </Button>
  254. )}
  255. {ListModel.entryHasUpdate(entry) &&
  256. !entry.blockedExtensionsEntry &&
  257. !(!entry.allowedExtensionsEntry && listMode === 'allow') &&
  258. ListModel.isDisclaimed() && (
  259. <Button
  260. onClick={() => props.performAction('install', entry)}
  261. minimal
  262. small
  263. >
  264. Update
  265. </Button>
  266. )}
  267. {entry.installed && (
  268. <Button
  269. onClick={() => props.performAction('uninstall', entry)}
  270. minimal
  271. small
  272. >
  273. Uninstall
  274. </Button>
  275. )}
  276. {entry.enabled && (
  277. <Button
  278. onClick={() => props.performAction('disable', entry)}
  279. minimal
  280. small
  281. >
  282. Disable
  283. </Button>
  284. )}
  285. {entry.installed && !entry.enabled && (
  286. <Button
  287. onClick={() => props.performAction('enable', entry)}
  288. minimal
  289. small
  290. >
  291. Enable
  292. </Button>
  293. )}
  294. </div>
  295. </div>
  296. </div>
  297. </li>
  298. );
  299. }
  300. /**
  301. * The namespace for extension entry statics.
  302. */
  303. export namespace ListEntry {
  304. export interface IProperties {
  305. /**
  306. * The entry to visualize.
  307. */
  308. entry: IEntry;
  309. /**
  310. * The list mode to apply.
  311. */
  312. listMode: 'block' | 'allow' | 'default' | 'invalid';
  313. /**
  314. * The requested view type.
  315. */
  316. viewType: 'installed' | 'searchResult';
  317. /**
  318. * Callback to use for performing an action on the entry.
  319. */
  320. performAction: (action: Action, entry: IEntry) => void;
  321. }
  322. }
  323. /**
  324. * List view widget for extensions
  325. */
  326. export function ListView(props: ListView.IProperties): React.ReactElement<any> {
  327. const entryViews = [];
  328. for (const entry of props.entries) {
  329. entryViews.push(
  330. <ListEntry
  331. entry={entry}
  332. listMode={props.listMode}
  333. viewType={props.viewType}
  334. key={entry.name}
  335. performAction={props.performAction}
  336. />
  337. );
  338. }
  339. let pagination;
  340. if (props.numPages > 1) {
  341. pagination = (
  342. <div className="jp-extensionmanager-pagination">
  343. <ReactPaginate
  344. previousLabel={'<'}
  345. nextLabel={'>'}
  346. breakLabel={<a href="">...</a>}
  347. breakClassName={'break-me'}
  348. pageCount={props.numPages}
  349. marginPagesDisplayed={2}
  350. pageRangeDisplayed={5}
  351. onPageChange={(data: { selected: number }) =>
  352. props.onPage(data.selected)
  353. }
  354. containerClassName={'pagination'}
  355. activeClassName={'active'}
  356. />
  357. </div>
  358. );
  359. }
  360. const listview = (
  361. <ul className="jp-extensionmanager-listview">{entryViews}</ul>
  362. );
  363. return (
  364. <div className="jp-extensionmanager-listview-wrapper">
  365. {entryViews.length > 0 ? (
  366. listview
  367. ) : (
  368. <div key="message" className="jp-extensionmanager-listview-message">
  369. No entries
  370. </div>
  371. )}
  372. {pagination}
  373. </div>
  374. );
  375. }
  376. /**
  377. * The namespace for list view widget statics.
  378. */
  379. export namespace ListView {
  380. export interface IProperties {
  381. /**
  382. * The extension entries to display.
  383. */
  384. entries: ReadonlyArray<IEntry>;
  385. /**
  386. * The number of pages that can be viewed via pagination.
  387. */
  388. numPages: number;
  389. /**
  390. * The list mode to apply.
  391. */
  392. listMode: 'block' | 'allow' | 'default' | 'invalid';
  393. /**
  394. * The requested view type.
  395. */
  396. viewType: 'installed' | 'searchResult';
  397. /**
  398. * The callback to use for changing the page
  399. */
  400. onPage: (page: number) => void;
  401. /**
  402. * Callback to use for performing an action on an entry.
  403. */
  404. performAction: (action: Action, entry: IEntry) => void;
  405. }
  406. }
  407. function ErrorMessage(props: ErrorMessage.IProperties) {
  408. return (
  409. <div key="error-msg" className="jp-extensionmanager-error">
  410. {props.children}
  411. </div>
  412. );
  413. }
  414. namespace ErrorMessage {
  415. export interface IProperties {
  416. children: React.ReactNode;
  417. }
  418. }
  419. /**
  420. *
  421. */
  422. export class CollapsibleSection extends React.Component<
  423. CollapsibleSection.IProperties,
  424. CollapsibleSection.IState
  425. > {
  426. constructor(props: CollapsibleSection.IProperties) {
  427. super(props);
  428. this.state = {
  429. isOpen: props.isOpen ? true : false
  430. };
  431. }
  432. /**
  433. * Render the collapsible section using the virtual DOM.
  434. */
  435. render(): React.ReactNode {
  436. let icon = this.state.isOpen ? caretDownIconStyled : caretRightIconStyled;
  437. let isOpen = this.state.isOpen;
  438. let className = 'jp-extensionmanager-headerText';
  439. if (this.props.disabled) {
  440. icon = caretRightIconStyled;
  441. isOpen = false;
  442. className = 'jp-extensionmanager-headerTextDisabled';
  443. }
  444. return (
  445. <>
  446. <header>
  447. <ToolbarButtonComponent
  448. icon={icon}
  449. onClick={() => {
  450. this.handleCollapse();
  451. }}
  452. />
  453. <span className={className}>{this.props.header}</span>
  454. {!this.props.disabled && this.props.headerElements}
  455. </header>
  456. <Collapse isOpen={isOpen}>{this.props.children}</Collapse>
  457. </>
  458. );
  459. }
  460. /**
  461. * Handler for search input changes.
  462. */
  463. handleCollapse() {
  464. this.setState(
  465. {
  466. isOpen: !this.state.isOpen
  467. },
  468. () => {
  469. if (this.props.onCollapse) {
  470. this.props.onCollapse(this.state.isOpen);
  471. }
  472. }
  473. );
  474. }
  475. UNSAFE_componentWillReceiveProps(nextProps: CollapsibleSection.IProperties) {
  476. if (nextProps.forceOpen) {
  477. this.setState({
  478. isOpen: true
  479. });
  480. }
  481. }
  482. }
  483. /**
  484. * The namespace for collapsible section statics.
  485. */
  486. export namespace CollapsibleSection {
  487. /**
  488. * React properties for collapsible section component.
  489. */
  490. export interface IProperties {
  491. /**
  492. * The header string for section list.
  493. */
  494. header: string;
  495. /**
  496. * Whether the view will be expanded or collapsed initially, defaults to open.
  497. */
  498. isOpen?: boolean;
  499. /**
  500. * Handle collapse event.
  501. */
  502. onCollapse?: (isOpen: boolean) => void;
  503. /**
  504. * Any additional elements to add to the header.
  505. */
  506. headerElements?: React.ReactNode;
  507. /**
  508. * If given, this will be diplayed instead of the children.
  509. */
  510. errorMessage?: string | null;
  511. /**
  512. * If true, the section will be collapsed and will not respond
  513. * to open nor close actions.
  514. */
  515. disabled?: boolean;
  516. /**
  517. * If true, the section will be opened if not disabled.
  518. */
  519. forceOpen?: boolean;
  520. }
  521. /**
  522. * React state for collapsible section component.
  523. */
  524. export interface IState {
  525. /**
  526. * Whether the section is expanded or collapsed.
  527. */
  528. isOpen: boolean;
  529. }
  530. }
  531. /**
  532. * The main view for the discovery extension.
  533. */
  534. export class ExtensionView extends VDomRenderer<ListModel> {
  535. private _settings: ISettingRegistry.ISettings;
  536. private _forceOpen: boolean;
  537. constructor(
  538. app: JupyterFrontEnd,
  539. serviceManager: ServiceManager,
  540. settings: ISettingRegistry.ISettings
  541. ) {
  542. super(new ListModel(app, serviceManager, settings));
  543. this._settings = settings;
  544. this._forceOpen = false;
  545. this.addClass('jp-extensionmanager-view');
  546. }
  547. /**
  548. * The search input node.
  549. */
  550. get inputNode(): HTMLInputElement {
  551. return this.node.querySelector(
  552. '.jp-extensionmanager-search-wrapper input'
  553. ) as HTMLInputElement;
  554. }
  555. /**
  556. * Render the extension view using the virtual DOM.
  557. */
  558. protected render(): React.ReactElement<any>[] {
  559. const model = this.model!;
  560. if (!model.listMode) {
  561. return [<div key="empty"></div>];
  562. }
  563. if (model.listMode === 'invalid') {
  564. return [
  565. <div style={{ padding: 8 }} key="invalid">
  566. <div>
  567. The extension manager is disabled. Please contact your system
  568. administrator to verify the listings configuration.
  569. </div>
  570. <div>
  571. <a
  572. href="https://jupyterlab.readthedocs.io/en/stable/user/extensions.html"
  573. target="_blank"
  574. rel="noopener noreferrer"
  575. >
  576. Read more in the JupyterLab documentation.
  577. </a>
  578. </div>
  579. </div>
  580. ];
  581. }
  582. const pages = Math.ceil(model.totalEntries / model.pagination);
  583. const elements = [
  584. <SearchBar
  585. key="searchbar"
  586. placeholder="SEARCH"
  587. disabled={!ListModel.isDisclaimed()}
  588. settings={this._settings}
  589. />
  590. ];
  591. if (model.promptBuild) {
  592. elements.push(
  593. <BuildPrompt
  594. key="promt"
  595. performBuild={() => {
  596. model.performBuild();
  597. }}
  598. ignoreBuild={() => {
  599. model.ignoreBuildRecommendation();
  600. }}
  601. />
  602. );
  603. }
  604. // Indicator element for pending actions:
  605. elements.push(
  606. <div
  607. key="pending"
  608. className={`jp-extensionmanager-pending ${
  609. model.hasPendingActions() ? 'jp-mod-hasPending' : ''
  610. }`}
  611. />
  612. );
  613. const content = [];
  614. content.push(
  615. <CollapsibleSection
  616. key="warning-section"
  617. isOpen={!ListModel.isDisclaimed()}
  618. disabled={false}
  619. header={'Warning'}
  620. >
  621. <div className="jp-extensionmanager-disclaimer">
  622. <div>
  623. The JupyterLab development team is excited to have a robust
  624. third-party extension community. However, we do not review
  625. third-party extensions, and some extensions may introduce security
  626. risks or contain malicious code that runs on your machine.
  627. </div>
  628. <div style={{ paddingTop: 8 }}>
  629. {ListModel.isDisclaimed() && (
  630. <Button
  631. className="jp-extensionmanager-disclaimer-disable"
  632. onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
  633. this._settings.set('disclaimed', false).catch(reason => {
  634. console.error(
  635. `Something went wrong when setting disclaimed.\n${reason}`
  636. );
  637. });
  638. }}
  639. >
  640. Disable
  641. </Button>
  642. )}
  643. {!ListModel.isDisclaimed() && (
  644. <Button
  645. className="jp-extensionmanager-disclaimer-enable"
  646. onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
  647. this._forceOpen = true;
  648. this._settings.set('disclaimed', true).catch(reason => {
  649. console.error(
  650. `Something went wrong when setting disclaimed.\n${reason}`
  651. );
  652. });
  653. }}
  654. >
  655. Enable
  656. </Button>
  657. )}
  658. </div>
  659. </div>
  660. </CollapsibleSection>
  661. );
  662. if (!model.initialized) {
  663. content.push(
  664. <div key="loading-placeholder" className="jp-extensionmanager-loader">
  665. Updating extensions list
  666. </div>
  667. );
  668. } else if (model.serverConnectionError !== null) {
  669. content.push(
  670. <ErrorMessage key="error-msg">
  671. <p>
  672. Error communicating with server extension. Consult the documentation
  673. for how to ensure that it is enabled.
  674. </p>
  675. <p>Reason given:</p>
  676. <pre>{model.serverConnectionError}</pre>
  677. </ErrorMessage>
  678. );
  679. } else if (model.serverRequirementsError !== null) {
  680. content.push(
  681. <ErrorMessage key="server-requirements-error">
  682. <p>
  683. The server has some missing requirements for installing extensions.
  684. </p>
  685. <p>Details:</p>
  686. <pre>{model.serverRequirementsError}</pre>
  687. </ErrorMessage>
  688. );
  689. } else {
  690. // List installed and discovery sections
  691. const installedContent = [];
  692. if (model.installedError !== null) {
  693. installedContent.push(
  694. <ErrorMessage key="install-error">
  695. {`Error querying installed extensions${
  696. model.installedError ? `: ${model.installedError}` : '.'
  697. }`}
  698. </ErrorMessage>
  699. );
  700. } else {
  701. installedContent.push(
  702. <ListView
  703. key="installed-items"
  704. listMode={model.listMode}
  705. viewType={'installed'}
  706. entries={model.installed}
  707. numPages={1}
  708. onPage={value => {
  709. /* no-op */
  710. }}
  711. performAction={this.onAction.bind(this)}
  712. />
  713. );
  714. }
  715. content.push(
  716. <CollapsibleSection
  717. key="installed-section"
  718. isOpen={ListModel.isDisclaimed()}
  719. forceOpen={this._forceOpen}
  720. disabled={!ListModel.isDisclaimed()}
  721. header="Installed"
  722. headerElements={
  723. <ToolbarButtonComponent
  724. key="refresh-button"
  725. icon={refreshIcon}
  726. onClick={() => {
  727. model.refreshInstalled();
  728. }}
  729. tooltip="Refresh extension list"
  730. />
  731. }
  732. >
  733. {installedContent}
  734. </CollapsibleSection>
  735. );
  736. const searchContent = [];
  737. if (model.searchError !== null) {
  738. searchContent.push(
  739. <ErrorMessage key="search-error">
  740. {`Error searching for extensions${
  741. model.searchError ? `: ${model.searchError}` : '.'
  742. }`}
  743. </ErrorMessage>
  744. );
  745. } else {
  746. searchContent.push(
  747. <ListView
  748. key="search-items"
  749. listMode={model.listMode}
  750. viewType={'searchResult'}
  751. // Filter out installed extensions:
  752. entries={model.searchResult.filter(
  753. entry => model.installed.indexOf(entry) === -1
  754. )}
  755. numPages={pages}
  756. onPage={value => {
  757. this.onPage(value);
  758. }}
  759. performAction={this.onAction.bind(this)}
  760. />
  761. );
  762. }
  763. content.push(
  764. <CollapsibleSection
  765. key="search-section"
  766. isOpen={ListModel.isDisclaimed()}
  767. forceOpen={this._forceOpen}
  768. disabled={!ListModel.isDisclaimed()}
  769. header={model.query ? 'Search Results' : 'Discover'}
  770. onCollapse={(isOpen: boolean) => {
  771. if (isOpen && model.query === null) {
  772. model.query = '';
  773. }
  774. }}
  775. >
  776. {searchContent}
  777. </CollapsibleSection>
  778. );
  779. }
  780. elements.push(
  781. <div key="content" className="jp-extensionmanager-content">
  782. {content}
  783. </div>
  784. );
  785. // Reset the force open for future usage.
  786. this._forceOpen = false;
  787. return elements;
  788. }
  789. /**
  790. * Callback handler for the user specifies a new search query.
  791. *
  792. * @param value The new query.
  793. */
  794. onSearch(value: string) {
  795. this.model!.query = value;
  796. }
  797. /**
  798. * Callback handler for the user changes the page of the search result pagination.
  799. *
  800. * @param value The pagination page number.
  801. */
  802. onPage(value: number) {
  803. this.model!.page = value;
  804. }
  805. /**
  806. * Callback handler for when the user wants to perform an action on an extension.
  807. *
  808. * @param action The action to perform.
  809. * @param entry The entry to perform the action on.
  810. */
  811. onAction(action: Action, entry: IEntry) {
  812. switch (action) {
  813. case 'install':
  814. return this.model!.install(entry);
  815. case 'uninstall':
  816. return this.model!.uninstall(entry);
  817. case 'enable':
  818. return this.model!.enable(entry);
  819. case 'disable':
  820. return this.model!.disable(entry);
  821. default:
  822. throw new Error(`Invalid action: ${action}`);
  823. }
  824. }
  825. /**
  826. * Handle the DOM events for the command palette.
  827. *
  828. * @param event - The DOM event sent to the command palette.
  829. *
  830. * #### Notes
  831. * This method implements the DOM `EventListener` interface and is
  832. * called in response to events on the command palette's DOM node.
  833. * It should not be called directly by user code.
  834. */
  835. handleEvent(event: Event): void {
  836. switch (event.type) {
  837. case 'input':
  838. this.onSearch(this.inputNode.value);
  839. break;
  840. case 'focus':
  841. case 'blur':
  842. this._toggleFocused();
  843. break;
  844. default:
  845. break;
  846. }
  847. }
  848. /**
  849. * A message handler invoked on a `'before-attach'` message.
  850. */
  851. protected onBeforeAttach(msg: Message): void {
  852. this.node.addEventListener('input', this);
  853. this.node.addEventListener('focus', this, true);
  854. this.node.addEventListener('blur', this, true);
  855. }
  856. /**
  857. * A message handler invoked on an `'after-detach'` message.
  858. */
  859. protected onAfterDetach(msg: Message): void {
  860. this.node.removeEventListener('input', this);
  861. this.node.removeEventListener('focus', this, true);
  862. this.node.removeEventListener('blur', this, true);
  863. }
  864. /**
  865. * A message handler invoked on an `'activate-request'` message.
  866. */
  867. protected onActivateRequest(msg: Message): void {
  868. if (this.isAttached) {
  869. const input = this.inputNode;
  870. if (input) {
  871. input.focus();
  872. input.select();
  873. }
  874. }
  875. }
  876. /**
  877. * Toggle the focused modifier based on the input node focus state.
  878. */
  879. private _toggleFocused(): void {
  880. const focused = document.activeElement === this.inputNode;
  881. this.toggleClass('lm-mod-focused', focused);
  882. }
  883. }