mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-13 15:35:20 +01:00
Refactors the inline completion feature.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
+43
-39
@@ -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);
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user