@krassowski/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
545 lines • 26.6 kB
JavaScript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
// Based on the @jupyterlab/codemirror-extension statusbar
import { VDomModel, VDomRenderer, Dialog, showDialog } from '@jupyterlab/apputils';
import { GroupItem, TextItem, interactiveItem, showPopup } from '@jupyterlab/statusbar';
import { caretDownIcon, caretUpIcon, circleEmptyIcon, circleIcon, stopIcon } from '@jupyterlab/ui-components';
import React from 'react';
import '../../style/statusbar.css';
import { SERVER_EXTENSION_404 } from '../errors';
import { collect_documents } from '../virtual/document';
import { codeCheckIcon, codeClockIcon, codeWarningIcon } from './icons';
import { DocumentLocator } from './utils';
var okButton = Dialog.okButton;
function ServerStatus(props) {
let list = props.server.spec.languages.map((language, i) => (React.createElement("li", { key: i }, language)));
return (React.createElement("div", { className: 'lsp-server-status' },
React.createElement("h5", null, props.server.spec.display_name),
React.createElement("ul", null, list)));
}
class CollapsibleList extends React.Component {
constructor(props) {
super(props);
this.handleClick = () => {
this.setState(state => ({
isCollapsed: !state.isCollapsed
}));
};
this.state = { isCollapsed: props.startCollapsed || false };
}
render() {
const collapseExpandIcon = !this.state.isCollapsed
? caretUpIcon
: caretDownIcon;
return (React.createElement("div", { className: 'lsp-collapsible-list ' +
(this.state.isCollapsed ? 'lsp-collapsed' : '') },
React.createElement("h4", { onClick: this.handleClick },
React.createElement(collapseExpandIcon.react, { tag: "span", className: "lsp-caret-icon" }),
this.props.title,
": ",
this.props.list.length),
React.createElement("div", null, this.props.list)));
}
}
class LanguageServerInfo extends React.Component {
render() {
const specification = this.props.specs;
const trans = this.props.trans;
return (React.createElement("div", null,
React.createElement("h3", null, specification.display_name),
React.createElement("div", null,
React.createElement("ul", { className: 'lsp-server-links-list' }, Object.entries((specification === null || specification === void 0 ? void 0 : specification.urls) || {}).map(([name, url]) => (React.createElement("li", { key: this.props.serverId + '-url-' + name },
name,
":",
' ',
React.createElement("a", { href: url, target: "_blank", rel: "noreferrer" }, url))))),
React.createElement("h4", null, trans.__('Troubleshooting')),
React.createElement("p", { className: 'lsp-troubleshoot-section' }, specification.troubleshoot
? specification.troubleshoot
: trans.__('In case of issues with installation feel welcome to ask a question on GitHub.')),
React.createElement("h4", null, trans.__('Installation')),
React.createElement("ul", null, (specification === null || specification === void 0 ? void 0 : specification.install) ? Object.entries((specification === null || specification === void 0 ? void 0 : specification.install) || {}).map(([name, command]) => (React.createElement("li", { key: this.props.serverId + '-install-' + name },
name,
": ",
React.createElement("code", null, command))))
: trans.__('No installation instructions were provided with this specification.')))));
}
}
class HelpButton extends React.Component {
constructor() {
super(...arguments);
this.handleClick = () => {
const trans = this.props.trans;
showDialog({
title: trans.__('No language server for %1 detected', this.props.language),
body: (React.createElement("div", null, this.props.servers.size ? (React.createElement("div", null,
React.createElement("p", null, trans._n('There is %1 language server you can easily install that supports %2.', 'There are %1 language servers you can easily install that supports %2.', this.props.servers.size, this.props.language)),
[...this.props.servers.entries()].map(([key, specification]) => (React.createElement(LanguageServerInfo, { specs: specification, serverId: key, key: key, trans: trans }))))) : (React.createElement("div", null,
React.createElement("p", null, trans.__('We do not have an auto-detection ready for a language servers supporting %1 yet.', this.props.language)),
React.createElement("p", null,
trans.__('You may contribute a specification for auto-detection as described in our '),
' ',
React.createElement("a", { href: 'https://jupyterlab-lsp.readthedocs.io/en/latest/Contributing.html#specs' }, trans.__('documentation'))))))),
buttons: [okButton()]
}).catch(console.warn);
};
}
render() {
return (React.createElement("button", { type: 'button', className: 'jp-Button lsp-help-button', onClick: this.handleClick }, "?"));
}
}
class LSPPopup extends VDomRenderer {
constructor(model) {
super(model);
this.addClass('lsp-popover');
}
render() {
var _a;
if (!((_a = this.model) === null || _a === void 0 ? void 0 : _a.connection_manager)) {
return null;
}
const servers_available = this.model.servers_available_not_in_use.map((session, i) => React.createElement(ServerStatus, { key: i, server: session }));
let running_servers = new Array();
let key = -1;
for (let [session, documents_by_language] of this.model.documents_by_server.entries()) {
key += 1;
let documents_html = new Array();
for (let [language, documents] of documents_by_language) {
// TODO: stop button
// TODO: add a config buttons next to the language header
let list = documents.map((document, i) => {
let connection = this.model.connection_manager.connections.get(document.uri);
let status = '';
if (connection === null || connection === void 0 ? void 0 : connection.isInitialized) {
status = 'initialized';
}
else if (connection === null || connection === void 0 ? void 0 : connection.isConnected) {
status = 'connected';
}
else {
status = 'not connected';
}
const icon = status === 'initialized' ? circleIcon : circleEmptyIcon;
return (React.createElement("li", { key: i },
React.createElement(DocumentLocator, { document: document, adapter: this.model.adapter }),
React.createElement("span", { className: 'lsp-document-status' },
this.model.trans.__(status),
React.createElement(icon.react, { tag: "span", className: "lsp-document-status-icon", elementSize: 'small' }))));
});
documents_html.push(React.createElement("div", { key: key, className: 'lsp-documents-by-language' },
React.createElement("h5", null,
language,
' ',
React.createElement("span", { className: 'lsp-language-server-name' },
"(",
session.spec.display_name,
")")),
React.createElement("ul", null, list)));
}
running_servers.push(React.createElement("div", { key: key }, documents_html));
}
const missing_languages = this.model.missing_languages.map((language, i) => {
const specs_for_missing = this.model.language_server_manager.getMatchingSpecs({ language });
return (React.createElement("div", { key: i, className: 'lsp-missing-server' },
language,
specs_for_missing.size ? (React.createElement(HelpButton, { language: language, servers: specs_for_missing, trans: this.model.trans })) : ('')));
});
const trans = this.model.trans;
return (React.createElement("div", { className: 'lsp-popover-content' },
React.createElement("div", { className: 'lsp-servers-menu' },
React.createElement("h3", { className: 'lsp-servers-title' }, trans.__('LSP servers')),
React.createElement("div", { className: 'lsp-servers-lists' },
servers_available.length ? (React.createElement(CollapsibleList, { key: 'available', title: trans.__('Available'), list: servers_available, startCollapsed: true })) : (''),
running_servers.length ? (React.createElement(CollapsibleList, { key: 'running', title: trans.__('Running'), list: running_servers })) : (''),
missing_languages.length ? (React.createElement(CollapsibleList, { key: 'missing', title: trans.__('Missing'), list: missing_languages })) : (''))),
React.createElement("div", { className: 'lsp-popover-status' },
trans.__('Documentation:'),
' ',
React.createElement("a", { href: 'https://jupyterlab-lsp.readthedocs.io/en/latest/Language%20Servers.html', target: "_blank", rel: "noreferrer" }, trans.__('Language Servers')))));
}
}
const SELECTED_CLASS = 'jp-mod-selected';
/**
* StatusBar item.
*/
export class LSPStatus extends VDomRenderer {
/**
* Construct a new VDomRenderer for the status item.
*/
constructor(widget_manager, displayText = true, trans) {
super(new LSPStatus.Model(widget_manager, trans));
this.displayText = displayText;
this._popup = null;
this.handleClick = () => {
if (this._popup) {
this._popup.dispose();
}
if (this.model.status.status == 'no_server_extension') {
showDialog({
title: this.trans.__('LSP server extension not found'),
body: SERVER_EXTENSION_404,
buttons: [okButton()]
}).catch(console.warn);
}
else {
this._popup = showPopup({
body: new LSPPopup(this.model),
anchor: this,
align: 'left'
});
}
};
this.addClass(interactiveItem);
this.addClass('lsp-statusbar-item');
this.trans = trans;
this.title.caption = this.trans.__('LSP status');
// add human-readable (and stable) class name reflecting otherwise obfuscated typestyle interactiveItem
this.interactiveStateObserver = new MutationObserver(() => {
const has_selected = this.node.classList.contains(SELECTED_CLASS);
if (!this.node.classList.contains(interactiveItem)) {
if (!has_selected) {
this.addClass(SELECTED_CLASS);
}
}
else {
if (has_selected) {
this.removeClass(SELECTED_CLASS);
}
}
});
}
onAfterAttach(msg) {
super.onAfterAttach(msg);
this.interactiveStateObserver.observe(this.node, {
attributes: true,
attributeFilter: ['class']
});
}
onBeforeDetach(msg) {
super.onBeforeDetach(msg);
this.interactiveStateObserver.disconnect();
}
/**
* Render the status item.
*/
render() {
const { model } = this;
if (model == null) {
return null;
}
return (React.createElement(GroupItem, { spacing: this.displayText ? 2 : 0, title: model.long_message, onClick: this.handleClick, className: 'lsp-status-group' },
React.createElement(model.status_icon.react, { top: '2px', kind: 'statusBar', title: this.trans.__('LSP Code Intelligence') }),
this.displayText ? (React.createElement(TextItem, { className: 'lsp-status-message', source: model.short_message })) : (React.createElement(React.Fragment, null)),
React.createElement(TextItem, { source: model.feature_message })));
}
}
export class StatusButtonExtension {
constructor(options) {
this.options = options;
}
/**
* For statusbar registration and for internal use.
*/
createItem(displayText = true) {
const status_bar_item = new LSPStatus(this.options.adapter_manager, displayText, this.options.translator_bundle);
status_bar_item.model.language_server_manager =
this.options.language_server_manager;
status_bar_item.model.connection_manager = this.options.connection_manager;
return status_bar_item;
}
/**
* For registration on notebook panels.
*/
createNew(panel, context) {
const item = this.createItem(false);
item.addClass('jp-ToolbarButton');
panel.toolbar.insertAfter('spacer', 'LSPStatus', item);
return item;
}
}
function collect_languages(virtual_document) {
let documents = collect_documents(virtual_document);
return new Set([...documents].map(document => document.language.toLocaleLowerCase()));
}
const classByStatus = {
no_server_extension: 'error',
waiting: 'inactive',
initialized: 'ready',
initializing: 'preparing',
initialized_but_some_missing: 'ready',
connecting: 'preparing'
};
const iconByStatus = {
no_server_extension: codeWarningIcon,
waiting: codeClockIcon,
initialized: codeCheckIcon,
initializing: codeClockIcon,
initialized_but_some_missing: codeWarningIcon,
connecting: codeClockIcon
};
const shortMessageByStatus = {
no_server_extension: 'Server extension missing',
waiting: 'Waiting...',
initialized: 'Fully initialized',
initialized_but_some_missing: 'Initialized (additional servers needed)',
initializing: 'Initializing...',
connecting: 'Connecting...'
};
(function (LSPStatus) {
/**
* A VDomModel for the LSP of current file editor/notebook.
*/
class Model extends VDomModel {
constructor(widget_adapter_manager, trans) {
super();
this.server_extension_status = null;
this._onChange = () => {
this.stateChanged.emit(void 0);
};
this._adapter = null;
this.trans = trans;
widget_adapter_manager.adapterChanged.connect((manager, adapter) => {
this.change_adapter(adapter);
}, this);
widget_adapter_manager.adapterDisposed.connect((manager, adapter) => {
if (this.adapter === adapter) {
this.change_adapter(null);
}
}, this);
}
get available_servers() {
return this.language_server_manager.sessions;
}
get supported_languages() {
const languages = new Set();
for (let server of this.available_servers.values()) {
for (let language of server.spec.languages) {
languages.add(language.toLocaleLowerCase());
}
}
return languages;
}
is_server_running(id, server) {
for (const language of this.detected_languages) {
const matchedServers = this.language_server_manager.getMatchingServers({
language
});
// TODO server.status === "started" ?
// TODO update once multiple servers are allowed
if (matchedServers.length && matchedServers[0] === id) {
return true;
}
}
return false;
}
get documents_by_server() {
var _a;
let data = new Map();
if (!((_a = this.adapter) === null || _a === void 0 ? void 0 : _a.virtual_editor)) {
return data;
}
let main_document = this.adapter.virtual_editor.virtual_document;
let documents = collect_documents(main_document);
for (let document of documents.values()) {
let language = document.language.toLocaleLowerCase();
let server_ids = this._connection_manager.language_server_manager.getMatchingServers({
language: document.language
});
if (server_ids.length === 0) {
continue;
}
// For now only use the server with the highest priority
let server = this.language_server_manager.sessions.get(server_ids[0]);
if (!data.has(server)) {
data.set(server, new Map());
}
let documents_map = data.get(server);
if (!documents_map.has(language)) {
documents_map.set(language, new Array());
}
let documents = documents_map.get(language);
documents.push(document);
}
return data;
}
get servers_available_not_in_use() {
return [...this.available_servers.entries()]
.filter(([id, server]) => !this.is_server_running(id, server))
.map(([id, server]) => server);
}
get detected_languages() {
var _a;
if (!((_a = this.adapter) === null || _a === void 0 ? void 0 : _a.virtual_editor)) {
return new Set();
}
let document = this.adapter.virtual_editor.virtual_document;
return collect_languages(document);
}
get missing_languages() {
// TODO: false negative for r vs R?
return [...this.detected_languages].filter(language => !this.supported_languages.has(language.toLocaleLowerCase()));
}
get status() {
var _a;
let detected_documents;
if (!((_a = this.adapter) === null || _a === void 0 ? void 0 : _a.virtual_editor)) {
detected_documents = new Map();
}
else {
let main_document = this.adapter.virtual_editor.virtual_document;
const all_documents = this._connection_manager.documents;
// detected documents that are open in the current virtual editor
const detected_documents_set = collect_documents(main_document);
detected_documents = new Map([...all_documents].filter(([id, doc]) => detected_documents_set.has(doc)));
}
let connected_documents = new Set();
let initialized_documents = new Set();
let absent_documents = new Set();
// detected documents with LSP servers available
let documents_with_available_servers = new Set();
// detected documents with LSP servers known
let documents_with_known_servers = new Set();
detected_documents.forEach((document, uri) => {
let connection = this._connection_manager.connections.get(uri);
let server_ids = this._connection_manager.language_server_manager.getMatchingServers({
language: document.language
});
if (server_ids.length !== 0) {
documents_with_known_servers.add(document);
}
if (!connection) {
absent_documents.add(document);
return;
}
else {
documents_with_available_servers.add(document);
}
if (connection.isConnected) {
connected_documents.add(document);
}
if (connection.isInitialized) {
initialized_documents.add(document);
}
});
// there may be more open connections than documents if a document was recently closed
// and the grace period has not passed yet
let open_connections = new Array();
this._connection_manager.connections.forEach((connection, path) => {
if (connection.isConnected) {
open_connections.push(connection);
}
});
let status;
if (this.language_server_manager.statusCode === 404) {
status = 'no_server_extension';
}
else if (detected_documents.size === 0) {
status = 'waiting';
}
else if (initialized_documents.size === detected_documents.size) {
status = 'initialized';
}
else if (initialized_documents.size === documents_with_available_servers.size &&
detected_documents.size > documents_with_known_servers.size) {
status = 'initialized_but_some_missing';
}
else if (connected_documents.size === documents_with_available_servers.size) {
status = 'initializing';
}
else {
status = 'connecting';
}
return {
open_connections,
connected_documents,
initialized_documents,
detected_documents: new Set([...detected_documents.values()]),
status
};
}
get status_icon() {
if (!this.adapter) {
return stopIcon;
}
return iconByStatus[this.status.status].bindprops({
className: 'lsp-status-icon ' + classByStatus[this.status.status]
});
}
get short_message() {
if (!this.adapter) {
return this.trans.__('not initialized');
}
return this.trans.__(shortMessageByStatus[this.status.status]);
}
get feature_message() {
var _a, _b;
return ((_b = (_a = this.adapter) === null || _a === void 0 ? void 0 : _a.status_message) === null || _b === void 0 ? void 0 : _b.message) || '';
}
get long_message() {
if (!this.adapter) {
return this.trans.__('not initialized');
}
let status = this.status;
let msg = '';
if (status.status === 'waiting') {
msg = this.trans.__('Waiting for documents initialization...');
}
else if (status.status === 'initialized') {
msg = this.trans._n('Fully connected & initialized (%2 virtual document)', 'Fully connected & initialized (%2 virtual document)', status.detected_documents.size, status.detected_documents.size);
}
else if (status.status === 'initializing') {
const uninitialized = new Set(status.detected_documents);
for (let initialized of status.initialized_documents.values()) {
uninitialized.delete(initialized);
}
// servers for n documents did not respond to the initialization request
msg = this.trans._np('pluralized', 'Fully connected, but %2/%3 virtual document stuck uninitialized: %4', 'Fully connected, but %2/%3 virtual documents stuck uninitialized: %4', status.detected_documents.size, uninitialized.size, status.detected_documents.size, [...uninitialized].map(document => document.id_path).join(', '));
}
else {
const unconnected = new Set(status.detected_documents);
for (let connected of status.connected_documents.values()) {
unconnected.delete(connected);
}
msg = this.trans._np('pluralized', '%2/%3 virtual document connected (%4 connections; waiting for: %5)', '%2/%3 virtual documents connected (%4 connections; waiting for: %5)', status.detected_documents.size, status.connected_documents.size, status.detected_documents.size, status.open_connections.length, [...unconnected].map(document => document.id_path).join(', '));
}
return msg;
}
get adapter() {
return this._adapter;
}
change_adapter(adapter) {
if (this._adapter != null) {
this._adapter.status_message.changed.disconnect(this._onChange);
}
if (adapter != null) {
adapter.status_message.changed.connect(this._onChange);
}
this._adapter = adapter;
}
get connection_manager() {
return this._connection_manager;
}
/**
* Note: it is ever only set once, as connection_manager is a singleton.
*/
set connection_manager(connection_manager) {
if (this._connection_manager != null) {
this._connection_manager.connected.disconnect(this._onChange);
this._connection_manager.initialized.connect(this._onChange);
this._connection_manager.disconnected.disconnect(this._onChange);
this._connection_manager.closed.disconnect(this._onChange);
this._connection_manager.documents_changed.disconnect(this._onChange);
}
if (connection_manager != null) {
connection_manager.connected.connect(this._onChange);
connection_manager.initialized.connect(this._onChange);
connection_manager.disconnected.connect(this._onChange);
connection_manager.closed.connect(this._onChange);
connection_manager.documents_changed.connect(this._onChange);
}
this._connection_manager = connection_manager;
}
}
LSPStatus.Model = Model;
})(LSPStatus || (LSPStatus = {}));
//# sourceMappingURL=statusbar.js.map