diff --git a/src/editor/codemirror/language-server/signatureHelp.ts b/src/editor/codemirror/language-server/signatureHelp.ts index 9903cdf79..b14789ddb 100644 --- a/src/editor/codemirror/language-server/signatureHelp.ts +++ b/src/editor/codemirror/language-server/signatureHelp.ts @@ -7,7 +7,7 @@ * * SPDX-License-Identifier: MIT */ -import { EditorState, StateEffect, StateField } from "@codemirror/state"; +import { Facet, StateEffect, StateField } from "@codemirror/state"; import { Command, EditorView, @@ -22,8 +22,8 @@ import { import { IntlShape } from "react-intl"; import { MarkupContent, + Position, SignatureHelp, - SignatureHelpParams, SignatureHelpRequest, } from "vscode-languageserver-protocol"; import { ApiReferenceMap } from "../../../documentation/mapping/content"; @@ -38,6 +38,10 @@ import { import { nameFromSignature, removeFullyQualifiedName } from "./names"; import { offsetToPosition } from "./positions"; +export const automaticFacet = Facet.define({ + combine: (values) => values[values.length - 1] ?? true, +}); + export const setSignatureHelpRequestPosition = StateEffect.define({}); export const setSignatureHelpResult = StateEffect.define( @@ -49,6 +53,12 @@ class SignatureHelpState { * -1 for no signature help requested. */ pos: number; + + /** + * The LSP position for pos. + */ + position: Position | null; + /** * The latest result we want to display. * @@ -57,11 +67,16 @@ class SignatureHelpState { */ result: SignatureHelp | null; - constructor(pos: number, result: SignatureHelp | null) { + constructor( + pos: number, + position: Position | null, + result: SignatureHelp | null + ) { if (result && pos === -1) { throw new Error("Invalid state"); } this.pos = pos; + this.position = position; this.result = result; } } @@ -77,44 +92,22 @@ const signatureHelpToolTipBaseTheme = EditorView.baseTheme({ }, }); -const triggerSignatureHelpRequest = async ( - view: EditorView, - state: EditorState -): Promise => { - const uri = state.facet(uriFacet)!; - const client = state.facet(clientFacet)!; - const pos = state.selection.main.from; - const params: SignatureHelpParams = { - textDocument: { uri }, - position: offsetToPosition(state.doc, pos), - }; - try { - // Must happen before other event handling that might dispatch more - // changes that invalidate our position. - queueMicrotask(() => { - view.dispatch({ - effects: [setSignatureHelpRequestPosition.of(pos)], - }); - }); - const result = await client.connection.sendRequest( - SignatureHelpRequest.type, - params - ); - view.dispatch({ - effects: [setSignatureHelpResult.of(result)], - }); - } catch (e) { - if (!isErrorDueToDispose(e)) { - logException(state, e, "signature-help"); - } - view.dispatch({ - effects: [setSignatureHelpResult.of(null)], - }); +const positionEq = (a: Position | null, b: Position | null): boolean => { + if (a === null) { + return b === null; + } + if (b === null) { + return a === null; } + return a.character === b.character && a.line === b.line; }; const openSignatureHelp: Command = (view: EditorView) => { - triggerSignatureHelpRequest(view, view.state); + view.dispatch({ + effects: [ + setSignatureHelpRequestPosition.of(view.state.selection.main.from), + ], + }); return true; }; @@ -124,7 +117,7 @@ export const signatureHelp = ( apiReferenceMap: ApiReferenceMap ) => { const signatureHelpTooltipField = StateField.define({ - create: () => new SignatureHelpState(-1, null), + create: () => new SignatureHelpState(-1, null, null), update(state, tr) { let { pos, result } = state; for (const effect of tr.effects) { @@ -138,17 +131,45 @@ export const signatureHelp = ( } } } + // Even if we just got a result, if the position has been cleared we don't want it. if (pos === -1) { result = null; } + // By default map the previous position forward pos = pos === -1 ? -1 : tr.changes.mapPos(pos); - if (state.pos === pos && state.result === result) { + + // Did the selection moved while open? We'll re-request but keep the old result for now. + if (pos !== -1 && tr.selection) { + pos = tr.selection.main.from; + } + + // Automatic triggering cases + const automatic = tr.state.facet(automaticFacet).valueOf(); + if ( + automatic && + ((tr.docChanged && tr.isUserEvent("input")) || + tr.isUserEvent("dnd.drop.call")) + ) { + tr.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { + if (inserted.sliceString(0).trim().endsWith("()")) { + // Triggered + pos = tr.newSelection.main.from; + } + }); + } + + const position = pos === -1 ? null : offsetToPosition(tr.state.doc, pos); + if ( + state.pos === pos && + state.result === result && + positionEq(state.position, position) + ) { // Avoid pointless tooltip updates. If nothing else it makes e2e tests hard. return state; } - return new SignatureHelpState(pos, result); + return new SignatureHelpState(pos, position, result); }, provide: (f) => showTooltip.from(f, (val) => { @@ -191,30 +212,54 @@ export const signatureHelp = ( extends BaseLanguageServerView implements PluginValue { - constructor(view: EditorView, private automatic: boolean) { + private destroyed = false; + private lastPosition: Position | null = null; + + constructor(view: EditorView) { super(view); } update(update: ViewUpdate) { - if ( - (update.docChanged || update.selectionSet) && - this.view.state.field(signatureHelpTooltipField).pos !== -1 - ) { - triggerSignatureHelpRequest(this.view, update.state); - } else if (this.automatic && update.docChanged) { - const last = update.transactions[update.transactions.length - 1]; - - // This needs to trigger for autocomplete adding function parens - // as well as normal user input with `closebrackets` inserting - // the closing bracket. - if (last.isUserEvent("input") || last.isUserEvent("dnd.drop.call")) { - last.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { - if (inserted.sliceString(0).trim().endsWith("()")) { - triggerSignatureHelpRequest(this.view, update.state); + const { view, state } = update; + const uri = state.facet(uriFacet)!; + const client = state.facet(clientFacet)!; + const { position } = update.state.field(signatureHelpTooltipField); + if (!positionEq(this.lastPosition, position)) { + this.lastPosition = position; + if (position !== null) { + (async () => { + try { + const result = await client.connection.sendRequest( + SignatureHelpRequest.type, + { + textDocument: { uri }, + position, + } + ); + if (!this.destroyed) { + view.dispatch({ + effects: [setSignatureHelpResult.of(result)], + }); + } + } catch (e) { + if (!isErrorDueToDispose(e)) { + logException(state, e, "signature-help"); + } + // The sendRequest call can fail synchronously when disposed so we need to ensure our clean-up doesn't happen inside the CM update call. + queueMicrotask(() => { + if (!this.destroyed) { + view.dispatch({ + effects: [setSignatureHelpResult.of(null)], + }); + } + }); } - }); + })(); } } } + destroy(): void { + this.destroyed = true; + } } const formatSignatureHelp = ( @@ -306,10 +351,11 @@ export const signatureHelp = ( return [ // View only handles automatic triggering. - ViewPlugin.define((view) => new SignatureHelpView(view, automatic)), + ViewPlugin.define((view) => new SignatureHelpView(view)), signatureHelpTooltipField, signatureHelpToolTipBaseTheme, keymap.of(signatureHelpKeymap), + automaticFacet.of(automatic), EditorView.domEventHandlers({ blur(event, view) { // Close signature help as it interacts badly with drag and drop if