diff --git a/package-lock.json b/package-lock.json index d1aa27f5ab..5a2dca5bfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2458,6 +2458,19 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sveltestack/svelte-query": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sveltestack/svelte-query/-/svelte-query-1.6.0.tgz", + "integrity": "sha512-C0wWuh6av1zu3Pzwrg6EQmX3BhDZQ4gMAdYu6Tfv4bjbEZTB00uEDz52z92IZdONh+iUKuyo0xRZ2e16k2Xifg==", + "peerDependencies": { + "broadcast-channel": "^4.5.0" + }, + "peerDependenciesMeta": { + "broadcast-channel": { + "optional": true + } + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "dev": true, @@ -18229,6 +18242,7 @@ "packages/webview-ui": { "version": "0.0.1", "dependencies": { + "@sveltestack/svelte-query": "^1.6.0", "@vscode/webview-ui-toolkit": "^1.2.2", "fast-equals": "^5.0.1", "sirv-cli": "^2.0.2", diff --git a/packages/_integrationTests/src/extension.test.cts b/packages/_integrationTests/src/extension.test.cts index 813c5acd4e..731bdd34e3 100644 --- a/packages/_integrationTests/src/extension.test.cts +++ b/packages/_integrationTests/src/extension.test.cts @@ -35,18 +35,9 @@ const apiSignature: Api = { describe('Launch code spell extension', function () { this.timeout(120000); const docUri = getDocUri('diagnostics.txt'); - const spellCheckNotifications: OnSpellCheckDocumentStep[] = []; - const disposables: { dispose: () => void }[] = []; this.beforeAll(async () => { - const ext = await activateExtension(); - disposables.push(ext.extApi.cSpellClient().onSpellCheckDocumentNotification((n) => spellCheckNotifications.push(n))); - }); - - this.afterEach(async () => { - disposables.forEach((d) => d.dispose()); - disposables.length = 0; - await sleep(1000); + await activateExtension(); }); it('Verify the extension starts', async () => { diff --git a/packages/client/src/infoViewer/infoHelper.ts b/packages/client/src/infoViewer/infoHelper.ts index 2171eba0ef..6736cd60c9 100644 --- a/packages/client/src/infoViewer/infoHelper.ts +++ b/packages/client/src/infoViewer/infoHelper.ts @@ -38,7 +38,7 @@ export async function calcSettings( client: CSpellClient, log: Logger, ): Promise { - const activeFolderUri = folderUri || getDefaultWorkspaceFolderUri(); + const activeFolderUri = folderUri || getDefaultWorkspaceFolderUri(document?.uri); const config = inspectConfig(activeFolderUri); const docConfig = await client.getConfigurationForDocument(document); const settings: Settings = { @@ -46,7 +46,7 @@ export async function calcSettings( dictionaries: extractDictionariesFromConfig(docConfig.settings), configs: extractViewerConfigFromConfig(config, docConfig, document, log), workspace: mapWorkspace(client.allowedSchemas, vscode.workspace), - activeFileUri: document && document.uri.toString(), + activeFileUri: document?.uri.toString(), activeFolderUri: activeFolderUri?.toString(), }; return settings; @@ -187,8 +187,9 @@ function getDefaultWorkspaceFolder() { return vscode.workspace.workspaceFolders?.[0]; } -function getDefaultWorkspaceFolderUri() { - return getDefaultWorkspaceFolder()?.uri; +function getDefaultWorkspaceFolderUri(docUri?: Uri) { + const docFolder = docUri && vscode.workspace.getWorkspaceFolder(docUri); + return docFolder || getDefaultWorkspaceFolder()?.uri; } function normalizeFilenameToFriendlyName(filename: string | Uri): string { diff --git a/packages/client/src/webview/AppState/Subscribables.ts b/packages/client/src/webview/AppState/Subscribables.ts deleted file mode 100644 index 379d7e174e..0000000000 --- a/packages/client/src/webview/AppState/Subscribables.ts +++ /dev/null @@ -1,227 +0,0 @@ -import deepEqual from 'fast-deep-equal'; -import type { DisposableHybrid as Disposable, DisposableLike, DisposeFn } from 'utils-disposables'; -import { createDisposable, disposeOf, InheritableDisposable } from 'utils-disposables'; - -export type MakeSubscribable = { - [K in keyof T]: K extends RO ? SubscribableValue : ObservableValue; -}; - -export type SubscriberFn = (v: T) => void; - -export interface Subscribable { - subscribe(s: SubscriberFn): Disposable; - dispose?: () => void; -} - -export interface SubscribableValue extends Required>, Disposable { - value: T | undefined; -} - -export interface ObservableValue extends SubscribableValue { - value: T; - set(v: T): void; - update(u: (v: T) => T): void; -} - -class Observable extends InheritableDisposable implements ObservableValue { - private _value: T; - private _subscriptions = new Set>(); - private _busy = false; - constructor(value: T) { - super(); - this._value = value; - } - - get value() { - return this._value; - } - - set value(v: T) { - this.set(v); - } - - set(value: T) { - // Do not update if the value has not changed. - if (this._value === value || deepEqual(this._value, value)) return; - this._value = value; - this.notify(); - return; - } - - subscribe(s: (v: T) => unknown): Disposable { - const subscriptions = this._subscriptions; - subscriptions.add(s); - s(this._value); - return createDisposable(() => subscriptions.delete(s)); - } - - update(u: (v: T) => T) { - return this.set(u(this._value)); - } - - private notify() { - if (this._busy) return; - try { - this._busy = true; - for (const s of this._subscriptions) { - s(this._value); - } - } finally { - this._busy = false; - } - } -} - -export function createStoreValue(v: T): ObservableValue { - return new Observable(v); -} - -export type SubscribeFn = (emitter: (v: T) => void) => DisposableLike | DisposeFn | undefined; - -const symbolNotSet = Symbol('A values that has not been set.'); - -interface CreateSubscribableValueOptions { - timeout?: number; -} - -export function createSubscribableValue( - subscribe: SubscribeFn, - initialValue?: T, - options?: CreateSubscribableValueOptions, -): SubscribableValue { - return new SubscribableValueImpl(subscribe, initialValue, options); -} - -type TimeOutHandle = ReturnType; - -class SubscribableValueImpl extends InheritableDisposable implements SubscribableValue { - private _timeout = 5000; - private _handleTimeout: TimeOutHandle | undefined = undefined; - private _subscriptions = new Set>(); - private _source: SubscribeFn; - - protected _started = false; - protected _value: T | typeof symbolNotSet; - protected _dispose: DisposableLike | DisposeFn | undefined; - protected _busy = false; - - constructor(subscribe: SubscribeFn, initialValue?: T, options?: CreateSubscribableValueOptions) { - super([() => this._cleanup()]); - this._source = subscribe; - this._value = initialValue !== undefined ? initialValue : symbolNotSet; - this._timeout = options?.timeout ?? this._timeout; - } - - private _cleanup() { - this._subscriptions.clear(); - disposeOf(this._dispose); - this._setTimeout(undefined); - } - - private _setTimeout(h: TimeOutHandle | undefined) { - const handle = this._handleTimeout; - this._handleTimeout = h; - if (handle) { - clearTimeout(handle); - } - } - - private _stop() { - if (this._subscriptions.size) return; - - this._started = false; - disposeOf(this._dispose); - } - - private _unSub(s: SubscriberFn) { - this._subscriptions.delete(s); - if (!this._subscriptions.size) { - this._setTimeout(setTimeout(() => this._stop(), this._timeout)); - } - } - - private _start() { - if (this._started) return; - this._started = true; - this._dispose = this._source((v: T) => this._notify(v)); - } - - private _notify(v: T) { - this._value = v; - if (this._busy) return; - try { - this._busy = true; - for (const s of this._subscriptions) { - s(v); - } - } finally { - this._busy = false; - } - } - - get value() { - return this._value === symbolNotSet ? undefined : this._value; - } - - private _sub(s: SubscriberFn): Disposable { - this._subscriptions.add(s); - if (this._value !== symbolNotSet) { - s(this._value); - } - this._start(); - return createDisposable(() => this._unSub(s)); - } - - public subscribe = this._sub.bind(this); -} - -export function awaitForSubscribable(sub: Subscribable): Promise { - let disposable: DisposableLike | undefined; - - return new Promise((resolve, _reject) => { - // prevent double resolve. - let resolved = false; - disposable = sub.subscribe((v) => { - if (resolved) return; - resolved = true; - try { - resolve(v); - } finally { - disposeOf(disposable); - } - }); - }); -} - -export interface Emitter extends Subscribable { - emit(value: T): void; - dispose(): void; -} - -export function createEmitter(): Emitter { - const subscriptions = new Set>(); - let _busy = false; - - function emit(value: T) { - if (_busy) return; - try { - _busy = true; - for (const s of subscriptions) { - s(value); - } - } finally { - _busy = false; - } - } - - return { - emit, - subscribe(s) { - subscriptions.add(s); - return createDisposable(() => subscriptions.delete(s)); - }, - dispose() { - subscriptions.clear(); - }, - }; -} diff --git a/packages/client/src/webview/AppState/Subscribables/StoreValue.test.ts b/packages/client/src/webview/AppState/Subscribables/StoreValue.test.ts new file mode 100644 index 0000000000..648f600191 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/StoreValue.test.ts @@ -0,0 +1,25 @@ +import { createEmitter, createSubscribable } from './createFunctions'; +import { awaitSubscribable } from './helpers/awaitSubscribable'; +import { createStoreValue } from './StoreValue'; + +describe('StoreValue', () => { + test('createStoreValue', async () => { + const store = createStoreValue(5); + expect(store.value).toBe(5); + store.set(7); + expect(store.value).toBe(7); + const cb = jest.fn(); + store.subscribe(cb); + expect(cb).toHaveBeenLastCalledWith(7); + store.dispose(); + }); + + test('createSubscribableValue', async () => { + const source = createEmitter(); + const sub = createSubscribable((s) => source.subscribe(s)); + const pValue = awaitSubscribable(sub); + source.notify(6); + await expect(pValue).resolves.toBe(6); + sub.dispose(); + }); +}); diff --git a/packages/client/src/webview/AppState/Subscribables/StoreValue.ts b/packages/client/src/webview/AppState/Subscribables/StoreValue.ts new file mode 100644 index 0000000000..65afaf19d7 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/StoreValue.ts @@ -0,0 +1,54 @@ +import deepEqual from 'fast-deep-equal'; + +import { toSubscriberFn } from './helpers/toSubscriber'; +import { AbstractSubscribable } from './internal/AbstractSubscribable'; +import type { Subscribable, SubscribableValue, SubscriberLike } from './Subscribables'; + +export interface StoreValue extends SubscribableValue { + value: T; + set(v: T): void; + update(u: (v: T) => T): void; +} + +export function createStoreValue(v: T): StoreValue { + return new StoreValueImpl(v); +} + +class StoreValueImpl extends AbstractSubscribable implements StoreValue { + private _value: T; + constructor(value: T) { + super(); + this._value = value; + } + + get value() { + return this._value; + } + + set value(v: T) { + this.set(v); + } + + set(value: T) { + // Do not update if the value has not changed. + if (this._value === value || deepEqual(this._value, value)) return; + this._value = value; + this.notify(value); + return; + } + + subscribe(s: SubscriberLike) { + const dispose = super.subscribe(s); + const sFn = toSubscriberFn(s); + sFn(this._value); + return dispose; + } + + update(u: (v: T) => T) { + return this.set(u(this._value)); + } +} + +export type MakeSubscribable = { + [K in keyof T]: K extends RO ? Subscribable : StoreValue; +}; diff --git a/packages/client/src/webview/AppState/Subscribables/SubscribableView.test.ts b/packages/client/src/webview/AppState/Subscribables/SubscribableView.test.ts new file mode 100644 index 0000000000..e232aa5b47 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/SubscribableView.test.ts @@ -0,0 +1,75 @@ +import { disposeOf } from 'utils-disposables'; + +import { map, rx } from '.'; +import { createEmitter } from './createFunctions'; +import { createSubscribableView } from './SubscribableView'; + +describe('SubscribableView', () => { + test('SubscribableView auto', () => { + const emitter = createEmitter(); + const view = createSubscribableView(emitter); + + let count = 10; + let last = count; + + expect(view.hasValue()).toBe(false); + expect(view.value).toBe(undefined); + emitter.notify((last = ++count)); + expect(view.value).toBe(last); + emitter.notify((last = ++count)); + expect(view.value).toBe(last); + disposeOf(view); + emitter.notify(++count); + expect(view.value).toBe(last); + }); + + test('SubscribableView non-auto', () => { + const emitter = createEmitter(); + const view1 = createSubscribableView(emitter, false); + + let count = 10; + let last = count; + + expect(view1.hasValue()).toBe(false); + expect(view1.value).toBe(undefined); + emitter.notify((last = ++count)); + expect(view1.hasValue()).toBe(false); + expect(view1.value).toBe(undefined); + + const view2 = createSubscribableView( + rx( + view1, + map((v) => v * 2), + map((v) => v.toString()), + ), + ); + expect(view2.hasValue()).toBe(false); + emitter.notify((last = ++count)); + expect(view1.value).toBe(last); + expect(view2.value).toBe((last * 2).toString()); + + emitter.notify((last = ++count)); + expect(view1.value).toBe(last); + expect(view2.value).toBe((last * 2).toString()); + + // Disposing of view2 stops everyone since it is the last subscriber. + disposeOf(view2); + emitter.notify(++count); + expect(view1.value).toBe(last); + expect(view2.value).toBe((last * 2).toString()); + + const view3 = createSubscribableView( + rx( + view1, + map((v) => v * 3), + map((v) => v.toString()), + ), + ); + // View 3 picks up the last value from view 1 + expect(view1.value).toBe(last); + expect(view3.value).toBe((last * 3).toString()); + emitter.notify((last = ++count)); + expect(view1.value).toBe(last); + expect(view3.value).toBe((last * 3).toString()); + }); +}); diff --git a/packages/client/src/webview/AppState/Subscribables/SubscribableView.ts b/packages/client/src/webview/AppState/Subscribables/SubscribableView.ts new file mode 100644 index 0000000000..50734968cd --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/SubscribableView.ts @@ -0,0 +1,51 @@ +import type { DisposableLike } from 'utils-disposables'; +import { disposeOf } from 'utils-disposables'; + +import { toSubscriberFn } from './helpers/toSubscriber'; +import { SubscribableImpl } from './internal/SubscribableImpl'; +import type { SubscribableLike, SubscribableValue, SubscriberLike } from './Subscribables'; + +export interface SubscribableView extends SubscribableValue { + value: T | undefined; + hasValue(): boolean; +} +const symbolNoValue = Symbol('has been set'); +type SymbolNoValue = typeof symbolNoValue; +class ViewImpl extends SubscribableImpl implements SubscribableView { + private _value: T | SymbolNoValue = symbolNoValue; + constructor(subscribable: SubscribableLike, autoStart = true) { + super(subscribable); + if (autoStart) { + let disposable: DisposableLike | undefined = this.subscribe({ + notify: () => undefined, + done: () => (disposeOf(disposable), (disposable = undefined)), + }); + } + } + + get value() { + return this._value === symbolNoValue ? undefined : this._value; + } + + protected notify(value: T): void { + this._value = value; + super.notify(value); + } + + subscribe(s: SubscriberLike) { + const dispose = super.subscribe(s); + if (this._value !== symbolNoValue) { + const sFn = toSubscriberFn(s); + sFn(this._value); + } + return dispose; + } + + hasValue(): boolean { + return this._value !== symbolNoValue; + } +} + +export function createSubscribableView(source: SubscribableLike, autoStart?: boolean): SubscribableView { + return new ViewImpl(source, autoStart); +} diff --git a/packages/client/src/webview/AppState/Subscribables.test.ts b/packages/client/src/webview/AppState/Subscribables/Subscribables.test.ts similarity index 55% rename from packages/client/src/webview/AppState/Subscribables.test.ts rename to packages/client/src/webview/AppState/Subscribables/Subscribables.test.ts index 5859fa9902..68151bb63d 100644 --- a/packages/client/src/webview/AppState/Subscribables.test.ts +++ b/packages/client/src/webview/AppState/Subscribables/Subscribables.test.ts @@ -1,4 +1,6 @@ -import { awaitForSubscribable, createEmitter, createStoreValue, createSubscribableValue } from './Subscribables'; +import { createEmitter, createSubscribable } from './createFunctions'; +import { awaitSubscribable } from './helpers/awaitSubscribable'; +import { delayUnsubscribe } from './operators/delayUnsubscribe'; describe('Subscribables', () => { test('createEmitter', () => { @@ -6,47 +8,45 @@ describe('Subscribables', () => { const sub = jest.fn(); emitter.subscribe(sub); expect(sub).not.toHaveBeenCalled(); - emitter.emit(7); + emitter.notify(7); expect(sub).toHaveBeenLastCalledWith(7); const sub2 = jest.fn(); const d2 = emitter.subscribe(sub2); expect(sub2).not.toHaveBeenCalled(); - emitter.emit(49); + emitter.notify(49); expect(sub).toHaveBeenLastCalledWith(49); expect(sub2).toHaveBeenLastCalledWith(49); d2.dispose(); - emitter.emit(42); + emitter.notify(42); expect(sub).toHaveBeenLastCalledWith(42); expect(sub2).toHaveBeenLastCalledWith(49); - emitter.dispose(); - emitter.emit(99); + emitter.done(); + emitter.notify(99); expect(sub).toHaveBeenLastCalledWith(42); expect(sub2).toHaveBeenLastCalledWith(49); }); test('awaitForSubscribable', async () => { const emitter = createEmitter(); - const pValue = awaitForSubscribable(emitter); - emitter.emit(42); + const pValue = awaitSubscribable(emitter); + emitter.notify(42); await expect(pValue).resolves.toBe(42); }); - test('createStoreValue', async () => { - const store = createStoreValue(5); - expect(store.value).toBe(5); - store.set(7); - expect(store.value).toBe(7); - const cb = jest.fn(); - store.subscribe(cb); - expect(cb).toHaveBeenLastCalledWith(7); - store.dispose(); + test('createSubscribableValue', async () => { + const source = createEmitter(); + const sub = delayUnsubscribe(5000)(source); + const pValue = awaitSubscribable(sub); + source.notify(6); + await expect(pValue).resolves.toBe(6); + sub.dispose?.(); }); - test('createSubscribableValue', async () => { + test('createSubscribable', async () => { const source = createEmitter(); - const sub = createSubscribableValue((s) => source.subscribe(s)); - const pValue = awaitForSubscribable(sub); - source.emit(6); + const sub = createSubscribable((s) => source.subscribe(s)); + const pValue = awaitSubscribable(sub); + source.notify(6); await expect(pValue).resolves.toBe(6); sub.dispose(); }); diff --git a/packages/client/src/webview/AppState/Subscribables/Subscribables.ts b/packages/client/src/webview/AppState/Subscribables/Subscribables.ts new file mode 100644 index 0000000000..da248dd150 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/Subscribables.ts @@ -0,0 +1,59 @@ +import type { DisposableHybrid as Disposable, DisposableLike, DisposeFn } from 'utils-disposables'; + +export type SubscriberLike = SubscriberFn | Subscriber; + +export type SubscriberFn = (v: T) => void; + +export type SubscribeFn = (notify: SubscriberLike) => DisposableLike | DisposeFn | undefined; + +export type SubscribableLike = SubscribeFn | Subscribable; + +export type EventType = 'onStart' | 'onStop' | 'onDone' | 'onNotify'; + +export interface SubscribableEvent { + name: EventType; + value?: unknown; +} + +export type EventListener = (event: SubscribableEvent) => void; + +export interface Subscriber { + /** Push a value to the subscriber */ + notify(value: T): void; + /** + * Called when the subscribable is done and no more values will be pushed. + */ + done?(): void; +} + +export interface Subscribable { + subscribe(s: SubscriberLike): Disposable; + /** Called to Dispose of this Subscribable */ + dispose: () => void; + + /** + * Listen to Subscribable Events + */ + onEvent(listener: EventListener): Disposable; + onEvent(eventType: EventType, listener: EventListener): Disposable; +} + +export interface SubscribableSubscriber extends Subscribable, Subscriber { + /** + * Called when the subscribable is done and no more values will be pushed. + * Any subscribers will be unsubscribed. + */ + done(): void; + /** + * Used to Dispose of the Subscribable + * It will first call {@link SubscribableSubscriber.done|done()} before + * disposing. + */ + dispose(): void; +} + +export interface SubscribableValue extends Subscribable { + value: T | undefined; +} + +export type OperatorFn = (source: Subscribable) => Subscribable; diff --git a/packages/client/src/webview/AppState/Subscribables/createFunctions.ts b/packages/client/src/webview/AppState/Subscribables/createFunctions.ts new file mode 100644 index 0000000000..832e5075d4 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/createFunctions.ts @@ -0,0 +1,11 @@ +import { EmitterImpl } from './internal/EmitterImpl'; +import { SubscribableImpl } from './internal/SubscribableImpl'; +import type { Subscribable, SubscribableLike, SubscribableSubscriber } from './Subscribables'; + +export function createEmitter(): SubscribableSubscriber { + return new EmitterImpl(); +} + +export function createSubscribable(subscribe: SubscribableLike): Subscribable { + return new SubscribableImpl(subscribe); +} diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/awaitAsyncIterable.ts b/packages/client/src/webview/AppState/Subscribables/helpers/awaitAsyncIterable.ts new file mode 100644 index 0000000000..a93d6cd386 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/awaitAsyncIterable.ts @@ -0,0 +1,9 @@ +export async function awaitAsyncIterable(i: AsyncIterable | AsyncIterableIterator): Promise { + const buffer: T[] = []; + + for await (const v of i) { + buffer.push(v); + } + + return buffer; +} diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/awaitSubscribable.ts b/packages/client/src/webview/AppState/Subscribables/helpers/awaitSubscribable.ts new file mode 100644 index 0000000000..a9ed2a0daa --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/awaitSubscribable.ts @@ -0,0 +1,54 @@ +import type { DisposableLike } from 'utils-disposables'; +import { disposeOf } from 'utils-disposables'; + +import type { EventType, Subscribable, SubscribableEvent } from '../Subscribables'; + +export function awaitSubscribable(sub: Subscribable): Promise { + let disposable: DisposableLike | undefined; + + return new Promise((resolve, _reject) => { + // prevent double resolve. + let resolved = false; + disposable = sub.subscribe((v) => { + if (resolved) return; + resolved = true; + try { + resolve(v); + } finally { + disposeOf(disposable); + } + }); + }); +} + +export function awaitSubscribableAll(sub: Subscribable): Promise { + let disposable: DisposableLike | undefined; + + return new Promise((resolve, _reject) => { + // prevent double resolve. + let resolved = false; + const buffer: T[] = []; + + function onDone() { + if (resolved) return; + resolved = true; + disposeOf(disposable); + resolve(buffer); + } + + disposable = sub.subscribe({ notify: (v) => buffer.push(v), done: onDone }); + }); +} + +export function awaitEvent(sub: Subscribable, eventName: EventType): Promise { + let disposable: DisposableLike | undefined; + + return new Promise((resolve) => { + disposable = sub.onEvent((e) => { + if (e.name == eventName) { + disposeOf(disposable); + resolve(e); + } + }); + }); +} diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/fromAsyncIterable.ts b/packages/client/src/webview/AppState/Subscribables/helpers/fromAsyncIterable.ts new file mode 100644 index 0000000000..a5932efa72 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/fromAsyncIterable.ts @@ -0,0 +1,33 @@ +import { disposeOf } from 'utils-disposables'; + +import { createEmitter } from '../createFunctions'; +import type { Subscribable, SubscribableEvent } from '../Subscribables'; + +export function fromAsyncIterable(iter: AsyncIterableIterator | AsyncIterable): Subscribable { + const emitter = createEmitter(); + let stop = false; + const disposeEventListener = emitter.onEvent(handleEvents); + + function handleEvents(e: SubscribableEvent) { + if (e.name === 'onStart') { + setTimeout(emitValues); + } + if (e.name === 'onStop' || e.name === 'onDone') { + stop = true; + } + } + + async function emitValues() { + try { + for await (const val of iter) { + if (stop) break; + emitter.notify(val); + } + } finally { + disposeOf(disposeEventListener); + emitter.done(); + } + } + + return emitter; +} diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/fromIterable.test.ts b/packages/client/src/webview/AppState/Subscribables/helpers/fromIterable.test.ts new file mode 100644 index 0000000000..aece4c4d44 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/fromIterable.test.ts @@ -0,0 +1,11 @@ +import { awaitSubscribableAll } from './awaitSubscribable'; +import { fromIterable } from './fromIterable'; + +describe('fromIterable', () => { + test('fromIterable', async () => { + const example = [5, 6, 7, 8]; + const s = fromIterable(example); + const result = await awaitSubscribableAll(s); + expect(result).toEqual(example); + }); +}); diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/fromIterable.ts b/packages/client/src/webview/AppState/Subscribables/helpers/fromIterable.ts new file mode 100644 index 0000000000..0a4cc6d903 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/fromIterable.ts @@ -0,0 +1,33 @@ +import { disposeOf } from 'utils-disposables'; + +import { createEmitter } from '../createFunctions'; +import type { Subscribable, SubscribableEvent } from '../Subscribables'; + +export function fromIterable(iter: IterableIterator | Iterable): Subscribable { + const emitter = createEmitter(); + let stop = false; + const disposeEventListener = emitter.onEvent(handleEvents); + + function handleEvents(e: SubscribableEvent) { + if (e.name === 'onStart') { + setTimeout(emitValues); + } + if (e.name === 'onStop' || e.name === 'onDone') { + stop = true; + } + } + + function emitValues() { + try { + for (const val of iter) { + if (stop) break; + emitter.notify(val); + } + } finally { + disposeOf(disposeEventListener); + emitter.done(); + } + } + + return emitter; +} diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/subscribeTo.ts b/packages/client/src/webview/AppState/Subscribables/helpers/subscribeTo.ts new file mode 100644 index 0000000000..c28ddccefe --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/subscribeTo.ts @@ -0,0 +1,8 @@ +import type { DisposableLike } from 'utils-disposables'; + +import type { SubscribableLike, SubscribeFn, SubscriberLike } from '../Subscribables'; + +export function subscribeTo(source: SubscribableLike, subscriber: SubscriberLike): DisposableLike { + const src: SubscribeFn = typeof source === 'function' ? source : (s) => source.subscribe(s); + return src(subscriber) ?? (() => undefined); +} diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/toAsyncIterable.test.ts b/packages/client/src/webview/AppState/Subscribables/helpers/toAsyncIterable.test.ts new file mode 100644 index 0000000000..231e7ff8c5 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/toAsyncIterable.test.ts @@ -0,0 +1,13 @@ +import { awaitAsyncIterable } from './awaitAsyncIterable'; +import { fromIterable } from './fromIterable'; +import { toAsyncIterable } from './toAsyncIterable'; + +describe('toAsyncIterable', () => { + test('toAsyncIterable', async () => { + const example = [5, 6, 7, 8]; + const s = fromIterable(example); + const a = toAsyncIterable(s); + const result = await awaitAsyncIterable(a); + expect(result).toEqual(example); + }); +}); diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/toAsyncIterable.ts b/packages/client/src/webview/AppState/Subscribables/helpers/toAsyncIterable.ts new file mode 100644 index 0000000000..53ff497561 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/toAsyncIterable.ts @@ -0,0 +1,81 @@ +import type { DisposableLike } from 'utils-disposables'; +import { disposeOf } from 'utils-disposables'; + +import type { Subscribable } from '../Subscribables'; + +export function toAsyncIterable(source: Subscribable): AsyncIterable { + let done = false; + let sourceDone = false; + let disposable: DisposableLike | undefined = undefined; + let pNextResolve: ResolveFn> | undefined = undefined; + const buffer: T[] = []; + + function stop() { + done = true; + sourceDone = true; + disposeOf(disposable); + disposable = undefined; + } + + function resolveValue(result: IteratorResult): boolean { + if (!pNextResolve) return false; + const resolve = pNextResolve; + pNextResolve = undefined; + resolve(result); + return true; + } + + function onNotify(value: T): void { + if (resolveValue({ value })) return; + buffer.push(value); + } + + function onDone() { + sourceDone = true; + if (pNextResolve && !buffer.length) { + done = true; + return resolveValue({ done: true, value: undefined }); + } + } + + function listen() { + if (disposable || sourceDone) return; + disposable = source.subscribe({ notify: onNotify, done: onDone }); + } + + async function next(): Promise> { + if (done) return { done, value: undefined }; + if (buffer.length) { + const nextValue = buffer.shift() as T; + return { value: nextValue }; + } + if (sourceDone) { + done = true; + return { done, value: undefined }; + } + + listen(); + + const pNext = new Promise>((resolve) => { + pNextResolve = resolve; + }); + return pNext; + } + + async function iterReturn(): Promise> { + stop(); + return { done: true, value: undefined }; + } + + function getIterator(): AsyncIterator { + return { + next, + return: iterReturn, + }; + } + + return { + [Symbol.asyncIterator]: getIterator, + }; +} +type ResolveFn = (value: T | PromiseLike) => void; diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/toSubscribable.ts b/packages/client/src/webview/AppState/Subscribables/helpers/toSubscribable.ts new file mode 100644 index 0000000000..de6e370032 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/toSubscribable.ts @@ -0,0 +1,6 @@ +import { createSubscribable } from '../createFunctions'; +import type { Subscribable, SubscribableLike } from '../Subscribables'; + +export function fromSubscribableLike(subscribable: SubscribableLike): Subscribable { + return typeof subscribable === 'function' ? createSubscribable(subscribable) : subscribable; +} diff --git a/packages/client/src/webview/AppState/Subscribables/helpers/toSubscriber.ts b/packages/client/src/webview/AppState/Subscribables/helpers/toSubscriber.ts new file mode 100644 index 0000000000..f11c5c4d64 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/helpers/toSubscriber.ts @@ -0,0 +1,13 @@ +import type { Subscriber, SubscriberFn, SubscriberLike } from '../Subscribables'; + +export function toSubscriber(subscriberLike: SubscriberLike): Subscriber { + if (typeof subscriberLike !== 'function') return subscriberLike; + return { + notify: subscriberLike, + }; +} + +export function toSubscriberFn(subscriberLike: SubscriberLike): SubscriberFn { + if (typeof subscriberLike === 'function') return subscriberLike; + return (value) => subscriberLike.notify(value); +} diff --git a/packages/client/src/webview/AppState/Subscribables/index.ts b/packages/client/src/webview/AppState/Subscribables/index.ts new file mode 100644 index 0000000000..bd8d72fee2 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/index.ts @@ -0,0 +1,6 @@ +export * from './createFunctions'; +export * from './operators/index'; +export { pipe } from './pipe'; +export { rx } from './rx'; +export type * from './Subscribables'; +export { createSubscribableView } from './SubscribableView'; diff --git a/packages/client/src/webview/AppState/Subscribables/internal/AbstractSubscribable.ts b/packages/client/src/webview/AppState/Subscribables/internal/AbstractSubscribable.ts new file mode 100644 index 0000000000..fa37a571e3 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/internal/AbstractSubscribable.ts @@ -0,0 +1,111 @@ +import type { DisposableHybrid as Disposable } from 'utils-disposables'; +import { createDisposable, InheritableDisposable } from 'utils-disposables'; + +import type { EventListener, EventType, Subscribable, SubscribableEvent, SubscriberLike } from '../Subscribables'; + +export abstract class AbstractSubscribable extends InheritableDisposable implements Subscribable { + private _subscriptions = new Set>(); + private _eventListeners = new Set(); + + protected _isRunning = false; + protected _isNotifyBusy = false; + + constructor() { + super([() => this.done(), () => this._eventListeners.clear()], 'AbstractSubscribable'); + } + + protected _hasSubscribers() { + return !!this._subscriptions.size; + } + + protected _stop() { + this._isRunning = false; + this.sendEvents({ name: 'onStop' }); + } + + protected _tryToStop() { + if (this._hasSubscribers()) return; + this._stop(); + } + + private _markAsDone(s: SubscriberLike) { + if (typeof s === 'function') return; + s.done?.(); + } + + private _markAllSubscriptionsAsDone() { + for (const s of this._subscriptions) { + this._subscriptions.delete(s); + this._markAsDone(s); + } + } + + private _unSub(s: SubscriberLike) { + this._subscriptions.delete(s); + // this._markAsDone(s); + this._tryToStop(); + } + + protected _start() { + if (this._isRunning) return; + this.sendEvents({ name: 'onStart' }); + this._isRunning = true; + } + + protected _notifySubscriber(s: SubscriberLike, value: T) { + return typeof s === 'function' ? s(value) : s.notify(value); + } + + private _notify(v: T) { + if (this._isNotifyBusy) return; + try { + this._isNotifyBusy = true; + for (const s of this._subscriptions) { + this._notifySubscriber(s, v); + } + } finally { + this._isNotifyBusy = false; + } + } + + public subscribe(s: SubscriberLike): Disposable { + this._subscriptions.add(s); + this._start(); + return createDisposable(() => this._unSub(s), undefined, 'subscribe'); + } + + protected notify(value: T): void { + this.sendEvents({ name: 'onNotify', value }); + this._notify(value); + } + + protected done(): void { + this.sendEvents({ name: 'onDone' }); + this._markAllSubscriptionsAsDone(); + this._subscriptions.clear(); + this._stop(); + } + + protected sendEvents(event: SubscribableEvent) { + for (const listener of this._eventListeners) { + listener(event); + } + } + + public onEvent(listener: EventListener): Disposable; + public onEvent(eventType: EventType, listener: EventListener): Disposable; + public onEvent(etOrL: EventType | EventListener, listener?: EventListener): Disposable { + if (typeof etOrL === 'function') { + this._eventListeners.add(etOrL); + return createDisposable(() => this._eventListeners.delete(etOrL), undefined, 'onEvent'); + } + + const eventType = etOrL; + const eventlistener: EventListener = (e) => { + if (e.name !== eventType) return; + listener?.(e); + }; + this._eventListeners.add(eventlistener); + return createDisposable(() => this._eventListeners.delete(eventlistener), undefined, `onEvent ${eventType}`); + } +} diff --git a/packages/client/src/webview/AppState/Subscribables/internal/EmitterImpl.ts b/packages/client/src/webview/AppState/Subscribables/internal/EmitterImpl.ts new file mode 100644 index 0000000000..e683bd4b49 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/internal/EmitterImpl.ts @@ -0,0 +1,16 @@ +import type { SubscribableSubscriber } from '../Subscribables'; +import { AbstractSubscribable } from './AbstractSubscribable'; + +export class EmitterImpl extends AbstractSubscribable implements SubscribableSubscriber { + constructor() { + super(); + } + + public notify(value: T) { + super.notify(value); + } + + public done() { + super.done(); + } +} diff --git a/packages/client/src/webview/AppState/Subscribables/internal/SubscribableImpl.ts b/packages/client/src/webview/AppState/Subscribables/internal/SubscribableImpl.ts new file mode 100644 index 0000000000..f66bb7c1b1 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/internal/SubscribableImpl.ts @@ -0,0 +1,30 @@ +import type { DisposableLike, DisposeFn } from 'utils-disposables'; +import { disposeOf } from 'utils-disposables'; + +import { subscribeTo } from '../helpers/subscribeTo'; +import type { SubscribableLike } from '../Subscribables'; +import { AbstractSubscribable } from './AbstractSubscribable'; + +export class SubscribableImpl extends AbstractSubscribable { + private _source: SubscribableLike; + + protected _dispose: DisposableLike | DisposeFn | undefined; + + constructor(subscribe: SubscribableLike) { + super(); + this._source = subscribe; + } + + protected _stop() { + super._stop(); + disposeOf(this._dispose); + this._dispose = undefined; + } + + protected _start() { + super._start(); + if (this._isRunning && !this._dispose) { + this._dispose = subscribeTo(this._source, { notify: (v) => this.notify(v), done: () => this.done() }); + } + } +} diff --git a/packages/client/src/webview/AppState/Subscribables/operators/awaitPromise.test.ts b/packages/client/src/webview/AppState/Subscribables/operators/awaitPromise.test.ts new file mode 100644 index 0000000000..0a26c5f57b --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/awaitPromise.test.ts @@ -0,0 +1,80 @@ +import { disposeOf } from 'utils-disposables'; + +import { createEmitter } from '../createFunctions'; +import { rx } from '../rx'; +import { awaitPromise, type AwaitPromiseErrorHandler } from './awaitPromise'; + +describe('awaitPromise', () => { + test('awaitPromise', async () => { + const emitter = createEmitter>(); + + const onReject = jest.fn(); + const stream = rx(emitter, awaitPromise(onReject)); + + const notify = jest.fn(); + const dispose = stream.subscribe(notify); + + const p0 = Promise.resolve('p0'); + emitter.notify(p0); + await p0; + expect(notify).toHaveBeenLastCalledWith('p0'); + expect(onReject).not.toHaveBeenCalled(); + + disposeOf(dispose); + }); + + test('awaitPromise rejected', async () => { + const emitter = createEmitter>(); + + const onReject = jest.fn(); + const stream = rx(emitter, awaitPromise(onReject)); + + const notify = jest.fn(); + const dispose = stream.subscribe(notify); + + const p0 = rejectIn(1, 'error'); + emitter.notify(p0); + await expect(p0).rejects.toEqual('error'); + expect(onReject).toHaveBeenCalledWith('error', expect.any(Function), p0); + expect(notify).not.toHaveBeenCalled(); + + disposeOf(dispose); + }); + + test('awaitPromise rejected and recovered', async () => { + type T = Promise; + const emitter = createEmitter(); + + const promiseExpected = new WeakMap(); + + const onReject = jest.fn>>((err, emitter, pValue) => + emitter(promiseExpected.get(pValue) || toStr(err)), + ); + const stream = rx(emitter, awaitPromise(onReject)); + + const notify = jest.fn(); + const dispose = stream.subscribe(notify); + + const p0 = rejectIn(1, 'error 0'); + const p1 = rejectIn(1, 'error 1'); + emitter.notify(p0); + emitter.notify(p1); + promiseExpected.set(p0, 'error P0'); + await expect(p0).rejects.toEqual('error 0'); + await expect(p1).rejects.toEqual('error 1'); + expect(onReject).toHaveBeenCalledWith('error 0', expect.any(Function), p0); + expect(onReject).toHaveBeenCalledWith('error 1', expect.any(Function), p1); + expect(notify).toHaveBeenCalledWith('error P0'); + expect(notify).toHaveBeenCalledWith('error 1'); + + disposeOf(dispose); + }); +}); + +function rejectIn(ms: number, err: unknown): Promise { + return new Promise((_resolve, reject) => setTimeout(() => reject(err), ms)); +} + +function toStr(err: unknown): string { + return typeof err === 'string' ? err : `[${typeof err}]`; +} diff --git a/packages/client/src/webview/AppState/Subscribables/operators/awaitPromise.ts b/packages/client/src/webview/AppState/Subscribables/operators/awaitPromise.ts new file mode 100644 index 0000000000..ac97470ee8 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/awaitPromise.ts @@ -0,0 +1,27 @@ +import type { OperatorFn } from '../Subscribables'; +import { operate } from './operate'; + +export type AwaitPromiseErrorHandler = ( + /** The error that occurred */ + err: unknown, + /** An emitter to publish a new value. */ + emitter: (v: Awaited) => void, + /** The promise that was rejected */ + value: T, +) => void; + +/** + * Waits for promises to be resolved before publishing the value. + * + * NOTE: the order is not guaranteed. + * @param onCatch - handler for the rejected promises. + * @returns + */ +export function awaitPromise(onCatch: AwaitPromiseErrorHandler): OperatorFn> { + return (source) => + operate(source, (value, emitter) => { + Promise.resolve(value) + .then(emitter) + .catch((err) => onCatch(err, emitter, value)); + }); +} diff --git a/packages/client/src/webview/AppState/Subscribables/operators/delayUnsubscribe.test.ts b/packages/client/src/webview/AppState/Subscribables/operators/delayUnsubscribe.test.ts new file mode 100644 index 0000000000..195880021c --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/delayUnsubscribe.test.ts @@ -0,0 +1,95 @@ +import { createDisposable, disposeOf } from 'utils-disposables'; + +import { createSubscribable } from '../createFunctions'; +import { toSubscriberFn } from '../helpers/toSubscriber'; +import { rx } from '../rx'; +import type { SubscribeFn, SubscriberFn, SubscriberLike } from '../Subscribables'; +import { delayUnsubscribe } from './delayUnsubscribe'; + +describe('delayUnsubscribe', () => { + test('delayUnsubscribe', () => { + const receiver = jest.fn(); + const defaultEmitter = () => undefined; + let emitter: SubscriberFn = defaultEmitter; + const resetEmitter = jest.fn(() => (emitter = defaultEmitter)); + expect(emitter).toBe(defaultEmitter); + const source: SubscribeFn = (subscriber: SubscriberLike) => { + emitter = toSubscriberFn(subscriber); + return createDisposable(resetEmitter); + }; + const sub = createSubscribable(source); + + // Since there haven't been any subscribers, do not expect the emitter to change. + expect(emitter).toBe(defaultEmitter); + const dispose1 = sub.subscribe(receiver); + expect(emitter).not.toBe(defaultEmitter); + // There should be a new emitter. + expect(emitter).not.toBe(defaultEmitter); + disposeOf(dispose1); + // Expect the emitter to be restored. + expect(emitter).toBe(defaultEmitter); + + const opDelayUnsubscribe = delayUnsubscribe(10000); + + const subDelayed = opDelayUnsubscribe(sub); + + // Expect the emitter to still be the default. + expect(emitter).toBe(defaultEmitter); + + const dispose2 = subDelayed.subscribe(receiver); + // There should be a new emitter. + expect(emitter).not.toBe(defaultEmitter); + const emitter2 = emitter; + emitter(42); + expect(receiver).toHaveBeenLastCalledWith(42); + disposeOf(dispose2); + // The emitter should still be emitter2; + expect(emitter).toBe(emitter2); + emitter(27); + expect(receiver).toHaveBeenLastCalledWith(42); + disposeOf(subDelayed); + expect(emitter).toBe(defaultEmitter); + }); + + test('delayUnsubscribe rx', () => { + const receiver = jest.fn(); + const defaultEmitter = () => undefined; + let emitter: SubscriberFn = defaultEmitter; + const resetEmitter = jest.fn(() => (emitter = defaultEmitter)); + expect(emitter).toBe(defaultEmitter); + const source: SubscribeFn = (subscriber: SubscriberLike) => { + emitter = toSubscriberFn(subscriber); + return createDisposable(resetEmitter); + }; + const sub = createSubscribable(source); + + // Since there haven't been any subscribers, do not expect the emitter to change. + expect(emitter).toBe(defaultEmitter); + const dispose1 = sub.subscribe(receiver); + expect(emitter).not.toBe(defaultEmitter); + // There should be a new emitter. + expect(emitter).not.toBe(defaultEmitter); + disposeOf(dispose1); + // Expect the emitter to be restored. + expect(emitter).toBe(defaultEmitter); + + const subDelayed = rx(sub, delayUnsubscribe(10000)); + + // Expect the emitter to still be the default. + expect(emitter).toBe(defaultEmitter); + + const dispose2 = subDelayed.subscribe(receiver); + // There should be a new emitter. + expect(emitter).not.toBe(defaultEmitter); + const emitter2 = emitter; + emitter(42); + expect(receiver).toHaveBeenLastCalledWith(42); + disposeOf(dispose2); + // The emitter should still be emitter2; + expect(emitter).toBe(emitter2); + emitter(27); + expect(receiver).toHaveBeenLastCalledWith(42); + disposeOf(subDelayed); + expect(emitter).toBe(defaultEmitter); + }); +}); diff --git a/packages/client/src/webview/AppState/Subscribables/operators/delayUnsubscribe.ts b/packages/client/src/webview/AppState/Subscribables/operators/delayUnsubscribe.ts new file mode 100644 index 0000000000..0ea17b9eae --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/delayUnsubscribe.ts @@ -0,0 +1,40 @@ +import { SubscribableImpl } from '../internal/SubscribableImpl'; +import type { OperatorFn, SubscribeFn } from '../Subscribables'; + +export function delayUnsubscribe(timeout: number): OperatorFn { + return (a) => { + return new SubscribableDelayedUnsubscribeImpl((s) => a.subscribe(s), timeout); + }; +} + +type TimeOutHandle = ReturnType; + +class SubscribableDelayedUnsubscribeImpl extends SubscribableImpl { + private _timeout: number; + private _handleTimeout: TimeOutHandle | undefined = undefined; + + constructor(subscribe: SubscribeFn, timeout: number) { + super(subscribe); + this._timeout = timeout; + } + + private clearTimeout() { + if (this._handleTimeout) { + clearTimeout(this._handleTimeout); + } + this._handleTimeout = undefined; + } + + protected _tryToStop(): void { + this.clearTimeout(); + this._handleTimeout = setTimeout(() => { + if (this._hasSubscribers()) return; + this._stop(); + }, this._timeout); + } + + protected done() { + this.clearTimeout(); + super.done(); + } +} diff --git a/packages/client/src/webview/AppState/Subscribables/operators/index.ts b/packages/client/src/webview/AppState/Subscribables/operators/index.ts new file mode 100644 index 0000000000..21e78bceca --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/index.ts @@ -0,0 +1,5 @@ +export type { AwaitPromiseErrorHandler } from './awaitPromise'; +export { awaitPromise } from './awaitPromise'; +export { delayUnsubscribe } from './delayUnsubscribe'; +export { map } from './map'; +export { throttle } from './throttle'; diff --git a/packages/client/src/webview/AppState/Subscribables/operators/map.test.ts b/packages/client/src/webview/AppState/Subscribables/operators/map.test.ts new file mode 100644 index 0000000000..8ca354b816 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/map.test.ts @@ -0,0 +1,16 @@ +import { awaitSubscribableAll } from '../helpers/awaitSubscribable'; +import { rx } from '../rx'; +import { map } from './map'; + +describe('map', () => { + test('map', async () => { + const data = [6, 5, 4, 3, 2]; + const s = rx( + data, + map((v) => v * 6), + ); + // s.onEvent((event) => console.log('%o Event: %o', new Date(), event)); + const result = await awaitSubscribableAll(s); + expect(result).toEqual(data.map((v) => v * 6)); + }); +}); diff --git a/packages/client/src/webview/AppState/Subscribables/operators/map.ts b/packages/client/src/webview/AppState/Subscribables/operators/map.ts new file mode 100644 index 0000000000..db6ebdd08c --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/map.ts @@ -0,0 +1,6 @@ +import type { OperatorFn } from '../Subscribables'; +import { operate } from './operate'; + +export function map(project: (v: T) => U): OperatorFn { + return (source) => operate(source, (value, emitter) => emitter(project(value))); +} diff --git a/packages/client/src/webview/AppState/Subscribables/operators/operate.ts b/packages/client/src/webview/AppState/Subscribables/operators/operate.ts new file mode 100644 index 0000000000..708af1a16f --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/operate.ts @@ -0,0 +1,20 @@ +import { createSubscribable } from '../createFunctions'; +import { toSubscriber } from '../helpers/toSubscriber'; +import type { Subscribable, SubscriberLike } from '../Subscribables'; + +export type OperateFn = (value: T, emitter: (value: U) => void) => void; + +export function operate(subscribable: Subscribable, next: OperateFn): Subscribable { + function subscribe(target: SubscriberLike) { + const subscriber = toSubscriber(target); + const emit = (value: U) => subscriber.notify(value); + return subscribable.subscribe({ + notify: (value: T) => { + next(value, emit); + }, + done: () => subscriber.done?.(), + }); + } + + return createSubscribable(subscribe); +} diff --git a/packages/client/src/webview/AppState/Subscribables/operators/throttle.test.ts b/packages/client/src/webview/AppState/Subscribables/operators/throttle.test.ts new file mode 100644 index 0000000000..53d8093aef --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/throttle.test.ts @@ -0,0 +1,47 @@ +import { disposeOf } from 'utils-disposables'; + +import { createEmitter } from '../createFunctions'; +import { rx } from '../rx'; +import { throttle } from './throttle'; + +describe('throttle', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + }); + + test('throttle', () => { + const emitter = createEmitter(); + const stream = rx(emitter, throttle(1000)); + const notify = jest.fn(); + const done = jest.fn(); + let counter = 55; + let leading = counter; + let trailing = counter; + emitter.notify(++counter); + const disposable = stream.subscribe({ notify, done }); + + jest.runOnlyPendingTimers(); + + expect(notify).not.toHaveBeenCalled(); + expect(done).not.toHaveBeenCalled(); + + emitter.notify((leading = ++counter)); + emitter.notify(++counter); + emitter.notify((trailing = ++counter)); + + expect(notify).toHaveBeenLastCalledWith(leading); + + jest.runOnlyPendingTimers(); + expect(notify).toHaveBeenLastCalledWith(trailing); + jest.runOnlyPendingTimers(); + emitter.notify((leading = ++counter)); + expect(notify).toHaveBeenLastCalledWith(leading); + + disposeOf(disposable); + }); +}); diff --git a/packages/client/src/webview/AppState/Subscribables/operators/throttle.ts b/packages/client/src/webview/AppState/Subscribables/operators/throttle.ts new file mode 100644 index 0000000000..442a11dd39 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/operators/throttle.ts @@ -0,0 +1,37 @@ +import type { OperatorFn } from '../Subscribables'; +import { operate } from './operate'; + +const symbolNotSet = Symbol('not set'); +type SymbolNotSet = typeof symbolNotSet; + +/** + * Throttle the release of events. + * @param waitMs - delay in milliseconds. + * @returns + */ +export function throttle(waitMs: number): OperatorFn { + let pendingValue: T | SymbolNotSet = symbolNotSet; + let timer: ReturnType | undefined = undefined; + + return (source) => { + const subscribable = operate(source, (value, notify) => { + pendingValue = value; + if (timer) { + return; + } + function handleTimer() { + const value = pendingValue; + pendingValue = symbolNotSet; + timer = undefined; + if (value !== symbolNotSet) { + timer = setTimeout(handleTimer, waitMs); + notify(value); + } + } + + handleTimer(); + }); + subscribable.onEvent('onStop', () => clearTimeout(timer)); + return subscribable; + }; +} diff --git a/packages/client/src/webview/AppState/Subscribables/pipe.test.ts b/packages/client/src/webview/AppState/Subscribables/pipe.test.ts new file mode 100644 index 0000000000..bdb146f81a --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/pipe.test.ts @@ -0,0 +1,34 @@ +import { awaitSubscribableAll } from './helpers/awaitSubscribable'; +import { map } from './operators'; +import { pipe } from './pipe'; +import { rx } from './rx'; + +describe('pipe', () => { + test('pipe', async () => { + const data = [6, 5, 4, 3, 2]; + const s = pipe( + rx(data), + map((v) => 2 * v), + map((v) => 3 * v), + ); + const result = await awaitSubscribableAll(s); + expect(result).toEqual(data.map((v) => v * 6)); + }); + + test('pipe async', async () => { + const data = [6, 5, 4, 3, 2]; + const s = pipe( + rx(toAsync(data)), + map((v) => 2 * v), + map((v) => 3 * v), + ); + const result = await awaitSubscribableAll(s); + expect(result).toEqual(data.map((v) => v * 6)); + }); +}); + +async function* toAsync(i: Iterable | AsyncIterable): AsyncIterable { + for await (const v of i) { + yield v; + } +} diff --git a/packages/client/src/webview/AppState/Subscribables/pipe.ts b/packages/client/src/webview/AppState/Subscribables/pipe.ts new file mode 100644 index 0000000000..f264356577 --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/pipe.ts @@ -0,0 +1,19 @@ +import type { OperatorFn, Subscribable } from './Subscribables'; + +export function pipe(subscribable: Subscribable): Subscribable; +export function pipe(subscribable: Subscribable, op0: OperatorFn): Subscribable; +export function pipe(subscribable: Subscribable, op0: OperatorFn, op1: OperatorFn): Subscribable; +export function pipe( + subscribable: Subscribable, + op0: OperatorFn, + op1: OperatorFn, + op2: OperatorFn, +): Subscribable; +export function pipe(subscribable: Subscribable, ...ops: OperatorFn[]): Subscribable; +export function pipe(subscribable: Subscribable, ...ops: OperatorFn[]): Subscribable { + let s = subscribable; + for (const op of ops) { + s = op(s); + } + return s; +} diff --git a/packages/client/src/webview/AppState/Subscribables/rx.ts b/packages/client/src/webview/AppState/Subscribables/rx.ts new file mode 100644 index 0000000000..36496744ac --- /dev/null +++ b/packages/client/src/webview/AppState/Subscribables/rx.ts @@ -0,0 +1,35 @@ +import { fromAsyncIterable } from './helpers/fromAsyncIterable'; +import { fromIterable } from './helpers/fromIterable'; +import { fromSubscribableLike } from './helpers/toSubscribable'; +import { pipe } from './pipe'; +import type { OperatorFn, Subscribable, SubscribableLike } from './Subscribables'; + +export type Subscribables = SubscribableLike | AsyncIterable | Iterable; + +type OpFn = OperatorFn; + +type Op2 = [OpFn, OpFn]; +type Op3 = [...Op2, OpFn]; +type Op4 = [...Op3, OpFn]; + +export function rx(subscribable: Subscribables): Subscribable; +export function rx(subscribable: Subscribables): Subscribable; +export function rx(subscribable: Subscribables, operator: OperatorFn): Subscribable; +export function rx(subscribable: Subscribables, op1: OperatorFn, op2: OperatorFn): Subscribable; +export function rx(subscribable: Subscribables, ...ops: Op2): Subscribable; +export function rx(subscribable: Subscribables, ...ops: Op3): Subscribable; +export function rx(subscribable: Subscribables, ...ops: Op4): Subscribable; +export function rx(subscribable: Subscribables, ...ops: OperatorFn[]): Subscribable; +export function rx(subscribable: Subscribables, ...ops: OperatorFn[]): Subscribable { + return pipe(toSubscribable(subscribable), ...ops); +} + +function toSubscribable(subscribable: Subscribables): Subscribable { + if (Symbol.iterator in subscribable) { + return fromIterable(subscribable); + } + if (Symbol.asyncIterator in subscribable) { + return fromAsyncIterable(subscribable); + } + return fromSubscribableLike(subscribable); +} diff --git a/packages/client/src/webview/AppState/index.ts b/packages/client/src/webview/AppState/index.ts index f48c46a6c2..ed8c0332b9 100644 --- a/packages/client/src/webview/AppState/index.ts +++ b/packages/client/src/webview/AppState/index.ts @@ -1,2 +1,2 @@ -export { store } from './store'; -export { awaitForSubscribable } from './Subscribables'; +export { calcDocSettings, getWebviewGlobalStore } from './store'; +export { awaitSubscribable } from './Subscribables/helpers/awaitSubscribable'; diff --git a/packages/client/src/webview/AppState/store.ts b/packages/client/src/webview/AppState/store.ts index 03cbc2f735..faa559163e 100644 --- a/packages/client/src/webview/AppState/store.ts +++ b/packages/client/src/webview/AppState/store.ts @@ -1,53 +1,87 @@ -import type { DisposableClassic, DisposableHybrid } from 'utils-disposables'; -import { createDisposableFromList } from 'utils-disposables'; -import type { TextEditor } from 'vscode'; +import type { DisposableClassic, DisposableHybrid, DisposableLike } from 'utils-disposables'; +import { createDisposable, createDisposableFromList, disposeOf, injectDisposable } from 'utils-disposables'; +import type { TextDocument, TextEditor, Uri } from 'vscode'; import { window } from 'vscode'; import { getLogLevel, LogLevel, setLogLevel } from 'vscode-webview-rpc/logger'; import type { WatchFieldList, WatchFields } from 'webview-api'; +import { getDependencies } from '../../di'; +import { calcSettings } from '../../infoViewer/infoHelper'; import type { AppStateData } from '../apiTypes'; -import type { MakeSubscribable, ObservableValue, SubscriberFn } from './Subscribables'; -import { createStoreValue, createSubscribableValue } from './Subscribables'; +import { createSubscribableView, pipe, rx, throttle } from './Subscribables'; +import { toSubscriberFn } from './Subscribables/helpers/toSubscriber'; +import type { MakeSubscribable, StoreValue } from './Subscribables/StoreValue'; +import { createStoreValue } from './Subscribables/StoreValue'; +import type { SubscriberLike } from './Subscribables/Subscribables'; export interface Storage { seq: number; state: MakeSubscribable; + dispose(): void; } const debug = false; debug && setLogLevel(LogLevel.debug); -const writableState = { - logLevel: createStoreValue(getLogLevel()), - todos: createStoreValue([]), -} as const; - -export const store: Storage = { - seq: 1, - state: { - ...writableState, - currentDocument: createSubscribableValue(subscribeToCurrentDocument), - }, -}; - -function subscribeToCurrentDocument(emitter: SubscriberFn): DisposableHybrid { - const disposables: DisposableClassic[] = []; +let store: Storage | undefined = undefined; + +export function getWebviewGlobalStore(): Storage { + if (store) return store; + + const currentDocumentSub = rx(subscribeToCurrentDocument); + const currentDocument = pipe(currentDocumentSub, throttle(500), /* delayUnsubscribe(5000), */ createSubscribableView); + currentDocument.onEvent('onNotify', (event) => console.log('current document update: %o', event)); + + function dispose() { + disposeOf(currentDocumentSub); + const _store = store; + store = undefined; + if (!_store) return; + Object.values(_store.state).forEach((s) => disposeOf(s)); + } + + const writableState = { + logLevel: createStoreValue(getLogLevel()), + todos: createStoreValue([]), + } as const; + + const _store: Storage = injectDisposable( + { + seq: 1, + state: { + ...writableState, + currentDocument, + }, + }, + dispose, + 'getWebviewGlobalStore', + ); + + return (store = _store); +} + +function subscribeToCurrentDocument(subscriber: SubscriberLike): DisposableHybrid { + const emitter = toSubscriberFn(subscriber); + const disposables: DisposableLike[] = []; const disposable = createDisposableFromList(disposables); setCurrentDocument(window.activeTextEditor); - window.onDidChangeActiveTextEditor(setCurrentDocument, undefined, disposables); - window.onDidChangeTextEditorSelection( - (event) => event.textEditor === window.activeTextEditor && setCurrentDocument(event.textEditor), - undefined, - disposables, + disposables.push(disposeClassic(window.onDidChangeActiveTextEditor(setCurrentDocument, undefined))); + disposables.push( + disposeClassic( + window.onDidChangeTextEditorSelection( + (event) => event.textEditor === window.activeTextEditor && setCurrentDocument(event.textEditor), + undefined, + ), + ), ); return disposable; function setCurrentDocument(textEditor: TextEditor | undefined) { if (!textEditor) { - emitter(null); + // emitter(null); return; } @@ -61,13 +95,20 @@ function subscribeToCurrentDocument(emitter: SubscriberFn { seq: number; success: boolean; value: T; } -export function updateState(seq: number | undefined, value: T, s: ObservableValue): StateUpdate { +export function updateState(seq: number | undefined, value: T, s: StoreValue): StateUpdate { + const store = getWebviewGlobalStore(); if (seq && seq !== store.seq) return { seq: store.seq, value: s.value, success: false }; store.seq++; @@ -76,6 +117,7 @@ export function updateState(seq: number | undefined, value: T, s: ObservableV } export function watchFieldList(fieldsToWatch: Set, onChange: (changedFields: WatchFieldList) => void): DisposableHybrid { + const store = getWebviewGlobalStore(); const list = [...fieldsToWatch]; const disposables = list .map((field) => ({ field, sub: store.state[field] })) @@ -85,3 +127,32 @@ export function watchFieldList(fieldsToWatch: Set, onChange: (chang return createDisposableFromList(disposables); } + +function findMatchTextDocument(url: UrlLike): TextDocument | undefined { + return findMatchingEditor(url)?.document; +} + +function findMatchingEditor(url: UrlLike): TextEditor | undefined { + for (const editor of window.visibleTextEditors) { + if (!compareUrl(editor.document.uri, url)) return editor; + } + return undefined; +} + +type UrlLike = URL | Uri | string; + +function compareUrl(a: UrlLike, b: UrlLike): number { + const aa = normalizeUrlToString(a); + const bb = normalizeUrlToString(b); + if (aa === bb) return 0; + return aa < bb ? -1 : 1; +} + +function normalizeUrlToString(url: UrlLike): string { + const decoded = decodeURIComponent(decodeURIComponent(url.toString())).normalize('NFC'); + return decoded.replace(/^file:\/\/\/[a-z]:/i, (fileUrl) => fileUrl.toLowerCase()); +} + +function disposeClassic(disposable: DisposableClassic): DisposableHybrid { + return createDisposable(() => disposable.dispose(), undefined, 'disposeClassic'); +} diff --git a/packages/client/src/webview/api/api.ts b/packages/client/src/webview/api/api.ts index 67a76607cf..c8b020b521 100644 --- a/packages/client/src/webview/api/api.ts +++ b/packages/client/src/webview/api/api.ts @@ -1,24 +1,25 @@ -import { createDisposeMethodFromList, type DisposableLike, disposeOf, injectDisposable } from 'utils-disposables'; +import { createDisposableList, type DisposableLike, disposeOf, injectDisposable, makeDisposable } from 'utils-disposables'; import { window } from 'vscode'; import { type MessageConnection } from 'vscode-jsonrpc/node'; import type { RequestResult, SetValueRequest, SetValueResult, WatchFieldList, WatchFields } from 'webview-api'; import { createServerSideSpellInfoWebviewApi } from 'webview-api'; import type { ServerSideApi, ServerSideApiDef } from '../apiTypes'; -import { awaitForSubscribable, store } from '../AppState'; -import { type Storage, updateState, watchFieldList } from '../AppState/store'; -import type { ObservableValue, SubscribableValue } from '../AppState/Subscribables'; -import { sampleList } from './staticData'; +import { awaitSubscribable, getWebviewGlobalStore } from '../AppState'; +import { calcDocSettings, type Storage, updateState, watchFieldList } from '../AppState/store'; +import type { StoreValue } from '../AppState/Subscribables/StoreValue'; +import type { Subscribable } from '../AppState/Subscribables/Subscribables'; +import { sampleList } from './staticTestData'; export function createApi(connection: MessageConnection) { - return bindApiAndStore(connection, store); + return bindApiAndStore(connection, getWebviewGlobalStore()); } export function bindApiAndStore(connection: MessageConnection, store: Storage): ServerSideApi { let watcher: DisposableLike | undefined = undefined; const fieldsToWatch = new Set(); - const disposables: DisposableLike[] = [() => disposeOf(watcher)]; - const dispose = createDisposeMethodFromList(disposables); + const disposables = createDisposableList([() => disposeOf(watcher)], 'bindApiAndStore'); + const dispose = disposables.dispose; const api: ServerSideApiDef = { serverRequests: { @@ -26,6 +27,7 @@ export function bindApiAndStore(connection: MessageConnection, store: Storage): getLogLevel: () => resolveRequest(store.state.logLevel), getTodos: () => resolveRequest(store.state.todos), getCurrentDocument: () => resolveRequest(store.state.currentDocument), + getDocSettings: calcDocSettings, setLogLevel: (r) => updateStateRequest(r, store.state.logLevel), setTodos: (r) => updateStateRequest(r, store.state.todos), watchFields, @@ -41,9 +43,9 @@ export function bindApiAndStore(connection: MessageConnection, store: Storage): }; const serverSideApi = createServerSideSpellInfoWebviewApi(connection, api); - disposables.push(serverSideApi); + disposables.push(makeDisposable(serverSideApi)); - return injectDisposable({ ...serverSideApi }, dispose); + return injectDisposable({ ...serverSideApi }, dispose, 'bindApiAndStore'); /** Add fields to be watched. */ function watchFields(req: WatchFieldList) { @@ -74,12 +76,12 @@ export function bindApiAndStore(connection: MessageConnection, store: Storage): } } -function updateStateRequest(r: SetValueRequest, s: ObservableValue): SetValueResult { +function updateStateRequest(r: SetValueRequest, s: StoreValue): SetValueResult { return updateState(r.seq, r.value, s); } -function resolveRequest(s: SubscribableValue): Promise> { - return asyncToResultP(awaitForSubscribable(s)); +function resolveRequest(s: Subscribable): Promise> { + return asyncToResultP(awaitSubscribable(s)); } async function asyncToResultP(value: Promise): Promise> { @@ -90,7 +92,7 @@ async function asyncToResultP(value: Promise): Promise> { function toResult(value: T): RequestResult { // console.warn('toResult: %o', value); return { - seq: store.seq, + seq: getWebviewGlobalStore().seq, value, }; } diff --git a/packages/client/src/webview/api/staticData.ts b/packages/client/src/webview/api/staticTestData.ts similarity index 100% rename from packages/client/src/webview/api/staticData.ts rename to packages/client/src/webview/api/staticTestData.ts diff --git a/packages/client/src/webview/index.ts b/packages/client/src/webview/index.ts index ad8aba841f..6518864996 100644 --- a/packages/client/src/webview/index.ts +++ b/packages/client/src/webview/index.ts @@ -1,38 +1 @@ -import type { ExtensionContext } from 'vscode'; -import { commands, window } from 'vscode'; -import { supportedViewsByName } from 'webview-api'; - -import { HelloWorldPanel } from './panels/HelloWorldPanel'; -import { TodoViewProvider } from './providers/TodoViewProvider'; -import { WebviewApiViewProvider } from './providers/viewProviders'; - -export const registeredCommands = ['cspell-info.showHelloWorld'] as const; - -type CommandNames = (typeof registeredCommands)[number]; - -type RegisteredCommandNames = { - [P in CommandNames]: P; -}; - -const rCommands = Object.fromEntries(registeredCommands.map((name) => [name, name] as const)) as RegisteredCommandNames; - -export function activate(context: ExtensionContext) { - const { subscriptions, extensionUri } = context; - - const views = [ - new TodoViewProvider(extensionUri), - new WebviewApiViewProvider(extensionUri, supportedViewsByName['cspell-info'], 'cspell-info.infoView'), - ]; - - for (const view of views) { - subscriptions.push(window.registerWebviewViewProvider(view.viewType, view)); - } - - // Create the show hello world command - const showHelloWorldCommand = commands.registerCommand(rCommands['cspell-info.showHelloWorld'], () => { - HelloWorldPanel.render(context.extensionUri); - }); - - // Add command to the extension context - subscriptions.push(showHelloWorldCommand, { dispose: () => HelloWorldPanel.currentPanel?.dispose() }); -} +export { activate, registeredCommands } from './webview'; diff --git a/packages/client/src/webview/panels/HelloWorldPanel.ts b/packages/client/src/webview/panels/HelloWorldPanel.ts index 616e62792b..20da477977 100644 --- a/packages/client/src/webview/panels/HelloWorldPanel.ts +++ b/packages/client/src/webview/panels/HelloWorldPanel.ts @@ -1,4 +1,5 @@ -import type { Disposable, Uri, WebviewPanel } from 'vscode'; +import { createDisposableList } from 'utils-disposables'; +import type { Uri, WebviewPanel } from 'vscode'; import { ViewColumn, window } from 'vscode'; import { HelloWorldView } from '../views/HelloWorldView'; @@ -16,7 +17,7 @@ import { HelloWorldView } from '../views/HelloWorldView'; export class HelloWorldPanel { public static currentPanel: HelloWorldPanel | undefined; private readonly _panel: WebviewPanel; - private _disposables: Disposable[] = []; + private _disposables = createDisposableList(undefined, 'HelloWorldPanel'); /** * The HelloWorldPanel class private constructor (called only from the render method). @@ -30,8 +31,7 @@ export class HelloWorldPanel { // Set an event listener to listen for when the panel is disposed (i.e. when the user closes // the panel or when the panel is closed programmatically) - this._panel.onDidDispose(() => this.dispose(), null, this._disposables); - + this._disposables.push(this._panel.onDidDispose(() => this.dispose())); this._disposables.push(HelloWorldView.bindView(this._panel.webview, extensionUri)); } @@ -75,11 +75,6 @@ export class HelloWorldPanel { // this._panel.dispose() is the first element on the list.; // Dispose of all disposables (i.e. commands) for the current webview panel - while (this._disposables.length) { - const disposable = this._disposables.pop(); - if (disposable) { - disposable.dispose(); - } - } + this._disposables.dispose(); } } diff --git a/packages/client/src/webview/views/AppView.ts b/packages/client/src/webview/views/AppView.ts index 8bacf8b487..063bbcd937 100644 --- a/packages/client/src/webview/views/AppView.ts +++ b/packages/client/src/webview/views/AppView.ts @@ -1,4 +1,5 @@ -import type { Disposable, Webview, WebviewOptions } from 'vscode'; +import { createDisposableList, makeDisposable } from 'utils-disposables'; +import type { Webview, WebviewOptions } from 'vscode'; import { Uri } from 'vscode'; import { createConnectionToWebview } from 'vscode-webview-rpc/extension'; import type { SupportedViews } from 'webview-api'; @@ -8,17 +9,18 @@ import { getNonce } from '../utilities/getNonce'; import { getUri } from '../utilities/getUri'; /** - * This class manages the state and behavior of HelloWorld webview panels. + * This class manages the state and behavior of the webview panels. * * It contains all the data and methods for: * - * - Creating and rendering HelloWorld webview panels + * - Creating and rendering webview panels * - Properly cleaning up and disposing of webview resources when the panel is closed * - Setting the HTML (and by proxy CSS/JavaScript) content of the webview panel * - Setting message listeners so data can be passed between the webview and extension */ export class AppView { - private _disposables: Disposable[] = []; + private _disposables = createDisposableList(undefined, 'AppView'); + public readonly dispose = this._disposables.dispose; /** * The HelloWorldPanel class private constructor (called only from the render method). @@ -38,9 +40,9 @@ export class AppView { // Set an event listener to listen for messages passed from the webview context const rpc = createConnectionToWebview(webview); - this._disposables.push(createApi(rpc)); + this._disposables.push(makeDisposable(createApi(rpc), 'createApi')); rpc.listen(); - this._disposables.push(rpc); + this._disposables.push(makeDisposable(rpc, 'rpc')); } public calcOptions(): WebviewOptions { @@ -53,22 +55,6 @@ export class AppView { }; } - /** - * Cleans up and disposes of webview resources when the webview panel is closed. - */ - public dispose() { - // Dispose of the current webview panel - // this._panel.dispose() is the first element on the list.; - - // Dispose of all disposables (i.e. commands) for the current webview panel - while (this._disposables.length) { - const disposable = this._disposables.pop(); - if (disposable) { - disposable.dispose(); - } - } - } - /** * Defines and returns the HTML that should be rendered within the webview panel. * diff --git a/packages/client/src/webview/webview.ts b/packages/client/src/webview/webview.ts new file mode 100644 index 0000000000..c18ff4fa69 --- /dev/null +++ b/packages/client/src/webview/webview.ts @@ -0,0 +1,57 @@ +import type { ExtensionContext } from 'vscode'; +import { commands, window } from 'vscode'; +import { supportedViewsByName } from 'webview-api'; + +import { getWebviewGlobalStore } from './AppState'; +// import { getWebviewGlobalStore } from './AppState/store'; +import { HelloWorldPanel } from './panels/HelloWorldPanel'; +import { TodoViewProvider } from './providers/TodoViewProvider'; +import { WebviewApiViewProvider } from './providers/viewProviders'; + +export const registeredCommands = ['cspell-info.showHelloWorld'] as const; + +type CommandNames = (typeof registeredCommands)[number]; + +type RegisteredCommandNames = { + [P in CommandNames]: P; +}; + +const rCommands = Object.fromEntries(registeredCommands.map((name) => [name, name] as const)) as RegisteredCommandNames; + +export function activate(context: ExtensionContext) { + const { subscriptions, extensionUri } = context; + + // subscriptions.push(debugDispose('Dispose Activate 0')); + + const views = [ + new TodoViewProvider(extensionUri), + new WebviewApiViewProvider(extensionUri, supportedViewsByName['cspell-info'], 'cspell-info.infoView'), + ]; + + for (const view of views) { + subscriptions.push(window.registerWebviewViewProvider(view.viewType, view)); + } + + // subscriptions.push(debugDispose('Dispose Activate 1')); + + // Create the show hello world command + const showHelloWorldCommand = commands.registerCommand(rCommands['cspell-info.showHelloWorld'], () => { + HelloWorldPanel.render(context.extensionUri); + }); + + // subscriptions.push(debugDispose('Dispose Activate 2')); + + // Add command to the extension context + subscriptions.push(showHelloWorldCommand, { dispose: () => HelloWorldPanel.currentPanel?.dispose() }); + + // subscriptions.push(debugDispose('Dispose Activate 3')); + + // Add state clean up. + subscriptions.push(getWebviewGlobalStore()); + + // subscriptions.push(debugDispose('Dispose Activate 4')); +} + +// function debugDispose(name: string) { +// return createDisposable(() => console.error(name), undefined, name); +// } diff --git a/packages/utils-disposables/src/DisposableList.test.ts b/packages/utils-disposables/src/DisposableList.test.ts new file mode 100644 index 0000000000..cfd316d932 --- /dev/null +++ b/packages/utils-disposables/src/DisposableList.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from '@jest/globals'; + +import { type DisposableLike, disposeOf, isDisposableHybrid, isDisposed } from './disposable.js'; +import { createDisposableList, DisposableList, InheritableDisposable } from './DisposableList.js'; + +describe('disposable', () => { + test('InheritableDisposable', () => { + let count = 0; + class MyDisposable extends InheritableDisposable { + constructor(disposables: DisposableLike[]) { + super(disposables); + } + } + + function use() { + using _d = new MyDisposable([() => (count += 10)]); + } + + expect(count).toBe(0); + + use(); + + expect(count).toBe(10); + }); + + test('DisposableList', () => { + let count = 0; + + function use() { + using list = new DisposableList([() => (count += 10)]); + list.push(() => (count += 100)); + } + + expect(count).toBe(0); + + use(); + + expect(count).toBe(110); + }); + + test('createDisposableList', () => { + const list = createDisposableList(); + let count = 0; + list.push(() => (count += 1)); + disposeOf(list); + expect(count).toBe(1); + expect(isDisposed(list)).toBe(true); + expect(list.isDisposed()).toBe(true); + }); + + test('double dispose', () => { + let count = 0; + const list = createDisposableList([() => (count += 10)]); + function use() { + using aliasList = list; + aliasList.push(() => (count += 100)); + } + + expect(list.length).toBe(1); + use(); + + expect(count).toBe(110); + expect(list.length).toBe(0); + + expect(() => list.push(() => (count += 1000))).toThrowError('Already disposed, cannot add items.'); + + expect(list.length).toBe(0); + list.dispose(); + expect(list.length).toBe(0); + + expect(count).toBe(110); + }); + + test('', () => { + const list = createDisposableList(); + expect(isDisposableHybrid(list)).toBe(true); + expect(list.isDisposed()); + }); +}); diff --git a/packages/utils-disposables/src/DisposableList.ts b/packages/utils-disposables/src/DisposableList.ts new file mode 100644 index 0000000000..ebce341b83 --- /dev/null +++ b/packages/utils-disposables/src/DisposableList.ts @@ -0,0 +1,60 @@ +import type { DisposableHybrid, DisposableLike } from './disposable.js'; +import { createDisposeMethodFromList, symbolIsDisposed } from './disposable.js'; + +/** This is a class that can be inherited to provide Disposable support. */ + +export const noop = () => undefined; + +export class InheritableDisposable implements DisposableHybrid { + public dispose: () => void; + public [Symbol.dispose]: () => void = noop; + public [symbolIsDisposed]: boolean = false; + + /** the inherited class can safely add disposables to _disposables */ + protected readonly _disposables: DisposableLike[]; + constructor(disposables?: DisposableLike[], name = 'InheritableDisposable') { + this._disposables = disposables ?? []; + const _dispose = createDisposeMethodFromList(this._disposables, name); + const dispose = () => { + if (this.isDisposed()) return; + this[symbolIsDisposed] = true; + _dispose(); + // Prevent new disposables from being added. + Object.freeze(this._disposables); + }; + this.dispose = dispose; + this[Symbol.dispose] = dispose; + } + + protected isDisposed(): boolean { + return this[symbolIsDisposed]; + } +} + +export class DisposableList extends InheritableDisposable { + constructor( + public readonly disposables: DisposableLike[] = [], + readonly name = 'DisposableList', + ) { + super(disposables); + } + + public push(disposable: DisposableLike) { + if (this.isDisposed()) { + throw new Error('Already disposed, cannot add items.'); + } + this.disposables.push(disposable); + } + + get length() { + return this.disposables.length; + } + + public isDisposed(): boolean { + return super.isDisposed(); + } +} + +export function createDisposableList(disposables?: DisposableLike[], name?: string): DisposableList { + return new DisposableList(disposables, name); +} diff --git a/packages/utils-disposables/src/disposable.test.ts b/packages/utils-disposables/src/disposable.test.ts index a7aa4e66b1..a814081276 100644 --- a/packages/utils-disposables/src/disposable.test.ts +++ b/packages/utils-disposables/src/disposable.test.ts @@ -1,7 +1,20 @@ import { describe, expect, jest, test } from '@jest/globals'; -import type { DisposableLike } from './disposable.js'; -import { createDisposable, createDisposableFromList, InheritableDisposable, injectDisposable } from './disposable.js'; +import type { DisposableLike, DisposeFn } from './disposable.js'; +import { + createDisposable, + createDisposableFromList, + createDisposeMethodFromList, + disposeOf, + getDisposableName, + injectDisposable, + isDisposableHybrid, + isDisposed, + makeDisposable, + setDebugMode, + setDisposableName, +} from './disposable.js'; +import { InheritableDisposable } from './DisposableList.js'; describe('disposable', () => { test('createDisposable', () => { @@ -15,6 +28,20 @@ describe('disposable', () => { expect(dispose).toHaveBeenCalledTimes(1); }); + test('createDisposable named', () => { + const dispose = jest.fn(); + const myDisposable = createDisposable(dispose, undefined, 'MyDisposable'); + + function use() { + using _obj = myDisposable; + } + expect(isDisposed(myDisposable)).toBe(false); + use(); + expect(isDisposed(myDisposable)).toBe(true); + expect(getDisposableName(myDisposable)).toBe('MyDisposable'); + expect(dispose).toHaveBeenCalledTimes(1); + }); + test('createDisposable thisArg', () => { const myObj = { callMe: jest.fn(), @@ -108,4 +135,113 @@ describe('disposable', () => { expect(count).toBe(10); }); + + test.each` + value | expected + ${undefined} | ${false} + ${null} | ${false} + ${1} | ${false} + ${'hello'} | ${false} + ${{}} | ${false} + ${makeDisposable(() => undefined)} | ${true} + `('isDisposableHybrid', ({ value, expected }) => { + expect(isDisposableHybrid(value)).toBe(expected); + }); + + test('makeDisposable', () => { + let count = 0; + const a1 = () => (count += 1); + const d1 = makeDisposable(a1); + expect(isDisposableHybrid(d1)).toBe(true); + expect(isDisposed(d1)).toBe(false); + expect(makeDisposable(d1)).toBe(d1); + expect(count).toBe(0); + expect(getDisposableName(d1)).toBe('makeDisposable'); + + const a2 = { dispose: () => (count += 10) }; + const d2 = makeDisposable(a2); + expect(isDisposableHybrid(d2)).toBe(true); + + const a3 = { [Symbol.dispose]: () => (count += 100) }; + const d3 = makeDisposable(a3); + expect(isDisposableHybrid(d3)).toBe(true); + + const a4 = { [Symbol.dispose]: a1, dispose: a1 }; + const d4 = makeDisposable(a4, 'a4'); + expect(isDisposableHybrid(d4)).toBe(true); + expect(getDisposableName(d4)).toBe('a4'); + + d4.dispose(); + expect(count).toBe(1); + }); + + test('get/set name', () => { + const d = createDisposable(() => undefined, undefined, 'name1'); + expect(getDisposableName(d)).toBe('name1'); + setDisposableName(d, 'name2'); + expect(getDisposableName(d)).toBe('name2'); + }); +}); + +describe('disposable debug', () => { + beforeEach(() => { + setDebugMode(true); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + setDebugMode(false); + jest.resetAllMocks(); + }); + + test('createDisposableFromList', () => { + let count = 0; + const disposables = [ + createDisposable(() => (count += 1)), + createDisposable(() => (count += 10)), + createDisposable(() => (count += 100)), + ]; + function use() { + using _obj = createDisposableFromList(disposables); + } + use(); + expect(count).toBe(111); + }); + + test('dispose with errors', () => { + const e1 = Error('one'); + const e2 = Error('two'); + + const d = createDisposableFromList([ + () => { + throw e1; + }, + () => { + throw e2; + }, + ]); + + expect(() => disposeOf(d)).toThrow(e2); + }); + + test('createDisposableFromList double dispose', () => { + const list: DisposeFn[] = []; + const d = createDisposableFromList(list); + d.dispose(); + list.push(() => undefined); + d.dispose(); + // It was not disposed. + expect(list.length).toBe(1); + }); + + test('createDisposableFromList double dispose', () => { + const list: DisposeFn[] = []; + const d = createDisposeMethodFromList(list); + d(); + list.push(() => undefined); + d(); + // It was disposed. + expect(list.length).toBe(0); + }); }); diff --git a/packages/utils-disposables/src/disposable.ts b/packages/utils-disposables/src/disposable.ts index 090ff16414..0c8e8c9597 100644 --- a/packages/utils-disposables/src/disposable.ts +++ b/packages/utils-disposables/src/disposable.ts @@ -9,6 +9,27 @@ interface Disposable { [Symbol.dispose](): void; } +export const symbolDisposableName = Symbol('Disposable Name'); +export type SymbolDisposableName = typeof symbolDisposableName; + +export const symbolDisposableTs = Symbol('Disposable Timestamp'); +export type SymbolDisposableTs = typeof symbolDisposableTs; + +export const symbolIsDisposed = Symbol('Disposable Is Disposed'); +export type SymbolIsDisposed = typeof symbolIsDisposed; + +let debugMode = false; + +let debugDepth = 0; +let activeDisposables = 0; + +function logDebug(...params: Parameters): void { + if (!debugMode) return; + const [msg, ...rest] = params; + + console.log(' '.repeat(debugDepth) + msg, ...rest); +} + export type DisposeFn = () => void; export interface DisposableHybrid { @@ -17,6 +38,20 @@ export interface DisposableHybrid { */ dispose(): void; [Symbol.dispose](): void; + /** + * The name of the disposable, used for debugging purposes. + * It can be helpful to trace the origins of the disposable. + */ + [symbolDisposableName]?: string; + /** + * The timestamp, see: [Performance.now()](https://developer.mozilla.org/en-US/docs/Web/API/Performance/now) + */ + [symbolDisposableTs]?: number; + + /** + * Indicates if the disposable has been disposed. + */ + [symbolIsDisposed]?: boolean; } export interface DisposableClassic { @@ -39,65 +74,127 @@ export type DisposableLike = DisposableHybrid | DisposableClassic | DisposablePr * Create a Disposable object. * @param disposeFn - function to call when this option is disposed. * @param thisArg - optional this value + * @param name - optional debug name * @returns A Disposable */ -export function createDisposable(disposeFn: DisposeFn, thisArg?: T): DisposableHybrid { +export function createDisposable(disposeFn: DisposeFn, thisArg?: T, name?: string): DisposableHybrid { // We want to prevent double disposal calls. // This can happen if there are multiple systems calling dispose. let isDisposed = false; + let errors = false; - function dispose() { - if (isDisposed) return; - isDisposed = true; - thisArg ? disposeFn.call(thisArg) : disposeFn(); - } - - return { + const disposable: DisposableHybrid = { dispose, [Symbol.dispose]: dispose, + [symbolDisposableTs]: performance.now(), + [symbolDisposableName]: name || undefined, + [symbolIsDisposed]: false, }; + + ++activeDisposables; + debugMode && logDebug('Created: %s, active: %i', debugId(disposable), activeDisposables); + + return disposable; + + function dispose() { + try { + debugMode && logDebug('Dispose Start -> %s Active %i', debugId(disposable), activeDisposables); + ++debugDepth; + // isDisposed is the source of truth, not `disposable[symbolIsDisposed]` + if (isDisposed) { + disposable[symbolIsDisposed] = true; + debugMode && console.error('Already disposed %s', debugId(disposable)); + return; + } + --activeDisposables; + disposable[symbolIsDisposed] = isDisposed = true; + thisArg ? disposeFn.call(thisArg) : disposeFn(); + } catch (err) { + errors = true; + throw err; + } finally { + --debugDepth; + debugMode && + logDebug('Dispose End -> %s Active %i%s', debugId(disposable), activeDisposables, errors ? ' ** with errors ** ' : ''); + } + } +} + +function debugId(disposable: DisposableHybrid): string { + const name = getDisposableName(disposable) || ''; + const ts = getDisposableTs(disposable)?.toFixed(4); + return name + ' ' + ts; } /** * Make and object Disposable by adding disposable properties. * @param obj - Object to modify * @param dispose - the dispose function. + * @param name - optional debug name * @returns the same object. */ -export function injectDisposable(obj: T, dispose: () => void): T & DisposableHybrid { - return Object.assign(obj, createDisposable(dispose, obj)); +export function injectDisposable(obj: T, dispose: () => void, name?: string): T & DisposableHybrid { + return Object.assign(obj, createDisposable(dispose, obj, name)); } /** * Create a Disposable that will dispose the list of disposables. * @param disposables - list of Disposables + * @param name - optional debug name * @returns A Disposable */ -export function createDisposableFromList(disposables: DisposableLike[]): DisposableHybrid { - return createDisposable(createDisposeMethodFromList(disposables)); +export function createDisposableFromList(disposables: DisposableLike[], name = 'createDisposableFromList'): DisposableHybrid { + return createDisposable(createDisposeMethodFromList(disposables), undefined, name); } /** * Create a disposeFn based upon a list of disposables. * @param disposables - list of Disposables + * @param name - optional debug name * @returns A dispose function */ -export function createDisposeMethodFromList(disposables: DisposableLike[]): () => void { +export function createDisposeMethodFromList(disposables: DisposableLike[], name = ''): () => void { + let disposed = false; + const tsId = performance.now().toFixed(4); + + debugMode && (name = 'createDisposeMethodFromList ' + (name || '')); + + debugMode && logDebug('Create: %s %s', name, tsId); + + let errors = 0; + function dispose() { - let error: unknown | undefined = undefined; + try { + debugMode && logDebug('Dispose Start -> %s %s', name, tsId); + ++debugDepth; + let error: unknown | undefined = undefined; + if (disposed) { + debugMode && console.error('Already disposed %s with %o open.', name, disposables.length); + if (!disposables.length) return; + // keep going, try to clean up the list if possible. + } + disposed = true; - let disposable: DisposableLike | undefined; + let disposable: DisposableLike | undefined; - // Note disposables are disposed in reverse order by default. - while ((disposable = disposables.pop())) { - try { - disposeOf(disposable); - } catch (e) { - error ??= e; + // Note disposables are disposed in reverse order by default. + while ((disposable = disposables.pop())) { + try { + disposeOf(disposable); + } catch (e) { + ++errors; + error ??= e; + } } - } - if (error) throw error; + if (error) { + debugMode && console.error(error); + throw error; + } + } finally { + --debugDepth; + debugMode && logDebug('Dispose End -> %s %s%s', name, tsId, errors ? ` *** with ${errors} errors ***` : ''); + } } return dispose; } @@ -121,17 +218,41 @@ export function disposeOf(disposable: DisposableLike | DisposeFn | undefined): v _disposable.dispose.call(disposable); } -/** This is a class that can be inherited to provide Disposable support. */ -export class InheritableDisposable implements DisposableHybrid { - public dispose: () => void; - public [Symbol.dispose]: () => void = () => undefined; +/** + * Make a disposable into a DisposableHybrid + * @param disposable - Disposable Like + * @param name - optional debug name + * @returns DisposableHybrid + */ +export function makeDisposable(disposable: DisposableLike, name = 'makeDisposable'): DisposableHybrid { + if (isDisposableHybrid(disposable)) return disposable; + if (Symbol.dispose in disposable) return createDisposable(disposable[Symbol.dispose], disposable, name); + if ('dispose' in disposable) return createDisposable(disposable['dispose'], disposable, name); + return createDisposable(disposable, undefined, name); +} + +export function setDisposableName(disposable: DisposableHybrid, name: string | undefined): DisposableHybrid { + disposable[symbolDisposableName] = name; + return disposable; +} + +export function getDisposableName(disposable: DisposableHybrid): string | undefined { + return disposable[symbolDisposableName]; +} - /** the inherited class can safely add disposables to _disposables */ - protected readonly _disposables: DisposableLike[]; - constructor(disposables?: DisposableLike[]) { - this._disposables = disposables ?? []; - const dispose = createDisposeMethodFromList(this._disposables); - this.dispose = dispose; - this[Symbol.dispose] = dispose; - } +export function getDisposableTs(disposable: DisposableHybrid): number | undefined { + return disposable[symbolDisposableTs]; +} + +export function isDisposed(disposable: DisposableHybrid): boolean | undefined { + return disposable[symbolIsDisposed]; +} + +export function setDebugMode(enable: boolean) { + debugMode = enable; +} + +export function isDisposableHybrid(disposable: unknown): disposable is DisposableHybrid { + if (!disposable || typeof disposable !== 'object') return false; + return symbolIsDisposed in disposable; } diff --git a/packages/utils-disposables/src/index.ts b/packages/utils-disposables/src/index.ts index 57903b4444..fa6d7397d9 100644 --- a/packages/utils-disposables/src/index.ts +++ b/packages/utils-disposables/src/index.ts @@ -4,6 +4,8 @@ export { createDisposableFromList, createDisposeMethodFromList, disposeOf, - InheritableDisposable, + getDisposableTs, injectDisposable, + makeDisposable, } from './disposable.js'; +export { createDisposableList, DisposableList, InheritableDisposable } from './DisposableList.js'; diff --git a/packages/webview-api/src/__snapshots__/api.test.ts.snap b/packages/webview-api/src/__snapshots__/api.test.ts.snap index b1e8207946..0f7b900952 100644 --- a/packages/webview-api/src/__snapshots__/api.test.ts.snap +++ b/packages/webview-api/src/__snapshots__/api.test.ts.snap @@ -5,6 +5,7 @@ exports[`api > Creating a Server API 1`] = ` "clientNotification.onStateChange", "serverNotification.showInformationMessage", "serverRequest.getCurrentDocument", + "serverRequest.getDocSettings", "serverRequest.getLogLevel", "serverRequest.getTodos", "serverRequest.resetTodos", @@ -26,6 +27,7 @@ exports[`api > Creating a Server API 2`] = ` "function", "function", "function", + "function", "object", ] `; diff --git a/packages/webview-api/src/api.test.ts b/packages/webview-api/src/api.test.ts index 07405056de..6a4408c32d 100644 --- a/packages/webview-api/src/api.test.ts +++ b/packages/webview-api/src/api.test.ts @@ -22,6 +22,7 @@ describe('api', () => { }, serverRequests: { getCurrentDocument: true, + getDocSettings: true, getLogLevel: true, getTodos: true, resetTodos: true, diff --git a/packages/webview-api/src/api.ts b/packages/webview-api/src/api.ts index 0624c17001..6a0e831202 100644 --- a/packages/webview-api/src/api.ts +++ b/packages/webview-api/src/api.ts @@ -10,7 +10,16 @@ import type { } from 'vscode-webview-rpc'; import { createClientApi, createServerApi } from 'vscode-webview-rpc'; -import type { LogLevel, RequestResult, SetValueRequest, SetValueResult, TextDocumentRef, TodoList, WatchFieldList } from './apiModels'; +import type { + LogLevel, + RequestResult, + Settings, + SetValueRequest, + SetValueResult, + TextDocumentRef, + TodoList, + WatchFieldList, +} from './apiModels'; export { setLogLevel } from 'vscode-webview-rpc/logger'; @@ -20,6 +29,7 @@ export interface ServerRequestsAPI { getLogLevel(): RequestResult; getTodos(): RequestResult; getCurrentDocument(): RequestResult; + getDocSettings(docUrl?: string): Settings | null; resetTodos(): SetValueResult; setLogLevel(req: SetValueRequest): SetValueResult; setTodos(req: SetValueRequest): SetValueResult; diff --git a/packages/webview-api/src/apiModels.ts b/packages/webview-api/src/apiModels.ts index e29fe4017a..a6ac072fdf 100644 --- a/packages/webview-api/src/apiModels.ts +++ b/packages/webview-api/src/apiModels.ts @@ -1,5 +1,6 @@ import type { LogLevel } from 'vscode-webview-rpc'; +export { Settings } from './models/settings'; export { LogLevel } from 'vscode-webview-rpc'; export interface Todo { diff --git a/packages/webview-api/src/models/settings.ts b/packages/webview-api/src/models/settings.ts new file mode 100644 index 0000000000..365aaa19a8 --- /dev/null +++ b/packages/webview-api/src/models/settings.ts @@ -0,0 +1,91 @@ +import type { TextDocument, Workspace } from './workspace'; + +export interface Settings { + dictionaries: DictionaryEntry[]; + configs: Configs; + knownLanguageIds: string[]; + workspace?: Workspace; + activeFileUri?: string; + activeFolderUri?: string; +} + +export type LocaleId = string; +export type LocaleList = LocaleId[]; + +export type FileType = string; +export type FileTypeList = FileType[]; + +export type FileUri = string; + +export type ConfigTarget = keyof SettingByConfigTarget; +export type ConfigSource = ConfigTarget | 'default'; + +export type Extends = T; + +export interface SettingByConfigTarget { + user: T; + workspace: T; + folder: T; +} + +export interface Configs extends SettingByConfigTarget { + file: FileConfig | undefined; +} + +export interface DictionaryEntry { + name: string; + locales: LocaleList; + languageIds: FileTypeList; + description?: string; + uri?: FileUri; + uriName?: string; +} + +export interface Config { + inherited: { [key in keyof Config]?: ConfigSource }; + locales: Extends; + languageIdsEnabled: Extends; +} + +export interface FileConfig extends TextDocument, IsSpellCheckEnabledResult { + dictionaries: DictionaryEntry[]; + configFiles: ConfigFile[]; +} + +export interface ExcludeRef { + glob: string; + id?: string | undefined; + name?: string | undefined; + configUri?: string | undefined; +} + +export interface GitignoreInfo { + gitignoreFileUri: string; + gitignoreName: string; + glob: string | undefined; + line: number | undefined; + matched: boolean; + root: string | undefined; +} + +export interface BlockedFileReason { + code: string; + message: string; + documentationRefUri?: string; +} + +export interface IsSpellCheckEnabledResult { + languageEnabled?: boolean | undefined; + fileEnabled: boolean; + fileIsIncluded: boolean; + fileIsExcluded: boolean; + fileIsInWorkspace: boolean; + excludedBy?: ExcludeRef[] | undefined; + gitignoreInfo: GitignoreInfo | undefined; + blockedReason: BlockedFileReason | undefined; +} + +export interface ConfigFile { + uri: FileUri; + name: string; +} diff --git a/packages/webview-api/src/models/workspace.ts b/packages/webview-api/src/models/workspace.ts new file mode 100644 index 0000000000..4062435d71 --- /dev/null +++ b/packages/webview-api/src/models/workspace.ts @@ -0,0 +1,18 @@ +export interface WorkspaceFolder { + readonly uri: string; + readonly name: string; + readonly index: number; +} + +export interface TextDocument { + readonly uri: string; + readonly fileName: string; + readonly isUntitled: boolean; + readonly languageId: string; +} + +export interface Workspace { + workspaceFolders: WorkspaceFolder[] | undefined; + name: string | undefined; + textDocuments: TextDocument[]; +} diff --git a/packages/webview-rpc/src/common/json-rpc-api.ts b/packages/webview-rpc/src/common/json-rpc-api.ts index 291e9c8ecf..02e78839a2 100644 --- a/packages/webview-rpc/src/common/json-rpc-api.ts +++ b/packages/webview-rpc/src/common/json-rpc-api.ts @@ -205,12 +205,16 @@ function bindNotifications( } function mapRequestsToFn(connection: MessageConnection, prefix: string, requests: DefUseAPI): MakeMethodsAsync { + let reqSeqNum = 1; return Object.fromEntries( Object.entries(requests).map(([name]) => { const methodName = prefix + name; const fn = (...params: any) => { - log(`send request "${name}" %o`, params); - return connection.sendRequest(methodName, params); + const seq = ++reqSeqNum; + log(`send request "${name}" %o: Params: %o`, seq, params); + return connection + .sendRequest(methodName, params) + .then((value) => (log(`send request "${name}" %o: Response: %o`, seq, value), value)); }; return [name, fn]; }), @@ -225,7 +229,7 @@ function mapNotificationsToFn( return Object.fromEntries( Object.entries(notifications).map(([name]) => { const methodName = prefix + name; - const fn = (...params: any) => (log(`send request "${name}" %o`, params), connection.sendNotification(methodName, params)); + const fn = (...params: any) => (log(`send notification "${name}" %o`, params), connection.sendNotification(methodName, params)); return [name, fn]; }), ) as MakeMethodsAsync; diff --git a/packages/webview-ui/package.json b/packages/webview-ui/package.json index 226e6794f3..12766a1dc4 100644 --- a/packages/webview-ui/package.json +++ b/packages/webview-ui/package.json @@ -13,6 +13,7 @@ }, "type": "module", "dependencies": { + "@sveltestack/svelte-query": "^1.6.0", "@vscode/webview-ui-toolkit": "^1.2.2", "fast-equals": "^5.0.1", "sirv-cli": "^2.0.2", diff --git a/packages/webview-ui/src/App.svelte b/packages/webview-ui/src/App.svelte index 81aedc4895..9be3db8fd1 100644 --- a/packages/webview-ui/src/App.svelte +++ b/packages/webview-ui/src/App.svelte @@ -8,6 +8,7 @@ import { createDisposableFromList } from 'utils-disposables'; import { appState } from './state/appState'; import { LogLevel, setLogLevel } from 'webview-api'; + import { QueryClient, QueryClientProvider } from '@sveltestack/svelte-query'; // In order to use the Webview UI Toolkit web components they // must be registered with the browser (i.e. webview) using the @@ -37,24 +38,28 @@ const disposables = [logLevel.subscribe((level) => setLogLevel(level))]; const disposable = createDisposableFromList(disposables); + const queryClient = new QueryClient(); + onDestroy(() => { disposable.dispose(); }); -
-
- {#if view == supportedViewsByName['hello-world']} - - {:else if view == supportedViewsByName.todo} - - {:else if view == supportedViewsByName['cspell-info']} - - {:else} -

Unknown View {view}

- {/if} -
-
+ +
+
+ {#if view == supportedViewsByName['hello-world']} + + {:else if view == supportedViewsByName.todo} + + {:else if view == supportedViewsByName['cspell-info']} + + {:else} +

Unknown View {view}

+ {/if} +
+
+
diff --git a/packages/webview-ui/src/views/HelloWorld.svelte b/packages/webview-ui/src/views/HelloWorld.svelte index 9bb5612bff..0f101c5789 100644 --- a/packages/webview-ui/src/views/HelloWorld.svelte +++ b/packages/webview-ui/src/views/HelloWorld.svelte @@ -1,22 +1,17 @@
@@ -67,7 +49,7 @@ {/if} Show VSCode Component Samples - Log Debug Info + {#if showVsCodeComponents}