// 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})
);
}
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 ? (
) : (
''
)}
);
}
}
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;
}
}