From e041149fd1a7949bd6a9752fd5f0231025480826 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 11 Feb 2022 12:20:27 +0100 Subject: [PATCH] Iterates on audio cues. --- .../browser/ghostTextController.ts | 5 + .../audioCues/browser/audioCueContribution.ts | 306 +++++----- .../contrib/audioCues/browser/observable.ts | 575 ++++++++++++++++++ 3 files changed, 739 insertions(+), 147 deletions(-) create mode 100644 src/vs/workbench/contrib/audioCues/browser/observable.ts diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextController.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextController.ts index 1b42ba757c3..564c37033d2 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextController.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter } from 'vs/base/common/event'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { firstNonWhitespaceIndex } from 'vs/base/common/strings'; @@ -37,6 +38,9 @@ export class GhostTextController extends Disposable { return this.activeController.value?.model; } + private readonly activeModelDidChangeEmitter = this._register(new Emitter()); + public readonly onActiveModelDidChange = this.activeModelDidChangeEmitter.event; + constructor( public readonly editor: ICodeEditor, @IInstantiationService private readonly instantiationService: IInstantiationService @@ -71,6 +75,7 @@ export class GhostTextController extends Disposable { this.editor ) : undefined; + this.activeModelDidChangeEmitter.fire(); } public shouldShowHoverAt(hoverRange: Range): boolean { diff --git a/src/vs/workbench/contrib/audioCues/browser/audioCueContribution.ts b/src/vs/workbench/contrib/audioCues/browser/audioCueContribution.ts index 13265027a42..f917a1045e9 100644 --- a/src/vs/workbench/contrib/audioCues/browser/audioCueContribution.ts +++ b/src/vs/workbench/contrib/audioCues/browser/audioCueContribution.ts @@ -5,9 +5,9 @@ import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; -import { DisposableStore } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { Emitter, Event } from 'vs/base/common/event'; +import { Event } from 'vs/base/common/event'; import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { raceTimeout } from 'vs/base/common/async'; @@ -15,17 +15,18 @@ import { FileAccess } from 'vs/base/common/network'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers'; import { FoldingController } from 'vs/editor/contrib/folding/browser/folding'; -import { FoldingModel } from 'vs/editor/contrib/folding/browser/foldingModel'; -import { URI } from 'vs/base/common/uri'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { autorunDelta, constObservable, debouncedObservable, fromEvent, fromPromise, IObservable, LazyDerived, wasEventTriggeredRecently } from 'vs/workbench/contrib/audioCues/browser/observable'; +import { ITextModel } from 'vs/editor/common/model'; +import { GhostTextController } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextController'; -export class AudioCueContribution extends DisposableStore implements IWorkbenchContribution { +export class AudioCueContribution extends Disposable implements IWorkbenchContribution { private audioCuesEnabled = false; - private readonly store = this.add(new DisposableStore()); + private readonly store = this._register(new DisposableStore()); constructor( @IEditorService private readonly editorService: IEditorService, - @IConfigurationService private readonly _configurationService: IConfigurationService, + @IConfigurationService private readonly configurationService: IConfigurationService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { @@ -35,8 +36,8 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC this.updateAudioCuesEnabled(); }); - this.add( - _configurationService.onDidChangeConfiguration((e) => { + this._register( + configurationService.onDidChangeConfiguration((e) => { if (e.affectsConfiguration('audioCues.enabled')) { this.updateAudioCuesEnabled(); } @@ -47,7 +48,7 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC } private getAudioCuesEnabled(): boolean { - const value = this._configurationService.getValue<'auto' | 'on' | 'off'>('audioCues.enabled'); + const value = this.configurationService.getValue<'auto' | 'on' | 'off'>('audioCues.enabled'); if (value === 'on') { return true; } else if (value === 'auto') { @@ -81,96 +82,90 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC ? activeTextEditorControl : undefined; - if (editor) { - this.handleCurrentEditor(editor, store); + if (editor && editor.hasModel()) { + this.handleCurrentEditor(editor, editor.getModel(), store); } } ) ); } - private handleCurrentEditor(editor: ICodeEditor, store: DisposableStore): void { + private handleCurrentEditor(editor: ICodeEditor, editorModel: ITextModel, store: DisposableStore): void { const features: Feature[] = [ this.instantiationService.createInstance(ErrorFeature), this.instantiationService.createInstance(FoldedAreaFeature), this.instantiationService.createInstance(BreakpointFeature), + this.instantiationService.createInstance(InlineCompletionFeature), ]; - - const featuresPerEditor = new Map( - features.map((feature) => [ - feature, - feature.createForEditor(editor, editor.getModel()!.uri), - ]) + const observableFeatureStates = features.map((feature) => + feature.getObservableState(editor, editorModel) ); - interface State { - lineNumber: number; - featureStates: Map; - } + const curLineNumber = fromEvent( + editor.onDidChangeCursorPosition, + () => editor.getPosition()?.lineNumber + ); + const debouncedLineNumber = debouncedObservable(curLineNumber, 100, store); - const computeNewState = (): State | undefined => { - if (!editor.hasModel()) { + const lineNumberWithObservableFeatures = debouncedLineNumber.map( + (lineNumber) => lineNumber === undefined ? undefined : { + lineNumber, + featureStatesForLine: observableFeatureStates.map( + (featureResult) => + // This caches the feature state for the active line + new LazyDerived( + (reader) => featureResult.read(reader).isActive(lineNumber), + 'isActiveForLine' + ) + ), + } + ); + + const isTyping = wasEventTriggeredRecently( + editorModel.onDidChangeContent.bind(editorModel), + 1000, + store + ); + const featureStatesBeforeTyping = isTyping.map((isTyping) => + !isTyping + ? undefined + : lineNumberWithObservableFeatures + .get() + ?.featureStatesForLine?.map((featureState, idx) => + features[idx].debounceWhileTyping ? featureState.get() : undefined + ) + ); + + const state = new LazyDerived(reader => { + const lineInfo = lineNumberWithObservableFeatures.read(reader); + if (lineInfo === undefined) { return undefined; } - const position = editor.getPosition(); - - const lineNumber = position.lineNumber; - const featureStates = new Map( - features.map((feature) => [ - feature, - featuresPerEditor.get(feature)!.isActive(lineNumber), - ]) - ); return { - lineNumber, - featureStates + lineNumber: lineInfo.lineNumber, + featureStates: new Map( + lineInfo.featureStatesForLine.map((featureState, idx) => [ + features[idx], + featureStatesBeforeTyping.read(reader)?.at(idx) ?? + featureState.read(reader), + ]) + ), }; - }; + }, 'state'); - let lastState: State | undefined; - const updateState = () => { - const newState = computeNewState(); - - for (const feature of features) { - if ( - newState && - newState.featureStates.get(feature) && - (!lastState?.featureStates?.get(feature) || - newState.lineNumber !== lastState.lineNumber) - ) { - this.playSound(feature.audioCueFilename); + store.add( + autorunDelta(state, ({ lastValue, newValue }) => { + for (const feature of features) { + if ( + newValue?.featureStates.get(feature) && + (!lastValue?.featureStates?.get(feature) || + newValue.lineNumber !== lastValue.lineNumber) + ) { + this.playSound(feature.audioCueFilename); + } } - } - - lastState = newState; - }; - - for (const feature of featuresPerEditor.values()) { - if (feature.onChange) { - store.add(feature.onChange(updateState)); - } - } - - { - let lastLineNumber = -1; - store.add( - editor.onDidChangeCursorPosition(() => { - const position = editor.getPosition(); - if (!position) { - return; - } - const lineNumber = position.lineNumber; - if (lineNumber === lastLineNumber) { - return; - } - lastLineNumber = lineNumber; - - updateState(); - }) - ); - } - - updateState(); + }) + ); } private async playSound(fileName: string) { @@ -197,82 +192,69 @@ export class AudioCueContribution extends DisposableStore implements IWorkbenchC interface Feature { audioCueFilename: string; - createForEditor( + debounceWhileTyping?: boolean; + getObservableState( editor: ICodeEditor, - uri: URI - ): FeatureResult; + model: ITextModel + ): IObservable; } -interface FeatureResult { +interface FeatureState { isActive(lineNumber: number): boolean; - onChange?: Event; } class ErrorFeature implements Feature { public readonly audioCueFilename = 'error'; + public readonly debounceWhileTyping = true; constructor(@IMarkerService private readonly markerService: IMarkerService) { } - createForEditor( - editor: ICodeEditor, - uri: URI - ): FeatureResult { - return { - isActive: (lineNumber) => { - const hasMarker = this.markerService - .read({ resource: uri }) - .some( - (m) => - m.severity === MarkerSeverity.Error && - m.startLineNumber <= lineNumber && - lineNumber <= m.endLineNumber - ); - return hasMarker; - }, - onChange: Event.map( - Event.filter( - this.markerService.onMarkerChanged, - (changedUris) => { - const curUri = editor.getModel()?.uri?.toString(); - return ( - !!curUri && changedUris.some((u) => u.toString() === curUri) - ); - } - ), - (x) => undefined + getObservableState(editor: ICodeEditor, model: ITextModel): IObservable { + return fromEvent( + Event.filter(this.markerService.onMarkerChanged, (changedUris) => + changedUris.some((u) => u.toString() === model.uri.toString()) ), - }; + () => ({ + isActive: (lineNumber) => { + const hasMarker = this.markerService + .read({ resource: model.uri }) + .some( + (m) => + m.severity === MarkerSeverity.Error && + m.startLineNumber <= lineNumber && + lineNumber <= m.endLineNumber + ); + return hasMarker; + }, + }) + ); } } class FoldedAreaFeature implements Feature { public readonly audioCueFilename = 'foldedAreas'; - createForEditor( - editor: ICodeEditor, - uri: URI - ): FeatureResult { - const emitter = new Emitter(); - let foldingModel: FoldingModel | null = null; - editor - .getContribution(FoldingController.ID) - ?.getFoldingModel() - ?.then((newFoldingModel) => { - foldingModel = newFoldingModel; - emitter.fire(); + getObservableState(editor: ICodeEditor, model: ITextModel): IObservable { + const foldingController = FoldingController.get(editor); + if (!foldingController) { + return constObservable({ + isActive: () => false, }); + } - return { - isActive: lineNumber => { - const regionAtLine = foldingModel?.getRegionAtLine(lineNumber); + const foldingModel = fromPromise( + foldingController.getFoldingModel() ?? Promise.resolve(undefined) + ); + return foldingModel.map((v) => ({ + isActive: (lineNumber) => { + const regionAtLine = v.value?.getRegionAtLine(lineNumber); const hasFolding = !regionAtLine ? false : regionAtLine.isCollapsed && regionAtLine.startLineNumber === lineNumber; return hasFolding; }, - onChange: emitter.event, - }; + })); } } @@ -281,22 +263,52 @@ class BreakpointFeature implements Feature { constructor(@IDebugService private readonly debugService: IDebugService) { } - createForEditor( - editor: ICodeEditor, - uri: URI - ): FeatureResult { - return { - isActive: (lineNumber) => { - const breakpoints = this.debugService - .getModel() - .getBreakpoints({ uri, lineNumber }); - const hasBreakpoints = breakpoints.length > 0; - return hasBreakpoints; - }, - onChange: Event.map( - this.debugService.getModel().onDidChangeBreakpoints, - () => undefined - ), - }; + getObservableState(editor: ICodeEditor, model: ITextModel): IObservable { + return fromEvent( + this.debugService.getModel().onDidChangeBreakpoints, + () => ({ + isActive: (lineNumber) => { + const breakpoints = this.debugService + .getModel() + .getBreakpoints({ uri: model.uri, lineNumber }); + const hasBreakpoints = breakpoints.length > 0; + return hasBreakpoints; + }, + }) + ); + } +} + +class InlineCompletionFeature implements Feature { + public readonly audioCueFilename = 'break'; + + getObservableState(editor: ICodeEditor, model: ITextModel): IObservable { + const ghostTextController = GhostTextController.get(editor); + if (!ghostTextController) { + return constObservable({ + isActive: () => false, + }); + } + + const activeGhostText = fromEvent( + ghostTextController.onActiveModelDidChange, + () => ghostTextController.activeModel + ).map((activeModel) => ( + activeModel + ? fromEvent( + activeModel.inlineCompletionsModel.onDidChange, + () => activeModel.inlineCompletionsModel.ghostText + ) + : undefined + )); + + return new LazyDerived(reader => { + const ghostText = activeGhostText.read(reader)?.read(reader); + return { + isActive(lineNumber) { + return ghostText?.lineNumber === lineNumber; + } + }; + }, 'ghostText'); } } diff --git a/src/vs/workbench/contrib/audioCues/browser/observable.ts b/src/vs/workbench/contrib/audioCues/browser/observable.ts new file mode 100644 index 00000000000..0c4fd38006d --- /dev/null +++ b/src/vs/workbench/contrib/audioCues/browser/observable.ts @@ -0,0 +1,575 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; + +export interface IObservable { + /** + * Reads the current value. + * + * This causes a recomputation if needed. + * Calling this method forces changes to propagate to observers during update operations. + * Must not be called from {@link IObserver.handleChange}. + */ + get(): T; + + /** + * Registers an observer. + * + * Calls {@link IObserver.handleChange} immediately after a change is noticed. + * Might happen while someone calls {@link IObservable.get} or {@link IObservable.read}. + */ + subscribe(observer: IObserver): void; + unsubscribe(observer: IObserver): void; + + /** + * Calls {@link IObservable.get} and then {@link IReader.handleBeforeReadObservable}. + */ + read(reader: IReader): T; + + map(fn: (value: T) => TNew): IObservable; +} + +export interface IReader { + /** + * Reports an observable that was read. + * + * Is called by `Observable.read`. + */ + handleBeforeReadObservable(observable: IObservable): void; +} + +export interface IObserver { + /** + * Indicates that an update operation is about to begin. + * + * During an update, invariants might not hold for subscribed observables and + * change events might be delayed. + * However, all changes must be reported before all update operations are over. + */ + beginUpdate(observable: IObservable): void; + + /** + * Is called by a subscribed observable immediately after it notices a change. + * + * When {@link IObservable.get} returns and no change has been reported, + * there has been no change for that observable. + * + * Implementations must not call into other observables! + * The change should be processed when {@link IObserver.endUpdate} is called. + */ + handleChange(observable: IObservable): void; + + /** + * Indicates that an update operation has completed. + */ + endUpdate(observable: IObservable): void; +} + +export interface ISettable { + set(value: T, transaction: ITransaction | undefined): void; +} + +export interface ITransaction { + /** + * Calls `Observer.beginUpdate` immediately + * and `Observer.endUpdate` when the transaction is complete. + */ + updateObserver( + observer: IObserver, + observable: IObservable + ): void; +} + +// === Base === +export abstract class ConvenientObservable implements IObservable { + public abstract get(): T; + public abstract subscribe(observer: IObserver): void; + public abstract unsubscribe(observer: IObserver): void; + + public read(reader: IReader): T { + reader.handleBeforeReadObservable(this); + return this.get(); + } + + public map(fn: (value: T) => TNew): IObservable { + return new LazyDerived((reader) => fn(this.read(reader)), '(mapped)'); + } +} + +export abstract class BaseObservable extends ConvenientObservable { + protected readonly observers = new Set(); + + public subscribe(observer: IObserver): void { + const len = this.observers.size; + this.observers.add(observer); + if (len === 0) { + this.onFirstObserverSubscribed(); + } + } + + public unsubscribe(observer: IObserver): void { + const deleted = this.observers.delete(observer); + if (deleted && this.observers.size === 0) { + this.onLastObserverUnsubscribed(); + } + } + + protected onFirstObserverSubscribed(): void { } + protected onLastObserverUnsubscribed(): void { } +} + +export function transaction(fn: (tx: ITransaction) => void) { + const tx = new TransactionImpl(); + try { + fn(tx); + } finally { + tx.finish(); + } +} + +class TransactionImpl implements ITransaction { + private readonly finishActions = new Array<() => void>(); + + public updateObserver( + observer: IObserver, + observable: IObservable + ): void { + this.finishActions.push(function () { + observer.endUpdate(observable); + }); + observer.beginUpdate(observable); + } + + public finish(): void { + for (const action of this.finishActions) { + action(); + } + } +} + +export class ObservableValue + extends BaseObservable + implements ISettable +{ + private value: T; + + constructor(initialValue: T, public readonly name: string) { + super(); + this.value = initialValue; + } + + public get(): T { + return this.value; + } + + public set(value: T, tx: ITransaction | undefined): void { + if (this.value === value) { + return; + } + + if (!tx) { + transaction((tx) => { + this.set(value, tx); + }); + return; + } + + this.value = value; + + for (const observer of this.observers) { + tx.updateObserver(observer, this); + observer.handleChange(this); + } + } +} + +export function constObservable(value: T): IObservable { + return new ConstObservable(value); +} + +class ConstObservable extends ConvenientObservable { + constructor(private readonly value: T) { + super(); + } + + public get(): T { + return this.value; + } + public subscribe(observer: IObserver): void { + // NO OP + } + public unsubscribe(observer: IObserver): void { + // NO OP + } +} + +// == autorun == +export function autorun( + fn: (reader: IReader) => void, + name: string +): IDisposable { + return new AutorunObserver(fn, name); +} + +export class AutorunObserver implements IObserver, IReader, IDisposable { + public needsToRun = true; + private updateCount = 0; + + /** + * The actual dependencies. + */ + private _dependencies = new Set>(); + public get dependencies() { + return this._dependencies; + } + + /** + * Dependencies that have to be removed when {@link runFn} ran through. + */ + private staleDependencies = new Set>(); + + constructor( + private readonly runFn: (reader: IReader) => void, + public readonly name: string + ) { + this.runIfNeeded(); + } + + public handleBeforeReadObservable(observable: IObservable) { + this._dependencies.add(observable); + if (!this.staleDependencies.delete(observable)) { + observable.subscribe(this); + } + } + + public handleChange() { + this.needsToRun = true; + + if (this.updateCount === 0) { + this.runIfNeeded(); + } + } + + public beginUpdate() { + this.updateCount++; + } + + public endUpdate() { + this.updateCount--; + if (this.updateCount === 0) { + this.runIfNeeded(); + } + } + + private runIfNeeded(): void { + if (!this.needsToRun) { + return; + } + // Assert: this.staleDependencies is an empty set. + const emptySet = this.staleDependencies; + this.staleDependencies = this._dependencies; + this._dependencies = emptySet; + + this.needsToRun = false; + + try { + this.runFn(this); + } finally { + // We don't want our observed observables to think that they are (not even temporarily) not being observed. + // Thus, we only unsubscribe from observables that are definitely not read anymore. + for (const o of this.staleDependencies) { + o.unsubscribe(this); + } + this.staleDependencies.clear(); + } + } + + public dispose() { + for (const o of this._dependencies) { + o.unsubscribe(this); + } + this._dependencies.clear(); + } +} + +export function autorunDelta( + observable: IObservable, + handler: (args: { lastValue: T | undefined; newValue: T }) => void +): IDisposable { + let _lastValue: T | undefined; + return autorun((reader) => { + const newValue = observable.read(reader); + const lastValue = _lastValue; + _lastValue = newValue; + handler({ lastValue, newValue }); + }, ''); +} + + +// == Lazy Derived == + +export class LazyDerived extends ConvenientObservable { + private readonly observer: LazyDerivedObserver; + + constructor(computeFn: (reader: IReader) => T, name: string) { + super(); + this.observer = new LazyDerivedObserver(computeFn, name); + } + + public subscribe(observer: IObserver): void { + this.observer.subscribe(observer); + } + + public unsubscribe(observer: IObserver): void { + this.observer.unsubscribe(observer); + } + + public get(): T { + return this.observer.get(); + } +} + +/** + * @internal + */ +class LazyDerivedObserver + extends BaseObservable + implements IReader, IObserver { + private hadValue = false; + private hasValue = false; + private value: T | undefined = undefined; + private updateCount = 0; + + private _dependencies = new Set>(); + public get dependencies(): ReadonlySet> { + return this._dependencies; + } + + /** + * Dependencies that have to be removed when {@link runFn} ran through. + */ + private staleDependencies = new Set>(); + + constructor( + private readonly computeFn: (reader: IReader) => T, + public readonly name: string + ) { + super(); + } + + protected override onLastObserverUnsubscribed(): void { + /** + * We are not tracking changes anymore, thus we have to assume + * that our cache is invalid. + */ + this.hasValue = false; + this.hadValue = false; + this.value = undefined; + for (const d of this._dependencies) { + d.unsubscribe(this); + } + this._dependencies.clear(); + } + + public handleBeforeReadObservable(observable: IObservable) { + this._dependencies.add(observable); + if (!this.staleDependencies.delete(observable)) { + observable.subscribe(this); + } + } + + public handleChange() { + if (this.hasValue) { + this.hadValue = true; + this.hasValue = false; + } + + // Not in transaction: Recompute & inform observers immediately + if (this.updateCount === 0 && this.observers.size > 0) { + this.get(); + } + + // Otherwise, recompute in `endUpdate` or on demand. + } + + public beginUpdate() { + if (this.updateCount === 0) { + for (const r of this.observers) { + r.beginUpdate(this); + } + } + this.updateCount++; + } + + public endUpdate() { + this.updateCount--; + if (this.updateCount === 0) { + if (this.observers.size > 0) { + // Propagate invalidation + this.get(); + } + + for (const r of this.observers) { + r.endUpdate(this); + } + } + } + + public get(): T { + if (this.observers.size === 0) { + // Cache is not valid and don't refresh the cache. + // Observables should not be read in non-reactive contexts. + return this.computeFn(this); + } + + if (this.updateCount > 0 && this.hasValue) { + // Refresh dependencies + for (const d of this._dependencies) { + // Maybe `.get()` triggers `handleChange`? + d.get(); + if (!this.hasValue) { + // The other dependencies will refresh on demand + break; + } + } + } + + if (!this.hasValue) { + const emptySet = this.staleDependencies; + this.staleDependencies = this._dependencies; + this._dependencies = emptySet; + + const oldValue = this.value; + try { + this.value = this.computeFn(this); + } finally { + // We don't want our observed observables to think that they are (not even temporarily) not being observed. + // Thus, we only unsubscribe from observables that are definitely not read anymore. + for (const o of this.staleDependencies) { + o.unsubscribe(this); + } + this.staleDependencies.clear(); + } + + this.hasValue = true; + if (this.hadValue && oldValue !== this.value) { + // + for (const r of this.observers) { + r.handleChange(this); + } + } + } + return this.value!; + } +} + +export function fromPromise(promise: Promise): IObservable<{ value?: T }> { + const observable = new ObservableValue<{ value?: T }>({}, 'promiseValue'); + promise.then((value) => { + observable.set({ value }, undefined); + }); + return observable; +} + +export function fromEvent( + event: Event, + getValue: (args: TArgs | undefined) => T +): IObservable { + return new FromEventObservable(event, getValue); +} + +class FromEventObservable extends BaseObservable { + private value: T | undefined; + private hasValue = false; + private subscription: IDisposable | undefined; + + constructor( + private readonly event: Event, + private readonly getValue: (args: TArgs | undefined) => T + ) { + super(); + } + + protected override onFirstObserverSubscribed(): void { + this.subscription = this.event(this.handleEvent); + } + + private readonly handleEvent = (args: TArgs | undefined) => { + const newValue = this.getValue(args); + if (this.value !== newValue) { + this.value = newValue; + + if (this.hasValue) { + transaction(tx => { + for (const o of this.observers) { + tx.updateObserver(o, this); + o.handleChange(this); + } + }); + } + this.hasValue = true; + } + }; + + protected override onLastObserverUnsubscribed(): void { + this.subscription!.dispose(); + this.subscription = undefined; + this.hasValue = false; + this.value = undefined; + } + + public get(): T { + if (this.subscription) { + if (!this.hasValue) { + this.handleEvent(undefined); + } + return this.value!; + } else { + // no cache, as there are no subscribers to clean it up + return this.getValue(undefined); + } + } +} + +export function debouncedObservable(observable: IObservable, debounceMs: number, disposableStore: DisposableStore): IObservable { + const debouncedObservable = new ObservableValue(observable.get(), 'debounced'); + + let timeout: NodeJS.Timeout | undefined; + + disposableStore.add(autorun(reader => { + const value = observable.read(reader); + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + transaction(tx => { + debouncedObservable.set(value, tx); + }); + }, debounceMs); + }, 'debounce')); + + return debouncedObservable; +} + +export function wasEventTriggeredRecently(event: Event, timeoutMs: number, disposableStore: DisposableStore): IObservable { + const observable = new ObservableValue(false, 'triggeredRecently'); + + let timeout: NodeJS.Timeout | undefined; + + disposableStore.add(event(() => { + observable.set(true, undefined); + + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + observable.set(false, undefined); + }, timeoutMs); + })); + + return observable; +}