UNPKG

@jupyterlab/extensionmanager

Version:
441 lines 14.3 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /* global RequestInit */ import { Dialog, showDialog } from '@jupyterlab/apputils'; import { PageConfig, URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; import { nullTranslator } from '@jupyterlab/translation'; import { VDomModel } from '@jupyterlab/ui-components'; import { Debouncer } from '@lumino/polling'; import * as semver from 'semver'; import { reportInstallError } from './dialog'; /** * The server API path for querying/modifying installed extensions. */ const EXTENSION_API_PATH = 'lab/api/extensions'; /** * Model for an extension list. */ export class ListModel extends VDomModel { constructor(serviceManager, translator) { super(); this.actionError = null; /** * Contains an error message if an error occurred when querying installed extensions. */ this.installedError = null; /** * Contains an error message if an error occurred when searching for extensions. */ this.searchError = null; /** * Whether a reload should be considered due to actions taken. */ this.promptReload = false; this._isDisclaimed = false; this._isEnabled = false; this._isLoadingInstalledExtensions = false; this._isSearching = false; this._query = ''; this._page = 1; this._pagination = 30; this._lastPage = 1; this._pendingActions = []; const metadata = JSON.parse( // The page config option may not be defined; e.g. in the federated example PageConfig.getOption('extensionManager') || '{}'); this.name = metadata.name; this.canInstall = metadata.can_install; this.installPath = metadata.install_path; this.translator = translator || nullTranslator; this._installed = []; this._lastSearchResult = []; this.serviceManager = serviceManager; this._debouncedSearch = new Debouncer(this.search.bind(this), 1000); } /** * A readonly array of the installed extensions. */ get installed() { return this._installed; } /** * Whether the warning is disclaimed or not. */ get isDisclaimed() { return this._isDisclaimed; } set isDisclaimed(v) { if (v !== this._isDisclaimed) { this._isDisclaimed = v; this.stateChanged.emit(); void this._debouncedSearch.invoke(); } } /** * Whether the extension manager is enabled or not. */ get isEnabled() { return this._isEnabled; } set isEnabled(v) { if (v !== this._isEnabled) { this._isEnabled = v; this.stateChanged.emit(); } } get isLoadingInstalledExtensions() { return this._isLoadingInstalledExtensions; } get isSearching() { return this._isSearching; } /** * A readonly array containing the latest search result */ get searchResult() { return this._lastSearchResult; } /** * The search query. * * Setting its value triggers a new search. */ get query() { return this._query; } set query(value) { if (this._query !== value) { this._query = value; this._page = 1; void this._debouncedSearch.invoke(); } } /** * The current search page. * * Setting its value triggers a new search. * * ### Note * First page is 1. */ get page() { return this._page; } set page(value) { if (this._page !== value) { this._page = value; void this._debouncedSearch.invoke(); } } /** * The search pagination. * * Setting its value triggers a new search. */ get pagination() { return this._pagination; } set pagination(value) { if (this._pagination !== value) { this._pagination = value; void this._debouncedSearch.invoke(); } } /** * The last page of results in the current search. */ get lastPage() { return this._lastPage; } /** * Dispose the extensions list model. */ dispose() { if (this.isDisposed) { return; } this._debouncedSearch.dispose(); super.dispose(); } /** * Whether there are currently any actions pending. */ hasPendingActions() { return this._pendingActions.length > 0; } /** * Install an extension. * * @param entry An entry indicating which extension to install. * @param options Additional options for the action. */ async install(entry, options = {}) { await this.performAction('install', entry, options).then(data => { if (data.status !== 'ok') { reportInstallError(entry.name, data.message, this.translator); } return this.update(true); }); } /** * Uninstall an extension. * * @param entry An entry indicating which extension to uninstall. */ async uninstall(entry) { if (!entry.installed) { throw new Error(`Not installed, cannot uninstall: ${entry.name}`); } await this.performAction('uninstall', entry); return this.update(true); } /** * Enable an extension. * * @param entry An entry indicating which extension to enable. */ async enable(entry) { if (entry.enabled) { throw new Error(`Already enabled: ${entry.name}`); } await this.performAction('enable', entry); await this.refreshInstalled(true); } /** * Disable an extension. * * @param entry An entry indicating which extension to disable. */ async disable(entry) { if (!entry.enabled) { throw new Error(`Already disabled: ${entry.name}`); } await this.performAction('disable', entry); await this.refreshInstalled(true); } /** * Refresh installed packages * * @param force Force refreshing the list of installed packages */ async refreshInstalled(force = false) { this.installedError = null; this._isLoadingInstalledExtensions = true; this.stateChanged.emit(); try { const [extensions] = await Private.requestAPI({ refresh: force ? 1 : 0 }); this._installed = extensions.sort(Private.installedComparator); } catch (reason) { this.installedError = reason.toString(); } finally { this._isLoadingInstalledExtensions = false; this.stateChanged.emit(); } } /** * Search with current query. * * Sets searchError and totalEntries as appropriate. * * @returns The extensions matching the current query. */ async search(force = false) { var _a, _b; if (!this.isDisclaimed) { return Promise.reject('Installation warning is not disclaimed.'); } this.searchError = null; this._isSearching = true; this.stateChanged.emit(); try { const [extensions, links] = await Private.requestAPI({ query: (_a = this.query) !== null && _a !== void 0 ? _a : '', page: this.page, per_page: this.pagination, refresh: force ? 1 : 0 }); const lastURL = links['last']; if (lastURL) { const lastPage = URLExt.queryStringToObject((_b = URLExt.parse(lastURL).search) !== null && _b !== void 0 ? _b : '')['page']; if (lastPage) { this._lastPage = parseInt(lastPage, 10); } } const installedNames = this._installed.map(pkg => pkg.name); this._lastSearchResult = extensions.filter(pkg => !installedNames.includes(pkg.name)); } catch (reason) { this.searchError = reason.toString(); } finally { this._isSearching = false; this.stateChanged.emit(); } } /** * Update the current model. * * This will query the packages repository, and the notebook server. * * Emits the `stateChanged` signal on successful completion. */ async update(force = false) { if (this.isDisclaimed) { // First refresh the installed list - so the search results are correctly filtered await this.refreshInstalled(force); await this.search(); } } /** * Send a request to the server to perform an action on an extension. * * @param action A valid action to perform. * @param entry The extension to perform the action on. * @param actionOptions Additional options for the action. */ performAction(action, entry, actionOptions = {}) { const bodyJson = { cmd: action, extension_name: entry.name }; if (actionOptions.useVersion) { bodyJson['extension_version'] = actionOptions.useVersion; } const actionRequest = Private.requestAPI({}, { method: 'POST', body: JSON.stringify(bodyJson) }); actionRequest.then(([reply]) => { const trans = this.translator.load('jupyterlab'); if (reply.needs_restart.includes('server')) { void showDialog({ title: trans.__('Information'), body: trans.__('You will need to restart JupyterLab to apply the changes.'), buttons: [Dialog.okButton({ label: trans.__('Ok') })] }); } else { const followUps = []; if (reply.needs_restart.includes('frontend')) { followUps.push( // @ts-expect-error isElectron is not a standard attribute window.isElectron ? trans.__('reload JupyterLab') : trans.__('refresh the web page')); } if (reply.needs_restart.includes('kernel')) { followUps.push(trans.__('install the extension in all kernels and restart them')); } void showDialog({ title: trans.__('Information'), body: trans.__('You will need to %1 to apply the changes.', followUps.join(trans.__(' and '))), buttons: [Dialog.okButton({ label: trans.__('Ok') })] }); } this.actionError = null; }, reason => { this.actionError = reason.toString(); }); this.addPendingAction(actionRequest); return actionRequest.then(([reply]) => reply); } /** * Add a pending action. * * @param pending A promise that resolves when the action is completed. */ addPendingAction(pending) { // Add to pending actions collection this._pendingActions.push(pending); // Ensure action is removed when resolved const remove = () => { const i = this._pendingActions.indexOf(pending); this._pendingActions.splice(i, 1); this.stateChanged.emit(undefined); }; pending.then(remove, remove); // Signal changed state this.stateChanged.emit(undefined); } } /** * ListModel statics. */ (function (ListModel) { /** * Utility function to check whether an entry can be updated. * * @param entry The entry to check. */ function entryHasUpdate(entry) { if (!entry.installed || !entry.latest_version) { return false; } return semver.lt(entry.installed_version, entry.latest_version); } ListModel.entryHasUpdate = entryHasUpdate; })(ListModel || (ListModel = {})); /** * A namespace for private functionality. */ var Private; (function (Private) { /** * A comparator function that sorts installed extensions. * * In past it used to sort allowedExtensions orgs to the top, * which needs to be restored (or documentation updated). */ function installedComparator(a, b) { return a.name.localeCompare(b.name); } Private.installedComparator = installedComparator; const LINK_PARSER = /<([^>]+)>; rel="([^"]+)",?/g; /** * Call the API extension * * @param queryArgs Query arguments * @param init Initial values for the request * @returns The response body interpreted as JSON and the response link header */ async function requestAPI(queryArgs = {}, init = {}) { var _a; // Make request to Jupyter API const settings = ServerConnection.makeSettings(); const requestUrl = URLExt.join(settings.baseUrl, EXTENSION_API_PATH // API Namespace ); let response; try { response = await ServerConnection.makeRequest(requestUrl + URLExt.objectToQueryString(queryArgs), init, settings); } catch (error) { throw new ServerConnection.NetworkError(error); } let data = await response.text(); if (data.length > 0) { try { data = JSON.parse(data); } catch (error) { console.log('Not a JSON response body.', response); } } if (!response.ok) { throw new ServerConnection.ResponseError(response, data.message || data); } const link = (_a = response.headers.get('Link')) !== null && _a !== void 0 ? _a : ''; const links = {}; let match = null; while ((match = LINK_PARSER.exec(link)) !== null) { links[match[2]] = match[1]; } return [data, links]; } Private.requestAPI = requestAPI; })(Private || (Private = {})); //# sourceMappingURL=model.js.map