// 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 { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry'; import { INotebookModel, NotebookPanel } from '@jupyterlab/notebook'; import { GroupItem, Popup, TextItem, interactiveItem, showPopup } from '@jupyterlab/statusbar'; import { TranslationBundle } from '@jupyterlab/translation'; import { LabIcon, caretDownIcon, caretUpIcon, circleEmptyIcon, circleIcon, stopIcon } from '@jupyterlab/ui-components'; import React from 'react'; import '../../style/statusbar.css'; import * as SCHEMA from '../_schema'; import { WidgetAdapter } from '../adapters/adapter'; import { LSPConnection } from '../connection'; import { DocumentConnectionManager } from '../connection_manager'; import { SERVER_EXTENSION_404 } from '../errors'; import { LanguageServerManager } from '../manager'; import { ILSPAdapterManager, ILanguageServerManager, TSessionMap, TLanguageServerId, TSpecsMap } from '../tokens'; import { VirtualDocument, collect_documents } from '../virtual/document'; import { codeCheckIcon, codeClockIcon, codeWarningIcon } from './icons'; import { DocumentLocator } from './utils'; import okButton = Dialog.okButton; interface IServerStatusProps { server: SCHEMA.LanguageServerSession; } function ServerStatus(props: IServerStatusProps) { let list = props.server.spec.languages!.map((language, i) => (
  • {language}
  • )); return (
    {props.server.spec.display_name}
    ); } export interface IListProps { /** * A title to display. */ title: string; list: any[]; /** * By default the list will be expanded; to change the initial state to collapsed, set to true. */ startCollapsed?: boolean; } export interface ICollapsibleListStates { isCollapsed: boolean; } class CollapsibleList extends React.Component< IListProps, ICollapsibleListStates > { constructor(props: IListProps) { super(props); this.state = { isCollapsed: props.startCollapsed || false }; } handleClick = () => { this.setState(state => ({ isCollapsed: !state.isCollapsed })); }; render() { const collapseExpandIcon = !this.state.isCollapsed ? caretUpIcon : caretDownIcon; return (

    {this.props.title}: {this.props.list.length}

    {this.props.list}
    ); } } interface IHelpButtonProps { language: string; servers: TSpecsMap; trans: TranslationBundle; } interface ILanguageServerInfo { serverId: TLanguageServerId; specs: SCHEMA.LanguageServerSpec; trans: TranslationBundle; } class LanguageServerInfo extends React.Component { render() { const specification = this.props.specs; const trans = this.props.trans; return (

    {specification.display_name}

      {Object.entries(specification?.urls || {}).map(([name, url]) => (
    • {name}:{' '} {url}
    • ))}

    {trans.__('Troubleshooting')}

    {specification.troubleshoot ? specification.troubleshoot : trans.__( 'In case of issues with installation feel welcome to ask a question on GitHub.' )}

    {trans.__('Installation')}

      {specification?.install ? Object.entries(specification?.install || {}).map( ([name, command]) => (
    • {name}: {command}
    • ) ) : trans.__( 'No installation instructions were provided with this specification.' )}
    ); } } class HelpButton extends React.Component { handleClick = () => { const trans = this.props.trans; showDialog({ title: trans.__( 'No language server for %1 detected', this.props.language ), body: (
    {this.props.servers.size ? (

    {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]) => ( ))}
    ) : (

    {trans.__( 'We do not have an auto-detection ready for a language servers supporting %1 yet.', this.props.language )}

    {trans.__( 'You may contribute a specification for auto-detection as described in our ' )}{' '} {trans.__('documentation')}

    )}
    ), buttons: [okButton()] }).catch(console.warn); }; render() { return ( ); } } class LSPPopup extends VDomRenderer { constructor(model: LSPStatus.Model) { super(model); this.addClass('lsp-popover'); } render() { if (!this.model?.connection_manager) { return null; } const servers_available = this.model.servers_available_not_in_use.map( (session, i) => ); 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?.isInitialized) { status = 'initialized'; } else if (connection?.isConnected) { status = 'connected'; } else { status = 'not connected'; } const icon = status === 'initialized' ? circleIcon : circleEmptyIcon; return (
  • {this.model.trans.__(status)}
  • ); }); documents_html.push(
    {language}{' '} ({session.spec.display_name})
      {list}
    ); } running_servers.push(
    {documents_html}
    ); } const missing_languages = this.model.missing_languages.map( (language, i) => { const specs_for_missing = this.model.language_server_manager.getMatchingSpecs({ language }); return (
    {language} {specs_for_missing.size ? ( ) : ( '' )}
    ); } ); const trans = this.model.trans; return (

    {trans.__('LSP servers')}

    {servers_available.length ? ( ) : ( '' )} {running_servers.length ? ( ) : ( '' )} {missing_languages.length ? ( ) : ( '' )}
    {trans.__('Documentation:')}{' '} {trans.__('Language Servers')}
    ); } } const SELECTED_CLASS = 'jp-mod-selected'; /** * StatusBar item. */ export class LSPStatus extends VDomRenderer { protected _popup: Popup | null = null; private interactiveStateObserver: MutationObserver; private trans: TranslationBundle; /** * Construct a new VDomRenderer for the status item. */ constructor( widget_manager: ILSPAdapterManager, protected displayText: boolean = true, trans: TranslationBundle ) { super(new LSPStatus.Model(widget_manager, trans)); 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); } } }); } protected onAfterAttach(msg: any) { super.onAfterAttach(msg); this.interactiveStateObserver.observe(this.node, { attributes: true, attributeFilter: ['class'] }); } protected onBeforeDetach(msg: any) { super.onBeforeDetach(msg); this.interactiveStateObserver.disconnect(); } /** * Render the status item. */ render() { const { model } = this; if (model == null) { return null; } return ( {this.displayText ? ( ) : ( <> )} ); } 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' }); } }; } export class StatusButtonExtension implements DocumentRegistry.IWidgetExtension { constructor( private options: { language_server_manager: LanguageServerManager; connection_manager: DocumentConnectionManager; adapter_manager: ILSPAdapterManager; translator_bundle: TranslationBundle; } ) {} /** * For statusbar registration and for internal use. */ createItem(displayText: boolean = true): LSPStatus { 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: NotebookPanel, context: DocumentRegistry.IContext ): LSPStatus { const item = this.createItem(false); item.addClass('jp-ToolbarButton'); panel.toolbar.insertAfter('spacer', 'LSPStatus', item); return item; } } type StatusCode = | 'no_server_extension' | 'waiting' | 'initializing' | 'initialized' | 'connecting' | 'initialized_but_some_missing'; export interface IStatus { connected_documents: Set; initialized_documents: Set; open_connections: Array; detected_documents: Set; status: StatusCode; } function collect_languages(virtual_document: VirtualDocument): Set { let documents = collect_documents(virtual_document); return new Set( [...documents].map(document => document.language.toLocaleLowerCase()) ); } type StatusMap = Record; type StatusIconClass = Record; const classByStatus: StatusIconClass = { no_server_extension: 'error', waiting: 'inactive', initialized: 'ready', initializing: 'preparing', initialized_but_some_missing: 'ready', connecting: 'preparing' }; const iconByStatus: Record = { no_server_extension: codeWarningIcon, waiting: codeClockIcon, initialized: codeCheckIcon, initializing: codeClockIcon, initialized_but_some_missing: codeWarningIcon, connecting: codeClockIcon }; const shortMessageByStatus: StatusMap = { no_server_extension: 'Server extension missing', waiting: 'Waiting...', initialized: 'Fully initialized', initialized_but_some_missing: 'Initialized (additional servers needed)', initializing: 'Initializing...', connecting: 'Connecting...' }; export namespace LSPStatus { /** * A VDomModel for the LSP of current file editor/notebook. */ export class Model extends VDomModel { server_extension_status: SCHEMA.ServersResponse | null = null; language_server_manager: ILanguageServerManager; trans: TranslationBundle; private _connection_manager: DocumentConnectionManager; constructor( widget_adapter_manager: ILSPAdapterManager, trans: TranslationBundle ) { super(); 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(): TSessionMap { return this.language_server_manager.sessions; } get supported_languages(): Set { 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; } private is_server_running( id: TLanguageServerId, server: SCHEMA.LanguageServerSession ): boolean { 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(): Map< SCHEMA.LanguageServerSession, Map > { let data = new Map< SCHEMA.LanguageServerSession, Map >(); if (!this.adapter?.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(): Array { return [...this.available_servers.entries()] .filter(([id, server]) => !this.is_server_running(id, server)) .map(([id, server]) => server); } get detected_languages(): Set { if (!this.adapter?.virtual_editor) { return new Set(); } let document = this.adapter.virtual_editor.virtual_document; return collect_languages(document); } get missing_languages(): Array { // TODO: false negative for r vs R? return [...this.detected_languages].filter( language => !this.supported_languages.has(language.toLocaleLowerCase()) ); } get status(): IStatus { let detected_documents: Map; if (!this.adapter?.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: StatusCode; 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(): LabIcon { if (!this.adapter) { return stopIcon; } return iconByStatus[this.status.status].bindprops({ className: 'lsp-status-icon ' + classByStatus[this.status.status] }); } get short_message(): string { if (!this.adapter) { return this.trans.__('not initialized'); } return this.trans.__(shortMessageByStatus[this.status.status]); } get feature_message(): string { return this.adapter?.status_message?.message || ''; } get long_message(): string { 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(): WidgetAdapter | null { return this._adapter; } change_adapter(adapter: WidgetAdapter | null) { 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; } private _onChange = () => { this.stateChanged.emit(void 0); }; private _adapter: WidgetAdapter | null = null; } }