UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

494 lines 21 kB
import { EditorView } from '@codemirror/view'; import { EditorExtensionRegistry } from '@jupyterlab/codemirror'; import { ProtocolCoordinates, ILSPFeatureManager, isEqual, ILSPDocumentConnectionManager } from '@jupyterlab/lsp'; 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 { ContextAssembler } from '../context'; import { PositionConverter, documentAtRootPosition, rootPositionToVirtualPosition, rootPositionToEditorPosition, editorPositionToRootPosition, rangeToEditorRange } from '../converter'; import { FeatureSettings, Feature } from '../feature'; import { createMarkManager } from '../marks'; import { PLUGIN_ID } from '../tokens'; import { getModifierState } from '../utils'; import { BrowserConsole } from '../virtual/console'; 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 { get data() { return this._data; } constructor(maxSize) { this.maxSize = maxSize; this._data = []; } store(item) { const previousIndex = this._data.findIndex(previous => previous.document === item.document && isEqual(previous.editorRange.start, item.editorRange.start) && isEqual(previous.editorRange.end, item.editorRange.end) && previous.editorRange.editor === item.editorRange.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 toMarkup(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 HoverFeature extends Feature { constructor(options) { super(options); this.capabilities = { textDocument: { hover: { dynamicRegistration: true, contentFormat: ['markdown', 'plaintext'] } } }; this.id = HoverFeature.id; this.console = new BrowserConsole().scope('Hover'); this.lastHoverCharacter = null; this.hasMarker = false; this._previousHoverRequest = null; this.onKeyDown = (event, adapter) => { if (getModifierState(event, this.modifierKey) && this.lastHoverCharacter !== 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 = documentAtRootPosition(adapter, this.lastHoverCharacter); let responseData = this.restoreFromCache(document, this.virtualPosition); if (responseData == null) { return; } event.stopPropagation(); this.handleResponse(adapter, responseData, this.lastHoverCharacter, true); } }; this.onMouseLeave = (event) => { this.removeRangeHighlight(); this.maybeHideTooltip(event); }; this.getHover = async (virtualDocument, virtualPosition, context) => { const connection = this.connectionManager.connections.get(virtualDocument.uri); if (!(connection.isReady && connection.serverCapabilities.hoverProvider)) { return null; } let response = await connection.clientRequests['textDocument/hover'].request({ textDocument: { uri: virtualDocument.documentInfo.uri }, position: { line: virtualPosition.line, character: virtualPosition.ch } }); if (response == null) { return null; } if (typeof response.range !== 'undefined') { return response; } // Harmonise response by adding range const editorRange = this._getEditorRange(context.adapter, response, context.token, context.editor, virtualDocument); return this._addRange(context.adapter, response, editorRange, context.editorAccessor); }; /** * marks the word if a tooltip is available. * Displays tooltip if asked to do so. * * Returns true is the tooltip was shown. */ this.handleResponse = (adapter, responseData, rootPosition, showTooltip) => { let response = responseData.response; // testing for object equality because the response will likely be reused from cache if (this.lastHoverResponse != response) { this.removeRangeHighlight(); const range = responseData.editorRange; const editorView = range.editor.editor; const from = range.editor.getOffsetAt(PositionConverter.cm_to_ce(range.start)); const to = range.editor.getOffsetAt(PositionConverter.cm_to_ce(range.end)); this.markManager.putMarks(editorView, [{ from, to, kind: 'hover' }]); this.hasMarker = true; } this.lastHoverResponse = response; if (showTooltip) { const markup = HoverFeature.getMarkupForHover(response); let editorPosition = rootPositionToEditorPosition(adapter, rootPosition); this.tooltip = this.tooltipManager.showOrCreate({ markup, position: editorPosition, ceEditor: responseData.ceEditor, adapter: adapter, className: 'lsp-hover' }); return true; } return false; }; this.updateUnderlineAndTooltip = (event, adapter) => { try { return this._updateUnderlineAndTooltip(event, adapter); } catch (e) { this.console.warn(e); return undefined; } }; this.removeRangeHighlight = () => { if (this.hasMarker) { this.markManager.clearAllMarks(); this.hasMarker = false; this.lastHoverResponse = null; this.lastHoverCharacter = null; } }; this.settings = options.settings; this.tooltipManager = new EditorTooltipManager(options.renderMimeRegistry); this.contextAssembler = options.contextAssembler; this.cache = new ResponseCache(10); this.markManager = createMarkManager({ hover: { class: 'cm-lsp-hover-available' } }); this.extensionFactory = { name: 'lsp:hover', factory: factoryOptions => { const { widgetAdapter: adapter } = factoryOptions; const updateListener = EditorView.updateListener.of(viewUpdate => { if (viewUpdate.docChanged) { this.afterChange(); } }); const eventListeners = EditorView.domEventHandlers({ mousemove: event => { var _a; // this is used to hide the tooltip on leaving cells in notebook (_a = this.updateUnderlineAndTooltip(event, adapter)) === null || _a === void 0 ? void 0 : _a.then(keepTooltip => { if (!keepTooltip) { this.maybeHideTooltip(event); } }).catch(this.console.warn); }, mouseleave: event => { this.onMouseLeave(event); }, // show hover after pressing the modifier key keydown: event => { this.onKeyDown(event, adapter); } }); return EditorExtensionRegistry.createImmutableExtension([ eventListeners, updateListener ]); } }; this.debouncedGetHover = this.createThrottler(); this.settings.changed.connect(() => { this.cache.maxSize = this.settings.composite.cacheSize; this.debouncedGetHover = this.createThrottler(); }); } createThrottler() { return new Throttler(this.getHover, { limit: this.settings.composite.throttlerDelay || 0, edge: 'trailing' }); } get modifierKey() { return this.settings.composite.modifierKey; } get isHoverAutomatic() { return this.settings.composite.autoActivate; } restoreFromCache(document, virtualPosition) { const { line, ch } = virtualPosition; const matchingItems = this.cache.data.filter(cacheItem => { if (cacheItem.document !== document) { return false; } let range = cacheItem.response.range; return ProtocolCoordinates.isWithinRange({ line, character: ch }, range); }); if (matchingItems.length > 1) { this.console.warn('Potential hover cache malfunction: ', virtualPosition, matchingItems); } return matchingItems.length != 0 ? matchingItems[0] : null; } maybeHideTooltip(mouseEvent) { if (typeof this.tooltip !== 'undefined' && !isCloseTo(this.tooltip.node, mouseEvent)) { this.tooltip.dispose(); } } afterChange() { // reset cache on any change in the document this.cache.clean(); this.lastHoverCharacter = null; this.removeRangeHighlight(); } static getMarkupForHover(response) { let contents = response.contents; if (typeof contents === 'string') { contents = [contents]; } if (!Array.isArray(contents)) { return contents; } let markups = contents.map(toMarkup); 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') }; } } isTokenEmpty(token) { return token.value.length === 0; // TODO || token.type.length === 0? (sometimes the underline is shown on meaningless tokens) } isEventInsideVisible(event) { let target = event.target; return target.closest('.cm-scroller') != null; } isResponseUseful(response) { return (response && response.contents && !(Array.isArray(response.contents) && response.contents.length === 0)); } /** * Returns true if the tooltip should stay. */ async _updateUnderlineAndTooltip(event, adapter) { const target = event.target; // if over an empty space in a line (and not over a token) then not worth checking if (target == null // TODO this no longer works in CodeMirror6 as it tires to avoid wrapping // html elements as much as possible. // || (target as HTMLElement).classList.contains('cm-line') ) { this.removeRangeHighlight(); return false; } const showTooltip = this.isHoverAutomatic || getModifierState(event, this.modifierKey); // Filtering is needed to determine in hovered character belongs to this virtual document // TODO: or should the adapter be derived from model and passed as an argument? Or maybe we should try both? // const adapter = this.contextAssembler.adapterFromNode(target as HTMLElement); if (!adapter) { this.removeRangeHighlight(); return false; } // We cannot just use: // > const editorAccessor = adapter.activeEditor // as it relies on the editor under the cursor being the active editor, which is not the case in notebook, // especially for actions invoked using mouse (hover, rename from context menu). const accessorFromNode = this.contextAssembler.editorFromNode(adapter, target); if (!accessorFromNode) { this.console.warn('Editor accessor not found from node, falling back to activeEditor'); } const editorAccessor = accessorFromNode ? accessorFromNode : adapter.activeEditor; if (!editorAccessor) { this.removeRangeHighlight(); this.console.warn('Could not find editor accessor'); return false; } const rootPosition = this.contextAssembler.positionFromCoordinates(event.clientX, event.clientY, adapter, editorAccessor); // happens because some regions of the editor (between lines) have no characters if (rootPosition == null) { this.removeRangeHighlight(); return false; } const editor = editorAccessor.getEditor(); if (!editor) { this.console.warn('Editor not available from accessor'); this.removeRangeHighlight(); return false; } const editorPosition = rootPositionToEditorPosition(adapter, rootPosition); const offset = editor.getOffsetAt(PositionConverter.cm_to_ce(editorPosition)); const token = editor.getTokenAt(offset); const document = documentAtRootPosition(adapter, rootPosition); if (this.isTokenEmpty(token) || //document !== this.virtualDocument || !this.isEventInsideVisible(event)) { this.removeRangeHighlight(); return false; } if (!this.lastHoverCharacter || !isEqual(rootPosition, this.lastHoverCharacter)) { let virtualPosition = rootPositionToVirtualPosition(adapter, rootPosition); this.virtualPosition = virtualPosition; this.lastHoverCharacter = rootPosition; // 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 responseData = this.restoreFromCache(document, virtualPosition); let delayMilliseconds = this.settings.composite.delay; if (responseData == null) { //const ceEditor = // editorAtRootPosition(adapter, rootPosition).getEditor()!; const promise = this.debouncedGetHover.invoke(document, virtualPosition, { adapter, token, editor, editorAccessor }); this._previousHoverRequest = promise; let response = await promise; if (this._previousHoverRequest === promise) { this._previousHoverRequest = null; } if (response && response.range && ProtocolCoordinates.isWithinRange({ line: virtualPosition.line, character: virtualPosition.ch }, response.range) && this.isResponseUseful(response)) { // TODO: I am reconstructing the range anyways - do I really want to ensure it in getHover? const editorRange = this._getEditorRange(adapter, response, token, editor, document); responseData = { response: response, document: document, editorRange: editorRange, ceEditor: editor }; this.cache.store(responseData); delayMilliseconds = Math.max(0, this.settings.composite.delay - this.settings.composite.throttlerDelay); } else { this.removeRangeHighlight(); return false; } } if (this.isHoverAutomatic) { await new Promise(resolve => setTimeout(resolve, delayMilliseconds)); } return this.handleResponse(adapter, responseData, rootPosition, showTooltip); } else { return true; } } remove() { this.cache.clean(); this.removeRangeHighlight(); this.debouncedGetHover.dispose(); } /** * Construct the range to underline manually using the token information. */ _getEditorRange(adapter, response, token, editor, document) { if (typeof response.range !== 'undefined') { return rangeToEditorRange(adapter, response.range, editor, document); } const startInEditor = editor.getPositionAt(token.offset); const endInEditor = editor.getPositionAt(token.offset + token.value.length); if (!startInEditor || !endInEditor) { throw Error('Could not reconstruct editor range: start or end of token in editor do not resolve to a position'); } return { start: PositionConverter.ce_to_cm(startInEditor), end: PositionConverter.ce_to_cm(endInEditor), editor }; } _addRange(adapter, response, editorEange, editorAccessor) { return { ...response, range: { start: PositionConverter.cm_to_lsp(rootPositionToVirtualPosition(adapter, editorPositionToRootPosition(adapter, editorAccessor, editorEange.start))), end: PositionConverter.cm_to_lsp(rootPositionToVirtualPosition(adapter, editorPositionToRootPosition(adapter, editorAccessor, editorEange.end))) } }; } } (function (HoverFeature) { HoverFeature.id = PLUGIN_ID + ':hover'; })(HoverFeature || (HoverFeature = {})); export const HOVER_PLUGIN = { id: HoverFeature.id, requires: [ ILSPFeatureManager, ISettingRegistry, IRenderMimeRegistry, ILSPDocumentConnectionManager ], autoStart: true, activate: async (app, featureManager, settingRegistry, renderMimeRegistry, connectionManager) => { const contextAssembler = new ContextAssembler({ app, connectionManager }); const settings = new FeatureSettings(settingRegistry, HoverFeature.id); await settings.ready; if (settings.composite.disable) { return; } const feature = new HoverFeature({ settings, renderMimeRegistry, connectionManager, contextAssembler }); featureManager.register(feature); } }; //# sourceMappingURL=hover.js.map