Merge pull request #131183 from microsoft/hediet/inline-completions-autocomplete
allows inline suggestion providers to interact with the suggest widget.
This commit is contained in:
commit
03ed03a373
|
@ -691,6 +691,13 @@ export interface InlineCompletionContext {
|
|||
* How the completion was triggered.
|
||||
*/
|
||||
readonly triggerKind: InlineCompletionTriggerKind;
|
||||
|
||||
readonly selectedSuggestionInfo: SelectedSuggestionInfo | undefined;
|
||||
}
|
||||
|
||||
export interface SelectedSuggestionInfo {
|
||||
range: IRange;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface InlineCompletion {
|
||||
|
|
|
@ -23,3 +23,7 @@
|
|||
opacity: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.monaco-editor .ghost-text-decoration-preview {
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
|
@ -100,6 +100,10 @@ export class GhostTextPart {
|
|||
constructor(
|
||||
readonly column: number,
|
||||
readonly lines: readonly string[],
|
||||
/**
|
||||
* Indicates if this part is a preview of an inline suggestion when a suggestion is previewed.
|
||||
*/
|
||||
readonly preview: boolean,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
|
@ -5,14 +5,15 @@
|
|||
|
||||
import { Disposable, IReference, MutableDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
|
||||
import { SuggestWidgetAdapterModel } from 'vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel';
|
||||
import { InlineCompletionsModel, LiveInlineCompletions, SynchronizedInlineCompletionsCache } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
|
||||
import { SuggestWidgetPreviewModel } from 'vs/editor/contrib/inlineCompletions/suggestWidgetPreviewModel';
|
||||
import { ICommandService } from 'vs/platform/commands/common/commands';
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { Position } from 'vs/editor/common/core/position';
|
||||
import { createDisposableRef } from 'vs/editor/contrib/inlineCompletions/utils';
|
||||
import { GhostTextWidgetModel, GhostText } from 'vs/editor/contrib/inlineCompletions/ghostText';
|
||||
import { InlineCompletionTriggerKind } from 'vs/editor/common/modes';
|
||||
|
||||
export abstract class DelegatingModel extends Disposable implements GhostTextWidgetModel {
|
||||
private readonly onDidChangeEmitter = new Emitter<void>();
|
||||
|
@ -65,8 +66,9 @@ export abstract class DelegatingModel extends Disposable implements GhostTextWid
|
|||
* A ghost text model that is both driven by inline completions and the suggest widget.
|
||||
*/
|
||||
export class GhostTextModel extends DelegatingModel implements GhostTextWidgetModel {
|
||||
public readonly suggestWidgetAdapterModel = this._register(new SuggestWidgetAdapterModel(this.editor));
|
||||
public readonly inlineCompletionsModel = this._register(new InlineCompletionsModel(this.editor, this.commandService));
|
||||
public readonly sharedCache = this._register(new SharedInlineCompletionCache());
|
||||
public readonly suggestWidgetAdapterModel = this._register(new SuggestWidgetPreviewModel(this.editor, this.sharedCache));
|
||||
public readonly inlineCompletionsModel = this._register(new InlineCompletionsModel(this.editor, this.sharedCache, this.commandService));
|
||||
|
||||
public get activeInlineCompletionsModel(): InlineCompletionsModel | undefined {
|
||||
if (this.targetModel === this.inlineCompletionsModel) {
|
||||
|
@ -129,3 +131,34 @@ export class GhostTextModel extends DelegatingModel implements GhostTextWidgetMo
|
|||
return result !== undefined ? result : false;
|
||||
}
|
||||
}
|
||||
|
||||
export class SharedInlineCompletionCache extends Disposable {
|
||||
private readonly onDidChangeEmitter = new Emitter<void>();
|
||||
public readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
|
||||
private readonly cache = this._register(new MutableDisposable<SynchronizedInlineCompletionsCache>());
|
||||
|
||||
public get value(): SynchronizedInlineCompletionsCache | undefined {
|
||||
return this.cache.value;
|
||||
}
|
||||
|
||||
public setValue(editor: IActiveCodeEditor,
|
||||
completionsSource: LiveInlineCompletions,
|
||||
triggerKind: InlineCompletionTriggerKind
|
||||
) {
|
||||
this.cache.value = new SynchronizedInlineCompletionsCache(
|
||||
editor,
|
||||
completionsSource,
|
||||
() => this.onDidChangeEmitter.fire(),
|
||||
triggerKind
|
||||
);
|
||||
}
|
||||
|
||||
public clearAndLeak(): SynchronizedInlineCompletionsCache | undefined {
|
||||
return this.cache.clearAndLeak();
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -116,6 +116,7 @@ export class GhostTextWidget extends Disposable {
|
|||
inlineTexts.push({
|
||||
column: part.column,
|
||||
text: lines[0],
|
||||
preview: part.preview,
|
||||
});
|
||||
lines = lines.slice(1);
|
||||
} else {
|
||||
|
@ -192,6 +193,7 @@ interface HiddenText {
|
|||
interface InsertedInlineText {
|
||||
column: number;
|
||||
text: string;
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
class DecorationsWidget implements IDisposable {
|
||||
|
@ -273,6 +275,7 @@ class DecorationsWidget implements IDisposable {
|
|||
opacity,
|
||||
color,
|
||||
border,
|
||||
fontWeight: p.preview ? 'bold' : 'normal',
|
||||
},
|
||||
}));
|
||||
|
||||
|
@ -280,7 +283,7 @@ class DecorationsWidget implements IDisposable {
|
|||
range: Range.fromPositions(new Position(lineNumber, p.column)),
|
||||
options: shouldUseInjectedText ? {
|
||||
description: 'ghost-text',
|
||||
after: { content: contentText, inlineClassName: 'ghost-text-decoration' }
|
||||
after: { content: contentText, inlineClassName: p.preview ? 'ghost-text-decoration-preview' : 'ghost-text-decoration' }
|
||||
} : {
|
||||
...decorationType.resolve()
|
||||
}
|
||||
|
@ -495,6 +498,7 @@ registerThemingParticipant((theme, collector) => {
|
|||
const color = Color.Format.CSS.format(opaque(foreground))!;
|
||||
|
||||
collector.addRule(`.monaco-editor .ghost-text-decoration { opacity: ${opacity}; color: ${color}; }`);
|
||||
collector.addRule(`.monaco-editor .ghost-text-decoration-preview { color: ${foreground.toString()}; }`);
|
||||
collector.addRule(`.monaco-editor .suggest-preview-text .ghost-text { opacity: ${opacity}; color: ${color}; }`);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as strings from 'vs/base/common/strings';
|
||||
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 } from 'vs/editor/common/modes';
|
||||
import { IDiffChange, LcsDiff } from 'vs/base/common/diff/diff';
|
||||
import { GhostText, GhostTextPart } from 'vs/editor/contrib/inlineCompletions/ghostText';
|
||||
|
||||
|
||||
export interface NormalizedInlineCompletion extends InlineCompletion {
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export function inlineCompletionToGhostText(
|
||||
inlineCompletion: NormalizedInlineCompletion,
|
||||
textModel: ITextModel,
|
||||
mode: 'prefix' | 'subword' | 'subwordSmart',
|
||||
cursorPosition?: Position,
|
||||
previewSuffixLength = 0
|
||||
): GhostText | undefined {
|
||||
if (inlineCompletion.range.startLineNumber !== inlineCompletion.range.endLineNumber) {
|
||||
// Only single line replacements are supported.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const modifiedLength = inlineCompletion.text.length;
|
||||
const previewStartInModified = modifiedLength - previewSuffixLength;
|
||||
|
||||
// This is a single line string
|
||||
const valueToBeReplaced = textModel.getValueInRange(inlineCompletion.range);
|
||||
|
||||
const changes = cachingDiff(valueToBeReplaced, inlineCompletion.text);
|
||||
|
||||
const lineNumber = inlineCompletion.range.startLineNumber;
|
||||
|
||||
const parts = new Array<GhostTextPart>();
|
||||
|
||||
if (mode === 'prefix') {
|
||||
const filteredChanges = changes.filter(c => c.originalLength === 0);
|
||||
if (filteredChanges.length > 1 || filteredChanges.length === 1 && filteredChanges[0].originalStart !== valueToBeReplaced.length) {
|
||||
// Prefixes only have a single change.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of changes) {
|
||||
const insertColumn = inlineCompletion.range.startColumn + c.originalStart + c.originalLength;
|
||||
|
||||
if (mode === 'subwordSmart' && cursorPosition && cursorPosition.lineNumber === inlineCompletion.range.startLineNumber && insertColumn < cursorPosition.column) {
|
||||
// No ghost text before cursor
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (c.originalLength > 0) {
|
||||
const originalText = valueToBeReplaced.substr(c.originalStart, c.originalLength);
|
||||
const firstNonWsCol = textModel.getLineFirstNonWhitespaceColumn(lineNumber);
|
||||
if (!(/^(\t| )*$/.test(originalText) && (firstNonWsCol === 0 || insertColumn <= firstNonWsCol))) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (c.modifiedLength === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const modifiedEnd = c.modifiedStart + c.modifiedLength;
|
||||
const nonPreviewTextEnd = Math.max(c.modifiedStart, Math.min(modifiedEnd, previewStartInModified));
|
||||
const nonPreviewText = inlineCompletion.text.substring(c.modifiedStart, nonPreviewTextEnd);
|
||||
const italicText = inlineCompletion.text.substring(nonPreviewTextEnd, Math.max(c.modifiedStart, modifiedEnd));
|
||||
|
||||
if (nonPreviewText.length > 0) {
|
||||
const lines = strings.splitLines(nonPreviewText);
|
||||
parts.push(new GhostTextPart(insertColumn, lines, false));
|
||||
}
|
||||
if (italicText.length > 0) {
|
||||
const lines = strings.splitLines(italicText);
|
||||
parts.push(new GhostTextPart(insertColumn, lines, true));
|
||||
}
|
||||
}
|
||||
|
||||
return new GhostText(lineNumber, parts, 0);
|
||||
}
|
||||
|
||||
let lastRequest: { originalValue: string; newValue: string; changes: readonly IDiffChange[]; } | undefined = undefined;
|
||||
function cachingDiff(originalValue: string, newValue: string): readonly IDiffChange[] {
|
||||
if (lastRequest?.originalValue === originalValue && lastRequest?.newValue === newValue) {
|
||||
return lastRequest?.changes;
|
||||
} else {
|
||||
const changes = smartDiff(originalValue, newValue);
|
||||
lastRequest = {
|
||||
originalValue,
|
||||
newValue,
|
||||
changes
|
||||
};
|
||||
return changes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When matching `if ()` with `if (f() = 1) { g(); }`,
|
||||
* align it like this: `if ( )`
|
||||
* Not like this: `if ( )`
|
||||
* Also not like this: `if ( )`.
|
||||
*
|
||||
* The parenthesis are preprocessed to ensure that they match correctly.
|
||||
*/
|
||||
function smartDiff(originalValue: string, newValue: string): readonly IDiffChange[] {
|
||||
function getMaxCharCode(val: string): number {
|
||||
let maxCharCode = 0;
|
||||
for (let i = 0, len = val.length; i < len; i++) {
|
||||
const charCode = val.charCodeAt(i);
|
||||
if (charCode > maxCharCode) {
|
||||
maxCharCode = charCode;
|
||||
}
|
||||
}
|
||||
return maxCharCode;
|
||||
}
|
||||
|
||||
const maxCharCode = Math.max(getMaxCharCode(originalValue), getMaxCharCode(newValue));
|
||||
function getUniqueCharCode(id: number): number {
|
||||
if (id < 0) {
|
||||
throw new Error('unexpected');
|
||||
}
|
||||
return maxCharCode + id + 1;
|
||||
}
|
||||
|
||||
function getElements(source: string): Int32Array {
|
||||
let level = 0;
|
||||
let group = 0;
|
||||
const characters = new Int32Array(source.length);
|
||||
for (let i = 0, len = source.length; i < len; i++) {
|
||||
const id = group * 100 + level;
|
||||
|
||||
// TODO support more brackets
|
||||
if (source[i] === '(') {
|
||||
characters[i] = getUniqueCharCode(2 * id);
|
||||
level++;
|
||||
} else if (source[i] === ')') {
|
||||
characters[i] = getUniqueCharCode(2 * id + 1);
|
||||
if (level === 1) {
|
||||
group++;
|
||||
}
|
||||
level = Math.max(level - 1, 0);
|
||||
} else {
|
||||
characters[i] = source.charCodeAt(i);
|
||||
}
|
||||
}
|
||||
return characters;
|
||||
}
|
||||
|
||||
const elements1 = getElements(originalValue);
|
||||
const elements2 = getElements(newValue);
|
||||
|
||||
return new LcsDiff({ getElements: () => elements1 }, { getElements: () => elements2 }).ComputeDiff(false).changes;
|
||||
}
|
|
@ -5,23 +5,23 @@
|
|||
|
||||
import { CancelablePromise, createCancelablePromise, RunOnceScheduler } from 'vs/base/common/async';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { IDiffChange, LcsDiff } from 'vs/base/common/diff/diff';
|
||||
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 { CoreEditingCommands } from 'vs/editor/browser/controller/coreCommands';
|
||||
import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { RedoCommand, UndoCommand } from 'vs/editor/browser/editorExtensions';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { EditOperation } from 'vs/editor/common/core/editOperation';
|
||||
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, InlineCompletionsProvider, InlineCompletionsProviderRegistry, InlineCompletionTriggerKind } from 'vs/editor/common/modes';
|
||||
import { EditOperation } from 'vs/editor/common/core/editOperation';
|
||||
import { ICommandService } from 'vs/platform/commands/common/commands';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { RedoCommand, UndoCommand } from 'vs/editor/browser/editorExtensions';
|
||||
import { CoreEditingCommands } from 'vs/editor/browser/controller/coreCommands';
|
||||
import { GhostTextWidgetModel, GhostText, BaseGhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/ghostText';
|
||||
import { inlineCompletionToGhostText, NormalizedInlineCompletion } from './inlineCompletionToGhostText';
|
||||
import { inlineSuggestCommitId } from './consts';
|
||||
import { BaseGhostTextWidgetModel, GhostText, GhostTextPart, GhostTextWidgetModel } from './ghostText';
|
||||
import { SharedInlineCompletionCache } from './ghostTextModel';
|
||||
|
||||
export class InlineCompletionsModel extends Disposable implements GhostTextWidgetModel {
|
||||
protected readonly onDidChangeEmitter = new Emitter<void>();
|
||||
|
@ -34,7 +34,8 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
|
|||
|
||||
constructor(
|
||||
private readonly editor: IActiveCodeEditor,
|
||||
@ICommandService private readonly commandService: ICommandService
|
||||
private readonly cache: SharedInlineCompletionCache,
|
||||
@ICommandService private readonly commandService: ICommandService,
|
||||
) {
|
||||
super();
|
||||
|
||||
|
@ -126,7 +127,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.commandService);
|
||||
this.completionSession.value = new InlineCompletionsSession(this.editor, this.editor.getPosition(), () => this.active, this.commandService, this.cache);
|
||||
this.completionSession.value.takeOwnership(
|
||||
this.completionSession.value.onDidChange(() => {
|
||||
this.onDidChangeEmitter.fire();
|
||||
|
@ -162,16 +163,15 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel {
|
|||
public readonly minReservedLineCount = 0;
|
||||
|
||||
private readonly updateOperation = this._register(new MutableDisposable<UpdateOperation>());
|
||||
private readonly cache = this._register(new MutableDisposable<SynchronizedInlineCompletionsCache>());
|
||||
|
||||
private readonly updateSoon = this._register(new RunOnceScheduler(() => this.update(InlineCompletionTriggerKind.Automatic), 50));
|
||||
private readonly textModel = this.editor.getModel();
|
||||
|
||||
constructor(
|
||||
editor: IActiveCodeEditor,
|
||||
private readonly triggerPosition: Position,
|
||||
private readonly shouldUpdate: () => boolean,
|
||||
private readonly commandService: ICommandService,
|
||||
private readonly cache: SharedInlineCompletionCache,
|
||||
) {
|
||||
super(editor);
|
||||
|
||||
|
@ -195,24 +195,6 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel {
|
|||
}));
|
||||
|
||||
this._register(this.editor.onDidChangeModelContent((e) => {
|
||||
if (this.cache.value) {
|
||||
let hasChanged = false;
|
||||
for (const c of this.cache.value.completions) {
|
||||
const newRange = this.textModel.getDecorationRange(c.decorationId);
|
||||
if (!newRange) {
|
||||
onUnexpectedError(new Error('Decoration has no range'));
|
||||
continue;
|
||||
}
|
||||
if (!c.synchronizedRange.equalsRange(newRange)) {
|
||||
hasChanged = true;
|
||||
c.synchronizedRange = newRange;
|
||||
}
|
||||
}
|
||||
if (hasChanged) {
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleAutomaticUpdate();
|
||||
}));
|
||||
|
||||
|
@ -311,14 +293,7 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel {
|
|||
if (!completion) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
text: completion.inlineCompletion.text,
|
||||
range: completion.synchronizedRange,
|
||||
command: completion.inlineCompletion.command,
|
||||
sourceProvider: completion.inlineCompletion.sourceProvider,
|
||||
sourceInlineCompletions: completion.inlineCompletion.sourceInlineCompletions,
|
||||
sourceInlineCompletion: completion.inlineCompletion.sourceInlineCompletion,
|
||||
};
|
||||
return completion.toLiveInlineCompletion();
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
|
@ -344,7 +319,7 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel {
|
|||
try {
|
||||
result = await provideInlineCompletions(position,
|
||||
this.editor.getModel(),
|
||||
{ triggerKind },
|
||||
{ triggerKind, selectedSuggestionInfo: undefined },
|
||||
token
|
||||
);
|
||||
} catch (e) {
|
||||
|
@ -356,10 +331,9 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel {
|
|||
return;
|
||||
}
|
||||
|
||||
this.cache.value = new SynchronizedInlineCompletionsCache(
|
||||
this.cache.setValue(
|
||||
this.editor,
|
||||
result,
|
||||
() => this.onDidChangeEmitter.fire(),
|
||||
triggerKind
|
||||
);
|
||||
this.onDidChangeEmitter.fire();
|
||||
|
@ -414,7 +388,7 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel {
|
|||
}
|
||||
}
|
||||
|
||||
class UpdateOperation implements IDisposable {
|
||||
export class UpdateOperation implements IDisposable {
|
||||
constructor(public readonly promise: CancelablePromise<void>, public readonly triggerKind: InlineCompletionTriggerKind) {
|
||||
}
|
||||
|
||||
|
@ -427,7 +401,7 @@ class UpdateOperation implements IDisposable {
|
|||
* The cache keeps itself in sync with the editor.
|
||||
* It also owns the completions result and disposes it when the cache is diposed.
|
||||
*/
|
||||
class SynchronizedInlineCompletionsCache extends Disposable {
|
||||
export class SynchronizedInlineCompletionsCache extends Disposable {
|
||||
public readonly completions: readonly CachedInlineCompletion[];
|
||||
|
||||
constructor(
|
||||
|
@ -483,6 +457,7 @@ class CachedInlineCompletion {
|
|||
startColumn: this.inlineCompletion.range.startColumn,
|
||||
command: this.inlineCompletion.command
|
||||
});
|
||||
|
||||
/**
|
||||
* The range, synchronized with text model changes.
|
||||
*/
|
||||
|
@ -494,135 +469,19 @@ class CachedInlineCompletion {
|
|||
) {
|
||||
this.synchronizedRange = inlineCompletion.range;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NormalizedInlineCompletion extends InlineCompletion {
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCompletion, textModel: ITextModel, mode: 'prefix' | 'subword' | 'subwordSmart', cursorPosition?: Position): GhostText | undefined {
|
||||
if (inlineCompletion.range.startLineNumber !== inlineCompletion.range.endLineNumber) {
|
||||
// Only single line replacements are supported.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// This is a single line string
|
||||
const valueToBeReplaced = textModel.getValueInRange(inlineCompletion.range);
|
||||
|
||||
const changes = cachingDiff(valueToBeReplaced, inlineCompletion.text);
|
||||
|
||||
const lineNumber = inlineCompletion.range.startLineNumber;
|
||||
|
||||
const parts = new Array<GhostTextPart>();
|
||||
|
||||
if (mode === 'prefix') {
|
||||
const filteredChanges = changes.filter(c => c.originalLength === 0);
|
||||
if (filteredChanges.length > 1 || filteredChanges.length === 1 && filteredChanges[0].originalStart !== valueToBeReplaced.length) {
|
||||
// Prefixes only have a single change.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of changes) {
|
||||
const insertColumn = inlineCompletion.range.startColumn + c.originalStart + c.originalLength;
|
||||
|
||||
if (mode === 'subwordSmart' && cursorPosition && cursorPosition.lineNumber === inlineCompletion.range.startLineNumber && insertColumn < cursorPosition.column) {
|
||||
// No ghost text before cursor
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (c.originalLength > 0) {
|
||||
const originalText = valueToBeReplaced.substr(c.originalStart, c.originalLength);
|
||||
const firstNonWsCol = textModel.getLineFirstNonWhitespaceColumn(lineNumber);
|
||||
if (!(/^(\t| )*$/.test(originalText) && (firstNonWsCol === 0 || insertColumn <= firstNonWsCol))) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (c.modifiedLength === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = inlineCompletion.text.substr(c.modifiedStart, c.modifiedLength);
|
||||
const lines = strings.splitLines(text);
|
||||
parts.push(new GhostTextPart(insertColumn, lines));
|
||||
}
|
||||
|
||||
return new GhostText(lineNumber, parts, 0);
|
||||
}
|
||||
|
||||
let lastRequest: { originalValue: string, newValue: string, changes: readonly IDiffChange[] } | undefined = undefined;
|
||||
function cachingDiff(originalValue: string, newValue: string): readonly IDiffChange[] {
|
||||
if (lastRequest?.originalValue === originalValue && lastRequest?.newValue === newValue) {
|
||||
return lastRequest?.changes;
|
||||
} else {
|
||||
const changes = smartDiff(originalValue, newValue);
|
||||
lastRequest = {
|
||||
originalValue,
|
||||
newValue,
|
||||
changes
|
||||
public toLiveInlineCompletion(): LiveInlineCompletion | undefined {
|
||||
return {
|
||||
text: this.inlineCompletion.text,
|
||||
range: this.synchronizedRange,
|
||||
command: this.inlineCompletion.command,
|
||||
sourceProvider: this.inlineCompletion.sourceProvider,
|
||||
sourceInlineCompletions: this.inlineCompletion.sourceInlineCompletions,
|
||||
sourceInlineCompletion: this.inlineCompletion.sourceInlineCompletion,
|
||||
};
|
||||
return changes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When matching `if ()` with `if (f() = 1) { g(); }`,
|
||||
* align it like this: `if ( )`
|
||||
* Not like this: `if ( )`
|
||||
* Also not like this: `if ( )`.
|
||||
*
|
||||
* The parenthesis are preprocessed to ensure that they match correctly.
|
||||
*/
|
||||
function smartDiff(originalValue: string, newValue: string): readonly IDiffChange[] {
|
||||
function getMaxCharCode(val: string): number {
|
||||
let maxCharCode = 0;
|
||||
for (let i = 0, len = val.length; i < len; i++) {
|
||||
const charCode = val.charCodeAt(i);
|
||||
if (charCode > maxCharCode) {
|
||||
maxCharCode = charCode;
|
||||
}
|
||||
}
|
||||
return maxCharCode;
|
||||
}
|
||||
const maxCharCode = Math.max(getMaxCharCode(originalValue), getMaxCharCode(newValue));
|
||||
function getUniqueCharCode(id: number): number {
|
||||
if (id < 0) {
|
||||
throw new Error('unexpected');
|
||||
}
|
||||
return maxCharCode + id + 1;
|
||||
}
|
||||
|
||||
function getElements(source: string): Int32Array {
|
||||
let level = 0;
|
||||
let group = 0;
|
||||
const characters = new Int32Array(source.length);
|
||||
for (let i = 0, len = source.length; i < len; i++) {
|
||||
const id = group * 100 + level;
|
||||
|
||||
// TODO support more brackets
|
||||
if (source[i] === '(') {
|
||||
characters[i] = getUniqueCharCode(2 * id);
|
||||
level++;
|
||||
} else if (source[i] === ')') {
|
||||
characters[i] = getUniqueCharCode(2 * id + 1);
|
||||
if (level === 1) {
|
||||
group++;
|
||||
}
|
||||
level = Math.max(level - 1, 0);
|
||||
} else {
|
||||
characters[i] = source.charCodeAt(i);
|
||||
}
|
||||
}
|
||||
return characters;
|
||||
}
|
||||
|
||||
const elements1 = getElements(originalValue);
|
||||
const elements2 = getElements(newValue);
|
||||
|
||||
return new LcsDiff({ getElements: () => elements1 }, { getElements: () => elements2 }).ComputeDiff(false).changes;
|
||||
}
|
||||
|
||||
export interface LiveInlineCompletion extends NormalizedInlineCompletion {
|
||||
sourceProvider: InlineCompletionsProvider;
|
||||
sourceInlineCompletion: InlineCompletion;
|
||||
|
@ -646,7 +505,7 @@ function getDefaultRange(position: Position, model: ITextModel): Range {
|
|||
: Range.fromPositions(position, position.with(undefined, maxColumn));
|
||||
}
|
||||
|
||||
async function provideInlineCompletions(
|
||||
export async function provideInlineCompletions(
|
||||
position: Position,
|
||||
model: ITextModel,
|
||||
context: InlineCompletionContext,
|
||||
|
|
|
@ -1,231 +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 { RunOnceScheduler } from 'vs/base/common/async';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
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/ghostText';
|
||||
import { inlineCompletionToGhostText, NormalizedInlineCompletion } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
|
||||
import { SnippetParser } from 'vs/editor/contrib/snippet/snippetParser';
|
||||
import { SnippetSession } from 'vs/editor/contrib/snippet/snippetSession';
|
||||
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;
|
||||
private _isActive: boolean = false;
|
||||
private isShiftKeyPressed = false;
|
||||
private currentCompletion: NormalizedInlineCompletion | undefined;
|
||||
|
||||
public override minReservedLineCount: number = 0;
|
||||
|
||||
public get isActive() { return this._isActive; }
|
||||
|
||||
// This delay fixes an suggest widget issue when typing "." immediately restarts the suggestion session.
|
||||
private setInactiveDelayed = this._register(new RunOnceScheduler(() => {
|
||||
if (!this.isSuggestWidgetVisible) {
|
||||
if (this.isActive) {
|
||||
this._isActive = false;
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
}, 100));
|
||||
|
||||
constructor(
|
||||
editor: IActiveCodeEditor
|
||||
) {
|
||||
super(editor);
|
||||
|
||||
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._isActive = true;
|
||||
this.updateFromSuggestion();
|
||||
}));
|
||||
this._register(suggestController.widget.value.onDidHide(() => {
|
||||
this.isSuggestWidgetVisible = false;
|
||||
this.setInactiveDelayed.schedule();
|
||||
this.minReservedLineCount = 0;
|
||||
this.updateFromSuggestion();
|
||||
}));
|
||||
this._register(suggestController.widget.value.onDidFocus(() => {
|
||||
this.isSuggestWidgetVisible = true;
|
||||
this._isActive = true;
|
||||
this.updateFromSuggestion();
|
||||
}));
|
||||
};
|
||||
|
||||
this._register(Event.once(suggestController.model.onDidTrigger)(e => {
|
||||
bindToSuggestWidget();
|
||||
}));
|
||||
}
|
||||
this.updateFromSuggestion();
|
||||
|
||||
this._register(this.editor.onDidChangeCursorPosition((e) => {
|
||||
if (this.isSuggestionPreviewEnabled()) {
|
||||
this.minReservedLineCount = 0;
|
||||
this.update();
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
const suggestController = SuggestController.get(this.editor);
|
||||
if (suggestController) {
|
||||
suggestController.stopForceRenderingAbove();
|
||||
}
|
||||
}));
|
||||
|
||||
// See the command acceptAlternativeSelectedSuggestion that is bound to shift+tab
|
||||
this._register(editor.onKeyDown(e => {
|
||||
if (e.shiftKey && !this.isShiftKeyPressed) {
|
||||
this.isShiftKeyPressed = true;
|
||||
this.updateFromSuggestion();
|
||||
}
|
||||
}));
|
||||
this._register(editor.onKeyUp(e => {
|
||||
if (e.shiftKey && this.isShiftKeyPressed) {
|
||||
this.isShiftKeyPressed = false;
|
||||
this.updateFromSuggestion();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public override setExpanded(expanded: boolean): void {
|
||||
super.setExpanded(expanded);
|
||||
this.updateFromSuggestion();
|
||||
}
|
||||
|
||||
private isSuggestionPreviewEnabled(): boolean {
|
||||
const suggestOptions = this.editor.getOption(EditorOption.suggest);
|
||||
return suggestOptions.preview;
|
||||
}
|
||||
|
||||
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,
|
||||
this.isShiftKeyPressed
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private setCurrentInlineCompletion(completion: NormalizedInlineCompletion | undefined): void {
|
||||
this.currentCompletion = completion;
|
||||
this.update();
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
const completion = this.currentCompletion;
|
||||
const mode = this.editor.getOptions().get(EditorOption.suggest).previewMode;
|
||||
|
||||
this.setGhostText(
|
||||
completion
|
||||
? (
|
||||
inlineCompletionToGhostText(completion, this.editor.getModel(), mode, this.editor.getPosition()) ||
|
||||
// Show an invisible ghost text to reserve space
|
||||
new GhostText(completion.range.endLineNumber, [], this.minReservedLineCount)
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
private setGhostText(newGhostText: GhostText | undefined): void {
|
||||
if (GhostText.equals(this.currentGhostText, newGhostText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentGhostText = newGhostText;
|
||||
|
||||
if (this.currentGhostText && this.expanded) {
|
||||
function sum(arr: number[]): number {
|
||||
return arr.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
this.minReservedLineCount = Math.max(this.minReservedLineCount, sum(this.currentGhostText.parts.map(p => p.lines.length - 1)));
|
||||
}
|
||||
|
||||
const suggestController = SuggestController.get(this.editor);
|
||||
if (suggestController) {
|
||||
if (this.minReservedLineCount >= 1 && this.isSuggestionPreviewEnabled()) {
|
||||
suggestController.forceRenderingAbove();
|
||||
} else {
|
||||
suggestController.stopForceRenderingAbove();
|
||||
}
|
||||
}
|
||||
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
|
||||
public override get ghostText(): GhostText | undefined {
|
||||
return this.isSuggestionPreviewEnabled()
|
||||
? this.currentGhostText
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getInlineCompletion(suggestController: SuggestController, position: Position, suggestion: ISelectedSuggestion, toggleMode: boolean): 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) {
|
||||
const snippet = new SnippetParser().parse(insertText);
|
||||
const model = suggestController.editor.getModel()!;
|
||||
SnippetSession.adjustWhitespace(
|
||||
model, position, snippet,
|
||||
true,
|
||||
true
|
||||
);
|
||||
insertText = snippet.toString();
|
||||
}
|
||||
|
||||
const info = suggestController.getOverwriteInfo(item, toggleMode);
|
||||
return {
|
||||
text: insertText,
|
||||
range: Range.fromPositions(
|
||||
position.delta(0, -info.overwriteBefore),
|
||||
position.delta(0, Math.max(info.overwriteAfter, 0))
|
||||
),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RunOnceScheduler } from 'vs/base/common/async';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { Disposable } 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 { SnippetParser } from 'vs/editor/contrib/snippet/snippetParser';
|
||||
import { SnippetSession } from 'vs/editor/contrib/snippet/snippetSession';
|
||||
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
|
||||
import { ISelectedSuggestion } from 'vs/editor/contrib/suggest/suggestWidget';
|
||||
import { NormalizedInlineCompletion } from './inlineCompletionToGhostText';
|
||||
|
||||
export interface SuggestWidgetState {
|
||||
/**
|
||||
* Represents the currently selected item in the suggest widget as inline completion, if possible.
|
||||
*/
|
||||
selectedItemAsInlineCompletion: NormalizedInlineCompletion | undefined;
|
||||
}
|
||||
|
||||
export class SuggestWidgetInlineCompletionProvider extends Disposable {
|
||||
private isSuggestWidgetVisible: boolean = false;
|
||||
private isShiftKeyPressed = false;
|
||||
private _isActive = false;
|
||||
private _currentInlineCompletion: NormalizedInlineCompletion | undefined = undefined;
|
||||
private readonly onDidChangeEmitter = new Emitter<void>();
|
||||
|
||||
public readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
|
||||
// This delay fixes a suggest widget issue when typing "." immediately restarts the suggestion session.
|
||||
private readonly setInactiveDelayed = this._register(new RunOnceScheduler(() => {
|
||||
if (!this.isSuggestWidgetVisible) {
|
||||
if (this._isActive) {
|
||||
this._isActive = false;
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
}, 100));
|
||||
|
||||
/**
|
||||
* Returns undefined if the suggest widget is not active.
|
||||
*/
|
||||
get state(): SuggestWidgetState | undefined {
|
||||
if (!this._isActive) {
|
||||
return undefined;
|
||||
}
|
||||
return { selectedItemAsInlineCompletion: this._currentInlineCompletion };
|
||||
}
|
||||
|
||||
constructor(private readonly editor: IActiveCodeEditor) {
|
||||
super();
|
||||
|
||||
// See the command acceptAlternativeSelectedSuggestion that is bound to shift+tab
|
||||
this._register(editor.onKeyDown(e => {
|
||||
if (e.shiftKey && !this.isShiftKeyPressed) {
|
||||
this.isShiftKeyPressed = true;
|
||||
this.update(this._isActive);
|
||||
}
|
||||
}));
|
||||
this._register(editor.onKeyUp(e => {
|
||||
if (e.shiftKey && this.isShiftKeyPressed) {
|
||||
this.isShiftKeyPressed = false;
|
||||
this.update(this._isActive);
|
||||
}
|
||||
}));
|
||||
|
||||
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.update(true);
|
||||
}));
|
||||
this._register(suggestController.widget.value.onDidHide(() => {
|
||||
this.isSuggestWidgetVisible = false;
|
||||
this.setInactiveDelayed.schedule();
|
||||
this.update(this._isActive);
|
||||
}));
|
||||
this._register(suggestController.widget.value.onDidFocus(() => {
|
||||
this.isSuggestWidgetVisible = true;
|
||||
this.update(true);
|
||||
}));
|
||||
};
|
||||
|
||||
this._register(Event.once(suggestController.model.onDidTrigger)(e => {
|
||||
bindToSuggestWidget();
|
||||
}));
|
||||
}
|
||||
this.update(this._isActive);
|
||||
}
|
||||
|
||||
private update(newActive: boolean): void {
|
||||
const newInlineCompletion = this.getInlineCompletion();
|
||||
let shouldFire = false;
|
||||
if (this._currentInlineCompletion !== newInlineCompletion) {
|
||||
this._currentInlineCompletion = newInlineCompletion;
|
||||
shouldFire = true;
|
||||
}
|
||||
if (this._isActive !== newActive) {
|
||||
this._isActive = newActive;
|
||||
shouldFire = true;
|
||||
}
|
||||
if (shouldFire) {
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
private getInlineCompletion(): NormalizedInlineCompletion | undefined {
|
||||
const suggestController = SuggestController.get(this.editor);
|
||||
if (!suggestController) {
|
||||
return undefined;
|
||||
}
|
||||
if (!this.isSuggestWidgetVisible) {
|
||||
return undefined;
|
||||
}
|
||||
const focusedItem = suggestController.widget.value.getFocusedItem();
|
||||
if (!focusedItem) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// TODO: item.isResolved
|
||||
return suggestionToInlineCompletion(
|
||||
suggestController,
|
||||
this.editor.getPosition(),
|
||||
focusedItem,
|
||||
this.isShiftKeyPressed
|
||||
);
|
||||
}
|
||||
|
||||
public stopForceRenderingAbove(): void {
|
||||
const suggestController = SuggestController.get(this.editor);
|
||||
if (suggestController) {
|
||||
suggestController.stopForceRenderingAbove();
|
||||
}
|
||||
}
|
||||
|
||||
public forceRenderingAbove(): void {
|
||||
const suggestController = SuggestController.get(this.editor);
|
||||
if (suggestController) {
|
||||
suggestController.forceRenderingAbove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function suggestionToInlineCompletion(suggestController: SuggestController, position: Position, suggestion: ISelectedSuggestion, toggleMode: boolean): 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) {
|
||||
const snippet = new SnippetParser().parse(insertText);
|
||||
const model = suggestController.editor.getModel()!;
|
||||
SnippetSession.adjustWhitespace(
|
||||
model, position, snippet,
|
||||
true,
|
||||
true
|
||||
);
|
||||
insertText = snippet.toString();
|
||||
}
|
||||
|
||||
const info = suggestController.getOverwriteInfo(item, toggleMode);
|
||||
return {
|
||||
text: insertText,
|
||||
range: Range.fromPositions(
|
||||
position.delta(0, -info.overwriteBefore),
|
||||
position.delta(0, Math.max(info.overwriteAfter, 0))
|
||||
),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { createCancelablePromise, RunOnceScheduler } from 'vs/base/common/async';
|
||||
import { onUnexpectedError } from 'vs/base/common/errors';
|
||||
import { MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { InlineCompletionTriggerKind, SelectedSuggestionInfo } from 'vs/editor/common/modes';
|
||||
import { SharedInlineCompletionCache } from 'vs/editor/contrib/inlineCompletions/ghostTextModel';
|
||||
import { BaseGhostTextWidgetModel, GhostText } from './ghostText';
|
||||
import { provideInlineCompletions, UpdateOperation } from './inlineCompletionsModel';
|
||||
import { inlineCompletionToGhostText, NormalizedInlineCompletion } from './inlineCompletionToGhostText';
|
||||
import { SuggestWidgetInlineCompletionProvider } from './suggestWidgetInlineCompletionProvider';
|
||||
|
||||
export class SuggestWidgetPreviewModel extends BaseGhostTextWidgetModel {
|
||||
private readonly suggestionInlineCompletionSource = this._register(new SuggestWidgetInlineCompletionProvider(this.editor));
|
||||
private readonly updateOperation = this._register(new MutableDisposable<UpdateOperation>());
|
||||
private readonly updateCacheSoon = this._register(new RunOnceScheduler(() => this.updateCache(), 50));
|
||||
|
||||
public override minReservedLineCount: number = 0;
|
||||
|
||||
public get isActive(): boolean {
|
||||
return this.suggestionInlineCompletionSource.state !== undefined;
|
||||
}
|
||||
|
||||
constructor(
|
||||
editor: IActiveCodeEditor,
|
||||
private readonly cache: SharedInlineCompletionCache,
|
||||
) {
|
||||
super(editor);
|
||||
|
||||
this._register(this.suggestionInlineCompletionSource.onDidChange(() => {
|
||||
const suggestWidgetState = this.suggestionInlineCompletionSource.state;
|
||||
if (!suggestWidgetState) {
|
||||
this.minReservedLineCount = 0;
|
||||
}
|
||||
|
||||
this.updateCacheSoon.schedule();
|
||||
|
||||
if (this.minReservedLineCount >= 1 && this.isSuggestionPreviewEnabled()) {
|
||||
this.suggestionInlineCompletionSource.forceRenderingAbove();
|
||||
} else {
|
||||
this.suggestionInlineCompletionSource.stopForceRenderingAbove();
|
||||
}
|
||||
this.onDidChangeEmitter.fire();
|
||||
}));
|
||||
|
||||
this._register(this.cache.onDidChange(() => {
|
||||
this.onDidChangeEmitter.fire();
|
||||
}));
|
||||
|
||||
this._register(this.editor.onDidChangeCursorPosition((e) => {
|
||||
if (this.isSuggestionPreviewEnabled()) {
|
||||
this.minReservedLineCount = 0;
|
||||
this.updateCacheSoon.schedule();
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(toDisposable(() => this.suggestionInlineCompletionSource.stopForceRenderingAbove()));
|
||||
}
|
||||
|
||||
private isSuggestionPreviewEnabled(): boolean {
|
||||
const suggestOptions = this.editor.getOption(EditorOption.suggest);
|
||||
return suggestOptions.preview;
|
||||
}
|
||||
|
||||
private async updateCache() {
|
||||
const state = this.suggestionInlineCompletionSource.state;
|
||||
if (!state || !state.selectedItemAsInlineCompletion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const info: SelectedSuggestionInfo = {
|
||||
text: state.selectedItemAsInlineCompletion.text,
|
||||
range: state.selectedItemAsInlineCompletion.range,
|
||||
};
|
||||
|
||||
const position = this.editor.getPosition();
|
||||
|
||||
const promise = createCancelablePromise(async token => {
|
||||
let result;
|
||||
try {
|
||||
result = await provideInlineCompletions(position,
|
||||
this.editor.getModel(),
|
||||
{ triggerKind: InlineCompletionTriggerKind.Automatic, selectedSuggestionInfo: info },
|
||||
token
|
||||
);
|
||||
} catch (e) {
|
||||
onUnexpectedError(e);
|
||||
return;
|
||||
}
|
||||
if (token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
this.cache.setValue(
|
||||
this.editor,
|
||||
result,
|
||||
InlineCompletionTriggerKind.Automatic
|
||||
);
|
||||
this.onDidChangeEmitter.fire();
|
||||
});
|
||||
const operation = new UpdateOperation(promise, InlineCompletionTriggerKind.Automatic);
|
||||
this.updateOperation.value = operation;
|
||||
await promise;
|
||||
if (this.updateOperation.value === operation) {
|
||||
this.updateOperation.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public override get ghostText(): GhostText | undefined {
|
||||
function lengthOfLongestCommonPrefix(str1: string, str2: string): number {
|
||||
let i = 0;
|
||||
while (i < str1.length && i < str2.length && str1[i] === str2[i]) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function lengthOfLongestCommonSuffix(str1: string, str2: string): number {
|
||||
let i = 0;
|
||||
while (i < str1.length && i < str2.length && str1[str1.length - i - 1] === str2[str2.length - i - 1]) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function minimizeInlineCompletion(model: ITextModel, inlineCompletion: NormalizedInlineCompletion | undefined): NormalizedInlineCompletion | undefined {
|
||||
if (!inlineCompletion) {
|
||||
return inlineCompletion;
|
||||
}
|
||||
const valueToReplace = model.getValueInRange(inlineCompletion.range);
|
||||
const commonPrefixLength = lengthOfLongestCommonPrefix(valueToReplace, inlineCompletion.text);
|
||||
const start = model.getPositionAt(model.getOffsetAt(inlineCompletion.range.getStartPosition()) + commonPrefixLength);
|
||||
|
||||
const commonSuffixLength = lengthOfLongestCommonSuffix(valueToReplace, inlineCompletion.text);
|
||||
const end = model.getPositionAt(model.getOffsetAt(inlineCompletion.range.getEndPosition()) - commonSuffixLength);
|
||||
|
||||
return {
|
||||
range: Range.fromPositions(start, end),
|
||||
text: inlineCompletion.text.substr(commonPrefixLength, inlineCompletion.text.length - commonPrefixLength - commonSuffixLength),
|
||||
};
|
||||
}
|
||||
|
||||
const suggestWidgetState = this.suggestionInlineCompletionSource.state;
|
||||
|
||||
const originalInlineCompletion = minimizeInlineCompletion(this.editor.getModel()!, suggestWidgetState?.selectedItemAsInlineCompletion);
|
||||
const augmentedCompletion = minimizeInlineCompletion(this.editor.getModel()!, this.cache.value?.completions[0]?.toLiveInlineCompletion());
|
||||
|
||||
const finalCompletion =
|
||||
augmentedCompletion
|
||||
&& originalInlineCompletion
|
||||
&& augmentedCompletion.text.startsWith(originalInlineCompletion.text)
|
||||
&& augmentedCompletion.range.equalsRange(originalInlineCompletion.range)
|
||||
? augmentedCompletion : (originalInlineCompletion || augmentedCompletion);
|
||||
|
||||
const inlineCompletionPreviewLength = (finalCompletion?.text.length || 0) - (originalInlineCompletion?.text.length || 0);
|
||||
|
||||
const toGhostText = (completion: NormalizedInlineCompletion | undefined): GhostText | undefined => {
|
||||
const mode = this.editor.getOptions().get(EditorOption.suggest).previewMode;
|
||||
return completion
|
||||
? (
|
||||
inlineCompletionToGhostText(completion, this.editor.getModel(), mode, this.editor.getPosition(), inlineCompletionPreviewLength) ||
|
||||
// Show an invisible ghost text to reserve space
|
||||
new GhostText(completion.range.endLineNumber, [], this.minReservedLineCount)
|
||||
)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const newGhostText = toGhostText(finalCompletion);
|
||||
|
||||
if (newGhostText) {
|
||||
this.minReservedLineCount = Math.max(this.minReservedLineCount, sum(newGhostText.parts.map(p => p.lines.length - 1)));
|
||||
}
|
||||
|
||||
return this.isSuggestionPreviewEnabled()
|
||||
? newGhostText
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sum(arr: number[]): number {
|
||||
return arr.reduce((a, b) => a + b, 0);
|
||||
}
|
|
@ -9,12 +9,14 @@ import { DisposableStore } from 'vs/base/common/lifecycle';
|
|||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { InlineCompletionsProvider, InlineCompletionsProviderRegistry } from 'vs/editor/common/modes';
|
||||
import { ViewModel } from 'vs/editor/common/viewModel/viewModelImpl';
|
||||
import { InlineCompletionsModel, inlineCompletionToGhostText } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
|
||||
import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/inlineCompletionsModel';
|
||||
import { inlineCompletionToGhostText } from '../inlineCompletionToGhostText';
|
||||
import { GhostTextContext, MockInlineCompletionsProvider } from 'vs/editor/contrib/inlineCompletions/test/utils';
|
||||
import { ITestCodeEditor, TestCodeEditorCreationOptions, withAsyncTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor';
|
||||
import { createTextModel } from 'vs/editor/test/common/editorTestUtils';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
|
||||
import { runWithFakedTimers } from 'vs/editor/contrib/inlineCompletions/test/timeTravelScheduler';
|
||||
import { SharedInlineCompletionCache } from 'vs/editor/contrib/inlineCompletions/ghostTextModel';
|
||||
|
||||
suite('Inline Completions', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
@ -84,7 +86,7 @@ suite('Inline Completions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('Does trigger automatically if disabled', async function () {
|
||||
test('Does not trigger automatically if disabled', async function () {
|
||||
const provider = new MockInlineCompletionsProvider();
|
||||
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
|
||||
{ fakeClock: true, provider, inlineSuggest: { enabled: false } },
|
||||
|
@ -147,13 +149,14 @@ suite('Inline Completions', () => {
|
|||
async ({ editor, editorViewModel, model, context }) => {
|
||||
model.setActive(true);
|
||||
|
||||
context.keyboardType('foo');
|
||||
provider.setReturnValue({ text: 'foobar', range: new Range(1, 1, 1, 4) });
|
||||
context.keyboardType('foo');
|
||||
model.trigger();
|
||||
await timeout(1000);
|
||||
|
||||
provider.setReturnValue({ text: 'foobizz', range: new Range(1, 1, 1, 6) });
|
||||
context.keyboardType('bi');
|
||||
context.keyboardType('b');
|
||||
context.keyboardType('i');
|
||||
await timeout(1000);
|
||||
|
||||
assert.deepStrictEqual(provider.getAndClearCallHistory(), [
|
||||
|
@ -449,11 +452,6 @@ suite('Inline Completions', () => {
|
|||
{ position: '(1,5)', text: 'foob', triggerKind: 0, }
|
||||
]);
|
||||
assert.deepStrictEqual(context.getAndClearViewStates(), [
|
||||
/*
|
||||
TODO: Remove this flickering. Fortunately, it is not visible.
|
||||
It is caused by the text model updating before the cursor position.
|
||||
*/
|
||||
'foob',
|
||||
'foob[ar]',
|
||||
'foob[az]'
|
||||
]);
|
||||
|
@ -506,7 +504,8 @@ async function withAsyncTestCodeEditorAndInlineCompletionsModel<T>(
|
|||
|
||||
let result: T;
|
||||
await withAsyncTestCodeEditor(text, options, async (editor, editorViewModel, instantiationService) => {
|
||||
const model = instantiationService.createInstance(InlineCompletionsModel, editor);
|
||||
const cache = disposableStore.add(new SharedInlineCompletionCache());
|
||||
const model = instantiationService.createInstance(InlineCompletionsModel, editor, cache);
|
||||
const context = new GhostTextContext(model, editor);
|
||||
result = await callback({ editor, editorViewModel, model, context });
|
||||
context.dispose();
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { SuggestWidgetAdapterModel } from 'vs/editor/contrib/inlineCompletions/suggestWidgetAdapterModel';
|
||||
import { SuggestWidgetPreviewModel } from 'vs/editor/contrib/inlineCompletions/suggestWidgetPreviewModel';
|
||||
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
|
||||
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
|
||||
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
|
||||
|
@ -27,6 +27,7 @@ import assert = require('assert');
|
|||
import { GhostTextContext } from 'vs/editor/contrib/inlineCompletions/test/utils';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { runWithFakedTimers } from 'vs/editor/contrib/inlineCompletions/test/timeTravelScheduler';
|
||||
import { SharedInlineCompletionCache } from 'vs/editor/contrib/inlineCompletions/ghostTextModel';
|
||||
|
||||
suite('Suggest Widget Model', () => {
|
||||
test('Active', async () => {
|
||||
|
@ -69,11 +70,11 @@ suite('Suggest Widget Model', () => {
|
|||
const suggestController = (editor.getContribution(SuggestController.ID) as SuggestController);
|
||||
suggestController.triggerSuggest();
|
||||
await timeout(1000);
|
||||
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'h[ello]']);
|
||||
assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'h', 'h[ello]']);
|
||||
|
||||
context.keyboardType('.');
|
||||
await timeout(1000);
|
||||
assert.deepStrictEqual(context.getAndClearViewStates(), ['hello', 'hello.[hello]']);
|
||||
assert.deepStrictEqual(context.getAndClearViewStates(), ['hello', 'hello.', 'hello.[hello]']);
|
||||
|
||||
suggestController.cancelSuggestWidget();
|
||||
|
||||
|
@ -107,7 +108,7 @@ const provider: CompletionItemProvider = {
|
|||
async function withAsyncTestCodeEditorAndInlineCompletionsModel(
|
||||
text: string,
|
||||
options: TestCodeEditorCreationOptions & { provider?: CompletionItemProvider, fakeClock?: boolean, serviceCollection?: never },
|
||||
callback: (args: { editor: ITestCodeEditor, editorViewModel: ViewModel, model: SuggestWidgetAdapterModel, context: GhostTextContext }) => Promise<void>
|
||||
callback: (args: { editor: ITestCodeEditor, editorViewModel: ViewModel, model: SuggestWidgetPreviewModel, context: GhostTextContext }) => Promise<void>
|
||||
): Promise<void> {
|
||||
await runWithFakedTimers({ useFakeTimers: options.fakeClock }, async () => {
|
||||
const disposableStore = new DisposableStore();
|
||||
|
@ -145,7 +146,8 @@ async function withAsyncTestCodeEditorAndInlineCompletionsModel(
|
|||
await withAsyncTestCodeEditor(text, { ...options, serviceCollection }, async (editor, editorViewModel, instantiationService) => {
|
||||
editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2);
|
||||
editor.registerAndInstantiateContribution(SuggestController.ID, SuggestController);
|
||||
const model = instantiationService.createInstance(SuggestWidgetAdapterModel, editor);
|
||||
const cache = disposableStore.add(new SharedInlineCompletionCache());
|
||||
const model = instantiationService.createInstance(SuggestWidgetPreviewModel, editor, cache);
|
||||
const context = new GhostTextContext(model, editor);
|
||||
await callback({ editor, editorViewModel, model, context });
|
||||
model.dispose();
|
||||
|
|
6
src/vs/monaco.d.ts
vendored
6
src/vs/monaco.d.ts
vendored
|
@ -5936,6 +5936,12 @@ declare namespace monaco.languages {
|
|||
* How the completion was triggered.
|
||||
*/
|
||||
readonly triggerKind: InlineCompletionTriggerKind;
|
||||
readonly selectedSuggestionInfo: SelectedSuggestionInfo | undefined;
|
||||
}
|
||||
|
||||
export interface SelectedSuggestionInfo {
|
||||
range: IRange;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface InlineCompletion {
|
||||
|
|
12
src/vs/vscode.proposed.d.ts
vendored
12
src/vs/vscode.proposed.d.ts
vendored
|
@ -2415,6 +2415,18 @@ declare module 'vscode' {
|
|||
* How the completion was triggered.
|
||||
*/
|
||||
readonly triggerKind: InlineCompletionTriggerKind;
|
||||
|
||||
/**
|
||||
* Provides information about the currently selected item in the suggest widget.
|
||||
* If set, provided inline completions must extend the text of the selected item
|
||||
* and use the same range, otherwise they are not shown as preview.
|
||||
*/
|
||||
readonly selectedSuggestionInfo: SelectedSuggestionInfo | undefined;
|
||||
}
|
||||
|
||||
export interface SelectedSuggestionInfo {
|
||||
range: Range;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1033,11 +1033,20 @@ class InlineCompletionAdapter {
|
|||
private readonly _commands: CommandsConverter,
|
||||
) { }
|
||||
|
||||
public async provideInlineCompletions(resource: URI, position: IPosition, context: vscode.InlineCompletionContext, token: CancellationToken): Promise<extHostProtocol.IdentifiableInlineCompletions | undefined> {
|
||||
public async provideInlineCompletions(resource: URI, position: IPosition, context: modes.InlineCompletionContext, token: CancellationToken): Promise<extHostProtocol.IdentifiableInlineCompletions | undefined> {
|
||||
const doc = this._documents.getDocument(resource);
|
||||
const pos = typeConvert.Position.to(position);
|
||||
|
||||
const result = await this._provider.provideInlineCompletionItems(doc, pos, context, token);
|
||||
const result = await this._provider.provideInlineCompletionItems(doc, pos, {
|
||||
selectedSuggestionInfo:
|
||||
context.selectedSuggestionInfo
|
||||
? {
|
||||
range: typeConvert.Range.to(context.selectedSuggestionInfo.range),
|
||||
text: context.selectedSuggestionInfo.text
|
||||
}
|
||||
: undefined,
|
||||
triggerKind: context.triggerKind
|
||||
}, token);
|
||||
|
||||
if (!result) {
|
||||
// undefined and null are valid results
|
||||
|
|
Loading…
Reference in a new issue