@jupyter-lsp/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
494 lines • 21 kB
JavaScript
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