@jupyter-lsp/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
507 lines • 21.8 kB
JavaScript
import { EditorView } from '@codemirror/view';
import { FileEditorJumper, NotebookJumper } from '@jupyter-lsp/code-jumpers';
import { InputDialog, ICommandPalette, Notification } from '@jupyterlab/apputils';
import { CodeMirrorEditor, EditorExtensionRegistry } from '@jupyterlab/codemirror';
import { URLExt } from '@jupyterlab/coreutils';
import { IDocumentManager } from '@jupyterlab/docmanager';
import { IEditorTracker } from '@jupyterlab/fileeditor';
import { ProtocolCoordinates, ILSPFeatureManager, ILSPDocumentConnectionManager } from '@jupyterlab/lsp';
import { INotebookTracker } from '@jupyterlab/notebook';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
import { LabIcon } from '@jupyterlab/ui-components';
import jumpToSvg from '../../style/icons/jump-to.svg';
import { ContextAssembler } from '../context';
import { PositionConverter, documentAtRootPosition, editorAtRootPosition, rootPositionToVirtualPosition, rootPositionToEditorPosition } from '../converter';
import { FeatureSettings, Feature } from '../feature';
import { PLUGIN_ID } from '../tokens';
import { getModifierState, uriToContentsPath, urisEqual } from '../utils';
import { BrowserConsole } from '../virtual/console';
import { VirtualDocument } from '../virtual/document';
export const jumpToIcon = new LabIcon({
name: 'lsp:jump-to',
svgstr: jumpToSvg
});
const jumpBackIcon = new LabIcon({
name: 'lsp:jump-back',
svgstr: jumpToSvg.replace('jp-icon3', 'lsp-icon-flip-x jp-icon3')
});
export class NavigationFeature extends Feature {
constructor(options) {
super(options);
this.id = NavigationFeature.id;
this.capabilities = {
textDocument: {
declaration: {
dynamicRegistration: true,
linkSupport: true
},
definition: {
dynamicRegistration: true,
linkSupport: true
},
typeDefinition: {
dynamicRegistration: true,
linkSupport: true
},
implementation: {
dynamicRegistration: true,
linkSupport: true
}
}
};
this.console = new BrowserConsole().scope('Navigation');
this.settings = options.settings;
this._trans = options.trans;
this.contextAssembler = options.contextAssembler;
this.extensionFactory = {
name: 'lsp:jump',
factory: factoryOptions => {
const { widgetAdapter: adapter } = factoryOptions;
const clickListener = EditorView.domEventHandlers({
mouseup: event => {
this._jumpOnMouseUp(event, adapter);
}
});
return EditorExtensionRegistry.createImmutableExtension([
clickListener
]);
}
};
this._jumpers = new Map();
const { fileEditorTracker, notebookTracker, documentManager } = options;
if (fileEditorTracker !== null) {
fileEditorTracker.widgetAdded.connect((_, widget) => {
let fileEditor = widget.content;
if (fileEditor.editor instanceof CodeMirrorEditor) {
let jumper = new FileEditorJumper(widget, documentManager);
this._jumpers.set(widget.id, jumper);
}
});
}
notebookTracker.widgetAdded.connect(async (_, widget) => {
let jumper = new NotebookJumper(widget, documentManager);
this._jumpers.set(widget.id, jumper);
});
}
getJumper(adapter) {
let current = adapter.widget.id;
return this._jumpers.get(current);
}
get modifierKey() {
return this.settings.composite.modifierKey;
}
_jumpOnMouseUp(event, adapter) {
// For Alt + click we need to wait for mouse up to enable users to create
// rectangular selections with Alt + drag.
if (this.modifierKey === 'Alt') {
document.body.addEventListener('mouseup', (mouseUpEvent) => {
if (mouseUpEvent.target !== event.target) {
// Cursor moved, possibly block selection was attempted, see:
// https://github.com/jupyter-lsp/jupyterlab-lsp/issues/823
return;
}
return this._jumpToDefinitionOrRefernce(event, adapter);
}, {
once: true
});
}
else {
// For Ctrl + click we need to act on mouse down to prevent
// adding multiple cursors if jump were to occur.
return this._jumpToDefinitionOrRefernce(event, adapter);
}
}
_jumpToDefinitionOrRefernce(event, adapter) {
const { button } = event;
const shouldJump = button === 0 && getModifierState(event, this.modifierKey);
if (!shouldJump) {
return;
}
const accessorFromNode = this.contextAssembler.editorFromNode(adapter, event.target);
if (!accessorFromNode) {
this.console.warn('Editor accessor not found from node, falling back to activeEditor');
}
const editorAccessor = accessorFromNode
? accessorFromNode
: adapter.activeEditor;
const rootPosition = this.contextAssembler.positionFromCoordinates(event.clientX, event.clientY, adapter, editorAccessor);
if (rootPosition == null) {
this.console.warn('Could not retrieve root position from mouse event to jump to definition/reference');
return;
}
const virtualPosition = rootPositionToVirtualPosition(adapter, rootPosition);
const document = documentAtRootPosition(adapter, rootPosition);
const connection = this.connectionManager.connections.get(document.uri);
const positionParams = {
textDocument: {
uri: document.documentInfo.uri
},
position: {
line: virtualPosition.line,
character: virtualPosition.ch
}
};
connection.clientRequests['textDocument/definition']
.request(positionParams)
.then(targets => {
this.handleJump(targets, positionParams, adapter, document)
.then((result) => {
if (result === 1 /* JumpResult.NoTargetsFound */ ||
result === 6 /* JumpResult.AlreadyAtTarget */) {
// definition was not found, or we are in definition already, suggest references
connection.clientRequests['textDocument/references']
.request({
...positionParams,
context: { includeDeclaration: false }
})
.then(targets =>
// TODO: explain that we are now presenting references?
this.handleJump(targets, positionParams, adapter, document))
.catch(this.console.warn);
}
})
.catch(this.console.warn);
})
.catch(this.console.warn);
event.preventDefault();
event.stopPropagation();
}
_harmonizeLocations(locationData) {
if (locationData == null) {
return [];
}
const locationsList = Array.isArray(locationData)
? locationData
: [locationData];
return locationsList
.map((locationOrLink) => {
if ('targetUri' in locationOrLink) {
return {
uri: locationOrLink.targetUri,
range: locationOrLink.targetRange
};
}
else if ('uri' in locationOrLink) {
return {
uri: locationOrLink.uri,
range: locationOrLink.range
};
}
else {
this.console.warn('Returned jump location is incorrect (no uri or targetUri):', locationOrLink);
return undefined;
}
})
.filter((location) => location != null);
}
async _chooseTarget(locations) {
if (locations.length > 1) {
const choices = locations.map(location => {
// TODO: extract the line, the line above and below, and show it
const path = this._resolvePath(location.uri) || location.uri;
return path + ', line: ' + location.range.start.line;
});
// TODO: use selector with preview, basically needs the ui-component
// from jupyterlab-citation-manager; let's try to move it to JupyterLab core
// (and re-implement command palette with it)
// the preview should use this.jumper.document_manager.services.contents
let getItemOptions = {
title: this._trans.__('Choose the jump target'),
okLabel: this._trans.__('Jump'),
items: choices
};
// TODO: use showHints() or completion-like widget instead?
const choice = await InputDialog.getItem(getItemOptions).catch(this.console.warn);
if (!choice || choice.value == null) {
this.console.warn('No choice selected for jump location selection');
return;
}
const choiceIndex = choices.indexOf(choice.value);
if (choiceIndex === -1) {
this.console.error('Choice selection error: please report this as a bug:', choices, choice);
return;
}
return locations[choiceIndex];
}
else {
return locations[0];
}
}
_resolvePath(uri) {
let contentsPath = uriToContentsPath(uri);
if (contentsPath == null) {
if (uri.startsWith('file://')) {
contentsPath = decodeURIComponent(uri.slice(7));
}
else {
contentsPath = decodeURIComponent(uri);
}
}
return contentsPath;
}
async handleJump(locationData, positionParams, adapter, document) {
const locations = this._harmonizeLocations(locationData);
const targetInfo = await this._chooseTarget(locations);
const jumper = this.getJumper(adapter);
if (!targetInfo) {
Notification.info(this._trans.__('No jump targets found'), {
autoClose: 3 * 1000
});
return 1 /* JumpResult.NoTargetsFound */;
}
let { uri, range } = targetInfo;
let virtualPosition = PositionConverter.lsp_to_cm(range.start);
if (urisEqual(uri, positionParams.textDocument.uri)) {
// if in current file, transform from the position within virtual document to the editor position:
// because `openForeign()` does not use new this.constructor, we need to workaround it for now:
// const rootPosition = document.transformVirtualToRoot(virtualPosition);
// https://github.com/jupyterlab/jupyterlab/issues/15126
const rootPosition = VirtualDocument.prototype.transformVirtualToRoot.call(document, virtualPosition);
if (rootPosition === null) {
this.console.warn('Could not jump: conversion from virtual position to editor position failed', virtualPosition);
return 2 /* JumpResult.PositioningFailure */;
}
const editorPosition = rootPositionToEditorPosition(adapter, rootPosition);
const editorAccessor = editorAtRootPosition(adapter, rootPosition);
// TODO: getEditorIndex should work, but does not
// adapter.getEditorIndex(editorAccessor)
await editorAccessor.reveal();
const editor = editorAccessor.getEditor();
const editorIndex = adapter.editors.findIndex(e => e.ceEditor.getEditor() === editor);
if (editorIndex === -1) {
return 2 /* JumpResult.PositioningFailure */;
}
this.console.log(`Jumping to ${editorIndex}th editor of ${uri}`);
this.console.log('Jump target within editor:', editorPosition);
let contentsPath = adapter.widget.context.path;
const didUserChooseThis = locations.length > 1;
// note: we already know that URIs are equal, so just check the position range
if (!didUserChooseThis &&
ProtocolCoordinates.isWithinRange(positionParams.position, range)) {
return 6 /* JumpResult.AlreadyAtTarget */;
}
jumper.globalJump({
line: editorPosition.line,
column: editorPosition.ch,
editorIndex,
isSymlink: false,
contentsPath
});
return 4 /* JumpResult.AssumeSuccess */;
}
else {
// otherwise there is no virtual document and we expect the returned position to be source position:
let sourcePosition = PositionConverter.cm_to_ce(virtualPosition);
this.console.log(`Jumping to external file: ${uri}`);
this.console.log('Jump target (source location):', sourcePosition);
let jumpData = {
editorIndex: 0,
line: sourcePosition.line,
column: sourcePosition.column
};
// assume that we got a relative path to a file within the project
// TODO use is_relative() or something? It would need to be not only compatible
// with different OSes but also with JupyterHub and other platforms.
// can it be resolved vs our guessed server root?
const contentsPath = this._resolvePath(uri);
if (contentsPath === null) {
this.console.warn('contents_path could not be resolved');
return 3 /* JumpResult.PathResolutionFailure */;
}
try {
await jumper.documentManager.services.contents.get(contentsPath, {
content: false
});
jumper.globalJump({
contentsPath,
...jumpData,
isSymlink: false
});
return 4 /* JumpResult.AssumeSuccess */;
}
catch (err) {
this.console.warn(err);
}
// TODO: user debugger source request?
jumper.globalJump({
contentsPath: URLExt.join('.lsp_symlink', contentsPath),
...jumpData,
isSymlink: true
});
return 4 /* JumpResult.AssumeSuccess */;
}
}
}
(function (NavigationFeature) {
NavigationFeature.id = PLUGIN_ID + ':jump_to';
})(NavigationFeature || (NavigationFeature = {}));
export var CommandIDs;
(function (CommandIDs) {
CommandIDs.jumpToDefinition = 'lsp:jump-to-definition';
CommandIDs.jumpToReference = 'lsp:jump-to-reference';
CommandIDs.jumpBack = 'lsp:jump-back';
})(CommandIDs || (CommandIDs = {}));
export const JUMP_PLUGIN = {
id: NavigationFeature.id,
requires: [
ILSPFeatureManager,
ISettingRegistry,
ILSPDocumentConnectionManager,
INotebookTracker,
IDocumentManager
],
optional: [IEditorTracker, ICommandPalette, ITranslator],
autoStart: true,
activate: async (app, featureManager, settingRegistry, connectionManager, notebookTracker, documentManager, fileEditorTracker, palette, translator) => {
const trans = (translator || nullTranslator).load('jupyterlab_lsp');
const contextAssembler = new ContextAssembler({ app, connectionManager });
const settings = new FeatureSettings(settingRegistry, NavigationFeature.id);
await settings.ready;
if (settings.composite.disable) {
return;
}
const feature = new NavigationFeature({
settings,
connectionManager,
notebookTracker,
documentManager,
fileEditorTracker,
contextAssembler,
trans
});
featureManager.register(feature);
app.commands.addCommand(CommandIDs.jumpToDefinition, {
execute: async () => {
const context = contextAssembler.getContext();
if (!context) {
console.warn('Could not get context');
return;
}
const { connection, virtualPosition, document, adapter } = context;
if (!connection) {
Notification.warning(trans.__('Connection not found for jump'), {
autoClose: 4 * 1000
});
return;
}
const positionParams = {
textDocument: {
uri: document.documentInfo.uri
},
position: {
line: virtualPosition.line,
character: virtualPosition.ch
}
};
const targets = await connection.clientRequests['textDocument/definition'].request(positionParams);
await feature.handleJump(targets, positionParams, adapter, document);
},
label: trans.__('Jump to definition'),
icon: jumpToIcon,
isEnabled: () => {
const context = contextAssembler.getContext();
if (!context) {
console.debug('Could not get context');
return false;
}
const { connection } = context;
return connection ? connection.provides('definitionProvider') : false;
}
});
app.commands.addCommand(CommandIDs.jumpToReference, {
execute: async () => {
const context = contextAssembler.getContext();
if (!context) {
console.warn('Could not get context');
return;
}
const { connection, virtualPosition, document, adapter } = context;
if (!connection) {
Notification.warning(trans.__('Connection not found for jump'), {
autoClose: 5 * 1000
});
return;
}
const positionParams = {
textDocument: {
uri: document.documentInfo.uri
},
position: {
line: virtualPosition.line,
character: virtualPosition.ch
}
};
const targets = await connection.clientRequests['textDocument/references'].request({
...positionParams,
context: { includeDeclaration: false }
});
await feature.handleJump(targets, positionParams, adapter, document);
},
label: trans.__('Jump to references'),
icon: jumpToIcon,
isEnabled: () => {
const context = contextAssembler.getContext();
if (!context) {
console.debug('Could not get context');
return false;
}
const { connection } = context;
return connection ? connection.provides('referencesProvider') : false;
}
});
app.commands.addCommand(CommandIDs.jumpBack, {
execute: async () => {
const context = contextAssembler.getContext();
if (!context) {
console.warn('Could not get context');
return;
}
feature.getJumper(context.adapter).globalJumpBack();
},
label: trans.__('Jump back'),
icon: jumpBackIcon,
isEnabled: () => {
const context = contextAssembler.getContext();
if (!context) {
console.debug('Could not get context');
return false;
}
const { connection } = context;
return connection
? connection.provides('definitionProvider') ||
connection.provides('referencesProvider')
: false;
}
});
for (const commandID of [
CommandIDs.jumpToDefinition,
CommandIDs.jumpToReference
]) {
// add to menus
app.contextMenu.addItem({
selector: '.jp-Notebook .jp-CodeCell .jp-Editor',
command: commandID,
rank: 10
});
app.contextMenu.addItem({
selector: '.jp-FileEditor',
command: commandID,
rank: 0
});
}
for (const commandID of [
CommandIDs.jumpToDefinition,
CommandIDs.jumpToReference,
CommandIDs.jumpBack
]) {
if (palette) {
palette.addItem({
command: commandID,
category: trans.__('Language Server Protocol')
});
}
}
}
};
//# sourceMappingURL=jump_to.js.map