Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ghost text updates #124707

Merged
merged 5 commits into from
May 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/vs/editor/common/config/editorOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3315,7 +3315,7 @@ class EditorSuggest extends BaseEditorOption<EditorOption.suggest, InternalSugge
showIcons: true,
showStatusBar: false,
showSuggestionPreview: false,
suggestionPreviewExpanded: false,
suggestionPreviewExpanded: true,
showInlineDetails: true,
showMethods: true,
showFunctions: true,
Expand Down
15 changes: 10 additions & 5 deletions src/vs/editor/common/modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,27 +718,32 @@ export interface InlineCompletion {
* If the text contains a line break, the range must end at the end of a line.
* If existing text should be replaced, the existing text must be a prefix of the text to insert.
*/
text: string;
readonly text: string;

/**
* The range to replace.
* Must begin and end on the same line.
*/
range?: IRange;
readonly range?: IRange;

readonly command?: Command;
}

/**
* @internal
*/
export interface InlineCompletions<TItem extends InlineCompletion = InlineCompletion> {
items: TItem[];
readonly items: readonly TItem[];
}

/**
* @internal
*/
export interface InlineCompletionsProvider {
provideInlineCompletions(model: model.ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): ProviderResult<InlineCompletions>;
export interface InlineCompletionsProvider<T extends InlineCompletions = InlineCompletions> {
provideInlineCompletions(model: model.ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): ProviderResult<T>;

handleItemDidShow?(completions: T, item: T['items'][number]): void;
freeInlineCompletions(completions: T): void;
}

export interface CodeAction {
Expand Down
10 changes: 6 additions & 4 deletions src/vs/editor/contrib/inlineCompletions/ghostTextController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { GhostTextWidget } from 'vs/editor/contrib/inlineCompletions/ghostTextWi
import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
import { SuggestWidgetAdapterModel } from 'vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel';
import * as nls from 'vs/nls';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';

Expand All @@ -31,7 +32,7 @@ class GhostTextController extends Disposable {

constructor(
private readonly editor: ICodeEditor,
@IInstantiationService instantiationService: IInstantiationService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super();
Expand All @@ -55,7 +56,7 @@ class GhostTextController extends Disposable {

this.activeController.value = undefined;
this.activeController.value = this.editor.hasModel() && suggestOptions.showSuggestionPreview
? new ActiveGhostTextController(this.editor, this.widget, this.contextKeys)
? this.instantiationService.createInstance(ActiveGhostTextController, this.editor, this.widget, this.contextKeys)
: undefined;
}

Expand Down Expand Up @@ -92,12 +93,13 @@ class GhostTextContextKeys {
*/
export class ActiveGhostTextController extends Disposable {
private readonly suggestWidgetAdapterModel = new SuggestWidgetAdapterModel(this.editor);
private readonly inlineCompletionsModel = new InlineCompletionsModel(this.editor);
private readonly inlineCompletionsModel = new InlineCompletionsModel(this.editor, this.commandService);

constructor(
private readonly editor: IActiveCodeEditor,
private readonly widget: GhostTextWidget,
private readonly contextKeys: GhostTextContextKeys,
@ICommandService private readonly commandService: ICommandService,
) {
super();

Expand Down Expand Up @@ -226,7 +228,7 @@ export class ShowPreviousInlineCompletionAction extends EditorAction {
public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise<void> {
const controller = GhostTextController.get(editor);
if (controller) {
controller.showNextInlineCompletion();
controller.showPreviousInlineCompletion();
}
}
}
Expand Down
19 changes: 12 additions & 7 deletions src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ export class GhostTextWidget extends Disposable {
private decorationIds: string[] = [];
private viewZoneId: string | null = null;
private viewMoreContentWidget: ViewMoreLinesContentWidget | null = null;
private viewMoreContentWidget2: ViewMoreLinesContentWidget | null = null;

constructor(
private readonly editor: ICodeEditor,
Expand Down Expand Up @@ -191,11 +190,6 @@ export class GhostTextWidget extends Disposable {
this.viewMoreContentWidget = null;
}

if (this.viewMoreContentWidget2) {
this.viewMoreContentWidget2.dispose();
this.viewMoreContentWidget2 = null;
}

this.editor.changeViewZones((changeAccessor) => {
if (this.viewZoneId) {
changeAccessor.removeZone(this.viewZoneId);
Expand Down Expand Up @@ -348,9 +342,20 @@ class ViewMoreLinesContentWidget extends Disposable implements IContentWidget {
}

registerThemingParticipant((theme, collector) => {

const suggestPreviewForeground = theme.getColor(editorSuggestPreviewOpacity);

if (suggestPreviewForeground) {
collector.addRule(`.monaco-editor .suggest-preview-text { opacity: ${suggestPreviewForeground.rgba.a}; }`);
function opaque(color: Color): Color {
const { r, b, g } = color.rgba;
return new Color(new RGBA(r, g, b, 255));
}

const opacity = String(suggestPreviewForeground.rgba.a);
const color = Color.Format.CSS.format(opaque(suggestPreviewForeground))!;

// We need to override the only used token type .mtk1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks off to me. Why do we need to do something special only for mtk1? Could it be that we are just testing in a context where mtk1 is always used? How about ghosted text inside a comment, is that also always painted with mtk1?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. We are passing an empty tokens array, so I guess there only will be mtkw and mtk1. What would be the proper way? I tried setting color directly (even with !important), but the token's color overrides that.

collector.addRule(`.monaco-editor .suggest-preview-text .mtk1 { opacity: ${opacity}; color: ${color}; }`);
}

const suggestPreviewBorder = theme.getColor(editorSuggestPreviewBorder);
Expand Down
123 changes: 98 additions & 25 deletions src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@

import { CancelablePromise, createCancelablePromise, RunOnceScheduler } from 'vs/base/common/async';
import { CancellationToken } from 'vs/base/common/cancellation';
import { onUnexpectedError } from 'vs/base/common/errors';
import { onUnexpectedError, onUnexpectedExternalError } 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 { InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider, 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';
import { ICommandService } from 'vs/platform/commands/common/commands';

export class InlineCompletionsModel extends Disposable implements GhostTextWidgetModel {
protected readonly onDidChangeEmitter = new Emitter<void>();
Expand All @@ -25,7 +26,10 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge

private active: boolean = false;

constructor(private readonly editor: IActiveCodeEditor) {
constructor(
private readonly editor: IActiveCodeEditor,
private readonly commandService: ICommandService
) {
super();

this._register(this.editor.onDidChangeModelContent((e) => {
Expand Down Expand Up @@ -83,7 +87,7 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
if (this.completionSession.value) {
return;
}
this.completionSession.value = new InlineCompletionsSession(this.editor, this.editor.getPosition(), () => this.active);
this.completionSession.value = new InlineCompletionsSession(this.editor, this.editor.getPosition(), () => this.active, this.commandService);
this.completionSession.value.takeOwnership(
this.completionSession.value.onDidChange(() => {
this.onDidChangeEmitter.fire();
Expand Down Expand Up @@ -120,7 +124,7 @@ class CachedInlineCompletion {
public lastRange: Range;

constructor(
public readonly inlineCompletion: NormalizedInlineCompletion,
public readonly inlineCompletion: LiveInlineCompletion,
public readonly decorationId: string,
) {
this.lastRange = inlineCompletion.range;
Expand All @@ -130,19 +134,38 @@ class CachedInlineCompletion {
class InlineCompletionsSession extends BaseGhostTextWidgetModel {
public readonly minReservedLineCount = 0;

private updatePromise: CancelablePromise<NormalizedInlineCompletions> | undefined = undefined;
private updatePromise: CancelablePromise<LiveInlineCompletions> | undefined = undefined;
private cachedCompletions: CachedInlineCompletion[] | undefined = undefined;
private cachedCompletionsSource: LiveInlineCompletions | undefined = undefined;

private updateSoon = this._register(new RunOnceScheduler(() => this.update(), 50));
private readonly textModel = this.editor.getModel();

constructor(editor: IActiveCodeEditor, private readonly triggerPosition: Position, private readonly shouldUpdate: () => boolean) {
constructor(
editor: IActiveCodeEditor,
private readonly triggerPosition: Position,
private readonly shouldUpdate: () => boolean,
private readonly commandService: ICommandService,
) {
super(editor);
this._register(toDisposable(() => {
this.clearGhostTextPromise();
this.clearCache();
}));

let lastCompletionItem: InlineCompletion | undefined = undefined;
this._register(this.onDidChange(() => {
const currentCompletion = this.currentCompletion;
if (currentCompletion && currentCompletion.sourceInlineCompletion !== lastCompletionItem) {
lastCompletionItem = currentCompletion.sourceInlineCompletion;

const provider = currentCompletion.sourceProvider;
if (provider.handleItemDidShow) {
provider.handleItemDidShow(currentCompletion.sourceInlineCompletions, lastCompletionItem);
}
}
}));

this._register(this.editor.onDidChangeModelDecorations(e => {
if (!this.cachedCompletions) {
return;
Expand Down Expand Up @@ -219,14 +242,18 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
return currentCompletion ? inlineCompletionToGhostText(currentCompletion, this.editor.getModel()) : undefined;
}

get currentCompletion(): NormalizedInlineCompletion | undefined {
get currentCompletion(): LiveInlineCompletion | undefined {
const completion = this.currentCachedCompletion;
if (!completion) {
return undefined;
}
return {
text: completion.inlineCompletion.text,
range: completion.lastRange
range: completion.lastRange,
command: completion.inlineCompletion.command,
sourceProvider: completion.inlineCompletion.sourceProvider,
sourceInlineCompletions: completion.inlineCompletion.sourceInlineCompletions,
sourceInlineCompletion: completion.inlineCompletion.sourceInlineCompletion,
};
}

Expand Down Expand Up @@ -262,15 +289,24 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}))
);

this.cachedCompletionsSource?.dispose();
this.cachedCompletionsSource = result;
this.cachedCompletions = result.items.map((item, idx) => new CachedInlineCompletion(item, decorationIds[idx]));
this.onDidChangeEmitter.fire();
}, onUnexpectedError);
}

private clearCache(): void {
if (this.cachedCompletions) {
this.editor.deltaDecorations(this.cachedCompletions.map(c => c.decorationId), []);
const completions = this.cachedCompletions;
if (completions) {
this.cachedCompletions = undefined;
this.editor.deltaDecorations(completions.map(c => c.decorationId), []);

if (!this.cachedCompletionsSource) {
throw new Error('Unexpected state');
}
this.cachedCompletionsSource.dispose();
this.cachedCompletionsSource = undefined;
}
}

Expand All @@ -292,14 +328,18 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
}

public commit(completion: NormalizedInlineCompletion): void {
public commit(completion: LiveInlineCompletion): void {
this.clearCache();
this.editor.executeEdits(
'inlineCompletions.accept',
[
EditOperation.replaceMove(completion.range, completion.text)
]
);
if (completion.command) {
this.commandService.executeCommand(completion.command.id, ...(completion.command.arguments || [])).then(undefined, onUnexpectedExternalError);
}

this.onDidChangeEmitter.fire();
}
}
Expand All @@ -308,12 +348,6 @@ export interface NormalizedInlineCompletion extends InlineCompletion {
range: Range;
}

/**
* Contains no duplicated items.
*/
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)) {
Expand All @@ -328,6 +362,20 @@ export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCo
};
}

export interface LiveInlineCompletion extends InlineCompletion {
range: Range;
sourceProvider: InlineCompletionsProvider;
sourceInlineCompletion: InlineCompletion;
sourceInlineCompletions: InlineCompletions;
}

/**
* Contains no duplicated items.
*/
export interface LiveInlineCompletions extends InlineCompletions<LiveInlineCompletion> {
dispose(): void;
}

function getDefaultRange(position: Position, model: ITextModel): Range {
const word = model.getWordAtPosition(position);
const maxColumn = model.getLineMaxColumn(position.lineNumber);
Expand All @@ -343,25 +391,50 @@ async function provideInlineCompletions(
model: ITextModel,
context: InlineCompletionContext,
token: CancellationToken = CancellationToken.None
): Promise<NormalizedInlineCompletions> {
): Promise<LiveInlineCompletions> {
const defaultReplaceRange = getDefaultRange(position, model);

const providers = InlineCompletionsProviderRegistry.all(model);
const results = await Promise.all(
providers.map(provider => provider.provideInlineCompletions(model, position, context, token))
providers.map(
async provider => {
const completions = await provider.provideInlineCompletions(model, position, context, token);
return ({
completions,
provider,
dispose: () => {
if (completions) {
provider.freeInlineCompletions(completions);
}
}
});
}
)
);

const itemsByHash = new Map<string, NormalizedInlineCompletion>();
const itemsByHash = new Map<string, LiveInlineCompletion>();
for (const result of results) {
if (result) {
for (const item of result.items.map<NormalizedInlineCompletion>(item => ({
const completions = result.completions;
if (completions) {
for (const item of completions.items.map<LiveInlineCompletion>(item => ({
text: item.text,
range: item.range ? Range.lift(item.range) : defaultReplaceRange
range: item.range ? Range.lift(item.range) : defaultReplaceRange,
command: item.command,
sourceProvider: result.provider,
sourceInlineCompletions: completions,
sourceInlineCompletion: item
}))) {
itemsByHash.set(JSON.stringify({ text: item.text, range: item.range }), item);
}
}
}

return { items: [...itemsByHash.values()] };
return {
items: [...itemsByHash.values()],
dispose: () => {
for (const result of results) {
result.dispose();
}
},
};
}