// 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 (
{this.props.trans.__('Ignore')}
);
}
}
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})
);
}
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 ? (
) : (
''
)}
);
}
}
/**
* 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);
};
}
}