UNPKG

@krassowski/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

545 lines 26.6 kB
// 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