From 32d5ad58e7b53c47d754de706cc606c10596b3d5 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Tue, 2 Apr 2024 18:30:59 +0200 Subject: [PATCH] Inline completion refactoring (#209011) --- src/vs/base/common/equals.ts | 38 +++++++ src/vs/base/common/observableInternal/base.ts | 37 ++++-- .../base/common/observableInternal/derived.ts | 20 ++-- .../base/common/observableInternal/promise.ts | 5 +- .../components/diffEditorEditors.ts | 2 +- src/vs/editor/common/core/textLength.ts | 7 ++ .../browser/hoverParticipant.ts | 2 +- .../browser/inlineCompletionsController.ts | 5 +- .../browser/inlineCompletionsHintsWidget.ts | 11 +- .../browser/inlineCompletionsModel.ts | 49 ++++---- .../browser/inlineCompletionsSource.ts | 106 +++++------------- 11 files changed, 149 insertions(+), 133 deletions(-) create mode 100644 src/vs/base/common/equals.ts diff --git a/src/vs/base/common/equals.ts b/src/vs/base/common/equals.ts new file mode 100644 index 0000000000000..02f943d69647f --- /dev/null +++ b/src/vs/base/common/equals.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as arrays from 'vs/base/common/arrays'; + +export type EqualityComparer = (a: T, b: T) => boolean; +export const strictEquals: EqualityComparer = (a, b) => a === b; + +/** + * Checks if the items of two arrays are equal. + * By default, strict equality is used to compare elements, but a custom equality comparer can be provided. + */ +export function itemsEquals(itemEquals: EqualityComparer = strictEquals): EqualityComparer { + return (a, b) => arrays.equals(a, b, itemEquals); +} + +/** + * Two items are considered equal, if their stringified representations are equal. +*/ +export function jsonStringifyEquals(): EqualityComparer { + return (a, b) => JSON.stringify(a) === JSON.stringify(b); +} + +/** + * Uses `item.equals(other)` to determine equality. + */ +export function itemEquals(): EqualityComparer { + return (a, b) => a.equals(b); +} + +export function equalsIfDefined(v1: T | undefined, v2: T | undefined, equals: EqualityComparer): boolean { + if (!v1 || !v2) { + return v1 === v2; + } + return equals(v1, v2); +} diff --git a/src/vs/base/common/observableInternal/base.ts b/src/vs/base/common/observableInternal/base.ts index 4e738e633447b..9f8057ff07300 100644 --- a/src/vs/base/common/observableInternal/base.ts +++ b/src/vs/base/common/observableInternal/base.ts @@ -3,9 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { strictEquals, EqualityComparer } from 'vs/base/common/equals'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { keepObserved, recomputeInitiallyAndOnChange } from 'vs/base/common/observable'; -import { DebugNameData, Owner, getFunctionName } from 'vs/base/common/observableInternal/debugName'; +import { DebugNameData, IDebugNameData, Owner, getFunctionName } from 'vs/base/common/observableInternal/debugName'; import type { derivedOpts } from 'vs/base/common/observableInternal/derived'; import { getLogger } from 'vs/base/common/observableInternal/logging'; @@ -225,6 +226,7 @@ export abstract class ConvenientObservable implements IObservable fn(this.read(reader), reader), ); @@ -370,11 +372,26 @@ export interface ISettableObservable extends IObservable(name: string, initialValue: T): ISettableObservable; export function observableValue(owner: object, initialValue: T): ISettableObservable; export function observableValue(nameOrOwner: string | object, initialValue: T): ISettableObservable { + let debugNameData: DebugNameData; if (typeof nameOrOwner === 'string') { - return new ObservableValue(undefined, nameOrOwner, initialValue); + debugNameData = new DebugNameData(undefined, nameOrOwner, undefined); } else { - return new ObservableValue(nameOrOwner, undefined, initialValue); + debugNameData = new DebugNameData(nameOrOwner, undefined, undefined); } + return new ObservableValue(debugNameData, initialValue, strictEquals); +} + +export function observableValueOpts( + options: IDebugNameData & { + equalsFn?: EqualityComparer; + }, + initialValue: T +): ISettableObservable { + return new ObservableValue( + new DebugNameData(options.owner, options.debugName, undefined), + initialValue, + options.equalsFn ?? strictEquals, + ); } export class ObservableValue @@ -383,13 +400,13 @@ export class ObservableValue protected _value: T; get debugName() { - return new DebugNameData(this._owner, this._debugName, undefined).getDebugName(this) ?? 'ObservableValue'; + return this._debugNameData.getDebugName(this) ?? 'ObservableValue'; } constructor( - private readonly _owner: Owner, - private readonly _debugName: string | undefined, + private readonly _debugNameData: DebugNameData, initialValue: T, + private readonly _equalityComparator: EqualityComparer, ) { super(); this._value = initialValue; @@ -399,7 +416,7 @@ export class ObservableValue } public set(value: T, tx: ITransaction | undefined, change: TChange): void { - if (this._value === value) { + if (this._equalityComparator(this._value, value)) { return; } @@ -437,11 +454,13 @@ export class ObservableValue * When a new value is set, the previous value is disposed. */ export function disposableObservableValue(nameOrOwner: string | object, initialValue: T): ISettableObservable & IDisposable { + let debugNameData: DebugNameData; if (typeof nameOrOwner === 'string') { - return new DisposableObservableValue(undefined, nameOrOwner, initialValue); + debugNameData = new DebugNameData(undefined, nameOrOwner, undefined); } else { - return new DisposableObservableValue(nameOrOwner, undefined, initialValue); + debugNameData = new DebugNameData(nameOrOwner, undefined, undefined); } + return new DisposableObservableValue(debugNameData, initialValue, strictEquals); } export class DisposableObservableValue extends ObservableValue implements IDisposable { diff --git a/src/vs/base/common/observableInternal/derived.ts b/src/vs/base/common/observableInternal/derived.ts index a7b66b44221a2..61a6138a0833e 100644 --- a/src/vs/base/common/observableInternal/derived.ts +++ b/src/vs/base/common/observableInternal/derived.ts @@ -4,14 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { assertFn } from 'vs/base/common/assert'; +import { strictEquals, EqualityComparer } from 'vs/base/common/equals'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; -import { BaseObservable, IChangeContext, IObservable, IObserver, IReader, _setDerivedOpts } from 'vs/base/common/observableInternal/base'; +import { BaseObservable, IChangeContext, IObservable, IObserver, IReader, _setDerivedOpts, } from 'vs/base/common/observableInternal/base'; import { DebugNameData, IDebugNameData, Owner } from 'vs/base/common/observableInternal/debugName'; import { getLogger } from 'vs/base/common/observableInternal/logging'; -export type EqualityComparer = (a: T, b: T) => boolean; -export const defaultEqualityComparer: EqualityComparer = (a, b) => a === b; - /** * Creates an observable that is derived from other observables. * The value is only recomputed when absolutely needed. @@ -28,7 +26,7 @@ export function derived(computeFnOrOwner: ((reader: IReader) => T) | Owner, c undefined, undefined, undefined, - defaultEqualityComparer + strictEquals ); } return new Derived( @@ -37,13 +35,13 @@ export function derived(computeFnOrOwner: ((reader: IReader) => T) | Owner, c undefined, undefined, undefined, - defaultEqualityComparer + strictEquals ); } export function derivedOpts( options: IDebugNameData & { - equalityComparer?: EqualityComparer; + equalsFn?: EqualityComparer; onLastObserverRemoved?: (() => void); }, computeFn: (reader: IReader) => T @@ -54,7 +52,7 @@ export function derivedOpts( undefined, undefined, options.onLastObserverRemoved, - options.equalityComparer ?? defaultEqualityComparer + options.equalsFn ?? strictEquals ); } @@ -87,7 +85,7 @@ export function derivedHandleChanges( options.createEmptyChangeSummary, options.handleChange, undefined, - options.equalityComparer ?? defaultEqualityComparer + options.equalityComparer ?? strictEquals ); } @@ -113,7 +111,7 @@ export function derivedWithStore(computeFnOrOwner: ((reader: IReader, store: }, undefined, undefined, () => store.dispose(), - defaultEqualityComparer + strictEquals ); } @@ -143,7 +141,7 @@ export function derivedDisposable(computeFnOr }, undefined, undefined, () => store.dispose(), - defaultEqualityComparer + strictEquals ); } diff --git a/src/vs/base/common/observableInternal/promise.ts b/src/vs/base/common/observableInternal/promise.ts index 363ffea7d6e36..46d594914d849 100644 --- a/src/vs/base/common/observableInternal/promise.ts +++ b/src/vs/base/common/observableInternal/promise.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { autorun } from 'vs/base/common/observableInternal/autorun'; import { IObservable, IReader, observableValue, transaction } from './base'; -import { Derived, defaultEqualityComparer, derived } from 'vs/base/common/observableInternal/derived'; +import { Derived, derived } from 'vs/base/common/observableInternal/derived'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { DebugNameData, Owner } from 'vs/base/common/observableInternal/debugName'; +import { strictEquals } from 'vs/base/common/equals'; export class ObservableLazy { private readonly _value = observableValue(this, undefined); @@ -181,6 +182,6 @@ export function derivedWithCancellationToken(computeFnOrOwner: ((reader: IRea }, undefined, undefined, () => cancellationTokenSource?.dispose(), - defaultEqualityComparer, + strictEquals, ); } diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts index 2abc8e74badc5..9a23f163d0a04 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts @@ -32,7 +32,7 @@ export class DiffEditorEditors extends Disposable { public readonly modifiedModel = observableFromEvent(this.modified.onDidChangeModel, () => /** @description modified.model */ this.modified.getModel()); public readonly modifiedSelections = observableFromEvent(this.modified.onDidChangeCursorSelection, () => this.modified.getSelections() ?? []); - public readonly modifiedCursor = derivedOpts({ owner: this, equalityComparer: Position.equals }, reader => this.modifiedSelections.read(reader)[0]?.getPosition() ?? new Position(1, 1)); + public readonly modifiedCursor = derivedOpts({ owner: this, equalsFn: Position.equals }, reader => this.modifiedSelections.read(reader)[0]?.getPosition() ?? new Position(1, 1)); public readonly originalCursor = observableFromEvent(this.original.onDidChangeCursorPosition, () => this.original.getPosition() ?? new Position(1, 1)); diff --git a/src/vs/editor/common/core/textLength.ts b/src/vs/editor/common/core/textLength.ts index 632895c55fdbb..bee3897d5fdd0 100644 --- a/src/vs/editor/common/core/textLength.ts +++ b/src/vs/editor/common/core/textLength.ts @@ -71,6 +71,13 @@ export class TextLength { return this.columnCount > other.columnCount; } + public isGreaterThanOrEqualTo(other: TextLength): boolean { + if (this.lineCount !== other.lineCount) { + return this.lineCount > other.lineCount; + } + return this.columnCount >= other.columnCount; + } + public equals(other: TextLength): boolean { return this.lineCount === other.lineCount && this.columnCount === other.columnCount; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts b/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts index 96b27ee7bf54f..90d4ffcba0c12 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts @@ -113,7 +113,7 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan constObservable(null), model.selectedInlineCompletionIndex, model.inlineCompletionsCount, - model.selectedInlineCompletion.map(v => /** @description commands */ v?.inlineCompletion.source.inlineCompletions.commands ?? []), + model.activeCommands, ); context.fragment.appendChild(w.getDomNode()); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts index 320a8f1726482..1ecb464978f3d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts @@ -30,7 +30,8 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { mapObservableArrayCached } from 'vs/base/common/observableInternal/utils'; -import { ISettableObservable } from 'vs/base/common/observableInternal/base'; +import { ISettableObservable, observableValueOpts } from 'vs/base/common/observableInternal/base'; +import { itemsEquals, itemEquals } from 'vs/base/common/equals'; export class InlineCompletionsController extends Disposable { static ID = 'editor.contrib.inlineCompletionsController'; @@ -41,7 +42,7 @@ export class InlineCompletionsController extends Disposable { public readonly model = this._register(disposableObservableValue('inlineCompletionModel', undefined)); private readonly _textModelVersionId = observableValue(this, -1); - private readonly _positions = observableValue(this, [new Position(1, 1)]); + private readonly _positions = observableValueOpts({ owner: this, equalsFn: itemsEquals(itemEquals()) }, [new Position(1, 1)]); private readonly _suggestWidgetAdaptor = this._register(new SuggestWidgetAdaptor( this.editor, () => this.model.get()?.selectedInlineCompletion.get()?.toSingleTextEdit(undefined), diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts index e847839c04205..0bc511b4f7153 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts @@ -78,7 +78,7 @@ export class InlineCompletionsHintsWidget extends Disposable { this.position, model.selectedInlineCompletionIndex, model.inlineCompletionsCount, - model.selectedInlineCompletion.map(v => /** @description commands */ v?.inlineCompletion.source.inlineCompletions.commands ?? []), + model.activeCommands, )); editor.addContentWidget(contentWidget); store.add(toDisposable(() => editor.removeContentWidget(contentWidget))); @@ -151,8 +151,6 @@ export class InlineSuggestionHintsContentWidget extends Disposable implements IC this.previousAction.enabled = this.nextAction.enabled = false; }, 100)); - private lastCommands: Command[] = []; - constructor( private readonly editor: ICodeEditor, private readonly withBorder: boolean, @@ -225,13 +223,6 @@ export class InlineSuggestionHintsContentWidget extends Disposable implements IC this._register(autorun(reader => { /** @description extra commands */ const extraCommands = this._extraCommands.read(reader); - if (equals(this.lastCommands, extraCommands)) { - // nothing to update - return; - } - - this.lastCommands = extraCommands; - const extraActions = extraCommands.map(c => ({ class: undefined, id: c.id, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts index ab627cb1868e6..7cb849da9db45 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts @@ -5,9 +5,10 @@ import { Permutation } from 'vs/base/common/arrays'; import { mapFindFirst } from 'vs/base/common/arraysFind'; +import { itemsEquals } from 'vs/base/common/equals'; import { BugIndicatingError, onUnexpectedError, onUnexpectedExternalError } from 'vs/base/common/errors'; import { Disposable } from 'vs/base/common/lifecycle'; -import { IObservable, IReader, ITransaction, autorun, derived, derivedHandleChanges, derivedOpts, recomputeInitiallyAndOnChange, observableSignal, observableValue, subtransaction, transaction } from 'vs/base/common/observable'; +import { IObservable, IReader, ITransaction, autorun, derived, derivedHandleChanges, derivedOpts, observableSignal, observableValue, recomputeInitiallyAndOnChange, subtransaction, transaction } from 'vs/base/common/observable'; import { commonPrefixLength, splitLinesIncludeSeparators } from 'vs/base/common/strings'; import { isDefined } from 'vs/base/common/types'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; @@ -15,20 +16,20 @@ 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 { Selection } from 'vs/editor/common/core/selection'; -import { InlineCompletionContext, InlineCompletionTriggerKind, PartialAcceptTriggerKind } from 'vs/editor/common/languages'; +import { SingleTextEdit, TextEdit } from 'vs/editor/common/core/textEdit'; +import { TextLength } from 'vs/editor/common/core/textLength'; +import { Command, InlineCompletionContext, InlineCompletionTriggerKind, PartialAcceptTriggerKind } from 'vs/editor/common/languages'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; import { IFeatureDebounceInformation } from 'vs/editor/common/services/languageFeatureDebounce'; import { GhostText, GhostTextOrReplacement, ghostTextOrReplacementEquals, ghostTextsOrReplacementsEqual } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; import { InlineCompletionWithUpdatedRange, InlineCompletionsSource } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource'; -import { SingleTextEdit, TextEdit } from 'vs/editor/common/core/textEdit'; +import { computeGhostText, singleTextEditAugments, singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; import { SuggestItemInfo } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider'; import { addPositions, subtractPositions } from 'vs/editor/contrib/inlineCompletions/browser/utils'; import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { singleTextEditAugments, computeGhostText, singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; -import { TextLength } from 'vs/editor/common/core/textLength'; export enum VersionIdChangeReason { Undo, @@ -40,7 +41,7 @@ export enum VersionIdChangeReason { export class InlineCompletionsModel extends Disposable { private readonly _source = this._register(this._instantiationService.createInstance(InlineCompletionsSource, this.textModel, this.textModelVersionId, this._debounceValue)); private readonly _isActive = observableValue(this, false); - readonly _forceUpdateSignal = observableSignal('forceUpdate'); + readonly _forceUpdateExplicitlySignal = observableSignal(this); // We use a semantic id to keep the same inline completion selected even if the provider reorders the completions. private readonly _selectedInlineCompletionId = observableValue(this, undefined); @@ -65,7 +66,7 @@ export class InlineCompletionsModel extends Disposable { ) { super(); - this._register(recomputeInitiallyAndOnChange(this._fetchInlineCompletions)); + this._register(recomputeInitiallyAndOnChange(this._fetchInlineCompletionsPromise)); let lastItem: InlineCompletionWithUpdatedRange | undefined = undefined; this._register(autorun(reader => { @@ -88,7 +89,8 @@ export class InlineCompletionsModel extends Disposable { VersionIdChangeReason.Undo, VersionIdChangeReason.AcceptWord, ]); - private readonly _fetchInlineCompletions = derivedHandleChanges({ + + private readonly _fetchInlineCompletionsPromise = derivedHandleChanges({ owner: this, createEmptyChangeSummary: () => ({ preserveCurrentCompletion: false, @@ -98,13 +100,13 @@ export class InlineCompletionsModel extends Disposable { /** @description fetch inline completions */ if (ctx.didChange(this.textModelVersionId) && this._preserveCurrentCompletionReasons.has(ctx.change)) { changeSummary.preserveCurrentCompletion = true; - } else if (ctx.didChange(this._forceUpdateSignal)) { - changeSummary.inlineCompletionTriggerKind = ctx.change; + } else if (ctx.didChange(this._forceUpdateExplicitlySignal)) { + changeSummary.inlineCompletionTriggerKind = InlineCompletionTriggerKind.Explicit; } return true; }, }, (reader, changeSummary) => { - this._forceUpdateSignal.read(reader); + this._forceUpdateExplicitlySignal.read(reader); const shouldUpdate = (this._enabled.read(reader) && this.selectedSuggestItem.read(reader)) || this._isActive.read(reader); if (!shouldUpdate) { this._source.cancelUpdate(); @@ -113,10 +115,6 @@ export class InlineCompletionsModel extends Disposable { this.textModelVersionId.read(reader); // Refetch on text change - const itemToPreserveCandidate = this.selectedInlineCompletion.get(); - const itemToPreserve = changeSummary.preserveCurrentCompletion || itemToPreserveCandidate?.forwardStable - ? itemToPreserveCandidate : undefined; - const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.get(); const suggestItem = this.selectedSuggestItem.read(reader); if (suggestWidgetInlineCompletions && !suggestItem) { @@ -135,20 +133,23 @@ export class InlineCompletionsModel extends Disposable { triggerKind: changeSummary.inlineCompletionTriggerKind, selectedSuggestionInfo: suggestItem?.toSelectedSuggestionInfo(), }; + const itemToPreserveCandidate = this.selectedInlineCompletion.get(); + const itemToPreserve = changeSummary.preserveCurrentCompletion || itemToPreserveCandidate?.forwardStable + ? itemToPreserveCandidate : undefined; return this._source.fetch(cursorPosition, context, itemToPreserve); }); public async trigger(tx?: ITransaction): Promise { this._isActive.set(true, tx); - await this._fetchInlineCompletions.get(); + await this._fetchInlineCompletionsPromise.get(); } public async triggerExplicitly(tx?: ITransaction): Promise { subtransaction(tx, tx => { this._isActive.set(true, tx); - this._forceUpdateSignal.trigger(tx, InlineCompletionTriggerKind.Explicit); + this._forceUpdateExplicitlySignal.trigger(tx); }); - await this._fetchInlineCompletions.get(); + await this._fetchInlineCompletionsPromise.get(); } public stop(tx?: ITransaction): void { @@ -158,7 +159,7 @@ export class InlineCompletionsModel extends Disposable { }); } - private readonly _filteredInlineCompletionItems = derived(this, reader => { + private readonly _filteredInlineCompletionItems = derivedOpts({ owner: this, equalsFn: itemsEquals() }, reader => { const c = this._source.inlineCompletions.read(reader); if (!c) { return []; } const cursorPosition = this._primaryPosition.read(reader); @@ -185,6 +186,10 @@ export class InlineCompletionsModel extends Disposable { return filteredCompletions[idx]; }); + public readonly activeCommands = derivedOpts({ owner: this, equalsFn: itemsEquals() }, + r => this.selectedInlineCompletion.read(r)?.inlineCompletion.source.inlineCompletions.commands ?? [] + ); + public readonly lastTriggerKind: IObservable = this._source.inlineCompletions.map(this, v => v?.request.context.triggerKind); @@ -204,7 +209,7 @@ export class InlineCompletionsModel extends Disposable { inlineCompletion: InlineCompletionWithUpdatedRange | undefined; } | undefined>({ owner: this, - equalityComparer: (a, b) => { + equalsFn: (a, b) => { if (!a || !b) { return a === b; } return ghostTextsOrReplacementsEqual(a.ghostTexts, b.ghostTexts) && a.inlineCompletion === b.inlineCompletion @@ -267,7 +272,7 @@ export class InlineCompletionsModel extends Disposable { public readonly ghostTexts = derivedOpts({ owner: this, - equalityComparer: ghostTextsOrReplacementsEqual + equalsFn: ghostTextsOrReplacementsEqual }, reader => { const v = this.state.read(reader); if (!v) { return undefined; } @@ -276,7 +281,7 @@ export class InlineCompletionsModel extends Disposable { public readonly primaryGhostText = derivedOpts({ owner: this, - equalityComparer: ghostTextOrReplacementEquals + equalsFn: ghostTextOrReplacementEquals }, reader => { const v = this.state.read(reader); if (!v) { return undefined; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts index 94a8b33d477aa..32b77dd23fe5e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts @@ -4,18 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { equalsIfDefined, itemEquals } from 'vs/base/common/equals'; import { matchesSubString } from 'vs/base/common/filters'; import { Disposable, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; -import { IObservable, IReader, ITransaction, derived, disposableObservableValue, transaction } from 'vs/base/common/observable'; +import { IObservable, IReader, ITransaction, derivedOpts, disposableObservableValue, transaction } from 'vs/base/common/observable'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; +import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; +import { TextLength } from 'vs/editor/common/core/textLength'; import { InlineCompletionContext, InlineCompletionTriggerKind } from 'vs/editor/common/languages'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; import { IFeatureDebounceInformation } from 'vs/editor/common/services/languageFeatureDebounce'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { InlineCompletionItem, InlineCompletionProviderResult, provideInlineCompletions } from 'vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions'; -import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; import { singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; export class InlineCompletionsSource extends Disposable { @@ -149,20 +151,13 @@ class UpdateRequest { public satisfies(other: UpdateRequest): boolean { return this.position.equals(other.position) - && equals(this.context.selectedSuggestionInfo, other.context.selectedSuggestionInfo, (v1, v2) => v1.equals(v2)) + && equalsIfDefined(this.context.selectedSuggestionInfo, other.context.selectedSuggestionInfo, itemEquals()) && (other.context.triggerKind === InlineCompletionTriggerKind.Automatic || this.context.triggerKind === InlineCompletionTriggerKind.Explicit) && this.versionId === other.versionId; } } -function equals(v1: T | undefined, v2: T | undefined, equals: (v1: T, v2: T) => boolean): boolean { - if (!v1 || !v2) { - return v1 === v2; - } - return equals(v1, v2); -} - class UpdateOperation implements IDisposable { constructor( public readonly request: UpdateRequest, @@ -183,26 +178,13 @@ export class UpToDateInlineCompletions implements IDisposable { private _refCount = 1; private readonly _prependedInlineCompletionItems: InlineCompletionItem[] = []; - private _rangeVersionIdValue = 0; - private readonly _rangeVersionId = derived(this, reader => { - this.versionId.read(reader); - let changed = false; - for (const i of this._inlineCompletions) { - changed = changed || i._updateRange(this.textModel); - } - if (changed) { - this._rangeVersionIdValue++; - } - return this._rangeVersionIdValue; - }); - constructor( private readonly inlineCompletionProviderResult: InlineCompletionProviderResult, public readonly request: UpdateRequest, - private readonly textModel: ITextModel, - private readonly versionId: IObservable, + private readonly _textModel: ITextModel, + private readonly _versionId: IObservable, ) { - const ids = textModel.deltaDecorations([], inlineCompletionProviderResult.completions.map(i => ({ + const ids = _textModel.deltaDecorations([], inlineCompletionProviderResult.completions.map(i => ({ range: i.range, options: { description: 'inline-completion-tracking-range' @@ -210,7 +192,7 @@ export class UpToDateInlineCompletions implements IDisposable { }))); this._inlineCompletions = inlineCompletionProviderResult.completions.map( - (i, index) => new InlineCompletionWithUpdatedRange(i, ids[index], this._rangeVersionId) + (i, index) => new InlineCompletionWithUpdatedRange(i, ids[index], this._textModel, this._versionId) ); } @@ -224,9 +206,9 @@ export class UpToDateInlineCompletions implements IDisposable { if (this._refCount === 0) { setTimeout(() => { // To fix https://github.com/microsoft/vscode/issues/188348 - if (!this.textModel.isDisposed()) { + if (!this._textModel.isDisposed()) { // This is just cleanup. It's ok if it happens with a delay. - this.textModel.deltaDecorations(this._inlineCompletions.map(i => i.decorationId), []); + this._textModel.deltaDecorations(this._inlineCompletions.map(i => i.decorationId), []); } }, 0); this.inlineCompletionProviderResult.dispose(); @@ -241,13 +223,13 @@ export class UpToDateInlineCompletions implements IDisposable { inlineCompletion.source.addRef(); } - const id = this.textModel.deltaDecorations([], [{ + const id = this._textModel.deltaDecorations([], [{ range, options: { description: 'inline-completion-tracking-range' }, }])[0]; - this._inlineCompletions.unshift(new InlineCompletionWithUpdatedRange(inlineCompletion, id, this._rangeVersionId, range)); + this._inlineCompletions.unshift(new InlineCompletionWithUpdatedRange(inlineCompletion, id, this._textModel, this._versionId)); this._prependedInlineCompletionItems.push(inlineCompletion); } } @@ -258,36 +240,38 @@ export class InlineCompletionWithUpdatedRange { this.inlineCompletion.insertText, this.inlineCompletion.range.getStartPosition().toString() ]); - private _updatedRange: Range; - private _isValid = true; public get forwardStable() { return this.inlineCompletion.source.inlineCompletions.enableForwardStability ?? false; } + private readonly _updatedRange = derivedOpts({ owner: this, equalsFn: Range.equalsRange }, reader => { + this._modelVersion.read(reader); + return this._textModel.getDecorationRange(this.decorationId); + }); + constructor( public readonly inlineCompletion: InlineCompletionItem, public readonly decorationId: string, - private readonly rangeVersion: IObservable, - initialRange?: Range, + private readonly _textModel: ITextModel, + private readonly _modelVersion: IObservable, ) { - this._updatedRange = initialRange ?? inlineCompletion.range; } public toInlineCompletion(reader: IReader | undefined): InlineCompletionItem { - return this.inlineCompletion.withRange(this._getUpdatedRange(reader)); + return this.inlineCompletion.withRange(this._updatedRange.read(reader) ?? emptyRange); } public toSingleTextEdit(reader: IReader | undefined): SingleTextEdit { - return new SingleTextEdit(this._getUpdatedRange(reader), this.inlineCompletion.insertText); + return new SingleTextEdit(this._updatedRange.read(reader) ?? emptyRange, this.inlineCompletion.insertText); } public isVisible(model: ITextModel, cursorPosition: Position, reader: IReader | undefined): boolean { const minimizedReplacement = singleTextRemoveCommonPrefix(this._toFilterTextReplacement(reader), model); - + const updatedRange = this._updatedRange.read(reader); if ( - !this._isValid - || !this.inlineCompletion.range.getStartPosition().equals(this._getUpdatedRange(reader).getStartPosition()) + !updatedRange + || !this.inlineCompletion.range.getStartPosition().equals(updatedRange.getStartPosition()) || cursorPosition.lineNumber !== minimizedReplacement.range.startLineNumber ) { return false; @@ -323,45 +307,17 @@ export class InlineCompletionWithUpdatedRange { } public canBeReused(model: ITextModel, position: Position): boolean { - const result = this._isValid - && this._getUpdatedRange(undefined).containsPosition(position) + const updatedRange = this._updatedRange.read(undefined); + const result = !!updatedRange + && updatedRange.containsPosition(position) && this.isVisible(model, position, undefined) - && !this._isSmallerThanOriginal(undefined); + && TextLength.ofRange(updatedRange).isGreaterThanOrEqualTo(TextLength.ofRange(this.inlineCompletion.range)); return result; } private _toFilterTextReplacement(reader: IReader | undefined): SingleTextEdit { - return new SingleTextEdit(this._getUpdatedRange(reader), this.inlineCompletion.filterText); - } - - private _isSmallerThanOriginal(reader: IReader | undefined): boolean { - return length(this._getUpdatedRange(reader)).isBefore(length(this.inlineCompletion.range)); - } - - private _getUpdatedRange(reader: IReader | undefined): Range { - this.rangeVersion.read(reader); // This makes sure all the ranges are updated. - return this._updatedRange; - } - - public _updateRange(textModel: ITextModel): boolean { - const range = textModel.getDecorationRange(this.decorationId); - if (!range) { - // A setValue call might flush all decorations. - this._isValid = false; - return true; - } - if (!this._updatedRange.equalsRange(range)) { - this._updatedRange = range; - return true; - } - return false; + return new SingleTextEdit(this._updatedRange.read(reader) ?? emptyRange, this.inlineCompletion.filterText); } } -function length(range: Range): Position { - if (range.startLineNumber === range.endLineNumber) { - return new Position(1, 1 + range.endColumn - range.startColumn); - } else { - return new Position(1 + range.endLineNumber - range.startLineNumber, range.endColumn); - } -} +const emptyRange = new Range(1, 1, 1, 1);