diff --git a/src/vs/base/common/equals.ts b/src/vs/base/common/equals.ts index 22825c59d9e..6e2ae8503ab 100644 --- a/src/vs/base/common/equals.ts +++ b/src/vs/base/common/equals.ts @@ -6,6 +6,10 @@ import * as arrays from 'vs/base/common/arrays'; export type EqualityComparer = (a: T, b: T) => boolean; + +/** + * Compares two items for equality using strict equality. +*/ export const strictEquals: EqualityComparer = (a, b) => a === b; /** @@ -30,11 +34,30 @@ export function itemEquals(): EqualityC return (a, b) => a.equals(b); } -export function equalsIfDefined(v1: T | undefined, v2: T | undefined, equals: EqualityComparer): boolean { - if (!v1 || !v2) { - return v1 === v2; +/** + * Checks if two items are both null or undefined, or are equal according to the provided equality comparer. +*/ +export function equalsIfDefined(v1: T | undefined | null, v2: T | undefined | null, equals: EqualityComparer): boolean; +/** + * Returns an equality comparer that checks if two items are both null or undefined, or are equal according to the provided equality comparer. +*/ +export function equalsIfDefined(equals: EqualityComparer): EqualityComparer; +export function equalsIfDefined(equalsOrV1: EqualityComparer | T, v2?: T | undefined | null, equals?: EqualityComparer): EqualityComparer | boolean { + if (equals !== undefined) { + const v1 = equalsOrV1 as T | undefined; + if (v1 === undefined || v1 === null || v2 === undefined || v2 === null) { + return v2 === v1; + } + return equals(v1, v2); + } else { + const equals = equalsOrV1 as EqualityComparer; + return (v1, v2) => { + if (v1 === undefined || v1 === null || v2 === undefined || v2 === null) { + return v2 === v1; + } + return equals(v1, v2); + }; } - return equals(v1, v2); } /** diff --git a/src/vs/base/common/observable.ts b/src/vs/base/common/observable.ts index a4b21404a1a..c090a272068 100644 --- a/src/vs/base/common/observable.ts +++ b/src/vs/base/common/observable.ts @@ -60,6 +60,9 @@ export { waitForState, derivedWithCancellationToken, } from 'vs/base/common/observableInternal/promise'; +export { + observableValueOpts +} from 'vs/base/common/observableInternal/api'; import { ConsoleObservableLogger, setLogger } from 'vs/base/common/observableInternal/logging'; diff --git a/src/vs/base/common/observableInternal/api.ts b/src/vs/base/common/observableInternal/api.ts new file mode 100644 index 00000000000..6e56671b7e5 --- /dev/null +++ b/src/vs/base/common/observableInternal/api.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EqualityComparer, strictEquals } from 'vs/base/common/equals'; +import { ISettableObservable } from 'vs/base/common/observable'; +import { ObservableValue } from 'vs/base/common/observableInternal/base'; +import { IDebugNameData, DebugNameData } from 'vs/base/common/observableInternal/debugName'; +import { LazyObservableValue } from 'vs/base/common/observableInternal/lazyObservableValue'; + +export function observableValueOpts( + options: IDebugNameData & { + equalsFn?: EqualityComparer; + lazy?: boolean; + }, + initialValue: T +): ISettableObservable { + if (options.lazy) { + return new LazyObservableValue( + new DebugNameData(options.owner, options.debugName, undefined), + initialValue, + options.equalsFn ?? strictEquals, + ); + } + return new ObservableValue( + new DebugNameData(options.owner, options.debugName, undefined), + initialValue, + options.equalsFn ?? strictEquals, + ); +} diff --git a/src/vs/base/common/observableInternal/base.ts b/src/vs/base/common/observableInternal/base.ts index 7f76c8cc1ab..fe3a465dfb1 100644 --- a/src/vs/base/common/observableInternal/base.ts +++ b/src/vs/base/common/observableInternal/base.ts @@ -6,7 +6,7 @@ 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, IDebugNameData, Owner, getFunctionName } from 'vs/base/common/observableInternal/debugName'; +import { DebugNameData, 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'; @@ -385,19 +385,6 @@ export function observableValue(nameOrOwner: string | object, 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 extends BaseObservable implements ISettableObservable { diff --git a/src/vs/base/common/observableInternal/debugName.ts b/src/vs/base/common/observableInternal/debugName.ts index 481d24f0377..3dff1889137 100644 --- a/src/vs/base/common/observableInternal/debugName.ts +++ b/src/vs/base/common/observableInternal/debugName.ts @@ -113,6 +113,14 @@ function findKey(obj: object, value: object): string | undefined { const countPerClassName = new Map(); const ownerId = new WeakMap(); +/** + * Don't call this method outside of tests. +*/ +export function testingClearObservableNamingCache(): void { + countPerName.clear(); + countPerClassName.clear(); +} + function formatOwner(owner: object): string { const id = ownerId.get(owner); if (id) { diff --git a/src/vs/base/common/observableInternal/lazyObservableValue.ts b/src/vs/base/common/observableInternal/lazyObservableValue.ts new file mode 100644 index 00000000000..1c35f458161 --- /dev/null +++ b/src/vs/base/common/observableInternal/lazyObservableValue.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EqualityComparer } from 'vs/base/common/equals'; +import { ISettableObservable, ITransaction } from 'vs/base/common/observable'; +import { BaseObservable, IObserver, TransactionImpl } from 'vs/base/common/observableInternal/base'; +import { DebugNameData } from 'vs/base/common/observableInternal/debugName'; + +/** + * Holds off updating observers until the value is actually read. +*/ +export class LazyObservableValue + extends BaseObservable + implements ISettableObservable { + protected _value: T; + private _isUpToDate = true; + private readonly _deltas: TChange[] = []; + + get debugName() { + return this._debugNameData.getDebugName(this) ?? 'LazyObservableValue'; + } + + constructor( + private readonly _debugNameData: DebugNameData, + initialValue: T, + private readonly _equalityComparator: EqualityComparer, + ) { + super(); + this._value = initialValue; + } + + public override get(): T { + this._update(); + return this._value; + } + + private _update(): void { + if (this._isUpToDate) { + return; + } + this._isUpToDate = true; + + if (this._deltas.length > 0) { + for (const observer of this.observers) { + for (const change of this._deltas) { + observer.handleChange(this, change); + } + } + this._deltas.length = 0; + } else { + for (const observer of this.observers) { + observer.handleChange(this, undefined); + } + } + } + + private _updateCounter = 0; + + private _beginUpdate(): void { + this._updateCounter++; + if (this._updateCounter === 1) { + for (const observer of this.observers) { + observer.beginUpdate(this); + } + } + } + + private _endUpdate(): void { + this._updateCounter--; + if (this._updateCounter === 0) { + this._update(); + + // End update could change the observer list. + const observers = [...this.observers]; + for (const r of observers) { + r.endUpdate(this); + } + } + } + + public override addObserver(observer: IObserver): void { + const shouldCallBeginUpdate = !this.observers.has(observer) && this._updateCounter > 0; + super.addObserver(observer); + + if (shouldCallBeginUpdate) { + observer.beginUpdate(this); + } + } + + public override removeObserver(observer: IObserver): void { + const shouldCallEndUpdate = this.observers.has(observer) && this._updateCounter > 0; + super.removeObserver(observer); + + if (shouldCallEndUpdate) { + // Calling end update after removing the observer makes sure endUpdate cannot be called twice here. + observer.endUpdate(this); + } + } + + public set(value: T, tx: ITransaction | undefined, change: TChange): void { + if (change === undefined && this._equalityComparator(this._value, value)) { + return; + } + + let _tx: TransactionImpl | undefined; + if (!tx) { + tx = _tx = new TransactionImpl(() => { }, () => `Setting ${this.debugName}`); + } + try { + this._isUpToDate = false; + this._setValue(value); + if (change !== undefined) { + this._deltas.push(change); + } + + tx.updateObserver({ + beginUpdate: () => this._beginUpdate(), + endUpdate: () => this._endUpdate(), + handleChange: (observable, change) => { }, + handlePossibleChange: (observable) => { }, + }, this); + + if (this._updateCounter > 1) { + // We already started begin/end update, so we need to manually call handlePossibleChange + for (const observer of this.observers) { + observer.handlePossibleChange(this); + } + } + + } finally { + if (_tx) { + _tx.finish(); + } + } + } + + override toString(): string { + return `${this.debugName}: ${this._value}`; + } + + protected _setValue(newValue: T): void { + this._value = newValue; + } +} diff --git a/src/vs/base/common/observableInternal/utils.ts b/src/vs/base/common/observableInternal/utils.ts index 409fe4a10ec..d1597b8b48a 100644 --- a/src/vs/base/common/observableInternal/utils.ts +++ b/src/vs/base/common/observableInternal/utils.ts @@ -54,9 +54,9 @@ export function observableFromPromise(promise: Promise): IObservable<{ val export function observableFromEvent( event: Event, - getValue: (args: TArgs | undefined) => T + getValue: (args: TArgs | undefined) => T, ): IObservable { - return new FromEventObservable(event, getValue); + return new FromEventObservable(event, getValue, () => FromEventObservable.globalTransaction); } export class FromEventObservable extends BaseObservable { @@ -68,7 +68,8 @@ export class FromEventObservable extends BaseObservable { constructor( private readonly event: Event, - public readonly _getValue: (args: TArgs | undefined) => T + public readonly _getValue: (args: TArgs | undefined) => T, + private readonly _getTransaction: () => ITransaction | undefined, ) { super(); } @@ -99,7 +100,7 @@ export class FromEventObservable extends BaseObservable { if (this.hasValue) { didRunTransaction = true; subtransaction( - FromEventObservable.globalTransaction, + this._getTransaction(), (tx) => { getLogger()?.handleFromEventObservableTriggered(this, { oldValue, newValue, change: undefined, didChange, hadValue: this.hasValue }); @@ -229,6 +230,10 @@ class ObservableSignal extends BaseObservable implements return new DebugNameData(this._owner, this._debugName, undefined).getDebugName(this) ?? 'Observable Signal'; } + public override toString(): string { + return this.debugName; + } + constructor( private readonly _debugName: string | undefined, private readonly _owner?: object, diff --git a/src/vs/editor/browser/observableUtilities.ts b/src/vs/editor/browser/observableUtilities.ts index 7bcfe7ecd6a..6e99c148d77 100644 --- a/src/vs/editor/browser/observableUtilities.ts +++ b/src/vs/editor/browser/observableUtilities.ts @@ -3,11 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { autorunOpts, derivedOpts, IObservable, observableFromEvent } from 'vs/base/common/observable'; +import { equalsIfDefined, itemsEquals } from 'vs/base/common/equals'; +import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, ITransaction, autorunOpts, derived, derivedOpts, observableFromEvent, observableSignal, observableValue, observableValueOpts } from 'vs/base/common/observable'; +import { TransactionImpl } from 'vs/base/common/observableInternal/base'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { Position } from 'vs/editor/common/core/position'; -import { IModelDeltaDecoration } from 'vs/editor/common/model'; +import { Selection } from 'vs/editor/common/core/selection'; +import { ICursorSelectionChangedEvent } from 'vs/editor/common/cursorEvents'; +import { IModelDeltaDecoration, ITextModel } from 'vs/editor/common/model'; +import { IModelContentChangedEvent } from 'vs/editor/common/textModelEvents'; /** * Returns a facade for the code editor that provides observables for various states/events. @@ -16,7 +22,7 @@ export function obsCodeEditor(editor: ICodeEditor): ObservableCodeEditor { return ObservableCodeEditor.get(editor); } -class ObservableCodeEditor { +class ObservableCodeEditor extends Disposable { private static _map = new Map(); /** @@ -28,21 +34,129 @@ class ObservableCodeEditor { result = new ObservableCodeEditor(editor); ObservableCodeEditor._map.set(editor, result); const d = editor.onDidDispose(() => { - ObservableCodeEditor._map.delete(editor); - d.dispose(); + const item = ObservableCodeEditor._map.get(editor); + if (item) { + ObservableCodeEditor._map.delete(editor); + item.dispose(); + d.dispose(); + } }); } return result; } - private constructor(public readonly editor: ICodeEditor) { + private _updateCounter = 0; + private _currentTransaction: TransactionImpl | undefined = undefined; + + private _beginUpdate(): void { + this._updateCounter++; + if (this._updateCounter === 1) { + this._currentTransaction = new TransactionImpl(() => { + /** @description Update editor state */ + }); + } } - public readonly model = observableFromEvent(this.editor.onDidChangeModel, () => this.editor.getModel()); - public readonly value = observableFromEvent(this.editor.onDidChangeModelContent, () => this.editor.getValue()); - public readonly valueIsEmpty = observableFromEvent(this.editor.onDidChangeModelContent, () => this.editor.getModel()?.getValueLength() === 0); - public readonly selections = observableFromEvent(this.editor.onDidChangeCursorSelection, () => this.editor.getSelections()); - public readonly cursorPosition = derivedOpts({ owner: this, equalsFn: Position.equals }, reader => this.selections.read(reader)?.[0]?.getPosition() ?? null); + private _endUpdate(): void { + this._updateCounter--; + if (this._updateCounter === 0) { + const t = this._currentTransaction!; + this._currentTransaction = undefined; + t.finish(); + } + } + + private constructor(public readonly editor: ICodeEditor) { + super(); + + this._register(this.editor.onBeginUpdate(() => this._beginUpdate())); + this._register(this.editor.onEndUpdate(() => this._endUpdate())); + + this._register(this.editor.onDidChangeModel(() => { + this._beginUpdate(); + try { + this._model.set(this.editor.getModel(), this._currentTransaction); + this._forceUpdate(); + } finally { + this._endUpdate(); + } + })); + + this._register(this.editor.onDidType((e) => { + this._beginUpdate(); + try { + this._forceUpdate(); + this.onDidType.trigger(this._currentTransaction, e); + } finally { + this._endUpdate(); + } + })); + + this._register(this.editor.onDidChangeModelContent(e => { + this._beginUpdate(); + try { + this._versionId.set(this.editor.getModel()?.getVersionId() ?? null, this._currentTransaction, e); + this._forceUpdate(); + } finally { + this._endUpdate(); + } + })); + + this._register(this.editor.onDidChangeCursorSelection(e => { + this._beginUpdate(); + try { + this._selections.set(this.editor.getSelections(), this._currentTransaction, e); + this._forceUpdate(); + } finally { + this._endUpdate(); + } + })); + } + + public forceUpdate(): void; + public forceUpdate(cb: (tx: ITransaction) => T): T; + public forceUpdate(cb?: (tx: ITransaction) => T): T { + this._beginUpdate(); + try { + this._forceUpdate(); + if (!cb) { return undefined as T; } + return cb(this._currentTransaction!); + } finally { + this._endUpdate(); + } + } + + private _forceUpdate(): void { + this._beginUpdate(); + try { + this._model.set(this.editor.getModel(), this._currentTransaction); + this._versionId.set(this.editor.getModel()?.getVersionId() ?? null, this._currentTransaction, undefined); + this._selections.set(this.editor.getSelections(), this._currentTransaction, undefined); + } finally { + this._endUpdate(); + } + } + + private readonly _model = observableValue(this, this.editor.getModel()); + public readonly model: IObservable = this._model; + + public readonly isReadonly = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.readOnly)); + + private readonly _versionId = observableValueOpts({ owner: this, lazy: true }, this.editor.getModel()?.getVersionId() ?? null); + public readonly versionId: IObservable = this._versionId; + + private readonly _selections = observableValueOpts( + { owner: this, equalsFn: equalsIfDefined(itemsEquals(Selection.selectionsEqual)), lazy: true }, + this.editor.getSelections() ?? null + ); + public readonly selections: IObservable = this._selections; + + + public readonly positions = derivedOpts( + { owner: this, equalsFn: equalsIfDefined(itemsEquals(Position.equals)) }, + reader => this.selections.read(reader)?.map(s => s.getStartPosition()) ?? null + ); + public readonly isFocused = observableFromEvent(e => { const d1 = this.editor.onDidFocusEditorWidget(e); const d2 = this.editor.onDidBlurEditorWidget(e); @@ -54,6 +168,12 @@ class ObservableCodeEditor { }; }, () => this.editor.hasWidgetFocus()); + public readonly value = derived(this, reader => { this.versionId.read(reader); return this.model.read(reader)?.getValue(); }); + public readonly valueIsEmpty = derived(this, reader => { this.versionId.read(reader); return this.editor.getModel()?.getValueLength() === 0; }); + public readonly cursorPosition = derivedOpts({ owner: this, equalsFn: Position.equals }, reader => this.selections.read(reader)?.[0]?.getPosition() ?? null); + + public readonly onDidType = observableSignal(this); + public setDecorations(decorations: IObservable): IDisposable { const d = new DisposableStore(); const decorationsCollection = this.editor.createDecorationsCollection(); diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index 07753688f7e..0ca963b3511 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -1648,6 +1648,16 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE this.languageConfigurationService, this._themeService, attachedView, + { + batchChanges: (cb) => { + try { + this._beginUpdate(); + return cb(); + } finally { + this._endUpdate(); + } + }, + } ); // Someone might destroy the model from under the editor, so prevent any exceptions by setting a null model diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 626217e8bff..75653b9ed6c 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -413,7 +413,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati private _assertNotDisposed(): void { if (this._isDisposed) { - throw new Error('Model is disposed!'); + throw new BugIndicatingError('Model is disposed!'); } } diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index c5af99cca77..50366d54958 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -71,6 +71,7 @@ export class ViewModel extends Disposable implements IViewModel { private readonly languageConfigurationService: ILanguageConfigurationService, private readonly _themeService: IThemeService, private readonly _attachedView: IAttachedView, + private readonly _transactionalTarget: IBatchableTarget, ) { super(); @@ -1102,12 +1103,14 @@ export class ViewModel extends Disposable implements IViewModel { //#endregion private _withViewEventsCollector(callback: (eventsCollector: ViewModelEventsCollector) => T): T { - try { - const eventsCollector = this._eventDispatcher.beginEmitViewEvents(); - return callback(eventsCollector); - } finally { - this._eventDispatcher.endEmitViewEvents(); - } + return this._transactionalTarget.batchChanges(() => { + try { + const eventsCollector = this._eventDispatcher.beginEmitViewEvents(); + return callback(eventsCollector); + } finally { + this._eventDispatcher.endEmitViewEvents(); + } + }); } public batchEvents(callback: () => void): void { @@ -1127,6 +1130,13 @@ export class ViewModel extends Disposable implements IViewModel { } } +export interface IBatchableTarget { + /** + * Allows the target to apply the changes introduced by the callback in a batch. + */ + batchChanges(cb: () => T): T; +} + class ViewportStart implements IDisposable { public static create(model: ITextModel): ViewportStart { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts index c11920c4d65..bfe080474d8 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts @@ -7,26 +7,25 @@ import { createStyleSheet2 } from 'vs/base/browser/dom'; import { alert } from 'vs/base/browser/ui/aria/aria'; import { timeout } from 'vs/base/common/async'; import { cancelOnDispose } from 'vs/base/common/cancellation'; -import { itemEquals, itemsEquals } from 'vs/base/common/equals'; import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; -import { IObservable, ITransaction, autorun, autorunHandleChanges, constObservable, derived, disposableObservableValue, observableFromEvent, observableSignal, observableValue, transaction, waitForState } from 'vs/base/common/observable'; -import { ISettableObservable, observableValueOpts } from 'vs/base/common/observableInternal/base'; +import { IObservable, ITransaction, autorun, autorunHandleChanges, autorunWithStoreHandleChanges, constObservable, derived, disposableObservableValue, observableFromEvent, observableSignal, observableValue, transaction, waitForState } from 'vs/base/common/observable'; +import { ISettableObservable } from 'vs/base/common/observableInternal/base'; import { mapObservableArrayCached } from 'vs/base/common/observableInternal/utils'; import { isUndefined } from 'vs/base/common/types'; import { CoreEditingCommands } from 'vs/editor/browser/coreCommands'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { obsCodeEditor } from 'vs/editor/browser/observableUtilities'; 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 { CursorChangeReason } from 'vs/editor/common/cursorEvents'; import { ILanguageFeatureDebounceService } from 'vs/editor/common/services/languageFeatureDebounce'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; -import { IModelContentChangedEvent } from 'vs/editor/common/textModelEvents'; import { inlineSuggestCommitId } from 'vs/editor/contrib/inlineCompletions/browser/commandIds'; import { GhostTextWidget } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextWidget'; import { InlineCompletionContextKeys } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionContextKeys'; import { InlineCompletionsHintsWidget, InlineSuggestionHintsContentWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget'; -import { InlineCompletionsModel, VersionIdChangeReason } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; +import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; import { SuggestWidgetAdaptor } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider'; import { localize } from 'vs/nls'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; @@ -44,42 +43,47 @@ export class InlineCompletionsController extends Disposable { return editor.getContribution(InlineCompletionsController.ID); } - public readonly model = this._register(disposableObservableValue('inlineCompletionModel', undefined)); - private readonly _textModelVersionId = observableValue(this, -1); - private readonly _positions = observableValueOpts({ owner: this, equalsFn: itemsEquals(itemEquals()) }, [new Position(1, 1)]); + public readonly model = this._register(disposableObservableValue(this, undefined)); + private readonly _editorObs = obsCodeEditor(this.editor); + private readonly _positions = derived(this, reader => this._editorObs.positions.read(reader) ?? [new Position(1, 1)]); private readonly _suggestWidgetAdaptor = this._register(new SuggestWidgetAdaptor( this.editor, - () => this.model.get()?.selectedInlineCompletion.get()?.toSingleTextEdit(undefined), - (tx) => this.updateObservables(tx, VersionIdChangeReason.Other), + () => { + this._editorObs.forceUpdate(); + return this.model.get()?.selectedInlineCompletion.get()?.toSingleTextEdit(undefined); + }, (item) => { - transaction(tx => { + this._editorObs.forceUpdate(tx => { /** @description InlineCompletionsController.handleSuggestAccepted */ - this.updateObservables(tx, VersionIdChangeReason.Other); this.model.get()?.handleSuggestAccepted(item); }); } )); + private readonly _suggestWidgetSelectedItem = observableFromEvent(cb => this._suggestWidgetAdaptor.onDidSelectedItemChange(() => { + this._editorObs.forceUpdate(_tx => cb(undefined)); + }), () => this._suggestWidgetAdaptor.selectedItem); private readonly _enabledInConfig = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineSuggest).enabled); private readonly _isScreenReaderEnabled = observableFromEvent(this._accessibilityService.onDidChangeScreenReaderOptimized, () => this._accessibilityService.isScreenReaderOptimized()); - private readonly _editorDictationInProgress = observableFromEvent(this._contextKeyService.onDidChangeContext, () => this._contextKeyService.getContext(this.editor.getDomNode()).getValue('editorDictation.inProgress') === true); + private readonly _editorDictationInProgress = observableFromEvent( + this._contextKeyService.onDidChangeContext, + () => this._contextKeyService.getContext(this.editor.getDomNode()).getValue('editorDictation.inProgress') === true + ); private readonly _enabled = derived(this, reader => this._enabledInConfig.read(reader) && (!this._isScreenReaderEnabled.read(reader) || !this._editorDictationInProgress.read(reader))); - private readonly _fontFamily = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineSuggest).fontFamily); private readonly _ghostTexts = derived(this, (reader) => { const model = this.model.read(reader); return model?.ghostTexts.read(reader) ?? []; }); - private readonly _stablizedGhostTexts = convertItemsToStableObservables(this._ghostTexts, this._store); - private readonly _ghostTextWidgets = mapObservableArrayCached(this, this._stablizedGhostTexts, (ghostText, store) => { - return store.add(this._instantiationService.createInstance(GhostTextWidget, this.editor, { + private readonly _ghostTextWidgets = mapObservableArrayCached(this, this._stablizedGhostTexts, (ghostText, store) => + store.add(this._instantiationService.createInstance(GhostTextWidget, this.editor, { ghostText: ghostText, minReservedLineCount: constObservable(0), targetTextModel: this.model.map(v => v?.textModel), - })); - }).recomputeInitiallyAndOnChange(this._store); + })) + ).recomputeInitiallyAndOnChange(this._store); private readonly _debounceValue = this._debounceService.for( this._languageFeaturesService.inlineCompletionsProvider, @@ -88,10 +92,7 @@ export class InlineCompletionsController extends Disposable { ); private readonly _playAccessibilitySignal = observableSignal(this); - - private readonly _isReadonly = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.readOnly)); - private readonly _textModel = observableFromEvent(this.editor.onDidChangeModel, () => this.editor.getModel()); - private readonly _textModelIfWritable = derived(reader => this._isReadonly.read(reader) ? undefined : this._textModel.read(reader)); + private readonly _textModelIfWritable = derived(reader => this._editorObs.isReadonly.read(reader) ? undefined : this._editorObs.model.read(reader)); constructor( public readonly editor: ICodeEditor, @@ -115,14 +116,12 @@ export class InlineCompletionsController extends Disposable { transaction(tx => { /** @description InlineCompletionsController.onDidChangeModel/readonly */ this.model.set(undefined, tx); - this.updateObservables(tx, VersionIdChangeReason.Other); - if (textModel) { const model = _instantiationService.createInstance( InlineCompletionsModel, textModel, - this._suggestWidgetAdaptor.selectedItem, - this._textModelVersionId, + this._suggestWidgetSelectedItem, + this._editorObs.versionId, this._positions, this._debounceValue, observableFromEvent(editor.onDidChangeConfiguration, () => editor.getOption(EditorOption.suggest).preview), @@ -146,28 +145,26 @@ export class InlineCompletionsController extends Disposable { }`); })); - const getReason = (e: IModelContentChangedEvent): VersionIdChangeReason => { - if (e.isUndoing) { return VersionIdChangeReason.Undo; } - if (e.isRedoing) { return VersionIdChangeReason.Redo; } - if (this.model.get()?.isAcceptingPartially) { return VersionIdChangeReason.AcceptWord; } - return VersionIdChangeReason.Other; - }; - this._register(editor.onDidChangeModelContent((e) => transaction(tx => - /** @description InlineCompletionsController.onDidChangeModelContent */ - this.updateObservables(tx, getReason(e)) - ))); - - this._register(editor.onDidChangeCursorPosition(e => transaction(tx => { - /** @description InlineCompletionsController.onDidChangeCursorPosition */ - this.updateObservables(tx, VersionIdChangeReason.Other); - if (e.reason === CursorChangeReason.Explicit || e.source === 'api') { - this.model.get()?.stop(tx); + this._register(autorunWithStoreHandleChanges({ + createEmptyChangeSummary: () => ({ shouldStop: false }), + handleChange: (context, changeSummary) => { + if (context.didChange(this._editorObs.selections)) { + const e = context.change; + if (e && (e.reason === CursorChangeReason.Explicit || e.source === 'api')) { + changeSummary.shouldStop = true; + } + } + return changeSummary.shouldStop; + }, + }, (reader, changeSummary) => { + this._editorObs.selections.read(reader); + if (changeSummary.shouldStop) { + this.model.get()?.stop(undefined); } - }))); + })); - this._register(editor.onDidType(() => transaction(tx => { + this._register(editor.onDidType(() => this._editorObs.forceUpdate(tx => { /** @description InlineCompletionsController.onDidType */ - this.updateObservables(tx, VersionIdChangeReason.Other); if (this._enabled.get()) { this.model.get()?.trigger(tx); } @@ -183,7 +180,7 @@ export class InlineCompletionsController extends Disposable { 'acceptSelectedSuggestion', ]); if (commands.has(e.commandId) && editor.hasTextFocus() && this._enabled.get()) { - transaction(tx => { + this._editorObs.forceUpdate(tx => { /** @description onDidExecuteCommand */ this.model.get()?.trigger(tx); }); @@ -246,7 +243,7 @@ export class InlineCompletionsController extends Disposable { const lineText = model.textModel.getLineContent(state.primaryGhostText.lineNumber); await timeout(50, cancelOnDispose(cancellationStore)); - await waitForState(this._suggestWidgetAdaptor.selectedItem, isUndefined, () => false, cancelOnDispose(cancellationStore)); + await waitForState(this._suggestWidgetSelectedItem, isUndefined, () => false, cancelOnDispose(cancellationStore)); await this._accessibilitySignalService.playSignal(AccessibilitySignal.inlineSuggestion); @@ -257,6 +254,8 @@ export class InlineCompletionsController extends Disposable { })); this._register(new InlineCompletionsHintsWidget(this.editor, this.model, this._instantiationService)); + + // TODO@hediet this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('accessibility.verbosity.inlineCompletions')) { this.editor.updateOptions({ inlineCompletionsAccessibilityVerbose: this._configurationService.getValue('accessibility.verbosity.inlineCompletions') }); @@ -279,17 +278,6 @@ export class InlineCompletionsController extends Disposable { hint ? alert(content + ', ' + hint) : alert(content); } - /** - * Copies over the relevant state from the text model to observables. - * This solves all kind of eventing issues, as we make sure we always operate on the latest state, - * regardless of who calls into us. - */ - private updateObservables(tx: ITransaction, changeReason: VersionIdChangeReason): void { - const newModel = this.editor.getModel(); - this._textModelVersionId.set(newModel?.getVersionId() ?? -1, tx, changeReason); - this._positions.set(this.editor.getSelections()?.map(selection => selection.getPosition()) ?? [new Position(1, 1)], tx); - } - public shouldShowHoverAt(range: Range) { const ghostText = this.model.get()?.primaryGhostText.get(); if (ghostText) { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts index bc10c23e491..3a7ac0f015f 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts @@ -23,6 +23,7 @@ import { Command, InlineCompletionContext, InlineCompletionTriggerKind, PartialA 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 { IModelContentChangedEvent } from 'vs/editor/common/textModelEvents'; import { GhostText, GhostTextOrReplacement, ghostTextOrReplacementEquals, ghostTextsOrReplacementsEqual } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; import { InlineCompletionWithUpdatedRange, InlineCompletionsSource } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource'; import { computeGhostText, singleTextEditAugments, singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; @@ -41,7 +42,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); + private readonly _isActive = observableValue(this, false); readonly _forceUpdateExplicitlySignal = observableSignal(this); // We use a semantic id to keep the same inline completion selected even if the provider reorders the completions. @@ -54,7 +55,7 @@ export class InlineCompletionsModel extends Disposable { constructor( public readonly textModel: ITextModel, public readonly selectedSuggestItem: IObservable, - public readonly textModelVersionId: IObservable, + public readonly textModelVersionId: IObservable, private readonly _positions: IObservable, private readonly _debounceValue: IFeatureDebounceInformation, private readonly _suggestPreviewEnabled: IObservable, @@ -91,6 +92,13 @@ export class InlineCompletionsModel extends Disposable { VersionIdChangeReason.AcceptWord, ]); + private _getReason(e: IModelContentChangedEvent | undefined): VersionIdChangeReason { + if (e?.isUndoing) { return VersionIdChangeReason.Undo; } + if (e?.isRedoing) { return VersionIdChangeReason.Redo; } + if (this.isAcceptingPartially) { return VersionIdChangeReason.AcceptWord; } + return VersionIdChangeReason.Other; + } + private readonly _fetchInlineCompletionsPromise = derivedHandleChanges({ owner: this, createEmptyChangeSummary: () => ({ @@ -99,7 +107,7 @@ export class InlineCompletionsModel extends Disposable { }), handleChange: (ctx, changeSummary) => { /** @description fetch inline completions */ - if (ctx.didChange(this.textModelVersionId) && this._preserveCurrentCompletionReasons.has(ctx.change)) { + if (ctx.didChange(this.textModelVersionId) && this._preserveCurrentCompletionReasons.has(this._getReason(ctx.change))) { changeSummary.preserveCurrentCompletion = true; } else if (ctx.didChange(this._forceUpdateExplicitlySignal)) { changeSummary.inlineCompletionTriggerKind = InlineCompletionTriggerKind.Explicit; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts index 32b77dd23fe..1eb06aa11c1 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts @@ -27,7 +27,7 @@ export class InlineCompletionsSource extends Disposable { constructor( private readonly textModel: ITextModel, - private readonly versionId: IObservable, + private readonly versionId: IObservable, private readonly _debounceValue: IFeatureDebounceInformation, @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @ILanguageConfigurationService private readonly languageConfigurationService: ILanguageConfigurationService, @@ -62,7 +62,7 @@ export class InlineCompletionsSource extends Disposable { await wait(this._debounceValue.get(this.textModel), source.token); } - if (source.token.isCancellationRequested || this.textModel.getVersionId() !== request.versionId) { + if (source.token.isCancellationRequested || this._store.isDisposed || this.textModel.getVersionId() !== request.versionId) { return false; } @@ -76,7 +76,7 @@ export class InlineCompletionsSource extends Disposable { this.languageConfigurationService ); - if (source.token.isCancellationRequested || this.textModel.getVersionId() !== request.versionId) { + if (source.token.isCancellationRequested || this._store.isDisposed || this.textModel.getVersionId() !== request.versionId) { return false; } @@ -182,7 +182,7 @@ export class UpToDateInlineCompletions implements IDisposable { private readonly inlineCompletionProviderResult: InlineCompletionProviderResult, public readonly request: UpdateRequest, private readonly _textModel: ITextModel, - private readonly _versionId: IObservable, + private readonly _versionId: IObservable, ) { const ids = _textModel.deltaDecorations([], inlineCompletionProviderResult.completions.map(i => ({ range: i.range, @@ -254,7 +254,7 @@ export class InlineCompletionWithUpdatedRange { public readonly inlineCompletion: InlineCompletionItem, public readonly decorationId: string, private readonly _textModel: ITextModel, - private readonly _modelVersion: IObservable, + private readonly _modelVersion: IObservable, ) { } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts b/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts index 95d135ce324..daf73173ef4 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts @@ -3,39 +3,36 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from 'vs/base/common/event'; +import { compareBy, numberComparator } from 'vs/base/common/arrays'; +import { findFirstMax } from 'vs/base/common/arraysFind'; +import { Emitter, Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; 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 { CompletionItemInsertTextRule, CompletionItemKind, SelectedSuggestionInfo } from 'vs/editor/common/languages'; +import { ITextModel } from 'vs/editor/common/model'; +import { singleTextEditAugments, singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser'; import { SnippetSession } from 'vs/editor/contrib/snippet/browser/snippetSession'; import { CompletionItem } from 'vs/editor/contrib/suggest/browser/suggest'; import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController'; -import { IObservable, ITransaction, observableValue, transaction } from 'vs/base/common/observable'; -import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; -import { ITextModel } from 'vs/editor/common/model'; -import { compareBy, numberComparator } from 'vs/base/common/arrays'; -import { findFirstMax } from 'vs/base/common/arraysFind'; -import { singleTextEditAugments, singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; export class SuggestWidgetAdaptor extends Disposable { private isSuggestWidgetVisible: boolean = false; private isShiftKeyPressed = false; private _isActive = false; private _currentSuggestItemInfo: SuggestItemInfo | undefined = undefined; - - private readonly _selectedItem = observableValue(this, undefined as SuggestItemInfo | undefined); - - public get selectedItem(): IObservable { - return this._selectedItem; + public get selectedItem(): SuggestItemInfo | undefined { + return this._currentSuggestItemInfo; } + private _onDidSelectedItemChange = this._register(new Emitter()); + public readonly onDidSelectedItemChange: Event = this._onDidSelectedItemChange.event; constructor( private readonly editor: ICodeEditor, private readonly suggestControllerPreselector: () => SingleTextEdit | undefined, - private readonly checkModelVersion: (tx: ITransaction) => void, private readonly onWillAccept: (item: SuggestItemInfo) => void, ) { super(); @@ -59,8 +56,6 @@ export class SuggestWidgetAdaptor extends Disposable { this._register(suggestController.registerSelector({ priority: 100, select: (model, pos, suggestItems) => { - transaction(tx => this.checkModelVersion(tx)); - const textModel = this.editor.getModel(); if (!textModel) { // Should not happen @@ -142,11 +137,7 @@ export class SuggestWidgetAdaptor extends Disposable { this._isActive = newActive; this._currentSuggestItemInfo = newInlineCompletion; - transaction(tx => { - /** @description Update state from suggest widget */ - this.checkModelVersion(tx); - this._selectedItem.set(this._isActive ? this._currentSuggestItemInfo : undefined, tx); - }); + this._onDidSelectedItemChange.fire(); } } diff --git a/src/vs/editor/test/browser/viewModel/testViewModel.ts b/src/vs/editor/test/browser/viewModel/testViewModel.ts index fe7f0c6e2db..36749b71bd6 100644 --- a/src/vs/editor/test/browser/viewModel/testViewModel.ts +++ b/src/vs/editor/test/browser/viewModel/testViewModel.ts @@ -22,6 +22,8 @@ export function testViewModel(text: string[], options: IEditorOptions, callback: const viewModel = new ViewModel(EDITOR_ID, configuration, model, monospaceLineBreaksComputerFactory, monospaceLineBreaksComputerFactory, null!, testLanguageConfigurationService, new TestThemeService(), { setVisibleLines(visibleLines, stabilized) { }, + }, { + batchChanges: (cb) => cb(), }); callback(viewModel, model); diff --git a/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts b/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts new file mode 100644 index 00000000000..b2f5eb36cbb --- /dev/null +++ b/src/vs/editor/test/browser/widget/observableCodeEditor.test.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { IObservable, derivedHandleChanges } from 'vs/base/common/observable'; +import { testingClearObservableNamingCache } from 'vs/base/common/observableInternal/debugName'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { obsCodeEditor } from 'vs/editor/browser/observableUtilities'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { ViewModel } from 'vs/editor/common/viewModel/viewModelImpl'; +import { withTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; + +suite('CodeEditorWidget', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function withTestFixture(cb: (args: { editor: ICodeEditor; viewModel: ViewModel; log: Log; derived: IObservable }) => void) { + withEditorSetupTestFixture(undefined, cb); + } + + function withEditorSetupTestFixture( + preSetupCallback: ((editor: ICodeEditor, disposables: DisposableStore) => void) | undefined, + cb: (args: { editor: ICodeEditor; viewModel: ViewModel; log: Log; derived: IObservable }) => void, + ) { + testingClearObservableNamingCache(); + withTestCodeEditor('hello world', {}, (editor, viewModel) => { + + const disposables = new DisposableStore(); + + preSetupCallback?.(editor, disposables); + + const obsEditor = obsCodeEditor(editor); + + const log = new Log(); + + const derived = derivedHandleChanges({ + createEmptyChangeSummary: () => undefined, + handleChange: (context, changeSummary) => { + const formattedChange = JSON.stringify( + context.change, + (key, value) => { + if (value instanceof Range) { + return value.toString(); + } + if (value === false || Array.isArray(value) && value.length === 0) { return undefined; } + return value; + } + ); + log.log(`handle change ${context.changedObservable.toString()} ${formattedChange}`); + return true; + }, + }, reader => { + const versionId = obsEditor.versionId.read(reader); + const selection = obsEditor.selections.read(reader)?.map(s => s.toString()).join(', '); + obsEditor.onDidType.read(reader); + + const str = `running derived -> selection: ${selection}, value: ${versionId}`; + log.log(str); + return str; + }); + + derived.recomputeInitiallyAndOnChange(disposables); + + assert.deepStrictEqual(log.getAndClearEntries(), (["running derived -> selection: [1,1 -> 1,1], value: 1"])); + + cb({ editor, viewModel, log, derived }); + + disposables.dispose(); + }); + } + + test('setPosition', () => withTestFixture(({ editor, log }) => { + editor.setPosition(new Position(1, 2)); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'handle change ObservableCodeEditor._selections: [1,2 -> 1,2] {"selection":"[1,2 -> 1,2]","modelVersionId":1,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"api","reason":0}', + "running derived -> selection: [1,2 -> 1,2], value: 1", + ]); + })); + + test('keyboard.type', () => withTestFixture(({ editor, log }) => { + editor.trigger('keyboard', 'type', { text: 'abc' }); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'handle change ObservableCodeEditor.onDidType "abc"', + 'handle change ObservableCodeEditor._versionId: 4 {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"a","rangeOffset":0}],"eol":"\\n","versionId":2}', + 'handle change ObservableCodeEditor._versionId: 4 {"changes":[{"range":"[1,2 -> 1,2]","rangeLength":0,"text":"b","rangeOffset":1}],"eol":"\\n","versionId":3}', + 'handle change ObservableCodeEditor._versionId: 4 {"changes":[{"range":"[1,3 -> 1,3]","rangeLength":0,"text":"c","rangeOffset":2}],"eol":"\\n","versionId":4}', + 'handle change ObservableCodeEditor._selections: [1,4 -> 1,4] {"selection":"[1,4 -> 1,4]","modelVersionId":4,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"keyboard","reason":0}', + "running derived -> selection: [1,4 -> 1,4], value: 4", + ]); + })); + + test('keyboard.type and set position', () => withTestFixture(({ editor, log }) => { + editor.trigger('keyboard', 'type', { text: 'abc' }); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'handle change ObservableCodeEditor.onDidType "abc"', + 'handle change ObservableCodeEditor._versionId: 4 {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"a","rangeOffset":0}],"eol":"\\n","versionId":2}', + 'handle change ObservableCodeEditor._versionId: 4 {"changes":[{"range":"[1,2 -> 1,2]","rangeLength":0,"text":"b","rangeOffset":1}],"eol":"\\n","versionId":3}', + 'handle change ObservableCodeEditor._versionId: 4 {"changes":[{"range":"[1,3 -> 1,3]","rangeLength":0,"text":"c","rangeOffset":2}],"eol":"\\n","versionId":4}', + 'handle change ObservableCodeEditor._selections: [1,4 -> 1,4] {"selection":"[1,4 -> 1,4]","modelVersionId":4,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"keyboard","reason":0}', + "running derived -> selection: [1,4 -> 1,4], value: 4", + ]); + + editor.setPosition(new Position(1, 5), 'test'); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'handle change ObservableCodeEditor._selections: [1,5 -> 1,5] {"selection":"[1,5 -> 1,5]","modelVersionId":4,"oldSelections":["[1,4 -> 1,4]"],"oldModelVersionId":4,"source":"test","reason":0}', + "running derived -> selection: [1,5 -> 1,5], value: 4", + ]); + })); + + test('listener interaction', () => { + let derived: IObservable; + let log: Log; + let force = false; + withEditorSetupTestFixture( + (editor, disposables) => { + disposables.add(editor.onDidChangeModelContent(() => { + if (force) { + log.log('>>> before forceUpdate'); + obsCodeEditor(editor).forceUpdate(); + } + log.log('>>> before get'); + derived.get(); + log.log('<<< after get'); + })); + }, + (args) => { + const editor = args.editor; + derived = args.derived; + log = args.log; + + editor.trigger("keyboard", "type", { text: "a" }); + assert.deepStrictEqual(log.getAndClearEntries(), [ + ">>> before get", + "<<< after get", + 'handle change ObservableCodeEditor.onDidType "a"', + 'handle change ObservableCodeEditor._versionId: 2 {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"a","rangeOffset":0}],"eol":"\\n","versionId":2}', + 'handle change ObservableCodeEditor._selections: [1,2 -> 1,2] {"selection":"[1,2 -> 1,2]","modelVersionId":2,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"keyboard","reason":0}', + "running derived -> selection: [1,2 -> 1,2], value: 2", + ]); + + editor.executeEdits(undefined, [ + { range: new Range(1, 1, 1, 1), text: "x" }, + ]); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + ">>> before get", + "<<< after get", + 'handle change ObservableCodeEditor._versionId: 3 {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"x","rangeOffset":0}],"eol":"\\n","versionId":3}', + 'handle change ObservableCodeEditor._selections: [1,3 -> 1,3] {"selection":"[1,3 -> 1,3]","modelVersionId":3,"oldSelections":["[1,2 -> 1,2]"],"oldModelVersionId":3,"source":"modelChange","reason":2}', + "running derived -> selection: [1,3 -> 1,3], value: 3", + ]); + + force = true; + + editor.trigger("keyboard", "type", { text: "a" }); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + ">>> before forceUpdate", + ">>> before get", + "handle change ObservableCodeEditor._versionId: 4 undefined", + "running derived -> selection: [1,4 -> 1,4], value: 4", + "<<< after get", + 'handle change ObservableCodeEditor.onDidType "a"', + 'handle change ObservableCodeEditor._versionId: 4 {"changes":[{"range":"[1,3 -> 1,3]","rangeLength":0,"text":"a","rangeOffset":2}],"eol":"\\n","versionId":4}', + 'handle change ObservableCodeEditor._selections: [1,4 -> 1,4] {"selection":"[1,4 -> 1,4]","modelVersionId":4,"oldSelections":["[1,3 -> 1,3]"],"oldModelVersionId":3,"source":"keyboard","reason":0}', + "running derived -> selection: [1,4 -> 1,4], value: 4", + ]); + + editor.executeEdits(undefined, [ + { range: new Range(1, 1, 1, 1), text: "x" }, + ]); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + ">>> before forceUpdate", + ">>> before get", + "handle change ObservableCodeEditor._versionId: 5 undefined", + "running derived -> selection: [1,5 -> 1,5], value: 5", + "<<< after get", + 'handle change ObservableCodeEditor._versionId: 5 {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"x","rangeOffset":0}],"eol":"\\n","versionId":5}', + 'handle change ObservableCodeEditor._selections: [1,5 -> 1,5] {"selection":"[1,5 -> 1,5]","modelVersionId":5,"oldSelections":["[1,4 -> 1,4]"],"oldModelVersionId":5,"source":"modelChange","reason":2}', + "running derived -> selection: [1,5 -> 1,5], value: 5", + ]); + + } + ); + }); +}); + +class Log { + private readonly entries: string[] = []; + public log(message: string): void { + this.entries.push(message); + } + + public getAndClearEntries(): string[] { + const entries = [...this.entries]; + this.entries.length = 0; + return entries; + } +}