Refactors the inline completion feature.

This commit is contained in:
Henning Dieterichs
2021-05-21 19:40:38 +02:00
parent 9086ecd9f0
commit 231c155d31
11 changed files with 685 additions and 712 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
"vs/editor/browser/view/domLineBreaksComputer.ts",
"vs/editor/browser/view/viewLayer.ts",
"vs/editor/browser/widget/diffEditorWidget.ts",
"vs/editor/contrib/inlineSuggestions/ghostTextWidget.ts",
"vs/editor/contrib/inlineCompletions/ghostTextWidget.ts",
"vs/editor/browser/widget/diffReview.ts",
"vs/editor/standalone/browser/colorizer.ts",
"vs/workbench/api/worker/extHostExtensionService.ts",
+1 -1
View File
@@ -1829,7 +1829,7 @@ export const CompletionProviderRegistry = new LanguageFeatureRegistry<Completion
/**
* @internal
*/
export const InlineSuggestionsProviderRegistry = new LanguageFeatureRegistry<InlineCompletionsProvider>();
export const InlineCompletionsProviderRegistry = new LanguageFeatureRegistry<InlineCompletionsProvider>();
/**
* @internal
@@ -0,0 +1,195 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { KeyCode } from 'vs/base/common/keyCodes';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { EditorAction, EditorCommand, registerEditorAction, registerEditorCommand, registerEditorContribution, ServicesAccessor } from 'vs/editor/browser/editorExtensions';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
import { GhostTextWidget } from 'vs/editor/contrib/inlineCompletions/ghostTextWidget';
import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
import { SuggestWidgetAdapterModel } from 'vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel';
import * as nls from 'vs/nls';
import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
class GhostTextController extends Disposable {
public static readonly inlineCompletionsVisible = new RawContextKey<boolean>('inlineCompletionsVisible ', false, nls.localize('inlineCompletionsVisible', "Whether inline suggestions are visible"));
static ID = 'editor.contrib.ghostTextController';
public static get(editor: ICodeEditor): GhostTextController {
return editor.getContribution<GhostTextController>(GhostTextController.ID);
}
private readonly widget: GhostTextWidget;
private readonly activeController = this._register(new MutableDisposable<ActiveGhostTextController>());
private readonly contextKeys: GhostTextContextKeys;
constructor(
private readonly editor: ICodeEditor,
@IInstantiationService instantiationService: IInstantiationService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super();
this.contextKeys = new GhostTextContextKeys(contextKeyService);
this.widget = this._register(instantiationService.createInstance(GhostTextWidget, this.editor));
this._register(this.editor.onDidChangeModel(() => {
this.updateModelController();
}));
this._register(this.editor.onDidChangeConfiguration((e) => {
if (e.hasChanged(EditorOption.suggest)) {
this.updateModelController();
}
}));
this.updateModelController();
}
private updateModelController(): void {
const suggestOptions = this.editor.getOption(EditorOption.suggest);
this.activeController.value = undefined;
this.activeController.value = this.editor.hasModel() && suggestOptions.showSuggestionPreview
? new ActiveGhostTextController(this.editor, this.widget, this.contextKeys)
: undefined;
}
public trigger(): void {
if (this.activeController.value) {
this.activeController.value.trigger();
}
}
public commit(): void {
if (this.activeController.value) {
this.activeController.value.commit();
}
}
public hide(): void {
if (this.activeController.value) {
this.activeController.value.hide();
}
}
}
class GhostTextContextKeys {
public readonly inlineCompletionVisible = GhostTextController.inlineCompletionsVisible.bindTo(this.contextKeyService);
constructor(private readonly contextKeyService: IContextKeyService) {
}
}
/**
* The controller for a text editor with an initialized text model.
*/
export class ActiveGhostTextController extends Disposable {
private readonly suggestWidgetAdapterModel = new SuggestWidgetAdapterModel(this.editor);
private readonly inlineCompletionsModel = new InlineCompletionsModel(this.editor);
constructor(
private readonly editor: IActiveCodeEditor,
private readonly widget: GhostTextWidget,
private readonly contextKeys: GhostTextContextKeys,
) {
super();
this._register(this.suggestWidgetAdapterModel.onDidChange(() => {
this.updateModel();
}));
this.updateModel();
this._register(toDisposable(() => {
if (widget.model === this.suggestWidgetAdapterModel || widget.model === this.inlineCompletionsModel) {
widget.setModel(undefined);
}
}));
this._register(this.inlineCompletionsModel.onDidChange(() => {
this.updateContextKeys();
}));
}
private updateContextKeys(): void {
this.contextKeys.inlineCompletionVisible.set(
this.widget.model === this.inlineCompletionsModel
&& this.inlineCompletionsModel.ghostText !== undefined
);
}
public trigger(): void {
if (this.widget.model === this.inlineCompletionsModel) {
this.inlineCompletionsModel.startSession();
}
}
public commit(): void {
if (this.widget.model === this.inlineCompletionsModel) {
this.inlineCompletionsModel.commitCurrentSuggestion();
}
}
public hide(): void {
if (this.widget.model === this.inlineCompletionsModel) {
this.inlineCompletionsModel.hide();
}
}
private updateModel() {
this.widget.setModel(this.suggestWidgetAdapterModel.isActive ? this.suggestWidgetAdapterModel : this.inlineCompletionsModel);
this.inlineCompletionsModel.setActive(this.widget.model === this.inlineCompletionsModel);
}
}
const GhostTextCommand = EditorCommand.bindToContribution<GhostTextController>(GhostTextController.get);
registerEditorCommand(new GhostTextCommand({
id: 'commitInlineCompletion',
precondition: GhostTextController.inlineCompletionsVisible,
kbOpts: {
weight: 100,
primary: KeyCode.Tab,
},
handler(x) {
x.commit();
}
}));
registerEditorCommand(new GhostTextCommand({
id: 'hideInlineCompletion',
precondition: GhostTextController.inlineCompletionsVisible,
kbOpts: {
weight: 100,
primary: KeyCode.Escape,
},
handler(x) {
x.hide();
}
}));
export class TriggerInlineCompletionsAction extends EditorAction {
constructor() {
super({
id: 'editor.action.triggerInlineCompletions',
label: nls.localize('triggerInlineCompletionsAction', "Trigger Inline Completions"),
alias: 'Trigger Inline Completions',
precondition: EditorContextKeys.writable
});
}
public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise<void> {
const controller = GhostTextController.get(editor);
if (controller) {
controller.trigger();
}
}
}
registerEditorContribution(GhostTextController.ID, GhostTextController);
registerEditorAction(TriggerInlineCompletionsAction);
@@ -16,53 +16,48 @@ import { createStringBuilder } from 'vs/editor/common/core/stringBuilder';
import { Configuration } from 'vs/editor/browser/config/configuration';
import { LineTokens } from 'vs/editor/common/core/lineTokens';
import { Position } from 'vs/editor/common/core/position';
import { Event, Emitter } from 'vs/base/common/event';
import { Emitter, Event } from 'vs/base/common/event';
import { IModelDeltaDecoration } from 'vs/editor/common/model';
import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
import { editorSuggestPreviewBorder, editorSuggestPreviewOpacity } from 'vs/editor/common/view/editorColorRegistry';
const ttPolicy = window.trustedTypes?.createPolicy('editorGhostText', { createHTML: value => value });
export interface GhostTextWidgetModel {
readonly onDidChange: Event<void>;
readonly ghostText: GhostText | undefined;
expand(): void;
readonly expanded: boolean;
readonly minReservedLineCount: number;
}
export interface GhostText {
lines: string[];
position: Position;
minAdditionalLineCount: number;
multiline: boolean;
expandCallback: () => void;
readonly lines: string[];
readonly position: Position;
}
// TODO: use connors interface, maybe move to common?
export interface IObservableValue<T> {
onDidChange: Event<T>;
readonly value: T;
}
export abstract class BaseGhostTextWidgetModel extends Disposable implements GhostTextWidgetModel {
public abstract readonly ghostText: GhostText | undefined;
export class ObservableValue<T> implements IObservableValue<T> {
private _value: T;
private readonly onDidChangeEmitter = new Emitter<T>();
private _expanded = false;
protected readonly onDidChangeEmitter = new Emitter<void>();
public readonly onDidChange = this.onDidChangeEmitter.event;
constructor(value: T) {
this._value = value;
public abstract readonly minReservedLineCount: number;
public get expanded() {
return this._expanded;
}
get value() { return this._value; }
public setValue(value: T): void {
this._value = value;
this.onDidChangeEmitter.fire(this._value);
public expand(): void {
this._expanded = true;
this.onDidChangeEmitter.fire();
}
}
export type GhostTextWidgetModel = IObservableValue<GhostText | undefined>;
function createDisposableRef<T>(object: T, disposable: IDisposable): IReference<T> {
return {
object,
dispose: () => disposable.dispose(),
};
}
export class GhostTextWidget extends Disposable {
private static instanceCount = 0;
@@ -112,23 +107,25 @@ export class GhostTextWidget extends Disposable {
}
private getRenderData() {
if (!this.editor.hasModel() || !this.model?.value) {
if (!this.editor.hasModel() || !this.model?.ghostText) {
return undefined;
}
let { position, lines, minAdditionalLineCount, multiline, expandCallback } = this.model?.value;
const { minReservedLineCount, expanded } = this.model;
let { position, lines } = this.model.ghostText;
const textModel = this.editor.getModel();
const maxColumn = textModel.getLineMaxColumn(position.lineNumber);
const { tabSize } = textModel.getOptions();
if (position.column !== maxColumn) {
// TODO enable single line decorations that are not at the end of the line
if (/*lines.length > 1 &&*/ position.column !== maxColumn) {
console.warn('Can only show multiline ghost text at the end of a line');
lines = [];
position = new Position(position.lineNumber, maxColumn);
}
return { tabSize, position, lines, minAdditionalLineCount, multiline, expandCallback };
return { tabSize, position, lines, minReservedLineCount, expanded };
}
private render(): void {
@@ -177,9 +174,9 @@ export class GhostTextWidget extends Disposable {
if (renderData) {
const remainingLines = renderData.lines.slice(1);
const heightInLines = Math.max(remainingLines.length, renderData.minAdditionalLineCount);
const heightInLines = Math.max(remainingLines.length, renderData.minReservedLineCount);
if (heightInLines > 0) {
if (renderData.multiline) {
if (renderData.expanded) {
const domNode = document.createElement('div');
this.renderLines(domNode, renderData.tabSize, remainingLines);
@@ -190,14 +187,14 @@ export class GhostTextWidget extends Disposable {
domNode,
});
} else if (remainingLines.length > 0) {
this.viewMoreContentWidget = this.renderViewMoreLines(renderData.position, renderData.lines[0], remainingLines.length, renderData.expandCallback);
this.viewMoreContentWidget = this.renderViewMoreLines(renderData.position, renderData.lines[0], remainingLines.length);
}
}
}
});
}
private renderViewMoreLines(position: Position, firstLineText: string, remainingLinesLength: number, expandCallback: () => void): ViewMoreLinesContentWidget {
private renderViewMoreLines(position: Position, firstLineText: string, remainingLinesLength: number): ViewMoreLinesContentWidget {
const fontInfo = this.editor.getOption(EditorOption.fontInfo);
const domNode = document.createElement('div');
domNode.className = 'suggest-preview-additional-widget';
@@ -220,7 +217,7 @@ export class GhostTextWidget extends Disposable {
button.append(`+${remainingLinesLength} lines…`);
disposableStore.add(dom.addStandardDisposableListener(button, 'click', (e) => {
expandCallback();
this.model?.expand();
e.preventDefault();
this.editor.focus();
}));
@@ -331,3 +328,10 @@ registerThemingParticipant((theme, collector) => {
collector.addRule(`.monaco-editor .suggest-preview-text { border-bottom: 2px dashed ${suggestPreviewBorder}; }`);
}
});
function createDisposableRef<T>(object: T, disposable: IDisposable): IReference<T> {
return {
object,
dispose: () => disposable.dispose(),
};
}
@@ -0,0 +1,300 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancelablePromise, createCancelablePromise, RunOnceScheduler } from 'vs/base/common/async';
import { CancellationToken } from 'vs/base/common/cancellation';
import { onUnexpectedError } from 'vs/base/common/errors';
import { Emitter } from 'vs/base/common/event';
import { Disposable, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import * as strings from 'vs/base/common/strings';
import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { ITextModel } from 'vs/editor/common/model';
import { InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProviderRegistry, InlineCompletionTriggerKind } from 'vs/editor/common/modes';
import { BaseGhostTextWidgetModel, GhostText, GhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/ghostTextWidget';
import { EditOperation } from 'vs/editor/common/core/editOperation';
export class InlineCompletionsModel extends Disposable implements GhostTextWidgetModel {
protected readonly onDidChangeEmitter = new Emitter<void>();
public readonly onDidChange = this.onDidChangeEmitter.event;
private readonly completionSession = this._register(new MutableDisposable<InlineCompletionsSession>());
private readonly textModel = this.editor.getModel();
private active: boolean = false;
constructor(private readonly editor: IActiveCodeEditor) {
super();
this._register(this.editor.onDidChangeModelContent((e) => {
if (this.session && !this.session.isValid) {
this.hide();
}
setTimeout(() => {
this.startSessionIfTriggered();
}, 0);
}));
this._register(this.editor.onDidChangeCursorPosition((e) => {
if (this.session && !this.session.isValid) {
this.hide();
}
}));
}
private get session(): InlineCompletionsSession | undefined {
return this.completionSession.value;
}
public get ghostText(): GhostText | undefined {
return this.session?.ghostText;
}
public get minReservedLineCount(): number {
return this.session ? this.session.minReservedLineCount : 0;
}
public get expanded(): boolean {
return this.session ? this.session.expanded : false;
}
public expand(): void {
this.session?.expand();
}
public setActive(active: boolean) {
this.active = active;
if (active) {
this.session?.scheduleUpdate();
}
}
private startSessionIfTriggered(): void {
if (this.session && this.session.isValid) {
return;
}
this.startSession();
}
public startSession(): void {
if (this.completionSession.value) {
return;
}
this.completionSession.value = new InlineCompletionsSession(this.editor, this.editor.getPosition(), () => this.active);
this.completionSession.value.takeOwnership(
this.completionSession.value.onDidChange(() => {
this.onDidChangeEmitter.fire();
})
);
}
public hide(): void {
this.completionSession.clear();
this.onDidChangeEmitter.fire();
}
public commitCurrentSuggestion(): void {
if (this.session) {
this.session.commitCurrentCompletion();
}
}
}
interface CachedInlineCompletion {
inlineCompletion: NormalizedInlineCompletion;
decorationId: string;
lastRange: Range;
}
class InlineCompletionsSession extends BaseGhostTextWidgetModel {
public readonly minReservedLineCount = 0;
private updatePromise: CancelablePromise<NormalizedInlineCompletions> | undefined = undefined;
private cachedCompletions: CachedInlineCompletion[] | undefined = undefined;
private updateSoon = this._register(new RunOnceScheduler(() => this.update(), 50));
private readonly textModel = this.editor.getModel();
constructor(private readonly editor: IActiveCodeEditor, private readonly triggerPosition: Position, private readonly shouldUpdate: () => boolean) {
super();
this._register(toDisposable(() => {
this.clearGhostTextPromise();
}));
this._register(this.editor.onDidChangeModelDecorations(e => {
if (!this.cachedCompletions) {
return;
}
let hasChanged = false;
for (const c of this.cachedCompletions) {
const newRange = this.textModel.getDecorationRange(c.decorationId);
if (!newRange) {
onUnexpectedError(new Error('Decoration has no range'));
continue;
}
if (!c.lastRange.equalsRange(newRange)) {
hasChanged = true;
c.lastRange = newRange;
}
}
if (hasChanged) {
this.onDidChangeEmitter.fire();
}
}));
this._register(this.editor.onDidChangeModelContent((e) => {
this.updateSoon.schedule();
}));
this.updateSoon.schedule();
}
public get ghostText(): GhostText | undefined {
const currentCompletion = this.currentCompletion;
return currentCompletion ? inlineCompletionToGhostText(currentCompletion, this.editor.getModel()) : undefined;
}
get currentCompletion(): NormalizedInlineCompletion | undefined {
if (!this.cachedCompletions) {
return undefined;
}
const completion = this.cachedCompletions[0];
if (!completion) {
return undefined;
}
return {
text: completion.inlineCompletion.text,
range: completion.lastRange
};
}
get isValid(): boolean {
return this.editor.getPosition().lineNumber === this.triggerPosition.lineNumber;
}
public scheduleUpdate(): void {
this.updateSoon.schedule();
}
private update(): void {
if (!this.shouldUpdate()) {
return;
}
const position = this.editor.getPosition();
this.clearGhostTextPromise();
this.updatePromise = createCancelablePromise(token =>
provideInlineCompletions(position,
this.editor.getModel(),
{ triggerKind: InlineCompletionTriggerKind.Automatic },
token
)
);
this.updatePromise.then((result) => {
this.cachedCompletions = [];
const decorationIds = this.editor.deltaDecorations(
(this.cachedCompletions || []).map(c => c.decorationId),
(result.items).map(i => ({
range: i.range,
options: {},
}))
);
this.cachedCompletions = result.items.map((item, idx) => ({
decorationId: decorationIds[idx],
inlineCompletion: item,
lastRange: item.range
}));
this.onDidChangeEmitter.fire();
}, onUnexpectedError);
}
private clearGhostTextPromise(): void {
if (this.updatePromise) {
this.updatePromise.cancel();
this.updatePromise = undefined;
}
}
public takeOwnership(disposable: IDisposable): void {
this._register(disposable);
}
public commitCurrentCompletion(): void {
const completion = this.currentCompletion;
if (completion) {
this.commit(completion);
}
}
public commit(completion: NormalizedInlineCompletion): void {
this.editor.executeEdits(
'inlineCompletions.accept',
[
EditOperation.replaceMove(completion.range, completion.text)
]
);
}
}
export interface NormalizedInlineCompletion extends InlineCompletion {
range: Range;
}
export interface NormalizedInlineCompletions extends InlineCompletions<NormalizedInlineCompletion> {
}
export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCompletion, textModel: ITextModel): GhostText | undefined {
const valueToBeReplaced = textModel.getValueInRange(inlineCompletion.range);
if (!inlineCompletion.text.startsWith(valueToBeReplaced)) {
return undefined;
}
const lines = strings.splitLines(inlineCompletion.text.substr(valueToBeReplaced.length));
return {
lines,
position: inlineCompletion.range.getEndPosition()
};
}
function getDefaultRange(position: Position, model: ITextModel): Range {
const word = model.getWordAtPosition(position);
const maxColumn = model.getLineMaxColumn(position.lineNumber);
// By default, always replace up until the end of the current line.
// This default might be subject to change!
return word
? new Range(position.lineNumber, word.startColumn, position.lineNumber, maxColumn)
: Range.fromPositions(position, position.with(undefined, maxColumn));
}
async function provideInlineCompletions(
position: Position,
model: ITextModel,
context: InlineCompletionContext,
token: CancellationToken = CancellationToken.None
): Promise<NormalizedInlineCompletions> {
const defaultReplaceRange = getDefaultRange(position, model);
const providers = InlineCompletionsProviderRegistry.all(model);
const results = await Promise.all(
providers.map(provider => provider.provideInlineCompletions(model, position, context, token))
);
const items = new Array<NormalizedInlineCompletion>();
for (const result of results) {
if (result) {
items.push(...result.items.map<NormalizedInlineCompletion>(item => ({
text: item.text,
range: item.range ? Range.lift(item.range) : defaultReplaceRange
})));
}
}
return { items };
}
@@ -0,0 +1,142 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event } from 'vs/base/common/event';
import { toDisposable } from 'vs/base/common/lifecycle';
import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { CompletionItemInsertTextRule } from 'vs/editor/common/modes';
import { BaseGhostTextWidgetModel, GhostText } from 'vs/editor/contrib/inlineCompletions/ghostTextWidget';
import { inlineCompletionToGhostText, NormalizedInlineCompletion } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
import { SnippetParser } from 'vs/editor/contrib/snippet/snippetParser';
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
import { ISelectedSuggestion } from 'vs/editor/contrib/suggest/suggestWidget';
export class SuggestWidgetAdapterModel extends BaseGhostTextWidgetModel {
private isSuggestWidgetVisible: boolean = false;
private currentGhostText: GhostText | undefined = undefined;
public override minReservedLineCount: number = 0;
public get isActive() { return this.isSuggestWidgetVisible; }
constructor(
private readonly editor: IActiveCodeEditor
) {
super();
const suggestController = SuggestController.get(this.editor);
if (suggestController) {
let isBoundToSuggestWidget = false;
const bindToSuggestWidget = () => {
if (isBoundToSuggestWidget) {
return;
}
isBoundToSuggestWidget = true;
this._register(suggestController.widget.value.onDidShow(() => {
this.isSuggestWidgetVisible = true;
this.updateFromSuggestion();
}));
this._register(suggestController.widget.value.onDidHide(() => {
this.isSuggestWidgetVisible = false;
this.minReservedLineCount = 0;
this.updateFromSuggestion();
}));
this._register(suggestController.widget.value.onDidFocus(() => {
this.isSuggestWidgetVisible = true;
this.updateFromSuggestion();
}));
};
this._register(Event.once(suggestController.model.onDidTrigger)(e => {
bindToSuggestWidget();
}));
}
this.updateFromSuggestion();
this._register(toDisposable(() => {
const suggestController = SuggestController.get(this.editor);
if (suggestController) {
suggestController.stopForceRenderingAbove();
}
}));
}
private updateFromSuggestion(): void {
const suggestController = SuggestController.get(this.editor);
if (!suggestController) {
this.setCurrentInlineCompletion(undefined);
return;
}
if (!this.isSuggestWidgetVisible) {
this.setCurrentInlineCompletion(undefined);
return;
}
const focusedItem = suggestController.widget.value.getFocusedItem();
if (!focusedItem) {
this.setCurrentInlineCompletion(undefined);
return;
}
// TODO: item.isResolved
this.setCurrentInlineCompletion(getInlineCompletion(suggestController, this.editor.getPosition(), focusedItem));
}
private setCurrentInlineCompletion(completion: NormalizedInlineCompletion | undefined): void {
this.currentGhostText = completion
? (
inlineCompletionToGhostText(completion, this.editor.getModel()) ||
// Show an invisible ghost text to reserve space
{
lines: [],
position: completion.range.getEndPosition(),
}
) : undefined;
if (this.currentGhostText && this.expanded) {
this.minReservedLineCount = Math.max(this.minReservedLineCount, this.currentGhostText.lines.length - 1);
}
const suggestController = SuggestController.get(this.editor);
if (suggestController) {
if (this.minReservedLineCount >= 1) {
suggestController.forceRenderingAbove();
} else {
suggestController.stopForceRenderingAbove();
}
}
this.onDidChangeEmitter.fire();
}
public override get ghostText(): GhostText | undefined {
return this.currentGhostText;
}
}
function getInlineCompletion(suggestController: SuggestController, position: Position, suggestion: ISelectedSuggestion): NormalizedInlineCompletion {
const item = suggestion.item;
if (Array.isArray(item.completion.additionalTextEdits)) {
// cannot represent additional text edits
return {
text: '',
range: Range.fromPositions(position, position),
};
}
let { insertText } = item.completion;
if (item.completion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet) {
insertText = new SnippetParser().text(insertText);
}
const info = suggestController.getOverwriteInfo(item, false);
return {
text: insertText,
range: Range.fromPositions(position.delta(0, -info.overwriteBefore), position.delta(0, info.overwriteAfter)),
};
}
@@ -1,668 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as nls from 'vs/nls';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { EditorAction, EditorCommand, registerEditorAction, registerEditorCommand, registerEditorContribution, ServicesAccessor } from 'vs/editor/browser/editorExtensions';
import { ITextModel } from 'vs/editor/common/model';
import { CompletionItemInsertTextRule, InlineCompletionContext, InlineCompletionTriggerKind, InlineCompletion as InlineCompletion, InlineCompletions as InlineCompletions, InlineSuggestionsProviderRegistry as InlineCompletionsProviderRegistry } from 'vs/editor/common/modes';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { CancelablePromise, createCancelablePromise, RunOnceScheduler } from 'vs/base/common/async';
import * as errors from 'vs/base/common/errors';
import { GhostText, GhostTextWidget, ObservableValue } from 'vs/editor/contrib/inlineSuggestions/ghostTextWidget';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
import { Emitter, Event } from 'vs/base/common/event';
import * as strings from 'vs/base/common/strings';
import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { KeyCode } from 'vs/base/common/keyCodes';
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
import { EditOperation } from 'vs/editor/common/core/editOperation';
import { ISelectedSuggestion } from 'vs/editor/contrib/suggest/suggestWidget';
import { SnippetParser } from 'vs/editor/contrib/snippet/snippetParser';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
class InlineCompletionsController extends Disposable {
public static readonly inlineCompletionVisible = new RawContextKey<boolean>('inlineSuggestionVisible ', false, nls.localize('inlineSuggestionVisible ', "TODO"));
static ID = 'editor.contrib.inlineSuggestionsController';
public static get(editor: ICodeEditor): InlineCompletionsController {
return editor.getContribution<InlineCompletionsController>(InlineCompletionsController.ID);
}
private readonly widget: GhostTextWidget;
private readonly modelController = this._register(new MutableDisposable<InlineSuggestionsModelController>());
private readonly contextKeys: InlineSuggestionsContextKeys;
constructor(
private readonly editor: ICodeEditor,
@IInstantiationService instantiationService: IInstantiationService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super();
this.contextKeys = new InlineSuggestionsContextKeys(contextKeyService);
this.widget = this._register(instantiationService.createInstance(GhostTextWidget, this.editor));
this._register(this.editor.onDidChangeModel(() => {
this.updateModelController();
}));
this._register(this.editor.onDidChangeConfiguration((e) => {
if (e.hasChanged(EditorOption.suggest)) {
this.updateModelController();
}
}));
this.updateModelController();
}
private updateModelController(): void {
const suggestOptions = this.editor.getOption(EditorOption.suggest);
this.modelController.value = this.editor.hasModel() && suggestOptions.showSuggestionPreview
? new InlineSuggestionsModelController(this.editor, this.widget, this.contextKeys)
: undefined;
}
public trigger(): void {
if (this.modelController.value) {
this.modelController.value.trigger();
}
}
public commit(): void {
if (this.modelController.value) {
this.modelController.value.commit();
}
}
public hide(): void {
if (this.modelController.value) {
this.modelController.value.hide();
}
}
}
class InlineSuggestionsContextKeys {
public readonly inlineSuggestionVisible = InlineCompletionsController.inlineCompletionVisible.bindTo(this.contextKeyService);
constructor(private readonly contextKeyService: IContextKeyService) {
}
}
/**
* The model controller for a text editor with a specific text model.
*/
export class InlineSuggestionsModelController extends Disposable {
private readonly textModel = this.editor.getModel();
private readonly suggestWidgetModel = this._register(new SuggestWidgetInlineSuggestionsModel(this.editor));
private readonly completionSession = this._register(new MutableDisposable<InlineSuggestionsSession>());
private readonly checkAndStartSessionSoon = this._register(new RunOnceScheduler(() => this.checkAndStartSession(), 50));
constructor(
private readonly editor: IActiveCodeEditor,
private readonly widget: GhostTextWidget,
private readonly contextKeys: InlineSuggestionsContextKeys,
) {
super();
this._register(this.editor.onDidChangeModelContent((e) => {
if (this.completionSession.value && !this.completionSession.value.isValid()) {
this.completionSession.clear();
}
this.checkAndStartSessionSoon.schedule();
}));
const suggestController = SuggestController.get(this.editor);
if (suggestController) {
let isBoundToSuggestWidget = false;
const bindToSuggestWidget = () => {
if (isBoundToSuggestWidget) {
return;
}
isBoundToSuggestWidget = true;
this._register(suggestController.widget.value.onDidShow(() => {
this.checkAndStartSession();
}));
};
this._register(Event.once(suggestController.model.onDidTrigger)(e => {
bindToSuggestWidget();
}));
}
this._register(this.editor.onDidChangeCursorPosition((e) => {
if (this.completionSession.value && !this.completionSession.value.isValid()) {
this.completionSession.clear();
}
}));
}
private checkAndStartSession(): void {
if (this.completionSession.value && this.completionSession.value.isValid()) {
return;
}
const pos = this.editor.getPosition();
if (pos.column === this.textModel.getLineMaxColumn(pos.lineNumber)) {
this.triggerAt(pos);
}
}
private triggerAt(position: Position): void {
this.completionSession.clear();
this.completionSession.value = new InlineSuggestionsSession(this.editor, this.widget, this.contextKeys, this.suggestWidgetModel, position);
}
public trigger(): void {
if (!this.completionSession.value) {
this.triggerAt(this.editor.getPosition());
}
}
public commit(): void {
if (this.completionSession.value) {
this.completionSession.value.commitCurrentSuggestion();
}
}
public hide(): void {
this.completionSession.clear();
}
}
class InlineSuggestionsSession extends Disposable {
private readonly textModel = this.editor.getModel();
private readonly ghostTextModel = new ObservableValue<GhostText | undefined>(undefined);
private readonly model = this._register(new DelegatingInlineSuggestionsModel(this.editor, this.suggestWidgetModel));
private maxLineCount: number = 0;
private multiline: boolean = false;
constructor(
private readonly editor: IActiveCodeEditor,
private readonly widget: GhostTextWidget,
private readonly contextKeys: InlineSuggestionsContextKeys,
private readonly suggestWidgetModel: SuggestWidgetInlineSuggestionsModel,
private readonly triggerPosition: Position,
) {
super();
this._register(this.editor.onDidChangeModelContent((e) => {
this.update();
}));
this._register(this.editor.onDidChangeCursorPosition((e) => {
this.update();
}));
this._register(this.model.onDidChange(() => {
this.update();
}));
// console.log(`CREATING SESSION`);
this._register(toDisposable(() => {
// console.log(`DISPOSING SESSION`);
if (this.widget.model === this.ghostTextModel) {
this.widget.setModel(undefined);
}
}));
this._register(toDisposable(() => {
const suggestController = SuggestController.get(this.editor);
if (suggestController) {
suggestController.stopForceRenderingAbove();
}
}));
this.update();
this.widget.setModel(this.ghostTextModel);
}
get currentSuggestion(): ValidatedInlineCompletion | undefined {
const cursorPos = this.editor.getPosition();
const suggestions = this.model.getInlineSuggestions(cursorPos);
const validatedSuggestions = suggestions.items
.map(s => validateSuggestion(s, this.textModel))
.filter(s => s !== undefined);
const first = validatedSuggestions[0];
return first;
}
isValid(): boolean {
const pos = this.editor.getPosition();
if (this.currentSuggestion) {
return this.currentSuggestion.suggestion.range.containsPosition(pos);
}
return this.triggerPosition.lineNumber === pos.lineNumber; // the cursor is still on this line
}
private update() {
const suggestion = this.currentSuggestion;
// const cursorPos = this.editor.getPosition();
this.contextKeys.inlineSuggestionVisible.set(!!suggestion);
if (suggestion) {
const text = suggestion.suggestion.text.substr(suggestion.committedSuggestionLength);
const lines = strings.splitLines(text);
this.maxLineCount = Math.max(this.maxLineCount, lines.length);
this.ghostTextModel.setValue({
position: suggestion.suggestion.range.getStartPosition().delta(0, suggestion.committedSuggestionLength),
lines,
minAdditionalLineCount: this.maxLineCount - 1,
multiline: this.multiline,
expandCallback: () => this._onDidExpand()
});
if (this.maxLineCount > 1 && this.multiline) {
const suggestController = SuggestController.get(this.editor);
if (suggestController) {
suggestController.forceRenderingAbove();
}
}
} else {
if (this.maxLineCount > 1) {
const maxColumn = this.textModel.getLineMaxColumn(this.triggerPosition.lineNumber);
this.ghostTextModel.setValue({
position: new Position(this.triggerPosition.lineNumber, maxColumn),
lines: [],
minAdditionalLineCount: this.maxLineCount - 1,
multiline: this.multiline,
expandCallback: () => this._onDidExpand()
});
} else {
this.ghostTextModel.setValue(undefined);
}
}
}
private _onDidExpand(): void {
this.multiline = true;
this.update();
}
public commitCurrentSuggestion(): void {
const s = this.currentSuggestion;
if (s) {
this.commit(s);
}
}
public commit(suggestion: ValidatedInlineCompletion): void {
this.editor.executeEdits(
'inlineSuggestions.accept',
[
EditOperation.replaceMove(suggestion.suggestion.range, suggestion.suggestion.text)
]
);
}
}
interface ValidatedInlineCompletion {
suggestion: NormalizedInlineCompletion;
lineNumber: number;
/**
* Indicates the length of the prefix of the suggestion that agrees with the text buffer.
*/
committedSuggestionLength: number;
}
class DelegatingInlineSuggestionsModel extends Disposable {
private readonly onDidChangeEventEmitter = this._register(new Emitter<void>());
public readonly onDidChange = this.onDidChangeEventEmitter.event;
private readonly directModel = this._register(new InlineSuggestionsModel(this.editor));
private currentModel: SuggestWidgetInlineSuggestionsModel | InlineSuggestionsModel;
constructor(
private readonly editor: IActiveCodeEditor,
private readonly suggestWidgetModel: SuggestWidgetInlineSuggestionsModel
) {
super();
this.directModel.activate();
this.currentModel = this.directModel;
this._register(this.suggestWidgetModel.onDidChange(() => {
if (this.suggestWidgetModel.hasFocusedItem) {
if (this.currentModel !== this.suggestWidgetModel) {
this.directModel.deactivate();
this.currentModel = this.suggestWidgetModel;
}
} else {
if (this.currentModel !== this.directModel) {
this.directModel.activate();
this.currentModel = this.directModel;
}
}
this.onDidChangeEventEmitter.fire();
}));
this._register(this.directModel.onDidChange(() => {
this.onDidChangeEventEmitter.fire();
}));
}
getInlineSuggestions(position: Position): NormalizedInlineCompletions {
return this.currentModel.getInlineSuggestions(position);
}
}
class SuggestWidgetInlineSuggestion {
constructor(
public lineNumber: number,
public overwriteBefore: number,
public overwriteAfter: number,
public text: string
) { }
}
class SuggestWidgetInlineSuggestionsModel extends Disposable {
private readonly onDidChangeEventEmitter = this._register(new Emitter<void>());
public readonly onDidChange = this.onDidChangeEventEmitter.event;
private isSuggestWidgetVisible: boolean = false;
private _hasFocusedItem: boolean = false;
private currentSuggestion: SuggestWidgetInlineSuggestion | null = null;
get hasFocusedItem() { return this._hasFocusedItem; }
constructor(
private readonly editor: IActiveCodeEditor
) {
super();
const suggestController = SuggestController.get(this.editor);
if (suggestController) {
let isBoundToSuggestWidget = false;
const bindToSuggestWidget = () => {
if (isBoundToSuggestWidget) {
return;
}
isBoundToSuggestWidget = true;
this._register(suggestController.widget.value.onDidShow(() => {
this.isSuggestWidgetVisible = true;
this.updateFromSuggestion();
}));
this._register(suggestController.widget.value.onDidHide(() => {
this.isSuggestWidgetVisible = false;
this.updateFromSuggestion();
}));
this._register(suggestController.widget.value.onDidFocus(() => {
this.updateFromSuggestion();
}));
};
this._register(Event.once(suggestController.model.onDidTrigger)(e => {
bindToSuggestWidget();
}));
}
this.updateFromSuggestion();
}
private getSuggestText(suggestController: SuggestController, lineNumber: number, suggestion: ISelectedSuggestion): SuggestWidgetInlineSuggestion | null {
const item = suggestion.item;
if (Array.isArray(item.completion.additionalTextEdits)) {
// cannot represent additional text edits
return null;
}
let { insertText } = item.completion;
if (item.completion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet) {
insertText = new SnippetParser().text(insertText);
}
const info = suggestController.getOverwriteInfo(item, false);
return new SuggestWidgetInlineSuggestion(lineNumber, info.overwriteBefore, info.overwriteAfter, insertText);
}
private updateFromSuggestion(): void {
const suggestController = SuggestController.get(this.editor);
if (!suggestController) {
this.setNoFocusedItem();
return;
}
if (!this.isSuggestWidgetVisible) {
this.setNoFocusedItem();
return;
}
const focusedItem = suggestController.widget.value.getFocusedItem();
if (!focusedItem) {
this.setNoFocusedItem();
return;
}
// TODO: item.isResolved
this.setFocusedItem(this.getSuggestText(suggestController, this.editor.getPosition().lineNumber, focusedItem));
}
private setNoFocusedItem(): void {
if (!this._hasFocusedItem) {
// no change
return;
}
this._hasFocusedItem = false;
this.currentSuggestion = null;
this.onDidChangeEventEmitter.fire();
}
private setFocusedItem(currentSuggestion: SuggestWidgetInlineSuggestion | null): void {
this._hasFocusedItem = true;
this.currentSuggestion = currentSuggestion;
this.onDidChangeEventEmitter.fire();
}
getInlineSuggestions(position: Position): NormalizedInlineCompletions {
if (this.currentSuggestion && this.currentSuggestion.lineNumber !== position.lineNumber) {
this.currentSuggestion = null;
}
if (this.currentSuggestion) {
return {
items: [{
range: Range.fromPositions(position.delta(0, -this.currentSuggestion.overwriteBefore), position.delta(0, this.currentSuggestion.overwriteAfter)),
text: this.currentSuggestion.text
}]
};
}
return { items: [] };
}
}
class InlineSuggestionsModel extends Disposable {
private readonly onDidChangeEventEmitter = this._register(new Emitter<void>());
public readonly onDidChange = this.onDidChangeEventEmitter.event;
private readonly textModel: ITextModel = this.editor.getModel();
private isActive: boolean = false;
private updatePromise: CancelablePromise<NormalizedInlineCompletions | undefined> | undefined = undefined;
private cachedList: NormalizedInlineCompletions | undefined = undefined;
private cachedPosition: Position | undefined = undefined;
private updateSoon = this._register(new RunOnceScheduler(() => this._update(), 50));
constructor(
private readonly editor: IActiveCodeEditor
) {
super();
this._register(toDisposable(() => {
this.clearGhostTextPromise();
}));
this._register(this.editor.onDidChangeModelContent(() => {
if (this.isActive) {
this.updateSoon.schedule();
}
}));
}
activate() {
this.isActive = true;
}
deactivate() {
this.isActive = false;
this.updateSoon.cancel();
}
public getInlineSuggestions(position: Position): NormalizedInlineCompletions {
if (this.cachedList && this.cachedPosition && position.lineNumber === this.cachedPosition.lineNumber) {
return this.cachedList;
}
return {
items: []
};
}
private _update(): void {
const position = this.editor.getPosition();
this.clearGhostTextPromise();
this.updatePromise = createCancelablePromise(token => provideInlineCompletions(position, this.textModel, { triggerKind: InlineCompletionTriggerKind.Automatic }, token));
this.updatePromise.then((result) => {
this.cachedList = result;
this.cachedPosition = position;
this.onDidChangeEventEmitter.fire(undefined);
}, errors.onUnexpectedError);
}
private clearGhostTextPromise(): void {
if (this.updatePromise) {
this.updatePromise.cancel();
this.updatePromise = undefined;
}
}
}
function validateSuggestion(suggestion: NormalizedInlineCompletion, model: ITextModel): ValidatedInlineCompletion | undefined {
// Multiline replacements are not supported
if (suggestion.range.startLineNumber !== suggestion.range.endLineNumber) {
return undefined;
}
const lineNumber = suggestion.range.startLineNumber;
const suggestedLines = strings.splitLines(suggestion.text);
const firstSuggestedLine = suggestedLines[0];
const modelLine = model.getLineContent(lineNumber);
const suggestionStartIdx = suggestion.range.startColumn - 1;
let committedSuggestionLength = 0;
while (
committedSuggestionLength < firstSuggestedLine.length
&& suggestionStartIdx + committedSuggestionLength < modelLine.length
&& firstSuggestedLine[committedSuggestionLength] === modelLine[suggestionStartIdx + committedSuggestionLength]) {
committedSuggestionLength++;
}
// If a suggestion wants to replace text, the suggestion may not replace that text with different text
//if (committedSuggestionLength !== suggestion.replaceRange.endColumn - suggestion.replaceRange.startColumn) {
// return undefined;
//}
// For now, we don't support any left over text. An entire line suffix must be replaced.
//if (suggestion.replaceRange.endColumn !== model.getLineMaxColumn(lineNumber)) {
// return undefined;
//}
return {
lineNumber,
committedSuggestionLength,
suggestion
};
}
export class TriggerGhostTextAction extends EditorAction {
constructor() {
super({
id: 'editor.action.triggerInlineCompletions',
label: nls.localize('triggerInlineCompletionsAction', "Trigger Inline Completions"),
alias: 'Trigger Inline Completions',
precondition: EditorContextKeys.writable
});
}
public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise<void> {
const controller = InlineCompletionsController.get(editor);
if (controller) {
controller.trigger();
}
}
}
export interface NormalizedInlineCompletion extends InlineCompletion {
range: Range;
}
export interface NormalizedInlineCompletions extends InlineCompletions<NormalizedInlineCompletion> {
}
function getDefaultRange(position: Position, model: ITextModel): Range {
const word = model.getWordAtPosition(position);
const maxColumn = model.getLineMaxColumn(position.lineNumber);
// By default, always replace up until the end of the current line.
// This default might be subject to change!
return word
? new Range(position.lineNumber, word.startColumn, position.lineNumber, maxColumn)
: Range.fromPositions(position, position.with(undefined, maxColumn));
}
async function provideInlineCompletions(
position: Position,
model: ITextModel,
context: InlineCompletionContext,
token: CancellationToken = CancellationToken.None
): Promise<NormalizedInlineCompletions | undefined> {
console.log(`provideInlineCompletions at ${position}`);
const defaultReplaceRange = getDefaultRange(position, model);
const providers = InlineCompletionsProviderRegistry.all(model);
const results = await Promise.all(
providers.map(provider => provider.provideInlineCompletions(model, position, context, token))
);
const items = new Array<NormalizedInlineCompletion>();
for (const result of results) {
if (result) {
items.push(...result.items.map<NormalizedInlineCompletion>(item => ({
text: item.text,
range: item.range ? Range.lift(item.range) : defaultReplaceRange
})));
}
}
return { items };
}
const InlineCompletionCommand = EditorCommand.bindToContribution<InlineCompletionsController>(InlineCompletionsController.get);
registerEditorCommand(new InlineCompletionCommand({
id: 'commitInlineCompletion',
precondition: InlineCompletionsController.inlineCompletionVisible,
kbOpts: {
weight: 100,
primary: KeyCode.Tab,
},
handler(x) {
x.commit();
}
}));
registerEditorCommand(new InlineCompletionCommand({
id: 'hideInlineCompletion',
precondition: InlineCompletionsController.inlineCompletionVisible,
kbOpts: {
weight: 100,
primary: KeyCode.Escape,
},
handler(x) {
x.hide();
}
}));
registerEditorContribution(InlineCompletionsController.ID, InlineCompletionsController);
registerEditorAction(TriggerGhostTextAction);
+1 -1
View File
@@ -24,7 +24,7 @@ import 'vs/editor/contrib/folding/folding';
import 'vs/editor/contrib/fontZoom/fontZoom';
import 'vs/editor/contrib/format/formatActions';
import 'vs/editor/contrib/documentSymbols/documentSymbols';
import 'vs/editor/contrib/inlineSuggestions/inlineSuggestionsController';
import 'vs/editor/contrib/inlineCompletions/ghostTextController';
import 'vs/editor/contrib/gotoSymbol/goToCommands';
import 'vs/editor/contrib/gotoSymbol/link/goToDefinitionAtPosition';
import 'vs/editor/contrib/gotoError/gotoError';
@@ -505,7 +505,7 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha
return this._proxy.$provideInlineCompletions(handle, model.uri, position, context, token);
}
};
this._registrations.set(handle, modes.InlineSuggestionsProviderRegistry.register(selector, provider));
this._registrations.set(handle, modes.InlineCompletionsProviderRegistry.register(selector, provider));
}
// --- parameter hints
@@ -1066,7 +1066,7 @@ class InlineCompletionsAdapter {
return {
items: result.items.map(item => ({
text: item.text,
replaceRange: item.range ? typeConvert.Range.from(item.range) : undefined,
range: item.range ? typeConvert.Range.from(item.range) : undefined,
})),
};
}