diff --git a/packages/jupyterlab-lsp/src/adapters/jupyterlab/components/statusbar.tsx b/packages/jupyterlab-lsp/src/adapters/jupyterlab/components/statusbar.tsx index a9cb43db0..3c2fe927a 100644 --- a/packages/jupyterlab-lsp/src/adapters/jupyterlab/components/statusbar.tsx +++ b/packages/jupyterlab-lsp/src/adapters/jupyterlab/components/statusbar.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { VDomRenderer, VDomModel } from '@jupyterlab/apputils'; +import '../../../../style/statusbar.css'; import { interactiveItem, @@ -18,23 +19,187 @@ import { DefaultIconReact } from '@jupyterlab/ui-components'; import { JupyterLabWidgetAdapter } from '../jl_adapter'; import { VirtualDocument } from '../../../virtual/document'; import { LSPConnection } from '../../../connection'; +import { PageConfig } from '@jupyterlab/coreutils'; + +interface IServerStatusProps { + server: IServer; +} + +function ServerStatus(props: IServerStatusProps) { + let list = props.server.spec.languages.map(language =>
  • {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: any) { + super(props); + this.state = { isCollapsed: props.startCollapsed || false }; + + // This binding is necessary to make `this` work in the callback + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.setState(state => ({ + isCollapsed: !state.isCollapsed + })); + } + + render() { + return ( +
    +

    + + {this.props.title} ({this.props.list.length}) +

    +
    {this.props.list}
    +
    + ); + } +} class LSPPopup extends VDomRenderer { constructor(model: LSPStatus.Model) { super(); this.model = model; - // TODO: add proper, custom class? - this.addClass('p-Menu'); + this.addClass('lsp-popover'); } render() { if (!this.model) { return null; } + const servers_available = this.model.servers_available_not_in_use.map( + (session: IServer) => + ); + + let running_servers = new Array(); + for (let [ + session, + documents_by_language + ] of this.model.documents_by_server.entries()) { + let documents_html = new Array(); + for (let [language, documents] of documents_by_language) { + // TODO user readable document ids: filename, [cell id] + // TODO: stop button + // TODO: add a config buttons next to the language header + let list = documents.map(document => { + let connection = this.model.adapter.connection_manager.connections.get( + document.id_path + ); + + let status = ''; + if (connection.isInitialized) { + status = 'initialized'; + } else if (connection.isConnected) { + status = 'connected'; + } else { + status = 'not connected'; + } + + return ( +
  • + {document.id_path} + + {status} + + +
  • + ); + }); + + documents_html.push( +
    +
    + {language}{' '} + + ({session.spec.display_name}) + +
    +
      {list}
    +
    + ); + } + + running_servers.push(
    {documents_html}
    ); + } + + const missing_languages = this.model.missing_languages.map(language => ( +
    {language}
    + )); return ( - - - - +
    +
    +

    LSP servers

    +
    + {servers_available.length ? ( + + ) : ( + '' + )} + {running_servers.length ? ( + + ) : ( + '' + )} + {missing_languages.length ? ( + + ) : ( + '' + )} +
    +
    +
    + Documentation:{' '} + + Language Servers + +
    +
    ); } } @@ -64,15 +229,14 @@ export class LSPStatus extends VDomRenderer { return ( - - @@ -102,16 +266,25 @@ export interface IStatus { status: StatusCode; } -function collect_languages(virtual_document: VirtualDocument): Set { - let collected = new Set(); - collected.add(virtual_document.language); +function collect_documents( + virtual_document: VirtualDocument +): Set { + let collected = new Set(); + collected.add(virtual_document); for (let foreign of virtual_document.foreign_documents.values()) { - let foreign_languages = collect_languages(foreign); + let foreign_languages = collect_documents(foreign); foreign_languages.forEach(collected.add, collected); } return collected; } +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; const iconByStatus: StatusMap = { @@ -124,41 +297,133 @@ const iconByStatus: StatusMap = { const shortMessageByStatus: StatusMap = { waiting: 'Waiting...', initialized: 'Fully initialized', - initializing: 'Fully connected & partially initialized', + initializing: 'Partially initialized', connecting: 'Connecting...' }; +// TODO ideally, this would be generated from schema +export interface IServerSpecification { + display_name: string; + install: object; + urls: object; + languages: string[]; +} + +export interface IServer { + spec: IServerSpecification; + status: string; + handler_count: number; + last_server_message_at: string; + last_handler_message_at: string; +} + export namespace LSPStatus { /** * A VDomModel for the LSP of current file editor/notebook. */ export class Model extends VDomModel { - get lsp_servers(): string { - if (!this.adapter) { - return ''; + server_extension_status: any = null; + + constructor() { + super(); + + // PathExt.join skips on of the slashes in https:// + let url = PageConfig.getBaseUrl() + 'lsp'; + fetch(url) + .then(response => { + // TODO: retry a few times + if (!response.ok) { + throw new Error(response.statusText); + } + response + .json() + .then(data => (this.server_extension_status = data)) + .catch(console.warn); + }) + .catch(console.error); + } + + get available_servers(): Array { + return this.server_extension_status.sessions; + } + + get supported_languages(): Set { + const languages = new Set(); + for (let server of this.available_servers) { + for (let language of server.spec.languages) { + languages.add(language.toLocaleLowerCase()); + } } - let document = this.adapter.virtual_editor.virtual_document; - return `Languages detected: ${[...collect_languages(document)].join( - ', ' - )}`; + return languages; } - get lsp_servers_truncated(): string { + private is_server_running(server: IServer): boolean { + for (let language of server.spec.languages) { + if (this.detected_languages.has(language.toLocaleLowerCase())) { + return true; + } + } + return false; + } + + get documents_by_server(): Map> { + let data = new Map(); if (!this.adapter) { - return ''; + return new Map(); } - let document = this.adapter.virtual_editor.virtual_document; - let foreign_languages = collect_languages(document); - foreign_languages.delete(this.adapter.language); - if (foreign_languages.size) { - if (foreign_languages.size < 4) { - return `${this.adapter.language}, ${[...foreign_languages].join( - ', ' - )}`; + + 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 servers = this.available_servers.filter( + server => server.spec.languages.indexOf(language) !== -1 + ); + if (servers.length > 1) { + console.warn('More than one server per language for' + language); + } + if (servers.length === 0) { + continue; } - return `${this.adapter.language} (+${foreign_languages.size} more)`; + let server = servers[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.filter( + server => !this.is_server_running(server) + ); + } + + get detected_languages(): Set { + if (!this.adapter) { + return new Set(); } - return this.adapter.language; + + 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 { diff --git a/packages/jupyterlab-lsp/style/statusbar.css b/packages/jupyterlab-lsp/style/statusbar.css new file mode 100644 index 000000000..4f28de051 --- /dev/null +++ b/packages/jupyterlab-lsp/style/statusbar.css @@ -0,0 +1,109 @@ +.lsp-popover { + /* jupyterlab Menu */ + z-index: 10000; + padding: 4px 0px; + background: var(--jp-layout-color0); + color: var(--jp-ui-font-color1); + border: var(--jp-border-width) solid var(--jp-border-color1); + font-size: var(--jp-ui-font-size1); + box-shadow: var(--jp-elevation-z6); +} + +.lsp-popover-content { + padding: 5px 10px; +} + +.lsp-servers-title { + margin-bottom: 5px; +} + +.lsp-servers-menu { + height: 350px; + overflow-y: auto; +} + +.lsp-server-status, +.lsp-missing-server, +.lsp-documents-by-language { + margin-left: 20px; +} + +h5 .lsp-language-server-name { + color: var(--jp-ui-font-color2); +} + +.lsp-documents-by-language ul, +.lsp-server-status ul { + margin: 0; +} + +.lsp-servers-menu h5 { + font-weight: normal; + font-size: 100%; +} + +.lsp-servers-lists { + margin-left: 5px; +} + +.lsp-popover-status { + padding-top: 5px; + border-top: 1px solid var(--jp-border-color1); +} + +.lsp-popover-status a { + color: var(--jp-content-link-color); +} + +.lsp-servers-menu h3, +h4, +h5 { + margin: 0; +} + +.lsp-caret, +.lsp-document-status-icon { + height: 16px; + width: 16px; + background-size: 18px; + background-image: 18px; + background-repeat: no-repeat; + background-position: center; + display: inline-block; + position: relative; + top: 3px; +} + +.lsp-documents-by-language li { + clear: both; + width: 100%; +} + +.lsp-document-status { + float: right; + padding-left: 15px; +} + +.lsp-document-status-icon { + height: 13px; + width: 13px; + background-size: 15px; + background-image: 15px; + margin-left: 10px; +} + +.lsp-collapsible-list h4 { + cursor: pointer; +} + +.lsp-collapsible-list .lsp-caret { + background-image: var(--jp-icon-caretdown); +} + +.lsp-collapsible-list.lsp-collapsed .lsp-caret { + background-image: var(--jp-icon-caretright); +} + +.lsp-collapsible-list.lsp-collapsed div { + display: none; +}