Refactors inline completions model.

This commit is contained in:
Henning Dieterichs 2021-05-27 22:09:48 +02:00
parent ccedcbdf5e
commit a9c54044e5
No known key found for this signature in database
GPG key ID: 771381EFFDB9EC06
2 changed files with 152 additions and 123 deletions

View file

@ -37,6 +37,7 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
this.hide();
}
setTimeout(() => {
// Wait for the cursor update that happens in the same iteration loop iteration
this.startSessionIfTriggered();
}, 0);
}));
@ -101,50 +102,25 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge
}
public commitCurrentSuggestion(): void {
if (this.session) {
this.session.commitCurrentCompletion();
}
this.session?.commitCurrentCompletion();
}
public showNextInlineCompletion(): void {
if (this.session) {
this.session.showNextInlineCompletion();
}
this.session?.showNextInlineCompletion();
}
public showPreviousInlineCompletion(): void {
if (this.session) {
this.session.showPreviousInlineCompletion();
}
}
}
class CachedInlineCompletion {
public readonly semanticId: string = JSON.stringify({
text: this.inlineCompletion.text,
startLine: this.inlineCompletion.range.startLineNumber,
startColumn: this.inlineCompletion.range.startColumn,
command: this.inlineCompletion.command
});
public lastRange: Range;
constructor(
public readonly inlineCompletion: LiveInlineCompletion,
public readonly decorationId: string,
) {
this.lastRange = inlineCompletion.range;
this.session?.showPreviousInlineCompletion();
}
}
class InlineCompletionsSession extends BaseGhostTextWidgetModel {
public readonly minReservedLineCount = 0;
private updatePromise: CancelablePromise<void> | undefined = undefined;
private cachedCompletions: CachedInlineCompletion[] | undefined = undefined;
private fetchMoreContext = false;
private cachedCompletionsSource: LiveInlineCompletions | undefined = undefined;
private readonly updateOperation = this._register(new MutableDisposable<UpdateOperation>());
private readonly cache = this._register(new MutableDisposable<SynchronizedInlineCompletionsCache>());
private updateSoon = this._register(new RunOnceScheduler(() => this.update(), 50));
private updateSoon = this._register(new RunOnceScheduler(() => this.update(InlineCompletionTriggerKind.Automatic), 50));
private readonly textModel = this.editor.getModel();
constructor(
@ -154,10 +130,6 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
private readonly commandService: ICommandService,
) {
super(editor);
this._register(toDisposable(() => {
this.clearUpdatePromise();
this.clearCache();
}));
let lastCompletionItem: InlineCompletion | undefined = undefined;
this._register(this.onDidChange(() => {
@ -172,29 +144,25 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
}));
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) => {
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();
}));
@ -207,15 +175,15 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
private currentlySelectedCompletionId: string | undefined = undefined;
private fixAndGetIndexOfCurrentSelection(): number {
if (!this.currentlySelectedCompletionId || !this.cachedCompletions) {
if (!this.currentlySelectedCompletionId || !this.cache.value) {
return 0;
}
if (this.cachedCompletions.length === 0) {
if (this.cache.value.completions.length === 0) {
// don't reset the selection in this case
return 0;
}
const idx = this.cachedCompletions.findIndex(v => v.semanticId === this.currentlySelectedCompletionId);
const idx = this.cache.value.completions.findIndex(v => v.semanticId === this.currentlySelectedCompletionId);
if (idx === -1) {
// Reset the selection so that the selection does not jump back when it appears again
this.currentlySelectedCompletionId = undefined;
@ -225,19 +193,19 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
private get currentCachedCompletion(): CachedInlineCompletion | undefined {
if (!this.cachedCompletions) {
if (!this.cache.value) {
return undefined;
}
return this.cachedCompletions[this.fixAndGetIndexOfCurrentSelection()];
return this.cache.value.completions[this.fixAndGetIndexOfCurrentSelection()];
}
public async showNextInlineCompletion(): Promise<void> {
await this.ensureUpdateWithExplicitContext();
this.fetchMoreContext = true;
if (this.cachedCompletions && this.cachedCompletions.length > 0) {
const newIdx = (this.fixAndGetIndexOfCurrentSelection() + 1) % this.cachedCompletions.length;
this.currentlySelectedCompletionId = this.cachedCompletions[newIdx].semanticId;
const completions = this.cache.value?.completions || [];
if (completions.length > 0) {
const newIdx = (this.fixAndGetIndexOfCurrentSelection() + 1) % completions.length;
this.currentlySelectedCompletionId = completions[newIdx].semanticId;
} else {
this.currentlySelectedCompletionId = undefined;
}
@ -247,9 +215,10 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
public async showPreviousInlineCompletion(): Promise<void> {
await this.ensureUpdateWithExplicitContext();
if (this.cachedCompletions && this.cachedCompletions.length > 0) {
const newIdx = (this.fixAndGetIndexOfCurrentSelection() + this.cachedCompletions.length - 1) % this.cachedCompletions.length;
this.currentlySelectedCompletionId = this.cachedCompletions[newIdx].semanticId;
const completions = this.cache.value?.completions || [];
if (completions.length > 0) {
const newIdx = (this.fixAndGetIndexOfCurrentSelection() + completions.length - 1) % completions.length;
this.currentlySelectedCompletionId = completions[newIdx].semanticId;
} else {
this.currentlySelectedCompletionId = undefined;
}
@ -257,16 +226,16 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
private async ensureUpdateWithExplicitContext(): Promise<void> {
if (this.fetchMoreContext) {
if (this.updatePromise) {
await this.updatePromise;
if (this.updateOperation.value) {
// Restart or wait for current update operation
if (this.updateOperation.value.triggerKind === InlineCompletionTriggerKind.Explicit) {
await this.updateOperation.value.promise;
} else {
await this.update(InlineCompletionTriggerKind.Explicit);
}
} else {
if (this.updatePromise) {
this.clearUpdatePromise();
}
this.fetchMoreContext = true;
await this.update();
} else if (this.cache.value?.triggerKind !== InlineCompletionTriggerKind.Explicit) {
// Refresh cache
await this.update(InlineCompletionTriggerKind.Explicit);
}
}
@ -284,7 +253,7 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
return {
text: completion.inlineCompletion.text,
range: completion.lastRange,
range: completion.synchronizedRange,
command: completion.inlineCompletion.command,
sourceProvider: completion.inlineCompletion.sourceProvider,
sourceInlineCompletions: completion.inlineCompletion.sourceInlineCompletions,
@ -297,23 +266,22 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
public scheduleAutomaticUpdate(): void {
this.fetchMoreContext = false;
this.updateSoon.schedule();
}
private async update(): Promise<void> {
private async update(triggerKind: InlineCompletionTriggerKind): Promise<void> {
if (!this.shouldUpdate()) {
return;
}
const position = this.editor.getPosition();
this.clearUpdatePromise();
this.updatePromise = createCancelablePromise(async token => {
const promise = createCancelablePromise(async token => {
let result;
try {
result = await provideInlineCompletions(position,
this.editor.getModel(),
{ triggerKind: this.fetchMoreContext ? InlineCompletionTriggerKind.Explicit : InlineCompletionTriggerKind.Automatic },
{ triggerKind },
token
);
} catch (e) {
@ -325,46 +293,19 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
return;
}
this.cachedCompletions = [];
const decorationIds = this.editor.deltaDecorations(
(this.cachedCompletions || []).map(c => c.decorationId),
(result.items).map(i => ({
range: i.range,
options: {
description: 'inline-completion-tracking-range'
},
}))
this.cache.value = new SynchronizedInlineCompletionsCache(
this.editor,
result,
() => this.onDidChangeEmitter.fire(),
triggerKind
);
this.cachedCompletionsSource?.dispose();
this.cachedCompletionsSource = result;
this.cachedCompletions = result.items.map((item, idx) => new CachedInlineCompletion(item, decorationIds[idx]));
this.onDidChangeEmitter.fire();
this.updatePromise = undefined;
});
await this.updatePromise;
}
private clearCache(): void {
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;
}
}
private clearUpdatePromise(): void {
if (this.updatePromise) {
this.updatePromise.cancel();
this.updatePromise = undefined;
const operation = new UpdateOperation(promise, triggerKind);
this.updateOperation.value = operation;
await promise;
if (this.updateOperation.value === operation) {
this.updateOperation.clear();
}
}
@ -380,7 +321,7 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
public commit(completion: LiveInlineCompletion): void {
this.clearCache();
this.cache.clear();
this.editor.executeEdits(
'inlineCompletions.accept',
[
@ -395,6 +336,88 @@ class InlineCompletionsSession extends BaseGhostTextWidgetModel {
}
}
class UpdateOperation implements IDisposable {
constructor(public readonly promise: CancelablePromise<void>, public readonly triggerKind: InlineCompletionTriggerKind) {
}
dispose() {
this.promise.cancel();
}
}
/**
* 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 {
public readonly completions: readonly CachedInlineCompletion[];
constructor(
editor: IActiveCodeEditor,
completionsSource: LiveInlineCompletions,
onChange: () => void,
public readonly triggerKind: InlineCompletionTriggerKind,
) {
super();
const decorationIds = editor.deltaDecorations(
[],
completionsSource.items.map(i => ({
range: i.range,
options: {
description: 'inline-completion-tracking-range'
},
}))
);
this._register(toDisposable(() => {
editor.deltaDecorations(decorationIds, []);
}));
this.completions = completionsSource.items.map((c, idx) => new CachedInlineCompletion(c, decorationIds[idx]));
this._register(editor.onDidChangeModelContent(() => {
let hasChanged = false;
const model = editor.getModel();
for (const c of this.completions) {
const newRange = model.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) {
onChange();
}
}));
this._register(completionsSource);
}
}
class CachedInlineCompletion {
public readonly semanticId: string = JSON.stringify({
text: this.inlineCompletion.text,
startLine: this.inlineCompletion.range.startLineNumber,
startColumn: this.inlineCompletion.range.startColumn,
command: this.inlineCompletion.command
});
/**
* The range, synchronized with text model changes.
*/
public synchronizedRange: Range;
constructor(
public readonly inlineCompletion: LiveInlineCompletion,
public readonly decorationId: string,
) {
this.synchronizedRange = inlineCompletion.range;
}
}
export interface NormalizedInlineCompletion extends InlineCompletion {
range: Range;
}

View file

@ -83,7 +83,13 @@ export class SuggestWidgetAdapterModel extends BaseGhostTextWidgetModel {
}
// TODO: item.isResolved
this.setCurrentInlineCompletion(getInlineCompletion(suggestController, this.editor.getPosition(), focusedItem));
this.setCurrentInlineCompletion(
getInlineCompletion(
suggestController,
this.editor.getPosition(),
focusedItem
)
);
}
private setCurrentInlineCompletion(completion: NormalizedInlineCompletion | undefined): void {