@jupyterlab/extensionmanager
Version:
JupyterLab - Extension Manager
441 lines • 14.3 kB
JavaScript
// 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