UNPKG

@krassowski/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

426 lines 18.3 kB
import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { LabIcon } from '@jupyterlab/ui-components'; import { Throttler } from '@lumino/polling'; import hoverSvg from '../../style/icons/hover.svg'; import { EditorTooltipManager } from '../components/free_tooltip'; import { PositionConverter } from '../converter'; import { CodeMirrorIntegration } from '../editor_integration/codemirror'; import { FeatureSettings } from '../feature'; import { is_equal } from '../positioning'; import { ILSPFeatureManager, PLUGIN_ID } from '../tokens'; import { getModifierState } from '../utils'; export const hoverIcon = new LabIcon({ name: 'lsp:hover', svgstr: hoverSvg }); /** * Check whether mouse is close to given element (within a specified number of pixels) * @param what target element * @param who mouse event determining position and target * @param cushion number of pixels on each side defining "closeness" boundary */ function isCloseTo(what, who, cushion = 50) { const target = who.type === 'mouseleave' ? who.relatedTarget : who.target; if (what === target || what.contains(target)) { return true; } const whatRect = what.getBoundingClientRect(); return !(who.x < whatRect.left - cushion || who.x > whatRect.right + cushion || who.y < whatRect.top - cushion || who.y > whatRect.bottom + cushion); } class ResponseCache { constructor(maxSize) { this.maxSize = maxSize; this._data = []; } get data() { return this._data; } store(item) { const previousIndex = this._data.findIndex(previous => previous.document === item.document && is_equal(previous.editor_range.start, item.editor_range.start) && is_equal(previous.editor_range.end, item.editor_range.end) && previous.editor_range.editor === item.editor_range.editor); if (previousIndex !== -1) { this._data[previousIndex] = item; return; } if (this._data.length >= this.maxSize) { this._data.shift(); } this._data.push(item); } clean() { this._data = []; } } function to_markup(content) { if (typeof content === 'string') { // coerce deprecated MarkedString to an MarkupContent; if given as a string it is markdown too, // quote: "It is either a markdown string or a code-block that provides a language and a code snippet." // (https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#markedString) return { kind: 'markdown', value: content }; } else { return { kind: 'markdown', value: '```' + content.language + '\n' + content.value + '\n```' }; } } export class HoverCM extends CodeMirrorIntegration { constructor(options) { super(options); this.last_hover_character = null; this.hover_marker = null; this._previousHoverRequest = null; this.onKeyDown = (event) => { if (getModifierState(event, this.modifierKey) && this.last_hover_character !== null) { // does not need to be shown if it is already visible (otherwise we would be creating an identical tooltip again!) if (this.tooltip && this.tooltip.isVisible && !this.tooltip.isDisposed) { return; } const document = this.virtual_editor.document_at_root_position(this.last_hover_character); let response_data = this.restore_from_cache(document, this.virtual_position); if (response_data == null) { return; } event.stopPropagation(); this.handleResponse(response_data, this.last_hover_character, true); } }; this.onMouseLeave = (event) => { this.remove_range_highlight(); this.maybeHideTooltip(event); }; this.on_hover = async () => { var _a; if (!(this.connection.isReady && ((_a = this.connection.serverCapabilities) === null || _a === void 0 ? void 0 : _a.hoverProvider))) { return; } let position = this.virtual_position; return await this.connection.clientRequests['textDocument/hover'].request({ textDocument: { // this might be wrong - should not it be using the specific virtual document? uri: this.virtual_document.document_info.uri }, position: { line: position.line, character: position.ch } }); }; /** * Underlines the word if a tooltip is available. * Displays tooltip if asked to do so. * * Returns true is the tooltip was shown. */ this.handleResponse = (response_data, root_position, show_tooltip) => { let response = response_data.response; // testing for object equality because the response will likely be reused from cache if (this.last_hover_response != response) { this.remove_range_highlight(); this.hover_marker = this.highlight_range(response_data.editor_range, 'cm-lsp-hover-available'); } this.last_hover_response = response; if (show_tooltip) { this.lab_integration.tooltip.remove(); const markup = HoverCM.get_markup_for_hover(response); let editor_position = this.virtual_editor.root_position_to_editor(root_position); this.tooltip = this.lab_integration.tooltip.create({ markup, position: editor_position, ce_editor: response_data.ce_editor, adapter: this.adapter, className: 'lsp-hover' }); return true; } return false; }; this.updateUnderlineAndTooltip = (event) => { try { return this._updateUnderlineAndTooltip(event); } catch (e) { if (!(e.message === 'Cell not found in cell_line_map' || e.message === "Cannot read property 'string' of undefined")) { this.console.warn(e); } return undefined; } }; this.remove_range_highlight = () => { if (this.hover_marker) { this.hover_marker.clear(); this.hover_marker = null; this.last_hover_response = null; this.last_hover_character = null; } }; } get modifierKey() { return this.settings.composite.modifierKey; } get lab_integration() { return super.lab_integration; } get settings() { return super.settings; } restore_from_cache(document, virtual_position) { const { line, ch } = virtual_position; const matching_items = this.cache.data.filter(cache_item => { if (cache_item.document !== document) { return false; } let range = cache_item.response.range; return (line >= range.start.line && line <= range.end.line && // need to be non-overlapping see https://github.com/jupyter-lsp/jupyterlab-lsp/issues/628 (line != range.start.line || ch > range.start.character) && (line != range.end.line || ch <= range.end.character)); }); if (matching_items.length > 1) { this.console.warn('Potential hover cache malfunction: ', virtual_position, matching_items); } return matching_items.length != 0 ? matching_items[0] : null; } register() { this.cache = new ResponseCache(this.settings.composite.cacheSize); this.wrapper_handlers.set('mousemove', event => { var _a; // as CodeMirror.Editor does not support mouseleave nor mousemove, // we simulate the mouseleave for the editor in wrapper's mousemove; // this is used to hide the tooltip on leaving cells in notebook (_a = this.updateUnderlineAndTooltip(event)) === null || _a === void 0 ? void 0 : _a.then(keep_tooltip => { if (!keep_tooltip) { this.maybeHideTooltip(event); } }).catch(this.console.warn); }); this.wrapper_handlers.set('mouseleave', this.onMouseLeave); // show hover after pressing the modifier key // TODO: when the editor (notebook or file editor) is not focused, the keydown event is not getting to us // (probably getting captured by lab); this gives subpar experience when using hover in two editors open // side-by-side, BUT this does not happen for mousemove which properly reads keyModifier from the event // (so this is no too bad as most of the time the user will get the desired outcome - they just need to // budge the mice when holding ctrl if looking at a document which is not active). // whether the editor is focused this.wrapper_handlers.set('keydown', this.onKeyDown); // or just the wrapper (e.g. the notebook but no cell active) this.editor_handlers.set('keydown', (instance, event) => this.onKeyDown(event)); this.debounced_get_hover = this.create_throttler(); this.settings.changed.connect(() => { this.cache.maxSize = this.settings.composite.cacheSize; this.debounced_get_hover = this.create_throttler(); }); super.register(); } maybeHideTooltip(mouseEvent) { if (typeof this.tooltip !== 'undefined' && !isCloseTo(this.tooltip.node, mouseEvent)) { this.tooltip.dispose(); } } create_throttler() { return new Throttler(this.on_hover, { limit: this.settings.composite.throttlerDelay, edge: 'trailing' }); } afterChange(change, root_position) { super.afterChange(change, root_position); // reset cache on any change in the document this.cache.clean(); this.last_hover_character = null; this.remove_range_highlight(); } static get_markup_for_hover(response) { let contents = response.contents; if (typeof contents === 'string') { contents = [contents]; } if (!Array.isArray(contents)) { return contents; } let markups = contents.map(to_markup); if (markups.every(markup => markup.kind == 'plaintext')) { return { kind: 'plaintext', value: markups.map(markup => markup.value).join('\n') }; } else { return { kind: 'markdown', value: markups.map(markup => markup.value).join('\n\n') }; } } is_token_empty(token) { return token.string.length === 0; // TODO || token.type.length === 0? (sometimes the underline is shown on meaningless tokens) } is_event_inside_visible(event) { let target = event.target; return target.closest('.CodeMirror-sizer') != null; } is_useful_response(response) { return (response && response.contents && !(Array.isArray(response.contents) && response.contents.length === 0)); } /** * Returns true if the tooltip should stay. */ async _updateUnderlineAndTooltip(event) { const target = event.target; // if over an empty space in a line (and not over a token) then not worth checking if (target.classList.contains('CodeMirror-line')) { this.remove_range_highlight(); return false; } const show_tooltip = getModifierState(event, this.modifierKey); // currently the events are coming from notebook panel; ideally these would be connected to individual cells, // (only cells with code) instead, but this is more complex to implement right. In any case filtering // is needed to determine in hovered character belongs to this virtual document let root_position = this.position_from_mouse(event); // happens because mousemove is attached to panel, not individual code cells, // and because some regions of the editor (between lines) have no characters if (root_position == null) { this.remove_range_highlight(); return false; } let token = this.virtual_editor.getTokenAt(root_position); let document = this.virtual_editor.document_at_root_position(root_position); if (this.is_token_empty(token) || document !== this.virtual_document || !this.is_event_inside_visible(event)) { this.remove_range_highlight(); return false; } if (!this.last_hover_character || !is_equal(root_position, this.last_hover_character)) { let virtual_position = this.virtual_editor.root_position_to_virtual_position(root_position); this.virtual_position = virtual_position; this.last_hover_character = root_position; // if we already sent a request, maybe it already covers the are of interest? // not harm waiting as the server won't be able to help us anyways if (this._previousHoverRequest) { await Promise.race([ this._previousHoverRequest, // just in case if the request stalled, set a timeout so we do not // get stuck indefinitely new Promise(resolve => { return setTimeout(resolve, 1000); }) ]); } let response_data = this.restore_from_cache(document, virtual_position); if (response_data == null) { const promise = this.debounced_get_hover.invoke(); this._previousHoverRequest = promise; let response = await promise; if (this._previousHoverRequest === promise) { this._previousHoverRequest = null; } if (response && this.is_useful_response(response)) { let ce_editor = this.virtual_editor.get_editor_at_root_position(root_position); let cm_editor = this.virtual_editor.ce_editor_to_cm_editor.get(ce_editor); let editor_range = this.get_editor_range(response, root_position, token, cm_editor); response_data = { response: this.add_range_if_needed(response, editor_range, ce_editor), document: document, editor_range: editor_range, ce_editor: ce_editor }; this.cache.store(response_data); } else { this.remove_range_highlight(); return false; } } return this.handleResponse(response_data, root_position, show_tooltip); } else { return true; } } remove() { this.cache.clean(); this.remove_range_highlight(); this.debounced_get_hover.dispose(); super.remove(); } get_editor_range(response, position, token, cm_editor) { if (typeof response.range !== 'undefined') { return this.range_to_editor_range(response.range, cm_editor); } // construct the range manually using the token information let start_in_root = { line: position.line, ch: token.start }; let end_in_root = { line: position.line, ch: token.end }; return { start: this.virtual_editor.root_position_to_editor(start_in_root), end: this.virtual_editor.root_position_to_editor(end_in_root), editor: cm_editor }; } add_range_if_needed(response, editor_range, ce_editor) { if (typeof response.range !== 'undefined') { return response; } return Object.assign(Object.assign({}, response), { range: { start: PositionConverter.cm_to_lsp(this.virtual_editor.root_position_to_virtual_position(this.virtual_editor.transform_from_editor_to_root(ce_editor, editor_range.start))), end: PositionConverter.cm_to_lsp(this.virtual_editor.root_position_to_virtual_position(this.virtual_editor.transform_from_editor_to_root(ce_editor, editor_range.end))) } }); } } class HoverLabIntegration { constructor(app, settings, renderMimeRegistry) { this.tooltip = new EditorTooltipManager(renderMimeRegistry); } } const FEATURE_ID = PLUGIN_ID + ':hover'; export const HOVER_PLUGIN = { id: FEATURE_ID, requires: [ILSPFeatureManager, ISettingRegistry, IRenderMimeRegistry], autoStart: true, activate: (app, featureManager, settingRegistry, renderMimeRegistry) => { const settings = new FeatureSettings(settingRegistry, FEATURE_ID); const labIntegration = new HoverLabIntegration(app, settings, renderMimeRegistry); featureManager.register({ feature: { editorIntegrationFactory: new Map([['CodeMirrorEditor', HoverCM]]), id: FEATURE_ID, name: 'LSP Hover tooltip', labIntegration: labIntegration, settings: settings, capabilities: { textDocument: { hover: { dynamicRegistration: true, contentFormat: ['markdown', 'plaintext'] } } } } }); } }; //# sourceMappingURL=hover.js.map