vscode: redesign inlay hints to be capable of handling multiple editors

This commit is contained in:
Veetaha 2020-02-29 19:28:26 +02:00
parent 0e6d066a29
commit 6441988d84
2 changed files with 272 additions and 105 deletions

View file

@ -1,156 +1,323 @@
import * as lc from "vscode-languageclient";
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as ra from './rust-analyzer-api'; import * as ra from './rust-analyzer-api';
import { Ctx } from './ctx'; import { Ctx } from './ctx';
import { log, sendRequestWithRetry, isRustDocument } from './util'; import { sendRequestWithRetry, assert } from './util';
export function activateInlayHints(ctx: Ctx) { export function activateInlayHints(ctx: Ctx) {
const hintsUpdater = new HintsUpdater(ctx); const hintsUpdater = new HintsUpdater(ctx.client);
vscode.window.onDidChangeVisibleTextEditors( vscode.window.onDidChangeVisibleTextEditors(
async _ => hintsUpdater.refresh(), visibleEditors => hintsUpdater.refreshVisibleRustEditors(
visibleEditors.filter(isRustTextEditor)
),
null, null,
ctx.subscriptions ctx.subscriptions
); );
vscode.workspace.onDidChangeTextDocument( vscode.workspace.onDidChangeTextDocument(
async event => { ({ contentChanges, document }) => {
if (event.contentChanges.length === 0) return; if (contentChanges.length === 0) return;
if (!isRustDocument(event.document)) return; if (!isRustTextDocument(document)) return;
await hintsUpdater.refresh();
hintsUpdater.refreshRustDocument(document);
}, },
null, null,
ctx.subscriptions ctx.subscriptions
); );
vscode.workspace.onDidChangeConfiguration( vscode.workspace.onDidChangeConfiguration(
async _ => hintsUpdater.setEnabled(ctx.config.displayInlayHints), async _ => {
// FIXME: ctx.config may have not been refreshed at this point of time, i.e.
// it's on onDidChangeConfiguration() handler may've not executed yet
// (order of invokation is unspecified)
// To fix this we should expose an event emitter from our `Config` itself.
await hintsUpdater.setEnabled(ctx.config.displayInlayHints);
},
null, null,
ctx.subscriptions ctx.subscriptions
); );
ctx.pushCleanup({ ctx.pushCleanup({
dispose() { dispose() {
hintsUpdater.clear(); hintsUpdater.clearHints();
} }
}); });
// XXX: we don't await this, thus Promise rejections won't be handled, but hintsUpdater.setEnabled(ctx.config.displayInlayHints);
// this should never throw in fact...
void hintsUpdater.setEnabled(ctx.config.displayInlayHints);
} }
const typeHintDecorationType = vscode.window.createTextEditorDecorationType({
const typeHints = {
decorationType: vscode.window.createTextEditorDecorationType({
after: { after: {
color: new vscode.ThemeColor('rust_analyzer.inlayHint'), color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
fontStyle: "normal", fontStyle: "normal",
}, }
}); }),
const parameterHintDecorationType = vscode.window.createTextEditorDecorationType({ toDecoration(hint: ra.InlayHint.TypeHint, conv: lc.Protocol2CodeConverter): vscode.DecorationOptions {
return {
range: conv.asRange(hint.range),
renderOptions: { after: { contentText: `: ${hint.label}` } }
};
}
};
const paramHints = {
decorationType: vscode.window.createTextEditorDecorationType({
before: { before: {
color: new vscode.ThemeColor('rust_analyzer.inlayHint'), color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
fontStyle: "normal", fontStyle: "normal",
}, }
}); }),
toDecoration(hint: ra.InlayHint.ParamHint, conv: lc.Protocol2CodeConverter): vscode.DecorationOptions {
return {
range: conv.asRange(hint.range),
renderOptions: { before: { contentText: `${hint.label}: ` } }
};
}
};
class HintsUpdater { class HintsUpdater {
private pending = new Map<string, vscode.CancellationTokenSource>(); private sourceFiles = new RustSourceFiles();
private ctx: Ctx; private enabled = false;
private enabled: boolean;
constructor(ctx: Ctx) { constructor(readonly client: lc.LanguageClient) { }
this.ctx = ctx;
this.enabled = false;
}
async setEnabled(enabled: boolean): Promise<void> {
log.debug({ enabled, prev: this.enabled });
setEnabled(enabled: boolean) {
if (this.enabled === enabled) return; if (this.enabled === enabled) return;
this.enabled = enabled; this.enabled = enabled;
if (this.enabled) { if (this.enabled) {
return await this.refresh(); this.refreshVisibleRustEditors(vscode.window.visibleTextEditors.filter(isRustTextEditor));
} else { } else {
return this.clear(); this.clearHints();
} }
} }
clear() { clearHints() {
this.ctx.visibleRustEditors.forEach(it => { for (const file of this.sourceFiles) {
this.setTypeDecorations(it, []); file.inlaysRequest?.cancel();
this.setParameterDecorations(it, []); this.renderHints(file, []);
}); }
} }
async refresh() { private renderHints(file: RustSourceFile, hints: ra.InlayHint[]) {
file.renderHints(hints, this.client.protocol2CodeConverter);
}
refreshRustDocument(document: RustTextDocument) {
if (!this.enabled) return; if (!this.enabled) return;
await Promise.all(this.ctx.visibleRustEditors.map(it => this.refreshEditor(it)));
const file = this.sourceFiles.getSourceFile(document.uri.toString());
assert(!!file, "Document must be opened in some text editor!");
void file.fetchAndRenderHints(this.client);
} }
private async refreshEditor(editor: vscode.TextEditor): Promise<void> { refreshVisibleRustEditors(visibleEditors: RustTextEditor[]) {
const newHints = await this.queryHints(editor.document.uri.toString()); if (!this.enabled) return;
if (newHints == null) return;
const newTypeDecorations = newHints const visibleSourceFiles = this.sourceFiles.drainEditors(visibleEditors);
.filter(hint => hint.kind === ra.InlayKind.TypeHint)
.map(hint => ({
range: this.ctx.client.protocol2CodeConverter.asRange(hint.range),
renderOptions: {
after: {
contentText: `: ${hint.label}`,
},
},
}));
this.setTypeDecorations(editor, newTypeDecorations);
const newParameterDecorations = newHints // Cancel requests for source files whose editors were disposed (leftovers after drain).
.filter(hint => hint.kind === ra.InlayKind.ParameterHint) for (const { inlaysRequest } of this.sourceFiles) inlaysRequest?.cancel();
.map(hint => ({
range: this.ctx.client.protocol2CodeConverter.asRange(hint.range), this.sourceFiles = visibleSourceFiles;
renderOptions: {
before: { for (const file of this.sourceFiles) {
contentText: `${hint.label}: `, if (!file.rerenderHints()) {
}, void file.fetchAndRenderHints(this.client);
}, }
})); }
this.setParameterDecorations(editor, newParameterDecorations); }
} }
private setTypeDecorations(
editor: vscode.TextEditor, /**
decorations: vscode.DecorationOptions[], * This class encapsulates a map of file uris to respective inlay hints
) { * request cancellation token source (cts) and an array of editors.
editor.setDecorations( * E.g.
typeHintDecorationType, * ```
this.enabled ? decorations : [], * {
); * file1.rs -> (cts, (typeDecor, paramDecor), [editor1, editor2])
* ^-- there is a cts to cancel the in-flight request
* file2.rs -> (cts, null, [editor3])
* ^-- no decorations are applied to this source file yet
* file3.rs -> (null, (typeDecor, paramDecor), [editor4])
* } ^-- there is no inflight request
* ```
*
* Invariants: each stored source file has at least 1 editor.
*/
class RustSourceFiles {
private files = new Map<string, RustSourceFile>();
/**
* Removes `editors` from `this` source files and puts them into a returned
* source files object. cts and decorations are moved to the returned source files.
*/
drainEditors(editors: RustTextEditor[]): RustSourceFiles {
const result = new RustSourceFiles;
for (const editor of editors) {
const oldFile = this.removeEditor(editor);
const newFile = result.addEditor(editor);
if (oldFile) newFile.stealCacheFrom(oldFile);
} }
private setParameterDecorations( return result;
editor: vscode.TextEditor,
decorations: vscode.DecorationOptions[],
) {
editor.setDecorations(
parameterHintDecorationType,
this.enabled ? decorations : [],
);
} }
private async queryHints(documentUri: string): Promise<ra.InlayHint[] | null> { /**
this.pending.get(documentUri)?.cancel(); * Remove the editor and if it was the only editor for a source file,
* the source file is removed altogether.
*
* @returns A reference to the source file for this editor or
* null if no such source file was not found.
*/
private removeEditor(editor: RustTextEditor): null | RustSourceFile {
const uri = editor.document.uri.toString();
const file = this.files.get(uri);
if (!file) return null;
const editorIndex = file.editors.findIndex(suspect => areEditorsEqual(suspect, editor));
if (editorIndex >= 0) {
file.editors.splice(editorIndex, 1);
if (file.editors.length === 0) this.files.delete(uri);
}
return file;
}
/**
* @returns A reference to an existing source file or newly created one for the editor.
*/
private addEditor(editor: RustTextEditor): RustSourceFile {
const uri = editor.document.uri.toString();
const file = this.files.get(uri);
if (!file) {
const newFile = new RustSourceFile([editor]);
this.files.set(uri, newFile);
return newFile;
}
if (!file.editors.find(suspect => areEditorsEqual(suspect, editor))) {
file.editors.push(editor);
}
return file;
}
getSourceFile(uri: string): undefined | RustSourceFile {
return this.files.get(uri);
}
[Symbol.iterator](): IterableIterator<RustSourceFile> {
return this.files.values();
}
}
class RustSourceFile {
constructor(
/**
* Editors for this source file (one text document may be opened in multiple editors).
* We keep this just an array, because most of the time we have 1 editor for 1 source file.
*/
readonly editors: RustTextEditor[],
/**
* Source of the token to cancel in-flight inlay hints request if any.
*/
public inlaysRequest: null | vscode.CancellationTokenSource = null,
public decorations: null | {
type: vscode.DecorationOptions[];
param: vscode.DecorationOptions[];
} = null
) { }
stealCacheFrom(other: RustSourceFile) {
if (other.inlaysRequest) this.inlaysRequest = other.inlaysRequest;
if (other.decorations) this.decorations = other.decorations;
other.inlaysRequest = null;
other.decorations = null;
}
rerenderHints(): boolean {
if (!this.decorations) return false;
for (const editor of this.editors) {
editor.setDecorations(typeHints.decorationType, this.decorations.type);
editor.setDecorations(paramHints.decorationType, this.decorations.param);
}
return true;
}
renderHints(hints: ra.InlayHint[], conv: lc.Protocol2CodeConverter) {
this.decorations = { type: [], param: [] };
for (const hint of hints) {
switch (hint.kind) {
case ra.InlayHint.Kind.TypeHint: {
this.decorations.type.push(typeHints.toDecoration(hint, conv));
continue;
}
case ra.InlayHint.Kind.ParamHint: {
this.decorations.param.push(paramHints.toDecoration(hint, conv));
continue;
}
}
}
this.rerenderHints();
}
async fetchAndRenderHints(client: lc.LanguageClient): Promise<void> {
this.inlaysRequest?.cancel();
const tokenSource = new vscode.CancellationTokenSource(); const tokenSource = new vscode.CancellationTokenSource();
this.pending.set(documentUri, tokenSource); this.inlaysRequest = tokenSource;
const request = { textDocument: { uri: documentUri } }; const request = { textDocument: { uri: this.editors[0].document.uri.toString() } };
return sendRequestWithRetry(this.ctx.client, ra.inlayHints, request, tokenSource.token) try {
.catch(_ => null) const hints = await sendRequestWithRetry(client, ra.inlayHints, request, tokenSource.token);
.finally(() => { this.renderHints(hints, client.protocol2CodeConverter);
if (!tokenSource.token.isCancellationRequested) { } catch {
this.pending.delete(documentUri); /* ignore */
} } finally {
}); if (this.inlaysRequest === tokenSource) {
this.inlaysRequest = null;
} }
} }
}
}
type RustTextDocument = vscode.TextDocument & { languageId: "rust" };
type RustTextEditor = vscode.TextEditor & { document: RustTextDocument; id: string };
function areEditorsEqual(a: RustTextEditor, b: RustTextEditor): boolean {
return a.id === b.id;
}
function isRustTextEditor(suspect: vscode.TextEditor & { id?: unknown }): suspect is RustTextEditor {
// Dirty hack, we need to access private vscode editor id,
// see https://github.com/microsoft/vscode/issues/91788
assert(
typeof suspect.id === "string",
"Private text editor id is no longer available, please update the workaround!"
);
return isRustTextDocument(suspect.document);
}
function isRustTextDocument(suspect: vscode.TextDocument): suspect is RustTextDocument {
return suspect.languageId === "rust";
}

View file

@ -98,8 +98,8 @@ export namespace InlayHint {
range: lc.Range; range: lc.Range;
label: string; label: string;
} }
export type TypeHint = Common & { kind: Kind.TypeHint; } export type TypeHint = Common & { kind: Kind.TypeHint };
export type ParamHint = Common & { kind: Kind.ParamHint; } export type ParamHint = Common & { kind: Kind.ParamHint };
} }
export interface InlayHintsParams { export interface InlayHintsParams {
textDocument: lc.TextDocumentIdentifier; textDocument: lc.TextDocumentIdentifier;