// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. // Based on the @jupyterlab/codemirror-extension statusbar import { JupyterFrontEnd } from '@jupyterlab/application'; import { VDomModel, VDomRenderer, Dialog, showDialog } from '@jupyterlab/apputils'; import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry'; import { ILSPConnection, collectDocuments, ILSPDocumentConnectionManager, VirtualDocument, WidgetLSPAdapter, ILanguageServerManager } from '@jupyterlab/lsp'; import { INotebookModel, NotebookPanel } from '@jupyterlab/notebook'; import { GroupItem, Popup, TextItem, 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 { SERVER_EXTENSION_404 } from '../errors'; import { TSessionMap, TLanguageServerId, TSpecsMap } from '../tokens'; import { codeCheckIcon, codeClockIcon, codeWarningIcon } from './icons'; import { DocumentLocator, ServerLinksList } from './utils'; import okButton = Dialog.okButton; // NOTE: Keep in sync with the "default" value for `ignored_languages` in schema/plugin.json. export const DEFAULT_IGNORED_LANGUAGES = Object.freeze(['markdown']); 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 IIgnoreButtonProps { language: string; trans: TranslationBundle; onClick: () => void; } 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}

    {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 IgnoreButton extends React.Component { render() { return ( ); } } class LSPPopup extends VDomRenderer { constructor(model: LSPStatus.Model) { super(model); this.addClass('lsp-popover'); } render() { if (!this.model?.connectionManager) { return null; } const serversAvailable = this.model.serversAvailableNotInUse.map( (session, i) => ); let runningServers = new Array(); let key = -1; for (let [ session, documentsByLanguage ] of this.model.documentsByServer.entries()) { key += 1; let documentsHtml = new Array(); for (let [language, documents] of documentsByLanguage) { // TODO: stop button // TODO: add a config buttons next to the language header let list = documents.map((document, i) => { let connection = this.model.connectionManager.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)}
  • ); }); documentsHtml.push(
    {language}{' '} ({session.spec.display_name})
      {list}
    ); } runningServers.push(
    {documentsHtml}
    ); } const missingLanguages = this.model.missingLanguages.map((language, i) => { const isIgnored = this.model.isIgnoredLanguage(language); const specsForMissing = this.model.languageServerManager.getMatchingSpecs( { language } ); return (
    {language} {isIgnored ? ( {this.model.trans.__('(ignored)')} ) : ( this.model.ignoreLanguage(language)} /> )} {specsForMissing.size ? ( ) : ( '' )}
    ); }); const trans = this.model.trans; return (

    {trans.__('LSP servers')}

    {serversAvailable.length ? ( ) : ( '' )} {runningServers.length ? ( ) : ( '' )} {missingLanguages.length ? ( ) : ( '' )}
    {trans.__('Documentation:')}{' '} {trans.__('Language Servers')}
    ); } } /** * StatusBar item. */ export class LSPStatus extends VDomRenderer { protected _popup: Popup | null = null; private trans: TranslationBundle; /** * Construct a new VDomRenderer for the status item. */ constructor( protected displayText: boolean = true, shell: JupyterFrontEnd.IShell, trans: TranslationBundle ) { super(new LSPStatus.Model(shell, trans)); this.addClass('jp-mod-highlighted'); this.addClass('lsp-statusbar-item'); this.trans = trans; this.title.caption = this.trans.__('LSP status'); } /** * 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 == 'noServerExtension') { 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', hasDynamicSize: true }); } }; } export class StatusButtonExtension implements DocumentRegistry.IWidgetExtension { constructor( private options: { languageServerManager: ILanguageServerManager; connectionManager: ILSPDocumentConnectionManager; shell: JupyterFrontEnd.IShell; translatorBundle: TranslationBundle; onIgnoreLanguage: (language: string) => Promise; } ) {} /** * For statusbar registration and for internal use. */ createItem(displayText: boolean = true): LSPStatus { const statusBarItem = new LSPStatus( displayText, this.options.shell, this.options.translatorBundle ); statusBarItem.model.languageServerManager = this.options.languageServerManager; statusBarItem.model.connectionManager = this.options.connectionManager; statusBarItem.model.ignoredLanguages = this._ignoredLanguages; statusBarItem.model.onIgnoreLanguage = this.options.onIgnoreLanguage; this._items.add(statusBarItem); statusBarItem.disposed.connect(() => { this._items.delete(statusBarItem); }); return statusBarItem; } setIgnoredLanguages(languages: string[]) { this._ignoredLanguages = [...languages]; for (const item of this._items) { item.model.ignoredLanguages = this._ignoredLanguages; } } /** * For registration on notebook panels. */ createNewToolbarItem(): LSPStatus { const item = this.createItem(false); item.addClass('jp-ToolbarButton'); return item; } /** * For registration on notebook panels. * @deprecated use createNewToolbarItem() instead. */ createNew( panel: NotebookPanel, context: DocumentRegistry.IContext ): LSPStatus { const item = this.createItem(false); item.addClass('jp-ToolbarButton'); panel.toolbar.insertAfter('spacer', 'LSPStatus', item); return item; } private _items = new Set(); private _ignoredLanguages: string[] = [...DEFAULT_IGNORED_LANGUAGES]; } type StatusCode = | 'noServerExtension' | 'waiting' | 'initializing' | 'initialized' | 'connecting' | 'initializedButSomeMissing'; export interface IStatus { connectedDocuments: Set; initializedDocuments: Set; openConnections: Array; detectedDocuments: Set; status: StatusCode; } function collectLanguages(virtualDocument: VirtualDocument): Set { let documents = collectDocuments(virtualDocument); return new Set( [...documents].map(document => document.language.toLocaleLowerCase()) ); } type StatusMap = Record; type StatusIconClass = Record; const classByStatus: StatusIconClass = { noServerExtension: 'error', waiting: 'inactive', initialized: 'ready', initializing: 'preparing', initializedButSomeMissing: 'ready', connecting: 'preparing' }; const iconByStatus: Record = { noServerExtension: codeWarningIcon, waiting: codeClockIcon, initialized: codeCheckIcon, initializing: codeClockIcon, initializedButSomeMissing: codeWarningIcon, connecting: codeClockIcon }; export namespace LSPStatus { /** * A VDomModel for the LSP of current file editor/notebook. */ export class Model extends VDomModel { languageServerManager: ILanguageServerManager; trans: TranslationBundle; onIgnoreLanguage: (language: string) => Promise; private _connectionManager: ILSPDocumentConnectionManager; private _ignoredLanguages: Set; private _shortMessageByStatus: StatusMap; constructor( private _shell: JupyterFrontEnd.IShell, trans: TranslationBundle ) { super(); this.trans = trans; this.onIgnoreLanguage = async () => undefined; this._ignoredLanguages = new Set(DEFAULT_IGNORED_LANGUAGES); this._shortMessageByStatus = { noServerExtension: trans.__('Server extension missing'), waiting: trans.__('Waiting…'), initialized: trans.__('Fully initialized'), initializedButSomeMissing: trans.__('Initialized*'), initializing: trans.__('Initializing…'), connecting: trans.__('Connecting…') }; } get ignoredLanguages(): string[] { return [...this._ignoredLanguages.values()]; } set ignoredLanguages(languages: string[]) { this._ignoredLanguages = new Set( (languages || []).map(language => language.toLocaleLowerCase()) ); this.stateChanged.emit(void 0); } get availableServers(): TSessionMap { return this.languageServerManager.sessions; } get supportedLanguages(): Set { const languages = new Set(); for (let server of this.availableServers.values()) { for (let language of server.spec.languages!) { languages.add(language.toLocaleLowerCase()); } } return languages; } private _isServerRunning( id: TLanguageServerId, server: SCHEMA.LanguageServerSession ): boolean { for (const language of this.detectedLanguages) { const matchedServers = this.languageServerManager.getMatchingServers({ language }); // TODO server.status === "started" ? // TODO update once multiple servers are allowed if (matchedServers.length && matchedServers[0] === id) { return true; } } return false; } get documentsByServer(): Map< SCHEMA.LanguageServerSession, Map > { let data = new Map< SCHEMA.LanguageServerSession, Map >(); if (!this.adapter?.virtualDocument) { return data; } let mainDocument = this.adapter.virtualDocument; let documents = collectDocuments(mainDocument); for (let document of documents.values()) { let language = document.language.toLocaleLowerCase(); let serverIds = this._connectionManager.languageServerManager.getMatchingServers({ language: document.language }); if (serverIds.length === 0) { continue; } // For now only use the server with the highest priority let server = this.languageServerManager.sessions.get(serverIds[0])!; if (!data.has(server)) { data.set(server, new Map()); } let documentsMap = data.get(server)!; if (!documentsMap.has(language)) { documentsMap.set(language, new Array()); } let documents = documentsMap.get(language)!; documents.push(document); } return data; } get serversAvailableNotInUse(): Array { return [...this.availableServers.entries()] .filter(([id, server]) => !this._isServerRunning(id, server)) .map(([id, server]) => server); } get detectedLanguages(): Set { if (!this.adapter?.virtualDocument) { return new Set(); } let document = this.adapter.virtualDocument; return collectLanguages(document); } get missingLanguages(): Array { // TODO: false negative for r vs R? return [...this.detectedLanguages].filter( language => !this.supportedLanguages.has(language.toLocaleLowerCase()) ); } isIgnoredLanguage(language: string): boolean { return this._ignoredLanguages.has(language.toLocaleLowerCase()); } ignoreLanguage(language: string): void { const result = this.onIgnoreLanguage(language.toLocaleLowerCase()); void result.catch(error => { // Prevent unhandled promise rejections from async implementations. console.error('Failed to ignore language', error); }); } get status(): IStatus { let detectedDocuments: Map; if (!this.adapter?.virtualDocument) { detectedDocuments = new Map(); } else { let mainDocument = this.adapter.virtualDocument; const allDocuments = this._connectionManager.documents; // detected documents that are open in the current virtual editor const detectedDocumentsSet = collectDocuments(mainDocument); detectedDocuments = new Map( [...allDocuments].filter(([id, doc]) => detectedDocumentsSet.has(doc)) ); } let connectedDocuments = new Set(); let initializedDocuments = new Set(); let absentDocuments = new Set(); // detected documents with LSP servers available let documentsWithAvailableServers = new Set(); // detected documents with LSP servers known let documentsWithKnownServers = new Set(); detectedDocuments.forEach((document, uri) => { let connection = this._connectionManager.connections.get(uri); let serverIds = this._connectionManager.languageServerManager.getMatchingServers({ language: document.language }); if (serverIds.length !== 0) { documentsWithKnownServers.add(document); } if (!connection) { absentDocuments.add(document); return; } else { documentsWithAvailableServers.add(document); } if (connection.isConnected) { connectedDocuments.add(document); } if (connection.isInitialized) { initializedDocuments.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 openConnections = new Array(); this._connectionManager.connections.forEach((connection, path) => { if (connection.isConnected) { openConnections.push(connection); } }); let status: StatusCode; const missingLanguagesNotIgnored = this.missingLanguages.filter( language => !this.isIgnoredLanguage(language) ); if (this.languageServerManager.statusCode === 404) { status = 'noServerExtension'; } else if (detectedDocuments.size === 0) { status = 'waiting'; } else if (initializedDocuments.size === detectedDocuments.size) { status = 'initialized'; } else if ( initializedDocuments.size === documentsWithAvailableServers.size && documentsWithAvailableServers.size === documentsWithKnownServers.size && missingLanguagesNotIgnored.length === 0 ) { // Ignore languages configured by user when deciding readiness. status = 'initialized'; } else if ( initializedDocuments.size === documentsWithAvailableServers.size && detectedDocuments.size > documentsWithKnownServers.size && missingLanguagesNotIgnored.length > 0 ) { status = 'initializedButSomeMissing'; } else if ( connectedDocuments.size === documentsWithAvailableServers.size ) { status = 'initializing'; } else { status = 'connecting'; } return { openConnections, connectedDocuments, initializedDocuments, detectedDocuments: new Set([...detectedDocuments.values()]), status }; } get statusIcon(): LabIcon { if (!this.adapter) { return stopIcon; } return iconByStatus[this.status.status].bindprops({ className: 'lsp-status-icon ' + classByStatus[this.status.status] }); } get shortMessage(): string { if (!this.adapter) { return this.trans.__('Not initialized'); } return this._shortMessageByStatus[this.status.status]; } get longMessage(): 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.detectedDocuments.size, status.detectedDocuments.size ); } else if (status.status === 'initializing') { const uninitialized = new Set( status.detectedDocuments ); for (let initialized of status.initializedDocuments.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.detectedDocuments.size, uninitialized.size, status.detectedDocuments.size, [...uninitialized].map(document => document.idPath).join(', ') ); } else { const unconnected = new Set(status.detectedDocuments); for (let connected of status.connectedDocuments.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.detectedDocuments.size, status.connectedDocuments.size, status.detectedDocuments.size, status.openConnections.length, [...unconnected].map(document => document.idPath).join(', ') ); } return msg; } get adapter(): WidgetLSPAdapter | null { const adapter = [...this.connectionManager.adapters.values()].find( adapter => adapter.widget == this._shell.currentWidget ); return adapter ?? null; } get connectionManager() { return this._connectionManager; } /** * Note: it is ever only set once, as connectionManager is a singleton. */ set connectionManager(connectionManager) { if (this._connectionManager != null) { this._connectionManager.connected.disconnect(this._onChange); this._connectionManager.initialized.connect(this._onChange); this._connectionManager.disconnected.disconnect(this._onChange); this._connectionManager.closed.disconnect(this._onChange); this._connectionManager.documentsChanged.disconnect(this._onChange); } if (connectionManager != null) { connectionManager.connected.connect(this._onChange); connectionManager.initialized.connect(this._onChange); connectionManager.disconnected.connect(this._onChange); connectionManager.closed.connect(this._onChange); connectionManager.documentsChanged.connect(this._onChange); } this._connectionManager = connectionManager; } private _onChange = () => { this.stateChanged.emit(void 0); }; } }